Compare commits

..

No commits in common. "82e4ae8a81dd38341cee00b88a71581edc2d2d1a" and "0b15fb02d20ef1dede2a61a0d3512383e307ee96" have entirely different histories.

12 changed files with 782 additions and 1175 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("locationObject")] [BsonElement("resources")]
public VisibleLocationObject? LocationObject { get; set; } public List<VisibleLocationResource> Resources { get; set; } = [];
} }

View File

@ -1,21 +0,0 @@
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

@ -1,15 +0,0 @@
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,45 +13,87 @@ 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("plains", 3.0, new() ["plains"] = new BiomeDefinition(
{ "plains",
["forest"] = 1.7, 3.0,
["wetlands"] = 0.9, new Dictionary<string, double>
["rocky"] = 0.8, {
["desert"] = 0.4 ["forest"] = 1.7,
}), ["wetlands"] = 0.9,
["forest"] = new("forest", 3.4, new() ["rocky"] = 0.8,
{ ["desert"] = 0.4
["plains"] = 1.6, },
["wetlands"] = 1.3, new[]
["rocky"] = 0.5, {
["desert"] = 0.1 new ResourceRule("grass", 180, 420, 12),
}), new ResourceRule("wood", 25, 90, 3),
["wetlands"] = new("wetlands", 3.1, new() new ResourceRule("stone", 10, 40, 2)
{ }),
["forest"] = 1.5, ["forest"] = new BiomeDefinition(
["plains"] = 1.1, "forest",
["rocky"] = 0.2, 3.4,
["desert"] = 0.05 new Dictionary<string, double>
}), {
["rocky"] = new("rocky", 3.0, new() ["plains"] = 1.6,
{ ["wetlands"] = 1.3,
["plains"] = 1.2, ["rocky"] = 0.5,
["forest"] = 0.6, ["desert"] = 0.1
["desert"] = 1.1, },
["wetlands"] = 0.1 new[]
}), {
["desert"] = new("desert", 3.2, new() new ResourceRule("wood", 220, 520, 6),
{ new ResourceRule("grass", 90, 220, 8),
["rocky"] = 1.4, new ResourceRule("stone", 15, 45, 2)
["plains"] = 0.8, }),
["forest"] = 0.1, ["wetlands"] = new BiomeDefinition(
["wetlands"] = 0.05 "wetlands",
}) 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)
{ {
@ -66,9 +108,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();
@ -83,8 +125,10 @@ 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)
{ {
@ -109,13 +153,6 @@ 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();
} }
@ -136,6 +173,39 @@ 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(
@ -160,8 +230,7 @@ public class CharacterStore
{ "y", y } { "y", y }
}) })
.SetOnInsert("biomeKey", biomeKey) .SetOnInsert("biomeKey", biomeKey)
.SetOnInsert("locationObject", CreateLocationObjectValueForBiome(biomeKey, x, y)) .SetOnInsert("resources", BuildResourcesDocument(biomeKey, x, y))
.SetOnInsert("locationObjectResolved", true)
.SetOnInsert("createdUtc", DateTime.UtcNow); .SetOnInsert("createdUtc", DateTime.UtcNow);
try try
@ -185,17 +254,11 @@ 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 (!objectResolved) if (!document.Contains("resources"))
{ 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;
@ -254,7 +317,14 @@ 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[] { (x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1) }; var coords = new[]
{
(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),
@ -287,131 +357,68 @@ public class CharacterStore
return "plains"; return "plains";
} }
private static BsonValue CreateLocationObjectValueForBiome(string biomeKey, int x, int y) private static BsonArray BuildResourcesDocument(string biomeKey, int x, int y)
{ {
var roll = StableNoise(x, y, 401); if (!Biomes.TryGetValue(biomeKey, out var biome))
return biomeKey switch biome = Biomes["plains"];
{
"forest" => roll switch
{
< 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) var resources = new BsonArray();
{ foreach (var rule in biome.ResourceRules)
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) var roll = StableNoise(x, y, StableHash(rule.ItemKey));
var quantity = rule.MinQuantity + (int)Math.Round(roll * (rule.MaxQuantity - rule.MinQuantity));
if (quantity <= 0)
continue; continue;
var remainingQuantity = resource.GetValue("remainingQuantity", 0).ToInt32(); resources.Add(new BsonDocument
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 itemKey = NormalizeItemKey(resource.GetValue("itemKey", "").AsString); var resource = value.AsBsonDocument;
var gatherQuantity = Math.Max(1, resource.GetValue("gatherQuantity", 1).ToInt32()); results.Add(new VisibleLocationResource
return CreateGatherableObjectDocument(itemKey, remainingQuantity, gatherQuantity); {
ItemKey = resource.GetValue("itemKey", "").AsString,
RemainingQuantity = resource.GetValue("remainingQuantity", 0).ToInt32(),
GatherQuantity = resource.GetValue("gatherQuantity", 1).ToInt32()
});
} }
return null; return results;
}
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()
@ -428,7 +435,11 @@ public class CharacterStore
_locations.Indexes.DropOne(name); _locations.Indexes.DropOne(name);
} }
var coordIndex = new BsonDocument { { "coord.x", 1 }, { "coord.y", 1 } }; var coordIndex = new BsonDocument
{
{ "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));
} }
@ -451,42 +462,22 @@ 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 static string DefaultLocationName(int x, int y) private sealed record ResourceRule(string ItemKey, int MinQuantity, int MaxQuantity, int GatherQuantity);
{
if (x == 0 && y == 0)
return "Origin";
return $"Location {x},{y}";
}
private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant(); private sealed record BiomeDefinition(
string Key,
private static string HumanizeItemKey(string itemKey) double ContinuationWeight,
{ Dictionary<string, double> TransitionWeights,
return string.Join(" ", itemKey.Split('_', StringSplitOptions.RemoveEmptyEntries) IReadOnlyList<ResourceRule> ResourceRules);
.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,42 +94,40 @@ public class LocationsController : ControllerBase
return Ok("Updated"); return Ok("Updated");
} }
[HttpPost("{id}/interact")] [HttpPost("{id}/gather")]
[Authorize(Roles = "USER,SUPER")] [Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Interact(string id, [FromBody] InteractLocationObjectRequest req) public async Task<IActionResult> Gather(string id, [FromBody] GatherResourceRequest req)
{ {
if (string.IsNullOrWhiteSpace(req.CharacterId)) if (string.IsNullOrWhiteSpace(req.CharacterId))
return BadRequest("characterId required"); return BadRequest("characterId required");
if (string.IsNullOrWhiteSpace(req.ObjectId)) if (string.IsNullOrWhiteSpace(req.ResourceKey))
return BadRequest("objectId required"); return BadRequest("resourceKey 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 interact = await _locations.InteractWithObjectAsync(id, req.CharacterId, req.ObjectId, userId, allowAnyOwner); var gather = await _locations.GatherResourceAsync(id, req.CharacterId, req.ResourceKey, userId, allowAnyOwner);
if (interact.Status == InteractStatus.LocationNotFound) if (gather.Status == GatherStatus.LocationNotFound)
return NotFound("Location not found"); return NotFound("Location not found");
if (interact.Status == InteractStatus.CharacterNotFound) if (gather.Status == GatherStatus.CharacterNotFound)
return NotFound("Character not found"); return NotFound("Character not found");
if (interact.Status == InteractStatus.Forbidden) if (gather.Status == GatherStatus.Forbidden)
return Forbid(); return Forbid();
if (interact.Status == InteractStatus.Invalid) if (gather.Status == GatherStatus.Invalid)
return BadRequest("Character is not at the target location"); return BadRequest("Character is not at the target location");
if (interact.Status == InteractStatus.ObjectNotFound) if (gather.Status == GatherStatus.ResourceNotFound)
return NotFound("Location object not found"); return NotFound("Resource not found at location");
if (interact.Status == InteractStatus.UnsupportedObjectType) if (gather.Status == GatherStatus.ResourceDepleted)
return BadRequest("Location object type is not supported"); return Conflict("Resource is depleted");
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 = interact.ItemKey, itemKey = gather.ResourceKey,
quantity = interact.QuantityGranted quantity = gather.QuantityGranted
}); });
var client = _httpClientFactory.CreateClient(); var client = _httpClientFactory.CreateClient();
@ -144,21 +142,17 @@ public class LocationsController : ControllerBase
var responseBody = await response.Content.ReadAsStringAsync(); var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
if (interact.PreviousObject is not null) await _locations.RestoreGatheredResourceAsync(id, gather.ResourceKey, gather.QuantityGranted);
await _locations.RestoreObjectInteractionAsync(id, interact.PreviousObject);
return StatusCode((int)response.StatusCode, responseBody); return StatusCode((int)response.StatusCode, responseBody);
} }
return Ok(new InteractLocationObjectResponse return Ok(new GatherResourceResponse
{ {
LocationId = id, LocationId = id,
CharacterId = req.CharacterId, CharacterId = req.CharacterId,
ObjectId = interact.ObjectId, ResourceKey = gather.ResourceKey,
ObjectType = interact.ObjectType, QuantityGranted = gather.QuantityGranted,
ItemKey = interact.ItemKey, RemainingQuantity = gather.RemainingQuantity,
QuantityGranted = interact.QuantityGranted,
RemainingQuantity = interact.RemainingQuantity,
Consumed = interact.Consumed,
InventoryResponseJson = responseBody InventoryResponseJson = responseBody
}); });
} }

