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:
parent
433634ac8d
commit
bdc82daeb8
2 changed files with 145 additions and 5 deletions
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue