Compare commits

...

2 Commits

Author SHA1 Message Date
82e4ae8a81 Merge branch 'main' of https://git.ranaze.com/null/promiscuity into main
All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 46s
Deploy Promiscuity Character API / deploy (push) Successful in 58s
Deploy Promiscuity Inventory API / deploy (push) Successful in 45s
Deploy Promiscuity Locations API / deploy (push) Successful in 59s
k8s smoke test / test (push) Successful in 7s
2026-03-19 16:44:12 -05:00
1aefd5ba88 Adding interactable object spawning 2026-03-19 16:35:07 -05:00
12 changed files with 1175 additions and 782 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,24 @@
using MongoDB.Bson.Serialization.Attributes; using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Bson; using MongoDB.Bson;
namespace CharacterApi.Models; namespace CharacterApi.Models;
[BsonIgnoreExtraElements] [BsonIgnoreExtraElements]
public class VisibleLocation public class VisibleLocation
{ {
[BsonId] [BsonId]
[BsonRepresentation(BsonType.ObjectId)] [BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; } public string? Id { get; set; }
[BsonElement("name")] [BsonElement("name")]
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
[BsonElement("coord")] [BsonElement("coord")]
public LocationCoord Coord { get; set; } = new(); public LocationCoord Coord { get; set; } = new();
[BsonElement("biomeKey")] [BsonElement("biomeKey")]
public string BiomeKey { get; set; } = "plains"; public string BiomeKey { get; set; } = "plains";
[BsonElement("resources")] [BsonElement("locationObject")]
public List<VisibleLocationResource> Resources { get; set; } = []; public VisibleLocationObject? LocationObject { get; set; }
} }

View File

@ -0,0 +1,21 @@
using MongoDB.Bson.Serialization.Attributes;
namespace CharacterApi.Models;
public class VisibleLocationObject
{
[BsonElement("id")]
public string Id { get; set; } = string.Empty;
[BsonElement("objectType")]
public string ObjectType { get; set; } = string.Empty;
[BsonElement("objectKey")]
public string ObjectKey { get; set; } = string.Empty;
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("state")]
public VisibleLocationObjectState State { get; set; } = new();
}

View File

@ -0,0 +1,15 @@
using MongoDB.Bson.Serialization.Attributes;
namespace CharacterApi.Models;
public class VisibleLocationObjectState
{
[BsonElement("itemKey")]
public string ItemKey { get; set; } = string.Empty;
[BsonElement("remainingQuantity")]
public int RemainingQuantity { get; set; }
[BsonElement("gatherQuantity")]
public int GatherQuantity { get; set; } = 1;
}

View File

