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:
parent
daf9cbfab6
commit
9d75210947
2 changed files with 990 additions and 23 deletions
113
src/simulator/crates/mc-vision/benches/compute_vision.rs
Normal file
113
src/simulator/crates/mc-vision/benches/compute_vision.rs
Normal 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);
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue