diff --git a/src/simulator/crates/mc-vision/benches/compute_vision.rs b/src/simulator/crates/mc-vision/benches/compute_vision.rs new file mode 100644 index 00000000..14788467 --- /dev/null +++ b/src/simulator/crates/mc-vision/benches/compute_vision.rs @@ -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); diff --git a/src/simulator/crates/mc-vision/src/lib.rs b/src/simulator/crates/mc-vision/src/lib.rs index 943f5a76..8ea4a3e6 100644 --- a/src/simulator/crates/mc-vision/src/lib.rs +++ b/src/simulator/crates/mc-vision/src/lib.rs @@ -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, + /// Improvements present at sighting. + improvement_set: Vec, + /// Owner of the tile at sighting (None for unowned wilderness). + last_known_owner: Option, + }, + /// 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, + }, + /// 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, +} + +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, /// Tiles ever seen (`visible ∪ keys(last_seen)`). pub explored: BTreeSet, - /// Last-known snapshot per explored-but-not-visible tile. - pub last_seen: BTreeMap, + /// Last-known snapshot per explored-but-not-visible tile, tagged with + /// progressive-decay fidelity (Communications Phase 1). + pub last_seen: BTreeMap, + /// 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, + /// 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, + /// 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, } 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, 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 = match prior { + let mut last_seen: BTreeMap = 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 = 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 = 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 { +fn snapshot_tile(grid: &GridState, state: &GameState, hex: HexCoord, turn: u32) -> Option { 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 = None; + let mut improvement_set: Vec = Vec::new(); + let mut last_known_owner: Option = 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__` scheme matching +/// `CityBuildingCompleted` and other replay variants. +pub fn diff_for_events( + prior: Option<&VisionState>, + next: &mut VisionState, + state: &GameState, +) -> Vec { + let mut events: Vec = 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" + ); + } }