diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index 68564d3..e245331 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -9,7 +9,11 @@ var title_font: Font var grid_view var http_request: HTTPRequest var visible_request: HTTPRequest +var resources_request: HTTPRequest var session: Dictionary = {} +var escape_overlay: Control +var escape_panel: PanelContainer +var gold_label: Label func _ready() -> void: @@ -18,6 +22,12 @@ func _ready() -> void: _assign_home_location() +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + _toggle_escape_menu() + get_viewport().set_input_as_handled() + + func _build_ui() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) title_font = load("res://assets/ui/kenney_ui_pack/Font/Kenney Future.ttf") @@ -75,6 +85,12 @@ func _build_ui() -> void: legend.add_child(_legend_item(Color(0.38, 0.56, 0.36), "Open")) legend.add_child(_legend_item(Color(0.045, 0.065, 0.065), "Unknown")) + gold_label = Label.new() + gold_label.text = "Gold: --" + gold_label.add_theme_font_size_override("font_size", 15) + gold_label.add_theme_color_override("font_color", Color(0.95, 0.78, 0.32)) + top_content.add_child(gold_label) + var user_label := Label.new() user_label.text = _session_name() user_label.add_theme_font_size_override("font_size", 15) @@ -121,6 +137,12 @@ func _build_ui() -> void: visible_request.request_completed.connect(_on_visible_request_completed) add_child(visible_request) + resources_request = HTTPRequest.new() + resources_request.request_completed.connect(_on_resources_request_completed) + add_child(resources_request) + + _build_escape_menu() + func _on_tile_hovered(q: int, r: int, terrain: String) -> void: if coordinate_label: @@ -237,6 +259,148 @@ 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." + _load_resources() + + +func _load_resources() -> void: + var access_token: String = str(session.get("accessToken", "")) + if access_token.is_empty(): + gold_label.text = "Gold: --" + return + + var error: int = resources_request.request( + LOCATIONS_BASE_URL + "/api/resources/me", + PackedStringArray([ + "Authorization: Bearer %s" % access_token + ]), + HTTPClient.METHOD_GET + ) + if error != OK: + gold_label.text = "Gold: ?" + + +func _on_resources_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: + gold_label.text = "Gold: ?" + return + + var parsed = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + gold_label.text = "Gold: ?" + return + + var resources: Dictionary = parsed + var gold: int = int(resources.get("gold", 0)) + var gold_per_minute: int = int(resources.get("goldPerMinute", 0)) + gold_label.text = "Gold: %d (+%d/min)" % [gold, gold_per_minute] + + +func _build_escape_menu() -> void: + escape_overlay = Control.new() + escape_overlay.visible = false + escape_overlay.mouse_filter = Control.MOUSE_FILTER_STOP + escape_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(escape_overlay) + + var scrim := ColorRect.new() + scrim.color = Color(0.01, 0.015, 0.015, 0.64) + scrim.set_anchors_preset(Control.PRESET_FULL_RECT) + escape_overlay.add_child(scrim) + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + escape_overlay.add_child(center) + + escape_panel = PanelContainer.new() + escape_panel.custom_minimum_size = Vector2(360, 310) + escape_panel.add_theme_stylebox_override("panel", _panel_style(Color(0.09, 0.13, 0.13, 0.98), Color(0.34, 0.49, 0.46), 2, 8)) + center.add_child(escape_panel) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 30) + margin.add_theme_constant_override("margin_top", 28) + margin.add_theme_constant_override("margin_right", 30) + margin.add_theme_constant_override("margin_bottom", 26) + escape_panel.add_child(margin) + + var menu := VBoxContainer.new() + menu.add_theme_constant_override("separation", 14) + margin.add_child(menu) + + var title := Label.new() + title.text = "Paused" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 30) + title.add_theme_color_override("font_color", Color(0.94, 0.96, 0.9)) + if title_font: + title.add_theme_font_override("font", title_font) + menu.add_child(title) + + var account := Label.new() + account.text = _session_name() + account.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + account.add_theme_font_size_override("font_size", 14) + account.add_theme_color_override("font_color", Color(0.64, 0.78, 0.74)) + menu.add_child(account) + + var spacer := Control.new() + spacer.custom_minimum_size = Vector2(0, 10) + menu.add_child(spacer) + + var resume_button := _menu_button("Resume") + resume_button.pressed.connect(_toggle_escape_menu) + menu.add_child(resume_button) + + var logout_button := _menu_button("Log Out") + logout_button.pressed.connect(_logout) + menu.add_child(logout_button) + + var quit_button := _menu_button("Quit") + quit_button.pressed.connect(func() -> void: get_tree().quit()) + menu.add_child(quit_button) + + +func _toggle_escape_menu() -> void: + if not escape_overlay: + return + + escape_overlay.visible = not escape_overlay.visible + if escape_overlay.visible and escape_panel: + var first_button := _find_first_button(escape_panel) + if first_button: + first_button.grab_focus() + + +func _logout() -> void: + if FileAccess.file_exists(SESSION_PATH): + DirAccess.remove_absolute(ProjectSettings.globalize_path(SESSION_PATH)) + get_tree().remove_meta("auth_session") + get_tree().change_scene_to_file("res://scenes/ui/login_screen.tscn") + + +func _menu_button(text_value: String) -> Button: + var button := Button.new() + button.text = text_value + button.custom_minimum_size = Vector2(0, 48) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.add_theme_font_size_override("font_size", 17) + button.add_theme_stylebox_override("normal", _button_style(Color(0.75, 0.78, 0.68))) + button.add_theme_stylebox_override("hover", _button_style(Color(0.88, 0.82, 0.47))) + button.add_theme_stylebox_override("pressed", _button_style(Color(0.58, 0.62, 0.52))) + button.add_theme_color_override("font_color", Color(0.08, 0.12, 0.11)) + button.add_theme_color_override("font_hover_color", Color(0.08, 0.12, 0.11)) + button.add_theme_color_override("font_pressed_color", Color(0.08, 0.12, 0.11)) + return button + + +func _find_first_button(node: Node) -> Button: + if node is Button: + return node + for child in node.get_children(): + var button := _find_first_button(child) + if button: + return button + return null func _panel_style(color: Color, border_color: Color, border_width: int, radius: int) -> StyleBoxFlat: @@ -248,6 +412,15 @@ func _panel_style(color: Color, border_color: Color, border_width: int, radius: return style +func _button_style(color: Color) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = color + style.border_color = color.darkened(0.28) + style.set_border_width_all(2) + style.set_corner_radius_all(6) + 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/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index a8d5b44..e07c04e 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -126,15 +126,15 @@ func _draw_tile(tile: Dictionary) -> void: var is_hovered: bool = coord == hovered_tile var is_selected: bool = coord == selected_tile var key: String = _tile_key(q, r) - var is_visible: bool = visible_tiles.has(key) + var tile_visible: bool = visible_tiles.has(key) var is_owned: bool = owned_tiles.has(key) var is_rival: bool = rival_tiles.has(key) var side_points: PackedVector2Array = PackedVector2Array([points[2], points[3], points[4], points[4] + Vector2(0, 14), points[3] + Vector2(0, 14), points[2] + Vector2(0, 14)]) - var draw_color: Color = _ownership_fill_color(color, coord, is_visible, is_owned, is_rival) + var draw_color: Color = _ownership_fill_color(color, coord, tile_visible, is_owned, is_rival) draw_colored_polygon(side_points, draw_color.darkened(0.28)) - if not is_visible: + if not tile_visible: draw_colored_polygon(points, draw_color) elif is_selected: draw_colored_polygon(points, draw_color.lightened(0.2)) @@ -147,7 +147,7 @@ func _draw_tile(tile: Dictionary) -> void: draw_polyline(_closed_points(points), Color(0.04, 0.065, 0.06, 0.72), 2.0) - if not is_visible: + if not tile_visible: draw_polyline(_closed_points(points), Color(0.11, 0.15, 0.15, 0.75), 1.0) elif is_selected: draw_polyline(_closed_points(points), Color(0.95, 0.76, 0.28), 4.0) @@ -244,14 +244,14 @@ func set_visible_locations(locations: Array, username: String) -> void: var q: int = int(location.get("q", 0)) var r: int = int(location.get("r", 0)) var terrain: String = str(location.get("terrain", "Field")) - var owner: String = _owner_username(location) + var owner_username: String = _owner_username(location) var key: String = _tile_key(q, r) visible_tiles[key] = location _add_or_update_tile(q, r, terrain) - if not owner.is_empty(): - if owner == username: + if not owner_username.is_empty(): + if owner_username == username: owned_tiles[key] = location else: rival_tiles[key] = location @@ -263,8 +263,8 @@ func _tile_key(q: int, r: int) -> String: return "%d,%d" % [q, r] -func _ownership_fill_color(base_color: Color, coord: Vector2i, is_visible: bool, is_owned: bool, is_rival: bool) -> Color: - if not is_visible: +func _ownership_fill_color(base_color: Color, coord: Vector2i, tile_visible: bool, is_owned: bool, is_rival: bool) -> Color: + if not tile_visible: return Color(0.045, 0.065, 0.065) if coord == home_tile: return Color(0.78, 0.62, 0.19) @@ -283,10 +283,10 @@ func _tile_display_label(key: String, data: Dictionary) -> String: return "%s | Your territory" % terrain if rival_tiles.has(key): var rival_data: Dictionary = rival_tiles[key] - var owner: String = _owner_username(rival_data) - if owner.is_empty(): + var owner_username: String = _owner_username(rival_data) + if owner_username.is_empty(): return "%s | Unclaimed" % terrain - return "%s | Controlled by %s" % [terrain, owner] + return "%s | Controlled by %s" % [terrain, owner_username] return "%s | Unclaimed" % terrain diff --git a/microservices/LocationsApi/Controllers/ResourcesController.cs b/microservices/LocationsApi/Controllers/ResourcesController.cs new file mode 100644 index 0000000..5281e18 --- /dev/null +++ b/microservices/LocationsApi/Controllers/ResourcesController.cs @@ -0,0 +1,33 @@ +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 ResourcesController : ControllerBase +{ + private readonly ResourceService _resources; + + public ResourcesController(ResourceService resources) + { + _resources = resources; + } + + [Authorize(Roles = "USER,SUPER")] + [HttpGet("me")] + public async Task> GetMine() + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + var resources = await _resources.GetAsync(username); + return Ok(PlayerResourcesResponse.FromResources(resources)); + } +} diff --git a/microservices/LocationsApi/Models/PlayerResources.cs b/microservices/LocationsApi/Models/PlayerResources.cs new file mode 100644 index 0000000..88ecbd0 --- /dev/null +++ b/microservices/LocationsApi/Models/PlayerResources.cs @@ -0,0 +1,18 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace LocationsApi.Models; + +public class PlayerResources +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ""; + + public string Username { get; set; } = ""; + public int Gold { get; set; } = 100; + public int GoldPerMinute { get; set; } = 10; + public DateTime LastGoldGeneratedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/microservices/LocationsApi/Models/ResourceDto.cs b/microservices/LocationsApi/Models/ResourceDto.cs new file mode 100644 index 0000000..b08e8c8 --- /dev/null +++ b/microservices/LocationsApi/Models/ResourceDto.cs @@ -0,0 +1,19 @@ +namespace LocationsApi.Models; + +public class PlayerResourcesResponse +{ + public string Username { get; set; } = ""; + public int Gold { get; set; } + public int GoldPerMinute { get; set; } + public DateTime LastGoldGeneratedAtUtc { get; set; } + public DateTime UpdatedAtUtc { get; set; } + + public static PlayerResourcesResponse FromResources(PlayerResources resources) => new() + { + Username = resources.Username, + Gold = resources.Gold, + GoldPerMinute = resources.GoldPerMinute, + LastGoldGeneratedAtUtc = resources.LastGoldGeneratedAtUtc, + UpdatedAtUtc = resources.UpdatedAtUtc + }; +} diff --git a/microservices/LocationsApi/Program.cs b/microservices/LocationsApi/Program.cs index d8b7e83..caa8d43 100644 --- a/microservices/LocationsApi/Program.cs +++ b/microservices/LocationsApi/Program.cs @@ -8,6 +8,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); 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 1d652d4..6abf109 100644 --- a/microservices/LocationsApi/README.md +++ b/microservices/LocationsApi/README.md @@ -13,9 +13,16 @@ Tracks hex locations for the territory map. - `GET /api/locations/visible` Get locations visible to the authenticated user. - `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. `claim` and `put` require the same JWT issued by AuthApi. When a player gains visibility over previously unseen coordinates, the service generates and persists those locations before returning them. Generated visible locations are unowned `Open` records until claimed by a player. + +## Resources + +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. diff --git a/microservices/LocationsApi/Services/LocationService.cs b/microservices/LocationsApi/Services/LocationService.cs index 0810428..4f6a858 100644 --- a/microservices/LocationsApi/Services/LocationService.cs +++ b/microservices/LocationsApi/Services/LocationService.cs @@ -6,17 +6,19 @@ namespace LocationsApi.Services; public class LocationService { private readonly IMongoCollection _locations; + private readonly ResourceService _resources; private readonly int _homeMinDistance; private readonly int _homeSearchRadius; private readonly int _visibilityDepth; - public LocationService(IConfiguration cfg) + public LocationService(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); _locations = database.GetCollection("Locations"); + _resources = resources; _homeMinDistance = int.TryParse(cfg["World:HomeMinDistance"], out var minDistance) ? Math.Max(1, minDistance) : 8; _homeSearchRadius = int.TryParse(cfg["World:HomeSearchRadius"], out var searchRadius) ? Math.Max(_homeMinDistance, searchRadius) : 80; _visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3; @@ -117,6 +119,7 @@ public class LocationService var existingHome = await GetHomeAsync(username); if (existingHome is not null) { + await _resources.EnsureAsync(username); return existingHome; } @@ -138,6 +141,7 @@ public class LocationService var home = await TryCreateHomeAsync(username, candidate.q, candidate.r); if (home is not null) { + await _resources.EnsureAsync(username); return home; } diff --git a/microservices/LocationsApi/Services/ResourceService.cs b/microservices/LocationsApi/Services/ResourceService.cs new file mode 100644 index 0000000..a95ebae --- /dev/null +++ b/microservices/LocationsApi/Services/ResourceService.cs @@ -0,0 +1,89 @@ +using LocationsApi.Models; +using MongoDB.Driver; + +namespace LocationsApi.Services; + +public class ResourceService +{ + private readonly IMongoCollection _resources; + private readonly int _startingGold; + private readonly int _headquartersGoldPerMinute; + + public ResourceService(IConfiguration cfg) + { + 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); + _resources = database.GetCollection("PlayerResources"); + _startingGold = int.TryParse(cfg["Resources:StartingGold"], out var startingGold) ? Math.Max(0, startingGold) : 100; + _headquartersGoldPerMinute = int.TryParse(cfg["Resources:HeadquartersGoldPerMinute"], out var goldPerMinute) + ? Math.Max(0, goldPerMinute) + : 10; + + _resources.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.Username), + new CreateIndexOptions { Unique = true })); + } + + public async Task EnsureAsync(string username) + { + var now = DateTime.UtcNow; + var update = Builders.Update + .SetOnInsert(x => x.Username, username) + .SetOnInsert(x => x.Gold, _startingGold) + .SetOnInsert(x => x.CreatedAtUtc, now) + .SetOnInsert(x => x.LastGoldGeneratedAtUtc, now) + .Set(x => x.GoldPerMinute, _headquartersGoldPerMinute) + .Set(x => x.UpdatedAtUtc, now); + + var resources = await _resources.FindOneAndUpdateAsync( + x => x.Username == username, + update, + new FindOneAndUpdateOptions + { + IsUpsert = true, + ReturnDocument = ReturnDocument.After + }); + + return await ApplyGoldGenerationAsync(resources); + } + + public async Task GetAsync(string username) + { + var resources = await EnsureAsync(username); + return await ApplyGoldGenerationAsync(resources); + } + + private async Task ApplyGoldGenerationAsync(PlayerResources resources) + { + if (resources.GoldPerMinute <= 0) + { + return resources; + } + + var now = DateTime.UtcNow; + var elapsed = now - resources.LastGoldGeneratedAtUtc; + var elapsedMinutes = (int)Math.Floor(elapsed.TotalMinutes); + if (elapsedMinutes <= 0) + { + return resources; + } + + var generatedGold = elapsedMinutes * resources.GoldPerMinute; + var generationTimestamp = resources.LastGoldGeneratedAtUtc.AddMinutes(elapsedMinutes); + var update = Builders.Update + .Inc(x => x.Gold, generatedGold) + .Set(x => x.GoldPerMinute, _headquartersGoldPerMinute) + .Set(x => x.LastGoldGeneratedAtUtc, generationTimestamp) + .Set(x => x.UpdatedAtUtc, now); + + return await _resources.FindOneAndUpdateAsync( + x => x.Username == resources.Username, + update, + new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After + }); + } +} diff --git a/microservices/LocationsApi/appsettings.json b/microservices/LocationsApi/appsettings.json index db4cbce..103fa5c 100644 --- a/microservices/LocationsApi/appsettings.json +++ b/microservices/LocationsApi/appsettings.json @@ -19,5 +19,9 @@ "HomeSearchRadius": 80, "VisibilityDepth": 3 }, + "Resources": { + "StartingGold": 100, + "HeadquartersGoldPerMinute": 10 + }, "AllowedHosts": "*" } diff --git a/microservices/LocationsApi/k8s/configmap.yaml b/microservices/LocationsApi/k8s/configmap.yaml index 31447d2..ce38506 100644 --- a/microservices/LocationsApi/k8s/configmap.yaml +++ b/microservices/LocationsApi/k8s/configmap.yaml @@ -11,3 +11,5 @@ data: World__HomeMinDistance: "8" World__HomeSearchRadius: "80" World__VisibilityDepth: "3" + Resources__StartingGold: "100" + Resources__HeadquartersGoldPerMinute: "10"