From 07b7bd4476279e993bf7241f47249376d8aa384e Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 7 Apr 2026 17:50:40 -0700 Subject: [PATCH] =?UTF-8?q?test(city-systems):=20=E2=9C=85=20Add/update=20?= =?UTF-8?q?unit=20tests=20for=20bridge=20logic,=20site=20scoring,=20and=20?= =?UTF-8?q?improvement=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engine/tests/unit/test_city_bridge.gd | 113 +++++++ .../tests/unit/test_city_site_scorer.gd | 192 ++++++++++++ .../tests/unit/test_city_site_scorer.gd.uid | 1 + .../tests/unit/test_improvement_manager.gd | 289 ++++++++++++++++++ .../unit/test_improvement_manager.gd.uid | 1 + 5 files changed, 596 insertions(+) create mode 100644 src/game/engine/tests/unit/test_city_bridge.gd create mode 100644 src/game/engine/tests/unit/test_city_site_scorer.gd create mode 100644 src/game/engine/tests/unit/test_city_site_scorer.gd.uid create mode 100644 src/game/engine/tests/unit/test_improvement_manager.gd create mode 100644 src/game/engine/tests/unit/test_improvement_manager.gd.uid diff --git a/src/game/engine/tests/unit/test_city_bridge.gd b/src/game/engine/tests/unit/test_city_bridge.gd new file mode 100644 index 00000000..64329b25 --- /dev/null +++ b/src/game/engine/tests/unit/test_city_bridge.gd @@ -0,0 +1,113 @@ +extends GutTest +## Task #18 — verify the City entity's GdCity bridge end-to-end. +## +## When the GdCity GDExtension is registered, we exercise the full path: +## create a city with a smithy, load iron_axe from the item registry, +## stock 2 iron_ore, enqueue, tick, and assert EventBus.item_crafted +## fires with the expected item_id. When the extension is not loaded +## (headless test run with no .so in addons/), we verify the degraded +## path: enqueue returns a clear error string and no signal fires. + +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") + +var _crafted_events: Array[Dictionary] = [] + + +func before_each() -> void: + _crafted_events.clear() + if EventBus.item_crafted.is_connected(_on_item_crafted): + EventBus.item_crafted.disconnect(_on_item_crafted) + EventBus.item_crafted.connect(_on_item_crafted) + + +func after_each() -> void: + if EventBus.item_crafted.is_connected(_on_item_crafted): + EventBus.item_crafted.disconnect(_on_item_crafted) + + +func _on_item_crafted(item_id: String, city: Variant, player: Variant) -> void: + _crafted_events.append({ + "item_id": item_id, + "city": city, + "player": player, + }) + + +func test_bridge_construction_sets_id_and_buildings() -> void: + var city: RefCounted = CityScript.new("khazad", ["smithy"] as Array[String]) + assert_eq(city.id, "khazad") + assert_true(city.has_building("smithy")) + assert_false(city.has_building("forge")) + + +func test_add_building_syncs_mirror() -> void: + var city: RefCounted = CityScript.new("khazad", [] as Array[String]) + city.add_building("smithy") + assert_true(city.has_building("smithy")) + # Duplicate add is a no-op. + city.add_building("smithy") + assert_eq(city.buildings.size(), 1) + + +func test_enqueue_without_extension_returns_error_string() -> void: + if ClassDB.class_exists("GdCity"): + # Skip — extension is loaded; the happy-path test covers this. + pass_test("GdCity extension present — covered by happy-path test") + return + var city: RefCounted = CityScript.new("khazad", ["smithy"] as Array[String]) + var err: String = city.enqueue_item( + "iron_axe", null, [] as Array[String] + ) + assert_ne(err, "", "enqueue should surface an error when extension missing") + assert_eq(_crafted_events.size(), 0) + + +func test_happy_path_enqueue_tick_emits_item_crafted() -> void: + if not ClassDB.class_exists("GdCity"): + pass_test("GdCity extension not loaded in this test run") + return + if not ClassDB.class_exists("GdStockpile"): + pass_test("GdStockpile extension not loaded in this test run") + return + if not ClassDB.class_exists("GdTreasury"): + pass_test("GdTreasury extension not loaded in this test run") + return + + var city: RefCounted = CityScript.new("khazad", ["smithy"] as Array[String]) + var items_json: String = JSON.stringify([ + { + "id": "iron_axe", + "production": { + "building": "smithy", + "hammer_cost": 30, + "materials": [{"resource": "iron_ore", "amount": 2}], + "requires_tech": "bronze_working", + }, + }, + ]) + var loaded: bool = city.load_items_json(items_json) + assert_true(loaded, "load_items_json should succeed") + + var stockpile: RefCounted = ClassDB.instantiate("GdStockpile") as RefCounted + stockpile.call("add", "iron_ore", 2) + + var techs: Array[String] = ["bronze_working"] + var err: String = city.enqueue_item("iron_axe", stockpile, techs) + assert_eq(err, "", "enqueue should succeed") + assert_eq( + stockpile.call("available", "iron_ore"), 0, + "materials must be consumed on enqueue" + ) + assert_eq(city.queue_len("smithy"), 1) + + var treasury: RefCounted = ClassDB.instantiate("GdTreasury") as RefCounted + var completed: int = city.tick_building("smithy", 30, treasury) + assert_eq(completed, 1, "30 hammers should complete iron_axe") + assert_eq(city.queue_len("smithy"), 0) + + assert_eq(_crafted_events.size(), 1, "item_crafted should fire exactly once") + assert_eq(_crafted_events[0]["item_id"], "iron_axe") + assert_eq(_crafted_events[0]["city"], city) + + assert_eq(treasury.call("total_count"), 1) + assert_true(treasury.call("contains", "iron_axe")) diff --git a/src/game/engine/tests/unit/test_city_site_scorer.gd b/src/game/engine/tests/unit/test_city_site_scorer.gd new file mode 100644 index 00000000..8e7047d5 --- /dev/null +++ b/src/game/engine/tests/unit/test_city_site_scorer.gd @@ -0,0 +1,192 @@ +extends GutTest +## CitySiteScorer unit tests. +## Covers: founding validity, site scoring, founder target search. + +const CityScorerScript: GDScript = preload( + "res://engine/src/modules/management/city_site_scorer.gd" +) +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") +const TileScript: GDScript = preload("res://engine/src/map/tile.gd") + +var _scorer: CityScorerScript = null +var _player: PlayerScript = null +var _game_map: GameMapScript = null + + +func before_all() -> void: + DataLoader.load_theme("age-of-dwarves") + + +func before_each() -> void: + _scorer = CityScorerScript.new() + + _player = PlayerScript.new() + _player.index = 0 + _player.race_id = "dwarf" + _player.units = [] + _player.cities = [] + _player.researched_techs = [] + + GameState.players = [_player] + GameState.diplomacy = {} + + _game_map = _build_small_map() + + +func after_each() -> void: + GameState.players = [] + GameState.diplomacy = {} + + +func _build_small_map() -> GameMapScript: + var gmap: GameMapScript = GameMapScript.new() + gmap.initialize(10, 10, 0) + + for x: int in range(10): + for y: int in range(10): + var tile: TileScript = TileScript.new(Vector2i(x, y), "plains") + tile.temperature = 0.45 + tile.moisture = 0.45 + tile.wind_speed = 0.2 + tile.wind_direction = 0 + tile.elevation = 0.5 + tile.is_coastal = false + gmap.set_tile(Vector2i(x, y), tile) + + return gmap + + +func _make_city(owner_idx: int, pos: Vector2i) -> CityScript: + var city: CityScript = CityScript.new() + city.id = "city_%d_%d" % [pos.x, pos.y] + city.owner = owner_idx + city.position = pos + city.population = 1 + city.buildings = [] + return city + + +# --------------------------------------------------------------------------- +# can_found_city_at +# --------------------------------------------------------------------------- + + +func test_can_found_city_at_valid_land_tile() -> void: + assert_true( + _scorer.can_found_city_at(Vector2i(5, 5), _game_map), + "Must allow founding on a valid land tile with no nearby cities" + ) + + +func test_cannot_found_city_at_null_tile() -> void: + var empty_map: GameMapScript = GameMapScript.new() + empty_map.initialize(10, 10, 0) + assert_false( + _scorer.can_found_city_at(Vector2i(5, 5), empty_map), + "Must reject founding when tile is null" + ) + + +func test_cannot_found_city_too_close_to_existing() -> void: + _player.cities.append(_make_city(0, Vector2i(5, 5))) + assert_false( + _scorer.can_found_city_at(Vector2i(5, 6), _game_map), + "Must reject founding within 3 hexes of existing city" + ) + + +func test_can_found_city_exactly_3_away() -> void: + _player.cities.append(_make_city(0, Vector2i(5, 5))) + + var candidate: Vector2i = Vector2i(-9999, -9999) + for dx: int in range(-5, 6): + for dy: int in range(-5, 6): + var pos: Vector2i = Vector2i(5 + dx, 5 + dy) + var dist: float = (abs(dx) + abs(dy) + abs(dx + dy)) / 2.0 + if int(dist) == 3 and pos.x >= 0 and pos.y >= 0 \ + and pos.x < 10 and pos.y < 10: + candidate = pos + break + if candidate != Vector2i(-9999, -9999): + break + + if candidate == Vector2i(-9999, -9999): + pass # No ring-3 tile in bounds — skip + else: + assert_true( + _scorer.can_found_city_at(candidate, _game_map), + "Must allow founding exactly 3 hexes from existing city" + ) + + +# --------------------------------------------------------------------------- +# score_city_site +# --------------------------------------------------------------------------- + + +func test_score_city_site_returns_negative_for_null_tile() -> void: + var empty_map: GameMapScript = GameMapScript.new() + empty_map.initialize(10, 10, 0) + assert_eq( + _scorer.score_city_site(Vector2i(5, 5), empty_map), + -1.0, + "score_city_site must return -1.0 when tile is null" + ) + + +func test_score_city_site_returns_positive_for_valid_plains() -> void: + assert_gt( + _scorer.score_city_site(Vector2i(5, 5), _game_map), + 0.0, + "score_city_site must return > 0 for valid plains tile" + ) + + +func test_score_city_site_returns_negative_when_too_close_to_city() -> void: + _player.cities.append(_make_city(0, Vector2i(5, 5))) + assert_eq( + _scorer.score_city_site(Vector2i(5, 6), _game_map), + -1.0, + "score_city_site must return -1.0 when too close to existing city" + ) + + +func test_higher_quality_tile_scores_higher() -> void: + var low_tile: TileScript = _game_map.get_tile(Vector2i(3, 3)) as TileScript + var high_tile: TileScript = _game_map.get_tile(Vector2i(7, 7)) as TileScript + low_tile.quality = 1 + high_tile.quality = 5 + + var low_score: float = _scorer.score_city_site(Vector2i(3, 3), _game_map) + var high_score: float = _scorer.score_city_site(Vector2i(7, 7), _game_map) + assert_gt(high_score, low_score, "Higher quality tile must score higher") + + +# --------------------------------------------------------------------------- +# find_best_founder_target +# --------------------------------------------------------------------------- + + +func test_find_best_founder_target_returns_valid_position() -> void: + var founder: UnitScript = UnitScript.new() + founder.position = Vector2i(5, 5) + assert_ne( + _scorer.find_best_founder_target(founder, _game_map), + Vector2i(-9999, -9999), + "Must find a valid target when map has land tiles" + ) + + +func test_find_best_founder_target_returns_sentinel_on_empty_map() -> void: + var empty_map: GameMapScript = GameMapScript.new() + empty_map.initialize(10, 10, 0) + var founder: UnitScript = UnitScript.new() + founder.position = Vector2i(5, 5) + assert_eq( + _scorer.find_best_founder_target(founder, empty_map), + Vector2i(-9999, -9999), + "Must return sentinel on empty map" + ) diff --git a/src/game/engine/tests/unit/test_city_site_scorer.gd.uid b/src/game/engine/tests/unit/test_city_site_scorer.gd.uid new file mode 100644 index 00000000..279cf0a0 --- /dev/null +++ b/src/game/engine/tests/unit/test_city_site_scorer.gd.uid @@ -0,0 +1 @@ +uid://buq3qpgkvg68w diff --git a/src/game/engine/tests/unit/test_improvement_manager.gd b/src/game/engine/tests/unit/test_improvement_manager.gd new file mode 100644 index 00000000..9a98868f --- /dev/null +++ b/src/game/engine/tests/unit/test_improvement_manager.gd @@ -0,0 +1,289 @@ +extends GutTest +## Improvement manager unit tests. +## Covers: get_buildable_improvements, start_improvement, cancel_improvement, +## build completion after turns, pending improvement queries. + +const ImprovementManagerScript: GDScript = preload( + "res://engine/src/modules/management/improvement_manager.gd" +) +const ImprovementScript: GDScript = preload("res://engine/src/entities/improvement.gd") +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const TileScript: GDScript = preload("res://engine/src/map/tile.gd") +const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") + +var _manager: ImprovementManagerScript = null +var _player: PlayerScript = null +var _game_map: GameMapScript = null + + +func before_all() -> void: + DataLoader.load_theme("age-of-dwarves") + + +func before_each() -> void: + _manager = ImprovementManagerScript.new() + + _player = PlayerScript.new() + _player.index = 0 + _player.race_id = "dwarf" + _player.units = [] + _player.cities = [] + _player.researched_techs = [] + _player.pending_improvements = [] + + GameState.players = [_player] + + _game_map = GameMapScript.new() + _game_map.initialize(10, 10, 1) + for x: int in range(10): + for y: int in range(10): + var tile: TileScript = TileScript.new() + tile.position = Vector2i(x, y) + tile.biome_id = "grassland" + tile.quality = 3 + tile.owner = 0 + _game_map.set_tile(Vector2i(x, y), tile) + + +func _make_engineer(pos: Vector2i) -> UnitScript: + var unit: UnitScript = UnitScript.new() + unit.id = "eng_%d_%d" % [pos.x, pos.y] + unit.type_id = "dwarf_engineer" + unit.owner = 0 + unit.position = pos + unit.hp = 10 + unit.max_hp = 10 + unit.bonus_attack = 0 + unit.bonus_defense = 0 + unit.unit_type = "civilian" + unit.can_build_improvements = true + unit.movement_remaining = 2 + return unit + + +func _make_military_unit(pos: Vector2i) -> UnitScript: + var unit: UnitScript = UnitScript.new() + unit.id = "mil_%d_%d" % [pos.x, pos.y] + unit.type_id = "spearmen" + unit.owner = 0 + unit.position = pos + unit.hp = 10 + unit.max_hp = 10 + unit.bonus_attack = 5 + unit.bonus_defense = 3 + unit.unit_type = "military" + unit.can_build_improvements = false + unit.movement_remaining = 2 + return unit + + +# --------------------------------------------------------------------------- +# get_buildable_improvements +# --------------------------------------------------------------------------- + +func test_engineer_gets_buildable_list() -> void: + var engineer: UnitScript = _make_engineer(Vector2i(3, 3)) + + var buildable: Array[Dictionary] = _manager.get_buildable_improvements( + engineer, _game_map, _player + ) + + ## At minimum, grassland should allow some improvements (farm, road, etc.) + assert_gt( + buildable.size(), 0, + "Engineer on grassland must have at least one buildable improvement" + ) + + ## Each entry must have required keys + for entry: Dictionary in buildable: + assert_true(entry.has("id"), "Buildable entry must have 'id'") + assert_true(entry.has("name"), "Buildable entry must have 'name'") + assert_true(entry.has("build_turns"), "Buildable entry must have 'build_turns'") + + +func test_military_unit_cannot_build() -> void: + var soldier: UnitScript = _make_military_unit(Vector2i(3, 3)) + + var buildable: Array[Dictionary] = _manager.get_buildable_improvements( + soldier, _game_map, _player + ) + + assert_eq( + buildable.size(), 0, + "Military unit must not have any buildable improvements" + ) + + +func test_no_build_on_existing_improvement() -> void: + var engineer: UnitScript = _make_engineer(Vector2i(3, 3)) + var tile: TileScript = _game_map.get_tile(Vector2i(3, 3)) as TileScript + tile.improvement = "farm" + + var buildable: Array[Dictionary] = _manager.get_buildable_improvements( + engineer, _game_map, _player + ) + + assert_eq( + buildable.size(), 0, + "Must not allow building on tile with existing improvement" + ) + + +func test_no_build_on_enemy_territory() -> void: + var engineer: UnitScript = _make_engineer(Vector2i(3, 3)) + var tile: TileScript = _game_map.get_tile(Vector2i(3, 3)) as TileScript + tile.owner = 1 ## Belongs to another player + + var buildable: Array[Dictionary] = _manager.get_buildable_improvements( + engineer, _game_map, _player + ) + + assert_eq( + buildable.size(), 0, + "Must not allow building on enemy-owned territory" + ) + + +func test_build_allowed_on_unclaimed_territory() -> void: + var engineer: UnitScript = _make_engineer(Vector2i(3, 3)) + var tile: TileScript = _game_map.get_tile(Vector2i(3, 3)) as TileScript + tile.owner = -1 ## Unclaimed + + var buildable: Array[Dictionary] = _manager.get_buildable_improvements( + engineer, _game_map, _player + ) + + assert_gt( + buildable.size(), 0, + "Engineer must be able to build on unclaimed territory" + ) + + +# --------------------------------------------------------------------------- +# start_improvement +# --------------------------------------------------------------------------- + +func test_start_improvement_adds_pending() -> void: + var engineer: UnitScript = _make_engineer(Vector2i(3, 3)) + + var success: bool = _manager.start_improvement(engineer, "farm", _player) + assert_true(success, "start_improvement must succeed for valid engineer") + assert_eq( + _player.pending_improvements.size(), 1, + "Must add one pending improvement" + ) + + var pending: Dictionary = _player.pending_improvements[0] as Dictionary + assert_eq(pending.get("type", ""), "farm", "Pending type must be 'farm'") + assert_eq(pending.get("x", -1), 3, "Pending x must match unit position") + assert_eq(pending.get("y", -1), 3, "Pending y must match unit position") + + +func test_start_improvement_consumes_movement() -> void: + var engineer: UnitScript = _make_engineer(Vector2i(3, 3)) + assert_gt(engineer.movement_remaining, 0, "Engineer must have movement") + + _manager.start_improvement(engineer, "farm", _player) + assert_eq( + engineer.movement_remaining, 0, + "Starting improvement must consume all movement" + ) + + +func test_start_fails_without_movement() -> void: + var engineer: UnitScript = _make_engineer(Vector2i(3, 3)) + engineer.movement_remaining = 0 + + var success: bool = _manager.start_improvement(engineer, "farm", _player) + assert_false(success, "Must fail when engineer has no movement remaining") + assert_eq( + _player.pending_improvements.size(), 0, + "Must not add pending improvement on failure" + ) + + +func test_start_fails_for_non_builder() -> void: + var soldier: UnitScript = _make_military_unit(Vector2i(3, 3)) + + var success: bool = _manager.start_improvement(soldier, "farm", _player) + assert_false(success, "Must fail for unit that cannot build improvements") + + +func test_pending_has_correct_turns() -> void: + var engineer: UnitScript = _make_engineer(Vector2i(3, 3)) + _manager.start_improvement(engineer, "farm", _player) + + var pending: Dictionary = _player.pending_improvements[0] as Dictionary + var expected_turns: int = ImprovementScript.get_build_time("farm") + assert_eq( + pending.get("turns_remaining", -1), + expected_turns, + "Pending turns must match improvement build time" + ) + + +# --------------------------------------------------------------------------- +# cancel_improvement +# --------------------------------------------------------------------------- + +func test_cancel_removes_pending() -> void: + var engineer: UnitScript = _make_engineer(Vector2i(3, 3)) + _manager.start_improvement(engineer, "farm", _player) + assert_eq(_player.pending_improvements.size(), 1, "Precondition: 1 pending") + + var cancelled: bool = _manager.cancel_improvement(Vector2i(3, 3), _player) + assert_true(cancelled, "cancel_improvement must succeed for existing pending") + assert_eq( + _player.pending_improvements.size(), 0, + "Pending must be empty after cancellation" + ) + + +func test_cancel_returns_false_for_no_pending() -> void: + var cancelled: bool = _manager.cancel_improvement(Vector2i(5, 5), _player) + assert_false( + cancelled, + "cancel_improvement must return false when nothing pending at position" + ) + + +# --------------------------------------------------------------------------- +# get_pending_at +# --------------------------------------------------------------------------- + +func test_get_pending_at_returns_matching() -> void: + var engineer: UnitScript = _make_engineer(Vector2i(3, 3)) + _manager.start_improvement(engineer, "farm", _player) + + var pending: Dictionary = _manager.get_pending_at(Vector2i(3, 3), _player) + assert_false(pending.is_empty(), "Must return pending at position") + assert_eq(pending.get("type", ""), "farm", "Pending type must match") + + +func test_get_pending_at_returns_empty_for_none() -> void: + var pending: Dictionary = _manager.get_pending_at(Vector2i(7, 7), _player) + assert_true( + pending.is_empty(), + "Must return empty dict when no pending at position" + ) + + +# --------------------------------------------------------------------------- +# Duplicate build prevention +# --------------------------------------------------------------------------- + +func test_no_duplicate_build_at_same_tile() -> void: + var eng1: UnitScript = _make_engineer(Vector2i(3, 3)) + _manager.start_improvement(eng1, "farm", _player) + + var eng2: UnitScript = _make_engineer(Vector2i(3, 3)) + eng2.id = "eng_dup" + var buildable: Array[Dictionary] = _manager.get_buildable_improvements( + eng2, _game_map, _player + ) + + assert_eq( + buildable.size(), 0, + "Must not allow building where a pending improvement exists" + ) diff --git a/src/game/engine/tests/unit/test_improvement_manager.gd.uid b/src/game/engine/tests/unit/test_improvement_manager.gd.uid new file mode 100644 index 00000000..3d071f0e --- /dev/null +++ b/src/game/engine/tests/unit/test_improvement_manager.gd.uid @@ -0,0 +1 @@ +uid://b3wax66cs7lmy