feat(skill-check): Feature A timed checks — durationSeconds + in-memory timer + timeout finalize (Story 6.1 pt2)

skill_check_emit gains an optional durationSeconds arg (1–600). When set
(and the client is available on the tool ctx), an in-memory timer is armed;
on expiry, if the check is still pending (the roll hasn't landed), it
finalizes as FAILURE (timer expired): conditionally clears the pending
check inside atomicMutate (only when the messageId still matches — a stale
timer can't finalize a different check, and a roll that already resolved it
wins), edits the embed to a timed-out state, pushes the [SKILL CHECK RESULT]
system message, and schedules the next LLM turn. rollHandler clears the
timer when the roll is accepted.

- ToolContext += optional client (so the tool can schedule follow-up work);
  passed at the runLLMTurn dispatch site.
- PendingSkillCheck.durationSeconds persisted (so the boot sweep in 6.2 can
  tell a pending check was timed and finalize it on restart).
- In-memory timers are lost on restart — accepted; the 6.2 boot sweep finalizes
  a pending timed check on restart as a fail.

Tests: skillCheckTimer (4) — expiry finalizes FAILURE (timer expired); no
finalize when already resolved; no finalize for a different check (stale
timer); clearSkillCheckTimer cancels. 427 unit pass; tsc clean.

Story 6.1 complete (FR-43 single-Roll + Feature A timed checks).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-21 00:39:34 +00:00
parent 8f8335802e
commit d49dfbae16
6 changed files with 213 additions and 2 deletions

View File

@@ -383,7 +383,7 @@ export async function runLLMTurn(
if (response.toolCall) {
const freshSession = await sessionManager.get(session.threadId);
if (freshSession) {
const result = await dispatchTool(response.toolCall, { session: freshSession, thread });
const result = await dispatchTool(response.toolCall, { session: freshSession, thread, client: _client });
const toolMsg: ChatMessage = {
role: 'system',

View File

@@ -8,6 +8,7 @@ import {
import { sessionManager } from '../../session/sessionManager.js';
import { buildSkillCheckEmbed, EMBED_COLOR } from '../embeds/skillCheck.js';
import { scheduleEncounterLLMTurn } from './messageRouter.js';
import { clearSkillCheckTimer } from './skillCheckTimer.js';
import type { ChatMessage } from '../../types/index.js';
type RollChannel = ThreadChannel | TextChannel;
@@ -59,6 +60,10 @@ async function submitResult(interaction: ButtonInteraction, client: Client): Pro
return;
}
// The roll is accepted — cancel any in-memory timed-check timer so it can't
// fire after the roll resolves.
clearSkillCheckTimer(channel.id);
// Advantage/disadvantage and the modifier are decided upstream (LLM emit +
// Foundry stats) and stored on pendingSkillCheck — the player only clicks Roll.
const { dc, player, prompt, advantage, disadvantage } = pending;

View File

@@ -0,0 +1,93 @@
import type { Client, ThreadChannel, TextChannel } from 'discord.js';
import { sessionManager } from '../../session/sessionManager.js';
import { scheduleEncounterLLMTurn } from './messageRouter.js';
import { buildSkillCheckEmbed, EMBED_COLOR } from '../embeds/skillCheck.js';
import type { ChatMessage, PendingSkillCheck } from '../../types/index.js';
type RollChannel = ThreadChannel | TextChannel;
// In-memory timed-check timers, keyed by threadId. Single process only — lost
// on restart, which is accepted: a pending timed check on restart is finalized
// as a fail by the boot sweep (Story 6.2). Cleared when the roll lands
// (rollHandler.submitResult calls clearSkillCheckTimer).
const timers = new Map<string, NodeJS.Timeout>();
export function clearSkillCheckTimer(threadId: string): void {
const handle = timers.get(threadId);
if (handle) {
clearTimeout(handle);
timers.delete(threadId);
}
}
// Arm a timed skill check. On expiry, if the check is still pending (the roll
// hasn't landed), finalize it as FAILURE (timer expired): clear the pending
// state, edit the embed to a timed-out state, push the [SKILL CHECK RESULT]
// system message, and schedule the next LLM turn so the LLM narrates the
// timeout. `messageId` identifies THIS check so a stale timer can't finalize a
// different check that started after it (and a roll that already resolved it
// wins — the mutator only clears when the messageId still matches).
export function armSkillCheckTimer(
threadId: string,
messageId: string,
thread: RollChannel,
client: Client,
durationSeconds: number,
): void {
clearSkillCheckTimer(threadId);
const handle = setTimeout(
() => void finalizeTimedOut(threadId, messageId, thread, client),
durationSeconds * 1000,
);
timers.set(threadId, handle);
}
async function finalizeTimedOut(
threadId: string,
messageId: string,
thread: RollChannel,
client: Client,
): Promise<void> {
timers.delete(threadId);
// Conditionally clear the pending check ONLY if it is still THIS check. The
// roll may have landed between the timer fire and now; `won` (set inside the
// serialized mutator) tells us whether we won the race. Capture the pending
// info inside the mutator so the embed/message use the right values.
let won: PendingSkillCheck | undefined;
await sessionManager.atomicMutate(threadId, s => {
if (s.pendingSkillCheck?.messageId === messageId) {
won = s.pendingSkillCheck;
return { pendingSkillCheck: undefined, pendingSkillCheckAttempts: undefined };
}
return {};
});
if (!won) return;
if (won.messageId) {
const original = await thread.messages.fetch(won.messageId).catch(() => null);
if (original) {
await original
.edit({ embeds: [buildTimedOutEmbed(won)], components: [] })
.catch(() => null);
}
}
const failMsg: ChatMessage = {
role: 'system',
content: `[SKILL CHECK RESULT] ${won.player} did not respond in time vs DC ${won.dc}. Result: FAILURE (timer expired).`,
timestamp: Date.now(),
};
await sessionManager.addMessage(threadId, failMsg);
scheduleEncounterLLMTurn(threadId, thread, client, true);
}
function buildTimedOutEmbed(p: PendingSkillCheck) {
return buildSkillCheckEmbed(
p.player,
p.prompt,
p.dc,
EMBED_COLOR.FAILURE,
'⏰ Times up',
).addFields({ name: 'Result', value: '❌ FAILURE (timer expired)', inline: true });
}

View File

@@ -1,9 +1,13 @@
import type { ThreadChannel, TextChannel } from 'discord.js';
import type { ThreadChannel, TextChannel, Client } from 'discord.js';
import type { SessionState, EncounterSpec } from '../types/index.js';
export interface ToolContext {
session: SessionState;
thread: ThreadChannel | TextChannel;
// The Discord client — available so tools that need to schedule follow-up
// work (e.g. skill_check_emit arming a timed-check timer that must schedule
// the next LLM turn on expiry) can reach it. Optional: pure tools ignore it.
client?: Client;
}
export interface DispatchResult {

View File

@@ -3,6 +3,7 @@ import { buildSuspenseEmbed, buildSkillCheckEmbed, buildRollButtons } from '../.
import { registerTool, type ToolPlugin } from '../toolRegistry.js';
import { characterRegistry } from '../../session/characterRegistry.js';
import { getActorDetails, type FoundryActorDetails } from '../../vtt/foundryClient.js';
import { armSkillCheckTimer } from '../../bot/handlers/skillCheckTimer.js';
import { log } from '../../lib/logger.js';
// ---------------------------------------------------------------------------
@@ -73,6 +74,7 @@ const skillCheckEmit: ToolPlugin = {
dc: { type: 'number', description: 'Difficulty Class or target AC (130). For spell/melee attacks use the target\'s AC. Use preset values when available.' },
advantage: { type: 'boolean', description: 'Set true when the narrative grants advantage (e.g. attacking while hidden, helped by an ally, using a spell that grants advantage).' },
disadvantage: { type: 'boolean', description: 'Set true when the narrative imposes disadvantage (e.g. restrained, poisoned, attacking at long range without a feat, blinded).' },
durationSeconds: { type: 'number', description: 'Optional wall-clock deadline in seconds (1600). When set, the skill check is TIMED: if the player does not roll before it elapses, the check auto-resolves as FAILURE (timer expired) and the LLM narrates the missed beat. Use for tense, time-pressured actions (disarm a trap, outrun a collapse). Omit for an untimed check.' },
},
contextDocs: (spec) => {
const lines = Object.entries(spec.skillChecks)
@@ -90,6 +92,7 @@ const skillCheckEmit: ToolPlugin = {
const dc = args.dc as number;
const advantage = (args.advantage as boolean | undefined) ?? false;
const disadvantage = (args.disadvantage as boolean | undefined) ?? false;
const durationSeconds = (args.durationSeconds as number | undefined) ?? undefined;
// Resolve the player's Discord ID from the session roster
const discordEntry = Object.entries(ctx.session.players)
@@ -115,9 +118,13 @@ const skillCheckEmit: ToolPlugin = {
advantage: advantage || undefined,
disadvantage: disadvantage || undefined,
discordId: discordId || undefined,
durationSeconds: durationSeconds || undefined,
},
pendingSkillCheckAttempts: 0,
}));
if (durationSeconds && ctx.client) {
armSkillCheckTimer(ctx.session.threadId, sent.id, ctx.thread, ctx.client, durationSeconds);
}
setTimeout(() => {
sent
.edit({

View File

@@ -0,0 +1,102 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
const { mockGet, mockAtomicMutate, mockAddMessage, mockSchedule } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockAtomicMutate: vi.fn(),
mockAddMessage: vi.fn(),
mockSchedule: vi.fn(),
}));
vi.mock('../../src/session/sessionManager.js', () => ({
sessionManager: { get: mockGet, atomicMutate: mockAtomicMutate, addMessage: mockAddMessage },
}));
vi.mock('../../src/bot/handlers/messageRouter.js', () => ({
scheduleEncounterLLMTurn: mockSchedule,
}));
vi.mock('../../src/bot/embeds/skillCheck.js', () => ({
buildSkillCheckEmbed: vi.fn(() => ({ addFields: () => ({}) })),
EMBED_COLOR: { PENDING: 3, SUCCESS: 1, FAILURE: 2 },
}));
import { armSkillCheckTimer, clearSkillCheckTimer } from '../../src/bot/handlers/skillCheckTimer.js';
function fakeThread() {
return { messages: { fetch: vi.fn().mockResolvedValue({ edit: vi.fn().mockResolvedValue(undefined) }) } } as any;
}
function fakeClient() {
return {} as any;
}
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
// Faithful atomicMutate: run the mutator against the session mockGet returns,
// so the conditional-clear logic (compare messageId) actually runs.
mockAtomicMutate.mockImplementation(async (_tid: string, mutator: (s: any) => any) => {
const s = (await mockGet()) ?? {};
const patch = await mutator(s);
return { ...s, ...patch };
});
});
afterEach(() => {
vi.useRealTimers();
});
describe('skillCheckTimer (Feature A timed checks)', () => {
it('finalizes a pending timed check as FAILURE (timer expired) on expiry', async () => {
mockGet.mockResolvedValue({
threadId: 't1',
pendingSkillCheck: { player: 'Aelindra', prompt: 'disarm the trap', dc: 15, messageId: 'm1', durationSeconds: 30 },
});
armSkillCheckTimer('t1', 'm1', fakeThread(), fakeClient(), 30);
await vi.advanceTimersByTimeAsync(30_000);
expect(mockAtomicMutate).toHaveBeenCalledWith('t1', expect.any(Function));
expect(mockAddMessage).toHaveBeenCalledWith(
't1',
expect.objectContaining({
role: 'system',
content: expect.stringContaining('FAILURE (timer expired)'),
}),
);
expect(mockSchedule).toHaveBeenCalledWith('t1', expect.anything(), expect.anything(), true);
});
it('does not finalize if the roll already resolved the check (no pending)', async () => {
mockGet.mockResolvedValue({ threadId: 't1', pendingSkillCheck: undefined });
armSkillCheckTimer('t1', 'm1', fakeThread(), fakeClient(), 30);
await vi.advanceTimersByTimeAsync(30_000);
expect(mockAddMessage).not.toHaveBeenCalled();
expect(mockSchedule).not.toHaveBeenCalled();
});
it('does not finalize a different check that started after the timer armed', async () => {
// A new check with a different messageId is now pending — the stale timer
// (armed for m1) must not finalize it.
mockGet.mockResolvedValue({
threadId: 't1',
pendingSkillCheck: { player: 'Boris', prompt: 'x', dc: 10, messageId: 'm-OTHER', durationSeconds: 30 },
});
armSkillCheckTimer('t1', 'm1', fakeThread(), fakeClient(), 30);
await vi.advanceTimersByTimeAsync(30_000);
expect(mockAddMessage).not.toHaveBeenCalled();
});
it('clearSkillCheckTimer cancels an armed timer (no finalize on advance)', async () => {
mockGet.mockResolvedValue({
threadId: 't1',
pendingSkillCheck: { player: 'A', prompt: 'x', dc: 10, messageId: 'm1', durationSeconds: 30 },
});
armSkillCheckTimer('t1', 'm1', fakeThread(), fakeClient(), 30);
clearSkillCheckTimer('t1');
await vi.advanceTimersByTimeAsync(30_000);
expect(mockAddMessage).not.toHaveBeenCalled();
});
});