test(game-engine): Update unit tests for population stability, save manager, species generation, tech tree, and victory conditions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-07 17:50:43 -07:00
parent d3d6dccd22
commit b359e10d58
8 changed files with 612 additions and 0 deletions

View file

@ -0,0 +1 @@
uid://42sl40oguaro

View file

@ -0,0 +1,234 @@
extends GutTest
## SaveManager unit tests.
## Verifies save/load round-trip, slot listing, and edge cases.
const SaveManagerScript: GDScript = preload("res://engine/src/core/save_manager.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")
const TEST_SLOT: String = "_gut_test_slot"
func before_all() -> void:
DataLoader.load_theme("age-of-dwarves")
func after_each() -> void:
## Clean up any test save files after each test.
SaveManagerScript.delete_save(TEST_SLOT)
GameState.players = []
GameState.layers = []
GameState.turn_number = 1
GameState.current_player_index = 0
func _make_player(idx: int, gold_amount: int, race: String) -> RefCounted:
var player: PlayerScript = PlayerScript.new()
player.index = idx
player.player_name = "Test Player %d" % idx
player.race_id = race
player.is_player_controlled = true
player.gold = gold_amount
player.gold_per_turn = 10
player.science_per_turn = 5
player.culture_per_turn = 3
player.culture_total = 42
player.happiness = 7
return player
func _make_city(
owner_idx: int, pos: Vector2i, city_name: String, pop: int,
) -> CityScript:
var city: CityScript = CityScript.new()
city.id = "%d_%d_%d" % [owner_idx, pos.x, pos.y]
city.city_name = city_name
city.owner = owner_idx
city.position = pos
city.population = pop
city.owned_tiles = [pos]
return city
func _make_unit(owner_idx: int, type_id: String, pos: Vector2i) -> UnitScript:
var unit: UnitScript = UnitScript.new()
unit.id = "u_%d_%d_%d" % [owner_idx, pos.x, pos.y]
unit.type_id = type_id
unit.owner = owner_idx
unit.position = pos
unit.hp = 10
unit.max_hp = 10
return unit
func _make_game_map() -> GameMapScript:
var game_map: GameMapScript = GameMapScript.new()
game_map.initialize(10, 10, 0)
for col: int in range(10):
for row: int in range(10):
var tile: TileScript = TileScript.new()
tile.position = Vector2i(col, row)
tile.biome_id = "plains"
game_map.set_tile(tile.position, tile)
return game_map
func _setup_game_state_with_map() -> void:
## Set up GameState with a single layer containing a 10x10 map.
var game_map: GameMapScript = _make_game_map()
GameState.layers = [{"game_map": game_map, "cities": [], "units": []}]
func test_save_game_writes_file() -> void:
## save_game() must write a file and return true.
var player: RefCounted = _make_player(0, 100, "dwarf")
GameState.players = [player]
_setup_game_state_with_map()
var success: bool = SaveManagerScript.save_game(TEST_SLOT)
assert_true(success, "save_game must return true on success")
var path: String = SaveManagerScript.SAVE_DIR + TEST_SLOT + SaveManagerScript.SAVE_EXTENSION
assert_true(
FileAccess.file_exists(path),
"Save file must exist at '%s'" % path,
)
func test_load_game_reads_back() -> void:
## load_game() must restore GameState from a previously saved file.
var player: RefCounted = _make_player(0, 250, "dwarf")
GameState.players = [player]
_setup_game_state_with_map()
GameState.turn_number = 15
SaveManagerScript.save_game(TEST_SLOT)
## Wipe state to verify load restores it.
GameState.players = []
GameState.turn_number = 1
GameState.layers = []
var success: bool = SaveManagerScript.load_game(TEST_SLOT)
assert_true(success, "load_game must return true")
assert_eq(GameState.turn_number, 15, "Turn number must be restored")
assert_eq(
GameState.players.size(), 1,
"Must restore exactly one player",
)
var restored: RefCounted = GameState.players[0]
assert_eq(restored.gold, 250, "Player gold must round-trip")
assert_eq(restored.race_id, "dwarf", "Player race_id must round-trip")
func test_round_trip_gold_cities_units() -> void:
## Full round-trip: Player with gold=500, 2 cities, 3 units.
var player: RefCounted = _make_player(0, 500, "terrans")
var city_a: CityScript = _make_city(0, Vector2i(2, 3), "Alpha", 5)
var city_b: CityScript = _make_city(0, Vector2i(5, 6), "Beta", 3)
player.cities = [city_a, city_b]
var unit_a: UnitScript = _make_unit(0, "dwarf_warrior", Vector2i(1, 1))
var unit_b: UnitScript = _make_unit(0, "dwarf_warrior", Vector2i(2, 2))
var unit_c: UnitScript = _make_unit(0, "dwarf_warrior", Vector2i(3, 3))
player.units = [unit_a, unit_b, unit_c]
GameState.players = [player]
GameState.turn_number = 42
_setup_game_state_with_map()
SaveManagerScript.save_game(TEST_SLOT)
## Wipe and reload.
GameState.players = []
GameState.turn_number = 1
GameState.layers = []
SaveManagerScript.load_game(TEST_SLOT)
var restored: RefCounted = GameState.players[0]
assert_eq(restored.gold, 500, "Gold must be 500 after round-trip")
assert_eq(restored.units.size(), 3, "Must restore 3 units")
assert_eq(restored.player_name, "Test Player 0", "Player name must round-trip")
func test_get_save_slots_returns_saved_slot() -> void:
## After saving, get_save_slots() must include our test slot.
var player: RefCounted = _make_player(0, 100, "dwarf")
GameState.players = [player]
_setup_game_state_with_map()
SaveManagerScript.save_game(TEST_SLOT)
var slots: Array[Dictionary] = SaveManagerScript.get_save_slots()
var found: bool = false
for slot: Dictionary in slots:
if slot.get("slot", "") == TEST_SLOT:
found = true
break
assert_true(found, "get_save_slots must include the test slot")
func test_save_with_no_players_succeeds() -> void:
## Saving with empty game state must not crash.
GameState.players = []
_setup_game_state_with_map()
var success: bool = SaveManagerScript.save_game(TEST_SLOT)
assert_true(success, "Saving with empty player list must succeed")
func test_load_nonexistent_slot_fails() -> void:
## Loading a slot that was never saved must return false.
var success: bool = SaveManagerScript.load_game("_gut_nonexistent_slot")
assert_false(success, "Loading a nonexistent save must return false")
func test_delete_save_removes_file() -> void:
## delete_save() must remove the file, and subsequent load must fail.
var player: RefCounted = _make_player(0, 100, "dwarf")
GameState.players = [player]
_setup_game_state_with_map()
SaveManagerScript.save_game(TEST_SLOT)
var deleted: bool = SaveManagerScript.delete_save(TEST_SLOT)
assert_true(deleted, "delete_save must return true for existing slot")
var load_result: bool = SaveManagerScript.load_game(TEST_SLOT)
assert_false(load_result, "Loading deleted save must fail")
func test_slot_number_convenience() -> void:
## save_slot() / load_slot() must work with numeric slot indices.
var player: RefCounted = _make_player(0, 777, "orc")
GameState.players = [player]
_setup_game_state_with_map()
var save_ok: bool = SaveManagerScript.save_slot(0)
assert_true(save_ok, "save_slot(0) must succeed")
GameState.players = []
GameState.layers = []
var load_ok: bool = SaveManagerScript.load_slot(0)
assert_true(load_ok, "load_slot(0) must succeed")
assert_eq(GameState.players[0].gold, 777, "Gold must round-trip through slot 0")
## Cleanup numbered slot.
SaveManagerScript.delete_save("slot_0")
func test_invalid_slot_number_rejected() -> void:
## Slot numbers outside [0, MAX_SLOTS) must fail.
assert_false(
SaveManagerScript.save_slot(-1),
"Negative slot index must be rejected",
)
assert_false(
SaveManagerScript.save_slot(SaveManagerScript.MAX_SLOTS),
"Slot index >= MAX_SLOTS must be rejected",
)

