adding resource limits and archers

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