feat(group-check): skill_check_group_emit tool + scoreboard embed (Story 8.1 pt2b)

The emit side of Feature C is complete: the LLM can now post a group
skill-check scoreboard and the bot persists the pending group check.

- buildGroupScoreboardEmbed(skill, prompt, dc, rolls, opts?): the scoreboard
  — per-player rows (awaiting / +total / +total), DC, optional Roll Mode
  (adv/dis) + timer (Time / Final sands) fields, PENDING→URGENT in the final
  stretch. One embed, edited in place by the 8.2 runner.
- skill_check_group_emit tool: validates the successRule (built from primitive
  args — majority/all/n_of_m(n,m)/sum_threshold(threshold,sumOf) — then
  SuccessRuleSchema.parse), resolves targeted players ('all' = roster, or a
  comma-separated dndName list), resolves each modifier via
  characterContext.getModifier, posts the scoreboard + Roll button, and
  persists pendingGroupCheck via atomicMutate. Rejects n_of_m m > N and
  no-targeted-players. Whole-group advantage/disadvantage + durationSeconds
  stored on the pending check.
- PendingGroupCheck += advantage/disadvantage. Registered in tools/index.ts.

specsToolsConsistency: allowlist skill_check_group_emit (registered ahead of
its spec — a group spec lands with the lobby in Story 9).

Tests: groupScoreboard (5 — title/DC, rows, PENDING, roll mode, timer/URGENT),
skill_check_group_emit (6 — all-players default majority, named players,
no-players error, n_of_m m>N error, sum_threshold, advantage). 489 unit pass;
tsc clean.

Story 8.1 complete (successRule evaluator + types + resolver + embed + tool).
The roll side (atomic roll registration, scoreboard live updates, aggregate
[GROUP CHECK RESULT], once-per-check LLM call) is Story 8.2.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-21 02:24:24 +00:00
parent 736ca374b8
commit cf06687a2c
7 changed files with 336 additions and 8 deletions

View File

@@ -0,0 +1,52 @@
import { EmbedBuilder } from 'discord.js';
import { EMBED_COLOR } from './skillCheck.js';
import type { PendingGroupCheckRoll } from '../../types/index.js';
// Group skill-check scoreboard (Feature C). One embed, edited in place as rolls
// arrive (the runner in Story 8.2 re-renders via this builder). Shows the skill,
// DC, optional roll-mode + timer fields, and a per-player row each. Single Roll
// button (FR-43 — the clicker is the roller; the roll handler routes to the
// group-check record path). PENDING color; URGENT in the timed final stretch.
export function buildGroupScoreboardEmbed(
skill: string,
prompt: string,
dc: number,
rolls: PendingGroupCheckRoll[],
opts?: { remainingSeconds?: number; advantage?: boolean; disadvantage?: boolean },
): EmbedBuilder {
const remaining = opts?.remainingSeconds;
const finalStretch = remaining !== undefined && remaining <= 10;
const embed = new EmbedBuilder()
.setTitle(`⚔️ Group Check — ${skill}`)
.setDescription(`*${prompt}*`)
.addFields({ name: '⚖️ DC', value: `**${dc}**`, inline: true })
.setColor(finalStretch ? EMBED_COLOR.URGENT : EMBED_COLOR.PENDING)
.setFooter({ text: '⚔️ The party faces the trial together.' });
if (opts?.advantage) {
embed.addFields({ name: '🟢 Roll Mode', value: '**Advantage**', inline: true });
} else if (opts?.disadvantage) {
embed.addFields({ name: '🔴 Roll Mode', value: '**Disadvantage**', inline: true });
}
if (remaining !== undefined) {
if (finalStretch) {
embed.addFields({ name: '⏳ Final sands', value: '**Roll now — the moment slips away.**', inline: true });
} else {
embed.addFields({ name: '⏳ Time', value: `**~${Math.ceil(remaining / 10) * 10}s**`, inline: true });
}
}
const rowText = rolls
.map(r => {
if (!r.rolled) return `${r.dndName} — …awaiting`;
const sign = r.success ? '✅' : '❌';
const modPart = r.modifier ? ` ${r.modifier >= 0 ? '+' : ''}${r.modifier}` : '';
return `${r.dndName}${sign} ${r.total} (rolled ${r.roll}${modPart})`;
})
.join('\n');
embed.addFields({ name: 'Rolled', value: rowText || '—', inline: false });
return embed;
}

View File

@@ -1,6 +1,7 @@
// Side-effect imports — each module calls registerTool() at load time.
// Add new tool files here to make them available to all encounters.
import './skillCheckEmit.js';
import './skillCheckGroupEmit.js';
import './encounterResolve.js';
import './contextRecall.js';
import './goalRegister.js';

View File

@@ -0,0 +1,118 @@
import { sessionManager } from '../../session/sessionManager.js';
import { buildGroupScoreboardEmbed } from '../../bot/embeds/groupScoreboard.js';
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 type { PendingGroupCheck, PendingGroupCheckRoll, Player } from '../../types/index.js';
// 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 {
const kind = (args.successRule as string) ?? 'majority';
let rule: SuccessRule;
if (kind === 'n_of_m') {
rule = { kind: 'n_of_m', n: Number(args.n), m: Number(args.m) };
} else if (kind === 'sum_threshold') {
rule = { kind: 'sum_threshold', t: Number(args.threshold), of: (args.sumOf as string) === 'roll' ? 'roll' : 'total' };
} else if (kind === 'all') {
rule = { kind: 'all' };
} else {
rule = { kind: 'majority' };
}
return SuccessRuleSchema.parse(rule); // throws on invalid n/m/t/of
}
// Resolve the targeted players from the `players` arg: "all" → the session
// roster; or a comma-separated list of character names matched by dndName.
// Unmatched names are skipped.
function resolvePlayers(playersArg: string, roster: Player[]): Player[] {
if (playersArg.trim().toLowerCase() === 'all') return roster;
const names = playersArg.split(',').map(s => s.trim()).filter(Boolean);
const out: Player[] = [];
for (const name of names) {
const match = roster.find(p => p.dndName === name);
if (match) out.push(match);
}
return out;
}
const skillCheckGroupEmit: ToolPlugin = {
name: 'skill_check_group_emit',
description:
'Post a group skill-check scoreboard embed when the PARTY faces a check together ' +
'(group Stealth to slip past guards, group Athletics to cross a chasm, a party saving throw). ' +
'Each targeted player clicks Roll; the bot computes the group outcome from the successRule and ' +
'narrates it. Do NOT narrate the outcome — emit and wait for the [GROUP CHECK RESULT] message.',
args: {
skill: { type: 'string', description: 'The skill being tested (e.g. "Stealth", "Athletics"). Used to display each player\'s modifier.' },
prompt: { type: 'string', description: 'One sentence describing the group action (e.g. "Slip the party past the sentries").' },
dc: { type: 'number', description: 'Difficulty Class each roller is checked against (130).' },
players: { type: 'string', description: '"all" (every player in the encounter) OR a comma-separated list of character names (e.g. "Aelindra, Boris").' },
successRule: { type: 'string', description: 'How the group outcome is decided: "majority" (default — at least half succeed), "all" (every roller must succeed), "n_of_m" (at least n of m succeed — set n and m), "sum_threshold" (the group\'s combined rolls/totals must reach threshold — set threshold and sumOf).' },
n: { type: 'number', description: 'For successRule "n_of_m": the minimum number of successes required.' },
m: { type: 'number', description: 'For successRule "n_of_m": must equal the number of targeted players.' },
threshold: { type: 'number', description: 'For successRule "sum_threshold": the combined total the group must reach.' },
sumOf: { type: 'string', description: 'For successRule "sum_threshold": "roll" (sum raw d20 faces) or "total" (sum d20+modifier). Default "total".' },
advantage: { type: 'boolean', description: 'Set true when the narrative grants the whole group advantage on this check.' },
disadvantage: { type: 'boolean', description: 'Set true when the narrative imposes disadvantage on the whole group.' },
durationSeconds: { type: 'number', description: 'Optional wall-clock deadline (seconds). When set, the check is timed: unrolled players at expiry count as failures. Omit for an untimed group check (a no-show grace period still finalizes).' },
},
handler: async (args, ctx) => {
const skill = (args.skill as string) ?? '';
const prompt = (args.prompt as string) ?? '';
const dc = args.dc as number;
const playersArg = (args.players as string) ?? 'all';
const advantage = (args.advantage as boolean) ?? false;
const disadvantage = (args.disadvantage as boolean) ?? false;
const durationSeconds = (args.durationSeconds as number | undefined) ?? undefined;
let rule: SuccessRule;
try {
rule = buildSuccessRule(args);
} catch (err) {
return { systemMessage: `[TOOL ERROR] Invalid successRule (${String(err)}). Use majority / all / n_of_m (with n, m) / sum_threshold (with threshold, sumOf).` };
}
const roster = Object.values(ctx.session.players);
const targeted = resolvePlayers(playersArg, roster);
if (targeted.length === 0) {
return { systemMessage: `[TOOL ERROR] No targeted players resolved for the group check (players="${playersArg}").` };
}
if (rule.kind === 'n_of_m' && rule.m > targeted.length) {
return { systemMessage: `[TOOL ERROR] successRule n_of_m: m (${rule.m}) exceeds the targeted player count (${targeted.length}).` };
}
// Resolve each player's modifier (Foundry); 0 if unresolvable (they roll at +0).
const rolls: PendingGroupCheckRoll[] = [];
for (const p of targeted) {
const modifier = skill ? (await getModifier(ctx.session.guildId, p.discordId, skill)) ?? 0 : 0;
rolls.push({ discordId: p.discordId, dndName: p.dndName, rolled: false, modifier });
}
const sent = await ctx.thread.send({
embeds: [buildGroupScoreboardEmbed(skill, prompt, dc, rolls, { advantage, disadvantage })],
components: [buildRollButtons()],
});
const gc: PendingGroupCheck = {
skill,
prompt,
dc,
messageId: sent.id,
successRule: rule,
rolls,
advantage: advantage || undefined,
disadvantage: disadvantage || undefined,
durationSeconds: durationSeconds || undefined,
deadline: durationSeconds ? Date.now() + durationSeconds * 1000 : undefined,
};
await sessionManager.atomicMutate(ctx.session.threadId, () => ({ pendingGroupCheck: gc }));
const modeNote = advantage ? ' [ADVANTAGE]' : disadvantage ? ' [DISADVANTAGE]' : '';
return { systemMessage: `[TOOL] Group ${skill || 'check'} posted (DC ${dc}, ${rolls.length} players, rule: ${rule.kind}).${modeNote}` };
},
};
registerTool(skillCheckGroupEmit);
export default skillCheckGroupEmit;