View file

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

View file

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

View file

@ -0,0 +1,151 @@
extends GutTest
## Tech Web unit tests.
## Verifies prerequisite graph construction, availability logic, and the
## masonry → walls unlock path.
##
## Acceptance criterion: "Research 'masonry' → city Walls becomes buildable."
const TechWebScript: GDScript = preload("res://engine/src/modules/tech/tech_web.gd")
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
var _tech_web: TechWebScript = null
var _player: PlayerScript = null
func before_all() -> void:
DataLoader.load_theme("age-of-dwarves")
func before_each() -> void:
_tech_web = TechWebScript.new()
_tech_web.initialize()
_player = PlayerScript.new()
_player.index = 0
_player.race_id = "dwarf"
_player.researched_techs = []
_player.researching = ""
_player.research_progress = 0
_player.schools = []
GameState.players = [_player]
func test_tech_web_loads_techs() -> void:
assert_gt(_tech_web.get_tech_count(), 0, "TechWeb must load at least one tech")
func test_can_research_starter_techs() -> void:
## Tier-1 techs with no prerequisites must be available at game start
## regardless of what other techs exist.
var available: Array[String] = _tech_web.get_available_techs(0)
assert_false(available.is_empty(), "At least one tech must be available at game start")
## Every available tech must have all prerequisites satisfied (vacuously for
## starter techs, which have an empty requires list).
for tech_id: String in available:
var prereqs: Array[String] = _tech_web.get_prereqs(tech_id)
for prereq_id: String in prereqs:
assert_true(
_player.has_tech(prereq_id),
"Available tech '%s' has unmet prereq '%s'" % [tech_id, prereq_id]
)
## Stonecutting is the masonry pillar's tier-1 tech — must be available.
assert_true(
_tech_web.can_research("stonecutting", 0),
"'stonecutting' must be available with no techs researched"
)
func test_locked_tech_not_available_without_prereq() -> void:
## 'masonry' requires 'stonecutting' — must not be researchable before stonecutting.
assert_false(
_tech_web.can_research("masonry", 0),
"'masonry' must be locked until 'stonecutting' is researched"
)
func test_masonry_available_after_stonecutting() -> void:
_player.add_tech("stonecutting")
assert_true(
_tech_web.can_research("masonry", 0),
"'masonry' must be available once 'stonecutting' is researched"
)
func test_masonry_unlocks_walls() -> void:
## Core acceptance criterion: Research 'masonry' → city Walls becomes buildable.
var city: CityScript = CityScript.new()
city.owner = 0
city.buildings = []
## Before masonry: walls must not be buildable.
assert_false(
city.can_build("walls", _player),
"'walls' must not be buildable before 'masonry' is researched"
)
## Research both stonecutting (prerequisite) and masonry.
_player.add_tech("stonecutting")
_player.add_tech("masonry")
## After masonry: walls must be buildable.
assert_true(
city.can_build("walls", _player),
"'walls' must be buildable after 'masonry' is researched"
)
func test_start_research_sets_current() -> void:
var success: bool = _tech_web.start_research("stonecutting", 0)
assert_true(success, "start_research must succeed for an available tech")
assert_eq(_player.researching, "stonecutting", "researching field must be set")
assert_eq(_player.research_progress, 0, "research_progress must reset to 0")
func test_add_science_completes_research() -> void:
_tech_web.start_research("stonecutting", 0)
var tech_data: Dictionary = _tech_web.get_tech_data("stonecutting")
var cost: int = tech_data.get("cost", 0)
assert_gt(cost, 0, "stonecutting must have a positive cost")
_tech_web.add_science(cost, 0)
assert_true(
_player.has_tech("stonecutting"),
"Player must have stonecutting after full science input"
)
assert_eq(_player.researching, "", "researching must be cleared after completion")
func test_progress_fraction_reflects_input() -> void:
_tech_web.start_research("stonecutting", 0)
var tech_data: Dictionary = _tech_web.get_tech_data("stonecutting")
var cost: int = tech_data.get("cost", 1)
_tech_web.add_science(cost / 2, 0)
var fraction: float = _tech_web.get_progress_fraction(0)
assert_gt(fraction, 0.0, "Progress fraction must be above 0 after partial science")
assert_lt(fraction, 1.0, "Progress fraction must be below 1 when research is incomplete")
func test_get_turns_remaining() -> void:
_tech_web.start_research("stonecutting", 0)
var tech_data: Dictionary = _tech_web.get_tech_data("stonecutting")
var cost: int = tech_data.get("cost", 1)
var science_per_turn: int = 5
var turns: int = _tech_web.get_turns_remaining(0, science_per_turn)
var expected: int = ceili(float(cost) / float(science_per_turn))
assert_eq(turns, expected, "get_turns_remaining must match ceil(cost / spt) formula")
func test_overflow_carries_to_next() -> void:
## Research stonecutting with excess science — overflow must carry to research_progress.
_tech_web.start_research("stonecutting", 0)
var cost: int = _tech_web.get_tech_data("stonecutting").get("cost", 20)
var overflow: int = 7
_tech_web.add_science(cost + overflow, 0)
assert_true(_player.has_tech("stonecutting"), "stonecutting must complete")
assert_eq(_player.research_progress, overflow, "Overflow science must carry forward")

