diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index 94a1c8f..9077457 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -14,6 +14,7 @@ var resources_request: HTTPRequest var units_request: HTTPRequest var train_unit_request: HTTPRequest var claim_request: HTTPRequest +var build_request: HTTPRequest var resources_timer: Timer var session: Dictionary = {} var escape_overlay: Control @@ -31,9 +32,11 @@ var training_started_unix := 0.0 var training_completes_unix := 0.0 var training_completion_refresh_requested := false var claim_button: Button +var build_log_camp_button: Button var selected_q := 0 var selected_r := 0 var selected_can_claim := false +var selected_can_build_log_camp := false func _ready() -> void: @@ -104,9 +107,9 @@ func _build_ui() -> void: legend.add_theme_constant_override("separation", 12) top_content.add_child(legend) - legend.add_child(_legend_item(Color(0.78, 0.62, 0.19), "Home")) - legend.add_child(_legend_item(Color(0.26, 0.62, 0.32), "Yours")) - legend.add_child(_legend_item(Color(0.58, 0.28, 0.24), "Rival")) + legend.add_child(_legend_item(Color(1.0, 0.76, 0.16), "Home")) + legend.add_child(_legend_item(Color(0.16, 0.86, 0.92), "Yours")) + legend.add_child(_legend_item(Color(0.95, 0.22, 0.18), "Rival")) 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")) @@ -181,8 +184,8 @@ func _build_ui() -> void: bottom_content.add_child(coordinate_label) unit_label = Label.new() - unit_label.text = "Scouts: --" - unit_label.custom_minimum_size = Vector2(88, 0) + unit_label.text = "Scouts: -- | Queue: --" + unit_label.custom_minimum_size = Vector2(142, 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) @@ -217,6 +220,11 @@ func _build_ui() -> void: claim_button.pressed.connect(_claim_selected_tile) bottom_content.add_child(claim_button) + build_log_camp_button = _small_button("Build Log Camp") + build_log_camp_button.disabled = true + build_log_camp_button.pressed.connect(_build_log_camp) + bottom_content.add_child(build_log_camp_button) + http_request = HTTPRequest.new() http_request.request_completed.connect(_on_home_request_completed) add_child(http_request) @@ -241,6 +249,10 @@ func _build_ui() -> void: claim_request.request_completed.connect(_on_claim_request_completed) add_child(claim_request) + build_request = HTTPRequest.new() + build_request.request_completed.connect(_on_build_request_completed) + add_child(build_request) + resources_timer = Timer.new() resources_timer.wait_time = RESOURCE_REFRESH_SECONDS resources_timer.autostart = false @@ -259,8 +271,11 @@ func _on_tile_selected(q: int, r: int, terrain: String) -> void: selected_q = q selected_r = r selected_can_claim = terrain.contains("Unclaimed") + selected_can_build_log_camp = terrain.contains("Forest") and terrain.contains("Your territory") and not terrain.contains("Log Camp") if claim_button: claim_button.disabled = not selected_can_claim + if build_log_camp_button: + build_log_camp_button.disabled = not selected_can_build_log_camp if coordinate_label: coordinate_label.text = "Selected q:%d r:%d | %s territory" % [q, r, terrain] @@ -412,7 +427,9 @@ 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)) - gold_label.text = "Gold: %d (+%d/min)" % [gold, gold_per_minute] + var wood: int = int(resources.get("wood", 0)) + var wood_per_minute: int = int(resources.get("woodPerMinute", 0)) + gold_label.text = "Gold: %d (+%d/min) | Wood: %d (+%d/min)" % [gold, gold_per_minute, wood, wood_per_minute] var anchor_text: String = str(resources.get("lastGoldGeneratedAtUtc", "")) gold_tick_anchor_unix = _parse_utc_timestamp(anchor_text) @@ -452,12 +469,13 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack 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 + + unit_label.text = "Scouts: %d | Queue: %d" % [int(units.get("completedScouts", 0)), training.size()] + if training.is_empty(): training_started_unix = 0.0 training_completes_unix = 0.0 @@ -572,6 +590,55 @@ func _on_claim_request_completed(result: int, response_code: int, _headers: Pack _refresh_player_state() +func _build_log_camp() -> void: + if not selected_can_build_log_camp: + coordinate_label.text = "Select controlled Forest territory without a building." + return + + var access_token: String = str(session.get("accessToken", "")) + if access_token.is_empty(): + coordinate_label.text = "No auth token available. Log Camp was not built." + return + + if build_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + + build_log_camp_button.disabled = true + coordinate_label.text = "Building Log Camp at q:%d r:%d..." % [selected_q, selected_r] + var error: int = build_request.request( + LOCATIONS_BASE_URL + "/api/locations/build", + PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % access_token + ]), + HTTPClient.METHOD_POST, + JSON.stringify({ "q": selected_q, "r": selected_r, "buildingType": "LogCamp" }) + ) + if error != OK: + build_log_camp_button.disabled = false + coordinate_label.text = "Could not contact the locations service." + + +func _on_build_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var text: String = body.get_string_from_utf8() + if result != HTTPRequest.RESULT_SUCCESS: + coordinate_label.text = "Log Camp build request failed." + build_log_camp_button.disabled = not selected_can_build_log_camp + return + + if response_code < 200 or response_code >= 300: + coordinate_label.text = "Log Camp build failed: %s" % text + build_log_camp_button.disabled = not selected_can_build_log_camp + _refresh_player_state() + return + + selected_can_build_log_camp = false + build_log_camp_button.disabled = true + coordinate_label.text = "Log Camp built. Wood production increased." + _load_visible_locations() + _refresh_player_state() + + func _update_tick_bar() -> void: if not tick_bar or not tick_label: return diff --git a/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index 50f89a5..e5962e1 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -6,6 +6,9 @@ signal tile_selected(q: int, r: int, terrain: String) const HEX_SIZE := 42.0 const ISO_Y_SCALE := 0.56 const SQRT_3 := 1.7320508075688772 +const INFLUENCE_RADIUS_X := 54.0 +const INFLUENCE_RADIUS_Y := 30.0 +const INFLUENCE_LINK_WIDTH := 54.0 var camera_offset: Vector2 = Vector2.ZERO var zoom: float = 1.0 @@ -101,10 +104,11 @@ func _draw() -> void: return _tile_center(int(a["q"]), int(a["r"])).y < _tile_center(int(b["q"]), int(b["r"])).y ) + _draw_control_regions(sorted_tiles) + for tile in sorted_tiles: _draw_tile(tile) - func _draw_grid_lines() -> void: var line_color: Color = Color(0.12, 0.17, 0.17, 0.35) for x in range(-8, 9): @@ -155,10 +159,55 @@ func _draw_tile(tile: Dictionary) -> void: draw_polyline(_closed_points(points), Color(0.95, 0.76, 0.28), 4.0) elif coord == home_tile: draw_polyline(_closed_points(points), Color(1.0, 0.88, 0.38), 3.0) - elif is_owned: - draw_polyline(_closed_points(points), Color(0.48, 0.95, 0.55), 3.0) - elif is_rival: - draw_polyline(_closed_points(points), Color(0.94, 0.36, 0.29), 3.0) + + var visible_location: Dictionary = visible_tiles.get(key, {}) + var building_type: String = str(visible_location.get("buildingType", "")) + if building_type == "LogCamp": + _draw_log_camp_marker(center, height) + + +func _draw_control_regions(sorted_tiles: Array) -> void: + for control in ["owned", "rival"]: + var control_tiles: Array = sorted_tiles.filter(func(tile: Dictionary) -> bool: + return _tile_control(_tile_key(int(tile["q"]), int(tile["r"]))) == control + ) + if control_tiles.is_empty(): + continue + + _draw_control_layer(control_tiles, control, 1.34, 0.16, INFLUENCE_LINK_WIDTH + 18.0) + _draw_control_layer(control_tiles, control, 1.08, 0.28, INFLUENCE_LINK_WIDTH) + _draw_control_layer(control_tiles, control, 0.82, 0.20, INFLUENCE_LINK_WIDTH - 16.0) + + +func _draw_control_layer(control_tiles: Array, control: String, layer_scale: float, alpha: float, link_width: float) -> void: + var control_color: Color = _control_color(control) + var layer_color := Color(control_color.r, control_color.g, control_color.b, alpha) + var tile_lookup := {} + + for tile in control_tiles: + var q: int = int(tile["q"]) + var r: int = int(tile["r"]) + tile_lookup[_tile_key(q, r)] = true + + for tile in control_tiles: + var q: int = int(tile["q"]) + var r: int = int(tile["r"]) + var center: Vector2 = _tile_center(q, r) + + for neighbor in _neighbor_coordinates(q, r): + var neighbor_key: String = _tile_key(neighbor.x, neighbor.y) + if not tile_lookup.has(neighbor_key): + continue + if _tile_key(q, r) > neighbor_key: + continue + + var neighbor_center: Vector2 = _tile_center(neighbor.x, neighbor.y) + draw_line(center, neighbor_center, layer_color, link_width * zoom * layer_scale) + + draw_colored_polygon( + _ellipse_points(center, INFLUENCE_RADIUS_X * zoom * layer_scale, INFLUENCE_RADIUS_Y * zoom * layer_scale), + layer_color + ) func _update_hover(mouse_position: Vector2) -> void: @@ -202,11 +251,15 @@ func _screen_point(world_point: Vector2) -> Vector2: func _hex_points(center: Vector2, height: float) -> PackedVector2Array: + return _hex_points_scaled(center, height, 1.0) + + +func _hex_points_scaled(center: Vector2, height: float, point_scale: float) -> PackedVector2Array: var points: PackedVector2Array = PackedVector2Array() var top_lift: Vector2 = Vector2(0, -height * zoom) for i in range(6): var angle: float = deg_to_rad(60.0 * i + 30.0) - var local: Vector2 = Vector2(cos(angle) * HEX_SIZE, sin(angle) * HEX_SIZE * ISO_Y_SCALE) * zoom + var local: Vector2 = Vector2(cos(angle) * HEX_SIZE * point_scale, sin(angle) * HEX_SIZE * ISO_Y_SCALE * point_scale) * zoom points.append(center + local + top_lift) return points @@ -218,6 +271,33 @@ func _closed_points(points: PackedVector2Array) -> PackedVector2Array: return closed +func _ellipse_points(center: Vector2, radius_x: float, radius_y: float) -> PackedVector2Array: + var points: PackedVector2Array = PackedVector2Array() + for i in range(32): + var angle: float = TAU * float(i) / 32.0 + points.append(center + Vector2(cos(angle) * radius_x, sin(angle) * radius_y)) + return points + + +func _draw_log_camp_marker(center: Vector2, height: float) -> void: + var marker_center: Vector2 = center + Vector2(0, -height * zoom - 2.0) + var width: float = 18.0 * zoom + var marker_height: float = 10.0 * zoom + var hut_color := Color(0.46, 0.29, 0.14) + var roof_color := Color(0.28, 0.17, 0.09) + var log_color := Color(0.72, 0.52, 0.28) + + var roof := PackedVector2Array([ + marker_center + Vector2(-width * 0.55, -marker_height * 0.12), + marker_center + Vector2(0, -marker_height * 0.75), + marker_center + Vector2(width * 0.55, -marker_height * 0.12) + ]) + draw_colored_polygon(roof, roof_color) + draw_rect(Rect2(marker_center + Vector2(-width * 0.38, -marker_height * 0.08), Vector2(width * 0.76, marker_height * 0.62)), hut_color, true) + draw_line(marker_center + Vector2(-width * 0.48, marker_height * 0.62), marker_center + Vector2(width * 0.48, marker_height * 0.62), log_color, 3.0 * zoom) + draw_line(marker_center + Vector2(-width * 0.38, marker_height * 0.86), marker_center + Vector2(width * 0.38, marker_height * 0.86), log_color.darkened(0.12), 3.0 * zoom) + + func _tile_data(q: int, r: int) -> Dictionary: for tile in tiles: if int(tile["q"]) == q and int(tile["r"]) == r: @@ -269,27 +349,61 @@ func _ownership_fill_color(base_color: Color, coord: Vector2i, tile_visible: boo if not tile_visible: return Color(0.045, 0.065, 0.065) if coord == home_tile: - return Color(0.78, 0.62, 0.19) + return base_color.lerp(Color(0.95, 0.72, 0.18), 0.38) if is_owned: - return Color(0.26, 0.62, 0.32) + return base_color.lerp(Color(0.20, 0.68, 0.72), 0.32) if is_rival: - return Color(0.58, 0.28, 0.24) + return base_color.lerp(Color(0.72, 0.22, 0.18), 0.35) return base_color +func _tile_control(key: String) -> String: + if owned_tiles.has(key): + return "owned" + if rival_tiles.has(key): + return "rival" + return "" + + +func _control_color(control: String) -> Color: + match control: + "owned": + return Color(0.16, 0.86, 0.92) + "rival": + return Color(0.95, 0.22, 0.18) + _: + return Color(0.4, 0.5, 0.5) + + +func _neighbor_coordinates(q: int, r: int) -> Array[Vector2i]: + return [ + Vector2i(q + 1, r), + Vector2i(q + 1, r - 1), + Vector2i(q, r - 1), + Vector2i(q - 1, r), + Vector2i(q - 1, r + 1), + Vector2i(q, r + 1) + ] + + func _tile_display_label(key: String, data: Dictionary) -> String: var terrain: String = str(data.get("terrain", "Open")) + var building_type: String = str(data.get("buildingType", "")) + var building_label: String = "" + if building_type == "LogCamp": + building_label = " | Log Camp" + if home_tile == Vector2i(int(data.get("q", 0)), int(data.get("r", 0))): - return "%s | Your home" % terrain + return "%s | Your home%s" % [terrain, building_label] if owned_tiles.has(key): - return "%s | Your territory" % terrain + return "%s | Your territory%s" % [terrain, building_label] if rival_tiles.has(key): var rival_data: Dictionary = rival_tiles[key] var owner_username: String = _owner_username(rival_data) if owner_username.is_empty(): - return "%s | Unclaimed" % terrain - return "%s | Controlled by %s" % [terrain, owner_username] - return "%s | Unclaimed" % terrain + return "%s | Unclaimed%s" % [terrain, building_label] + return "%s | Controlled by %s%s" % [terrain, owner_username, building_label] + return "%s | Unclaimed%s" % [terrain, building_label] func _owner_username(location: Dictionary) -> String: diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index 53a25e1..2983ba5 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -113,6 +113,36 @@ public class LocationsController : ControllerBase return Ok(LocationResponse.FromLocation(location)); } + [Authorize(Roles = "USER,SUPER")] + [HttpPost("build")] + public async Task> Build([FromBody] BuildLocationRequest request) + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + try + { + var location = await _locations.BuildAsync(request, username); + if (location is null) + { + return NotFound("Build target must be controlled by the player."); + } + + return Ok(LocationResponse.FromLocation(location)); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + [Authorize(Roles = "USER,SUPER")] [HttpGet("home")] public async Task> GetHome() diff --git a/microservices/LocationsApi/Models/Dto.cs b/microservices/LocationsApi/Models/Dto.cs index 663ccfb..a95dd2d 100644 --- a/microservices/LocationsApi/Models/Dto.cs +++ b/microservices/LocationsApi/Models/Dto.cs @@ -15,6 +15,13 @@ public class ClaimLocationRequest public int R { get; set; } } +public class BuildLocationRequest +{ + public int Q { get; set; } + public int R { get; set; } + public string BuildingType { get; set; } = "LogCamp"; +} + public class LocationResponse { public string Id { get; set; } = ""; @@ -25,6 +32,8 @@ public class LocationResponse public string? OwnerUsername { get; set; } public string? Label { get; set; } public int Level { get; set; } + public string? BuildingType { get; set; } + public DateTime? BuildingBuiltAtUtc { get; set; } public DateTime? HomeAssignedAtUtc { get; set; } public DateTime UpdatedAtUtc { get; set; } public bool IsVisible { get; set; } = true; @@ -39,6 +48,8 @@ public class LocationResponse OwnerUsername = location.OwnerUsername, Label = location.Label, Level = location.Level, + BuildingType = location.BuildingType, + BuildingBuiltAtUtc = location.BuildingBuiltAtUtc, HomeAssignedAtUtc = location.HomeAssignedAtUtc, UpdatedAtUtc = location.UpdatedAtUtc, IsVisible = true diff --git a/microservices/LocationsApi/Models/Location.cs b/microservices/LocationsApi/Models/Location.cs index da2b09a..53cf7c8 100644 --- a/microservices/LocationsApi/Models/Location.cs +++ b/microservices/LocationsApi/Models/Location.cs @@ -16,6 +16,8 @@ public class Location public string? OwnerUsername { get; set; } public string? Label { get; set; } public int Level { get; set; } = 1; + public string? BuildingType { get; set; } + public DateTime? BuildingBuiltAtUtc { get; set; } public DateTime? HomeAssignedAtUtc { get; set; } public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; diff --git a/microservices/LocationsApi/Models/PlayerResources.cs b/microservices/LocationsApi/Models/PlayerResources.cs index 88ecbd0..c43404a 100644 --- a/microservices/LocationsApi/Models/PlayerResources.cs +++ b/microservices/LocationsApi/Models/PlayerResources.cs @@ -12,7 +12,10 @@ public class PlayerResources public string Username { get; set; } = ""; public int Gold { get; set; } = 100; public int GoldPerMinute { get; set; } = 10; + public int Wood { get; set; } + public int WoodPerMinute { get; set; } public DateTime LastGoldGeneratedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime LastWoodGeneratedAtUtc { 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 index b08e8c8..7b14030 100644 --- a/microservices/LocationsApi/Models/ResourceDto.cs +++ b/microservices/LocationsApi/Models/ResourceDto.cs @@ -5,7 +5,10 @@ public class PlayerResourcesResponse public string Username { get; set; } = ""; public int Gold { get; set; } public int GoldPerMinute { get; set; } + public int Wood { get; set; } + public int WoodPerMinute { get; set; } public DateTime LastGoldGeneratedAtUtc { get; set; } + public DateTime LastWoodGeneratedAtUtc { get; set; } public DateTime UpdatedAtUtc { get; set; } public static PlayerResourcesResponse FromResources(PlayerResources resources) => new() @@ -13,7 +16,10 @@ public class PlayerResourcesResponse Username = resources.Username, Gold = resources.Gold, GoldPerMinute = resources.GoldPerMinute, + Wood = resources.Wood, + WoodPerMinute = resources.WoodPerMinute, LastGoldGeneratedAtUtc = resources.LastGoldGeneratedAtUtc, + LastWoodGeneratedAtUtc = resources.LastWoodGeneratedAtUtc, UpdatedAtUtc = resources.UpdatedAtUtc }; } diff --git a/microservices/LocationsApi/README.md b/microservices/LocationsApi/README.md index c4dc033..d1c49b8 100644 --- a/microservices/LocationsApi/README.md +++ b/microservices/LocationsApi/README.md @@ -8,6 +8,7 @@ Tracks hex locations for the territory map. - `GET /api/locations?minQ=-7&maxQ=7&minR=-7&maxR=7` List a coordinate range, constrained by visibility for USER. - `GET /api/locations/{q}/{r}` Fetch one visible location for USER, any known location for SUPER. - `POST /api/locations/claim` Claim visible unclaimed territory for the authenticated user. Normal players spend one completed Scout. +- `POST /api/locations/build` Build on controlled territory. - `GET /api/locations/home` Get the authenticated user's home location. - `POST /api/locations/home` Assign or return the authenticated user's home location. - `GET /api/locations/visible` Get locations visible to the authenticated user. @@ -27,7 +28,8 @@ 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. +minute. Log Camps generate wood. Generation is applied lazily when the resource +record is read. ## Units @@ -35,3 +37,9 @@ 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. + +## Buildings + +Buildings are stored on location records. The first building type is `LogCamp`, +which can be built on controlled Forest territory and adds 5 wood per minute to +the owning player's resource generation. diff --git a/microservices/LocationsApi/Services/LocationService.cs b/microservices/LocationsApi/Services/LocationService.cs index 4f6a858..e71210e 100644 --- a/microservices/LocationsApi/Services/LocationService.cs +++ b/microservices/LocationsApi/Services/LocationService.cs @@ -10,6 +10,7 @@ public class LocationService private readonly int _homeMinDistance; private readonly int _homeSearchRadius; private readonly int _visibilityDepth; + private readonly int _logCampWoodPerMinute; public LocationService(IConfiguration cfg, ResourceService resources) { @@ -22,6 +23,9 @@ public class LocationService _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; + _logCampWoodPerMinute = int.TryParse(cfg["Buildings:LogCampWoodPerMinute"], out var logCampWoodPerMinute) + ? Math.Max(1, logCampWoodPerMinute) + : 5; var coordinateIndex = Builders.IndexKeys .Ascending(x => x.Q) @@ -111,6 +115,53 @@ public class LocationService return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username); } + public async Task BuildAsync(BuildLocationRequest request, string username) + { + if (!string.Equals(request.BuildingType, "LogCamp", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Unsupported building type.", nameof(request.BuildingType)); + } + + var location = await GetAsync(request.Q, request.R); + if (location is null || + !string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(location.BuildingType)) + { + throw new InvalidOperationException("This location already has a building."); + } + + if (!string.Equals(location.Terrain, "Forest", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Log Camps can only be built on Forest territory."); + } + + var now = DateTime.UtcNow; + var update = Builders.Update + .Set(x => x.BuildingType, "LogCamp") + .Set(x => x.BuildingBuiltAtUtc, now) + .Set(x => x.UpdatedAtUtc, now); + + var updated = await _locations.FindOneAndUpdateAsync( + x => x.Id == location.Id && x.OwnerUsername == username && x.BuildingType == null, + update, + new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After + }); + + if (updated is null) + { + throw new InvalidOperationException("This location already has a building."); + } + + await _resources.AddWoodGenerationAsync(username, _logCampWoodPerMinute); + return updated; + } + public async Task GetHomeAsync(string username) => await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync(); diff --git a/microservices/LocationsApi/Services/ResourceService.cs b/microservices/LocationsApi/Services/ResourceService.cs index c112407..0299920 100644 --- a/microservices/LocationsApi/Services/ResourceService.cs +++ b/microservices/LocationsApi/Services/ResourceService.cs @@ -32,8 +32,10 @@ public class ResourceService var update = Builders.Update .SetOnInsert(x => x.Username, username) .SetOnInsert(x => x.Gold, _startingGold) + .SetOnInsert(x => x.Wood, 0) .SetOnInsert(x => x.CreatedAtUtc, now) .SetOnInsert(x => x.LastGoldGeneratedAtUtc, now) + .SetOnInsert(x => x.LastWoodGeneratedAtUtc, now) .Set(x => x.GoldPerMinute, _headquartersGoldPerMinute) .Set(x => x.UpdatedAtUtc, now); @@ -78,29 +80,61 @@ public class ResourceService return updated is not null; } + public async Task AddWoodGenerationAsync(string username, int woodPerMinute) + { + if (woodPerMinute <= 0) + { + return; + } + + var resources = await EnsureAsync(username); + var now = DateTime.UtcNow; + + var update = Builders.Update + .Inc(x => x.WoodPerMinute, woodPerMinute) + .Set(x => x.UpdatedAtUtc, now); + + if (resources.WoodPerMinute <= 0 || resources.LastWoodGeneratedAtUtc.Year < 2000) + { + update = update.Set(x => x.LastWoodGeneratedAtUtc, now); + } + + await _resources.UpdateOneAsync(x => x.Username == username, update); + } + 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) + var goldElapsedMinutes = resources.GoldPerMinute <= 0 + ? 0 + : (int)Math.Floor((now - resources.LastGoldGeneratedAtUtc).TotalMinutes); + var woodElapsedMinutes = resources.WoodPerMinute <= 0 || resources.LastWoodGeneratedAtUtc.Year < 2000 + ? 0 + : (int)Math.Floor((now - resources.LastWoodGeneratedAtUtc).TotalMinutes); + + if (goldElapsedMinutes <= 0 && woodElapsedMinutes <= 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); + if (goldElapsedMinutes > 0) + { + update = update + .Inc(x => x.Gold, goldElapsedMinutes * resources.GoldPerMinute) + .Set(x => x.LastGoldGeneratedAtUtc, resources.LastGoldGeneratedAtUtc.AddMinutes(goldElapsedMinutes)); + } + + if (woodElapsedMinutes > 0) + { + update = update + .Inc(x => x.Wood, woodElapsedMinutes * resources.WoodPerMinute) + .Set(x => x.LastWoodGeneratedAtUtc, resources.LastWoodGeneratedAtUtc.AddMinutes(woodElapsedMinutes)); + } + return await _resources.FindOneAndUpdateAsync( x => x.Username == resources.Username, update, diff --git a/microservices/LocationsApi/appsettings.json b/microservices/LocationsApi/appsettings.json index bb3df4c..a382d50 100644 --- a/microservices/LocationsApi/appsettings.json +++ b/microservices/LocationsApi/appsettings.json @@ -27,5 +27,8 @@ "ScoutGoldCost": 50, "ScoutTrainingSeconds": 60 }, + "Buildings": { + "LogCampWoodPerMinute": 5 + }, "AllowedHosts": "*" } diff --git a/microservices/LocationsApi/k8s/configmap.yaml b/microservices/LocationsApi/k8s/configmap.yaml index 70a3eff..986c069 100644 --- a/microservices/LocationsApi/k8s/configmap.yaml +++ b/microservices/LocationsApi/k8s/configmap.yaml @@ -15,3 +15,4 @@ data: Resources__HeadquartersGoldPerMinute: "10" Units__ScoutGoldCost: "50" Units__ScoutTrainingSeconds: "60" + Buildings__LogCampWoodPerMinute: "5"