Compare commits
4 Commits
main
...
backlog/up
| Author | SHA1 | Date | |
|---|---|---|---|
| 4513b9a0ae | |||
| 2d701fecfd | |||
|
|
7709f19e6e | ||
|
|
4bb524f3e0 |
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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: 3–4`.
|
||||
- `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.
|
||||
@@ -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-11–14) 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-11–14)
|
||||
- **status:** **done (2026-06-22)** — all 4 ACs live-covered · **confidence: high**
|
||||
- **Source:** multiplayer E2E PRD FR-11–14; 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
51
src/lib/systemStrings.ts
Normal 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.*';
|
||||
@@ -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-11–14) remain a follow-up story.
|
||||
// The 4 gap-case ACs (FR-11–14) 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-11–14) ──────────────────────────────────
|
||||
// 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);
|
||||
});
|
||||
55
tests/unit/systemStrings.test.ts
Normal file
55
tests/unit/systemStrings.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user