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:
52
src/bot/embeds/groupScoreboard.ts
Normal file
52
src/bot/embeds/groupScoreboard.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
118
src/harness/tools/skillCheckGroupEmit.ts
Normal file
118
src/harness/tools/skillCheckGroupEmit.ts
Normal 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 (1–30).' },
|
||||
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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
48
tests/unit/groupScoreboard.test.ts
Normal file
48
tests/unit/groupScoreboard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
110
tests/unit/skillCheckGroupEmit.test.ts
Normal file
110
tests/unit/skillCheckGroupEmit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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(', ')}`,
|
||||
|
||||
Reference in New Issue
Block a user