diff --git a/src/game/engine/scenes/tests/gameplay_arc_proof.gd b/src/game/engine/scenes/tests/gameplay_arc_proof.gd index ac68331f..f71c0a09 100644 --- a/src/game/engine/scenes/tests/gameplay_arc_proof.gd +++ b/src/game/engine/scenes/tests/gameplay_arc_proof.gd @@ -1,12 +1,15 @@ extends Node2D -## p2-66 follow-up — Gameplay-arc multi-shot proof. +## p2-66 follow-up — Gameplay-arc multi-shot proof (v2). ## -## Drives a deterministic gameplay arc through direct `GameState` mutations -## and captures one frame per arc step. Each step represents a real gameplay -## moment (founding, training, moving, combat, capture, growth, borders). -## Bypasses `TurnManager.start_turn()` because the production turn loop has -## its own initialization order issues that aren't gameplay defects but -## block this proof — see p2-66 status notes. +## Drives a deterministic gameplay arc via the canonical entity APIs +## (`City::set_population`, `City::set_hp`, `City::add_tile`, mutating +## `placed_buildings` dict; `Unit` direct construction) and captures one +## frame per arc step. Each step represents a real gameplay moment. +## +## v1 had silent failures because `city.population = N` is a read-only +## getter on top of the Rust bridge — the assignment was dropped and +## three subsequent frames serialised the same bytes as the prior frame. +## v2 uses the right setter for each prop and verifies via hash dedup. const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd") const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") @@ -27,6 +30,12 @@ var _cam: Camera2D = null var _game_map: RefCounted = null var _all_positions: Array[Vector2i] = [] var _captured: int = 0 +var _p1: PlayerScript = null +var _p2: PlayerScript = null +var _c1: CityScript = null +var _c2: CityScript = null +var _p1_pos: Vector2i = Vector2i.ZERO +var _p2_pos: Vector2i = Vector2i.ZERO func _ready() -> void: @@ -58,17 +67,14 @@ func _ready() -> void: for pos: Vector2i in _game_map.tiles: _all_positions.append(pos) - # 2 dwarven clans, AI-flag set on both (no-op here since we drive state directly). for i: int in range(2): var player: PlayerScript = PlayerScript.new() player.index = i player.is_human = false player.player_name = "Clan %d" % (i + 1) player.race_id = "dwarf" - player.color = Color(0.9, 0.7, 0.25) if i == 0 else Color(0.25, 0.55, 0.95) + player.color = Color(0.95, 0.65, 0.15) if i == 0 else Color(0.20, 0.50, 0.95) GameState.players.append(player) - - # Founder unit at the player's start hex. var start: Vector2i = Vector2i.ZERO if i < _game_map.start_positions.size(): start = _game_map.start_positions[i] @@ -76,163 +82,194 @@ func _ready() -> void: founder.id = "founder_%d" % i player.units.append(founder) + _p1 = GameState.players[0] as PlayerScript + _p2 = GameState.players[1] as PlayerScript + _p1_pos = _p1.units[0].position + _p2_pos = _p2.units[0].position + _setup_renderers() _setup_camera_fit() - DirAccess.make_dir_recursive_absolute( ProjectSettings.globalize_path(OUTPUT_DIR) ) - # ── Arc step 0: world seeded — both founders alive, no cities ────────── - await _capture_step("01_world_seeded", "fit") + # ── ACT I: Empire birth ──────────────────────────────────────────────── + await _capture("01_world_seeded", "fit") + await _capture("02_founder_zoomed", "focus", _p1_pos, 6.0) - # ── Arc step 1: P1 founds capital at start hex ───────────────────────── - var p1: PlayerScript = GameState.players[0] as PlayerScript - var p2: PlayerScript = GameState.players[1] as PlayerScript - var p1_capital_pos: Vector2i = p1.units[0].position - p1.units.clear() # founder consumed - var c1: CityScript = CityScript.new("clan1_capital") - c1.owner_index = 0 - c1.position = p1_capital_pos - if "city_name" in c1: c1.city_name = "Karak Ankor" - if "population" in c1: c1.population = 1 - p1.cities.append(c1) - # Re-spawn a warrior at capital so we have an inhabitant for screenshots. - var w1: UnitScript = UnitScript.new("dwarf_warrior", 0, p1_capital_pos) - w1.id = "p1_warrior_initial" - p1.units.append(w1) - await _capture_step("02_capital_founded", "focus", p1_capital_pos) - await _capture_step("03_capital_founded_wide", "fit") + # P1 founds capital + _p1.units.clear() + _c1 = CityScript.new("clan1_capital") + _c1.owner_index = 0 + _c1.position = _p1_pos + _c1.city_name = "Karak Ankor" + _c1.set_population(1) + _p1.cities.append(_c1) + await _capture("03_p1_capital_founded", "focus", _p1_pos, 6.0) - # ── Arc step 2: P2 founds capital ────────────────────────────────────── - var p2_capital_pos: Vector2i = p2.units[0].position - p2.units.clear() - var c2: CityScript = CityScript.new("clan2_capital") - c2.owner_index = 1 - c2.position = p2_capital_pos - if "city_name" in c2: c2.city_name = "Khazad Dar" - if "population" in c2: c2.population = 1 - p2.cities.append(c2) - var w2: UnitScript = UnitScript.new("dwarf_warrior", 1, p2_capital_pos) - w2.id = "p2_warrior_initial" - p2.units.append(w2) - await _capture_step("04_two_empires", "fit") + # P2 founds capital + _p2.units.clear() + _c2 = CityScript.new("clan2_capital") + _c2.owner_index = 1 + _c2.position = _p2_pos + _c2.city_name = "Khazad Dar" + _c2.set_population(1) + _p2.cities.append(_c2) + await _capture("04_two_empires_wide", "fit") + await _capture("05_p2_capital_zoomed", "focus", _p2_pos, 6.0) - # ── Arc step 3: P1 city grows to pop 5 + has 4 buildings ─────────────── - if "population" in c1: - c1.population = 5 - # Hint to the renderer that the city is well-developed by adding - # placed buildings into a property the renderer might draw. If the - # field doesn't exist, harmless. - if "buildings" in c1: - c1.buildings = ["dwarf_granary", "dwarf_forge", "dwarf_market", "dwarf_barracks"] - await _capture_step("05_capital_pop5_buildings", "focus", p1_capital_pos) + # ── ACT II: Growth ───────────────────────────────────────────────────── + # P1 grows: pop 3 + _c1.set_population(3) + await _capture("06_capital_pop3", "focus", _p1_pos, 6.0) - # ── Arc step 4: train an army — 3 warriors near P1 capital ───────────── - var neighbours: Array[Vector2i] = _hex_ring(p1_capital_pos, 1) + # P1 grows further: pop 7 + _c1.set_population(7) + await _capture("07_capital_pop7", "focus", _p1_pos, 6.0) + + # Add placed buildings around the capital + var ring1: Array[Vector2i] = _hex_ring(_p1_pos, 1) + _c1.placed_buildings = { + "dwarf_granary": ring1[0] if ring1.size() > 0 else _p1_pos, + "dwarf_forge": ring1[1] if ring1.size() > 1 else _p1_pos, + "dwarf_market": ring1[2] if ring1.size() > 2 else _p1_pos, + "dwarf_barracks": ring1[3] if ring1.size() > 3 else _p1_pos, + } + await _capture("08_capital_with_buildings", "focus", _p1_pos, 7.0) + + # ── ACT III: Borders / culture ───────────────────────────────────────── + # Claim 1-ring around capital via add_tile + for t: Vector2i in ring1: + _c1.add_tile(t.x, t.y) + await _capture("09_borders_1ring", "focus", _p1_pos, 7.0) + + # Expand to 2 rings + for t: Vector2i in _hex_ring(_p1_pos, 2): + _c1.add_tile(t.x, t.y) + await _capture("10_borders_2ring", "focus", _p1_pos, 9.0) + + # Expand to 3 rings + for t: Vector2i in _hex_ring(_p1_pos, 3): + _c1.add_tile(t.x, t.y) + await _capture("11_borders_3ring_wide", "focus", _p1_pos, 12.0) + + # Mirror borders for P2 (smaller — 1 ring) so the wide view shows two + # coloured culture zones. + for t: Vector2i in _hex_ring(_p2_pos, 1): + _c2.add_tile(t.x, t.y) + await _capture("12_two_culture_zones_wide", "fit") + + # ── ACT IV: Military buildup ─────────────────────────────────────────── var spawned: int = 0 - for n_pos: Vector2i in neighbours: + for n_pos: Vector2i in ring1: if not _game_map.tiles.has(n_pos): continue - var tile: Resource = _game_map.tiles[n_pos] - if tile == null: - continue - var is_water: bool = false - if "biome_id" in tile and (tile.biome_id == "ocean" or tile.biome_id == "coast"): - is_water = true - if is_water: - continue var u: UnitScript = UnitScript.new("dwarf_warrior", 0, n_pos) u.id = "p1_army_%d" % spawned - p1.units.append(u) + _p1.units.append(u) spawned += 1 if spawned >= 3: break - await _capture_step("06_army_assembled", "focus", p1_capital_pos) + await _capture("13_p1_army_3warriors", "focus", _p1_pos, 7.0) - # ── Arc step 5: borders expand (tile-claim hint via player.owned_tiles) ── - if "owned_tiles" in p1: - var ring2: Array[Vector2i] = _hex_ring(p1_capital_pos, 1) + _hex_ring(p1_capital_pos, 2) - var owned: Array = p1.owned_tiles if p1.owned_tiles is Array else [] - for t: Vector2i in ring2: - if t not in owned: - owned.append(t) - p1.owned_tiles = owned - await _capture_step("07_borders_expanded", "focus", p1_capital_pos) + # Add a mixed army: archer + scout + var ring2: Array[Vector2i] = _hex_ring(_p1_pos, 2) + if ring2.size() > 1 and _game_map.tiles.has(ring2[0]): + var arch: UnitScript = UnitScript.new("dwarf_archer", 0, ring2[0]) + arch.id = "p1_archer" + _p1.units.append(arch) + if ring2.size() > 2 and _game_map.tiles.has(ring2[1]): + var sct: UnitScript = UnitScript.new("dwarf_scout", 0, ring2[1]) + sct.id = "p1_scout" + _p1.units.append(sct) + await _capture("14_mixed_army", "focus", _p1_pos, 9.0) - # ── Arc step 6: tile improvement — placeholder by mutating tile data ──── - # The hex_renderer draws indicator decorations from tile observations; - # adding an indicator_decorations entry is the cleanest way to surface - # an improvement in the rendered frame without touching ImprovementManager. - var improvement_hex: Vector2i = Vector2i.ZERO - for cand in _hex_ring(p1_capital_pos, 1): - if _game_map.tiles.has(cand): - improvement_hex = cand - break - if _game_map.tiles.has(improvement_hex): - var tile_for_imp: Resource = _game_map.tiles[improvement_hex] - if tile_for_imp != null and "indicator_decorations" in tile_for_imp: - var ids: Array = tile_for_imp.indicator_decorations - ids.append("farm") - tile_for_imp.indicator_decorations = ids - await _capture_step("08_improvement_placed", "focus", p1_capital_pos) + # P2 puts up a defensive force too + for j: int in range(2): + var n2: Vector2i = _hex_ring(_p2_pos, 1)[j] if _hex_ring(_p2_pos, 1).size() > j else _p2_pos + if not _game_map.tiles.has(n2): + continue + var u2: UnitScript = UnitScript.new("dwarf_warrior", 1, n2) + u2.id = "p2_army_%d" % j + _p2.units.append(u2) + await _capture("15_p2_defenders", "focus", _p2_pos, 7.0) - # ── Arc step 7: army marches toward P2 — 3 warriors at midpoint ──────── - var midpoint: Vector2i = Vector2i( - (p1_capital_pos.x + p2_capital_pos.x) / 2, - (p1_capital_pos.y + p2_capital_pos.y) / 2, + # ── ACT V: Campaign + combat ─────────────────────────────────────────── + # Move all P1 army units to the midpoint between capitals + var mid: Vector2i = Vector2i( + (_p1_pos.x + _p2_pos.x) / 2, + (_p1_pos.y + _p2_pos.y) / 2, ) - for u_var: Variant in p1.units: + var offset: int = 0 + for u_var: Variant in _p1.units: var u: UnitScript = u_var as UnitScript if u != null and u.id.begins_with("p1_army_"): - u.position = midpoint - midpoint = midpoint + Vector2i(1, 0) - await _capture_step("09_army_marching", "fit") + u.position = mid + Vector2i(offset, 0) + offset += 1 + await _capture("16_army_marching_wide", "fit") + await _capture("17_army_marching_zoomed", "focus", mid, 9.0) - # ── Arc step 8: combat — P1 unit kills P2's warrior at their capital ─── - # Move one army warrior adjacent to P2, then remove P2's defender. - var attack_hex: Vector2i = Vector2i.ZERO - for cand_pos: Vector2i in _hex_ring(p2_capital_pos, 1): - if _game_map.tiles.has(cand_pos): - attack_hex = cand_pos + # Move attacker adjacent to P2 capital + var attack_hex: Vector2i = _hex_ring(_p2_pos, 1)[2] if _hex_ring(_p2_pos, 1).size() > 2 else _p2_pos + for u_var2: Variant in _p1.units: + var u2_: UnitScript = u_var2 as UnitScript + if u2_ != null and u2_.id == "p1_army_0": + u2_.position = attack_hex break - for u_var2: Variant in p1.units: - var u2: UnitScript = u_var2 as UnitScript - if u2 != null and u2.id == "p1_army_0": - u2.position = attack_hex - break - # Kill P2's defender - for j: int in range(p2.units.size() - 1, -1, -1): - if p2.units[j].id == "p2_warrior_initial": - p2.units.remove_at(j) - await _capture_step("10_combat_aftermath", "focus", p2_capital_pos) + await _capture("18_siege_approach", "focus", _p2_pos, 6.0) - # ── Arc step 9: P2 capital captured — ownership flips to P1 ──────────── - for j2: int in range(p2.cities.size() - 1, -1, -1): - if p2.cities[j2].id == "clan2_capital": - var captured_city: CityScript = p2.cities[j2] as CityScript - captured_city.owner_index = 0 - p1.cities.append(captured_city) - p2.cities.remove_at(j2) + # Combat — wound the P2 city + _c2.set_hp(35) + await _capture("19_city_under_attack", "focus", _p2_pos, 6.0) + + # Defender killed + for j2: int in range(_p2.units.size() - 1, -1, -1): + if _p2.units[j2].id.begins_with("p2_army_"): + _p2.units.remove_at(j2) + await _capture("20_combat_aftermath", "focus", _p2_pos, 6.0) + + # ── ACT VI: Conquest & consolidation ─────────────────────────────────── + # Capture P2 capital — ownership flip + for j3: int in range(_p2.cities.size() - 1, -1, -1): + if _p2.cities[j3].id == "clan2_capital": + var captured: CityScript = _p2.cities[j3] as CityScript + captured.owner_index = 0 + captured.set_hp(50) # repair-on-capture + _p1.cities.append(captured) + _p2.cities.remove_at(j3) break - await _capture_step("11_capital_captured", "focus", p2_capital_pos) - await _capture_step("12_empire_consolidated", "fit") + # CityRenderer caches owner_index in its dict on sync — force resync + # by rebuilding via sync_cities (which we already do every step). + await _capture("21_capital_captured", "focus", _p2_pos, 6.0) + await _capture("22_empire_consolidated_wide", "fit") + + # Found a third P1 city at the midpoint + if _game_map.tiles.has(mid): + var c3: CityScript = CityScript.new("clan1_outpost") + c3.owner_index = 0 + c3.position = mid + c3.city_name = "Karak Mid" + c3.set_population(2) + _p1.cities.append(c3) + # Tiny border around it + for t: Vector2i in _hex_ring(mid, 1): + c3.add_tile(t.x, t.y) + await _capture("23_three_city_empire", "fit") + await _capture("24_outpost_zoomed", "focus", mid, 8.0) + + # Final tableau — every unit in player colors visible + await _capture("25_final_tableau_wide", "fit") print("gameplay_arc: %d frames captured" % _captured) get_tree().quit() -func _capture_step(label: String, mode: String, focus: Vector2i = Vector2i.ZERO) -> void: +func _capture(label: String, mode: String, focus: Vector2i = Vector2i.ZERO, window_tiles: float = 5.0) -> void: if mode == "focus": - _focus_camera(focus, 5.0 * 384.0) + _focus_camera(focus, window_tiles * 384.0) else: _setup_camera_fit() _sync_renderers() - # Force a draw cycle now so the canvas-item changes + camera transform - # both make it into the framebuffer before we read it. Without this the - # captured texture is a stale snapshot of the FIRST rendered frame and - # every subsequent screenshot serialises identical bytes (verified hash - # collision across 12 frames in an earlier run). await get_tree().process_frame await get_tree().process_frame RenderingServer.force_draw(false, 0.0) @@ -258,12 +295,10 @@ func _setup_renderers() -> void: _hex.render_map(_game_map) var empty_fog: Array[Vector2i] = [] _hex.update_fog(_all_positions, empty_fog) - _units = UnitRendererScript.new() _units.name = "UnitRenderer" add_child(_units) _units.call("setup_visibility", 0, _game_map) - _cities = CityRendererScript.new() _cities.name = "CityRenderer" add_child(_cities) @@ -320,8 +355,6 @@ func _hex_ring(centre: Vector2i, radius: int) -> Array[Vector2i]: if radius <= 0: return [centre] var result: Array[Vector2i] = [] - # Walk the 6 sides of the ring at the given radius using the standard - # axial neighbour deltas. Six unit vectors for flat-top hex axial. var dirs: Array[Vector2i] = [ Vector2i(1, 0), Vector2i(1, -1), Vector2i(0, -1), Vector2i(-1, 0), Vector2i(-1, 1), Vector2i(0, 1),