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;
namespace CharacterApi.Models;
[BsonIgnoreExtraElements]
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Bson;
namespace CharacterApi.Models;
[BsonIgnoreExtraElements]
public class VisibleLocation
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("coord")]
public LocationCoord Coord { get; set; } = new();
[BsonElement("biomeKey")]
public string BiomeKey { get; set; } = "plains";
[BsonElement("locationObject")]
public VisibleLocationObject? LocationObject { get; set; }
[BsonElement("resources")]
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 MongoDB.Bson;
using MongoDB.Driver;
namespace CharacterApi.Services;
namespace CharacterApi.Services;
public class CharacterStore
{
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 Dictionary<string, BiomeDefinition> Biomes = new()
{
["plains"] = new("plains", 3.0, new()
{
["forest"] = 1.7,
["wetlands"] = 0.9,
["rocky"] = 0.8,
["desert"] = 0.4
}),
["forest"] = new("forest", 3.4, new()
{
["plains"] = 1.6,
["wetlands"] = 1.3,
["rocky"] = 0.5,
["desert"] = 0.1
}),
["wetlands"] = new("wetlands", 3.1, new()
{
["forest"] = 1.5,
["plains"] = 1.1,
["rocky"] = 0.2,
["desert"] = 0.05
}),
["rocky"] = new("rocky", 3.0, new()
{
["plains"] = 1.2,
["forest"] = 0.6,
["desert"] = 1.1,
["wetlands"] = 0.1
}),
["desert"] = new("desert", 3.2, new()
{
["rocky"] = 1.4,
["plains"] = 0.8,
["forest"] = 0.1,
["wetlands"] = 0.05
})
["plains"] = new BiomeDefinition(
"plains",
3.0,
new Dictionary<string, double>
{
["forest"] = 1.7,
["wetlands"] = 0.9,
["rocky"] = 0.8,
["desert"] = 0.4
},
new[]
{
new ResourceRule("grass", 180, 420, 12),
new ResourceRule("wood", 25, 90, 3),
new ResourceRule("stone", 10, 40, 2)
}),
["forest"] = new BiomeDefinition(
"forest",
3.4,
new Dictionary<string, double>
{
["plains"] = 1.6,
["wetlands"] = 1.3,
["rocky"] = 0.5,
["desert"] = 0.1
},
new[]
{
new ResourceRule("wood", 220, 520, 6),
new ResourceRule("grass", 90, 220, 8),
new ResourceRule("stone", 15, 45, 2)
}),
["wetlands"] = new BiomeDefinition(
"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);
private sealed record BiomeDefinition(string Key, double ContinuationWeight, Dictionary<string, double> TransitionWeights);
public CharacterStore(IConfiguration cfg)
{
@ -66,9 +108,9 @@ public class CharacterStore
var ownerIndex = Builders<Character>.IndexKeys.Ascending(c => c.OwnerUserId);
_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) =>
_col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync();
@ -83,8 +125,10 @@ public class CharacterStore
return result.ModifiedCount > 0 || result.MatchedCount > 0;
}
public Task<List<VisibleLocation>> GetVisibleLocationsAsync(Character character) =>
GetVisibleLocationsInternalAsync(character, ensureGenerated: false);
public Task<List<VisibleLocation>> GetVisibleLocationsAsync(Character character)
{
return GetVisibleLocationsInternalAsync(character, ensureGenerated: false);
}
public async Task<VisibleLocationResult> GetOrCreateVisibleLocationsAsync(Character character)
{
@ -109,13 +153,6 @@ public class CharacterStore
);
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();
}
@ -136,6 +173,39 @@ public class CharacterStore
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)
{
var filter = Builders<BsonDocument>.Filter.And(
@ -160,8 +230,7 @@ public class CharacterStore
{ "y", y }
})
.SetOnInsert("biomeKey", biomeKey)
.SetOnInsert("locationObject", CreateLocationObjectValueForBiome(biomeKey, x, y))
.SetOnInsert("locationObjectResolved", true)
.SetOnInsert("resources", BuildResourcesDocument(biomeKey, x, y))
.SetOnInsert("createdUtc", DateTime.UtcNow);
try
@ -185,17 +254,11 @@ public class CharacterStore
var biomeKey = document.GetValue("biomeKey", BsonNull.Value).IsBsonNull
? await DetermineBiomeKeyAsync(x, y)
: document.GetValue("biomeKey", "plains").AsString;
var objectResolved = document.TryGetValue("locationObjectResolved", out var resolvedValue) &&
resolvedValue.ToBoolean();
if (!document.Contains("biomeKey"))
updates.Add(Builders<BsonDocument>.Update.Set("biomeKey", biomeKey));
if (!objectResolved)
{
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 (!document.Contains("resources"))
updates.Add(Builders<BsonDocument>.Update.Set("resources", BuildResourcesDocument(biomeKey, x, y)));
if (updates.Count == 0)
return;
@ -254,7 +317,14 @@ public class CharacterStore
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 =>
Builders<BsonDocument>.Filter.And(
Builders<BsonDocument>.Filter.Eq("coord.x", coord.Item1),
@ -287,131 +357,68 @@ public class CharacterStore
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);
return biomeKey switch
{
"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)
}
};
}
if (!Biomes.TryGetValue(biomeKey, out var biome))
biome = Biomes["plains"];
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)
var resources = new BsonArray();
foreach (var rule in biome.ResourceRules)
{
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;
var remainingQuantity = resource.GetValue("remainingQuantity", 0).ToInt32();
if (remainingQuantity <= 0)
resources.Add(new BsonDocument
{
{ "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;
var itemKey = NormalizeItemKey(resource.GetValue("itemKey", "").AsString);
var gatherQuantity = Math.Max(1, resource.GetValue("gatherQuantity", 1).ToInt32());
return CreateGatherableObjectDocument(itemKey, remainingQuantity, gatherQuantity);
var resource = value.AsBsonDocument;
results.Add(new VisibleLocationResource
{
ItemKey = resource.GetValue("itemKey", "").AsString,
RemainingQuantity = resource.GetValue("remainingQuantity", 0).ToInt32(),
GatherQuantity = resource.GetValue("gatherQuantity", 1).ToInt32()
});
}
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()
}
};
return results;
}
private void EnsureLocationCoordIndexes()
@ -428,7 +435,11 @@ public class CharacterStore
_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 };
_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);
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);
return result.DeletedCount > 0;
}
private static string DefaultLocationName(int x, int y)
{
if (x == 0 && y == 0)
return "Origin";
return $"Location {x},{y}";
}
private sealed record ResourceRule(string ItemKey, int MinQuantity, int MaxQuantity, int GatherQuantity);
private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant();
private static string HumanizeItemKey(string itemKey)
{
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;
}
}
private sealed record BiomeDefinition(
string Key,
double ContinuationWeight,
Dictionary<string, double> TransitionWeights,
IReadOnlyList<ResourceRule> ResourceRules);
}

View File

@ -94,42 +94,40 @@ public class LocationsController : ControllerBase
return Ok("Updated");
}
[HttpPost("{id}/interact")]
[HttpPost("{id}/gather")]
[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))
return BadRequest("characterId required");
if (string.IsNullOrWhiteSpace(req.ObjectId))
return BadRequest("objectId required");
if (string.IsNullOrWhiteSpace(req.ResourceKey))
return BadRequest("resourceKey required");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var allowAnyOwner = User.IsInRole("SUPER");
var interact = await _locations.InteractWithObjectAsync(id, req.CharacterId, req.ObjectId, userId, allowAnyOwner);
if (interact.Status == InteractStatus.LocationNotFound)
var gather = await _locations.GatherResourceAsync(id, req.CharacterId, req.ResourceKey, userId, allowAnyOwner);
if (gather.Status == GatherStatus.LocationNotFound)
return NotFound("Location not found");
if (interact.Status == InteractStatus.CharacterNotFound)
if (gather.Status == GatherStatus.CharacterNotFound)
return NotFound("Character not found");
if (interact.Status == InteractStatus.Forbidden)
if (gather.Status == GatherStatus.Forbidden)
return Forbid();
if (interact.Status == InteractStatus.Invalid)
if (gather.Status == GatherStatus.Invalid)
return BadRequest("Character is not at the target location");
if (interact.Status == InteractStatus.ObjectNotFound)
return NotFound("Location object not found");
if (interact.Status == InteractStatus.UnsupportedObjectType)
return BadRequest("Location object type is not supported");
if (interact.Status == InteractStatus.ObjectConsumed)
return Conflict("Location object is consumed");
if (gather.Status == GatherStatus.ResourceNotFound)
return NotFound("Resource not found at location");
if (gather.Status == GatherStatus.ResourceDepleted)
return Conflict("Resource is depleted");
var inventoryBaseUrl = (_configuration["Services:InventoryApiBaseUrl"] ?? "http://localhost:5003").TrimEnd('/');
var token = Request.Headers.Authorization.ToString();
var grantBody = JsonSerializer.Serialize(new
{
itemKey = interact.ItemKey,
quantity = interact.QuantityGranted
itemKey = gather.ResourceKey,
quantity = gather.QuantityGranted
});
var client = _httpClientFactory.CreateClient();
@ -144,21 +142,17 @@ public class LocationsController : ControllerBase
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
if (interact.PreviousObject is not null)
await _locations.RestoreObjectInteractionAsync(id, interact.PreviousObject);
await _locations.RestoreGatheredResourceAsync(id, gather.ResourceKey, gather.QuantityGranted);
return StatusCode((int)response.StatusCode, responseBody);
}
return Ok(new InteractLocationObjectResponse
return Ok(new GatherResourceResponse
{
LocationId = id,
CharacterId = req.CharacterId,
ObjectId = interact.ObjectId,
ObjectType = interact.ObjectType,
ItemKey = interact.ItemKey,
QuantityGranted = interact.QuantityGranted,
RemainingQuantity = interact.RemainingQuantity,
Consumed = interact.Consumed,
ResourceKey = gather.ResourceKey,
QuantityGranted = gather.QuantityGranted,
RemainingQuantity = gather.RemainingQuantity,
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.Serialization.Attributes;
namespace LocationsApi.Models;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace LocationsApi.Models;
public class Location
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
[BsonElement("name")]
public string Name { get; set; } = string.Empty;
[BsonElement("coord")]
public required Coord Coord { get; set; }
@ -21,12 +21,6 @@ public class Location
[BsonElement("resources")]
public List<LocationResource> Resources { get; set; } = [];
[BsonElement("locationObject")]
public LocationObject? LocationObject { get; set; }
[BsonElement("locationObjectResolved")]
public bool LocationObjectResolved { get; set; }
[BsonElement("createdUtc")]
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
[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" } } }
}
}
}
}
}
}
};
var collections = db.ListCollectionNames().ToList();
@ -156,79 +126,54 @@ public class LocationStore
return result.ModifiedCount > 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 sealed record GatherResult(GatherStatus Status, string ResourceKey = "", int QuantityGranted = 0, int RemainingQuantity = 0);
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();
if (location is null)
return new InteractResult(InteractStatus.LocationNotFound);
location = await EnsureLocationMetadataAsync(location);
return new GatherResult(GatherStatus.LocationNotFound);
var character = await _characters.Find(c => c.Id == characterId).FirstOrDefaultAsync();
if (character is null)
return new InteractResult(InteractStatus.CharacterNotFound);
return new GatherResult(GatherStatus.CharacterNotFound);
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)
return new InteractResult(InteractStatus.Invalid);
return new GatherResult(GatherStatus.Invalid);
var locationObject = location.LocationObject;
if (locationObject is null)
return new InteractResult(InteractStatus.ObjectNotFound);
if (!string.Equals(locationObject.Id, objectId, StringComparison.Ordinal))
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 resource = location.Resources.FirstOrDefault(r => NormalizeItemKey(r.ItemKey) == normalizedKey);
if (resource is null)
return new GatherResult(GatherStatus.ResourceNotFound);
if (resource.RemainingQuantity <= 0)
return new GatherResult(GatherStatus.ResourceDepleted);
var quantityGranted = Math.Min(locationObject.State.GatherQuantity, locationObject.State.RemainingQuantity);
var remainingQuantity = locationObject.State.RemainingQuantity - quantityGranted;
var objectFilter = Builders<Location>.Filter.And(
var quantityGranted = Math.Min(resource.GatherQuantity, resource.RemainingQuantity);
var filter = Builders<Location>.Filter.And(
Builders<Location>.Filter.Eq(l => l.Id, locationId),
Builders<Location>.Filter.Eq("locationObject.id", locationObject.Id),
Builders<Location>.Filter.Eq("locationObject.state.remainingQuantity", locationObject.State.RemainingQuantity)
Builders<Location>.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == resource.ItemKey && r.RemainingQuantity >= quantityGranted)
);
UpdateDefinition<Location> 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);
var update = Builders<Location>.Update.Inc("resources.$.remainingQuantity", -quantityGranted);
var result = await _col.UpdateOneAsync(filter, update);
if (result.ModifiedCount == 0)
return new InteractResult(InteractStatus.ObjectConsumed);
return new GatherResult(GatherStatus.ResourceDepleted);
return new InteractResult(
InteractStatus.Ok,
locationObject.Id,
locationObject.ObjectType,
locationObject.State.ItemKey,
return new GatherResult(
GatherStatus.Ok,
resource.ItemKey,
quantityGranted,
Math.Max(0, remainingQuantity),
remainingQuantity <= 0,
CloneLocationObject(locationObject));
resource.RemainingQuantity - quantityGranted);
}
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 update = Builders<Location>.Update.Set(l => l.LocationObject, previousObject);
var normalizedKey = NormalizeItemKey(resourceKey);
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);
}
@ -273,19 +218,35 @@ public class LocationStore
{
var filter = Builders<Location>.Filter.And(
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();
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;
}
var origin = new Location
{
Name = "Origin",
Coord = new Coord { X = 0, Y = 0 },
BiomeKey = DetermineBiomeKey(0, 0),
LocationObject = CreateLocationObjectForBiome(DetermineBiomeKey(0, 0), 0, 0),
LocationObjectResolved = true,
BiomeKey = "plains",
Resources =
[
new LocationResource { ItemKey = "wood", RemainingQuantity = 100, GatherQuantity = 3 },
new LocationResource { ItemKey = "grass", RemainingQuantity = 500, GatherQuantity = 10 }
],
CreatedUtc = DateTime.UtcNow
};
@ -301,150 +262,6 @@ public class LocationStore
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]
private class CharacterDocument
{
@ -458,14 +275,13 @@ public class LocationStore
}
}
public enum InteractStatus
public enum GatherStatus
{
Ok,
LocationNotFound,
CharacterNotFound,
Forbidden,
Invalid,
ObjectNotFound,
UnsupportedObjectType,
ObjectConsumed
ResourceNotFound,
ResourceDepleted
}