refactor(hex-grid): ♻️ Standardize hex direction calculations and update geometry documentation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-18 18:41:41 -07:00
parent 05db83146c
commit e8c407af0f
2 changed files with 52 additions and 3 deletions

View file

@ -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<EdgeId, MoveBlockedReason>`. 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. |

View file

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