From e8c407af0f111feecaf8eeba082d241857ef2ceb Mon Sep 17 00:00:00 2001 From: autocommit Date: Mon, 18 May 2026 18:41:41 -0700 Subject: [PATCH] =?UTF-8?q?refactor(hex-grid):=20=E2=99=BB=EF=B8=8F=20Stan?= =?UTF-8?q?dardize=20hex=20direction=20calculations=20and=20update=20geome?= =?UTF-8?q?try=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../games/age-of-dwarves/docs/HEX_GEOMETRY.md | 3 +- .../crates/mc-core/src/algorithms/hex.rs | 52 ++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md b/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md index 0d7a1302..fcb0ee1c 100644 --- a/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md +++ b/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md @@ -212,7 +212,8 @@ The model in this doc is the **target** spec; the code is partial. | Concern | File | Status | |---|---|---| | Hex coord math (axial / cube / odd-q offset) | `src/simulator/crates/mc-core/src/algorithms/hex.rs` | ✅ Implemented; matches `HexUtils.gd` and `HexGrid.ts` | -| Direction indices `0..5` | `mc-core/src/algorithms/hex.rs:11-19` | ✅ Single source of truth | +| Direction indices `0..5` | `mc-core/src/algorithms/hex.rs:11-19` | ✅ Single source of truth (`AXIAL_DIRECTIONS`) | +| Odd-q offset neighbour table | `mc-core/src/algorithms/hex.rs::ODD_Q_NEIGHBORS` | ✅ Derived from `AXIAL_DIRECTIONS`; pinned by `odd_q_table_agrees_with_axial_directions`. Tile fields that store axial direction indices (e.g., `wind_direction`, `flow_out`) round-trip through either table without drift. | | Edge identity + occupancy | `mc-core/src/grid/edge.rs` | ✅ `EdgeId(min_hex, dir_from_min)`, `EdgeOccupant { unit_id, aligned_to, owner_player_id }`, `EdgeFeatures { river, road, bridge, wall_owner }`. Sparse storage in `GridState::edges` and `GridState::edge_features`. | | Edge passability + move validation | `mc-core/src/grid/mod.rs` | ✅ `GridState::is_edge_passable_for(edge, player_id)`, `validate_centre_to_centre_move(from, to, player_id) -> Result`. Wall and occupant rules compose. | | Edge terrain (blends — §8) | `mc-core/src/grid/terrain_blend.rs` + `public/resources/tiles/terrain_blends.json` + `public/resources/tiles/land_blends.json` | ✅ `TerrainBlendTable::lookup(host, neighbour)` with canonical-pair sort. 10 canonical Game 1 ecotones. | diff --git a/src/simulator/crates/mc-core/src/algorithms/hex.rs b/src/simulator/crates/mc-core/src/algorithms/hex.rs index 9264c102..92a114dd 100644 --- a/src/simulator/crates/mc-core/src/algorithms/hex.rs +++ b/src/simulator/crates/mc-core/src/algorithms/hex.rs @@ -6,6 +6,21 @@ //! //! Primary storage: axial (q, r) where s = -q - r (cube constraint). //! Offset grid: odd-q (col = q, row = r + (q - (q & 1)) / 2). +//! +//! ## Direction convention +//! +//! `AXIAL_DIRECTIONS` is the single source of truth for what direction `d` +//! means. `ODD_Q_NEIGHBORS` is its offset-coordinate projection — derived +//! such that, for any `(col, row, dir)`: +//! +//! ```ignore +//! (col + dc, row + dr) == axial_to_offset(offset_to_axial(col, row) + AXIAL_DIRECTIONS[dir]) +//! ``` +//! +//! pinned by the `odd_q_table_agrees_with_axial_directions` test. Tile +//! fields that store a "direction" (e.g., `wind_direction`, `flow_out`) +//! are axial indices and can be routed through either table without +//! drifting. /// Axial direction vectors: E(0), NE(1), NW(2), W(3), SW(4), SE(5). /// Matches GDScript HexUtils.AXIAL_DIRECTIONS. @@ -20,11 +35,17 @@ pub const AXIAL_DIRECTIONS: [(i32, i32); 6] = [ /// Odd-q offset neighbor deltas: [even_col][dir], [odd_col][dir]. /// Each entry is (dcol, drow) for directions 0-5 (E, NE, NW, W, SW, SE). +/// +/// Derived from `AXIAL_DIRECTIONS` via the `offset_to_axial` / +/// `axial_to_offset` bijection — i.e., for every `(col, row, dir)`: +/// `(col + dc, row + dr)` equals +/// `axial_to_offset(offset_to_axial(col, row) + AXIAL_DIRECTIONS[dir])`. +/// The `odd_q_table_agrees_with_axial_directions` test pins this. pub const ODD_Q_NEIGHBORS: [[(i32, i32); 6]; 2] = [ // even col (col & 1 == 0) - [(1, 0), (1, -1), (0, -1), (-1, 0), (-1, 1), (0, 1)], + [(1, 0), (1, -1), (0, -1), (-1, -1), (-1, 0), (0, 1)], // odd col (col & 1 == 1) - [(1, 0), (1, 1), (0, 1), (-1, 0), (-1, -1), (0, -1)], + [(1, 1), (1, 0), (0, -1), (-1, 0), (-1, 1), (0, 1)], ]; /// Direction-to-polygon-edge mapping (critical: NOT the same index). @@ -368,6 +389,33 @@ mod tests { assert!(a >= 0.0 && a < 1.0); } + /// Pins the `ODD_Q_NEIGHBORS` table to be axially consistent — every + /// entry must match what you'd get by routing through axial coords + + /// `AXIAL_DIRECTIONS`. Without this, the two direction conventions + /// diverge silently and any code crossing offset↔axial (e.g., + /// `tile.wind_direction` stored as an axial index, read by + /// `upwind_offset`) computes the wrong neighbour. + #[test] + fn odd_q_table_agrees_with_axial_directions() { + for col in -5..=5i32 { + for row in -5..=5i32 { + for dir in 0..6usize { + let (q, r) = offset_to_axial(col, row); + let (dq, dr) = AXIAL_DIRECTIONS[dir]; + let via_axial = axial_to_offset(q + dq, r + dr); + let parity = (col & 1) as usize; + let (dc, drt) = ODD_Q_NEIGHBORS[parity][dir]; + let via_table = (col + dc, row + drt); + assert_eq!( + via_axial, via_table, + "ODD_Q_NEIGHBORS disagrees with AXIAL_DIRECTIONS at \ + offset=({col},{row}) dir={dir}", + ); + } + } + } + } + #[test] fn test_classify_terrain_basic() { assert_eq!(classify_terrain(0.02, 0.5, 0.3, 0.0), "ice");