feat(skill-check): timed-check embed — 10s countdown + hourglass GIF + Final sands cue (Story 6.2 pt2)

The timed-check embed UX (per the UX spec): a countdown field the runner
updates in 10s increments; below ~10s it switches to the final-stretch
'Final sands — roll now' urgency cue (an announced FIELD, not the footer —
discord.js embed images take no alt text, so the text cue is the a11y
backstop) and, when TIMER_GIF_URL is set, a ~10s-loop hourglass GIF. No URL
→ the text cue alone (static fallback; the asset is non-blocking).

- EMBED_COLOR += URGENT (amber).
- buildTimedCheckEmbed(player, prompt, dc, remaining, ...mods, gifUrl):
  countdown field ('~Ns') for remaining > 10; final-sands field + URGENT +
  optional setImage(gifUrl) for remaining <= 10; timed footer.
- skillCheckTimer.startCountdown: a 10s setInterval that edits the embed in
  place (preserving the Roll button — only embeds passed), re-checks pending
  state each tick (stops early if the roll lands / the timer fires), and
  stops after the final-stretch edit. clearSkillCheckTimer clears it with the
  finalize timeout. skillCheckEmit starts it alongside armSkillCheckTimer.
- config += TIMER_GIF_URL (optional, default '').

Tests: buildTimedCheckEmbed (countdown field + PENDING; final-sands + URGENT;
GIF setImage; static fallback no image; timed footer); startCountdown (10s
cadence + stops after final stretch; stops when the check resolves mid-count).
438 unit pass; tsc clean.

Story 6.2 complete (boot restart sweep + timed-check embed UX).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-21 01:11:29 +00:00
parent 326ce4265a
commit 6239c2103a
6 changed files with 198 additions and 4 deletions

View File

