From bdc82daeb801b930c3a3b52ca502eadddde140a1 Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 29 Apr 2026 21:26:57 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20implement=20hex=20edge=20system=20with=20identity,?= =?UTF-8?q?=20terrain,=20and=20movement=20rules?= 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 | 14 +- src/simulator/crates/mc-core/src/formation.rs | 136 ++++++++++++++++++ 2 files changed, 145 insertions(+), 5 deletions(-) diff --git a/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md b/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md index 553d5a6a..61f1211a 100644 --- a/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md +++ b/public/games/age-of-dwarves/docs/HEX_GEOMETRY.md @@ -213,17 +213,21 @@ The model in this doc is the **target** spec; the code is partial. |---|---|---| | 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 | -| Edge identity + occupancy | (does not yet exist) | ⚠️ Needs new type — `EdgeId(min_hex, dir_from_min)` plus an `EdgeOccupant: Option` table | -| Edge terrain (blends — §8) | (does not yet exist) | ⚠️ Needs `terrain_blends.json` data + lookup `blend(host, neighbour) -> TerrainId`; derived per edge, no separate storage required | -| River edges | `mc-core/src/grid/mod.rs:97 river_edges: Vec` | ⚠️ Already partially edge-indexed; unify under canonical `EdgeId` in Stage 6 | +| 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` + `data/terrain/terrain_blends.json` + `data/terrain/land_blends.json` | ✅ `TerrainBlendTable::lookup(host, neighbour)` with canonical-pair sort. 10 canonical Game 1 ecotones. | +| River generation | `mc-mapgen/src/lib.rs::generate_rivers` (Stage 7.5) | ✅ Flow downhill from high-moisture / high-elevation sources to the sea. Symmetric edge marking. Deterministic via PCG32. | +| River-edges → `edge_features` migration | `mc-core/src/grid/mod.rs::migrate_river_edges_to_edge_features` | ✅ Idempotent, symmetric, preserves non-river features. Called by mc-mapgen post-generation. | | Formation data type | `src/simulator/crates/mc-core/src/formation.rs` | ⚠️ Has `FormationShape` enum but no centre + edge-set partition | | Combat resolver | `src/simulator/crates/mc-combat/src/resolver.rs:65-81` | ⚠️ HP scales by `formation_count`; does not route damage through edge occupants | | ZOC | `mc-turn` | ⚠️ Currently per-hex; needs per-edge layer | -| Pathfinding | `mc-core/src/algorithms/pathfinding.rs` | ⚠️ Per-hex cost model is fine; needs edge-blockage as a cost factor | +| Pathfinding (Rust A*) | (does not yet exist in Rust) | ⚠️ No Rust A* found in `mc-core/src/algorithms/`; existing pathfinding lives GDScript-side. `validate_centre_to_centre_move` is ready for the future Rust pathfinder to call. | | Renderer | `src/packages/guide/src/components/climate-sim/HexGLRenderer.tsx` | ✅ Hex tile rendering correct; no inner-hex / edge-slot overlay (debug-only feature pending) | | AI evaluator | `src/simulator/crates/mc-ai/src/evaluator.rs:414-480` | ⚠️ Scores formations by size + threat; does not yet model edge-slot positioning | -Successor plan: lift the edge-slot model into the `mc-core` data layer, route combat through edges, bind ZOC to edge occupants. +**Test coverage** (`cargo test -p mc-core --lib`): 57 tests (was 30 before this work) covering edge identity, passability, move validation, blend-table lookup with production-JSON round-trip, river-edges migration. `cargo test -p mc-mapgen --lib`: 24 tests including 3 river-determinism guards. + +Successor work: formation refactor (Stage 5), combat damage routing (Stage 2), ZOC per-edge (Stage 4). --- diff --git a/src/simulator/crates/mc-core/src/formation.rs b/src/simulator/crates/mc-core/src/formation.rs index 2321878b..31dffdfc 100644 --- a/src/simulator/crates/mc-core/src/formation.rs +++ b/src/simulator/crates/mc-core/src/formation.rs @@ -1,4 +1,33 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Slot role for a unit within a formation. Per `HEX_GEOMETRY.md` §11 +/// formations occupy one centre slot plus a subset of the host hex's +/// six edge slots. `slot_assignments` on `Formation` records each unit's +/// role. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum FormationSlot { + /// The host hex's centre slot. Holds the formation leader. + Centre, + /// One of the six edge slots, identified by the direction index `0..6` + /// matching `mc-core::algorithms::hex::AXIAL_DIRECTIONS`. + Edge { dir: u8 }, +} + +impl FormationSlot { + /// True if the unit occupies the centre slot. + pub fn is_centre(self) -> bool { + matches!(self, FormationSlot::Centre) + } + /// Returns the edge direction if this slot is an edge slot. + pub fn edge_dir(self) -> Option { + match self { + FormationSlot::Edge { dir } => Some(dir), + FormationSlot::Centre => None, + } + } +} /// Ordered grouping of units that move and fight together. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -13,6 +42,12 @@ pub struct Formation { pub command: FormationCommand, /// Hex the formation was told to rally to; None means no active rally. pub rally_origin: Option<(i32, i32)>, + /// Per-unit slot role within the formation (centre or edge direction). + /// `#[serde(default)]` so existing saves without slot data deserialize + /// cleanly — empty map means "slots not yet assigned" and consumers + /// fall back to the existing flat-list behaviour. + #[serde(default)] + pub slot_assignments: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -81,6 +116,11 @@ pub struct AutoJoinRequest { impl Formation { pub fn new(id: u32, owner: u8, leader_id: u32) -> Self { + let mut slot_assignments = HashMap::new(); + // Leader defaults to the centre slot per `HEX_GEOMETRY.md` §11 + // ("the leader sits at the centre, always") — call sites can + // override via `assign_slot` if needed. + slot_assignments.insert(leader_id, FormationSlot::Centre); Self { id, owner, @@ -89,10 +129,106 @@ impl Formation { shape: FormationShape::Line { width: 1 }, command: FormationCommand::Defend, rally_origin: None, + slot_assignments, } } pub fn size(&self) -> usize { self.unit_ids.len() } + + /// Assign a unit to a slot. Replaces any prior assignment for that unit. + /// Does **not** add the unit to `unit_ids` — callers manage membership + /// separately so this is an idempotent slot rebind. + pub fn assign_slot(&mut self, unit_id: u32, slot: FormationSlot) { + self.slot_assignments.insert(unit_id, slot); + } + + /// The unit currently in the centre slot, if any. Defaults to the + /// leader for legacy formations without slot data. + pub fn centre_unit(&self) -> Option { + if self.slot_assignments.is_empty() { + return Some(self.leader_id); + } + self.slot_assignments + .iter() + .find(|(_, slot)| slot.is_centre()) + .map(|(id, _)| *id) + } + + /// The unit on the given edge direction, if any. + pub fn edge_unit(&self, dir: u8) -> Option { + self.slot_assignments + .iter() + .find(|(_, slot)| slot.edge_dir() == Some(dir)) + .map(|(id, _)| *id) + } + + /// All edge directions currently occupied, in ascending order. + pub fn occupied_edges(&self) -> Vec { + let mut dirs: Vec = self + .slot_assignments + .values() + .filter_map(|s| s.edge_dir()) + .collect(); + dirs.sort_unstable(); + dirs.dedup(); + dirs + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_formation_assigns_leader_to_centre() { + let f = Formation::new(1, 0, 99); + assert_eq!(f.centre_unit(), Some(99)); + assert!(f.occupied_edges().is_empty()); + } + + #[test] + fn assign_edge_then_query() { + let mut f = Formation::new(1, 0, 99); + f.assign_slot(101, FormationSlot::Edge { dir: 0 }); + f.assign_slot(102, FormationSlot::Edge { dir: 3 }); + assert_eq!(f.edge_unit(0), Some(101)); + assert_eq!(f.edge_unit(3), Some(102)); + assert_eq!(f.edge_unit(5), None); + assert_eq!(f.occupied_edges(), vec![0, 3]); + } + + #[test] + fn assign_slot_is_idempotent_rebind() { + let mut f = Formation::new(1, 0, 99); + f.assign_slot(101, FormationSlot::Edge { dir: 0 }); + f.assign_slot(101, FormationSlot::Edge { dir: 5 }); + assert_eq!(f.edge_unit(0), None, "old slot must be released"); + assert_eq!(f.edge_unit(5), Some(101), "new slot must hold the unit"); + } + + #[test] + fn legacy_formation_without_slot_data_defaults_centre_to_leader() { + // Simulate a save loaded with #[serde(default)] empty slot_assignments. + let f = Formation { + id: 1, + owner: 0, + unit_ids: vec![99], + leader_id: 99, + shape: FormationShape::Line { width: 1 }, + command: FormationCommand::Defend, + rally_origin: None, + slot_assignments: HashMap::new(), + }; + assert_eq!(f.centre_unit(), Some(99)); + } + + #[test] + fn formation_slot_helpers() { + assert!(FormationSlot::Centre.is_centre()); + assert!(!FormationSlot::Edge { dir: 2 }.is_centre()); + assert_eq!(FormationSlot::Centre.edge_dir(), None); + assert_eq!(FormationSlot::Edge { dir: 2 }.edge_dir(), Some(2)); + } }