fix(@projects/@magic-civilization): 🐛 fix axial tile iteration and coordinate handling

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-15 21:55:40 -07:00
parent 5753d0d4ab
commit c3b1ae550e
2 changed files with 60 additions and 11 deletions

View file

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

View file

@ -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<TacticalTile> =
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");