diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index e245331..2268cdb 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -2,6 +2,7 @@ extends Control const SESSION_PATH := "user://auth_session.json" const LOCATIONS_BASE_URL := "https://tloc.ranaze.com" +const RESOURCE_REFRESH_SECONDS := 15.0 const HexGridView := preload("res://scenes/game/hex_grid_view.gd") var coordinate_label: Label @@ -10,10 +11,23 @@ var grid_view var http_request: HTTPRequest var visible_request: HTTPRequest var resources_request: HTTPRequest +var units_request: HTTPRequest +var train_unit_request: HTTPRequest +var resources_timer: Timer var session: Dictionary = {} var escape_overlay: Control var escape_panel: PanelContainer var gold_label: Label +var tick_bar: ProgressBar +var tick_label: Label +var gold_tick_anchor_unix := 0.0 +var gold_tick_seconds := 60.0 +var train_button: Button +var unit_label: Label +var training_label: Label +var training_bar: ProgressBar +var training_started_unix := 0.0 +var training_completes_unix := 0.0 func _ready() -> void: @@ -22,6 +36,11 @@ func _ready() -> void: _assign_home_location() +func _process(_delta: float) -> void: + _update_tick_bar() + _update_training_bar() + + func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_cancel"): _toggle_escape_menu() @@ -91,6 +110,27 @@ func _build_ui() -> void: gold_label.add_theme_color_override("font_color", Color(0.95, 0.78, 0.32)) top_content.add_child(gold_label) + var tick_box := VBoxContainer.new() + tick_box.custom_minimum_size = Vector2(130, 0) + tick_box.add_theme_constant_override("separation", 4) + top_content.add_child(tick_box) + + tick_label = Label.new() + tick_label.text = "Next: --" + tick_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + tick_label.add_theme_font_size_override("font_size", 12) + tick_label.add_theme_color_override("font_color", Color(0.72, 0.82, 0.78)) + tick_box.add_child(tick_label) + + tick_bar = ProgressBar.new() + tick_bar.custom_minimum_size = Vector2(130, 8) + tick_bar.max_value = 1.0 + tick_bar.value = 0.0 + tick_bar.show_percentage = false + tick_bar.add_theme_stylebox_override("background", _progress_style(Color(0.035, 0.055, 0.055), Color(0.18, 0.28, 0.26))) + tick_bar.add_theme_stylebox_override("fill", _progress_style(Color(0.82, 0.65, 0.2), Color(0.9, 0.78, 0.36))) + tick_box.add_child(tick_bar) + var user_label := Label.new() user_label.text = _session_name() user_label.add_theme_font_size_override("font_size", 15) @@ -123,11 +163,48 @@ func _build_ui() -> void: bottom_margin.add_theme_constant_override("margin_bottom", 8) bottom_bar.add_child(bottom_margin) + var bottom_content := HBoxContainer.new() + bottom_content.add_theme_constant_override("separation", 16) + bottom_margin.add_child(bottom_content) + coordinate_label = Label.new() coordinate_label.text = "Drag to pan. Mouse wheel zooms. Select a tile to inspect territory." + coordinate_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL coordinate_label.add_theme_font_size_override("font_size", 14) coordinate_label.add_theme_color_override("font_color", Color(0.72, 0.8, 0.78)) - bottom_margin.add_child(coordinate_label) + bottom_content.add_child(coordinate_label) + + unit_label = Label.new() + unit_label.text = "Scouts: --" + unit_label.custom_minimum_size = Vector2(88, 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)) + bottom_content.add_child(unit_label) + + var training_box := VBoxContainer.new() + training_box.custom_minimum_size = Vector2(150, 0) + training_box.add_theme_constant_override("separation", 4) + bottom_content.add_child(training_box) + + training_label = Label.new() + training_label.text = "Training: idle" + training_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + training_label.add_theme_font_size_override("font_size", 12) + training_label.add_theme_color_override("font_color", Color(0.72, 0.82, 0.78)) + training_box.add_child(training_label) + + training_bar = ProgressBar.new() + training_bar.custom_minimum_size = Vector2(150, 8) + training_bar.max_value = 1.0 + training_bar.value = 0.0 + training_bar.show_percentage = false + training_bar.add_theme_stylebox_override("background", _progress_style(Color(0.035, 0.055, 0.055), Color(0.18, 0.28, 0.26))) + training_bar.add_theme_stylebox_override("fill", _progress_style(Color(0.28, 0.62, 0.72), Color(0.43, 0.82, 0.92))) + training_box.add_child(training_bar) + + train_button = _small_button("Train Scout 50g") + train_button.pressed.connect(_train_scout) + bottom_content.add_child(train_button) http_request = HTTPRequest.new() http_request.request_completed.connect(_on_home_request_completed) @@ -141,6 +218,20 @@ func _build_ui() -> void: resources_request.request_completed.connect(_on_resources_request_completed) add_child(resources_request) + units_request = HTTPRequest.new() + units_request.request_completed.connect(_on_units_request_completed) + add_child(units_request) + + train_unit_request = HTTPRequest.new() + train_unit_request.request_completed.connect(_on_train_unit_request_completed) + add_child(train_unit_request) + + resources_timer = Timer.new() + resources_timer.wait_time = RESOURCE_REFRESH_SECONDS + resources_timer.autostart = false + resources_timer.timeout.connect(_refresh_player_state) + add_child(resources_timer) + _build_escape_menu() @@ -259,7 +350,13 @@ func _on_visible_request_completed(result: int, response_code: int, _headers: Pa var visible_locations: Array = parsed grid_view.set_visible_locations(visible_locations, username) coordinate_label.text = "Visible territory loaded. Sight extends 3 hexes beyond controlled land." + _refresh_player_state() + resources_timer.start() + + +func _refresh_player_state() -> void: _load_resources() + _load_units() func _load_resources() -> void: @@ -268,6 +365,9 @@ func _load_resources() -> void: gold_label.text = "Gold: --" return + if resources_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + var error: int = resources_request.request( LOCATIONS_BASE_URL + "/api/resources/me", PackedStringArray([ @@ -294,6 +394,170 @@ func _on_resources_request_completed(result: int, response_code: int, _headers: var gold_per_minute: int = int(resources.get("goldPerMinute", 0)) gold_label.text = "Gold: %d (+%d/min)" % [gold, gold_per_minute] + var anchor_text: String = str(resources.get("lastGoldGeneratedAtUtc", "")) + gold_tick_anchor_unix = _parse_utc_timestamp(anchor_text) + if gold_tick_anchor_unix <= 0.0: + gold_tick_anchor_unix = Time.get_unix_time_from_system() + _update_tick_bar() + + +func _load_units() -> void: + var access_token: String = str(session.get("accessToken", "")) + if access_token.is_empty(): + unit_label.text = "Scouts: --" + return + + if units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + + var error: int = units_request.request( + LOCATIONS_BASE_URL + "/api/units/me", + PackedStringArray([ + "Authorization: Bearer %s" % access_token + ]), + HTTPClient.METHOD_GET + ) + if error != OK: + unit_label.text = "Scouts: ?" + + +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: ?" + return + + var parsed = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + unit_label.text = "Scouts: ?" + return + + var units: Dictionary = parsed + unit_label.text = "Scouts: %d" % int(units.get("completedScouts", 0)) + + var training: Array = [] + var raw_training = units.get("training", []) + if typeof(raw_training) == TYPE_ARRAY: + training = raw_training + if training.is_empty(): + training_started_unix = 0.0 + training_completes_unix = 0.0 + _update_training_bar() + return + + if typeof(training[0]) != TYPE_DICTIONARY: + return + + var active_training: Dictionary = training[0] + training_started_unix = _parse_utc_timestamp(str(active_training.get("startedAtUtc", ""))) + training_completes_unix = _parse_utc_timestamp(str(active_training.get("completesAtUtc", ""))) + _update_training_bar() + + +func _train_scout() -> 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." + return + + if train_unit_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + + train_button.disabled = true + coordinate_label.text = "Starting scout training..." + var error: int = train_unit_request.request( + LOCATIONS_BASE_URL + "/api/units/train", + PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % access_token + ]), + HTTPClient.METHOD_POST, + JSON.stringify({ "unitType": "Scout" }) + ) + if error != OK: + train_button.disabled = false + 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 + var text: String = body.get_string_from_utf8() + if result != HTTPRequest.RESULT_SUCCESS: + coordinate_label.text = "Scout training request failed." + return + + if response_code < 200 or response_code >= 300: + coordinate_label.text = "Scout training failed: %s" % text + _refresh_player_state() + return + + var parsed = JSON.parse_string(text) + if typeof(parsed) == TYPE_DICTIONARY: + var training: Dictionary = parsed + training_started_unix = _parse_utc_timestamp(str(training.get("startedAtUtc", ""))) + training_completes_unix = _parse_utc_timestamp(str(training.get("completesAtUtc", ""))) + coordinate_label.text = "Scout training started. Completion takes 1 minute." + + _refresh_player_state() + + +func _update_tick_bar() -> void: + if not tick_bar or not tick_label: + return + + if gold_tick_anchor_unix <= 0.0: + tick_bar.value = 0.0 + tick_label.text = "Next: --" + return + + var elapsed: float = max(0.0, Time.get_unix_time_from_system() - gold_tick_anchor_unix) + var tick_progress: float = fposmod(elapsed, gold_tick_seconds) / gold_tick_seconds + var seconds_remaining: int = int(ceil(gold_tick_seconds - fposmod(elapsed, gold_tick_seconds))) + if seconds_remaining >= int(gold_tick_seconds): + seconds_remaining = int(gold_tick_seconds) + + tick_bar.value = tick_progress + tick_label.text = "Next: %ds" % seconds_remaining + + +func _update_training_bar() -> void: + if not training_bar or not training_label: + return + + if training_started_unix <= 0.0 or training_completes_unix <= 0.0: + training_bar.value = 0.0 + training_label.text = "Training: idle" + return + + var now: float = Time.get_unix_time_from_system() + var duration: float = max(1.0, training_completes_unix - training_started_unix) + var elapsed: float = clamp(now - training_started_unix, 0.0, duration) + var remaining: int = int(max(0, int(ceil(training_completes_unix - now)))) + training_bar.value = elapsed / duration + + if remaining <= 0: + training_label.text = "Training: ready" + else: + training_label.text = "Training: %ds" % remaining + + +func _parse_utc_timestamp(timestamp: String) -> float: + if timestamp.is_empty(): + return 0.0 + + var normalized := timestamp.strip_edges() + var timezone_index := normalized.find("+") + if timezone_index > -1: + normalized = normalized.substr(0, timezone_index) + if normalized.ends_with("Z"): + normalized = normalized.substr(0, normalized.length() - 1) + + var fractional_index := normalized.find(".") + if fractional_index > -1: + normalized = normalized.substr(0, fractional_index) + + normalized = normalized.replace("T", " ") + return Time.get_unix_time_from_datetime_string(normalized) + func _build_escape_menu() -> void: escape_overlay = Control.new() @@ -393,6 +657,13 @@ func _menu_button(text_value: String) -> Button: return button +func _small_button(text_value: String) -> Button: + var button := _menu_button(text_value) + button.custom_minimum_size = Vector2(128, 32) + button.add_theme_font_size_override("font_size", 13) + return button + + func _find_first_button(node: Node) -> Button: if node is Button: return node @@ -421,6 +692,15 @@ func _button_style(color: Color) -> StyleBoxFlat: return style +func _progress_style(color: Color, border_color: Color) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = color + style.border_color = border_color + style.set_border_width_all(1) + style.set_corner_radius_all(3) + return style + + func _legend_item(color: Color, label_text: String) -> HBoxContainer: var item := HBoxContainer.new() item.add_theme_constant_override("separation", 5) diff --git a/microservices/LocationsApi/Controllers/UnitsController.cs b/microservices/LocationsApi/Controllers/UnitsController.cs new file mode 100644 index 0000000..e3687c9 --- /dev/null +++ b/microservices/LocationsApi/Controllers/UnitsController.cs @@ -0,0 +1,58 @@ +using LocationsApi.Models; +using LocationsApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace LocationsApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class UnitsController : ControllerBase +{ + private readonly UnitTrainingService _units; + + public UnitsController(UnitTrainingService units) + { + _units = units; + } + + [Authorize(Roles = "USER,SUPER")] + [HttpGet("me")] + public async Task> GetMine() + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + return Ok(await _units.GetMineAsync(username)); + } + + [Authorize(Roles = "USER,SUPER")] + [HttpPost("train")] + public async Task> Train([FromBody] TrainUnitRequest request) + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + try + { + var training = await _units.TrainAsync(username, request.UnitType); + if (training is null) + { + return BadRequest(new { error = "Not enough gold." }); + } + + return Ok(UnitTrainingResponse.FromTraining(training)); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + } +} diff --git a/microservices/LocationsApi/Models/UnitDto.cs b/microservices/LocationsApi/Models/UnitDto.cs new file mode 100644 index 0000000..6133713 --- /dev/null +++ b/microservices/LocationsApi/Models/UnitDto.cs @@ -0,0 +1,36 @@ +namespace LocationsApi.Models; + +public class TrainUnitRequest +{ + public string UnitType { get; set; } = "Scout"; +} + +public class UnitTrainingResponse +{ + public string Id { get; set; } = ""; + public string Username { get; set; } = ""; + public string UnitType { get; set; } = ""; + public int GoldCost { get; set; } + public string Status { get; set; } = ""; + public DateTime StartedAtUtc { get; set; } + public DateTime CompletesAtUtc { get; set; } + public DateTime? CompletedAtUtc { get; set; } + + public static UnitTrainingResponse FromTraining(UnitTraining training) => new() + { + Id = training.Id, + Username = training.Username, + UnitType = training.UnitType, + GoldCost = training.GoldCost, + Status = training.Status, + StartedAtUtc = training.StartedAtUtc, + CompletesAtUtc = training.CompletesAtUtc, + CompletedAtUtc = training.CompletedAtUtc + }; +} + +public class PlayerUnitsResponse +{ + public int CompletedScouts { get; set; } + public List Training { get; set; } = []; +} diff --git a/microservices/LocationsApi/Models/UnitTraining.cs b/microservices/LocationsApi/Models/UnitTraining.cs new file mode 100644 index 0000000..894bf69 --- /dev/null +++ b/microservices/LocationsApi/Models/UnitTraining.cs @@ -0,0 +1,21 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace LocationsApi.Models; + +public class UnitTraining +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ""; + + public string Username { get; set; } = ""; + public string UnitType { get; set; } = "Scout"; + public int GoldCost { get; set; } = 50; + public string Status { get; set; } = "Training"; + public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime CompletesAtUtc { get; set; } = DateTime.UtcNow.AddMinutes(1); + public DateTime? CompletedAtUtc { get; set; } + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/microservices/LocationsApi/Program.cs b/microservices/LocationsApi/Program.cs index caa8d43..2ff755b 100644 --- a/microservices/LocationsApi/Program.cs +++ b/microservices/LocationsApi/Program.cs @@ -9,6 +9,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { diff --git a/microservices/LocationsApi/README.md b/microservices/LocationsApi/README.md index 6abf109..c7c4ff9 100644 --- a/microservices/LocationsApi/README.md +++ b/microservices/LocationsApi/README.md @@ -14,6 +14,8 @@ Tracks hex locations for the territory map. - `GET /api/locations/visible?depth=3` Override visibility depth for a request. - `PUT /api/locations` Upsert location data (SUPER only). - `GET /api/resources/me` Get the authenticated player's resources. +- `GET /api/units/me` Get completed units and active training for the authenticated player. +- `POST /api/units/train` Spend gold and start training a unit. `claim` and `put` require the same JWT issued by AuthApi. @@ -26,3 +28,9 @@ 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. Generation is applied lazily when the resource record is read. + +## 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. diff --git a/microservices/LocationsApi/Services/ResourceService.cs b/microservices/LocationsApi/Services/ResourceService.cs index a95ebae..c112407 100644 --- a/microservices/LocationsApi/Services/ResourceService.cs +++ b/microservices/LocationsApi/Services/ResourceService.cs @@ -51,8 +51,31 @@ public class ResourceService public async Task GetAsync(string username) { - var resources = await EnsureAsync(username); - return await ApplyGoldGenerationAsync(resources); + return await EnsureAsync(username); + } + + public async Task TrySpendGoldAsync(string username, int amount) + { + if (amount <= 0) + { + return true; + } + + await EnsureAsync(username); + + var update = Builders.Update + .Inc(x => x.Gold, -amount) + .Set(x => x.UpdatedAtUtc, DateTime.UtcNow); + + var updated = await _resources.FindOneAndUpdateAsync( + x => x.Username == username && x.Gold >= amount, + update, + new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After + }); + + return updated is not null; } private async Task ApplyGoldGenerationAsync(PlayerResources resources) diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs new file mode 100644 index 0000000..3f51398 --- /dev/null +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -0,0 +1,93 @@ +using LocationsApi.Models; +using MongoDB.Driver; + +namespace LocationsApi.Services; + +public class UnitTrainingService +{ + private const string ScoutUnitType = "Scout"; + + private readonly IMongoCollection _training; + private readonly ResourceService _resources; + private readonly int _scoutGoldCost; + private readonly int _scoutTrainingSeconds; + + public UnitTrainingService(IConfiguration cfg, ResourceService resources) + { + var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var databaseName = cfg["MongoDB:DatabaseName"] ?? "territory-autobattler"; + var client = new MongoClient(connectionString); + var database = client.GetDatabase(databaseName); + _training = database.GetCollection("UnitTraining"); + _resources = resources; + _scoutGoldCost = int.TryParse(cfg["Units:ScoutGoldCost"], out var scoutGoldCost) ? Math.Max(0, scoutGoldCost) : 50; + _scoutTrainingSeconds = int.TryParse(cfg["Units:ScoutTrainingSeconds"], out var scoutTrainingSeconds) + ? Math.Max(1, scoutTrainingSeconds) + : 60; + + _training.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.Username).Ascending(x => x.Status))); + } + + public async Task GetMineAsync(string username) + { + await CompleteFinishedTrainingAsync(username); + + var all = await _training.Find(x => x.Username == username).ToListAsync(); + var completedScouts = all.Count(x => x.UnitType == ScoutUnitType && x.Status == "Completed"); + var activeTraining = all + .Where(x => x.Status == "Training") + .OrderBy(x => x.CompletesAtUtc) + .Select(UnitTrainingResponse.FromTraining) + .ToList(); + + return new PlayerUnitsResponse + { + CompletedScouts = completedScouts, + Training = activeTraining + }; + } + + public async Task TrainAsync(string username, string unitType) + { + if (!string.Equals(unitType, ScoutUnitType, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Unsupported unit type.", nameof(unitType)); + } + + var spent = await _resources.TrySpendGoldAsync(username, _scoutGoldCost); + if (!spent) + { + return null; + } + + var now = DateTime.UtcNow; + var training = new UnitTraining + { + Username = username, + UnitType = ScoutUnitType, + GoldCost = _scoutGoldCost, + Status = "Training", + StartedAtUtc = now, + CompletesAtUtc = now.AddSeconds(_scoutTrainingSeconds), + CreatedAtUtc = now, + UpdatedAtUtc = now + }; + + await _training.InsertOneAsync(training); + return training; + } + + private async Task CompleteFinishedTrainingAsync(string username) + { + var now = DateTime.UtcNow; + var update = Builders.Update + .Set(x => x.Status, "Completed") + .Set(x => x.CompletedAtUtc, now) + .Set(x => x.UpdatedAtUtc, now); + + await _training.UpdateManyAsync( + x => x.Username == username && x.Status == "Training" && x.CompletesAtUtc <= now, + update); + } +} diff --git a/microservices/LocationsApi/appsettings.json b/microservices/LocationsApi/appsettings.json index 103fa5c..bb3df4c 100644 --- a/microservices/LocationsApi/appsettings.json +++ b/microservices/LocationsApi/appsettings.json @@ -23,5 +23,9 @@ "StartingGold": 100, "HeadquartersGoldPerMinute": 10 }, + "Units": { + "ScoutGoldCost": 50, + "ScoutTrainingSeconds": 60 + }, "AllowedHosts": "*" } diff --git a/microservices/LocationsApi/k8s/configmap.yaml b/microservices/LocationsApi/k8s/configmap.yaml index ce38506..70a3eff 100644 --- a/microservices/LocationsApi/k8s/configmap.yaml +++ b/microservices/LocationsApi/k8s/configmap.yaml @@ -13,3 +13,5 @@ data: World__VisibilityDepth: "3" Resources__StartingGold: "100" Resources__HeadquartersGoldPerMinute: "10" + Units__ScoutGoldCost: "50" + Units__ScoutTrainingSeconds: "60"