View File

@@ -74,6 +74,8 @@ export interface PendingGroupCheck {
successRule: SuccessRule;
durationSeconds?: number; // timed group check
deadline?: number; // epoch ms (for timed)
advantage?: boolean; // whole-group advantage (decided upstream, applied per roll)
disadvantage?: boolean; // whole-group disadvantage
rolls: PendingGroupCheckRoll[]; // one entry per targeted player
}

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { buildGroupScoreboardEmbed } from '../../src/bot/embeds/groupScoreboard.js';
import { EMBED_COLOR } from '../../src/bot/embeds/skillCheck.js';
import type { PendingGroupCheckRoll } from '../../src/types/index.js';
const rolls: PendingGroupCheckRoll[] = [
{ discordId: 'a', dndName: 'Aelindra', rolled: false, modifier: 3 },
{ discordId: 'b', dndName: 'Boris', rolled: true, modifier: 2, roll: 15, total: 17, success: true },
{ discordId: 'c', dndName: 'Cira', rolled: true, modifier: 1, roll: 5, total: 6, success: false },
];
describe('buildGroupScoreboardEmbed (Feature C)', () => {
it('titles with the skill and shows the DC', () => {
const data = buildGroupScoreboardEmbed('Stealth', 'Slip past the guards', 13, rolls).toJSON();
expect(data.title).toContain('Stealth');
expect(data.title).toContain('⚔️');
expect(data.fields).toContainEqual(expect.objectContaining({ name: '⚖️ DC', value: '**13**' }));
});
it('lists each player — awaiting for unrolled, ✅/❌ + total for rolled', () => {
const data = buildGroupScoreboardEmbed('Stealth', 'x', 13, rolls).toJSON();
const rolled = data.fields.find((f: { name: string }) => f.name === 'Rolled');
expect(rolled?.value).toContain('Aelindra — …awaiting');
expect(rolled?.value).toContain('Boris — ✅ 17');
expect(rolled?.value).toContain('Cira — ❌ 6');
});
it('uses PENDING color by default', () => {
const data = buildGroupScoreboardEmbed('Stealth', 'x', 13, rolls).toJSON();
expect(data.color).toBe(EMBED_COLOR.PENDING);
});
it('shows a Roll Mode field for advantage / disadvantage', () => {
const adv = buildGroupScoreboardEmbed('Stealth', 'x', 13, rolls, { advantage: true }).toJSON();
expect(adv.fields).toContainEqual(expect.objectContaining({ name: '🟢 Roll Mode', value: '**Advantage**' }));
const dis = buildGroupScoreboardEmbed('Stealth', 'x', 13, rolls, { disadvantage: true }).toJSON();
expect(dis.fields).toContainEqual(expect.objectContaining({ name: '🔴 Roll Mode', value: '**Disadvantage**' }));
});
it('shows a ~10s-increment Time field while > ~10s remain, Final sands + URGENT at <= ~10s', () => {
const t20 = buildGroupScoreboardEmbed('Stealth', 'x', 13, rolls, { remainingSeconds: 20 }).toJSON();
expect(t20.fields).toContainEqual(expect.objectContaining({ name: '⏳ Time', value: '**~20s**' }));
expect(t20.color).toBe(EMBED_COLOR.PENDING);
const t8 = buildGroupScoreboardEmbed('Stealth', 'x', 13, rolls, { remainingSeconds: 8 }).toJSON();
expect(t8.fields).toContainEqual(expect.objectContaining({ name: '⏳ Final sands' }));
expect(t8.color).toBe(EMBED_COLOR.URGENT);
});
});

View File

@@ -0,0 +1,110 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
const { mockAtomicMutate, mockGetModifier } = vi.hoisted(() => ({
mockAtomicMutate: vi.fn(),
mockGetModifier: vi.fn(),
}));
vi.mock('../../src/session/sessionManager.js', () => ({
sessionManager: { atomicMutate: mockAtomicMutate },
}));
vi.mock('../../src/harness/characterContext.js', () => ({ getModifier: mockGetModifier }));
import { dispatchTool } from '../../src/harness/toolDispatcher.js';
import '../../src/harness/tools/index.js'; // register plugins
import { mockSession } from '../fixtures/spec.js';
import type { SessionState } from '../../src/types/index.js';
const session: SessionState = {
...mockSession,
players: {
'u-a': { discordId: 'u-a', dndName: 'Aelindra' },
'u-b': { discordId: 'u-b', dndName: 'Boris' },
},
};
function makeThread() {
return { send: vi.fn().mockResolvedValue({ id: 'msg-gc' }) } as any;
}
beforeEach(() => {
vi.clearAllMocks();
mockGetModifier.mockResolvedValue(3);
mockAtomicMutate.mockImplementation(async (_tid: string, mutator: (s: any) => any) => {
const patch = await mutator(session);
return { ...session, ...patch };
});
});
describe('dispatchTool — skill_check_group_emit', () => {
it('posts a scoreboard and persists a pending group check for "all" players (default majority)', async () => {
const result = await dispatchTool(
{ tool: 'skill_check_group_emit', args: { skill: 'Stealth', prompt: 'Slip past the guards', dc: 13, players: 'all' } },
{ session, thread: makeThread() } as any,
);
expect(result.systemMessage).toContain('Group Stealth');
expect(result.systemMessage).toContain('2 players');
expect(result.systemMessage).toContain('rule: majority');
// Capture the persisted pendingGroupCheck from the atomicMutate mutator.
expect(mockAtomicMutate).toHaveBeenCalledWith(session.threadId, expect.any(Function));
const mutator = mockAtomicMutate.mock.calls[0][1] as (s: any) => any;
const patch = mutator(session) as { pendingGroupCheck: any };
expect(patch.pendingGroupCheck.rolls).toHaveLength(2);
expect(patch.pendingGroupCheck.rolls.map((r: any) => r.dndName).sort()).toEqual(['Aelindra', 'Boris']);
expect(patch.pendingGroupCheck.successRule).toEqual({ kind: 'majority' });
expect(patch.pendingGroupCheck.dc).toBe(13);
expect(patch.pendingGroupCheck.messageId).toBe('msg-gc');
expect(patch.pendingGroupCheck.rolls[0].modifier).toBe(3); // resolved via getModifier
});
it('resolves a named-players list by dndName', async () => {
const thread = makeThread();
await dispatchTool(
{ tool: 'skill_check_group_emit', args: { skill: 'Athletics', prompt: 'Cross the chasm', dc: 14, players: 'Boris, Aelindra' } },
{ session, thread } as any,
);
const mutator = mockAtomicMutate.mock.calls[0][1] as (s: any) => any;
const patch = mutator(session) as { pendingGroupCheck: any };
expect(patch.pendingGroupCheck.rolls).toHaveLength(2);
});
it('errors when no targeted players resolve', async () => {
const result = await dispatchTool(
{ tool: 'skill_check_group_emit', args: { skill: 'Stealth', prompt: 'x', dc: 13, players: 'Nobody, Ghost' } },
{ session, thread: makeThread() } as any,
);
expect(result.systemMessage).toContain('[TOOL ERROR]');
expect(result.systemMessage).toContain('No targeted players');
expect(mockAtomicMutate).not.toHaveBeenCalled();
});
it('rejects n_of_m where m exceeds the targeted player count', async () => {
const result = await dispatchTool(
{ tool: 'skill_check_group_emit', args: { skill: 'Stealth', prompt: 'x', dc: 13, players: 'all', successRule: 'n_of_m', n: 2, m: 5 } },
{ session, thread: makeThread() } as any,
);
expect(result.systemMessage).toContain('[TOOL ERROR]');
expect(result.systemMessage).toContain('m (5) exceeds');
});
it('builds a sum_threshold rule from the primitive args', async () => {
await dispatchTool(
{ tool: 'skill_check_group_emit', args: { skill: 'Athletics', prompt: 'x', dc: 10, players: 'all', successRule: 'sum_threshold', threshold: 30, sumOf: 'roll' } },
{ session, thread: makeThread() } as any,
);
const mutator = mockAtomicMutate.mock.calls[0][1] as (s: any) => any;
const patch = mutator(session) as { pendingGroupCheck: any };
expect(patch.pendingGroupCheck.successRule).toEqual({ kind: 'sum_threshold', t: 30, of: 'roll' });
});
it('stores whole-group advantage on the pending group check', async () => {
await dispatchTool(
{ tool: 'skill_check_group_emit', args: { skill: 'Stealth', prompt: 'x', dc: 13, players: 'all', advantage: true } },
{ session, thread: makeThread() } as any,
);
const mutator = mockAtomicMutate.mock.calls[0][1] as (s: any) => any;
const patch = mutator(session) as { pendingGroupCheck: any };
expect(patch.pendingGroupCheck.advantage).toBe(true);
});
});

