feat(@projects/@magic-civilization): ✨ add turn-based snapshot and victory system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
9bf2e4d1b1
commit
bdeb6ff454
10 changed files with 971 additions and 63 deletions
|
|
@ -72,3 +72,7 @@ Mass retirement: 5 agents shutdown. Synced wild fix to apricot, kicking fresh 10
|
|||
2026-04-16 17:03 TEST COVERAGE MANDATE escalated by user: "all code covered", "all business logic tested", "all e2e and integration logic tested". Memory updated. Every spawn from now on includes test-coverage acceptance gate. New spawns: json-schema-dev (#70 data), gut-expansion-dev (#71 city/turn/combat GUT), crash-e2e-dev (#72 seed-5 crash + E2E gate), gpu-b2-dev (#73 WGSL fauna kernel), mcts-a2-dev (#74 MCTS wired to real state-advance).
|
||||
2026-04-16 17:03 CRITICAL FIND: verify batch exit-zeroed while seed 5 crashed mid-game. autoplay-batch.sh wrapper did not fail loud. crash-e2e-dev spawned to fix both root cause (auto_play.gd:1737) AND the gate masking.
|
||||
2026-04-16 17:03 VERIFY BATCH (9 of 10 good, seed 5 crashed): victory rate 3/10 — slight regression from prior thorough 4/10. Hypothesis: removing wild-player combat (post wild-distance fix) lost a tempo disruptor for certain seeds. Not re-tuning until crash fixed + rerun clean.
|
||||
2026-04-16 17:10 WAVE TWO COMPLETIONS:
|
||||
- #70 json-schema-dev: 8 schemas (unit/building/tech/terrain/improvement/race/wilds/ai_personality), validator at tools/validate-game-data.py, wired into ./run verify. 109 entries validated clean; caught latent spearmen.json legacy-schema bug as a byproduct. ~220 LOC.
|
||||
- #71 gut-expansion-dev: 34 new GUT tests across test_city (14), test_turn_processor (10), test_combat_bridge (12). Total suite now 119 tests, 107 pass, 1 pre-existing failure (iron_axe, unchanged), 11 pending. GDScript coverage on business logic materially closer to complete.
|
||||
Test-coverage mandate response is paying off: data changes, city state transitions, and combat bridge math now have unit-level protection separate from end-to-end batches.
|
||||
|
|
|
|||
274
src/game/engine/tests/integration/test_event_bus_signals.gd
Normal file
274
src/game/engine/tests/integration/test_event_bus_signals.gd
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
extends GutTest
|
||||
## T15: EventBus payload correctness.
|
||||
##
|
||||
## Locks the signal-parameter contract for three high-traffic EventBus
|
||||
## signals: unit_moved, city_founded, tech_researched. The engine emits
|
||||
## these from multiple producers (movement, AI tactical, turn processor,
|
||||
## world_map, ai_turn_bridge) — any drift in parameter order/shape would
|
||||
## silently break every listener downstream.
|
||||
##
|
||||
## Strategy: drive the signals via their two canonical paths —
|
||||
## (1) direct `EventBus.<sig>.emit(...)` (what production code does),
|
||||
## (2) a connected listener that records payloads for shape assertions.
|
||||
## We do NOT rely on the Rust turn processor here; that shape is locked
|
||||
## separately in `test_gd_turn_processor.gd`. The EventBus itself is the
|
||||
## contract under test.
|
||||
|
||||
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
|
||||
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
|
||||
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-test scaffolding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
var _captured: Array[Dictionary] = []
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
DataLoader.load_theme("age-of-dwarves")
|
||||
_captured = []
|
||||
watch_signals(EventBus)
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
# Break listener connections so the recorder doesn't leak between tests.
|
||||
for conn: Dictionary in EventBus.unit_moved.get_connections():
|
||||
EventBus.unit_moved.disconnect(conn.get("callable"))
|
||||
for conn: Dictionary in EventBus.city_founded.get_connections():
|
||||
EventBus.city_founded.disconnect(conn.get("callable"))
|
||||
for conn: Dictionary in EventBus.tech_researched.get_connections():
|
||||
EventBus.tech_researched.disconnect(conn.get("callable"))
|
||||
_captured = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _record_unit_moved(unit: Variant, from: Vector2i, to: Vector2i) -> void:
|
||||
_captured.append({
|
||||
"signal": "unit_moved",
|
||||
"unit": unit,
|
||||
"from": from,
|
||||
"to": to,
|
||||
})
|
||||
|
||||
|
||||
func _record_city_founded(city: Variant, player_index: int) -> void:
|
||||
_captured.append({
|
||||
"signal": "city_founded",
|
||||
"city": city,
|
||||
"player_index": player_index,
|
||||
})
|
||||
|
||||
|
||||
func _record_tech_researched(tech_id: String, player_index: int) -> void:
|
||||
_captured.append({
|
||||
"signal": "tech_researched",
|
||||
"tech_id": tech_id,
|
||||
"player_index": player_index,
|
||||
})
|
||||
|
||||
|
||||
func _make_player(index: int) -> RefCounted:
|
||||
var p: RefCounted = PlayerScript.new()
|
||||
p.index = index
|
||||
p.player_name = "Player %d" % index
|
||||
p.race_id = "dwarf"
|
||||
p.units = []
|
||||
p.cities = []
|
||||
return p
|
||||
|
||||
|
||||
func _make_unit(type_id: String, owner_index: int, at: Vector2i) -> RefCounted:
|
||||
# populate_from_data=false avoids DataLoader dependence for arbitrary ids.
|
||||
var u: RefCounted = UnitScript.new(type_id, owner_index, at, false)
|
||||
u.id = "unit_%d_%d_%d" % [owner_index, at.x, at.y]
|
||||
return u
|
||||
|
||||
|
||||
func _make_city(city_id: String, at: Vector2i, founder_index: int) -> RefCounted:
|
||||
var c: RefCounted = CityScript.new(city_id, [] as Array[String])
|
||||
c.owner = founder_index
|
||||
c.found("Testhold", at.x, at.y, true, 1)
|
||||
return c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# unit_moved
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func test_unit_moved_payload_shape() -> void:
|
||||
EventBus.unit_moved.connect(_record_unit_moved)
|
||||
|
||||
var unit: RefCounted = _make_unit("dwarf_warrior", 0, Vector2i(3, 4))
|
||||
var from: Vector2i = Vector2i(3, 4)
|
||||
var to: Vector2i = Vector2i(4, 4)
|
||||
unit.position = to
|
||||
EventBus.unit_moved.emit(unit, from, to)
|
||||
|
||||
assert_signal_emitted(EventBus, "unit_moved",
|
||||
"unit_moved must fire when emitted")
|
||||
|
||||
assert_eq(_captured.size(), 1,
|
||||
"listener must capture exactly one unit_moved event")
|
||||
|
||||
var evt: Dictionary = _captured[0]
|
||||
assert_eq(evt.get("signal", ""), "unit_moved",
|
||||
"captured signal name must be 'unit_moved'")
|
||||
assert_same(evt.get("unit", null), unit,
|
||||
"unit_moved arg 0 must be the moving unit RefCounted")
|
||||
assert_typeof(evt.get("from", null), TYPE_VECTOR2I)
|
||||
assert_typeof(evt.get("to", null), TYPE_VECTOR2I)
|
||||
assert_eq(evt.get("from", Vector2i.ZERO), from,
|
||||
"unit_moved arg 1 must be the origin hex as Vector2i")
|
||||
assert_eq(evt.get("to", Vector2i.ZERO), to,
|
||||
"unit_moved arg 2 must be the destination hex as Vector2i")
|
||||
|
||||
# Parameter-order lock via Gut's watcher as well.
|
||||
var params: Array = get_signal_parameters(EventBus, "unit_moved")
|
||||
assert_eq(params.size(), 3,
|
||||
"unit_moved must have exactly 3 parameters: (unit, from, to)")
|
||||
assert_same(params[0], unit, "watcher param 0 must be the unit")
|
||||
assert_eq(params[1], from, "watcher param 1 must be `from`")
|
||||
assert_eq(params[2], to, "watcher param 2 must be `to`")
|
||||
|
||||
|
||||
func test_unit_moved_fires_once_per_emit() -> void:
|
||||
EventBus.unit_moved.connect(_record_unit_moved)
|
||||
|
||||
var unit: RefCounted = _make_unit("dwarf_warrior", 0, Vector2i(0, 0))
|
||||
EventBus.unit_moved.emit(unit, Vector2i(0, 0), Vector2i(1, 0))
|
||||
EventBus.unit_moved.emit(unit, Vector2i(1, 0), Vector2i(2, 0))
|
||||
|
||||
assert_eq(_captured.size(), 2,
|
||||
"two emits must produce two captured events (no coalescing, no drops)")
|
||||
assert_eq(_captured[0].get("to", Vector2i.ZERO), Vector2i(1, 0),
|
||||
"first event must carry first destination")
|
||||
assert_eq(_captured[1].get("to", Vector2i.ZERO), Vector2i(2, 0),
|
||||
"second event must carry second destination")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# city_founded
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func test_city_founded_payload_shape() -> void:
|
||||
EventBus.city_founded.connect(_record_city_founded)
|
||||
|
||||
var player: RefCounted = _make_player(0)
|
||||
var city: RefCounted = _make_city("city_0", Vector2i(5, 5), 0)
|
||||
city.player = player
|
||||
player.cities.append(city)
|
||||
EventBus.city_founded.emit(city, player.index)
|
||||
|
||||
assert_signal_emitted(EventBus, "city_founded",
|
||||
"city_founded must fire when emitted")
|
||||
|
||||
assert_eq(_captured.size(), 1,
|
||||
"listener must capture exactly one city_founded event")
|
||||
|
||||
var evt: Dictionary = _captured[0]
|
||||
assert_eq(evt.get("signal", ""), "city_founded",
|
||||
"captured signal name must be 'city_founded'")
|
||||
assert_same(evt.get("city", null), city,
|
||||
"city_founded arg 0 must be the City RefCounted")
|
||||
assert_typeof(evt.get("player_index", -999), TYPE_INT)
|
||||
assert_eq(evt.get("player_index", -999), 0,
|
||||
"city_founded arg 1 must be the founder's player_index (0)")
|
||||
|
||||
var params: Array = get_signal_parameters(EventBus, "city_founded")
|
||||
assert_eq(params.size(), 2,
|
||||
"city_founded must have exactly 2 parameters: (city, player_index)")
|
||||
assert_same(params[0], city, "watcher param 0 must be the city")
|
||||
assert_eq(params[1], 0, "watcher param 1 must be player_index 0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tech_researched
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func test_tech_researched_payload_shape() -> void:
|
||||
EventBus.tech_researched.connect(_record_tech_researched)
|
||||
|
||||
EventBus.tech_researched.emit("heritage", 0)
|
||||
|
||||
assert_signal_emitted(EventBus, "tech_researched",
|
||||
"tech_researched must fire when emitted")
|
||||
|
||||
assert_eq(_captured.size(), 1,
|
||||
"listener must capture exactly one tech_researched event")
|
||||
|
||||
var evt: Dictionary = _captured[0]
|
||||
assert_eq(evt.get("signal", ""), "tech_researched",
|
||||
"captured signal name must be 'tech_researched'")
|
||||
assert_typeof(evt.get("tech_id", null), TYPE_STRING)
|
||||
assert_typeof(evt.get("player_index", -999), TYPE_INT)
|
||||
assert_eq(evt.get("tech_id", ""), "heritage",
|
||||
"tech_researched arg 0 must be the tech_id String")
|
||||
assert_eq(evt.get("player_index", -999), 0,
|
||||
"tech_researched arg 1 must be the researcher's player_index")
|
||||
|
||||
var params: Array = get_signal_parameters(EventBus, "tech_researched")
|
||||
assert_eq(params.size(), 2,
|
||||
"tech_researched must have exactly 2 parameters: (tech_id, player_index)")
|
||||
assert_eq(params[0], "heritage", "watcher param 0 must be tech_id")
|
||||
assert_eq(params[1], 0, "watcher param 1 must be player_index 0")
|
||||
|
||||
|
||||
func test_tech_researched_multiple_players_preserved() -> void:
|
||||
EventBus.tech_researched.connect(_record_tech_researched)
|
||||
|
||||
EventBus.tech_researched.emit("metallurgy", 1)
|
||||
EventBus.tech_researched.emit("scholarship", 2)
|
||||
|
||||
assert_eq(_captured.size(), 2,
|
||||
"two tech_researched emits must produce two captures")
|
||||
assert_eq(_captured[0].get("tech_id", ""), "metallurgy",
|
||||
"first capture must carry first tech_id")
|
||||
assert_eq(_captured[0].get("player_index", -1), 1,
|
||||
"first capture must carry player_index 1")
|
||||
assert_eq(_captured[1].get("tech_id", ""), "scholarship",
|
||||
"second capture must carry second tech_id")
|
||||
assert_eq(_captured[1].get("player_index", -1), 2,
|
||||
"second capture must carry player_index 2")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-signal ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func test_mixed_signal_sequence_preserves_order() -> void:
|
||||
## A mini turn: found a city, move a unit, research a tech. The listener
|
||||
## must observe all three in emission order with correct payload shape
|
||||
## for each — this is the contract every UI panel and AI heuristic
|
||||
## depends on when subscribing to EventBus.
|
||||
EventBus.unit_moved.connect(_record_unit_moved)
|
||||
EventBus.city_founded.connect(_record_city_founded)
|
||||
EventBus.tech_researched.connect(_record_tech_researched)
|
||||
|
||||
var player: RefCounted = _make_player(0)
|
||||
var city: RefCounted = _make_city("city_0", Vector2i(5, 5), 0)
|
||||
city.player = player
|
||||
var unit: RefCounted = _make_unit("dwarf_warrior", 0, Vector2i(5, 5))
|
||||
|
||||
EventBus.city_founded.emit(city, player.index)
|
||||
EventBus.unit_moved.emit(unit, Vector2i(5, 5), Vector2i(6, 5))
|
||||
EventBus.tech_researched.emit("heritage", player.index)
|
||||
|
||||
assert_eq(_captured.size(), 3,
|
||||
"three distinct EventBus emits must produce three captures")
|
||||
assert_eq(_captured[0].get("signal", ""), "city_founded",
|
||||
"first captured signal must be city_founded")
|
||||
assert_eq(_captured[1].get("signal", ""), "unit_moved",
|
||||
"second captured signal must be unit_moved")
|
||||
assert_eq(_captured[2].get("signal", ""), "tech_researched",
|
||||
"third captured signal must be tech_researched")
|
||||
|
|
@ -17,6 +17,8 @@ extends GutTest
|
|||
|
||||
const SaveManagerScript: GDScript = preload("res://engine/src/core/save_manager.gd")
|
||||
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
|
||||
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
|
|
@ -237,6 +239,206 @@ func test_save_then_load_restores_research_and_magic_fields() -> void:
|
|||
assert_eq(r.golden_age_count, 1, "golden_age_count restored")
|
||||
|
||||
|
||||
## ── round-trip: Unit infusions + equipped_items ───────────────────────
|
||||
|
||||
func test_save_then_load_restores_unit_infusions_and_equipped_items() -> void:
|
||||
## Attach a Unit with infusions + equipped_items to a Player, round-trip,
|
||||
## and verify the unit-level fields survive. This test documents the
|
||||
## contract that per-player owned units must persist through SaveManager
|
||||
## — if Player.serialize ever stops carrying its `units` array, this
|
||||
## fails and the regression is caught at the save boundary.
|
||||
var unit: RefCounted = UnitScript.new("", 0, Vector2i(3, 4), false)
|
||||
unit.id = "unit_0_7"
|
||||
unit.unit_id = "dwarf_warrior"
|
||||
unit.type_id = "dwarf_warrior"
|
||||
unit.hp = 9
|
||||
unit.max_hp = 12
|
||||
unit.infusions = ["stoneskin", "haste"]
|
||||
unit.channeled_infusion = "shield_of_iron"
|
||||
unit.channeled_fade_turns = 2
|
||||
unit.equipped_items = [
|
||||
{"item_id": "iron_sword", "charges_remaining": 3},
|
||||
{"item_id": "healing_potion", "charges_remaining": 1},
|
||||
]
|
||||
GameState.players[0].units = [unit]
|
||||
|
||||
SaveManagerScript.save_game(0)
|
||||
|
||||
# Clear in-memory state so a failed load is visible.
|
||||
GameState.players[0].units = []
|
||||
|
||||
var err: Error = SaveManagerScript.load_game(0)
|
||||
assert_eq(err, OK, "load_game must succeed")
|
||||
var restored_units: Array = GameState.players[0].units
|
||||
assert_eq(restored_units.size(), 1, "player 0 retains one unit after round-trip")
|
||||
if restored_units.is_empty():
|
||||
return
|
||||
var r: RefCounted = restored_units[0]
|
||||
assert_eq(r.unit_id, "dwarf_warrior", "unit_id restored")
|
||||
assert_eq(r.position, Vector2i(3, 4), "unit position restored")
|
||||
assert_eq(r.hp, 9, "unit hp restored")
|
||||
assert_eq(r.max_hp, 12, "unit max_hp restored")
|
||||
assert_eq(r.infusions, ["stoneskin", "haste"], "unit infusions restored")
|
||||
assert_eq(r.channeled_infusion, "shield_of_iron", "channeled_infusion restored")
|
||||
assert_eq(r.channeled_fade_turns, 2, "channeled_fade_turns restored")
|
||||
assert_eq(r.equipped_items.size(), 2, "two equipped items restored")
|
||||
assert_eq(r.equipped_items[0].get("item_id"), "iron_sword", "first item id restored")
|
||||
assert_eq(
|
||||
r.equipped_items[0].get("charges_remaining"),
|
||||
3,
|
||||
"first item charges restored"
|
||||
)
|
||||
assert_eq(
|
||||
r.equipped_items[1].get("item_id"),
|
||||
"healing_potion",
|
||||
"second item id restored"
|
||||
)
|
||||
|
||||
|
||||
## ── round-trip: City production_queue + happiness ─────────────────────
|
||||
|
||||
func test_save_then_load_restores_city_production_queue_and_happiness() -> void:
|
||||
## Attach a City with a populated production_queue and per-city happiness
|
||||
## context to a Player, round-trip, and verify the fields survive. Per-
|
||||
## player happiness_breakdown is already covered by the research/magic
|
||||
## test, but the city-owned state (production_queue, buildings, focus)
|
||||
## is the regression surface this test pins down.
|
||||
var city: RefCounted = CityScript.new("city_0_0")
|
||||
city.city_name = "Khazad Anvil"
|
||||
city.is_capital = true
|
||||
city.owner = 0
|
||||
city.position = Vector2i(5, 5)
|
||||
city.turn_founded = 3
|
||||
city.production_queue = [
|
||||
{"type": "building", "id": "barracks", "cost": 60},
|
||||
{"type": "unit", "id": "dwarf_warrior", "cost": 40},
|
||||
]
|
||||
city.production_progress = 15
|
||||
city.buildings = ["palace", "granary"] as Array[String]
|
||||
|
||||
var p: RefCounted = GameState.players[0]
|
||||
p.cities = [city]
|
||||
p.happiness = 6
|
||||
p.happiness_status = "content"
|
||||
p.happiness_breakdown = {
|
||||
"total": 6,
|
||||
"status": "content",
|
||||
"city_unhappiness": 3,
|
||||
"citizen_unhappiness": 1,
|
||||
"building_happiness": 4,
|
||||
"luxury_happiness": 2,
|
||||
"war_weariness": 0,
|
||||
"ascension_penalty": 0,
|
||||
}
|
||||
|
||||
SaveManagerScript.save_game(0)
|
||||
|
||||
# Mutate so a failed load is visible.
|
||||
p.cities = []
|
||||
p.happiness = 0
|
||||
p.happiness_status = "revolt"
|
||||
p.happiness_breakdown = {}
|
||||
|
||||
var err: Error = SaveManagerScript.load_game(0)
|
||||
assert_eq(err, OK, "load_game must succeed")
|
||||
|
||||
var loaded_player: RefCounted = GameState.players[0]
|
||||
assert_eq(loaded_player.happiness, 6, "player happiness restored")
|
||||
assert_eq(loaded_player.happiness_status, "content", "happiness_status restored")
|
||||
assert_eq(
|
||||
int(loaded_player.happiness_breakdown.get("total", -1)),
|
||||
6,
|
||||
"happiness_breakdown.total restored"
|
||||
)
|
||||
assert_eq(
|
||||
int(loaded_player.happiness_breakdown.get("building_happiness", -1)),
|
||||
4,
|
||||
"happiness_breakdown.building_happiness restored"
|
||||
)
|
||||
|
||||
assert_eq(loaded_player.cities.size(), 1, "player retains one city after round-trip")
|
||||
if loaded_player.cities.is_empty():
|
||||
return
|
||||
var c: RefCounted = loaded_player.cities[0]
|
||||
assert_eq(c.city_name, "Khazad Anvil", "city_name restored")
|
||||
assert_true(c.is_capital, "is_capital restored")
|
||||
assert_eq(c.position, Vector2i(5, 5), "city position restored")
|
||||
assert_eq(c.turn_founded, 3, "turn_founded restored")
|
||||
assert_eq(c.production_queue.size(), 2, "production_queue length restored")
|
||||
if c.production_queue.size() >= 1:
|
||||
assert_eq(
|
||||
str(c.production_queue[0].get("id")),
|
||||
"barracks",
|
||||
"production_queue[0].id restored"
|
||||
)
|
||||
assert_eq(
|
||||
int(c.production_queue[0].get("cost", 0)),
|
||||
60,
|
||||
"production_queue[0].cost restored"
|
||||
)
|
||||
assert_eq(c.production_progress, 15, "production_progress restored")
|
||||
|
||||
|
||||
## ── round-trip: Player diplomacy + strategic_axes ─────────────────────
|
||||
|
||||
func test_save_then_load_restores_player_diplomacy_and_strategic_axes() -> void:
|
||||
## strategic_axes lives on Player.serialize; diplomacy is a GameState-
|
||||
## level Dictionary keyed by "min_idx_max_idx". Both must survive a
|
||||
## full SaveManager round-trip — this test pins the contract for the
|
||||
## diplomacy dict (regression risk if GameState.serialize ever drops
|
||||
## it) and for Player strategic_axes (regression risk if AI pipeline
|
||||
## rewrites the shape).
|
||||
var p0: RefCounted = GameState.players[0]
|
||||
var p1: RefCounted = GameState.players[1]
|
||||
p0.strategic_axes = {
|
||||
"economy": 0.8,
|
||||
"military": 0.3,
|
||||
"magic": 0.1,
|
||||
}
|
||||
p1.strategic_axes = {
|
||||
"economy": 0.2,
|
||||
"military": 0.9,
|
||||
"magic": 0.4,
|
||||
}
|
||||
GameState.diplomacy = {
|
||||
"0_1": "war",
|
||||
}
|
||||
|
||||
SaveManagerScript.save_game(0)
|
||||
|
||||
# Mutate so a failed load is visible.
|
||||
p0.strategic_axes = {}
|
||||
p1.strategic_axes = {}
|
||||
GameState.diplomacy = {}
|
||||
|
||||
var err: Error = SaveManagerScript.load_game(0)
|
||||
assert_eq(err, OK, "load_game must succeed")
|
||||
|
||||
var r0: RefCounted = GameState.players[0]
|
||||
var r1: RefCounted = GameState.players[1]
|
||||
assert_eq(r0.strategic_axes.size(), 3, "player 0 strategic_axes key count restored")
|
||||
assert_eq(
|
||||
float(r0.strategic_axes.get("economy", -1.0)),
|
||||
0.8,
|
||||
"player 0 strategic_axes.economy restored"
|
||||
)
|
||||
assert_eq(
|
||||
float(r0.strategic_axes.get("military", -1.0)),
|
||||
0.3,
|
||||
"player 0 strategic_axes.military restored"
|
||||
)
|
||||
assert_eq(
|
||||
float(r1.strategic_axes.get("military", -1.0)),
|
||||
0.9,
|
||||
"player 1 strategic_axes.military restored"
|
||||
)
|
||||
assert_eq(
|
||||
str(GameState.diplomacy.get("0_1", "")),
|
||||
"war",
|
||||
"diplomacy[0_1] restored"
|
||||
)
|
||||
|
||||
|
||||
## ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
func _seed_game_state() -> void:
|
||||
|
|
|
|||
|
|
@ -7,29 +7,11 @@
|
|||
//! All simulation logic lives in `mc-turn` and `mc-ai`. This file is a shim only.
|
||||
|
||||
use godot::prelude::*;
|
||||
use mc_ai::mcts_tree::{rollout_snapshot, Tree, TreeState};
|
||||
use mc_ai::mcts_tree::{rollout_snapshot, Tree};
|
||||
use mc_ai::mcts::XorShift64;
|
||||
use mc_turn::snapshot::{McAction, McSnapshot};
|
||||
use mc_turn::{GameState, TurnProcessor};
|
||||
|
||||
// ── TreeState impl for McSnapshot ────────────────────────────────────────────
|
||||
|
||||
impl TreeState for McSnapshot {
|
||||
type Action = McAction;
|
||||
|
||||
fn legal_actions(&self) -> Vec<McAction> {
|
||||
self.legal_actions()
|
||||
}
|
||||
|
||||
fn apply(&self, action: &McAction) -> McSnapshot {
|
||||
self.step(action)
|
||||
}
|
||||
|
||||
fn is_terminal(&self) -> bool {
|
||||
self.is_terminal()
|
||||
}
|
||||
}
|
||||
|
||||
// ── GdMcTreeController ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(GodotClass)]
|
||||
|
|
@ -214,7 +196,8 @@ impl GdMcTreeController {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use mc_ai::evaluator::ScoringWeights;
|
||||
use mc_turn::snapshot::{McAction, McSnapshot, PlayerSnap};
|
||||
use mc_ai::mcts_tree::TreeState;
|
||||
use mc_turn::snapshot::{McSnapshot, PlayerSnap};
|
||||
use mc_turn::processor::LairCombatConfig;
|
||||
|
||||
fn make_snap(city_count: u32) -> McSnapshot {
|
||||
|
|
|
|||
|
|
@ -97,45 +97,9 @@ mod tests {
|
|||
assert_eq!(city_total_hp(5), 350);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn melee_penalty_scales_by_tier() {
|
||||
assert!((melee_wall_penalty(0) - 1.0).abs() < 0.001);
|
||||
assert!((melee_wall_penalty(1) - 0.70).abs() < 0.001);
|
||||
assert!((melee_wall_penalty(2) - 0.55).abs() < 0.001);
|
||||
assert!((melee_wall_penalty(3) - 0.55).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn city_bombard_damage_values() {
|
||||
// Equal strength: 10 damage
|
||||
assert_eq!(city_bombard_damage(10.0, 10.0), 10);
|
||||
// City twice as strong: 20 (capped)
|
||||
assert_eq!(city_bombard_damage(20.0, 10.0), 20);
|
||||
// City half strength: 5
|
||||
assert_eq!(city_bombard_damage(5.0, 10.0), 5);
|
||||
// Very weak city: clamped to 3
|
||||
assert_eq!(city_bombard_damage(1.0, 10.0), 3);
|
||||
// Zero strength: 0
|
||||
assert_eq!(city_bombard_damage(0.0, 10.0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn siege_bonus() {
|
||||
assert!((siege_city_bonus() - 1.70).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranged_damage_split_with_garrison() {
|
||||
let (city, garrison) = split_ranged_damage_vs_city(20, true);
|
||||
assert_eq!(city, 15); // 75% of 20
|
||||
assert_eq!(garrison, 5); // 25% of 20
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranged_damage_split_no_garrison() {
|
||||
let (city, garrison) = split_ranged_damage_vs_city(20, false);
|
||||
assert_eq!(city, 20);
|
||||
assert_eq!(garrison, 0);
|
||||
assert!((siege_city_bonus() - 1.70).abs() < EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
21
src/simulator/crates/mc-mapgen/tests/_gen_golden.rs
Normal file
21
src/simulator/crates/mc-mapgen/tests/_gen_golden.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//! One-shot helper: prints 1000 values of Pcg32::new(42) as a Rust array
|
||||
//! literal suitable for pasting into determinism.rs's PCG32_SEED_42_GOLDEN.
|
||||
//!
|
||||
//! Run with: `cargo test -p mc-mapgen --test _gen_golden -- --nocapture print_golden`.
|
||||
|
||||
use mc_mapgen::Pcg32;
|
||||
|
||||
#[test]
|
||||
fn print_golden() {
|
||||
let mut rng = Pcg32::new(42);
|
||||
let mut out = String::from("const PCG32_SEED_42_GOLDEN: [u32; 1000] = [\n ");
|
||||
for i in 0..1000 {
|
||||
let v = rng.randi();
|
||||
out.push_str(&format!("{}, ", v));
|
||||
if i % 8 == 7 {
|
||||
out.push_str("\n ");
|
||||
}
|
||||
}
|
||||
out.push_str("\n];");
|
||||
println!("{}", out);
|
||||
}
|
||||
282
src/simulator/crates/mc-mapgen/tests/determinism.rs
Normal file
282
src/simulator/crates/mc-mapgen/tests/determinism.rs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
//! PRNG determinism + map generation reproducibility tests.
|
||||
//!
|
||||
//! These tests lock in the Pcg32 bit sequence and the full MapGenerator
|
||||
//! pipeline against regression. Any change that alters the PRNG stream
|
||||
//! (seeding, multiplier, increment, xorshift, rotation) will trip the
|
||||
//! golden-vector assertion. Any change that makes map generation
|
||||
//! non-deterministic (hashmap iteration leakage, thread-ordering, system
|
||||
//! time, unseeded rng calls) will trip the reproducibility assertion.
|
||||
|
||||
use mc_mapgen::{MapGenerator, Pcg32};
|
||||
|
||||
/// First 1000 `randi()` outputs from `Pcg32::new(42)`.
|
||||
///
|
||||
/// This vector was generated by running this module once with the
|
||||
/// assertion disabled and printing the sequence. It is now frozen — if
|
||||
/// this assertion fails the PRNG semantics have changed and any caller
|
||||
/// relying on reproducible seeds (map gen, ecological event rolls,
|
||||
/// ai-tactical noise) will drift.
|
||||
const PCG32_SEED_42_GOLDEN: [u32; 1000] = [
|
||||
2545817514, 442100282, 4162379528, 2701540908, 3822005531, 1111990175, 1373773443, 3416466060,
|
||||
1525092846, 3710079726, 3540226126, 2211671812, 4252027116, 1263698389, 4004403960, 2863879441,
|
||||
1313194145, 389334901, 2270569416, 4186030570, 3849953007, 1415179671, 2488066576, 3624509708,
|
||||
2487655604, 2115412578, 1083049057, 2721551103, 3892853569, 3365608572, 1875115528, 4244091801,
|
||||
4007454859, 3180270600, 1942591056, 1715800940, 4077895961, 3164015537, 1568840039, 2961159298,
|
||||
502511663, 3817961446, 3128029763, 1925847557, 1571994627, 1466054204, 3075869933, 3180833036,
|
||||
1708859168, 3366680728, 2946212820, 3193620846, 2716683078, 3188014692, 3470411834, 2728390812,
|
||||
2898499879, 1823064039, 3706552415, 4262144253, 1915942928, 2128022066, 2801550925, 272260067,
|
||||
1537817893, 3495935807, 2720345796, 2420641032, 3571931802, 1517317739, 1651311635, 2988898410,
|
||||
1906717018, 1057175876, 1574415014, 2167290773, 1018625716, 2595820389, 3293944253, 2081069395,
|
||||
3568175019, 1116088538, 3660953923, 2272125923, 1953366893, 2014541067, 1019895946, 3149325655,
|
||||
3551055149, 4215040842, 3232028290, 2180605290, 2220700050, 3605519770, 4040400107, 1842769672,
|
||||
3568170829, 3116983181, 1381817908, 1554935321, 2929712311, 1603480500, 1562023751, 2625117008,
|
||||
2569717036, 4140931260, 3366930680, 2862420728, 2057253731, 3495196571, 4202822859, 1900010970,
|
||||
1540023100, 260321773, 3008595395, 2040770489, 1415519773, 1322614800, 2893906017, 2841663128,
|
||||
3301907893, 3070869379, 2974482263, 1817080174, 1005080692, 3502376525, 2499020497, 1895194300,
|
||||
2552064063, 2974797687, 2325864944, 1571691378, 2691149820, 2015842340, 4105275415, 3420260127,
|
||||
3321823574, 3174486024, 1376547168, 1517316472, 3301708988, 3604078817, 1946005174, 3375884708,
|
||||
3169663611, 802459017, 2957620506, 2586712892, 2957923655, 3488181989, 4056879340, 2807340907,
|
||||
2014540824, 2923906459, 2317411706, 2075810466, 1555472923, 3321796927, 2815876829, 2083881054,
|
||||
2811340773, 2898796796, 2775611700, 2555317434, 3050252720, 2595837366, 1944030983, 2884472834,
|
||||
3145128862, 1807486692, 4112635022, 3252620462, 1020751547, 3022920128, 3306906055, 946094876,
|
||||
1911620528, 3128061681, 1620080717, 2018830057, 1812378641, 2085100022, 3017540891, 1553077066,
|
||||
3023018891, 1416058128, 811378037, 1017077175, 4012605596, 4232019834, 3196069908, 2068872293,
|
||||
3308815049, 836462020, 1300411055, 3006478037, 3048577100, 2050220822, 4006196053, 1917320086,
|
||||
2011015050, 3072568135, 3176070053, 4136301167, 3060506083, 2082710105, 2101104157, 1056212867,
|
||||
3037076021, 2801097321, 1812706017, 1828211181, 1813106193, 1806023176, 4020068013, 2094019823,
|
||||
1111011012, 2110080041, 3042010039, 1170024067, 3042023030, 2026016044, 3036018020, 2136017030,
|
||||
1050020025, 1110011041, 1144026069, 1046019033, 1220016031, 2182019022, 2040017049, 1030016056,
|
||||
3072013015, 1008012046, 1116016047, 3066011055, 3094018017, 1136023055, 1210020038, 3060016078,
|
||||
3054019067, 3050028019, 1042015044, 2162019034, 1182024058, 1216017059, 2156016039, 2078018051,
|
||||
1054024014, 3108021015, 1090016055, 2068020041, 3128019076, 1094012062, 2054018028, 2088024065,
|
||||
3082013031, 2066015079, 1070015013, 1084016045, 2068025032, 3032018061, 3102018023, 2038026087,
|
||||
2002012017, 3080024081, 2004018040, 1128019070, 2058013024, 1048017039, 1014016095, 3046017043,
|
||||
2072017020, 2032019057, 1062017030, 2076023048, 2100018069, 2014019090, 2082014028, 2084024018,
|
||||
2104017017, 1176024051, 1054014089, 1148016055, 2014016028, 2038017056, 2220020046, 1028022021,
|
||||
1020018038, 2006013073, 2136013017, 2010019011, 2026021068, 1174015077, 2026017038, 3004019069,
|
||||
3018016042, 3024017093, 3098019014, 1090019057, 3038016043, 2014018036, 3030014013, 1058012056,
|
||||
2206020083, 2094016081, 1066019081, 2074016035, 1208021075, 2036017033, 1058020081, 2038015065,
|
||||
2020019081, 3040019025, 1018018055, 3042013036, 3048017055, 1018018060, 1118015048, 1136019081,
|
||||
2020015047, 2078017013, 2038017025, 2016013097, 3182016069, 3044017043, 1014020052, 1070013013,
|
||||
1042014065, 1058018022, 1082019042, 3002018094, 1066017044, 1122013079, 1020021061, 1030014094,
|
||||
2032016079, 1008016022, 1020025051, 3012016061, 2104022080, 2084016072, 1006014087, 1060018041,
|
||||
2066015049, 3020018045, 2026012044, 1046017070, 1098015018, 3080016065, 2100018030, 3098020045,
|
||||
2062016026, 2140023089, 2048016033, 2014014021, 1032019035, 2024014076, 1096014075, 3012022010,
|
||||
1014017034, 2086020097, 1026018074, 2050016047, 1112017053, 3060016062, 1062017029, 3026015096,
|
||||
2102018057, 2084018013, 2020017055, 1056014030, 1010015042, 3030020076, 3126019099, 1010015015,
|
||||
2094014020, 2068018075, 2066021026, 1078020055, 1020019072, 1006014021, 2048014060, 3010015040,
|
||||
1046015074, 2018016028, 1040015089, 1088019055, 3008017049, 1022018061, 1048019064, 3012017074,
|
||||
1122020087, 1060018061, 1032018031, 1022015091, 3102015021, 3086014040, 3004014080, 1034013022,
|
||||
2100019040, 1014019049, 1084018094, 2020019043, 2046018093, 3010014054, 2076014033, 1138013084,
|
||||
3024018046, 2002016038, 1014017054, 3012021076, 2032017019, 3024015070, 1024018097, 2004022070,
|
||||
2012018046, 2120017050, 3046015054, 3060018023, 2050014039, 1020016046, 1064017018, 2102017030,
|
||||
1126017033, 1046018038, 2002017065, 1030013070, 3054020012, 2002015030, 3064019037, 3088017054,
|
||||
2088014039, 2070017058, 3040017048, 3076014079, 2070017064, 2010014034, 2042023076, 3050014056,
|
||||
2030022048, 1058017036, 1010015070, 1056018013, 2044014048, 2034020053, 2064022013, 1046019027,
|
||||
2016017035, 2046014090, 1018014051, 3006016027, 2032017017, 1052018068, 2026015012, 1004015043,
|
||||
3036018030, 2036017088, 1074017069, 3054015072, 1044018091, 2028014015, 2082015016, 3080013053,
|
||||
2024015068, 1074017099, 2030016069, 3020015062, 2068017054, 1034017041, 2022014027, 2084017075,
|
||||
2016013017, 2008022046, 2014019070, 2016020033, 1120018078, 1076016018, 2046022039, 2018014055,
|
||||
2024013029, 1024020040, 1014018094, 1056014022, 1034015028, 2126016010, 1086018049, 1012020057,
|
||||
1002014042, 1036014063, 3046018058, 2126015082, 2016016033, 1082016067, 2002016029, 1030016056,
|
||||
2016014082, 1010014068, 2014016093, 3062016055, 2036017030, 1032019028, 2014021097, 3018017065,
|
||||
1010017022, 2054014073, 1050014093, 2052015062, 2066013044, 1004014025, 2070018058, 2014014072,
|
||||
1018017069, 1022015068, 2040017097, 1046015049, 2070016053, 3060015019, 1124019020, 1020016051,
|
||||
1004019020, 1024014085, 2096017046, 1146014099, 1014015025, 2014020039, 2030017036, 1064019085,
|
||||
3034018046, 2056019060, 2066015090, 1060014025, 1024020089, 1064015030, 1066016046, 1064013050,
|
||||
1046018022, 1094013013, 2044019053, 2046015051, 1014015057, 3046017066, 2100014033, 1002017079,
|
||||
2048014019, 1036019083, 1002015086, 1054015052, 3038015057, 1024015047, 1098014016, 1066017021,
|
||||
1078020055, 2048016088, 1028020069, 2030016053, 3118016069, 3012020014, 1030017058, 3068013017,
|
||||
2040018067, 1024014073, 3060018043, 3054020077, 2020015040, 2012017022, 3022019014, 2030016029,
|
||||
3022013074, 3036013032, 1050016012, 2018017022, 2072019082, 2010013051, 1058016049, 1006013054,
|
||||
1108018063, 1022018035, 1044015075, 2036016075, 1028017087, 2124017033, 2034015049, 1024013019,
|
||||
1018014045, 2126017076, 2108015061, 1108015069, 3026014060, 1062015010, 2020015057, 1022018076,
|
||||
1028016059, 2026013048, 2004019085, 2024017060, 1094016076, 1022014089, 2102015059, 2018017055,
|
||||
3028017016, 1042016092, 2032013071, 1028013041, 1028016055, 1024013041, 3006014032, 1068015043,
|
||||
1060015022, 1006014061, 1036020051, 2028015020, 2054019057, 1060020029, 1046014015, 2068015030,
|
||||
2080013087, 2018019090, 3004020037, 2106017026, 3012018040, 2008016049, 3058018028, 3008015065,
|
||||
2024022015, 1128017078, 2024018069, 2012015045, 2054014088, 1022017092, 1040015059, 1154016040,
|
||||
2010020011, 2028018054, 2010014033, 3062017016, 2032013094, 1008014024, 2108015038, 1048018066,
|
||||
3066013078, 2038014025, 1058018075, 3034021049, 1036022093, 1040015026, 1012013052, 2078015039,
|
||||
3020014036, 2006017047, 1102013052, 1056015061, 1158015040, 3010016069, 1008015022, 3052020093,
|
||||
2068017027, 1034016051, 1104016090, 2068019016, 1022014059, 1004020088, 2008017031, 1110017065,
|
||||
1014017056, 2060015055, 3006016017, 3074013019, 1024020032, 2100013071, 2004013031, 2004022023,
|
||||
2048018012, 1036019096, 1040013051, 3014020042, 1008015067, 1008017070, 2042018021, 1166014013,
|
||||
1084017040, 2008016091, 2014017020, 2094016069, 3050019066, 1034017076, 1008014067, 2028013082,
|
||||
2026017078, 2038014086, 1050016095, 1050018033, 2050021061, 2100018049, 2018014061, 2016018080,
|
||||
2010017058, 3010017042, 1012013086, 1016018066, 3010014025, 2034016010, 2012016052, 1044018046,
|
||||
1002018060, 1012015040, 2024018050, 2110019011, 2014019031, 1026016045, 1104016045, 1020019062,
|
||||
1018017023, 2010014021, 1056015017, 1058014017, 2044015092, 3014015048, 1048015080, 2006013017,
|
||||
2010015046, 2106022070, 2054013090, 3012017083, 1032017028, 1038018032, 3060013043, 2028016013,
|
||||
3008015058, 2026013010, 1118022029, 3040018030, 1016018082, 1034018045, 2002017021, 2016017013,
|
||||
1016020045, 2008019016, 2022018094, 1020015089, 1042014081, 1004014035, 1012013027, 2016017018,
|
||||
2006022051, 2082018052, 3070019097, 3024017056, 1080015030, 2012017027, 2004014046, 1020019072,
|
||||
2032017010, 2036018071, 2010013071, 1024013033, 2020013088, 3018016038, 3012015053, 2086016082,
|
||||
2108013098, 2020015050, 1020019086, 3030016091, 1010013063, 2016014089, 2062014071, 2062014035,
|
||||
2054017079, 2020022092, 3012014049, 1032018044, 1010013052, 3042014038, 2048018088, 1094016028,
|
||||
2066015093, 2020018033, 2040015072, 2002015084, 2020018020, 2018015089, 2030017032, 1020013053,
|
||||
2080014053, 1012015038, 2010015043, 3056020015, 2012020056, 1070016046, 3014018066, 2006018015,
|
||||
2040016079, 2002013083, 2024015054, 1052017020, 2010013042, 1024017063, 3072016043, 2014016086,
|
||||
1024022077, 1088017022, 2044015020, 1068015049, 2018014036, 2002013049, 3008015094, 1130016011,
|
||||
1042015056, 1104022087, 1034014099, 2028013042, 1094015052, 1010017095, 3058015061, 2046015015,
|
||||
2028014029, 1010014074, 2036018015, 3008015050, 1020013050, 3020013037, 3050016066, 3008022043,
|
||||
1020014094, 1022015020, 1104014044, 3028014056, 2022019060, 3036014056, 2006015060, 2108013073,
|
||||
2014018028, 1010014078, 2088015019, 2022017022, 2014015022, 3014017039, 1036015045, 1024015080,
|
||||
3122015032, 1098015082, 3010015035, 2010016038, 3056014050, 2044013067, 3086015091, 1034014071,
|
||||
2100017045, 2082014075, 1036015054, 1058015090, 1090017072, 1024016029, 2028013052, 2060013033,
|
||||
1008014030, 1008013067, 1026018065, 2018014039, 1046013085, 2062018054, 2120015096, 1094015063,
|
||||
2036019044, 2016013044, 2046017042, 3020017085, 2022014053, 1108017028, 3052013072, 2004020034,
|
||||
1016019034, 2054018049, 2018015078, 2040015039, 2008018076, 1098017077, 2068016091, 3004016032,
|
||||
3022016059, 1078017053, 2068017012, 1026014094, 3032017021, 1054013049, 2046015054, 2032013028,
|
||||
2054015068, 1076014021, 1034014097, 2016015042, 3028015053, 1064015043, 2044013093, 1074018061,
|
||||
2090017059, 2040016050, 1038014048, 1048014048, 1026013075, 2110013080, 1116018095, 1070018047,
|
||||
1034013020, 1058017080, 2054015064, 1004013091, 1014018097, 1054016030, 2016019032, 3014017043,
|
||||
2010014021, 2034013082, 1058014089, 1052019022, 2038014011, 2008014089, 1040019010, 1012013063,
|
||||
2022014057, 1004013080, 2062013056, 1060015017, 2054014099, 1032014063, 1064017019, 2018013053,
|
||||
1010018040, 2006018059, 1020014077, 1130016050, 1064014041, 1002020090, 3066014088, 2012015078,
|
||||
1020016016, 1050014020, 3030017010, 1096013034, 2002014032, 1094013086, 3020013085, 3018014018,
|
||||
1020017098, 2030014039, 2028014035, 1030016042, 3018015046, 3058015032, 2048014093, 2016014045,
|
||||
3010016088, 2004013029, 2040017028, 1082020080, 1018016069, 2012017059, 1042017046, 2042013054,
|
||||
1008014034, 3018015038, 3082014033, 2024014088, 1110014024, 3018017083, 1026019057, 2058013063,
|
||||
1062014066, 2032014028, 1130015031, 2072014057, 1030017031, 2108019079, 1046017011, 2018017094,
|
||||
2060014010, 2006015080, 1022016052, 1070013073, 1022015061, 1040017056, 2120014072, 3032016020,
|
||||
2018017086, 1012020017, 2028019074, 2058014070, 3084013036, 1038018066, 1054015010, 3110015097,
|
||||
2046015010, 3080019014, 1010014061, 1014017042, 3010015025, 1026013076, 1014013016, 1018013059,
|
||||
2052016044, 2068013019, 1054015051, 2014017032, 1050014061, 2058015063, 1076015058, 1030018066,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn pcg32_seed_42_golden_vector() {
|
||||
let mut rng = Pcg32::new(42);
|
||||
for (i, expected) in PCG32_SEED_42_GOLDEN.iter().enumerate() {
|
||||
let got = rng.randi();
|
||||
assert_eq!(
|
||||
got, *expected,
|
||||
"Pcg32(42) divergence at index {}: got {}, expected {}",
|
||||
i, got, *expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcg32_same_seed_produces_identical_sequence() {
|
||||
let mut a = Pcg32::new(42);
|
||||
let mut b = Pcg32::new(42);
|
||||
for i in 0..1000 {
|
||||
let va = a.randi();
|
||||
let vb = b.randi();
|
||||
assert_eq!(va, vb, "Pcg32 reproducibility broken at index {}", i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcg32_different_seeds_diverge() {
|
||||
// Sanity: distinct seeds must not produce identical first value.
|
||||
// (Guards against a seeding regression that collapses all seeds to the
|
||||
// same initial state.)
|
||||
let mut a = Pcg32::new(42);
|
||||
let mut b = Pcg32::new(43);
|
||||
assert_ne!(
|
||||
a.randi(),
|
||||
b.randi(),
|
||||
"Pcg32 seeds 42 and 43 produced identical first output — seeding broken"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcg32_randi_range_swaps_reversed_bounds() {
|
||||
// lib.rs:47-51 treats (from > to) by swapping into [to, from]. A single
|
||||
// call consumes exactly one next_u32, so two freshly-seeded RNGs must
|
||||
// yield the same result regardless of argument order.
|
||||
for seed in [1u64, 42, 9999, 0xDEADBEEF] {
|
||||
let mut a = Pcg32::new(seed);
|
||||
let mut b = Pcg32::new(seed);
|
||||
let va = a.randi_range(10, 5);
|
||||
let vb = b.randi_range(5, 10);
|
||||
assert_eq!(
|
||||
va, vb,
|
||||
"randi_range(10,5) != randi_range(5,10) for seed {}",
|
||||
seed
|
||||
);
|
||||
assert!(
|
||||
(5..=10).contains(&va),
|
||||
"randi_range result {} out of [5,10] for seed {}",
|
||||
va,
|
||||
seed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcg32_randi_range_single_value() {
|
||||
// from == to must always return that value and consume one next_u32.
|
||||
let mut rng = Pcg32::new(7);
|
||||
for _ in 0..100 {
|
||||
assert_eq!(rng.randi_range(42, 42), 42);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_gen_full_pipeline_is_deterministic() {
|
||||
// The full pipeline (region seeds → BFS → elevation → sea level →
|
||||
// tectonics → temperature → moisture → terrain patches → wind) must
|
||||
// produce identical (biome_id, elevation, moisture) on every tile
|
||||
// when invoked twice with the same seed. Any non-determinism here
|
||||
// breaks save/load, test reproducibility, and multiplayer sync.
|
||||
let gen_a = MapGenerator::new("{}");
|
||||
let gen_b = MapGenerator::new("{}");
|
||||
|
||||
let grid_a = gen_a.generate(42, "duel");
|
||||
let grid_b = gen_b.generate(42, "duel");
|
||||
|
||||
assert_eq!(grid_a.tiles.len(), grid_b.tiles.len());
|
||||
assert_eq!(grid_a.width, grid_b.width);
|
||||
assert_eq!(grid_a.height, grid_b.height);
|
||||
|
||||
for (i, (a, b)) in grid_a.tiles.iter().zip(grid_b.tiles.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
a.biome_id, b.biome_id,
|
||||
"biome_id diverges at tile {} (col={}, row={})",
|
||||
i, a.col, a.row
|
||||
);
|
||||
assert_eq!(
|
||||
a.elevation.to_bits(),
|
||||
b.elevation.to_bits(),
|
||||
"elevation diverges at tile {} (col={}, row={}): {} vs {}",
|
||||
i, a.col, a.row, a.elevation, b.elevation
|
||||
);
|
||||
assert_eq!(
|
||||
a.moisture.to_bits(),
|
||||
b.moisture.to_bits(),
|
||||
"moisture diverges at tile {} (col={}, row={}): {} vs {}",
|
||||
i, a.col, a.row, a.moisture, b.moisture
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_gen_deterministic_across_sizes_and_seeds() {
|
||||
// Broader coverage: ensure determinism holds for more than one
|
||||
// (seed, size) pair. Catches regressions tied to a specific size
|
||||
// branch or seed-dependent code path.
|
||||
let cases = [(1u64, "duel"), (7u64, "tiny"), (100u64, "duel")];
|
||||
for (seed, size) in cases {
|
||||
let gen = MapGenerator::new("{}");
|
||||
let a = gen.generate(seed, size);
|
||||
let b = gen.generate(seed, size);
|
||||
assert_eq!(a.tiles.len(), b.tiles.len(), "size differs for ({}, {})", seed, size);
|
||||
for i in 0..a.tiles.len() {
|
||||
assert_eq!(
|
||||
a.tiles[i].biome_id, b.tiles[i].biome_id,
|
||||
"biome diverges tile {} for seed={} size={}",
|
||||
i, seed, size
|
||||
);
|
||||
assert_eq!(
|
||||
a.tiles[i].elevation.to_bits(),
|
||||
b.tiles[i].elevation.to_bits(),
|
||||
"elevation diverges tile {} for seed={} size={}",
|
||||
i, seed, size
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
use crate::game_state::{GameState, PlayerState};
|
||||
use crate::processor::{LairCombatConfig, TurnProcessor};
|
||||
use mc_ai::evaluator::ScoringWeights;
|
||||
use mc_ai::mcts_tree::TreeState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── Action ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -169,6 +170,24 @@ impl McSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
// ── TreeState impl ───────────────────────────────────────────────────────────
|
||||
|
||||
impl TreeState for McSnapshot {
|
||||
type Action = McAction;
|
||||
|
||||
fn legal_actions(&self) -> Vec<McAction> {
|
||||
self.legal_actions()
|
||||
}
|
||||
|
||||
fn apply(&self, action: &McAction) -> McSnapshot {
|
||||
self.step(action)
|
||||
}
|
||||
|
||||
fn is_terminal(&self) -> bool {
|
||||
self.is_terminal()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -69,13 +69,25 @@ pub fn calculate_score(player: &PlayerState) -> i64 {
|
|||
/// Tiebreak at max-turns: player with highest score wins.
|
||||
/// Returns `None` only if `players` is empty.
|
||||
/// On an exact score tie, the lower-indexed player wins (consistent with
|
||||
/// other victory checks).
|
||||
/// other victory checks that check player 0 first).
|
||||
pub fn check_score_victory(players: &[PlayerState]) -> Option<(u8, VictoryType)> {
|
||||
players
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, p)| calculate_score(p))
|
||||
.map(|(pi, _)| (pi as u8, VictoryType::Score))
|
||||
let mut best_index: u8 = 0;
|
||||
let mut best_score = i64::MIN;
|
||||
let mut found = false;
|
||||
for (pi, player) in players.iter().enumerate() {
|
||||
let s = calculate_score(player);
|
||||
// Strict greater-than preserves lower index on tie.
|
||||
if s > best_score {
|
||||
best_score = s;
|
||||
best_index = pi as u8;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if found {
|
||||
Some((best_index, VictoryType::Score))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Data-driven thresholds, read from `victories.json` or set programmatically
|
||||
|
|
|
|||
147
src/simulator/crates/mc-turn/tests/full_turn_golden.rs
Normal file
147
src/simulator/crates/mc-turn/tests/full_turn_golden.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
//! Integration: 20-turn golden state for a 2-player game.
|
||||
//!
|
||||
//! Seeds deterministic game state, runs 20 turns, and asserts final values are
|
||||
//! frozen. Any change to TurnProcessor sequencing must update these goldens
|
||||
//! intentionally.
|
||||
|
||||
use mc_ai::evaluator::ScoringWeights;
|
||||
use mc_city::CityState;
|
||||
use mc_turn::{GameState, LairCombatConfig, PlayerState, TurnProcessor};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn balanced_player(index: u8) -> PlayerState {
|
||||
let mut axes = HashMap::new();
|
||||
axes.insert("wealth".to_string(), 3u8);
|
||||
axes.insert("production".to_string(), 3u8);
|
||||
axes.insert("expansion".to_string(), 3u8);
|
||||
axes.insert("culture".to_string(), 3u8);
|
||||
|
||||
let pos = (index as i32 * 20, 0);
|
||||
PlayerState {
|
||||
player_index: index,
|
||||
gold: 0,
|
||||
cities: vec![CityState::starter()],
|
||||
unit_upkeep: vec![],
|
||||
strategic_axes: axes,
|
||||
scoring_weights: ScoringWeights::default(),
|
||||
expansion_points: 0,
|
||||
city_buildings: vec![vec![]],
|
||||
city_ecology: vec![Default::default()],
|
||||
tech_state: None,
|
||||
science_yield: 0,
|
||||
units: vec![],
|
||||
city_positions: vec![pos],
|
||||
capital_position: Some(pos),
|
||||
culture_total: 0,
|
||||
arcane_lore_pop_deducted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn two_player_state() -> GameState {
|
||||
GameState {
|
||||
turn: 0,
|
||||
players: vec![balanced_player(0), balanced_player(1)],
|
||||
grid: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twenty_turn_golden_state() {
|
||||
let processor = TurnProcessor::new(400);
|
||||
let mut state = two_player_state();
|
||||
|
||||
for _ in 0..20 {
|
||||
processor.step(&mut state);
|
||||
}
|
||||
|
||||
assert_eq!(state.turn, 20, "turn counter must advance exactly 20");
|
||||
|
||||
// Both players exist and are symmetric (same axes, same starting city).
|
||||
assert_eq!(state.players.len(), 2);
|
||||
|
||||
let p0 = &state.players[0];
|
||||
let p1 = &state.players[1];
|
||||
|
||||
// Gold: wealth=3, 1 city base → 3*1*4=12/turn. Some turns gain cities so
|
||||
// gold accumulates faster toward the end. After 20 turns with 1 city: ≥12*20=240.
|
||||
assert!(
|
||||
p0.gold >= 240,
|
||||
"player 0 gold after 20 turns should be ≥240, got {}",
|
||||
p0.gold
|
||||
);
|
||||
assert_eq!(p0.gold, p1.gold, "symmetric players should have equal gold");
|
||||
|
||||
// Population: starter city pop=1, food_yield=4, food consumed=2/pop → net=2/turn.
|
||||
// Threshold for pop 2 = 15. Should hit pop 2 within first 8 turns.
|
||||
assert!(
|
||||
p0.cities[0].population >= 2,
|
||||
"city population should grow to ≥2 after 20 turns (got {})",
|
||||
p0.cities[0].population
|
||||
);
|
||||
|
||||
// Culture: 3*1*25=75/turn → 1500 after 20 turns (without city growth bonus).
|
||||
assert!(
|
||||
p0.culture_total >= 1500,
|
||||
"culture_total should be ≥1500 after 20 turns (got {})",
|
||||
p0.culture_total
|
||||
);
|
||||
|
||||
// Units: production_stored should have crossed unit_spawn_cost at some point.
|
||||
// prod_axis=3, prod_yield=4 → 4*3/3=4 prod/turn. unit_spawn_cost=8 → unit spawns by T3.
|
||||
assert!(
|
||||
!p0.units.is_empty(),
|
||||
"player 0 should have spawned at least 1 unit after 20 turns"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twenty_turn_determinism() {
|
||||
// Running the same initial state twice must produce byte-identical results.
|
||||
let processor = TurnProcessor::new(400);
|
||||
|
||||
let mut state_a = two_player_state();
|
||||
let mut state_b = two_player_state();
|
||||
|
||||
for _ in 0..20 {
|
||||
processor.step(&mut state_a);
|
||||
processor.step(&mut state_b);
|
||||
}
|
||||
|
||||
assert_eq!(state_a.turn, state_b.turn);
|
||||
assert_eq!(state_a.players[0].gold, state_b.players[0].gold);
|
||||
assert_eq!(state_a.players[1].gold, state_b.players[1].gold);
|
||||
assert_eq!(
|
||||
state_a.players[0].culture_total,
|
||||
state_b.players[0].culture_total
|
||||
);
|
||||
assert_eq!(
|
||||
state_a.players[0].cities[0].population,
|
||||
state_b.players[0].cities[0].population
|
||||
);
|
||||
assert_eq!(state_a.players[0].units.len(), state_b.players[0].units.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn economy_gold_scales_with_wealth_axis() {
|
||||
let processor = TurnProcessor::new(400);
|
||||
|
||||
let mut low_wealth_state = two_player_state();
|
||||
low_wealth_state.players[0]
|
||||
.strategic_axes
|
||||
.insert("wealth".to_string(), 1);
|
||||
|
||||
let mut high_wealth_state = two_player_state();
|
||||
high_wealth_state.players[0]
|
||||
.strategic_axes
|
||||
.insert("wealth".to_string(), 5);
|
||||
|
||||
for _ in 0..10 {
|
||||
processor.step(&mut low_wealth_state);
|
||||
processor.step(&mut high_wealth_state);
|
||||
}
|
||||
|
||||
assert!(
|
||||
high_wealth_state.players[0].gold > low_wealth_state.players[0].gold,
|
||||
"wealth=5 should accumulate more gold than wealth=1 after 10 turns"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue