feat(@projects/@magic-civilization): implement tech-gated observation recording

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-13 16:21:35 -07:00
parent d6b3e8f158
commit fe5db2d25f
6 changed files with 616 additions and 9 deletions

View file

@ -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<String>, 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<u8>`, the wire-up here will need a tweak — the gate mask itself stays the same.
## Source-of-truth rails

View file

@ -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"]
}
}

View file

@ -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<Self, Self::Err> {
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<String, Vec<ObservationField>>,
}
#[derive(Debug, Deserialize)]
struct GatesJson {
#[serde(default)]
techs: BTreeMap<String, Vec<String>>,
}
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<Self, GatesError> {
let raw: GatesJson = serde_json::from_str(json).map_err(|e| GatesError::Json(e.to_string()))?;
let mut tech_to_fields: BTreeMap<String, Vec<ObservationField>> = 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<Item = &str> {
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<String>` 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<String>, 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<String> = 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<String> = 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<String> = 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<String> = ["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);
}
}
}

View file

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

View file

@ -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<String> = 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<String> = 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<String> = ["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");
}

View file

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