@ -1,9 +1,9 @@
using CharacterApi.Models; using CharacterApi.Models;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
namespace CharacterApi.Services; namespace CharacterApi.Services;
public class CharacterStore public class CharacterStore
{ {
private readonly IMongoCollection<Character> _col; private readonly IMongoCollection<Character> _col;
@ -13,87 +13,45 @@ public class CharacterStore
private static readonly string[] BiomeOrder = ["plains", "forest", "wetlands", "rocky", "desert"]; private static readonly string[] BiomeOrder = ["plains", "forest", "wetlands", "rocky", "desert"];
private static readonly Dictionary<string, BiomeDefinition> Biomes = new() private static readonly Dictionary<string, BiomeDefinition> Biomes = new()
{ {
["plains"] = new BiomeDefinition( ["plains"] = new("plains", 3.0, new()
"plains", {
3.0, ["forest"] = 1.7,
new Dictionary<string, double> ["wetlands"] = 0.9,
{ ["rocky"] = 0.8,
["forest"] = 1.7, ["desert"] = 0.4
["wetlands"] = 0.9, }),
["rocky"] = 0.8, ["forest"] = new("forest", 3.4, new()
["desert"] = 0.4 {
}, ["plains"] = 1.6,
new[] ["wetlands"] = 1.3,
{ ["rocky"] = 0.5,
new ResourceRule("grass", 180, 420, 12), ["desert"] = 0.1
new ResourceRule("wood", 25, 90, 3), }),
new ResourceRule("stone", 10, 40, 2) ["wetlands"] = new("wetlands", 3.1, new()
}), {
["forest"] = new BiomeDefinition( ["forest"] = 1.5,
"forest", ["plains"] = 1.1,
3.4, ["rocky"] = 0.2,
new Dictionary<string, double> ["desert"] = 0.05
{ }),
["plains"] = 1.6, ["rocky"] = new("rocky", 3.0, new()
["wetlands"] = 1.3, {
["rocky"] = 0.5, ["plains"] = 1.2,
["desert"] = 0.1 ["forest"] = 0.6,
}, ["desert"] = 1.1,
new[] ["wetlands"] = 0.1
{ }),
new ResourceRule("wood", 220, 520, 6), ["desert"] = new("desert", 3.2, new()
new ResourceRule("grass", 90, 220, 8), {
new ResourceRule("stone", 15, 45, 2) ["rocky"] = 1.4,
}), ["plains"] = 0.8,
["wetlands"] = new BiomeDefinition( ["forest"] = 0.1,
"wetlands", ["wetlands"] = 0.05
3.1, })
new Dictionary<string, double>
{
["forest"] = 1.5,
["plains"] = 1.1,
["rocky"] = 0.2,
["desert"] = 0.05
},
new[]
{
new ResourceRule("grass", 260, 600, 15),
new ResourceRule("wood", 40, 120, 4)
}),
["rocky"] = new BiomeDefinition(
"rocky",
3.0,
new Dictionary<string, double>
{
["plains"] = 1.2,
["forest"] = 0.6,
["desert"] = 1.1,
["wetlands"] = 0.1
},
new[]
{
new ResourceRule("stone", 220, 540, 8),
new ResourceRule("wood", 10, 40, 2),
new ResourceRule("grass", 20, 70, 4)
}),
["desert"] = new BiomeDefinition(
"desert",
3.2,
new Dictionary<string, double>
{
["rocky"] = 1.4,
["plains"] = 0.8,
["forest"] = 0.1,
["wetlands"] = 0.05
},
new[]
{
new ResourceRule("stone", 80, 220, 5),
new ResourceRule("grass", 5, 25, 2)
})
}; };
public sealed record VisibleLocationResult(List<VisibleLocation> Locations, int GeneratedCount); public sealed record VisibleLocationResult(List<VisibleLocation> Locations, int GeneratedCount);
private sealed record BiomeDefinition(string Key, double ContinuationWeight, Dictionary<string, double> TransitionWeights);
public CharacterStore(IConfiguration cfg) public CharacterStore(IConfiguration cfg)
{ {
@ -108,9 +66,9 @@ public class CharacterStore
var ownerIndex = Builders<Character>.IndexKeys.Ascending(c => c.OwnerUserId); var ownerIndex = Builders<Character>.IndexKeys.Ascending(c => c.OwnerUserId);
_col.Indexes.CreateOne(new CreateIndexModel<Character>(ownerIndex)); _col.Indexes.CreateOne(new CreateIndexModel<Character>(ownerIndex));
} }
public Task CreateAsync(Character character) => _col.InsertOneAsync(character); public Task CreateAsync(Character character) => _col.InsertOneAsync(character);
public Task<List<Character>> GetForOwnerAsync(string ownerUserId) => public Task<List<Character>> GetForOwnerAsync(string ownerUserId) =>
_col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync(); _col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync();
@ -125,10 +83,8 @@ public class CharacterStore
return result.ModifiedCount > 0 || result.MatchedCount > 0; return result.ModifiedCount > 0 || result.MatchedCount > 0;
} }
public Task<List<VisibleLocation>> GetVisibleLocationsAsync(Character character) public Task<List<VisibleLocation>> GetVisibleLocationsAsync(Character character) =>
{ GetVisibleLocationsInternalAsync(character, ensureGenerated: false);
return GetVisibleLocationsInternalAsync(character, ensureGenerated: false);
}
public async Task<VisibleLocationResult> GetOrCreateVisibleLocationsAsync(Character character) public async Task<VisibleLocationResult> GetOrCreateVisibleLocationsAsync(Character character)
{ {
@ -153,6 +109,13 @@ public class CharacterStore
); );
var documents = await _locations.Find(filter).ToListAsync(); var documents = await _locations.Find(filter).ToListAsync();
if (ensureGenerated)
{
foreach (var document in documents)
await BackfillLocationStateAsync(document);
documents = await _locations.Find(filter).ToListAsync();
}
return documents.Select(MapVisibleLocation).ToList(); return documents.Select(MapVisibleLocation).ToList();
} }
@ -173,39 +136,6 @@ public class CharacterStore
return generatedCount; return generatedCount;
} }
private static string DefaultLocationName(int x, int y)
{
if (x == 0 && y == 0)
return "Origin";
return $"Location {x},{y}";
}
private static VisibleLocation MapVisibleLocation(BsonDocument document)
{
var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument;
var idValue = document.GetValue("_id", BsonNull.Value);
string? id = null;
if (!idValue.IsBsonNull)
{
id = idValue.BsonType == BsonType.ObjectId
? idValue.AsObjectId.ToString()
: idValue.ToString();
}
return new VisibleLocation
{
Id = id,
Name = document.GetValue("name", "").AsString,
Coord = new LocationCoord
{
X = coord.GetValue("x", 0).ToInt32(),
Y = coord.GetValue("y", 0).ToInt32()
},
BiomeKey = document.GetValue("biomeKey", "plains").AsString,
Resources = MapVisibleLocationResources(document)
};
}
private async Task<bool> EnsureLocationStateAsync(int x, int y) private async Task<bool> EnsureLocationStateAsync(int x, int y)
{ {
var filter = Builders<BsonDocument>.Filter.And( var filter = Builders<BsonDocument>.Filter.And(
@ -230,7 +160,8 @@ public class CharacterStore
{ "y", y } { "y", y }
}) })
.SetOnInsert("biomeKey", biomeKey) .SetOnInsert("biomeKey", biomeKey)
.SetOnInsert("resources", BuildResourcesDocument(biomeKey, x, y)) .SetOnInsert("locationObject", CreateLocationObjectValueForBiome(biomeKey, x, y))
.SetOnInsert("locationObjectResolved", true)
.SetOnInsert("createdUtc", DateTime.UtcNow); .SetOnInsert("createdUtc", DateTime.UtcNow);
try try
@ -254,11 +185,17 @@ public class CharacterStore
var biomeKey = document.GetValue("biomeKey", BsonNull.Value).IsBsonNull var biomeKey = document.GetValue("biomeKey", BsonNull.Value).IsBsonNull
? await DetermineBiomeKeyAsync(x, y) ? await DetermineBiomeKeyAsync(x, y)
: document.GetValue("biomeKey", "plains").AsString; : document.GetValue("biomeKey", "plains").AsString;
var objectResolved = document.TryGetValue("locationObjectResolved", out var resolvedValue) &&
resolvedValue.ToBoolean();
if (!document.Contains("biomeKey")) if (!document.Contains("biomeKey"))
updates.Add(Builders<BsonDocument>.Update.Set("biomeKey", biomeKey)); updates.Add(Builders<BsonDocument>.Update.Set("biomeKey", biomeKey));
if (!document.Contains("resources")) if (!objectResolved)
updates.Add(Builders<BsonDocument>.Update.Set("resources", BuildResourcesDocument(biomeKey, x, y))); {
var locationObject = TryMigrateLegacyResource(document) ?? CreateLocationObjectValueForBiome(biomeKey, x, y);
updates.Add(Builders<BsonDocument>.Update.Set("locationObject", locationObject));
updates.Add(Builders<BsonDocument>.Update.Set("locationObjectResolved", true));
}
if (updates.Count == 0) if (updates.Count == 0)
return; return;
@ -317,14 +254,7 @@ public class CharacterStore
private async Task<List<string>> LoadNeighborBiomeKeysAsync(int x, int y) private async Task<List<string>> LoadNeighborBiomeKeysAsync(int x, int y)
{ {
var coords = new[] var coords = new[] { (x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1) };
{
(x - 1, y),
(x + 1, y),
(x, y - 1),
(x, y + 1)
};
var filters = coords.Select(coord => var filters = coords.Select(coord =>
Builders<BsonDocument>.Filter.And( Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("coord.x", coord.Item1), Builders<BsonDocument>.Filter.Eq("coord.x", coord.Item1),
@ -357,68 +287,131 @@ public class CharacterStore
return "plains"; return "plains";
} }
private static BsonArray BuildResourcesDocument(string biomeKey, int x, int y) private static BsonValue CreateLocationObjectValueForBiome(string biomeKey, int x, int y)
{ {
if (!Biomes.TryGetValue(biomeKey, out var biome)) var roll = StableNoise(x, y, 401);
biome = Biomes["plains"]; return biomeKey switch
var resources = new BsonArray();
foreach (var rule in biome.ResourceRules)
{ {
var roll = StableNoise(x, y, StableHash(rule.ItemKey)); "forest" => roll switch
var quantity = rule.MinQuantity + (int)Math.Round(roll * (rule.MaxQuantity - rule.MinQuantity)); {
if (quantity <= 0) < 0.35 => BsonNull.Value,
< 0.80 => CreateGatherableObjectDocument("wood", 60, 3),
< 0.95 => CreateGatherableObjectDocument("grass", 120, 10),
_ => CreateGatherableObjectDocument("stone", 40, 2)
},
"rocky" => roll switch
{
< 0.60 => BsonNull.Value,
< 0.90 => CreateGatherableObjectDocument("stone", 40, 2),
_ => CreateGatherableObjectDocument("wood", 60, 3)
},
"wetlands" => roll switch
{
< 0.40 => BsonNull.Value,
< 0.90 => CreateGatherableObjectDocument("grass", 120, 10),
_ => CreateGatherableObjectDocument("wood", 60, 3)
},
"desert" => roll switch
{
< 0.70 => BsonNull.Value,
< 0.95 => CreateGatherableObjectDocument("stone", 40, 2),
_ => CreateGatherableObjectDocument("wood", 60, 3)
},
_ => roll switch
{
< 0.50 => BsonNull.Value,
< 0.85 => CreateGatherableObjectDocument("grass", 120, 10),
_ => CreateGatherableObjectDocument("wood", 60, 3)
}
};
}
private static BsonDocument? TryMigrateLegacyResource(BsonDocument document)
{
if (!document.TryGetValue("resources", out var resourcesValue) || resourcesValue is not BsonArray resources)
return null;
foreach (var resourceValue in resources)
{
if (resourceValue is not BsonDocument resource)
continue; continue;
resources.Add(new BsonDocument var remainingQuantity = resource.GetValue("remainingQuantity", 0).ToInt32();
{ if (remainingQuantity <= 0)
{ "itemKey", rule.ItemKey },
{ "remainingQuantity", quantity },
{ "gatherQuantity", rule.GatherQuantity }
});
}
return resources;
}
private static double StableNoise(int x, int y, int salt)
{
var value = Math.Sin((x * 12.9898) + (y * 78.233) + ((WorldSeed + salt) * 0.1597)) * 43758.5453;
return value - Math.Floor(value);
}
private static int StableHash(string value)
{
unchecked
{
var hash = 23;
foreach (var c in value)
hash = (hash * 31) + c;
return hash;
}
}
private static List<VisibleLocationResource> MapVisibleLocationResources(BsonDocument document)
{
if (!document.TryGetValue("resources", out var resourcesValue) || resourcesValue.BsonType != BsonType.Array)
return [];
var results = new List<VisibleLocationResource>();
foreach (var value in resourcesValue.AsBsonArray)
{
if (value.BsonType != BsonType.Document)
continue; continue;
var resource = value.AsBsonDocument; var itemKey = NormalizeItemKey(resource.GetValue("itemKey", "").AsString);
results.Add(new VisibleLocationResource var gatherQuantity = Math.Max(1, resource.GetValue("gatherQuantity", 1).ToInt32());
{ return CreateGatherableObjectDocument(itemKey, remainingQuantity, gatherQuantity);
ItemKey = resource.GetValue("itemKey", "").AsString,
RemainingQuantity = resource.GetValue("remainingQuantity", 0).ToInt32(),
GatherQuantity = resource.GetValue("gatherQuantity", 1).ToInt32()
});
} }
return results; return null;
}
private static BsonDocument CreateGatherableObjectDocument(string itemKey, int remainingQuantity, int gatherQuantity)
{
var normalizedItemKey = NormalizeItemKey(itemKey);
return new BsonDocument
{
{ "id", Guid.NewGuid().ToString("N") },
{ "objectType", "gatherable" },
{ "objectKey", $"{normalizedItemKey}_node" },
{ "name", HumanizeItemKey(normalizedItemKey) },
{
"state", new BsonDocument
{
{ "itemKey", normalizedItemKey },
{ "remainingQuantity", remainingQuantity },
{ "gatherQuantity", gatherQuantity }
}
}
};
}
private static VisibleLocation MapVisibleLocation(BsonDocument document)
{
var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument;
var locationObject = MapVisibleLocationObject(document.GetValue("locationObject", BsonNull.Value));
var idValue = document.GetValue("_id", BsonNull.Value);
string? id = null;
if (!idValue.IsBsonNull)
{
id = idValue.BsonType == BsonType.ObjectId ? idValue.AsObjectId.ToString() : idValue.ToString();
}
return new VisibleLocation
{
Id = id,
Name = document.GetValue("name", "").AsString,
Coord = new LocationCoord
{
X = coord.GetValue("x", 0).ToInt32(),
Y = coord.GetValue("y", 0).ToInt32()
},
BiomeKey = document.GetValue("biomeKey", "plains").AsString,
LocationObject = locationObject
};
}
private static VisibleLocationObject? MapVisibleLocationObject(BsonValue value)
{
if (value.IsBsonNull || value is not BsonDocument document)
return null;
var stateDoc = document.GetValue("state", new BsonDocument()).AsBsonDocument;
return new VisibleLocationObject
{
Id = document.GetValue("id", "").AsString,
ObjectType = document.GetValue("objectType", "").AsString,
ObjectKey = document.GetValue("objectKey", "").AsString,
Name = document.GetValue("name", "").AsString,
State = new VisibleLocationObjectState
{
ItemKey = stateDoc.GetValue("itemKey", "").AsString,
RemainingQuantity = stateDoc.GetValue("remainingQuantity", 0).ToInt32(),
GatherQuantity = stateDoc.GetValue("gatherQuantity", 1).ToInt32()
}
};
} }
private void EnsureLocationCoordIndexes() private void EnsureLocationCoordIndexes()
@ -435,11 +428,7 @@ public class CharacterStore
_locations.Indexes.DropOne(name); _locations.Indexes.DropOne(name);
} }
var coordIndex = new BsonDocument var coordIndex = new BsonDocument { { "coord.x", 1 }, { "coord.y", 1 } };
{
{ "coord.x", 1 },
{ "coord.y", 1 }
};
var coordIndexOptions = new CreateIndexOptions { Unique = true, Name = CoordIndexName }; var coordIndexOptions = new CreateIndexOptions { Unique = true, Name = CoordIndexName };
_locations.Indexes.CreateOne(new CreateIndexModel<BsonDocument>(coordIndex, coordIndexOptions)); _locations.Indexes.CreateOne(new CreateIndexModel<BsonDocument>(coordIndex, coordIndexOptions));
} }
@ -462,22 +451,42 @@ public class CharacterStore
{ {
var filter = Builders<Character>.Filter.Eq(c => c.Id, id); var filter = Builders<Character>.Filter.Eq(c => c.Id, id);
if (!allowAnyOwner) if (!allowAnyOwner)
{ filter = Builders<Character>.Filter.And(filter, Builders<Character>.Filter.Eq(c => c.OwnerUserId, ownerUserId));
filter = Builders<Character>.Filter.And(
filter,
Builders<Character>.Filter.Eq(c => c.OwnerUserId, ownerUserId)
);
}
var result = await _col.DeleteOneAsync(filter); var result = await _col.DeleteOneAsync(filter);
return result.DeletedCount > 0; return result.DeletedCount > 0;
} }
private sealed record ResourceRule(string ItemKey, int MinQuantity, int MaxQuantity, int GatherQuantity); private static string DefaultLocationName(int x, int y)
{
if (x == 0 && y == 0)
return "Origin";
return $"Location {x},{y}";
}
private sealed record BiomeDefinition( private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant();
string Key,
double ContinuationWeight, private static string HumanizeItemKey(string itemKey)
Dictionary<string, double> TransitionWeights, {
IReadOnlyList<ResourceRule> ResourceRules); return string.Join(" ", itemKey.Split('_', StringSplitOptions.RemoveEmptyEntries)
.Where(part => part.Length > 0)
.Select(part => char.ToUpperInvariant(part[0]) + part[1..]));
}
private static double StableNoise(int x, int y, int salt)
{
var value = Math.Sin((x * 12.9898) + (y * 78.233) + ((WorldSeed + salt) * 0.1597)) * 43758.5453;
return value - Math.Floor(value);
}
private static int StableHash(string value)
{
unchecked
{
var hash = 17;
foreach (var ch in value)
hash = (hash * 31) + ch;
return hash;
}
}
} }