View File

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

View File

@ -1,22 +0,0 @@
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,12 +21,6 @@ public class Location
[BsonElement("resources")] [BsonElement("resources")]
public List<LocationResource> Resources { get; set; } = []; public List<LocationResource> Resources { get; set; } = [];
[BsonElement("locationObject")] [BsonElement("createdUtc")]
public LocationObject? LocationObject { get; set; } public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
[BsonElement("locationObjectResolved")]
public bool LocationObjectResolved { get; set; }
[BsonElement("createdUtc")]
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}

View File

@ -1,21 +0,0 @@
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

@ -1,15 +0,0 @@
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,41 +77,11 @@ 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();
@ -156,79 +126,54 @@ public class LocationStore
return result.ModifiedCount > 0; return result.ModifiedCount > 0;
} }
public sealed record InteractResult( public sealed record GatherResult(GatherStatus Status, string ResourceKey = "", int QuantityGranted = 0, int RemainingQuantity = 0);
InteractStatus Status,
string ObjectId = "",
string ObjectType = "",
string ItemKey = "",
int QuantityGranted = 0,
int RemainingQuantity = 0,
bool Consumed = false,
LocationObject? PreviousObject = null);
public async Task<InteractResult> InteractWithObjectAsync(string locationId, string characterId, string objectId, string userId, bool allowAnyOwner) public async Task<GatherResult> GatherResourceAsync(string locationId, string characterId, string resourceKey, 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 InteractResult(InteractStatus.LocationNotFound); return new GatherResult(GatherStatus.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 InteractResult(InteractStatus.CharacterNotFound); return new GatherResult(GatherStatus.CharacterNotFound);
if (!allowAnyOwner && character.OwnerUserId != userId) if (!allowAnyOwner && character.OwnerUserId != userId)
return new InteractResult(InteractStatus.Forbidden); return new GatherResult(GatherStatus.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 InteractResult(InteractStatus.Invalid); return new GatherResult(GatherStatus.Invalid);
var locationObject = location.LocationObject; var resource = location.Resources.FirstOrDefault(r => NormalizeItemKey(r.ItemKey) == normalizedKey);
if (locationObject is null) if (resource is null)
return new InteractResult(InteractStatus.ObjectNotFound); return new GatherResult(GatherStatus.ResourceNotFound);
if (!string.Equals(locationObject.Id, objectId, StringComparison.Ordinal)) if (resource.RemainingQuantity <= 0)
return new InteractResult(InteractStatus.ObjectNotFound); return new GatherResult(GatherStatus.ResourceDepleted);
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(locationObject.State.GatherQuantity, locationObject.State.RemainingQuantity); var quantityGranted = Math.Min(resource.GatherQuantity, resource.RemainingQuantity);
var remainingQuantity = locationObject.State.RemainingQuantity - quantityGranted; var filter = Builders<Location>.Filter.And(
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.Eq("locationObject.id", locationObject.Id), Builders<Location>.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == resource.ItemKey && r.RemainingQuantity >= quantityGranted)
Builders<Location>.Filter.Eq("locationObject.state.remainingQuantity", locationObject.State.RemainingQuantity)
); );
var update = Builders<Location>.Update.Inc("resources.$.remainingQuantity", -quantityGranted);
UpdateDefinition<Location> update; var result = await _col.UpdateOneAsync(filter, update);
if (remainingQuantity <= 0)
{
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) if (result.ModifiedCount == 0)
return new InteractResult(InteractStatus.ObjectConsumed); return new GatherResult(GatherStatus.ResourceDepleted);
return new InteractResult( return new GatherResult(
InteractStatus.Ok, GatherStatus.Ok,
locationObject.Id, resource.ItemKey,
locationObject.ObjectType,
locationObject.State.ItemKey,
quantityGranted, quantityGranted,
Math.Max(0, remainingQuantity), resource.RemainingQuantity - quantityGranted);
remainingQuantity <= 0,
CloneLocationObject(locationObject));
} }
public async Task RestoreObjectInteractionAsync(string locationId, LocationObject previousObject) public async Task RestoreGatheredResourceAsync(string locationId, string resourceKey, int quantity)
{ {
var filter = Builders<Location>.Filter.Eq(l => l.Id, locationId); var normalizedKey = NormalizeItemKey(resourceKey);
var update = Builders<Location>.Update.Set(l => l.LocationObject, previousObject); var filter = Builders<Location>.Filter.And(
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);
} }
@ -273,19 +218,35 @@ 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 = DetermineBiomeKey(0, 0), BiomeKey = "plains",
LocationObject = CreateLocationObjectForBiome(DetermineBiomeKey(0, 0), 0, 0), Resources =
LocationObjectResolved = true, [
new LocationResource { ItemKey = "wood", RemainingQuantity = 100, GatherQuantity = 3 },
new LocationResource { ItemKey = "grass", RemainingQuantity = 500, GatherQuantity = 10 }
],
CreatedUtc = DateTime.UtcNow CreatedUtc = DateTime.UtcNow
}; };
@ -301,150 +262,6 @@ 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
{ {
@ -458,14 +275,13 @@ public class LocationStore
} }
} }
public enum InteractStatus public enum GatherStatus
{ {
Ok, Ok,
LocationNotFound, LocationNotFound,
CharacterNotFound, CharacterNotFound,
Forbidden, Forbidden,
Invalid, Invalid,
ObjectNotFound, ResourceNotFound,
UnsupportedObjectType, ResourceDepleted
ObjectConsumed
} }