merge: multiplayer live E2E + FU-13 lobby-on-all + FU-14 TTL auto-expiry
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 31s
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 31s
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# Addendum 2 — Lobby on All Encounters + Encounter End/Expiry Event
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**Amends:** the Group Encounters PRD (`prd.md`) — FR-19, FR-20, FR-26, and the
|
||||
`phase: open | resolved` model. These are requirements the PRD missed, raised
|
||||
after the feature set shipped.
|
||||
|
||||
---
|
||||
|
||||
## A. Lobby on ALL encounters (amends FR-19/FR-20)
|
||||
|
||||
**Today:** `/encounter start` opens a lobby **only** when `minPlayers >= 2`
|
||||
(FR-20); solo encounters (`minPlayers <= 1`) begin immediately with implicit
|
||||
join (FR-19).
|
||||
|
||||
**New requirement:** **every** `/encounter start` opens a lobby, including solo.
|
||||
The `minPlayers` gate still controls when **Begin** enables, but there is no
|
||||
"begin immediately" path — the lobby is the single entry point for all
|
||||
encounters.
|
||||
|
||||
- Solo (`minPlayers: 1`): lobby opens → 0 joined → Begin disabled → one player
|
||||
Joins → 1 joined (min met) → Begin enabled → begin.
|
||||
- Group (`minPlayers >= 2`): unchanged gating, just no longer a separate branch.
|
||||
|
||||
## B. Starter is NOT pre-joined (amends FR-26)
|
||||
|
||||
**Today:** `/encounter start` pre-joins the starter (`joined: [interaction.user.id]`,
|
||||
`joinedNames: [starterName]`).
|
||||
|
||||
**New requirement:** the starter (the DM, "usually me") is **not** added to the
|
||||
roster. The lobby opens **empty** (`joined: []`, `joinedNames: []`). The starter
|
||||
creates the lobby; players Join; **Begin** is pressable by any joined player
|
||||
once the minimum is met (FR-22 unchanged). The starter's `starterId` is still
|
||||
recorded (for Cancel authorization, FR-23) but they are not a participant.
|
||||
|
||||
**Impact on existing specs/behavior:** solo play now requires the single player
|
||||
to Join + Begin (one extra click) rather than implicit-join-on-chat. This is an
|
||||
intentional behavior change to shipped behavior (call it out in the changelog +
|
||||
the release-playtest checklist, same family as FR-43 ship-and-break).
|
||||
|
||||
## C. TTL auto-expiry + event (new)
|
||||
|
||||
**New requirement:** encounters **auto-expire** after an inactivity TTL — no
|
||||
player message and no LLM turn for a configurable period (default TBD, e.g. 24h,
|
||||
env-configurable `ENCOUNTER_INACTIVITY_TTL_HOURS`). On expiry:
|
||||
|
||||
1. `session.phase` transitions to a new **`expired`** state (alongside
|
||||
`open` / `resolved`).
|
||||
2. A **GraphMCP `log_encounter` event** is emitted with `kind: "expired"`
|
||||
(distinct from `kind: "ended"` below), recording the encounter + participants
|
||||
+ a summary like "Encounter expired due to inactivity."
|
||||
3. A **Discord notice** is posted in the thread (in-world voice: e.g. *"The
|
||||
moment passes unresolved, and the road moves on…"*) — not a raw system string.
|
||||
4. The thread is **archived**.
|
||||
|
||||
## D. End vs expired — the two events
|
||||
|
||||
- **`/encounter end`** (existing) → `phase: resolved`, GraphMCP event
|
||||
`kind: "ended"` (the DM explicitly ended it). Today `log_encounter` fires on
|
||||
end but does not tag `kind`; add the `kind` field so end vs expired are
|
||||
distinguishable in the graph.
|
||||
- **Auto-expiry** (new) → `phase: expired`, GraphMCP event `kind: "expired"`.
|
||||
|
||||
Both post a Discord notice + archive the thread.
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes (for the stories)
|
||||
|
||||
- **A + B** live in `src/bot/commands/encounter.ts` `handleStart`: remove the
|
||||
`if (spec.minPlayers >= 2)` branch (always lobby); change `setLobby` to
|
||||
`joined: []`, `joinedNames: []` (drop the starter pre-join). `lobbyHandler`
|
||||
is unchanged (Join/Begin already work on the joined list). Update the lobby
|
||||
embed initial state (0 joined) + the live E2E test's lobby AC.
|
||||
- **C + D** need: a `lastActivityAt` on `SessionState` (bumped on each message +
|
||||
LLM turn); a periodic sweep (or extension of `restartSweep`) that finalizes
|
||||
sessions past the TTL as `expired`; a `phase: 'expired'` literal in the type;
|
||||
a `kind` field on `log_encounter`; the in-world expiry notice (centralize in
|
||||
`src/lib/systemStrings.ts` if it exists — see FU-11). Boot sweep should also
|
||||
catch expired-by-inactivity on restart.
|
||||
- **Follow-up stories:** FU-13 (A + B), FU-14 (C + D) — see `follow-up-stories.md`.
|
||||
|
||||
## Open questions
|
||||
- **OQ-A1** Default inactivity TTL value (24h? 12h to match session TTL?).
|
||||
- **OQ-A2** Does auto-expiry also clear an in-flight `pendingGroupCheck` /
|
||||
`pendingSkillCheck` (finalize as expired-failure)? Recommend yes.
|
||||
- **OQ-A3** Should the expiry sweep run on a periodic timer (in-process) or only
|
||||
at boot + on activity? Periodic is more responsive; boot-only is simpler.
|
||||
@@ -121,6 +121,37 @@ deferred, accepted as ship-and-break trade-offs, or left open. The feature set
|
||||
|
||||
---
|
||||
|
||||
## FU-13 — Lobby on ALL encounters + starter NOT pre-joined
|
||||
- **status:** backlog (PRD addendum 2, items A + B) · **confidence: high**
|
||||
- **Source:** addendum-2026-06-22-lobby-all-and-expiry.md (amends FR-19/FR-20/FR-26).
|
||||
- **Today:** `/encounter start` lobbies only for `minPlayers >= 2` and pre-joins the starter (`joined: [interaction.user.id]`).
|
||||
- **Change:**
|
||||
- Every `/encounter start` opens a lobby (remove the `minPlayers >= 2` branch in `handleStart`). Solo (`minPlayers: 1`) also lobbies.
|
||||
- The starter is NOT pre-joined — lobby opens empty (`joined: []`, `joinedNames: []`). `starterId` is still recorded (for Cancel). Players Join; Begin at min.
|
||||
- **Acceptance:**
|
||||
- Solo spec (`minPlayers: 1`): `/encounter start` → lobby (0 joined, Begin disabled) → one Join → Begin enabled → begin.
|
||||
- Group spec: unchanged gating, lobby flow only.
|
||||
- The live multiplayer E2E test's lobby AC is updated (no pre-join: 0 → player1 join → player2 join → Begin).
|
||||
- Changelog + release-playtest checklist note the solo behavior change (ship-and-break family).
|
||||
- **Depends on:** none. Touches `src/bot/commands/encounter.ts` + the lobby live test.
|
||||
|
||||
## FU-14 — TTL auto-expiry + end/expired event
|
||||
- **status:** backlog (PRD addendum 2, items C + D) · **confidence: high**
|
||||
- **Source:** addendum-2026-06-22-lobby-all-and-expiry.md.
|
||||
- **Today:** encounters end only via `/encounter end` (`phase: resolved`); no inactivity expiry; `log_encounter` has no `kind`.
|
||||
- **Change:**
|
||||
- `SessionState.lastActivityAt` (bumped on each message + LLM turn).
|
||||
- Inactivity TTL (env `ENCOUNTER_INACTIVITY_TTL_HOURS`, default TBD — OQ-A1).
|
||||
- A sweep finalizes sessions past the TTL as `phase: 'expired'` (new phase literal); clears any in-flight `pendingGroupCheck`/`pendingSkillCheck` as expired-failure (OQ-A2).
|
||||
- On expiry: GraphMCP `log_encounter` with `kind: "expired"`; an in-world Discord notice (centralized system string); thread archived.
|
||||
- `/encounter end` tags `log_encounter` `kind: "ended"` (so end vs expired are distinguishable).
|
||||
- Boot sweep also catches expired-by-inactivity on restart.
|
||||
- **Acceptance:**
|
||||
- An idle session past the TTL is finalized as `expired` with a GraphMCP `kind: "expired"` event + a Discord notice + archived thread.
|
||||
- `/encounter end` emits `kind: "ended"`.
|
||||
- A unit test covers the expiry sweep (clock-injected TTL) + the kind tagging.
|
||||
- **Open:** OQ-A1 (default TTL), OQ-A2 (clear in-flight checks on expiry — recommend yes), OQ-A3 (periodic timer vs boot-only sweep).
|
||||
|
||||
## Not deferred (intentionally closed — do not re-open)
|
||||
- **FR-43 deprecation window / `SKILL_CHECK_SURFACE` flag (FR-49/FR-50):** RETIRED by the round-3 party-mode decision (Mary's trim). Ship-and-break in one release, no flag, plain system notice for old buttons, pinned Discord message. This is *done by design*, not a follow-up.
|
||||
- **Straggler deadline as a spec field:** retracted → became a DM "resolve with current rolls" UX affordance (FR-47), not a spec field.
|
||||
|
||||
90
specs/e2e-group-multiplayer.yaml
Normal file
90
specs/e2e-group-multiplayer.yaml
Normal file
@@ -0,0 +1,90 @@
|
||||
encounterId: "e2e-group-multiplayer-001"
|
||||
title: "E2E Group Multiplayer Fixture"
|
||||
tone: "tense"
|
||||
|
||||
# E2E fixture — minPlayers:2 so two player-bots satisfy the lobby gate.
|
||||
# encounterId is prefixed e2e- so flushRedisForGuild tears down its session.
|
||||
# Not intended for production play; exists to exercise the lobby, multi-player
|
||||
# message routing, and the group skill-check scoreboard end-to-end live.
|
||||
minPlayers: 2
|
||||
|
||||
setting:
|
||||
location: "E2E fixture — abandoned warehouse approach"
|
||||
mood: >
|
||||
A quiet, tense approach. Two guards patrol the warehouse perimeter with
|
||||
slow, measured steps, lantern light sweeping the gravel. The party must
|
||||
move as one to avoid detection. This is a live E2E fixture spec — minimal
|
||||
prose, exercising the group-encounter mechanics, not a polished encounter.
|
||||
ambientNpcs: >
|
||||
Two warehouse guards walking a fixed patrol, lanterns swinging at their sides.
|
||||
|
||||
openingNarrative: >
|
||||
The warehouse looms ahead, its windows dark. Two guards patrol the perimeter
|
||||
with slow, measured steps, lantern light sweeping the gravel with every turn.
|
||||
To reach the side door unseen, the party will need to move as one — a single
|
||||
stumble and every lantern swings your way. How does the group approach?
|
||||
|
||||
npcs:
|
||||
- id: "e2e-warehouse-patrol"
|
||||
name: "The Patrol"
|
||||
role: "Two warehouse guards on perimeter patrol"
|
||||
persona: >
|
||||
Alert and predictable. They walk a fixed route and react to noise. For this
|
||||
E2E fixture they are a single narrative obstacle, not a conversational NPC.
|
||||
|
||||
goals:
|
||||
hidden: true
|
||||
primary:
|
||||
- id: "slip_past"
|
||||
label: >
|
||||
The party slips past the patrol to the side door without being detected
|
||||
(group Stealth success).
|
||||
- id: "door_opened"
|
||||
label: >
|
||||
The party reaches and opens the side door (group Athletics or Thieves' Tools).
|
||||
secondary:
|
||||
- id: "spotted"
|
||||
label: >
|
||||
The party is spotted and the alarm is raised — the approach fails.
|
||||
|
||||
sportsmanshipRules:
|
||||
- "No splitting the party mid-approach for this fixture — move as a group."
|
||||
- >
|
||||
If a player attempts something absurd, respond in-character to redirect, or
|
||||
break character with: "⚠️ Let's keep this grounded for the test scenario."
|
||||
|
||||
skillChecks:
|
||||
group_stealth_dc: 13
|
||||
group_stealth_skill: "Stealth"
|
||||
group_stealth_note: >
|
||||
A coordinated group Stealth check to slip past the patrol. Emit as a GROUP
|
||||
check via skill_check_group_emit targeting all joined players, with
|
||||
successRule: all (a single sound alerts the patrol — every roller must
|
||||
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.
|
||||
|
||||
# Passive reveal — bot-applied at encounter start, group-visible, attributed.
|
||||
passiveReveals:
|
||||
- skill: "Insight"
|
||||
threshold: 15
|
||||
revealText: >
|
||||
Notices one guard limping slightly on every other pass — a brief gap in
|
||||
the patrol rhythm the party could exploit.
|
||||
|
||||
tools:
|
||||
- skill_check_emit
|
||||
- skill_check_group_emit
|
||||
- encounter_resolve
|
||||
- context_recall
|
||||
|
||||
dmNotes: >
|
||||
E2E fixture spec for the multiplayer live E2E MVP. Its encounterId is prefixed
|
||||
e2e- so flushRedisForGuild cleans its session. Exists to exercise the lobby
|
||||
(minPlayers:2), multi-player message routing through messageRouter, and the
|
||||
group skill-check scoreboard end-to-end against real Discord. Not a polished
|
||||
encounter — keep minimal.
|
||||
301
specs/old-friend-bad-timing.yaml
Normal file
301
specs/old-friend-bad-timing.yaml
Normal file
@@ -0,0 +1,301 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# old-friend-bad-timing
|
||||
# A wacky chaos-bar encounter. The party walks into the Lonespear Tavern in
|
||||
# Barristown looking for Olena — the legendary diplomat-proprietor whose charter
|
||||
# was carried on the Mayflower. They find a half-asleep barmaid, a Khull'Kai
|
||||
# simian trader, a fiddler, and — somewhere in the room, or walking through
|
||||
# the door — a previously-met NPC who has come a long way to find them.
|
||||
#
|
||||
# This is a RANDOM / REPLAYABLE encounter. On-stage NPCs are archetypes with
|
||||
# randomizable name draws; the dynamic returning NPC is pulled at runtime via
|
||||
# context_recall. Canon places and traditions may be backdrop; named canon
|
||||
# NPCs are not placed on stage.
|
||||
#
|
||||
# Canon-authoring disclosures (these need to land in the wiki):
|
||||
# * Olena = the king's diplomat who received the charter for the Lonespear.
|
||||
# (Wiki currently has an anonymous diplomat; canonize the name to Olena.)
|
||||
# * The Khull'Kai are converted Moonwhisper elves, cursed by Anonmock.
|
||||
# (Wiki has Khull'Kai as simians; the elf-curse origin is partial. Canonize
|
||||
# the curse origin.)
|
||||
# * Dix Beowulf, Son of Beowulf, has opened a brothel across the street from
|
||||
# the Lonespear. (Wiki has a different Beowulf lineage; new character.)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
encounterId: "old-friend-bad-timing"
|
||||
|
||||
title: "Old Friend, Bad Timing"
|
||||
|
||||
setting:
|
||||
location: "Lonespear Tavern — Barristown's harbor quarter"
|
||||
mood: >
|
||||
Late-night, loud, half-lit, ale-thick air; a few drinks in and the room
|
||||
is starting to wobble. The charter hangs on the wall behind the bar —
|
||||
older than the city, signed in Olena's hand, brought over on the
|
||||
Mayflower. The Lonespear has been destroyed and rebuilt so often that
|
||||
nobody counts the times anymore; the next round of riverfoot ale is the
|
||||
only certainty.
|
||||
ambientNpcs: >
|
||||
A Khull'Kai simian nursing an ale alone in the corner and watching the
|
||||
door. A half-asleep barmaid wiping the same tankard for ten minutes. A
|
||||
fiddler playing slightly off-key near the entrance.
|
||||
|
||||
openingNarrative: >
|
||||
As the door closes behind you, nobody notices you for even a second. Until
|
||||
the barmaid, in her loose linen weave, wavy hair, and supple breasts, looks
|
||||
you over, and welcomes you. She offers a fresh pint of Riverfeet ale, and
|
||||
promises there isn't any hair in this batch. If drinking isn't your thing,
|
||||
maybe a cigar? Or perhaps you were here to trade with the Khull'Kai? they
|
||||
have awesome snake jerky. As you take in the place, the door opens again
|
||||
behind you, and {{returning_npc_disposition}} can be seen stepping inside.
|
||||
|
||||
npcs:
|
||||
- id: "khullkai-lonespear-trader"
|
||||
name: "KorBak' Nai"
|
||||
nameKey: simian_name
|
||||
role: "Khull'Kai simian trader"
|
||||
persona: >
|
||||
Speaks with the measured cadence of a court elf who has outlived several
|
||||
forests, each word placed like a stone in a wall — a strange fit for the
|
||||
seven-foot gorilla form that delivers them. He trades Emberloc snake
|
||||
jerky to anyone who can stomach it, and his real interest is the trade
|
||||
itself: he wants to know what you know, what you've seen, and what
|
||||
you're willing to barter. When cornered or refused, he doesn't bluster
|
||||
— he simply stops speaking, watches, and waits; the centuries of
|
||||
Moonwhisper patience that once served an ancient lineage now serve a
|
||||
creature who has nothing left to lose and no reason to hurry.
|
||||
|
||||
goals:
|
||||
hidden: true
|
||||
primary:
|
||||
- id: "reunion_resolves"
|
||||
label: >
|
||||
The reunion with the returning NPC reaches a stable new state: drinking
|
||||
together, fighting together, parting as enemies, parting as friends,
|
||||
or one of them leaving on the other's shoulder.
|
||||
- id: "bar_survives"
|
||||
label: >
|
||||
The party de-escalates, pays their tab, walks out. The Lonespear is
|
||||
still standing at closing. A "nothing terrible happened" win.
|
||||
secondary:
|
||||
- id: "thrown_out"
|
||||
label: >
|
||||
The party is thrown out or banned: too much chaos, the barmaid cuts
|
||||
them off, the watch gets called. They lose the bar; the Lonespear
|
||||
keeps standing.
|
||||
- id: "brothel_fork"
|
||||
label: >
|
||||
The party follows Dix Beowulf's sign across the street. The Lonespear
|
||||
scene closes; what happens next is the table's to author.
|
||||
- id: "barmaid_takes_party"
|
||||
label: >
|
||||
The barmaid invites the party somewhere private. The encounter ends
|
||||
at the Lonespear door; what happens next is the table's to author.
|
||||
|
||||
sportsmanshipRules:
|
||||
- "No instant kills on a non-threatening NPC without prior escalation."
|
||||
- "No controlling another player character's actions or speaking for them."
|
||||
- "No spells or abilities the player has not established owning in a prior scene."
|
||||
- "No claiming information the character could not realistically know (a returning NPC's history, etc.)."
|
||||
- "No teleportation or flight without prior narrative establishment."
|
||||
- >
|
||||
NPCs in the Lonespear — the barmaid, the Khull'Kai trader, the returning
|
||||
NPC, the regulars — can be crude, drunk, flirtatious, rude, threatening,
|
||||
or violent. The LLM should not sand off the edge; it should narrate them
|
||||
as people.
|
||||
- >
|
||||
If the party follows Dix Beowulf's sign across the street, or accepts the
|
||||
barmaid's invitation, the encounter ends. The LLM does not narrate what
|
||||
happens there; the table takes it from there.
|
||||
- >
|
||||
If a player attempts something absurd or game-breaking, respond
|
||||
in-character to redirect, or break character with: "⚠️ That wasn't great
|
||||
sportsmanship. Let's keep it grounded — what would your character
|
||||
realistically attempt here?"
|
||||
|
||||
skillChecks:
|
||||
drink_with_simian_dc: 12
|
||||
drink_with_simian_skill: "Constitution"
|
||||
drink_with_simian_note: >
|
||||
Outdrinking the simian. He is patient and ancient, but a determined
|
||||
drinker can match him — the morning will be brutal for whoever does.
|
||||
|
||||
read_simian_dc: 14
|
||||
read_simian_skill: "Insight"
|
||||
read_simian_note: >
|
||||
A successful check reveals a flicker of something older than the gorilla
|
||||
form — elven, courtly, an almost courtly grace beneath the simian menace.
|
||||
|
||||
notice_recognition_dc: 11
|
||||
notice_recognition_skill: "Perception"
|
||||
notice_recognition_note: >
|
||||
Spot the exact moment the returning NPC identifies one of the party. The
|
||||
tell is small — a pause, a half-finished sentence, a glass set down too
|
||||
carefully.
|
||||
|
||||
calm_bar_dc: 13
|
||||
calm_bar_skill: "Persuasion or Intimidation (player's choice)"
|
||||
calm_bar_note: >
|
||||
Talk the room down before something breaks. Failure means the brawl
|
||||
starts anyway; a critical failure means it starts with the calmer in the
|
||||
middle of it.
|
||||
|
||||
sneak_out_dc: 10
|
||||
sneak_out_skill: "Stealth"
|
||||
sneak_out_note: >
|
||||
Slip out the back door without whoever is watching noticing. The
|
||||
Khull'Kai always notices.
|
||||
|
||||
pickpocket_simian_dc: 15
|
||||
pickpocket_simian_skill: "Sleight of Hand"
|
||||
pickpocket_simian_note: >
|
||||
The simian has lived a long time in many forms. He knows when he is
|
||||
being robbed. A failed check is a confrontation.
|
||||
|
||||
persuade_simian_dc: 11
|
||||
persuade_simian_skill: "Persuasion"
|
||||
persuade_simian_note: >
|
||||
The simian is not in a hurry to do anything. A high roll moves him; a
|
||||
low roll buys time, not agreement.
|
||||
|
||||
endure_ale_dc: 10
|
||||
endure_ale_skill: "Constitution"
|
||||
endure_ale_note: >
|
||||
The barmaid's 'fresh pint' is fresh in the sense of unbottled, not in
|
||||
the sense of kind. Failure is a rough evening; a critical failure is a
|
||||
long night on the floor.
|
||||
|
||||
endure_shots_dc: 8
|
||||
endure_shots_skill: "Constitution"
|
||||
endure_shots_note: >
|
||||
A GROUP test — invoke via skill_check_group_emit with successRule:
|
||||
'majority'. On majority failure, the LLM narrates a single funny,
|
||||
scene-appropriate consequence visible to the whole party (someone passes
|
||||
out under a table, someone starts singing, someone confesses to a crime
|
||||
they did not commit). Keep it comedic; do not roll individual
|
||||
consequences.
|
||||
|
||||
seduce_barmaid_dc: 14
|
||||
seduce_barmaid_skill: "Persuasion or Charisma (player's choice)"
|
||||
seduce_barmaid_note: >
|
||||
The barmaid is interested. On success, she offers to take the party
|
||||
somewhere private; the encounter ends at the Lonespear door and what
|
||||
happens next is the table's to author.
|
||||
|
||||
tackle_drunk_dc: 12
|
||||
tackle_drunk_skill: "Strength (Athletics)"
|
||||
tackle_drunk_note: >
|
||||
First strike of the brawl. Success pins the drunk; failure gives him a
|
||||
turn to swing back, and a third of the bar with him.
|
||||
|
||||
dodge_pint_dc: 11
|
||||
dodge_pint_skill: "Dexterity"
|
||||
dodge_pint_note: >
|
||||
A pint, a chair, a bottle — somebody's throwing. Success means clean;
|
||||
failure means wet, deaf, or both.
|
||||
|
||||
take_a_punch_dc: 12
|
||||
take_a_punch_skill: "Constitution"
|
||||
take_a_punch_note: >
|
||||
Stand in the brawl and trade blows. Success means still standing;
|
||||
failure means a slow count off the floor.
|
||||
|
||||
throw_brawler_dc: 13
|
||||
throw_brawler_skill: "Strength (Athletics)"
|
||||
throw_brawler_note: >
|
||||
Pick up a brawler and put them through a table, a door, or out a window.
|
||||
The Lonespear is durable; the brawler, less so.
|
||||
|
||||
follow_fortune_teller_dc: 10
|
||||
follow_fortune_teller_skill: "Persuasion or Charisma"
|
||||
follow_fortune_teller_note: >
|
||||
A fortune teller in the corner offers to read palms, cups, auras. She
|
||||
tells the party, with great solemnity, that they will be mugged tonight —
|
||||
and that going with her out back is the way to avoid it. They are not
|
||||
avoiding it. A successful check means the party trusts her; the mugging
|
||||
is then a narrated consequence, not a roll.
|
||||
|
||||
see_through_dirt_dc: 9
|
||||
see_through_dirt_skill: "Insight"
|
||||
see_through_dirt_note: >
|
||||
A broad, scarred, snarling figure in the corner looks exactly like an
|
||||
urkin. He is not an urkin. He is a halfling-tall dwarf who has worked a
|
||||
coal-heap for nine hours and is too proud to wash at the bar. A
|
||||
successful insight reveals the 'scarring' is grime and a failed
|
||||
beard-dye job. The dwarf is mortified if asked directly.
|
||||
|
||||
resist_drunk_kiss_dc: 13
|
||||
resist_drunk_kiss_skill: "Wisdom"
|
||||
resist_drunk_kiss_note: >
|
||||
After enough Riverfeet, one of the party is going to try to kiss the
|
||||
beautiful elf leaning by the door. A successful Wisdom check resists the
|
||||
impulse and the immediate regret. Failure means the kiss happens; the
|
||||
next check decides what happens next.
|
||||
|
||||
recognize_mawfang_dc: 15
|
||||
recognize_mawfang_skill: "Perception or Insight"
|
||||
recognize_mawfang_note: >
|
||||
The 'elf' is not an elf. The teeth are wrong, the eyes are wrong, the
|
||||
breath is wrong — at kissing distance the mimicry falls apart and a
|
||||
mawfang brood mother is wearing a beautiful face over a carapace. A high
|
||||
roll reveals the truth before contact. A low roll means the player finds
|
||||
out the hard way. There is no mechanical penalty; this is a comedic
|
||||
beat.
|
||||
|
||||
randomizable:
|
||||
- key: simian_name
|
||||
source: vocabulary
|
||||
category: names.simian.khullkai
|
||||
query: "Khull'Kai simian names — ape-folk rulers of the Emberloc Jungle, with names that suit a creature of great patience and ancient memory"
|
||||
fallback: "KorBak' Nai"
|
||||
|
||||
- key: returning_npc_disposition
|
||||
query: "a previously-met NPC's reason for walking into the Lonespear tonight — angry, drunk, hopeful, fleeing, scheming"
|
||||
fallback: "drunk, and looking for the people who cheated him at dice three weeks back"
|
||||
|
||||
- key: barmaid_drink
|
||||
query: "unusual or unkind ales, ciders, and spirits served at rough Mardonar taverns"
|
||||
fallback: "Riverfoot stout — her own brew, with a kick that hides until the second pint"
|
||||
|
||||
- key: chaos_trigger
|
||||
query: "small, ordinary events in a crowded tavern that escalate into a brawl"
|
||||
fallback: "someone's elbow catches a full mug and the splash lands on the wrong lap"
|
||||
|
||||
# All registered tools active. Per getActiveTools, an empty tools: list (like
|
||||
# omitting the field) means EVERY registered tool is active — the spec references
|
||||
# skill_check_emit, skill_check_group_emit, context_recall, goal_register, and
|
||||
# encounter_resolve, so it wants the full set.
|
||||
tools: []
|
||||
|
||||
dmNotes: >
|
||||
This is a wacky chaos-bar encounter. The Lonespear is the most famous
|
||||
tavern in the new world; it has been destroyed and rebuilt so many times
|
||||
that the charter on the wall is older than the city. Olena — the original
|
||||
king's diplomat who received that charter — is the bar's namesake and
|
||||
legend, though she is not here tonight. The party walks in looking for
|
||||
her; they find a half-asleep barmaid, a Khull'Kai simian trader nursing
|
||||
an ale, a fiddler, and the chatter of strangers.
|
||||
|
||||
The engine of this scene is recognition. Somewhere in the room, or walking
|
||||
through the door, is a previously-met NPC from the party's past. Use
|
||||
context_recall to pull them. They have come a long way to be here, and
|
||||
their reason is one of a few: drunk, angry, hopeful, fleeing, or scheming
|
||||
— the draw from randomizable surfaces it, with the soft fallback being
|
||||
"drunk, looking for the people who cheated him at dice." If the draw
|
||||
resolves to an empty or unhelpful value, pick one of the other four
|
||||
reasons yourself: angry (lost ship, wife, or temper), hopeful (party is
|
||||
his way out of an indescribable debt), fleeing (something he refuses to
|
||||
name, hoping the Lonespear's crowd hides him), or scheming (a deal the
|
||||
party should absolutely refuse). When they walk in and clock the party,
|
||||
register a new hidden goal via goal_register that captures what they want
|
||||
from this reunion. Do not let the encounter drift; the moment recognition
|
||||
lands, the new goal IS the encounter.
|
||||
|
||||
Lean into the edge. The barmaid is inviting, the simian is ancient and
|
||||
patient and has nothing to lose, the regulars are rough. NPCs can be
|
||||
crude, drunk, flirtatious, rude, threatening, or violent — the
|
||||
sportsmanship rules explicitly permit it. The humor is loud, the
|
||||
consequences are real, and the brawl, when it starts, is for keeps.
|
||||
|
||||
If the party follows Dix Beowulf's sign across the street, or accepts the
|
||||
barmaid's invitation, the encounter closes. Resolve the secondary goal
|
||||
and stop narrating. What happens next is the table's to author.
|
||||
@@ -12,7 +12,6 @@ import { config } from '../../config.js';
|
||||
import {
|
||||
queryAsNPC, formatNPCMemory, logEncounter,
|
||||
} from '../../graphmcp/client.js';
|
||||
import { resolveRandomizables } from '../../graphmcp/loreResolver.js';
|
||||
import { buildOpeningNarrative } from '../../harness/promptBuilder.js';
|
||||
import { computePassiveReveals } from '../../harness/passiveReveals.js';
|
||||
import { getPassiveScore } from '../../harness/characterContext.js';
|
||||
@@ -181,28 +180,18 @@ async function handleStart(
|
||||
reason: `Encounter: ${spec.encounterId}`,
|
||||
});
|
||||
|
||||
// Feature D: group encounters (minPlayers >= 2) open a lobby; players Join
|
||||
// and the encounter begins when Begin is pressed (re-resolves the spec then).
|
||||
// Solo encounters (minPlayers <= 1) begin immediately.
|
||||
if (spec.minPlayers >= 2) {
|
||||
const starterProfile = await playerRegistry.get(guildId, interaction.user.id).catch(() => null);
|
||||
const starterName = starterProfile?.dndName ?? interaction.user.username;
|
||||
const { embed, components } = buildLobbyEmbed(spec.title, [starterName], spec.minPlayers, spec.maxPlayers, false);
|
||||
const sent = await thread.send({ embeds: [embed], components });
|
||||
await setLobby(thread.id, {
|
||||
specName, guildId, title: spec.title, minPlayers: spec.minPlayers, maxPlayers: spec.maxPlayers,
|
||||
joined: [interaction.user.id], joinedNames: [starterName], starterId: interaction.user.id, messageId: sent.id,
|
||||
});
|
||||
await interaction.editReply(`Lobby opened for **${spec.title}** — <#${thread.id}>. Players press Join; Begin when the minimum is met.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Solo: resolve + begin immediately.
|
||||
const resolvedContext = await resolveRandomizables(spec.randomizable ?? []);
|
||||
const resolvedSpec = applyResolved(spec, resolvedContext);
|
||||
const npcMemories = await loadNpcMemories(resolvedSpec);
|
||||
await beginEncounter(thread, resolvedSpec, resolvedContext, npcMemories, guildId, specName, {}, interaction.user.id);
|
||||
await interaction.editReply(`Encounter started: <#${thread.id}>`);
|
||||
// FU-13: EVERY encounter opens a lobby (solo included). The starter (the DM)
|
||||
// is NOT pre-joined — the lobby opens empty; players Join; Begin enables at
|
||||
// minPlayers. Solo (minPlayers:1) needs one Join to begin. resolveRandomizables,
|
||||
// NPC memories, and beginEncounter run on Begin (lobbyHandler.handleStartBtn),
|
||||
// not here.
|
||||
const { embed, components } = buildLobbyEmbed(spec.title, [], spec.minPlayers, spec.maxPlayers, false);
|
||||
const sent = await thread.send({ embeds: [embed], components });
|
||||
await setLobby(thread.id, {
|
||||
specName, guildId, title: spec.title, minPlayers: spec.minPlayers, maxPlayers: spec.maxPlayers,
|
||||
joined: [], joinedNames: [], starterId: interaction.user.id, messageId: sent.id,
|
||||
});
|
||||
await interaction.editReply(`Lobby opened for **${spec.title}** — <#${thread.id}>. Players press Join; Begin when the minimum is met.`);
|
||||
}
|
||||
|
||||
// Load NPC memories for a resolved spec (shared by solo start + lobby Begin).
|
||||
@@ -463,6 +452,7 @@ async function handleEnd(interaction: ChatInputCommandInteraction): Promise<void
|
||||
summary: dmNotes || `Encounter ended early by ${interaction.user.username}.`,
|
||||
location: session.spec.setting.location,
|
||||
type: 'encounter',
|
||||
kind: 'ended',
|
||||
}).catch(err => console.error('[encounter end] logEncounter failed:', err));
|
||||
|
||||
const dm = await playerRegistry.get(session.guildId, interaction.user.id);
|
||||
|
||||
94
src/bot/handlers/expirySweep.ts
Normal file
94
src/bot/handlers/expirySweep.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { Client, ThreadChannel } from 'discord.js';
|
||||
import { redis } from '../../db/redis.js';
|
||||
import { sessionManager } from '../../session/sessionManager.js';
|
||||
import { config } from '../../config.js';
|
||||
import { logEncounter } from '../../graphmcp/client.js';
|
||||
import { log } from '../../lib/logger.js';
|
||||
|
||||
// Inactivity expiry sweep (FU-14). An open encounter with no activity for
|
||||
// longer than ENCOUNTER_INACTIVITY_TTL_HOURS is finalized as phase:'expired'.
|
||||
// "Activity" = any session mutation — `updatedAt` is bumped on every
|
||||
// atomicMutate (messages, LLM turns, pending-check changes), so a stale
|
||||
// `updatedAt` means a genuinely idle encounter.
|
||||
//
|
||||
// On expiry: phase → 'expired', in-flight pending checks cleared (an expired
|
||||
// encounter can't still be awaiting a roll), a GraphMCP log_encounter event
|
||||
// with kind:'expired', an in-world Discord notice, and the thread archived.
|
||||
// Run at boot (alongside runRestartSweep) and on a periodic timer from
|
||||
// src/bot/index.ts. Race-safe: the phase transition runs inside atomicMutate;
|
||||
// a concurrent player message bumps `updatedAt` and the sweep re-checks phase
|
||||
// inside the mutator. SCAN (never KEYS) so a growing keyspace doesn't block.
|
||||
|
||||
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;
|
||||
const keys = await scanSessionKeys();
|
||||
const now = Date.now();
|
||||
|
||||
for (const key of keys) {
|
||||
scanned++;
|
||||
const threadId = key.replace(/^session:/, '');
|
||||
const session = await sessionManager.get(threadId);
|
||||
if (!session) continue;
|
||||
// Only in-progress encounters can expire. Resolved/expired are terminal.
|
||||
if (session.phase !== 'open' && session.phase !== 'active') continue;
|
||||
const lastActivity = session.updatedAt ?? session.createdAt;
|
||||
if (now - lastActivity < EXPIRY_TTL_MS) continue; // not idle long enough
|
||||
|
||||
// Expire: terminal phase + clear any in-flight checks (OQ-A2: yes).
|
||||
await sessionManager.atomicMutate(threadId, () => ({
|
||||
phase: 'expired',
|
||||
pendingSkillCheck: undefined,
|
||||
pendingSkillCheckAttempts: undefined,
|
||||
pendingGroupCheck: undefined,
|
||||
}));
|
||||
|
||||
// GraphMCP event — kind:'expired' (distinct from /encounter end's 'ended').
|
||||
const participants = [
|
||||
...session.spec.npcs.map(n => n.name),
|
||||
...Object.values(session.players).map(p => p.dndName),
|
||||
].join(', ');
|
||||
logEncounter({
|
||||
title: `${session.spec.title} — expired (inactivity)`,
|
||||
participants,
|
||||
summary: 'Encounter expired due to inactivity.',
|
||||
location: session.spec.setting.location,
|
||||
kind: 'expired',
|
||||
}).catch(() => null); // best-effort — a GraphMCP failure must not block expiry
|
||||
|
||||
// Discord notice + archive (only when a client is available — the unit
|
||||
// sweep runs without one).
|
||||
if (client) {
|
||||
try {
|
||||
const thread = (await client.channels.fetch(threadId).catch(() => null)) as ThreadChannel | null;
|
||||
if (thread?.isThread()) {
|
||||
await thread.send(EXPIRED_NOTICE).catch(() => null);
|
||||
await thread.setArchived(true).catch(() => null);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
finalized++;
|
||||
}
|
||||
|
||||
log.info('boot', 'expiry sweep complete', { scanned, finalized });
|
||||
return { scanned, finalized };
|
||||
}
|
||||
|
||||
async function scanSessionKeys(): Promise<string[]> {
|
||||
const keys: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
const [next, batch] = await redis.scan(cursor, 'MATCH', 'session:*', 'COUNT', 100);
|
||||
cursor = next;
|
||||
keys.push(...batch);
|
||||
} while (cursor !== '0');
|
||||
return keys;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { scheduleLLMTurn } from './generationQueue.js';
|
||||
import { filterLLMResponse, logFiltered, detectMissedSkillCheck } from './responseFilter.js';
|
||||
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 { log } from '../../lib/logger.js';
|
||||
import type { ChatMessage, SessionState } from '../../types/index.js';
|
||||
|
||||
@@ -30,7 +31,13 @@ function isAllowedChannel(parentId: string | null): boolean {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleMessage(message: Message, client: Client): Promise<void> {
|
||||
if (message.author.bot) return;
|
||||
if (message.author.bot) {
|
||||
// Live multiplayer E2E: route allowlisted player-bot messages as player
|
||||
// turns. Gated by config.E2E_ALLOW_PLAYER_BOTS (default false) + the
|
||||
// runtime e2ePlayerAllowlist populated by connectLiveBots after login.
|
||||
// Production (flag off) is byte-for-byte unchanged.
|
||||
if (!(config.E2E_ALLOW_PLAYER_BOTS && isE2EPlayerBot(message.author.id))) return;
|
||||
}
|
||||
if (!message.channel.isThread()) return;
|
||||
|
||||
const thread = message.channel as ThreadChannel;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { handleMention } from './handlers/mentionHandler.js';
|
||||
import { handleRollInteraction, isSkillCheckInteraction } from './handlers/rollHandler.js';
|
||||
import { handleLobbyInteraction, isLobbyInteraction } from './handlers/lobbyHandler.js';
|
||||
import { runRestartSweep } from './handlers/restartSweep.js';
|
||||
import { runExpirySweep } from './handlers/expirySweep.js';
|
||||
import * as dndnameCmd from './commands/dndname.js';
|
||||
import * as encounterCmd from './commands/encounter.js';
|
||||
import * as characterCmd from './commands/character.js';
|
||||
@@ -60,6 +61,19 @@ client.once('ready', async () => {
|
||||
} catch (err) {
|
||||
log.error('boot', 'restart sweep failed', { error: String(err) });
|
||||
}
|
||||
// FU-14: finalize idle encounters as expired at boot, then re-sweep hourly.
|
||||
// `updatedAt` is bumped on every session mutation, so a stale updatedAt means
|
||||
// a genuinely idle encounter past ENCOUNTER_INACTIVITY_TTL_HOURS.
|
||||
try {
|
||||
await runExpirySweep(client);
|
||||
} catch (err) {
|
||||
log.error('boot', 'expiry sweep failed', { error: String(err) });
|
||||
}
|
||||
setInterval(() => {
|
||||
void runExpirySweep(client).catch(err =>
|
||||
log.error('expiry', 'periodic expiry sweep failed', { error: String(err) }),
|
||||
);
|
||||
}, 60 * 60 * 1000); // hourly
|
||||
});
|
||||
|
||||
client.on('interactionCreate', async (interaction) => {
|
||||
|
||||
29
src/bot/lib/e2ePlayerAllowlist.ts
Normal file
29
src/bot/lib/e2ePlayerAllowlist.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Runtime allowlist of player-bot userIds for live E2E.
|
||||
//
|
||||
// The live multiplayer E2E harness (tests/integration/graphmcp/support/liveBots.ts)
|
||||
// logs in a second player-side bot and needs its real gateway messages to route
|
||||
// through messageRouter as player turns. The bot-under-test normally ignores
|
||||
// bot-authored messages (anti-loop guard, messageRouter.ts). When
|
||||
// config.E2E_ALLOW_PLAYER_BOTS is true, messageRouter consults this allowlist
|
||||
// instead of skipping.
|
||||
//
|
||||
// Why a runtime Set and not a config field: config.ts parses process.env ONCE
|
||||
// at import (`export const config = EnvSchema.parse(process.env)`), but the
|
||||
// player-bot userIds are only known AFTER `client.login()` resolves — long
|
||||
// after config is parsed. So connectLiveBots calls addPlayerBotIds() after
|
||||
// login and clearE2EPlayerBots() on disconnect. In production the flag is
|
||||
// false, so this set is never consulted and stays empty.
|
||||
|
||||
const playerBotIds = new Set<string>();
|
||||
|
||||
export function addPlayerBotIds(ids: string[]): void {
|
||||
for (const id of ids) playerBotIds.add(id);
|
||||
}
|
||||
|
||||
export function isE2EPlayerBot(id: string): boolean {
|
||||
return playerBotIds.has(id);
|
||||
}
|
||||
|
||||
export function clearE2EPlayerBots(): void {
|
||||
playerBotIds.clear();
|
||||
}
|
||||
@@ -84,6 +84,22 @@ const EnvSchema = z.object({
|
||||
// returns [] until the relay ships the endpoint. Flip + run the integration
|
||||
// test at cutover.
|
||||
FOUNDRY_CONDITIONS_ENABLED: z.coerce.boolean().default(false),
|
||||
|
||||
// ── Live multiplayer E2E ──────────────────────────────────────────────────
|
||||
// When true, the anti-loop guard in messageRouter routes messages from
|
||||
// player-bot ids in the runtime e2ePlayerAllowlist (src/bot/lib/) as player
|
||||
// turns, so the live E2E harness can drive a second player-bot through the
|
||||
// real messageCreate path. Default false — production behavior is unchanged.
|
||||
// The id allowlist itself is a runtime Set (populated by connectLiveBots
|
||||
// after login), NOT a config field, because config is parsed once at import.
|
||||
E2E_ALLOW_PLAYER_BOTS: z.coerce.boolean().default(false),
|
||||
|
||||
// ── Encounter inactivity expiry (FU-14) ───────────────────────────────────
|
||||
// An open encounter with no activity (no message / LLM turn — `updatedAt` is
|
||||
// bumped on every session mutation) for longer than this many hours is
|
||||
// finalized by runExpirySweep as phase:'expired' (GraphMCP kind:'expired' +
|
||||
// an in-world notice + thread archived). Default 24h.
|
||||
ENCOUNTER_INACTIVITY_TTL_HOURS: z.coerce.number().default(24),
|
||||
});
|
||||
|
||||
export { EnvSchema };
|
||||
|
||||
@@ -47,6 +47,10 @@ export interface LogEncounterParams {
|
||||
summary: string;
|
||||
location?: string;
|
||||
type?: string;
|
||||
// FU-14: distinguishes how the encounter ended — 'ended' (DM ran /encounter
|
||||
// end) vs 'expired' (auto-expired by inactivity TTL). Best-effort: passed to
|
||||
// the log_encounter tool; servers that don't know the field drop it.
|
||||
kind?: 'ended' | 'expired';
|
||||
}
|
||||
|
||||
export interface LogEncounterResult {
|
||||
@@ -159,6 +163,7 @@ export async function logEncounter(params: LogEncounterParams): Promise<LogEncou
|
||||
summary: params.summary,
|
||||
location: params.location ?? '',
|
||||
type: params.type ?? 'encounter',
|
||||
...(params.kind ? { kind: params.kind } : {}),
|
||||
});
|
||||
return result as LogEncounterResult;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface Player {
|
||||
// Session State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SessionPhase = 'open' | 'active' | 'resolved';
|
||||
export type SessionPhase = 'open' | 'active' | 'resolved' | 'expired';
|
||||
|
||||
export interface PendingSkillCheck {
|
||||
player: string;
|
||||
|
||||
305
tests/integration/graphmcp/group-encounter-live.test.ts
Normal file
305
tests/integration/graphmcp/group-encounter-live.test.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
// Live multiplayer E2E — two player-bots run a real group encounter, driving
|
||||
// the REAL embed UI (lobby embed + scoreboard embed), not just Redis state.
|
||||
//
|
||||
// Gate: RUN_FULL_E2E=1 AND E2E_PLAYER2_TOKEN set. Requires the full live stack:
|
||||
// DISCORD_TOKEN, E2E_DRIVER_TOKEN, E2E_PLAYER2_TOKEN, E2E_TEST_GUILD_ID,
|
||||
// E2E_TEST_CHANNEL_ID, E2E_ALLOW_PLAYER_BOTS=1, plus Redis + LLM up.
|
||||
// (This fixture has no randomizable / no NPC memoryKey, so GraphMCP is NOT
|
||||
// called on the start path — GRAPHMCP_URL need not be host-reachable here.)
|
||||
// Skipped by default → CI-safe.
|
||||
//
|
||||
// Topology (FU-13): player1 (E2E_DRIVER_TOKEN) is the STARTER — NOT pre-joined
|
||||
// (the lobby opens empty; the DM creates it, players Join). player1 joins too,
|
||||
// then player2 (E2E_PLAYER2_TOKEN) joins → Begin enables at minPlayers:2. Both
|
||||
// end up in the roster and act as the two players. The test asserts on the REAL
|
||||
// lobby embed (Seats field + Begin button disabled/enabled) and the REAL
|
||||
// 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.
|
||||
|
||||
import './support/env.js';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import type { Message, ThreadChannel } from 'discord.js';
|
||||
import { execute } from '../../../src/bot/commands/encounter.js';
|
||||
import { handleLobbyInteraction } from '../../../src/bot/handlers/lobbyHandler.js';
|
||||
import { handleRollInteraction } from '../../../src/bot/handlers/rollHandler.js';
|
||||
import { handleMessage } from '../../../src/bot/handlers/messageRouter.js';
|
||||
import { sessionManager } from '../../../src/session/sessionManager.js';
|
||||
import { playerRegistry } from '../../../src/session/playerRegistry.js';
|
||||
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 { 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';
|
||||
|
||||
const runE2E = process.env.RUN_FULL_E2E === '1' && !!process.env.E2E_PLAYER2_TOKEN;
|
||||
const specName = 'e2e-group-multiplayer';
|
||||
|
||||
// ── Real-embed assertion helpers ────────────────────────────────────────────
|
||||
function field(msg: Message, name: string): string {
|
||||
return (msg.embeds[0]?.fields ?? []).find(f => f.name === name)?.value ?? '';
|
||||
}
|
||||
function seatsField(msg: Message): string {
|
||||
return (msg.embeds[0]?.fields ?? []).find(f => f.name.startsWith('Seats'))?.value ?? '';
|
||||
}
|
||||
// The Begin button lives in the second action row (lobby_start). Read its real
|
||||
// disabled state from the fetched message components.
|
||||
function beginDisabled(msg: Message): boolean {
|
||||
for (const row of msg.components ?? []) {
|
||||
for (const c of (row as { components?: { customId?: string; disabled?: boolean }[] }).components ?? []) {
|
||||
if (c.customId === 'lobby_start') return c.disabled ?? false;
|
||||
}
|
||||
}
|
||||
return true; // button absent → treat as disabled
|
||||
}
|
||||
function scoreboardFooter(msg: Message): string {
|
||||
return msg.embeds[0]?.footer?.text ?? '';
|
||||
}
|
||||
|
||||
describe.skipIf(!runE2E)('Multiplayer live E2E — MVP (2 player-bots, driving the real embeds)', () => {
|
||||
let bots: LiveBots;
|
||||
let player1Id: string;
|
||||
let player2Id: string;
|
||||
// The STARTER is an authorized user (a human in DISCORD_ALLOWED_USERS), NOT a
|
||||
// bot — bots aren't in the user allowlist, so /encounter start driven as a bot
|
||||
// hits the auth guard. The starter just opens the lobby (FU-13: not pre-joined);
|
||||
// the two player-bots Join, and player1 presses Begin.
|
||||
let starterId: string;
|
||||
let threadId: string | null = null;
|
||||
let thread: ThreadChannel | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
bots = await connectLiveBots();
|
||||
expect(bots.players.length, 'multiplayer E2E needs 2 player-bots').toBeGreaterThanOrEqual(2);
|
||||
player1Id = bots.players[0].user!.id; // joins + presses Begin
|
||||
player2Id = bots.players[1].user!.id; // the second joiner
|
||||
starterId = config.DISCORD_ALLOWED_USERS[0] ?? player1Id; // authorized starter
|
||||
// Wire the bot-under-test's messageCreate handler. connectLiveBots creates a
|
||||
// raw client (the production wiring lives in src/bot/index.ts, which the test
|
||||
// doesn't run). Without this, the player bots' real gateway messages never
|
||||
// reach handleMessage — the whole point of AC-9 (real routing via the
|
||||
// e2ePlayerAllowlist). The bot's own messages are skipped by the anti-loop
|
||||
// guard (its id isn't allowlisted), so no loop.
|
||||
bots.botClient.on('messageCreate', (message) => {
|
||||
void handleMessage(message, bots.botClient).catch(() => null);
|
||||
});
|
||||
await flushRedisForGuild(bots.guild.id);
|
||||
// Register the player-bots in playerRegistry so their real messages pass the
|
||||
// player gate in processEncounterMessage (otherwise the message is held +
|
||||
// a gate embed is posted). dndName matches the usernames used on Join.
|
||||
await playerRegistry.set(bots.guild.id, player1Id, 'Player1').catch(() => null);
|
||||
await playerRegistry.set(bots.guild.id, player2Id, 'Player2').catch(() => null);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (threadId) {
|
||||
await deleteSession(threadId);
|
||||
await deleteThread(bots.channel, threadId);
|
||||
}
|
||||
await playerRegistry.delete(bots.guild.id, player1Id).catch(() => null);
|
||||
await playerRegistry.delete(bots.guild.id, player2Id).catch(() => null);
|
||||
} finally {
|
||||
await disconnectRedis();
|
||||
await disconnectLiveBots(bots);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// AC-8 — lobby gating + start (FU-13: no pre-join), asserting on the REAL lobby embed
|
||||
it('lobby embed shows the gate: 0 joined → join ×2 → Begin enabled → start', async () => {
|
||||
// FU-13: /encounter start opens a lobby with NO one pre-joined. The starter
|
||||
// is an authorized user (the DM); the two player-bots Join, player1 presses Begin.
|
||||
const { interaction, lastText } = fakeInteraction({
|
||||
subcommand: 'start',
|
||||
stringOptions: { spec: specName },
|
||||
channel: bots.channel,
|
||||
guildId: bots.guild.id,
|
||||
userId: starterId,
|
||||
username: 'E2E Starter',
|
||||
});
|
||||
await execute(interaction);
|
||||
|
||||
threadId = parseThreadIdFromReply(lastText());
|
||||
expect(threadId, 'start must reply with the created thread reference').toBeTruthy();
|
||||
thread = await bots.channel.threads.fetch(threadId!);
|
||||
expect(thread, 'lobby thread must exist on the real gateway').toBeTruthy();
|
||||
|
||||
// Lobby opens EMPTY (FU-13: starter not pre-joined). 0/2, Begin disabled.
|
||||
const lobby0 = await waitFor(() => getLobby(threadId!).then(l => l ?? null), {
|
||||
timeoutMs: 15_000, intervalMs: 500,
|
||||
});
|
||||
expect(lobby0!.joined, 'lobby opens empty — starter NOT pre-joined').toEqual([]);
|
||||
expect(lobby0!.joined.length < lobby0!.minPlayers, 'below minPlayers at 0 joined').toBe(true);
|
||||
const lobbyMsg0 = await thread!.messages.fetch(lobby0!.messageId!);
|
||||
expect(seatsField(lobbyMsg0), 'seats at 0').toContain('0 / 2 minimum');
|
||||
expect(beginDisabled(lobbyMsg0), 'Begin disabled at 0').toBe(true);
|
||||
|
||||
// player1 joins → 1/2, Begin still disabled.
|
||||
await handleLobbyInteraction(
|
||||
fakeButton(thread!, 'lobby_join', player1Id, 'Player1', lobby0!.messageId).interaction,
|
||||
bots.botClient,
|
||||
);
|
||||
const lobby1 = await waitFor(
|
||||
() => getLobby(threadId!).then(l => (l && l.joined.length >= 1) ? l : null),
|
||||
{ timeoutMs: 15_000, intervalMs: 500 },
|
||||
);
|
||||
expect(lobby1!.joined, 'player1 joined').toContain(player1Id);
|
||||
const lobbyMsg1 = await thread!.messages.fetch(lobby0!.messageId!);
|
||||
expect(seatsField(lobbyMsg1), 'seats at 1').toContain('1 / 2 minimum');
|
||||
expect(beginDisabled(lobbyMsg1), 'Begin disabled at 1').toBe(true);
|
||||
|
||||
// player2 joins → 2/2, Begin enabled. fakeButton.update EDITS THE REAL lobby
|
||||
// embed (messageId passed), so the embed reflects the click — UI is driven.
|
||||
await handleLobbyInteraction(
|
||||
fakeButton(thread!, 'lobby_join', player2Id, 'Player2', lobby0!.messageId).interaction,
|
||||
bots.botClient,
|
||||
);
|
||||
const lobby2 = await waitFor(
|
||||
() => getLobby(threadId!).then(l => (l && l.joined.length >= 2) ? l : null),
|
||||
{ timeoutMs: 15_000, intervalMs: 500 },
|
||||
);
|
||||
expect(lobby2!.joined, 'both joined').toEqual(expect.arrayContaining([player1Id, player2Id]));
|
||||
expect(lobby2!.joined.length >= lobby2!.minPlayers, 'meets minPlayers at 2').toBe(true);
|
||||
const lobbyMsg2 = await thread!.messages.fetch(lobby0!.messageId!);
|
||||
expect(seatsField(lobbyMsg2), 'seats met').toContain('min 2 met');
|
||||
expect(beginDisabled(lobbyMsg2), 'Begin enabled at min').toBe(false);
|
||||
|
||||
// Begin the encounter from the lobby.
|
||||
await handleLobbyInteraction(
|
||||
fakeButton(thread!, 'lobby_start', player1Id, 'Player1').interaction,
|
||||
bots.botClient,
|
||||
);
|
||||
const session = await waitFor(
|
||||
async () => {
|
||||
const s = await sessionManager.get(threadId!);
|
||||
return s && s.phase === 'open' ? s : null;
|
||||
},
|
||||
{ timeoutMs: 30_000, intervalMs: 1_000 },
|
||||
);
|
||||
expect(session, 'session must be open after Begin').toBeTruthy();
|
||||
expect(Object.keys(session!.players), 'both player-bots are in the roster').toEqual(
|
||||
expect.arrayContaining([player1Id, player2Id]),
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
// AC-9 — 2 real chat turns routed through handleMessage (👀 reaction = routed) ──
|
||||
it('two players post real gateway messages that route through handleMessage', async () => {
|
||||
expect(threadId, 'depends on lobby start').toBeTruthy();
|
||||
thread = thread ?? (await bots.channel.threads.fetch(threadId!));
|
||||
const p1Thread = (await bots.players[0].channels.fetch(threadId!)) as ThreadChannel;
|
||||
const p2Thread = (await bots.players[1].channels.fetch(threadId!)) as ThreadChannel;
|
||||
|
||||
// Each player-bot posts a REAL gateway message. It routes through the bot-
|
||||
// under-test's messageCreate → handleMessage because E2E_ALLOW_PLAYER_BOTS=1
|
||||
// and the bots' ids are in the e2ePlayerAllowlist. handleMessage fires the 👀
|
||||
// reaction BEFORE the player-gate / pending-check guards — so the 👀 is the
|
||||
// robust routing proof (it lands even if a pending group check the LLM
|
||||
// emitted would hold the turn for the roll).
|
||||
const p1Msg = await p1Thread.send('Player1: I ready my tools and watch the patrol rhythm.');
|
||||
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 },
|
||||
);
|
||||
|
||||
const p2Msg = await p2Thread.send('Player2: I flank left while they are distracted.');
|
||||
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 },
|
||||
);
|
||||
// Both player-bots' real messages routed through handleMessage — the
|
||||
// e2ePlayerAllowlist + anti-loop branch work for both distinct players.
|
||||
}, 180_000);
|
||||
|
||||
// AC-10 — group check N=2 finalizes; the REAL scoreboard embed reflects it ─
|
||||
it('group check scoreboard reflects each roll and finalizes with the real successRule', async () => {
|
||||
expect(threadId, 'depends on lobby start').toBeTruthy();
|
||||
thread = thread ?? (await bots.channel.threads.fetch(threadId!));
|
||||
|
||||
// Post a REAL scoreboard embed + Roll button, then set up a pending group
|
||||
// check targeting both players (deterministic setup; successRule: majority).
|
||||
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', 'Slip the party past the patrol', 13, rolls)],
|
||||
components: [buildRollButtons()],
|
||||
});
|
||||
const gc: PendingGroupCheck = {
|
||||
skill: 'Stealth',
|
||||
prompt: 'Slip the party past the patrol',
|
||||
dc: 13,
|
||||
messageId: scoreboard.id,
|
||||
successRule: { kind: 'all' }, // a single sound alerts the patrol — every roller must succeed
|
||||
rolls,
|
||||
};
|
||||
await sessionManager.atomicMutate(threadId!, () => ({ pendingGroupCheck: gc }));
|
||||
|
||||
// player1 rolls → the REAL scoreboard is edited in place to show player1's roll.
|
||||
await handleRollInteraction(
|
||||
fakeButton(thread!, 'sc_roll', player1Id, 'Player1').interaction,
|
||||
bots.botClient,
|
||||
);
|
||||
await waitFor(
|
||||
async () => {
|
||||
const m = await thread!.messages.fetch(scoreboard.id).catch(() => null);
|
||||
const rolled = m ? field(m, 'Rolled') : '';
|
||||
return rolled.includes('Player1') && !rolled.includes('Player1 — …awaiting') ? m : null;
|
||||
},
|
||||
{ timeoutMs: 15_000, intervalMs: 500 },
|
||||
);
|
||||
|
||||
// player2 rolls → allRolled → finalizeGroupCheck edits the scoreboard to its
|
||||
// final state and appends [GROUP CHECK RESULT].
|
||||
await handleRollInteraction(
|
||||
fakeButton(thread!, 'sc_roll', player2Id, 'Player2').interaction,
|
||||
bots.botClient,
|
||||
);
|
||||
const finalized = await waitFor(
|
||||
async () => {
|
||||
const s = await sessionManager.get(threadId!);
|
||||
if (!s || s.pendingGroupCheck) return null;
|
||||
const hasResult = s.history.some(
|
||||
m => typeof m.content === 'string' && m.content.startsWith('[GROUP CHECK RESULT]'),
|
||||
);
|
||||
return hasResult ? s : null;
|
||||
},
|
||||
{ timeoutMs: 30_000, intervalMs: 1_000 },
|
||||
);
|
||||
expect(finalized, 'finalized: [GROUP CHECK RESULT] appended + pendingGroupCheck cleared').toBeTruthy();
|
||||
|
||||
// REAL scoreboard embed: both rolls shown, footer = prevails/falters, buttons removed.
|
||||
const finalBoard = await thread!.messages.fetch(scoreboard.id);
|
||||
const rolled = field(finalBoard, 'Rolled');
|
||||
expect(rolled, 'scoreboard shows player1 roll').toContain('Player1');
|
||||
expect(rolled, 'scoreboard shows player2 roll').toContain('Player2');
|
||||
const footer = scoreboardFooter(finalBoard);
|
||||
expect(
|
||||
footer.includes('prevails') || footer.includes('falters'),
|
||||
'final footer reflects the group outcome',
|
||||
).toBe(true);
|
||||
expect((finalBoard.components ?? []).length, 'Roll button removed on finalize').toBe(0);
|
||||
|
||||
// The [GROUP CHECK RESULT] outcome is consistent with the scoreboard footer.
|
||||
const resultMsg = finalized!.history.find(
|
||||
m => typeof m.content === 'string' && m.content.startsWith('[GROUP CHECK RESULT]'),
|
||||
)!;
|
||||
expect(resultMsg.content, 'successRule kind recorded').toContain('Rule: all');
|
||||
const success = resultMsg.content.includes('SUCCESS');
|
||||
expect(
|
||||
success ? footer.includes('prevails') : footer.includes('falters'),
|
||||
'scoreboard footer matches the recorded outcome',
|
||||
).toBe(true);
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -106,23 +106,44 @@ export function parseThreadIdFromReply(text: string | undefined): string | null
|
||||
export interface FakeButton {
|
||||
interaction: import('discord.js').ButtonInteraction;
|
||||
updates: unknown[];
|
||||
replies: CapturedReply[];
|
||||
}
|
||||
|
||||
export function fakeButton(channel: ThreadChannel, customId: string): FakeButton {
|
||||
// `userId`/`username` are optional so existing 2-arg callers (skill-check.test,
|
||||
// long-encounter.test) keep working. Multiplayer E2E passes each player-bot's
|
||||
// real userId so submitGroupRoll / handleJoin can identify the clicker.
|
||||
// `messageId` (optional): when set, `update()` edits the REAL message with that
|
||||
// id — so the embed the button lives on actually reflects the click (drives the
|
||||
// UI). Without it, `update()` is captured (back-compat for non-live tests).
|
||||
export function fakeButton(
|
||||
channel: ThreadChannel,
|
||||
customId: string,
|
||||
userId?: string,
|
||||
username?: string,
|
||||
messageId?: string,
|
||||
): FakeButton {
|
||||
const updates: unknown[] = [];
|
||||
const replies: CapturedReply[] = [];
|
||||
const interaction = {
|
||||
isButton: () => true,
|
||||
isModalSubmit: () => false,
|
||||
isStringSelectMenu: () => false,
|
||||
customId,
|
||||
channel,
|
||||
user: { id: userId ?? 'e2e-driver-user', username: username ?? 'E2E Driver', bot: false },
|
||||
async update(payload: unknown) {
|
||||
if (messageId) {
|
||||
const msg = await channel.messages.fetch(messageId).catch(() => null);
|
||||
if (msg) await msg.edit(payload as Parameters<typeof msg.edit>[0]).catch(() => null);
|
||||
}
|
||||
updates.push(payload);
|
||||
return {};
|
||||
},
|
||||
async reply(_payload: unknown) {
|
||||
async reply(payload: unknown) {
|
||||
const entry = typeof payload === 'string' ? { content: payload } : payload;
|
||||
replies.push(entry as CapturedReply);
|
||||
return {};
|
||||
},
|
||||
} as unknown as import('discord.js').ButtonInteraction;
|
||||
return { interaction, updates };
|
||||
return { interaction, updates, replies };
|
||||
}
|
||||
@@ -1,35 +1,59 @@
|
||||
// Real connected discord.js Client fixtures.
|
||||
//
|
||||
// This suite deliberately exercises the REAL Discord gateway (no message mocks
|
||||
// on the under-test bot). Two clients are involved:
|
||||
// on the under-test bot). The clients involved:
|
||||
// - botClient : the bot under test, logged in with DISCORD_TOKEN, used both
|
||||
// as the `client` passed to command.execute() / handleMessage()
|
||||
// and to fetch real channel/thread objects.
|
||||
// - driverBot : a SECOND bot (E2E_DRIVER_TOKEN) that posts real chat messages
|
||||
// into the encounter thread, firing the bot's real messageCreate
|
||||
// path through the live gateway. (Bots cannot invoke each other's
|
||||
// slash commands, so this is how we drive conversation turns.)
|
||||
// - players[] : player-side bots that act as PLAYERS in a multiplayer
|
||||
// encounter. players[0] is the E2E_DRIVER_TOKEN bot (formerly
|
||||
// the "driver"); players[1] is E2E_PLAYER2_TOKEN (the second
|
||||
// player bot, multiplayer E2E only). Their real gateway messages
|
||||
// route through the live messageRouter as player turns because
|
||||
// config.E2E_ALLOW_PLAYER_BOTS=1 and their userIds are in the
|
||||
// runtime e2ePlayerAllowlist (populated below after login).
|
||||
// - driverBot : alias for players[0], preserved so existing AC2–AC4 tests
|
||||
// that reference bots.driverBot keep working.
|
||||
//
|
||||
// Bots cannot invoke each other's slash commands or click each other's buttons,
|
||||
// so slash commands and button interactions are driven via fakeInteraction /
|
||||
// fakeButton (synthesized, but backed by REAL channel/thread objects + real
|
||||
// Redis state). Chat TURNS, however, are driven by the player bots posting real
|
||||
// gateway messages — the multiplayer point of this harness.
|
||||
//
|
||||
// Requires in env:
|
||||
// DISCORD_TOKEN — token for the bot under test
|
||||
// E2E_DRIVER_TOKEN — token for the driver bot
|
||||
// E2E_DRIVER_TOKEN — token for player bot #1 (the former "driver")
|
||||
// E2E_PLAYER2_TOKEN — token for player bot #2 (multiplayer E2E only)
|
||||
// E2E_TEST_GUILD_ID — the dedicated test guild
|
||||
// E2E_TEST_CHANNEL_ID — the channel to start encounters in
|
||||
// E2E_ALLOW_PLAYER_BOTS=1 — opt-in flag that lets messageRouter route the
|
||||
// player bots' messages as player turns
|
||||
//
|
||||
// All four are only needed for AC2–AC4 (RUN_FULL_E2E=1). AC1 needs none of them.
|
||||
// AC2–AC4 need DISCORD_TOKEN + E2E_DRIVER_TOKEN + the guild/channel. The
|
||||
// multiplayer MVP additionally needs E2E_PLAYER2_TOKEN + E2E_ALLOW_PLAYER_BOTS=1.
|
||||
|
||||
import { Client, GatewayIntentBits, type TextChannel, type Guild } from 'discord.js';
|
||||
import { addPlayerBotIds, clearE2EPlayerBots } from '../../../../src/bot/lib/e2ePlayerAllowlist.js';
|
||||
|
||||
export interface LiveBots {
|
||||
botClient: Client;
|
||||
driverBot: Client;
|
||||
driverBot: Client; // alias for players[0] (back-compat)
|
||||
players: Client[]; // player-side bots that act as players
|
||||
guild: Guild;
|
||||
channel: TextChannel;
|
||||
}
|
||||
|
||||
const INTENTS = [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
];
|
||||
|
||||
export async function connectLiveBots(): Promise<LiveBots> {
|
||||
const botToken = process.env.DISCORD_TOKEN;
|
||||
const driverToken = process.env.E2E_DRIVER_TOKEN;
|
||||
const player2Token = process.env.E2E_PLAYER2_TOKEN;
|
||||
const guildId = process.env.E2E_TEST_GUILD_ID;
|
||||
const channelId = process.env.E2E_TEST_CHANNEL_ID;
|
||||
for (const [k, v] of [
|
||||
@@ -41,19 +65,36 @@ export async function connectLiveBots(): Promise<LiveBots> {
|
||||
if (!v) throw new Error(`Live E2E requires env ${k} (set, or unset RUN_FULL_E2E).`);
|
||||
}
|
||||
|
||||
const botClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
|
||||
const driverBot = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
|
||||
// Player-side bots: player1 (driver) always; player2 only when multiplayer is
|
||||
// opted in. AC2–AC4 get players.length === 1; the multiplayer MVP gets 2.
|
||||
const playerTokens = [driverToken!, player2Token].filter(
|
||||
(t): t is string => typeof t === 'string' && t.length > 0,
|
||||
);
|
||||
|
||||
await Promise.all([botClient.login(botToken!), driverBot.login(driverToken!)]);
|
||||
const botClient = new Client({ intents: INTENTS });
|
||||
const players = playerTokens.map(t => new Client({ intents: INTENTS }));
|
||||
|
||||
await Promise.all([
|
||||
botClient.login(botToken!),
|
||||
...players.map(p => p.login(playerTokens[players.indexOf(p)]!)),
|
||||
]);
|
||||
|
||||
// Populate the anti-loop allowlist with the player-bot userIds so messageRouter
|
||||
// routes their real messages as player turns (gated by E2E_ALLOW_PLAYER_BOTS).
|
||||
const playerIds = players
|
||||
.map(p => p.user?.id)
|
||||
.filter((id): id is string => typeof id === 'string');
|
||||
addPlayerBotIds(playerIds);
|
||||
|
||||
const guild = await botClient.guilds.fetch(guildId!);
|
||||
const channel = (await botClient.channels.fetch(channelId!)) as TextChannel;
|
||||
if (!channel?.isTextBased() || channel.isThread()) {
|
||||
throw new Error(`E2E_TEST_CHANNEL_ID must resolve to a guild text channel.`);
|
||||
}
|
||||
return { botClient, driverBot, guild, channel };
|
||||
return { botClient, driverBot: players[0], players, guild, channel };
|
||||
}
|
||||
|
||||
export async function disconnectLiveBots(b: LiveBots): Promise<void> {
|
||||
await Promise.allSettled([b.botClient.destroy(), b.driverBot.destroy()]);
|
||||
await Promise.allSettled([b.botClient.destroy(), ...b.players.map(p => p.destroy())]);
|
||||
clearE2EPlayerBots();
|
||||
}
|
||||
43
tests/unit/e2ePlayerAllowlist.test.ts
Normal file
43
tests/unit/e2ePlayerAllowlist.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
addPlayerBotIds,
|
||||
isE2EPlayerBot,
|
||||
clearE2EPlayerBots,
|
||||
} from '../../src/bot/lib/e2ePlayerAllowlist.js';
|
||||
|
||||
// e2ePlayerAllowlist is a runtime Set populated by the live E2E harness
|
||||
// (connectLiveBots) after each player-bot client logs in, and read by
|
||||
// messageRouter's anti-loop guard. It is gated by config.E2E_ALLOW_PLAYER_BOTS,
|
||||
// so in production (flag off) it is never consulted.
|
||||
|
||||
describe('e2ePlayerAllowlist', () => {
|
||||
beforeEach(() => clearE2EPlayerBots());
|
||||
|
||||
it('is empty by default — no id is a player bot', () => {
|
||||
expect(isE2EPlayerBot('any-id')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an added id as a player bot', () => {
|
||||
addPlayerBotIds(['player-bot-1']);
|
||||
expect(isE2EPlayerBot('player-bot-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat an unrelated id as a player bot after an add', () => {
|
||||
addPlayerBotIds(['player-bot-1']);
|
||||
expect(isE2EPlayerBot('player-bot-2')).toBe(false);
|
||||
});
|
||||
|
||||
it('clears all ids', () => {
|
||||
addPlayerBotIds(['player-bot-1']);
|
||||
clearE2EPlayerBots();
|
||||
expect(isE2EPlayerBot('player-bot-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts multiple ids at once and is additive across calls', () => {
|
||||
addPlayerBotIds(['player-bot-1', 'player-bot-2']);
|
||||
addPlayerBotIds(['player-bot-3']);
|
||||
expect(isE2EPlayerBot('player-bot-1')).toBe(true);
|
||||
expect(isE2EPlayerBot('player-bot-2')).toBe(true);
|
||||
expect(isE2EPlayerBot('player-bot-3')).toBe(true);
|
||||
});
|
||||
});
|
||||
84
tests/unit/encounterStartLobby.test.ts
Normal file
84
tests/unit/encounterStartLobby.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
// Capture the lobby state handleStart writes.
|
||||
const { mockSetLobby } = vi.hoisted(() => ({ mockSetLobby: vi.fn() }));
|
||||
vi.mock('../../src/session/lobbyManager.js', () => ({
|
||||
setLobby: mockSetLobby,
|
||||
getLobby: vi.fn(),
|
||||
joinLobby: vi.fn(),
|
||||
leaveLobby: vi.fn(),
|
||||
clearLobby: vi.fn(),
|
||||
}));
|
||||
|
||||
// config: the fake channel must be allowed; SPECS_DIR points at the real specs/.
|
||||
const { mockConfig } = vi.hoisted(() => ({
|
||||
mockConfig: {
|
||||
DISCORD_ALLOWED_CHANNELS: ['test-channel'],
|
||||
DISCORD_ALLOWED_USERS: [],
|
||||
SPECS_DIR: './specs',
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/config.js', () => ({ config: mockConfig }));
|
||||
|
||||
// Populate the tool registry so handleStart's tools check passes.
|
||||
import '../../src/harness/tools/index.js';
|
||||
import { execute } from '../../src/bot/commands/encounter.js';
|
||||
|
||||
function fakeStartInteraction(specName: string, userId = 'dm-starter') {
|
||||
const edits: string[] = [];
|
||||
const replies: string[] = [];
|
||||
const fakeThread = {
|
||||
id: 'thread-123',
|
||||
send: vi.fn().mockResolvedValue({ id: 'lobby-msg-1' }),
|
||||
};
|
||||
const channel = {
|
||||
id: 'test-channel',
|
||||
isTextBased: () => true,
|
||||
isThread: () => false,
|
||||
threads: { create: vi.fn().mockResolvedValue(fakeThread) },
|
||||
};
|
||||
const interaction = {
|
||||
guildId: 'guild-1',
|
||||
get channelId() {
|
||||
return channel.id;
|
||||
},
|
||||
channel,
|
||||
user: { id: userId, username: 'DM' },
|
||||
options: {
|
||||
getSubcommand: () => 'start',
|
||||
getString: (name: string) => (name === 'spec' ? specName : null),
|
||||
},
|
||||
async deferReply(_o?: { ephemeral?: boolean }) {},
|
||||
async editReply(payload: string) {
|
||||
edits.push(payload);
|
||||
return {};
|
||||
},
|
||||
async reply(payload: { content: string }) {
|
||||
replies.push(payload.content);
|
||||
return {};
|
||||
},
|
||||
} as never;
|
||||
return { interaction, edits, replies, fakeThread, lastText: () => edits.at(-1) ?? replies.at(-1) };
|
||||
}
|
||||
|
||||
describe('/encounter start — FU-13: lobby on all encounters + starter NOT pre-joined', () => {
|
||||
beforeEach(() => {
|
||||
mockSetLobby.mockReset();
|
||||
});
|
||||
|
||||
it('opens a lobby for a group spec with the starter NOT pre-joined (joined: [])', async () => {
|
||||
const { interaction, lastText } = fakeStartInteraction('e2e-group-multiplayer');
|
||||
await execute(interaction);
|
||||
|
||||
expect(mockSetLobby, 'a lobby is opened').toHaveBeenCalledTimes(1);
|
||||
const state = mockSetLobby.mock.calls[0][1] as {
|
||||
joined: string[]; joinedNames: string[]; minPlayers: number; starterId: string; messageId: string;
|
||||
};
|
||||
expect(state.joined, 'starter is NOT pre-joined').toEqual([]);
|
||||
expect(state.joinedNames, 'no joined names').toEqual([]);
|
||||
expect(state.minPlayers).toBe(2);
|
||||
expect(state.starterId, 'starterId still recorded (for Cancel)').toBe('dm-starter');
|
||||
expect(state.messageId, 'lobby embed message id recorded').toBe('lobby-msg-1');
|
||||
expect(lastText(), 'reply is the lobby-opened message').toMatch(/Lobby opened/);
|
||||
});
|
||||
});
|
||||
86
tests/unit/expirySweep.test.ts
Normal file
86
tests/unit/expirySweep.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
// ioredis-mock backs the session store so the sweep's SCAN + sessionManager
|
||||
// reads/writes run against a real (in-memory) Redis shape.
|
||||
const refs = vi.hoisted(() => ({ mockRedis: null as any, mockLogEncounter: vi.fn().mockResolvedValue({}) }));
|
||||
|
||||
vi.mock('../../src/db/redis.js', async () => {
|
||||
const { default: RedisMock } = await import('ioredis-mock');
|
||||
refs.mockRedis = new RedisMock();
|
||||
return { redis: refs.mockRedis };
|
||||
});
|
||||
|
||||
// TTL of 1 hour for the test.
|
||||
vi.mock('../../src/config.js', () => ({
|
||||
config: {
|
||||
SESSION_TTL_HOURS: 12,
|
||||
GRAPHMCP_SCORE_THRESHOLD: 0.68,
|
||||
ENCOUNTER_INACTIVITY_TTL_HOURS: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/graphmcp/client.js', () => ({
|
||||
logEncounter: refs.mockLogEncounter,
|
||||
queryAsNPC: vi.fn(),
|
||||
formatNPCMemory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/logger.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { sessionManager } from '../../src/session/sessionManager.js';
|
||||
import { runExpirySweep } from '../../src/bot/handlers/expirySweep.js';
|
||||
import { mockSession } from '../fixtures/spec.js';
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
beforeEach(async () => {
|
||||
await refs.mockRedis?.flushall();
|
||||
refs.mockLogEncounter.mockClear(); // keep mockResolvedValue({}) implementation
|
||||
});
|
||||
|
||||
describe('runExpirySweep (inactivity TTL → expired)', () => {
|
||||
it('expires an idle open session past the TTL: phase→expired, pending cleared, logEncounter kind=expired', async () => {
|
||||
const threadId = 't-idle';
|
||||
await sessionManager.create(threadId, {
|
||||
...mockSession,
|
||||
threadId,
|
||||
encounterId: 'e-idle',
|
||||
phase: 'open',
|
||||
updatedAt: Date.now() - 2 * HOUR, // idle 2h, TTL 1h
|
||||
pendingSkillCheck: { player: 'Aelindra', prompt: 'x', dc: 10, messageId: 'm1' },
|
||||
});
|
||||
|
||||
const { finalized } = await runExpirySweep(); // no client → skip Discord notice/archive
|
||||
|
||||
expect(finalized).toBe(1);
|
||||
const s = await sessionManager.get(threadId);
|
||||
expect(s?.phase).toBe('expired');
|
||||
expect(s?.pendingSkillCheck).toBeUndefined();
|
||||
expect(s?.pendingGroupCheck).toBeUndefined();
|
||||
expect(refs.mockLogEncounter).toHaveBeenCalledWith(expect.objectContaining({ kind: 'expired' }));
|
||||
});
|
||||
|
||||
it('leaves a recent open session alone (within TTL)', async () => {
|
||||
const threadId = 't-recent';
|
||||
await sessionManager.create(threadId, {
|
||||
...mockSession, threadId, encounterId: 'e-recent',
|
||||
phase: 'open', updatedAt: Date.now() - 5 * 60 * 1000, // 5 min ago
|
||||
});
|
||||
const { finalized } = await runExpirySweep();
|
||||
expect(finalized).toBe(0);
|
||||
expect((await sessionManager.get(threadId))?.phase).toBe('open');
|
||||
});
|
||||
|
||||
it('leaves an already-resolved session alone', async () => {
|
||||
const threadId = 't-resolved';
|
||||
await sessionManager.create(threadId, {
|
||||
...mockSession, threadId, encounterId: 'e-resolved',
|
||||
phase: 'resolved', updatedAt: Date.now() - 10 * HOUR,
|
||||
});
|
||||
const { finalized } = await runExpirySweep();
|
||||
expect(finalized).toBe(0);
|
||||
expect((await sessionManager.get(threadId))?.phase).toBe('resolved');
|
||||
});
|
||||
});
|
||||
68
tests/unit/messageRouterAllowlist.test.ts
Normal file
68
tests/unit/messageRouterAllowlist.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { Message } from 'discord.js';
|
||||
|
||||
// Controllable config — messageRouter reads DISCORD_ALLOWED_CHANNELS (via
|
||||
// isAllowedChannel) and E2E_ALLOW_PLAYER_BOTS (the new anti-loop flag).
|
||||
const { mockConfig } = vi.hoisted(() => ({
|
||||
mockConfig: {
|
||||
DISCORD_ALLOWED_CHANNELS: ['parent-1'],
|
||||
E2E_ALLOW_PLAYER_BOTS: false,
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/config.js', () => ({ config: mockConfig }));
|
||||
|
||||
// Only sessionManager.get is reached on the path under test (handleMessage →
|
||||
// line 39). Returning undefined makes it return at the !session guard before
|
||||
// processEncounterMessage, so no other dep is exercised.
|
||||
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
|
||||
vi.mock('../../src/session/sessionManager.js', () => ({
|
||||
sessionManager: { get: mockGet },
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/logger.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { handleMessage } from '../../src/bot/handlers/messageRouter.js';
|
||||
import { addPlayerBotIds, clearE2EPlayerBots } from '../../src/bot/lib/e2ePlayerAllowlist.js';
|
||||
|
||||
// A bot-authored message in a thread whose parent is allowlisted.
|
||||
function fakeBotMessage(authorId: string, parentId = 'parent-1'): Message {
|
||||
return {
|
||||
author: { bot: true, id: authorId },
|
||||
channel: { isThread: () => true, parentId, id: 'thread-1' },
|
||||
react: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
describe('handleMessage — E2E player-bot anti-loop allowlist', () => {
|
||||
beforeEach(() => {
|
||||
clearE2EPlayerBots();
|
||||
mockGet.mockReset();
|
||||
mockConfig.E2E_ALLOW_PLAYER_BOTS = false;
|
||||
});
|
||||
|
||||
it('skips a bot-authored message when E2E_ALLOW_PLAYER_BOTS is off (prod default)', async () => {
|
||||
await handleMessage(fakeBotMessage('player-bot-1'), {} as never);
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes an allowlisted player-bot message when the flag is on', async () => {
|
||||
mockConfig.E2E_ALLOW_PLAYER_BOTS = true;
|
||||
addPlayerBotIds(['player-bot-1']);
|
||||
mockGet.mockResolvedValue(undefined); // no session → returns at !session guard
|
||||
|
||||
await handleMessage(fakeBotMessage('player-bot-1'), {} as never);
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('thread-1');
|
||||
});
|
||||
|
||||
it('skips a bot-authored message when the flag is on but the author is not allowlisted', async () => {
|
||||
mockConfig.E2E_ALLOW_PLAYER_BOTS = true;
|
||||
addPlayerBotIds(['player-bot-1']);
|
||||
|
||||
await handleMessage(fakeBotMessage('some-other-bot'), {} as never);
|
||||
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user