View File

@ -94,40 +94,42 @@ public class LocationsController : ControllerBase
return Ok("Updated"); return Ok("Updated");
} }
[HttpPost("{id}/gather")] [HttpPost("{id}/interact")]
[Authorize(Roles = "USER,SUPER")] [Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Gather(string id, [FromBody] GatherResourceRequest req) public async Task<IActionResult> Interact(string id, [FromBody] InteractLocationObjectRequest req)
{ {
if (string.IsNullOrWhiteSpace(req.CharacterId)) if (string.IsNullOrWhiteSpace(req.CharacterId))
return BadRequest("characterId required"); return BadRequest("characterId required");
if (string.IsNullOrWhiteSpace(req.ResourceKey)) if (string.IsNullOrWhiteSpace(req.ObjectId))
return BadRequest("resourceKey required"); return BadRequest("objectId required");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId)) if (string.IsNullOrWhiteSpace(userId))
return Unauthorized(); return Unauthorized();
var allowAnyOwner = User.IsInRole("SUPER"); var allowAnyOwner = User.IsInRole("SUPER");
var gather = await _locations.GatherResourceAsync(id, req.CharacterId, req.ResourceKey, userId, allowAnyOwner); var interact = await _locations.InteractWithObjectAsync(id, req.CharacterId, req.ObjectId, userId, allowAnyOwner);
if (gather.Status == GatherStatus.LocationNotFound) if (interact.Status == InteractStatus.LocationNotFound)
return NotFound("Location not found"); return NotFound("Location not found");
if (gather.Status == GatherStatus.CharacterNotFound) if (interact.Status == InteractStatus.CharacterNotFound)
return NotFound("Character not found"); return NotFound("Character not found");
if (gather.Status == GatherStatus.Forbidden) if (interact.Status == InteractStatus.Forbidden)
return Forbid(); return Forbid();
if (gather.Status == GatherStatus.Invalid) if (interact.Status == InteractStatus.Invalid)
return BadRequest("Character is not at the target location"); return BadRequest("Character is not at the target location");
if (gather.Status == GatherStatus.ResourceNotFound) if (interact.Status == InteractStatus.ObjectNotFound)
return NotFound("Resource not found at location"); return NotFound("Location object not found");
if (gather.Status == GatherStatus.ResourceDepleted) if (interact.Status == InteractStatus.UnsupportedObjectType)
return Conflict("Resource is depleted"); return BadRequest("Location object type is not supported");
if (interact.Status == InteractStatus.ObjectConsumed)
return Conflict("Location object is consumed");
var inventoryBaseUrl = (_configuration["Services:InventoryApiBaseUrl"] ?? "http://localhost:5003").TrimEnd('/'); var inventoryBaseUrl = (_configuration["Services:InventoryApiBaseUrl"] ?? "http://localhost:5003").TrimEnd('/');
var token = Request.Headers.Authorization.ToString(); var token = Request.Headers.Authorization.ToString();
var grantBody = JsonSerializer.Serialize(new var grantBody = JsonSerializer.Serialize(new
{ {
itemKey = gather.ResourceKey, itemKey = interact.ItemKey,
quantity = gather.QuantityGranted quantity = interact.QuantityGranted
}); });
var client = _httpClientFactory.CreateClient(); var client = _httpClientFactory.CreateClient();
@ -142,17 +144,21 @@ public class LocationsController : ControllerBase
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
await _locations.RestoreGatheredResourceAsync(id, gather.ResourceKey, gather.QuantityGranted); if (interact.PreviousObject is not null)
await _locations.RestoreObjectInteractionAsync(id, interact.PreviousObject);
return StatusCode((int)response.StatusCode, responseBody); return StatusCode((int)response.StatusCode, responseBody);
} }
return Ok(new GatherResourceResponse return Ok(new InteractLocationObjectResponse
{ {
LocationId = id, LocationId = id,
CharacterId = req.CharacterId, CharacterId = req.CharacterId,
ResourceKey = gather.ResourceKey, ObjectId = interact.ObjectId,
QuantityGranted = gather.QuantityGranted, ObjectType = interact.ObjectType,
RemainingQuantity = gather.RemainingQuantity, ItemKey = interact.ItemKey,
QuantityGranted = interact.QuantityGranted,
RemainingQuantity = interact.RemainingQuantity,
Consumed = interact.Consumed,
InventoryResponseJson = responseBody InventoryResponseJson = responseBody
}); });
} }

View File

@ -0,0 +1,8 @@
namespace LocationsApi.Models;
public class InteractLocationObjectRequest
{
public string CharacterId { get; set; } = string.Empty;
public string ObjectId { get; set; } = string.Empty;
}

View File

@ -0,0 +1,22 @@
namespace LocationsApi.Models;
public class InteractLocationObjectResponse
{
public string LocationId { get; set; } = string.Empty;
public string CharacterId { get; set; } = string.Empty;
public string ObjectId { get; set; } = string.Empty;
public string ObjectType { get; set; } = string.Empty;
public string ItemKey { get; set; } = string.Empty;
public int QuantityGranted { get; set; }
public int RemainingQuantity { get; set; }
public bool Consumed { get; set; }
public string InventoryResponseJson { get; set; } = string.Empty;
}

View File

@ -1,17 +1,17 @@
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes; using MongoDB.Bson.Serialization.Attributes;
namespace LocationsApi.Models; namespace LocationsApi.Models;
public class Location public class Location
{ {
[BsonId] [BsonId]
[BsonRepresentation(BsonType.ObjectId)] [BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; } public string? Id { get; set; }
[BsonElement("name")] [BsonElement("name")]
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
[BsonElement("coord")] [BsonElement("coord")]
public required Coord Coord { get; set; } public required Coord Coord { get; set; }
@ -21,6 +21,12 @@ public class Location
[BsonElement("resources")] [BsonElement("resources")]
public List<LocationResource> Resources { get; set; } = []; public List<LocationResource> Resources { get; set; } = [];
[BsonElement("createdUtc")] [BsonElement("locationObject")]
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public LocationObject? LocationObject { get; set; }
}
[BsonElement("locationObjectResolved")]
public bool LocationObjectResolved { get; set; }
[BsonElement("createdUtc")]
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,21 @@
using MongoDB.Bson.Serialization.Attributes;
namespace LocationsApi.Models;
public class LocationObject
{
[BsonElement("id")]
public string Id { get; set; } = Guid.NewGuid().ToString("N");
[BsonElement("objectType")]
public string ObjectType { get; set; } = "gatherable";
[BsonElement("objectKey")]
public string ObjectKey { get; set; } = string.Empty;
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("state")]
public LocationObjectState State { get; set; } = new();
}

View File

@ -0,0 +1,15 @@
using MongoDB.Bson.Serialization.Attributes;
namespace LocationsApi.Models;
public class LocationObjectState
{
[BsonElement("itemKey")]
public string ItemKey { get; set; } = string.Empty;
[BsonElement("remainingQuantity")]
public int RemainingQuantity { get; set; }
[BsonElement("gatherQuantity")]
public int GatherQuantity { get; set; } = 1;
}

View File

@ -77,11 +77,41 @@ public class LocationStore
} }
} }
}, },
{
"locationObject", new BsonDocument
{
{ "bsonType", new BsonArray { "object", "null" } },
{
"properties", new BsonDocument
{
{ "id", new BsonDocument { { "bsonType", "string" } } },
{ "objectType", new BsonDocument { { "bsonType", "string" } } },
{ "objectKey", new BsonDocument { { "bsonType", "string" } } },
{ "name", new BsonDocument { { "bsonType", "string" } } },
{
"state", new BsonDocument
{
{ "bsonType", new BsonArray { "object", "null" } },
{
"properties", new BsonDocument
{
{ "itemKey", new BsonDocument { { "bsonType", "string" } } },
{ "remainingQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 0 } } },
{ "gatherQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 1 } } }
}
}
}
}
}
}
}
},
{ "locationObjectResolved", new BsonDocument { { "bsonType", new BsonArray { "bool", "null" } } } },
{ "createdUtc", new BsonDocument { { "bsonType", "date" } } } { "createdUtc", new BsonDocument { { "bsonType", "date" } } }
} }
} }
} }
} }
}; };
var collections = db.ListCollectionNames().ToList(); var collections = db.ListCollectionNames().ToList();
@ -126,54 +156,79 @@ public class LocationStore
return result.ModifiedCount > 0; return result.ModifiedCount > 0;
} }
public sealed record GatherResult(GatherStatus Status, string ResourceKey = "", int QuantityGranted = 0, int RemainingQuantity = 0); public sealed record InteractResult(
InteractStatus Status,
string ObjectId = "",
string ObjectType = "",
string ItemKey = "",
int QuantityGranted = 0,
int RemainingQuantity = 0,
bool Consumed = false,
LocationObject? PreviousObject = null);
public async Task<GatherResult> GatherResourceAsync(string locationId, string characterId, string resourceKey, string userId, bool allowAnyOwner) public async Task<InteractResult> InteractWithObjectAsync(string locationId, string characterId, string objectId, string userId, bool allowAnyOwner)
{ {
var normalizedKey = resourceKey.Trim().ToLowerInvariant();
var location = await _col.Find(l => l.Id == locationId).FirstOrDefaultAsync(); var location = await _col.Find(l => l.Id == locationId).FirstOrDefaultAsync();
if (location is null) if (location is null)
return new GatherResult(GatherStatus.LocationNotFound); return new InteractResult(InteractStatus.LocationNotFound);
location = await EnsureLocationMetadataAsync(location);
var character = await _characters.Find(c => c.Id == characterId).FirstOrDefaultAsync(); var character = await _characters.Find(c => c.Id == characterId).FirstOrDefaultAsync();
if (character is null) if (character is null)
return new GatherResult(GatherStatus.CharacterNotFound); return new InteractResult(InteractStatus.CharacterNotFound);
if (!allowAnyOwner && character.OwnerUserId != userId) if (!allowAnyOwner && character.OwnerUserId != userId)
return new GatherResult(GatherStatus.Forbidden); return new InteractResult(InteractStatus.Forbidden);
if (character.Coord.X != location.Coord.X || character.Coord.Y != location.Coord.Y) if (character.Coord.X != location.Coord.X || character.Coord.Y != location.Coord.Y)
return new GatherResult(GatherStatus.Invalid); return new InteractResult(InteractStatus.Invalid);
var resource = location.Resources.FirstOrDefault(r => NormalizeItemKey(r.ItemKey) == normalizedKey); var locationObject = location.LocationObject;
if (resource is null) if (locationObject is null)
return new GatherResult(GatherStatus.ResourceNotFound); return new InteractResult(InteractStatus.ObjectNotFound);
if (resource.RemainingQuantity <= 0) if (!string.Equals(locationObject.Id, objectId, StringComparison.Ordinal))
return new GatherResult(GatherStatus.ResourceDepleted); return new InteractResult(InteractStatus.ObjectNotFound);
if (!string.Equals(locationObject.ObjectType, "gatherable", StringComparison.OrdinalIgnoreCase))
return new InteractResult(InteractStatus.UnsupportedObjectType);
if (locationObject.State.RemainingQuantity <= 0)
return new InteractResult(InteractStatus.ObjectConsumed);
var quantityGranted = Math.Min(resource.GatherQuantity, resource.RemainingQuantity); var quantityGranted = Math.Min(locationObject.State.GatherQuantity, locationObject.State.RemainingQuantity);
var filter = Builders<Location>.Filter.And( var remainingQuantity = locationObject.State.RemainingQuantity - quantityGranted;
var objectFilter = Builders<Location>.Filter.And(
Builders<Location>.Filter.Eq(l => l.Id, locationId), Builders<Location>.Filter.Eq(l => l.Id, locationId),
Builders<Location>.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == resource.ItemKey && r.RemainingQuantity >= quantityGranted) Builders<Location>.Filter.Eq("locationObject.id", locationObject.Id),
Builders<Location>.Filter.Eq("locationObject.state.remainingQuantity", locationObject.State.RemainingQuantity)
); );
var update = Builders<Location>.Update.Inc("resources.$.remainingQuantity", -quantityGranted);
var result = await _col.UpdateOneAsync(filter, update);
if (result.ModifiedCount == 0)
return new GatherResult(GatherStatus.ResourceDepleted);
return new GatherResult( UpdateDefinition<Location> update;
GatherStatus.Ok, if (remainingQuantity <= 0)
resource.ItemKey, {
update = Builders<Location>.Update.Unset("locationObject");
}
else
{
update = Builders<Location>.Update.Set("locationObject.state.remainingQuantity", remainingQuantity);
}
var result = await _col.UpdateOneAsync(objectFilter, update);
if (result.ModifiedCount == 0)
return new InteractResult(InteractStatus.ObjectConsumed);
return new InteractResult(
InteractStatus.Ok,
locationObject.Id,
locationObject.ObjectType,
locationObject.State.ItemKey,
quantityGranted, quantityGranted,
resource.RemainingQuantity - quantityGranted); Math.Max(0, remainingQuantity),
remainingQuantity <= 0,
CloneLocationObject(locationObject));
} }
public async Task RestoreGatheredResourceAsync(string locationId, string resourceKey, int quantity) public async Task RestoreObjectInteractionAsync(string locationId, LocationObject previousObject)
{ {
var normalizedKey = NormalizeItemKey(resourceKey); var filter = Builders<Location>.Filter.Eq(l => l.Id, locationId);
var filter = Builders<Location>.Filter.And( var update = Builders<Location>.Update.Set(l => l.LocationObject, previousObject);
Builders<Location>.Filter.Eq(l => l.Id, locationId),
Builders<Location>.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == normalizedKey)
);
var update = Builders<Location>.Update.Inc("resources.$.remainingQuantity", quantity);
await _col.UpdateOneAsync(filter, update); await _col.UpdateOneAsync(filter, update);
} }
@ -218,35 +273,19 @@ public class LocationStore
{ {
var filter = Builders<Location>.Filter.And( var filter = Builders<Location>.Filter.And(
Builders<Location>.Filter.Eq(l => l.Coord.X, 0), Builders<Location>.Filter.Eq(l => l.Coord.X, 0),
Builders<Location>.Filter.Eq(l => l.Coord.Y, 0) Builders<Location>.Filter.Eq(l => l.Coord.Y, 0)
); );
var existing = _col.Find(filter).FirstOrDefault(); var existing = _col.Find(filter).FirstOrDefault();
if (existing is not null) if (existing is not null)
{
var updates = new List<UpdateDefinition<Location>>();
if (string.IsNullOrWhiteSpace(existing.BiomeKey))
updates.Add(Builders<Location>.Update.Set(l => l.BiomeKey, "plains"));
if (existing.Resources.Count == 0)
updates.Add(Builders<Location>.Update.Set(l => l.Resources, new List<LocationResource>
{
new() { ItemKey = "wood", RemainingQuantity = 100, GatherQuantity = 3 },
new() { ItemKey = "grass", RemainingQuantity = 500, GatherQuantity = 10 }
}));
if (updates.Count > 0)
_col.UpdateOne(filter, Builders<Location>.Update.Combine(updates));
return; return;
}
var origin = new Location var origin = new Location
{ {
Name = "Origin", Name = "Origin",
Coord = new Coord { X = 0, Y = 0 }, Coord = new Coord { X = 0, Y = 0 },
BiomeKey = "plains", BiomeKey = DetermineBiomeKey(0, 0),
Resources = LocationObject = CreateLocationObjectForBiome(DetermineBiomeKey(0, 0), 0, 0),
[ LocationObjectResolved = true,
new LocationResource { ItemKey = "wood", RemainingQuantity = 100, GatherQuantity = 3 },
new LocationResource { ItemKey = "grass", RemainingQuantity = 500, GatherQuantity = 10 }
],
CreatedUtc = DateTime.UtcNow CreatedUtc = DateTime.UtcNow
}; };
@ -262,6 +301,150 @@ public class LocationStore
private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant(); private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant();
private async Task<Location> EnsureLocationMetadataAsync(Location location)
{
if (!string.IsNullOrWhiteSpace(location.BiomeKey) && location.LocationObjectResolved)
return location;
var biomeKey = location.BiomeKey;
if (string.IsNullOrWhiteSpace(biomeKey))
biomeKey = DetermineBiomeKey(location.Coord.X, location.Coord.Y);
var migratedObject = TryMigrateLegacyResources(location) ?? CreateLocationObjectForBiome(biomeKey, location.Coord.X, location.Coord.Y);
var filter = Builders<Location>.Filter.And(
Builders<Location>.Filter.Eq(l => l.Id, location.Id)
);
var update = Builders<Location>.Update
.Set(l => l.BiomeKey, biomeKey)
.Set(l => l.LocationObjectResolved, true)
.Set(l => l.LocationObject, migratedObject);
await _col.UpdateOneAsync(filter, update);
location.BiomeKey = biomeKey;
location.LocationObject = migratedObject;
location.LocationObjectResolved = true;
return location;
}
private static LocationObject? TryMigrateLegacyResources(Location location)
{
var legacyResource = location.Resources.FirstOrDefault(r => r.RemainingQuantity > 0);
if (legacyResource is null)
return null;
return CreateGatherableObject(
legacyResource.ItemKey,
legacyResource.RemainingQuantity,
legacyResource.GatherQuantity);
}
private static string DetermineBiomeKey(int x, int y)
{
if (x == 0 && y == 0)
return "plains";
var regionX = FloorDiv(x, 4);
var regionY = FloorDiv(y, 4);
var roll = Math.Abs(HashCode.Combine(regionX, regionY, 7919)) % 100;
if (roll < 35)
return "plains";
if (roll < 60)
return "forest";
if (roll < 80)
return "rocky";
if (roll < 92)
return "wetlands";
return "desert";
}
private static LocationObject? CreateLocationObjectForBiome(string biomeKey, int x, int y)
{
var roll = Math.Abs(HashCode.Combine(x, y, 1543)) % 100;
return biomeKey switch
{
"forest" => roll switch
{
< 35 => null,
< 80 => CreateGatherableObject("wood", 60, 3),
< 95 => CreateGatherableObject("grass", 120, 10),
_ => CreateGatherableObject("stone", 40, 2)
},
"rocky" => roll switch
{
< 60 => null,
< 90 => CreateGatherableObject("stone", 40, 2),
_ => CreateGatherableObject("wood", 60, 3)
},
"wetlands" => roll switch
{
< 40 => null,
< 90 => CreateGatherableObject("grass", 120, 10),
_ => CreateGatherableObject("wood", 60, 3)
},
"desert" => roll switch
{
< 70 => null,
< 95 => CreateGatherableObject("stone", 40, 2),
_ => CreateGatherableObject("wood", 60, 3)
},
_ => roll switch
{
< 50 => null,
< 85 => CreateGatherableObject("grass", 120, 10),
_ => CreateGatherableObject("wood", 60, 3)
}
};
}
private static LocationObject CreateGatherableObject(string itemKey, int remainingQuantity, int gatherQuantity)
{
var normalizedItemKey = NormalizeItemKey(itemKey);
return new LocationObject
{
Id = Guid.NewGuid().ToString("N"),
ObjectType = "gatherable",
ObjectKey = $"{normalizedItemKey}_node",
Name = HumanizeItemKey(normalizedItemKey),
State = new LocationObjectState
{
ItemKey = normalizedItemKey,
RemainingQuantity = remainingQuantity,
GatherQuantity = gatherQuantity
}
};
}
private static string HumanizeItemKey(string itemKey)
{
return string.Join(" ", itemKey.Split('_', StringSplitOptions.RemoveEmptyEntries)
.Select(part => char.ToUpperInvariant(part[0]) + part[1..]));
}
private static int FloorDiv(int value, int divisor)
{
var quotient = value / divisor;
var remainder = value % divisor;
if (remainder != 0 && ((remainder < 0) != (divisor < 0)))
quotient -= 1;
return quotient;
}
private static LocationObject CloneLocationObject(LocationObject source)
{
return new LocationObject
{
Id = source.Id,
ObjectType = source.ObjectType,
ObjectKey = source.ObjectKey,
Name = source.Name,
State = new LocationObjectState
{
ItemKey = source.State.ItemKey,
RemainingQuantity = source.State.RemainingQuantity,
GatherQuantity = source.State.GatherQuantity
}
};
}
[MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements] [MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements]
private class CharacterDocument private class CharacterDocument
{ {
@ -275,13 +458,14 @@ public class LocationStore
} }
} }
public enum GatherStatus public enum InteractStatus
{ {
Ok, Ok,
LocationNotFound, LocationNotFound,
CharacterNotFound, CharacterNotFound,
Forbidden, Forbidden,
Invalid, Invalid,
ResourceNotFound, ObjectNotFound,
ResourceDepleted UnsupportedObjectType,
ObjectConsumed
} }