feat(mc-vision): Introduce vision computation module and benchmarking infrastructure for performance testing

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-18 20:10:30 -07:00
parent daf9cbfab6
commit 9d75210947
2 changed files with 990 additions and 23 deletions

View file

@ -0,0 +1,113 @@
// `criterion_group!` expands to an undocumented pub fn; bench harness is
// not part of any public API surface, so silence the workspace-wide
// `-W missing-docs` lint for this file rather than threading allow
// attrs through every macro-generated item.
#![allow(missing_docs)]
//! Criterion benchmark for [`mc_vision::compute_vision`].
//!
//! Workstream F of `.project/objectives/p1-60-fog-of-war-testing-ai-fairness.md`.
//!
//! Two scenarios:
//!
//! - `small_map`: 60×60 grassland, 4 players, 8 scouts each (sight=2).
//! Aspirational median: < 5 ms.
//! - `large_map`: 200×200 grassland, 8 players, 50 scouts each (sight=2).
//! Aspirational median: < 50 ms.
//!
//! Targets are documentation-only — this bench compiles and runs, but does
//! not enforce hard thresholds. The numbers serve as a tripwire when we
//! later optimise vision LOS or add layered fog data.
//!
//! Unit placement is fully deterministic (no RNG): each scout is scattered
//! by a fixed `(player_step, intra_step)` modulo grid dimensions, so the
//! bench produces stable timing across runs and machines.
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use mc_core::grid::GridState;
use mc_turn::game_state::{GameState, MapUnit, PlayerState};
use mc_vision::{compute_vision, VisionCatalog};
/// Build a flat `width × height` grassland grid — replicates the
/// `#[cfg(test)]` helper at `mc-vision/src/lib.rs:547-555`.
fn make_flat_grid(width: i32, height: i32, biome: &str) -> GridState {
let mut grid = GridState::new(width, height);
for t in &mut grid.tiles {
t.biome_label_id = biome.into();
}
grid
}
/// Build a `MapUnit` at `(col, row)` with the given unit-id — replicates
/// the `#[cfg(test)]` helper at `mc-vision/src/lib.rs:562-568`.
fn unit_at(unit_id: &str, col: i32, row: i32) -> MapUnit {
let mut u = MapUnit::default();
u.unit_id = unit_id.into();
u.col = col;
u.row = row;
u
}
/// Build a deterministic `GameState`:
///
/// - flat grassland grid `width × height`
/// - `n_players` players (indexed 0..n_players)
/// - `units_per_player` scouts each, scattered across the grid via
/// `(col, row) = ((p * 7 + i * 13) % width, (p * 11 + i * 17) % height)`
/// — the primes guarantee a wide spread without ever placing two
/// scouts at the same hex for the parameters used by this bench.
fn build_state(
width: i32,
height: i32,
n_players: u8,
units_per_player: u32,
) -> GameState {
let mut state = GameState::default();
state.grid = Some(make_flat_grid(width, height, "grassland"));
for p in 0..n_players {
let mut ps = PlayerState::default();
ps.player_index = p;
for i in 0..units_per_player {
let pp = p as i32;
let ii = i as i32;
let col = ((pp * 7 + ii * 13).rem_euclid(width)) as i32;
let row = ((pp * 11 + ii * 17).rem_euclid(height)) as i32;
ps.units.push(unit_at("scout", col, row));
}
state.players.push(ps);
}
state
}
fn vision_catalog() -> VisionCatalog {
let mut cat = VisionCatalog::default();
cat.insert_unit("scout", 2);
cat
}
fn bench_small_map(c: &mut Criterion) {
let state = build_state(60, 60, 4, 8);
let catalog = vision_catalog();
c.bench_function("small_map", |b| {
b.iter(|| {
let out = compute_vision(black_box(&state), black_box(&catalog), None);
black_box(out);
});
});
}
fn bench_large_map(c: &mut Criterion) {
let state = build_state(200, 200, 8, 50);
let catalog = vision_catalog();
c.bench_function("large_map", |b| {
b.iter(|| {
let out = compute_vision(black_box(&state), black_box(&catalog), None);
black_box(out);
});
});
}
criterion_group!(benches, bench_small_map, bench_large_map);
criterion_main!(benches);

View file

