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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}` };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user