feat(@projects/@magic-civilization): implement hex edge system with identity, terrain, and movement rules

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-29 21:26:57 -04:00
parent 433634ac8d
commit bdc82daeb8
2 changed files with 145 additions and 5 deletions

View file

@ -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<UnitId>` 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<i32>` | ⚠️ 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<EdgeId, MoveBlockedReason>`. 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).
---

View file

@ -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<u8> {
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<u32, FormationSlot>,
}
#[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<u32> {
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<u32> {
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<u8> {
let mut dirs: Vec<u8> = 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));
}
}