Workshop outputs from Knowledge Flow & NPC Information Boundaries workshop (2026-02-24): Decisions registered in decisions/perception.md: - D-079: Knowledge Grant Architecture (unified KnowledgeGranted event, Fact+Entity enum, ContentEntityRegistry) - D-080: NPC-to-NPC Knowledge Propagation (transfer_npc_knowledge system, trust-tier gating, ToldBy source) - D-081: Unprompted Disclosure Design (DisclosureCandidates component, two-stage trait filter, trigger gates) - D-082: NPC Information Boundaries MVP Scope (tell_state + disclosure; pathfinding KG integration explicitly not planned) - D-083: Contradiction Detection Pipeline (event-driven at observe_entity, ContradictionClaim struct, downstream chain) Questions closed in decisions/questions.md: - Q-024: closed by D-080 (immediate during conversation, not queued) - Q-025: confirmed closed (gossip propagation ships Sprint 17; ~12MB peak, no cap needed before v0.3) - Q-026: closed by D-083 (event-driven at KG write time, ContradictionClaim struct) Tickets created (#545–#551, Sprint 17, team server): - #545 KnowledgeGrant schema + ContentEntityRegistry (critical, blocks all downstream) - #546 KnowledgeGranted event + process_knowledge_events handler - #547 ContradictionClaim struct + detection in observe_entity - #548 NPC-to-NPC knowledge transfer system - #549 tell_state.rs KG awareness — MVP information boundary - #550 Contradiction monologue + event chain completion - #551 DisclosureCandidates + unprompted disclosure Existing tickets updated: #141, #142 (sprint 17 + team assigned, descriptions updated); #172, #173 (full mechanical spec from D-081). Workshop source files committed: all round docs + workshop-outcomes.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
495 lines
26 KiB
Markdown
495 lines
26 KiB
Markdown
# Workshop Round 2 — Dudley: Cross-Review and Synthesis
|
|
|
|
**Workshop:** Knowledge Flow & NPC Information Boundaries
|
|
**Author:** Dudley (Server Developer)
|
|
**Date:** 2026-02-23
|
|
**Focus:** Tension resolution, implementation ticket specs, implementation order
|
|
|
|
---
|
|
|
|
## Reading Summary
|
|
|
|
After reviewing all Round 1 outputs:
|
|
|
|
- Tyre and Gestalt have converged on architecturally identical solutions for Tension B (`ContradictionClaim` struct). Different field names, same structure.
|
|
- Paula makes the blocking case for Tension A (entity grants in Sprint 17) that I cannot dismiss on architectural grounds — only on infrastructure grounds I am now reconsidering.
|
|
- Gestalt raises the only substantive dissent (no global disclosure rate limit) that I will address.
|
|
- Two open questions are directed at me specifically: the `DisclosureCandidates` compute trigger and the StableId→name lookup in the monologue system. Both have answers.
|
|
|
|
---
|
|
|
|
## Tension A: Entity Grants — Position Change
|
|
|
|
**I am changing my position. Entity grants are Sprint 17 scope.**
|
|
|
|
My Round 1 concern was: entity grants require a "name → StableId" content registry that doesn't exist, and building it is non-trivial. Let me examine this more carefully.
|
|
|
|
### The actual infrastructure needed
|
|
|
|
Tyre's proposal stores `entity_ref: "kael"` in the YAML and resolves it to `StableId` at content load via a `BTreeMap<String, StableId>`. The question I raised: where does this map come from?
|
|
|
|
The answer: **resolve at spawn time, not content load time.** When an authored NPC is spawned from its content definition (which includes an authored identifier like `"kael"`), the spawn system inserts `"kael" → StableId(N)` into a new `ContentEntityRegistry` resource. The content definition already has the authored identifier — NPCs need to be referenced in dialogue YAML. The spawn registration is ~5 lines per spawn site.
|
|
|
|
At grant processing time, `process_knowledge_events` queries `ContentEntityRegistry` to resolve `entity_id: "kael"` → `StableId(N)`. If the entity is not yet in the registry (not yet spawned), the grant is dropped with `tracing::warn!` and a log entry. This is graceful — no panic, no undefined behavior.
|
|
|
|
```
|
|
ContentEntityRegistry: BTreeMap<String, StableId>
|
|
|
|
|
├── Populated at: NPC spawn from authored content
|
|
├── Read by: process_knowledge_events (entity grant processing)
|
|
└── Size: O(authored NPCs) — small, ~20-50 entries for v0.1
|
|
```
|
|
|
|
This is genuinely minimal infrastructure. I was treating it as a large unknown; it is a ~60-line addition.
|
|
|
|
### Why my workaround was worse
|
|
|
|
My Round 1 alternative — structured FactId strings like `FactId("entity.kael.position.second_shift_dock")` — fails Paula's test: contradiction detection operates on `EntityKnowledge.last_known_position`, not on `FactKnowledge`. A `DirectObservation` updates `EntityKnowledge` (via `observe_entity`). If Sera's testimony only creates a `FactKnowledge` entry, the contradiction detector in `observe_entity` has nothing to compare against. The chain breaks at step 4 of THE FRIEND arc.
|
|
|
|
Tyre and Gestalt are correct. Paula's blocking case is sound.
|
|
|
|
### Confirmed schema: two-type grant
|
|
|
|
For Sprint 17: `FactGrant` and `EntityGrant`. `Compound` (grants that do both simultaneously) can be Sprint 18.
|
|
|
|
```yaml
|
|
# Fact grant (existing format, backwards-compatible):
|
|
knowledge_grant:
|
|
fact_id: "poi.dock_7_restricted"
|
|
confidence: "knows_of"
|
|
|
|
# Entity grant (new format):
|
|
knowledge_grant:
|
|
entity_ref: "kael"
|
|
attributes:
|
|
location: "dock-7"
|
|
shift: "second"
|
|
confidence: "knows_of"
|
|
# source is always ToldBy { source_id: speaking_npc_sid, tick } — inferred by system
|
|
```
|
|
|
|
The Rust type:
|
|
|
|
```rust
|
|
// In server/src/content/types.rs — replaces current KnowledgeGrant struct
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum KnowledgeGrant {
|
|
Fact {
|
|
fact_id: String,
|
|
confidence: String,
|
|
},
|
|
Entity {
|
|
entity_ref: String,
|
|
#[serde(default)]
|
|
attributes: std::collections::BTreeMap<String, String>,
|
|
confidence: String,
|
|
#[serde(default)]
|
|
disclosure_blocked: bool,
|
|
},
|
|
}
|
|
```
|
|
|
|
The `#[serde(untagged)]` attribute allows the existing `fact_id/confidence` YAML format to deserialize as `Fact` variant without any content changes to existing YAML files. Backwards compatible.
|
|
|
|
---
|
|
|
|
## Tension B: ContradictionClaim Struct — Position Change
|
|
|
|
**I am conceding Option B. The `ContradictionClaim` struct approach is correct.**
|
|
|
|
Tyre and Gestalt have proposed structurally identical solutions. My Option B (encoding position claims as structured `known_attributes` strings) has three problems I underweighted:
|
|
|
|
1. **String parsing is fragile.** A malformed string `"claim.T1.position" = "dock_7,T1,sera_sid"` silently fails. The struct approach fails loudly at compile time.
|
|
2. **It doesn't help the monologue system.** The monologue needs to display "Sera said Kael was at the dock" — which requires `ToldBy { source_id: sera_sid }` as a typed field, not a parsed string. Paula's requirement for source-named contradiction monologues is correct and it requires the struct.
|
|
3. **Zero schema changes was a false economy.** One optional field on `EntityKnowledge` is a smaller change than the refactor obligation Option B creates.
|
|
|
|
### Agreed struct design
|
|
|
|
Aligning on Tyre's naming (`ContradictionClaim`) — the field is specifically "the claim that was contradicted," which is precise:
|
|
|
|
```rust
|
|
// In server/src/knowledge/types.rs — add to EntityKnowledge
|
|
pub struct EntityKnowledge {
|
|
// ... all existing fields unchanged ...
|
|
|
|
/// When state is Contradicted: the prior claim that conflicts with the
|
|
/// current observation. Populated by contradiction detection in observe_entity().
|
|
/// None while state is Active or Stale.
|
|
pub contradicted_claim: Option<ContradictionClaim>,
|
|
}
|
|
|
|
pub struct ContradictionClaim {
|
|
/// Who made the contradicted claim (typically ToldBy { source_id, tick }).
|
|
pub source: KnowledgeSource,
|
|
/// Where they claimed the entity was.
|
|
pub claimed_position: Option<TilePosition>,
|
|
/// Tick when the contradiction was detected.
|
|
pub detected_at_tick: u64,
|
|
}
|
|
```
|
|
|
|
Detection in `observe_entity()` at graph.rs line 111, before the overwrite:
|
|
|
|
```rust
|
|
// BEFORE updating the entry: check for contradiction
|
|
if let Some(entry) = self.entities.get_mut(&target) {
|
|
if let KnowledgeSource::ToldBy { tick: told_tick, .. } = entry.source {
|
|
if let Some(old_pos) = entry.last_known_position {
|
|
if old_pos != position
|
|
&& current_tick.saturating_sub(told_tick) < CONTRADICTION_WINDOW_TICKS
|
|
{
|
|
entry.state = KnowledgeState::Contradicted;
|
|
entry.contradicted_claim = Some(ContradictionClaim {
|
|
source: entry.source.clone(),
|
|
claimed_position: entry.last_known_position,
|
|
detected_at_tick: current_tick,
|
|
});
|
|
// Don't return — fall through to update position and source
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
The `CONTRADICTION_WINDOW_TICKS` constant: **600 ticks (1 game-hour)**. This is Gestalt's proposal. Rationale: within a game-hour, a claim about "where Kael was during second shift" is not stale — second shift hasn't ended. Beyond a game-hour, the decay system should mark entries Stale, not Contradicted. The contradiction is a fresh conflict, not an archaeology project.
|
|
|
|
---
|
|
|
|
## Open Questions Directed at Dudley — Answers
|
|
|
|
### DisclosureCandidates compute trigger
|
|
|
|
Gestalt asks: is there a "player entered dialogue range" event or does this need a range-query every N ticks?
|
|
|
|
**Answer: range-query in the existing Active NPC processing loop, every tick, gated on distance.**
|
|
|
|
There is no "player entered dialogue range" event — the game doesn't have a proximity-event system. The disclosure system should piggyback on the conversation system's existing proximity check pattern:
|
|
|
|
```rust
|
|
// In derive_disclosure_candidates system:
|
|
// Query Active NPCs within CONVERSATION_PROXIMITY of the player.
|
|
// Same O(N_active) scan the conversation system already does.
|
|
let player_pos = player_query.single().0;
|
|
for (npc_entity, npc_pos, npc_kg, ...) in npc_query.iter() {
|
|
let dist = npc_pos.manhattan_distance(player_pos).unwrap_or(u32::MAX);
|
|
if dist > DISCLOSURE_RANGE {
|
|
continue; // Skip NPCs far from player
|
|
}
|
|
// Derive candidates for this NPC
|
|
}
|
|
```
|
|
|
|
`DISCLOSURE_RANGE` can match `CONVERSATION_PROXIMITY` (3 tiles) or be slightly larger. This is O(N_active) per tick — at 30-80 Active NPCs, this is ~60 comparisons per tick. Negligible.
|
|
|
|
The `DisclosureCandidates` component acts as a cache. It's only populated for NPCs within range and expires after a configurable number of ticks (suggest 30 ticks = 3 game-minutes). If the NPC moves out of range or the player moves away, the component decays and is not repopulated.
|
|
|
|
### Monologue StableId → displayable name lookup
|
|
|
|
Gestalt asks: is `EntityRegistry + NpcName` available from the monologue system context?
|
|
|
|
**Answer: yes, with a small signature addition.**
|
|
|
|
The monologue system at `server/src/simulation/monologue.rs` currently takes:
|
|
- `ContentStoreResource`
|
|
- `SimRng`
|
|
- `SimulationTime`
|
|
- Player entity queries
|
|
|
|
Adding `EntityRegistry` and a `Query<&NpcName>` to the system signature is ~5 lines. The lookup chain:
|
|
|
|
```rust
|
|
fn resolve_name(
|
|
source_id: &StableId,
|
|
registry: &EntityRegistry,
|
|
name_query: &Query<&NpcName>,
|
|
) -> String {
|
|
registry.to_entity(source_id)
|
|
.and_then(|entity| name_query.get(entity).ok())
|
|
.map(|name| name.0.clone())
|
|
.unwrap_or_else(|| format!("Unknown({})", source_id.0))
|
|
}
|
|
```
|
|
|
|
This is available and works. The monologue system CAN produce "Sera said Kael was at the dock" as Paula requires.
|
|
|
|
For THE FRIEND arc specifically: Paula's Option 3 (generic fallback + hand-authored override) is the right authoring model. The monologue pool gets a `trigger: contradiction_detected` variant. The system selects a line, then substitutes `{source_name}`, `{entity_name}`, `{claimed_location}`, `{actual_location}` from the `ContradictionClaim` struct. For FRIEND-pattern NPCs (Sera, Kael), Mellanie authors specific lines that are selected first via the priority system; the generic template serves all other cases.
|
|
|
|
Parameterized string substitution is ~20 lines in the monologue emission path. Worth it for the emotional payoff.
|
|
|
|
---
|
|
|
|
## Secondary Disputes — Positions
|
|
|
|
### Global disclosure rate limit (3-vs-1 split with Gestalt dissenting)
|
|
|
|
Gestalt argues: a global limit is invisible to the player and creates unintelligible competition.
|
|
|
|
This is correct but incomplete. The global limit is NOT a gameplay-visible competition — it is a degenerate-case safeguard. Consider: the player enters a crowded room with 15 Active NPCs, all at Friendly relationship, all with disclosure candidates. Without a global limit, 15 NPCs may attempt disclosure in the same tick. The per-NPC disclosure cooldown (300 ticks) prevents repeat disclosures from ONE NPC but does nothing about simultaneous first-time disclosures from MANY NPCs.
|
|
|
|
**Resolution: include the global limit, but make it predictable rather than random.** NPCs are evaluated in `StableId` order (ascending). The first NPC in StableId order whose gates all pass fires its disclosure. The cap is 1 per tick. This means:
|
|
- The competition is deterministic (D-010 principle 4)
|
|
- The same-tick scenario resolves gracefully
|
|
- No NPC is silently blocked from EVER disclosing — they simply fire on a different tick
|
|
|
|
The cap is a tick-granularity concern only. Over game-minutes, every NPC with a valid disclosure eventually fires. Gestalt's concern applies to a cap that permanently suppresses NPCs; this one does not.
|
|
|
|
### Trust-weighted transfer count (Paula's 0-1/1-2/up-to-3 vs flat 1-3)
|
|
|
|
Paula's weighting adds a trust-tier dependency. I adopt it — it's cleaner narratively and the implementation is trivial:
|
|
|
|
```rust
|
|
let max_transfers = match trust_tier {
|
|
TrustTier::None => return, // no transfer
|
|
TrustTier::Surface => rng.random_range(0..=1),
|
|
TrustTier::Real => rng.random_range(1..=2),
|
|
TrustTier::Secret => rng.random_range(2..=3),
|
|
};
|
|
```
|
|
|
|
Surface trust NPC pairs may transfer 0 facts (nothing worth saying). This makes surface trust feel surface-level.
|
|
|
|
### Major secret `disclosure_blocked` flag (Paula's addition)
|
|
|
|
Paula's proposal: a per-KG-entry flag preventing transfer even at maximum trust tier.
|
|
|
|
**Include it.** Implementation: `disclosure_blocked: bool` field on `FactKnowledge` (default `false`). The `transfer_npc_knowledge` system skips entries where `disclosure_blocked == true`. Content authors mark Major-secret facts as `disclosure_blocked: true` in the initial KG YAML.
|
|
|
|
This is ~15 lines total and correctly models "some things you never tell anyone regardless of trust."
|
|
|
|
### Witness inhibition + location privacy gate (Gestalt + Paula additions)
|
|
|
|
Both are additive trigger gates for unprompted disclosure. I include both:
|
|
|
|
**Witness inhibition** (Gestalt): count Active NPCs within 5 tiles of the disclosing NPC. If `count > 2`, disclosure is suppressed unless the NPC's trait overrides it (a `Talkative` NPC ignores witnesses).
|
|
|
|
**Location privacy** (Paula): disclosure candidates can be tagged `"location_privacy: private|semi_private|any"` in content. The disclosure trigger checks the NPC's current location zone tag. A `private`-tagged fact won't fire at `The Terminal`. This creates the spatial behavior pattern Paula describes: "If you want Sera to confide, find her at Lera's."
|
|
|
|
Implementation of both gates: ~30 lines combined. Both use data already available in the system (NPC positions for witness count, location zone tags for privacy).
|
|
|
|
### Runtime NPC KG guardrail
|
|
|
|
Tyre proposes a 3-line runtime check; I called it Tier 3 in Round 1.
|
|
|
|
**I recalibrate: include it.** Tyre's 3-line check is straightforward once the entity grant architecture is in place. The check is:
|
|
|
|
```rust
|
|
// For fact grants: verify granting NPC knows the fact
|
|
if let Some(npc_kg) = npc_kg_query.get(granting_npc_entity).ok() {
|
|
if !npc_kg.knows_fact(&fact_id) {
|
|
tracing::warn!("NPC {} granted unknown fact {}", npc_sid.0, fact_id.0);
|
|
continue;
|
|
}
|
|
}
|
|
// For entity grants: verify granting NPC knows the target entity
|
|
// (check entities BTreeMap contains target_sid at appropriate confidence)
|
|
```
|
|
|
|
This handles the runtime KG decay case Gestalt identifies: a dialogue line remains eligible after the NPC's KG decays — the runtime check catches it. Include in Sprint 17.
|
|
|
|
---
|
|
|
|
## Implementation Ticket Specifications
|
|
|
|
### Ticket A: KnowledgeGrant schema + ContentEntityRegistry
|
|
|
|
**Depends on:** Nothing (foundational)
|
|
**Blocks:** Tickets B, D, E, F
|
|
|
|
**Changes:**
|
|
- `server/src/content/types.rs`: Replace `KnowledgeGrant` struct with `KnowledgeGrant` enum (`#[serde(untagged)]`); add `disclosure_blocked: bool` field to `Entity` variant
|
|
- `server/src/content/line_pool.rs`: Update `IndexedDialogueLine.knowledge_grant` type; update index building to parse both variants
|
|
- `server/src/knowledge/types.rs`: Add `disclosure_blocked: bool` to `FactKnowledge` (default `false`)
|
|
- New file `server/src/knowledge/content_registry.rs`: `ContentEntityRegistry` resource — `BTreeMap<String, StableId>` + `register(content_id, stable_id)` + `resolve(content_id) -> Option<StableId>`
|
|
- NPC spawn sites: add `content_registry.register(npc_content_id, sid)` call
|
|
|
|
**Line estimate:** ~120 lines total
|
|
|
|
---
|
|
|
|
### Ticket B: KnowledgeGranted event + process_knowledge_events handler
|
|
|
|
**Depends on:** Ticket A (ContentEntityRegistry, schema)
|
|
**Blocks:** Tickets D, E, F
|
|
|
|
**Changes:**
|
|
- `server/src/knowledge/events.rs`: Add `KnowledgeEventType::KnowledgeGranted` variant:
|
|
```rust
|
|
KnowledgeGranted {
|
|
recipient: Entity,
|
|
grant: ProcessedKnowledgeGrant,
|
|
granting_npc: Option<Entity>, // None for evidence/POI discovery
|
|
}
|
|
|
|
pub enum ProcessedKnowledgeGrant {
|
|
Fact { fact_id: FactId, confidence: KnowledgeConfidence },
|
|
Entity { target_sid: StableId, attributes: BTreeMap<String, String>, confidence: KnowledgeConfidence },
|
|
}
|
|
```
|
|
- `server/src/knowledge/events.rs`: Add match arm in `process_knowledge_events` for `KnowledgeGranted`:
|
|
- For `Fact`: `observer_kg.facts.insert(fact_id, FactKnowledge { confidence, source: ToldBy/DirectObservation, ... })`
|
|
- For `Entity`: `observer_kg.entities.entry(target_sid).or_insert_with(...)` with `source: ToldBy { source_id: granting_npc_sid, tick }`
|
|
- Runtime guardrail: if `granting_npc.is_some()`, verify granting NPC's KG contains the granted fact/entity before applying
|
|
- `server/src/knowledge/types.rs`: Add `TryFrom<&str> for KnowledgeConfidence` for parsing confidence strings
|
|
- `server/src/simulation/dialogue.rs`: In `process_talk_interaction`, after line selection, if `selected_line.knowledge_grant.is_some()`, push `KnowledgeGranted` event
|
|
|
|
**Line estimate:** ~150 lines
|
|
|
|
---
|
|
|
|
### Ticket C: ContradictionClaim struct + detection in observe_entity
|
|
|
|
**Depends on:** Nothing (changes only `types.rs` and `graph.rs`)
|
|
**Blocks:** Ticket F (contradiction monologue)
|
|
|
|
**Changes:**
|
|
- `server/src/knowledge/types.rs`: Add `ContradictionClaim` struct; add `contradicted_claim: Option<ContradictionClaim>` to `EntityKnowledge`; add `CONTRADICTION_WINDOW_TICKS: u64 = 600` constant
|
|
- `server/src/knowledge/graph.rs`: In `observe_entity()`, add pre-overwrite contradiction check (see algorithm above); push `ContradictionDetected` event when detected
|
|
- `server/src/knowledge/events.rs`: Add `KnowledgeEventType::ContradictionDetected { observer: Entity, entity_sid: StableId }` variant; add processing in `process_knowledge_events` that fires relationship shift for the `ToldBy` source entity
|
|
- `server/src/knowledge/graph.rs`: Tests — location contradiction, time window boundary, no-false-positive for non-ToldBy sources
|
|
|
|
**Line estimate:** ~120 lines + ~40 lines of tests
|
|
|
|
---
|
|
|
|
### Ticket D: NPC-to-NPC knowledge transfer system
|
|
|
|
**Depends on:** Ticket B (KnowledgeGranted event infrastructure)
|
|
**Blocks:** Ticket E (contradiction detection needs ToldBy entries)
|
|
|
|
**Changes:**
|
|
- New file `server/src/simulation/npc_knowledge_transfer.rs`: System `transfer_npc_knowledge`
|
|
- Queries `NpcConversation` components (identifies conversation pairs)
|
|
- Reads speaker's `KnowledgeGraph` + `Relationships` for trust-tier lookup
|
|
- Calls `KnowledgeEventQueue.push(KnowledgeGranted { ... })` for each transferred fact
|
|
- Trust-weighted rate: `Surface → 0..=1`, `Real → 1..=2`, `Secret → 2..=3` (SimRng drawn)
|
|
- Confidence downgrade: `min(source_confidence, KnowsOf)` via `KnowledgeConfidence::min()`
|
|
- Skips entries with `disclosure_blocked == true`
|
|
- `KnowledgeSource::ToldBy { source_id: speaker_sid, tick: current_tick }`
|
|
- Overheard grant for player: if player within `VOICE_RANGE_TILES`, push entity-level `KnowledgeGranted` at `Suspects` confidence with `KnowledgeSource::Heard { tick, range: Medium }`
|
|
- `server/src/simulation/conversation.rs`: Register `transfer_npc_knowledge.after(run_npc_conversations)` in system ordering
|
|
- ECS constraint note: `transfer_npc_knowledge` uses `get_many_mut([entity_a, entity_b])` for dual-mutable KG access — this requires the system to own the query, not inline it in `run_npc_conversations`
|
|
|
|
**Line estimate:** ~160 lines
|
|
|
|
---
|
|
|
|
### Ticket E: tell_state.rs KG awareness (MVP boundary #142)
|
|
|
|
**Depends on:** Ticket B (needed for KG to contain meaningful content)
|
|
**Blocks:** Nothing (standalone improvement)
|
|
|
|
**Changes:**
|
|
- `server/src/npc/tell_state.rs`: Add `Option<&KnowledgeGraph>` to `derive_tell_state` query
|
|
- Replace the `Friendly` tell's direct `relationships.entries` read (lines 105-112) with `kg.relationship_with(&entity_sid)` query against observed entities
|
|
- Note: self-axes (`Secret`, `Contentment`, `Tolerance`) remain ground-truth reads — KG applies to other-entity state only
|
|
- Tests: existing tests continue to pass (KG is optional, None falls back to current behavior)
|
|
|
|
**Line estimate:** ~35 lines
|
|
|
|
---
|
|
|
|
### Ticket F: Contradiction monologue + event chain completion
|
|
|
|
**Depends on:** Tickets B + C (ToldBy entries + ContradictionClaim struct)
|
|
**Blocks:** Nothing (downstream consumers already exist)
|
|
|
|
**Changes:**
|
|
- `server/src/simulation/monologue.rs`: Add `EntityRegistry` + `Query<&NpcName>` to system signature; add `resolve_name(source_id, registry, name_query)` helper (~15 lines); add match arm for `ContradictionDetected` events that selects contradiction monologue line with name substitution
|
|
- `server/src/knowledge/events.rs`: In `ContradictionDetected` processing, call `kg.set_relationship(&told_by_source_sid, RelationshipState::PersonOfInterest)` — shifts Sera to amber (D-033 downstream via existing pipeline)
|
|
- Monologue pool (content): Add `trigger: contradiction_detected` line category with `{source_name}`, `{entity_name}`, `{claimed_location}`, `{actual_location}` substitution tokens. Generic fallback line authored by Mellanie; FRIEND-specific lines separately authored.
|
|
- Test: THE FRIEND arc integration test — Tick 100 grant creates ToldBy, Tick 150 DirectObservation triggers contradiction, monologue fires with correct names, Sera shifts to PersonOfInterest, AnomalyMarker set on Kael
|
|
|
|
**Line estimate:** ~90 lines + content (Mellanie)
|
|
|
|
---
|
|
|
|
### Ticket G: DisclosureCandidates + Unprompted Disclosure system (#172)
|
|
|
|
**Depends on:** Tickets B + D (KG must have meaningful NPC content before this is useful)
|
|
**Blocks:** Nothing
|
|
|
|
**Changes:**
|
|
- New file `server/src/npc/disclosure.rs`:
|
|
- `DisclosureCandidates` component with `candidates: Vec<FactId>`, `entity_candidates: Vec<(StableId, String)>`, `computed_tick: u64`
|
|
- `DisclosureCooldown` component with `per_fact_history: BTreeSet<FactId>`, `npc_cooldown_until: u64`
|
|
- `derive_disclosure_candidates` system: range-query Active NPCs near player, filter NPC KG by trust tier + trait filters + `disclosure_blocked`, populate component
|
|
- `process_unprompted_disclosure` system: 5-gate check (trust ≥ surface, mood ≠ Hostile, contentment ≥ -10, witness count ≤ 2, location privacy gate), global rate limit (1 per tick, StableId-ordered), push line to dialogue pipeline if gates pass
|
|
- `server/src/simulation/dialogue.rs` integration: Layer 4 reads `DisclosureCandidates` for NPC-initiated dialogue selection
|
|
- System ordering: `derive_disclosure_candidates.after(process_knowledge_events)`, `process_unprompted_disclosure.after(derive_disclosure_candidates)`
|
|
|
|
**Line estimate:** ~220 lines
|
|
|
|
---
|
|
|
|
## Implementation Order with Dependencies
|
|
|
|
```
|
|
SPRINT 17 CRITICAL PATH:
|
|
|
|
Ticket A: KnowledgeGrant schema + ContentEntityRegistry (~120 lines)
|
|
│
|
|
├──→ Ticket B: KnowledgeGranted event + dialogue wire (~150 lines)
|
|
│ │
|
|
│ ├──→ Ticket D: NPC-to-NPC transfer system (~160 lines)
|
|
│ │ │
|
|
│ │ └──→ [ToldBy entries now exist in KGs]
|
|
│ │
|
|
│ └──→ Ticket E: tell_state KG awareness (~35 lines) ← can parallel with D
|
|
│
|
|
└──→ Ticket C: ContradictionClaim + detect in observe_entity (~160 lines)
|
|
│
|
|
└──→ [Requires ToldBy from B+D to fire. But struct can land independently.]
|
|
|
|
After B + C + D complete:
|
|
└──→ Ticket F: Contradiction monologue + event chain (~90 lines)
|
|
|
|
After B + D complete (KG populated with meaningful NPC content):
|
|
└──→ Ticket G: DisclosureCandidates + unprompted disclosure (~220 lines)
|
|
|
|
|
|
TOTAL: ~935 lines across 7 tickets
|
|
```
|
|
|
|
### Parallelization notes
|
|
|
|
- **Ticket A** must complete first. It's the schema foundation for everything else.
|
|
- **Tickets B and C** can develop in parallel after A. B wires the grant pipe; C wires the detection pipe. They don't conflict.
|
|
- **Ticket D** requires B's `KnowledgeGranted` event type to exist (it pushes events). B's system changes don't affect D's query structure.
|
|
- **Ticket E** is fully independent of D-F. It can be done any time after A if someone needs a small task.
|
|
- **Ticket F** requires both B (for ToldBy sources to exist) and C (for `ContradictionClaim` struct). It's the last step on the critical path.
|
|
- **Ticket G** (unprompted disclosure) has no hard dependency on C or F, but benefits greatly from D having populated NPC KGs. Implement after D.
|
|
|
|
---
|
|
|
|
## Unresolved Items — For the D-Record
|
|
|
|
The following are forming positions that need to be captured in the decision document:
|
|
|
|
| Item | Resolution |
|
|
|------|------------|
|
|
| Entity grants Sprint 17 | **Yes** — via `ContentEntityRegistry` (spawn-time registration) |
|
|
| `ContradictionClaim` struct vs attribute encoding | **`ContradictionClaim` struct** — Tyre/Gestalt approach adopted |
|
|
| `ContradictionClaim` field name | `contradicted_claim: Option<ContradictionClaim>` on `EntityKnowledge` |
|
|
| `CONTRADICTION_WINDOW_TICKS` | **600 ticks (1 game-hour)** |
|
|
| Global disclosure rate limit | **1 per tick, StableId-ordered** (deterministic, includes Tyre/Paula's cap, addresses Gestalt's concern) |
|
|
| Trust-weighted transfer count | **Paula's proposal adopted** (Surface 0-1, Real 1-2, Secret 2-3) |
|
|
| `disclosure_blocked` flag on FactKnowledge | **Include** — for Major secret no-transfer override |
|
|
| Witness inhibition gate | **Include** (count Active NPCs ≤5 tiles; threshold = 2) |
|
|
| Location privacy gate | **Include** (disclosure candidate tag: private/semi_private/any) |
|
|
| Runtime NPC KG guardrail | **Include** — 3-line check in KnowledgeGranted processing |
|
|
| Contradiction monologue authoring | **Option 3: generic template + hand-authored override for FRIEND-pattern NPCs** |
|
|
| `DisclosureCandidates` compute trigger | **Range-query per tick in Active NPC loop** — no special event needed |
|
|
| StableId → name in monologue system | **Available** — add EntityRegistry + Query<&NpcName> to system signature |
|
|
| Q-025 formal closure | **Close** — 30KB per Active NPC at current scale is not a constraint |
|
|
|
|
---
|
|
|
|
## Final Notes
|
|
|
|
The downstream chain for THE FRIEND arc is complete from step 3 onward. The simulation guarantees: once `ContradictionClaim` is stored and `KnowledgeState::Contradicted` is set, the anomaly system, relationship system, and D-033 color pipeline fire correctly without additional work — all tested and passing.
|
|
|
|
The two new structural pieces this workshop adds are `ContradictionClaim` on `EntityKnowledge` (Topic 5 prerequisite) and `ContentEntityRegistry` as a new resource (Topic 1 prerequisite). Both are minimal. Everything else is new systems that plug into existing, tested infrastructure.
|
|
|
|
State consistency across all 7 tickets: each ticket produces a well-defined state transition. None of them reach across system boundaries in ways that break existing determinism guarantees. The BTreeMap iteration order mandate (D-010 principle 4) is preserved throughout — `ContentEntityRegistry`, `DisclosureCandidates`, and the transfer system all use `BTreeMap` for deterministic ordering.
|