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:
2026-06-21 02:08:28 +00:00
parent b374a4f90c
commit 736ca374b8
4 changed files with 96 additions and 34 deletions

View File

@@ -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;
}
}

View File

@@ -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)] });

View File

@@ -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;

View File

@@ -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();
});
});