feat(mc-replay): Introduce VisibilityFilter logic for replay history filtering with new functions and comprehensive tests

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-03 23:28:32 -07:00
parent 4884545694
commit 7d77fe7289
3 changed files with 156 additions and 1 deletions

View file

@ -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<ClanId, u32>;
/// 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.

View file

@ -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,

View file

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