From e78eb245438b92b7d8023e0e29d473fbd8252a4f Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 19 May 2026 05:11:57 -0500 Subject: [PATCH] adding resource limits and archers --- .../scenes/game/hex_grid_screen.gd | 106 +++++++++++++++--- .../Controllers/UnitsController.cs | 6 +- .../LocationsApi/Models/PlayerResources.cs | 3 + .../LocationsApi/Models/ResourceDto.cs | 6 + microservices/LocationsApi/Models/UnitDto.cs | 5 + .../LocationsApi/Models/UnitTraining.cs | 2 + microservices/LocationsApi/README.md | 17 +-- .../Services/GameSettingsService.cs | 1 + .../LocationsApi/Services/ResourceService.cs | 43 ++++++- .../Services/UnitTrainingService.cs | 75 ++++++++++++- microservices/LocationsApi/appsettings.json | 10 +- microservices/LocationsApi/k8s/configmap.yaml | 6 + 12 files changed, 241 insertions(+), 39 deletions(-) diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index 3032465..b050db4 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -31,6 +31,7 @@ var resource_text := "Gold: --\nWood: --\nOre: --" var gold_tick_anchor_unix := 0.0 var gold_tick_seconds := 60.0 var train_button: Button +var train_archer_button: Button var unit_label: Label var training_box: VBoxContainer var training_label: Label @@ -47,6 +48,7 @@ var building_started_unix := 0.0 var building_completes_unix := 0.0 var building_completion_refresh_requested := false var active_building_label := "Build" +var active_training_label := "Training" var claim_button: Button var build_log_camp_button: Button var build_mine_button: Button @@ -57,8 +59,10 @@ var selected_can_claim := false var selected_can_build_log_camp := false var selected_can_build_mine := false var selected_can_build_barracks := false +var selected_can_train_archer := false var selected_is_home := false var completed_scout_count := 0 +var completed_archer_count := 0 var refresh_prompt_active := false var refresh_request_active := false @@ -202,6 +206,11 @@ func _build_ui() -> void: train_button.pressed.connect(_train_scout) action_buttons.add_child(train_button) + train_archer_button = _small_button("Archer 100g 100w") + train_archer_button.visible = false + train_archer_button.pressed.connect(_train_archer) + action_buttons.add_child(train_archer_button) + claim_button = _small_button("Claim 1 Scout") claim_button.visible = false claim_button.pressed.connect(_claim_selected_tile) @@ -223,8 +232,8 @@ func _build_ui() -> void: action_buttons.add_child(build_barracks_button) unit_label = Label.new() - unit_label.text = "Scouts: -- | Queue: --" - unit_label.custom_minimum_size = Vector2(126, 0) + unit_label.text = "Scouts: -- | Archers: -- | Queue: --" + unit_label.custom_minimum_size = Vector2(180, 0) unit_label.add_theme_font_size_override("font_size", 14) unit_label.add_theme_color_override("font_color", Color(0.72, 0.82, 0.78)) status_row.add_child(unit_label) @@ -408,6 +417,7 @@ func _on_tile_selected(q: int, r: int, terrain: String) -> void: selected_can_build_log_camp = _can_build_log_camp_on_selected_tile() selected_can_build_mine = _can_build_mine_on_selected_tile() selected_can_build_barracks = _can_build_barracks_on_selected_tile() + selected_can_train_archer = _selected_tile_has_built_barracks() _update_action_buttons() if coordinate_label: coordinate_label.text = "Selected q:%d r:%d | %s territory" % [q, r, terrain] @@ -449,6 +459,9 @@ func _update_action_buttons() -> void: if train_button: train_button.visible = selected_is_home train_button.disabled = false + if train_archer_button: + train_archer_button.visible = selected_can_train_archer + train_archer_button.disabled = false if claim_button: claim_button.visible = _can_claim_selected_tile() claim_button.disabled = false @@ -477,6 +490,21 @@ func _selected_tile_is_home() -> bool: return owner_username == username and kind == "Home" +func _selected_tile_has_built_barracks() -> bool: + if not grid_view: + return false + + var location: Dictionary = grid_view.get_visible_location(selected_q, selected_r) + if location.is_empty(): + return false + + var username: String = str(session.get("username", "")) + var owner_username: String = str(location.get("ownerUsername", "")) + var building_type: String = _nullable_string(location.get("buildingType", "")) + var building_status: String = _nullable_string(location.get("buildingStatus", "")) + return owner_username == username and building_type == "Barracks" and (building_status == "Built" or building_status.is_empty()) + + func _session_name() -> String: if not session.is_empty(): var session_username: String = str(session.get("username", "Signed in")) @@ -720,11 +748,24 @@ func _on_resources_request_completed(result: int, response_code: int, _headers: var resources: Dictionary = parsed var gold: int = int(resources.get("gold", 0)) var gold_per_minute: int = int(resources.get("goldPerMinute", 0)) + var gold_capacity: int = int(resources.get("goldCapacity", 500)) var wood: int = int(resources.get("wood", 0)) var wood_per_minute: int = int(resources.get("woodPerMinute", 0)) + var wood_capacity: int = int(resources.get("woodCapacity", 100)) var ore: int = int(resources.get("ore", 0)) var ore_per_minute: int = int(resources.get("orePerMinute", 0)) - resource_text = "Gold: %d (+%d/min)\nWood: %d (+%d/min)\nOre: %d (+%d/min)" % [gold, gold_per_minute, wood, wood_per_minute, ore, ore_per_minute] + var ore_capacity: int = int(resources.get("oreCapacity", 100)) + resource_text = "Gold: %d/%d (+%d/min)\nWood: %d/%d (+%d/min)\nOre: %d/%d (+%d/min)" % [ + gold, + gold_capacity, + gold_per_minute, + wood, + wood_capacity, + wood_per_minute, + ore, + ore_capacity, + ore_per_minute + ] gold_label.text = resource_text var anchor_text: String = str(resources.get("lastGoldGeneratedAtUtc", "")) @@ -737,7 +778,7 @@ func _on_resources_request_completed(result: int, response_code: int, _headers: func _load_units() -> void: var access_token: String = str(session.get("accessToken", "")) if access_token.is_empty(): - unit_label.text = "Scouts: --" + unit_label.text = "Scouts: -- | Archers: -- | Queue: --" return if units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: @@ -756,12 +797,12 @@ func _load_units() -> void: func _on_units_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: - unit_label.text = "Scouts: ?" + unit_label.text = "Scouts: ? | Archers: ? | Queue: ?" return var parsed = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_DICTIONARY: - unit_label.text = "Scouts: ?" + unit_label.text = "Scouts: ? | Archers: ? | Queue: ?" return var units: Dictionary = parsed @@ -771,12 +812,14 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack training = raw_training completed_scout_count = int(units.get("completedScouts", 0)) - unit_label.text = "Scouts: %d | Queue: %d" % [completed_scout_count, training.size()] + completed_archer_count = int(units.get("completedArchers", 0)) + unit_label.text = "Scouts: %d | Archers: %d | Queue: %d" % [completed_scout_count, completed_archer_count, training.size()] _update_action_buttons() if training.is_empty(): training_started_unix = 0.0 training_completes_unix = 0.0 + active_training_label = "Training" training_completion_refresh_requested = false _update_training_bar() return @@ -785,6 +828,7 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack return var active_training: Dictionary = training[0] + active_training_label = _unit_display_name(str(active_training.get("unitType", "Training"))) training_started_unix = _parse_utc_timestamp(str(active_training.get("startedAtUtc", ""))) training_completes_unix = _parse_utc_timestamp(str(active_training.get("completesAtUtc", ""))) training_completion_refresh_requested = false @@ -792,16 +836,31 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack func _train_scout() -> void: + _request_unit_training("Scout") + + +func _train_archer() -> void: + if not selected_can_train_archer: + coordinate_label.text = "Select a completed Barracks to train Archers." + return + + _request_unit_training("Archer") + + +func _request_unit_training(unit_type: String) -> void: var access_token: String = str(session.get("accessToken", "")) if access_token.is_empty(): - coordinate_label.text = "No auth token available. Scout training was not started." + coordinate_label.text = "No auth token available. %s training was not started." % unit_type return if train_unit_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: return - train_button.disabled = true - coordinate_label.text = "Starting scout training..." + if unit_type == "Archer": + train_archer_button.disabled = true + else: + train_button.disabled = true + coordinate_label.text = "Starting %s training..." % unit_type var error: int = train_unit_request.request( LOCATIONS_BASE_URL + "/api/units/train", PackedStringArray([ @@ -809,32 +868,33 @@ func _train_scout() -> void: "Authorization: Bearer %s" % access_token ]), HTTPClient.METHOD_POST, - JSON.stringify({ "unitType": "Scout" }) + JSON.stringify({ "unitType": unit_type }) ) if error != OK: - train_button.disabled = false + _update_action_buttons() coordinate_label.text = "Could not contact the unit training service." func _on_train_unit_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: - train_button.disabled = false + _update_action_buttons() var text: String = body.get_string_from_utf8() if result != HTTPRequest.RESULT_SUCCESS: - coordinate_label.text = "Scout training request failed." + coordinate_label.text = "Unit training request failed." return if response_code < 200 or response_code >= 300: - coordinate_label.text = "Scout training failed: %s" % text + coordinate_label.text = "Unit training failed: %s" % text _refresh_player_state() return var parsed = JSON.parse_string(text) if typeof(parsed) == TYPE_DICTIONARY: var training: Dictionary = parsed + active_training_label = _unit_display_name(str(training.get("unitType", "Training"))) training_started_unix = _parse_utc_timestamp(str(training.get("startedAtUtc", ""))) training_completes_unix = _parse_utc_timestamp(str(training.get("completesAtUtc", ""))) training_completion_refresh_requested = false - coordinate_label.text = "Scout training started. Completion takes 1 minute." + coordinate_label.text = "%s training started. Completion takes 1 minute." % active_training_label _refresh_player_state() @@ -1018,12 +1078,12 @@ func _update_training_bar() -> void: training_bar.value = elapsed / duration if remaining <= 0: - training_label.text = "Training: ready" + training_label.text = "%s: ready" % active_training_label if not training_completion_refresh_requested: training_completion_refresh_requested = true _load_units() else: - training_label.text = "Training: %ds" % remaining + training_label.text = "%s: %ds" % [active_training_label, remaining] _update_progress_panel_visibility() @@ -1108,6 +1168,16 @@ func _building_display_name(building_type: String) -> String: return "Build" +func _unit_display_name(unit_type: String) -> String: + match unit_type: + "Scout": + return "Scout" + "Archer": + return "Archer" + _: + return "Training" + + func _parse_utc_timestamp(timestamp: String) -> float: if timestamp.is_empty(): return 0.0 diff --git a/microservices/LocationsApi/Controllers/UnitsController.cs b/microservices/LocationsApi/Controllers/UnitsController.cs index e3687c9..6d50340 100644 --- a/microservices/LocationsApi/Controllers/UnitsController.cs +++ b/microservices/LocationsApi/Controllers/UnitsController.cs @@ -45,7 +45,7 @@ public class UnitsController : ControllerBase var training = await _units.TrainAsync(username, request.UnitType); if (training is null) { - return BadRequest(new { error = "Not enough gold." }); + return BadRequest(new { error = "Not enough resources." }); } return Ok(UnitTrainingResponse.FromTraining(training)); @@ -54,5 +54,9 @@ public class UnitsController : ControllerBase { return BadRequest(new { error = ex.Message }); } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } } } diff --git a/microservices/LocationsApi/Models/PlayerResources.cs b/microservices/LocationsApi/Models/PlayerResources.cs index c30a707..b4bfaa5 100644 --- a/microservices/LocationsApi/Models/PlayerResources.cs +++ b/microservices/LocationsApi/Models/PlayerResources.cs @@ -12,10 +12,13 @@ public class PlayerResources public string Username { get; set; } = ""; public int Gold { get; set; } = 100; public int GoldPerMinute { get; set; } = 10; + public int GoldCapacity { get; set; } = 500; public int Wood { get; set; } public int WoodPerMinute { get; set; } + public int WoodCapacity { get; set; } = 100; public int Ore { get; set; } public int OrePerMinute { get; set; } + public int OreCapacity { get; set; } = 100; public DateTime LastGoldGeneratedAtUtc { get; set; } = DateTime.UtcNow; public DateTime LastWoodGeneratedAtUtc { get; set; } = DateTime.UtcNow; public DateTime LastOreGeneratedAtUtc { get; set; } = DateTime.UtcNow; diff --git a/microservices/LocationsApi/Models/ResourceDto.cs b/microservices/LocationsApi/Models/ResourceDto.cs index d385cc0..6f959a1 100644 --- a/microservices/LocationsApi/Models/ResourceDto.cs +++ b/microservices/LocationsApi/Models/ResourceDto.cs @@ -5,10 +5,13 @@ public class PlayerResourcesResponse public string Username { get; set; } = ""; public int Gold { get; set; } public int GoldPerMinute { get; set; } + public int GoldCapacity { get; set; } public int Wood { get; set; } public int WoodPerMinute { get; set; } + public int WoodCapacity { get; set; } public int Ore { get; set; } public int OrePerMinute { get; set; } + public int OreCapacity { get; set; } public DateTime LastGoldGeneratedAtUtc { get; set; } public DateTime LastWoodGeneratedAtUtc { get; set; } public DateTime LastOreGeneratedAtUtc { get; set; } @@ -19,10 +22,13 @@ public class PlayerResourcesResponse Username = resources.Username, Gold = resources.Gold, GoldPerMinute = resources.GoldPerMinute, + GoldCapacity = resources.GoldCapacity, Wood = resources.Wood, WoodPerMinute = resources.WoodPerMinute, + WoodCapacity = resources.WoodCapacity, Ore = resources.Ore, OrePerMinute = resources.OrePerMinute, + OreCapacity = resources.OreCapacity, LastGoldGeneratedAtUtc = resources.LastGoldGeneratedAtUtc, LastWoodGeneratedAtUtc = resources.LastWoodGeneratedAtUtc, LastOreGeneratedAtUtc = resources.LastOreGeneratedAtUtc, diff --git a/microservices/LocationsApi/Models/UnitDto.cs b/microservices/LocationsApi/Models/UnitDto.cs index 6133713..acbd5f9 100644 --- a/microservices/LocationsApi/Models/UnitDto.cs +++ b/microservices/LocationsApi/Models/UnitDto.cs @@ -11,6 +11,8 @@ public class UnitTrainingResponse public string Username { get; set; } = ""; public string UnitType { get; set; } = ""; public int GoldCost { get; set; } + public int WoodCost { get; set; } + public int OreCost { get; set; } public string Status { get; set; } = ""; public DateTime StartedAtUtc { get; set; } public DateTime CompletesAtUtc { get; set; } @@ -22,6 +24,8 @@ public class UnitTrainingResponse Username = training.Username, UnitType = training.UnitType, GoldCost = training.GoldCost, + WoodCost = training.WoodCost, + OreCost = training.OreCost, Status = training.Status, StartedAtUtc = training.StartedAtUtc, CompletesAtUtc = training.CompletesAtUtc, @@ -32,5 +36,6 @@ public class UnitTrainingResponse public class PlayerUnitsResponse { public int CompletedScouts { get; set; } + public int CompletedArchers { get; set; } public List Training { get; set; } = []; } diff --git a/microservices/LocationsApi/Models/UnitTraining.cs b/microservices/LocationsApi/Models/UnitTraining.cs index 894bf69..52fdbf5 100644 --- a/microservices/LocationsApi/Models/UnitTraining.cs +++ b/microservices/LocationsApi/Models/UnitTraining.cs @@ -12,6 +12,8 @@ public class UnitTraining public string Username { get; set; } = ""; public string UnitType { get; set; } = "Scout"; public int GoldCost { get; set; } = 50; + public int WoodCost { get; set; } + public int OreCost { get; set; } public string Status { get; set; } = "Training"; public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow; public DateTime CompletesAtUtc { get; set; } = DateTime.UtcNow.AddMinutes(1); diff --git a/microservices/LocationsApi/README.md b/microservices/LocationsApi/README.md index 8cf193d..06dcb9f 100644 --- a/microservices/LocationsApi/README.md +++ b/microservices/LocationsApi/README.md @@ -28,16 +28,18 @@ locations are unowned `Open` records until claimed by a player. Player resource state is stored server-side in the `PlayerResources` collection. New players start with 100 gold. A player's headquarters generates 10 gold per -minute. Log Camps generate wood and Mines generate ore. Generation is applied -lazily when the resource record is read. +minute. The starting town hall stores up to 500 gold, 100 wood, and 100 ore. +Log Camps generate wood and Mines generate ore. Generation is applied lazily +when the resource record is read and clamps at the current storage capacity. ## Units -Unit training is stored server-side in the `UnitTraining` collection. The first -unit type is `Scout`, which costs 50 gold and takes 60 seconds to train. -Finished training is completed lazily when unit state is read. Claiming an -unclaimed visible location consumes one completed Scout by deleting that Scout's -training record. +Unit training is stored server-side in the `UnitTraining` collection. `Scout` +costs 50 gold and takes 60 seconds to train. `Archer` requires a completed +Barracks, costs 100 gold and 100 wood, and takes 60 seconds to train. Finished +training is completed lazily when unit state is read. Claiming an unclaimed +visible location consumes one completed Scout by deleting that Scout's training +record. ## Buildings @@ -53,6 +55,7 @@ Timer settings are stored in the MongoDB `GameSettings` collection and are seeded on first read: - `ScoutTrainingSeconds` +- `ArcherTrainingSeconds` - `LogCampBuildSeconds` - `MineBuildSeconds` - `BarracksBuildSeconds` diff --git a/microservices/LocationsApi/Services/GameSettingsService.cs b/microservices/LocationsApi/Services/GameSettingsService.cs index 2ba5e24..57fad5d 100644 --- a/microservices/LocationsApi/Services/GameSettingsService.cs +++ b/microservices/LocationsApi/Services/GameSettingsService.cs @@ -6,6 +6,7 @@ namespace LocationsApi.Services; public class GameSettingsService { public const string ScoutTrainingSeconds = "ScoutTrainingSeconds"; + public const string ArcherTrainingSeconds = "ArcherTrainingSeconds"; public const string LogCampBuildSeconds = "LogCampBuildSeconds"; public const string MineBuildSeconds = "MineBuildSeconds"; public const string BarracksBuildSeconds = "BarracksBuildSeconds"; diff --git a/microservices/LocationsApi/Services/ResourceService.cs b/microservices/LocationsApi/Services/ResourceService.cs index b38f572..c1c7322 100644 --- a/microservices/LocationsApi/Services/ResourceService.cs +++ b/microservices/LocationsApi/Services/ResourceService.cs @@ -8,6 +8,9 @@ public class ResourceService private readonly IMongoCollection _resources; private readonly int _startingGold; private readonly int _headquartersGoldPerMinute; + private readonly int _townHallGoldCapacity; + private readonly int _townHallWoodCapacity; + private readonly int _townHallOreCapacity; public ResourceService(IConfiguration cfg) { @@ -20,6 +23,15 @@ public class ResourceService _headquartersGoldPerMinute = int.TryParse(cfg["Resources:HeadquartersGoldPerMinute"], out var goldPerMinute) ? Math.Max(0, goldPerMinute) : 10; + _townHallGoldCapacity = int.TryParse(cfg["Resources:TownHallGoldCapacity"], out var goldCapacity) + ? Math.Max(0, goldCapacity) + : 500; + _townHallWoodCapacity = int.TryParse(cfg["Resources:TownHallWoodCapacity"], out var woodCapacity) + ? Math.Max(0, woodCapacity) + : 100; + _townHallOreCapacity = int.TryParse(cfg["Resources:TownHallOreCapacity"], out var oreCapacity) + ? Math.Max(0, oreCapacity) + : 100; _resources.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.Username), @@ -31,7 +43,7 @@ public class ResourceService var now = DateTime.UtcNow; var update = Builders.Update .SetOnInsert(x => x.Username, username) - .SetOnInsert(x => x.Gold, _startingGold) + .SetOnInsert(x => x.Gold, Math.Min(_startingGold, _townHallGoldCapacity)) .SetOnInsert(x => x.Wood, 0) .SetOnInsert(x => x.Ore, 0) .SetOnInsert(x => x.CreatedAtUtc, now) @@ -39,6 +51,9 @@ public class ResourceService .SetOnInsert(x => x.LastWoodGeneratedAtUtc, now) .SetOnInsert(x => x.LastOreGeneratedAtUtc, now) .Set(x => x.GoldPerMinute, _headquartersGoldPerMinute) + .Set(x => x.GoldCapacity, _townHallGoldCapacity) + .Set(x => x.WoodCapacity, _townHallWoodCapacity) + .Set(x => x.OreCapacity, _townHallOreCapacity) .Set(x => x.UpdatedAtUtc, now); var resources = await _resources.FindOneAndUpdateAsync( @@ -167,34 +182,52 @@ public class ResourceService var oreElapsedMinutes = resources.OrePerMinute <= 0 || resources.LastOreGeneratedAtUtc.Year < 2000 ? 0 : (int)Math.Floor((now - resources.LastOreGeneratedAtUtc).TotalMinutes); + var cappedGold = Math.Min(resources.Gold, _townHallGoldCapacity); + var cappedWood = Math.Min(resources.Wood, _townHallWoodCapacity); + var cappedOre = Math.Min(resources.Ore, _townHallOreCapacity); + var isOverCapacity = resources.Gold != cappedGold || resources.Wood != cappedWood || resources.Ore != cappedOre; - if (goldElapsedMinutes <= 0 && woodElapsedMinutes <= 0 && oreElapsedMinutes <= 0) + if (goldElapsedMinutes <= 0 && woodElapsedMinutes <= 0 && oreElapsedMinutes <= 0 && !isOverCapacity) { return resources; } var update = Builders.Update .Set(x => x.GoldPerMinute, _headquartersGoldPerMinute) + .Set(x => x.GoldCapacity, _townHallGoldCapacity) + .Set(x => x.WoodCapacity, _townHallWoodCapacity) + .Set(x => x.OreCapacity, _townHallOreCapacity) .Set(x => x.UpdatedAtUtc, now); + if (isOverCapacity) + { + update = update + .Set(x => x.Gold, cappedGold) + .Set(x => x.Wood, cappedWood) + .Set(x => x.Ore, cappedOre); + } + if (goldElapsedMinutes > 0) { + var generatedGold = Math.Min(_townHallGoldCapacity, cappedGold + (goldElapsedMinutes * resources.GoldPerMinute)); update = update - .Inc(x => x.Gold, goldElapsedMinutes * resources.GoldPerMinute) + .Set(x => x.Gold, generatedGold) .Set(x => x.LastGoldGeneratedAtUtc, resources.LastGoldGeneratedAtUtc.AddMinutes(goldElapsedMinutes)); } if (woodElapsedMinutes > 0) { + var generatedWood = Math.Min(_townHallWoodCapacity, cappedWood + (woodElapsedMinutes * resources.WoodPerMinute)); update = update - .Inc(x => x.Wood, woodElapsedMinutes * resources.WoodPerMinute) + .Set(x => x.Wood, generatedWood) .Set(x => x.LastWoodGeneratedAtUtc, resources.LastWoodGeneratedAtUtc.AddMinutes(woodElapsedMinutes)); } if (oreElapsedMinutes > 0) { + var generatedOre = Math.Min(_townHallOreCapacity, cappedOre + (oreElapsedMinutes * resources.OrePerMinute)); update = update - .Inc(x => x.Ore, oreElapsedMinutes * resources.OrePerMinute) + .Set(x => x.Ore, generatedOre) .Set(x => x.LastOreGeneratedAtUtc, resources.LastOreGeneratedAtUtc.AddMinutes(oreElapsedMinutes)); } diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs index 227366d..238c33b 100644 --- a/microservices/LocationsApi/Services/UnitTrainingService.cs +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -6,12 +6,17 @@ namespace LocationsApi.Services; public class UnitTrainingService { private const string ScoutUnitType = "Scout"; + private const string ArcherUnitType = "Archer"; private readonly IMongoCollection _training; + private readonly IMongoCollection _locations; private readonly ResourceService _resources; private readonly GameSettingsService _settings; private readonly int _scoutGoldCost; + private readonly int _archerGoldCost; + private readonly int _archerWoodCost; private readonly int _defaultScoutTrainingSeconds; + private readonly int _defaultArcherTrainingSeconds; public UnitTrainingService(IConfiguration cfg, ResourceService resources, GameSettingsService settings) { @@ -20,12 +25,18 @@ public class UnitTrainingService var client = new MongoClient(connectionString); var database = client.GetDatabase(databaseName); _training = database.GetCollection("UnitTraining"); + _locations = database.GetCollection("Locations"); _resources = resources; _settings = settings; _scoutGoldCost = int.TryParse(cfg["Units:ScoutGoldCost"], out var scoutGoldCost) ? Math.Max(0, scoutGoldCost) : 50; + _archerGoldCost = int.TryParse(cfg["Units:ArcherGoldCost"], out var archerGoldCost) ? Math.Max(0, archerGoldCost) : 100; + _archerWoodCost = int.TryParse(cfg["Units:ArcherWoodCost"], out var archerWoodCost) ? Math.Max(0, archerWoodCost) : 100; _defaultScoutTrainingSeconds = int.TryParse(cfg["Units:ScoutTrainingSeconds"], out var scoutTrainingSeconds) ? Math.Max(1, scoutTrainingSeconds) : 60; + _defaultArcherTrainingSeconds = int.TryParse(cfg["Units:ArcherTrainingSeconds"], out var archerTrainingSeconds) + ? Math.Max(1, archerTrainingSeconds) + : 60; _training.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.Username).Ascending(x => x.Status))); @@ -38,6 +49,7 @@ public class UnitTrainingService var all = await _training.Find(x => x.Username == username).ToListAsync(); var completedScouts = all.Count(x => x.UnitType == ScoutUnitType && x.Status == "Completed"); + var completedArchers = all.Count(x => x.UnitType == ArcherUnitType && x.Status == "Completed"); var activeTraining = all .Where(x => x.Status == "Training") .OrderBy(x => x.CompletesAtUtc) @@ -47,33 +59,47 @@ public class UnitTrainingService return new PlayerUnitsResponse { CompletedScouts = completedScouts, + CompletedArchers = completedArchers, Training = activeTraining }; } public async Task TrainAsync(string username, string unitType) { - if (!string.Equals(unitType, ScoutUnitType, StringComparison.OrdinalIgnoreCase)) + var normalizedUnitType = NormalizeUnitType(unitType); + if (normalizedUnitType is null) { throw new ArgumentException("Unsupported unit type.", nameof(unitType)); } - var spent = await _resources.TrySpendGoldAsync(username, _scoutGoldCost); + if (normalizedUnitType == ArcherUnitType && !await HasBuiltBarracksAsync(username)) + { + throw new InvalidOperationException("A completed Barracks is required to train Archers."); + } + + var goldCost = normalizedUnitType == ArcherUnitType ? _archerGoldCost : _scoutGoldCost; + var woodCost = normalizedUnitType == ArcherUnitType ? _archerWoodCost : 0; + var spent = normalizedUnitType == ArcherUnitType + ? await _resources.TrySpendResourcesAsync(username, goldCost, woodCost, 0) + : await _resources.TrySpendGoldAsync(username, goldCost); if (!spent) { return null; } var now = DateTime.UtcNow; - var scoutTrainingSeconds = await _settings.GetIntAsync(GameSettingsService.ScoutTrainingSeconds, _defaultScoutTrainingSeconds); + var trainingSeconds = normalizedUnitType == ArcherUnitType + ? await _settings.GetIntAsync(GameSettingsService.ArcherTrainingSeconds, _defaultArcherTrainingSeconds) + : await _settings.GetIntAsync(GameSettingsService.ScoutTrainingSeconds, _defaultScoutTrainingSeconds); var training = new UnitTraining { Username = username, - UnitType = ScoutUnitType, - GoldCost = _scoutGoldCost, + UnitType = normalizedUnitType, + GoldCost = goldCost, + WoodCost = woodCost, Status = "Training", StartedAtUtc = now, - CompletesAtUtc = now.AddSeconds(scoutTrainingSeconds), + CompletesAtUtc = now.AddSeconds(trainingSeconds), CreatedAtUtc = now, UpdatedAtUtc = now }; @@ -82,6 +108,32 @@ public class UnitTrainingService return training; } + private async Task HasBuiltBarracksAsync(string username) + { + await CompleteFinishedBuildingsAsync(username); + return await _locations.Find(x => + x.OwnerUsername == username && + x.BuildingType == "Barracks" && + (x.BuildingStatus == "Built" || x.BuildingStatus == null || x.BuildingStatus == "")) + .AnyAsync(); + } + + private async Task CompleteFinishedBuildingsAsync(string username) + { + var now = DateTime.UtcNow; + var update = Builders.Update + .Set(x => x.BuildingStatus, "Built") + .Set(x => x.BuildingBuiltAtUtc, now) + .Set(x => x.UpdatedAtUtc, now); + + await _locations.UpdateManyAsync( + x => x.OwnerUsername == username && + x.BuildingType == "Barracks" && + x.BuildingStatus == "Building" && + x.BuildingCompletesAtUtc <= now, + update); + } + public async Task TryConsumeCompletedScoutAsync(string username) { await CompleteFinishedTrainingAsync(username); @@ -114,4 +166,15 @@ public class UnitTrainingService x => x.Username == username && x.Status == "Training" && x.CompletesAtUtc <= now, update); } + + private static string? NormalizeUnitType(string unitType) + { + var trimmed = unitType.Trim(); + return trimmed.ToLowerInvariant() switch + { + "scout" => ScoutUnitType, + "archer" => ArcherUnitType, + _ => null + }; + } } diff --git a/microservices/LocationsApi/appsettings.json b/microservices/LocationsApi/appsettings.json index da8174d..f8f3d7e 100644 --- a/microservices/LocationsApi/appsettings.json +++ b/microservices/LocationsApi/appsettings.json @@ -21,11 +21,17 @@ }, "Resources": { "StartingGold": 100, - "HeadquartersGoldPerMinute": 10 + "HeadquartersGoldPerMinute": 10, + "TownHallGoldCapacity": 500, + "TownHallWoodCapacity": 100, + "TownHallOreCapacity": 100 }, "Units": { "ScoutGoldCost": 50, - "ScoutTrainingSeconds": 60 + "ScoutTrainingSeconds": 60, + "ArcherGoldCost": 100, + "ArcherWoodCost": 100, + "ArcherTrainingSeconds": 60 }, "Buildings": { "LogCampWoodPerMinute": 5, diff --git a/microservices/LocationsApi/k8s/configmap.yaml b/microservices/LocationsApi/k8s/configmap.yaml index 14dcbac..9468547 100644 --- a/microservices/LocationsApi/k8s/configmap.yaml +++ b/microservices/LocationsApi/k8s/configmap.yaml @@ -13,8 +13,14 @@ data: World__VisibilityDepth: "3" Resources__StartingGold: "100" Resources__HeadquartersGoldPerMinute: "10" + Resources__TownHallGoldCapacity: "500" + Resources__TownHallWoodCapacity: "100" + Resources__TownHallOreCapacity: "100" Units__ScoutGoldCost: "50" Units__ScoutTrainingSeconds: "60" + Units__ArcherGoldCost: "100" + Units__ArcherWoodCost: "100" + Units__ArcherTrainingSeconds: "60" Buildings__LogCampWoodPerMinute: "5" Buildings__LogCampBuildSeconds: "60" Buildings__MineOrePerMinute: "5"