feat(group-check): characterContext.getModifier + PendingGroupCheck types (Story 8.1 pt2a)
Groundwork for the skill_check_group_emit tool (pt2b): - characterContext.getModifier(guildId, discordId, skill): the skill/ability modifier resolver, consolidated out of skillCheckEmit so the group tool can reuse it. Skill checks use Foundry skills[key].total; ability checks fall back to abilities[key].mod; undefined when no Foundry char / unrecognized skill / lookup fails (graceful). skillCheckEmit now calls it (removed its inline resolveModifier + the characterRegistry/foundryClient/SKILL_KEY/log imports it no longer needs). - types: PendingGroupCheckRoll (per-player: rolled, modifier, roll?, total?, success?) + PendingGroupCheck (skill, prompt, dc, messageId, successRule, durationSeconds?, deadline?, rolls[]) on SessionState.pendingGroupCheck — a distinct field, not overloading pendingSkillCheck's shape; mutated only via atomicMutate. Tests: characterContext += getModifier (5 — skill total / ability fallback / unrecognized / no Foundry char / graceful throw). 478 unit pass; tsc clean. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { characterRegistry } from '../session/characterRegistry.js';
|
||||
import { getActorDetails, type FoundryActorDetails } from '../vtt/foundryClient.js';
|
||||
import { resolvePassiveScore } from './skillKeys.js';
|
||||
import { resolvePassiveScore, SKILL_KEY } from './skillKeys.js';
|
||||
import { log } from '../lib/logger.js';
|
||||
|
||||
// 30-second in-memory cache for actor details (avoids hammering the relay).
|
||||
@@ -37,4 +37,34 @@ export async function getPassiveScore(
|
||||
log.warn('characterContext', 'passive lookup failed', { discordId, skill: skillName, error: String(err) });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a player's skill/ability modifier for a roll, from Foundry via the
|
||||
// character registry. Skill checks use Foundry's `skills[key].total`
|
||||
// (proficiency + ability mod already rolled in); ability checks fall back to
|
||||
// `abilities[key].mod`. Returns undefined when the player has no Foundry
|
||||
// character, the skill isn't recognized, or the lookup fails (caller rolls at
|
||||
// +0 / skips). Shared by skill_check_emit (single) and skill_check_group_emit.
|
||||
export async function getModifier(
|
||||
guildId: string,
|
||||
discordId: string,
|
||||
skillName: string,
|
||||
): Promise<number | undefined> {
|
||||
const key = SKILL_KEY[skillName.toLowerCase()];
|
||||
if (!key) return undefined;
|
||||
const profile = await characterRegistry.get(guildId, discordId);
|
||||
if (!profile?.foundryActorUuid) return undefined;
|
||||
try {
|
||||
const actor = await fetchActorCached(profile.foundryActorUuid);
|
||||
if (actor.skills?.[key]) {
|
||||
const mod = actor.skills[key].total;
|
||||
log.info('characterContext', 'resolved modifier', { discordId, skill: skillName, modifier: mod });
|
||||
return mod;
|
||||
}
|
||||
if (actor.abilities?.[key]) return actor.abilities[key].mod;
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
log.warn('characterContext', 'modifier lookup failed', { discordId, skill: skillName, error: String(err) });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,8 @@
|
||||
import { sessionManager } from '../../session/sessionManager.js';
|
||||
import { buildSuspenseEmbed, buildSkillCheckEmbed, buildRollButtons } from '../../bot/embeds/skillCheck.js';
|
||||
import { registerTool, type ToolPlugin } from '../toolRegistry.js';
|
||||
import { characterRegistry } from '../../session/characterRegistry.js';
|
||||
import { fetchActorCached } from '../characterContext.js';
|
||||
import { SKILL_KEY } from '../skillKeys.js';
|
||||
import { getModifier } from '../characterContext.js';
|
||||
import { armSkillCheckTimer, startCountdown } from '../../bot/handlers/skillCheckTimer.js';
|
||||
import { log } from '../../lib/logger.js';
|
||||
|
||||
async function resolveModifier(
|
||||
guildId: string,
|
||||
discordId: string,
|
||||
skillName: string,
|
||||
): Promise<number | undefined> {
|
||||
const key = SKILL_KEY[skillName.toLowerCase()];
|
||||
if (!key) return undefined;
|
||||
|
||||
const profile = await characterRegistry.get(guildId, discordId);
|
||||
if (!profile?.foundryActorUuid) return undefined;
|
||||
|
||||
const actor = await fetchActorCached(profile.foundryActorUuid);
|
||||
|
||||
// Skill check (proficiency + ability mod already rolled in by Foundry)
|
||||
if (actor.skills?.[key]) return actor.skills[key].total;
|
||||
// Ability check fallback
|
||||
if (actor.abilities?.[key]) return actor.abilities[key].mod;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool plugin
|
||||
@@ -72,14 +49,7 @@ const skillCheckEmit: ToolPlugin = {
|
||||
|
||||
let modifier: number | undefined;
|
||||
if (discordId && skill) {
|
||||
try {
|
||||
modifier = await resolveModifier(ctx.session.guildId, discordId, skill);
|
||||
if (modifier !== undefined) {
|
||||
log.info('tool', 'resolved modifier', { player, skill, modifier });
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('tool', 'modifier lookup failed, continuing without', { player, skill, error: String(err) });
|
||||
}
|
||||
modifier = await getModifier(ctx.session.guildId, discordId, skill);
|
||||
}
|
||||
|
||||
const sent = await ctx.thread.send({ embeds: [buildSuspenseEmbed(player, prompt)] });
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { EncounterSpec } from '../spec/loader.js';
|
||||
import type { SuccessRule } from '../harness/successRule.js';
|
||||
|
||||
export type {
|
||||
NpcPersona,
|
||||
@@ -48,6 +49,34 @@ export interface PendingSkillCheck {
|
||||
durationSeconds?: number; // If set, the check is timed; expiry finalizes as FAILURE (Feature A)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pending group skill check (Feature C) — multi-player, distinct from the
|
||||
// singular PendingSkillCheck. Lives on SessionState.pendingGroupCheck (a
|
||||
// separate field, not overloading pendingSkillCheck's shape) and is mutated
|
||||
// only via sessionManager.atomicMutate (per-threadId mutex).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PendingGroupCheckRoll {
|
||||
discordId: string;
|
||||
dndName: string;
|
||||
rolled: boolean; // has this player clicked Roll yet?
|
||||
modifier: number; // resolved at emit (Foundry); 0 if unresolvable
|
||||
roll?: number; // d20 face (set when rolled)
|
||||
total?: number; // roll + modifier (set when rolled)
|
||||
success?: boolean; // total >= dc (set when rolled)
|
||||
}
|
||||
|
||||
export interface PendingGroupCheck {
|
||||
skill: string;
|
||||
prompt: string;
|
||||
dc: number;
|
||||
messageId?: string; // the scoreboard embed message id
|
||||
successRule: SuccessRule;
|
||||
durationSeconds?: number; // timed group check
|
||||
deadline?: number; // epoch ms (for timed)
|
||||
rolls: PendingGroupCheckRoll[]; // one entry per targeted player
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
encounterId: string;
|
||||
threadId: string;
|
||||
@@ -65,6 +94,7 @@ export interface SessionState {
|
||||
resolvedContext: Record<string, string>;
|
||||
pendingSkillCheck?: PendingSkillCheck;
|
||||
pendingSkillCheckAttempts?: number;
|
||||
pendingGroupCheck?: PendingGroupCheck; // Feature C — multi-player pending group check
|
||||
outcome?: string;
|
||||
outcomeSummary?: string;
|
||||
createdAt: number;
|
||||
|
||||
@@ -9,7 +9,7 @@ vi.mock('../../src/session/characterRegistry.js', () => ({ characterRegistry: {
|
||||
vi.mock('../../src/vtt/foundryClient.js', () => ({ getActorDetails: mockGetActorDetails }));
|
||||
vi.mock('../../src/lib/logger.js', () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } }));
|
||||
|
||||
import { getPassiveScore, fetchActorCached } from '../../src/harness/characterContext.js';
|
||||
import { getPassiveScore, getModifier, fetchActorCached } from '../../src/harness/characterContext.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -50,4 +50,36 @@ describe('fetchActorCached', () => {
|
||||
await fetchActorCached('actor-cache');
|
||||
expect(mockGetActorDetails).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModifier', () => {
|
||||
it('resolves a skill modifier via Foundry (skills[key].total)', async () => {
|
||||
mockCharGet.mockResolvedValue({ discordId: 'u1', foundryActorUuid: 'actor-mod-skill' });
|
||||
mockGetActorDetails.mockResolvedValue({ skills: { ath: { total: 3, ability: 'str' } }, abilities: {} });
|
||||
expect(await getModifier('g1', 'u1', 'Athletics')).toBe(3);
|
||||
});
|
||||
|
||||
it('falls back to the ability modifier for a raw ability check', async () => {
|
||||
mockCharGet.mockResolvedValue({ discordId: 'u2', foundryActorUuid: 'actor-mod-ability' });
|
||||
mockGetActorDetails.mockResolvedValue({ skills: {}, abilities: { str: { value: 14, mod: 2 } } });
|
||||
expect(await getModifier('g1', 'u2', 'Strength')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns undefined for an unrecognized skill', async () => {
|
||||
mockCharGet.mockResolvedValue({ discordId: 'u3', foundryActorUuid: 'actor-mod-unknown' });
|
||||
mockGetActorDetails.mockResolvedValue({ skills: {}, abilities: {} });
|
||||
expect(await getModifier('g1', 'u3', 'Cooking')).toBeUndefined();
|
||||
expect(mockGetActorDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined when the player has no Foundry character', async () => {
|
||||
mockCharGet.mockResolvedValue({ discordId: 'u4', foundryActorUuid: undefined });
|
||||
expect(await getModifier('g1', 'u4', 'Athletics')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined (graceful) when the Foundry lookup throws', async () => {
|
||||
mockCharGet.mockResolvedValue({ discordId: 'u5', foundryActorUuid: 'actor-mod-throw' });
|
||||
mockGetActorDetails.mockRejectedValue(new Error('relay down'));
|
||||
expect(await getModifier('g1', 'u5', 'Athletics')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user