diff --git a/crates/sidequest-server/src/dispatch/encounter_gate.rs b/crates/sidequest-server/src/dispatch/encounter_gate.rs new file mode 100644 index 00000000..c4a86945 --- /dev/null +++ b/crates/sidequest-server/src/dispatch/encounter_gate.rs @@ -0,0 +1,198 @@ +//! Story 37-13: Encounter creation gate — every branch observable. +//! +//! The narrator signals a new encounter by emitting +//! `"confrontation": "combat"` (or any ConfrontationDef type) in the +//! game_patch. This module centralises the decision of what to do with +//! that signal given the current `snapshot.encounter` state. +//! +//! Previously the decision was inline in `dispatch_player_action` and +//! silently dropped the new type whenever an unresolved encounter already +//! existed. That was a CLAUDE.md "No Silent Fallbacks" violation and the +//! root cause for 37-12 (narrator never re-declares confrontation after +//! first emission). +//! +//! The gate now covers six cases, each with a distinct `WatcherEvent`: +//! +//! | Case | Current state | Action | Event | +//! |------|------------------------------------------|---------------|-------------------------------------------------| +//! | A | `None` | Create | `encounter.created` | +//! | B | `Some(resolved)` | Create | `encounter.created` | +//! | C | `Some(unresolved, same type)` | No-op | `encounter.redeclare_noop` | +//! | D | `Some(unresolved, diff, beat == 0)` | Replace | `encounter.replaced_pre_beat` | +//! | E | `Some(unresolved, diff, beat > 0)` | Reject | `encounter.new_type_rejected_mid_encounter` | +//! | F | Any, `find_confrontation_def` → `None` | No-op + warn | `encounter.creation_failed_unknown_type` | + +use sidequest_agents::orchestrator::NpcMention; +use sidequest_game::encounter::{EncounterActor, StructuredEncounter}; +use sidequest_game::state::GameSnapshot; +use sidequest_genre::ConfrontationDef; +use sidequest_telemetry::{WatcherEventBuilder, WatcherEventType}; + +/// Outcome of the confrontation gate. One variant per observable branch. +/// +/// Marked `#[non_exhaustive]` to signal that the variant set is open-ended — +/// the gate's case matrix is expected to grow as new encounter lifecycle +/// stories land (e.g., rate-limited redeclares, superseded-by-scenario +/// transitions). +/// +/// **Note:** Because this type is `pub(crate)`, the compiler does NOT force +/// wildcard arms on match sites within `sidequest-server` — intra-crate +/// matches can still be exhaustive without an `_ =>` arm. The attribute only +/// has compiler bite if this type is ever promoted to `pub` or moved into +/// `sidequest-protocol`. Until then, add a wildcard arm by convention at +/// every new match site so a future variant is a pure additive change. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConfrontationGateOutcome { + /// Case A or B — new encounter created on empty or resolved state. + Created, + /// Case C — narrator re-declared the active encounter's own type. + Redeclared, + /// Case D — old encounter had no beats yet; safe to replace. + ReplacedPreBeat, + /// Case E — old encounter has mechanical state; narrator prose diverges, + /// but we protect state and surface the divergence as a warning. + RejectedMidEncounter, + /// Case F — `find_confrontation_def` could not locate the incoming type. + UnknownType, +} + +/// Apply the confrontation gate to the current snapshot. +/// +/// Mutates `snapshot.encounter` only for `Created` and `ReplacedPreBeat` +/// outcomes. **Always emits exactly one `WatcherEvent`** on the `encounter` +/// channel so every gate decision is visible on the GM panel — this is the +/// primary side-effect contract of the function. +/// +/// `narrator_npcs` is the narrator's `result.npcs_present` list for the +/// current turn — a live, per-turn view, not a persistent registry. These +/// are appended as actors with role `"npc"` on newly-built encounters. +pub(crate) fn apply_confrontation_gate( + snapshot: &mut GameSnapshot, + incoming_type: &str, + confrontation_defs: &[ConfrontationDef], + narrator_npcs: &[NpcMention], +) -> ConfrontationGateOutcome { + // Case F: def missing wins over every other branch. We cannot build an + // encounter without a def, and the existing `tracing::warn!` is preserved + // so console users still see it. + let Some(def) = crate::find_confrontation_def(confrontation_defs, incoming_type) else { + tracing::warn!( + confrontation_type = %incoming_type, + "encounter.creation_failed — no ConfrontationDef found for type" + ); + WatcherEventBuilder::new("encounter", WatcherEventType::ValidationWarning) + .field("event", "encounter.creation_failed_unknown_type") + .field("encounter_type", incoming_type) + .field("source", "narrator_confrontation") + .send(); + return ConfrontationGateOutcome::UnknownType; + }; + + match snapshot.encounter.as_ref() { + // Case A — no current encounter. + None => { + let encounter = build_encounter(def, &snapshot.characters, narrator_npcs); + emit_created(&encounter, incoming_type); + snapshot.encounter = Some(encounter); + ConfrontationGateOutcome::Created + } + + // Case B — old encounter resolved; the new one supersedes it. + Some(old) if old.resolved => { + let encounter = build_encounter(def, &snapshot.characters, narrator_npcs); + emit_created(&encounter, incoming_type); + snapshot.encounter = Some(encounter); + ConfrontationGateOutcome::Created + } + + // Case C — narrator re-declares the active encounter type. + // Keep state as-is; the narrator often restates for prompt clarity. + Some(old) if old.encounter_type == incoming_type => { + WatcherEventBuilder::new("encounter", WatcherEventType::StateTransition) + .field("event", "encounter.redeclare_noop") + .field("encounter_type", incoming_type) + .field("beat_count", old.beat) + .field("source", "narrator_confrontation") + .send(); + ConfrontationGateOutcome::Redeclared + } + + // Case D — different type, no beats fired yet: safe to replace. + // Old encounter had no mechanical state worth preserving. + Some(old) if old.beat == 0 => { + let previous_encounter_type = old.encounter_type.clone(); + let encounter = build_encounter(def, &snapshot.characters, narrator_npcs); + WatcherEventBuilder::new("encounter", WatcherEventType::StateTransition) + .field("event", "encounter.replaced_pre_beat") + .field("encounter_type", incoming_type) + .field("previous_encounter_type", &previous_encounter_type) + .field("actor_count", encounter.actors.len()) + .field("source", "narrator_confrontation") + .send(); + snapshot.encounter = Some(encounter); + ConfrontationGateOutcome::ReplacedPreBeat + } + + // Case E — different type, beats already fired. Mid-encounter state + // is sacred; we keep the old encounter and surface the divergence. + // The explicit `old.beat > 0` guard is redundant with the match arm + // ordering above (Cases A-D have exhausted the alternatives) but keeps + // the code honest to the doc table's case definition. + Some(old) if old.beat > 0 => { + WatcherEventBuilder::new("encounter", WatcherEventType::ValidationWarning) + .field("event", "encounter.new_type_rejected_mid_encounter") + .field("encounter_type", incoming_type) + .field("previous_encounter_type", &old.encounter_type) + .field("beat_count", old.beat) + .field("source", "narrator_confrontation") + .send(); + ConfrontationGateOutcome::RejectedMidEncounter + } + + // Unreachable — Cases A through E above exhaust every (None, resolved, + // same-type, beat==0, beat>0) combination. Left as a compiler-verified + // safety net so adding a new variant to the match requires a conscious + // choice about how to route uncovered states. + Some(_) => unreachable!( + "encounter gate: match arms A-E should cover every (encounter, incoming) state" + ), + } +} + +/// Build a fresh `StructuredEncounter` from a ConfrontationDef, populating +/// actors from the current character roster and the narrator's NPC list for +/// this turn. Mirrors the original inline logic in `dispatch_player_action`. +fn build_encounter( + def: &ConfrontationDef, + characters: &[sidequest_game::Character], + narrator_npcs: &[NpcMention], +) -> StructuredEncounter { + let mut encounter = StructuredEncounter::from_confrontation_def(def); + + for ch in characters { + encounter.actors.push(EncounterActor { + name: ch.core.name.as_str().to_string(), + role: "player".to_string(), + per_actor_state: std::collections::HashMap::new(), + }); + } + for npc in narrator_npcs { + encounter.actors.push(EncounterActor { + name: npc.name.clone(), + role: "npc".to_string(), + per_actor_state: std::collections::HashMap::new(), + }); + } + + encounter +} + +fn emit_created(encounter: &StructuredEncounter, incoming_type: &str) { + WatcherEventBuilder::new("encounter", WatcherEventType::StateTransition) + .field("event", "encounter.created") + .field("encounter_type", incoming_type) + .field("actor_count", encounter.actors.len()) + .field("source", "narrator_confrontation") + .send(); +} diff --git a/crates/sidequest-server/src/dispatch/mod.rs b/crates/sidequest-server/src/dispatch/mod.rs index e06e17e8..4866a0db 100644 --- a/crates/sidequest-server/src/dispatch/mod.rs +++ b/crates/sidequest-server/src/dispatch/mod.rs @@ -17,7 +17,10 @@ mod beat; pub(crate) mod catch_up; pub(crate) mod chargen_summary; pub(crate) mod connect; +pub(crate) mod encounter_gate; pub(crate) mod lore_embed_worker; + +pub(crate) use encounter_gate::apply_confrontation_gate; mod lore_sync; mod npc_registry; mod patching; @@ -1766,56 +1769,22 @@ pub(crate) async fn dispatch_player_action(ctx: &mut DispatchContext<'_>) -> Vec .await; let tier_events = mutation_result.tier_events; - // Story 28-8: Encounter creation — the narrator signals a new encounter by emitting - // `"confrontation": "combat"` (or any ConfrontationDef type) in the game_patch. - // This creates a StructuredEncounter from the genre pack's ConfrontationDef and - // populates actors from the player characters + NPCs present in the scene. + // Story 37-13: Encounter creation gate — route the narrator's confrontation + // signal through `apply_confrontation_gate`, which covers every case of + // (current_encounter_state, incoming_type) with a distinct WatcherEvent. + // Replaces the previous inline block that silently dropped new types + // whenever an unresolved encounter was already active. The observable + // contract is the side effects — WatcherEvent on every branch, snapshot + // mutation on Created/ReplacedPreBeat. The `_gate_outcome` binding is a + // named placeholder so the outcome can be consumed without altering this + // call site. if let Some(ref confrontation_type) = result.confrontation { - if ctx.snapshot.encounter.is_none() - || ctx.snapshot.encounter.as_ref().is_some_and(|e| e.resolved) - { - if let Some(def) = - crate::find_confrontation_def(&ctx.confrontation_defs, confrontation_type) - { - let mut encounter = - sidequest_game::encounter::StructuredEncounter::from_confrontation_def(def); - - // Populate actors: player characters + NPCs mentioned this turn - for ch in &ctx.snapshot.characters { - encounter - .actors - .push(sidequest_game::encounter::EncounterActor { - name: ch.core.name.as_str().to_string(), - role: "player".to_string(), - per_actor_state: std::collections::HashMap::new(), - }); - } - // Add NPCs from this turn's narration (the narrator knows who's in the scene) - for npc_mention in &result.npcs_present { - encounter - .actors - .push(sidequest_game::encounter::EncounterActor { - name: npc_mention.name.clone(), - role: "npc".to_string(), - per_actor_state: std::collections::HashMap::new(), - }); - } - - WatcherEventBuilder::new("encounter", WatcherEventType::StateTransition) - .field("event", "encounter.created") - .field("encounter_type", confrontation_type) - .field("actor_count", encounter.actors.len()) - .field("source", "narrator_confrontation") - .send(); - - ctx.snapshot.encounter = Some(encounter); - } else { - tracing::warn!( - confrontation_type = %confrontation_type, - "encounter.creation_failed — no ConfrontationDef found for type" - ); - } - } + let _gate_outcome = apply_confrontation_gate( + ctx.snapshot, + confrontation_type, + &ctx.confrontation_defs, + &result.npcs_present, + ); } // Story 28-5: Beat selection dispatch — route narrator's beat_selection through diff --git a/crates/sidequest-server/src/encounter_gate_story_37_13_tests.rs b/crates/sidequest-server/src/encounter_gate_story_37_13_tests.rs new file mode 100644 index 00000000..3b0ca514 --- /dev/null +++ b/crates/sidequest-server/src/encounter_gate_story_37_13_tests.rs @@ -0,0 +1,717 @@ +//! Tests for `dispatch::apply_confrontation_gate` — every branch observable. +//! +//! The gate routes the narrator's `"confrontation": ` signal given the +//! current `snapshot.encounter` state. Each of the six cases (A-F) emits a +//! distinct `WatcherEvent` so the GM panel can verify the decision. +//! +//! Test matrix — one case per letter in the module doc of `encounter_gate.rs`: +//! A. None → Created +//! B. Some(resolved) → Created +//! C. Some(unresolved, same type) → Redeclared (no-op) +//! D. Some(unresolved, diff, b==0) → ReplacedPreBeat +//! E. Some(unresolved, diff, b>0) → RejectedMidEncounter +//! F. Unknown type (def missing) → UnknownType +//! +//! Plus a source-scanning wiring test asserting `dispatch/mod.rs` calls the +//! helper and does not contain the old inline branch. + +use sidequest_agents::orchestrator::NpcMention; +use sidequest_game::encounter::{ + EncounterActor, EncounterMetric, MetricDirection, StructuredEncounter, +}; +use sidequest_game::state::GameSnapshot; +use sidequest_genre::ConfrontationDef; +use sidequest_telemetry::{WatcherEvent, WatcherEventType}; + +use crate::dispatch::apply_confrontation_gate; +use crate::dispatch::encounter_gate::ConfrontationGateOutcome; +use crate::test_support::telemetry::{drain_events, fresh_subscriber}; + +fn find_encounter_events(events: &[WatcherEvent], event_name: &str) -> Vec { + events + .iter() + .filter(|e| { + e.component == "encounter" + && e.fields.get("event").and_then(serde_json::Value::as_str) == Some(event_name) + }) + .cloned() + .collect() +} + +/// Every event the gate emits must carry `source = "narrator_confrontation"` so +/// the GM panel can attribute it to the narrator subsystem. Centralising this +/// check keeps per-case tests focused on their own invariants while guaranteeing +/// the attribution field never silently drops. +fn assert_source_is_narrator(event: &WatcherEvent) { + assert_eq!( + event.fields.get("source").and_then(|v| v.as_str()), + Some("narrator_confrontation"), + "encounter events must carry source=narrator_confrontation" + ); +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +fn combat_yaml() -> &'static str { + r#" +type: combat +label: "Combat" +category: combat +metric: + name: hp + direction: descending + starting: 20 + threshold_low: 0 +beats: + - id: attack + label: "Attack" + metric_delta: -3 + stat_check: STRENGTH + - id: defend + label: "Defend" + metric_delta: 0 + stat_check: CONSTITUTION +"# +} + +fn standoff_yaml() -> &'static str { + r#" +type: standoff +label: "Tense Standoff" +category: pre_combat +metric: + name: tension + direction: ascending + starting: 0 + threshold_high: 10 +beats: + - id: size_up + label: "Size Up" + metric_delta: 2 + stat_check: CUNNING + - id: bluff + label: "Bluff" + metric_delta: 3 + stat_check: NERVE + - id: draw + label: "Draw" + metric_delta: 5 + stat_check: DRAW + resolution: true +"# +} + +fn load_defs() -> Vec { + vec![ + serde_yaml::from_str(combat_yaml()).expect("combat yaml parses"), + serde_yaml::from_str(standoff_yaml()).expect("standoff yaml parses"), + ] +} + +fn npc(name: &str) -> NpcMention { + NpcMention { + name: name.to_string(), + pronouns: String::new(), + role: String::new(), + appearance: String::new(), + is_new: false, + } +} + +fn test_character(name: &str) -> sidequest_game::Character { + use sidequest_game::creature_core::CreatureCore; + use sidequest_game::{Character, Inventory}; + use sidequest_protocol::NonBlankString; + Character { + core: CreatureCore { + name: NonBlankString::new(name).unwrap(), + description: NonBlankString::new("Test character for gate tests").unwrap(), + personality: NonBlankString::new("Brave").unwrap(), + level: 1, + hp: 10, + max_hp: 10, + ac: 10, + xp: 0, + inventory: Inventory { + items: vec![], + gold: 0, + }, + statuses: vec![], + }, + backstory: NonBlankString::new("Test backstory").unwrap(), + narrative_state: "exploring".to_string(), + hooks: vec![], + char_class: NonBlankString::new("Fighter").unwrap(), + race: NonBlankString::new("Human").unwrap(), + pronouns: String::new(), + stats: std::collections::HashMap::new(), + abilities: vec![], + known_facts: vec![], + affinities: vec![], + is_friendly: true, + } +} + +fn empty_snapshot() -> GameSnapshot { + GameSnapshot::default() +} + +fn snapshot_with(encounter: StructuredEncounter) -> GameSnapshot { + GameSnapshot { + encounter: Some(encounter), + ..GameSnapshot::default() + } +} + +/// Build a `StructuredEncounter` of the given type with a specific beat counter. +/// Used to pre-populate `snapshot.encounter` for gate tests. Not a production +/// builder — a test-local shortcut so we don't need the full narrator pipeline. +fn existing_encounter(encounter_type: &str, beat: u32, resolved: bool) -> StructuredEncounter { + StructuredEncounter { + encounter_type: encounter_type.to_string(), + metric: EncounterMetric { + name: "hp".to_string(), + current: 15, + starting: 20, + direction: MetricDirection::Descending, + threshold_high: None, + threshold_low: Some(0), + }, + beat, + structured_phase: None, + secondary_stats: None, + actors: vec![EncounterActor { + name: "Old Combatant".to_string(), + role: "combatant".to_string(), + per_actor_state: { + let mut m = std::collections::HashMap::new(); + m.insert("stance".to_string(), serde_json::json!("guarded")); + m + }, + }], + outcome: None, + resolved, + mood_override: None, + narrator_hints: vec![], + } +} + +// --------------------------------------------------------------------------- +// Case A — No current encounter → create +// --------------------------------------------------------------------------- + +#[test] +fn case_a_no_current_encounter_creates_new() { + let (_guard, mut rx) = fresh_subscriber(); + let defs = load_defs(); + let mut snapshot = empty_snapshot(); + + let outcome = apply_confrontation_gate(&mut snapshot, "combat", &defs, &[]); + + assert_eq!(outcome, ConfrontationGateOutcome::Created); + assert!( + snapshot.encounter.is_some(), + "new encounter must be stored on snapshot" + ); + assert_eq!( + snapshot.encounter.as_ref().unwrap().encounter_type, + "combat" + ); + assert_eq!(snapshot.encounter.as_ref().unwrap().beat, 0); + assert!(!snapshot.encounter.as_ref().unwrap().resolved); + + let events = drain_events(&mut rx); + let created = find_encounter_events(&events, "encounter.created"); + assert_eq!( + created.len(), + 1, + "Case A must emit exactly one encounter.created event" + ); + assert!( + matches!(created[0].event_type, WatcherEventType::StateTransition), + "encounter.created must be a StateTransition event" + ); + assert_eq!( + created[0] + .fields + .get("encounter_type") + .and_then(|v| v.as_str()), + Some("combat") + ); + assert_source_is_narrator(&created[0]); +} + +// --------------------------------------------------------------------------- +// Case B — Current encounter resolved → create new +// --------------------------------------------------------------------------- + +#[test] +fn case_b_resolved_current_encounter_creates_new() { + let (_guard, mut rx) = fresh_subscriber(); + let defs = load_defs(); + let mut snapshot = snapshot_with(existing_encounter("standoff", 3, true)); + + let outcome = apply_confrontation_gate(&mut snapshot, "combat", &defs, &[]); + + assert_eq!(outcome, ConfrontationGateOutcome::Created); + assert_eq!( + snapshot.encounter.as_ref().unwrap().encounter_type, + "combat", + "resolved old encounter should be replaced by new type" + ); + assert_eq!(snapshot.encounter.as_ref().unwrap().beat, 0); + + let events = drain_events(&mut rx); + let created = find_encounter_events(&events, "encounter.created"); + assert_eq!(created.len(), 1); + assert_source_is_narrator(&created[0]); +} + +// --------------------------------------------------------------------------- +// Case C — Same type, unresolved → no-op redeclare +// --------------------------------------------------------------------------- + +#[test] +fn case_c_same_type_redeclare_is_noop() { + let (_guard, mut rx) = fresh_subscriber(); + let defs = load_defs(); + let mut snapshot = snapshot_with(existing_encounter("combat", 2, false)); + let before_metric = snapshot.encounter.as_ref().unwrap().metric.current; + let before_actors = snapshot.encounter.as_ref().unwrap().actors.clone(); + let before_beat = snapshot.encounter.as_ref().unwrap().beat; + + let outcome = apply_confrontation_gate(&mut snapshot, "combat", &defs, &[]); + + assert_eq!(outcome, ConfrontationGateOutcome::Redeclared); + let after = snapshot.encounter.as_ref().unwrap(); + assert_eq!(after.metric.current, before_metric, "metric unchanged"); + assert_eq!(after.beat, before_beat, "beat counter unchanged"); + assert_eq!( + after.actors.len(), + before_actors.len(), + "actor list unchanged" + ); + + let events = drain_events(&mut rx); + let redeclare = find_encounter_events(&events, "encounter.redeclare_noop"); + assert_eq!( + redeclare.len(), + 1, + "Case C must emit exactly one encounter.redeclare_noop" + ); + assert_source_is_narrator(&redeclare[0]); + assert!( + find_encounter_events(&events, "encounter.created").is_empty(), + "Case C must NOT emit encounter.created" + ); + assert!( + find_encounter_events(&events, "encounter.replaced_pre_beat").is_empty(), + "Case C must NOT emit encounter.replaced_pre_beat" + ); +} + +// --------------------------------------------------------------------------- +// Case D — Different type, unresolved, beat == 0 → replace +// --------------------------------------------------------------------------- + +#[test] +fn case_d_different_type_pre_beat_replaces() { + let (_guard, mut rx) = fresh_subscriber(); + let defs = load_defs(); + let mut snapshot = snapshot_with(existing_encounter("standoff", 0, false)); + + let outcome = apply_confrontation_gate(&mut snapshot, "combat", &defs, &[]); + + assert_eq!(outcome, ConfrontationGateOutcome::ReplacedPreBeat); + let after = snapshot.encounter.as_ref().unwrap(); + assert_eq!( + after.encounter_type, "combat", + "new encounter type must replace old" + ); + assert_eq!(after.beat, 0, "replacement starts fresh at beat 0"); + assert!(!after.resolved, "replacement is not resolved"); + + let events = drain_events(&mut rx); + let replaced = find_encounter_events(&events, "encounter.replaced_pre_beat"); + assert_eq!( + replaced.len(), + 1, + "Case D must emit exactly one encounter.replaced_pre_beat" + ); + assert_eq!( + replaced[0] + .fields + .get("previous_encounter_type") + .and_then(|v| v.as_str()), + Some("standoff"), + "replacement event must record the previous encounter type" + ); + assert_eq!( + replaced[0] + .fields + .get("encounter_type") + .and_then(|v| v.as_str()), + Some("combat") + ); + assert_source_is_narrator(&replaced[0]); +} + +#[test] +fn case_d_replacement_repopulates_actors_from_snapshot_and_npcs() { + let (_guard, _rx) = fresh_subscriber(); + let defs = load_defs(); + let mut snapshot = snapshot_with(existing_encounter("standoff", 0, false)); + let npcs = vec![npc("Toggler Copperjaw"), npc("Nub")]; + + let outcome = apply_confrontation_gate(&mut snapshot, "combat", &defs, &npcs); + + assert_eq!(outcome, ConfrontationGateOutcome::ReplacedPreBeat); + let actors = &snapshot.encounter.as_ref().unwrap().actors; + + // Players: GameSnapshot::default() has zero characters, so player count is 0. + // NPC actors come from the narrator's npcs_present list. + let npc_names: Vec<&str> = actors + .iter() + .filter(|a| a.role == "npc") + .map(|a| a.name.as_str()) + .collect(); + assert!( + npc_names.contains(&"Toggler Copperjaw"), + "replacement must pull NPCs from narrator_npcs, got: {npc_names:?}" + ); + assert!( + npc_names.contains(&"Nub"), + "replacement must pull all NPCs from narrator_npcs, got: {npc_names:?}" + ); + + // Critically: the old "Old Combatant" actor must NOT carry over from the + // previous standoff encounter. A replace is a fresh actor set. + assert!( + !actors.iter().any(|a| a.name == "Old Combatant"), + "replacement must NOT carry forward actors from the old encounter" + ); +} + +#[test] +fn case_d_replacement_drops_old_per_actor_state() { + let (_guard, _rx) = fresh_subscriber(); + let defs = load_defs(); + // Old encounter had an actor with populated per_actor_state. + let mut snapshot = snapshot_with(existing_encounter("standoff", 0, false)); + + let outcome = + apply_confrontation_gate(&mut snapshot, "combat", &defs, &[npc("Toggler Copperjaw")]); + assert_eq!( + outcome, + ConfrontationGateOutcome::ReplacedPreBeat, + "Case D must return ReplacedPreBeat — without this assertion the leak \ + check below would pass even if the gate silently no-opped" + ); + + let actors = &snapshot.encounter.as_ref().unwrap().actors; + for actor in actors { + assert!( + actor.per_actor_state.is_empty(), + "new encounter actors must start with empty per_actor_state (leak check), \ + found {:?} on {}", + actor.per_actor_state, + actor.name + ); + } +} + +// --------------------------------------------------------------------------- +// Case E — Different type, unresolved, mid-encounter → reject with warning +// --------------------------------------------------------------------------- + +#[test] +fn case_e_different_type_mid_encounter_rejects_with_validation_warning() { + let (_guard, mut rx) = fresh_subscriber(); + let defs = load_defs(); + let mut snapshot = snapshot_with(existing_encounter("standoff", 3, false)); + let before = snapshot.encounter.clone().unwrap(); + + let outcome = apply_confrontation_gate(&mut snapshot, "combat", &defs, &[]); + + assert_eq!(outcome, ConfrontationGateOutcome::RejectedMidEncounter); + let after = snapshot.encounter.as_ref().unwrap(); + assert_eq!( + after.encounter_type, before.encounter_type, + "rejected gate must NOT mutate encounter_type" + ); + assert_eq!( + after.beat, before.beat, + "rejected gate must NOT touch beat counter" + ); + assert_eq!( + after.metric.current, before.metric.current, + "rejected gate must NOT touch metric" + ); + assert_eq!( + after.actors.len(), + before.actors.len(), + "rejected gate must NOT touch actors" + ); + + let events = drain_events(&mut rx); + let rejected = find_encounter_events(&events, "encounter.new_type_rejected_mid_encounter"); + assert_eq!( + rejected.len(), + 1, + "Case E must emit exactly one encounter.new_type_rejected_mid_encounter" + ); + assert!( + matches!(rejected[0].event_type, WatcherEventType::ValidationWarning), + "rejection event must use ValidationWarning type — this is a narrator/state divergence" + ); + assert_eq!( + rejected[0] + .fields + .get("previous_encounter_type") + .and_then(|v| v.as_str()), + Some("standoff") + ); + assert_eq!( + rejected[0] + .fields + .get("encounter_type") + .and_then(|v| v.as_str()), + Some("combat"), + "rejection event must record the incoming (rejected) type" + ); + assert_eq!( + rejected[0] + .fields + .get("beat_count") + .and_then(|v| v.as_u64()), + Some(3), + "rejection event must record the current beat count for the GM panel" + ); + assert_source_is_narrator(&rejected[0]); +} + +// --------------------------------------------------------------------------- +// Case F — Unknown confrontation type → validation warning +// --------------------------------------------------------------------------- + +#[test] +fn case_f_unknown_confrontation_type_emits_validation_warning() { + let (_guard, mut rx) = fresh_subscriber(); + let defs = load_defs(); + let mut snapshot = empty_snapshot(); + + let outcome = apply_confrontation_gate(&mut snapshot, "interpretive_dance", &defs, &[]); + + assert_eq!(outcome, ConfrontationGateOutcome::UnknownType); + assert!( + snapshot.encounter.is_none(), + "unknown type must NOT populate encounter" + ); + + let events = drain_events(&mut rx); + let failed = find_encounter_events(&events, "encounter.creation_failed_unknown_type"); + assert_eq!( + failed.len(), + 1, + "Case F must emit exactly one encounter.creation_failed_unknown_type" + ); + assert!( + matches!(failed[0].event_type, WatcherEventType::ValidationWarning), + "encounter.creation_failed_unknown_type must be ValidationWarning" + ); + assert_eq!( + failed[0] + .fields + .get("encounter_type") + .and_then(|v| v.as_str()), + Some("interpretive_dance") + ); + assert_source_is_narrator(&failed[0]); +} + +// --------------------------------------------------------------------------- +// Regression guards — edge cases that the Case A-F matrix leaves implicit. +// --------------------------------------------------------------------------- + +/// The match arms put Case C (same type) before Case D (beat == 0). Both +/// conditions can be true simultaneously — same type AND beat zero — and the +/// gate must treat that intersection as a redeclare, not a replace. This test +/// locks in the arm ordering so a future reorder can't silently change +/// semantics by promoting the `beat == 0` guard above the same-type check. +#[test] +fn case_c_same_type_with_beat_zero_still_redeclare() { + let (_guard, mut rx) = fresh_subscriber(); + let defs = load_defs(); + let mut snapshot = snapshot_with(existing_encounter("combat", 0, false)); + + let outcome = apply_confrontation_gate(&mut snapshot, "combat", &defs, &[]); + + assert_eq!( + outcome, + ConfrontationGateOutcome::Redeclared, + "same-type redeclare at beat 0 must NOT fall through to ReplacedPreBeat" + ); + assert_eq!( + snapshot.encounter.as_ref().unwrap().encounter_type, + "combat", + "redeclare must leave the old encounter in place" + ); + + let events = drain_events(&mut rx); + assert_eq!( + find_encounter_events(&events, "encounter.redeclare_noop").len(), + 1 + ); + assert!( + find_encounter_events(&events, "encounter.replaced_pre_beat").is_empty(), + "must NOT emit encounter.replaced_pre_beat when types match" + ); +} + +/// An empty incoming type goes through `find_confrontation_def`, which cannot +/// match any def (no def has an empty type), and therefore routes to Case F. +/// Lock that in so a future refactor doesn't accidentally create a bypass for +/// the empty string — the gate should always route to `UnknownType` with a +/// `ValidationWarning` event, not silently no-op. +#[test] +fn case_f_empty_incoming_type_routes_to_unknown() { + let (_guard, mut rx) = fresh_subscriber(); + let defs = load_defs(); + let mut snapshot = empty_snapshot(); + + let outcome = apply_confrontation_gate(&mut snapshot, "", &defs, &[]); + + assert_eq!(outcome, ConfrontationGateOutcome::UnknownType); + assert!(snapshot.encounter.is_none()); + + let events = drain_events(&mut rx); + let failed = find_encounter_events(&events, "encounter.creation_failed_unknown_type"); + assert_eq!( + failed.len(), + 1, + "empty incoming type must still emit a validation warning" + ); + assert_eq!( + failed[0] + .fields + .get("encounter_type") + .and_then(|v| v.as_str()), + Some("") + ); + assert_source_is_narrator(&failed[0]); +} + +/// Case F must also fire cleanly when an existing encounter is present — the +/// def-missing check happens before the match on `snapshot.encounter`, so the +/// current encounter must remain untouched. +#[test] +fn case_f_unknown_type_with_existing_encounter_preserves_state() { + let (_guard, mut rx) = fresh_subscriber(); + let defs = load_defs(); + let mut snapshot = snapshot_with(existing_encounter("combat", 2, false)); + let before = snapshot.encounter.clone().unwrap(); + + let outcome = apply_confrontation_gate(&mut snapshot, "interpretive_dance", &defs, &[]); + + assert_eq!(outcome, ConfrontationGateOutcome::UnknownType); + let after = snapshot.encounter.as_ref().unwrap(); + assert_eq!(after.encounter_type, before.encounter_type); + assert_eq!(after.beat, before.beat); + assert_eq!(after.metric.current, before.metric.current); + assert!( + !after.resolved, + "resolved flag must be preserved when Case F rejects" + ); + assert_eq!( + after.actors.len(), + before.actors.len(), + "actor list length must be preserved when Case F rejects" + ); + assert_eq!( + after.actors[0].name, before.actors[0].name, + "actor identities must be preserved when Case F rejects" + ); + assert_eq!( + after.actors[0].per_actor_state, before.actors[0].per_actor_state, + "per_actor_state must be preserved when Case F rejects" + ); + + let events = drain_events(&mut rx); + let failed = find_encounter_events(&events, "encounter.creation_failed_unknown_type"); + assert_eq!(failed.len(), 1); + assert_source_is_narrator(&failed[0]); +} + +/// The `build_encounter` helper populates actors from BOTH +/// `snapshot.characters` (role = "player") AND `narrator_npcs` (role = "npc"). +/// Every other test exercises one path at a time; this one exercises both so +/// the player-actor loop is never silently untested. +#[test] +fn case_a_populates_both_player_and_npc_actors() { + let (_guard, _rx) = fresh_subscriber(); + let defs = load_defs(); + let mut snapshot = empty_snapshot(); + snapshot.characters.push(test_character("Aragorn")); + let npcs = vec![npc("Strider the Second")]; + + let outcome = apply_confrontation_gate(&mut snapshot, "combat", &defs, &npcs); + + assert_eq!(outcome, ConfrontationGateOutcome::Created); + let actors = &snapshot.encounter.as_ref().unwrap().actors; + + let player_names: Vec<&str> = actors + .iter() + .filter(|a| a.role == "player") + .map(|a| a.name.as_str()) + .collect(); + let npc_names: Vec<&str> = actors + .iter() + .filter(|a| a.role == "npc") + .map(|a| a.name.as_str()) + .collect(); + + assert_eq!(player_names, vec!["Aragorn"]); + assert_eq!(npc_names, vec!["Strider the Second"]); +} + +// --------------------------------------------------------------------------- +// Wiring test — the production dispatch path must actually call the helper +// and the old silent-drop branch must be gone. Per CLAUDE.md, every test +// suite needs at least one wiring test so we don't ship unwired green tests. +// --------------------------------------------------------------------------- + +#[test] +fn wiring_dispatch_mod_calls_apply_confrontation_gate() { + let source = include_str!("dispatch/mod.rs"); + + // Positive side: scan for an actual call expression, not a bare identifier. + // A comment that merely mentions the function name would be fooled by a + // plain substring scan — the opening paren forces a real invocation. + assert!( + source.contains("apply_confrontation_gate("), + "dispatch/mod.rs must call apply_confrontation_gate(...) — the gate \ + logic cannot live as an inline block any longer" + ); + + // Negative side: the old silent-drop shape combined two tokens in a single + // boolean guard without an `else` branch. Fail if EITHER token appears in + // dispatch/mod.rs — the helper owns that logic now, so neither should + // ever appear at the dispatch layer. Using OR (not AND) closes the hole + // where a partial regression reintroducing just one of the two conditions + // would slip past an AND-only check. + let has_is_none_token = source.contains("ctx.snapshot.encounter.is_none()"); + let has_is_some_and_resolved_token = + source.contains("ctx.snapshot.encounter.as_ref().is_some_and(|e| e.resolved)"); + assert!( + !has_is_none_token, + "dispatch/mod.rs contains `ctx.snapshot.encounter.is_none()` — that \ + check belongs inside apply_confrontation_gate, not at the call site" + ); + assert!( + !has_is_some_and_resolved_token, + "dispatch/mod.rs contains `ctx.snapshot.encounter.as_ref().is_some_and(|e| e.resolved)` \ + — that check belongs inside apply_confrontation_gate, not at the call site" + ); +} diff --git a/crates/sidequest-server/src/lib.rs b/crates/sidequest-server/src/lib.rs index 07d27c86..2624ea7f 100644 --- a/crates/sidequest-server/src/lib.rs +++ b/crates/sidequest-server/src/lib.rs @@ -8,6 +8,8 @@ pub(crate) mod debug_api; mod dice_broadcast_34_8_tests; pub mod dice_dispatch; mod dispatch; +#[cfg(test)] +mod encounter_gate_story_37_13_tests; pub(crate) mod extraction; pub(crate) mod npc_context; #[cfg(test)] @@ -15,6 +17,8 @@ mod otel_dice_spans_34_11_tests; pub mod render_integration; pub(crate) mod session; pub mod shared_session; +#[cfg(test)] +mod test_support; pub mod tracing_setup; pub(crate) mod watcher; diff --git a/crates/sidequest-server/src/otel_dice_spans_34_11_tests.rs b/crates/sidequest-server/src/otel_dice_spans_34_11_tests.rs index df1f1a9e..4f7307c3 100644 --- a/crates/sidequest-server/src/otel_dice_spans_34_11_tests.rs +++ b/crates/sidequest-server/src/otel_dice_spans_34_11_tests.rs @@ -18,39 +18,10 @@ use std::num::NonZeroU8; use sidequest_game::dice::resolve_dice; use sidequest_protocol::{DiceRequestPayload, DieSides, DieSpec, ThrowParams}; -use sidequest_telemetry::{init_global_channel, subscribe_global, WatcherEvent, WatcherEventType}; +use sidequest_telemetry::{WatcherEvent, WatcherEventType}; use crate::dice_dispatch::{compose_dice_result, generate_dice_seed, validate_dice_inputs}; - -// --------------------------------------------------------------------------- -// Test infrastructure -// --------------------------------------------------------------------------- - -/// Serialize telemetry tests — the global broadcast channel is shared state. -static TELEMETRY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -/// Initialize channel, acquire lock, drain stale events, return clean receiver. -fn fresh_subscriber() -> ( - std::sync::MutexGuard<'static, ()>, - tokio::sync::broadcast::Receiver, -) { - let guard = TELEMETRY_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let _ = init_global_channel(); - let mut rx = subscribe_global().expect("channel must be initialized"); - while rx.try_recv().is_ok() {} - (guard, rx) -} - -/// Drain all currently-buffered events from the receiver. -fn drain_events(rx: &mut tokio::sync::broadcast::Receiver) -> Vec { - let mut events = Vec::new(); - while let Ok(event) = rx.try_recv() { - events.push(event); - } - events -} +use crate::test_support::telemetry::{drain_events, fresh_subscriber}; /// Find events by component and event field value. fn find_dice_events(events: &[WatcherEvent], event_name: &str) -> Vec { diff --git a/crates/sidequest-server/src/test_support.rs b/crates/sidequest-server/src/test_support.rs new file mode 100644 index 00000000..4586031e --- /dev/null +++ b/crates/sidequest-server/src/test_support.rs @@ -0,0 +1,47 @@ +//! Shared test infrastructure for sidequest-server test modules. +//! +//! The global telemetry broadcast channel is process-wide shared state — +//! `GLOBAL_TX` in `sidequest-telemetry` is a `OnceLock`. +//! Any test that subscribes and drains events must serialize against every +//! other telemetry test in this crate, not just tests within its own module. +//! +//! Before this module existed, each telemetry-sensitive test file declared its +//! own module-local `TELEMETRY_LOCK` static. `cargo test` runs tests across +//! modules in parallel by default, so two suites could drain each other's +//! events and produce spurious event-count failures. This module centralises +//! the lock and the drain helpers so all telemetry tests compose correctly. + +pub(crate) mod telemetry { + use sidequest_telemetry::{init_global_channel, subscribe_global, WatcherEvent}; + + /// Process-wide serialization gate for every telemetry test in the crate. + /// Tests that subscribe to the global channel MUST hold this lock for the + /// duration of their subscribe-drain-assert window. + pub(crate) static TELEMETRY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Acquire the shared lock, initialise the global channel if needed, and + /// return a receiver with any pre-existing events already drained. + pub(crate) fn fresh_subscriber() -> ( + std::sync::MutexGuard<'static, ()>, + tokio::sync::broadcast::Receiver, + ) { + let guard = TELEMETRY_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _ = init_global_channel(); + let mut rx = subscribe_global().expect("telemetry channel must be initialized"); + while rx.try_recv().is_ok() {} + (guard, rx) + } + + /// Drain every currently-buffered event from the receiver. + pub(crate) fn drain_events( + rx: &mut tokio::sync::broadcast::Receiver, + ) -> Vec { + let mut events = Vec::new(); + while let Ok(event) = rx.try_recv() { + events.push(event); + } + events + } +}