Merge remote-tracking branch 'origin/server'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-02-16 01:08:17 +01:00
50 changed files with 4989 additions and 426 deletions
+12
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -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": {
@@ -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, FactKnowledge>`
- `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::<ContentSlug>(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<u64>,
/// 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<String>,
/// 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<FactMetadata>, // NEW
}
```
**Why Option A over alternatives:**
- No separate data structure — interactions are facts, stored in the existing `BTreeMap<FactId, FactKnowledge>`
- The `FactMetadata` enum is extensible for future fact types (evidence, gossip) without changing the graph structure
- `Option<FactMetadata>` 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<InteractionId, InteractionRecord>` 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<Item = (&'a FactId, &'a InteractionMetadata)> {
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<String>,
},
/// 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<FactId, FactKnowledge>,
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<i32>`. Populates `ToleranceThreshold` at spawn. |
The proposed `InteractionTolerance` is **not a replacement** for either. It tracks walk-awayspecific 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-awayspecific 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-awayspecific delta
4. Check `permanent_confrontation_memory` for confrontation abandonment
```rust
/// Walk-awayspecific 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<StableId>` |
### 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<FactMetadata>` | 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<FactMetadata>,
}
```
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.
+582
View File
@@ -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\<enum\> | 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\<enum\> | 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\<enum\> | 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\<enum\> | 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\<string\> | 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\<object\> | 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\<object\> | 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<FactId, FactKnowledge>`, 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<StableId, EntityKnowledge>`, 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\<string\> | `[]` | 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<AccessTier>, // D-028 Layer 1
trust: TrustTier, // D-028 Layer 3
situation: Vec<Situation>, // D-028 Layer 2
topic: Vec<Topic>, // D-028 Layer 4
mood: Vec<Mood>, // D-028 Layer 4
tags: Vec<String>,
knowledge_grant: Option<KnowledgeGrant>,
}
/// A loaded monologue line, fully parsed and validated.
struct MonologueLine {
id: LineId,
text: String, // ≤ 160 chars
trigger: Trigger,
prerequisites: Option<Prerequisites>,
priority: u8, // 0-10, default 5
cooldown: u32, // ticks, default 0
tags: Vec<String>,
}
/// Pool container — one per file loaded.
struct DialoguePool {
location: LocationSlug,
role: RoleSlug,
lines: Vec<DialogueLine>,
}
struct MonologuePool {
character: Character, // smuggler | detective
location: LocationSlug, // or "general"
lines: Vec<MonologueLine>,
}
```
### 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<LineId, Tick>, // 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<MonologuePool>>` — 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.
+6 -3
View File
@@ -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
+24 -1
View File
@@ -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<MonologueEvent>,
/// 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<PendingRecognitionWire>,
}
/// 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)]
+260
View File
@@ -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<PathBuf, SystemTime>,
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<PathBuf, SystemTime>, 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<ContentConfig>,
watcher: Option<ResMut<ContentWatcher>>,
store_res: Option<ResMut<ContentStoreResource>>,
index_res: Option<ResMut<LinePoolIndexResource>>,
) {
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);
}
}
File diff suppressed because it is too large Load Diff
+14 -12
View File
@@ -93,8 +93,11 @@ fn discover_districts(campaign_path: &Path) -> Vec<PathBuf> {
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<T: serde::de::DeserializeOwned>(dir: &Path) -> Vec<T> {
/// Load all YAML files recursively under a directory, skipping stubs.
fn load_yaml_recursive<T: serde::de::DeserializeOwned>(dir: &Path) -> Vec<T> {
let mut results = Vec::new();
walk_yaml_files(dir, &mut |path| {
match load_yaml::<T>(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::<T>(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");
}
+42 -15
View File
@@ -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::<ContentConfig>().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);
+56 -25
View File
@@ -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::<EntityRegistry>().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::<EntityRegistry>().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::<npc::TellSystem>(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::<npc::SkillSet>(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::<KnowledgeGraph>(entity).expect("KnowledgeGraph should be attached");
let kg = world
.get::<KnowledgeGraph>(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::<KnowledgeGraph>(entity).expect("Phase 2 should attach KnowledgeGraph");
let kg = world
.get::<KnowledgeGraph>(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]
+5 -5
View File
@@ -491,7 +491,7 @@ pub struct DialogueLine {
pub knowledge_grant: Option<KnowledgeGrant>,
}
#[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<String>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Clone, Deserialize)]
pub struct Prerequisites {
#[serde(default)]
pub facts: Vec<FactPrerequisite>,
@@ -533,20 +533,20 @@ pub struct Prerequisites {
pub relationship: Option<RelationshipPrerequisite>,
}
#[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<String>,
+19 -4
View File
@@ -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);
+16 -24
View File
@@ -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]
+2 -5
View File
@@ -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");
+7 -1
View File
@@ -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(),
+32 -11
View File
@@ -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();
+6 -11
View File
@@ -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::<SimulationTime>().tick =
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
world.resource_mut::<SimulationTime>().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::<SimulationTime>().tick =
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
world.resource_mut::<SimulationTime>().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::<SimulationTime>().tick =
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_phase_transition);
+523
View File
@@ -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<PendingRecognition>,
}
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<StableId> 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<PendingRecognition> {
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<PendingRecognition> {
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<SimulationTime>,
mut query: Query<(Entity, &mut CognitiveDelay)>,
mut event_queue: ResMut<KnowledgeEventQueue>,
) {
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
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::<KnowledgeEventQueue>();
world.init_resource::<SimulationTime>();
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::<KnowledgeEventQueue>();
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::<CognitiveDelay>().unwrap();
assert!(cd.is_empty(), "pending should be cleared after drain");
}
}
+10 -13
View File
@@ -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::<SimulationTime>().tick =
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
world.resource_mut::<SimulationTime>().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
+9 -3
View File
@@ -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::<query::ActivePerceptionMode>()
.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");
}
+68 -11
View File
@@ -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<SimulationTime>,
buffer: Res<SnapshotBuffer>,
registry: Res<EntityRegistry>,
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
mut observer_query: Query<
(Entity, &KnowledgeGraph, Option<&mut CognitiveDelay>),
With<PlayerCharacter>,
>,
mut event_queue: ResMut<KnowledgeEventQueue>,
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<crate::knowledge::types::StableId> =
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]
+61 -8
View File
@@ -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<VisibilityGeometry>,
registry: Res<EntityRegistry>,
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<PlayerCharacter>,
>,
all_entities: Query<(
@@ -65,8 +77,18 @@ pub fn compute_observer_snapshot(
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
mut buffer: ResMut<SnapshotBuffer>,
) {
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,
+241 -50
View File
@@ -57,7 +57,7 @@ fn player_always_visible_in_snapshot() {
let buffer = world.resource::<SnapshotBuffer>();
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::<SnapshotBuffer>();
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::<SnapshotBuffer>();
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::<SnapshotBuffer>();
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::<SnapshotBuffer>();
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::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().unwrap();
assert!(
snapshot.pending_recognitions.is_empty(),
"no CognitiveDelay component should produce empty pending_recognitions"
);
}
+1 -2
View File
@@ -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()
+6 -1
View File
@@ -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);
}
+1 -2
View File
@@ -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()
}
+175 -76
View File
@@ -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<EntityRegistry>,
mut player_query: Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
(
Entity,
&TilePosition,
Option<&mut Stance>,
Option<&mut PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
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,
&registry,
&player_query,
&inventory_items,
target_entity_id,
);
}
Some("Place") => {
handle_place(
&mut commands,
&registry,
&player_query,
target_entity_id,
);
}
_ => {
tracing::info!(
PlayerAction::Interact {
target_entity_id,
ref verb,
} => match verb.as_deref() {
Some("Take") => {
handle_take(
&mut commands,
&registry,
&player_query,
&inventory_items,
target_entity_id,
);
}
Some("Place") => {
handle_place(&mut commands, &registry, &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<PlayerCharacter>,
>,
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<PlayerCharacter>,
>,
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::<TilePosition>()
.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<PlayerCharacter>,
>,
target_entity_id: Option<u64>,
@@ -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::<CarriedBy>()
.remove::<InventorySlot>()
.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::<SimulationTime>().tick_rate, TickRate::Paused);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Paused
);
}
#[test]
@@ -553,7 +593,10 @@ mod tests {
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some(), "first move should succeed");
assert!(
world.get::<MoveIntent>(player).is_some(),
"first move should succeed"
);
// Remove MoveIntent (simulating validate_movement consuming it)
world.entity_mut(player).remove::<MoveIntent>();
@@ -564,7 +607,10 @@ mod tests {
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_none(), "second move should be throttled");
assert!(
world.get::<MoveIntent>(player).is_none(),
"second move should be throttled"
);
// Tick 0 again: move north — should succeed (cooldown elapsed)
world.resource_mut::<InputQueue>().push(PlayerInput {
@@ -572,7 +618,10 @@ mod tests {
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some(), "third move should succeed after cooldown");
assert!(
world.get::<MoveIntent>(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::<MoveIntent>(player).is_some(), "sprint should allow every tick");
assert!(
world.get::<MoveIntent>(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::<crate::knowledge::EntityRegistry>().register(player);
let player_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.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::<crate::knowledge::EntityRegistry>().register(item);
let item_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(item);
// Issue Take verb
world.resource_mut::<InputQueue>().push(PlayerInput {
@@ -680,10 +733,17 @@ mod tests {
schedule.run(&mut world);
// Item should have CarriedBy + InventorySlot, no TilePosition
assert!(world.get::<TilePosition>(item).is_none(), "item should leave the ground");
let carried = world.get::<CarriedBy>(item).expect("item should have CarriedBy");
assert!(
world.get::<TilePosition>(item).is_none(),
"item should leave the ground"
);
let carried = world
.get::<CarriedBy>(item)
.expect("item should have CarriedBy");
assert_eq!(carried.0, player_sid);
let slot = world.get::<InventorySlot>(item).expect("item should have slot");
let slot = world
.get::<InventorySlot>(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::<crate::knowledge::EntityRegistry>().register(player);
let player_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.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::<crate::knowledge::EntityRegistry>().register(item);
let item_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(item);
// Issue Place verb
world.resource_mut::<InputQueue>().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::<TilePosition>(item).expect("item should be on ground");
assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position");
let pos = world
.get::<TilePosition>(item)
.expect("item should be on ground");
assert_eq!(
*pos,
TilePosition::new(5, 5, 0),
"placed at player position"
);
assert!(world.get::<CarriedBy>(item).is_none(), "CarriedBy removed");
assert!(world.get::<InventorySlot>(item).is_none(), "InventorySlot removed");
assert!(
world.get::<InventorySlot>(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::<crate::knowledge::EntityRegistry>().register(player);
let player_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.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::<crate::knowledge::EntityRegistry>().register(item2);
let item2_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(item2);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -769,7 +843,9 @@ mod tests {
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let slot = world.get::<InventorySlot>(item2).expect("item should have slot");
let slot = world
.get::<InventorySlot>(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::<crate::knowledge::EntityRegistry>().register(player);
let player_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.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::<crate::knowledge::EntityRegistry>().register(item);
let item_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(item);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -816,8 +893,14 @@ mod tests {
schedule.run(&mut world);
// Item should still be on the ground
assert!(world.get::<TilePosition>(item).is_some(), "item stays on ground");
assert!(world.get::<CarriedBy>(item).is_none(), "no CarriedBy when full");
assert!(
world.get::<TilePosition>(item).is_some(),
"item stays on ground"
);
assert!(
world.get::<CarriedBy>(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::<crate::knowledge::EntityRegistry>().register(player);
let player_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.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::<crate::knowledge::EntityRegistry>().register(item);
let item_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.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::<TilePosition>(item).is_none(), "item off ground after Take");
assert!(
world.get::<TilePosition>(item).is_none(),
"item off ground after Take"
);
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
assert_eq!(world.get::<InventorySlot>(item).unwrap().0, 0);
@@ -869,10 +956,22 @@ mod tests {
world.resource_mut::<SimulationTime>().tick = 1;
schedule.run(&mut world);
let pos = world.get::<TilePosition>(item).expect("item back on ground after Place");
assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position");
assert!(world.get::<CarriedBy>(item).is_none(), "CarriedBy removed after Place");
assert!(world.get::<InventorySlot>(item).is_none(), "InventorySlot removed after Place");
let pos = world
.get::<TilePosition>(item)
.expect("item back on ground after Place");
assert_eq!(
*pos,
TilePosition::new(5, 5, 0),
"placed at player position"
);
assert!(
world.get::<CarriedBy>(item).is_none(),
"CarriedBy removed after Place"
);
assert!(
world.get::<InventorySlot>(item).is_none(),
"InventorySlot removed after Place"
);
}
#[test]
+170 -54
View File
@@ -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"
);
}
}
+2 -8
View File
@@ -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<u8> {
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::<EntityRegistry>().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
+482
View File
@@ -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<StableId>,
/// 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<u32> {
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<PlayerCharacter>>,
) {
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::<ListeningFocus>(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::<TilePosition>(entity).unwrap() = make_position(5, 6);
run_system(&mut world);
let focus = world.get::<ListeningFocus>(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::<ListeningFocus>(entity)
.unwrap()
.stationary_ticks,
5
);
// Move
*world.get_mut::<TilePosition>(entity).unwrap() = make_position(5, 6);
run_system(&mut world);
assert_eq!(
world
.get::<ListeningFocus>(entity)
.unwrap()
.stationary_ticks,
0
);
// Stop again — counter restarts from 0
for _ in 0..3 {
run_system(&mut world);
}
assert_eq!(
world
.get::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(entity)
.unwrap()
.eavesdrop_target = Some(StableId(42));
world
.get_mut::<ListeningFocus>(entity)
.unwrap()
.stationary_ticks = 50;
// Move
*world.get_mut::<TilePosition>(entity).unwrap() = make_position(5, 6);
run_system(&mut world);
let focus = world.get::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(entity)
.unwrap()
.eavesdrop_target = Some(StableId(99));
// Switch to Walk stance — threshold goes from 20 to 30, so 25 < 30
world.get_mut::<Stance>(entity).unwrap().0 = MovementStance::Walk;
run_system(&mut world);
let focus = world.get::<ListeningFocus>(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::<ListeningFocus>(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::<ListeningFocus>(entity).unwrap();
assert_eq!(focus.stationary_ticks, u32::MAX);
// One more tick should not overflow
run_system(&mut world);
let focus = world.get::<ListeningFocus>(entity).unwrap();
assert_eq!(focus.stationary_ticks, u32::MAX);
}
}
+3 -2
View File
@@ -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),
),
);
+36 -10
View File
@@ -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<SimulationTime>,
mut rng: ResMut<SimRng>,
mut query: Query<
(&mut SprintAnomalyQueue, &mut MonologueBuffer, &mut MonologueState),
(
&mut SprintAnomalyQueue,
&mut MonologueBuffer,
&mut MonologueState,
),
With<PlayerCharacter>,
>,
) {
@@ -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::<SimulationTime>().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>();
+13 -3
View File
@@ -261,7 +261,12 @@ pub struct MoveIntent {
pub fn validate_movement(
mut commands: Commands,
walkability: Option<Res<WalkabilityMap>>,
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<MoveIntent>>,
) {
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));
+1 -4
View File
@@ -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),
+7 -5
View File
@@ -102,7 +102,12 @@ pub fn compute_paths(
Some((path, _cost)) => {
// path includes start position; skip it
let steps: Vec<TilePosition> = 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();
+8 -2
View File
@@ -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::<MovementProfile>(entity).unwrap();
assert_eq!(stored.default_stance, MovementStance::Walk);
+48 -12
View File
@@ -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);
+5 -1
View File
@@ -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,
},
},
];
+5 -1
View File
@@ -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,
},
},
];
+20 -7
View File
@@ -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::<npc::Want>(kael_entity).expect("Kael should have Want");
let kael_want = world
.get::<npc::Want>(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::<settled_reach_server::knowledge::graph::KnowledgeGraph>(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::<settled_reach_server::npc::relationships::RelationshipGraph>();
@@ -400,6 +411,8 @@ fn spawn_real_content_with_relationships_and_secrets() {
.resource::<EntityRegistry>()
.to_entity(&result.npc_ids["npc:nils-davan"])
.unwrap();
let nils_want = world.get::<npc::Want>(nils_entity).expect("Nils should have Want");
let nils_want = world
.get::<npc::Want>(nils_entity)
.expect("Nils should have Want");
assert_eq!(nils_want.primary, npc::WantKind::Power);
}
+13 -4
View File
@@ -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);
+6 -1
View File
@@ -33,6 +33,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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(
+115 -24
View File
@@ -22,6 +22,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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::<ObserverSnapshot>(&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::<Vec<PlayerInput>>(&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<InventoryItem> = (0..9).map(|i| InventoryItem {
item_id: 100 + i as u64,
name: format!("Item {}", i),
slot: i,
}).collect();
let items: Vec<InventoryItem> = (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)
);
}
+6 -4
View File
@@ -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