diff --git a/CHANGELOG.md b/CHANGELOG.md index d5251a27e..e28ab1cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Added - Archetype evidence presentation spec (#443, D-065/D-034/D-033) — detective case file vs smuggler notebook design document: item definitions, knowledge graph presentation, contradiction markers, THE FRIEND arc walkthroughs, systems interaction map, authoring guidelines +- Cognitive delay system (#423, D-060) — CognitiveDelay component buffers perception events before emitting KnowledgeEvents (0.6s base / 0.3s urgent at 10 tps), pending_recognitions in ObserverSnapshot v7 for client fog entity visualization, cancellation on entity LOS exit +- ListeningFocus eavesdrop system (#426, D-053) — stationary_ticks tracking for eavesdrop positioning bonus, 30-tick threshold (20 for Careful stance), Sprint blocks accumulation, registered after validate_movement +- YAML content loader with hot-reload (#326, D-028) — LinePool system parsing dialogue/monologue YAML into BTreeMap-indexed pools, 4-layer query filtering (access > situation > trust > topic+mood), timestamp-polling hot-reload (dev-only), graceful failure preserves previous content +- Line pool format specification (#308) — formal spec at docs/architecture/line-pool-format.md defining YAML structure, tag enums, 4-layer filtering pipeline, prerequisite-to-KG mapping, ID format, and Rust loader interface +- InteractionMemory KG schema design (#442, D-064) — design doc at docs/architecture/interaction-memory-schema.md extending FactKnowledge with interaction tracking, 5-state InteractionState enum, monologue prerequisite extension, NpcTolerance reconciliation + +### Changed +- Protocol version bumped from 6 to 7 (pending_recognitions field in ObserverSnapshot) +- MessagePack fixtures regenerated for protocol v7 +- Monologue trigger system uses .values() iterator (clippy fix) +- Monologue schema: relationship prerequisite now requires target and state fields +- 389 tests total (70 new) — cognitive delay pipeline, line pool loader, ListeningFocus, content watching, serialization - Client protocol v6 bridge — decode player_stance (4 variants) and player_inventory from ObserverSnapshot, TOGGLE_STANCE_UP/DOWN input actions, 25 gdUnit4 tests - Three-scope z-layer rendering pipeline (D-049) — world z:0-900 inside CanvasGroup, insert overlay CanvasLayer 10, UI CanvasLayer 20, modal CanvasLayer 30. Y-sort contract enforced, reserved VFX/airborne/lower-floor ranges documented - Cursor state machine (#429, D-056) — 4 states (Default/EntityHover/ObjectHover/WeaponAim), 150ms transitions, insert-styled geometric shapes diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index d76779755..e87009e6b 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 94055d595..edcc2b3b5 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 698b97852..b8edf9b55 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index a3be5bea6..e45b6ef57 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 2b830526b..c1488bd9a 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/content/_schema/monologue-pool.schema.json b/content/_schema/monologue-pool.schema.json index e266b5d32..81a6a0480 100644 --- a/content/_schema/monologue-pool.schema.json +++ b/content/_schema/monologue-pool.schema.json @@ -38,7 +38,7 @@ "type": "string", "minLength": 1, "maxLength": 160, - "description": "Line text — 160 char max per D-059" + "description": "Line text — 160 char max to fit monologue display without scrolling" }, "trigger": { "type": "string", @@ -63,6 +63,8 @@ }, "relationship": { "type": "object", + "required": ["target", "state"], + "additionalProperties": false, "properties": { "target": { "type": "string" }, "state": { diff --git a/docs/architecture/interaction-memory-schema.md b/docs/architecture/interaction-memory-schema.md new file mode 100644 index 000000000..d9b0dd8b0 --- /dev/null +++ b/docs/architecture/interaction-memory-schema.md @@ -0,0 +1,660 @@ +# InteractionMemory Knowledge Graph Schema Design + +**Ticket:** #442 | **Sprint:** 7 | **Priority:** MEDIUM +**Decisions:** D-041 (KG data model), D-064 (walk-away consequences), D-063 (confrontation), D-028 (dialogue architecture) +**Audience:** Engine developers (Dudley), narrative designers (Paula, Mellanie), QA (Hoshe) + +--- + +## 1. Purpose + +The InteractionMemory schema extends the existing `FactKnowledge` system (D-041) to track conversation history between the player character and NPCs. This enables: + +- **Walk-away consequences** (D-064): The KG records that an interaction was started but not finished, affecting future dialogue and NPC behavior. +- **Confrontation tracking** (D-063): The KG records whether an interaction escalated to confrontation. +- **Dialogue history queries**: "Have I talked to X before?", "Did I walk away from X?", "When did I last complete a conversation with X?" +- **Monologue triggers**: Post-interaction reflection that references prior conversation outcomes. + +This is a schema design document. It defines data structures and query patterns — implementation follows in a future ticket. + +--- + +## 2. Design Constraints + +From D-041: +- All maps are `BTreeMap` (deterministic iteration, non-negotiable per D-010 principle 4) +- Facts live in `KnowledgeGraph.facts: BTreeMap` +- `FactId` format is `"category.topic"` (string) +- `FactKnowledge` has `confidence`, `source`, `state`, `acquired_tick` + +From D-064: +- Walk-away triggers three phases: immediate break, NPC reaction, KG recording +- KG must log that the interaction was initiated but not completed +- This is queryable and affects future dialogue, monologue, and NPC behavior +- NPC tolerance thresholds vary per seed (no universal rules to metagame) + +From the existing codebase (`server/src/knowledge/types.rs`): +- `KnowledgeConfidence`: Suspects < KnowsOf < KnowsDetails < Direct +- `KnowledgeState`: Active, Contradicted, Stale +- `KnowledgeSource`: DirectObservation, Heard, ToldBy, Inferred, Background +- `RelationshipState`: Unknown, Known, Friendly, PersonOfInterest, Hostile + +--- + +## 3. Schema Extension + +### 3.1 Interaction FactId format + +Interaction facts use a dedicated `interaction` category in the FactId namespace: + +``` +interaction.{entity_slug}_{sequence} +``` + +| Segment | Format | Description | +|---------|--------|-------------| +| `interaction` | literal | Category prefix — distinguishes from other fact categories | +| `entity_slug` | kebab-case | NPC slug matching their content file (e.g., `kael-davan`, `lera-sessik`) | +| `_sequence` | `_NNN` | Zero-padded sequence number for multiple interactions with the same NPC | + +**Examples:** +- `interaction.kael-davan_001` — first tracked interaction with Kael +- `interaction.kael-davan_002` — second tracked interaction with Kael +- `interaction.sera-venn_001` — first tracked interaction with Sera + +**Why sequence numbers, not timestamps?** FactIds are string keys in a BTreeMap. Sequence numbers are shorter, sortable, and don't tie the ID format to the tick system. The timestamp is stored in the metadata (see Section 3.3). + +#### Entity slug resolution at runtime + +The `{entity_slug}` segment in the FactId (e.g., `kael-davan`) must be resolvable from an `Entity` (bevy ECS handle) during gameplay. Currently, no component maps Entity → content slug at runtime. + +**Proposed approach:** Add a `ContentSlug` component to the NPC entity bundle, populated from the NPC profile filename during spawn: + +```rust +/// Stable content identifier for an entity, derived from its YAML profile filename. +/// e.g., NPC profile `kael-davan.yaml` → ContentSlug("kael-davan"). +/// Used to construct FactIds like `interaction.kael-davan_001`. +#[derive(Component, Debug, Clone)] +pub struct ContentSlug(pub String); +``` + +The interaction event handler resolves Entity → ContentSlug → FactId: + +```rust +// In InteractionStarted event processing: +let slug = world.get::(target) + .expect("NPC must have ContentSlug component"); +let seq = next_interaction_sequence(&kg.facts, &slug.0); +let fact_id = FactId(format!("interaction.{}_{:03}", slug.0, seq)); +``` + +**Implementation note:** The `ContentSlug` component is a prerequisite for this schema. It should be added as part of the NPC spawn pipeline (#423 or a follow-up ticket) before interaction tracking is implemented. Until `ContentSlug` exists, the interaction system cannot construct FactIds. + +### 3.2 InteractionState enum + +New enum representing the outcome of an interaction: + +```rust +/// Outcome state of a tracked interaction. +/// Drives walk-away mechanics (D-064) and confrontation tracking (D-063). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum InteractionState { + /// Conversation started, still in progress. + /// Set when dialogue box opens. Cleared on transition to another state. + Active, + + /// Conversation completed normally (both parties finished talking). + /// The default "good" outcome. + Completed, + + /// Player walked away mid-conversation (D-064 Phase 3). + /// The KG records incompleteness. NPC tolerance threshold determines + /// severity of consequence. + WalkedAway, + + /// Conversation escalated to confrontation (D-063). + /// Player made an accusation or NPC became hostile during dialogue. + /// Separate from WalkedAway — a confrontation can be completed. + Confrontation, + + /// Confrontation was initiated but player walked away before resolution. + /// The most consequential outcome — walking away from a confrontation + /// you started is worse than walking away from smalltalk. + ConflictAbandoned, +} +``` + +**State transitions:** + +``` + ┌─────────────┐ + │ Active │ ← dialogue opens + └──────┬──────┘ + │ + ┌────────────┼────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────┐ ┌──────────┐ ┌──────────────┐ + │ Completed │ │ WalkedAway│ │Confrontation │ + └───────────┘ └──────────┘ └──────┬───────┘ + │ + ┌─────────┼──────────┐ + │ │ + ▼ ▼ + ┌───────────┐ ┌──────────────────┐ + │ Completed │ │ConflictAbandoned │ + └───────────┘ └──────────────────┘ +``` + +All transitions are one-way. Once an interaction reaches a terminal state (Completed, WalkedAway, ConflictAbandoned), it cannot change. + +### 3.3 InteractionMetadata + +New metadata struct attached to interaction facts. This extends the base `FactKnowledge` with interaction-specific data. + +```rust +/// Metadata for an interaction fact. +/// Stored alongside the FactKnowledge in the knowledge graph. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InteractionMetadata { + /// The NPC this interaction was with. + pub target_entity: StableId, + + /// Interaction outcome. + pub interaction_state: InteractionState, + + /// Tick when dialogue was initiated. + pub started_tick: u64, + + /// Tick when interaction ended (completed, walked away, etc.). + /// None if interaction is still Active. + pub ended_tick: Option, + + /// Primary conversation topic(s) discussed. + /// References topic enum values from content/global/enums/topics.yaml. + /// Empty if no specific topic was identified. + pub topics: Vec, + + /// Whether a confrontation occurred during this interaction. + /// True if InteractionState is Confrontation or ConflictAbandoned. + pub confrontation: bool, + + /// Whether the player initiated the conversation (approached NPC) + /// or the NPC initiated (unprompted disclosure, D-028 Layer 4). + pub player_initiated: bool, + + /// Location slug where the interaction took place. + pub location: String, +} +``` + +### 3.4 Storage strategy + +Interaction metadata is stored in a new field on `FactKnowledge`. There are two viable approaches: + +**Option A: Typed metadata field (recommended)** + +Add an optional metadata enum to `FactKnowledge`: + +```rust +/// Optional typed metadata attached to a fact. +/// Extensible for future fact types beyond interactions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum FactMetadata { + Interaction(InteractionMetadata), + // Future: Evidence(EvidenceMetadata), Gossip(GossipMetadata), etc. +} + +/// Updated FactKnowledge with optional metadata. +pub struct FactKnowledge { + pub confidence: KnowledgeConfidence, + pub source: KnowledgeSource, + pub state: KnowledgeState, + pub acquired_tick: u64, + pub metadata: Option, // NEW +} +``` + +**Why Option A over alternatives:** +- No separate data structure — interactions are facts, stored in the existing `BTreeMap` +- The `FactMetadata` enum is extensible for future fact types (evidence, gossip) without changing the graph structure +- `Option` adds zero overhead to facts that don't need metadata (the vast majority) +- Queries use existing `KnowledgeGraph.facts` infrastructure — no new maps to maintain + +**Option B (rejected): Separate InteractionLog map** + +A separate `BTreeMap` on KnowledgeGraph. Rejected because it fragments the knowledge model — interactions ARE facts about the world, and should be queryable through the same API as other facts. Separate storage also complicates serialization and tier transitions (D-026). + +--- + +## 4. Confidence Mapping + +Interaction facts use the existing 4-level confidence hierarchy, but the levels have interaction-specific semantics: + +| Confidence | Interaction meaning | When set | +|------------|-------------------|----------| +| `Suspects` | Not used for interactions | — | +| `KnowsOf` | "I talked to X" | Set when dialogue starts | +| `KnowsDetails` | "I talked to X about Y, and it ended with Z" | Set when dialogue ends with meaningful outcome | +| `Direct` | "I am talking to X right now" | Set during active dialogue, cleared on end | + +Interaction facts start at `Direct` (active conversation) and settle to `KnowsDetails` when the conversation ends. They do not decay below `KnowsDetails` — you don't forget that you had a conversation. + +#### Fact decay exemption status + +The current `decay_knowledge` system (`server/src/knowledge/events.rs:106`) only iterates `KnowledgeGraph.entities` (EntityKnowledge), **not** `KnowledgeGraph.facts` (FactKnowledge). This means interaction facts stored in `self.facts` are **inherently immune to decay today** — no code path touches them. + +However, this is an implementation coincidence, not an explicit guarantee. When a future fact decay system is built (to handle stale evidence, fading gossip, etc.), it **must** respect `DECAY_FLOOR` for interaction facts. Interaction facts should never decay below `KnowsDetails` — you remember that you had a conversation, even if other details fade. + +**Requirement for future fact decay implementation:** Check `FactMetadata::Interaction` and clamp confidence at `DECAY_FLOOR`: + +```rust +impl InteractionMetadata { + /// Interaction facts do not decay below KnowsDetails. + /// You remember that you had a conversation, even if details fade. + pub const DECAY_FLOOR: KnowledgeConfidence = KnowledgeConfidence::KnowsDetails; +} + +// In future fact decay system: +for (id, fact) in kg.facts.iter_mut() { + if let Some(FactMetadata::Interaction(_)) = &fact.metadata { + // Interaction facts: clamp at DECAY_FLOOR + if fact.confidence.decayed() < InteractionMetadata::DECAY_FLOOR { + continue; // Skip — would drop below floor + } + } + // ... normal decay logic ... +} +``` + +--- + +## 5. Query Patterns + +### 5.1 Core queries + +These are the queries the dialogue system, monologue system, and NPC behavior need: + +```rust +impl KnowledgeGraph { + /// Has the player ever interacted with this entity? + fn has_interacted_with(&self, target: StableId) -> bool; + + /// Get the most recent interaction with an entity. + fn last_interaction_with(&self, target: StableId) -> Option<&InteractionMetadata>; + + /// Get all interactions with an entity, ordered by started_tick. + fn interactions_with(&self, target: StableId) -> Vec<&InteractionMetadata>; + + /// Has the player walked away from this entity? + fn has_walked_away_from(&self, target: StableId) -> bool; + + /// Count of interactions with an entity. + fn interaction_count_with(&self, target: StableId) -> usize; + + /// Has the player ever had a confrontation with this entity? + fn has_confronted(&self, target: StableId) -> bool; + + /// Is there currently an active (in-progress) interaction? + fn active_interaction(&self) -> Option<&InteractionMetadata>; +} +``` + +### 5.2 Implementation sketch + +All queries scan `self.facts` for entries with `interaction.{slug}` prefix and `FactMetadata::Interaction` metadata. Since BTreeMap iterates in lexicographic order, all interactions with the same entity are contiguous (they share the `interaction.{slug}` prefix). + +```rust +/// Iterate all interaction facts for a given entity. +/// Uses BTreeMap range query for O(log N + K) where K = number of interactions. +fn interaction_facts_for<'a>( + &'a self, + entity_slug: &str, +) -> impl Iterator { + let prefix = format!("interaction.{entity_slug}_"); + self.facts + .range(FactId(prefix.clone())..) + .take_while(move |(id, _)| id.0.starts_with(&prefix)) + .filter_map(|(id, fact)| { + match &fact.metadata { + Some(FactMetadata::Interaction(meta)) => Some((id, meta)), + _ => None, + } + }) +} +``` + +**Performance:** At expected scale (10-30 interactions per playthrough), this is negligible. BTreeMap range queries are O(log N) to find the start, then O(K) to iterate matches. With N=50 total facts and K=5 interactions per NPC, this is ~6-8 comparisons + 5 iterations. + +### 5.3 Dialogue system queries + +The dialogue selection pipeline (D-028) needs interaction history for Layer 2 (relationship history): + +| Query | Use case | Implementation | +|-------|----------|----------------| +| "First time talking to X?" | Greeting selection — fresh vs. returning | `interaction_count_with(target) == 0` | +| "Did I walk away last time?" | NPC reacts differently, may reference it | `last_interaction_with(target).state == WalkedAway` | +| "How many times have we talked?" | Familiarity progression | `interaction_count_with(target)` | +| "Was there a confrontation?" | Permanently shifts NPC posture | `has_confronted(target)` | +| "Active conversation right now?" | Block new dialogue initiation | `active_interaction().is_some()` | + +### 5.4 Monologue system queries + +The monologue system (D-032, D-035) uses interaction facts as prerequisites: + +```yaml +# Monologue line that fires after walking away from Kael +- id: the-last-shift_m_s_050 + text: "Shouldn't have left like that. Kael noticed." + trigger: post_conversation + prerequisites: + facts: + - fact_id: interaction.kael-davan_001 + min_confidence: knows_details + tags: [kael, walk-away, regret] +``` + +For more precise gating on interaction outcome, the monologue system needs to check the `InteractionMetadata.interaction_state` field. This extends the prerequisite system: + +```yaml +# More specific: only fires if the player walked away +prerequisites: + facts: + - fact_id: interaction.kael-davan_001 + min_confidence: knows_details + interaction: # NEW prerequisite type + target: npc:kael-davan + state: walked_away # matches InteractionState::WalkedAway +``` + +This extends the monologue prerequisite schema (Section 6). + +--- + +## 6. Monologue Prerequisite Extension + +The existing `monologue-pool.schema.json` supports `facts`, `entity_attributes`, and `relationship` prerequisites. Interaction memory adds a new prerequisite type: + +```json +"interaction_prerequisite": { + "type": "object", + "required": ["target"], + "additionalProperties": false, + "properties": { + "target": { + "type": "string", + "description": "Entity reference (e.g., npc:kael-davan)" + }, + "state": { + "type": "string", + "enum": ["active", "completed", "walked_away", "confrontation", "conflict_abandoned"], + "description": "Required interaction outcome. If omitted, any interaction satisfies." + }, + "min_count": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Minimum number of interactions matching the criteria" + } + } +} +``` + +Added to the `prerequisites` object in the monologue schema: + +```json +"prerequisites": { + "type": "object", + "properties": { + "facts": { ... }, + "entity_attributes": { ... }, + "relationship": { ... }, + "interaction": { "$ref": "#/$defs/interaction_prerequisite" } + } +} +``` + +**Examples:** + +```yaml +# Fires after any interaction with Kael (first meeting) +prerequisites: + interaction: + target: npc:kael-davan + +# Fires only if player walked away from Kael +prerequisites: + interaction: + target: npc:kael-davan + state: walked_away + +# Fires after 3+ completed conversations with Lera +prerequisites: + interaction: + target: npc:lera-sessik + state: completed + min_count: 3 + +# Combined: walked away from Kael AND knows about the ring +prerequisites: + facts: + - fact_id: contraband.ring_exists + min_confidence: knows_of + interaction: + target: npc:kael-davan + state: walked_away +``` + +--- + +## 7. Event Integration + +### 7.1 New KnowledgeEventType variants + +Extend `KnowledgeEventType` in `server/src/knowledge/events.rs`: + +```rust +pub enum KnowledgeEventType { + // ... existing variants ... + + /// Dialogue initiated with an NPC. + InteractionStarted { + target: Entity, + player_initiated: bool, + location: String, + }, + + /// Dialogue completed normally. + InteractionCompleted { + target: Entity, + topics: Vec, + }, + + /// Player walked away mid-conversation (D-064). + InteractionWalkedAway { + target: Entity, + }, + + /// Conversation escalated to confrontation (D-063). + InteractionConfrontation { + target: Entity, + }, + + /// Player walked away from a confrontation they started. + InteractionConflictAbandoned { + target: Entity, + }, +} +``` + +### 7.2 Event flow + +``` +Player approaches NPC → dialogue system opens + │ + ├─ Emit InteractionStarted + │ → KG creates interaction.{slug}_{seq} fact with InteractionState::Active + │ → FactKnowledge.confidence = Direct (in conversation now) + │ + ├─ ... conversation proceeds ... + │ + └─ Conversation ends (one of): + │ + ├─ Normal end → Emit InteractionCompleted + │ → KG updates fact: Active → Completed, confidence → KnowsDetails + │ → Record topics discussed + │ + ├─ Player walks away (WASD) → Emit InteractionWalkedAway + │ → KG updates fact: Active → WalkedAway, confidence → KnowsDetails + │ → D-064 Phase 3: KG records incompleteness + │ + ├─ Confrontation escalation → Emit InteractionConfrontation + │ → KG updates fact: Active → Confrontation + │ → Conversation continues (may still complete or be abandoned) + │ + └─ Walk away from confrontation → Emit InteractionConflictAbandoned + → KG updates fact: Confrontation → ConflictAbandoned + → Most severe consequence path +``` + +### 7.3 Sequence number allocation + +The `process_knowledge_events` system allocates sequence numbers when processing `InteractionStarted` events: + +```rust +/// Determine next sequence number for an interaction FactId. +/// Scans existing facts for the highest sequence number with this prefix. +fn next_interaction_sequence( + facts: &BTreeMap, + entity_slug: &str, +) -> u32 { + let prefix = format!("interaction.{entity_slug}_"); + facts + .range(FactId(prefix.clone())..) + .take_while(|(id, _)| id.0.starts_with(&prefix)) + .count() as u32 + + 1 +} +``` + +This is O(log N + K) — acceptable for the expected interaction count. + +--- + +## 8. NPC Tolerance System + +Per D-064, walk-away consequences vary by NPC tolerance threshold per seed. This is orthogonal to the KG schema — it lives on the NPC entity, not in the player's knowledge graph. + +### 8.1 Relationship to existing tolerance types + +The codebase already has two tolerance-related types: + +| Type | Location | Purpose | +|------|----------|---------| +| `ToleranceThreshold` | `server/src/npc/mod.rs:122` | **ECS Component.** General NPC stress tracker with `current_stress: i16` and `threshold: i16`. Drives overall NPC frustration behavior (D-024 Axis 4). | +| `NpcTolerance` | `server/src/content/types.rs:302` | **Content definition.** Deserialization struct from NPC YAML profiles with `threshold: Option`. Populates `ToleranceThreshold` at spawn. | + +The proposed `InteractionTolerance` is **not a replacement** for either. It tracks walk-away–specific consequence parameters that `ToleranceThreshold` does not cover (penalty magnitude, forgiveness policy). The relationship: + +- `NpcTolerance` (content YAML) → populates `ToleranceThreshold` (ECS runtime) +- `ToleranceThreshold` tracks **general stress accumulation** — any irritant contributes +- `InteractionTolerance` tracks **walk-away–specific consequence parameters** — how the NPC reacts to conversational abandonment specifically + +**Recommended implementation approach:** Rather than adding a third standalone component, extend the existing `ToleranceThreshold` or co-locate walk-away fields on a new `InteractionTolerance` component that **reads** `ToleranceThreshold.current_stress` as a factor. Walk-away consequences should feed back into `ToleranceThreshold.current_stress` (walking away from a stressed NPC is worse). The walk-away system would: + +1. Check `InteractionTolerance.walk_away_threshold` — has the NPC been walked away from too many times? +2. If threshold exceeded, apply `walk_away_penalty` to relationship +3. Increase `ToleranceThreshold.current_stress` by a walk-away–specific delta +4. Check `permanent_confrontation_memory` for confrontation abandonment + +```rust +/// Walk-away–specific consequence parameters. Extends general tolerance +/// (ToleranceThreshold) with interaction-specific mechanics per D-064. +/// Populated from NPC profile YAML, seeded per playthrough. +/// Reads ToleranceThreshold.current_stress as an aggravating factor. +#[derive(Component, Debug, Clone)] +pub struct InteractionTolerance { + /// How many walk-aways before relationship degradation. + /// Range: 1-5, seeded per NPC per playthrough. + pub walk_away_threshold: u8, + + /// How much relationship damage per walk-away beyond threshold. + /// Range: 0.1-0.5, seeded per NPC. + pub walk_away_penalty: f32, + + /// Stress delta applied to ToleranceThreshold.current_stress per walk-away. + /// A walk-away from a stressed NPC (high current_stress) hits harder. + pub walk_away_stress_delta: i16, + + /// Whether this NPC remembers confrontation abandonment permanently. + /// Some NPCs forgive, some don't (per seed). + pub permanent_confrontation_memory: bool, +} +``` + +The NPC behavior system queries both the player's KG (for interaction history) and the NPC's tolerance components (for consequence magnitude). This separation means: +- The player's KG is what THEY remember +- `ToleranceThreshold` is the NPC's general frustration level +- `InteractionTolerance` is how the NPC reacts to walk-aways specifically (feeding back into general stress) +- The NPC may also have their own KG entry for the interaction (future: NPC-to-NPC gossip about "the person who walked out on Kael") + +--- + +## 9. Scope and Limitations + +### 9.1 v0.1 scope (this ticket) + +- Player character interaction tracking only +- Interactions with Active-tier NPCs (30-80 per D-026) +- Single `interaction` prerequisite per monologue line +- No NPC-to-NPC interaction tracking +- No gossip about interactions ("I heard you walked out on Kael") + +### 9.2 Future extensions + +| Feature | Ticket/Sprint | Schema impact | +|---------|--------------|---------------| +| NPC-to-NPC interaction tracking | Future | Same schema, applied to NPC KnowledgeGraphs | +| Gossip about interactions | Sprint 3+ (ToldBy source) | New `KnowledgeSource::ToldBy` entries referencing interaction facts | +| Interaction-driven relationship decay | Future | Query interaction history to modulate decay rates | +| Multi-NPC conversations | Future | `InteractionMetadata.target_entity` becomes `Vec` | + +### 9.3 Memory budget + +Per-interaction memory cost: ~200 bytes (InteractionMetadata + FactKnowledge overhead). + +Expected interactions per playthrough: 50-150 (2-5 per Active NPC). + +Total interaction memory: ~10-30 KB per player character. Negligible against the D-041 budget of ~6 MB for the full knowledge graph. + +--- + +## 10. Schema Summary + +| Component | Location | Type | Description | +|-----------|----------|------|-------------| +| `InteractionState` | `server/src/knowledge/types.rs` | enum | 5 outcome states | +| `InteractionMetadata` | `server/src/knowledge/types.rs` | struct | Per-interaction data | +| `FactMetadata` | `server/src/knowledge/types.rs` | enum | Extensible typed metadata on FactKnowledge | +| `FactKnowledge.metadata` | `server/src/knowledge/types.rs` | `Option` | New field on existing struct | +| `ContentSlug` | `server/src/npc/` (proposed) | Component | Entity → content slug mapping for FactId construction | +| `InteractionTolerance` | NPC template system | Component | Walk-away consequence parameters (extends ToleranceThreshold) | +| `interaction_prerequisite` | `content/_schema/monologue-pool.schema.json` | schema | New prerequisite type | +| `KnowledgeEventType` variants | `server/src/knowledge/events.rs` | enum variants | 5 new event types | +| Interaction FactId format | Convention | `interaction.{slug}_{NNN}` | Fact category for interactions | + +### Breaking changes + +One field addition to an existing struct: + +```diff + pub struct FactKnowledge { + pub confidence: KnowledgeConfidence, + pub source: KnowledgeSource, + pub state: KnowledgeState, + pub acquired_tick: u64, ++ pub metadata: Option, + } +``` + +This is an additive change. Existing facts have `metadata: None`. All existing queries continue to work unchanged. The only code change required beyond adding the field is to add `metadata: None` to existing `FactKnowledge` construction sites. diff --git a/docs/architecture/line-pool-format.md b/docs/architecture/line-pool-format.md new file mode 100644 index 000000000..d04c110a8 --- /dev/null +++ b/docs/architecture/line-pool-format.md @@ -0,0 +1,582 @@ +# Line Pool Format Specification + +**Ticket:** #308 | **Sprint:** 7 | **Priority:** HIGH (blocks #326, #305) +**Decisions:** D-028 (dialogue architecture), D-032 (separate monologue pools), D-035 (tag taxonomy), D-041 (KG data model) +**Audience:** Content authors (Mellanie, Paula), engine developers (Dudley), QA (Hoshe) + +--- + +## 1. Overview + +Line pools are the atomic content units for The Settled Reach's dialogue and monologue systems. Each pool is a YAML file containing tagged lines that the engine selects from at runtime using a four-layer filtering pipeline (D-028). + +There are two pool types: + +| Pool type | Scope | Selection model | Partition | +|-----------|-------|-----------------|-----------| +| **Dialogue** | Per-location, per-role | 4-layer filter (access > situation > trust > topic+mood) | None (role-based, character-agnostic) | +| **Monologue** | Per-location, per-character | Trigger-based with prerequisite gates | Hard partition by character (D-032) | + +Both types share a common tag vocabulary defined in `content/global/enums/`. + +--- + +## 2. Directory Structure + +Content lives under `content/campaigns/{campaign}/systems/{system}/stations/{station}/districts/{district}/`. Below the district level: + +``` +{district}/ + dialogue/ + {location}/ # one subdirectory per location + {role}.yaml # one file per template role at that location + {named-npc}.yaml # named NPCs get dedicated files + pc-smuggler.yaml # PC-specific dialogue (if applicable) + pc-detective.yaml + monologue/ + smuggler/ # hard partition per D-032 + general.yaml # location-independent lines + {location}.yaml # location-specific lines + {topic-slug}.yaml # topic-specific cross-location pools + detective/ + general.yaml + {location}.yaml + {topic-slug}.yaml +``` + +### Naming conventions + +| Element | Pattern | Examples | +|---------|---------|----------| +| Location directory | `kebab-case` matching location YAML slug | `the-terminal`, `the-last-shift`, `maintenance-corridors` | +| Dialogue file | `{role-slug}.yaml` or `{npc-slug}.yaml` | `dock-worker.yaml`, `kael-davan.yaml`, `pc-detective.yaml` | +| Monologue file | `{location-slug}.yaml` or `general.yaml` or `{topic}.yaml` | `the-terminal.yaml`, `general.yaml`, `pc-detective-tells.yaml` | +| Character directory | `smuggler/` or `detective/` | Matches `character` enum exactly | + +### What is NOT encoded in per-line tags + +Per D-035, the following are **implicit from directory structure** and never appear as line-level tags: + +- **Location** — derived from the parent directory name +- **Content type** — derived from whether the file is under `dialogue/` or `monologue/` +- **Character** (monologue only) — derived from the parent `smuggler/` or `detective/` directory, confirmed by the `character` field in the YAML header + +--- + +## 3. Dialogue Pool Format + +### 3.1 File structure + +```yaml +# {NPC name or role description} +# Context notes for authors (not consumed by engine) + +location: {location-slug} # required, must match parent directory name +role: {role-slug} # required, template role (not NPC name) +lines: + - id: {line-id} + text: "Dialogue line text." + role: {role-slug} + access: [{access-tier}, ...] + trust: {trust-tier} + situation: [{situation}, ...] + topic: [{topic}, ...] # optional, defaults to [] + mood: [{mood}, ...] # optional, defaults to [] + tags: [{freeform}, ...] # optional, defaults to [] + knowledge_grant: # optional + fact_id: {category.fact_id} + confidence: {confidence-level} +``` + +### 3.2 Header fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `location` | string | YES | Location slug, must match parent directory. Pattern: `^[a-z][a-z0-9-]*$` | +| `role` | string | YES | Template role slug. Pattern: `^[a-z][a-z0-9-]*$`. This is the abstract role (e.g. `dock-worker`), not a specific NPC name. NPC assignment to roles happens at runtime via template instantiation. | + +### 3.3 Line fields + +#### Structural tags (required on every line) + +| Field | Type | Required | Validation | Description | +|-------|------|----------|------------|-------------| +| `id` | string | YES | `^[a-z][a-z0-9-]*_d_[0-9]{3}$` | Stable line identifier. See [Section 5: ID Format](#5-id-format). | +| `text` | string | YES | Non-empty | The authored dialogue line. No length limit (unlike monologue). | +| `role` | string | YES | `^[a-z][a-z0-9-]*$` | Template role this line belongs to. Must match header `role` or be a valid role at this location. | +| `access` | list\ | YES | Min 1 item, unique | Access tiers this line is eligible for. **Hard filter** — line is invisible if player's access tier is not in this list. Values: `public`, `insider`, `authority`, `peer`, `hostile`. | +| `trust` | enum | YES | Single value | Minimum trust tier required. **Hard filter** — line is invisible below this tier. Values: `surface`, `real`, `secret`. | +| `situation` | list\ | YES | Min 1 item, unique | Situation contexts when this line can fire. **Context filter** — engine activates situations based on simulation state. Values: `arrival`, `shift_start`, `shift_end`, `shift_transition`, `bar_evening`, `night_shift`, `investigation`, `confrontation`, `social`, `alone`, `emergency`, `routine`, `observation`. | + +#### Selection tags (optional, influence weighted selection) + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `topic` | list\ | no | `[]` | Topic tags for Layer 4 weighted selection. Lines without topic tags are eligible for any topic context. Values: `colleague`, `routine`, `cargo`, `money`, `trust`, `danger`, `institution`, `personal`, `investigation`. | +| `mood` | list\ | no | `[]` | Mood tags for Layer 4 weighted selection. Lines without mood tags are eligible for any mood context. Values: `fond`, `comfortable`, `worried`, `suspicious`, `analytical`, `conflicted`, `concerned`, `relieved`. | +| `tags` | list\ | no | `[]` | Freeform escape hatch. Not consumed by the filtering engine — used for author organization, content queries, and the line previewer. No validation on values. | + +#### Knowledge grant (optional) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `knowledge_grant` | object | no | Knowledge the player gains from hearing this line. | +| `knowledge_grant.fact_id` | string | YES (if grant present) | Fact identifier in `{category}.{fact_id}` format. Must reference a fact defined in `content/global/knowledge/{category}.yaml`. | +| `knowledge_grant.confidence` | enum | YES (if grant present) | Confidence level granted. Values: `suspects`, `knows_of`, `knows_details`, `direct`. | + +### 3.4 Four-layer filtering pipeline (D-028) + +The engine processes dialogue lines through four layers in sequence: + +``` +All lines in pool + │ + ├─ Layer 1: ACCESS FILTER (hard) + │ Keep lines where player's access tier ∈ line.access + │ + ├─ Layer 2: SITUATION FILTER (context) + │ Keep lines where any active situation ∈ line.situation + │ + ├─ Layer 3: TRUST FILTER (hard) + │ Keep lines where player's trust ≥ line.trust + │ (surface < real < secret) + │ + └─ Layer 4: TOPIC + MOOD SELECTION (weighted) + Score remaining lines by topic and mood match. + Lines with no topic/mood tags get a neutral weight (always eligible, + never boosted). Select from top-scored candidates with randomization. +``` + +**Authoring implication:** Every line must pass Layers 1-3 to be eligible. Layers 1 and 3 are hard gates — get them wrong and the line is invisible. Layer 2 controls when the line fires. Layer 4 is a soft preference. + +### 3.5 Complete dialogue example + +```yaml +# Kael Davan — dock worker at The Last Shift +# Voice: direct, practical, short sentences, warm to trusted people +location: the-last-shift +role: dock-worker +lines: + - id: the-last-shift_d_001 + text: "Saved you a seat. Lera's got the spiced rice tonight." + role: dock-worker + access: [insider, peer] + trust: surface + situation: [bar_evening, social, arrival] + mood: [fond] + topic: [personal, colleague] + tags: [kael, greeting, phase-1] + + - id: the-last-shift_d_010 + text: "Nils wants to talk. Tomorrow, bay side. Said it's about volume." + role: dock-worker + access: [insider] + trust: real + situation: [bar_evening, social] + mood: [concerned] + topic: [danger] + tags: [kael, ring-ops, nils] + + - id: the-last-shift_d_012 + text: "Lera knows more than she lets on. She won't say anything — but don't test it." + role: dock-worker + access: [insider] + trust: real + situation: [bar_evening, social, alone] + mood: [concerned] + topic: [colleague, danger] + tags: [kael, ring-ops, lera, caution] + knowledge_grant: + fact_id: knowledge.bar_ring_awareness + confidence: suspects +``` + +--- + +## 4. Monologue Pool Format + +### 4.1 File structure + +```yaml +character: {character} # required, hard partition (D-032) +location: {location-slug} # required, or "general" for location-independent +lines: + - id: {line-id} + text: "Internal monologue text." + trigger: {trigger-type} + prerequisites: # optional + facts: + - fact_id: {category.fact_id} + min_confidence: {confidence-level} + entity_attributes: + - entity: {entity-ref} + key: {attribute-key} + value: {attribute-value} + relationship: + target: {entity-ref} + state: {relationship-state} + priority: {0-10} # optional, default 5 + cooldown: {ticks} # optional, minimum re-fire interval + tags: [{freeform}, ...] # optional +``` + +### 4.2 Header fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `character` | enum | YES | `smuggler` or `detective`. Hard partition per D-032 — pools are completely separate. The engine never crosses this boundary. | +| `location` | string | YES | Location slug, or `general` for location-independent lines. Pattern: `^[a-z][a-z0-9-]*$\|^general$` | + +### 4.3 Line fields + +#### Core fields (required) + +| Field | Type | Required | Validation | Description | +|-------|------|----------|------------|-------------| +| `id` | string | YES | `^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$` | Stable line identifier. See [Section 5: ID Format](#5-id-format). | +| `text` | string | YES | 1-160 characters | The monologue line. 160-char max — authoring constraint to fit the monologue display without scrolling (established in `monologue-pool.schema.json`, not yet formalized as a decision). | +| `trigger` | enum | YES | Single value | What causes this line to fire. Values: `enter_location`, `observe_npc`, `hear_sound`, `observe_anomaly`, `post_conversation`, `discover_evidence`, `witness_interaction`, `time_idle`, `return_visit`. | + +#### Prerequisites (optional, AND-combined) + +All prerequisite conditions are AND-combined: every specified condition must be true for the line to be eligible. + +| Field | Type | Description | +|-------|------|-------------| +| `prerequisites` | object | Knowledge state gates. If omitted, the line has no prerequisites (always eligible given trigger). | +| `prerequisites.facts` | list\ | Fact-based gates. Each entry requires `fact_id` (string, `{category}.{fact_id}` format) and `min_confidence` (enum: `suspects`, `knows_of`, `knows_details`, `direct`). The player's confidence for the referenced fact must be ≥ the specified minimum. | +| `prerequisites.entity_attributes` | list\ | Entity attribute gates. Each entry requires `entity` (string, entity reference like `npc:kael-davan`), `key` (string, attribute name), `value` (string, expected value). | +| `prerequisites.relationship` | object | Relationship state gate. Requires `target` (string, entity reference) and `state` (enum: `unknown`, `known`, `friendly`, `person_of_interest`, `hostile`). The player's relationship with the target must be at or beyond the specified state. | + +**Prerequisite-to-KG mapping:** + +| Prerequisite type | KG query | D-041 structure | +|-------------------|----------|-----------------| +| `facts[].fact_id` + `min_confidence` | Look up `FactId` in `BTreeMap`, check `confidence ≥ min` | `FactKnowledge.confidence` (4-level: Suspects < KnowsOf < KnowsDetails < Direct) | +| `entity_attributes[].entity` | Look up `StableId` via `EntityRegistry`, check attribute | `EntityKnowledge` attribute fields | +| `relationship.target` + `state` | Look up entity in `BTreeMap`, check relationship state | `EntityKnowledge.relationship_state` | + +#### Selection fields (optional) + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `priority` | integer | `5` | Selection priority, 0-10. Higher = more likely to fire when multiple lines match the same trigger + prerequisites. Priority 9-10 should be reserved for critical narrative moments. | +| `cooldown` | integer | `0` | Minimum ticks before this line can fire again for the same player. At 10 tps (D-031), a cooldown of 600 = 1 game-minute. | +| `tags` | list\ | `[]` | Freeform tags. Not consumed by the selection engine — used for author organization and the line previewer. | + +### 4.4 Monologue selection flow + +``` +Trigger fires (e.g. enter_location) + │ + ├─ Character partition: select pool for current PC (D-032) + │ + ├─ Location filter: prefer location-specific pool, fall back to general + │ + ├─ Trigger filter: keep lines matching this trigger type + │ + ├─ Prerequisite gate: evaluate all prerequisites against KG state + │ (AND-combined — all must pass) + │ + ├─ Cooldown check: exclude recently fired lines + │ + └─ Priority-weighted selection: pick from eligible lines + Higher priority = higher weight. Randomized among equal-priority. +``` + +### 4.5 Complete monologue example + +```yaml +character: detective +location: the-last-shift +lines: + # Basic atmospheric line — no prerequisites, any visit + - id: the-last-shift_m_d_001 + text: "The Last Shift. Only place in this district that doesn't smell like freight lubricant." + trigger: enter_location + tags: [arrival, atmospheric] + + # Knowledge-gated observation — requires prior suspicion + - id: the-last-shift_m_d_021 + text: "Sera left when Torek arrived. Second time. Different excuse. Same result." + trigger: observe_anomaly + prerequisites: + facts: + - fact_id: behavioral.sera_avoidance_pattern + min_confidence: suspects + priority: 7 + tags: [npc, sera, torek, tell, friend-arc] + + # Relationship-gated line — requires person_of_interest status + - id: the-last-shift_m_d_026 + text: "Same booth. Same warm smile. Same offer to buy me a drink. Everything except the truth." + trigger: observe_npc + prerequisites: + relationship: + target: npc:sera-venn + state: person_of_interest + priority: 8 + tags: [npc, sera, contaminated-trust, friend-arc] +``` + +--- + +## 5. ID Format + +### 5.1 Pattern + +``` +{location-slug}_{type}_{character?}_{sequence} +``` + +| Segment | Format | Values | Example | +|---------|--------|--------|---------| +| `location-slug` | kebab-case | Matches location directory name, or `general` | `the-last-shift`, `general` | +| `type` | single char | `d` = dialogue, `m` = monologue, `e` = environmental (future) | `d`, `m` | +| `character` | single char | `s` = smuggler, `d` = detective. **Monologue only.** | `s`, `d` | +| `sequence` | 3-digit zero-padded | `001`–`999` | `001`, `042` | + +**General validation regex** (matches both dialogue and monologue IDs): + +``` +^[a-z0-9-]+_(d|m)(_[a-z])?_\d{3}$ +``` + +- `[a-z0-9-]+` — location slug (kebab-case, at least one character) +- `(d|m)` — pool type: `d` for dialogue, `m` for monologue +- `(_[a-z])?` — optional character segment (monologue only): `_s` or `_d` +- `\d{3}` — three-digit zero-padded sequence number + +Use the pool-specific regexes in [Section 5.2](#52-regex-patterns) for strict per-type validation. This general regex is useful for quick format checks that accept either type. + +> **D-035 deviation note:** D-035 specifies `{template}_{d|m|e}_{###}`. This spec refines that to `{location-slug}_{d|m}_{###}` (dialogue) and `{location-slug}_m_{s|d}_{###}` (monologue) for two reasons: (1) location-slug is more precise than template name and matches the directory hierarchy, and (2) monologue IDs include a character segment (`s`/`d`) to ensure uniqueness across the hard character partition (D-032). All existing authored content already uses this refined format. D-035 should be updated to reflect the implemented convention. + +### 5.2 Regex patterns + +| Pool type | Regex | Example | +|-----------|-------|---------| +| Dialogue | `^[a-z][a-z0-9-]*_d_[0-9]{3}$` | `the-last-shift_d_001` | +| Monologue | `^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$` | `the-last-shift_m_d_021`, `general_m_s_003` | + +### 5.3 Uniqueness scope + +- IDs must be unique **within a single YAML file**. +- IDs are **not required to be globally unique** — location slug + file path provides global uniqueness. The engine uses `(file_path, line_id)` as the composite key. +- Sequence numbers need not be contiguous. Gaps are expected when lines are removed or reordered. + +### 5.4 ID stability + +IDs are **stable references**. Once assigned, a line ID should not change. Other systems (cooldown tracking, analytics, the line previewer) reference lines by ID. If a line's text changes, keep the ID. Only assign a new ID when creating a genuinely new line. + +--- + +## 6. Tag Enums Reference + +All enum values are defined in `content/global/enums/` and validated by the JSON schemas in `content/_schema/`. This section is a quick reference — see the enum YAML files for full descriptions. + +### 6.1 Access tiers (D-028 Layer 1) + +| Value | Description | +|-------|-------------| +| `public` | Available to anyone. Surface-level conversation. | +| `insider` | Available to group members. Ring membership, established regulars. | +| `authority` | Available to institutional figures. Commission agents, security. | +| `peer` | Available to social equals with earned personal trust. | +| `hostile` | Available when relationship has broken down. | + +### 6.2 Trust tiers (D-028 Layer 3) + +| Value | Gate level | Description | +|-------|------------|-------------| +| `surface` | Default | Safe, non-committal. What NPCs tell strangers. | +| `real` | Earned | Genuine opinions, workplace complaints, personal concerns. | +| `secret` | Highest | Information they'd normally hide. Ring involvement, vulnerabilities. | + +### 6.3 Situations (D-028 Layer 2) + +`arrival`, `shift_start`, `shift_end`, `shift_transition`, `bar_evening`, `night_shift`, `investigation`, `confrontation`, `social`, `alone`, `emergency`, `routine`, `observation` + +13 values for v0.1. See `content/global/enums/situations.yaml` for full descriptions. + +### 6.4 Topics (D-028 Layer 4) + +`colleague`, `routine`, `cargo`, `money`, `trust`, `danger`, `institution`, `personal`, `investigation` + +9 values for v0.1. Note: `crime` is deliberately excluded — NPCs think of smuggling as `cargo` or `money`. + +### 6.5 Moods (D-028 Layer 4) + +`fond`, `comfortable`, `worried`, `suspicious`, `analytical`, `conflicted`, `concerned`, `relieved` + +8 values for v0.1. + +### 6.6 Monologue triggers + +`enter_location`, `observe_npc`, `hear_sound`, `observe_anomaly`, `post_conversation`, `discover_evidence`, `witness_interaction`, `time_idle`, `return_visit` + +9 values for v0.1. + +### 6.7 Confidence levels (D-041) + +| Value | Gate meaning | Maps to D-028 | +|-------|-------------|----------------| +| `suspects` | "Something's off" | Gates initial investigation | +| `knows_of` | "X is involved in Y" | Gates `real` trust dialogue, peer access | +| `knows_details` | Actionable detail | Gates confrontation, `secret` trust dialogue | +| `direct` | Currently in LOS | Live position data, maximum rendering fidelity | + +--- + +## 7. Authoring-Only Fields + +These fields are consumed by the line previewer and authoring tools but **not by the runtime engine**. They may appear in any pool file. + +| Field | Type | Scope | Description | +|-------|------|-------|-------------| +| `dual_lens` | map | Per-line | Per-character authoring notes for mirror moments. Keys: `smuggler`, `detective`. Values: string notes about how each character interprets this line/moment differently. | +| `notes` | string | Per-line | Free-text author notes. Context, intent, voice reminders. | + +These fields are defined in the JSON schemas with `additionalProperties: false` — if you need to add them, the schemas must be updated first. Current schemas do not include these fields; they are reserved for a future schema update when the previewer authoring workflow matures. + +--- + +## 8. Validation Rules + +### 8.1 Structural validation (`make validate-content`) + +The existing `make validate-content` target (#392) validates against JSON schemas. The following rules are enforced: + +| Rule | Scope | Error level | +|------|-------|-------------| +| All required fields present | Both | ERROR | +| `id` matches regex pattern | Both | ERROR | +| `id` unique within file | Both | ERROR | +| Enum values match defined sets | Both | ERROR | +| `access` has ≥ 1 item | Dialogue | ERROR | +| `situation` has ≥ 1 item | Dialogue | ERROR | +| `text` is non-empty | Both | ERROR | +| `text` ≤ 160 chars | Monologue | ERROR | +| `character` matches parent directory | Monologue | ERROR | +| `location` matches parent directory | Both | ERROR | +| `priority` is 0-10 | Monologue | ERROR | +| `knowledge_grant.fact_id` references existing fact | Dialogue | WARNING | +| `prerequisites.facts[].fact_id` references existing fact | Monologue | WARNING | +| Lists have `uniqueItems` | Both | ERROR | + +### 8.2 Content-level validation (line previewer, future) + +These are not yet enforced by tooling but are authoring guidelines: + +- Every template role should have ≥ 5 `surface` trust lines at `public` access (baseline conversation) +- Every location should have ≥ 3 monologue lines per character for `enter_location` trigger (first-visit coverage) +- Lines tagged with `phase-5` (contaminated trust) should have corresponding `phase-1` baseline lines +- `knowledge_grant` confidence should not exceed `knows_of` from a single dialogue line (hearing one line shouldn't grant `knows_details`) +- Named NPC files should have lines across ≥ 2 trust tiers + +--- + +## 9. Rust Loader Interface + +For #326 (YAML content loader), the engine parses these files into in-memory structures. This section defines the target API — not the implementation. + +### 9.1 Core types + +```rust +/// A loaded dialogue line, fully parsed and validated. +struct DialogueLine { + id: LineId, + text: String, + role: RoleSlug, + access: Vec, // D-028 Layer 1 + trust: TrustTier, // D-028 Layer 3 + situation: Vec, // D-028 Layer 2 + topic: Vec, // D-028 Layer 4 + mood: Vec, // D-028 Layer 4 + tags: Vec, + knowledge_grant: Option, +} + +/// A loaded monologue line, fully parsed and validated. +struct MonologueLine { + id: LineId, + text: String, // ≤ 160 chars + trigger: Trigger, + prerequisites: Option, + priority: u8, // 0-10, default 5 + cooldown: u32, // ticks, default 0 + tags: Vec, +} + +/// Pool container — one per file loaded. +struct DialoguePool { + location: LocationSlug, + role: RoleSlug, + lines: Vec, +} + +struct MonologuePool { + character: Character, // smuggler | detective + location: LocationSlug, // or "general" + lines: Vec, +} +``` + +### 9.2 Query API + +```rust +/// Query dialogue lines through the 4-layer pipeline. +fn query_dialogue( + pool: &DialoguePool, + player_access: AccessTier, + active_situations: &[Situation], + player_trust: TrustTier, + npc_topics: &[Topic], // weighted preference, not hard filter + npc_mood: &[Mood], // weighted preference, not hard filter +) -> Vec<&DialogueLine>; + +/// Query monologue lines for a trigger event. +fn query_monologue( + pools: &[MonologuePool], // all pools for current character + location + character: Character, + trigger: Trigger, + knowledge: &KnowledgeGraph, // D-041 + fired_cooldowns: &BTreeMap, // BTreeMap per D-041 +) -> Vec<&MonologueLine>; +``` + +### 9.3 Indexing strategy + +Per D-041 determinism requirements, all internal maps use `BTreeMap`: + +- **Primary index:** `BTreeMap<(LocationSlug, RoleSlug), DialoguePool>` — dialogue pools by location + role +- **Access pre-filter:** Lines within each pool pre-sorted by access tier for O(1) hard filter +- **Monologue index:** `BTreeMap<(Character, LocationSlug), Vec>` — monologue pools by character + location +- **Trigger index:** Within each monologue pool, lines grouped by trigger type for fast lookup + +--- + +## 10. Content Volume Estimates + +Per D-028, target line counts for v0.1 vertical slice (Sova Transit District): + +| Content type | Per template role | Per location | District total | +|--------------|-------------------|--------------|----------------| +| Dialogue lines | 165-210 authored | ~500-700 | ~2,000-3,000 | +| Monologue lines (per character) | — | 20-40 | ~100-200 | +| Generation-expanded (4x) | 660-840 | ~2,000-2,800 | ~8,000-12,000 | + +The generation pass (write 10, generate 40) is a future pipeline step — authored YAML files contain only human-written lines. + +--- + +## 11. Schema Files + +The canonical JSON Schema files that validate this format: + +| Schema | Path | Validates | +|--------|------|-----------| +| Dialogue pool | `content/_schema/dialogue-pool.schema.json` | `dialogue/**/*.yaml` | +| Monologue pool | `content/_schema/monologue-pool.schema.json` | `monologue/**/*.yaml` | +| Fact catalog | `content/_schema/fact-catalog.schema.json` | `global/knowledge/*.yaml` | + +These schemas are the machine-enforceable subset of this specification. This document is the authoritative reference; the schemas enforce the structural rules. diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 98663e6fa..f1803b5bc 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -73,7 +73,11 @@ pub fn receive_bridge_inputs( match bridge.receive_inputs() { Ok(inputs) => { for input in &inputs { - tracing::debug!("Received input: tick={} action={:?}", input.tick, input.action); + tracing::debug!( + "Received input: tick={} action={:?}", + input.tick, + input.action + ); } for input in inputs { input_queue.push(input); @@ -153,8 +157,7 @@ impl Plugin for BridgePlugin { .add_systems( Update, ( - receive_bridge_inputs - .before(crate::simulation::input::process_player_input), + receive_bridge_inputs.before(crate::simulation::input::process_player_input), crate::perception::observer::compute_visibility_geometry .after(crate::simulation::movement::validate_movement), crate::simulation::interaction::compute_nearby_interactions diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 3251c94c1..7dbc94bec 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -15,7 +15,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 = 6; +pub const PROTOCOL_VERSION: u8 = 7; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -25,6 +25,7 @@ pub const PROTOCOL_VERSION: u8 = 6; /// v4 adds: nearby_interactions (D-060, #404 proximity + verbs[]). /// v5 adds: current_monologue (#414 internal monologue pipeline). /// v6 adds: player_stance (#449, D-053), player_inventory (#449, D-065). +/// v7 adds: pending_recognitions (#423, D-060 cognitive delay). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { @@ -56,6 +57,11 @@ pub struct ObserverSnapshot { /// None when no monologue is triggered. Client shows text and auto-fades. #[serde(default)] pub current_monologue: Option, + /// Entities undergoing cognitive delay recognition (#423, D-060). + /// Client renders these as grey blobs at position until recognition completes. + /// Empty when no recognitions are pending. + #[serde(default)] + pub pending_recognitions: Vec, } /// Game time data for client display (D-031) @@ -382,6 +388,23 @@ pub enum VerbKind { ExamineObject, } +/// A recognition pending cognitive delay, included in ObserverSnapshot (#423, D-060). +/// Client renders entity as grey blob at position until recognition completes. +/// Visual transition: grey blob -> D-033 color + silhouette over ~0.3s. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingRecognitionWire { + /// Wire entity ID (may match entity_id in VisibleEntity if also in entities list) + pub entity_id: u64, + /// Render position + pub x: f32, + pub y: f32, + pub z: i32, + /// Remaining ticks until recognition completes (for progress animation) + pub remaining_ticks: u64, + /// Total delay for this recognition (for animation timing) + pub total_delay_ticks: u64, +} + /// Internal monologue event sent to the client for display (#414). /// Contains the text and display duration. Client auto-fades after duration. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/server/src/content/hot_reload.rs b/server/src/content/hot_reload.rs new file mode 100644 index 000000000..257924499 --- /dev/null +++ b/server/src/content/hot_reload.rs @@ -0,0 +1,260 @@ +//! Content hot-reload via timestamp polling (dev-only). +//! +//! Periodically checks content YAML files for modifications and triggers +//! a full reload when changes are detected. Designed for the authoring +//! workflow — not enabled in production builds. +//! +//! Check interval: every 20 ticks (~2s at 10 tps per D-031). +//! Failures are non-critical: previous content is preserved on reload error. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use bevy_ecs::prelude::*; + +use crate::content::line_pool::LinePoolIndex; +use crate::content::loader; +use crate::content::{ContentConfig, ContentStoreResource, LinePoolIndexResource}; + +/// How often to check for content changes (in system ticks). +/// At 10 tps (D-031), 20 ticks = 2 seconds. +const CHECK_INTERVAL_TICKS: u64 = 20; + +/// Consecutive reload failures before escalating to a warning. +const FAILURE_WARN_THRESHOLD: u32 = 5; + +/// Resource tracking content file timestamps for change detection. +#[derive(Resource, Debug)] +pub struct ContentWatcher { + file_timestamps: BTreeMap, + ticks_since_check: u64, + /// Consecutive reload failures. Resets on success. + consecutive_failures: u32, +} + +impl ContentWatcher { + /// Create a new watcher and perform initial timestamp scan. + /// Returns a watcher with no tracked files if content_root is invalid. + pub fn new(content_root: &Path) -> Self { + let mut watcher = Self { + file_timestamps: BTreeMap::new(), + ticks_since_check: 0, + consecutive_failures: 0, + }; + if content_root.as_os_str().is_empty() || !content_root.is_dir() { + tracing::warn!( + "ContentWatcher: invalid content root {:?}, hot-reload disabled", + content_root, + ); + return watcher; + } + watcher.scan(content_root); + watcher + } + + /// Scan content directory tree and record all YAML file timestamps. + fn scan(&mut self, content_root: &Path) { + self.file_timestamps.clear(); + walk_yaml(content_root, &mut self.file_timestamps, 0); + tracing::debug!( + "ContentWatcher: tracking {} content files", + self.file_timestamps.len() + ); + } + + /// Check for changes and rescan. Returns true if any files changed. + fn check_and_rescan(&mut self, content_root: &Path) -> bool { + let mut new_timestamps = BTreeMap::new(); + walk_yaml(content_root, &mut new_timestamps, 0); + let changed = new_timestamps != self.file_timestamps; + if changed { + self.file_timestamps = new_timestamps; + } + changed + } + + /// Number of tracked files (for diagnostics). + pub fn tracked_file_count(&self) -> usize { + self.file_timestamps.len() + } +} + +/// Maximum recursion depth for directory walking (guards against symlink loops). +const MAX_WALK_DEPTH: usize = 100; + +/// Recursively walk a directory, recording .yaml file modification timestamps. +/// Stops recursing at MAX_WALK_DEPTH to guard against symlink loops. +fn walk_yaml(dir: &Path, timestamps: &mut BTreeMap, depth: usize) { + if depth >= MAX_WALK_DEPTH { + tracing::warn!("walk_yaml: max depth {} reached at {:?}, stopping", MAX_WALK_DEPTH, dir); + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_dir() { + walk_yaml(&path, timestamps, depth + 1); + } else if path.extension().and_then(|e| e.to_str()) == Some("yaml") { + if let Ok(meta) = std::fs::metadata(&path) { + if let Ok(modified) = meta.modified() { + timestamps.insert(path, modified); + } + } + } + } +} + +/// System: periodically check for content file changes and reload. +/// +/// Only runs when a ContentWatcher resource exists (hot-reload enabled). +/// Runs in PostUpdate to avoid interfering with the current tick. +pub fn hot_reload_content( + config: Res, + watcher: Option>, + store_res: Option>, + index_res: Option>, +) { + let Some(mut watcher) = watcher else { + return; + }; + let Some(mut store_res) = store_res else { + return; + }; + let Some(mut index_res) = index_res else { + return; + }; + + watcher.ticks_since_check += 1; + if watcher.ticks_since_check < CHECK_INTERVAL_TICKS { + return; + } + watcher.ticks_since_check = 0; + + if !watcher.check_and_rescan(&config.content_root) { + return; + } + + tracing::info!("Content files changed, reloading..."); + + match loader::load_content(&config.content_root) { + Ok(store) => { + let index = LinePoolIndex::build(&store); + let d_count = index.dialogue_line_count(); + let m_count = index.monologue_line_count(); + store_res.0 = store; + index_res.0 = index; + watcher.consecutive_failures = 0; + tracing::info!( + "Content hot-reloaded: {} dialogue lines, {} monologue lines", + d_count, + m_count + ); + } + Err(e) => { + watcher.consecutive_failures += 1; + if watcher.consecutive_failures >= FAILURE_WARN_THRESHOLD { + tracing::warn!( + "Content hot-reload failed {} consecutive times (keeping previous): {}", + watcher.consecutive_failures, + e, + ); + } else { + tracing::warn!("Content hot-reload failed (keeping previous): {}", e); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn watcher_tracks_yaml_files() { + let dir = std::env::temp_dir().join("sr_hotreload_test_track"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + fs::write(dir.join("test.yaml"), "key: value\n").unwrap(); + fs::write(dir.join("other.txt"), "ignored\n").unwrap(); + + let watcher = ContentWatcher::new(&dir); + assert_eq!(watcher.tracked_file_count(), 1); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn watcher_detects_new_file() { + let dir = std::env::temp_dir().join("sr_hotreload_test_new"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + fs::write(dir.join("a.yaml"), "key: a\n").unwrap(); + + let mut watcher = ContentWatcher::new(&dir); + assert!(!watcher.check_and_rescan(&dir)); // no change yet + + fs::write(dir.join("b.yaml"), "key: b\n").unwrap(); + assert!(watcher.check_and_rescan(&dir)); // new file detected + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn watcher_detects_deleted_file() { + let dir = std::env::temp_dir().join("sr_hotreload_test_del"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + fs::write(dir.join("a.yaml"), "key: a\n").unwrap(); + fs::write(dir.join("b.yaml"), "key: b\n").unwrap(); + + let mut watcher = ContentWatcher::new(&dir); + assert_eq!(watcher.tracked_file_count(), 2); + + fs::remove_file(dir.join("b.yaml")).unwrap(); + assert!(watcher.check_and_rescan(&dir)); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn watcher_detects_modification() { + let dir = std::env::temp_dir().join("sr_hotreload_test_mod"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + fs::write(dir.join("a.yaml"), "key: a\n").unwrap(); + + let mut watcher = ContentWatcher::new(&dir); + + // Sleep briefly to ensure modification time differs + std::thread::sleep(std::time::Duration::from_millis(50)); + fs::write(dir.join("a.yaml"), "key: modified\n").unwrap(); + + assert!(watcher.check_and_rescan(&dir)); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn watcher_recurses_subdirectories() { + let dir = std::env::temp_dir().join("sr_hotreload_test_recurse"); + let _ = fs::remove_dir_all(&dir); + let sub = dir.join("sub/deep"); + fs::create_dir_all(&sub).unwrap(); + + fs::write(dir.join("root.yaml"), "key: root\n").unwrap(); + fs::write(sub.join("deep.yaml"), "key: deep\n").unwrap(); + + let watcher = ContentWatcher::new(&dir); + assert_eq!(watcher.tracked_file_count(), 2); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/server/src/content/line_pool.rs b/server/src/content/line_pool.rs new file mode 100644 index 000000000..2078ae9b6 --- /dev/null +++ b/server/src/content/line_pool.rs @@ -0,0 +1,1208 @@ +//! Indexed line pool data structures and query API (D-028 four-layer filtering). +//! +//! Provides typed, pre-indexed runtime representations of dialogue and monologue +//! content pools. Built from the raw ContentStore after YAML deserialization. +//! +//! Indexing strategy (D-041 determinism): +//! - All maps use BTreeMap for deterministic iteration order +//! - Dialogue: BTreeMap<(location, role), pool> with lines ready for filtering +//! - Monologue: BTreeMap<(character, location), pool> with trigger grouping + +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; + +use crate::content::loader::ContentStore; +use crate::content::types; + +// --------------------------------------------------------------------------- +// Tag enums (D-035 converged taxonomy) +// --------------------------------------------------------------------------- + +/// D-028 Layer 1: Access tier — hard filter on who can hear this line. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AccessTier { + Public, + Insider, + Authority, + Peer, + Hostile, +} + +impl FromStr for AccessTier { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "public" => Ok(Self::Public), + "insider" => Ok(Self::Insider), + "authority" => Ok(Self::Authority), + "peer" => Ok(Self::Peer), + "hostile" => Ok(Self::Hostile), + _ => Err(ParseEnumError { + kind: "AccessTier", + value: s.to_string(), + }), + } + } +} + +/// D-028 Layer 3: Trust tier — hard filter on relationship depth. +/// +/// Ordering: Surface < Real < Secret (derived from PartialOrd on discriminant). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TrustTier { + Surface, + Real, + Secret, +} + +impl TrustTier { + /// Returns true if `self` meets or exceeds the `required` tier. + pub fn meets(self, required: TrustTier) -> bool { + self >= required + } +} + +impl FromStr for TrustTier { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "surface" => Ok(Self::Surface), + "real" => Ok(Self::Real), + "secret" => Ok(Self::Secret), + _ => Err(ParseEnumError { + kind: "TrustTier", + value: s.to_string(), + }), + } + } +} + +/// D-028 Layer 2: Situation context — when this line can fire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Situation { + Arrival, + ShiftStart, + ShiftEnd, + ShiftTransition, + BarEvening, + NightShift, + Investigation, + Confrontation, + Social, + Alone, + Emergency, + Routine, + Observation, +} + +impl FromStr for Situation { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "arrival" => Ok(Self::Arrival), + "shift_start" => Ok(Self::ShiftStart), + "shift_end" => Ok(Self::ShiftEnd), + "shift_transition" => Ok(Self::ShiftTransition), + "bar_evening" => Ok(Self::BarEvening), + "night_shift" => Ok(Self::NightShift), + "investigation" => Ok(Self::Investigation), + "confrontation" => Ok(Self::Confrontation), + "social" => Ok(Self::Social), + "alone" => Ok(Self::Alone), + "emergency" => Ok(Self::Emergency), + "routine" => Ok(Self::Routine), + "observation" => Ok(Self::Observation), + _ => Err(ParseEnumError { + kind: "Situation", + value: s.to_string(), + }), + } + } +} + +/// D-028 Layer 4: Topic tag — influences weighted selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Topic { + Colleague, + Routine, + Cargo, + Money, + Trust, + Danger, + Institution, + Personal, + Investigation, +} + +impl FromStr for Topic { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "colleague" => Ok(Self::Colleague), + "routine" => Ok(Self::Routine), + "cargo" => Ok(Self::Cargo), + "money" => Ok(Self::Money), + "trust" => Ok(Self::Trust), + "danger" => Ok(Self::Danger), + "institution" => Ok(Self::Institution), + "personal" => Ok(Self::Personal), + "investigation" => Ok(Self::Investigation), + _ => Err(ParseEnumError { + kind: "Topic", + value: s.to_string(), + }), + } + } +} + +/// D-028 Layer 4: Mood tag — influences weighted selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Mood { + Fond, + Comfortable, + Worried, + Suspicious, + Analytical, + Conflicted, + Concerned, + Relieved, +} + +impl FromStr for Mood { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "fond" => Ok(Self::Fond), + "comfortable" => Ok(Self::Comfortable), + "worried" => Ok(Self::Worried), + "suspicious" => Ok(Self::Suspicious), + "analytical" => Ok(Self::Analytical), + "conflicted" => Ok(Self::Conflicted), + "concerned" => Ok(Self::Concerned), + "relieved" => Ok(Self::Relieved), + _ => Err(ParseEnumError { + kind: "Mood", + value: s.to_string(), + }), + } + } +} + +/// Monologue trigger type — what causes this line to fire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Trigger { + EnterLocation, + ObserveNpc, + HearSound, + ObserveAnomaly, + PostConversation, + DiscoverEvidence, + WitnessInteraction, + TimeIdle, + ReturnVisit, +} + +impl FromStr for Trigger { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "enter_location" => Ok(Self::EnterLocation), + "observe_npc" => Ok(Self::ObserveNpc), + "hear_sound" => Ok(Self::HearSound), + "observe_anomaly" => Ok(Self::ObserveAnomaly), + "post_conversation" => Ok(Self::PostConversation), + "discover_evidence" => Ok(Self::DiscoverEvidence), + "witness_interaction" => Ok(Self::WitnessInteraction), + "time_idle" => Ok(Self::TimeIdle), + "return_visit" => Ok(Self::ReturnVisit), + _ => Err(ParseEnumError { + kind: "Trigger", + value: s.to_string(), + }), + } + } +} + +/// Hard character partition for monologue pools (D-032). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Character { + Smuggler, + Detective, +} + +impl FromStr for Character { + type Err = ParseEnumError; + fn from_str(s: &str) -> Result { + match s { + "smuggler" => Ok(Self::Smuggler), + "detective" => Ok(Self::Detective), + _ => Err(ParseEnumError { + kind: "Character", + value: s.to_string(), + }), + } + } +} + +/// Error type for enum parsing failures. +#[derive(Debug)] +pub struct ParseEnumError { + pub kind: &'static str, + pub value: String, +} + +impl fmt::Display for ParseEnumError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid {} value: {:?}", self.kind, self.value) + } +} + +impl std::error::Error for ParseEnumError {} + +// --------------------------------------------------------------------------- +// Indexed line types — typed runtime representations +// --------------------------------------------------------------------------- + +/// A dialogue line with typed enum fields, ready for filtering. +#[derive(Debug, Clone)] +pub struct IndexedDialogueLine { + pub id: String, + pub text: String, + pub role: String, + pub access: Vec, + pub trust: TrustTier, + pub situation: Vec, + pub topic: Vec, + pub mood: Vec, + pub tags: Vec, + pub knowledge_grant: Option, +} + +/// A monologue line with typed enum fields, ready for filtering. +#[derive(Debug, Clone)] +pub struct IndexedMonologueLine { + pub id: String, + pub text: String, + pub trigger: Trigger, + pub prerequisites: Option, + pub priority: u8, + pub cooldown: u32, + pub tags: Vec, +} + +// --------------------------------------------------------------------------- +// Pool index types +// --------------------------------------------------------------------------- + +/// Dialogue pool indexed for querying. +#[derive(Debug)] +pub struct IndexedDialoguePool { + pub location: String, + pub role: String, + pub lines: Vec, +} + +/// Monologue pool indexed by trigger for fast lookup. +#[derive(Debug)] +pub struct IndexedMonologuePool { + pub character: Character, + pub location: String, + /// Lines grouped by trigger type (BTreeMap for deterministic iteration). + pub by_trigger: BTreeMap>, +} + +// --------------------------------------------------------------------------- +// Top-level index +// --------------------------------------------------------------------------- + +/// Top-level line pool index — the queryable runtime data structure. +/// +/// Built from ContentStore at startup (and rebuilt on hot-reload). +/// All internal maps use BTreeMap per D-041 determinism requirement. +#[derive(Debug, Default)] +pub struct LinePoolIndex { + /// Dialogue pools indexed by (location, role). + pub dialogue: BTreeMap<(String, String), IndexedDialoguePool>, + /// Monologue pools indexed by (character, location). + pub monologue: BTreeMap<(Character, String), IndexedMonologuePool>, +} + +impl LinePoolIndex { + /// Build the index from a ContentStore. + /// + /// Parses string tags into typed enums. Lines with invalid required tags + /// are skipped with a warning log. + pub fn build(store: &ContentStore) -> Self { + let mut index = Self::default(); + + for district in store.districts.values() { + for pool in &district.dialogue_pools { + index.index_dialogue_pool(pool); + } + for pool in &district.monologue_pools { + index.index_monologue_pool(pool); + } + } + + index + } + + fn index_dialogue_pool(&mut self, pool: &types::DialoguePool) { + if pool.location.is_empty() { + tracing::warn!( + "Skipping dialogue pool with empty location (role={})", + pool.role, + ); + return; + } + + let key = (pool.location.clone(), pool.role.clone()); + + let indexed_lines: Vec = + pool.lines.iter().filter_map(parse_dialogue_line).collect(); + + let entry = self + .dialogue + .entry(key) + .or_insert_with(|| IndexedDialoguePool { + location: pool.location.clone(), + role: pool.role.clone(), + lines: Vec::new(), + }); + entry.lines.extend(indexed_lines); + } + + fn index_monologue_pool(&mut self, pool: &types::MonologuePool) { + if pool.location.is_empty() { + tracing::warn!( + "Skipping monologue pool with empty location (character={})", + pool.character, + ); + return; + } + + let character = match pool.character.parse::() { + Ok(c) => c, + Err(e) => { + tracing::warn!("Skipping monologue pool: {}", e); + return; + } + }; + + let key = (character, pool.location.clone()); + let entry = self + .monologue + .entry(key) + .or_insert_with(|| IndexedMonologuePool { + character, + location: pool.location.clone(), + by_trigger: BTreeMap::new(), + }); + + for line in &pool.lines { + if let Some(indexed) = parse_monologue_line(line) { + entry + .by_trigger + .entry(indexed.trigger) + .or_default() + .push(indexed); + } + } + } + + /// Query dialogue lines through Layers 1-3 of the D-028 pipeline. + /// + /// Returns lines that pass: + /// - Layer 1: player's access tier is in line.access + /// - Layer 2: any active situation is in line.situation + /// - Layer 3: player's trust >= line.trust + /// + /// Layer 4 (topic+mood scoring) is handled by the selection pipeline (#305). + pub fn query_dialogue( + &self, + location: &str, + role: &str, + player_access: AccessTier, + active_situations: &[Situation], + player_trust: TrustTier, + ) -> Vec<&IndexedDialogueLine> { + let key = (location.to_string(), role.to_string()); + let Some(pool) = self.dialogue.get(&key) else { + return Vec::new(); + }; + + pool.lines + .iter() + .filter(|line| { + // Layer 1: Access filter (hard) + line.access.contains(&player_access) + }) + .filter(|line| { + // Layer 2: Situation filter (context) + line.situation.iter().any(|s| active_situations.contains(s)) + }) + .filter(|line| { + // Layer 3: Trust filter (hard) + player_trust.meets(line.trust) + }) + .collect() + } + + /// Query monologue lines for a trigger event. + /// + /// Returns lines matching character + trigger from both: + /// - Location-specific pool (exact match) + /// - General pool (location = "general") + /// + /// Prerequisite evaluation and cooldown checking are the caller's + /// responsibility (they require KG state and tick tracking). + pub fn query_monologue( + &self, + character: Character, + location: &str, + trigger: Trigger, + ) -> Vec<&IndexedMonologueLine> { + let mut results = Vec::new(); + + // Location-specific pool + let key = (character, location.to_string()); + if let Some(pool) = self.monologue.get(&key) { + if let Some(lines) = pool.by_trigger.get(&trigger) { + results.extend(lines.iter()); + } + } + + // General pool fallback + if location != "general" { + let general_key = (character, "general".to_string()); + if let Some(pool) = self.monologue.get(&general_key) { + if let Some(lines) = pool.by_trigger.get(&trigger) { + results.extend(lines.iter()); + } + } + } + + results + } + + /// Returns total number of indexed dialogue lines. + pub fn dialogue_line_count(&self) -> usize { + self.dialogue.values().map(|p| p.lines.len()).sum() + } + + /// Returns total number of indexed monologue lines. + pub fn monologue_line_count(&self) -> usize { + self.monologue + .values() + .flat_map(|p| p.by_trigger.values()) + .map(|lines| lines.len()) + .sum() + } +} + +// --------------------------------------------------------------------------- +// Parsing helpers +// --------------------------------------------------------------------------- + +/// Parse a raw DialogueLine into an indexed line with typed enums. +/// Returns None if any required enum field fails to parse. +fn parse_dialogue_line(line: &types::DialogueLine) -> Option { + let access: Vec = line + .access + .iter() + .filter_map(|s| { + s.parse() + .map_err(|e: ParseEnumError| { + tracing::warn!("Line {}: {}", line.id, e); + }) + .ok() + }) + .collect(); + + if access.is_empty() { + tracing::warn!("Line {}: no valid access tiers, skipping", line.id); + return None; + } + + let trust = match line.trust.parse::() { + Ok(t) => t, + Err(e) => { + tracing::warn!("Line {}: {}, skipping", line.id, e); + return None; + } + }; + + let situation: Vec = line + .situation + .iter() + .filter_map(|s| { + s.parse() + .map_err(|e: ParseEnumError| { + tracing::warn!("Line {}: {}", line.id, e); + }) + .ok() + }) + .collect(); + + if situation.is_empty() { + tracing::warn!("Line {}: no valid situations, skipping", line.id); + return None; + } + + let topic: Vec = line + .topic + .iter() + .filter_map(|s| { + s.parse() + .map_err(|e: ParseEnumError| { + tracing::warn!("Line {}: {}", line.id, e); + }) + .ok() + }) + .collect(); + let mood: Vec = line + .mood + .iter() + .filter_map(|s| { + s.parse() + .map_err(|e: ParseEnumError| { + tracing::warn!("Line {}: {}", line.id, e); + }) + .ok() + }) + .collect(); + + Some(IndexedDialogueLine { + id: line.id.clone(), + text: line.text.clone(), + role: line.role.clone(), + access, + trust, + situation, + topic, + mood, + tags: line.tags.clone(), + knowledge_grant: line.knowledge_grant.clone(), + }) +} + +/// Parse a raw MonologueLine into an indexed line with typed enums. +/// Returns None if the trigger fails to parse. +fn parse_monologue_line(line: &types::MonologueLine) -> Option { + let trigger = match line.trigger.parse::() { + Ok(t) => t, + Err(e) => { + tracing::warn!("Line {}: {}, skipping", line.id, e); + return None; + } + }; + + Some(IndexedMonologueLine { + id: line.id.clone(), + text: line.text.clone(), + trigger, + prerequisites: line.prerequisites.clone(), + priority: line.priority.unwrap_or(5).clamp(0, 10) as u8, + cooldown: line.cooldown.unwrap_or(0).max(0) as u32, + tags: line.tags.clone(), + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::loader::{ContentStore, DistrictContent}; + + // -- Enum parsing tests -------------------------------------------------- + + #[test] + fn access_tier_parse_all_values() { + assert_eq!("public".parse::().unwrap(), AccessTier::Public); + assert_eq!( + "insider".parse::().unwrap(), + AccessTier::Insider + ); + assert_eq!( + "authority".parse::().unwrap(), + AccessTier::Authority + ); + assert_eq!("peer".parse::().unwrap(), AccessTier::Peer); + assert_eq!( + "hostile".parse::().unwrap(), + AccessTier::Hostile + ); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn trust_tier_ordering() { + assert!(TrustTier::Surface < TrustTier::Real); + assert!(TrustTier::Real < TrustTier::Secret); + assert!(TrustTier::Secret.meets(TrustTier::Secret)); + assert!(TrustTier::Secret.meets(TrustTier::Surface)); + assert!(!TrustTier::Surface.meets(TrustTier::Real)); + } + + #[test] + fn situation_parse_all_values() { + let values = [ + "arrival", + "shift_start", + "shift_end", + "shift_transition", + "bar_evening", + "night_shift", + "investigation", + "confrontation", + "social", + "alone", + "emergency", + "routine", + "observation", + ]; + for v in values { + assert!( + v.parse::().is_ok(), + "Failed to parse situation: {}", + v + ); + } + assert!("invalid".parse::().is_err()); + } + + #[test] + fn topic_parse_all_values() { + let values = [ + "colleague", + "routine", + "cargo", + "money", + "trust", + "danger", + "institution", + "personal", + "investigation", + ]; + for v in values { + assert!(v.parse::().is_ok(), "Failed to parse topic: {}", v); + } + } + + #[test] + fn mood_parse_all_values() { + let values = [ + "fond", + "comfortable", + "worried", + "suspicious", + "analytical", + "conflicted", + "concerned", + "relieved", + ]; + for v in values { + assert!(v.parse::().is_ok(), "Failed to parse mood: {}", v); + } + } + + #[test] + fn trigger_parse_all_values() { + let values = [ + "enter_location", + "observe_npc", + "hear_sound", + "observe_anomaly", + "post_conversation", + "discover_evidence", + "witness_interaction", + "time_idle", + "return_visit", + ]; + for v in values { + assert!( + v.parse::().is_ok(), + "Failed to parse trigger: {}", + v + ); + } + } + + #[test] + fn character_parse() { + assert_eq!( + "smuggler".parse::().unwrap(), + Character::Smuggler + ); + assert_eq!( + "detective".parse::().unwrap(), + Character::Detective + ); + assert!("other".parse::().is_err()); + } + + // -- Helper: build a test ContentStore ----------------------------------- + + fn make_dialogue_line( + id: &str, + access: &[&str], + trust: &str, + situations: &[&str], + ) -> types::DialogueLine { + types::DialogueLine { + id: id.to_string(), + text: format!("Text for {}", id), + role: "worker".to_string(), + access: access.iter().map(|s| s.to_string()).collect(), + trust: trust.to_string(), + situation: situations.iter().map(|s| s.to_string()).collect(), + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + } + } + + fn make_monologue_line(id: &str, trigger: &str, priority: i32) -> types::MonologueLine { + types::MonologueLine { + id: id.to_string(), + text: format!("Monologue {}", id), + trigger: trigger.to_string(), + prerequisites: None, + priority: Some(priority), + cooldown: None, + tags: vec![], + } + } + + fn test_store() -> ContentStore { + let mut store = ContentStore::default(); + + let dialogue_pools = vec![types::DialoguePool { + location: "the-terminal".to_string(), + role: "dock-worker".to_string(), + lines: vec![ + make_dialogue_line( + "the-terminal_d_001", + &["public"], + "surface", + &["arrival", "social"], + ), + make_dialogue_line( + "the-terminal_d_002", + &["insider", "peer"], + "real", + &["bar_evening"], + ), + make_dialogue_line( + "the-terminal_d_003", + &["insider"], + "secret", + &["investigation"], + ), + ], + }]; + + let monologue_pools = vec![ + types::MonologuePool { + character: "smuggler".to_string(), + location: "the-terminal".to_string(), + lines: vec![ + make_monologue_line("the-terminal_m_s_001", "enter_location", 5), + make_monologue_line("the-terminal_m_s_002", "observe_npc", 7), + ], + }, + types::MonologuePool { + character: "smuggler".to_string(), + location: "general".to_string(), + lines: vec![make_monologue_line("general_m_s_001", "enter_location", 3)], + }, + ]; + + let district = DistrictContent { + dialogue_pools, + monologue_pools, + ..Default::default() + }; + store + .districts + .insert("test.district".to_string(), district); + + store + } + + // -- Index building tests ------------------------------------------------ + + #[test] + fn build_indexes_dialogue_pools() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + assert_eq!(index.dialogue.len(), 1); + let pool = index + .dialogue + .get(&("the-terminal".to_string(), "dock-worker".to_string())) + .unwrap(); + assert_eq!(pool.lines.len(), 3); + } + + #[test] + fn build_indexes_monologue_pools() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + assert_eq!(index.monologue.len(), 2); + let loc_pool = index + .monologue + .get(&(Character::Smuggler, "the-terminal".to_string())) + .unwrap(); + assert_eq!(loc_pool.by_trigger.len(), 2); + + let general_pool = index + .monologue + .get(&(Character::Smuggler, "general".to_string())) + .unwrap(); + assert_eq!(general_pool.by_trigger.len(), 1); + } + + #[test] + fn line_counts() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + assert_eq!(index.dialogue_line_count(), 3); + assert_eq!(index.monologue_line_count(), 3); + } + + // -- Dialogue query tests (D-028 Layers 1-3) ---------------------------- + + #[test] + fn query_dialogue_layer1_access_filter() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Public access should only get the public line + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Public, + &[Situation::Arrival], + TrustTier::Secret, + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "the-terminal_d_001"); + } + + #[test] + fn query_dialogue_layer2_situation_filter() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Insider + secret trust but only bar_evening situation + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Insider, + &[Situation::BarEvening], + TrustTier::Secret, + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "the-terminal_d_002"); + } + + #[test] + fn query_dialogue_layer3_trust_filter() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Insider + investigation but only surface trust — should miss line 003 (secret) + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Insider, + &[Situation::Investigation], + TrustTier::Surface, + ); + assert_eq!(results.len(), 0); + + // With real trust — still no (line 003 requires secret) + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Insider, + &[Situation::Investigation], + TrustTier::Real, + ); + assert_eq!(results.len(), 0); + + // With secret trust — line 003 passes + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Insider, + &[Situation::Investigation], + TrustTier::Secret, + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "the-terminal_d_003"); + } + + #[test] + fn query_dialogue_multiple_situations() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Insider with multiple situations should get lines from both + let results = index.query_dialogue( + "the-terminal", + "dock-worker", + AccessTier::Insider, + &[Situation::Arrival, Situation::BarEvening], + TrustTier::Real, + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "the-terminal_d_002"); + } + + #[test] + fn query_dialogue_nonexistent_pool() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + let results = index.query_dialogue( + "nonexistent", + "worker", + AccessTier::Public, + &[Situation::Arrival], + TrustTier::Surface, + ); + assert!(results.is_empty()); + } + + // -- Monologue query tests ----------------------------------------------- + + #[test] + fn query_monologue_location_specific() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + let results = + index.query_monologue(Character::Smuggler, "the-terminal", Trigger::ObserveNpc); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "the-terminal_m_s_002"); + } + + #[test] + fn query_monologue_includes_general_pool() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // enter_location: 1 from the-terminal + 1 from general + let results = + index.query_monologue(Character::Smuggler, "the-terminal", Trigger::EnterLocation); + assert_eq!(results.len(), 2); + } + + #[test] + fn query_monologue_general_only() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Unknown location — only general pool matches + let results = index.query_monologue( + Character::Smuggler, + "unknown-location", + Trigger::EnterLocation, + ); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "general_m_s_001"); + } + + #[test] + fn query_monologue_wrong_character() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + // Detective character — no pools exist + let results = + index.query_monologue(Character::Detective, "the-terminal", Trigger::EnterLocation); + assert!(results.is_empty()); + } + + #[test] + fn query_monologue_no_trigger_match() { + let store = test_store(); + let index = LinePoolIndex::build(&store); + + let results = index.query_monologue( + Character::Smuggler, + "the-terminal", + Trigger::DiscoverEvidence, + ); + assert!(results.is_empty()); + } + + #[test] + fn query_monologue_line_with_prerequisite_excluded_when_fact_absent() { + // H10: Verify prerequisite field is populated through query so callers + // can filter. query_monologue returns ALL matching lines (prerequisite + // evaluation is caller's responsibility per D-028), but a line with a + // prerequisite should carry that data through for the caller to check. + let mut store = ContentStore::default(); + let monologue_pools = vec![types::MonologuePool { + character: "smuggler".to_string(), + location: "the-terminal".to_string(), + lines: vec![ + // Line WITHOUT prerequisite — should always be available + types::MonologueLine { + id: "prereq_none".to_string(), + text: "No prereq line".to_string(), + trigger: "enter_location".to_string(), + prerequisites: None, + priority: Some(5), + cooldown: None, + tags: vec![], + }, + // Line WITH prerequisite — caller must check before using + types::MonologueLine { + id: "prereq_fact".to_string(), + text: "Requires cargo_manifest_seen".to_string(), + trigger: "enter_location".to_string(), + prerequisites: Some(types::Prerequisites { + facts: vec![types::FactPrerequisite { + fact_id: "cargo_manifest_seen".to_string(), + min_confidence: "confirmed".to_string(), + }], + entity_attributes: vec![], + relationship: None, + }), + priority: Some(8), + cooldown: None, + tags: vec![], + }, + ], + }]; + let district = DistrictContent { + monologue_pools, + ..Default::default() + }; + store + .districts + .insert("test.district".to_string(), district); + + let index = LinePoolIndex::build(&store); + let results = + index.query_monologue(Character::Smuggler, "the-terminal", Trigger::EnterLocation); + + // Both lines are returned (query doesn't filter prerequisites) + assert_eq!(results.len(), 2); + + // Verify the prerequisite-bearing line carries its prerequisites through + let prereq_line = results.iter().find(|l| l.id == "prereq_fact").unwrap(); + assert!( + prereq_line.prerequisites.is_some(), + "prerequisite field should be populated for caller to evaluate" + ); + let prereqs = prereq_line.prerequisites.as_ref().unwrap(); + assert_eq!(prereqs.facts.len(), 1); + assert_eq!(prereqs.facts[0].fact_id, "cargo_manifest_seen"); + + // The no-prerequisite line should have None + let no_prereq_line = results.iter().find(|l| l.id == "prereq_none").unwrap(); + assert!( + no_prereq_line.prerequisites.is_none(), + "line without prerequisites should have None" + ); + + // Simulate caller-side filtering: if fact is absent, exclude the line + let player_known_facts: Vec<&str> = vec![]; // empty — fact not known + let available: Vec<_> = results + .iter() + .filter(|line| { + match &line.prerequisites { + None => true, // no prerequisites = always available + Some(prereqs) => prereqs + .facts + .iter() + .all(|f| player_known_facts.contains(&f.fact_id.as_str())), + } + }) + .collect(); + assert_eq!( + available.len(), + 1, + "only the no-prerequisite line should pass when fact is absent" + ); + assert_eq!(available[0].id, "prereq_none"); + } + + // -- Parse edge cases ---------------------------------------------------- + + #[test] + fn dialogue_line_with_invalid_access_is_skipped() { + let line = types::DialogueLine { + id: "test_d_001".to_string(), + text: "test".to_string(), + role: "worker".to_string(), + access: vec!["invalid".to_string()], + trust: "surface".to_string(), + situation: vec!["arrival".to_string()], + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + }; + assert!(parse_dialogue_line(&line).is_none()); + } + + #[test] + fn dialogue_line_with_invalid_trust_is_skipped() { + let line = types::DialogueLine { + id: "test_d_001".to_string(), + text: "test".to_string(), + role: "worker".to_string(), + access: vec!["public".to_string()], + trust: "invalid".to_string(), + situation: vec!["arrival".to_string()], + topic: vec![], + mood: vec![], + tags: vec![], + knowledge_grant: None, + }; + assert!(parse_dialogue_line(&line).is_none()); + } + + #[test] + fn monologue_line_defaults() { + let line = types::MonologueLine { + id: "test_m_s_001".to_string(), + text: "test".to_string(), + trigger: "enter_location".to_string(), + prerequisites: None, + priority: None, + cooldown: None, + tags: vec![], + }; + let indexed = parse_monologue_line(&line).unwrap(); + assert_eq!(indexed.priority, 5); // default + assert_eq!(indexed.cooldown, 0); // default + } + + #[test] + fn monologue_priority_clamped() { + let line = types::MonologueLine { + id: "test_m_s_001".to_string(), + text: "test".to_string(), + trigger: "enter_location".to_string(), + prerequisites: None, + priority: Some(15), // over max + cooldown: None, + tags: vec![], + }; + let indexed = parse_monologue_line(&line).unwrap(); + assert_eq!(indexed.priority, 10); // clamped + } +} diff --git a/server/src/content/loader.rs b/server/src/content/loader.rs index 868d0e9cc..aba68da37 100644 --- a/server/src/content/loader.rs +++ b/server/src/content/loader.rs @@ -93,8 +93,11 @@ fn discover_districts(campaign_path: &Path) -> Vec { if systems_path.is_dir() { walk_for_districts(&systems_path, &mut districts); } - // BTreeMap ordering guarantees deterministic district processing, - // but sort the discovery order too for consistency. + // Discovery order from fs::read_dir is platform-dependent. Sort the Vec + // here so districts load in a deterministic order regardless of OS. + // ContentStore.districts uses BTreeMap for deterministic *iteration* later, + // but sorted discovery ensures deterministic *load* order (and thus + // deterministic ID derivation and log output). districts.sort(); districts } @@ -291,15 +294,13 @@ fn load_yaml_dir(dir: &Path) -> Vec { /// Load all YAML files recursively under a directory, skipping stubs. fn load_yaml_recursive(dir: &Path) -> Vec { let mut results = Vec::new(); - walk_yaml_files(dir, &mut |path| { - match load_yaml::(path) { - Ok(item) => results.push(item), - Err(e) => { - if is_comment_only_file(path) { - tracing::debug!("Skipping stub: {:?}", path); - } else { - tracing::warn!("Failed to parse {:?}: {}", path, e); - } + walk_yaml_files(dir, &mut |path| match load_yaml::(path) { + Ok(item) => results.push(item), + Err(e) => { + if is_comment_only_file(path) { + tracing::debug!("Skipping stub: {:?}", path); + } else { + tracing::warn!("Failed to parse {:?}: {}", path, e); } } }); @@ -525,7 +526,8 @@ motivation: "HANDLER" #[test] fn derive_district_id_from_path() { let root = Path::new("/content"); - let district = Path::new("/content/campaigns/main/systems/krenn/stations/sova/districts/transit"); + let district = + Path::new("/content/campaigns/main/systems/krenn/stations/sova/districts/transit"); let id = derive_district_id(root, district); assert_eq!(id, "krenn.sova.transit"); } diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index e5421fad2..179fe4823 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -1,16 +1,17 @@ -//! Content loading and entity spawning system. -//! -//! Phase 2 content loader (ticket #408): loads YAML content files from disk, -//! deserializes into intermediate types, and spawns ECS entities. +//! Content loading, indexing, and entity spawning system. //! //! Architecture (per Tyre's D-020 guidance): -//! 1. Deserialize YAML → intermediate content types (types.rs) -//! 2. Content discovery + loading (loader.rs) → ContentStore resource -//! 3. ContentStore → ECS entity spawning (spawn.rs) +//! 1. Deserialize YAML -> intermediate content types (types.rs) +//! 2. Content discovery + loading (loader.rs) -> ContentStore resource +//! 3. ContentStore -> ECS entity spawning (spawn.rs) +//! 4. ContentStore -> indexed line pools (line_pool.rs) -> LinePoolIndex resource +//! 5. Optional hot-reload (hot_reload.rs) for dev/authoring workflow //! //! Content schema is decoupled from ECS components. The spawn module //! handles the mapping between the two representations. +pub mod hot_reload; +pub mod line_pool; pub mod loader; pub mod spawn; pub mod types; @@ -25,20 +26,24 @@ use std::path::PathBuf; pub struct ContentConfig { /// Root directory containing content.yaml and campaign directories. pub content_root: PathBuf, + /// Enable hot-reload (timestamp polling). Dev-only, not for production. + pub hot_reload: bool, } impl Default for ContentConfig { fn default() -> Self { Self { content_root: PathBuf::from("content"), + hot_reload: false, } } } /// Content loading plugin. /// -/// Loads content from YAML files at startup and spawns ECS entities. -/// Requires ContentConfig resource to be inserted before the plugin runs. +/// Loads content from YAML files at startup, spawns ECS entities, +/// and builds the indexed line pools for dialogue/monologue queries. +/// Optionally enables hot-reload for the authoring workflow. pub struct ContentPlugin; impl Plugin for ContentPlugin { @@ -48,12 +53,13 @@ impl Plugin for ContentPlugin { } app.add_systems(Startup, load_and_spawn_content); + app.add_systems(PostUpdate, hot_reload::hot_reload_content); tracing::debug!("ContentPlugin initialized"); } } -/// Startup system: load content from disk and spawn entities. +/// Startup system: load content from disk, spawn entities, and build line pool index. fn load_and_spawn_content(world: &mut World) { let config = world.resource::().clone(); @@ -62,20 +68,35 @@ fn load_and_spawn_content(world: &mut World) { match loader::load_content(&config.content_root) { Ok(store) => { let result = spawn::spawn_content(world, &store); + tracing::info!("Content loaded and spawned: {} NPCs", result.npcs_spawned); + + // Build line pool index + let index = line_pool::LinePoolIndex::build(&store); tracing::info!( - "Content loaded and spawned: {} NPCs", - result.npcs_spawned + "Line pool index built: {} dialogue lines, {} monologue lines", + index.dialogue_line_count(), + index.monologue_line_count() ); - // Insert the content store as a resource for runtime access - // (triangle queries, pool lookups, dialogue selection) + world.insert_resource(ContentStoreResource(store)); + world.insert_resource(LinePoolIndexResource(index)); } Err(e) => { tracing::error!("Failed to load content: {}", e); - // Insert empty store so downstream systems don't panic on missing resource world.insert_resource(ContentStoreResource(loader::ContentStore::default())); + world.insert_resource(LinePoolIndexResource(line_pool::LinePoolIndex::default())); } } + + // Set up hot-reload if enabled + if config.hot_reload { + let watcher = hot_reload::ContentWatcher::new(&config.content_root); + tracing::info!( + "Content hot-reload enabled — tracking {} files, polling every ~2s", + watcher.tracked_file_count() + ); + world.insert_resource(watcher); + } } /// Wrapper resource holding the loaded content store. @@ -83,3 +104,9 @@ fn load_and_spawn_content(world: &mut World) { /// (e.g., dialogue selection, triangle fork evaluation). #[derive(Resource, Debug)] pub struct ContentStoreResource(pub loader::ContentStore); + +/// Wrapper resource holding the indexed line pools. +/// Available for runtime systems that need to query dialogue/monologue lines +/// through the D-028 four-layer filtering pipeline. +#[derive(Resource, Debug)] +pub struct LinePoolIndexResource(pub line_pool::LinePoolIndex); diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 9d65283f5..7f805874f 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -184,11 +184,11 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR // Register in EntityRegistry for StableId mapping let stable_id = world.resource_mut::().register(entity); - world - .entity_mut(entity) - .insert(StableEntityId(stable_id)); + world.entity_mut(entity).insert(StableEntityId(stable_id)); - result.npc_ids.insert(profile.canonical_id.clone(), stable_id); + result + .npc_ids + .insert(profile.canonical_id.clone(), stable_id); result.npcs_spawned += 1; tracing::debug!( @@ -418,10 +418,7 @@ fn resolve_routines( for schedule in &routine_file.schedules { let Some(&stable_id) = npc_ids.get(&schedule.npc) else { - tracing::debug!( - "Skipping routine for {}: not in npc_ids map", - schedule.npc - ); + tracing::debug!("Skipping routine for {}: not in npc_ids map", schedule.npc); continue; }; let Some(entity) = world.resource::().to_entity(&stable_id) else { @@ -523,7 +520,10 @@ fn parse_relationship_kind(s: &str) -> npc::RelationshipKind { "superior" => npc::RelationshipKind::Superior, "subordinate" => npc::RelationshipKind::Subordinate, other => { - tracing::warn!("Unknown relationship kind '{}', defaulting to Colleague", other); + tracing::warn!( + "Unknown relationship kind '{}', defaulting to Colleague", + other + ); npc::RelationshipKind::Colleague } } @@ -594,10 +594,7 @@ fn parse_secret_severity(description: &str) -> npc::SecretSeverity { } // Minor: social embarrassment, mild secrets, ambiguous situations - if lower.contains("ambiguous") - || lower.contains("embarrassment") - || lower.contains("gossip") - { + if lower.contains("ambiguous") || lower.contains("embarrassment") || lower.contains("gossip") { return npc::SecretSeverity::Minor; } @@ -715,7 +712,10 @@ mod tests { let tells = world.get::(entity).unwrap(); assert_eq!(tells.tells.len(), 1); - assert_eq!(tells.tells[0].trigger, npc::TellTrigger::StressAboveThreshold); + assert_eq!( + tells.tells[0].trigger, + npc::TellTrigger::StressAboveThreshold + ); let skills = world.get::(entity).unwrap(); assert_eq!(skills.skills.len(), 2); @@ -771,7 +771,10 @@ mod tests { assert_eq!(parse_want_kind("Wealth"), Some(npc::WantKind::Wealth)); assert_eq!(parse_want_kind("safety"), Some(npc::WantKind::Safety)); assert_eq!(parse_want_kind("KNOWLEDGE"), Some(npc::WantKind::Knowledge)); - assert_eq!(parse_want_kind("Connection"), Some(npc::WantKind::Connection)); + assert_eq!( + parse_want_kind("Connection"), + Some(npc::WantKind::Connection) + ); assert_eq!(parse_want_kind("Power"), Some(npc::WantKind::Power)); assert_eq!(parse_want_kind("Freedom"), Some(npc::WantKind::Freedom)); assert_eq!(parse_want_kind("Justice"), Some(npc::WantKind::Justice)); @@ -851,7 +854,9 @@ mod tests { .to_entity(&stable_id) .unwrap(); - let kg = world.get::(entity).expect("KnowledgeGraph should be attached"); + let kg = world + .get::(entity) + .expect("KnowledgeGraph should be attached"); assert!(kg.knows_fact(&FactId("contraband.ring_exists".to_string()))); assert!(kg.knows_fact(&FactId("relationship.kael_trust".to_string()))); assert!(kg.fact_at_least( @@ -886,7 +891,9 @@ mod tests { .to_entity(&stable_id) .unwrap(); - let kg = world.get::(entity).expect("Phase 2 should attach KnowledgeGraph"); + let kg = world + .get::(entity) + .expect("Phase 2 should attach KnowledgeGraph"); assert!(kg.knows_fact(&FactId("investigation.inspection_lapses".to_string()))); } @@ -939,15 +946,39 @@ mod tests { #[test] fn parse_relationship_kinds() { - assert_eq!(parse_relationship_kind("friend"), npc::RelationshipKind::Friend); - assert_eq!(parse_relationship_kind("colleague"), npc::RelationshipKind::Colleague); - assert_eq!(parse_relationship_kind("family"), npc::RelationshipKind::Family); - assert_eq!(parse_relationship_kind("romantic"), npc::RelationshipKind::Romantic); - assert_eq!(parse_relationship_kind("superior"), npc::RelationshipKind::Superior); - assert_eq!(parse_relationship_kind("subordinate"), npc::RelationshipKind::Subordinate); - assert_eq!(parse_relationship_kind("rival"), npc::RelationshipKind::Rival); + assert_eq!( + parse_relationship_kind("friend"), + npc::RelationshipKind::Friend + ); + assert_eq!( + parse_relationship_kind("colleague"), + npc::RelationshipKind::Colleague + ); + assert_eq!( + parse_relationship_kind("family"), + npc::RelationshipKind::Family + ); + assert_eq!( + parse_relationship_kind("romantic"), + npc::RelationshipKind::Romantic + ); + assert_eq!( + parse_relationship_kind("superior"), + npc::RelationshipKind::Superior + ); + assert_eq!( + parse_relationship_kind("subordinate"), + npc::RelationshipKind::Subordinate + ); + assert_eq!( + parse_relationship_kind("rival"), + npc::RelationshipKind::Rival + ); // Unknown defaults to Colleague - assert_eq!(parse_relationship_kind("acquaintance"), npc::RelationshipKind::Colleague); + assert_eq!( + parse_relationship_kind("acquaintance"), + npc::RelationshipKind::Colleague + ); } #[test] diff --git a/server/src/content/types.rs b/server/src/content/types.rs index 196898d9b..0d67cc99e 100644 --- a/server/src/content/types.rs +++ b/server/src/content/types.rs @@ -491,7 +491,7 @@ pub struct DialogueLine { pub knowledge_grant: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct KnowledgeGrant { pub fact_id: String, pub confidence: String, @@ -523,7 +523,7 @@ pub struct MonologueLine { pub tags: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct Prerequisites { #[serde(default)] pub facts: Vec, @@ -533,20 +533,20 @@ pub struct Prerequisites { pub relationship: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct FactPrerequisite { pub fact_id: String, pub min_confidence: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct AttributePrerequisite { pub entity: String, pub key: String, pub value: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct RelationshipPrerequisite { #[serde(default)] pub target: Option, diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index 440ba6564..e97e9bc58 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -79,8 +79,15 @@ pub fn process_knowledge_events( if let Some(stable_id) = registry.to_stable(target) { observer_kg.observe_entity(stable_id, position, event.tick); } else { - debug_assert!(false, "DirectObservation target {:?} not in EntityRegistry", target); - tracing::error!("DirectObservation target {:?} not in EntityRegistry", target); + debug_assert!( + false, + "DirectObservation target {:?} not in EntityRegistry", + target + ); + tracing::error!( + "DirectObservation target {:?} not in EntityRegistry", + target + ); } } KnowledgeEventType::LeftLOS { target } => { @@ -262,7 +269,11 @@ mod tests { world.insert_resource(thresholds); // Tick 7: not a multiple of 10, decay should NOT run - world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 7; t }); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 7; + t + }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(decay_knowledge); schedule.run(&mut world); @@ -275,7 +286,11 @@ mod tests { ); // Tick 10: multiple of 10, decay SHOULD run (age = 10 > decay_after = 5) - world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 10; t }); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 10; + t + }); let mut schedule2 = bevy_ecs::schedule::Schedule::default(); schedule2.add_systems(decay_knowledge); schedule2.run(&mut world); diff --git a/server/src/knowledge/graph.rs b/server/src/knowledge/graph.rs index b65a21f64..bbdb16c87 100644 --- a/server/src/knowledge/graph.rs +++ b/server/src/knowledge/graph.rs @@ -108,22 +108,20 @@ impl KnowledgeGraph { // --- Write Operations --- /// Record a direct observation of another entity (entity is in LOS). - pub fn observe_entity( - &mut self, - target: StableId, - position: TilePosition, - tick: u64, - ) { - let entry = self.entities.entry(target).or_insert_with(|| EntityKnowledge { - last_known_position: None, - last_observed_tick: 0, - last_updated_tick: 0, - confidence: KnowledgeConfidence::Direct, - source: KnowledgeSource::DirectObservation { tick }, - state: KnowledgeState::Active, - relationship: RelationshipState::Unknown, - known_attributes: BTreeMap::new(), - }); + pub fn observe_entity(&mut self, target: StableId, position: TilePosition, tick: u64) { + let entry = self + .entities + .entry(target) + .or_insert_with(|| EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: 0, + confidence: KnowledgeConfidence::Direct, + source: KnowledgeSource::DirectObservation { tick }, + state: KnowledgeState::Active, + relationship: RelationshipState::Unknown, + known_attributes: BTreeMap::new(), + }); entry.last_known_position = Some(position); entry.last_observed_tick = tick; entry.last_updated_tick = tick; @@ -251,10 +249,7 @@ mod tests { g.observe_entity(target, make_position(5, 10), 100); g.set_relationship(&target, RelationshipState::Hostile); - assert_eq!( - g.relationship_with(&target), - RelationshipState::Hostile - ); + assert_eq!(g.relationship_with(&target), RelationshipState::Hostile); } #[test] @@ -330,10 +325,7 @@ mod tests { }; // Age = 200 - 100 = 100, which is > decay_after (10) g.decay(200, &thresholds); - assert_eq!( - g.confidence_of(&target), - Some(KnowledgeConfidence::KnowsOf) - ); + assert_eq!(g.confidence_of(&target), Some(KnowledgeConfidence::KnowsOf)); } #[test] diff --git a/server/src/knowledge/mod.rs b/server/src/knowledge/mod.rs index 209350220..4683109b3 100644 --- a/server/src/knowledge/mod.rs +++ b/server/src/knowledge/mod.rs @@ -12,9 +12,7 @@ pub mod graph; pub mod registry; pub mod types; -pub use events::{ - KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, -}; +pub use events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType}; pub use graph::KnowledgeGraph; pub use registry::{EntityRegistry, StableEntityId}; pub use types::*; @@ -33,8 +31,7 @@ impl Plugin for KnowledgePlugin { ( events::process_knowledge_events .after(crate::perception::observer::compute_observer_snapshot), - events::decay_knowledge - .after(events::process_knowledge_events), + events::decay_knowledge.after(events::process_knowledge_events), ), ); tracing::debug!("KnowledgePlugin initialized"); diff --git a/server/src/main.rs b/server/src/main.rs index 0301e2f61..750312267 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -13,9 +13,13 @@ use settled_reach_server::npc::{ Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold, Want, WantKind, }; +use settled_reach_server::perception::cognitive_delay::CognitiveDelay; use settled_reach_server::perception::vision_cone::Facing; use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer}; -use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue}; +use settled_reach_server::simulation::listening::ListeningFocus; +use settled_reach_server::simulation::monologue::{ + MonologueBuffer, MonologueState, SprintAnomalyQueue, +}; use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use settled_reach_server::simulation::path_follow::MovementSpeed; use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown}; @@ -77,6 +81,8 @@ fn main() { MonologueState::default(), MonologueBuffer::default(), SprintAnomalyQueue::default(), + CognitiveDelay::default(), + ListeningFocus::new(TilePosition::new(16, 16, 0)), profile, profile.initial_stance(), PlayerMoveCooldown::default(), diff --git a/server/src/npc/relationships.rs b/server/src/npc/relationships.rs index 44334e48c..9d4188598 100644 --- a/server/src/npc/relationships.rs +++ b/server/src/npc/relationships.rs @@ -26,11 +26,8 @@ pub struct RelationshipEdge { /// BTreeMap<(subject, target), edge> for deterministic iteration (D-010). /// Directed graph: edge (A, B) represents how A feels about B. /// -/// TODO(v0.2): This is a global omniscient resource — all entities share one -/// graph. This violates information boundaries (D-009/D-010) because any -/// system can read any relationship. For multiplayer, this needs per-observer -/// projection so each entity only sees relationships they should know about. -/// Acceptable for v0.1 single-player where the server is authoritative. +/// TODO(v0.2): RelationshipGraph is per-world. Multiplayer needs per-observer +/// relationship views (D-010). #[derive(Resource, Debug, Clone, Default, Serialize, Deserialize)] pub struct RelationshipGraph { edges: BTreeMap<(StableId, StableId), RelationshipEdge>, @@ -141,7 +138,11 @@ mod tests { graph.set_relationship(a, StableId(20), make_edge(RelationshipKind::Colleague, 2)); graph.set_relationship(a, StableId(30), make_edge(RelationshipKind::Rival, -3)); // Different subject — should not appear - graph.set_relationship(StableId(2), StableId(10), make_edge(RelationshipKind::Family, 8)); + graph.set_relationship( + StableId(2), + StableId(10), + make_edge(RelationshipKind::Family, 8), + ); let rels = graph.relationships_of(&a); assert_eq!(rels.len(), 3); @@ -158,9 +159,17 @@ mod tests { graph.set_relationship(StableId(1), target, make_edge(RelationshipKind::Friend, 5)); graph.set_relationship(StableId(2), target, make_edge(RelationshipKind::Rival, -2)); - graph.set_relationship(StableId(3), target, make_edge(RelationshipKind::Colleague, 0)); + graph.set_relationship( + StableId(3), + target, + make_edge(RelationshipKind::Colleague, 0), + ); // Edge to different target — should not appear - graph.set_relationship(StableId(1), StableId(99), make_edge(RelationshipKind::Family, 8)); + graph.set_relationship( + StableId(1), + StableId(99), + make_edge(RelationshipKind::Family, 8), + ); let knowers = graph.who_knows(&target); assert_eq!(knowers.len(), 3); @@ -199,9 +208,21 @@ mod tests { fn deterministic_iteration() { let mut graph = RelationshipGraph::new(); // Insert in arbitrary order - graph.set_relationship(StableId(3), StableId(1), make_edge(RelationshipKind::Rival, -1)); - graph.set_relationship(StableId(1), StableId(2), make_edge(RelationshipKind::Friend, 5)); - graph.set_relationship(StableId(2), StableId(3), make_edge(RelationshipKind::Colleague, 0)); + graph.set_relationship( + StableId(3), + StableId(1), + make_edge(RelationshipKind::Rival, -1), + ); + graph.set_relationship( + StableId(1), + StableId(2), + make_edge(RelationshipKind::Friend, 5), + ); + graph.set_relationship( + StableId(2), + StableId(3), + make_edge(RelationshipKind::Colleague, 0), + ); // Iteration order should be deterministic (sorted by (subject, target)) let keys: Vec<_> = graph.edges.keys().collect(); diff --git a/server/src/npc/routine.rs b/server/src/npc/routine.rs index ee157d967..8952c04d9 100644 --- a/server/src/npc/routine.rs +++ b/server/src/npc/routine.rs @@ -55,11 +55,9 @@ pub fn check_phase_transition( for (entity, current_pos, routine) in npcs.iter() { if let Some(expected_location) = routine.expected_location(current_phase) { if *current_pos != expected_location { - commands - .entity(entity) - .insert(PathRequest { - goal: expected_location, - }); + commands.entity(entity).insert(PathRequest { + goal: expected_location, + }); tracing::trace!( "Entity {:?}: routine path request to {:?} for {:?}", entity, @@ -105,8 +103,7 @@ mod tests { .id(); // Advance time to Afternoon boundary - world.resource_mut::().tick = - MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_phase_transition); @@ -163,8 +160,7 @@ mod tests { )) .id(); - world.resource_mut::().tick = - MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_phase_transition); @@ -193,8 +189,7 @@ mod tests { .id(); // Transition to Afternoon, but NPC only has Morning entry - world.resource_mut::().tick = - MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(check_phase_transition); diff --git a/server/src/perception/cognitive_delay.rs b/server/src/perception/cognitive_delay.rs new file mode 100644 index 000000000..1fba0c2ae --- /dev/null +++ b/server/src/perception/cognitive_delay.rs @@ -0,0 +1,523 @@ +//! Cognitive delay system for fog recognition (#423, D-060). +//! +//! Recognition is NOT instant. When the observer first perceives an entity +//! not yet in their knowledge graph, recognition is buffered through a +//! cognitive delay: 0.6s (6 ticks) normal, 0.3s (3 ticks) urgent. +//! +//! Flow: observation.rs detects new entity -> CognitiveDelay buffers -> +//! process_cognitive_delay drains on expiry -> KnowledgeEvent emitted. +//! +//! Client-facing: ObserverSnapshot.pending_recognitions lists entities +//! undergoing recognition (grey blobs with no identity until delay expires). + +use bevy_ecs::prelude::*; + +use crate::knowledge::types::StableId; +use crate::knowledge::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType}; +use crate::simulation::movement::TilePosition; +use crate::simulation::time::SimulationTime; + +/// Base cognitive delay: 0.6 seconds = 6 ticks at 10 tps (D-031, D-060). +/// Tunable: expect playtesting adjustments. +/// +/// Playtesting expectation: 0.6s should feel like a brief "processing" beat — +/// noticeable enough that new entities register as grey blobs before resolving, +/// but short enough to not feel sluggish. If playtesters report recognition +/// feels instant (reduce to test), or laggy (current value may be too high for +/// fast-paced encounters), adjust in 2-tick increments. The 2:1 ratio with +/// URGENT_DELAY_TICKS should be preserved. +pub const NORMAL_DELAY_TICKS: u64 = 6; + +/// Urgent cognitive delay: 0.3 seconds = 3 ticks at 10 tps (D-031, D-060). +/// Triggered when observe_anomaly context is active. +/// +/// Playtesting expectation: 0.3s should feel nearly instant but still register +/// visually as a "snap to attention" moment. If playtesters don't notice the +/// delay at all, consider whether the grey blob phase is too brief to read. +/// Must remain strictly less than NORMAL_DELAY_TICKS. +pub const URGENT_DELAY_TICKS: u64 = 3; + +/// How the recognition was triggered, determines delay duration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecognitionTrigger { + /// Standard perception event. 0.6s = 6 ticks. + Normal, + /// Urgent context (observe_anomaly). 0.3s = 3 ticks. + Urgent, +} + +impl RecognitionTrigger { + /// Delay in simulation ticks for this trigger type. + pub fn delay_ticks(self) -> u64 { + match self { + Self::Normal => NORMAL_DELAY_TICKS, + Self::Urgent => URGENT_DELAY_TICKS, + } + } +} + +/// A single pending recognition in the cognitive delay pipeline. +#[derive(Debug, Clone)] +pub struct PendingRecognition { + /// Bevy entity being recognized. + pub target: Entity, + /// Stable entity ID (for KG and wire format). + pub stable_id: StableId, + /// Position where the entity was perceived. + pub position: TilePosition, + /// Tick when recognition will complete. + pub delay_until_tick: u64, + /// What triggered this recognition. + pub trigger: RecognitionTrigger, +} + +/// Component: cognitive delay buffer for entity recognition (D-060). +/// +/// Attached to entities with a KnowledgeGraph (player character, NPCs in future). +/// Tracks pending recognitions -- entities that have been perceived but not yet +/// identified. Drained by `process_cognitive_delay` each tick. +#[derive(Component, Debug, Default)] +pub struct CognitiveDelay { + pending: Vec, +} + +impl CognitiveDelay { + /// Queue a new pending recognition. + pub fn push(&mut self, recognition: PendingRecognition) { + self.pending.push(recognition); + } + + /// Check if an entity is already pending recognition. + // Note: O(n) scan over pending vec. Fine for v0.1 (typically <10 pending). + // If NPC cognitive delay is added, consider HashSet index. + pub fn is_pending(&self, stable_id: &StableId) -> bool { + self.pending.iter().any(|p| p.stable_id == *stable_id) + } + + /// Cancel a pending recognition (e.g., entity left perception range). + /// Returns the cancelled entry if it existed. + pub fn cancel(&mut self, stable_id: &StableId) -> Option { + if let Some(idx) = self.pending.iter().position(|p| p.stable_id == *stable_id) { + Some(self.pending.swap_remove(idx)) + } else { + None + } + } + + /// Drain all recognitions whose delay has expired (delay_until_tick <= current_tick). + pub fn drain_ready(&mut self, current_tick: u64) -> Vec { + let mut ready = Vec::new(); + self.pending.retain(|p| { + if current_tick >= p.delay_until_tick { + ready.push(p.clone()); + false + } else { + true + } + }); + ready + } + + /// Read-only access to pending recognitions (for snapshot assembly). + pub fn pending(&self) -> &[PendingRecognition] { + &self.pending + } + + /// Number of pending recognitions. + pub fn len(&self) -> usize { + self.pending.len() + } + + /// Whether there are any pending recognitions. + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } +} + +/// System: drain expired cognitive delays and emit KnowledgeEvents (#423, D-060). +/// +/// Each tick, checks CognitiveDelay components for expired recognition timers. +/// Expired recognitions are converted to DirectObservation KnowledgeEvents. +/// +/// System ordering: after emit_observation_events, before process_knowledge_events. +pub fn process_cognitive_delay( + time: Res, + mut query: Query<(Entity, &mut CognitiveDelay)>, + mut event_queue: ResMut, +) { + for (observer, mut delay) in query.iter_mut() { + let ready = delay.drain_ready(time.tick); + for recognition in ready { + event_queue.push(KnowledgeEvent { + observer, + tick: time.tick, + event_type: KnowledgeEventType::DirectObservation { + target: recognition.target, + position: recognition.position, + }, + }); + tracing::debug!( + "Cognitive delay resolved: target={:?}, stable_id={}, trigger={:?}, tick={}", + recognition.target, + recognition.stable_id.0, + recognition.trigger, + time.tick, + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::registry::EntityRegistry; + use crate::knowledge::KnowledgeGraph; + use bevy_ecs::world::World; + + // --- RecognitionTrigger --- + + #[test] + fn trigger_delay_values() { + assert_eq!(RecognitionTrigger::Normal.delay_ticks(), NORMAL_DELAY_TICKS); + assert_eq!(RecognitionTrigger::Urgent.delay_ticks(), URGENT_DELAY_TICKS); + assert_eq!(NORMAL_DELAY_TICKS, 6); + assert_eq!(URGENT_DELAY_TICKS, 3); + } + + // --- CognitiveDelay component --- + + #[test] + fn default_is_empty() { + let delay = CognitiveDelay::default(); + assert!(delay.is_empty()); + assert_eq!(delay.len(), 0); + assert!(delay.pending().is_empty()); + } + + #[test] + fn push_adds_pending() { + let mut world = World::new(); + let target = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + assert_eq!(delay.len(), 1); + assert!(delay.is_pending(&StableId(1))); + assert!(!delay.is_pending(&StableId(2))); + } + + #[test] + fn cancel_removes_and_returns_entry() { + let mut world = World::new(); + let target = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + let cancelled = delay.cancel(&StableId(1)); + assert!(cancelled.is_some()); + assert_eq!(cancelled.unwrap().stable_id, StableId(1)); + assert!(delay.is_empty()); + } + + #[test] + fn cancel_missing_returns_none() { + let mut delay = CognitiveDelay::default(); + assert!(delay.cancel(&StableId(999)).is_none()); + } + + #[test] + fn drain_ready_before_delay() { + let mut world = World::new(); + let target = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + let ready = delay.drain_ready(105); + assert!(ready.is_empty()); + assert_eq!(delay.len(), 1, "entry should remain pending"); + } + + #[test] + fn drain_ready_at_exact_tick() { + let mut world = World::new(); + let target = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + let ready = delay.drain_ready(106); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].stable_id, StableId(1)); + assert!(delay.is_empty()); + } + + #[test] + fn drain_ready_past_tick() { + let mut world = World::new(); + let target = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + let ready = delay.drain_ready(200); + assert_eq!(ready.len(), 1); + assert!(delay.is_empty()); + } + + #[test] + fn drain_ready_partial_mixed_triggers() { + let mut world = World::new(); + let t1 = world.spawn_empty().id(); + let t2 = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target: t1, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 103, // Urgent: 3 ticks from tick 100 + trigger: RecognitionTrigger::Urgent, + }); + delay.push(PendingRecognition { + target: t2, + stable_id: StableId(2), + position: TilePosition::new(10, 10, 0), + delay_until_tick: 106, // Normal: 6 ticks from tick 100 + trigger: RecognitionTrigger::Normal, + }); + + // Tick 103: only urgent should drain + let ready = delay.drain_ready(103); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].stable_id, StableId(1)); + assert_eq!(delay.len(), 1); + + // Tick 106: normal should drain + let ready = delay.drain_ready(106); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].stable_id, StableId(2)); + assert!(delay.is_empty()); + } + + #[test] + fn is_pending_checks_stable_id() { + let mut world = World::new(); + let t1 = world.spawn_empty().id(); + let t2 = world.spawn_empty().id(); + + let mut delay = CognitiveDelay::default(); + delay.push(PendingRecognition { + target: t1, + stable_id: StableId(1), + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + assert!(delay.is_pending(&StableId(1))); + assert!(!delay.is_pending(&StableId(2))); + + delay.push(PendingRecognition { + target: t2, + stable_id: StableId(2), + position: TilePosition::new(10, 10, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + + assert!(delay.is_pending(&StableId(1))); + assert!(delay.is_pending(&StableId(2))); + } + + // --- process_cognitive_delay system --- + + #[test] + fn process_emits_knowledge_event_on_expiry() { + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let target = world.spawn_empty().id(); + let target_sid = registry.register(target); + + let mut cd = CognitiveDelay::default(); + cd.push(PendingRecognition { + target, + stable_id: target_sid, + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + world.spawn((KnowledgeGraph::new(), cd)); + + world.insert_resource(registry); + world.init_resource::(); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 106; + t + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_cognitive_delay); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 1, "should emit one KnowledgeEvent on expiry"); + } + + #[test] + fn process_does_not_emit_before_delay() { + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let target = world.spawn_empty().id(); + let target_sid = registry.register(target); + + let mut cd = CognitiveDelay::default(); + cd.push(PendingRecognition { + target, + stable_id: target_sid, + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + world.spawn((KnowledgeGraph::new(), cd)); + + world.insert_resource(registry); + world.init_resource::(); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 105; + t + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_cognitive_delay); + schedule.run(&mut world); + + let queue = world.resource::(); + assert!(queue.is_empty(), "should not emit before delay expires"); + } + + #[test] + fn process_urgent_resolves_faster() { + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let t1 = world.spawn_empty().id(); + let t1_sid = registry.register(t1); + let t2 = world.spawn_empty().id(); + let t2_sid = registry.register(t2); + + let mut cd = CognitiveDelay::default(); + cd.push(PendingRecognition { + target: t1, + stable_id: t1_sid, + position: TilePosition::new(5, 5, 0), + delay_until_tick: 103, // Urgent + trigger: RecognitionTrigger::Urgent, + }); + cd.push(PendingRecognition { + target: t2, + stable_id: t2_sid, + position: TilePosition::new(10, 10, 0), + delay_until_tick: 106, // Normal + trigger: RecognitionTrigger::Normal, + }); + world.spawn((KnowledgeGraph::new(), cd)); + + world.insert_resource(registry); + world.init_resource::(); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 103; + t + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_cognitive_delay); + schedule.run(&mut world); + + let queue = world.resource::(); + assert_eq!(queue.len(), 1, "only urgent should resolve at tick 103"); + } + + #[test] + fn process_no_crash_without_pending() { + let mut world = World::new(); + world.spawn((KnowledgeGraph::new(), CognitiveDelay::default())); + world.init_resource::(); + world.init_resource::(); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_cognitive_delay); + schedule.run(&mut world); // Should not panic + } + + #[test] + fn process_clears_pending_after_drain() { + let mut world = World::new(); + let mut registry = EntityRegistry::new(0); + + let target = world.spawn_empty().id(); + let target_sid = registry.register(target); + + let mut cd = CognitiveDelay::default(); + cd.push(PendingRecognition { + target, + stable_id: target_sid, + position: TilePosition::new(5, 5, 0), + delay_until_tick: 106, + trigger: RecognitionTrigger::Normal, + }); + let observer = world.spawn((KnowledgeGraph::new(), cd)).id(); + + world.insert_resource(registry); + world.init_resource::(); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 106; + t + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_cognitive_delay); + schedule.run(&mut world); + + let cd = world.entity(observer).get::().unwrap(); + assert!(cd.is_empty(), "pending should be cleared after drain"); + } +} diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 100be305b..95fd52300 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -210,14 +210,12 @@ mod tests { let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(( compute_visibility_geometry, - compute_observer_snapshot - .after(compute_visibility_geometry), + compute_observer_snapshot.after(compute_visibility_geometry), crate::perception::observation::emit_observation_events .after(compute_observer_snapshot), generate_observation_events .after(crate::perception::observation::emit_observation_events), - crate::knowledge::events::process_knowledge_events - .after(generate_observation_events), + crate::knowledge::events::process_knowledge_events.after(generate_observation_events), )); schedule.run(world); } @@ -228,8 +226,7 @@ mod tests { let mut registry = EntityRegistry::new(0); // Set time to Afternoon - world.resource_mut::().tick = - MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; + world.resource_mut::().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE; let player = world .spawn(( @@ -381,7 +378,11 @@ mod tests { ) }) .collect(); - assert_eq!(absences.len(), 1, "should detect absence at visible location"); + assert_eq!( + absences.len(), + 1, + "should detect absence at visible location" + ); } #[test] @@ -401,9 +402,7 @@ mod tests { .id(); registry.register(player); - let npc = world - .spawn((Npc, TilePosition::new(16, 14, 0))) - .id(); + let npc = world.spawn((Npc, TilePosition::new(16, 14, 0))).id(); let npc_sid = registry.register(npc); world.insert_resource(registry); @@ -428,9 +427,7 @@ mod tests { let mut world = setup_world(); let mut registry = EntityRegistry::new(0); - let npc = world - .spawn((Npc, TilePosition::new(16, 14, 0))) - .id(); + let npc = world.spawn((Npc, TilePosition::new(16, 14, 0))).id(); let npc_sid = registry.register(npc); // Player already knows about the NPC diff --git a/server/src/perception/mod.rs b/server/src/perception/mod.rs index 472b86ae1..b182770f3 100644 --- a/server/src/perception/mod.rs +++ b/server/src/perception/mod.rs @@ -5,6 +5,7 @@ use bevy_app::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; +pub mod cognitive_delay; pub mod interpretation; pub mod observation; pub mod observer; @@ -23,9 +24,14 @@ impl Plugin for PerceptionPlugin { .init_resource::() .add_systems( Update, - interpretation::generate_observation_events - .after(observation::emit_observation_events) - .before(crate::knowledge::events::process_knowledge_events), + ( + cognitive_delay::process_cognitive_delay + .after(observation::emit_observation_events) + .before(crate::knowledge::events::process_knowledge_events), + interpretation::generate_observation_events + .after(observation::emit_observation_events) + .before(crate::knowledge::events::process_knowledge_events), + ), ); tracing::debug!("PerceptionPlugin initialized"); } diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index 35a480100..5f29c3394 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -9,6 +9,7 @@ use crate::bridge::types::*; use crate::knowledge::{ EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph, }; +use crate::perception::cognitive_delay::{CognitiveDelay, PendingRecognition, RecognitionTrigger}; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::time::SimulationTime; @@ -21,7 +22,10 @@ pub fn emit_observation_events( time: Res, buffer: Res, registry: Res, - observer_query: Query<(Entity, &KnowledgeGraph), With>, + mut observer_query: Query< + (Entity, &KnowledgeGraph, Option<&mut CognitiveDelay>), + With, + >, mut event_queue: ResMut, entity_positions: Query<&TilePosition>, ) { @@ -29,7 +33,8 @@ pub fn emit_observation_events( return; }; - let Ok((observer_entity, observer_kg)) = observer_query.single() else { + let Ok((observer_entity, observer_kg, mut cognitive_delay)) = observer_query.single_mut() + else { return; }; @@ -41,7 +46,9 @@ pub fn emit_observation_events( .map(|e| e.entity_id) .collect(); - // Emit DirectObservation for each visible non-player entity + // Emit DirectObservation for each visible non-player entity. + // NEW entities (not in observer's KG) are routed through cognitive delay + // when the CognitiveDelay component is present (#423, D-060). for visible in &snapshot.entities { if matches!(visible.kind, EntityKind::Player) { continue; @@ -54,7 +61,43 @@ pub fn emit_observation_events( }; // Get tile position for knowledge tracking - if let Ok(pos) = entity_positions.get(entity) { + let Ok(pos) = entity_positions.get(entity) else { + continue; + }; + + if observer_kg.knows_entity(&stable_id) { + // Known entity: immediate DirectObservation (position update) + event_queue.push(KnowledgeEvent { + observer: observer_entity, + tick: time.tick, + event_type: KnowledgeEventType::DirectObservation { + target: entity, + position: *pos, + }, + }); + } else if let Some(ref mut delay) = cognitive_delay { + // New entity + cognitive delay available: buffer recognition + if !delay.is_pending(&stable_id) { + // TODO(#450): wire RecognitionTrigger::Urgent for observe_anomaly triggers + let trigger = RecognitionTrigger::Normal; + delay.push(PendingRecognition { + target: entity, + stable_id, + position: *pos, + delay_until_tick: time.tick + trigger.delay_ticks(), + trigger, + }); + tracing::debug!( + "Cognitive delay queued: stable_id={}, position=({},{},{}), delay_until={}", + stable_id.0, + pos.x, + pos.y, + pos.z, + time.tick + trigger.delay_ticks(), + ); + } + } else { + // No CognitiveDelay component: immediate (backward compat) event_queue.push(KnowledgeEvent { observer: observer_entity, tick: time.tick, @@ -83,6 +126,21 @@ pub fn emit_observation_events( } } } + + // Cancel pending cognitive delays for entities that left perception range. + // Without this, a delay could expire and emit a DirectObservation for an + // entity that is no longer visible (stale position, 1-tick flash). + if let Some(ref mut delay) = cognitive_delay { + let pending_ids: Vec = + delay.pending().iter().map(|p| p.stable_id).collect(); + for sid in pending_ids { + if !visible_stable_ids.contains(&sid.0) { + if delay.cancel(&sid).is_some() { + tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0); + } + } + } + } } #[cfg(test)] @@ -124,9 +182,7 @@ mod tests { .id(); registry.register(player); - let npc = world - .spawn((Npc, TilePosition::new(16, 14, 0))) - .id(); + let npc = world.spawn((Npc, TilePosition::new(16, 14, 0))).id(); registry.register(npc); world.insert_resource(registry); @@ -152,9 +208,7 @@ mod tests { let mut world = setup_world(32, 32); let mut registry = EntityRegistry::new(0); - let npc = world - .spawn((Npc, TilePosition::new(16, 14, 0))) - .id(); + let npc = world.spawn((Npc, TilePosition::new(16, 14, 0))).id(); let npc_sid = registry.register(npc); // Player with pre-existing Direct knowledge of NPC @@ -195,7 +249,10 @@ mod tests { KnowledgeEventType::LeftLOS { target } if target == npc ) }); - assert!(has_left_los, "should emit LeftLOS when NPC is no longer visible"); + assert!( + has_left_los, + "should emit LeftLOS when NPC is no longer visible" + ); } #[test] diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index cd80ec780..e39429eb2 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -12,6 +12,7 @@ use std::collections::HashSet; use crate::bridge::types::*; use crate::knowledge::types::KnowledgeState; use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId}; +use crate::perception::cognitive_delay::CognitiveDelay; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::interaction::NearbyInteractionBuffer; @@ -53,7 +54,18 @@ pub fn compute_observer_snapshot( geometry: Res, registry: Res, mut observer_query: Query< - (Entity, &TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>, Option<&mut SprintAnomalyQueue>), + ( + Entity, + &TilePosition, + Option<&Facing>, + &KnowledgeGraph, + &mut NearbyInteractionBuffer, + &mut MonologueBuffer, + Option<&Stance>, + Option<&CharacterArchetype>, + Option<&mut SprintAnomalyQueue>, + Option<&CognitiveDelay>, + ), With, >, all_entities: Query<( @@ -65,8 +77,18 @@ pub fn compute_observer_snapshot( inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, mut buffer: ResMut, ) { - let Ok((observer_entity, _observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt, mut anomaly_queue_opt)) = - observer_query.single_mut() + let Ok(( + observer_entity, + _observer_pos, + facing_opt, + observer_kg, + mut interaction_buffer, + mut monologue_buffer, + stance_opt, + archetype_opt, + mut anomaly_queue_opt, + cognitive_delay_opt, + )) = observer_query.single_mut() else { return; }; @@ -141,6 +163,27 @@ pub fn compute_observer_snapshot( let current_monologue = monologue_buffer.take(); + // Build pending recognitions from CognitiveDelay (#423, D-060) + let pending_recognitions = cognitive_delay_opt + .map(|delay| { + delay + .pending() + .iter() + .map(|p| { + let (rx, ry, rz) = p.position.to_render_coords(); + PendingRecognitionWire { + entity_id: p.stable_id.0, + x: rx, + y: ry, + z: rz, + remaining_ticks: p.delay_until_tick.saturating_sub(time.tick), + total_delay_ticks: p.trigger.delay_ticks(), + } + }) + .collect() + }) + .unwrap_or_default(); + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -152,6 +195,7 @@ pub fn compute_observer_snapshot( visible_tiles: geometry.visible_tiles.clone(), nearby_interactions, current_monologue, + pending_recognitions, }); } @@ -360,13 +404,16 @@ fn apply_phase2_verb_filter( // This implements D-057: "Character differentiation via Phase 2 observer // filter, not separate verb systems." for verb in &mut interaction.verbs { - if let Some(label) = archetype_verb_label(archetype, interaction.object_type, verb.kind) { + if let Some(label) = archetype_verb_label(archetype, interaction.object_type, verb.kind) + { verb.label = label.into(); } } // Re-sort after priority changes and verb additions - interaction.verbs.sort_by_key(|v| (v.priority, v.kind as u8)); + interaction + .verbs + .sort_by_key(|v| (v.priority, v.kind as u8)); } } @@ -385,11 +432,17 @@ fn archetype_verb_label( match (archetype, object_type, kind) { // Smuggler: Container verbs — physical manipulation vocabulary (CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Open) => Some("Move"), - (CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => Some("Stash"), + (CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => { + Some("Stash") + } // Detective: Container verbs — investigation vocabulary - (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => Some("Scan"), - (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => Some("Flag"), + (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => { + Some("Scan") + } + (CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => { + Some("Flag") + } // All other combinations: keep Phase 1 default label _ => None, diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index a84f1b351..4e594377e 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -57,7 +57,7 @@ fn player_always_visible_in_snapshot() { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 6); + assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!(snapshot.entities.len(), 1); assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); @@ -198,7 +198,10 @@ fn game_time_populated() { snapshot.game_time.day_phase, crate::simulation::time::DayPhase::Evening ); - assert_eq!(snapshot.game_time.tick_rate, crate::simulation::time::TickRate::Paused); + assert_eq!( + snapshot.game_time.tick_rate, + crate::simulation::time::TickRate::Paused + ); } #[test] @@ -298,7 +301,11 @@ fn remembered_entity_appears_as_ghost() { .id(); registry.register(player); world.insert_resource(registry); - world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t }); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 100; + t + }); run_observer_pipeline(&mut world); @@ -312,13 +319,20 @@ fn remembered_entity_appears_as_ghost() { .filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. })) .collect(); assert_eq!(remembered.len(), 1, "should have one remembered entity"); - assert_eq!(remembered[0].relationship, RelationshipState::PersonOfInterest); + assert_eq!( + remembered[0].relationship, + RelationshipState::PersonOfInterest + ); // Remembered entity at last_known_position (16, 28), not actual (16, 30) assert_eq!(remembered[0].x, 16.5); assert_eq!(remembered[0].y, 28.5); - if let EntityVisibility::Remembered { confidence, age_ticks } = &remembered[0].observation { + if let EntityVisibility::Remembered { + confidence, + age_ticks, + } = &remembered[0].observation + { assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails); assert_eq!(*age_ticks, 50); // tick 100 - last_observed 50 } @@ -479,16 +493,19 @@ fn knowledge_without_position_not_shown() { // Player knows about NPC but has never seen it (no position) let mut kg = KnowledgeGraph::new(); // Insert knowledge manually without a position - kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { - last_known_position: None, - last_observed_tick: 0, - last_updated_tick: 50, - confidence: KnowledgeConfidence::KnowsOf, - source: crate::knowledge::KnowledgeSource::Background, - state: crate::knowledge::KnowledgeState::Active, - relationship: RelationshipState::PersonOfInterest, - known_attributes: std::collections::BTreeMap::new(), - }); + kg.entities.insert( + npc_sid, + crate::knowledge::EntityKnowledge { + last_known_position: None, + last_observed_tick: 0, + last_updated_tick: 50, + confidence: KnowledgeConfidence::KnowsOf, + source: crate::knowledge::KnowledgeSource::Background, + state: crate::knowledge::KnowledgeState::Active, + relationship: RelationshipState::PersonOfInterest, + known_attributes: std::collections::BTreeMap::new(), + }, + ); let player = world .spawn(( @@ -547,7 +564,9 @@ fn multiple_npcs_in_los_all_visible() { .filter(|e| matches!(e.kind, EntityKind::Npc)) .collect(); assert_eq!(npcs.len(), 3); - assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible)); + assert!(npcs + .iter() + .all(|n| n.observation == EntityVisibility::Visible)); } #[test] @@ -657,9 +676,19 @@ fn snapshot_v6_fields_default_through_pipeline() { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 6, "should be protocol v6"); - assert_eq!(snapshot.player_stance, MovementStance::Walk, "default stance is Walk"); - assert!(snapshot.player_inventory.is_empty(), "default inventory is empty"); + assert_eq!( + snapshot.version, PROTOCOL_VERSION, + "should be current protocol version" + ); + assert_eq!( + snapshot.player_stance, + MovementStance::Walk, + "default stance is Walk" + ); + assert!( + snapshot.player_inventory.is_empty(), + "default inventory is empty" + ); } #[test] @@ -731,8 +760,14 @@ fn phase2_confront_injected_for_npc_with_knows_details() { let interaction = &snapshot.nearby_interactions[0]; // Should have Talk, ExamineNpc, AND Confront (Phase 2 injected) assert_eq!(interaction.verbs.len(), 3); - let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); - assert!(confront.is_some(), "Confront should be injected for KnowsDetails+"); + let confront = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Confront); + assert!( + confront.is_some(), + "Confront should be injected for KnowsDetails+" + ); assert_eq!(confront.unwrap().priority, 3); assert_eq!(confront.unwrap().label, "Confront"); } @@ -753,16 +788,19 @@ fn phase2_no_confront_without_knows_details() { // Player only Suspects this NPC (below KnowsDetails threshold) let mut kg = KnowledgeGraph::new(); - kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { - last_known_position: Some(TilePosition::new(16, 15, 0)), - last_observed_tick: 50, - last_updated_tick: 50, - confidence: KnowledgeConfidence::Suspects, - source: crate::knowledge::KnowledgeSource::Background, - state: KnowledgeState::Active, - relationship: RelationshipState::Unknown, - known_attributes: std::collections::BTreeMap::new(), - }); + kg.entities.insert( + npc_sid, + crate::knowledge::EntityKnowledge { + last_known_position: Some(TilePosition::new(16, 15, 0)), + last_observed_tick: 50, + last_updated_tick: 50, + confidence: KnowledgeConfidence::Suspects, + source: crate::knowledge::KnowledgeSource::Background, + state: KnowledgeState::Active, + relationship: RelationshipState::Unknown, + known_attributes: std::collections::BTreeMap::new(), + }, + ); let player = world .spawn(( @@ -783,8 +821,14 @@ fn phase2_no_confront_without_knows_details() { let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); let interaction = &snapshot.nearby_interactions[0]; - let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); - assert!(confront.is_none(), "Confront should NOT appear for Suspects confidence"); + let confront = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Confront); + assert!( + confront.is_none(), + "Confront should NOT appear for Suspects confidence" + ); } #[test] @@ -827,7 +871,10 @@ fn phase2_no_confront_at_mid_range() { assert_eq!(snapshot.nearby_interactions.len(), 1); let interaction = &snapshot.nearby_interactions[0]; // Mid range: only ExamineNpc, no Talk, no Confront - let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); + let confront = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Confront); assert!(confront.is_none(), "Confront requires close range"); } @@ -954,8 +1001,14 @@ fn phase2_smuggler_relabels_container_verbs() { let interaction = &snapshot.nearby_interactions[0]; // Container at close range: Open→"Move", Search→"Stash", Observe stays "Observe" let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); - let search_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Search); - let observe_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Observe); + let search_verb = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Search); + let observe_verb = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Observe); assert_eq!(open_verb.unwrap().label, "Move", "smuggler Open→Move"); assert_eq!(search_verb.unwrap().label, "Stash", "smuggler Search→Stash"); assert_eq!(observe_verb.unwrap().label, "Observe", "Observe unchanged"); @@ -997,7 +1050,10 @@ fn phase2_detective_relabels_container_verbs() { assert_eq!(snapshot.nearby_interactions.len(), 1); let interaction = &snapshot.nearby_interactions[0]; let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); - let search_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Search); + let search_verb = interaction + .verbs + .iter() + .find(|v| v.kind == VerbKind::Search); assert_eq!(open_verb.unwrap().label, "Scan", "detective Open→Scan"); assert_eq!(search_verb.unwrap().label, "Flag", "detective Search→Flag"); } @@ -1039,7 +1095,11 @@ fn phase2_default_archetype_is_detective() { let interaction = &snapshot.nearby_interactions[0]; // Default = Detective labels let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); - assert_eq!(open_verb.unwrap().label, "Scan", "default archetype should use Detective labels"); + assert_eq!( + open_verb.unwrap().label, + "Scan", + "default archetype should use Detective labels" + ); } #[test] @@ -1078,7 +1138,11 @@ fn phase2_non_container_keeps_default_labels() { assert_eq!(snapshot.nearby_interactions.len(), 1); let interaction = &snapshot.nearby_interactions[0]; let read_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Read); - assert_eq!(read_verb.unwrap().label, "Read", "Readable labels unchanged for smuggler"); + assert_eq!( + read_verb.unwrap().label, + "Read", + "Readable labels unchanged for smuggler" + ); } #[test] @@ -1155,8 +1219,7 @@ fn phase2_npc_object_type_is_none() { let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); assert_eq!( - snapshot.nearby_interactions[0].object_type, - None, + snapshot.nearby_interactions[0].object_type, None, "NPC should have object_type=None" ); } @@ -1256,7 +1319,11 @@ fn phase2_poi_with_confront_verb_order() { let snapshot = buffer.snapshot.as_ref().unwrap(); assert_eq!(snapshot.nearby_interactions.len(), 1); let verbs = &snapshot.nearby_interactions[0].verbs; - assert_eq!(verbs.len(), 3, "POI+KnowsDetails: ExamineNpc + Talk + Confront"); + assert_eq!( + verbs.len(), + 3, + "POI+KnowsDetails: ExamineNpc + Talk + Confront" + ); // POI flips ExamineNpc to priority 1, Talk to 2, Confront at 3 assert_eq!(verbs[0].kind, VerbKind::ExamineNpc); assert_eq!(verbs[0].priority, 1); @@ -1306,7 +1373,11 @@ fn carried_item_appears_in_snapshot_inventory() { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().unwrap(); - assert_eq!(snapshot.player_inventory.len(), 1, "carried item should appear in snapshot"); + assert_eq!( + snapshot.player_inventory.len(), + 1, + "carried item should appear in snapshot" + ); assert_eq!(snapshot.player_inventory[0].name, "Manifest Copy"); assert_eq!(snapshot.player_inventory[0].slot, 0); } @@ -1380,7 +1451,11 @@ fn multiple_carried_items_sorted_in_snapshot() { let player_sid = registry.register(player); // Spawn 3 v0.1 items in reverse slot order - for (slot, name) in [(2, "Personal Comm Log"), (0, "Manifest Copy"), (1, "Corridor Access Token")] { + for (slot, name) in [ + (2, "Personal Comm Log"), + (0, "Manifest Copy"), + (1, "Corridor Access Token"), + ] { let item = world .spawn(( CarriedBy(player_sid), @@ -1449,7 +1524,10 @@ fn sprint_past_contradicted_npc_queues_anomaly() { // Anomaly should be queued let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); - assert!(queue.has_pending(), "contradicted NPC while sprinting should queue anomaly"); + assert!( + queue.has_pending(), + "contradicted NPC while sprinting should queue anomaly" + ); } #[test] @@ -1488,7 +1566,10 @@ fn walk_past_contradicted_npc_does_not_queue_anomaly() { let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); - assert!(!queue.has_pending(), "walking past contradicted NPC should NOT queue anomaly"); + assert!( + !queue.has_pending(), + "walking past contradicted NPC should NOT queue anomaly" + ); } #[test] @@ -1527,7 +1608,10 @@ fn sprint_past_active_npc_does_not_queue_anomaly() { let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); - assert!(!queue.has_pending(), "sprint past Active NPC should NOT queue anomaly"); + assert!( + !queue.has_pending(), + "sprint past Active NPC should NOT queue anomaly" + ); } #[test] @@ -1612,7 +1696,10 @@ fn sprint_anomaly_without_queue_component_no_crash() { run_observer_pipeline(&mut world); let buffer = world.resource::(); - assert!(buffer.snapshot.is_some(), "snapshot should still be produced"); + assert!( + buffer.snapshot.is_some(), + "snapshot should still be produced" + ); } #[test] @@ -1667,12 +1754,18 @@ fn sprint_anomaly_npc_visible_but_interactions_suppressed() { assert_eq!(npcs.len(), 1, "NPC should be visible during sprint"); // Interactions should be empty (sprint suppression) - assert!(snapshot.nearby_interactions.is_empty(), "sprint suppresses interactions"); + assert!( + snapshot.nearby_interactions.is_empty(), + "sprint suppresses interactions" + ); // Anomaly should be queued let mut query = world.query::<&SprintAnomalyQueue>(); let queue = query.single(&world).unwrap(); - assert!(queue.has_pending(), "anomaly should be queued despite interaction suppression"); + assert!( + queue.has_pending(), + "anomaly should be queued despite interaction suppression" + ); } #[test] @@ -1724,3 +1817,101 @@ fn sprint_anomaly_multiple_contradicted_npcs_only_first_queued() { let queue = query.single(&world).unwrap(); assert!(queue.has_pending(), "one anomaly should be queued"); } + +// ----------------------------------------------------------------------- +// Pending recognitions in observer snapshot (#423, D-060) +// ----------------------------------------------------------------------- + +#[test] +fn pending_recognitions_appear_in_snapshot() { + // H11: When a player entity has a CognitiveDelay component with pending + // recognitions, compute_observer_snapshot should include them in + // pending_recognitions for the client to render as grey blobs. + use crate::perception::cognitive_delay::{ + CognitiveDelay, PendingRecognition, RecognitionTrigger, + }; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // Target entity that is being "recognized" + let target = world.spawn_empty().id(); + let target_sid = registry.register(target); + + // Player with CognitiveDelay containing a pending recognition + let mut cd = CognitiveDelay::default(); + cd.push(PendingRecognition { + target, + stable_id: target_sid, + position: TilePosition::new(16, 14, 0), + delay_until_tick: 110, // will complete at tick 110 + trigger: RecognitionTrigger::Normal, + }); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + cd, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + world.insert_resource({ + let mut t = SimulationTime::default(); + t.tick = 106; // 4 ticks remaining until recognition + t + }); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); + + assert_eq!( + snapshot.pending_recognitions.len(), + 1, + "should have one pending recognition in snapshot" + ); + let pending = &snapshot.pending_recognitions[0]; + assert_eq!(pending.entity_id, target_sid.0); + assert_eq!(pending.remaining_ticks, 4, "110 - 106 = 4 remaining"); + assert_eq!( + pending.total_delay_ticks, + crate::perception::cognitive_delay::NORMAL_DELAY_TICKS, + "total delay should match Normal trigger" + ); + // Position should be render coords of (16, 14, 0) + let (expected_x, expected_y, expected_z) = TilePosition::new(16, 14, 0).to_render_coords(); + assert_eq!(pending.x, expected_x); + assert_eq!(pending.y, expected_y); + assert_eq!(pending.z, expected_z); +} + +#[test] +fn no_cognitive_delay_component_means_empty_pending_recognitions() { + // H11 complement: player WITHOUT CognitiveDelay should produce + // an empty pending_recognitions vec (backward compatibility). + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert!( + snapshot.pending_recognitions.is_empty(), + "no CognitiveDelay component should produce empty pending_recognitions" + ); +} diff --git a/server/src/perception/query.rs b/server/src/perception/query.rs index 92114bd0e..078253dc8 100644 --- a/server/src/perception/query.rs +++ b/server/src/perception/query.rs @@ -62,8 +62,7 @@ impl PerceptionQuery for NaturalVision { z, ); - let cone_tiles = - apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config); + let cone_tiles = apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config); let visible_tiles = cone_tiles .iter() diff --git a/server/src/perception/shadowcast.rs b/server/src/perception/shadowcast.rs index 7da3779bf..e89659e05 100644 --- a/server/src/perception/shadowcast.rs +++ b/server/src/perception/shadowcast.rs @@ -101,7 +101,12 @@ pub fn symmetric_shadowcast( visible.insert((origin_x, origin_y)); // Origin is always visible // Process 4 cardinal quadrants - for &cardinal in &[Cardinal::North, Cardinal::East, Cardinal::South, Cardinal::West] { + for &cardinal in &[ + Cardinal::North, + Cardinal::East, + Cardinal::South, + Cardinal::West, + ] { scan_quadrant(&mut visible, is_opaque, origin_x, origin_y, range, cardinal); } diff --git a/server/src/perception/vision_cone.rs b/server/src/perception/vision_cone.rs index 982709a39..283b9a5ac 100644 --- a/server/src/perception/vision_cone.rs +++ b/server/src/perception/vision_cone.rs @@ -138,8 +138,7 @@ pub fn apply_vision_cone( ) -> Vec<(i32, i32, VisibilitySector)> { fov.visible_tiles() .filter_map(|(x, y)| { - classify_tile(observer_x, observer_y, x, y, facing, config) - .map(|sector| (x, y, sector)) + classify_tile(observer_x, observer_y, x, y, facing, config).map(|sector| (x, y, sector)) }) .collect() } diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index b3a9fee05..fb8a34fac 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -14,6 +14,10 @@ use crate::simulation::time::{SimulationTime, TickRate}; use bevy_ecs::prelude::*; use std::collections::VecDeque; +/// Maximum number of inputs the queue will hold before dropping oldest. +/// Prevents unbounded memory growth from input flooding. +pub const INPUT_QUEUE_CAPACITY: usize = 1000; + /// Queue of pending player inputs, ordered by tick #[derive(Resource, Debug, Default)] pub struct InputQueue { @@ -24,6 +28,7 @@ impl InputQueue { /// Add a new input to the queue. /// Inputs must be pushed in tick order for deterministic processing. /// Panics in debug builds if tick ordering is violated. + /// Drops oldest inputs when capacity is exceeded. pub fn push(&mut self, input: PlayerInput) { debug_assert!( self.queue.back().is_none_or(|last| last.tick <= input.tick), @@ -31,6 +36,14 @@ impl InputQueue { self.queue.back().map_or(0, |last| last.tick), input.tick, ); + if self.queue.len() >= INPUT_QUEUE_CAPACITY { + let dropped = self.queue.pop_front(); + tracing::warn!( + "InputQueue at capacity ({}), dropping oldest input (tick={})", + INPUT_QUEUE_CAPACITY, + dropped.map_or(0, |d| d.tick), + ); + } self.queue.push_back(input); } @@ -68,7 +81,12 @@ pub fn process_player_input( mut commands: Commands, registry: Res, mut player_query: Query< - (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), + ( + Entity, + &TilePosition, + Option<&mut Stance>, + Option<&mut PlayerMoveCooldown>, + ), With, >, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, @@ -143,34 +161,30 @@ pub fn process_player_input( time.tick_rate = rate; tracing::debug!("Tick rate set to {:?} by player input", rate); } - PlayerAction::Interact { target_entity_id, ref verb } => { - match verb.as_deref() { - Some("Take") => { - handle_take( - &mut commands, - ®istry, - &player_query, - &inventory_items, - target_entity_id, - ); - } - Some("Place") => { - handle_place( - &mut commands, - ®istry, - &player_query, - target_entity_id, - ); - } - _ => { - tracing::info!( + PlayerAction::Interact { + target_entity_id, + ref verb, + } => match verb.as_deref() { + Some("Take") => { + handle_take( + &mut commands, + ®istry, + &player_query, + &inventory_items, + target_entity_id, + ); + } + Some("Place") => { + handle_place(&mut commands, ®istry, &player_query, target_entity_id); + } + _ => { + tracing::info!( "Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)", target_entity_id, verb, ); - } } - } + }, PlayerAction::UsePerceptionMode(ref mode) => { tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode); } @@ -192,7 +206,12 @@ pub fn process_player_input( #[allow(clippy::type_complexity)] fn apply_move( player_query: &mut Query< - (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), + ( + Entity, + &TilePosition, + Option<&mut Stance>, + Option<&mut PlayerMoveCooldown>, + ), With, >, commands: &mut Commands, @@ -230,7 +249,12 @@ fn handle_take( commands: &mut Commands, registry: &EntityRegistry, player_query: &Query< - (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), + ( + Entity, + &TilePosition, + Option<&mut Stance>, + Option<&mut PlayerMoveCooldown>, + ), With, >, inventory_items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, @@ -260,12 +284,16 @@ fn handle_take( // Check inventory capacity let occupied = occupied_slots_for(player_sid, inventory_items); let Some(slot) = find_next_slot(&occupied) else { - tracing::info!("Inventory full ({} slots), cannot take item", MAX_INVENTORY_SLOTS); + tracing::info!( + "Inventory full ({} slots), cannot take item", + MAX_INVENTORY_SLOTS + ); return; }; // Remove TilePosition (item leaves the ground), add CarriedBy + InventorySlot - commands.entity(target_entity) + commands + .entity(target_entity) .remove::() .insert((CarriedBy(player_sid), InventorySlot(slot))); @@ -284,7 +312,12 @@ fn handle_place( commands: &mut Commands, registry: &EntityRegistry, player_query: &Query< - (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), + ( + Entity, + &TilePosition, + Option<&mut Stance>, + Option<&mut PlayerMoveCooldown>, + ), With, >, target_entity_id: Option, @@ -307,7 +340,8 @@ fn handle_place( let place_pos = *player_pos; // Remove inventory components, place item at player's tile - commands.entity(target_entity) + commands + .entity(target_entity) .remove::() .remove::() .insert(place_pos); @@ -338,7 +372,10 @@ mod tests { }); queue.push(PlayerInput { tick: 5, - action: PlayerAction::Interact { target_entity_id: None, verb: None }, + action: PlayerAction::Interact { + target_entity_id: None, + verb: None, + }, }); let inputs = queue.drain_for_tick(3); assert_eq!(inputs.len(), 2); @@ -406,7 +443,10 @@ mod tests { schedule.add_systems(process_player_input); schedule.run(&mut world); - assert_eq!(world.resource::().tick_rate, TickRate::Paused); + assert_eq!( + world.resource::().tick_rate, + TickRate::Paused + ); } #[test] @@ -553,7 +593,10 @@ mod tests { action: PlayerAction::MoveNorth, }); schedule.run(&mut world); - assert!(world.get::(player).is_some(), "first move should succeed"); + assert!( + world.get::(player).is_some(), + "first move should succeed" + ); // Remove MoveIntent (simulating validate_movement consuming it) world.entity_mut(player).remove::(); @@ -564,7 +607,10 @@ mod tests { action: PlayerAction::MoveNorth, }); schedule.run(&mut world); - assert!(world.get::(player).is_none(), "second move should be throttled"); + assert!( + world.get::(player).is_none(), + "second move should be throttled" + ); // Tick 0 again: move north — should succeed (cooldown elapsed) world.resource_mut::().push(PlayerInput { @@ -572,7 +618,10 @@ mod tests { action: PlayerAction::MoveNorth, }); schedule.run(&mut world); - assert!(world.get::(player).is_some(), "third move should succeed after cooldown"); + assert!( + world.get::(player).is_some(), + "third move should succeed after cooldown" + ); } #[test] @@ -609,7 +658,10 @@ mod tests { action: PlayerAction::MoveNorth, }); schedule.run(&mut world); - assert!(world.get::(player).is_some(), "sprint should allow every tick"); + assert!( + world.get::(player).is_some(), + "sprint should allow every tick" + ); } #[test] @@ -655,16 +707,17 @@ mod tests { let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); - let player_sid = world.resource_mut::().register(player); + let player_sid = world + .resource_mut::() + .register(player); // Spawn item near player let item = world - .spawn(( - TilePosition::new(5, 4, 0), - ItemName("Manifest Copy".into()), - )) + .spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into()))) .id(); - let item_sid = world.resource_mut::().register(item); + let item_sid = world + .resource_mut::() + .register(item); // Issue Take verb world.resource_mut::().push(PlayerInput { @@ -680,10 +733,17 @@ mod tests { schedule.run(&mut world); // Item should have CarriedBy + InventorySlot, no TilePosition - assert!(world.get::(item).is_none(), "item should leave the ground"); - let carried = world.get::(item).expect("item should have CarriedBy"); + assert!( + world.get::(item).is_none(), + "item should leave the ground" + ); + let carried = world + .get::(item) + .expect("item should have CarriedBy"); assert_eq!(carried.0, player_sid); - let slot = world.get::(item).expect("item should have slot"); + let slot = world + .get::(item) + .expect("item should have slot"); assert_eq!(slot.0, 0, "first item goes to slot 0"); } @@ -697,7 +757,9 @@ mod tests { let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); - let player_sid = world.resource_mut::().register(player); + let player_sid = world + .resource_mut::() + .register(player); // Spawn item already in inventory (no TilePosition) let item = world @@ -707,7 +769,9 @@ mod tests { InventorySlot(0), )) .id(); - let item_sid = world.resource_mut::().register(item); + let item_sid = world + .resource_mut::() + .register(item); // Issue Place verb world.resource_mut::().push(PlayerInput { @@ -723,10 +787,19 @@ mod tests { schedule.run(&mut world); // Item should have TilePosition at player's location, no CarriedBy/InventorySlot - let pos = world.get::(item).expect("item should be on ground"); - assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position"); + let pos = world + .get::(item) + .expect("item should be on ground"); + assert_eq!( + *pos, + TilePosition::new(5, 5, 0), + "placed at player position" + ); assert!(world.get::(item).is_none(), "CarriedBy removed"); - assert!(world.get::(item).is_none(), "InventorySlot removed"); + assert!( + world.get::(item).is_none(), + "InventorySlot removed" + ); } #[test] @@ -739,7 +812,9 @@ mod tests { let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); - let player_sid = world.resource_mut::().register(player); + let player_sid = world + .resource_mut::() + .register(player); // Item already in slot 0 world.spawn(( @@ -750,12 +825,11 @@ mod tests { // New item on the ground let item2 = world - .spawn(( - TilePosition::new(5, 4, 0), - ItemName("Token".into()), - )) + .spawn((TilePosition::new(5, 4, 0), ItemName("Token".into()))) .id(); - let item2_sid = world.resource_mut::().register(item2); + let item2_sid = world + .resource_mut::() + .register(item2); world.resource_mut::().push(PlayerInput { tick: 0, @@ -769,7 +843,9 @@ mod tests { schedule.add_systems(process_player_input); schedule.run(&mut world); - let slot = world.get::(item2).expect("item should have slot"); + let slot = world + .get::(item2) + .expect("item should have slot"); assert_eq!(slot.0, 1, "second item goes to slot 1"); } @@ -783,7 +859,9 @@ mod tests { let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); - let player_sid = world.resource_mut::().register(player); + let player_sid = world + .resource_mut::() + .register(player); // Fill all 9 slots for slot in 0..MAX_INVENTORY_SLOTS { @@ -796,12 +874,11 @@ mod tests { // Try to take another item let item = world - .spawn(( - TilePosition::new(5, 4, 0), - ItemName("Overflow".into()), - )) + .spawn((TilePosition::new(5, 4, 0), ItemName("Overflow".into()))) .id(); - let item_sid = world.resource_mut::().register(item); + let item_sid = world + .resource_mut::() + .register(item); world.resource_mut::().push(PlayerInput { tick: 0, @@ -816,8 +893,14 @@ mod tests { schedule.run(&mut world); // Item should still be on the ground - assert!(world.get::(item).is_some(), "item stays on ground"); - assert!(world.get::(item).is_none(), "no CarriedBy when full"); + assert!( + world.get::(item).is_some(), + "item stays on ground" + ); + assert!( + world.get::(item).is_none(), + "no CarriedBy when full" + ); } #[test] @@ -831,15 +914,16 @@ mod tests { let player = world .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) .id(); - let player_sid = world.resource_mut::().register(player); + let player_sid = world + .resource_mut::() + .register(player); let item = world - .spawn(( - TilePosition::new(5, 4, 0), - ItemName("Manifest Copy".into()), - )) + .spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into()))) .id(); - let item_sid = world.resource_mut::().register(item); + let item_sid = world + .resource_mut::() + .register(item); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(process_player_input); @@ -854,7 +938,10 @@ mod tests { }); schedule.run(&mut world); - assert!(world.get::(item).is_none(), "item off ground after Take"); + assert!( + world.get::(item).is_none(), + "item off ground after Take" + ); assert_eq!(world.get::(item).unwrap().0, player_sid); assert_eq!(world.get::(item).unwrap().0, 0); @@ -869,10 +956,22 @@ mod tests { world.resource_mut::().tick = 1; schedule.run(&mut world); - let pos = world.get::(item).expect("item back on ground after Place"); - assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position"); - assert!(world.get::(item).is_none(), "CarriedBy removed after Place"); - assert!(world.get::(item).is_none(), "InventorySlot removed after Place"); + let pos = world + .get::(item) + .expect("item back on ground after Place"); + assert_eq!( + *pos, + TilePosition::new(5, 5, 0), + "placed at player position" + ); + assert!( + world.get::(item).is_none(), + "CarriedBy removed after Place" + ); + assert!( + world.get::(item).is_none(), + "InventorySlot removed after Place" + ); } #[test] diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index e802a5303..c431c55d6 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -52,30 +52,100 @@ impl ObjectType { pub fn verb_set(&self) -> &'static [VerbDef] { match self { Self::Readable => &[ - VerbDef { kind: VerbKind::Read, label: "Read", priority: 1, close_only: true }, - VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false }, + VerbDef { + kind: VerbKind::Read, + label: "Read", + priority: 1, + close_only: true, + }, + VerbDef { + kind: VerbKind::Observe, + label: "Observe", + priority: 2, + close_only: false, + }, ], Self::Container => &[ - VerbDef { kind: VerbKind::Open, label: "Open", priority: 1, close_only: true }, - VerbDef { kind: VerbKind::Search, label: "Search", priority: 2, close_only: true }, - VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 3, close_only: false }, + VerbDef { + kind: VerbKind::Open, + label: "Open", + priority: 1, + close_only: true, + }, + VerbDef { + kind: VerbKind::Search, + label: "Search", + priority: 2, + close_only: true, + }, + VerbDef { + kind: VerbKind::Observe, + label: "Observe", + priority: 3, + close_only: false, + }, ], Self::Terminal => &[ - VerbDef { kind: VerbKind::Use, label: "Use", priority: 1, close_only: true }, - VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false }, + VerbDef { + kind: VerbKind::Use, + label: "Use", + priority: 1, + close_only: true, + }, + VerbDef { + kind: VerbKind::Observe, + label: "Observe", + priority: 2, + close_only: false, + }, ], Self::Door => &[ - VerbDef { kind: VerbKind::Open, label: "Open", priority: 1, close_only: true }, - VerbDef { kind: VerbKind::Close, label: "Close", priority: 2, close_only: true }, - VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 3, close_only: false }, + VerbDef { + kind: VerbKind::Open, + label: "Open", + priority: 1, + close_only: true, + }, + VerbDef { + kind: VerbKind::Close, + label: "Close", + priority: 2, + close_only: true, + }, + VerbDef { + kind: VerbKind::Observe, + label: "Observe", + priority: 3, + close_only: false, + }, ], Self::Pickup => &[ - VerbDef { kind: VerbKind::Take, label: "Take", priority: 1, close_only: true }, - VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false }, + VerbDef { + kind: VerbKind::Take, + label: "Take", + priority: 1, + close_only: true, + }, + VerbDef { + kind: VerbKind::Observe, + label: "Observe", + priority: 2, + close_only: false, + }, ], Self::Furniture => &[ - VerbDef { kind: VerbKind::Sit, label: "Sit", priority: 1, close_only: true }, - VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false }, + VerbDef { + kind: VerbKind::Sit, + label: "Sit", + priority: 1, + close_only: true, + }, + VerbDef { + kind: VerbKind::Observe, + label: "Observe", + priority: 2, + close_only: false, + }, ], } } @@ -201,7 +271,10 @@ pub fn compute_nearby_interactions( .to_stable(entity) .map(|sid| sid.0) .unwrap_or_else(|| { - tracing::error!(?entity, "entity in interaction range but not in EntityRegistry"); + tracing::error!( + ?entity, + "entity in interaction range but not in EntityRegistry" + ); entity.to_bits() }); @@ -216,9 +289,7 @@ pub fn compute_nearby_interactions( } // Sort interactions by distance (nearest first) - buffer - .interactions - .sort_by_key(|a| a.distance); + buffer.interactions.sort_by_key(|a| a.distance); } /// Buffer for nearby interaction results, consumed by snapshot generation. @@ -417,11 +488,7 @@ mod tests { fn door_close_range_gets_open_close_observe() { let mut world = setup_world(); spawn_player(&mut world, 5, 5); - world.spawn(( - TilePosition::new(5, 6, 0), - Interactable, - ObjectType::Door, - )); + world.spawn((TilePosition::new(5, 6, 0), Interactable, ObjectType::Door)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); @@ -439,11 +506,7 @@ mod tests { fn pickup_close_range_gets_take_and_observe() { let mut world = setup_world(); spawn_player(&mut world, 5, 5); - world.spawn(( - TilePosition::new(5, 6, 0), - Interactable, - ObjectType::Pickup, - )); + world.spawn((TilePosition::new(5, 6, 0), Interactable, ObjectType::Pickup)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); @@ -514,7 +577,10 @@ mod tests { let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 1); assert_eq!(buffer.interactions[0].verbs.len(), 1); - assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineObject); + assert_eq!( + buffer.interactions[0].verbs[0].kind, + VerbKind::ExamineObject + ); } #[test] @@ -593,7 +659,10 @@ mod tests { let buffer = read_buffer(&mut world); assert_eq!(buffer.interactions.len(), 2); - assert_eq!(buffer.interactions[0].distance, buffer.interactions[1].distance); + assert_eq!( + buffer.interactions[0].distance, + buffer.interactions[1].distance + ); } #[test] @@ -708,11 +777,7 @@ mod tests { let mut world = setup_world(); spawn_player(&mut world, 5, 5); // Distance 4 = mid range (> CLOSE_RANGE=2, <= MID_RANGE=5) - world.spawn(( - TilePosition::new(5, 9, 0), - Interactable, - obj_type, - )); + world.spawn((TilePosition::new(5, 9, 0), Interactable, obj_type)); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); @@ -720,16 +785,22 @@ mod tests { let buffer = read_buffer(&mut world); assert_eq!( - buffer.interactions.len(), 1, - "{:?} at mid range should produce 1 interaction", obj_type + buffer.interactions.len(), + 1, + "{:?} at mid range should produce 1 interaction", + obj_type ); assert_eq!( - buffer.interactions[0].verbs.len(), 1, - "{:?} at mid range should have exactly 1 verb (Observe)", obj_type + buffer.interactions[0].verbs.len(), + 1, + "{:?} at mid range should have exactly 1 verb (Observe)", + obj_type ); assert_eq!( - buffer.interactions[0].verbs[0].kind, VerbKind::Observe, - "{:?} at mid range verb should be Observe", obj_type + buffer.interactions[0].verbs[0].kind, + VerbKind::Observe, + "{:?} at mid range verb should be Observe", + obj_type ); } } @@ -795,11 +866,16 @@ mod tests { for obj_type in types { for def in obj_type.verb_set() { if def.kind == VerbKind::Observe { - assert!(!def.close_only, "{:?} Observe should be mid-range", obj_type); + assert!( + !def.close_only, + "{:?} Observe should be mid-range", + obj_type + ); } else { assert!( def.close_only, - "{:?} {:?} should be close-only", obj_type, def.kind + "{:?} {:?} should be close-only", + obj_type, def.kind ); } } @@ -822,7 +898,9 @@ mod tests { let verbs = obj_type.verb_set(); assert!( verbs.len() <= 4, - "{:?} has {} verbs, D-057 max is 4", obj_type, verbs.len() + "{:?} has {} verbs, D-057 max is 4", + obj_type, + verbs.len() ); } } @@ -832,7 +910,12 @@ mod tests { // ----------------------------------------------------------------------- /// Spawn player with Stance component for sprint suppression tests. - fn spawn_player_with_stance(world: &mut World, x: i32, y: i32, stance: MovementStance) -> Entity { + fn spawn_player_with_stance( + world: &mut World, + x: i32, + y: i32, + stance: MovementStance, + ) -> Entity { world .spawn(( PlayerCharacter, @@ -854,21 +937,31 @@ mod tests { schedule.run(&mut world); let buffer = read_buffer(&mut world); - assert!(buffer.interactions.is_empty(), "sprint should suppress all interactions"); + assert!( + buffer.interactions.is_empty(), + "sprint should suppress all interactions" + ); } #[test] fn sprint_suppresses_object_interactions() { let mut world = setup_world(); spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint); - world.spawn((TilePosition::new(5, 6, 0), Interactable, ObjectType::Terminal)); + world.spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Terminal, + )); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(compute_nearby_interactions); schedule.run(&mut world); let buffer = read_buffer(&mut world); - assert!(buffer.interactions.is_empty(), "sprint should suppress object interactions"); + assert!( + buffer.interactions.is_empty(), + "sprint should suppress object interactions" + ); } #[test] @@ -882,7 +975,11 @@ mod tests { schedule.run(&mut world); let buffer = read_buffer(&mut world); - assert_eq!(buffer.interactions.len(), 1, "Walk should allow interactions"); + assert_eq!( + buffer.interactions.len(), + 1, + "Walk should allow interactions" + ); } #[test] @@ -896,7 +993,11 @@ mod tests { schedule.run(&mut world); let buffer = read_buffer(&mut world); - assert_eq!(buffer.interactions.len(), 1, "Careful should allow interactions"); + assert_eq!( + buffer.interactions.len(), + 1, + "Careful should allow interactions" + ); } #[test] @@ -910,7 +1011,11 @@ mod tests { schedule.run(&mut world); let buffer = read_buffer(&mut world); - assert_eq!(buffer.interactions.len(), 1, "Crouch should allow interactions"); + assert_eq!( + buffer.interactions.len(), + 1, + "Crouch should allow interactions" + ); } #[test] @@ -925,7 +1030,11 @@ mod tests { schedule.run(&mut world); let buffer = read_buffer(&mut world); - assert_eq!(buffer.interactions.len(), 1, "no Stance component should allow interactions"); + assert_eq!( + buffer.interactions.len(), + 1, + "no Stance component should allow interactions" + ); } #[test] @@ -933,7 +1042,11 @@ mod tests { let mut world = setup_world(); spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint); world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable)); - world.spawn((TilePosition::new(6, 5, 0), Interactable, ObjectType::Container)); + world.spawn(( + TilePosition::new(6, 5, 0), + Interactable, + ObjectType::Container, + )); world.spawn((TilePosition::new(4, 5, 0), Interactable)); let mut schedule = bevy_ecs::schedule::Schedule::default(); @@ -941,6 +1054,9 @@ mod tests { schedule.run(&mut world); let buffer = read_buffer(&mut world); - assert!(buffer.interactions.is_empty(), "sprint should suppress all 3 nearby entities"); + assert!( + buffer.interactions.is_empty(), + "sprint should suppress all 3 nearby entities" + ); } } diff --git a/server/src/simulation/inventory.rs b/server/src/simulation/inventory.rs index 675eb6a49..01b68b34d 100644 --- a/server/src/simulation/inventory.rs +++ b/server/src/simulation/inventory.rs @@ -38,12 +38,7 @@ pub struct InventorySlot(pub u8); /// Find the next available inventory slot for a carrier. /// Returns None if all 9 slots are occupied. pub fn find_next_slot(occupied: &[u8]) -> Option { - for slot in 0..MAX_INVENTORY_SLOTS { - if !occupied.contains(&slot) { - return Some(slot); - } - } - None + (0..MAX_INVENTORY_SLOTS).find(|slot| !occupied.contains(slot)) } /// Collect inventory items for a specific carrier (by StableId). @@ -126,8 +121,7 @@ mod tests { let player = world.spawn_empty().id(); let player_sid = world.resource_mut::().register(player); - let mut query_state = - world.query::<(Entity, &CarriedBy, &ItemName, &InventorySlot)>(); + let mut query_state = world.query::<(Entity, &CarriedBy, &ItemName, &InventorySlot)>(); // Can't use system params directly in tests — use world query // Instead, verify the logic by spawning items and checking diff --git a/server/src/simulation/listening.rs b/server/src/simulation/listening.rs new file mode 100644 index 000000000..a1105eb07 --- /dev/null +++ b/server/src/simulation/listening.rs @@ -0,0 +1,482 @@ +// ListeningFocus — eavesdrop positioning via stationary_ticks +// Implements #426: deliberate positioning mechanic for sound perception bonus +// Decision refs: D-053 (stance system), D-018 (three-range sound model) +// +// When the player stands still for EAVESDROP_THRESHOLD ticks, they gain a +// sound perception bonus if within range of a conversation. Sprint stance +// blocks eavesdrop (too high-alert). Careful stance reduces the threshold. + +use bevy_ecs::prelude::*; + +use crate::bridge::types::MovementStance; +use crate::knowledge::types::StableId; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::stance::Stance; + +/// Ticks of being stationary before eavesdrop activates (~3 seconds at 10 tps). +pub const EAVESDROP_THRESHOLD: u32 = 30; + +/// Reduced eavesdrop threshold when in Careful stance (~2 seconds). +pub const EAVESDROP_THRESHOLD_CAREFUL: u32 = 20; + +/// Maximum eavesdrop range in tiles (Manhattan distance, same z-level). +/// Player must be within this distance of a conversation source. +pub const EAVESDROP_RANGE: u32 = 5; + +/// Component tracking stationary time for eavesdrop positioning. +/// Attached to the PlayerCharacter entity. +#[derive(Component, Debug, Clone)] +pub struct ListeningFocus { + /// Consecutive ticks the entity has been stationary. + pub stationary_ticks: u32, + /// Entity currently being eavesdropped on, if any. + /// Set when stationary_ticks exceeds threshold AND a conversation + /// source is within EAVESDROP_RANGE. + pub eavesdrop_target: Option, + /// Position at end of last tick — used to detect movement. + last_position: TilePosition, +} + +impl ListeningFocus { + pub fn new(position: TilePosition) -> Self { + Self { + stationary_ticks: 0, + eavesdrop_target: None, + last_position: position, + } + } + + /// Whether eavesdrop is currently active (threshold exceeded). + pub fn is_eavesdropping(&self) -> bool { + self.eavesdrop_target.is_some() + } + + /// Get the effective eavesdrop threshold for a given stance. + /// Returns None for Sprint (eavesdrop blocked entirely). + pub fn threshold_for_stance(stance: MovementStance) -> Option { + match stance { + MovementStance::Sprint => None, // Sprint blocks eavesdrop + MovementStance::Careful => Some(EAVESDROP_THRESHOLD_CAREFUL), + _ => Some(EAVESDROP_THRESHOLD), + } + } +} + +/// System: update ListeningFocus stationary tick counter. +/// +/// Runs each tick. Compares current position against last known position. +/// If unchanged, increments stationary_ticks. If changed (or in Sprint), +/// resets to zero and clears eavesdrop target. +/// +/// Does NOT evaluate eavesdrop targets — that requires knowledge of nearby +/// conversations, which is a perception concern. This system only tracks +/// the stationary state. Target evaluation is done by a separate perception +/// system that reads ListeningFocus.stationary_ticks. +pub fn update_listening_focus( + mut query: Query<(&TilePosition, &mut ListeningFocus, Option<&Stance>), With>, +) { + for (position, mut focus, stance_opt) in query.iter_mut() { + let stance = stance_opt.map(|s| s.0).unwrap_or(MovementStance::Walk); + + // Check if position changed since last tick + let moved = *position != focus.last_position; + focus.last_position = *position; + + if moved { + // Any movement resets the counter + focus.stationary_ticks = 0; + focus.eavesdrop_target = None; + continue; + } + + // Sprint stance: stationary but too high-alert to listen + let Some(threshold) = ListeningFocus::threshold_for_stance(stance) else { + if focus.eavesdrop_target.is_some() || focus.stationary_ticks > 0 { + tracing::debug!( + "Sprint stance resets eavesdrop: stationary_ticks={}, had_target={}", + focus.stationary_ticks, + focus.eavesdrop_target.is_some(), + ); + } + focus.stationary_ticks = 0; + focus.eavesdrop_target = None; + continue; + }; + + // Increment stationary counter (saturating to prevent overflow) + focus.stationary_ticks = focus.stationary_ticks.saturating_add(1); + + // Clear eavesdrop target if below threshold (e.g. stance changed + // from Careful to Walk, raising the threshold above current ticks) + if focus.stationary_ticks < threshold { + focus.eavesdrop_target = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_ecs::world::World; + + fn make_position(x: i32, y: i32) -> TilePosition { + TilePosition::new(x, y, 0) + } + + fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity { + let pos = make_position(x, y); + world + .spawn((PlayerCharacter, pos, ListeningFocus::new(pos))) + .id() + } + + fn spawn_player_with_stance( + world: &mut World, + x: i32, + y: i32, + stance: MovementStance, + ) -> Entity { + let pos = make_position(x, y); + world + .spawn(( + PlayerCharacter, + pos, + ListeningFocus::new(pos), + Stance(stance), + )) + .id() + } + + fn run_system(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_listening_focus); + schedule.run(world); + } + + // ----------------------------------------------------------------------- + // Stationary tick accumulation + // ----------------------------------------------------------------------- + + #[test] + fn stationary_ticks_increment_when_not_moving() { + let mut world = World::new(); + let entity = spawn_player(&mut world, 5, 5); + + // Run 10 ticks without moving + for _ in 0..10 { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, 10); + } + + #[test] + fn stationary_ticks_reset_on_movement() { + let mut world = World::new(); + let entity = spawn_player(&mut world, 5, 5); + + // Accumulate 10 stationary ticks + for _ in 0..10 { + run_system(&mut world); + } + + // Move the player + *world.get_mut::(entity).unwrap() = make_position(5, 6); + run_system(&mut world); + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, 0); + } + + #[test] + fn stationary_ticks_resume_after_stop() { + let mut world = World::new(); + let entity = spawn_player(&mut world, 5, 5); + + // 5 ticks stationary + for _ in 0..5 { + run_system(&mut world); + } + assert_eq!( + world + .get::(entity) + .unwrap() + .stationary_ticks, + 5 + ); + + // Move + *world.get_mut::(entity).unwrap() = make_position(5, 6); + run_system(&mut world); + assert_eq!( + world + .get::(entity) + .unwrap() + .stationary_ticks, + 0 + ); + + // Stop again — counter restarts from 0 + for _ in 0..3 { + run_system(&mut world); + } + assert_eq!( + world + .get::(entity) + .unwrap() + .stationary_ticks, + 3 + ); + } + + // ----------------------------------------------------------------------- + // Stance interaction + // ----------------------------------------------------------------------- + + #[test] + fn sprint_stance_blocks_stationary_ticks() { + let mut world = World::new(); + let entity = spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint); + + for _ in 0..50 { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!( + focus.stationary_ticks, 0, + "Sprint should block stationary tick accumulation" + ); + } + + #[test] + fn walk_stance_accumulates_normally() { + let mut world = World::new(); + let entity = spawn_player_with_stance(&mut world, 5, 5, MovementStance::Walk); + + for _ in 0..10 { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, 10); + } + + #[test] + fn careful_stance_accumulates_normally() { + let mut world = World::new(); + let entity = spawn_player_with_stance(&mut world, 5, 5, MovementStance::Careful); + + for _ in 0..10 { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, 10); + } + + #[test] + fn crouch_stance_accumulates_normally() { + let mut world = World::new(); + let entity = spawn_player_with_stance(&mut world, 5, 5, MovementStance::Crouch); + + for _ in 0..10 { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, 10); + } + + // ----------------------------------------------------------------------- + // Threshold calculations + // ----------------------------------------------------------------------- + + #[test] + fn threshold_for_sprint_is_none() { + assert!(ListeningFocus::threshold_for_stance(MovementStance::Sprint).is_none()); + } + + #[test] + fn threshold_for_careful_is_reduced() { + let careful = ListeningFocus::threshold_for_stance(MovementStance::Careful).unwrap(); + let walk = ListeningFocus::threshold_for_stance(MovementStance::Walk).unwrap(); + assert!(careful < walk, "Careful threshold should be less than Walk"); + assert_eq!(careful, EAVESDROP_THRESHOLD_CAREFUL); + assert_eq!(walk, EAVESDROP_THRESHOLD); + } + + #[test] + fn threshold_for_walk_and_crouch_equal() { + let walk = ListeningFocus::threshold_for_stance(MovementStance::Walk).unwrap(); + let crouch = ListeningFocus::threshold_for_stance(MovementStance::Crouch).unwrap(); + assert_eq!(walk, crouch); + } + + // ----------------------------------------------------------------------- + // Eavesdrop target management + // ----------------------------------------------------------------------- + + #[test] + fn eavesdrop_target_cleared_on_movement() { + let mut world = World::new(); + let entity = spawn_player(&mut world, 5, 5); + + // Manually set an eavesdrop target + world + .get_mut::(entity) + .unwrap() + .eavesdrop_target = Some(StableId(42)); + world + .get_mut::(entity) + .unwrap() + .stationary_ticks = 50; + + // Move + *world.get_mut::(entity).unwrap() = make_position(5, 6); + run_system(&mut world); + + let focus = world.get::(entity).unwrap(); + assert!( + focus.eavesdrop_target.is_none(), + "movement should clear eavesdrop target" + ); + } + + #[test] + fn eavesdrop_target_cleared_on_sprint() { + let mut world = World::new(); + let pos = make_position(5, 5); + let entity = world + .spawn(( + PlayerCharacter, + pos, + ListeningFocus { + stationary_ticks: 50, + eavesdrop_target: Some(StableId(42)), + last_position: pos, + }, + Stance(MovementStance::Sprint), + )) + .id(); + + run_system(&mut world); + + let focus = world.get::(entity).unwrap(); + assert!( + focus.eavesdrop_target.is_none(), + "sprint should clear eavesdrop target" + ); + assert_eq!(focus.stationary_ticks, 0); + } + + #[test] + fn eavesdrop_target_cleared_below_threshold_on_stance_change() { + let mut world = World::new(); + let entity = spawn_player_with_stance(&mut world, 5, 5, MovementStance::Careful); + + // Accumulate 25 ticks (above Careful threshold of 20, below Walk threshold of 30) + for _ in 0..25 { + run_system(&mut world); + } + // Manually set eavesdrop target (as perception system would) + world + .get_mut::(entity) + .unwrap() + .eavesdrop_target = Some(StableId(99)); + + // Switch to Walk stance — threshold goes from 20 to 30, so 25 < 30 + world.get_mut::(entity).unwrap().0 = MovementStance::Walk; + run_system(&mut world); + + let focus = world.get::(entity).unwrap(); + assert!( + focus.eavesdrop_target.is_none(), + "eavesdrop should clear when ticks drop below new stance threshold" + ); + // But ticks should still be incrementing (26 now) + assert_eq!(focus.stationary_ticks, 26); + } + + // ----------------------------------------------------------------------- + // is_eavesdropping helper + // ----------------------------------------------------------------------- + + #[test] + fn is_eavesdropping_false_without_target() { + let focus = ListeningFocus::new(make_position(0, 0)); + assert!(!focus.is_eavesdropping()); + } + + #[test] + fn is_eavesdropping_true_with_target() { + let mut focus = ListeningFocus::new(make_position(0, 0)); + focus.eavesdrop_target = Some(StableId(1)); + assert!(focus.is_eavesdropping()); + } + + // ----------------------------------------------------------------------- + // No stance component (backward compatibility) + // ----------------------------------------------------------------------- + + #[test] + fn no_stance_defaults_to_walk_behavior() { + let mut world = World::new(); + let entity = spawn_player(&mut world, 5, 5); // no Stance component + + for _ in 0..EAVESDROP_THRESHOLD { + run_system(&mut world); + } + + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, EAVESDROP_THRESHOLD); + } + + // ----------------------------------------------------------------------- + // Constant invariants + // ----------------------------------------------------------------------- + + #[test] + fn eavesdrop_threshold_careful_less_than_normal() { + // T4: The careful threshold MUST be strictly less than the normal + // threshold — careful stance rewards patience with faster eavesdrop + // activation (D-053, D-018). + assert!( + EAVESDROP_THRESHOLD_CAREFUL < EAVESDROP_THRESHOLD, + "EAVESDROP_THRESHOLD_CAREFUL ({}) must be < EAVESDROP_THRESHOLD ({})", + EAVESDROP_THRESHOLD_CAREFUL, + EAVESDROP_THRESHOLD, + ); + } + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + #[test] + fn stationary_ticks_saturate_not_overflow() { + let mut world = World::new(); + let pos = make_position(5, 5); + let entity = world + .spawn(( + PlayerCharacter, + pos, + ListeningFocus { + stationary_ticks: u32::MAX - 1, + eavesdrop_target: None, + last_position: pos, + }, + )) + .id(); + + run_system(&mut world); + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, u32::MAX); + + // One more tick should not overflow + run_system(&mut world); + let focus = world.get::(entity).unwrap(); + assert_eq!(focus.stationary_ticks, u32::MAX); + } +} diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 92c04a52b..10bee8fec 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs; pub mod input; pub mod interaction; pub mod inventory; +pub mod listening; pub mod monologue; pub mod movement; pub mod path_follow; @@ -35,8 +36,8 @@ impl Plugin for SimulationPlugin { path_follow::follow_paths.after(pathfinding::compute_paths), movement::validate_movement.after(path_follow::follow_paths), path_follow::cleanup_path_blocked.after(movement::validate_movement), - time::advance_tick - .after(path_follow::cleanup_path_blocked), + listening::update_listening_focus.after(movement::validate_movement), + time::advance_tick.after(path_follow::cleanup_path_blocked), ), ); diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 3f53c8b19..c0eabd33c 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -36,9 +36,18 @@ pub(crate) const ANOMALY_DELAY_TICKS: u64 = 90; /// Hardcoded v0.1 sprint anomaly "double-take" lines. /// Future: move to content pools with trigger="sprint_anomaly". const ANOMALY_LINES: &[(&str, &str)] = &[ - ("sprint_anomaly_01", "Wait \u{2014} something wasn't right back there."), - ("sprint_anomaly_02", "Hold on. That face... why were they there?"), - ("sprint_anomaly_03", "Something's off. That wasn't where they should be."), + ( + "sprint_anomaly_01", + "Wait \u{2014} something wasn't right back there.", + ), + ( + "sprint_anomaly_02", + "Hold on. That face... why were they there?", + ), + ( + "sprint_anomaly_03", + "Something's off. That wasn't where they should be.", + ), ]; /// Tracks monologue state for cooldown and trigger detection. @@ -148,7 +157,11 @@ pub fn process_sprint_anomaly_monologue( time: Res, mut rng: ResMut, mut query: Query< - (&mut SprintAnomalyQueue, &mut MonologueBuffer, &mut MonologueState), + ( + &mut SprintAnomalyQueue, + &mut MonologueBuffer, + &mut MonologueState, + ), With, >, ) { @@ -236,7 +249,7 @@ pub fn trigger_monologue( let character = state.character.as_str(); let mut candidates: Vec<(&str, &str)> = Vec::new(); // (id, text) - for (_district_id, district) in &content.0.districts { + for district in content.0.districts.values() { for pool in &district.monologue_pools { if pool.character != character { continue; @@ -255,7 +268,7 @@ pub fn trigger_monologue( if candidates.is_empty() { // All lines for this trigger have been shown; allow repeats - for (_district_id, district) in &content.0.districts { + for district in content.0.districts.values() { for pool in &district.monologue_pools { if pool.character != character { continue; @@ -688,7 +701,11 @@ mod tests { // All hardcoded v0.1 lines should have id prefix and non-empty text assert!(!ANOMALY_LINES.is_empty()); for (id, text) in ANOMALY_LINES { - assert!(id.starts_with("sprint_anomaly_"), "id={} should start with sprint_anomaly_", id); + assert!( + id.starts_with("sprint_anomaly_"), + "id={} should start with sprint_anomaly_", + id + ); assert!(!text.is_empty(), "text for {} should be non-empty", id); } } @@ -716,10 +733,16 @@ mod tests { schedule.run(&mut world); let mut buf_query = world.query::<&MonologueBuffer>(); - assert!(buf_query.single(&world).unwrap().event.is_none(), "should not fire before delay"); + assert!( + buf_query.single(&world).unwrap().event.is_none(), + "should not fire before delay" + ); let mut q_query = world.query::<&SprintAnomalyQueue>(); - assert!(q_query.single(&world).unwrap().has_pending(), "still pending before delay"); + assert!( + q_query.single(&world).unwrap().has_pending(), + "still pending before delay" + ); // Tick 90: delay elapsed — should fire world.resource_mut::().tick = ANOMALY_DELAY_TICKS; @@ -734,7 +757,10 @@ mod tests { // Queue should be cleared let mut q_query = world.query::<&SprintAnomalyQueue>(); - assert!(!q_query.single(&world).unwrap().has_pending(), "queue cleared after fire"); + assert!( + !q_query.single(&world).unwrap().has_pending(), + "queue cleared after fire" + ); // last_fired_tick should be updated let mut state_query = world.query::<&MonologueState>(); diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index 13ba4a7a6..7265b1e59 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -261,7 +261,12 @@ pub struct MoveIntent { pub fn validate_movement( mut commands: Commands, walkability: Option>, - mut movers: Query<(Entity, &MoveIntent, &mut TilePosition, Option<&TilePresence>)>, + mut movers: Query<( + Entity, + &MoveIntent, + &mut TilePosition, + Option<&TilePresence>, + )>, stationary: Query<(Entity, &TilePosition, Option<&TilePresence>), Without>, ) { let Some(map) = walkability else { @@ -290,12 +295,17 @@ pub fn validate_movement( } else if occupied.contains_key(&slot) { tracing::trace!( "Entity {:?} blocked by entity at {:?} (layer {:?})", - entity, target, layer + entity, + target, + layer ); } else { tracing::trace!( "Entity {:?} moving from {:?} to {:?} (layer {:?})", - entity, *position, target, layer + entity, + *position, + target, + layer ); // Free old layer slot, claim new one occupied.remove(&(*position, layer)); diff --git a/server/src/simulation/path_follow.rs b/server/src/simulation/path_follow.rs index 2e0d6bd88..64a11c3fe 100644 --- a/server/src/simulation/path_follow.rs +++ b/server/src/simulation/path_follow.rs @@ -151,10 +151,7 @@ mod tests { Npc, TilePosition::new(0, 0, 0), ComputedPath { - steps: vec![ - TilePosition::new(1, 0, 0), - TilePosition::new(2, 0, 0), - ], + steps: vec![TilePosition::new(1, 0, 0), TilePosition::new(2, 0, 0)], current_index: 0, }, MovementSpeed::new(3), diff --git a/server/src/simulation/pathfinding.rs b/server/src/simulation/pathfinding.rs index 6a64ef11c..cc1b1a365 100644 --- a/server/src/simulation/pathfinding.rs +++ b/server/src/simulation/pathfinding.rs @@ -102,7 +102,12 @@ pub fn compute_paths( Some((path, _cost)) => { // path includes start position; skip it let steps: Vec = path.into_iter().skip(1).collect(); - tracing::trace!("Entity {:?}: path to {:?}, {} steps", entity, goal, steps.len()); + tracing::trace!( + "Entity {:?}: path to {:?}, {} steps", + entity, + goal, + steps.len() + ); commands.entity(entity).insert(ComputedPath { steps, current_index: 0, @@ -208,10 +213,7 @@ mod tests { world.insert_resource(map); let entity = world - .spawn(( - TilePosition::new(5, 5, 0), - PathRequest { goal }, - )) + .spawn((TilePosition::new(5, 5, 0), PathRequest { goal })) .id(); let mut schedule = bevy_ecs::schedule::Schedule::default(); diff --git a/server/src/simulation/stance.rs b/server/src/simulation/stance.rs index 302318172..0109f405b 100644 --- a/server/src/simulation/stance.rs +++ b/server/src/simulation/stance.rs @@ -193,7 +193,7 @@ mod tests { fn cooldown_stance_switch_mid_cooldown() { let mut cd = PlayerMoveCooldown::default(); assert!(cd.try_move(MovementStance::Crouch)); // move at crouch speed - // Switch to sprint mid-cooldown + // Switch to sprint mid-cooldown assert!(cd.try_move(MovementStance::Sprint)); // sprint allows every tick } @@ -239,7 +239,13 @@ mod tests { fn movement_profile_as_ecs_component() { let mut world = bevy_ecs::world::World::new(); let profile = MovementProfile::smuggler(); - let entity = world.spawn((profile, profile.initial_stance(), PlayerMoveCooldown::default())).id(); + let entity = world + .spawn(( + profile, + profile.initial_stance(), + PlayerMoveCooldown::default(), + )) + .id(); let stored = world.get::(entity).unwrap(); assert_eq!(stored.default_stance, MovementStance::Walk); diff --git a/server/src/simulation/time.rs b/server/src/simulation/time.rs index b743cda98..e985316ca 100644 --- a/server/src/simulation/time.rs +++ b/server/src/simulation/time.rs @@ -115,7 +115,10 @@ mod tests { #[test] fn tick_to_minute_conversion() { - let time = SimulationTime { tick: 10, ..Default::default() }; + let time = SimulationTime { + tick: 10, + ..Default::default() + }; assert_eq!(time.game_minutes(), 1); } @@ -123,18 +126,30 @@ mod tests { fn day_phase_boundaries() { let time = SimulationTime::default(); assert_eq!(time.day_phase(), DayPhase::Morning); - let time = SimulationTime { tick: 360 * TICKS_PER_GAME_MINUTE, ..Default::default() }; + let time = SimulationTime { + tick: 360 * TICKS_PER_GAME_MINUTE, + ..Default::default() + }; assert_eq!(time.day_phase(), DayPhase::Afternoon); - let time = SimulationTime { tick: 720 * TICKS_PER_GAME_MINUTE, ..Default::default() }; + let time = SimulationTime { + tick: 720 * TICKS_PER_GAME_MINUTE, + ..Default::default() + }; assert_eq!(time.day_phase(), DayPhase::Evening); - let time = SimulationTime { tick: 1080 * TICKS_PER_GAME_MINUTE, ..Default::default() }; + let time = SimulationTime { + tick: 1080 * TICKS_PER_GAME_MINUTE, + ..Default::default() + }; assert_eq!(time.day_phase(), DayPhase::Night); } #[test] fn paused_prevents_tick_advance() { let mut world = bevy_ecs::world::World::new(); - world.insert_resource(SimulationTime { tick_rate: TickRate::Paused, ..Default::default() }); + world.insert_resource(SimulationTime { + tick_rate: TickRate::Paused, + ..Default::default() + }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(advance_tick); schedule.run(&mut world); @@ -154,7 +169,10 @@ mod tests { #[test] fn half_rate_advances_every_two_frames() { let mut world = bevy_ecs::world::World::new(); - world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() }); + world.insert_resource(SimulationTime { + tick_rate: TickRate::Half, + ..Default::default() + }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(advance_tick); @@ -179,15 +197,24 @@ mod tests { fn paused_helper_method() { let time = SimulationTime::default(); assert!(!time.paused()); - let time = SimulationTime { tick_rate: TickRate::Paused, ..Default::default() }; + let time = SimulationTime { + tick_rate: TickRate::Paused, + ..Default::default() + }; assert!(time.paused()); - let time = SimulationTime { tick_rate: TickRate::Half, ..Default::default() }; + let time = SimulationTime { + tick_rate: TickRate::Half, + ..Default::default() + }; assert!(!time.paused()); } #[test] fn day_wraparound_at_midnight() { - let time = SimulationTime { tick: MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE, ..Default::default() }; + let time = SimulationTime { + tick: MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE, + ..Default::default() + }; assert_eq!(time.day_phase(), DayPhase::Morning); assert_eq!(time.time_of_day_minutes(), 0); assert_eq!(time.day(), 1); @@ -195,7 +222,10 @@ mod tests { #[test] fn day_calculation() { - let time = SimulationTime { tick: 3 * MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE + 100, ..Default::default() }; + let time = SimulationTime { + tick: 3 * MINUTES_PER_DAY * TICKS_PER_GAME_MINUTE + 100, + ..Default::default() + }; assert_eq!(time.day(), 3); } @@ -203,7 +233,10 @@ mod tests { fn tick_rate_switch_mid_accumulation() { // Half->Full with 0.5 remainder: Full should tick immediately (0.5 + 1.0 >= 1.0) let mut world = bevy_ecs::world::World::new(); - world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() }); + world.insert_resource(SimulationTime { + tick_rate: TickRate::Half, + ..Default::default() + }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(advance_tick); @@ -233,7 +266,10 @@ mod tests { #[test] fn half_rate_no_drift_over_10000_frames() { let mut world = bevy_ecs::world::World::new(); - world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() }); + world.insert_resource(SimulationTime { + tick_rate: TickRate::Half, + ..Default::default() + }); let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(advance_tick); diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 69e52ddc5..f1aa4eb18 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -57,6 +57,7 @@ fn snapshot_roundtrip_over_unix_socket() { visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], }; bridge @@ -119,7 +120,10 @@ fn input_roundtrip_over_unix_socket() { }, PlayerInput { tick: 11, - action: PlayerAction::Interact { target_entity_id: None, verb: None }, + action: PlayerAction::Interact { + target_entity_id: None, + verb: None, + }, }, ]; diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 3e980b3d7..bc30231e7 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -43,6 +43,7 @@ fn snapshot_roundtrip_over_tcp() { visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], }; bridge @@ -98,7 +99,10 @@ fn input_roundtrip_over_tcp() { }, PlayerInput { tick: 11, - action: PlayerAction::Interact { target_entity_id: None, verb: None }, + action: PlayerAction::Interact { + target_entity_id: None, + verb: None, + }, }, ]; diff --git a/server/tests/content_loading.rs b/server/tests/content_loading.rs index 292f4d418..dfc2dcbf1 100644 --- a/server/tests/content_loading.rs +++ b/server/tests/content_loading.rs @@ -45,7 +45,10 @@ fn discover_real_content_structure() { assert!(!manifest.campaigns.is_empty()); // At least one district should be discovered - assert!(!store.districts.is_empty(), "should discover at least one district"); + assert!( + !store.districts.is_empty(), + "should discover at least one district" + ); } #[test] @@ -298,6 +301,7 @@ fn content_plugin_loads_via_app() { app.add_plugins(settled_reach_server::npc::NpcPlugin); app.insert_resource(ContentConfig { content_root: root, + ..Default::default() }); app.add_plugins(ContentPlugin); @@ -353,13 +357,18 @@ fn spawn_real_content_with_relationships_and_secrets() { ); npcs_with_want += 1; } - assert_eq!(npcs_with_want, 20, "All 20 NPCs should have Want components"); + assert_eq!( + npcs_with_want, 20, + "All 20 NPCs should have Want components" + ); // Spot-check specific Want values let kael_entity = registry .to_entity(&result.npc_ids["npc:kael-davan"]) .unwrap(); - let kael_want = world.get::(kael_entity).expect("Kael should have Want"); + let kael_want = world + .get::(kael_entity) + .expect("Kael should have Want"); assert_eq!(kael_want.primary, npc::WantKind::Safety); // Verify Kael has a Secret component @@ -383,9 +392,11 @@ fn spawn_real_content_with_relationships_and_secrets() { let kael_kg = world .get::(kael_entity) .expect("Kael should have KnowledgeGraph"); - assert!(kael_kg.knows_fact(&settled_reach_server::knowledge::types::FactId( - "contraband.ring_exists".to_string() - ))); + assert!( + kael_kg.knows_fact(&settled_reach_server::knowledge::types::FactId( + "contraband.ring_exists".to_string() + )) + ); // Verify global RelationshipGraph was populated let graph = world.resource::(); @@ -400,6 +411,8 @@ fn spawn_real_content_with_relationships_and_secrets() { .resource::() .to_entity(&result.npc_ids["npc:nils-davan"]) .unwrap(); - let nils_want = world.get::(nils_entity).expect("Nils should have Want"); + let nils_want = world + .get::(nils_entity) + .expect("Nils should have Want"); assert_eq!(nils_want.primary, npc::WantKind::Power); } diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 58df823a2..1959757b3 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -66,14 +66,20 @@ fn player_moves_north_through_full_pipeline() { rmp_serde::from_slice(&response).expect("deserialize snapshot"); // Snapshot captures state at end of tick 0 (before advance_tick increments to 1) - assert_eq!(snapshot.version, 6); + assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!(snapshot.tick, 0); assert_eq!(snapshot.entities.len(), 1); // v2 fields populated assert_eq!(snapshot.game_time.day, 0); - assert_eq!(snapshot.game_time.day_phase, settled_reach_server::simulation::time::DayPhase::Morning); - assert_eq!(snapshot.game_time.tick_rate, settled_reach_server::simulation::time::TickRate::Full); + assert_eq!( + snapshot.game_time.day_phase, + settled_reach_server::simulation::time::DayPhase::Morning + ); + assert_eq!( + snapshot.game_time.tick_rate, + settled_reach_server::simulation::time::TickRate::Full + ); let player_entity = &snapshot.entities[0]; // Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0) @@ -82,7 +88,10 @@ fn player_moves_north_through_full_pipeline() { assert_eq!(player_entity.y, 15.5); assert_eq!(player_entity.z, 0); assert!(matches!(player_entity.kind, EntityKind::Player)); - assert!(matches!(player_entity.visibility, VisibilitySector::Forward)); + assert!(matches!( + player_entity.visibility, + VisibilitySector::Forward + )); // Clean up drop(reader); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index af7b8c352..73208deb9 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -33,6 +33,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], } } @@ -200,6 +201,7 @@ fn generate_msgpack_fixtures() { ], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], }; write_fixture( "snapshot_v2_full", @@ -214,7 +216,10 @@ fn generate_msgpack_fixtures() { }, PlayerInput { tick: 0, - action: PlayerAction::Interact { target_entity_id: None, verb: None }, + action: PlayerAction::Interact { + target_entity_id: None, + verb: None, + }, }, ]; write_fixture( diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 5b6418ec8..5f08b8435 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -22,6 +22,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], } } @@ -86,7 +87,10 @@ fn all_player_action_variants_roundtrip() { PlayerAction::MoveNorthwest, PlayerAction::MoveSoutheast, PlayerAction::MoveSouthwest, - PlayerAction::Interact { target_entity_id: None, verb: None }, + PlayerAction::Interact { + target_entity_id: None, + verb: None, + }, PlayerAction::UsePerceptionMode("thermal".to_string()), PlayerAction::Pause, PlayerAction::Unpause, @@ -134,7 +138,11 @@ fn all_fixtures_deserialize() { if name.starts_with("snapshot") { let snap = rmp_serde::from_slice::(&bytes) .unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e)); - assert_eq!(snap.version, PROTOCOL_VERSION, "fixture {} has wrong version", name); + assert_eq!( + snap.version, PROTOCOL_VERSION, + "fixture {} has wrong version", + name + ); } else if name.starts_with("input_batch") { rmp_serde::from_slice::>(&bytes) .unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e)); @@ -225,6 +233,7 @@ fn snapshot_v2_fields_roundtrip() { ], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -237,8 +246,14 @@ fn snapshot_v2_fields_roundtrip() { assert_eq!(decoded.game_time.tick_rate, TickRate::Paused); assert_eq!(decoded.player_facing, FacingDirection::Southeast); assert_eq!(decoded.visible_tiles.len(), 2); - assert_eq!(decoded.visible_tiles[0].visibility, VisibilitySector::Forward); - assert_eq!(decoded.visible_tiles[1].visibility, VisibilitySector::Peripheral); + assert_eq!( + decoded.visible_tiles[0].visibility, + VisibilitySector::Forward + ); + assert_eq!( + decoded.visible_tiles[1].visibility, + VisibilitySector::Peripheral + ); assert_eq!(decoded.entities[0].visibility, VisibilitySector::Forward); } @@ -259,7 +274,11 @@ fn entity_to_bits_roundtrip() { for entity in [e1, e2, e3, e4] { let bits = entity.to_bits(); let restored = Entity::from_bits(bits); - assert_eq!(entity, restored, "Entity::to_bits() roundtrip failed for {:?}", entity); + assert_eq!( + entity, restored, + "Entity::to_bits() roundtrip failed for {:?}", + entity + ); } } @@ -268,7 +287,10 @@ fn entity_to_bits_roundtrip() { fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); - assert_eq!(PROTOCOL_VERSION, 6, "bump this assertion when protocol version changes"); + assert_eq!( + PROTOCOL_VERSION, 7, + "bump this assertion when protocol version changes" + ); } /// All FacingDirection variants round-trip @@ -302,6 +324,7 @@ fn all_facing_direction_variants_roundtrip() { visible_tiles: vec![], nearby_interactions: vec![], current_monologue: None, + pending_recognitions: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); @@ -412,19 +435,35 @@ fn v5_payload_deserializes_into_v6_struct() { // New fields should get their defaults assert_eq!(decoded.version, 5, "version field preserved from v5"); assert_eq!(decoded.tick, 42); - assert_eq!(decoded.player_stance, MovementStance::Walk, "missing stance should default to Walk"); - assert!(decoded.player_inventory.is_empty(), "missing inventory should default to empty"); - assert!(decoded.current_monologue.is_none(), "missing monologue should default to None"); + assert_eq!( + decoded.player_stance, + MovementStance::Walk, + "missing stance should default to Walk" + ); + assert!( + decoded.player_inventory.is_empty(), + "missing inventory should default to empty" + ); + assert!( + decoded.current_monologue.is_none(), + "missing monologue should default to None" + ); + assert!( + decoded.pending_recognitions.is_empty(), + "missing pending_recognitions should default to empty" + ); } /// Full 9-slot inventory roundtrip (D-065: 3x3 grid = 9 slots universal) #[test] fn snapshot_v6_full_inventory_roundtrip() { - let items: Vec = (0..9).map(|i| InventoryItem { - item_id: 100 + i as u64, - name: format!("Item {}", i), - slot: i, - }).collect(); + let items: Vec = (0..9) + .map(|i| InventoryItem { + item_id: 100 + i as u64, + name: format!("Item {}", i), + slot: i, + }) + .collect(); let mut snapshot = test_snapshot(0, vec![]); snapshot.player_inventory = items; @@ -441,6 +480,43 @@ fn snapshot_v6_full_inventory_roundtrip() { assert_eq!(decoded.player_inventory[8].slot, 8); } +/// PendingRecognitionWire round-trips through MessagePack (#423, D-060). +/// Guards against cognitive delay wire data corruption during serialization. +#[test] +fn pending_recognition_wire_roundtrip() { + let mut snapshot = test_snapshot(0, vec![]); + snapshot.pending_recognitions = vec![ + PendingRecognitionWire { + entity_id: 42, + x: 10.5, + y: 20.0, + z: 0, + remaining_ticks: 4, + total_delay_ticks: 6, + }, + PendingRecognitionWire { + entity_id: 99, + x: 15.0, + y: 8.5, + z: 1, + remaining_ticks: 1, + total_delay_ticks: 3, + }, + ]; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.pending_recognitions.len(), 2); + assert_eq!(decoded.pending_recognitions[0].entity_id, 42); + assert_eq!(decoded.pending_recognitions[0].remaining_ticks, 4); + assert_eq!(decoded.pending_recognitions[0].total_delay_ticks, 6); + assert!((decoded.pending_recognitions[0].x - 10.5).abs() < f32::EPSILON); + assert_eq!(decoded.pending_recognitions[1].entity_id, 99); + assert_eq!(decoded.pending_recognitions[1].z, 1); + assert_eq!(decoded.pending_recognitions[1].total_delay_ticks, 3); +} + /// All VerbKind variants must survive MessagePack round-trip (#421, D-057). /// Guards against serde mapping breakage when new verbs are added. #[test] @@ -482,7 +558,8 @@ fn all_verb_kind_variants_roundtrip() { assert_eq!(decoded.nearby_interactions.len(), 1); assert_eq!( decoded.nearby_interactions[0].verbs[0].kind, kind, - "VerbKind::{:?} did not roundtrip", kind + "VerbKind::{:?} did not roundtrip", + kind ); } } @@ -506,7 +583,11 @@ fn all_object_type_variants_roundtrip() { for obj_type in types { let bytes = rmp_serde::to_vec_named(&obj_type).expect("serialize"); let decoded: ObjectType = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded, obj_type, "ObjectType::{:?} roundtrip failed", obj_type); + assert_eq!( + decoded, obj_type, + "ObjectType::{:?} roundtrip failed", + obj_type + ); } } @@ -533,7 +614,10 @@ fn verb_kind_confront_roundtrip() { let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); assert_eq!(decoded.nearby_interactions.len(), 1); - assert_eq!(decoded.nearby_interactions[0].verbs[0].kind, VerbKind::Confront); + assert_eq!( + decoded.nearby_interactions[0].verbs[0].kind, + VerbKind::Confront + ); assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront"); } @@ -541,15 +625,16 @@ fn verb_kind_confront_roundtrip() { /// Used in Phase 2 label relabeling — must survive the wire. #[test] fn all_character_archetype_variants_roundtrip() { - let archetypes = [ - CharacterArchetype::Smuggler, - CharacterArchetype::Detective, - ]; + let archetypes = [CharacterArchetype::Smuggler, CharacterArchetype::Detective]; for archetype in archetypes { let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize"); let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded, archetype, "CharacterArchetype::{:?} roundtrip failed", archetype); + assert_eq!( + decoded, archetype, + "CharacterArchetype::{:?} roundtrip failed", + archetype + ); } } @@ -575,7 +660,10 @@ fn nearby_interaction_contradicted_roundtrip() { let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert!(decoded.nearby_interactions[0].contradicted, "contradicted flag should survive roundtrip"); + assert!( + decoded.nearby_interactions[0].contradicted, + "contradicted flag should survive roundtrip" + ); } /// NearbyInteraction.object_type round-trips through MessagePack (#422). @@ -600,5 +688,8 @@ fn nearby_interaction_object_type_roundtrip() { let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); - assert_eq!(decoded.nearby_interactions[0].object_type, Some(ObjectType::Container)); + assert_eq!( + decoded.nearby_interactions[0].object_type, + Some(ObjectType::Container) + ); } diff --git a/server/tests/shadowcast_bench.rs b/server/tests/shadowcast_bench.rs index 9852b7e5b..91ec6b660 100644 --- a/server/tests/shadowcast_bench.rs +++ b/server/tests/shadowcast_bench.rs @@ -4,9 +4,9 @@ //! Run with: cargo test --test shadowcast_bench -- --ignored --nocapture use rand::Rng; -use rand_chacha::ChaCha8Rng; use rand::SeedableRng; -use settled_reach_server::perception::shadowcast::{symmetric_shadowcast, recursive_shadowcast}; +use rand_chacha::ChaCha8Rng; +use settled_reach_server::perception::shadowcast::{recursive_shadowcast, symmetric_shadowcast}; use std::collections::HashSet; use std::time::Instant; @@ -173,12 +173,14 @@ fn benchmark_symmetric_vs_recursive() { let results = bench_config(&config); - println!(" Symmetric: {:.2}ms total, {:.2}µs/call, {:.1} tiles avg", + println!( + " Symmetric: {:.2}ms total, {:.2}µs/call, {:.1} tiles avg", results.symmetric_ms, results.symmetric_ms * 1000.0 / config.iterations as f64, results.symmetric_avg_tiles ); - println!(" Recursive: {:.2}ms total, {:.2}µs/call, {:.1} tiles avg", + println!( + " Recursive: {:.2}ms total, {:.2}µs/call, {:.1} tiles avg", results.recursive_ms, results.recursive_ms * 1000.0 / config.iterations as f64, results.recursive_avg_tiles