Scoped the vertical slice: 16 EntityKnowledge keys, v0.1 mechanical NPC mapping, YAML content format, 7-verb interaction model, server- authoritative pause, 38 tickets created across copy/server/client/ci. 20 decisions (D-042 through D-061). 8 agents, 2 rounds + closing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
788 lines
32 KiB
Markdown
788 lines
32 KiB
Markdown
# Round 2 — Dudley (Server Developer)
|
|
|
|
## 1. YAML Acceptance — Loader Adaptation
|
|
|
|
Accepted. YAML is the content format for v0.1. The lead has spoken and Tyre's rationale is sound — content authors (Mellanie, Paula) need to write and read these files, and `serde_yaml` is mature enough for our needs.
|
|
|
|
### What Changes in the Loader
|
|
|
|
Almost nothing structurally. The `serde` derive macros work identically across formats — the Rust structs stay the same, only the deserializer call changes:
|
|
|
|
```rust
|
|
// Round 1 (RON):
|
|
// let profile: NpcProfile = ron::from_str(&content)?;
|
|
|
|
// Round 2 (YAML):
|
|
let profile: NpcProfile = serde_yaml::from_str(&content)?;
|
|
```
|
|
|
|
The 6-phase load sequence from Round 1 is format-agnostic. All phase ordering, validation rules, and resource creation remain identical. Specific adaptations:
|
|
|
|
| Item | Round 1 (RON) | Round 2 (YAML) | Impact |
|
|
|------|---------------|----------------|--------|
|
|
| Manifest | `content/manifest.ron` | `content/content.yaml` | Filename only |
|
|
| NPC profiles | `.ron` files | `.yaml` files | Extension only |
|
|
| Dialogue/monologue | `.ron` files | `.yaml` files | Extension only |
|
|
| Multi-line text | Rust string escaping | YAML native `\|` / `>` blocks | Better for authors — no escaping |
|
|
| Validation | Serde-only | Serde + JSON Schema (Tyre's dual validation) | Strictly more validation |
|
|
| Dependency | `ron` crate | `serde_yaml` crate | Swap one dependency |
|
|
|
|
The one genuine improvement: YAML's native multi-line text support (`|` for literal blocks) means dialogue and monologue text will be cleaner in the content files. No escaped newlines, no awkward string concatenation. Content authors benefit directly.
|
|
|
|
### Cargo.toml Change
|
|
|
|
```toml
|
|
# Add:
|
|
serde_yaml = "0.9"
|
|
# The ron crate stays for potential future use (RON converter per lead's decision)
|
|
```
|
|
|
|
### RON Converter
|
|
|
|
Per the lead's decision, we build a RON converter. My recommendation:
|
|
|
|
- **Location:** `tooling/content-converter/` — a small Rust binary, not part of the server
|
|
- **Direction:** YAML → RON only (content files are authored in YAML; if we ever want RON for hot-path loading, the converter produces it)
|
|
- **When it runs:** Build-time, optional. Not load-time — the server loads YAML directly. The converter is insurance for a hypothetical future where YAML parse speed matters (it won't for v0.1)
|
|
- **Priority:** Low. Build it when someone asks for it, not proactively
|
|
|
|
---
|
|
|
|
## 2. Multi-Action Interaction Model
|
|
|
|
### The Architecture: N Actions, 1 Client Selection
|
|
|
|
The server computes ALL available actions for each nearby entity. The `InteractionOptions` struct from Round 1 already supports this. The v0.1 client selects the top-ranked action to display. The v0.2 client shows the full list.
|
|
|
|
```rust
|
|
/// Available interactions for a nearby entity.
|
|
/// Server computes all; client displays based on version.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct EntityInteractions {
|
|
/// StableId of the target entity
|
|
pub entity_id: u64,
|
|
/// Display name (knowledge-filtered — "Dock Worker" or "Kael Davan")
|
|
pub display_name: String,
|
|
/// All available actions, ordered by priority (highest first).
|
|
/// v0.1 client shows actions[0] only.
|
|
/// v0.2+ client shows all (radial menu, list, etc.)
|
|
pub actions: Vec<AvailableAction>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AvailableAction {
|
|
/// Action identifier used in PlayerAction::InteractWith
|
|
pub action_id: String,
|
|
/// What the action is
|
|
pub kind: ActionKind,
|
|
/// Display label for the client ("Talk", "Examine", "Ask about the schedule")
|
|
pub label: String,
|
|
/// Priority for v0.1 single-action selection (higher = shown first)
|
|
pub priority: u8,
|
|
/// Can this action be performed right now?
|
|
pub enabled: bool,
|
|
/// Why not? (shown as tooltip in v0.2+, ignored in v0.1)
|
|
pub disabled_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum ActionKind {
|
|
/// Study the entity without initiating contact.
|
|
/// Updates knowledge graph with behavioral observations.
|
|
Examine,
|
|
/// Initiate dialogue. Opens conversation state.
|
|
Talk,
|
|
/// Look at an environmental object. Returns description + knowledge grants.
|
|
ExamineObject,
|
|
/// Listen to a nearby NPC-NPC conversation. Passive — no social cost.
|
|
Overhear,
|
|
}
|
|
```
|
|
|
|
### Priority Ranking System
|
|
|
|
The server determines action priority per context. This is where the "single context-sensitive action" behavior lives:
|
|
|
|
```rust
|
|
/// Determine default action priority for a given entity and context.
|
|
/// Returns actions sorted by priority (highest first).
|
|
fn rank_actions(
|
|
target_kind: EntityKind,
|
|
observer_knowledge: &KnowledgeGraph,
|
|
target_stable_id: &StableId,
|
|
target_in_conversation: bool,
|
|
target_activity: &str,
|
|
) -> Vec<AvailableAction> {
|
|
let mut actions = Vec::new();
|
|
|
|
match target_kind {
|
|
EntityKind::Npc => {
|
|
// Examine is always available for NPCs in LOS
|
|
let examine_priority = if observer_knowledge
|
|
.entity_knowledge(target_stable_id)
|
|
.map(|ek| ek.known_attributes.contains_key("contradiction_flagged"))
|
|
.unwrap_or(false)
|
|
{
|
|
// Post-contradiction: Examine becomes high priority
|
|
// "Watch them more carefully"
|
|
90
|
|
} else {
|
|
// Default: Examine is secondary to Talk
|
|
40
|
|
};
|
|
|
|
actions.push(AvailableAction {
|
|
action_id: format!("examine_{}", target_stable_id.0),
|
|
kind: ActionKind::Examine,
|
|
label: "Observe".to_string(),
|
|
priority: examine_priority,
|
|
enabled: true,
|
|
disabled_reason: None,
|
|
});
|
|
|
|
// Talk: available if NPC is not sleeping, not in combat, not already
|
|
// in conversation with someone else
|
|
let talk_enabled = !target_in_conversation
|
|
&& target_activity != "sleeping";
|
|
|
|
actions.push(AvailableAction {
|
|
action_id: format!("talk_{}", target_stable_id.0),
|
|
kind: ActionKind::Talk,
|
|
label: "Talk".to_string(),
|
|
priority: if talk_enabled { 50 } else { 10 },
|
|
enabled: talk_enabled,
|
|
disabled_reason: if !talk_enabled {
|
|
Some("Busy".to_string())
|
|
} else {
|
|
None
|
|
},
|
|
});
|
|
|
|
// Overhear: available if NPC is in conversation with another NPC
|
|
if target_in_conversation {
|
|
actions.push(AvailableAction {
|
|
action_id: format!("overhear_{}", target_stable_id.0),
|
|
kind: ActionKind::Overhear,
|
|
label: "Listen".to_string(),
|
|
priority: 60,
|
|
enabled: true,
|
|
disabled_reason: None,
|
|
});
|
|
}
|
|
}
|
|
EntityKind::Object => {
|
|
actions.push(AvailableAction {
|
|
action_id: format!("examine_obj_{}", target_stable_id.0),
|
|
kind: ActionKind::ExamineObject,
|
|
label: "Examine".to_string(),
|
|
priority: 50,
|
|
enabled: true,
|
|
disabled_reason: None,
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
// Sort by priority descending
|
|
actions.sort_by(|a, b| b.priority.cmp(&a.priority));
|
|
actions
|
|
}
|
|
```
|
|
|
|
### How v0.1 and v0.2 Differ — Client Side Only
|
|
|
|
| Behavior | v0.1 | v0.2+ |
|
|
|----------|------|-------|
|
|
| Actions computed | ALL (server computes full list) | ALL (same) |
|
|
| Actions sent to client | ALL (via `EntityInteractions.actions`) | ALL (same) |
|
|
| Actions displayed | `actions[0]` only — single prompt | All enabled actions — menu/radial |
|
|
| Player input | `Interact` → server applies `actions[0]` | `InteractWith(action_id)` → server applies specific action |
|
|
| Architecture change needed for v0.2 | **None on server.** Client reads more of the existing data. | Client UI change only |
|
|
|
|
The key guarantee: **zero server-side changes for v0.2 multi-verb.** The server already sends everything. The client just reads more of what's already there.
|
|
|
|
### PlayerAction Extension
|
|
|
|
```rust
|
|
pub enum PlayerAction {
|
|
// ... existing movement variants ...
|
|
|
|
/// v0.1: interact with nearest entity, server picks top action.
|
|
Interact,
|
|
|
|
/// v0.2+: interact with specific entity using specific action.
|
|
/// Falls back to top action if action_id is None.
|
|
InteractWith {
|
|
target_id: u64, // StableId of target
|
|
action_id: Option<String>, // specific action, or None for default
|
|
},
|
|
|
|
/// Select a dialogue topic during active conversation.
|
|
DialogueSelect { topic: String },
|
|
|
|
/// End the active conversation.
|
|
DialogueEnd,
|
|
}
|
|
```
|
|
|
|
For v0.1, the client sends `Interact`. The server resolves to the nearest entity and applies `actions[0]`. When v0.2 introduces multi-verb UI, the client sends `InteractWith { target_id, action_id }`. The server handles both — `Interact` is syntactic sugar for "find nearest, pick top action."
|
|
|
|
### Stig's Question: IPC Message Format
|
|
|
|
Stig asked what the state update looks like. The answer: **full text in the message, not IDs for client-side lookup.** This keeps the client thin and prevents information leakage. The `ObserverSnapshot` already carries everything the client needs. Interaction data rides the same channel:
|
|
|
|
```rust
|
|
/// ObserverSnapshot v3 — adds interaction and dialogue data.
|
|
pub struct ObserverSnapshot {
|
|
pub version: u8, // 3
|
|
pub tick: u64,
|
|
pub game_time: GameTime,
|
|
pub player_facing: FacingDirection,
|
|
pub entities: Vec<VisibleEntity>,
|
|
pub visible_tiles: Vec<VisibleTile>,
|
|
|
|
// v3 additions:
|
|
|
|
/// Entities within interaction range with available actions.
|
|
/// Empty if no interactable entities are nearby.
|
|
pub nearby_interactions: Vec<EntityInteractions>,
|
|
|
|
/// Active dialogue state. None if not in conversation.
|
|
pub active_dialogue: Option<ActiveDialogue>,
|
|
|
|
/// Monologue lines triggered this tick. Usually 0 or 1.
|
|
pub monologue: Vec<MonologueDisplay>,
|
|
|
|
/// Overheard conversation fragments. Passive, proximity-based.
|
|
pub overheard: Vec<OverheardDisplay>,
|
|
|
|
/// Current simulation speed (1.0 = normal, 0.5 = overlay, 0.0 = paused).
|
|
/// Client uses this to adjust animation/interpolation rates.
|
|
pub sim_speed: f32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ActiveDialogue {
|
|
pub npc_entity_id: u64,
|
|
pub npc_name: String,
|
|
pub npc_relationship_color: RelationshipState,
|
|
/// Current NPC line. None before first line / between lines.
|
|
pub current_line: Option<DialogueLineDisplay>,
|
|
/// Available topics the player can raise. Empty if conversation is NPC-led.
|
|
pub available_topics: Vec<TopicOption>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DialogueLineDisplay {
|
|
pub line_id: String,
|
|
pub text: String,
|
|
pub mood: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TopicOption {
|
|
pub topic_id: String,
|
|
pub label: String,
|
|
pub enabled: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MonologueDisplay {
|
|
pub line_id: String,
|
|
pub text: String,
|
|
pub trigger: String,
|
|
/// true = use urgent chime (observe_anomaly, discover_evidence)
|
|
pub urgent: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct OverheardDisplay {
|
|
pub speaker_id: u64,
|
|
pub speaker_name: String,
|
|
pub fragment: String,
|
|
}
|
|
```
|
|
|
|
Everything the client needs to render is in the snapshot. The client never queries content files. The server is the single source of truth.
|
|
|
|
---
|
|
|
|
## 3. Pause System — Tick Rate Modulation
|
|
|
|
### The Model: SimulationSpeed Resource
|
|
|
|
Currently `SimulationTime` has a boolean `paused` field. That's insufficient for 50% speed. I'll replace it with a speed multiplier model:
|
|
|
|
```rust
|
|
/// Simulation speed states.
|
|
/// Determines how many ticks advance per real-time frame.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
pub enum SimSpeed {
|
|
/// Normal speed: 1 tick per frame at target TPS (10 tps per D-031).
|
|
Normal,
|
|
/// Reduced speed: tick every other frame (50% speed).
|
|
/// Used when UI overlays are active (knowledge panel, dialogue).
|
|
Overlay,
|
|
/// Full pause: no ticks advance.
|
|
/// Spacebar toggle. Always available.
|
|
Paused,
|
|
}
|
|
|
|
impl SimSpeed {
|
|
/// Returns the tick advance rate.
|
|
/// Normal = advance every frame.
|
|
/// Overlay = advance every 2nd frame.
|
|
/// Paused = never advance.
|
|
pub fn should_tick(&self, frame_counter: u64) -> bool {
|
|
match self {
|
|
SimSpeed::Normal => true,
|
|
SimSpeed::Overlay => frame_counter % 2 == 0,
|
|
SimSpeed::Paused => false,
|
|
}
|
|
}
|
|
|
|
/// Serialized speed for client animation adjustment.
|
|
pub fn as_f32(&self) -> f32 {
|
|
match self {
|
|
SimSpeed::Normal => 1.0,
|
|
SimSpeed::Overlay => 0.5,
|
|
SimSpeed::Paused => 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resource tracking simulation speed and frame count.
|
|
#[derive(Resource, Debug, Clone)]
|
|
pub struct SimulationSpeed {
|
|
/// Current speed state.
|
|
pub speed: SimSpeed,
|
|
/// Frame counter for Overlay tick skipping.
|
|
pub frame_counter: u64,
|
|
/// Stack of speed modifiers. Highest priority wins.
|
|
/// When all modifiers are removed, returns to Normal.
|
|
speed_stack: Vec<SpeedModifier>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SpeedModifier {
|
|
source: SpeedSource,
|
|
speed: SimSpeed,
|
|
priority: u8,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum SpeedSource {
|
|
/// Spacebar toggle — highest priority, always wins.
|
|
PlayerPause,
|
|
/// Knowledge panel open.
|
|
KnowledgePanel,
|
|
/// Active dialogue with NPC.
|
|
Dialogue,
|
|
}
|
|
```
|
|
|
|
### State Machine
|
|
|
|
```
|
|
┌──────────┐
|
|
┌──────→│ Normal │◄──────┐
|
|
│ │ (1.0x) │ │
|
|
│ └────┬─────┘ │
|
|
│ │ │
|
|
close overlay open overlay spacebar
|
|
│ │ │
|
|
│ ┌────▼─────┐ │
|
|
├──────→│ Overlay │──────→│
|
|
│ │ (0.5x) │ │
|
|
│ └────┬─────┘ │
|
|
│ │ │
|
|
│ spacebar │
|
|
│ │ │
|
|
│ ┌────▼─────┐ │
|
|
└───────│ Paused │───────┘
|
|
│ (0.0x) │
|
|
└──────────┘
|
|
```
|
|
|
|
Rules:
|
|
1. **Spacebar always toggles full pause.** From Normal → Paused. From Overlay → Paused. From Paused → returns to whatever state was active before pause (Normal or Overlay).
|
|
2. **Opening knowledge panel or dialogue → Overlay (50%).** Multiple overlays don't stack — it's 50% whether you have one or three open.
|
|
3. **Closing all overlays → Normal.** But only if not explicitly paused by spacebar.
|
|
4. **Spacebar overrides everything.** It's the master switch.
|
|
|
|
### Implementation: Modified advance_tick
|
|
|
|
```rust
|
|
/// System: advance tick based on current simulation speed.
|
|
/// Replaces the current boolean-paused advance_tick.
|
|
pub fn advance_tick(
|
|
mut time: ResMut<SimulationTime>,
|
|
mut speed: ResMut<SimulationSpeed>,
|
|
) {
|
|
speed.frame_counter += 1;
|
|
|
|
if speed.speed.should_tick(speed.frame_counter) {
|
|
time.tick += 1;
|
|
}
|
|
}
|
|
|
|
/// System: process pause/speed PlayerActions.
|
|
pub fn process_speed_input(
|
|
mut speed: ResMut<SimulationSpeed>,
|
|
input_events: Res<InputQueue>,
|
|
// ... other input handling
|
|
) {
|
|
// Spacebar pause toggle
|
|
// When paused by spacebar, restore previous speed on unpause
|
|
// Overlay open/close events modify the speed stack
|
|
}
|
|
```
|
|
|
|
### What the Client Needs to Know
|
|
|
|
The `sim_speed: f32` field in ObserverSnapshot v3 tells the client:
|
|
- `1.0` → normal animation/interpolation
|
|
- `0.5` → halve animation rates, entities move at half speed visually
|
|
- `0.0` → freeze all animation, UI stays responsive
|
|
|
|
The client doesn't manage pause state. It reads the speed from the snapshot and adjusts rendering accordingly. The server is authoritative.
|
|
|
|
### Dialogue-Specific Pause Behavior
|
|
|
|
When dialogue is active (player is talking to an NPC), simulation runs at Overlay speed (50%). This means:
|
|
- NPCs continue their routines at half speed — the world doesn't freeze when you talk
|
|
- Other NPCs can walk past during your conversation — you might miss something
|
|
- But it's slow enough that the player doesn't feel punished for engaging in dialogue
|
|
|
|
If the player hits spacebar during dialogue, full pause. They can read at their own pace. Spacebar again resumes to Overlay (because dialogue is still active).
|
|
|
|
---
|
|
|
|
## 4. FriendArc Phase Transitions — Triggers and Content Dependencies
|
|
|
|
### Phase Transition State Machine
|
|
|
|
```
|
|
WARMTH ──────→ TRUST ──────→ DOUBT ──────→ CONFLICT
|
|
│ │ │ │
|
|
│ auto (tick) │ knowledge │ observation │ knowledge +
|
|
│ │ threshold │ + monologue │ dialogue
|
|
│ │ │ │
|
|
▼ ▼ ▼ ▼
|
|
"We're good" "I trust you" "Something's "I know what
|
|
wrong" you did"
|
|
```
|
|
|
|
### Phase-by-Phase: Triggers, Content, and What the Server Needs
|
|
|
|
#### Phase 1: WARMTH → TRUST
|
|
|
|
**Trigger:** Interaction count threshold + time elapsed.
|
|
|
|
```rust
|
|
/// Transition from Warmth to Trust.
|
|
/// This is the "getting to know you" phase — automatic progression
|
|
/// as the player interacts with the FRIEND over time.
|
|
fn check_warmth_to_trust(
|
|
friend_arc: &FriendArc,
|
|
interaction_log: &InteractionLog,
|
|
time: &SimulationTime,
|
|
) -> bool {
|
|
let interactions_with_friend = interaction_log
|
|
.count_interactions(friend_arc.bonded_character, friend_arc.friend_entity);
|
|
let time_since_first = time.tick.saturating_sub(
|
|
interaction_log.first_interaction_tick(
|
|
friend_arc.bonded_character, friend_arc.friend_entity
|
|
).unwrap_or(time.tick)
|
|
);
|
|
|
|
// Require both: enough interactions AND enough time.
|
|
// Prevents rushing through the arc by spam-talking.
|
|
interactions_with_friend >= 3 && time_since_first >= 600 // 1 game-hour
|
|
}
|
|
```
|
|
|
|
**Content required:**
|
|
- 5-8 dialogue lines for Warmth phase (casual, friendly, establishing rapport)
|
|
- 3-5 monologue lines for Warmth (positive: "Kael's reliable. Good to have someone you can count on.")
|
|
- Trust transition is silent — no dramatic moment. The player just notices the FRIEND is warmer
|
|
|
|
**Server state change:** `FriendArc.phase = Trust`. No knowledge graph change. No visual change. The NPC's dialogue pool shifts to Trust-phase lines (gated by the `situation: [friend_trust]` tag).
|
|
|
|
#### Phase 2: TRUST → DOUBT
|
|
|
|
**Trigger:** Knowledge threshold — the player character learns specific facts that create tension.
|
|
|
|
```rust
|
|
/// Transition from Trust to Doubt.
|
|
/// Requires the bonded character to accumulate suspicious knowledge.
|
|
/// NOT a single event — it's a gradual shift.
|
|
fn check_trust_to_doubt(
|
|
friend_arc: &FriendArc,
|
|
observer_kg: &KnowledgeGraph,
|
|
) -> bool {
|
|
// The character must know at least ONE of the doubt-triggering facts
|
|
// at KnowsOf or higher confidence.
|
|
let doubt_facts = &friend_arc.doubt_trigger_facts;
|
|
|
|
doubt_facts.iter().any(|fact_id| {
|
|
observer_kg.fact_at_least(fact_id, KnowledgeConfidence::KnowsOf)
|
|
})
|
|
}
|
|
```
|
|
|
|
**Content required for Kael (smuggler's FRIEND):**
|
|
- Doubt-triggering facts (defined in NPC profile):
|
|
- `"kael.schedule_inconsistency"` — Kael's routine doesn't fully match the posted schedule
|
|
- `"ring.internal_pressure"` — the smuggler learns the ring is under strain
|
|
- 5-8 dialogue lines for Trust phase (deeper, more personal, sharing more)
|
|
- 3-5 monologue lines for Doubt phase (questioning: "Kael left early again. Third time this rotation. Where does he go?")
|
|
- Tell acceleration: Kael's tells become more frequent in Doubt phase. The `tell_stage` counter advances, making tells more visible
|
|
|
|
**Content required for Sera (detective's FRIEND):**
|
|
- Doubt-triggering facts:
|
|
- `"sera.avoidance_pattern"` — Sera consistently avoids Torek Lintar
|
|
- `"investigation.evidence_gap"` — evidence exists that should have been reported but wasn't
|
|
- Same line counts as Kael, detective-perspective
|
|
|
|
**Server state change:** `FriendArc.phase = Doubt`. Tell stage advances (`tell_stage += 1`). Monologue pool shifts. No visual change yet — the entity color stays green (Friendly). The player should feel uneasy but not alarmed.
|
|
|
|
#### Phase 3: DOUBT → CONFLICT
|
|
|
|
**Trigger:** Direct observation of the contradiction. This is the critical moment — wow moment #3.
|
|
|
|
```rust
|
|
/// Transition from Doubt to Conflict.
|
|
/// Requires DIRECT OBSERVATION of the contradiction.
|
|
/// This cannot be told to the player — they must SEE it.
|
|
fn check_doubt_to_conflict(
|
|
friend_arc: &FriendArc,
|
|
observer_kg: &KnowledgeGraph,
|
|
) -> bool {
|
|
// ALL contradiction facts must be known at KnowsDetails or higher.
|
|
// At least one must have source = DirectObservation.
|
|
let all_known = friend_arc.contradiction_facts.iter().all(|fact_id| {
|
|
observer_kg.fact_at_least(fact_id, KnowledgeConfidence::KnowsDetails)
|
|
});
|
|
|
|
// The discovery flag is set by the spatial staging system
|
|
// when the player observes the FRIEND in the wrong place.
|
|
all_known && friend_arc.contradiction_discovered
|
|
}
|
|
```
|
|
|
|
**How `contradiction_discovered` gets set — spatial staging:**
|
|
|
|
This is the hardest part. The server must arrange for the FRIEND to be in a specific place at a specific time, and the player must be able to observe it. The simulation can't script this — it must create the opportunity and let the player find it.
|
|
|
|
```rust
|
|
/// System: check if the bonded character observes the FRIEND
|
|
/// in a contradiction location during a contradiction window.
|
|
fn check_contradiction_observation(
|
|
friend_query: Query<(&FriendArc, &NpcRoutine, &TilePosition), With<Npc>>,
|
|
observer_query: Query<(&KnowledgeGraph, &TilePosition), With<PlayerCharacter>>,
|
|
location_map: Res<LocationMap>,
|
|
time: Res<SimulationTime>,
|
|
mut event_queue: ResMut<KnowledgeEventQueue>,
|
|
) {
|
|
for (arc, routine, npc_pos) in friend_query.iter() {
|
|
if arc.phase != FriendPhase::Doubt {
|
|
continue;
|
|
}
|
|
|
|
// Is the FRIEND currently at their contradiction location?
|
|
let at_contradiction = arc.contradiction_location.as_ref()
|
|
.map(|loc| location_map.contains_position(loc, npc_pos))
|
|
.unwrap_or(false);
|
|
|
|
// Is it the right time window?
|
|
let in_window = arc.contradiction_time_window.as_ref()
|
|
.map(|w| w.active_at(&time))
|
|
.unwrap_or(true); // no time window = always active
|
|
|
|
if !at_contradiction || !in_window {
|
|
continue;
|
|
}
|
|
|
|
// Is the bonded character observing?
|
|
// (They must have LOS to the FRIEND's current position)
|
|
// The perception system handles LOS. We check if the bonded
|
|
// character has Direct confidence on the FRIEND.
|
|
for (obs_kg, _obs_pos) in observer_query.iter() {
|
|
if let Some(entry) = obs_kg.entity_knowledge(&arc.friend_stable_id) {
|
|
if entry.confidence == KnowledgeConfidence::Direct {
|
|
// The player character is looking at their FRIEND
|
|
// in the wrong place at the wrong time.
|
|
// This is wow moment #3.
|
|
|
|
// Emit knowledge event for contradiction facts
|
|
// Emit monologue trigger (observe_npc with deviation)
|
|
// Set contradiction_discovered = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Content required for the contradiction moment:**
|
|
- **Kael:** Observed in Corridor B-7 (maintenance corridors) during shift transition, meeting unknown contact. The smuggler knows Kael has no reason to be there.
|
|
- Monologue line (urgent chime): "Kael? In Corridor B-7? During shift transition? He has no reason to be there. None that I know of." (`terminal_m_s_012` from Tyre's example)
|
|
- Knowledge grant: `"kael.secret_meetings": KnowsDetails`
|
|
- Knowledge grant: `"kael.restricted_corridor_visits": KnowsDetails`
|
|
- **Sera:** Observed avoiding Torek Lintar (turning away when he enters the bar). The detective recognizes the pattern.
|
|
- Monologue line (urgent chime): detective-equivalent — recognizing deliberate avoidance
|
|
- Knowledge grant: `"sera.avoidance_pattern": KnowsDetails`
|
|
|
|
**Server state change:** `FriendArc.phase = Conflict`. `FriendArc.contradiction_discovered = true`. **RelationshipState shifts: Friendly → PersonOfInterest.** Entity color shifts green → amber (D-033, 0.5s fade). Tell stage maxes out. Dialogue pool shifts to Conflict-phase lines.
|
|
|
|
This is the single most important moment in the vertical slice. State consistency requires:
|
|
1. Knowledge graph updated with contradiction facts
|
|
2. FriendArc phase advanced
|
|
3. RelationshipState changed
|
|
4. Monologue triggered (urgent)
|
|
5. Entity color change visible in next ObserverSnapshot
|
|
|
|
All five must happen atomically within the same tick. I need to verify the tick order handles this correctly — knowledge event processing must complete before snapshot generation.
|
|
|
|
#### Phase 4: CONFLICT (Terminal)
|
|
|
|
**Trigger:** None — this is the final phase. The player is in Conflict with their FRIEND.
|
|
|
|
**Content required:**
|
|
- 8-12 Conflict-phase dialogue lines (confrontation options, defensive NPC responses, the NPC's sympathetic motivation revealed)
|
|
- 5-8 Conflict-phase monologue lines (the character processing betrayal, re-evaluating past interactions)
|
|
- No clean resolution — D-034 mandates this. The conversation can happen, understanding can emerge, but the trust is broken
|
|
|
|
**Server state:** `FriendArc.phase = Conflict`. RelationshipState stays PersonOfInterest (or shifts to Hostile if the confrontation goes badly). The arc is complete.
|
|
|
|
### Revised FriendArc Component
|
|
|
|
```rust
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FriendArc {
|
|
/// Which character this FRIEND is bonded to (StableId).
|
|
pub bonded_character: StableId,
|
|
/// StableId of this FRIEND NPC (for knowledge graph lookups).
|
|
pub friend_stable_id: StableId,
|
|
/// Current phase.
|
|
pub phase: FriendPhase,
|
|
|
|
// --- Transition triggers ---
|
|
|
|
/// Facts that trigger Doubt phase (any one sufficient).
|
|
pub doubt_trigger_facts: Vec<FactId>,
|
|
/// Facts that constitute the full contradiction (all required for Conflict).
|
|
pub contradiction_facts: Vec<FactId>,
|
|
/// Location where the contradiction occurs.
|
|
pub contradiction_location: Option<String>, // canonical location ID
|
|
/// Time window for the contradiction (day phase + optional minute range).
|
|
pub contradiction_time_window: Option<TimeWindow>,
|
|
|
|
// --- State tracking ---
|
|
|
|
/// Tell visibility stage (0 = minimal, advances as phase progresses).
|
|
pub tell_stage: u8,
|
|
/// Has the bonded character directly observed the contradiction?
|
|
pub contradiction_discovered: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TimeWindow {
|
|
pub phase: DayPhase,
|
|
pub start_minute: Option<u64>, // within the phase
|
|
pub end_minute: Option<u64>,
|
|
}
|
|
|
|
impl TimeWindow {
|
|
pub fn active_at(&self, time: &SimulationTime) -> bool {
|
|
if time.day_phase() != self.phase {
|
|
return false;
|
|
}
|
|
let tod = time.time_of_day_minutes();
|
|
let phase_start = match self.phase {
|
|
DayPhase::Morning => 0,
|
|
DayPhase::Afternoon => 360,
|
|
DayPhase::Evening => 720,
|
|
DayPhase::Night => 1080,
|
|
};
|
|
let start = phase_start + self.start_minute.unwrap_or(0);
|
|
let end = phase_start + self.end_minute.unwrap_or(360);
|
|
tod >= start && tod < end
|
|
}
|
|
}
|
|
```
|
|
|
|
### Content Summary — What I Need from Mellanie and Paula
|
|
|
|
| FRIEND NPC | Phase | Lines Needed | Type | Blocker? |
|
|
|-----------|-------|-------------|------|----------|
|
|
| **Kael** | Warmth | 5-8 | dialogue (casual, friendly) | No — generic enough to synthesize |
|
|
| **Kael** | Warmth | 3-5 | monologue (positive, routine) | No |
|
|
| **Kael** | Trust | 5-8 | dialogue (personal, deeper) | Yes — needs character voice |
|
|
| **Kael** | Trust | 3-5 | monologue (warming, comfortable) | Yes |
|
|
| **Kael** | Doubt | 5-8 | dialogue (evasive, stressed) | Yes |
|
|
| **Kael** | Doubt | 3-5 | monologue (questioning, uneasy) | Yes |
|
|
| **Kael** | Conflict | 8-12 | dialogue (confrontation, revelation) | Yes — most critical |
|
|
| **Kael** | Conflict | 5-8 | monologue (processing, betrayal) | Yes |
|
|
| **Kael** | Contradiction | 1 | monologue (urgent, wow moment #3) | **CRITICAL** |
|
|
| **Sera** | (same counts) | ~35-46 | (same breakdown) | Same blockers |
|
|
|
|
**Total per FRIEND NPC: ~35-46 authored lines minimum.**
|
|
**Total for both: ~70-92 lines.**
|
|
|
|
This aligns with D-034's estimate of 70-100 per FRIEND. The breakdown by phase is what I need to test each transition independently.
|
|
|
|
### Interaction Log Component
|
|
|
|
I need a lightweight interaction tracking component to support the Warmth → Trust transition:
|
|
|
|
```rust
|
|
/// Tracks interaction history between entities.
|
|
/// Used for FriendArc Warmth→Trust transition and future NOBODY promotion.
|
|
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct InteractionLog {
|
|
/// (target_stable_id, tick) pairs.
|
|
/// BTreeMap for deterministic iteration.
|
|
pub interactions: BTreeMap<StableId, Vec<u64>>,
|
|
}
|
|
|
|
impl InteractionLog {
|
|
pub fn new() -> Self {
|
|
Self { interactions: BTreeMap::new() }
|
|
}
|
|
|
|
pub fn record(&mut self, target: StableId, tick: u64) {
|
|
self.interactions.entry(target).or_default().push(tick);
|
|
}
|
|
|
|
pub fn count(&self, target: &StableId) -> usize {
|
|
self.interactions.get(target).map(|v| v.len()).unwrap_or(0)
|
|
}
|
|
|
|
pub fn first_tick(&self, target: &StableId) -> Option<u64> {
|
|
self.interactions.get(target).and_then(|v| v.first().copied())
|
|
}
|
|
}
|
|
```
|
|
|
|
This also becomes the foundation for NOBODY tier promotion in v0.2 — the same interaction counter that drives FriendArc Warmth→Trust will drive NOBODY→NOTICED. Building it now means zero rework later.
|
|
|
|
---
|
|
|
|
## Summary of Round 2 Deliverables
|
|
|
|
| Decision | Resolution |
|
|
|----------|-----------|
|
|
| **YAML format** | Accepted. `serde_yaml` replaces `ron` for content loading. RON converter in `tooling/` as low-priority insurance. |
|
|
| **Multi-action model** | Server computes ALL available actions per entity, ranked by priority. v0.1 client shows `actions[0]`. v0.2 client shows all. Zero server changes needed for v0.2. |
|
|
| **Pause system** | `SimSpeed` enum: Normal (1.0x), Overlay (0.5x), Paused (0.0x). Spacebar always toggles full pause. UI overlays trigger Overlay speed. Speed stack with priority resolution. |
|
|
| **FriendArc transitions** | 4-phase state machine with explicit triggers: Warmth (interaction count + time) → Trust (knowledge threshold) → Doubt (specific facts) → Conflict (direct observation of contradiction). Contradiction detection requires spatial staging system. |
|
|
|
|
### Dependencies I'm Tracking
|
|
|
|
1. **ObserverSnapshot v3** — Tyre needs to sign off on the protocol extension. I've proposed the full struct. Stig needs the `sim_speed` field and `nearby_interactions` / `active_dialogue` / `monologue` / `overheard` fields.
|
|
2. **FRIEND content packs** — Mellanie needs to produce phase-tagged dialogue and monologue lines per the breakdown above. These unblock integration testing of the FriendArc state machine.
|
|
3. **Contradiction spatial data** — Paula needs to specify the exact contradiction setup for Kael and Sera in the NPC profiles: which location, which time window, what the contradiction looks like when observed.
|
|
4. **FactId catalog** — Still the #1 blocker. I need the machine-readable fact vocabulary before I can build prerequisite evaluation. Gestalt and Paula produce this.
|