Merge remote-tracking branch 'origin/server'
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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.
@@ -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-away–specific consequence parameters that `ToleranceThreshold` does not cover (penalty magnitude, forgiveness policy). The relationship:
|
||||
|
||||
- `NpcTolerance` (content YAML) → populates `ToleranceThreshold` (ECS runtime)
|
||||
- `ToleranceThreshold` tracks **general stress accumulation** — any irritant contributes
|
||||
- `InteractionTolerance` tracks **walk-away–specific consequence parameters** — how the NPC reacts to conversational abandonment specifically
|
||||
|
||||
**Recommended implementation approach:** Rather than adding a third standalone component, extend the existing `ToleranceThreshold` or co-locate walk-away fields on a new `InteractionTolerance` component that **reads** `ToleranceThreshold.current_stress` as a factor. Walk-away consequences should feed back into `ToleranceThreshold.current_stress` (walking away from a stressed NPC is worse). The walk-away system would:
|
||||
|
||||
1. Check `InteractionTolerance.walk_away_threshold` — has the NPC been walked away from too many times?
|
||||
2. If threshold exceeded, apply `walk_away_penalty` to relationship
|
||||
3. Increase `ToleranceThreshold.current_stress` by a walk-away–specific delta
|
||||
4. Check `permanent_confrontation_memory` for confrontation abandonment
|
||||
|
||||
```rust
|
||||
/// Walk-away–specific consequence parameters. Extends general tolerance
|
||||
/// (ToleranceThreshold) with interaction-specific mechanics per D-064.
|
||||
/// Populated from NPC profile YAML, seeded per playthrough.
|
||||
/// Reads ToleranceThreshold.current_stress as an aggravating factor.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct InteractionTolerance {
|
||||
/// How many walk-aways before relationship degradation.
|
||||
/// Range: 1-5, seeded per NPC per playthrough.
|
||||
pub walk_away_threshold: u8,
|
||||
|
||||
/// How much relationship damage per walk-away beyond threshold.
|
||||
/// Range: 0.1-0.5, seeded per NPC.
|
||||
pub walk_away_penalty: f32,
|
||||
|
||||
/// Stress delta applied to ToleranceThreshold.current_stress per walk-away.
|
||||
/// A walk-away from a stressed NPC (high current_stress) hits harder.
|
||||
pub walk_away_stress_delta: i16,
|
||||
|
||||
/// Whether this NPC remembers confrontation abandonment permanently.
|
||||
/// Some NPCs forgive, some don't (per seed).
|
||||
pub permanent_confrontation_memory: bool,
|
||||
}
|
||||
```
|
||||
|
||||
The NPC behavior system queries both the player's KG (for interaction history) and the NPC's tolerance components (for consequence magnitude). This separation means:
|
||||
- The player's KG is what THEY remember
|
||||
- `ToleranceThreshold` is the NPC's general frustration level
|
||||
- `InteractionTolerance` is how the NPC reacts to walk-aways specifically (feeding back into general stress)
|
||||
- The NPC may also have their own KG entry for the interaction (future: NPC-to-NPC gossip about "the person who walked out on Kael")
|
||||
|
||||
---
|
||||
|
||||
## 9. Scope and Limitations
|
||||
|
||||
### 9.1 v0.1 scope (this ticket)
|
||||
|
||||
- Player character interaction tracking only
|
||||
- Interactions with Active-tier NPCs (30-80 per D-026)
|
||||
- Single `interaction` prerequisite per monologue line
|
||||
- No NPC-to-NPC interaction tracking
|
||||
- No gossip about interactions ("I heard you walked out on Kael")
|
||||
|
||||
### 9.2 Future extensions
|
||||
|
||||
| Feature | Ticket/Sprint | Schema impact |
|
||||
|---------|--------------|---------------|
|
||||
| NPC-to-NPC interaction tracking | Future | Same schema, applied to NPC KnowledgeGraphs |
|
||||
| Gossip about interactions | Sprint 3+ (ToldBy source) | New `KnowledgeSource::ToldBy` entries referencing interaction facts |
|
||||
| Interaction-driven relationship decay | Future | Query interaction history to modulate decay rates |
|
||||
| Multi-NPC conversations | Future | `InteractionMetadata.target_entity` becomes `Vec<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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
®istry,
|
||||
&player_query,
|
||||
&inventory_items,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Place") => {
|
||||
handle_place(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
PlayerAction::Interact {
|
||||
target_entity_id,
|
||||
ref verb,
|
||||
} => match verb.as_deref() {
|
||||
Some("Take") => {
|
||||
handle_take(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
&inventory_items,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Place") => {
|
||||
handle_place(&mut commands, ®istry, &player_query, target_entity_id);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
|
||||
target_entity_id,
|
||||
verb,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
PlayerAction::UsePerceptionMode(ref mode) => {
|
||||
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
||||
}
|
||||
@@ -192,7 +206,12 @@ pub fn process_player_input(
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn apply_move(
|
||||
player_query: &mut Query<
|
||||
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&mut Stance>,
|
||||
Option<&mut PlayerMoveCooldown>,
|
||||
),
|
||||
With<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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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>();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user