diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index 9077457..41bb9fa 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -31,6 +31,9 @@ var training_bar: ProgressBar var training_started_unix := 0.0 var training_completes_unix := 0.0 var training_completion_refresh_requested := false +var building_started_unix := 0.0 +var building_completes_unix := 0.0 +var building_completion_refresh_requested := false var claim_button: Button var build_log_camp_button: Button var selected_q := 0 @@ -48,6 +51,7 @@ func _ready() -> void: func _process(_delta: float) -> void: _update_tick_bar() _update_training_bar() + _update_building_timer() func _unhandled_input(event: InputEvent) -> void: @@ -634,7 +638,13 @@ func _on_build_request_completed(result: int, response_code: int, _headers: Pack selected_can_build_log_camp = false build_log_camp_button.disabled = true - coordinate_label.text = "Log Camp built. Wood production increased." + var parsed = JSON.parse_string(text) + if typeof(parsed) == TYPE_DICTIONARY: + var location: Dictionary = parsed + building_started_unix = _parse_utc_timestamp(str(location.get("buildingStartedAtUtc", ""))) + building_completes_unix = _parse_utc_timestamp(str(location.get("buildingCompletesAtUtc", ""))) + building_completion_refresh_requested = false + coordinate_label.text = "Log Camp construction started. Completion takes 1 minute." _load_visible_locations() _refresh_player_state() @@ -682,6 +692,20 @@ func _update_training_bar() -> void: training_label.text = "Training: %ds" % remaining +func _update_building_timer() -> void: + if building_started_unix <= 0.0 or building_completes_unix <= 0.0: + return + + var remaining: int = int(max(0, int(ceil(building_completes_unix - Time.get_unix_time_from_system())))) + if remaining > 0: + return + + if not building_completion_refresh_requested: + building_completion_refresh_requested = true + _load_visible_locations() + _refresh_player_state() + + func _parse_utc_timestamp(timestamp: String) -> float: if timestamp.is_empty(): return 0.0 diff --git a/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index e5962e1..6a4d133 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -162,8 +162,11 @@ func _draw_tile(tile: Dictionary) -> void: var visible_location: Dictionary = visible_tiles.get(key, {}) var building_type: String = str(visible_location.get("buildingType", "")) - if building_type == "LogCamp": + var building_status: String = str(visible_location.get("buildingStatus", "")) + if building_type == "LogCamp" and (building_status == "Built" or building_status.is_empty()): _draw_log_camp_marker(center, height) + elif building_type == "LogCamp": + _draw_building_marker(center, height) func _draw_control_regions(sorted_tiles: Array) -> void: @@ -298,6 +301,13 @@ func _draw_log_camp_marker(center: Vector2, height: float) -> void: 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 _draw_building_marker(center: Vector2, height: float) -> void: + var marker_center: Vector2 = center + Vector2(0, -height * zoom - 1.0) + var radius: float = 7.0 * zoom + draw_arc(marker_center, radius, 0.0, TAU, 24, Color(0.95, 0.76, 0.28), 2.0 * zoom) + draw_line(marker_center + Vector2(-radius * 0.7, radius * 0.7), marker_center + Vector2(radius * 0.7, -radius * 0.7), Color(0.95, 0.76, 0.28), 2.0 * zoom) + + func _tile_data(q: int, r: int) -> Dictionary: for tile in tiles: if int(tile["q"]) == q and int(tile["r"]) == r: @@ -389,9 +399,10 @@ func _neighbor_coordinates(q: int, r: int) -> Array[Vector2i]: 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_status: String = str(data.get("buildingStatus", "")) var building_label: String = "" if building_type == "LogCamp": - building_label = " | Log Camp" + building_label = " | Log Camp" if building_status == "Built" or building_status.is_empty() else " | Log Camp building" if home_tile == Vector2i(int(data.get("q", 0)), int(data.get("r", 0))): return "%s | Your home%s" % [terrain, building_label] @@ -444,7 +455,7 @@ func _terrain_color_for_name(terrain: String, _q: int, _r: int, _height: float) "Command": return Color(0.68, 0.58, 0.28) "Forest": - return Color(0.24, 0.45, 0.29) + return Color(0.12, 0.31, 0.18) "Ore": return Color(0.42, 0.46, 0.5) "Highland": diff --git a/microservices/LocationsApi/Models/Dto.cs b/microservices/LocationsApi/Models/Dto.cs index a95dd2d..986d62c 100644 --- a/microservices/LocationsApi/Models/Dto.cs +++ b/microservices/LocationsApi/Models/Dto.cs @@ -33,6 +33,9 @@ public class LocationResponse public string? Label { get; set; } public int Level { get; set; } public string? BuildingType { get; set; } + public string? BuildingStatus { get; set; } + public DateTime? BuildingStartedAtUtc { get; set; } + public DateTime? BuildingCompletesAtUtc { get; set; } public DateTime? BuildingBuiltAtUtc { get; set; } public DateTime? HomeAssignedAtUtc { get; set; } public DateTime UpdatedAtUtc { get; set; } @@ -49,6 +52,11 @@ public class LocationResponse Label = location.Label, Level = location.Level, BuildingType = location.BuildingType, + BuildingStatus = location.BuildingType == "LogCamp" && string.IsNullOrWhiteSpace(location.BuildingStatus) + ? "Built" + : location.BuildingStatus, + BuildingStartedAtUtc = location.BuildingStartedAtUtc, + BuildingCompletesAtUtc = location.BuildingCompletesAtUtc, BuildingBuiltAtUtc = location.BuildingBuiltAtUtc, HomeAssignedAtUtc = location.HomeAssignedAtUtc, UpdatedAtUtc = location.UpdatedAtUtc, diff --git a/microservices/LocationsApi/Models/GameSetting.cs b/microservices/LocationsApi/Models/GameSetting.cs new file mode 100644 index 0000000..49aec79 --- /dev/null +++ b/microservices/LocationsApi/Models/GameSetting.cs @@ -0,0 +1,16 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace LocationsApi.Models; + +public class GameSetting +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ""; + + public string Key { get; set; } = ""; + public int IntValue { get; set; } + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/microservices/LocationsApi/Models/Location.cs b/microservices/LocationsApi/Models/Location.cs index 53cf7c8..362f79e 100644 --- a/microservices/LocationsApi/Models/Location.cs +++ b/microservices/LocationsApi/Models/Location.cs @@ -17,6 +17,9 @@ public class Location public string? Label { get; set; } public int Level { get; set; } = 1; public string? BuildingType { get; set; } + public string? BuildingStatus { get; set; } + public DateTime? BuildingStartedAtUtc { get; set; } + public DateTime? BuildingCompletesAtUtc { get; set; } public DateTime? BuildingBuiltAtUtc { get; set; } public DateTime? HomeAssignedAtUtc { get; set; } public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; diff --git a/microservices/LocationsApi/Program.cs b/microservices/LocationsApi/Program.cs index 2ff755b..86a70cc 100644 --- a/microservices/LocationsApi/Program.cs +++ b/microservices/LocationsApi/Program.cs @@ -10,6 +10,7 @@ builder.Services.AddControllers(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { diff --git a/microservices/LocationsApi/README.md b/microservices/LocationsApi/README.md index d1c49b8..815ffb7 100644 --- a/microservices/LocationsApi/README.md +++ b/microservices/LocationsApi/README.md @@ -41,5 +41,12 @@ 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. +which can be built on controlled Forest territory, takes 60 seconds to build, +and adds 5 wood per minute to the owning player's resource generation after it +finishes. + +Timer settings are stored in the MongoDB `GameSettings` collection and are +seeded on first read: + +- `ScoutTrainingSeconds` +- `LogCampBuildSeconds` diff --git a/microservices/LocationsApi/Services/GameSettingsService.cs b/microservices/LocationsApi/Services/GameSettingsService.cs new file mode 100644 index 0000000..2782d94 --- /dev/null +++ b/microservices/LocationsApi/Services/GameSettingsService.cs @@ -0,0 +1,44 @@ +using LocationsApi.Models; +using MongoDB.Driver; + +namespace LocationsApi.Services; + +public class GameSettingsService +{ + public const string ScoutTrainingSeconds = "ScoutTrainingSeconds"; + public const string LogCampBuildSeconds = "LogCampBuildSeconds"; + + private readonly IMongoCollection _settings; + + public GameSettingsService(IConfiguration cfg) + { + var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var databaseName = cfg["MongoDB:DatabaseName"] ?? "territory-autobattler"; + var client = new MongoClient(connectionString); + var database = client.GetDatabase(databaseName); + _settings = database.GetCollection("GameSettings"); + + _settings.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.Key), + new CreateIndexOptions { Unique = true })); + } + + public async Task GetIntAsync(string key, int defaultValue) + { + var now = DateTime.UtcNow; + var setting = await _settings.FindOneAndUpdateAsync( + x => x.Key == key, + Builders.Update + .SetOnInsert(x => x.Key, key) + .SetOnInsert(x => x.IntValue, defaultValue) + .SetOnInsert(x => x.CreatedAtUtc, now) + .Set(x => x.UpdatedAtUtc, now), + new FindOneAndUpdateOptions + { + IsUpsert = true, + ReturnDocument = ReturnDocument.After + }); + + return Math.Max(1, setting.IntValue); + } +} diff --git a/microservices/LocationsApi/Services/LocationService.cs b/microservices/LocationsApi/Services/LocationService.cs index e71210e..5e42084 100644 --- a/microservices/LocationsApi/Services/LocationService.cs +++ b/microservices/LocationsApi/Services/LocationService.cs @@ -7,12 +7,14 @@ public class LocationService { private readonly IMongoCollection _locations; private readonly ResourceService _resources; + private readonly GameSettingsService _settings; private readonly int _homeMinDistance; private readonly int _homeSearchRadius; private readonly int _visibilityDepth; private readonly int _logCampWoodPerMinute; + private readonly int _defaultLogCampBuildSeconds; - public LocationService(IConfiguration cfg, ResourceService resources) + public LocationService(IConfiguration cfg, ResourceService resources, GameSettingsService settings) { var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; var databaseName = cfg["MongoDB:DatabaseName"] ?? "territory-autobattler"; @@ -20,12 +22,16 @@ public class LocationService var database = client.GetDatabase(databaseName); _locations = database.GetCollection("Locations"); _resources = resources; + _settings = settings; _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; + _defaultLogCampBuildSeconds = int.TryParse(cfg["Buildings:LogCampBuildSeconds"], out var logCampBuildSeconds) + ? Math.Max(1, logCampBuildSeconds) + : 60; var coordinateIndex = Builders.IndexKeys .Ascending(x => x.Q) @@ -139,10 +145,15 @@ public class LocationService throw new InvalidOperationException("Log Camps can only be built on Forest territory."); } + await CompleteFinishedBuildingsAsync(username); + var now = DateTime.UtcNow; + var buildSeconds = await _settings.GetIntAsync(GameSettingsService.LogCampBuildSeconds, _defaultLogCampBuildSeconds); var update = Builders.Update .Set(x => x.BuildingType, "LogCamp") - .Set(x => x.BuildingBuiltAtUtc, now) + .Set(x => x.BuildingStatus, "Building") + .Set(x => x.BuildingStartedAtUtc, now) + .Set(x => x.BuildingCompletesAtUtc, now.AddSeconds(buildSeconds)) .Set(x => x.UpdatedAtUtc, now); var updated = await _locations.FindOneAndUpdateAsync( @@ -158,10 +169,41 @@ public class LocationService throw new InvalidOperationException("This location already has a building."); } - await _resources.AddWoodGenerationAsync(username, _logCampWoodPerMinute); return updated; } + public async Task CompleteFinishedBuildingsAsync(string username) + { + var now = DateTime.UtcNow; + var readyBuildings = await _locations.Find(x => + x.OwnerUsername == username && + x.BuildingType == "LogCamp" && + x.BuildingStatus == "Building" && + x.BuildingCompletesAtUtc <= now) + .ToListAsync(); + + foreach (var building in readyBuildings) + { + var update = Builders.Update + .Set(x => x.BuildingStatus, "Built") + .Set(x => x.BuildingBuiltAtUtc, now) + .Set(x => x.UpdatedAtUtc, now); + + var updated = await _locations.FindOneAndUpdateAsync( + x => x.Id == building.Id && x.BuildingStatus == "Building", + update, + new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After + }); + + if (updated is not null) + { + await _resources.AddWoodGenerationAsync(username, _logCampWoodPerMinute); + } + } + } + public async Task GetHomeAsync(string username) => await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync(); @@ -204,6 +246,8 @@ public class LocationService public async Task> GetVisibleAsync(string username, int? visibilityDepth = null) { + await CompleteFinishedBuildingsAsync(username); + var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); if (ownedLocations.Count == 0) { diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs index e0f0b5b..3bacba4 100644 --- a/microservices/LocationsApi/Services/UnitTrainingService.cs +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -9,10 +9,11 @@ public class UnitTrainingService private readonly IMongoCollection _training; private readonly ResourceService _resources; + private readonly GameSettingsService _settings; private readonly int _scoutGoldCost; - private readonly int _scoutTrainingSeconds; + private readonly int _defaultScoutTrainingSeconds; - public UnitTrainingService(IConfiguration cfg, ResourceService resources) + public UnitTrainingService(IConfiguration cfg, ResourceService resources, GameSettingsService settings) { var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; var databaseName = cfg["MongoDB:DatabaseName"] ?? "territory-autobattler"; @@ -20,8 +21,9 @@ public class UnitTrainingService var database = client.GetDatabase(databaseName); _training = database.GetCollection("UnitTraining"); _resources = resources; + _settings = settings; _scoutGoldCost = int.TryParse(cfg["Units:ScoutGoldCost"], out var scoutGoldCost) ? Math.Max(0, scoutGoldCost) : 50; - _scoutTrainingSeconds = int.TryParse(cfg["Units:ScoutTrainingSeconds"], out var scoutTrainingSeconds) + _defaultScoutTrainingSeconds = int.TryParse(cfg["Units:ScoutTrainingSeconds"], out var scoutTrainingSeconds) ? Math.Max(1, scoutTrainingSeconds) : 60; @@ -62,6 +64,7 @@ public class UnitTrainingService } var now = DateTime.UtcNow; + var scoutTrainingSeconds = await _settings.GetIntAsync(GameSettingsService.ScoutTrainingSeconds, _defaultScoutTrainingSeconds); var training = new UnitTraining { Username = username, @@ -69,7 +72,7 @@ public class UnitTrainingService GoldCost = _scoutGoldCost, Status = "Training", StartedAtUtc = now, - CompletesAtUtc = now.AddSeconds(_scoutTrainingSeconds), + CompletesAtUtc = now.AddSeconds(scoutTrainingSeconds), CreatedAtUtc = now, UpdatedAtUtc = now }; diff --git a/microservices/LocationsApi/appsettings.json b/microservices/LocationsApi/appsettings.json index a382d50..3102cc0 100644 --- a/microservices/LocationsApi/appsettings.json +++ b/microservices/LocationsApi/appsettings.json @@ -28,7 +28,8 @@ "ScoutTrainingSeconds": 60 }, "Buildings": { - "LogCampWoodPerMinute": 5 + "LogCampWoodPerMinute": 5, + "LogCampBuildSeconds": 60 }, "AllowedHosts": "*" } diff --git a/microservices/LocationsApi/k8s/configmap.yaml b/microservices/LocationsApi/k8s/configmap.yaml index 986c069..c34d2b9 100644 --- a/microservices/LocationsApi/k8s/configmap.yaml +++ b/microservices/LocationsApi/k8s/configmap.yaml @@ -16,3 +16,4 @@ data: Units__ScoutGoldCost: "50" Units__ScoutTrainingSeconds: "60" Buildings__LogCampWoodPerMinute: "5" + Buildings__LogCampBuildSeconds: "60"