Merge remote-tracking branch 'origin/server'

This commit is contained in:
2026-03-05 11:05:40 +01:00
43 changed files with 1583 additions and 32 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -10,7 +10,7 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
| File | Domain | Decisions |
|------|--------|-----------|
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109 |
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109, D-113 |
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078, D-086 |
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, D-075, D-084, D-090, D-092, D-093, D-095, D-098, D-104, D-105, D-107 |
| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065, D-087, D-089, D-091 |
+65 -1
View File
@@ -374,4 +374,68 @@ Technical foundation decisions that constrain implementation: engine, client-ser
---
*32 decisions. Last updated: 2026-02-28 (D-108 amended Sprint 22 — Idle state as stationary installation primitive note added, D-111 cross-reference added)*
### D-113: Tile data model — extensible per-tile properties
- **Date:** 2026-03-05
- **Decision:** Replace the current single-character tile encoding (`F/W/V/R` strings in location YAML) with a **tile palette/registry** system (option A from the design space). Tiles are typed by a palette ID; per-type properties are defined once in the palette and inherited by all tiles of that type. Per-tile overrides are supported via a sparse overlay map.
- **Current state:** Tiles are single characters in string arrays. Each character maps to a `TileKind` enum (`Floor`, `Wall`, `Door`, `Object`) and a walkability bool. `TileCell` in `WalkabilityMap` stores `{ walkable: bool, kind: TileKind }`. No per-tile properties (material, visual variant, sound, access lists, container contents, damage state, trigger zones) can be expressed.
- **Design survey — what systems need tile-level data:**
1. **Doors** — access lists (who can open), open/closed state, locked/unlocked. Currently no tile-level door data; `TileKind::Door` exists but carries no properties.
2. **Containers** — contents, capacity, searched state. Currently handled by entity `ObjectType::Container` on separate entities, not tiles. Containers should remain entities, not tile properties.
3. **Damage state**`DamageOverlay` (D-100) modifies tiles post-generation. Damage needs to degrade tile properties (walkability, visual, material) without replacing the base tile type.
4. **Visual variants** — same logical tile type (e.g., "industrial floor") with per-tile visual variation for visual richness. Currently impossible — all Floor tiles look identical to the client.
5. **Trigger zones** — tile-level triggers for entry/exit events (zone transitions, alarms, dialogue triggers). Currently handled by `ZoneMap` at zone granularity, not per-tile.
6. **Material properties** — footstep sound, movement speed modifier, surface type for particle effects. Currently all tiles produce the same footstep sound.
7. **WallBackside** (D-099) — structural classification behind wall surfaces. Already defined as an enum but not yet integrated into tile data.
- **Chosen approach — Tile Palette + Sparse Override:**
- **Tile palette** (YAML, per-district or global): defines tile types by string ID. Each type specifies: `walkable: bool`, `kind: TileKind`, `material: String` (footstep/SFX), `visual_base: String` (client sprite), `visual_variants: u8` (random variant count), `los_blocking: bool`, `movement_cost: f32` (default 1.0), optional `wall_backside: WallBackside` (D-099). The palette is the type-level contract — most tiles need no per-instance data beyond their palette ID.
- **Tile map** (YAML): retains the string-array format for human readability, but each character is a palette key (single char or short code). Backward-compatible: `F`, `W`, `V`, `R` are reserved palette keys that map to current behavior. New tile types use additional characters or a separate palette layer.
- **Sparse override map** (YAML): `overrides` key on Location — a list of `{ x, y, properties }` entries for tiles that differ from their palette type. Supports: door access lists, initial locked state, visual variant pinning, damage overlay data. Only tiles with non-default properties need entries. Keeps the string map clean for 90%+ of tiles.
- **Runtime representation:**
- `TilePalette` resource: `BTreeMap<char, TileType>` loaded at startup. Immutable after load.
- `TileCell` extended: `{ palette_id: char, walkable: bool, kind: TileKind, material_id: u16 }`. Material ID is a compact index into the palette's material table.
- `TileOverrideMap` resource: `BTreeMap<(i32, i32, i32), TileOverride>` for per-tile overrides. Sparse — only tiles with overrides consume memory.
- ECS queries: `WalkabilityMap` remains the primary interface for movement/pathfinding (unchanged API). `TilePalette` provides material/visual data when needed (snapshot construction, sound system). `TileOverrideMap` provides door state, access lists, damage overlays.
- **YAML authoring format:**
```yaml
# Palette definition (loaded once, reusable across locations)
palette:
F: { walkable: true, kind: Floor, material: metal-grate, visual_base: floor_industrial }
W: { walkable: false, kind: Wall, material: bulkhead, visual_base: wall_heavy, los_blocking: true }
D: { walkable: true, kind: Door, material: metal-door, visual_base: door_standard }
G: { walkable: true, kind: Floor, material: glass-panel, visual_base: floor_glass }
R: { walkable: false, kind: Floor, material: metal-grate, visual_base: floor_restricted }
# Location tile map (unchanged human-readable format)
tiles:
- "WWWWWWWWWWWWWW"
- "WFFFFDFFFFFFFW"
- "WFFFFFFFFFFGFW"
- "WWWWWWWWWWWWWW"
# Per-tile overrides (sparse, only for non-default properties)
overrides:
- { x: 5, y: 1, door_access: [faction.commission], locked: true }
- { x: 12, y: 2, visual_variant: 3 }
```
- **Loader contract:** `ContentPlugin` loads palette YAML first, then location tiles. The `apply_location_tiles()` function resolves each character via palette lookup instead of the current hardcoded match. Unknown characters fall back to `Floor` with a warning (same as current behavior). Overrides are loaded after tiles and applied to `TileOverrideMap`.
- **Migration effort for existing locations (5 files):**
- **Zero-migration path:** The default palette defines `F/W/V/R` with identical behavior to current hardcoded mapping. Existing location YAMLs work unchanged. No migration required for v0.1.
- **Incremental enrichment:** Locations can opt into the new palette by adding a `palette:` key. Locations without `palette:` use the global default. Migration is per-location, at author pace.
- **Estimated effort:** Palette definition = 0.5 day. Loader refactor = 1-2 days. Override system = 1 day. Total: 2-4 developer-days. No changes to location YAML files required for v0.1.
- **Alternatives considered:**
- **(b) Per-tile property bags** (arbitrary key-value per tile): Maximum flexibility but violates D-010 principle 4 (deterministic — dynamic typing makes serialization non-deterministic). Memory cost: ~100 bytes/tile vs ~6 bytes/tile with palette. Rejected.
- **(c) ECS-style tile components** (tiles as entities): Each tile becomes a bevy_ecs entity with optional components. Elegant in theory but 150x150x3 = 67,500 entities per location, potentially 4M+ entities for a district. ECS entity overhead (~128 bytes each) makes this prohibitively expensive. Queries scale poorly at this count. Rejected for spatial data; tiles remain grid-based. Entities are reserved for interactive objects placed ON tiles.
- **(d) Hybrid (palette + entity overlay):** Palette for base tiles, entities for interactive tile features (doors, containers, triggers). This is *almost* what we chose — the distinction is that our sparse override map is grid-indexed (O(1) lookup by position) rather than entity-query based. Interactive objects that have their own behavior (NPCs, containers, items) remain entities; tile properties that are spatial/static (material, visual variant, access) are grid data.
- **Key design principles:**
- Palette is the type; override is the instance. 90%+ of tiles need only a palette ID.
- String-array tile maps remain human-readable and merge-friendly. No JSON, no complex nested structures.
- `WalkabilityMap` API is unchanged — callers don't know about palettes.
- BTreeMap for deterministic iteration per D-010 principle 4.
- Palette keys are `char` (single Unicode codepoint) for direct mapping from tile string arrays.
- **Raised by:** Tyre (architecture), requested by #586 (Epic: extensible tile data model).
- **Dissent:** None anticipated — this is a design-only D-record for post-v0.1 implementation.
- **Cross-reference:** D-054 (tile-based movement), D-066 (dual-scale grid), D-094 (spatial hierarchy), D-099 (WallBackside classification), D-100 (DamageOverlay), D-012 (chunk architecture)
---
*33 decisions. Last updated: 2026-03-05 (D-113 added — tile data model design, Sprint 24)*
+1 -1
View File
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.22"
version = "0.1.23"
dependencies = [
"bevy_app",
"bevy_ecs",
+2
View File
@@ -318,6 +318,7 @@ mod tests {
state_hash: None,
sim_errors: vec![],
debug_response: None,
current_ticker: None,
}
}
@@ -457,6 +458,7 @@ mod tests {
state_hash: None,
sim_errors: vec![],
debug_response: None,
current_ticker: None,
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
+79 -5
View File
@@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 18;
pub const PROTOCOL_VERSION: u8 = 19;
/// Handshake message sent as the very first framed message after connection (#555).
/// Client reads this before entering the normal tick loop and validates
@@ -46,6 +46,12 @@ pub struct StartupMessage {
/// Generated by SessionManager.new_game() on the client.
/// Same seed → same EntanglementConfig → same NPC population (D-029).
pub world_seed: u64,
/// Character archetype selected by the player (#587).
/// Gates monologue pool selection, verb labels, and examine text.
/// Defaults to Detective for backward compatibility (old clients
/// that omit this field).
#[serde(default)]
pub character_archetype: CharacterArchetype,
}
/// The ONLY data structure crossing the client-server boundary (D-020)
@@ -73,10 +79,11 @@ pub struct StartupMessage {
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
/// sim_errors (#85, structured error reporting to client).
/// v18 adds: debug_response (#580, debug console server — command/response wire).
/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 18.
/// Protocol version for forward compatibility. Current: 19.
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
@@ -197,6 +204,26 @@ pub struct ObserverSnapshot {
/// the tilde console overlay. None in normal gameplay.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub debug_response: Option<DebugResponsePayload>,
/// Current news ticker headline (#591).
/// Populated only when player is in The Last Shift zone.
/// Rotates every TICKER_ROTATION_TICKS ticks (deterministic via SimRng).
/// None when player is outside the bar or no ticker content is loaded.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_ticker: Option<TickerLine>,
}
/// A single news ticker headline crossing the wire boundary (#591).
///
/// Populated from `ticker/the-last-shift.yaml`. The `dual_lens` field
/// in the source YAML is authoring metadata only — it is NOT included here.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TickerLine {
/// Stable identifier for this headline (e.g. "ticker_001").
pub id: String,
/// The headline text displayed in the HUD ticker.
pub text: String,
/// Thematic category (freight, politics, infrastructure, sports, etc.).
pub category: String,
}
/// Game time data for client display (D-031)
@@ -421,6 +448,16 @@ pub enum CharacterArchetype {
Detective,
}
impl CharacterArchetype {
/// String key for monologue pool filtering (#587).
pub fn as_monologue_key(&self) -> &'static str {
match self {
Self::Smuggler => "smuggler",
Self::Detective => "detective",
}
}
}
/// Semantic player actions, not raw key events (D-020)
/// Timestamped for deterministic processing
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -906,16 +943,50 @@ mod tests {
#[test]
fn startup_message_roundtrip() {
let msg = StartupMessage { world_seed: 0xDEADBEEF };
let msg = StartupMessage {
world_seed: 0xDEADBEEF,
character_archetype: CharacterArchetype::Detective,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded, msg);
assert_eq!(decoded.world_seed, 0xDEADBEEF);
assert_eq!(decoded.character_archetype, CharacterArchetype::Detective);
}
#[test]
fn startup_message_smuggler_roundtrip() {
let msg = StartupMessage {
world_seed: 42,
character_archetype: CharacterArchetype::Smuggler,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded, msg);
assert_eq!(decoded.character_archetype, CharacterArchetype::Smuggler);
}
#[test]
fn startup_message_missing_archetype_defaults_to_detective() {
// Simulate an old client that sends only world_seed (no character_archetype).
// serde(default) on StartupMessage.character_archetype should default to Detective.
#[derive(Serialize)]
struct OldStartupMessage {
world_seed: u64,
}
let old = OldStartupMessage { world_seed: 99 };
let bytes = rmp_serde::to_vec_named(&old).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.world_seed, 99);
assert_eq!(decoded.character_archetype, CharacterArchetype::Detective);
}
#[test]
fn startup_message_zero_seed() {
let msg = StartupMessage { world_seed: 0 };
let msg = StartupMessage {
world_seed: 0,
character_archetype: CharacterArchetype::default(),
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.world_seed, 0);
@@ -923,7 +994,10 @@ mod tests {
#[test]
fn startup_message_max_seed() {
let msg = StartupMessage { world_seed: u64::MAX };
let msg = StartupMessage {
world_seed: u64::MAX,
character_archetype: CharacterArchetype::default(),
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.world_seed, u64::MAX);
+20 -3
View File
@@ -30,6 +30,8 @@ pub struct DistrictContent {
pub routines: Option<RoutineFile>,
pub dialogue_pools: Vec<DialoguePool>,
pub monologue_pools: Vec<MonologuePool>,
/// Ticker headlines loaded from `ticker/*.yaml` files (#591).
pub ticker_headlines: Vec<crate::content::types::TickerHeadline>,
}
/// Errors that can occur during content loading.
@@ -234,16 +236,32 @@ fn load_district(district_dir: &Path) -> Result<DistrictContent, ContentError> {
content.monologue_pools = load_yaml_recursive::<MonologuePool>(&monologue_dir);
}
// Ticker headlines (#591): scan ticker/*.yaml in this district
let ticker_dir = district_dir.join("ticker");
if ticker_dir.is_dir() {
let ticker_files = load_yaml_dir::<crate::content::types::TickerFile>(&ticker_dir);
for tf in ticker_files {
tracing::info!(
"Ticker loaded: {} headlines from '{}' (feed: {})",
tf.headlines.len(),
tf.location,
tf.feed,
);
content.ticker_headlines.extend(tf.headlines);
}
}
let npc_count = content.npc_profiles.len();
let triangle_count = content.triangles.len();
let template_count = content.templates.len();
let pool_count = content.pools.len();
let dialogue_count = content.dialogue_pools.len();
let monologue_count = content.monologue_pools.len();
let ticker_count = content.ticker_headlines.len();
tracing::info!(
"District loaded: {} NPCs, {} triangles, {} templates, {} pools, {} dialogue pools, {} monologue pools",
npc_count, triangle_count, template_count, pool_count, dialogue_count, monologue_count
"District loaded: {} NPCs, {} triangles, {} templates, {} pools, {} dialogue pools, {} monologue pools, {} ticker headlines",
npc_count, triangle_count, template_count, pool_count, dialogue_count, monologue_count, ticker_count
);
Ok(content)
@@ -326,7 +344,6 @@ fn walk_yaml_files(dir: &Path, callback: &mut impl FnMut(&Path)) {
}
}
/// Check if a YAML file contains only comments and whitespace (stub file).
// ---------------------------------------------------------------------------
// Tile loading (#577)
// ---------------------------------------------------------------------------
+21
View File
@@ -96,6 +96,27 @@ fn load_and_spawn_content(world: &mut World) {
index.monologue_line_count()
);
// Build TickerPool from loaded headlines (#591).
let ticker_lines: Vec<crate::bridge::types::TickerLine> = store
.districts
.values()
.flat_map(|d| &d.ticker_headlines)
.map(|h| crate::bridge::types::TickerLine {
id: h.id.clone(),
text: h.text.clone(),
category: h.category.clone(),
})
.collect();
let ticker_count = ticker_lines.len();
if ticker_count > 0 {
world.insert_resource(
crate::simulation::ticker::TickerPool::from_lines(ticker_lines),
);
tracing::info!("TickerPool built: {} headlines loaded", ticker_count);
} else {
tracing::warn!("TickerPool: no ticker headlines found — current_ticker will be None");
}
world.insert_resource(ContentStoreResource(store));
world.insert_resource(LinePoolIndexResource(index));
}
+24
View File
@@ -594,3 +594,27 @@ pub struct RelationshipPrerequisite {
#[serde(default)]
pub state: Option<String>,
}
// ---------------------------------------------------------------------------
// Ticker content (#591)
// ---------------------------------------------------------------------------
/// Intermediate type for deserializing a news ticker YAML file.
/// The `dual_lens` field is authoring metadata — not deserialized or forwarded.
#[derive(Debug, Deserialize)]
pub struct TickerFile {
/// Location slug this ticker belongs to (e.g. "the-last-shift").
pub location: String,
/// Feed identifier (e.g. "meridian").
pub feed: String,
pub headlines: Vec<TickerHeadline>,
}
/// A single headline entry in a ticker YAML file.
#[derive(Debug, Deserialize)]
pub struct TickerHeadline {
pub id: String,
pub text: String,
pub category: String,
// dual_lens is intentionally omitted — authoring metadata only
}
+16 -6
View File
@@ -163,17 +163,20 @@ fn main() {
// Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0)
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
// Character archetype from client's StartupMessage (#587).
let archetype = startup.character_archetype;
// Gauntlet test world for --test-mode, proof room for normal mode.
if test_mode {
#[cfg(feature = "gauntlet")]
settled_reach_server::test_world::setup_gauntlet(&mut app);
settled_reach_server::test_world::setup_gauntlet(&mut app, archetype);
#[cfg(not(feature = "gauntlet"))]
{
eprintln!("--test-mode requires the 'gauntlet' feature");
std::process::exit(1);
}
} else {
setup_proof_room(&mut app);
setup_proof_room(&mut app, archetype);
}
tracing::info!(
@@ -301,6 +304,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
triangle_crisis_events: vec![],
state_hash: None,
debug_response: None,
current_ticker: None,
sim_errors: vec![SimError {
kind: SimErrorKind::Panic,
message: format!("Simulation panic: {}", panic_msg),
@@ -362,7 +366,7 @@ fn dump_schedule_graph() {
/// Proof room: 32x32 map, wall at (16,14), player at (16,16), 3 NPCs.
/// Extracted from the original inline setup for reuse by both test-mode and normal mode.
fn setup_proof_room(app: &mut App) {
fn setup_proof_room(app: &mut App, archetype: settled_reach_server::bridge::types::CharacterArchetype) {
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
@@ -394,8 +398,13 @@ fn setup_proof_room(app: &mut App) {
let mut registry = EntityRegistry::new(0);
// Player at (16,16) — smuggler archetype (#418, D-053)
let profile = MovementProfile::smuggler();
// Player at (16,16) — archetype from StartupMessage (#587, D-053)
let profile = match archetype {
settled_reach_server::bridge::types::CharacterArchetype::Smuggler => MovementProfile::smuggler(),
settled_reach_server::bridge::types::CharacterArchetype::Detective => MovementProfile::detective(),
};
let mut monologue_state = MonologueState::default();
monologue_state.character = archetype.as_monologue_key().to_string();
let player = app
.world_mut()
.spawn((
@@ -404,11 +413,12 @@ fn setup_proof_room(app: &mut App) {
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
monologue_state,
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
CognitiveDelay::default(),
ListeningFocus::new(TilePosition::new(16, 16, 0)),
archetype,
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
+12
View File
@@ -132,15 +132,25 @@ pub enum AnimationTier {
Tier2,
}
/// Duration (ticks) a storyteller-escalated RoutineDeviation persists.
/// 300 ticks = 30 game-minutes at 10 ticks/game-minute (D-031).
pub const TELL_ESCALATION_DURATION_TICKS: u64 = 300;
/// Tracks why and when an NPC's routine deviated from normal (D-064 Phase 2).
///
/// Inserted when a player action causes an NPC to break from their scheduled
/// behavior. Acts as a hook for the storyteller system and affects future
/// interactions (e.g., second-approach dialogue differences).
///
/// `expires_at_tick`: tick at which this component should be removed.
/// 0 means "never expires" (legacy default for old insertions).
#[derive(Component, Debug, Clone)]
pub struct RoutineDeviation {
pub trigger: DeviationTrigger,
pub tick: u64,
/// Tick at which this deviation expires (component removed by cleanup system).
/// Set to `tick + TELL_ESCALATION_DURATION_TICKS` for time-limited deviations.
pub expires_at_tick: u64,
}
/// What caused an NPC's routine deviation.
@@ -150,6 +160,8 @@ pub enum DeviationTrigger {
WalkAway,
/// Player delivered a confrontation (D-063).
Confrontation,
/// Storyteller activated a triangle this NPC belongs to (#589, D-024 axis 9).
TriangleEscalation,
}
// ---------------------------------------------------------------------------
+1
View File
@@ -247,6 +247,7 @@ mod tests {
NpcRoutineDeviation {
trigger: DeviationTrigger::WalkAway,
tick: 100,
expires_at_tick: 500,
}
}
+15
View File
@@ -31,6 +31,7 @@ use crate::simulation::rng::SimRng;
use crate::content::template::TriangleCrisisEventQueue;
use crate::simulation::sound::SoundEventQueue;
use crate::simulation::stance::Stance;
use crate::simulation::ticker::{TickerPool, LAST_SHIFT_ZONE_ID};
use crate::simulation::time::SimulationTime;
use crate::simulation::zone::ZoneMap;
@@ -106,6 +107,7 @@ pub fn compute_observer_snapshot(
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
error_buffer: Option<ResMut<SimErrorBuffer>>,
npc_count_query: Query<Entity, With<crate::npc::Npc>>,
ticker_pool: Option<Res<TickerPool>>,
) {
let Ok((
observer_entity,
@@ -437,6 +439,18 @@ pub fn compute_observer_snapshot(
.map(|mut buf| buf.drain())
.unwrap_or_default();
// News ticker (#591): populate when player is in The Last Shift zone.
let player_zone = zone_map
.as_deref()
.and_then(|zm| zm.zone_at(observer_pos.x, observer_pos.y, observer_pos.z));
let current_ticker = if player_zone == Some(LAST_SHIFT_ZONE_ID) {
ticker_pool
.as_deref()
.and_then(|pool| pool.current_line().cloned())
} else {
None
};
buffer.snapshot = Some(ObserverSnapshot {
version: crate::bridge::types::PROTOCOL_VERSION,
tick: time.tick,
@@ -468,6 +482,7 @@ pub fn compute_observer_snapshot(
state_hash,
sim_errors,
debug_response,
current_ticker,
});
}
+2
View File
@@ -765,6 +765,7 @@ pub fn process_walk_away(
.insert(crate::npc::RoutineDeviation {
trigger: crate::npc::DeviationTrigger::WalkAway,
tick: time.tick,
expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS,
});
// Phase 3: Emit IncompleteInteraction knowledge event
@@ -868,6 +869,7 @@ pub fn process_confrontation_response(
crate::npc::RoutineDeviation {
trigger: crate::npc::DeviationTrigger::Confrontation,
tick: time.tick,
expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS,
},
));
+10
View File
@@ -32,6 +32,7 @@ pub mod spatial;
pub mod stance;
pub mod tier;
pub mod time;
pub mod ticker;
pub mod zone;
/// Core simulation plugin
@@ -136,8 +137,17 @@ impl Plugin for SimulationPlugin {
.add_systems(
Update,
chunk_streaming::chunk_streaming.before(input::process_player_input),
)
// News ticker rotation (#591) — deterministic via SimRng, before snapshot.
.add_systems(
Update,
ticker::tick_news_ticker
.before(crate::perception::observer::compute_observer_snapshot),
);
// Initialize TickerPool with empty default; populated by ContentPlugin at Startup.
app.init_resource::<ticker::TickerPool>();
tracing::debug!("SimulationPlugin initialized");
}
}
+2 -2
View File
@@ -155,7 +155,7 @@ pub struct MonologueState {
pub entered: bool,
/// IDs of lines already shown (dedup within session).
pub shown_ids: BTreeSet<String>,
/// Character type for pool filtering. v0.1: always "detective".
/// Character type for pool filtering. Set from CharacterArchetype (#587).
pub character: String,
/// Tick of the last observation event we reacted to (#119, observe_npc).
/// Observation events arrive one tick after the snapshot that caused them,
@@ -171,7 +171,7 @@ impl Default for MonologueState {
idle_ticks: 0,
entered: false,
shown_ids: BTreeSet::new(),
// v0.1: default to detective; character selection sets this
// Default to detective; overridden by CharacterArchetype at spawn (#587)
character: "detective".to_string(),
last_observation_tick: 0,
}
+167
View File
@@ -0,0 +1,167 @@
//! News ticker pool for The Last Shift bar (#591).
//!
//! Holds the loaded headline pool and tracks which headline is currently
//! displayed. Rotates every `TICKER_ROTATION_TICKS` ticks using `SimRng`
//! for determinism (D-010 principle 4).
//!
//! Zone detection: headlines are only emitted when the player is in
//! `LAST_SHIFT_ZONE_ID` (The Last Shift bar tile region).
use bevy_ecs::prelude::*;
use rand::Rng;
use crate::bridge::types::TickerLine;
/// Zone ID assigned to The Last Shift bar tiles (#591, D-077).
///
/// Must match the zone_id used in the production location YAML
/// when the transit district ZoneMap is populated.
///
/// WARNING: Gauntlet assigns zone IDs sequentially and may differ from
/// production. This constant is for production use only; Gauntlet tests
/// should query the ContentStore for the correct zone ID.
pub const LAST_SHIFT_ZONE_ID: u16 = 1;
/// How often the displayed headline rotates, in ticks.
/// 200 ticks = 20 game-minutes at 10 ticks/game-minute (D-031).
pub const TICKER_ROTATION_TICKS: u64 = 200;
/// Resource: loaded ticker pool for the news feed (#591).
///
/// Built at startup from `ticker/the-last-shift.yaml`.
/// Holds all headlines and tracks the current display index.
/// Rotation is deterministic — always use `SimRng`, never system randomness.
#[derive(Resource, Debug, Default)]
pub struct TickerPool {
lines: Vec<TickerLine>,
current_index: usize,
last_rotated_tick: u64,
}
impl TickerPool {
/// Build a pool from the given headline list.
pub fn from_lines(lines: Vec<TickerLine>) -> Self {
Self {
lines,
current_index: 0,
last_rotated_tick: 0,
}
}
/// True if the pool has at least one headline.
pub fn is_empty(&self) -> bool {
self.lines.is_empty()
}
/// The currently active headline, or `None` if the pool is empty.
pub fn current_line(&self) -> Option<&TickerLine> {
self.lines.get(self.current_index)
}
/// Advance to a new headline using SimRng if `TICKER_ROTATION_TICKS` have elapsed.
///
/// Uses sliding-window timing: the next rotation fires `TICKER_ROTATION_TICKS`
/// after the last rotation, not on a fixed modulus boundary. This means the first
/// headline persists for 200 ticks from tick 0, then each subsequent headline
/// persists for 200 ticks from the moment it was selected.
///
/// Called each tick by `tick_news_ticker`. Deterministic: same seed → same rotation.
pub fn maybe_rotate(&mut self, current_tick: u64, rng: &mut crate::simulation::rng::SimRng) {
if self.lines.is_empty() {
return;
}
if current_tick > 0
&& current_tick.saturating_sub(self.last_rotated_tick) >= TICKER_ROTATION_TICKS
{
self.current_index = rng.rng.random_range(0..self.lines.len());
self.last_rotated_tick = current_tick;
}
}
}
/// System: advance ticker rotation each tick (#591, D-010).
///
/// Runs before `compute_observer_snapshot` so the correct headline
/// is available when the snapshot is assembled.
pub fn tick_news_ticker(
time: Res<crate::simulation::time::SimulationTime>,
mut pool: ResMut<TickerPool>,
mut rng: ResMut<crate::simulation::rng::SimRng>,
) {
pool.maybe_rotate(time.tick, &mut rng);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::rng::SimRng;
fn make_pool(count: usize) -> TickerPool {
let lines: Vec<TickerLine> = (0..count)
.map(|i| TickerLine {
id: format!("ticker_{:03}", i),
text: format!("Headline {}", i),
category: "test".to_string(),
})
.collect();
TickerPool::from_lines(lines)
}
#[test]
fn empty_pool_returns_none() {
let pool = TickerPool::default();
assert!(pool.current_line().is_none());
}
#[test]
fn non_empty_pool_returns_first_line() {
let pool = make_pool(5);
assert_eq!(pool.current_line().unwrap().id, "ticker_000");
}
#[test]
fn rotation_advances_after_interval() {
let mut pool = make_pool(30);
let mut rng = SimRng::new(42);
// No rotation at tick 0
pool.maybe_rotate(0, &mut rng);
assert_eq!(pool.current_index, 0);
// Rotation fires at tick TICKER_ROTATION_TICKS
pool.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng);
// index changed (RNG picks something; just verify it's in range)
assert!(pool.current_index < 30);
}
#[test]
fn rotation_is_deterministic() {
let mut pool_a = make_pool(30);
let mut rng_a = SimRng::new(42);
pool_a.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng_a);
let idx_a = pool_a.current_index;
let mut pool_b = make_pool(30);
let mut rng_b = SimRng::new(42);
pool_b.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng_b);
let idx_b = pool_b.current_index;
assert_eq!(idx_a, idx_b, "Same seed must produce same rotation");
}
#[test]
fn no_double_rotation_in_same_window() {
let mut pool = make_pool(30);
let mut rng = SimRng::new(42);
pool.maybe_rotate(TICKER_ROTATION_TICKS, &mut rng);
let idx_after_first = pool.current_index;
// One tick later — within same window, should not rotate
pool.maybe_rotate(TICKER_ROTATION_TICKS + 1, &mut rng);
assert_eq!(
pool.current_index, idx_after_first,
"Should not rotate within the same window"
);
}
}
+200 -1
View File
@@ -384,6 +384,17 @@ impl Plugin for StorytellerPlugin {
.after(append_player_history)
.after(tick_contamination_activation)
.before(crate::simulation::time::advance_tick),
)
.add_systems(
Update,
escalate_tells_on_activation
.after(activation_pass)
.before(crate::npc::tell_state::derive_tell_state),
)
.add_systems(
Update,
expire_routine_deviations
.before(crate::npc::tell_state::derive_tell_state),
);
tracing::debug!("StorytellerPlugin initialized");
@@ -553,9 +564,11 @@ pub fn activation_pass(
// Step 5: select best candidate; SimRng tie-break among equal top scores (D-010)
candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
let top_score = candidates[0].2;
// Exact equality: all inputs are discrete integers cast to f32, so scores
// derived from integer tick counts will compare exactly. No epsilon needed.
let top_count = candidates
.iter()
.take_while(|(_, _, s)| (*s - top_score).abs() <= f32::EPSILON * top_score.abs().max(1.0))
.take_while(|(_, _, s)| *s == top_score)
.count();
let selected_idx = if top_count > 1 {
rng.rng.random_range(0..top_count)
@@ -602,6 +615,75 @@ pub fn activation_pass(
);
}
// ---------------------------------------------------------------------------
// Tell escalation (#589)
// ---------------------------------------------------------------------------
/// System: insert RoutineDeviation on triangle NPCs when a triangle activates (#589).
///
/// Reads TriangleActivatedQueue without draining — other consumers remain unaffected.
/// For each activation event, finds all NPCs assigned to that triangle and inserts
/// a time-limited RoutineDeviation component. RoutineDeviation is priority-1 in the
/// tell derivation hierarchy (D-024), making the NPCs immediately observable as anomalous.
///
/// Runs after activation_pass (which populates the queue).
pub fn escalate_tells_on_activation(
time: Res<SimulationTime>,
queue: Res<TriangleActivatedQueue>,
triangle_query: Query<&TriangleState>,
registry: Res<EntityRegistry>,
mut commands: Commands,
) {
use crate::npc::{DeviationTrigger, RoutineDeviation, TELL_ESCALATION_DURATION_TICKS};
if queue.events.is_empty() {
return;
}
for event in &queue.events {
// Find the TriangleState matching this activation event
for triangle in triangle_query.iter() {
if triangle.triangle_id != event.triangle_id {
continue;
}
// Insert RoutineDeviation on every NPC in the triangle
for stable_id in triangle.role_assignments.values() {
let Some(entity) = registry.to_entity(stable_id) else {
tracing::warn!(
npc_id = stable_id.0,
"escalate_tells_on_activation: NPC not found in registry"
);
continue;
};
commands.entity(entity).insert(RoutineDeviation {
trigger: DeviationTrigger::TriangleEscalation,
tick: time.tick,
expires_at_tick: time.tick + TELL_ESCALATION_DURATION_TICKS,
});
}
break; // each triangle_id appears at most once
}
}
}
/// System: remove expired RoutineDeviation components (#589).
///
/// Each tick, removes RoutineDeviation from any entity whose expires_at_tick
/// has elapsed. A zero expires_at_tick means "never expires" (legacy behavior
/// for WalkAway/Confrontation deviations that predate this cleanup system).
pub fn expire_routine_deviations(
time: Res<SimulationTime>,
deviations: Query<(Entity, &crate::npc::RoutineDeviation)>,
mut commands: Commands,
) {
for (entity, deviation) in deviations.iter() {
if deviation.expires_at_tick > 0 && time.tick >= deviation.expires_at_tick {
commands.entity(entity).remove::<crate::npc::RoutineDeviation>();
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1081,4 +1163,121 @@ mod tests {
// capped at 5 * 2.0 = 10.0
assert!((score - 10.0_f32).abs() < 0.001, "score={score}");
}
// -----------------------------------------------------------------------
// #589: escalate_tells_on_activation
// -----------------------------------------------------------------------
#[test]
fn escalate_inserts_routine_deviation_on_triangle_npcs() {
use crate::knowledge::registry::EntityRegistry;
use crate::knowledge::types::StableId;
use crate::npc::{Npc, RoutineDeviation};
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.init_resource::<TriangleActivatedQueue>();
let mut registry = EntityRegistry::new(0);
// Spawn two NPCs and register them
let npc_a = world.spawn(Npc).id();
let npc_b = world.spawn(Npc).id();
registry.register(npc_a);
registry.register(npc_b);
let sid_a = registry.to_stable(npc_a).unwrap();
let sid_b = registry.to_stable(npc_b).unwrap();
world.insert_resource(registry);
// Spawn a triangle with these two NPCs
let triangle_id = TriangleId::from_seed_and_slug(0, "test-escalation");
let mut role_assignments = BTreeMap::new();
role_assignments.insert(RoleId::new("role_a"), sid_a);
role_assignments.insert(RoleId::new("role_b"), sid_b);
world.spawn((
TriangleState {
triangle_id,
role_assignments,
tension: 50,
phase: TrianglePhase::Active,
tension_rate: 1,
template_id: TemplateId::from_seed_and_slug(0, "test"),
classification: TriangleClassification::ActiveFork,
},
ActiveSim,
));
// Populate the activation queue
world.resource_mut::<TriangleActivatedQueue>().push(TriangleActivatedEvent {
triangle_id,
tick: 100,
anchor_entity: npc_a,
anchor_score: 15.0,
});
// Run system
let mut schedule = Schedule::default();
schedule.add_systems(escalate_tells_on_activation);
schedule.run(&mut world);
// Both NPCs should have RoutineDeviation
assert!(world.get::<RoutineDeviation>(npc_a).is_some(), "NPC A should have RoutineDeviation");
assert!(world.get::<RoutineDeviation>(npc_b).is_some(), "NPC B should have RoutineDeviation");
let dev = world.get::<RoutineDeviation>(npc_a).unwrap();
assert_eq!(dev.trigger, crate::npc::DeviationTrigger::TriangleEscalation);
assert_eq!(dev.expires_at_tick, crate::npc::TELL_ESCALATION_DURATION_TICKS);
}
#[test]
fn expire_routine_deviations_removes_expired() {
use crate::npc::{DeviationTrigger, RoutineDeviation};
let mut world = World::new();
let mut time = SimulationTime::default();
time.tick = 500;
world.insert_resource(time);
// Spawn entity with expired deviation
let entity = world.spawn(RoutineDeviation {
trigger: DeviationTrigger::TriangleEscalation,
tick: 100,
expires_at_tick: 400, // expired at tick 500
}).id();
let mut schedule = Schedule::default();
schedule.add_systems(expire_routine_deviations);
schedule.run(&mut world);
assert!(
world.get::<RoutineDeviation>(entity).is_none(),
"Expired RoutineDeviation should be removed"
);
}
#[test]
fn expire_routine_deviations_keeps_non_expired() {
use crate::npc::{DeviationTrigger, RoutineDeviation};
let mut world = World::new();
let mut time = SimulationTime::default();
time.tick = 200;
world.insert_resource(time);
// Deviation that expires at tick 500 — should survive at tick 200
let entity = world.spawn(RoutineDeviation {
trigger: DeviationTrigger::TriangleEscalation,
tick: 100,
expires_at_tick: 500,
}).id();
let mut schedule = Schedule::default();
schedule.add_systems(expire_routine_deviations);
schedule.run(&mut world);
assert!(
world.get::<RoutineDeviation>(entity).is_some(),
"Non-expired RoutineDeviation should not be removed"
);
}
}
+10 -7
View File
@@ -95,7 +95,7 @@ pub const MAP_HEIGHT: i32 = 125;
/// is intentional for deterministic test setups but should be revisited
/// if Gauntlet is ever served by the production startup pipeline.
#[cfg(feature = "gauntlet")]
pub fn setup_gauntlet(app: &mut App) {
pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterArchetype) {
// Start with a fully blocked map, then carve rooms and corridors.
let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1);
@@ -192,6 +192,8 @@ pub fn setup_gauntlet(app: &mut App) {
// Spawn at Hub center: absolute (50, 58)
let profile = MovementProfile::smuggler();
let player_pos = TilePosition::new(50, 58, 0);
let mut monologue_state = MonologueState::default();
monologue_state.character = archetype.as_monologue_key().to_string();
let player = app
.world_mut()
.spawn((
@@ -200,7 +202,7 @@ pub fn setup_gauntlet(app: &mut App) {
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
monologue_state,
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
ScanEventBuffer::default(),
@@ -212,6 +214,7 @@ pub fn setup_gauntlet(app: &mut App) {
crate::simulation::pressure::CharacterPressure::default(),
))
.id();
app.world_mut().entity_mut(player).insert(archetype);
registry.register(player);
// --- Hub signs (StableId 1-4) ---
@@ -695,7 +698,7 @@ mod tests {
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default());
// Run all 29 world-query invariants against the fully-initialized gauntlet world.
invariants::run_invariants(app.world_mut());
@@ -715,7 +718,7 @@ mod tests {
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default());
let wm = app.world().resource::<WalkabilityMap>();
// Hub center at (50, 58) must be walkable
@@ -729,7 +732,7 @@ mod tests {
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default());
let wm = app.world().resource::<WalkabilityMap>();
// North wall segment at absolute (90, 54) should be blocked
@@ -745,7 +748,7 @@ mod tests {
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default());
let wm = app.world().resource::<WalkabilityMap>();
// corridor-E center should be walkable
@@ -759,7 +762,7 @@ mod tests {
app.add_plugins(crate::knowledge::KnowledgePlugin);
app.add_plugins(crate::npc::NpcPlugin);
setup_gauntlet(&mut app);
setup_gauntlet(&mut app, crate::bridge::types::CharacterArchetype::default());
let registry = app.world().resource::<EntityRegistry>();
+154
View File
@@ -0,0 +1,154 @@
//! Regression tests: character archetype flows end-to-end to MonologueState (#595, D-032).
//!
//! Verifies that when a session starts with a given CharacterArchetype, the
//! player entity's MonologueState.character reflects it correctly. This is the
//! guard against the default "detective" string leaking into smuggler sessions.
//!
//! Two complementary approaches:
//! 1. Unit-level: CharacterArchetype::as_monologue_key() mapping is correct.
//! 2. Integration (gauntlet): setup_gauntlet() correctly wires archetype → MonologueState.
//!
//! Spec refs:
//! D-032: character tag is a hard pool partition, not a filter — wrong character string
//! silently serves wrong content.
//! D-010: no player identity baked into game loop — archetype is a configuration.
//! #587: character_archetype added to StartupMessage; monologue key derived from it.
//! #595: MonologueState.character initialized from CharacterArchetype at session start.
use settled_reach_server::bridge::types::CharacterArchetype;
// ---------------------------------------------------------------------------
// Layer 1 — pure unit tests, no ECS
// ---------------------------------------------------------------------------
#[test]
fn smuggler_archetype_maps_to_monologue_key() {
assert_eq!(
CharacterArchetype::Smuggler.as_monologue_key(),
"smuggler",
"Smuggler must produce the exact pool key 'smuggler' used in monologue YAML"
);
}
#[test]
fn detective_archetype_maps_to_monologue_key() {
assert_eq!(
CharacterArchetype::Detective.as_monologue_key(),
"detective",
"Detective must produce the exact pool key 'detective' used in monologue YAML"
);
}
#[test]
fn default_archetype_is_detective() {
// D-010: the safe fallback is Detective (the original single-character game).
// If serde default fires (old client, missing field), Detective must be chosen.
assert_eq!(
CharacterArchetype::default(),
CharacterArchetype::Detective,
"Default archetype must be Detective for backward compatibility (#587)"
);
}
#[test]
fn archetype_keys_are_distinct() {
// Sanity guard: the two keys must differ. If they were the same, pool partitioning
// (D-032) would be broken and both characters would see identical monologue lines.
assert_ne!(
CharacterArchetype::Smuggler.as_monologue_key(),
CharacterArchetype::Detective.as_monologue_key(),
"Smuggler and Detective monologue keys must be distinct (D-032 hard partition)"
);
}
// ---------------------------------------------------------------------------
// Layer 2 — integration: setup_gauntlet wires archetype → MonologueState
// ---------------------------------------------------------------------------
#[cfg(feature = "gauntlet")]
mod gauntlet_integration {
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use settled_reach_server::{
bridge::types::CharacterArchetype,
simulation::{monologue::MonologueState, movement::PlayerCharacter, SimulationPlugin},
test_world,
};
/// Build a minimal Gauntlet app with the given archetype and run one tick.
fn boot_gauntlet(archetype: CharacterArchetype) -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
test_world::setup_gauntlet(&mut app, archetype);
app.update();
app
}
#[test]
fn smuggler_archetype_sets_monologue_character_to_smuggler() {
let mut app = boot_gauntlet(CharacterArchetype::Smuggler);
let mut query = app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
let state = query
.single(app.world())
.expect("player entity with MonologueState must exist after gauntlet setup");
assert_eq!(
state.character, "smuggler",
"Smuggler archetype must produce MonologueState.character = 'smuggler' (D-032, #587)"
);
}
#[test]
fn detective_archetype_sets_monologue_character_to_detective() {
let mut app = boot_gauntlet(CharacterArchetype::Detective);
let mut query = app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
let state = query
.single(app.world())
.expect("player entity with MonologueState must exist after gauntlet setup");
assert_eq!(
state.character, "detective",
"Detective archetype must produce MonologueState.character = 'detective' (D-032, #587)"
);
}
#[test]
fn smuggler_and_detective_produce_different_monologue_characters() {
// Regression guard: if both sessions return the same character string, D-032
// partitioning is broken. This test catches copy-paste mistakes in setup paths.
let mut smuggler_app = boot_gauntlet(CharacterArchetype::Smuggler);
let mut detective_app = boot_gauntlet(CharacterArchetype::Detective);
let smuggler_char = {
let mut q = smuggler_app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
q.single(smuggler_app.world())
.expect("smuggler player must exist")
.character
.clone()
};
let detective_char = {
let mut q = detective_app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
q.single(detective_app.world())
.expect("detective player must exist")
.character
.clone()
};
assert_ne!(
smuggler_char, detective_char,
"Smuggler and Detective sessions must have different MonologueState.character values \
(D-032 hard partition: same key means both characters see each other's monologue pool)"
);
}
}
+1
View File
@@ -76,6 +76,7 @@ fn snapshot_roundtrip_over_unix_socket() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
bridge
+1
View File
@@ -62,6 +62,7 @@ fn snapshot_roundtrip_over_tcp() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
bridge
+1 -1
View File
@@ -66,7 +66,7 @@ fn setup_baseline() -> App {
app.add_plugins(NpcPlugin);
#[cfg(feature = "gauntlet")]
settled_reach_server::test_world::setup_gauntlet(&mut app);
settled_reach_server::test_world::setup_gauntlet(&mut app, settled_reach_server::bridge::types::CharacterArchetype::default());
app
}
+1
View File
@@ -303,6 +303,7 @@ fn snapshot_with_sim_errors_roundtrips() {
tick: 10,
},
],
current_ticker: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
+3
View File
@@ -52,6 +52,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
}
}
@@ -247,6 +248,7 @@ fn generate_msgpack_fixtures() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
write_fixture(
"snapshot_v2_full",
@@ -412,6 +414,7 @@ fn generate_msgpack_fixtures() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
write_fixture(
"snapshot_full",
+1 -1
View File
@@ -45,7 +45,7 @@ fn build_gauntlet(seed: u64) -> App {
app.add_plugins(NpcPlugin);
app.insert_resource(SimRng::new(seed));
test_world::setup_gauntlet(&mut app);
test_world::setup_gauntlet(&mut app, settled_reach_server::bridge::types::CharacterArchetype::default());
app
}
+1 -1
View File
@@ -40,7 +40,7 @@
"state_hash": 14452262397297540338,
"tick": 8,
"triangle_crisis_events": [],
"version": 18,
"version": 19,
"visible_tiles": [
{
"tile_kind": "Floor",
+4 -1
View File
@@ -85,7 +85,10 @@ fn server_subprocess_sends_snapshot_on_connect() {
);
// 5. Send StartupMessage with world_seed (#175)
let startup = StartupMessage { world_seed: 42 };
let startup = StartupMessage {
world_seed: 42,
character_archetype: settled_reach_server::bridge::types::CharacterArchetype::default(),
};
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize StartupMessage");
write_framed(&mut writer, &startup_payload).expect("send StartupMessage to server");
+242
View File
@@ -0,0 +1,242 @@
//! Tests for the news ticker system (#591, D-036).
//!
//! Covers:
//! - D-036: Sova Transit District — The Last Shift bar shows news ticker
//! - D-010 principle 4: ticker rotation must use SimRng (deterministic)
//! - #591: TickerPool loads ticker/the-last-shift.yaml, rotates every 200 ticks,
//! populates current_ticker in ObserverSnapshot when player is in "bar" zone
//!
//! Test structure:
//! - Layer 1 (pure): validate the ticker YAML content (30 headlines, required fields)
//! - Layer 2 (#[ignore]): TickerPool resource loads and rotates correctly (pending #591)
//! - Layer 2 (#[ignore]): current_ticker is None outside "bar" zone (pending #591)
//! - Layer 2 (#[ignore]): current_ticker is Some when in "bar" zone (pending #591)
//!
//! The Layer 1 tests run immediately and guard against content authoring errors.
//! The Layer 2 tests become runnable once TickerPool is registered in the content plugin.
// ---------------------------------------------------------------------------
// Layer 1: ticker YAML content validation (no ECS needed)
// ---------------------------------------------------------------------------
use std::path::PathBuf;
fn ticker_yaml_path() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir)
.join("../content/campaigns/main/systems/krenn/stations/sova/districts/transit/ticker/the-last-shift.yaml")
}
/// Minimal YAML structure for parsing just what we need to validate.
#[derive(serde::Deserialize)]
struct TickerFile {
location: String,
feed: String,
headlines: Vec<HeadlineEntry>,
}
#[derive(serde::Deserialize)]
struct HeadlineEntry {
id: String,
text: String,
category: String,
// dual_lens intentionally omitted — it's authoring metadata, not wire data (D-036)
}
#[test]
fn ticker_yaml_exists_and_parses() {
let path = ticker_yaml_path();
assert!(
path.exists(),
"Ticker YAML must exist at {:?} (D-036, #591). Run content generation if missing.",
path
);
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read ticker YAML: {}", e));
let _: TickerFile = serde_yaml::from_str(&content)
.unwrap_or_else(|e| panic!("Ticker YAML failed to parse: {}\nFile: {:?}", e, path));
}
#[test]
fn ticker_yaml_has_30_headlines() {
// Sprint 12 #306 delivered exactly 30 headlines. The pool size affects rotation coverage.
// If this fails, a headline was accidentally removed from the authored content.
let path = ticker_yaml_path();
if !path.exists() {
eprintln!("Skipping: ticker YAML not found");
return;
}
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
assert_eq!(
file.headlines.len(),
30,
"Ticker pool must have exactly 30 headlines (Sprint 12, #306). \
Found {}. Do not add or remove headlines without updating this test.",
file.headlines.len()
);
}
#[test]
fn ticker_yaml_location_is_the_last_shift() {
let path = ticker_yaml_path();
if !path.exists() { return; }
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
assert_eq!(
file.location, "the-last-shift",
"Ticker file must be scoped to 'the-last-shift' location (D-036)"
);
assert_eq!(
file.feed, "meridian",
"Ticker feed must be 'meridian' (D-036 Meridian Feed)"
);
}
#[test]
fn ticker_yaml_all_headlines_have_required_fields() {
let path = ticker_yaml_path();
if !path.exists() { return; }
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
for (i, headline) in file.headlines.iter().enumerate() {
assert!(
!headline.id.is_empty(),
"Headline[{}] missing id field",
i
);
assert!(
!headline.text.is_empty(),
"Headline[{}] (id={}) has empty text",
i, headline.id
);
assert!(
!headline.category.is_empty(),
"Headline[{}] (id={}) missing category",
i, headline.id
);
}
}
#[test]
fn ticker_yaml_ids_are_unique() {
let path = ticker_yaml_path();
if !path.exists() { return; }
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
let mut seen = std::collections::HashSet::new();
for headline in &file.headlines {
assert!(
seen.insert(headline.id.clone()),
"Duplicate ticker headline id: '{}' — each headline must have a unique id (#591)",
headline.id
);
}
}
#[test]
fn ticker_yaml_categories_are_valid() {
// D-036 defines 6 categories: freight, politics, infrastructure, sports, commission, community
let valid_categories = ["freight", "politics", "infrastructure", "sports", "commission", "community"];
let path = ticker_yaml_path();
if !path.exists() { return; }
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
for headline in &file.headlines {
assert!(
valid_categories.contains(&headline.category.as_str()),
"Headline '{}' has unknown category '{}'. Valid categories: {:?}",
headline.id, headline.category, valid_categories
);
}
}
#[test]
fn ticker_yaml_category_distribution_is_sane() {
// Comment in the YAML: freight (9), politics (4), infrastructure (5), sports (3),
// commission (5), community (4) = 30 total. Verify no category is completely absent.
let path = ticker_yaml_path();
if !path.exists() { return; }
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for headline in &file.headlines {
*counts.entry(headline.category.clone()).or_insert(0) += 1;
}
for cat in ["freight", "politics", "infrastructure", "sports", "commission", "community"] {
assert!(
*counts.get(cat).unwrap_or(&0) > 0,
"Category '{}' has no headlines — content is missing or miscategorized",
cat
);
}
}
// ---------------------------------------------------------------------------
// Layer 2: TickerPool runtime behavior (pending #591 implementation)
// ---------------------------------------------------------------------------
// These tests are ignored until TickerPool is implemented and pub-exported.
// When #591 lands, remove the #[ignore] attributes and verify they pass.
//
// Expected API to implement:
// - settled_reach_server::simulation::ticker::TickerPool (Resource)
// - settled_reach_server::bridge::types::TickerLine { id, text, category }
// - ObserverSnapshot.current_ticker: Option<TickerLine>
// - TICKER_ROTATION_TICKS: u64 = 200 (in ticker module)
#[test]
#[ignore = "pending TickerPool implementation (#591)"]
fn ticker_pool_loads_all_30_headlines() {
// TODO: TickerPool::load() should parse the YAML and hold all 30 headlines.
// Verifies content loader wiring (ticker/ subdirectory is scanned).
}
#[test]
#[ignore = "pending TickerPool implementation (#591)"]
fn ticker_rotates_at_200_tick_boundary() {
// TODO: run 200 ticks, assert current_headline changes.
// Must use SimRng — running with same seed must produce same sequence.
}
#[test]
#[ignore = "pending TickerPool implementation (#591)"]
fn ticker_rotation_is_deterministic_under_same_seed() {
// TODO: two apps, same seed, assert same ticker at tick 200 and 400.
// D-010 principle 4: deterministic simulation.
}
#[test]
#[ignore = "pending current_ticker in ObserverSnapshot (#591)"]
fn current_ticker_is_none_when_player_is_not_in_bar_zone() {
// TODO: player in terminal zone → snapshot.current_ticker is None.
// Edge case: don't leak bar headlines into The Terminal or corridor zones.
}
#[test]
#[ignore = "pending current_ticker in ObserverSnapshot (#591)"]
fn current_ticker_is_some_when_player_is_in_bar_zone() {
// TODO: player in bar zone → snapshot.current_ticker is Some.
// Spec: zone_id from zone.rs LAST_SHIFT_BAR_ZONE or equivalent.
}
#[test]
#[ignore = "pending current_ticker in ObserverSnapshot (#591)"]
fn ticker_line_dual_lens_field_not_in_wire_format() {
// TODO: serialize TickerLine, assert no dual_lens key in msgpack output.
// D-036 says dual_lens is authoring metadata ONLY — must not cross the wire.
}
+4 -1
View File
@@ -40,6 +40,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
}
}
@@ -302,6 +303,7 @@ fn snapshot_v2_fields_roundtrip() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -356,7 +358,7 @@ fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 18,
PROTOCOL_VERSION, 19,
"bump this assertion when protocol version changes"
);
}
@@ -410,6 +412,7 @@ fn all_facing_direction_variants_roundtrip() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
+233
View File
@@ -0,0 +1,233 @@
//! Integration tests for tell escalation on triangle activation (#589, D-024 axis 9).
//!
//! Covers:
//! - D-027 criterion 4: RoutineDeviation tell must be observable after activation
//! - D-024 axis 9: tell system is a simulation output, not authored content
//! - #589: escalate_tells_on_activation system inserts RoutineDeviation on triangle NPCs
//!
//! Test structure:
//! - Layer 1 (pure): verify RoutineDeviation dominates all other tells (already
//! covered by unit tests in tell_state.rs, regression guards here)
//! - Layer 2 (ECS world): verify activation event → RoutineDeviation insertion (pending #589)
//! - Layer 2 (ECS world): verify expired RoutineDeviation is removed (pending #589)
//!
//! Pending tests are marked #[ignore] — they compile against the current API but
//! will fail until escalate_tells_on_activation is registered in StorytellerPlugin.
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use settled_reach_server::{
npc::{
tell_state::{DerivedTellState, TellCategory},
Contentment, DeviationTrigger, Npc, RoutineDeviation, Secret, SecretSeverity,
ToleranceThreshold,
},
npc::mood::MoodState,
simulation::tier::ActiveSim,
};
// ---------------------------------------------------------------------------
// Layer 1 regression: RoutineDeviation component presence → RoutineDeviation tell
// (These pass today; guard against future tell priority regressions)
// ---------------------------------------------------------------------------
/// Build a minimal ECS world with one NPC and run derive_tell_state.
fn make_tell_world_with_deviation(deviation: Option<RoutineDeviation>) -> (World, Entity) {
let mut world = World::new();
let mut npc = world.spawn((
Npc,
ActiveSim,
Secret {
description: "minor".into(),
severity: SecretSeverity::Minor,
known_by: vec![],
},
ToleranceThreshold { current_stress: 0, threshold: 50 },
Contentment { level: 0 },
MoodState { mood: settled_reach_server::npc::mood::NpcMood::Neutral, changed_tick: 0 },
DerivedTellState::default(),
));
let entity = if let Some(dev) = deviation {
npc.insert(dev).id()
} else {
npc.id()
};
(world, entity)
}
#[test]
fn routine_deviation_component_produces_deviation_tell_via_system() {
// Layer 1 regression: verify that inserting RoutineDeviation on an NPC and running
// the derive_tell_state system produces TellCategory::RoutineDeviation.
// This guards against priority regressions in derive_tell_state (D-027 criterion 4).
let (mut world, entity) = make_tell_world_with_deviation(Some(RoutineDeviation {
trigger: DeviationTrigger::WalkAway,
tick: 0,
expires_at_tick: 300,
}));
let mut schedule = Schedule::default();
schedule.add_systems(settled_reach_server::npc::tell_state::derive_tell_state);
schedule.run(&mut world);
let tell = world.get::<DerivedTellState>(entity).unwrap();
assert_eq!(
tell.category,
Some(TellCategory::RoutineDeviation),
"NPC with RoutineDeviation component must produce RoutineDeviation tell (D-027 criterion 4)"
);
}
#[test]
fn no_deviation_component_does_not_produce_deviation_tell() {
// Layer 1 regression: absence of RoutineDeviation must not produce deviation tell.
let (mut world, entity) = make_tell_world_with_deviation(None);
let mut schedule = Schedule::default();
schedule.add_systems(settled_reach_server::npc::tell_state::derive_tell_state);
schedule.run(&mut world);
let tell = world.get::<DerivedTellState>(entity).unwrap();
assert_ne!(
tell.category,
Some(TellCategory::RoutineDeviation),
"NPC without RoutineDeviation must not produce RoutineDeviation tell"
);
}
// ---------------------------------------------------------------------------
// Layer 2: activation event → RoutineDeviation insertion (pending #589)
// ---------------------------------------------------------------------------
/// Set up a minimal gauntlet-based app with storyteller plugin running.
#[cfg(feature = "gauntlet")]
fn build_storyteller_app() -> App {
use settled_reach_server::{
bridge::types::CharacterArchetype,
simulation::SimulationPlugin,
test_world,
};
let mut app = App::new();
app.add_plugins(SimulationPlugin);
test_world::setup_gauntlet(&mut app, CharacterArchetype::default());
app
}
/// Retrieve the first NPC entity visible in the gauntlet (used to fabricate test events).
#[cfg(feature = "gauntlet")]
fn first_npc_entity(app: &mut App) -> Entity {
use settled_reach_server::npc::Npc;
let mut q = app.world_mut().query_filtered::<Entity, With<Npc>>();
q.iter(app.world()).next().expect("gauntlet must have at least one NPC")
}
#[cfg(feature = "gauntlet")]
#[test]
#[ignore = "pending escalate_tells_on_activation system (#589)"]
fn triangle_activation_event_inserts_routine_deviation_on_anchor_npc() {
// Inject a TriangleActivatedEvent pointing to an NPC, run one tick, assert
// RoutineDeviation is inserted on that NPC by escalate_tells_on_activation.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::{TriangleId};
let mut app = build_storyteller_app();
// Run one tick so the world is fully initialized before we inject
app.update();
let anchor = first_npc_entity(&mut app);
{
let mut queue = app.world_mut().resource_mut::<TriangleActivatedQueue>();
queue.push(TriangleActivatedEvent {
triangle_id: TriangleId::from_seed_and_slug(42, "test-escalation"),
tick: 1,
anchor_entity: anchor,
anchor_score: 25.0,
});
}
// Run one tick — escalate_tells_on_activation should fire
app.update();
let deviation = app.world().get::<RoutineDeviation>(anchor);
assert!(
deviation.is_some(),
"Anchor NPC must have RoutineDeviation after TriangleActivated event (#589, D-024 axis 9)"
);
}
#[cfg(feature = "gauntlet")]
#[test]
#[ignore = "pending escalate_tells_on_activation system (#589)"]
fn triangle_activation_produces_routine_deviation_tell_in_snapshot() {
// End-to-end: after activation event, DerivedTellState on anchor NPC must be
// TellCategory::RoutineDeviation. This verifies the full axis-9 pipeline.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::TriangleId;
let mut app = build_storyteller_app();
app.update(); // initialize
let anchor = first_npc_entity(&mut app);
{
let mut queue = app.world_mut().resource_mut::<TriangleActivatedQueue>();
queue.push(TriangleActivatedEvent {
triangle_id: TriangleId::from_seed_and_slug(42, "test-tell"),
tick: 1,
anchor_entity: anchor,
anchor_score: 25.0,
});
}
app.update(); // activation tick
app.update(); // derive_tell_state tick
let tell = app.world().get::<DerivedTellState>(anchor);
assert_eq!(
tell.map(|t| t.category),
Some(Some(TellCategory::RoutineDeviation)),
"After triangle activation, anchor NPC's tell must be RoutineDeviation (D-027 criterion 4)"
);
}
#[cfg(feature = "gauntlet")]
#[test]
#[ignore = "pending expires_at_tick field and removal system (#589)"]
fn routine_deviation_expires_after_duration() {
// After TELL_ESCALATION_DURATION_TICKS ticks, RoutineDeviation must be removed
// by the expiry system. This verifies the component doesn't persist forever.
//
// Edge case: D-027 criterion 4 must continue to fire DURING the window
// and stop firing AFTER it. NPCs shouldn't be permanently flagged.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::TriangleId;
// NOTE: TELL_ESCALATION_DURATION_TICKS constant (= 300) expected in storyteller module.
// This test will need updating once the constant is public.
let mut app = build_storyteller_app();
app.update(); // initialize
let anchor = first_npc_entity(&mut app);
{
let mut queue = app.world_mut().resource_mut::<TriangleActivatedQueue>();
queue.push(TriangleActivatedEvent {
triangle_id: TriangleId::from_seed_and_slug(42, "test-expiry"),
tick: 1,
anchor_entity: anchor,
anchor_score: 25.0,
});
}
// Run enough ticks to trigger expiry (301 > TELL_ESCALATION_DURATION_TICKS = 300).
// Note: 302 updates is acceptable for a unit test; if this becomes a perf concern
// when un-ignored, consider advancing SimulationTime directly instead of looping.
for _ in 0..302 {
app.update();
}
let deviation = app.world().get::<RoutineDeviation>(anchor);
assert!(
deviation.is_none(),
"RoutineDeviation must be removed after TELL_ESCALATION_DURATION_TICKS ticks (#589). \
Persistent deviation would flag NPCs permanently breaking the tell signal over time."
);
}
+289
View File
@@ -0,0 +1,289 @@
//! v0.1 integration playthrough test (#593, D-027).
//!
//! Validates the full session lifecycle from StartupMessage to storyteller activation:
//! D-027 criterion 1: player sees opening monologue on session start
//! D-027 criterion 4: NPC RoutineDeviation tell observable after triangle activation
//! D-036: news ticker headline visible in The Last Shift zone
//!
//! Test structure:
//! - `test_smuggler_opening_monologue`: asserts smuggler pool fires on tick 1 (runs now)
//! - `test_detective_opening_monologue`: asserts detective pool fires on tick 1 (runs now)
//! - `test_v0_1_integration_playthrough`: full E2E proof (#[ignore] until #589, #591 land)
//!
//! Uses Layer 3 pattern: real server subprocess, TCP IPC, no mocks.
//!
//! Prerequisites to unblock:
//! #589: escalate_tells_on_activation system (for RoutineDeviation assertion)
//! #591: TickerPool + current_ticker in snapshot (for ticker assertion)
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::types::*;
use settled_reach_server::npc::tell_state::TellCategory;
use std::io::{BufRead, BufReader, BufWriter};
use std::net::TcpStream;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Timeout for the server to emit LISTENING:{port} on stdout.
const LISTEN_TIMEOUT: Duration = Duration::from_secs(15);
/// Timeout for any individual snapshot read.
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(15);
// ---------------------------------------------------------------------------
// Server lifecycle helpers
// ---------------------------------------------------------------------------
struct TestServer {
child: std::process::Child,
reader: BufReader<TcpStream>,
writer: BufWriter<TcpStream>,
}
impl TestServer {
/// Boot the server binary in test mode (gauntlet), send StartupMessage,
/// return a connected handle ready to receive snapshots.
fn boot_gauntlet(world_seed: u64, archetype: CharacterArchetype) -> Self {
let server_bin = env!("CARGO_BIN_EXE_settled-reach-server");
let mut child = Command::new(server_bin)
.args(["--test-mode", "--port", "0"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn server binary");
let stdout = child.stdout.take().expect("stdout not captured");
let mut stdout_reader = BufReader::new(stdout);
// Parse LISTENING:{port}
let port = {
let deadline = Instant::now() + LISTEN_TIMEOUT;
let mut line = String::new();
loop {
line.clear();
match stdout_reader.read_line(&mut line) {
Ok(0) => panic!("server stdout closed before LISTENING signal"),
Ok(_) => {
let trimmed = line.trim();
if let Some(port_str) = trimmed.strip_prefix("LISTENING:") {
break port_str.parse::<u16>().expect("invalid port");
}
}
Err(e) => panic!("failed to read server stdout: {}", e),
}
assert!(Instant::now() < deadline, "timed out waiting for LISTENING signal");
}
};
let addr = format!("127.0.0.1:{}", port);
let stream = TcpStream::connect(&addr).expect("client connect");
stream.set_read_timeout(Some(SNAPSHOT_TIMEOUT)).expect("set timeout");
let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
let mut writer = BufWriter::new(stream);
// Protocol handshake
let hf = read_framed(&mut reader).expect("read handshake").expect("connection closed");
let _: HandshakeMessage = rmp_serde::from_slice(&hf).expect("deserialize handshake");
// StartupMessage with chosen archetype
let startup = StartupMessage { world_seed, character_archetype: archetype };
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize startup");
write_framed(&mut writer, &startup_payload).expect("send startup");
TestServer { child, reader, writer }
}
/// Send a tick's worth of inputs (empty = idle tick) and read back one snapshot.
fn tick(&mut self, inputs: Vec<PlayerInput>) -> ObserverSnapshot {
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize inputs");
write_framed(&mut self.writer, &payload).expect("send inputs");
let frame = read_framed(&mut self.reader)
.expect("read snapshot frame")
.expect("server closed connection");
rmp_serde::from_slice(&frame).expect("deserialize snapshot")
}
/// Send a debug command and get the next snapshot.
fn send_debug(&mut self, cmd: DebugCommandKind) -> ObserverSnapshot {
self.tick(vec![PlayerInput {
tick: 0,
action: PlayerAction::DebugCommand(cmd),
}])
}
fn shutdown(mut self) {
drop(self.reader);
drop(self.writer);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match self.child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if Instant::now() > deadline {
self.child.kill().ok();
self.child.wait().ok();
break;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => { self.child.kill().ok(); break; }
}
}
}
}
// ---------------------------------------------------------------------------
// Tests: opening monologue archetype partitioning (runs now — no #[ignore])
// ---------------------------------------------------------------------------
#[test]
fn test_smuggler_opening_monologue() {
// Boot with Smuggler, advance 1 tick, assert opening monologue fires from smuggler pool.
// Monologue IDs from smuggler/opening.yaml start with "pc-smuggler_".
// This verifies: archetype → MonologueState.character → pool selection (D-032, #587, #595).
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
let snapshot = server.tick(vec![PlayerInput { tick: 0, action: PlayerAction::MoveNorth }]);
assert_eq!(snapshot.version, PROTOCOL_VERSION, "protocol version mismatch");
let monologue = snapshot.current_monologue;
assert!(
monologue.is_some(),
"Smuggler session must fire opening monologue on tick 1 (enter_location trigger, D-027 criterion 1). \
Got None either MonologueState.character is wrong or opening.yaml lines are not loaded."
);
let monologue = monologue.unwrap();
assert!(
monologue.id.starts_with("pc-smuggler_"),
"Smuggler opening monologue ID must start with 'pc-smuggler_' (D-032 hard partition). \
Got id='{}'. Likely cause: MonologueState.character defaulted to 'detective' despite Smuggler archetype.",
monologue.id
);
server.shutdown();
}
#[test]
fn test_detective_opening_monologue() {
// Boot with Detective, advance 1 tick, assert opening monologue fires from detective pool.
// Monologue IDs from detective/opening.yaml start with "pc-detective_".
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective);
let snapshot = server.tick(vec![PlayerInput { tick: 0, action: PlayerAction::MoveNorth }]);
assert_eq!(snapshot.version, PROTOCOL_VERSION, "protocol version mismatch");
let monologue = snapshot.current_monologue;
assert!(
monologue.is_some(),
"Detective session must fire opening monologue on tick 1 (enter_location trigger). \
Got None either MonologueState.character is wrong or opening.yaml lines are not loaded."
);
let monologue = monologue.unwrap();
assert!(
monologue.id.starts_with("pc-detective_"),
"Detective opening monologue ID must start with 'pc-detective_' (D-032 hard partition). \
Got id='{}'. Likely cause: archetype defaulted incorrectly.",
monologue.id
);
server.shutdown();
}
#[test]
fn test_smuggler_and_detective_get_different_opening_monologue_ids() {
// Regression guard: two sessions with different archetypes must never produce
// the same monologue ID on tick 1. If they do, D-032 partitioning is broken.
let mut smug = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
let smug_snap = smug.tick(vec![]);
let smug_id = smug_snap.current_monologue
.as_ref()
.map(|m| m.id.clone())
.unwrap_or_default();
smug.shutdown();
let mut det = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective);
let det_snap = det.tick(vec![]);
let det_id = det_snap.current_monologue
.as_ref()
.map(|m| m.id.clone())
.unwrap_or_default();
det.shutdown();
assert_ne!(
smug_id, det_id,
"Smuggler and Detective must fire different opening monologue IDs (D-032). \
Both got '{}' pool partitioning is broken.",
smug_id
);
}
// ---------------------------------------------------------------------------
// Full v0.1 playthrough proof (blocked until #589 + #591 land)
// ---------------------------------------------------------------------------
#[test]
#[ignore = "blocked: TeleportToLocation debug command not implemented (needs location tile_bounds from ContentStore). Criteria 1+2 covered by non-ignored tests above."]
fn test_v0_1_integration_playthrough() {
// Full E2E proof per D-027 v0.1 success criteria:
// 1. Opening monologue fires in correct character pool
// 2. After activation, anchor NPC shows RoutineDeviation tell
// 3. News ticker visible when player is in "bar" zone
// (Manual criterion: walk to terminal, observe Kael, see fog-and-tension)
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
// === Criterion 1: Opening monologue (Smuggler) ===
let tick1 = server.tick(vec![]);
let monologue = tick1.current_monologue.expect("Opening monologue must fire on tick 1");
assert!(
monologue.id.starts_with("pc-smuggler_"),
"Tick-1 monologue must be from smuggler pool. Got: {}",
monologue.id
);
// === Skip to contamination phase (fast-forward via debug) ===
let _skip_snap = server.send_debug(DebugCommandKind::SkipToContamination);
let _contaminate = server.send_debug(DebugCommandKind::ForceContaminationActivate);
// === Run ticks and watch for triangle activation ===
let mut triangle_crisis_observed = false;
for _ in 0..20 {
let snap = server.tick(vec![]);
if !snap.triangle_crisis_events.is_empty() {
triangle_crisis_observed = true;
break;
}
}
assert!(
triangle_crisis_observed,
"Triangle crisis event must appear within 20 ticks after contamination activation (#589)"
);
// === Criterion 2 (D-027 criterion 4): RoutineDeviation tell visible ===
// After activation, at least one NPC must show RoutineDeviation tell in the snapshot.
let mut deviation_observed = false;
for _ in 0..5 {
let snap = server.tick(vec![]);
if snap.entities.iter().any(|e| e.tell_state == Some(TellCategory::RoutineDeviation)) {
deviation_observed = true;
break;
}
}
assert!(
deviation_observed,
"After triangle activation, at least one NPC must show RoutineDeviation tell (D-027 criterion 4, #589)"
);
// === Criterion 3 (D-036): News ticker visible in bar zone ===
// Teleport to The Last Shift bar zone and check current_ticker is Some.
let _teleport = server.send_debug(DebugCommandKind::TeleportToLocation("the-last-shift".into()));
let bar_snap = server.tick(vec![]);
assert!(
bar_snap.current_ticker.is_some(),
"current_ticker must be Some when player is in 'the-last-shift' zone (D-036, #591)"
);
server.shutdown();
}