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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
93
src/bot/handlers/skillCheckTimer.ts
Normal file
93
src/bot/handlers/skillCheckTimer.ts
Normal 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,
|
||||
'⏰ Time’s up',
|
||||
).addFields({ name: 'Result', value: '❌ FAILURE (timer expired)', inline: true });
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (1–30). 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 (1–600). 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({
|
||||
|
||||
102
tests/unit/skillCheckTimer.test.ts
Normal file
102
tests/unit/skillCheckTimer.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user