feat(@projects/@magic-civilization): ✨ implement tech-gated observation recording
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
d6b3e8f158
commit
fe5db2d25f
6 changed files with 616 additions and 9 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
11
public/resources/observation/gates.json
Normal file
11
public/resources/observation/gates.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
359
src/simulator/crates/mc-observation/src/gates.rs
Normal file
359
src/simulator/crates/mc-observation/src/gates.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
131
src/simulator/crates/mc-observation/tests/tech_gating.rs
Normal file
131
src/simulator/crates/mc-observation/tests/tech_gating.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue