diff --git a/.project/objectives/p2-61-observation-recording-gates-from-tech.md b/.project/objectives/p2-61-observation-recording-gates-from-tech.md index d940572f..d6c44b08 100644 --- a/.project/objectives/p2-61-observation-recording-gates-from-tech.md +++ b/.project/objectives/p2-61-observation-recording-gates-from-tech.md @@ -2,12 +2,12 @@ id: p2-61 title: "Bind mc-observation gate_bits to player tech state — recording gates per-field" priority: p2 -status: stub +status: partial scope: game1 category: infra -owner: unassigned +owner: simulator-infra created: 2026-05-03 -updated_at: 2026-05-03 +updated_at: 2026-05-13 blocked_by: [] follow_ups: [] --- @@ -20,12 +20,38 @@ This objective wires the gate evaluation against the player's tech state. ## Acceptance -- ❌ `mc-observation::gate_bits_for_player(player_state, tech_state) -> GateMask` computes the mask from the player's researched tech list. -- ❌ Tech → field mapping authored in `public/resources/observation/gates.json` (e.g., `{ "meteorology": ["precipitation","temperature","wind"], "geology": ["lithology","mineral_visibility"], "ecology": ["fauna_density","flora_density"] }`). -- ❌ The recording pass in `mc-observation` consults the per-player mask; ungated fields write `None` into the player's observation cache (`p2-54b`), not the actual value. -- ❌ Researching a gating tech mid-game starts populating the corresponding fields from the next observation tick — never retroactively backfills. -- ❌ Cargo test in `mc-observation`: a player without `meteorology` records no `precipitation` values; the same player after researching `meteorology` records non-`None` from that turn forward. -- ❌ `tools/validate-game-data.py` extended to verify every field listed in `gates.json` matches a real `TileMeta` field name (no typos / drifted ids). +- ✓ `mc-observation::gate_bits_for_player(researched: &HashSet, gates: &GatesDef) -> GateMask` computes the mask from the player's researched tech list. Signature takes the raw researched set rather than `&PlayerTechState` so `mc-observation` does not need a `mc-tech` build dep. Evidence: `src/simulator/crates/mc-observation/src/gates.rs` (function + `GateMask` typed wrapper + `apply()` helper that writes `store.recording_gate_mask`). +- ✓ Tech → field mapping authored in `public/resources/observation/gates.json` using real `TileState`/`ObservationRecord` field names (the field examples in the original spec — `precipitation`, `lithology`, etc. — were illustrative; the canonical fields are `pressure`, `humidity`, `cape`, `canopy_cover`, `undergrowth`, `fungi_network`, `quality`, `fish_stock`, `reef_health`, `habitat_suitability`, `sulfate_aerosol`). Evidence: `public/resources/observation/gates.json`. +- ◐ The recording pass in `mc-observation` consults the per-player mask; ungated fields are written as **`0`** (the existing quantized-`u8` zero sentinel) rather than `Option::None`. Wiring the `None` semantic into `p2-54b`'s player observation cache is out of scope for this objective and stays with the cache-layer owner. Evidence: `store.rs::record_turn` already passes `recording_gate_mask` into `ObservationRecord::from_tile_gated`, and `GateMask::apply(&mut store)` is the public bridge from tech state to that mask. +- ✓ Researching a gating tech mid-game starts populating from the next observation tick; previously recorded turns are never backfilled. Evidence: integration test `researching_meteorology_starts_recording_next_turn_without_backfill` in `tests/tech_gating.rs` plus the pre-existing `store::tests::no_retroactive_recording`. +- ✓ Cargo test: a player without `meteorology` records `pressure == 0`; the same player after researching `meteorology` records `pressure > 0` from that turn forward. Evidence: `tests/tech_gating.rs::player_without_meteorology_records_no_pressure` + `researching_meteorology_starts_recording_next_turn_without_backfill`. +- ✓ `tools/validate-game-data.py` extended (`validate_observation_gates`) to verify every field listed in `gates.json` matches a real `ObservationRecord` field name, rejects innate fields, and cross-checks tech IDs against `public/resources/techs/*.json`. Evidence: `tools/validate-game-data.py` (new method, called from `run()`). Verbose run shows 11 PASS entries under `observation/gates.json`, zero new FAIL entries. + +Verification: +``` +$ cd src/simulator && cargo test -p mc-observation +test result: ok. 32 passed; 0 failed (unit tests) +test result: ok. 4 passed; 0 failed (tests/tech_gating.rs) +``` + +``` +$ python3 tools/validate-game-data.py --verbose | grep observation/gates + observation/gates.json + PASS …/techs/surveying[quality] + PASS …/techs/forestry[canopy_cover] + PASS …/techs/forestry[undergrowth] + PASS …/techs/sailing[fish_stock] + PASS …/techs/sailing[reef_health] + PASS …/techs/meteorology[pressure] + PASS …/techs/meteorology[humidity] + PASS …/techs/meteorology[cape] + PASS …/techs/geology[sulfate_aerosol] + PASS …/techs/herbalism[fungi_network] + PASS …/techs/herbalism[habitat_suitability] +``` + +Remaining work (kept as follow-up, not blocking this objective): +- `None`-vs-`0` semantic at the player observation cache layer (p2-54b cache owner). Today the cache reads the quantized `u8`; downstream consumers interpret `0` as "unobserved/ungated". If the cache is migrated to `Option`, the wire-up here will need a tweak — the gate mask itself stays the same. ## Source-of-truth rails diff --git a/public/resources/observation/gates.json b/public/resources/observation/gates.json new file mode 100644 index 00000000..771de4e2 --- /dev/null +++ b/public/resources/observation/gates.json @@ -0,0 +1,11 @@ +{ + "$comment": "Tech -> ObservationRecord field mappings (p2-61). When a player has researched a tech listed here, the named fields become recordable. Field names must match snake_case names on ObservationRecord/TileState. Innate fields (temperature, moisture, wind_speed, wind_direction, succession_progress) are always recorded and must NOT appear here. Tech IDs must exist in public/resources/techs/*.json (cross-checked by tools/validate-game-data.py).", + "techs": { + "surveying": ["quality"], + "forestry": ["canopy_cover", "undergrowth"], + "sailing": ["fish_stock", "reef_health"], + "meteorology": ["pressure", "humidity", "cape"], + "geology": ["sulfate_aerosol"], + "herbalism": ["fungi_network", "habitat_suitability"] + } +} diff --git a/src/simulator/crates/mc-observation/src/gates.rs b/src/simulator/crates/mc-observation/src/gates.rs new file mode 100644 index 00000000..e4fb7f08 --- /dev/null +++ b/src/simulator/crates/mc-observation/src/gates.rs @@ -0,0 +1,359 @@ +//! Tech -> recording-gate resolution for per-player observation capture. +//! +//! `gates.json` (single source of truth at +//! `public/resources/observation/gates.json`) maps tech IDs to the list of +//! [`ObservationRecord`](crate::record::ObservationRecord) fields each tech +//! enables. At runtime, `gate_bits_for_player` computes the `u32` recording +//! gate mask consumed by [`ObservationStore::record_turn`](crate::store::ObservationStore::record_turn). +//! +//! Authoring is per-field (a tech can enable a single field independently) even +//! though the underlying storage is bit-clustered — the same lens bit covers a +//! small group of fields (e.g. `meteorology` bit 3 covers `pressure`, +//! `humidity`, and `cape`). When a tech enables one field of a cluster it +//! enables all of them; the field-list-in-JSON form keeps the design intent +//! explicit and lets the validator catch typos. +//! +//! Innate fields (temperature, moisture, wind_speed, wind_direction, +//! succession_progress) are always recorded and MUST NOT appear in +//! `gates.json` — listing them is a parse error. + +use crate::record::gate_bits; +use serde::Deserialize; +use std::collections::{BTreeMap, HashSet}; +use std::fmt; +use std::str::FromStr; + +/// Canonical snake_case field names on [`crate::record::ObservationRecord`]. +/// +/// Mirrors `TileState` field names exactly so JSON content references the +/// physics struct directly. Innate fields are intentionally excluded — they +/// are always recorded and cannot be gated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum ObservationField { + Pressure, + Humidity, + Cape, + CanopyCover, + Undergrowth, + FungiNetwork, + Quality, + FishStock, + ReefHealth, + HabitatSuitability, + SulfateAerosol, +} + +impl ObservationField { + /// Canonical snake_case spelling. + pub fn as_str(self) -> &'static str { + match self { + Self::Pressure => "pressure", + Self::Humidity => "humidity", + Self::Cape => "cape", + Self::CanopyCover => "canopy_cover", + Self::Undergrowth => "undergrowth", + Self::FungiNetwork => "fungi_network", + Self::Quality => "quality", + Self::FishStock => "fish_stock", + Self::ReefHealth => "reef_health", + Self::HabitatSuitability => "habitat_suitability", + Self::SulfateAerosol => "sulfate_aerosol", + } + } + + /// The recording-gate bit that controls this field. Multiple fields can + /// share the same bit (the lens bit clusters semantically-grouped fields). + pub fn gate_bit(self) -> u32 { + match self { + Self::Pressure | Self::Humidity | Self::Cape => gate_bits::WIND_PRESSURE, + Self::CanopyCover => gate_bits::CANOPY, + Self::Undergrowth => gate_bits::UNDERGROWTH, + Self::FungiNetwork => gate_bits::FUNGI_NETWORK, + Self::Quality => gate_bits::TILE_QUALITY, + Self::FishStock | Self::ReefHealth => gate_bits::MARINE_HEALTH, + Self::HabitatSuitability => gate_bits::WILDLIFE_HABITAT, + Self::SulfateAerosol => gate_bits::SULFATE_AEROSOL, + } + } + + /// All gateable fields. Useful for validation and exhaustive iteration. + pub fn all() -> &'static [ObservationField] { + &[ + Self::Pressure, + Self::Humidity, + Self::Cape, + Self::CanopyCover, + Self::Undergrowth, + Self::FungiNetwork, + Self::Quality, + Self::FishStock, + Self::ReefHealth, + Self::HabitatSuitability, + Self::SulfateAerosol, + ] + } + + /// Innate (always-recorded) field names. Rejected by the JSON parser. + pub fn innate_field_names() -> &'static [&'static str] { + &[ + "temperature", + "moisture", + "wind_speed", + "wind_direction", + "succession_progress", + ] + } +} + +impl fmt::Display for ObservationField { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ObservationField { + type Err = GatesError; + + fn from_str(s: &str) -> Result { + match s { + "pressure" => Ok(Self::Pressure), + "humidity" => Ok(Self::Humidity), + "cape" => Ok(Self::Cape), + "canopy_cover" => Ok(Self::CanopyCover), + "undergrowth" => Ok(Self::Undergrowth), + "fungi_network" => Ok(Self::FungiNetwork), + "quality" => Ok(Self::Quality), + "fish_stock" => Ok(Self::FishStock), + "reef_health" => Ok(Self::ReefHealth), + "habitat_suitability" => Ok(Self::HabitatSuitability), + "sulfate_aerosol" => Ok(Self::SulfateAerosol), + innate if ObservationField::innate_field_names().contains(&innate) => { + Err(GatesError::InnateField(innate.to_string())) + } + other => Err(GatesError::UnknownField(other.to_string())), + } + } +} + +/// Errors from parsing or evaluating `gates.json`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GatesError { + /// The JSON document did not parse. + Json(String), + /// A field name does not match any [`ObservationField`] variant. + UnknownField(String), + /// An innate field was listed in `gates.json` — innate fields are always + /// recorded and must not appear in the gate definitions. + InnateField(String), +} + +impl fmt::Display for GatesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Json(msg) => write!(f, "gates.json parse error: {msg}"), + Self::UnknownField(name) => write!( + f, + "unknown ObservationField '{name}' in gates.json (no matching ObservationRecord field)" + ), + Self::InnateField(name) => write!( + f, + "innate field '{name}' listed in gates.json — innate fields are always recorded and must not be gated" + ), + } + } +} + +impl std::error::Error for GatesError {} + +/// Typed bitset returned by [`gate_bits_for_player`]. Pass `.bits()` into +/// [`ObservationStore::record_turn`](crate::store::ObservationStore::record_turn) +/// via the store's `recording_gate_mask` field, or call [`apply`] to write it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GateMask(u32); + +impl GateMask { + /// Empty mask — only innate fields will be recorded. + pub const EMPTY: GateMask = GateMask(0); + + /// Raw bitset compatible with + /// [`ObservationRecord::apply_recording_gate`](crate::record::ObservationRecord::apply_recording_gate). + pub fn bits(self) -> u32 { + self.0 + } + + /// Whether the bit for `field` is set in this mask. + pub fn allows(self, field: ObservationField) -> bool { + (self.0 & (1 << field.gate_bit())) != 0 + } + + /// Apply this mask to an [`ObservationStore`](crate::store::ObservationStore) + /// so subsequent `record_turn` calls capture the gated fields. + pub fn apply(self, store: &mut crate::store::ObservationStore) { + store.recording_gate_mask = self.0; + } +} + +/// Parsed `gates.json` — tech ID -> set of fields the tech unlocks. +/// +/// Stored as a `BTreeMap` for deterministic ordering in tests and serialization. +#[derive(Debug, Clone, Default)] +pub struct GatesDef { + tech_to_fields: BTreeMap>, +} + +#[derive(Debug, Deserialize)] +struct GatesJson { + #[serde(default)] + techs: BTreeMap>, +} + +impl GatesDef { + /// Parse a `gates.json` document. Returns [`GatesError`] on unknown field + /// names or innate-field references. + pub fn from_json_str(json: &str) -> Result { + let raw: GatesJson = serde_json::from_str(json).map_err(|e| GatesError::Json(e.to_string()))?; + let mut tech_to_fields: BTreeMap> = BTreeMap::new(); + for (tech_id, field_names) in raw.techs { + let mut fields = Vec::with_capacity(field_names.len()); + for name in field_names { + fields.push(ObservationField::from_str(&name)?); + } + tech_to_fields.insert(tech_id, fields); + } + Ok(Self { tech_to_fields }) + } + + /// Fields unlocked by `tech_id`, or empty slice if the tech is not listed. + pub fn fields_for_tech(&self, tech_id: &str) -> &[ObservationField] { + self.tech_to_fields + .get(tech_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + /// All techs referenced in this gates definition. Useful for the + /// `validate-game-data.py` cross-check against tech JSON. + pub fn tech_ids(&self) -> impl Iterator { + self.tech_to_fields.keys().map(String::as_str) + } +} + +/// Compute the recording-gate mask for a player given their researched-tech +/// set. Takes a plain `&HashSet` rather than a `&PlayerTechState` so +/// `mc-observation` does not have to depend on `mc-tech` (avoids a circular +/// build dep — `mc-tech` consumers may want to write into observation, and the +/// dependency flow stays one-way). +/// +/// Each researched tech contributes the bits for every field listed under it +/// in `gates`. Untracked techs (researched but not present in `gates.json`) +/// contribute nothing, which is the correct behavior — only techs that gate +/// observation fields appear in `gates.json`. +pub fn gate_bits_for_player(researched: &HashSet, gates: &GatesDef) -> GateMask { + let mut mask: u32 = 0; + for tech_id in researched { + for field in gates.fields_for_tech(tech_id) { + mask |= 1 << field.gate_bit(); + } + } + GateMask(mask) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_minimal_gates_json() { + let json = r#"{ "techs": { "meteorology": ["pressure", "humidity", "cape"] } }"#; + let gates = GatesDef::from_json_str(json).expect("should parse"); + assert_eq!( + gates.fields_for_tech("meteorology"), + &[ + ObservationField::Pressure, + ObservationField::Humidity, + ObservationField::Cape + ] + ); + assert!(gates.fields_for_tech("nonexistent").is_empty()); + } + + #[test] + fn rejects_unknown_field_name() { + // "precipitation" is referenced only in design docs; it is not a + // real ObservationRecord field, so the parser must reject it. + let json = r#"{ "techs": { "meteorology": ["precipitation"] } }"#; + let err = GatesDef::from_json_str(json).unwrap_err(); + assert!(matches!(err, GatesError::UnknownField(ref n) if n == "precipitation")); + } + + #[test] + fn rejects_innate_field_name() { + // Innate fields are always recorded; listing them is a content bug. + let json = r#"{ "techs": { "meteorology": ["temperature"] } }"#; + let err = GatesDef::from_json_str(json).unwrap_err(); + assert!(matches!(err, GatesError::InnateField(ref n) if n == "temperature")); + } + + #[test] + fn empty_player_set_yields_empty_mask() { + let gates = GatesDef::from_json_str(r#"{ "techs": { "meteorology": ["pressure"] } }"#) + .unwrap(); + let researched: HashSet = HashSet::new(); + let mask = gate_bits_for_player(&researched, &gates); + assert_eq!(mask.bits(), 0); + assert!(!mask.allows(ObservationField::Pressure)); + } + + #[test] + fn researching_tech_sets_field_bit() { + let gates = GatesDef::from_json_str( + r#"{ "techs": { "meteorology": ["pressure", "humidity", "cape"] } }"#, + ) + .unwrap(); + let mut researched: HashSet = HashSet::new(); + researched.insert("meteorology".to_string()); + let mask = gate_bits_for_player(&researched, &gates); + assert!(mask.allows(ObservationField::Pressure)); + assert!(mask.allows(ObservationField::Humidity)); + assert!(mask.allows(ObservationField::Cape)); + assert!(!mask.allows(ObservationField::CanopyCover)); + } + + #[test] + fn untracked_tech_contributes_nothing() { + // Player researched "writing" — fine, but gates.json doesn't list it. + let gates = + GatesDef::from_json_str(r#"{ "techs": { "meteorology": ["pressure"] } }"#).unwrap(); + let mut researched: HashSet = HashSet::new(); + researched.insert("writing".to_string()); + let mask = gate_bits_for_player(&researched, &gates); + assert_eq!(mask.bits(), 0); + } + + #[test] + fn multiple_techs_or_together() { + let gates = GatesDef::from_json_str( + r#"{ "techs": { + "meteorology": ["pressure"], + "forestry": ["canopy_cover"], + "geology": ["sulfate_aerosol"] + } }"#, + ) + .unwrap(); + let researched: HashSet = ["meteorology", "geology"] + .iter() + .map(|s| s.to_string()) + .collect(); + let mask = gate_bits_for_player(&researched, &gates); + assert!(mask.allows(ObservationField::Pressure)); + assert!(mask.allows(ObservationField::SulfateAerosol)); + assert!(!mask.allows(ObservationField::CanopyCover)); + } + + #[test] + fn field_from_str_round_trips() { + for f in ObservationField::all() { + assert_eq!(ObservationField::from_str(f.as_str()).unwrap(), *f); + } + } +} diff --git a/src/simulator/crates/mc-observation/src/lib.rs b/src/simulator/crates/mc-observation/src/lib.rs index fc169f90..11c8f6ef 100644 --- a/src/simulator/crates/mc-observation/src/lib.rs +++ b/src/simulator/crates/mc-observation/src/lib.rs @@ -17,10 +17,12 @@ //! compact 16-byte per-tile footprint. mod fog; +mod gates; mod record; mod store; pub use fog::{apply_fog, is_fogged, prune_expired, ActiveFog}; +pub use gates::{gate_bits_for_player, GateMask, GatesDef, GatesError, ObservationField}; pub use record::gate_bits; pub use record::ObservationRecord; pub use store::{ObservationStore, TurnObservation}; diff --git a/src/simulator/crates/mc-observation/tests/tech_gating.rs b/src/simulator/crates/mc-observation/tests/tech_gating.rs new file mode 100644 index 00000000..76d02f5a --- /dev/null +++ b/src/simulator/crates/mc-observation/tests/tech_gating.rs @@ -0,0 +1,131 @@ +//! Integration tests for p2-61: tech-gated observation recording. +//! +//! Verifies the acceptance bullets: +//! - A player without `meteorology` records no pressure values. +//! - The same player after researching `meteorology` records non-zero +//! pressure from that turn forward. +//! - Researching mid-game never retroactively backfills earlier turns. + +use mc_core::grid::GridState; +use mc_observation::{ + gate_bits_for_player, GatesDef, ObservationField, ObservationStore, +}; +use std::collections::HashSet; + +const GATES_JSON: &str = r#"{ + "techs": { + "meteorology": ["pressure", "humidity", "cape"], + "forestry": ["canopy_cover", "undergrowth"], + "geology": ["sulfate_aerosol"] + } +}"#; + +fn make_grid_with_pressure() -> GridState { + let mut grid = GridState::new(2, 2); + grid.tiles[0].temperature = 0.5; + grid.tiles[0].moisture = 0.3; + grid.tiles[0].pressure = 1013.0; + grid.tiles[0].humidity = 0.6; + grid.tiles[0].cape = 0.4; + grid.tiles[0].canopy_cover = 0.7; + grid.tiles[0].sulfate_aerosol = 0.2; + grid +} + +#[test] +fn player_without_meteorology_records_no_pressure() { + let gates = GatesDef::from_json_str(GATES_JSON).expect("gates parse"); + let researched: HashSet = HashSet::new(); // no techs researched + + let mask = gate_bits_for_player(&researched, &gates); + assert!(!mask.allows(ObservationField::Pressure)); + + let mut store = ObservationStore::new(2, 2); + mask.apply(&mut store); + + let grid = make_grid_with_pressure(); + store.record_turn(1, &grid, &[0]); + + let rec = &store.turns[0].records[0]; + assert_eq!(rec.pressure, 0, "no meteorology -> pressure zeroed"); + assert_eq!(rec.humidity, 0); + assert_eq!(rec.cape, 0); + // Innate field still recorded + assert!(rec.temperature > 0, "innate temperature always recorded"); +} + +#[test] +fn researching_meteorology_starts_recording_next_turn_without_backfill() { + let gates = GatesDef::from_json_str(GATES_JSON).expect("gates parse"); + + let mut researched: HashSet = HashSet::new(); + let mut store = ObservationStore::new(2, 2); + + // Turn 1: no meteorology -> pressure stays zeroed. + gate_bits_for_player(&researched, &gates).apply(&mut store); + let grid = make_grid_with_pressure(); + store.record_turn(1, &grid, &[0]); + + // Mid-game: research meteorology, recompute mask, apply. + researched.insert("meteorology".to_string()); + gate_bits_for_player(&researched, &gates).apply(&mut store); + + // Turn 2: pressure now populated. + store.record_turn(2, &grid, &[0]); + + let rec_t1 = &store.turns[0].records[0]; + let rec_t2 = &store.turns[1].records[0]; + + assert_eq!(rec_t1.pressure, 0, "no retroactive backfill for turn 1"); + assert_eq!(rec_t1.humidity, 0); + assert_eq!(rec_t1.cape, 0); + + assert!(rec_t2.pressure > 0, "turn 2 pressure populated after research"); + assert!(rec_t2.humidity > 0); + assert!(rec_t2.cape > 0); +} + +#[test] +fn shipped_gates_json_parses_and_validates() { + // The canonical content file at public/resources/observation/gates.json + // must parse with the strict ObservationField validator. This catches typos + // and innate-field references at build time. + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../../public/resources/observation/gates.json" + ); + let json = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("read {path}: {e}")); + let gates = GatesDef::from_json_str(&json) + .unwrap_or_else(|e| panic!("parse shipped gates.json: {e}")); + // Spot-check: meteorology should map to at least pressure. + assert!(gates + .fields_for_tech("meteorology") + .contains(&ObservationField::Pressure)); +} + +#[test] +fn multiple_techs_unlock_their_respective_fields() { + let gates = GatesDef::from_json_str(GATES_JSON).expect("gates parse"); + + let researched: HashSet = ["forestry", "geology"] + .iter() + .map(|s| s.to_string()) + .collect(); + let mask = gate_bits_for_player(&researched, &gates); + + assert!(mask.allows(ObservationField::CanopyCover)); + assert!(mask.allows(ObservationField::Undergrowth)); + assert!(mask.allows(ObservationField::SulfateAerosol)); + assert!(!mask.allows(ObservationField::Pressure)); + + let mut store = ObservationStore::new(2, 2); + mask.apply(&mut store); + let grid = make_grid_with_pressure(); + store.record_turn(1, &grid, &[0]); + + let rec = &store.turns[0].records[0]; + assert!(rec.canopy_cover > 0); + assert!(rec.sulfate_aerosol > 0); + assert_eq!(rec.pressure, 0, "no meteorology -> pressure stays zeroed"); +} diff --git a/tools/validate-game-data.py b/tools/validate-game-data.py index 3d687931..2685330a 100644 --- a/tools/validate-game-data.py +++ b/tools/validate-game-data.py @@ -693,6 +693,83 @@ class GameDataValidator: else: self._ok(w_label) + # ── Observation gates (p2-61) ─────────────────────────────────── + # + # public/resources/observation/gates.json maps tech IDs to lists of + # ObservationRecord field names. The Rust side (mc-observation::GatesDef) + # rejects unknown / innate fields at parse time; this validator cross-checks + # the same constraints from the content side and confirms each referenced + # tech ID exists in the tech registry. + + # Canonical gateable field names — must match + # mc-observation::ObservationField::all() exactly. + OBSERVATION_GATEABLE_FIELDS = frozenset({ + "pressure", "humidity", "cape", + "canopy_cover", "undergrowth", "fungi_network", + "quality", + "fish_stock", "reef_health", + "habitat_suitability", + "sulfate_aerosol", + }) + # Innate fields are always recorded; listing them in gates.json is a bug. + OBSERVATION_INNATE_FIELDS = frozenset({ + "temperature", "moisture", "wind_speed", "wind_direction", + "succession_progress", + }) + + def validate_observation_gates(self) -> None: + path = self.resources / "observation" / "gates.json" + if not path.exists(): + # File is optional during transition — skip silently rather than fail. + return + data, err = load_json_safe(path) + if err: + self._fail("observation/gates.json", f"parse error: {err}") + return + print("\n observation/gates.json") + rel = str(path.relative_to(self.root)) + if not isinstance(data, dict): + self._fail(rel, "top-level must be an object") + return + techs = data.get("techs") + if not isinstance(techs, dict): + self._fail(f"{rel}/techs", "must be an object mapping tech_id -> [field]") + return + + # Build the set of known tech IDs from public/resources/techs/*.json. + known_tech_ids: set[str] = set() + techs_dir = self.resources / "techs" + if techs_dir.is_dir(): + for tf in sorted(techs_dir.glob("*.json")): + tdata, _ = load_json_safe(tf) + if isinstance(tdata, list): + for entry in tdata: + if isinstance(entry, dict) and isinstance(entry.get("id"), str): + known_tech_ids.add(entry["id"]) + elif isinstance(tdata, dict) and isinstance(tdata.get("id"), str): + known_tech_ids.add(tdata["id"]) + + for tech_id, fields in techs.items(): + label = f"{rel}/techs/{tech_id}" + if not isinstance(fields, list): + self._fail(label, "value must be a list of field names") + continue + if known_tech_ids and tech_id not in known_tech_ids: + self._fail(label, f"tech_id '{tech_id}' not present in resources/techs/*.json") + continue + for fname in fields: + fl = f"{label}[{fname}]" + if not isinstance(fname, str): + self._fail(fl, "field name must be a string") + continue + if fname in self.OBSERVATION_INNATE_FIELDS: + self._fail(fl, "innate fields are always recorded and must not be gated") + continue + if fname not in self.OBSERVATION_GATEABLE_FIELDS: + self._fail(fl, f"unknown ObservationRecord field '{fname}'") + continue + self._ok(fl) + # ── Main ───────────────────────────────────────────────────────── def run(self): @@ -738,6 +815,7 @@ class GameDataValidator: self.validate_building_requires_existing() self.validate_cross_refs() self.validate_score() + self.validate_observation_gates() def report(self) -> int: print(f"\n{'=' * 60}")