refactor(@projects/@magic-civilization): p3-18 — embark gate is data-driven per-player config, not hardcoded

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 04:40:14 -04:00
parent 7f8f8682ee
commit 27f4a4ea41
7 changed files with 184 additions and 27 deletions

View file

@ -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": {

View file

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

View file

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

View file

@ -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.

View file

@ -938,6 +938,12 @@ pub struct PlayerState {
/// to serve the victory system's science-tech requirement checks.
#[serde(default)]
pub player_tech: Option<PlayerTechState>,
/// 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<MapUnit>,
/// World-space (col, row) positions of each city, aligned with `cities`.

View file

@ -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<String>,
) -> 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<String> {
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<TechDefinition> =
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"
);
}
}

View file

@ -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 {