test(city-systems): Add/update unit tests for bridge logic, site scoring, and improvement management

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-07 17:50:40 -07:00
parent e5ce018ff1
commit 07b7bd4476
5 changed files with 596 additions and 0 deletions

View file

@ -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"))

View file

@ -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"
)

View file

@ -0,0 +1 @@
uid://buq3qpgkvg68w

View file

@ -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"
)

View file

@ -0,0 +1 @@
uid://b3wax66cs7lmy