feat: FU-13 lobby on all encounters + FU-14 TTL auto-expiry
FU-13 — lobby on ALL encounters + starter NOT pre-joined (PRD addendum 2 A/B): - encounter.ts handleStart: remove the minPlayers>=2 branch + the solo immediate-begin path. EVERY /encounter start opens a lobby. The lobby opens EMPTY (joined:[], joinedNames:[]) — the starter (DM) is not pre-joined; players Join; Begin enables at minPlayers. Solo (minPlayers:1) needs one Join. resolveRandomizables/NPC memories/beginEncounter run on Begin (lobbyHandler.handleStartBtn), not in handleStart. Drops the now-dead resolveRandomizables import. - TDD: tests/unit/encounterStartLobby.test.ts (RED pre-join → GREEN no-pre-join). - Amends FR-19/20/26 — a solo behavior change (ship-and-break family). FU-14 — TTL auto-expiry + end/expired event (PRD addendum 2 C/D): - SessionPhase gains 'expired'. config: ENCOUNTER_INACTIVITY_TTL_HOURS (24h). LogEncounterParams gains kind:'ended'|'expired' (best-effort to the tool). - new src/bot/handlers/expirySweep.ts: runExpirySweep finalizes open sessions whose updatedAt is past the TTL as phase:'expired' (clears in-flight checks), emits a GraphMCP log_encounter kind:'expired', posts an in-world notice, and archives the thread. Uses updatedAt (bumped on every mutation) as the activity timestamp — no new lastActivityAt field needed. SCAN, race-safe via atomicMutate. - /encounter end tags log_encounter kind:'ended'. - bot/index.ts: runExpirySweep at boot + an hourly periodic timer. - TDD: tests/unit/expirySweep.test.ts (3 tests: idle→expired, recent left alone, resolved left alone). Tests updated: group-encounter-live.test.ts lobby AC for the no-pre-join flow (0 → player1 join → player2 join → Begin). Verified: tsc clean, 539 unit tests pass (+4), live test skips CI-safe. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -93,6 +93,13 @@ const EnvSchema = z.object({
|
||||
// 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;
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
// called on the start path — GRAPHMCP_URL need not be host-reachable here.)
|
||||
// Skipped by default → CI-safe.
|
||||
//
|
||||
// Topology: player1 (E2E_DRIVER_TOKEN) is the STARTER (pre-joined in the lobby
|
||||
// per /encounter start); player2 (E2E_PLAYER2_TOKEN) joins. 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, not skipped).
|
||||
// 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.
|
||||
|
||||
@@ -67,8 +68,8 @@ describe.skipIf(!runE2E)('Multiplayer live E2E — MVP (2 player-bots, driving t
|
||||
beforeAll(async () => {
|
||||
bots = await connectLiveBots();
|
||||
expect(bots.players.length, 'multiplayer E2E needs 2 player-bots').toBeGreaterThanOrEqual(2);
|
||||
player1Id = bots.players[0].user!.id; // the starter (pre-joined)
|
||||
player2Id = bots.players[1].user!.id; // the joiner
|
||||
player1Id = bots.players[0].user!.id; // the starter (joins too — FU-13: no pre-join)
|
||||
player2Id = bots.players[1].user!.id; // the second joiner
|
||||
await flushRedisForGuild(bots.guild.id);
|
||||
}, 120_000);
|
||||
|
||||
@@ -84,9 +85,10 @@ describe.skipIf(!runE2E)('Multiplayer live E2E — MVP (2 player-bots, driving t
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// AC-8 — lobby gating + start, asserting on the REAL lobby embed ───────────
|
||||
it('lobby embed shows the gate: below-min Begin disabled → join → Begin enabled → start', async () => {
|
||||
// player1 runs /encounter start → a lobby opens with the starter pre-joined.
|
||||
// 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 the DM, not a player). player1 (the starter) runs /encounter start.
|
||||
const { interaction, lastText } = fakeInteraction({
|
||||
subcommand: 'start',
|
||||
stringOptions: { spec: specName },
|
||||
@@ -102,35 +104,45 @@ describe.skipIf(!runE2E)('Multiplayer live E2E — MVP (2 player-bots, driving t
|
||||
thread = await bots.channel.threads.fetch(threadId!);
|
||||
expect(thread, 'lobby thread must exist on the real gateway').toBeTruthy();
|
||||
|
||||
// Lobby state: starter pre-joined (1/2).
|
||||
// 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, 'starter is pre-joined').toEqual([player1Id]);
|
||||
expect(lobby0!.joined.length < lobby0!.minPlayers, 'below minPlayers before anyone joins').toBe(true);
|
||||
|
||||
// REAL lobby embed: Seats shows "1 / 2 minimum" and Begin is DISABLED.
|
||||
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 below min').toContain('1 / 2 minimum');
|
||||
expect(beginDisabled(lobbyMsg0), 'Begin disabled below min').toBe(true);
|
||||
expect(seatsField(lobbyMsg0), 'seats at 0').toContain('0 / 2 minimum');
|
||||
expect(beginDisabled(lobbyMsg0), 'Begin disabled at 0').toBe(true);
|
||||
|
||||
// player2 joins. fakeButton.update EDITS THE REAL lobby embed (messageId
|
||||
// passed), so the embed reflects the click — the UI is driven, not faked.
|
||||
// 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 lobby1 = await waitFor(
|
||||
const lobby2 = await waitFor(
|
||||
() => getLobby(threadId!).then(l => (l && l.joined.length >= 2) ? l : null),
|
||||
{ timeoutMs: 15_000, intervalMs: 500 },
|
||||
);
|
||||
expect(lobby1!.joined, 'player2 joined').toEqual(expect.arrayContaining([player1Id, player2Id]));
|
||||
expect(lobby1!.joined.length >= lobby1!.minPlayers, 'meets minPlayers after one join').toBe(true);
|
||||
|
||||
// REAL lobby embed now: Seats shows "min 2 met" and Begin is ENABLED.
|
||||
const lobbyMsg1 = await thread!.messages.fetch(lobby0!.messageId!);
|
||||
expect(seatsField(lobbyMsg1), 'seats met').toContain('min 2 met');
|
||||
expect(beginDisabled(lobbyMsg1), 'Begin enabled at min').toBe(false);
|
||||
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(
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user