View File

@@ -77,9 +77,10 @@ describe('specs/*.yaml tool references', () => {
});
it('every registered tool is referenced by at least one spec (sanity: the registry is reachable from the default active set)', () => {
// Skipped if a tool is intentionally global-only (currently none are).
// This catches the case where a tool gets registered but no spec opts
// into it, leaving it dead code from a spec's perspective.
// Tools registered ahead of their spec are allowlisted here — remove the
// entry once a spec references the tool. skill_check_group_emit lands a
// group spec with the lobby (Story 9).
const NOT_YET_REFERENCED = new Set(['skill_check_group_emit']);
const referenced = new Set<string>();
for (const { raw } of specFiles) {
if (Array.isArray(raw.tools)) {
@@ -89,11 +90,7 @@ describe('specs/*.yaml tool references', () => {
}
}
const registered = getAllToolNames();
const unused = Array.from(registered).filter(name => !referenced.has(name));
// If new tools are added that aren't yet referenced by any spec, the
// maintainer can suppress this failure or add a spec — surfacing it
// here is the point. As of 2026-06-19 all 6 registered tools are used.
const unused = Array.from(registered).filter(name => !referenced.has(name) && !NOT_YET_REFERENCED.has(name));
expect(
unused,
`registered tools never referenced by any spec: ${unused.join(', ')}`,