feat(group-check): gate migration + timed group timer (Story 8.2 pt2)

Feature C is complete.

- messageRouter: block player messages while a pendingGroupCheck is active
  (the targeted players roll via the scoreboard button through interactionCreate;
  other chat waits so the LLM doesn't narrate past an unresolved group check).
  No skip counter — the check finalizes on all-rolled or the timer.
- groupCheckManager: armGroupCheckTimer/clearGroupCheckTimer — timed checks fire
  after durationSeconds; untimed checks arm a 300s no-show backstop so an AFK
  player can't hang the check. finalizeGroupCheck clears the timer. Lost on
  restart (the boot sweep finalizes a pending timed group check).
- skill_check_group_emit: arms the timer (durationSeconds or the backstop) after
  persisting the pending group check.

Tests: group-check timer (2 — timeout finalizes with unrolled=failure; clear
cancels). 499 unit pass; tsc clean.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-21 02:49:34 +00:00
parent ac9573340d
commit 244f5bfc39
5 changed files with 83 additions and 1 deletions

View File

@@ -138,6 +138,16 @@ async function processEncounterMessage(
return;
}
// ── Block messages while a group skill check is pending (Feature C). The
// targeted players roll via the scoreboard button (interactionCreate, not
// here); other chat waits so the LLM doesn't narrate past an unresolved
// group check. No skip counter — the check finalizes on all-rolled or the
// timer (timed / no-show backstop).
if (session.pendingGroupCheck) {
await thread.send('*A group roll is still pending — the party must roll.*');
return;
}
// ── Block messages while a dice roll is pending
if (session.pendingSkillCheck) {
// Atomically increment the skip counter and read the new value, so two

View File

@@ -8,6 +8,34 @@ import type { PendingGroupCheck, ChatMessage } from '../types/index.js';
type RollChannel = ThreadChannel | TextChannel;
// In-memory group-check timers, keyed by threadId. Timed checks arm for
// durationSeconds; untimed checks arm a no-show backstop (the check finalizes
// on all-rolled before then; the backstop only fires if someone never rolls).
// Cleared by finalizeGroupCheck (and clearGroupCheckTimer). Lost on restart —
// the boot sweep finalizes a pending timed group check on restart.
const groupTimers = new Map<string, NodeJS.Timeout>();
export function clearGroupCheckTimer(threadId: string): void {
const h = groupTimers.get(threadId);
if (h) {
clearTimeout(h);
groupTimers.delete(threadId);
}
}
// Arm (or re-arm) the group-check timer. On expiry, finalize the check
// (unrolled players count as failures). Cleared when the check finalizes.
export function armGroupCheckTimer(
threadId: string,
thread: RollChannel,
client: Client,
durationSeconds: number,
): void {
clearGroupCheckTimer(threadId);
const h = setTimeout(() => void finalizeGroupCheck(threadId, thread, client), durationSeconds * 1000);
groupTimers.set(threadId, h);
}
export interface RecordResult {
gc: PendingGroupCheck | null; // null if the group check was finalized/gone
allRolled: boolean; // every targeted player has now rolled
@@ -59,6 +87,7 @@ export async function finalizeGroupCheck(
thread: RollChannel,
client: Client,
): Promise<{ success: boolean; rule: string } | null> {
clearGroupCheckTimer(threadId);
let gc: PendingGroupCheck | undefined;
await sessionManager.atomicMutate(threadId, s => {
if (s.pendingGroupCheck) {

View File

@@ -4,8 +4,13 @@ import { buildRollButtons } from '../../bot/embeds/skillCheck.js';
import { registerTool, type ToolPlugin } from '../toolRegistry.js';
import { getModifier } from '../characterContext.js';
import { SuccessRuleSchema, type SuccessRule } from '../successRule.js';
import { armGroupCheckTimer } from '../groupCheckManager.js';
import type { PendingGroupCheck, PendingGroupCheckRoll, Player } from '../../types/index.js';
// Untimed group checks arm a no-show backstop so an AFK player can't hang the
// check forever (the check finalizes on all-rolled well before this).
const GROUP_CHECK_BACKSTOP_SECONDS = 300;
// Build the SuccessRule union value from the LLM's primitive args, then validate
// it via SuccessRuleSchema (catches n<1, m<1, bad `of`, etc.).
function buildSuccessRule(args: Record<string, unknown>): SuccessRule {
@@ -108,6 +113,12 @@ const skillCheckGroupEmit: ToolPlugin = {
deadline: durationSeconds ? Date.now() + durationSeconds * 1000 : undefined,
};
await sessionManager.atomicMutate(ctx.session.threadId, () => ({ pendingGroupCheck: gc }));
// Arm the timer: timed checks fire after durationSeconds; untimed checks
// get a no-show backstop (GROUP_CHECK_BACKSTOP_SECONDS) so an AFK player
// can't hang the check. Cleared on finalization.
if (ctx.client) {
armGroupCheckTimer(ctx.session.threadId, ctx.thread, ctx.client, durationSeconds ?? GROUP_CHECK_BACKSTOP_SECONDS);
}
const modeNote = advantage ? ' [ADVANTAGE]' : disadvantage ? ' [DISADVANTAGE]' : '';
return { systemMessage: `[TOOL] Group ${skill || 'check'} posted (DC ${dc}, ${rolls.length} players, rule: ${rule.kind}).${modeNote}` };

View File

@@ -15,7 +15,7 @@ vi.mock('../../src/bot/handlers/messageRouter.js', () => ({
}));
import { sessionManager } from '../../src/session/sessionManager.js';
import { recordGroupRoll, finalizeGroupCheck } from '../../src/harness/groupCheckManager.js';
import { recordGroupRoll, finalizeGroupCheck, armGroupCheckTimer, clearGroupCheckTimer } from '../../src/harness/groupCheckManager.js';
import { mockSession } from '../fixtures/spec.js';
import type { PendingGroupCheck } from '../../src/types/index.js';
@@ -138,4 +138,35 @@ describe('finalizeGroupCheck', () => {
expect(result).toBeNull();
expect(refs.mockSchedule).not.toHaveBeenCalled();
});
});
describe('group-check timer', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('finalizes the group check on timeout (unrolled = failure)', async () => {
await sessionManager.create('t-t', {
...mockSession, threadId: 't-t',
pendingGroupCheck: gc([
{ discordId: 'u-a', dndName: 'A', rolled: true, modifier: 3, roll: 15, total: 18, success: true },
{ discordId: 'u-b', dndName: 'B', rolled: false, modifier: 2 },
]),
});
armGroupCheckTimer('t-t', fakeThread(), {} as any, 30);
await vi.advanceTimersByTimeAsync(30_000);
const s = await sessionManager.get('t-t');
expect(s?.pendingGroupCheck).toBeUndefined(); // finalized
expect(refs.mockSchedule).toHaveBeenCalledTimes(1);
});
it('clearGroupCheckTimer cancels the timer (no finalize on advance)', async () => {
await sessionManager.create('t-c', {
...mockSession, threadId: 't-c',
pendingGroupCheck: gc([{ discordId: 'u-a', dndName: 'A', rolled: false, modifier: 0 }]),
});
armGroupCheckTimer('t-c', fakeThread(), {} as any, 30);
clearGroupCheckTimer('t-c');
await vi.advanceTimersByTimeAsync(30_000);
expect(refs.mockSchedule).not.toHaveBeenCalled();
});
});

View File

@@ -9,6 +9,7 @@ vi.mock('../../src/session/sessionManager.js', () => ({
sessionManager: { atomicMutate: mockAtomicMutate },
}));
vi.mock('../../src/harness/characterContext.js', () => ({ getModifier: mockGetModifier }));
vi.mock('../../src/harness/groupCheckManager.js', () => ({ armGroupCheckTimer: vi.fn() }));
import { dispatchTool } from '../../src/harness/toolDispatcher.js';
import '../../src/harness/tools/index.js'; // register plugins