Compare commits

4 Commits

Author SHA1 Message Date
4513b9a0ae fix 2026-06-23 03:52:47 +00:00
2d701fecfd fix: add more multi tests, and update some specs to use new features 2026-06-22 23:41:17 +00:00
Kaysser Kayyali
7709f19e6e feat(FU-11): src/lib/systemStrings.ts registry + rendered-output forbidden-word guard
Centralize player-facing system strings in one named registry (arch line 114:
'systemStrings.voice = 7th harness'; in-world-voice rule). Closes the FU-11 gap
(the module did not exist).

- src/lib/systemStrings.ts: a named registry of player-facing strings (lobby
  state + announcements, roll rejections, group-roll-pending, expiry notice) +
  FORBIDDEN_UTILITY_WORDS (engine/tool/code terms) + hasForbiddenUtilityWord(text)
  — a guard against future narrator paths leaking tool_call / pendingSkillCheck /
  tool-name text (ADR-005 family).
- Migrate callers to the registry: lobbyHandler, rollHandler, messageRouter
  (group-roll-pending), expirySweep (expired notice). Behavior-preserving string
  swaps. (Templated strings + the [TOOL] LLM-facing system messages stay inline.)
- TDD: tests/unit/systemStrings.test.ts (4 tests) — every registry string is
  free of forbidden utility words; the helper catches engine terms (case-
  insensitive) + allows in-world text.

Verified: tsc clean, 547 unit tests pass (+4).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 22:27:43 +00:00
Kaysser Kayyali
4bb524f3e0 docs(backlog): break down open work — FU-10/11 real stories, add FU-15/16
- FU-10 → real story: wizard has no Players & Lobby / Passive Reveals sections
  (confirmed via the sections list) — add them.
- FU-11 → real story: src/lib/systemStrings.ts does NOT exist (confirmed) —
  create the registry + a rendered-output forbidden-word grep.
- FU-15: the 4 multiplayer gap-case live ACs (FR-11-14) — extend the green live
  multiplayer E2E.
- FU-16: trivial — contradictory 'solo scene' comment on mawfang (minPlayers:2)
  + old-friend (minPlayers:3); needs the user's intent call.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 22:17:58 +00:00
17 changed files with 525 additions and 65 deletions

View File

@@ -30,17 +30,19 @@ A guided, section-by-section authoring flow. You open by collecting the encounte
Walk these in order. For each, **read its skill file fresh** and follow it. The section file owns the detail (what to describe, which tool, how to frame the question, what to collect, which pitfalls apply). Your job per section is: describe → run the section's lore-options tool (if it has one) → present the real options via the question tool (`AskUserQuestion`) → collect the user's choice/prose → record → next section.
1. `sections/identity/SKILL.md``encounterId` + `title`
2. `sections/setting/SKILL.md``location` / `mood` / `ambientNpcs` (tool: `lore-locations`)
3. `sections/tone/SKILL.md``tone`
4. `sections/opening/SKILL.md``openingNarrative` (tool: `lore-atmosphere`)
5. `sections/npcs/SKILL.md``npcs` (tool: `lore-archetypes`)
6. `sections/goals/SKILL.md``goals` (tool: `lore-hooks`)
7. `sections/sportsmanship/SKILL.md``sportsmanshipRules`
8. `sections/skillChecks/SKILL.md``skillChecks`
9. `sections/randomizable/SKILL.md``randomizable` (tool: `lore-vocabulary`)
10. `sections/tools/SKILL.md``tools` (tool: `list-tools`)
11. `sections/dmNotes/SKILL.md``dmNotes`
12. `sections/xpReward/SKILL.md``xpReward`
2. `sections/players-lobby/SKILL.md``minPlayers` / `maxPlayers`
3. `sections/setting/SKILL.md``location` / `mood` / `ambientNpcs` (tool: `lore-locations`)
4. `sections/tone/SKILL.md``tone`
5. `sections/opening/SKILL.md``openingNarrative` (tool: `lore-atmosphere`)
6. `sections/npcs/SKILL.md``npcs` (tool: `lore-archetypes`)
7. `sections/goals/SKILL.md``goals` (tool: `lore-hooks`)
8. `sections/sportsmanship/SKILL.md``sportsmanshipRules`
9. `sections/skillChecks/SKILL.md``skillChecks`
10. `sections/passive-reveals/SKILL.md``passiveReveals`
11. `sections/randomizable/SKILL.md``randomizable` (tool: `lore-vocabulary`)
12. `sections/tools/SKILL.md``tools` (tool: `list-tools`)
13. `sections/dmNotes/SKILL.md``dmNotes`
14. `sections/xpReward/SKILL.md``xpReward`
### How to present options (the question tool)

View File

@@ -0,0 +1,24 @@
---
name: encounter-wizard-passive-reveals
description: Wizard section 10 — author passiveReveals (skill/threshold/revealText fire at encounter start). No lore tool. No dice in revealText.
---
# Section 10 — `passiveReveals` (optional)
## Describe
`passiveReveals` fire once at encounter start — there are no scenes/stages today (FU-5 deferred). Each entry is: `skill` (a string), `threshold` (a DC integer — **not** a modifier or a `"DC 15"` string), and `revealText` (the outcome prose the bot shows if a character's passive score meets the threshold). Group-visible, attributed to the qualifying player; there is no private delivery path. This is LLM-contract surface — the bot owns dice; `revealText` is what the player notices, never a roll result.
## Options
The passive checks the scene implies — Insight to notice a hidden motive, Perception to spot a concealed door, Investigation to read a faded inscription. Use `specs/velvet-auction.yaml` as the real example (`Insight` / `15` → Karr's tremor).
## Tool
None. You **propose the checks** from what the user described, then the user owns the thresholds + `revealText`.
## Ask
Open prompt: *"Here are the passive checks your scene implies: <list>. For each: skill, threshold, and what the bot reveals. Add any I missed."* Record the array (`skill`, `threshold`, `revealText` per entry).
## Pitfalls
- **CRITICAL — no dice results in `revealText`.** Same LLM-contract rule as `openingNarrative` / `_note`: the bot reveals, never rolls into prose.
- `threshold` written as a modifier (`+2`) or a `"DC 15"` string → rework to a bare integer with the user.
- `revealText` that leaks info beyond what the threshold gates (a `Perception 12` reveal that narrates the whole hidden door mechanism).
- A roll expression (`d20`, `1d6`) smuggled into `revealText` → strip it.

View File

@@ -0,0 +1,28 @@
---
name: encounter-wizard-players-lobby
description: Wizard section 2 — author minPlayers/maxPlayers (the lobby floor and cap). Begin is enabled only when enough players join; maxPlayers caps the lobby. No lore tool.
---
# Section 2 — `minPlayers` + `maxPlayers` (players & lobby)
## Describe
- **`minPlayers`** (optional, default `1`) — the floor before the lobby's Begin button is enabled. `1` (or omit) means solo-able; `≥ 2` requires a lobby that fills before start. `0` is invalid — omit the field to mean solo-able.
- **`maxPlayers`** (optional) — the lobby cap. Absent means no cap. At the cap, lobby Join is disabled.
- **Lobby gating:** `/encounter start` opens a lobby; players Join; Begin is enabled only when `joined.length >= minPlayers`; `maxPlayers` caps the lobby. Per recent commits the lobby now opens on ALL encounters including solo, and the starter is NOT pre-joined — so even a `minPlayers: 1` encounter runs through the lobby. Keep this guidance about the spec fields, not the command impl.
## Options
- Solo: `minPlayers: 1` (or omit).
- Duo: `minPlayers: 2`.
- Small group: `minPlayers: 34`.
- `maxPlayers` usually = party size or a bit above. Omit for no cap.
## Tool
None. You **propose the numbers** from what the user has described (the scene's scale), then the user owns the actual values.
## Ask
Open prompt: *"How many players minimum to begin, and what's the lobby cap?"* The user owns the numbers. Record `minPlayers` and `maxPlayers`.
## Pitfalls
- `minPlayers` set lower than the scene actually needs (a heist that needs three confederates at `minPlayers: 1`).
- `maxPlayers` < `minPlayers` → reject and rework with the user.
- Treating these as "recommended" rather than enforced gates — they are hard lobby gates.

View File

@@ -4,7 +4,7 @@ status: backlog
source_prd: ./prd.md
source_arch: ../../arch/arch-mardonar-encounter-engine-2026-06-20/architecture.md
created: 2026-06-22
updated: 2026-06-22
updated: 2026-06-22 (FU-8/10/11/13/14/15/16 closed)
---
# Group Encounters — Follow-up Stories
@@ -26,6 +26,12 @@ deferred, accepted as ship-and-break trade-offs, or left open. The feature set
## Completed (2026-06-22)
- **FU-12** *(added and closed same day)*`velvet-auction` now exercises the group tools: `minPlayers: 3`, a `passiveReveals` entry (Insight/15), and a group Stealth check (`skill_check_group_emit`, `successRule: majority`); `character_status` added to its tool set. The `specsToolsConsistency` `NOT_YET_REFERENCED` allowlist is now empty (all 8 registered tools referenced by specs). See `specs/velvet-auction.yaml`.
- **FU-9** — the 7-step manual pre-release multi-player playtest checklist is checked into the repo as a release gate at `docs/release-playtest-checklist.md` (previously buried only in the arch doc). The optional second-token live E2E remains open if the one-token constraint ever relaxes.
- **FU-11** — `src/lib/systemStrings.ts` created with a named registry of player-facing system strings; rendered-output forbidden-word guard added (catches runtime concatenation, not just source literals). Callers migrated; no behavior change. Commit `7709f19`.
- **FU-13** — `/encounter start` now lobbies on ALL encounters (incl. solo `minPlayers: 1`); the starter is NOT pre-joined (lobby opens empty). Commit `8318055`; lobby live AC updated (0 → join → join → Begin).
- **FU-14** — inactivity TTL auto-expiry + `phase: 'expired'` sweep; `log_encounter` `kind: 'expired'` vs `kind: 'ended'`; in-world Discord notice + thread archive. Commit `8318055`.
- **FU-15** — the 4 gap-case ACs (FR-1114) added to `tests/integration/graphmcp/group-encounter-live.test.ts`: FR-11 simultaneous fan-out (Promise.all, no lost/dup turn), FR-12 successRule matrix (majority/all/n_of_m on 2 rollers), FR-13 per-user ephemeral (each Roll reply = that player's d20+mod vs DC), FR-14 second-claimant rejection (FR-45 idempotency). Commit `2d701fe`.
- **FU-16** — the contradictory "solo scene" comment on `mawfang-pursuit` (`minPlayers: 2`) and `old-friend-bad-timing` (`minPlayers: 3`) corrected to "group scene"; both stay group (author intent confirmed). Stale follow-up comment in the live test header also fixed.
- **FU-8 (OQ-8)** — decision recorded: **keep open rolls** (modifiers visible on the scoreboard), per the assumed default. No hide-modifiers flag added. The arch's "no hide flag for v1 (deferred)" stands as the permanent decision, not a deferral.
---
@@ -91,7 +97,7 @@ deferred, accepted as ship-and-break trade-offs, or left open. The feature set
- **Note:** This was a deliberate scope cut, not an oversight. Pull in only if the DM workflow proves it's needed.
## FU-8 — OQ-8: hidden vs open modifiers on the group scoreboard
- **status:** open question · **confidence: high**
- **status:** **done (2026-06-22)** — keep open rolls (modifiers visible); no hide flag · **confidence: high**
- **Source:** PRD §8 OQ-8 ("The scoreboard shows each player's modifier (`rolled 16 +3`) to the group. Open rolls (modifiers visible) is the assumed default; confirm or require hidden modifiers."); arch line 474 ("no hide flag for v1 (deferred)").
- **Today:** Open rolls — modifiers visible on the scoreboard. No hide flag.
- **Acceptance:**
@@ -107,17 +113,39 @@ deferred, accepted as ship-and-break trade-offs, or left open. The feature set
- **Optional (if the one-token constraint ever relaxes):** a second-token live E2E covering the four "unit+integration only" cases.
- **Note:** Test debt, not a feature gap.
## FU-10 — Verify: encounter-wizard reconciliation sub-skills landed
- **status:** verify · **confidence: medium**
## FU-10 — Encounter-wizard: add Players & Lobby + Passive Reveals sections
- **status:** real story (confirmed gap 2026-06-22) · **confidence: high**
- **Source:** arch line 114 ("Wizard: 2 new sub-skills (Players & Lobby, Passive Reveals) + edits to 2 existing, schema MUST land before wizard reconciliation").
- **Today:** The encounter-wizard skill was built 2026-06-20 with 12 nested section skill files (per project memory). Whether the two new sub-skills (Players & Lobby, Passive Reveals) and the two edits actually landed is unconfirmed against this doc.
- **Acceptance:** Confirm `.claude/skills/mardonar-encounter-wizard/sections/` contains Players & Lobby + Passive Reveals sub-skills and the two edited existing sections reflect group-encounter fields. If missing → file as a real story.
- **Today:** `.claude/skills/mardonar-encounter-wizard/sections/` has: dmNotes, goals, identity, npcs, opening, randomizable, setting, skillChecks, sportsmanship, tone, tools, xpReward — **no Players & Lobby and no Passive Reveals sections**. So authors can't set `minPlayers`/`maxPlayers`/`passiveReveals` via the guided wizard (only by hand-editing YAML).
- **Acceptance:** Add a Players & Lobby section sub-skill (minPlayers/maxPlayers + the lobby-gating explanation) + a Passive Reveals section sub-skill (skill/threshold/revealText + the no-dice-in-revealText pitfall), per the arch + spec-authoring-guide. Wire them into the wizard's section order.
- **Depends on:** none (the schema fields already exist).
## FU-11 — Verify: `src/lib/systemStrings.ts` centralization landed
- **status:** verify · **confidence: medium**
## FU-11 — Create `src/lib/systemStrings.ts` + rendered-output forbidden-word grep
- **status:** real story (confirmed gap 2026-06-22 — the module does NOT exist) · **confidence: high**
- **Source:** arch line 114 ("In-world voice: centralize system-string templates in one module (`src/lib/systemStrings.ts`) + named registry + forbidden-word grep on rendered output"); decision-log round 3 (Paige: "systemStrings.voice = 7th harness").
- **Today:** The in-world-voice rule is enforced by convention. Whether the centralized `systemStrings.ts` module + the forbidden-word grep on **rendered** output (catches runtime concatenation, not just source literals) landed is unconfirmed.
- **Acceptance:** Confirm `src/lib/systemStrings.ts` exists, the named registry is in use, and a CI/hook grep runs on rendered output. If missing → file as a real story (it's a contract-integrity guard for new narrator paths, same family as ADR-005).
- **Today:** No `src/lib/systemStrings.ts`; system strings (the expiry notice, "A group roll is still pending", filter-correction text, etc.) are inlined across handlers. No rendered-output forbidden-word grep (a source-literal grep wouldn't catch runtime concatenation).
- **Acceptance:**
- Create `src/lib/systemStrings.ts` with a named registry of player-facing system strings (migrate the inlined ones).
- A check that scans **rendered** output for forbidden utility words (the in-world-voice rule) — unit test or CI step — catching runtime concatenation, not just source literals.
- Callers migrated to the registry; no behavior change.
- **Depends on:** none. Same family as ADR-005 (contract-integrity guard for new narrator paths).
## FU-15 — Multiplayer live E2E: the 4 gap-case ACs (FR-1114)
- **status:** **done (2026-06-22)** — all 4 ACs live-covered · **confidence: high**
- **Source:** multiplayer E2E PRD FR-1114; group-encounters arch line 111 (the "unit+integration only" gap cases).
- **Today:** The live multiplayer E2E (MVP) covers lobby gating, 2 real routed turns, + group check N=2 finalization. The 4 documented residual-risk gap cases are NOT yet live-covered.
- **Acceptance** (extend `tests/integration/graphmcp/group-encounter-live.test.ts`):
- **FR-11 (simultaneous fan-out):** both players post near-simultaneously (Promise.all); assert no lost/duplicated turn — history has both authors' turns.
- **FR-12 (successRule N>1 matrix):** run the group check under `majority`, `all`, and `n_of_m`; assert the correct group outcome for each on 2 rollers.
- **FR-13 (per-user ephemeral):** each player's Roll fakeInteraction reply contains that player's d20+mod vs DC (per-user, not shared).
- **FR-14 (second-claimant rejection):** the same userId clicks Roll twice → second rejected (FR-45 idempotency lock); scoreboard shows one roll for that player.
- **Depends on:** the live multiplayer E2E harness (done). No blockers.
## FU-16 — Fix contradictory "solo scene" comment on mawfang-pursuit + old-friend-bad-timing
- **status:** **done (2026-06-22)** — both kept group, comment corrected · **confidence: high**
- **Source:** 2026-06-22 review — both specs have the comment "Group encounters (Feature D) — solo scene… Default would be 1" but `minPlayers: 2` (mawfang) / `minPlayers: 3` (old-friend), which makes them group encounters.
- **Acceptance:** Either set `minPlayers: 1` (if solo was intended) OR fix the comment to reflect the group intent. **Owner: the user** — the value is a scene decision.
- **Note:** Trivial doc fix pending the user's intent call.
---

View File

@@ -130,11 +130,17 @@ skillChecks:
if players demonstrate knowledge of Consortium operations (showing respect
for the institution helps). Disadvantage if players have been hostile earlier.
trade_deal_dc: 12_then_14
trade_deal_skill: "Persuasion, then Insight or Investigation"
trade_deal_note: >
DC 12 Insight to read what Gorga actually wants. DC 14 Persuasion to close
the trade. Failing the first roll means guessing blind.
trade_deal_read_dc: 12
trade_deal_read_skill: "Insight"
trade_deal_read_note: >
Reading what Gorga actually wants — a name, a location, access. Failing
this means guessing blind on the close.
trade_deal_close_dc: 14
trade_deal_close_skill: "Persuasion"
trade_deal_close_note: >
Closing the trade once the players know what to offer. Requires a credible
proposal; succeeds on DC 14 with the right bait.
intimidate_gorga_dc: 16
intimidate_gorga_skill: "Intimidation"

View File

@@ -63,10 +63,84 @@ skillChecks:
succeed; optionally durationSeconds: 60). Failure means the patrol notices
the movement.
door_entry_dc: 12
door_entry_skill: "Athletics or Thieves' Tools"
door_entry_note: >
Forcing or picking the side door once the party has slipped past.
group_door_entry_dc: 12
group_door_entry_skill: "Athletics or Thieves' Tools"
group_door_entry_note: >
A GROUP check via skill_check_group_emit with successRule: majority —
forcing or picking the side door together once the party has slipped past.
A single roll won't cover both the lock and the lookout's attention; the
majority pass means at least one player keeps eyes on the patrol while the
others work the door.
group_alarm_dc: 14
group_alarm_skill: "Arcana"
group_alarm_note: >
A GROUP check via skill_check_group_emit with successRule: all — the party
must collectively disarm the warehouse alarm once inside. A single missed
pulse triggers the alert; the warehouse locks down and the encounter ends.
group_patrol_dc: 12
group_patrol_skill: "Perception"
group_patrol_note: >
A GROUP check via skill_check_group_emit with successRule: majority —
coordinated perception of the patrol's rhythm so the party times its
movement together. Majority failure means at least one player walks into
a lantern's arc.
group_window_dc: 13
group_window_skill: "Acrobatics"
group_window_note: >
A GROUP check via skill_check_group_emit with successRule: majority — the
alternate exit through the loading-bay window when the side door is
contested. The party has to clear the sill together so no one is left
dangling for the patrol to find.
# Individual checks — 3 per joined player, suffixed _p1 / _p2. Player ordinals
# match Discord's join order at session start; the LLM must resolve the suffix
# against the joined-player list before emitting. Per the architecture memory
# entry, ordinals are brittle (FR-43 fail-open name-match) — accepted as the
# author's choice for explicit per-player routing in the E2E fixture.
door_entry_p1_dc: 12
door_entry_p1_skill: "Thieves' Tools"
door_entry_p1_note: >
Player 1 solo: picking the side door's inner lock after the group has
slipped past the patrol. The group_door_entry check covers the brute-force
option; this is the finesse option for the lockpicker.
door_entry_p2_dc: 12
door_entry_p2_skill: "Athletics"
door_entry_p2_note: >
Player 2 solo: forcing the side door's hinges when the lock is beyond the
party's tools. The group_door_entry check is the cooperative version;
this is the solo brute-force for the muscle of the pair.
alarm_disarm_p1_dc: 14
alarm_disarm_p1_skill: "Arcana"
alarm_disarm_p1_note: >
Player 1 solo: tracing the alarm's resonance signature before triggering
it. Mirrors the group_alarm check's effect at lower stakes — succeeds in
silence on a single arcana pass, fails noisily otherwise.
alarm_disarm_p2_dc: 14
alarm_disarm_p2_skill: "Investigation"
alarm_disarm_p2_note: >
Player 2 solo: reading the alarm's physical trigger path (trip wires,
pressure plates) without the arcana route. Investigation instead of
Arcana so the two players have mechanically distinct paths.
loot_distribute_p1_dc: 10
loot_distribute_p1_skill: "Insight"
loot_distribute_p1_note: >
Player 1 solo: judging which crate is safe to open first, before the
patrol's second loop. A low DC — anything below 10 means the player
grabs the wrong crate and the wrong crate is loud.
loot_distribute_p2_dc: 10
loot_distribute_p2_skill: "Perception"
loot_distribute_p2_note: >
Player 2 solo: spotting the patrol's lantern returning down the second
aisle. Symmetric to loot_distribute_p1 but Perception-flavoured so each
player has a distinct read on the room.
# Passive reveal — bot-applied at encounter start, group-visible, attributed.
passiveReveals:

View File

@@ -1,8 +1,8 @@
encounterId: "mawfang-pursuit-001"
title: "Unwelcome Guests"
# Group encounters (Feature D) — solo scene, explicit for documentation.
# Default would be 1; explicit value documents the author's intent.
# Group encounters (Feature D) — group scene (duo pursuit).
# Explicit minPlayers documents the author's intent; Begin needs ≥ this many joined.
minPlayers: 2
tone: "tense"
@@ -175,11 +175,14 @@ skillChecks:
covers two of them; a second roll is needed for the third. Usk himself requires
DC 15 — he is hard to fool.
persuade_fizbet_device_dc: 16_or_12
persuade_fizbet_device_dc: 16
persuade_fizbet_device_skill: "Persuasion"
persuade_fizbet_device_note: >
DC 16 cold. DC 12 if players first listened to her explain the device and
acknowledged what it cost her to build it. The distinction matters to her.
DC 16 cold. The LLM may treat the effective DC as 12 if players first
listened to Fizbet explain the device and acknowledged what it cost her
to build it — that distinction matters to her. (Spec-author note: the
conditional reduction is encoded in prose because the engine only takes
a single integer DC per emit; the LLM applies the discount in-world.)
randomizable:
- key: gnome_name

View File

@@ -25,8 +25,8 @@ encounterId: "old-friend-bad-timing"
title: "Old Friend, Bad Timing"
# Group encounters (Feature D) — solo scene, explicit for documentation.
# Default would be 1; explicit value documents the author's intent.
# Group encounters (Feature D) — group scene (trio at the tavern).
# Explicit minPlayers documents the author's intent; Begin needs ≥ this many joined.
minPlayers: 3
setting:

View File

@@ -82,6 +82,10 @@ goals:
- id: "break_curse"
label: >
The party breaks the cursed timepiece's hold on the customer.
- id: "clock_revealed"
label: >
The party sees the truth: the curse is in the clocks themselves, not
the maker — his hands stay clean, his wares do not.
secondary:
- id: "customer_leaves_cursed"
label: >
@@ -91,10 +95,6 @@ goals:
label: >
The clock-maker's warmth holds; the party leaves uneasy, the wares
still ticking.
- id: "clock_revealed"
label: >
The party sees the truth: the curse is in the clocks themselves, not
the maker — his hands stay clean, his wares do not.
# sportsmanshipRules — LLM READS. Hard guardrails; the LLM redirects absurd or
# game-breaking actions in-character.

View File

@@ -112,6 +112,40 @@ skillChecks:
targeting all joined players, with successRule: majority and durationSeconds: 60.
Failure means the guards or the abjuration wards notice the coordinated movement.
group_distraction_dc: 14
group_distraction_skill: "Performance or Deception"
group_distraction_note: >
A GROUP check via skill_check_group_emit with successRule: majority — the
party stages a coordinated distraction to draw guards and Vesper's attention
away from the case. A single player cannot pull this alone; it requires
visible group motion (a toast, a brawl, a summoning). Failure means the
room's attention does not move and any subsequent solo steal picks up
disadvantage.
group_ward_disarm_dc: 14
group_ward_disarm_skill: "Arcana"
group_ward_disarm_note: >
A GROUP check via skill_check_group_emit with successRule: all — every
party member channels a synchronized pulse at the abjuration ward to drop
it for the window the steal needs. Partial success is failure: a single
unsynced pulse resets the ward to full strength and alerts Madame Vesper.
group_handoff_dc: 13
group_handoff_skill: "Sleight of Hand or Acrobatics"
group_handoff_note: >
A GROUP check via skill_check_group_emit with successRule: majority — once
the artifact is in hand, the party passes it from one to another through
the crowd so no single player is caught carrying it at the door. Failure
means the holder is the one Karr or the guards confront at the exit.
group_escape_dc: 12
group_escape_skill: "Stealth or Athletics"
group_escape_note: >
A GROUP check via skill_check_group_emit with successRule: majority — the
final coordinated exit, the party leaving in pairs through separate doors
so the loss isn't pinned to a single face. A single player running alone
through the front door is the failure mode this check prevents.
# Passive skill reveals (Feature B) — bot-applied at encounter start, group-visible,
# attributed to the qualifying player. threshold is a passive DC (integer); revealText
# is outcome prose only — no dice results (the engine owns rolls).

View File

@@ -4,6 +4,7 @@ import { sessionManager } from '../../session/sessionManager.js';
import { config } from '../../config.js';
import { logEncounter } from '../../graphmcp/client.js';
import { log } from '../../lib/logger.js';
import { EXPIRED_NOTICE } from '../../lib/systemStrings.js';
// Inactivity expiry sweep (FU-14). An open encounter with no activity for
// longer than ENCOUNTER_INACTIVITY_TTL_HOURS is finalized as phase:'expired'.
@@ -21,10 +22,6 @@ import { log } from '../../lib/logger.js';
const EXPIRY_TTL_MS = 60 * 60 * 1000 * config.ENCOUNTER_INACTIVITY_TTL_HOURS;
// In-world voice (no utility jargon). Inline until a systemStrings module lands
// (FU-11 verify item); keep it centralized here so it's the one place to edit.
const EXPIRED_NOTICE = '*The moment passes unresolved, and the road moves on. The gathering disperses.*';
export async function runExpirySweep(client?: Client): Promise<{ scanned: number; finalized: number }> {
let scanned = 0;
let finalized = 0;

View File

@@ -5,6 +5,10 @@ import { beginEncounter, loadNpcMemories, applyResolved } from '../commands/enco
import { playerRegistry } from '../../session/playerRegistry.js';
import { loadSpec } from '../../spec/loader.js';
import { resolveRandomizables } from '../../graphmcp/loreResolver.js';
import {
LOBBY_BEGIN_ANNOUNCEMENT, LOBBY_CANCELLED, LOBBY_NO_LONGER_OPEN,
LOBBY_ALREADY_JOINED, LOBBY_FULL, LOBBY_NOT_IN_LOBBY, LOBBY_ONLY_STARTER_CANCEL,
} from '../../lib/systemStrings.js';
import type { Player } from '../../types/index.js';
type RollChannel = ThreadChannel | TextChannel;
@@ -28,22 +32,22 @@ export async function handleLobbyInteraction(interaction: ButtonInteraction, cli
async function handleJoin(interaction: ButtonInteraction, channel: RollChannel, threadId: string): Promise<void> {
const lobby = await getLobby(threadId);
if (!lobby) {
await interaction.reply({ content: 'This lobby is no longer open.', flags: 64 });
await interaction.reply({ content: LOBBY_NO_LONGER_OPEN, flags: 64 });
return;
}
const profile = await playerRegistry.get(lobby.guildId, interaction.user.id).catch(() => null);
const dndName = profile?.dndName ?? interaction.user.username;
const res = await joinLobby(threadId, interaction.user.id, dndName);
if (!res) {
await interaction.reply({ content: 'This lobby is no longer open.', flags: 64 });
await interaction.reply({ content: LOBBY_NO_LONGER_OPEN, flags: 64 });
return;
}
if (res.alreadyJoined) {
await interaction.reply({ content: 'You have already joined.', flags: 64 });
await interaction.reply({ content: LOBBY_ALREADY_JOINED, flags: 64 });
return;
}
if (res.capReached) {
await interaction.reply({ content: 'The lobby is full.', flags: 64 });
await interaction.reply({ content: LOBBY_FULL, flags: 64 });
return;
}
const ready = res.state.joined.length >= res.state.minPlayers;
@@ -54,7 +58,7 @@ async function handleJoin(interaction: ButtonInteraction, channel: RollChannel,
async function handleLeave(interaction: ButtonInteraction, channel: RollChannel, threadId: string): Promise<void> {
const updated = await leaveLobby(threadId, interaction.user.id);
if (!updated) {
await interaction.reply({ content: 'You are not in this lobby.', flags: 64 });
await interaction.reply({ content: LOBBY_NOT_IN_LOBBY, flags: 64 });
return;
}
const ready = updated.joined.length >= updated.minPlayers;
@@ -65,7 +69,7 @@ async function handleLeave(interaction: ButtonInteraction, channel: RollChannel,
async function handleStartBtn(interaction: ButtonInteraction, channel: RollChannel, threadId: string, _client: Client): Promise<void> {
const lobby = await getLobby(threadId);
if (!lobby) {
await interaction.reply({ content: 'This lobby is no longer open.', flags: 64 });
await interaction.reply({ content: LOBBY_NO_LONGER_OPEN, flags: 64 });
return;
}
if (lobby.joined.length < lobby.minPlayers) {
@@ -94,7 +98,7 @@ async function handleStartBtn(interaction: ButtonInteraction, channel: RollChann
// Close the lobby UI: update the lobby embed in place to the begin
// announcement (drops the Join/Begin buttons so they don't stay stale) BEFORE
// the opening narrative, so the thread reads announcement → opening.
await interaction.update({ content: '🗡️ The gathering has set out — the encounter begins!', embeds: [], components: [] }).catch(() => null);
await interaction.update({ content: LOBBY_BEGIN_ANNOUNCEMENT, embeds: [], components: [] }).catch(() => null);
// Rename the thread to signal the lobby is closed + the encounter is underway.
if (channel.isThread()) await (channel as ThreadChannel).setName(`⚔️ ${resolvedSpec.title} — underway`).catch(() => null);
await beginEncounter(channel as ThreadChannel, resolvedSpec, resolvedContext, npcMemories, lobby.guildId, lobby.specName, players);
@@ -103,15 +107,15 @@ async function handleStartBtn(interaction: ButtonInteraction, channel: RollChann
async function handleCancel(interaction: ButtonInteraction, channel: RollChannel, threadId: string): Promise<void> {
const lobby = await getLobby(threadId);
if (!lobby) {
await interaction.reply({ content: 'This lobby is no longer open.', flags: 64 });
await interaction.reply({ content: LOBBY_NO_LONGER_OPEN, flags: 64 });
return;
}
if (interaction.user.id !== lobby.starterId) {
await interaction.reply({ content: 'Only the starter can cancel the lobby.', flags: 64 });
await interaction.reply({ content: LOBBY_ONLY_STARTER_CANCEL, flags: 64 });
return;
}
await clearLobby(threadId);
if (channel.isThread()) await (channel as ThreadChannel).setName(`⚔️ ${lobby.title} — cancelled`).catch(() => null);
await interaction.update({ content: '❌ The gathering was cancelled.', embeds: [], components: [] }).catch(() => null);
await interaction.update({ content: LOBBY_CANCELLED, embeds: [], components: [] }).catch(() => null);
if (channel.isThread()) await channel.setArchived(true).catch(() => null);
}

View File

@@ -13,6 +13,7 @@ import { filterLLMResponse, logFiltered, detectMissedSkillCheck } from './respon
import { registerScheduled, drainPending, clearPending, upgradeToProcessing, upgradeToComplete, cleanupReactions } from './reactionManager.js';
import { isBurstCapped, incrementBurst, resetBurst, sendDropNotice } from './queueCap.js';
import { isE2EPlayerBot } from '../lib/e2ePlayerAllowlist.js';
import { GROUP_ROLL_PENDING } from '../../lib/systemStrings.js';
import { log } from '../../lib/logger.js';
import type { ChatMessage, SessionState } from '../../types/index.js';
@@ -151,7 +152,7 @@ async function processEncounterMessage(
// group check. No skip counter — the check finalizes on all-rolled or the
// timer (timed / no-show backstop).
if (session.pendingGroupCheck) {
await thread.send('*A group roll is still pending — the party must roll.*');
await thread.send(GROUP_ROLL_PENDING);
return;
}

View File

@@ -11,6 +11,7 @@ import { scheduleEncounterLLMTurn } from './messageRouter.js';
import { clearSkillCheckTimer } from './skillCheckTimer.js';
import { recordGroupRoll, finalizeGroupCheck } from '../../harness/groupCheckManager.js';
import { buildGroupRollEphemeralEmbed, buildGroupScoreboardEmbed } from '../embeds/groupScoreboard.js';
import { ROLL_ALREADY_RESOLVED, ROLL_NOT_YOURS, ROLL_ALREADY_ROLLED } from '../../lib/systemStrings.js';
import type { ChatMessage } from '../../types/index.js';
type RollChannel = ThreadChannel | TextChannel;
@@ -52,13 +53,13 @@ async function submitResult(interaction: ButtonInteraction, client: Client): Pro
const session = await sessionManager.get(channel.id);
const pending = session?.pendingSkillCheck;
if (!pending) {
await interaction.reply({ content: 'This skill check has already been resolved.', flags: 64 });
await interaction.reply({ content: ROLL_ALREADY_RESOLVED, flags: 64 });
return;
}
// FR-43 player-lock.
if (!canRoll(pending.discordId, interaction.user.id)) {
await interaction.reply({ content: 'This roll is not yours to make.', flags: 64 });
await interaction.reply({ content: ROLL_NOT_YOURS, flags: 64 });
return;
}
@@ -127,17 +128,17 @@ async function submitGroupRoll(
): Promise<void> {
const gc = session.pendingGroupCheck;
if (!gc) {
await interaction.reply({ content: 'This skill check has already been resolved.', flags: 64 });
await interaction.reply({ content: ROLL_ALREADY_RESOLVED, flags: 64 });
return;
}
// FR-43 player-lock: the clicker must be a targeted player.
const targeted = gc.rolls.find(r => r.discordId === interaction.user.id);
if (!targeted) {
await interaction.reply({ content: 'This roll is not yours to make.', flags: 64 });
await interaction.reply({ content: ROLL_NOT_YOURS, flags: 64 });
return;
}
if (targeted.rolled) {
await interaction.reply({ content: 'You have already rolled.', flags: 64 });
await interaction.reply({ content: ROLL_ALREADY_ROLLED, flags: 64 });
return;
}
@@ -150,11 +151,11 @@ async function submitGroupRoll(
// Record atomically — re-check idempotency inside the mutex.
const result = await recordGroupRoll(channel.id, interaction.user.id, roll.value, modifier);
if (!result.gc) {
await interaction.reply({ content: 'This skill check has already been resolved.', flags: 64 });
await interaction.reply({ content: ROLL_ALREADY_RESOLVED, flags: 64 });
return;
}
if (result.alreadyRolled) {
await interaction.reply({ content: 'You have already rolled.', flags: 64 });
await interaction.reply({ content: ROLL_ALREADY_ROLLED, flags: 64 });
return;
}

51
src/lib/systemStrings.ts Normal file
View File

@@ -0,0 +1,51 @@
// Centralized player-facing system strings (FU-11 / in-world voice).
//
// All bot strings that players see in encounter threads / ephemeral replies
// live here as a named registry, so the in-world-voice rule (no utility jargon)
// has one place to enforce. `hasForbiddenUtilityWord` scans any rendered string
// for engine/code terms that must never reach players — a guard against future
// narrator paths leaking `tool_call` / `pendingSkillCheck` / tool-name text
// (same family as the responseFilter last-line defense, ADR-005). New
// player-facing strings should be added here + the registry test enforces they
// stay clean.
// Engine / utility terms that must NEVER appear in player-facing text. The
// underscored tool names + field names distinguish them from natural language
// (a player string may say "skill check"; it must never say "skill_check_emit").
export const FORBIDDEN_UTILITY_WORDS: readonly string[] = [
// code identifiers / field names
'tool_call', 'pendingSkillCheck', 'pendingGroupCheck', 'atomicMutate',
'sessionManager', 'discordId', 'dndName', 'encounterId', 'foundryActorUuid',
'resolvedContext', 'npcMemories', 'heldMessages',
// registered tool names
'skill_check_emit', 'skill_check_group_emit', 'encounter_resolve',
'context_recall', 'goal_register', 'character_status',
'foundry_lookup', 'foundry_reward',
// meta / system terms
'JSON', 'GraphMCP', 'redis', 'LLM', 'API', 'endpoint', 'Zod', 'schema',
'Foundry', 'VTT',
];
/** True if `text` contains any forbidden utility term (case-insensitive). */
export function hasForbiddenUtilityWord(text: string): boolean {
const lower = text.toLowerCase();
return FORBIDDEN_UTILITY_WORDS.some(w => lower.includes(w.toLowerCase()));
}
// ── Lobby (Feature D) ───────────────────────────────────────────────────────
export const LOBBY_BEGIN_ANNOUNCEMENT = '🗡️ The gathering has set out — the encounter begins!';
export const LOBBY_CANCELLED = '❌ The gathering was cancelled.';
export const LOBBY_NO_LONGER_OPEN = 'This lobby is no longer open.';
export const LOBBY_ALREADY_JOINED = 'You have already joined.';
export const LOBBY_FULL = 'The lobby is full.';
export const LOBBY_NOT_IN_LOBBY = 'You are not in this lobby.';
export const LOBBY_ONLY_STARTER_CANCEL = 'Only the starter can cancel the lobby.';
// ── Roll (FR-43 single player-locked Roll) ──────────────────────────────────
export const ROLL_ALREADY_RESOLVED = 'This skill check has already been resolved.';
export const ROLL_NOT_YOURS = 'This roll is not yours to make.';
export const ROLL_ALREADY_ROLLED = 'You have already rolled.';
// ── Encounter flow ──────────────────────────────────────────────────────────
export const GROUP_ROLL_PENDING = '*A group roll is still pending — the party must roll.*';
export const EXPIRED_NOTICE = '*The moment passes unresolved, and the road moves on. The gathering disperses.*';

View File

@@ -16,7 +16,9 @@
// scoreboard embed (Rolled field + final footer + buttons removed), plus the 👀
// reaction on a player's message (proves it routed through handleMessage).
//
// The 4 gap-case ACs (FR-1114) remain a follow-up story.
// The 4 gap-case ACs (FR-1114) are covered below (see "FU-15: the 4 gap-case
// ACs" block): simultaneous fan-out, successRule N>1 matrix, per-user
// ephemeral, and second-claimant rejection.
import './support/env.js';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -31,11 +33,12 @@ import { getLobby } from '../../../src/session/lobbyManager.js';
import { buildGroupScoreboardEmbed } from '../../../src/bot/embeds/groupScoreboard.js';
import { buildRollButtons } from '../../../src/bot/embeds/skillCheck.js';
import { connectLiveBots, disconnectLiveBots, type LiveBots } from './support/liveBots.js';
import { fakeInteraction, fakeButton, parseThreadIdFromReply } from './support/fakes.js';
import { fakeInteraction, fakeButton, parseThreadIdFromReply, type CapturedReply } from './support/fakes.js';
import { flushRedisForGuild, disconnectRedis, deleteThread, deleteSession } from './support/cleanup.js';
import { waitFor } from './support/poll.js';
import { config } from '../../../src/config.js';
import type { PendingGroupCheck, PendingGroupCheckRoll } from '../../../src/types/index.js';
import type { SuccessRule } from '../../../src/harness/successRule.js';
const runE2E = process.env.RUN_FULL_E2E === '1' && !!process.env.E2E_PLAYER2_TOKEN;
const specName = 'e2e-group-multiplayer';
@@ -317,4 +320,153 @@ describe.skipIf(!runE2E)('Multiplayer live E2E — MVP (2 player-bots, driving t
'scoreboard footer matches the recorded outcome',
).toBe(true);
}, 180_000);
// ── FU-15: the 4 gap-case ACs (FR-1114) ──────────────────────────────────
// Helpers (close over the describe's threadId/thread/bots/player ids).
async function setupGroupCheck(rule: SuccessRule, dc = 13): Promise<void> {
const rolls: PendingGroupCheckRoll[] = [
{ discordId: player1Id, dndName: 'Player1', rolled: false, modifier: 0 },
{ discordId: player2Id, dndName: 'Player2', rolled: false, modifier: 0 },
];
const scoreboard = await thread!.send({
embeds: [buildGroupScoreboardEmbed('Stealth', 'group check', dc, rolls)],
components: [buildRollButtons()],
});
const gc: PendingGroupCheck = {
skill: 'Stealth', prompt: 'group check', dc,
messageId: scoreboard.id, successRule: rule, rolls,
};
await sessionManager.atomicMutate(threadId!, () => ({ pendingGroupCheck: gc }));
}
async function driveBothRollsAndFinalize(): Promise<string> {
await handleRollInteraction(fakeButton(thread!, 'sc_roll', player1Id, 'Player1').interaction, bots.botClient);
await handleRollInteraction(fakeButton(thread!, 'sc_roll', player2Id, 'Player2').interaction, bots.botClient);
const result = await waitFor(async () => {
const s = await sessionManager.get(threadId!);
if (!s || s.pendingGroupCheck) return null;
const msgs = s.history.filter(m => typeof m.content === 'string' && m.content.startsWith('[GROUP CHECK RESULT]'));
return msgs.length ? msgs[msgs.length - 1].content as string : null;
}, { timeoutMs: 30_000, intervalMs: 1_000 });
return result ?? '';
}
function parseResult(content: string): { successes: number; recordedSuccess: boolean; rule: string } {
const successes = (content.match(/✅/g) ?? []).length;
const recordedSuccess = content.includes('SUCCESS');
const ruleMatch = content.match(/Rule: (\S+)/);
return { successes, recordedSuccess, rule: ruleMatch?.[1] ?? '' };
}
// For 2 rollers: majority = ≥ ceil(2/2)=1; all = both; n_of_m(n=1,m=2) = ≥1.
function expectedSuccess(rule: string, successes: number): boolean {
if (rule === 'all') return successes === 2;
if (rule === 'majority') return successes >= 1;
if (rule === 'n_of_m') return successes >= 1;
return false;
}
function ephemeralDescription(reply: CapturedReply): string {
const embed = (reply.embeds as unknown[] | undefined)?.[0] as
| { data?: { description?: string }; description?: string }
| undefined;
return embed?.data?.description ?? embed?.description ?? '';
}
// FR-11 — simultaneous fan-out: no lost/duplicated turn ─────────────────────
it('FR-11: both players post near-simultaneously — no lost or duplicated turn', async () => {
expect(threadId, 'depends on lobby start').toBeTruthy();
thread = thread ?? (await bots.channel.threads.fetch(threadId!));
// Clear any pending check so the turns aren't held at the pending guard.
await sessionManager.atomicMutate(threadId!, () => ({
pendingGroupCheck: undefined,
pendingSkillCheck: undefined,
}));
const p1Thread = (await bots.players[0].channels.fetch(threadId!)) as ThreadChannel;
const p2Thread = (await bots.players[1].channels.fetch(threadId!)) as ThreadChannel;
// Near-simultaneous: both fire in the same tick (Promise.all).
const [p1Msg, p2Msg] = await Promise.all([
p1Thread.send('Player1 simultaneous: I move on the door now.'),
p2Thread.send('Player2 simultaneous: I cover the left flank.'),
]);
// Both 👀 (routing — both received) + both turns in history exactly once.
await waitFor(async () => {
const m = await thread!.messages.fetch(p1Msg.id).catch(() => null);
return m && m.reactions.cache.some(r => r.emoji.name === '👀') ? m : null;
}, { timeoutMs: 30_000, intervalMs: 500 });
await waitFor(async () => {
const m = await thread!.messages.fetch(p2Msg.id).catch(() => null);
return m && m.reactions.cache.some(r => r.emoji.name === '👀') ? m : null;
}, { timeoutMs: 30_000, intervalMs: 500 });
const noLostDup = await waitFor(async () => {
const s = await sessionManager.get(threadId!);
if (!s) return null;
const p1Hits = s.history.filter(m => typeof m.content === 'string' && m.content.includes('I move on the door now')).length;
const p2Hits = s.history.filter(m => typeof m.content === 'string' && m.content.includes('I cover the left flank')).length;
return p1Hits === 1 && p2Hits === 1 ? s : null;
}, { timeoutMs: 30_000, intervalMs: 500 });
expect(noLostDup, 'both turns appended exactly once — no lost/duplicated turn').toBeTruthy();
}, 180_000);
// FR-12 — successRule N>1 matrix ───────────────────────────────────────────
it('FR-12: successRule N>1 — majority, all, n_of_m each finalize with the correct outcome', async () => {
expect(threadId, 'depends on lobby start').toBeTruthy();
thread = thread ?? (await bots.channel.threads.fetch(threadId!));
const cases: { rule: SuccessRule; label: string }[] = [
{ rule: { kind: 'majority' }, label: 'majority' },
{ rule: { kind: 'all' }, label: 'all' },
{ rule: { kind: 'n_of_m', n: 1, m: 2 }, label: 'n_of_m' },
];
for (const c of cases) {
await setupGroupCheck(c.rule);
const content = await driveBothRollsAndFinalize();
expect(content, `${c.label}: finalized with [GROUP CHECK RESULT]`).toBeTruthy();
const { successes, recordedSuccess, rule } = parseResult(content);
expect(rule, `${c.label}: rule kind recorded`).toBe(c.label);
expect(
recordedSuccess,
`${c.label}: outcome matches the rule + rolls (successes=${successes})`,
).toBe(expectedSuccess(rule, successes));
}
}, 300_000);
// FR-13 — per-user ephemeral ───────────────────────────────────────────────
it('FR-13: per-user ephemeral — each Roll reply shows that player\'s own roll', async () => {
expect(threadId, 'depends on lobby start').toBeTruthy();
thread = thread ?? (await bots.channel.threads.fetch(threadId!));
await setupGroupCheck({ kind: 'majority' });
const p1 = fakeButton(thread!, 'sc_roll', player1Id, 'Player1');
const p2 = fakeButton(thread!, 'sc_roll', player2Id, 'Player2');
await handleRollInteraction(p1.interaction, bots.botClient);
await handleRollInteraction(p2.interaction, bots.botClient);
const p1Reply = p1.replies.at(-1);
const p2Reply = p2.replies.at(-1);
expect(p1Reply, 'player1 got an ephemeral reply').toBeTruthy();
expect(p2Reply, 'player2 got an ephemeral reply').toBeTruthy();
const p1Desc = ephemeralDescription(p1Reply!);
const p2Desc = ephemeralDescription(p2Reply!);
// The ephemeral is a per-user roll embed: "d20 **X** ... vs DC **13**".
expect(p1Desc, 'player1 ephemeral is a roll embed').toContain('d20');
expect(p2Desc, 'player2 ephemeral is a roll embed').toContain('d20');
expect(p1Desc, 'player1 ephemeral shows the DC').toContain('DC');
expect(p2Desc, 'player2 ephemeral shows the DC').toContain('DC');
// Finalize so pendingGroupCheck clears for the next AC.
await waitFor(async () => {
const s = await sessionManager.get(threadId!);
return s && !s.pendingGroupCheck ? s : null;
}, { timeoutMs: 30_000, intervalMs: 1_000 });
}, 180_000);
// FR-14 — second-claimant rejection (FR-45 idempotency) ────────────────────
it('FR-14: a duplicate Roll click by the same user is rejected — no duplicate roll', async () => {
expect(threadId, 'depends on lobby start').toBeTruthy();
thread = thread ?? (await bots.channel.threads.fetch(threadId!));
await setupGroupCheck({ kind: 'majority' });
const first = fakeButton(thread!, 'sc_roll', player1Id, 'Player1');
const second = fakeButton(thread!, 'sc_roll', player1Id, 'Player1'); // same userId — duplicate
await handleRollInteraction(first.interaction, bots.botClient);
await handleRollInteraction(second.interaction, bots.botClient);
const secondReply = second.replies.at(-1);
expect(secondReply?.content, 'second duplicate Roll is rejected').toContain('already rolled');
// Clear the un-finalized check (only player1 rolled) for cleanup.
await sessionManager.atomicMutate(threadId!, () => ({ pendingGroupCheck: undefined }));
}, 180_000);
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import {
hasForbiddenUtilityWord,
FORBIDDEN_UTILITY_WORDS,
// Lobby
LOBBY_BEGIN_ANNOUNCEMENT,
LOBBY_CANCELLED,
LOBBY_NO_LONGER_OPEN,
LOBBY_ALREADY_JOINED,
LOBBY_FULL,
LOBBY_NOT_IN_LOBBY,
LOBBY_ONLY_STARTER_CANCEL,
// Roll
ROLL_ALREADY_RESOLVED,
ROLL_NOT_YOURS,
ROLL_ALREADY_ROLLED,
// Encounter flow
GROUP_ROLL_PENDING,
EXPIRED_NOTICE,
} from '../../src/lib/systemStrings.js';
const REGISTRY = [
LOBBY_BEGIN_ANNOUNCEMENT, LOBBY_CANCELLED, LOBBY_NO_LONGER_OPEN, LOBBY_ALREADY_JOINED,
LOBBY_FULL, LOBBY_NOT_IN_LOBBY, LOBBY_ONLY_STARTER_CANCEL,
ROLL_ALREADY_RESOLVED, ROLL_NOT_YOURS, ROLL_ALREADY_ROLLED,
GROUP_ROLL_PENDING, EXPIRED_NOTICE,
];
describe('systemStrings — in-world voice registry (FU-11)', () => {
it('every registry string is free of forbidden utility words', () => {
for (const s of REGISTRY) {
expect(hasForbiddenUtilityWord(s), `registry string leaks a utility term: ${JSON.stringify(s)}`).toBe(false);
}
});
it('hasForbiddenUtilityWord catches engine/utility terms (case-insensitive)', () => {
expect(hasForbiddenUtilityWord('the tool_call returned JSON')).toBe(true);
expect(hasForbiddenUtilityWord('pendingSkillCheck is set')).toBe(true);
expect(hasForbiddenUtilityWord('skill_check_emit was called')).toBe(true);
expect(hasForbiddenUtilityWord('the Foundry VTT endpoint')).toBe(true);
expect(hasForbiddenUtilityWord('TOOL_CALL')).toBe(true); // case-insensitive
});
it('hasForbiddenUtilityWord allows in-world text', () => {
expect(hasForbiddenUtilityWord('The gathering has set out — the encounter begins!')).toBe(false);
expect(hasForbiddenUtilityWord('*A group roll is still pending — the party must roll.*')).toBe(false);
expect(hasForbiddenUtilityWord('This roll is not yours to make.')).toBe(false);
});
it('FORBIDDEN_UTILITY_WORDS lists the engine terms that must never reach players', () => {
expect(FORBIDDEN_UTILITY_WORDS).toContain('tool_call');
expect(FORBIDDEN_UTILITY_WORDS).toContain('pendingSkillCheck');
expect(FORBIDDEN_UTILITY_WORDS).toContain('skill_check_emit');
});
});