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:
parent
7f8f8682ee
commit
27f4a4ea41
7 changed files with 184 additions and 27 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue