From 7d77fe7289e7983b8e05c437a1eed37a4cb05bad Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 3 Jun 2026 23:28:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(mc-replay):=20=E2=9C=A8=20Introduce=20Visi?= =?UTF-8?q?bilityFilter=20logic=20for=20replay=20history=20filtering=20wit?= =?UTF-8?q?h=20new=20functions=20and=20comprehensive=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-replay/src/history.rs | 40 ++++++ src/simulator/crates/mc-replay/src/lib.rs | 2 +- .../mc-replay/tests/visibility_filter.rs | 115 ++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/simulator/crates/mc-replay/tests/visibility_filter.rs diff --git a/src/simulator/crates/mc-replay/src/history.rs b/src/simulator/crates/mc-replay/src/history.rs index b7aee800..5f187725 100644 --- a/src/simulator/crates/mc-replay/src/history.rs +++ b/src/simulator/crates/mc-replay/src/history.rs @@ -1,6 +1,8 @@ //! [`GameHistory`] container + the [`TurnEventCollector`] resource that //! emitter crates push events into during turn processing. +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; use crate::archive::{GameOutcome, MapDescriptor, HISTORY_SCHEMA_VERSION}; @@ -8,6 +10,20 @@ use crate::event::TurnEvent; use crate::ids::{ClanId, GameId, LeaderId, PackId, PackVersion}; use crate::snapshot::TurnSnapshot; +/// Contact ledger for the statistics-modal visibility filter (p2-47). +/// +/// Maps each rival `ClanId` a viewer has met to the turn on which first +/// contact occurred. A clan absent from the map has never been met (its +/// snapshots are fully hidden); a clan present at turn `t` is visible only +/// from turn `t` forward — no retroactive backfill of the pre-contact history. +/// +/// The viewer's *own* clan is conventionally included with contact turn `0` +/// (you have always "met" yourself) so a player's own rows are never filtered. +/// The live emission of this map (first-contact detection in `mc-turn` / +/// `mc-comms`) is a separate wiring objective; this type + the filter below +/// are the pure, save-decoupled core that consumers project through. +pub type MetSet = BTreeMap; + /// Per-clan static metadata captured once at game-start. /// /// Distinct from [`TurnSnapshot`] (which is per-turn dynamic). The Past Games @@ -90,6 +106,30 @@ impl GameHistory { final_turn: 0, } } + + /// Project this history's snapshots through a viewer's contact ledger + /// (p2-47 contact-state visibility). + /// + /// Returns only the snapshots whose `clan_id` the viewer has met, and only + /// from the contact turn forward. A clan absent from `met_at` contributes + /// no rows; a clan met on turn `t` contributes only rows with + /// `snapshot.turn >= t` (no retroactive pre-contact backfill). Append + /// order is preserved — sorting remains the consumer's responsibility, + /// matching [`GameHistory::snapshots`]'s documented contract. + /// + /// The filter is computed here in Rust (Rail 1); the UI receives a + /// pre-filtered list and never re-applies a GDScript fog rule. + #[must_use] + pub fn snapshots_visible_to(&self, met_at: &MetSet) -> Vec<&TurnSnapshot> { + self.snapshots + .iter() + .filter(|snap| { + met_at + .get(&snap.clan_id) + .is_some_and(|&contact_turn| snap.turn >= contact_turn) + }) + .collect() + } } /// Per-turn buffer of [`TurnEvent`]s. diff --git a/src/simulator/crates/mc-replay/src/lib.rs b/src/simulator/crates/mc-replay/src/lib.rs index b18e41dd..f3e996a3 100644 --- a/src/simulator/crates/mc-replay/src/lib.rs +++ b/src/simulator/crates/mc-replay/src/lib.rs @@ -32,7 +32,7 @@ pub use archive::{ }; pub use awards::{compute_awards, AwardDef, AwardDefs, AwardWinner}; pub use event::{LeaderChangeCause, TurnEvent}; -pub use history::{ClanDescriptor, GameHistory, TurnEventCollector}; +pub use history::{ClanDescriptor, GameHistory, MetSet, TurnEventCollector}; pub use ids::{ CityName, ClanId, EraId, GameId, LeaderId, PackId, PackVersion, TechId, TileCoord, UnitKind, WonderId, diff --git a/src/simulator/crates/mc-replay/tests/visibility_filter.rs b/src/simulator/crates/mc-replay/tests/visibility_filter.rs new file mode 100644 index 00000000..e193dadf --- /dev/null +++ b/src/simulator/crates/mc-replay/tests/visibility_filter.rs @@ -0,0 +1,115 @@ +//! Integration test: contact-state visibility filter (p2-47 bullet 4). +//! +//! Covers `GameHistory::snapshots_visible_to(&MetSet)`: +//! - a clan never met contributes zero rows; +//! - a clan met on turn `t` contributes only rows with `turn >= t` +//! (no retroactive pre-contact backfill); +//! - the viewer's own clan (contact turn 0) is never filtered. +//! +//! Run with: `cargo test -p mc-replay --test visibility_filter` + +use mc_replay::history::{ClanDescriptor, GameHistory, MetSet}; +use mc_replay::ids::{ClanId, GameId, LeaderId, PackId, PackVersion}; +use mc_replay::archive::MapDescriptor; +use mc_replay::TurnSnapshot; + +fn clan(id: u32, name: &str) -> ClanDescriptor { + ClanDescriptor { + id: ClanId(id), + name: name.into(), + sigil_key: format!("{name}.png"), + colour_rgba: 0xCC_44_11_FF, + starting_leader: LeaderId("durin".into()), + } +} + +fn snap(turn: u32, clan_id: u32) -> TurnSnapshot { + TurnSnapshot { + turn, + clan_id: ClanId(clan_id), + population: 1, + cities: 1, + army_strength: 0.0, + gold: 0, + gold_per_turn: 0, + culture_per_turn: 0.0, + tech_count: 0, + land_area: 0, + buildings_built_total: 0, + culture_total: 0.0, + score: 0.0, + } +} + +/// Build a 3-clan history (A=1, B=2, C=3) with one snapshot per clan for +/// turns 10..=20. +fn three_clan_history() -> GameHistory { + let mut hist = GameHistory::new( + GameId::new_v4(), + PackId("age-of-dwarves".into()), + PackVersion("0.1.0".into()), + 42, + MapDescriptor { kind: "continents".into(), width: 32, height: 24 }, + vec![clan(1, "Ardent"), clan(2, "Bronzebeard"), clan(3, "Cragmaw")], + ); + for turn in 10u32..=20 { + for cid in 1u32..=3 { + hist.snapshots.push(snap(turn, cid)); + } + } + hist +} + +/// The spec scenario: viewer B (clan 2) contacts C (clan 3) on turn 17. +/// B's view of C must contain only snapshots from turn 17 onward; B sees its +/// own rows for the full range; B has never met A (clan 1) → zero A rows. +#[test] +fn met_clan_visible_from_contact_turn_forward() { + let hist = three_clan_history(); + + // B's contact ledger: self (B) from turn 0; C met on turn 17. A absent. + let mut met_at: MetSet = MetSet::new(); + met_at.insert(ClanId(2), 0); // own clan, always visible + met_at.insert(ClanId(3), 17); // C contacted on turn 17 + + let visible = hist.snapshots_visible_to(&met_at); + + // No rows for the never-met clan A. + assert!( + visible.iter().all(|s| s.clan_id != ClanId(1)), + "unmet clan A must contribute zero rows" + ); + + // C (clan 3) visible only from turn 17 forward (17..=20 → 4 rows). + let c_rows: Vec<&&TurnSnapshot> = + visible.iter().filter(|s| s.clan_id == ClanId(3)).collect(); + assert_eq!(c_rows.len(), 4, "C visible for turns 17,18,19,20"); + assert!( + c_rows.iter().all(|s| s.turn >= 17), + "no pre-contact backfill: every visible C row is turn >= 17" + ); + + // B (clan 2, self) visible for the full range 10..=20 → 11 rows. + let b_rows = visible.iter().filter(|s| s.clan_id == ClanId(2)).count(); + assert_eq!(b_rows, 11, "own clan B visible across the full turn range"); +} + +/// Empty contact ledger → fully hidden history (defensive boundary). +#[test] +fn empty_met_set_hides_everything() { + let hist = three_clan_history(); + let visible = hist.snapshots_visible_to(&MetSet::new()); + assert!(visible.is_empty(), "no clans met → no snapshots visible"); +} + +/// Contact on the exact first turn of a clan's data → that turn is included +/// (boundary: `turn >= contact_turn`, not `>`). +#[test] +fn contact_on_boundary_turn_is_inclusive() { + let hist = three_clan_history(); + let mut met_at: MetSet = MetSet::new(); + met_at.insert(ClanId(3), 20); // contacted on the very last turn + let visible = hist.snapshots_visible_to(&met_at); + let c_rows = visible.iter().filter(|s| s.clan_id == ClanId(3)).count(); + assert_eq!(c_rows, 1, "contact on turn 20 → exactly the turn-20 row"); +}