View file

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

View file

@ -0,0 +1,222 @@
extends GutTest
## VictoryManager unit tests.
## Verifies score calculation, domination detection, and score-leader logic.
const VictoryManagerScript: GDScript = preload(
"res://engine/src/modules/victory/victory_manager.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 _vm: VictoryManagerScript = null
var _game_map: GameMapScript = null
func before_all() -> void:
DataLoader.load_theme("age-of-dwarves")
func before_each() -> void:
_vm = VictoryManagerScript.new()
_game_map = GameMapScript.new()
_game_map.initialize(10, 10, 0)
for col: int in range(10):
for row: int in range(10):
var tile: TileScript = TileScript.new()
tile.position = Vector2i(col, row)
tile.biome_id = "plains"
_game_map.set_tile(tile.position, tile)
GameState.players = []
GameState.game_settings = {
"victory_domination": true,
"victory_score": true,
"turn_limit_enabled": false,
"turn_limit": 300,
}
GameState.turn_number = 1
func after_each() -> void:
GameState.players = []
GameState.turn_number = 1
func _make_player(idx: int) -> PlayerScript:
var player: PlayerScript = PlayerScript.new()
player.index = idx
player.player_name = "Player %d" % idx
player.race_id = "dwarf"
player.is_player_controlled = (idx == 0)
player.researched_techs = []
return player
func _make_city(owner_idx: int, pos: Vector2i, pop: int) -> CityScript:
var city: CityScript = CityScript.new()
city.id = "%d_%d_%d" % [owner_idx, pos.x, pos.y]
city.city_name = "City_%d" % owner_idx
city.owner = owner_idx
city.position = pos
city.population = pop
city.owned_tiles = [pos]
return city
func _make_unit(owner_idx: int, pos: Vector2i) -> UnitScript:
var unit: UnitScript = UnitScript.new()
unit.id = "u_%d_%d_%d" % [owner_idx, pos.x, pos.y]
unit.type_id = "dwarf_warrior"
unit.owner = owner_idx
unit.position = pos
return unit
func test_score_positive_for_player_with_cities_and_units() -> void:
## get_score() must return a positive value for a player with cities + units.
var p: PlayerScript = _make_player(0)
var city: CityScript = _make_city(0, Vector2i(2, 2), 3)
p.cities = [city]
var unit: UnitScript = _make_unit(0, Vector2i(1, 1))
p.units = [unit]
p.researched_techs = ["stonecutting"]
GameState.players = [p]
var score: int = _vm.get_score(0)
assert_gt(score, 0, "Score must be positive for player with cities, units, and techs")
## Verify formula: cities*10 + pop*2 + techs*5 + units*1
var expected: int = (
1 * VictoryManagerScript.SCORE_CITY
+ 3 * VictoryManagerScript.SCORE_POP
+ 1 * VictoryManagerScript.SCORE_TECH
+ 1 * VictoryManagerScript.SCORE_UNIT
)
assert_eq(score, expected, "Score must match formula: %d" % expected)
func test_score_zero_for_empty_player() -> void:
## A player with no cities, units, or techs must score 0.
var p: PlayerScript = _make_player(0)
GameState.players = [p]
var score: int = _vm.get_score(0)
assert_eq(score, 0, "Empty player must score 0")
func test_check_victory_returns_empty_when_game_ongoing() -> void:
## With two active players both holding cities, no victory condition is met.
var p0: PlayerScript = _make_player(0)
var p1: PlayerScript = _make_player(1)
p0.cities = [_make_city(0, Vector2i(1, 1), 2)]
p1.cities = [_make_city(1, Vector2i(5, 5), 2)]
GameState.players = [p0, p1]
var result: String = _vm.check_victory(0, _game_map)
assert_eq(result, "", "No victory condition should be met during ongoing game")
func test_domination_all_cities() -> void:
## Domination: player 0 controls all cities (player 1 has none left).
var p0: PlayerScript = _make_player(0)
var p1: PlayerScript = _make_player(1)
p0.cities = [_make_city(0, Vector2i(2, 2), 5)]
p1.cities = [] ## All cities captured
GameState.players = [p0, p1]
var result: String = _vm.check_victory(0, _game_map)
assert_eq(result, "domination", "Player controlling all cities must trigger domination")
func test_domination_land_majority() -> void:
## Domination: player 0 owns > 80% of all land tiles.
var p0: PlayerScript = _make_player(0)
var p1: PlayerScript = _make_player(1)
p0.cities = [_make_city(0, Vector2i(0, 0), 1)]
p1.cities = [_make_city(1, Vector2i(9, 9), 1)]
GameState.players = [p0, p1]
## Assign > 80% of tiles to player 0.
var total_tiles: int = _game_map.tiles.size()
var threshold_count: int = int(total_tiles * 0.85) ## well above 80%
var assigned: int = 0
for tile: Variant in _game_map.tiles.values():
if assigned < threshold_count:
tile.owner = 0
assigned += 1
else:
tile.owner = 1
var result: String = _vm.check_victory(0, _game_map)
assert_eq(result, "domination", "Player owning >80% land must trigger domination")
func test_no_domination_below_threshold() -> void:
## Owning exactly 50% of tiles must NOT trigger domination.
var p0: PlayerScript = _make_player(0)
var p1: PlayerScript = _make_player(1)
p0.cities = [_make_city(0, Vector2i(0, 0), 1)]
p1.cities = [_make_city(1, Vector2i(9, 9), 1)]
GameState.players = [p0, p1]
var total_tiles: int = _game_map.tiles.size()
var half: int = total_tiles / 2
var assigned: int = 0
for tile: Variant in _game_map.tiles.values():
if assigned < half:
tile.owner = 0
else:
tile.owner = 1
assigned += 1
var result: String = _vm.check_victory(0, _game_map)
assert_eq(result, "", "50% land ownership must not trigger domination")
func test_score_leader_wins_at_turn_limit() -> void:
## When turn limit is reached, the highest-scoring player wins via "score".
var p0: PlayerScript = _make_player(0)
var p1: PlayerScript = _make_player(1)
p0.cities = [_make_city(0, Vector2i(1, 1), 10)]
p0.researched_techs = ["stonecutting", "masonry"]
p0.units = [_make_unit(0, Vector2i(2, 2))]
p1.cities = [_make_city(1, Vector2i(5, 5), 1)]
p1.researched_techs = []
p1.units = []
GameState.players = [p0, p1]
GameState.game_settings["turn_limit_enabled"] = true
GameState.game_settings["turn_limit"] = 100
GameState.turn_number = 101 ## past the limit
var result_p0: String = _vm.check_victory(0, _game_map)
assert_eq(result_p0, "score", "Score leader must win when turn limit is exceeded")
var result_p1: String = _vm.check_victory(1, _game_map)
assert_eq(result_p1, "", "Non-leader must not win via score")
func test_get_scores_multiple_players() -> void:
## get_scores() must return a dictionary keyed by player index.
var p0: PlayerScript = _make_player(0)
var p1: PlayerScript = _make_player(1)
p0.cities = [_make_city(0, Vector2i(1, 1), 5)]
p0.units = [_make_unit(0, Vector2i(2, 2))]
p1.cities = [_make_city(1, Vector2i(5, 5), 2)]
GameState.players = [p0, p1]
var scores: Dictionary = _vm.get_scores()
assert_has(scores, 0, "Scores dict must have key for player 0")
assert_has(scores, 1, "Scores dict must have key for player 1")
assert_gt(scores[0], scores[1], "Player 0 must outscore player 1")
func test_score_invalid_player_returns_zero() -> void:
## get_score() for a non-existent player index must return 0.
GameState.players = []
var score: int = _vm.get_score(99)
assert_eq(score, 0, "Score for non-existent player must be 0")

View file

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