@ -88,9 +88,27 @@ use std::collections::{BTreeMap, BTreeSet};
use mc_core::grid::biome_registry::has_tag;
use mc_core::grid::biome_registry::BiomeTag;
use mc_core::grid::{GridState, TileState};
use mc_replay::event::TurnEvent;
use mc_replay::ids::{CityName, ClanId, TileCoord, UnitKind};
use mc_turn::game_state::{GameState, PlayerState};
use serde::{Deserialize, Serialize};
/// Default decay-short threshold (turns from snapshot to `Fresh → Faded`).
/// Matches `comm_tier_table[tier=2].decay_short` in
/// `public/games/age-of-dwarves/data/comms.json`.
// data-loader integration in Phase 2
pub const DEFAULT_DECAY_SHORT: u32 = 4;
/// Default decay-long threshold (turns from snapshot to `Faded → Dim`).
/// Matches `comm_tier_table[tier=2].decay_long` in
/// `public/games/age-of-dwarves/data/comms.json`.
// data-loader integration in Phase 2
pub const DEFAULT_DECAY_LONG: u32 = 12;
/// Debounce window for re-sighting a unit: a unit that has been out of
/// vision for `<` this many turns will NOT re-emit a `UnitSpotted` event.
pub const UNIT_SPOT_DEBOUNCE_TURNS: u32 = 3;
/// Player slot id — re-exported here to keep the crate's public surface
/// self-describing. Matches `mc_player_api::PlayerId`.
pub type PlayerId = u8;
@ -149,17 +167,148 @@ impl VisionCatalog {
// ── Output types ────────────────────────────────────────────────────────────
/// Snapshot of a single explored-but-not-currently-visible tile.
/// Mirrors the `VIS_SEEN_STALE = 1` payload in
/// `world_map_vision.gd:8-10` plus the renderer hooks at
/// `build_fog_arrays:59-74`.
///
/// Three fidelity bands modelling information decay (Communications Phase 1):
///
/// - `Fresh` — observed within the last `decay_short` turns. Carries the
/// full last-known snapshot: garrison count, city population, improvement
/// set, and last known owner.
/// - `Faded` — observed within the last `decay_long` turns. Garrison count
/// and detailed payload are lost; biome + "an enemy city was here" +
/// last known owner survive.
/// - `Dim` — observed earlier than `decay_long` turns ago. Only the biome
/// label survives ("you remember… mountains?").
///
/// The tile position is the `BTreeMap` key in `PlayerVision::last_seen`, so
/// no `hex` field is carried on the variants themselves.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LastSeenTile {
/// Tile position.
pub hex: HexCoord,
/// Biome label at last observation.
pub biome_label_id: String,
/// Turn on which we last had visible coverage of this tile.
pub last_seen_turn: u32,
pub enum LastSeen {
/// Recent, high-fidelity sighting.
Fresh {
/// Biome label at last observation.
biome_label_id: String,
/// Turn on which the snapshot was taken.
last_seen_turn: u32,
/// Number of foreign units stacked on the tile at sighting.
garrison_count: u8,
/// City population if the tile held a foreign city centre.
city_pop: Option<u32>,
/// Improvements present at sighting.
improvement_set: Vec<String>,
/// Owner of the tile at sighting (None for unowned wilderness).
last_known_owner: Option<u8>,
},
/// Aged snapshot — coarse fidelity.
Faded {
/// Biome label at last observation.
biome_label_id: String,
/// Turn on which the snapshot was originally taken.
last_seen_turn: u32,
/// Last known owner if any.
last_known_owner: Option<u8>,
},
/// Very old memory — only the biome is remembered.
Dim {
/// Biome label at last observation.
biome_label_id: String,
/// Turn on which the snapshot was originally taken.
last_seen_turn: u32,
},
}
impl LastSeen {
/// Accessor — biome label at the moment of last observation.
#[must_use]
pub fn biome_label_id(&self) -> &str {
match self {
Self::Fresh { biome_label_id, .. }
| Self::Faded { biome_label_id, .. }
| Self::Dim { biome_label_id, .. } => biome_label_id,
}
}
/// Accessor — turn on which the snapshot was originally taken.
#[must_use]
pub fn last_seen_turn(&self) -> u32 {
match *self {
Self::Fresh { last_seen_turn, .. }
| Self::Faded { last_seen_turn, .. }
| Self::Dim { last_seen_turn, .. } => last_seen_turn,
}
}
/// Age `self` against `current_turn`. Downgrades `Fresh → Faded` once
/// the snapshot is older than `decay_short` turns, and `Faded → Dim`
/// once older than `decay_long` turns. No-op if already `Dim` or if
/// the snapshot is still within its current band.
pub fn age(&mut self, current_turn: u32, decay_short: u32, decay_long: u32) {
let age = current_turn.saturating_sub(self.last_seen_turn());
match self {
Self::Fresh { .. } if age >= decay_long => {
*self = Self::Dim {
biome_label_id: self.biome_label_id().to_string(),
last_seen_turn: self.last_seen_turn(),
};
}
Self::Fresh {
biome_label_id,
last_seen_turn,
last_known_owner,
..
} if age >= decay_short => {
*self = Self::Faded {
biome_label_id: std::mem::take(biome_label_id),
last_seen_turn: *last_seen_turn,
last_known_owner: *last_known_owner,
};
}
Self::Faded {
biome_label_id,
last_seen_turn,
..
} if age >= decay_long => {
*self = Self::Dim {
biome_label_id: std::mem::take(biome_label_id),
last_seen_turn: *last_seen_turn,
};
}
_ => {}
}
}
}
/// First-contact bookkeeping per player. Communications Phase 1.
///
/// `known_players` is a packed bitset indexed by `PlayerId` (u8). Bit `i`
/// set ⇔ this player has met player slot `i`. Symmetric — when A meets B,
/// both `A.contact.known_players` and `B.contact.known_players` gain the
/// bit. Contact is permanent for the duration of the game.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Contact {
/// Packed bitset of player slots this player has met.
pub known_players: u64,
/// Turn on which each contact was first established.
pub first_contact_turn: BTreeMap<PlayerId, u32>,
}
impl Contact {
/// `true` if bit for `other` is set.
#[must_use]
pub fn knows(&self, other: PlayerId) -> bool {
(self.known_players >> u64::from(other)) & 1 == 1
}
/// Set the bit for `other` and record `first_contact_turn[other] = turn`
/// if it isn't already set. Returns `true` if this was a new contact
/// (so callers can emit a `PlayerDiscovered` event exactly once).
pub fn mark(&mut self, other: PlayerId, turn: u32) -> bool {
if self.knows(other) {
return false;
}
self.known_players |= 1u64 << u64::from(other);
self.first_contact_turn.insert(other, turn);
true
}
}
/// Per-player visibility state.
@ -178,8 +327,32 @@ pub struct PlayerVision {
pub visible: BTreeSet<HexCoord>,
/// Tiles ever seen (`visible keys(last_seen)`).
pub explored: BTreeSet<HexCoord>,
/// Last-known snapshot per explored-but-not-visible tile.
pub last_seen: BTreeMap<HexCoord, LastSeenTile>,
/// Last-known snapshot per explored-but-not-visible tile, tagged with
/// progressive-decay fidelity (Communications Phase 1).
pub last_seen: BTreeMap<HexCoord, LastSeen>,
/// First-contact bitset + per-player meeting turn.
#[serde(default)]
pub contact: Contact,
/// Per-foreign-unit-id last turn the unit was in this player's VISIBLE
/// set. Used to debounce `UnitSpotted` event emission: a unit reappearing
/// within `UNIT_SPOT_DEBOUNCE_TURNS` of its last sighting does NOT
/// re-fire the event. First sighting (no entry) always fires.
#[serde(default)]
pub unit_spot_last_turn: BTreeMap<u32, u32>,
/// Per-foreign-city-id "have we ever spotted this city" flag. A city
/// emits `CitySpotted` only on first sighting across the whole game.
#[serde(default)]
pub spotted_cities: BTreeSet<String>,
/// Per-currently-visible-tile snapshot captured this turn. Carried
/// forward by the next refresh so visible→stale transitions promote
/// the prior turn's *perceived* tile state into `last_seen` (rather
/// than re-reading the live grid, which may have changed since the
/// player last looked). Not serialised — this is a transient
/// per-turn cache rebuilt by every `refresh_for_player` call. Tuple
/// keys would also break `serde_json` (which the determinism test
/// asserts byte-equality against).
#[serde(skip)]
pub visible_snapshots: BTreeMap<HexCoord, LastSeen>,
}
impl PlayerVision {
@ -221,6 +394,12 @@ impl VisionState {
/// the visible set — matching the GDScript `recalculate_vision`
/// pattern at `world_map_vision.gd:19-36` of "downgrade 2→1, then
/// re-upgrade currently-visible tiles to 2".
///
/// First-contact bookkeeping (Communications Phase 1) runs as a second
/// pass once every player's `visible` set is computed: for each ordered
/// pair `(A, B)` where `A != B`, if any tile holding a unit or city of `B`
/// lies in `A.visible`, both players' `Contact.known_players` bitsets
/// gain the matching bit. Contact is permanent.
#[must_use]
pub fn compute_vision(
state: &GameState,
@ -233,9 +412,51 @@ pub fn compute_vision(
let pv = refresh_for_player(state, player.player_index, catalog, prior_pv);
per_player.insert(player.player_index, pv);
}
update_contact(&mut per_player, state);
VisionState { per_player }
}
/// Symmetric first-contact update. For every ordered pair `(A, B)`:
/// if A.visible contains any tile holding a unit or city of B's, mark
/// `A.contact[B]` and `B.contact[A]`. Caller has already populated each
/// player's `visible` set via [`refresh_for_player`].
fn update_contact(per_player: &mut BTreeMap<PlayerId, PlayerVision>, state: &GameState) {
let turn = state.turn;
let mut new_contacts: Vec<(PlayerId, PlayerId)> = Vec::new();
for observer in state.players.iter() {
let Some(observer_vision) = per_player.get(&observer.player_index) else {
continue;
};
for target in state.players.iter() {
if target.player_index == observer.player_index {
continue;
}
if observer_vision.contact.knows(target.player_index) {
continue;
}
let seen = target
.units
.iter()
.any(|u| observer_vision.visible.contains(&(u.col, u.row)))
|| target
.city_positions
.iter()
.any(|&pos| observer_vision.visible.contains(&pos));
if seen {
new_contacts.push((observer.player_index, target.player_index));
}
}
}
for (a, b) in new_contacts {
if let Some(pv) = per_player.get_mut(&a) {
pv.contact.mark(b, turn);
}
if let Some(pv) = per_player.get_mut(&b) {
pv.contact.mark(a, turn);
}
}
}
/// Compute (or refresh) visibility for a single player.
///
/// Mirrors `world_map_vision.gd:19-36`:
@ -268,19 +489,34 @@ pub fn refresh_for_player(
// Carry-forward last-seen snapshot.
// Step 1: start from prior.last_seen (tiles still stale).
// Step 2: any tile in prior.visible that is NOT in the new visible set
// transitions visible→stale; snapshot now.
// transitions visible→stale; snapshot now (always Fresh on
// transition; aging to Faded/Dim is handled by `age_last_seen`).
// Step 3: any tile in new visible drops out of last_seen
// (it's currently visible, not stale).
let mut last_seen: BTreeMap<HexCoord, LastSeenTile> = match prior {
let mut last_seen: BTreeMap<HexCoord, LastSeen> = match prior {
Some(pv) => pv.last_seen.clone(),
None => BTreeMap::new(),
};
if let Some(pv) = prior {
for &hex in &pv.visible {
if !visible.contains(&hex) {
if let Some(snapshot) = snapshot_tile(grid, hex, state.turn) {
last_seen.insert(hex, snapshot);
}
// Snapshot exactly once on the visible→stale transition. If a
// snapshot is already present (carried over from earlier), the
// existing record is the frozen point-in-time memory and must
// not be overwritten — otherwise mutations to the live grid
// would propagate into memory the player should have lost.
//
// Snapshot fidelity: the prior turn's `visible_snapshots` holds
// a per-tile capture from the moment the player last *saw* the
// tile. When that capture exists, we promote it into
// `last_seen` rather than reading the current grid (which may
// have changed since the player looked away).
if visible.contains(&hex) || last_seen.contains_key(&hex) {
continue;
}
if let Some(snap) = pv.visible_snapshots.get(&hex).cloned() {
last_seen.insert(hex, snap);
} else if let Some(snap) = snapshot_tile(grid, state, hex, state.turn) {
last_seen.insert(hex, snap);
}
}
}
@ -293,10 +529,48 @@ pub fn refresh_for_player(
let mut explored: BTreeSet<HexCoord> = visible.clone();
explored.extend(last_seen.keys().copied());
// Snapshot every currently-visible tile so the next refresh has a
// point-in-time record to promote into `last_seen` when the tile
// transitions out of vision.
let mut visible_snapshots: BTreeMap<HexCoord, LastSeen> = BTreeMap::new();
for &hex in &visible {
if let Some(snap) = snapshot_tile(grid, state, hex, state.turn) {
visible_snapshots.insert(hex, snap);
}
}
// Carry forward contact + spotting bookkeeping. Cross-player contact
// updates happen in `update_contact` after every player's visible set
// is computed; per-unit/per-city spotting state is owned by each
// player and updated below.
let (contact, unit_spot_last_turn, spotted_cities) = match prior {
Some(pv) => (
pv.contact.clone(),
pv.unit_spot_last_turn.clone(),
pv.spotted_cities.clone(),
),
None => (Contact::default(), BTreeMap::new(), BTreeSet::new()),
};
PlayerVision {
visible,
explored,
last_seen,
contact,
unit_spot_last_turn,
spotted_cities,
visible_snapshots,
}
}
/// Age every `LastSeen` entry in `pv.last_seen` against `current_turn`.
/// Used by the per-turn vision-refresh pipeline to downgrade fidelity in
/// place. `decay_short` / `decay_long` are loaded from `comms.json` in
/// production; for tests/bench paths, see [`DEFAULT_DECAY_SHORT`] /
/// [`DEFAULT_DECAY_LONG`].
pub fn age_last_seen(pv: &mut PlayerVision, current_turn: u32, decay_short: u32, decay_long: u32) {
for entry in pv.last_seen.values_mut() {
entry.age(current_turn, decay_short, decay_long);
}
}
@ -524,15 +798,205 @@ fn tile_at(grid: &GridState, (col, row): HexCoord) -> Option<&TileState> {
grid.tiles.get(idx)
}
fn snapshot_tile(grid: &GridState, hex: HexCoord, turn: u32) -> Option<LastSeenTile> {
fn snapshot_tile(grid: &GridState, state: &GameState, hex: HexCoord, turn: u32) -> Option<LastSeen> {
let tile = tile_at(grid, hex)?;
Some(LastSeenTile {
hex,
// Walk every player to find who (if anyone) has a unit or city on this
// hex. Garrison count = number of stacked units; city_pop = city
// population if the tile is a city centre; improvement_set is sourced
// from `city_improvements` keyed by city index when applicable.
let mut garrison_count: u8 = 0;
let mut city_pop: Option<u32> = None;
let mut improvement_set: Vec<String> = Vec::new();
let mut last_known_owner: Option<u8> = None;
for ps in &state.players {
let units_on_tile = ps
.units
.iter()
.filter(|u| (u.col, u.row) == hex)
.count();
if units_on_tile > 0 {
garrison_count = garrison_count.saturating_add(units_on_tile.min(u8::MAX as usize) as u8);
last_known_owner = Some(ps.player_index);
}
for (idx, &pos) in ps.city_positions.iter().enumerate() {
if pos == hex {
last_known_owner = Some(ps.player_index);
if let Some(city) = ps.cities.get(idx) {
city_pop = Some(city.population);
}
if let Some(impr) = ps.city_improvements.get(idx) {
improvement_set = impr.clone();
}
}
}
}
Some(LastSeen::Fresh {
biome_label_id: tile.biome_label_id.clone(),
last_seen_turn: turn,
garrison_count,
city_pop,
improvement_set,
last_known_owner,
})
}
// ── Event diff (Communications Phase 1) ────────────────────────────────────
/// Diff a prior and next [`VisionState`] against the current [`GameState`]
/// and return the [`TurnEvent`]s implied by visibility deltas. Pure
/// function — does not mutate either state. Callers (the per-turn
/// pipeline that owns the `TurnEventCollector`) push the returned events
/// into the collector.
///
/// Events produced:
///
/// - `PlayerDiscovered` — once per ordered pair `(discoverer, discovered)`
/// the first turn `discoverer.contact.knows(discovered)` flips from
/// false to true. Contact is symmetric, so a single meeting yields two
/// events (one per direction).
/// - `CitySpotted` — once per `(observer, city)` pair the first time a
/// foreign city centre enters `observer.visible`. Tracked permanently in
/// `observer.spotted_cities` so re-sightings do not re-emit.
/// - `UnitSpotted` — debounced. First sighting always emits; re-sightings
/// only emit if the unit has been out of vision for ≥
/// [`UNIT_SPOT_DEBOUNCE_TURNS`].
///
/// The function also updates the spot-debounce bookkeeping on `next` —
/// the caller is expected to retain `next` as the prior-state baseline
/// for the following turn.
///
/// City id strings use the `city_<pi>_<idx>` scheme matching
/// `CityBuildingCompleted` and other replay variants.
pub fn diff_for_events(
prior: Option<&VisionState>,
next: &mut VisionState,
state: &GameState,
) -> Vec<TurnEvent> {
let mut events: Vec<TurnEvent> = Vec::new();
let turn = state.turn;
// First-contact events. Compare prior contact bitset to next.
for player in &state.players {
let observer_idx = player.player_index;
let Some(pv) = next.per_player.get(&observer_idx) else {
continue;
};
let prior_known: u64 = prior
.and_then(|p| p.per_player.get(&observer_idx))
.map(|opv| opv.contact.known_players)
.unwrap_or(0);
let newly = pv.contact.known_players & !prior_known;
if newly == 0 {
continue;
}
for target in &state.players {
if target.player_index == observer_idx {
continue;
}
let bit = 1u64 << u64::from(target.player_index);
if newly & bit == 0 {
continue;
}
// Locate the hex that triggered the contact — first of the
// target's units or cities that lies in the observer's visible
// set. Used purely for replay-camera centering.
let at_hex: (i32, i32) = target
.units
.iter()
.map(|u| (u.col, u.row))
.chain(target.city_positions.iter().copied())
.find(|p| pv.visible.contains(p))
.unwrap_or((0, 0));
events.push(TurnEvent::PlayerDiscovered {
turn,
discoverer: ClanId(u32::from(observer_idx)),
discovered: ClanId(u32::from(target.player_index)),
at_hex: TileCoord::new(at_hex.0, at_hex.1),
});
}
}
// City + unit spotting events. Iterate observers, then targets.
// Snapshot every target's player_index → city id list once so the
// borrow checker is happy when we later mutate `next.per_player`.
let players_snapshot: Vec<(PlayerId, Vec<(usize, (i32, i32))>, Vec<(u32, String, (i32, i32))>)> =
state
.players
.iter()
.map(|p| {
let cities: Vec<(usize, (i32, i32))> = p
.city_positions
.iter()
.enumerate()
.map(|(i, &c)| (i, c))
.collect();
let units: Vec<(u32, String, (i32, i32))> = p
.units
.iter()
.map(|u| (u.id, u.unit_id.clone(), (u.col, u.row)))
.collect();
(p.player_index, cities, units)
})
.collect();
for &(observer_idx, _, _) in &players_snapshot {
let Some(pv) = next.per_player.get_mut(&observer_idx) else {
continue;
};
for &(target_idx, ref target_cities, ref target_units) in &players_snapshot {
if target_idx == observer_idx {
continue;
}
// Cities — fire once per (observer, city_id) globally.
for &(idx, pos) in target_cities {
if !pv.visible.contains(&pos) {
continue;
}
let city_id = format!("city_{}_{}", target_idx, idx);
if pv.spotted_cities.contains(&city_id) {
continue;
}
pv.spotted_cities.insert(city_id.clone());
events.push(TurnEvent::CitySpotted {
turn,
observer: ClanId(u32::from(observer_idx)),
city_owner: ClanId(u32::from(target_idx)),
city_id: CityName(city_id),
at_hex: TileCoord::new(pos.0, pos.1),
});
}
// Units — debounced. First sighting always fires; re-sighting
// fires only if the unit has been out of vision for at least
// UNIT_SPOT_DEBOUNCE_TURNS turns.
for (uid, archetype, pos) in target_units {
if !pv.visible.contains(pos) {
continue;
}
let last = pv.unit_spot_last_turn.get(uid).copied();
let should_emit = match last {
None => true,
Some(t) => turn.saturating_sub(t) >= UNIT_SPOT_DEBOUNCE_TURNS,
};
pv.unit_spot_last_turn.insert(*uid, turn);
if should_emit {
events.push(TurnEvent::UnitSpotted {
turn,
observer: ClanId(u32::from(observer_idx)),
unit_owner: ClanId(u32::from(target_idx)),
archetype: UnitKind(archetype.clone()),
at_hex: TileCoord::new(pos.0, pos.1),
});
}
}
}
}
events
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
@ -735,9 +1199,11 @@ mod tests {
// …but it remains explored, with a `last_seen` snapshot from turn 1.
assert!(pv2.explored.contains(&(3, 3)));
let snap = pv2.last_seen.get(&(3, 3)).expect("(3,3) in last_seen");
assert_eq!(snap.biome_label_id, "grassland");
assert_eq!(snap.biome_label_id(), "grassland");
// Snapshot timestamp is the turn on which it dropped out — i.e. turn 2.
assert_eq!(snap.last_seen_turn, 2);
assert_eq!(snap.last_seen_turn(), 2);
// Initial snapshot on a still-recent dropout is always Fresh.
assert!(matches!(snap, LastSeen::Fresh { .. }));
}
#[test]
@ -786,4 +1252,392 @@ mod tests {
// Player A's own (1, 1) is visible.
assert!(pa.visible.contains(&(1, 1)));
}
#[test]
fn multi_unit_vision_unions_not_double_counted() {
// Two scouts with overlapping radius-2 disks must produce a set
// union of visible tiles, never a multiset/double-count.
let mut cat = VisionCatalog::default();
cat.insert_unit("scout", 2);
// Single-unit run A: scout at (5, 5).
let mut state_a = GameState::default();
state_a.grid = Some(make_flat_grid(15, 15, "grassland"));
let mut pa = PlayerState::default();
pa.player_index = 0;
pa.units.push(unit_at("scout", 5, 5));
state_a.players.push(pa);
let vs_a = compute_vision(&state_a, &cat, None);
let visible_a = vs_a.for_player(0).unwrap().visible.clone();
// Single-unit run B: scout at (6, 5).
let mut state_b = GameState::default();
state_b.grid = Some(make_flat_grid(15, 15, "grassland"));
let mut pb = PlayerState::default();
pb.player_index = 0;
pb.units.push(unit_at("scout", 6, 5));
state_b.players.push(pb);
let vs_b = compute_vision(&state_b, &cat, None);
let visible_b = vs_b.for_player(0).unwrap().visible.clone();
// Combined: both scouts in one player.
let mut state_c = GameState::default();
state_c.grid = Some(make_flat_grid(15, 15, "grassland"));
let mut pc = PlayerState::default();
pc.player_index = 0;
pc.units.push(unit_at("scout", 5, 5));
pc.units.push(unit_at("scout", 6, 5));
state_c.players.push(pc);
let vs_c = compute_vision(&state_c, &cat, None);
let visible_c = vs_c.for_player(0).unwrap().visible.clone();
let expected_union: std::collections::BTreeSet<(i32, i32)> =
visible_a.union(&visible_b).copied().collect();
assert_eq!(
visible_c, expected_union,
"multi-unit visible set must equal union of single-unit sets"
);
// Sanity: cardinality is the union, strictly less than 2× a single disk.
assert!(visible_c.len() < visible_a.len() + visible_b.len());
}
#[test]
fn stale_snapshot_is_frozen_until_reobserved() {
// Turn 1: scout at (3,3) sees (3,3) as grassland.
let mut state = GameState::default();
state.grid = Some(make_flat_grid(15, 15, "grassland"));
state.turn = 1;
let mut ps = PlayerState::default();
ps.player_index = 0;
ps.units.push(unit_at("scout", 3, 3));
state.players.push(ps);
let mut cat = VisionCatalog::default();
cat.insert_unit("scout", 1);
let vs1 = compute_vision(&state, &cat, None);
assert!(vs1.for_player(0).unwrap().visible.contains(&(3, 3)));
// Turn 2: move scout to (10,10); mutate (3,3) to mountains.
state.turn = 2;
state.players[0].units[0].col = 10;
state.players[0].units[0].row = 10;
if let Some(grid) = state.grid.as_mut() {
set_biome(grid, 3, 3, "mountains");
}
let vs2 = compute_vision(&state, &cat, Some(&vs1));
let pv2 = vs2.for_player(0).unwrap();
let snap = pv2
.last_seen
.get(&(3, 3))
.expect("(3,3) frozen in last_seen");
assert_eq!(
snap.biome_label_id(), "grassland",
"snapshot must remain grassland — mutation must not propagate"
);
assert!(!pv2.visible.contains(&(3, 3)));
// Turn 3: move scout back to (3,3); re-observation removes stale entry.
state.turn = 3;
state.players[0].units[0].col = 3;
state.players[0].units[0].row = 3;
let vs3 = compute_vision(&state, &cat, Some(&vs2));
let pv3 = vs3.for_player(0).unwrap();
assert!(pv3.visible.contains(&(3, 3)), "(3,3) re-observed");
assert!(
!pv3.last_seen.contains_key(&(3, 3)),
"re-observation must remove (3,3) from last_seen"
);
}
#[test]
fn los_endpoint_behind_two_blockers() {
// Mountains at (5,3) AND (6,3); hawk at (3,3) sight 5.
// (5,3) is the endpoint past flat tiles — visible.
// (6,3) sits beyond (5,3) which is a blocker — NOT visible
// (endpoint rule only protects the FIRST blocker on the line).
// (7,3) sits beyond both blockers — NOT visible.
let mut state = GameState::default();
let mut grid = make_flat_grid(15, 15, "grassland");
set_biome(&mut grid, 5, 3, "mountains");
set_biome(&mut grid, 6, 3, "mountains");
state.grid = Some(grid);
let mut ps = PlayerState::default();
ps.player_index = 0;
ps.units.push(unit_at("hawk", 3, 3));
state.players.push(ps);
let mut cat = VisionCatalog::default();
cat.insert_unit("hawk", 5);
let vs = compute_vision(&state, &cat, None);
let pv = vs.for_player(0).expect("vision");
assert!(pv.visible.contains(&(5, 3)), "(5,3) endpoint visible");
assert!(
!pv.visible.contains(&(6, 3)),
"(6,3) lies beyond first blocker — endpoint rule does not extend"
);
assert!(
!pv.visible.contains(&(7, 3)),
"(7,3) lies beyond two blockers — hidden"
);
}
#[test]
fn wrap_mode_disk_clipped_on_bounded_map() {
// Scout at (0,0) sight=3 on a 5×5 bounded map. The radius-3 hex
// disk would normally have 37 tiles; clipping to bounds yields
// strictly fewer and every coord must lie in [0,5)×[0,5).
let mut state = GameState::default();
state.grid = Some(make_flat_grid(5, 5, "grassland"));
let mut ps = PlayerState::default();
ps.player_index = 0;
ps.units.push(unit_at("scout", 0, 0));
state.players.push(ps);
let mut cat = VisionCatalog::default();
cat.insert_unit("scout", 3);
let vs = compute_vision(&state, &cat, None);
let pv = vs.for_player(0).expect("vision");
for &(c, r) in pv.visible.iter() {
assert!(
c >= 0 && c < 5 && r >= 0 && r < 5,
"({},{}) out of bounds for 5×5 map",
c,
r
);
}
// Full radius-3 disk = 1 + 6 + 12 + 18 = 37; clipping must shrink it.
assert!(
pv.visible.len() < 37,
"disk must be clipped; got {} tiles",
pv.visible.len()
);
}
// ── Communications Phase 1 tests ────────────────────────────────────
/// `LastSeen::age` must walk `Fresh → Faded → Dim` at the documented
/// thresholds (default decay_short=4, decay_long=12).
#[test]
fn last_seen_fresh_to_faded_to_dim() {
let mut snap = LastSeen::Fresh {
biome_label_id: "grassland".into(),
last_seen_turn: 0,
garrison_count: 3,
city_pop: Some(7),
improvement_set: vec!["farm".into()],
last_known_owner: Some(1),
};
// Turn 3: still Fresh (age 3 < decay_short 4).
snap.age(3, DEFAULT_DECAY_SHORT, DEFAULT_DECAY_LONG);
assert!(matches!(snap, LastSeen::Fresh { .. }));
// Turn 4: ages to Faded (age = decay_short = 4).
snap.age(4, DEFAULT_DECAY_SHORT, DEFAULT_DECAY_LONG);
match &snap {
LastSeen::Faded {
biome_label_id,
last_seen_turn,
last_known_owner,
} => {
assert_eq!(biome_label_id, "grassland");
assert_eq!(*last_seen_turn, 0);
assert_eq!(*last_known_owner, Some(1));
}
other => panic!("expected Faded, got {:?}", other),
}
// Turn 11: still Faded.
snap.age(11, DEFAULT_DECAY_SHORT, DEFAULT_DECAY_LONG);
assert!(matches!(snap, LastSeen::Faded { .. }));
// Turn 12: ages to Dim (age = decay_long = 12).
snap.age(12, DEFAULT_DECAY_SHORT, DEFAULT_DECAY_LONG);
match &snap {
LastSeen::Dim {
biome_label_id,
last_seen_turn,
} => {
assert_eq!(biome_label_id, "grassland");
assert_eq!(*last_seen_turn, 0);
}
other => panic!("expected Dim, got {:?}", other),
}
// Further aging is a no-op.
snap.age(100, DEFAULT_DECAY_SHORT, DEFAULT_DECAY_LONG);
assert!(matches!(snap, LastSeen::Dim { .. }));
}
/// Fresh that is already past the long threshold collapses directly to
/// Dim, skipping Faded.
#[test]
fn last_seen_fresh_skips_to_dim_past_decay_long() {
let mut snap = LastSeen::Fresh {
biome_label_id: "mountains".into(),
last_seen_turn: 0,
garrison_count: 0,
city_pop: None,
improvement_set: vec![],
last_known_owner: None,
};
snap.age(20, DEFAULT_DECAY_SHORT, DEFAULT_DECAY_LONG);
assert!(matches!(snap, LastSeen::Dim { .. }));
}
/// First-contact must be symmetric: when A sees a tile of B, both
/// `A.contact.known_players` and `B.contact.known_players` gain the bit.
#[test]
fn contact_symmetric_on_first_witness() {
let mut state = GameState::default();
state.grid = Some(make_flat_grid(15, 15, "grassland"));
state.turn = 1;
// Player 0 has a scout at (5, 5); player 1 has a unit at (6, 5).
let mut a = PlayerState::default();
a.player_index = 0;
a.units.push(unit_at("scout", 5, 5));
let mut b = PlayerState::default();
b.player_index = 1;
b.units.push(unit_at("dwarf_warrior", 6, 5));
state.players.push(a);
state.players.push(b);
let mut cat = VisionCatalog::default();
cat.insert_unit("scout", 2);
cat.insert_unit("dwarf_warrior", 2);
let vs = compute_vision(&state, &cat, None);
let pa = vs.for_player(0).expect("player 0");
let pb = vs.for_player(1).expect("player 1");
assert!(pa.contact.knows(1), "A knows B");
assert!(pb.contact.knows(0), "B knows A (symmetric)");
assert_eq!(pa.contact.first_contact_turn.get(&1), Some(&1));
assert_eq!(pb.contact.first_contact_turn.get(&0), Some(&1));
}
/// Phase 2 stub — verifies that the contact bit can be set through a
/// future courier-delivery pathway. Wired in Phase 2.
#[test]
#[ignore = "wired in Phase 2"]
fn contact_via_courier_delivery_phase1_stub() {
// When Phase 2 lands `mc-comms`, an envelope-delivery event will
// call into a sibling of `update_contact` and flip the bit even
// when no tile-vision overlap exists. The Phase 1 entry point
// (`Contact::mark`) is the same surface that codepath will use,
// so this test reserves the public API contract.
let mut c = Contact::default();
assert!(c.mark(3, 7));
assert!(c.knows(3));
assert_eq!(c.first_contact_turn.get(&3), Some(&7));
// Second mark is a no-op.
assert!(!c.mark(3, 9));
}
/// `CitySpotted` fires exactly once per (observer, city_id) pair —
/// not on every turn the city sits in the observer's visible set.
#[test]
fn city_spotted_event_fires_once_per_first_sight() {
let mut state = GameState::default();
state.grid = Some(make_flat_grid(15, 15, "grassland"));
state.turn = 1;
let mut a = PlayerState::default();
a.player_index = 0;
a.units.push(unit_at("scout", 5, 5));
let mut b = PlayerState::default();
b.player_index = 1;
b.city_positions.push((6, 5));
b.cities.push(mc_city::CityState::default());
state.players.push(a);
state.players.push(b);
let mut cat = VisionCatalog::default();
cat.insert_unit("scout", 2);
let vs1 = compute_vision(&state, &cat, None);
let mut vs1_mut = vs1.clone();
let events_t1 = diff_for_events(None, &mut vs1_mut, &state);
let city_events_t1: Vec<_> = events_t1
.iter()
.filter(|e| matches!(e, TurnEvent::CitySpotted { .. }))
.collect();
assert_eq!(city_events_t1.len(), 1, "first sighting emits once");
// Turn 2: same setup, prior carries forward.
state.turn = 2;
let vs2 = compute_vision(&state, &cat, Some(&vs1_mut));
let mut vs2_mut = vs2.clone();
let events_t2 = diff_for_events(Some(&vs1_mut), &mut vs2_mut, &state);
let city_events_t2: Vec<_> = events_t2
.iter()
.filter(|e| matches!(e, TurnEvent::CitySpotted { .. }))
.collect();
assert!(
city_events_t2.is_empty(),
"re-sighting must not re-emit CitySpotted"
);
}
/// `UnitSpotted` honours the debounce window — a re-sighting within
/// `UNIT_SPOT_DEBOUNCE_TURNS` does not re-emit, but a re-sighting
/// after the window expires does.
#[test]
fn unit_spotted_event_debounced() {
let mut state = GameState::default();
state.grid = Some(make_flat_grid(20, 20, "grassland"));
let mut a = PlayerState::default();
a.player_index = 0;
a.units.push(unit_at("scout", 5, 5));
let mut b = PlayerState::default();
b.player_index = 1;
let mut bu = unit_at("dwarf_warrior", 6, 5);
bu.id = 42;
b.units.push(bu);
state.players.push(a);
state.players.push(b);
let mut cat = VisionCatalog::default();
cat.insert_unit("scout", 2);
// Turn 1: first sighting — fires.
state.turn = 1;
let vs1 = compute_vision(&state, &cat, None);
let mut vs1m = vs1.clone();
let ev1 = diff_for_events(None, &mut vs1m, &state);
let unit_ev_1: Vec<_> = ev1
.iter()
.filter(|e| matches!(e, TurnEvent::UnitSpotted { observer, .. } if observer.0 == 0))
.collect();
assert_eq!(unit_ev_1.len(), 1, "first sighting emits");
// Turn 2: still in vision; same unit; debounce — does NOT fire.
state.turn = 2;
let vs2 = compute_vision(&state, &cat, Some(&vs1m));
let mut vs2m = vs2.clone();
let ev2 = diff_for_events(Some(&vs1m), &mut vs2m, &state);
let unit_ev_2: Vec<_> = ev2
.iter()
.filter(|e| matches!(e, TurnEvent::UnitSpotted { observer, .. } if observer.0 == 0))
.collect();
assert!(unit_ev_2.is_empty(), "re-sighting within debounce: silent");
// Turn 3: move B out of vision (col 15).
state.turn = 3;
state.players[1].units[0].col = 15;
let vs3 = compute_vision(&state, &cat, Some(&vs2m));
let mut vs3m = vs3.clone();
let _ = diff_for_events(Some(&vs2m), &mut vs3m, &state);
// Turn 4-6: keep out of vision.
for t in 4..=6 {
state.turn = t;
let vs = compute_vision(&state, &cat, Some(&vs3m));
vs3m = vs.clone();
let _ = diff_for_events(Some(&vs3m), &mut vs3m.clone(), &state);
}
// Turn 7: bring back into vision. Has been out for ≥3 turns
// (turns 3..=6 out → debounce window expired).
state.turn = 7;
state.players[1].units[0].col = 6;
let vs7 = compute_vision(&state, &cat, Some(&vs3m));
let mut vs7m = vs7.clone();
let ev7 = diff_for_events(Some(&vs3m), &mut vs7m, &state);
let unit_ev_7: Vec<_> = ev7
.iter()
.filter(|e| matches!(e, TurnEvent::UnitSpotted { observer, .. } if observer.0 == 0))
.collect();
assert_eq!(
unit_ev_7.len(),
1,
"re-sighting after debounce expiry re-emits"
);
}
}