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

This commit is contained in:
2026-06-22 21:54:59 +00:00
19 changed files with 1354 additions and 41 deletions

View File

@@ -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.

View File

@@ -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.

View 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.

View 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.

View File

@@ -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);

View 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;
}

View File

@@ -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;

View File

@@ -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) => {

View 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();
}

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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;

View 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-1114) 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);
});

View File

@@ -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 };
}

View File

@@ -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 AC2AC4 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 AC2AC4 (RUN_FULL_E2E=1). AC1 needs none of them.
// AC2AC4 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. AC2AC4 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();
}

View 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);
});
});

View 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/);
});
});

View 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');
});
});

View 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();
});
});