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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user