From 1aefd5ba88e656c6203ccfb3a4871f7fea688eff Mon Sep 17 00:00:00 2001 From: Zeeshaun Date: Thu, 19 Mar 2026 16:35:07 -0500 Subject: [PATCH] Adding interactable object spawning --- game/scenes/Levels/location_level.gd | 208 ++++++++++++- .../CharacterApi/Models/VisibleLocation.cs | 6 + .../Models/VisibleLocationObject.cs | 21 ++ .../Models/VisibleLocationObjectState.cs | 15 + .../CharacterApi/Services/CharacterStore.cs | 180 +++++++++++ .../Controllers/LocationsController.cs | 46 +-- .../Models/InteractLocationObjectRequest.cs | 8 + .../Models/InteractLocationObjectResponse.cs | 22 ++ microservices/LocationsApi/Models/Location.cs | 9 + .../LocationsApi/Models/LocationObject.cs | 21 ++ .../Models/LocationObjectState.cs | 15 + .../LocationsApi/Services/LocationStore.cs | 285 +++++++++++++++--- 12 files changed, 768 insertions(+), 68 deletions(-) create mode 100644 microservices/CharacterApi/Models/VisibleLocationObject.cs create mode 100644 microservices/CharacterApi/Models/VisibleLocationObjectState.cs create mode 100644 microservices/LocationsApi/Models/InteractLocationObjectRequest.cs create mode 100644 microservices/LocationsApi/Models/InteractLocationObjectResponse.cs create mode 100644 microservices/LocationsApi/Models/LocationObject.cs create mode 100644 microservices/LocationsApi/Models/LocationObjectState.cs diff --git a/game/scenes/Levels/location_level.gd b/game/scenes/Levels/location_level.gd index 48d01b2..c390f8d 100644 --- a/game/scenes/Levels/location_level.gd +++ b/game/scenes/Levels/location_level.gd @@ -1,6 +1,7 @@ extends Node3D const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters" +const LOCATION_API_URL := "https://ploc.ranaze.com/api/Locations" @export var tile_size := 16.0 @export var block_height := 1.0 @@ -31,6 +32,7 @@ var _coord_sync_in_flight := false var _queued_coord_sync: Variant = null var _locations_refresh_in_flight := false var _queued_locations_refresh := false +var _interact_in_flight := false func _ready() -> void: @@ -69,6 +71,11 @@ func _process(_delta: float) -> void: _queue_locations_refresh() +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("interact"): + _try_interact_current_tile() + + func _get_stream_position() -> Vector3: if _tracked_node: return _tracked_node.global_position @@ -102,9 +109,11 @@ func _rebuild_tiles(center: Vector2i) -> void: if not _known_locations.has(coord): continue wanted_keys[coord] = true + var location_data := _known_locations[coord] as Dictionary if _tile_nodes.has(coord): + _update_tile(coord, location_data) continue - _spawn_tile(coord, String(_known_locations[coord])) + _spawn_tile(coord, location_data) for key in _tile_nodes.keys(): if wanted_keys.has(key): @@ -115,7 +124,7 @@ func _rebuild_tiles(center: Vector2i) -> void: _tile_nodes.erase(key) -func _spawn_tile(coord: Vector2i, location_name: String) -> void: +func _spawn_tile(coord: Vector2i, location_data: Dictionary) -> void: var tile_root := Node3D.new() tile_root.name = "Tile_%d_%d" % [coord.x, coord.y] tile_root.position = _coord_to_world(coord) @@ -138,11 +147,60 @@ func _spawn_tile(coord: Vector2i, location_name: String) -> void: tile.add_child(_create_tile_border()) if show_tile_labels: - tile_root.add_child(_create_tile_label(location_name)) + tile_root.add_child(_create_tile_label(String(location_data.get("name", "")))) + _update_tile_object(tile_root, location_data) _tile_nodes[coord] = tile_root +func _update_tile(coord: Vector2i, location_data: Dictionary) -> void: + var tile_root := _tile_nodes.get(coord) as Node3D + if tile_root == null: + return + + if show_tile_labels: + var label := tile_root.get_node_or_null("LocationNameLabel") as Label3D + if label: + label.text = String(location_data.get("name", "")) + + _update_tile_object(tile_root, location_data) + + +func _update_tile_object(tile_root: Node3D, location_data: Dictionary) -> void: + var existing := tile_root.get_node_or_null("LocationObject") + if existing: + existing.queue_free() + + var object_data_variant: Variant = location_data.get("locationObject", {}) + if typeof(object_data_variant) != TYPE_DICTIONARY: + return + + var object_data := object_data_variant as Dictionary + if object_data.is_empty(): + return + + var object_root := Node3D.new() + object_root.name = "LocationObject" + object_root.position = Vector3(0.0, (block_height * 0.5) + 0.6, 0.0) + tile_root.add_child(object_root) + + var object_mesh := MeshInstance3D.new() + object_mesh.name = "ObjectMesh" + object_mesh.mesh = SphereMesh.new() + object_mesh.scale = Vector3(0.6, 0.4, 0.6) + object_mesh.material_override = _create_object_material(String(object_data.get("objectKey", ""))) + object_root.add_child(object_mesh) + + var object_label := Label3D.new() + object_label.name = "ObjectLabel" + object_label.text = _build_object_label(object_data) + object_label.position = Vector3(0.0, 0.6, 0.0) + object_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + object_label.pixel_size = 0.01 + object_label.outline_size = 10 + object_root.add_child(object_label) + + func _create_tile_border() -> MeshInstance3D: var top_y := 0.5 + border_height_bias var corners := [ @@ -193,10 +251,42 @@ func _create_tile_label(location_name: String) -> Label3D: return label +func _create_object_material(object_key: String) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.roughness = 1.0 + material.metallic = 0.0 + + if object_key.contains("grass"): + material.albedo_color = Color(0.28, 0.68, 0.25, 1.0) + elif object_key.contains("wood"): + material.albedo_color = Color(0.54, 0.36, 0.18, 1.0) + elif object_key.contains("stone"): + material.albedo_color = Color(0.55, 0.57, 0.6, 1.0) + else: + material.albedo_color = Color(0.85, 0.75, 0.3, 1.0) + + return material + + +func _build_object_label(object_data: Dictionary) -> String: + var object_name := String(object_data.get("name", "")).strip_edges() + var state_variant: Variant = object_data.get("state", {}) + var remaining_quantity := 0 + if typeof(state_variant) == TYPE_DICTIONARY: + remaining_quantity = int((state_variant as Dictionary).get("remainingQuantity", 0)) + if object_name.is_empty(): + object_name = "Object" + return "%s x%d" % [object_name, remaining_quantity] + + func _ensure_selected_location_exists(coord: Vector2i) -> void: if _known_locations.has(coord): return - _known_locations[coord] = _selected_location_name(coord) + _known_locations[coord] = { + "id": "", + "name": _selected_location_name(coord), + "locationObject": {} + } func _selected_location_name(coord: Vector2i) -> String: @@ -217,6 +307,7 @@ func _load_existing_locations() -> void: if _character_id.is_empty(): push_warning("Selected character is missing an id; cannot load visible locations.") _locations_loaded = true + _locations_refresh_in_flight = false return var request := HTTPRequest.new() @@ -231,6 +322,7 @@ func _load_existing_locations() -> void: push_warning("Failed to request visible locations: %s" % err) request.queue_free() _locations_loaded = true + _locations_refresh_in_flight = false return var result: Array = await request.request_completed @@ -242,12 +334,14 @@ func _load_existing_locations() -> void: if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: push_warning("Failed to load visible locations (%s/%s): %s" % [result_code, response_code, response_body]) _locations_loaded = true + _locations_refresh_in_flight = false return var parsed: Variant = JSON.parse_string(response_body) if typeof(parsed) != TYPE_ARRAY: push_warning("Visible locations response was not an array.") _locations_loaded = true + _locations_refresh_in_flight = false return var loaded_count := 0 @@ -263,7 +357,15 @@ func _load_existing_locations() -> void: var location_name := String(location.get("name", "")).strip_edges() if location_name.is_empty(): location_name = "Location %d,%d" % [coord.x, coord.y] - _known_locations[coord] = location_name + var location_object := {} + var object_variant: Variant = location.get("locationObject", {}) + if typeof(object_variant) == TYPE_DICTIONARY: + location_object = (object_variant as Dictionary).duplicate(true) + _known_locations[coord] = { + "id": String(location.get("id", "")).strip_edges(), + "name": location_name, + "locationObject": location_object + } loaded_count += 1 print("LocationLevel loaded %d visible locations for character %s." % [loaded_count, _character_id]) @@ -295,6 +397,102 @@ func _refresh_visible_locations_async() -> void: await _load_existing_locations() +func _try_interact_current_tile() -> void: + if _interact_in_flight: + return + if _character_id.is_empty(): + return + + var location_data: Dictionary = _known_locations.get(_center_coord, {}) + if location_data.is_empty(): + push_warning("No known location data for %s." % _center_coord) + return + + var location_id := String(location_data.get("id", "")).strip_edges() + if location_id.is_empty(): + push_warning("Current location is missing an id.") + return + + var object_data: Dictionary = location_data.get("locationObject", {}) + if object_data.is_empty(): + push_warning("Current location has no interactable object.") + return + + var object_id := String(object_data.get("id", "")).strip_edges() + if object_id.is_empty(): + push_warning("Current location object is missing an id.") + return + + _interact_in_flight = true + _interact_with_location_async(location_id, object_id) + + +func _interact_with_location_async(location_id: String, object_id: String) -> void: + var request := HTTPRequest.new() + add_child(request) + + var headers := PackedStringArray() + if not AuthState.access_token.is_empty(): + headers.append("Authorization: Bearer %s" % AuthState.access_token) + headers.append("Content-Type: application/json") + + var body := JSON.stringify({ + "characterId": _character_id, + "objectId": object_id + }) + var err := request.request("%s/%s/interact" % [LOCATION_API_URL, location_id], headers, HTTPClient.METHOD_POST, body) + if err != OK: + push_warning("Failed to send location interaction request: %s" % err) + request.queue_free() + _interact_in_flight = false + return + + var result: Array = await request.request_completed + request.queue_free() + + var result_code: int = result[0] + var response_code: int = result[1] + var response_body: String = result[3].get_string_from_utf8() + if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + push_warning("Location interaction failed (%s/%s): %s" % [result_code, response_code, response_body]) + _interact_in_flight = false + return + + var parsed: Variant = JSON.parse_string(response_body) + if typeof(parsed) != TYPE_DICTIONARY: + push_warning("Location interaction response was not an object.") + _interact_in_flight = false + return + + var interaction := parsed as Dictionary + _apply_interaction_result(location_id, interaction) + _interact_in_flight = false + + +func _apply_interaction_result(location_id: String, interaction: Dictionary) -> void: + var consumed := bool(interaction.get("consumed", false)) + var remaining_quantity := int(interaction.get("remainingQuantity", 0)) + + for coord_variant in _known_locations.keys(): + var coord: Vector2i = coord_variant + var location_data: Dictionary = _known_locations[coord] + if String(location_data.get("id", "")) != location_id: + continue + + var updated_location := location_data.duplicate(true) + if consumed: + updated_location["locationObject"] = {} + else: + var object_data: Dictionary = updated_location.get("locationObject", {}) + var state: Dictionary = object_data.get("state", {}) + state["remainingQuantity"] = remaining_quantity + object_data["state"] = state + updated_location["locationObject"] = object_data + _known_locations[coord] = updated_location + _update_tile(coord, updated_location) + return + + func _queue_coord_sync(coord: Vector2i) -> void: if coord == _persisted_coord: return diff --git a/microservices/CharacterApi/Models/VisibleLocation.cs b/microservices/CharacterApi/Models/VisibleLocation.cs index 5aff51d..7378c79 100644 --- a/microservices/CharacterApi/Models/VisibleLocation.cs +++ b/microservices/CharacterApi/Models/VisibleLocation.cs @@ -15,4 +15,10 @@ public class VisibleLocation [BsonElement("coord")] public LocationCoord Coord { get; set; } = new(); + + [BsonElement("biomeKey")] + public string BiomeKey { get; set; } = string.Empty; + + [BsonElement("locationObject")] + public VisibleLocationObject? LocationObject { get; set; } } diff --git a/microservices/CharacterApi/Models/VisibleLocationObject.cs b/microservices/CharacterApi/Models/VisibleLocationObject.cs new file mode 100644 index 0000000..4d13d88 --- /dev/null +++ b/microservices/CharacterApi/Models/VisibleLocationObject.cs @@ -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(); +} diff --git a/microservices/CharacterApi/Models/VisibleLocationObjectState.cs b/microservices/CharacterApi/Models/VisibleLocationObjectState.cs new file mode 100644 index 0000000..9491027 --- /dev/null +++ b/microservices/CharacterApi/Models/VisibleLocationObjectState.cs @@ -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; +} diff --git a/microservices/CharacterApi/Services/CharacterStore.cs b/microservices/CharacterApi/Services/CharacterStore.cs index 4705e03..381a6d1 100644 --- a/microservices/CharacterApi/Services/CharacterStore.cs +++ b/microservices/CharacterApi/Services/CharacterStore.cs @@ -70,6 +70,12 @@ public class CharacterStore ); var documents = await _locations.Find(filter).ToListAsync(); + if (ensureGenerated) + { + foreach (var document in documents) + await EnsureLocationObjectAsync(document); + documents = await _locations.Find(filter).ToListAsync(); + } return documents.Select(MapVisibleLocation).ToList(); } @@ -95,6 +101,9 @@ public class CharacterStore { "x", x }, { "y", y } }) + .SetOnInsert("biomeKey", DetermineBiomeKey(x, y)) + .SetOnInsert("locationObject", CreateLocationObjectValueForBiome(DetermineBiomeKey(x, y), x, y)) + .SetOnInsert("locationObjectResolved", true) .SetOnInsert("createdUtc", DateTime.UtcNow); try @@ -123,6 +132,7 @@ public class CharacterStore 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) @@ -140,10 +150,180 @@ public class CharacterStore { X = coord.GetValue("x", 0).ToInt32(), Y = coord.GetValue("y", 0).ToInt32() + }, + BiomeKey = document.GetValue("biomeKey", "").AsString, + LocationObject = locationObject + }; + } + + private async Task EnsureLocationObjectAsync(BsonDocument document) + { + var hasBiome = document.TryGetValue("biomeKey", out var biomeValue) && + biomeValue.BsonType == BsonType.String && + !string.IsNullOrWhiteSpace(biomeValue.AsString); + var resolved = document.TryGetValue("locationObjectResolved", out var resolvedValue) && + resolvedValue.ToBoolean(); + if (hasBiome && resolved) + return; + + var idValue = document.GetValue("_id", BsonNull.Value); + if (idValue.IsBsonNull) + return; + + var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument; + var x = coord.GetValue("x", 0).ToInt32(); + var y = coord.GetValue("y", 0).ToInt32(); + var biomeKey = hasBiome ? biomeValue.AsString : DetermineBiomeKey(x, y); + var locationObject = TryMigrateLegacyResource(document) ?? CreateLocationObjectValueForBiome(biomeKey, x, y); + var filter = Builders.Filter.And( + Builders.Filter.Eq("_id", idValue) + ); + var update = Builders.Update + .Set("biomeKey", biomeKey) + .Set("locationObjectResolved", true) + .Set("locationObject", locationObject); + await _locations.UpdateOneAsync(filter, update); + } + + 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; + + var remainingQuantity = resource.GetValue("remainingQuantity", 0).ToInt32(); + if (remainingQuantity <= 0) + continue; + + var itemKey = NormalizeItemKey(resource.GetValue("itemKey", "").AsString); + var gatherQuantity = Math.Max(1, resource.GetValue("gatherQuantity", 1).ToInt32()); + return CreateGatherableObjectDocument(itemKey, remainingQuantity, gatherQuantity); + } + + return null; + } + + 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 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 BsonValue CreateLocationObjectValueForBiome(string biomeKey, int x, int y) + { + var roll = Math.Abs(HashCode.Combine(x, y, 1543)) % 100; + return biomeKey switch + { + "forest" => roll switch + { + < 35 => BsonNull.Value, + < 80 => CreateGatherableObjectDocument("wood", 60, 3), + < 95 => CreateGatherableObjectDocument("grass", 120, 10), + _ => CreateGatherableObjectDocument("stone", 40, 2) + }, + "rocky" => roll switch + { + < 60 => BsonNull.Value, + < 90 => CreateGatherableObjectDocument("stone", 40, 2), + _ => CreateGatherableObjectDocument("wood", 60, 3) + }, + "wetlands" => roll switch + { + < 40 => BsonNull.Value, + < 90 => CreateGatherableObjectDocument("grass", 120, 10), + _ => CreateGatherableObjectDocument("wood", 60, 3) + }, + "desert" => roll switch + { + < 70 => BsonNull.Value, + < 95 => CreateGatherableObjectDocument("stone", 40, 2), + _ => CreateGatherableObjectDocument("wood", 60, 3) + }, + _ => roll switch + { + < 50 => BsonNull.Value, + < 85 => CreateGatherableObjectDocument("grass", 120, 10), + _ => CreateGatherableObjectDocument("wood", 60, 3) + } + }; + } + + 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 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 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 void EnsureLocationCoordIndexes() { var indexes = _locations.Indexes.List().ToList(); diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index aa33f88..b24e067 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -94,40 +94,42 @@ public class LocationsController : ControllerBase return Ok("Updated"); } - [HttpPost("{id}/gather")] + [HttpPost("{id}/interact")] [Authorize(Roles = "USER,SUPER")] - public async Task Gather(string id, [FromBody] GatherResourceRequest req) + public async Task Interact(string id, [FromBody] InteractLocationObjectRequest req) { if (string.IsNullOrWhiteSpace(req.CharacterId)) return BadRequest("characterId required"); - if (string.IsNullOrWhiteSpace(req.ResourceKey)) - return BadRequest("resourceKey required"); + if (string.IsNullOrWhiteSpace(req.ObjectId)) + return BadRequest("objectId required"); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var allowAnyOwner = User.IsInRole("SUPER"); - var gather = await _locations.GatherResourceAsync(id, req.CharacterId, req.ResourceKey, userId, allowAnyOwner); - if (gather.Status == GatherStatus.LocationNotFound) + var interact = await _locations.InteractWithObjectAsync(id, req.CharacterId, req.ObjectId, userId, allowAnyOwner); + if (interact.Status == InteractStatus.LocationNotFound) return NotFound("Location not found"); - if (gather.Status == GatherStatus.CharacterNotFound) + if (interact.Status == InteractStatus.CharacterNotFound) return NotFound("Character not found"); - if (gather.Status == GatherStatus.Forbidden) + if (interact.Status == InteractStatus.Forbidden) return Forbid(); - if (gather.Status == GatherStatus.Invalid) + if (interact.Status == InteractStatus.Invalid) return BadRequest("Character is not at the target location"); - if (gather.Status == GatherStatus.ResourceNotFound) - return NotFound("Resource not found at location"); - if (gather.Status == GatherStatus.ResourceDepleted) - return Conflict("Resource is depleted"); + 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"); var inventoryBaseUrl = (_configuration["Services:InventoryApiBaseUrl"] ?? "http://localhost:5003").TrimEnd('/'); var token = Request.Headers.Authorization.ToString(); var grantBody = JsonSerializer.Serialize(new { - itemKey = gather.ResourceKey, - quantity = gather.QuantityGranted + itemKey = interact.ItemKey, + quantity = interact.QuantityGranted }); var client = _httpClientFactory.CreateClient(); @@ -142,17 +144,21 @@ public class LocationsController : ControllerBase var responseBody = await response.Content.ReadAsStringAsync(); 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 Ok(new GatherResourceResponse + return Ok(new InteractLocationObjectResponse { LocationId = id, CharacterId = req.CharacterId, - ResourceKey = gather.ResourceKey, - QuantityGranted = gather.QuantityGranted, - RemainingQuantity = gather.RemainingQuantity, + ObjectId = interact.ObjectId, + ObjectType = interact.ObjectType, + ItemKey = interact.ItemKey, + QuantityGranted = interact.QuantityGranted, + RemainingQuantity = interact.RemainingQuantity, + Consumed = interact.Consumed, InventoryResponseJson = responseBody }); } diff --git a/microservices/LocationsApi/Models/InteractLocationObjectRequest.cs b/microservices/LocationsApi/Models/InteractLocationObjectRequest.cs new file mode 100644 index 0000000..abadb6a --- /dev/null +++ b/microservices/LocationsApi/Models/InteractLocationObjectRequest.cs @@ -0,0 +1,8 @@ +namespace LocationsApi.Models; + +public class InteractLocationObjectRequest +{ + public string CharacterId { get; set; } = string.Empty; + + public string ObjectId { get; set; } = string.Empty; +} diff --git a/microservices/LocationsApi/Models/InteractLocationObjectResponse.cs b/microservices/LocationsApi/Models/InteractLocationObjectResponse.cs new file mode 100644 index 0000000..6fd38f3 --- /dev/null +++ b/microservices/LocationsApi/Models/InteractLocationObjectResponse.cs @@ -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; +} diff --git a/microservices/LocationsApi/Models/Location.cs b/microservices/LocationsApi/Models/Location.cs index f477932..a3737d3 100644 --- a/microservices/LocationsApi/Models/Location.cs +++ b/microservices/LocationsApi/Models/Location.cs @@ -18,6 +18,15 @@ public class Location [BsonElement("resources")] public List Resources { get; set; } = []; + [BsonElement("biomeKey")] + public string BiomeKey { get; set; } = string.Empty; + + [BsonElement("locationObject")] + public LocationObject? LocationObject { get; set; } + + [BsonElement("locationObjectResolved")] + public bool LocationObjectResolved { get; set; } + [BsonElement("createdUtc")] public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; } diff --git a/microservices/LocationsApi/Models/LocationObject.cs b/microservices/LocationsApi/Models/LocationObject.cs new file mode 100644 index 0000000..df340ce --- /dev/null +++ b/microservices/LocationsApi/Models/LocationObject.cs @@ -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(); +} diff --git a/microservices/LocationsApi/Models/LocationObjectState.cs b/microservices/LocationsApi/Models/LocationObjectState.cs new file mode 100644 index 0000000..beb3b41 --- /dev/null +++ b/microservices/LocationsApi/Models/LocationObjectState.cs @@ -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; +} diff --git a/microservices/LocationsApi/Services/LocationStore.cs b/microservices/LocationsApi/Services/LocationStore.cs index 570dbda..4b0ce51 100644 --- a/microservices/LocationsApi/Services/LocationStore.cs +++ b/microservices/LocationsApi/Services/LocationStore.cs @@ -76,11 +76,42 @@ public class LocationStore } } }, + { "biomeKey", new BsonDocument { { "bsonType", new BsonArray { "string", "null" } } } }, + { + "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(); @@ -125,54 +156,79 @@ public class LocationStore 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 GatherResourceAsync(string locationId, string characterId, string resourceKey, string userId, bool allowAnyOwner) + public async Task 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(); 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(); if (character is null) - return new GatherResult(GatherStatus.CharacterNotFound); + return new InteractResult(InteractStatus.CharacterNotFound); 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) - return new GatherResult(GatherStatus.Invalid); + return new InteractResult(InteractStatus.Invalid); - 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 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 quantityGranted = Math.Min(resource.GatherQuantity, resource.RemainingQuantity); - var filter = Builders.Filter.And( + var quantityGranted = Math.Min(locationObject.State.GatherQuantity, locationObject.State.RemainingQuantity); + var remainingQuantity = locationObject.State.RemainingQuantity - quantityGranted; + var objectFilter = Builders.Filter.And( Builders.Filter.Eq(l => l.Id, locationId), - Builders.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == resource.ItemKey && r.RemainingQuantity >= quantityGranted) + Builders.Filter.Eq("locationObject.id", locationObject.Id), + Builders.Filter.Eq("locationObject.state.remainingQuantity", locationObject.State.RemainingQuantity) ); - var update = Builders.Update.Inc("resources.$.remainingQuantity", -quantityGranted); - var result = await _col.UpdateOneAsync(filter, update); - if (result.ModifiedCount == 0) - return new GatherResult(GatherStatus.ResourceDepleted); - return new GatherResult( - GatherStatus.Ok, - resource.ItemKey, + UpdateDefinition update; + if (remainingQuantity <= 0) + { + update = Builders.Update.Unset("locationObject"); + } + else + { + update = Builders.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, - 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.Filter.And( - Builders.Filter.Eq(l => l.Id, locationId), - Builders.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == normalizedKey) - ); - var update = Builders.Update.Inc("resources.$.remainingQuantity", quantity); + var filter = Builders.Filter.Eq(l => l.Id, locationId); + var update = Builders.Update.Set(l => l.LocationObject, previousObject); await _col.UpdateOneAsync(filter, update); } @@ -220,18 +276,16 @@ public class LocationStore Builders.Filter.Eq(l => l.Coord.Y, 0) ); var existing = _col.Find(filter).FirstOrDefault(); - if (existing is not null) - return; - + if (existing is not null) + return; + var origin = new Location { Name = "Origin", Coord = new Coord { X = 0, Y = 0 }, - Resources = - [ - new LocationResource { ItemKey = "wood", RemainingQuantity = 100, GatherQuantity = 3 }, - new LocationResource { ItemKey = "grass", RemainingQuantity = 500, GatherQuantity = 10 } - ], + BiomeKey = DetermineBiomeKey(0, 0), + LocationObject = CreateLocationObjectForBiome(DetermineBiomeKey(0, 0), 0, 0), + LocationObjectResolved = true, CreatedUtc = DateTime.UtcNow }; @@ -247,6 +301,150 @@ public class LocationStore private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant(); + private async Task 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.Filter.And( + Builders.Filter.Eq(l => l.Id, location.Id) + ); + var update = Builders.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 { @@ -260,13 +458,14 @@ public class LocationStore } } -public enum GatherStatus +public enum InteractStatus { Ok, LocationNotFound, CharacterNotFound, Forbidden, Invalid, - ResourceNotFound, - ResourceDepleted + ObjectNotFound, + UnsupportedObjectType, + ObjectConsumed }