From c3b1ae550e58ffd1011ca0687642386d32c6c415 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 15 May 2026 21:55:40 -0700 Subject: [PATCH] =?UTF-8?q?fix(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=90=9B=20fix=20axial=20tile=20iteration=20and=20coordinat?= =?UTF-8?q?e=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/modules/ai/ai_turn_bridge_state.gd | 33 ++++++++++------ src/simulator/api-gdext/src/ai.rs | 38 +++++++++++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd b/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd index e10b9c78..59c89d3b 100644 --- a/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd +++ b/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd @@ -118,25 +118,32 @@ static func build_tactical_state(focal: RefCounted) -> Dictionary: ## Build the full tiles JSON array for GdAiController::set_map(). ## Called once at game-start to populate the Rust-resident tile catalog. ## Returns a JSON string (Array of TacticalTile objects). +## +## CRITICAL: `game_map.tiles` is an axial-keyed dict — `r` is legitimately +## negative across roughly half the map (parallelogram, not rectangle). We +## MUST iterate the dict directly. An offset-rectangle `for row in range(h)` +## loop misses real axial tiles like `(25, -2)` AND mis-labels every cached +## tile (the `[col, row]` pair gets stored in the `hex` field, but +## `update_tile` later searches by axial `tile.position` and never matches). +## See `mc-ai/src/tactical/settle.rs:tile_at` for the consumer-side contract. static func build_tactical_tiles_json(game_map: RefCounted) -> String: if game_map == null: return "[]" - var width: int = int(game_map.width) - var height: int = int(game_map.height) var tiles: Array = [] - for row: int in range(height): - for col: int in range(width): - tiles.append(tile_to_dict(game_map, col, row)) + for axial: Vector2i in game_map.tiles.keys(): + tiles.append(tile_to_dict(game_map, axial.x, axial.y)) return JSON.stringify(tiles) ## Build a single tile JSON string for GdAiController::update_tile(). ## Called when a tile mutates (improvement built, border expanded, etc.). -static func build_single_tile_json(col: int, row: int) -> String: +## `q`, `r` are axial coordinates — matches the `tile.position` axial pair +## emitted by every EventBus signal that drives this path. +static func build_single_tile_json(q: int, r: int) -> String: var game_map: RefCounted = GameState.get_game_map() if game_map == null: return "" - return JSON.stringify(tile_to_dict(game_map, col, row)) + return JSON.stringify(tile_to_dict(game_map, q, r)) static func build_unit_catalog() -> Array: @@ -298,17 +305,21 @@ static func dict_string_field(entry: Dictionary, key: String) -> String: return "" -static func tile_to_dict(game_map: RefCounted, col: int, row: int) -> Dictionary: - var tile: Resource = game_map.get_tile(Vector2i(col, row)) +## `q`, `r` are axial coordinates. `game_map.get_tile` expects axial and +## `tiles` is axial-keyed; the emitted `hex` field is the axial pair so +## `update_tile`'s linear-search by `hex` (`mc-ai::tactical::settle::tile_at`) +## can locate the tile after later mutations. +static func tile_to_dict(game_map: RefCounted, q: int, r: int) -> Dictionary: + var tile: Resource = game_map.get_tile(Vector2i(q, r)) if tile == null: return { - "hex": [col, row], "biome": "", "yields": [0, 0, 0], + "hex": [q, r], "biome": "", "yields": [0, 0, 0], "resource": null, "is_coast": false, "owner": null, } var yields: Dictionary = tile.get_yields(-1) var resource: String = String(tile.resource_id) return { - "hex": [col, row], + "hex": [q, r], "biome": String(tile.biome_id), "yields": [ maxi(0, int(yields.get("food", 0))), diff --git a/src/simulator/api-gdext/src/ai.rs b/src/simulator/api-gdext/src/ai.rs index 914feacc..b2e16055 100644 --- a/src/simulator/api-gdext/src/ai.rs +++ b/src/simulator/api-gdext/src/ai.rs @@ -959,6 +959,44 @@ mod tests { assert!(!replace_by_hex(&mut map, (99, 99), tile_with((99, 99), "x"))); } + #[test] + fn set_map_json_roundtrip_preserves_negative_axial_r() { + // Regression — p2-10l-followup-gdai-set-map: + // Production failure mode was a 960-tile cache where `(25, -2)` could + // not be located after `update_tile`. Root cause: the GDScript + // producer iterated an offset rectangle and wrote `[col, row]` into + // the `hex` field, so legitimate negative-axial-r tiles were both + // absent from the cache AND mis-labelled. + // + // This test pins the Rust side of the contract: when the JSON tile + // array carries axial coords (including negative `r`), the cache + // round-trips them faithfully and a subsequent `update_tile`-style + // linear search by `hex` field locates the tile. + let tiles_json = r#"[ + {"hex":[0,0],"biome":"plains","yields":[2,1,1],"resource":null,"is_coast":false,"owner":null}, + {"hex":[25,-2],"biome":"hills","yields":[1,2,0],"resource":null,"is_coast":false,"owner":null}, + {"hex":[13,-7],"biome":"forest","yields":[1,1,0],"resource":null,"is_coast":false,"owner":null}, + {"hex":[-3,-1],"biome":"tundra","yields":[1,0,0],"resource":null,"is_coast":false,"owner":null} + ]"#; + let parsed: Vec = + serde_json::from_str(tiles_json).expect("axial JSON parses"); + let map = TacticalMap { width: 40, height: 24, tiles: parsed }; + + // `update_tile`'s key contract: `tiles.iter().find(|t| t.hex == key)`. + // Negative axial r must be findable. + let hit = map.tiles.iter().find(|t| t.hex == (25, -2)); + assert!(hit.is_some(), "(25, -2) must be locatable after JSON round-trip"); + assert_eq!(hit.unwrap().biome, "hills"); + + assert!(map.tiles.iter().find(|t| t.hex == (13, -7)).is_some()); + assert!(map.tiles.iter().find(|t| t.hex == (-3, -1)).is_some()); + + // Sanity: offset-rectangle pairs that would have been emitted by the + // buggy producer must NOT match — proves the test detects the + // regression rather than passing trivially. + assert!(map.tiles.iter().find(|t| t.hex == (25, 22)).is_none()); + } + #[test] fn stats_payload_for_emits_canonical_dict_shape() { let json = stats_payload_for("Attack");