adding log camps

This commit is contained in:
hz
2026-05-18 20:42:49 -05:00
parent 3167558e04
commit bf75f02dbb
12 changed files with 365 additions and 35 deletions
@@ -14,6 +14,7 @@ var resources_request: HTTPRequest
var units_request: HTTPRequest var units_request: HTTPRequest
var train_unit_request: HTTPRequest var train_unit_request: HTTPRequest
var claim_request: HTTPRequest var claim_request: HTTPRequest
var build_request: HTTPRequest
var resources_timer: Timer var resources_timer: Timer
var session: Dictionary = {} var session: Dictionary = {}
var escape_overlay: Control var escape_overlay: Control
@@ -31,9 +32,11 @@ var training_started_unix := 0.0
var training_completes_unix := 0.0 var training_completes_unix := 0.0
var training_completion_refresh_requested := false var training_completion_refresh_requested := false
var claim_button: Button var claim_button: Button
var build_log_camp_button: Button
var selected_q := 0 var selected_q := 0
var selected_r := 0 var selected_r := 0
var selected_can_claim := false var selected_can_claim := false
var selected_can_build_log_camp := false
func _ready() -> void: func _ready() -> void:
@@ -104,9 +107,9 @@ func _build_ui() -> void:
legend.add_theme_constant_override("separation", 12) legend.add_theme_constant_override("separation", 12)
top_content.add_child(legend) top_content.add_child(legend)
legend.add_child(_legend_item(Color(0.78, 0.62, 0.19), "Home")) legend.add_child(_legend_item(Color(1.0, 0.76, 0.16), "Home"))
legend.add_child(_legend_item(Color(0.26, 0.62, 0.32), "Yours")) legend.add_child(_legend_item(Color(0.16, 0.86, 0.92), "Yours"))
legend.add_child(_legend_item(Color(0.58, 0.28, 0.24), "Rival")) 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.38, 0.56, 0.36), "Open"))
legend.add_child(_legend_item(Color(0.045, 0.065, 0.065), "Unknown")) 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) bottom_content.add_child(coordinate_label)
unit_label = Label.new() unit_label = Label.new()
unit_label.text = "Scouts: --" unit_label.text = "Scouts: -- | Queue: --"
unit_label.custom_minimum_size = Vector2(88, 0) unit_label.custom_minimum_size = Vector2(142, 0)
unit_label.add_theme_font_size_override("font_size", 14) 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)) unit_label.add_theme_color_override("font_color", Color(0.72, 0.82, 0.78))
bottom_content.add_child(unit_label) bottom_content.add_child(unit_label)
@@ -217,6 +220,11 @@ func _build_ui() -> void:
claim_button.pressed.connect(_claim_selected_tile) claim_button.pressed.connect(_claim_selected_tile)
bottom_content.add_child(claim_button) 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 = HTTPRequest.new()
http_request.request_completed.connect(_on_home_request_completed) http_request.request_completed.connect(_on_home_request_completed)
add_child(http_request) add_child(http_request)
@@ -241,6 +249,10 @@ func _build_ui() -> void:
claim_request.request_completed.connect(_on_claim_request_completed) claim_request.request_completed.connect(_on_claim_request_completed)
add_child(claim_request) 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 = Timer.new()
resources_timer.wait_time = RESOURCE_REFRESH_SECONDS resources_timer.wait_time = RESOURCE_REFRESH_SECONDS
resources_timer.autostart = false resources_timer.autostart = false
@@ -259,8 +271,11 @@ func _on_tile_selected(q: int, r: int, terrain: String) -> void:
selected_q = q selected_q = q
selected_r = r selected_r = r
selected_can_claim = terrain.contains("Unclaimed") 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: if claim_button:
claim_button.disabled = not selected_can_claim 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: if coordinate_label:
coordinate_label.text = "Selected q:%d r:%d | %s territory" % [q, r, terrain] 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 resources: Dictionary = parsed
var gold: int = int(resources.get("gold", 0)) var gold: int = int(resources.get("gold", 0))
var gold_per_minute: int = int(resources.get("goldPerMinute", 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", "")) var anchor_text: String = str(resources.get("lastGoldGeneratedAtUtc", ""))
gold_tick_anchor_unix = _parse_utc_timestamp(anchor_text) 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 return
var units: Dictionary = parsed var units: Dictionary = parsed
unit_label.text = "Scouts: %d" % int(units.get("completedScouts", 0))
var training: Array = [] var training: Array = []
var raw_training = units.get("training", []) var raw_training = units.get("training", [])
if typeof(raw_training) == TYPE_ARRAY: if typeof(raw_training) == TYPE_ARRAY:
training = raw_training training = raw_training
unit_label.text = "Scouts: %d | Queue: %d" % [int(units.get("completedScouts", 0)), training.size()]
if training.is_empty(): if training.is_empty():
training_started_unix = 0.0 training_started_unix = 0.0
training_completes_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() _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: func _update_tick_bar() -> void:
if not tick_bar or not tick_label: if not tick_bar or not tick_label:
return return
@@ -6,6 +6,9 @@ signal tile_selected(q: int, r: int, terrain: String)
const HEX_SIZE := 42.0 const HEX_SIZE := 42.0
const ISO_Y_SCALE := 0.56 const ISO_Y_SCALE := 0.56
const SQRT_3 := 1.7320508075688772 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 camera_offset: Vector2 = Vector2.ZERO
var zoom: float = 1.0 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 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: for tile in sorted_tiles:
_draw_tile(tile) _draw_tile(tile)
func _draw_grid_lines() -> void: func _draw_grid_lines() -> void:
var line_color: Color = Color(0.12, 0.17, 0.17, 0.35) var line_color: Color = Color(0.12, 0.17, 0.17, 0.35)
for x in range(-8, 9): 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) draw_polyline(_closed_points(points), Color(0.95, 0.76, 0.28), 4.0)
elif coord == home_tile: elif coord == home_tile:
draw_polyline(_closed_points(points), Color(1.0, 0.88, 0.38), 3.0) 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) var visible_location: Dictionary = visible_tiles.get(key, {})
elif is_rival: var building_type: String = str(visible_location.get("buildingType", ""))
draw_polyline(_closed_points(points), Color(0.94, 0.36, 0.29), 3.0) 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: 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: 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 points: PackedVector2Array = PackedVector2Array()
var top_lift: Vector2 = Vector2(0, -height * zoom) var top_lift: Vector2 = Vector2(0, -height * zoom)
for i in range(6): for i in range(6):
var angle: float = deg_to_rad(60.0 * i + 30.0) 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) points.append(center + local + top_lift)
return points return points
@@ -218,6 +271,33 @@ func _closed_points(points: PackedVector2Array) -> PackedVector2Array:
return closed 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: func _tile_data(q: int, r: int) -> Dictionary:
for tile in tiles: for tile in tiles:
if int(tile["q"]) == q and int(tile["r"]) == r: 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: if not tile_visible:
return Color(0.045, 0.065, 0.065) return Color(0.045, 0.065, 0.065)
if coord == home_tile: 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: 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: 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 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: func _tile_display_label(key: String, data: Dictionary) -> String:
var terrain: String = str(data.get("terrain", "Open")) 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))): 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): if owned_tiles.has(key):
return "%s | Your territory" % terrain return "%s | Your territory%s" % [terrain, building_label]
if rival_tiles.has(key): if rival_tiles.has(key):
var rival_data: Dictionary = rival_tiles[key] var rival_data: Dictionary = rival_tiles[key]
var owner_username: String = _owner_username(rival_data) var owner_username: String = _owner_username(rival_data)
if owner_username.is_empty(): if owner_username.is_empty():
return "%s | Unclaimed" % terrain return "%s | Unclaimed%s" % [terrain, building_label]
return "%s | Controlled by %s" % [terrain, owner_username] return "%s | Controlled by %s%s" % [terrain, owner_username, building_label]
return "%s | Unclaimed" % terrain return "%s | Unclaimed%s" % [terrain, building_label]
func _owner_username(location: Dictionary) -> String: func _owner_username(location: Dictionary) -> String:
@@ -113,6 +113,36 @@ public class LocationsController : ControllerBase
return Ok(LocationResponse.FromLocation(location)); return Ok(LocationResponse.FromLocation(location));
} }
[Authorize(Roles = "USER,SUPER")]
[HttpPost("build")]
public async Task<ActionResult<LocationResponse>> 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")] [Authorize(Roles = "USER,SUPER")]
[HttpGet("home")] [HttpGet("home")]
public async Task<ActionResult<LocationResponse>> GetHome() public async Task<ActionResult<LocationResponse>> GetHome()
+11
View File
@@ -15,6 +15,13 @@ public class ClaimLocationRequest
public int R { get; set; } 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 class LocationResponse
{ {
public string Id { get; set; } = ""; public string Id { get; set; } = "";
@@ -25,6 +32,8 @@ public class LocationResponse
public string? OwnerUsername { get; set; } public string? OwnerUsername { get; set; }
public string? Label { get; set; } public string? Label { get; set; }
public int Level { get; set; } public int Level { get; set; }
public string? BuildingType { get; set; }
public DateTime? BuildingBuiltAtUtc { get; set; }
public DateTime? HomeAssignedAtUtc { get; set; } public DateTime? HomeAssignedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; } public DateTime UpdatedAtUtc { get; set; }
public bool IsVisible { get; set; } = true; public bool IsVisible { get; set; } = true;
@@ -39,6 +48,8 @@ public class LocationResponse
OwnerUsername = location.OwnerUsername, OwnerUsername = location.OwnerUsername,
Label = location.Label, Label = location.Label,
Level = location.Level, Level = location.Level,
BuildingType = location.BuildingType,
BuildingBuiltAtUtc = location.BuildingBuiltAtUtc,
HomeAssignedAtUtc = location.HomeAssignedAtUtc, HomeAssignedAtUtc = location.HomeAssignedAtUtc,
UpdatedAtUtc = location.UpdatedAtUtc, UpdatedAtUtc = location.UpdatedAtUtc,
IsVisible = true IsVisible = true
@@ -16,6 +16,8 @@ public class Location
public string? OwnerUsername { get; set; } public string? OwnerUsername { get; set; }
public string? Label { get; set; } public string? Label { get; set; }
public int Level { get; set; } = 1; public int Level { get; set; } = 1;
public string? BuildingType { get; set; }
public DateTime? BuildingBuiltAtUtc { get; set; }
public DateTime? HomeAssignedAtUtc { get; set; } public DateTime? HomeAssignedAtUtc { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
@@ -12,7 +12,10 @@ public class PlayerResources
public string Username { get; set; } = ""; public string Username { get; set; } = "";
public int Gold { get; set; } = 100; public int Gold { get; set; } = 100;
public int GoldPerMinute { get; set; } = 10; 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 LastGoldGeneratedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime LastWoodGeneratedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
} }
@@ -5,7 +5,10 @@ public class PlayerResourcesResponse
public string Username { get; set; } = ""; public string Username { get; set; } = "";
public int Gold { get; set; } public int Gold { get; set; }
public int GoldPerMinute { get; set; } public int GoldPerMinute { get; set; }
public int Wood { get; set; }
public int WoodPerMinute { get; set; }
public DateTime LastGoldGeneratedAtUtc { get; set; } public DateTime LastGoldGeneratedAtUtc { get; set; }
public DateTime LastWoodGeneratedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; } public DateTime UpdatedAtUtc { get; set; }
public static PlayerResourcesResponse FromResources(PlayerResources resources) => new() public static PlayerResourcesResponse FromResources(PlayerResources resources) => new()
@@ -13,7 +16,10 @@ public class PlayerResourcesResponse
Username = resources.Username, Username = resources.Username,
Gold = resources.Gold, Gold = resources.Gold,
GoldPerMinute = resources.GoldPerMinute, GoldPerMinute = resources.GoldPerMinute,
Wood = resources.Wood,
WoodPerMinute = resources.WoodPerMinute,
LastGoldGeneratedAtUtc = resources.LastGoldGeneratedAtUtc, LastGoldGeneratedAtUtc = resources.LastGoldGeneratedAtUtc,
LastWoodGeneratedAtUtc = resources.LastWoodGeneratedAtUtc,
UpdatedAtUtc = resources.UpdatedAtUtc UpdatedAtUtc = resources.UpdatedAtUtc
}; };
} }
+9 -1
View File
@@ -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?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. - `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/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. - `GET /api/locations/home` Get the authenticated user's home location.
- `POST /api/locations/home` Assign or return 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. - `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. 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 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 ## 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. 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 Finished training is completed lazily when unit state is read. Claiming an
unclaimed visible location consumes one completed Scout. 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.
@@ -10,6 +10,7 @@ public class LocationService
private readonly int _homeMinDistance; private readonly int _homeMinDistance;
private readonly int _homeSearchRadius; private readonly int _homeSearchRadius;
private readonly int _visibilityDepth; private readonly int _visibilityDepth;
private readonly int _logCampWoodPerMinute;
public LocationService(IConfiguration cfg, ResourceService resources) 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; _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; _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; _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<Location>.IndexKeys var coordinateIndex = Builders<Location>.IndexKeys
.Ascending(x => x.Q) .Ascending(x => x.Q)
@@ -111,6 +115,53 @@ public class LocationService
return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username); return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username);
} }
public async Task<Location?> 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<Location>.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<Location>
{
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<Location?> GetHomeAsync(string username) => public async Task<Location?> GetHomeAsync(string username) =>
await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync(); await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync();
@@ -32,8 +32,10 @@ public class ResourceService
var update = Builders<PlayerResources>.Update var update = Builders<PlayerResources>.Update
.SetOnInsert(x => x.Username, username) .SetOnInsert(x => x.Username, username)
.SetOnInsert(x => x.Gold, _startingGold) .SetOnInsert(x => x.Gold, _startingGold)
.SetOnInsert(x => x.Wood, 0)
.SetOnInsert(x => x.CreatedAtUtc, now) .SetOnInsert(x => x.CreatedAtUtc, now)
.SetOnInsert(x => x.LastGoldGeneratedAtUtc, now) .SetOnInsert(x => x.LastGoldGeneratedAtUtc, now)
.SetOnInsert(x => x.LastWoodGeneratedAtUtc, now)
.Set(x => x.GoldPerMinute, _headquartersGoldPerMinute) .Set(x => x.GoldPerMinute, _headquartersGoldPerMinute)
.Set(x => x.UpdatedAtUtc, now); .Set(x => x.UpdatedAtUtc, now);
@@ -78,29 +80,61 @@ public class ResourceService
return updated is not null; 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<PlayerResources>.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<PlayerResources> ApplyGoldGenerationAsync(PlayerResources resources) private async Task<PlayerResources> ApplyGoldGenerationAsync(PlayerResources resources)
{ {
if (resources.GoldPerMinute <= 0)
{
return resources;
}
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var elapsed = now - resources.LastGoldGeneratedAtUtc; var goldElapsedMinutes = resources.GoldPerMinute <= 0
var elapsedMinutes = (int)Math.Floor(elapsed.TotalMinutes); ? 0
if (elapsedMinutes <= 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; return resources;
} }
var generatedGold = elapsedMinutes * resources.GoldPerMinute;
var generationTimestamp = resources.LastGoldGeneratedAtUtc.AddMinutes(elapsedMinutes);
var update = Builders<PlayerResources>.Update var update = Builders<PlayerResources>.Update
.Inc(x => x.Gold, generatedGold)
.Set(x => x.GoldPerMinute, _headquartersGoldPerMinute) .Set(x => x.GoldPerMinute, _headquartersGoldPerMinute)
.Set(x => x.LastGoldGeneratedAtUtc, generationTimestamp)
.Set(x => x.UpdatedAtUtc, now); .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( return await _resources.FindOneAndUpdateAsync(
x => x.Username == resources.Username, x => x.Username == resources.Username,
update, update,
@@ -27,5 +27,8 @@
"ScoutGoldCost": 50, "ScoutGoldCost": 50,
"ScoutTrainingSeconds": 60 "ScoutTrainingSeconds": 60
}, },
"Buildings": {
"LogCampWoodPerMinute": 5
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }
@@ -15,3 +15,4 @@ data:
Resources__HeadquartersGoldPerMinute: "10" Resources__HeadquartersGoldPerMinute: "10"
Units__ScoutGoldCost: "50" Units__ScoutGoldCost: "50"
Units__ScoutTrainingSeconds: "60" Units__ScoutTrainingSeconds: "60"
Buildings__LogCampWoodPerMinute: "5"