From 27f4a4ea41888b104157ee9275f12cf49e9589a2 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 25 Jun 2026 04:40:14 -0400 Subject: [PATCH] =?UTF-8?q?refactor(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9B=B5=20p3-18=20=E2=80=94=20embark=20gate=20is=20data-drive?= =?UTF-8?q?n=20per-player=20config,=20not=20hardcoded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 hardcoded the tech ids ("shipbuilding"/"ocean_navigation") in Rust — a Rail-2 violation (JSON is the canonical content store) and not single-source. Per owner direction ("should be a player-level config setting"), the embark grant now lives in data and is cached per player: - mc_core::EmbarkLevel moves to the shared base crate (was mc-pathfinding) so PlayerState can hold it; mc-pathfinding re-exports it. Adds from_mechanic_key (the ONLY place the embark_* mechanic-key strings live). - The naval techs carry the grant in JSON via unlocks.mechanics: shipbuilding → embark_coast, ocean_navigation → embark_ocean. Which tech grants embark is now authored data, not Rust. - TechWeb::embark_level(researched) derives the strongest grant across a player's researched techs (None < Coast < Ocean). - PlayerState gains a cached embark_level field; process_science recomputes it each turn from the researched set (idempotent → save-load / tech injection covered). The move handler reads the cache (no per-move tech parsing). Tests: mc-core EmbarkLevel ordering + mapping; mc-tech embark_level method (inline web) + a real-data guard that authored naval.json carries the mechanics; mc-pathfinding 9/9 unchanged. All green. Co-Authored-By: Claude Opus 4.8 (1M context) --- public/resources/techs/naval.json | 16 +++- src/simulator/crates/mc-core/src/lib.rs | 1 + src/simulator/crates/mc-core/src/units.rs | 55 ++++++++++++ .../crates/mc-pathfinding/src/lib.rs | 20 ++--- .../crates/mc-state/src/game_state.rs | 6 ++ src/simulator/crates/mc-tech/src/web.rs | 87 +++++++++++++++++++ src/simulator/crates/mc-turn/src/processor.rs | 26 +++--- 7 files changed, 184 insertions(+), 27 deletions(-) diff --git a/public/resources/techs/naval.json b/public/resources/techs/naval.json index 17085957..64d33f2c 100644 --- a/public/resources/techs/naval.json +++ b/public/resources/techs/naval.json @@ -19,7 +19,13 @@ "units": [ "dwarf_river_galley" ], - "improvements": [] + "improvements": [], + "mechanics": [ + { + "key": "embark_coast", + "label": "Coastal Embarkation" + } + ] }, "flavor": "The river does not care how deep your mine goes.", "encyclopedia": { @@ -113,7 +119,13 @@ "units": [ "dwarf_deep_frigate" ], - "improvements": [] + "improvements": [], + "mechanics": [ + { + "key": "embark_ocean", + "label": "Ocean Embarkation" + } + ] }, "flavor": "The ocean is just a very wide mine with worse visibility.", "encyclopedia": { diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index b56cbac1..c1a80f9e 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -57,6 +57,7 @@ pub use lair::{LairCombatMode, LairId, SiegeOutcome, SiegePressure, SiegeState}; pub use resources::{ResourceKind, ResourceStockpile, StockpileError as ResourceStockpileError}; pub use scoring_weights::{LoadError, PersonalityDef, ScoringWeights}; pub use tactical_types::{BuildingPriors, TacticalBuildingSpec, TacticalMemory, TacticalUnitSpec}; +pub use units::EmbarkLevel; pub use player::{HexCoord, PlayerPrologue}; pub use player_presentation::PresentationPlayer; pub use production_origin::ProductionOrigin; diff --git a/src/simulator/crates/mc-core/src/units.rs b/src/simulator/crates/mc-core/src/units.rs index 7b6f6c83..a024a53c 100644 --- a/src/simulator/crates/mc-core/src/units.rs +++ b/src/simulator/crates/mc-core/src/units.rs @@ -15,6 +15,45 @@ use serde::{Deserialize, Serialize}; +/// How far a unit's owner can carry land units across water (Civ-style +/// embarkation; p3-18). Ordered `None < Coast < Ocean` so the strongest grant +/// across a player's researched techs wins via [`Ord::max`]. +/// +/// This is **data-driven, per-player config**: a tech's `unlocks.mechanics` may +/// carry the key `"embark_coast"` or `"embark_ocean"` (see the naval tech tree +/// JSON). `mc-turn` recomputes each player's level from their researched techs +/// and caches it on `PlayerState::embark_level`; the move pathfinder reads that +/// cached level to gate land-on-water movement. No tech ids are hardcoded in +/// Rust — only the stable mechanic-key → level mapping below. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum EmbarkLevel { + /// No embarkation — land units cannot enter water (default; no naval tech). + #[default] + None, + /// Coastal embark — cross near-shore (`IsCoast`) water only. Civ "Optics". + Coast, + /// Deep-water embark — cross any water, incl. open / deep ocean. Civ + /// "Astronomy". + Ocean, +} + +impl EmbarkLevel { + /// Map a tech `Mechanic.key` to the embark level it grants, if any: + /// `"embark_ocean"` → [`Self::Ocean`], `"embark_coast"` → [`Self::Coast`], + /// anything else → [`Self::None`]. This is the only place the mechanic-key + /// strings live; which tech carries which key is authored in JSON. + pub fn from_mechanic_key(key: &str) -> Self { + match key { + "embark_ocean" => Self::Ocean, + "embark_coast" => Self::Coast, + _ => Self::None, + } + } +} + /// AP cost of a single Specialist action (Found City, Prepare Land, Build /// Improvement, …). Values come from per-action JSON (see objective p3-11). /// @@ -187,4 +226,20 @@ mod tests { assert!(ap.can_afford(ActionCost::new(4))); assert!(!ap.can_afford(ActionCost::new(5))); } + + #[test] + fn embark_level_ordering_and_mechanic_mapping() { + // Ordered so the strongest grant wins via `max`. + assert!(EmbarkLevel::None < EmbarkLevel::Coast); + assert!(EmbarkLevel::Coast < EmbarkLevel::Ocean); + assert_eq!( + EmbarkLevel::None.max(EmbarkLevel::Coast).max(EmbarkLevel::Ocean), + EmbarkLevel::Ocean + ); + // Data-driven mechanic-key mapping. + assert_eq!(EmbarkLevel::from_mechanic_key("embark_coast"), EmbarkLevel::Coast); + assert_eq!(EmbarkLevel::from_mechanic_key("embark_ocean"), EmbarkLevel::Ocean); + assert_eq!(EmbarkLevel::from_mechanic_key("ocean_dominance"), EmbarkLevel::None); + assert_eq!(EmbarkLevel::default(), EmbarkLevel::None); + } } diff --git a/src/simulator/crates/mc-pathfinding/src/lib.rs b/src/simulator/crates/mc-pathfinding/src/lib.rs index f9a642f1..80c446b8 100644 --- a/src/simulator/crates/mc-pathfinding/src/lib.rs +++ b/src/simulator/crates/mc-pathfinding/src/lib.rs @@ -102,21 +102,13 @@ pub fn hex_distance(a: HexCoord, b: HexCoord) -> i32 { mc_core::algorithms::hex::offset_distance(a.0, a.1, b.0, b.1) } -/// Embarkation capability — how much water a land unit's player can cross -/// (Civ-style; p3-18). Derived from the naval tech tree by the caller; non-land -/// domains ignore it. Embarked land units fight at halved defence +/// Embarkation capability — re-exported from `mc-core`, the canonical shared +/// definition (p3-18). `is_passable` / `find_path` consume it to gate land units +/// on water per the owner's naval tech; the level is computed data-drivenly from +/// tech `unlocks.mechanics` and cached on `PlayerState::embark_level` by +/// `mc-turn`. Embarked land units fight at halved defence /// (`mc_combat::siege::embarked_defence_penalty`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EmbarkLevel { - /// No embarkation — land units are confined to land (no naval tech). - None, - /// Coastal embark — cross `IsCoast` (near-shore) water only. Civ "Optics"; - /// here gated by `shipbuilding`. - Coast, - /// Deep-water embark — cross any water, incl. open / deep ocean. Civ - /// "Astronomy"; here gated by `ocean_navigation`. - Ocean, -} +pub use mc_core::EmbarkLevel; /// Passability check — mirrors `_is_passable` at `pathfinder.gd:245-260`, plus /// the p3-18 embarkation gate for land units on water. diff --git a/src/simulator/crates/mc-state/src/game_state.rs b/src/simulator/crates/mc-state/src/game_state.rs index a6ce40e9..988f1d56 100644 --- a/src/simulator/crates/mc-state/src/game_state.rs +++ b/src/simulator/crates/mc-state/src/game_state.rs @@ -938,6 +938,12 @@ pub struct PlayerState { /// to serve the victory system's science-tech requirement checks. #[serde(default)] pub player_tech: Option, + /// p3-18 — cached embarkation capability, recomputed each turn from the + /// player's researched techs' `unlocks.mechanics` (data-driven; see + /// [`mc_core::EmbarkLevel`]). The move pathfinder reads this to gate land + /// units crossing water. `None` until a naval embark tech is researched. + #[serde(default)] + pub embark_level: mc_core::EmbarkLevel, /// Movable units currently on the map. pub units: Vec, /// World-space (col, row) positions of each city, aligned with `cities`. diff --git a/src/simulator/crates/mc-tech/src/web.rs b/src/simulator/crates/mc-tech/src/web.rs index b939fbf7..17bd28ab 100644 --- a/src/simulator/crates/mc-tech/src/web.rs +++ b/src/simulator/crates/mc-tech/src/web.rs @@ -232,6 +232,28 @@ impl TechWeb { .all(|req| researched.contains(req)) } + /// p3-18 — the embarkation capability granted by a set of researched techs. + /// + /// Data-driven: scans each researched tech's `unlocks.mechanics` for an + /// `embark_*` key and takes the strongest grant (`None < Coast < Ocean`). + /// The mechanic-key → level mapping is owned by + /// [`mc_core::EmbarkLevel::from_mechanic_key`]; which tech carries which key + /// is authored in the tech JSON — no tech ids are hardcoded here. + pub fn embark_level( + &self, + researched: &std::collections::HashSet, + ) -> mc_core::EmbarkLevel { + let mut lvl = mc_core::EmbarkLevel::None; + for tid in researched { + if let Some(def) = self.get_tech(tid) { + for m in &def.unlocks.mechanics { + lvl = lvl.max(mc_core::EmbarkLevel::from_mechanic_key(&m.key)); + } + } + } + lvl + } + /// Returns tech IDs belonging to `pillar`, sorted ascending by tier. pub fn techs_by_pillar(&self, pillar: &str) -> Vec<&str> { let mut matched: Vec<&TechDefinition> = self @@ -475,4 +497,69 @@ mod tests { failures.join("\n") ); } + + // ── p3-18 embarkation grants (data-driven) ──────────────────────────── + + #[test] + fn embark_level_takes_strongest_grant_across_researched() { + let json = r#"[ + {"id":"a","name":"A","unlocks":{"mechanics":[{"key":"embark_coast","label":"x"}]}}, + {"id":"b","name":"B","requires":["a"],"unlocks":{"mechanics":[{"key":"embark_ocean","label":"y"}]}}, + {"id":"c","name":"C"} + ]"#; + let web = TechWeb::from_json(json).expect("inline web builds"); + let set = + |ids: &[&str]| -> std::collections::HashSet { + ids.iter().map(|s| s.to_string()).collect() + }; + assert_eq!(web.embark_level(&set(&[])), mc_core::EmbarkLevel::None); + assert_eq!(web.embark_level(&set(&["c"])), mc_core::EmbarkLevel::None); + assert_eq!(web.embark_level(&set(&["a"])), mc_core::EmbarkLevel::Coast); + assert_eq!(web.embark_level(&set(&["b"])), mc_core::EmbarkLevel::Ocean); + assert_eq!( + web.embark_level(&set(&["a", "b"])), + mc_core::EmbarkLevel::Ocean, + "strongest grant across the set wins" + ); + } + + #[test] + fn authored_naval_techs_carry_embark_mechanics() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("workspace root") + .join("public/resources/techs/naval.json"); + let content = std::fs::read_to_string(&path).expect("naval.json readable"); + let defs: Vec = + serde_json::from_str(&content).expect("naval.json parses"); + + let grant = |id: &str| -> mc_core::EmbarkLevel { + let def = defs + .iter() + .find(|d| d.id == id) + .unwrap_or_else(|| panic!("tech {id} missing from naval.json")); + def.unlocks + .mechanics + .iter() + .map(|m| mc_core::EmbarkLevel::from_mechanic_key(&m.key)) + .max() + .unwrap_or(mc_core::EmbarkLevel::None) + }; + assert_eq!( + grant("shipbuilding"), + mc_core::EmbarkLevel::Coast, + "shipbuilding must carry the embark_coast mechanic" + ); + assert_eq!( + grant("ocean_navigation"), + mc_core::EmbarkLevel::Ocean, + "ocean_navigation must carry the embark_ocean mechanic" + ); + assert_eq!( + grant("naval_warfare"), + mc_core::EmbarkLevel::None, + "non-embark naval techs grant nothing" + ); + } } diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 35dffdd7..f8d58e0b 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -1045,6 +1045,14 @@ impl TurnProcessor { }); } player.science_pool = pt.research_progress() as i64; + + // p3-18 — recompute the cached embark capability from the player's + // researched-tech `unlocks.mechanics` (data-driven; idempotent, so + // save-load and mid-game tech injection are covered). + if let Some(pt_ref) = player.player_tech.as_ref() { + let lvl = web.embark_level(pt_ref.researched_techs()); + player.embark_level = lvl; + } } else { // No TechWeb loaded — accumulate raw pool so GDScript always has // a meaningful science_pool value to display. @@ -4687,17 +4695,13 @@ pub enum MoveOutcome { }, } -/// p3-18 — a player's embarkation capability, derived from the naval tech tree. -/// `ocean_navigation` → cross any water (deep ocean); `shipbuilding` → coastal -/// water only; otherwise land units stay landlocked. Used to gate `find_path` -/// for land units so a teched army can cross water. -fn embark_level_for(player: &crate::game_state::PlayerState) -> mc_pathfinding::EmbarkLevel { - use mc_pathfinding::EmbarkLevel; - match &player.player_tech { - Some(pt) if pt.has_tech("ocean_navigation") => EmbarkLevel::Ocean, - Some(pt) if pt.has_tech("shipbuilding") => EmbarkLevel::Coast, - _ => EmbarkLevel::None, - } +/// p3-18 — a player's embarkation capability, read from the per-player cache +/// (`PlayerState::embark_level`) that `process_science` recomputes each turn +/// from researched-tech `unlocks.mechanics` (data-driven; see +/// [`mc_core::EmbarkLevel`]). Used to gate `find_path` for land units so a +/// teched army can cross water. +fn embark_level_for(player: &crate::game_state::PlayerState) -> mc_core::EmbarkLevel { + player.embark_level } fn process_one_move(state: &mut GameState, req: &crate::game_state::MoveRequest) -> MoveOutcome {