@@ -4,6 +4,7 @@ export const EMBED_COLOR = {
PENDING: 0x5865f2, // blue — awaiting player action
SUCCESS: 0x2ecc71, // green — roll succeeded
FAILURE: 0xe74c3c, // red — roll failed
URGENT: 0xf1c40f, // amber — timed check final stretch (~10s left)
} as const;
export function buildSuspenseEmbed(player: string, prompt: string): EmbedBuilder {
@@ -46,6 +47,46 @@ export function buildSkillCheckEmbed(
return embed;
}
// Timed skill-check embed (Feature A). The countdown runner updates this in
// 10-second increments; below ~10s it switches to the final-stretch "Final
// sands" urgency cue — an announced FIELD (not the footer), because discord.js
// embed images take no alt text, so the text cue is the a11y backstop. When a
// TIMER_GIF_URL is configured, a ~10s-loop hourglass GIF is attached in the
// final stretch; no URL → the text cue alone (static fallback; the asset is
// non-blocking).
export function buildTimedCheckEmbed(
player: string,
prompt: string,
dc: number,
remainingSeconds: number,
modifier?: number,
skillLabel?: string,
advantage?: boolean,
disadvantage?: boolean,
gifUrl?: string,
): EmbedBuilder {
const finalStretch = remainingSeconds <= 10;
const embed = buildSkillCheckEmbed(
player,
prompt,
dc,
finalStretch ? EMBED_COLOR.URGENT : EMBED_COLOR.PENDING,
'⏳ The sands run out. Roll before time slips away.',
modifier,
skillLabel,
advantage,
disadvantage,
);
if (finalStretch) {
embed.addFields({ name: '⏳ Final sands', value: '**Roll now — the moment slips away.**', inline: true });
if (gifUrl) embed.setImage(gifUrl);
} else {
const shown = Math.ceil(remainingSeconds / 10) * 10;
embed.addFields({ name: '⏳ Time', value: `**~${shown}s**`, inline: true });
}
return embed;
}
// FR-43: a single player-locked Roll button. Advantage/disadvantage and the
// modifier are decided upstream (LLM emit + Foundry stats) and shown as embed
// fields — the player no longer chooses them. The button is locked to the

View File

@@ -1,8 +1,9 @@
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 { buildSkillCheckEmbed, buildTimedCheckEmbed, EMBED_COLOR } from '../embeds/skillCheck.js';
import { timedOutSystemMessage } from '../../lib/skillCheckMessages.js';
import { config } from '../../config.js';
import type { PendingSkillCheck } from '../../types/index.js';
type RollChannel = ThreadChannel | TextChannel;
@@ -12,8 +13,20 @@ type RollChannel = ThreadChannel | TextChannel;
// 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>();
// 10s-increment countdown editors, keyed by threadId. Cleared together with
// the finalize timeout by clearSkillCheckTimer.
const countdowns = new Map<string, NodeJS.Timeout>();
function clearCountdown(threadId: string): void {
const iv = countdowns.get(threadId);
if (iv) {
clearInterval(iv);
countdowns.delete(threadId);
}
}
export function clearSkillCheckTimer(threadId: string): void {
clearCountdown(threadId);
const handle = timers.get(threadId);
if (handle) {
clearTimeout(handle);
@@ -43,6 +56,66 @@ export function armSkillCheckTimer(
timers.set(threadId, handle);
}
// Start a 10s-increment countdown that edits the timed-check embed in place.
// Below ~10s it switches to the final-stretch "Final sands" cue (+ the hourglass
// GIF when TIMER_GIF_URL is set) and stops. Stops early if the check resolves
// (the roll lands / the timer fires) — tickCountdown re-checks pending state.
// In-memory only (lost on restart — accepted; the boot sweep finalizes).
export function startCountdown(
threadId: string,
messageId: string,
thread: RollChannel,
durationSeconds: number,
): void {
clearCountdown(threadId);
const deadline = Date.now() + durationSeconds * 1000;
const gifUrl = config.TIMER_GIF_URL || undefined;
const interval = setInterval(
() => void tickCountdown(threadId, messageId, thread, deadline, gifUrl),
10_000,
);
countdowns.set(threadId, interval);
}
async function tickCountdown(
threadId: string,
messageId: string,
thread: RollChannel,
deadline: number,
gifUrl: string | undefined,
): Promise<void> {
const session = await sessionManager.get(threadId);
if (!session?.pendingSkillCheck) {
clearCountdown(threadId); // check resolved — stop editing
return;
}
const remaining = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
const p = session.pendingSkillCheck;
if (remaining > 10) {
await editTimedEmbed(thread, messageId, p, remaining, undefined);
} else {
// Final stretch — show the GIF + "Final sands" once, then stop the interval.
await editTimedEmbed(thread, messageId, p, remaining, gifUrl);
clearCountdown(threadId);
}
}
async function editTimedEmbed(
thread: RollChannel,
messageId: string,
p: PendingSkillCheck,
remaining: number,
gifUrl: string | undefined,
): Promise<void> {
const original = await thread.messages.fetch(messageId).catch(() => null);
if (!original) return;
const embed = buildTimedCheckEmbed(
p.player, p.prompt, p.dc, remaining, p.modifier, p.skill, p.advantage, p.disadvantage, gifUrl,
);
// Pass only embeds so the Roll button (components) is preserved across edits.
await original.edit({ embeds: [embed] }).catch(() => null);
}
async function finalizeTimedOut(
threadId: string,
messageId: string,

View File

@@ -58,6 +58,10 @@ const EnvSchema = z.object({
ENCOUNTER_ARCHIVE_DELAY_MS: z.coerce.number().default(5_000),
// How long the player-gate embed lingers before auto-delete (ms).
ENCOUNTER_GATE_TIMEOUT_MS: z.coerce.number().default(30_000),
// URL of the ~10s-loop hourglass GIF shown in the final stretch of a timed
// skill check. Optional — when unset, the final stretch shows the "Final
// sands" text cue only (static fallback; the asset is non-blocking).
TIMER_GIF_URL: z.string().default(''),
// ── Persona ──────────────────────────────────────────────────────────────
// Path to the YAML file defining the bot's @mention persona.

View File

@@ -3,7 +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 { armSkillCheckTimer, startCountdown } from '../../bot/handlers/skillCheckTimer.js';
import { log } from '../../lib/logger.js';
// ---------------------------------------------------------------------------
@@ -124,6 +124,7 @@ const skillCheckEmit: ToolPlugin = {
}));
if (durationSeconds && ctx.client) {
armSkillCheckTimer(ctx.session.threadId, sent.id, ctx.thread, ctx.client, durationSeconds);
startCountdown(ctx.session.threadId, sent.id, ctx.thread, durationSeconds);
}
setTimeout(() => {
sent

View File

@@ -3,6 +3,7 @@ import {
buildSkillCheckEmbed,
buildSuspenseEmbed,
buildRollButtons,
buildTimedCheckEmbed,
EMBED_COLOR,
} from '../../src/bot/embeds/skillCheck.js';
@@ -97,3 +98,35 @@ describe('buildRollButtons (FR-43 single player-locked Roll)', () => {
expect(data.components[0].label).toBe('Roll');
});
});
describe('buildTimedCheckEmbed (Feature A timed checks)', () => {
it('shows a ~10s-increment countdown field while more than ~10s remain', () => {
const data = buildTimedCheckEmbed('Aelindra', 'disarm the trap', 15, 20).toJSON();
expect(data.fields).toContainEqual(expect.objectContaining({ name: '⏳ Time', value: '**~20s**' }));
expect(data.color).toBe(EMBED_COLOR.PENDING);
});
it('switches to the "Final sands" cue + URGENT color at <= ~10s', () => {
const data = buildTimedCheckEmbed('Aelindra', 'disarm the trap', 15, 8).toJSON();
expect(data.fields).toContainEqual(
expect.objectContaining({ name: '⏳ Final sands', value: expect.stringContaining('Roll now') }),
);
expect(data.color).toBe(EMBED_COLOR.URGENT);
});
it('attaches the hourglass GIF in the final stretch when a gifUrl is configured', () => {
const data = buildTimedCheckEmbed('A', 'x', 10, 5, undefined, undefined, undefined, undefined, 'https://example/hourglass.gif').toJSON();
expect(data.image?.url).toBe('https://example/hourglass.gif');
});
it('falls back to the text cue only (no image) when no gifUrl is configured', () => {
const data = buildTimedCheckEmbed('A', 'x', 10, 5).toJSON();
expect(data.image).toBeUndefined();
expect(data.fields).toContainEqual(expect.objectContaining({ name: '⏳ Final sands' }));
});
it('uses the timed-check footer ("the sands run out")', () => {
const data = buildTimedCheckEmbed('A', 'x', 10, 20).toJSON();
expect(data.footer?.text).toContain('sands run out');
});
});

View File

@@ -15,10 +15,12 @@ vi.mock('../../src/bot/handlers/messageRouter.js', () => ({
}));
vi.mock('../../src/bot/embeds/skillCheck.js', () => ({
buildSkillCheckEmbed: vi.fn(() => ({ addFields: () => ({}) })),
EMBED_COLOR: { PENDING: 3, SUCCESS: 1, FAILURE: 2 },
buildTimedCheckEmbed: vi.fn(() => ({})),
EMBED_COLOR: { PENDING: 3, SUCCESS: 1, FAILURE: 2, URGENT: 4 },
}));
vi.mock('../../src/config.js', () => ({ config: { TIMER_GIF_URL: '' } }));
import { armSkillCheckTimer, clearSkillCheckTimer } from '../../src/bot/handlers/skillCheckTimer.js';
import { armSkillCheckTimer, clearSkillCheckTimer, startCountdown } from '../../src/bot/handlers/skillCheckTimer.js';
function fakeThread() {
return { messages: { fetch: vi.fn().mockResolvedValue({ edit: vi.fn().mockResolvedValue(undefined) }) } } as any;
@@ -99,4 +101,44 @@ describe('skillCheckTimer (Feature A timed checks)', () => {
expect(mockAddMessage).not.toHaveBeenCalled();
});
});
describe('startCountdown (10s-increment countdown editor)', () => {
it('edits the embed in 10s increments and stops after the final stretch', async () => {
const edit = vi.fn().mockResolvedValue(undefined);
const thread = { messages: { fetch: vi.fn().mockResolvedValue({ edit }) } } as any;
mockGet.mockResolvedValue({
threadId: 't1',
pendingSkillCheck: { player: 'A', prompt: 'x', dc: 15, messageId: 'm1', durationSeconds: 30 },
});
startCountdown('t1', 'm1', thread, 30);
await vi.advanceTimersByTimeAsync(10_000); // tick 1 (~20s left) → countdown edit
expect(thread.messages.fetch).toHaveBeenCalledWith('m1');
expect(edit).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(10_000); // tick 2 (~10s left) → final-stretch edit, interval stops
expect(edit).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(10_000); // interval stopped → no further edit
expect(edit).toHaveBeenCalledTimes(2);
});
it('stops editing when the check resolves mid-countdown (the roll landed)', async () => {
const edit = vi.fn().mockResolvedValue(undefined);
const thread = { messages: { fetch: vi.fn().mockResolvedValue({ edit }) } } as any;
mockGet.mockResolvedValue({
threadId: 't2',
pendingSkillCheck: { player: 'A', prompt: 'x', dc: 15, messageId: 'm2', durationSeconds: 30 },
});
startCountdown('t2', 'm2', thread, 30);
await vi.advanceTimersByTimeAsync(10_000); // tick 1 → countdown edit
expect(edit).toHaveBeenCalledTimes(1);
// The roll lands — pending is cleared.
mockGet.mockResolvedValue({ threadId: 't2', pendingSkillCheck: undefined });
await vi.advanceTimersByTimeAsync(10_000); // tick 2 sees no pending → stops, no edit
expect(edit).toHaveBeenCalledTimes(1);
});
});