Phase 11: data-driven Assistant
- Deterministic advisors (src/lib/assistant): party resources (downed/bloodied/ spent slots/rest nudges), session prep (dangling [[wiki links]], active quests, empty journal), live combat hints (turn, downed combatants) — pure + tested - Data-driven encounter builder: greedily assembles level/CR-appropriate monsters to a target difficulty via the Phase-3 budget; one click builds + opens combat - Assistant page: suggestion cards with one-click actions (goto, long rest, create note); wired into routes, dashboard, command palette - 8 advisor/builder unit tests + assistant e2e Conversational LLM layer intentionally deferred (no provider/key in this env); the deterministic advisor is the offline default per the plan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resourceSuggestions, planningSuggestions, combatSuggestions } from './advisors';
|
||||
import { buildSuggestedEncounter } from './encounter';
|
||||
import { characterDefaults, type Character, type Note, type Encounter } from '@/lib/schemas';
|
||||
|
||||
function pc(name: string, cur: number, max: number, over: Partial<Character> = {}): Character {
|
||||
return {
|
||||
id: name, campaignId: 'c', system: '5e', kind: 'pc', name, ancestry: '', className: '', level: 3,
|
||||
abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 },
|
||||
hp: { current: cur, max, temp: 0 }, speed: 30, armorBonus: 0,
|
||||
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
||||
...characterDefaults(), notes: '', createdAt: '', updatedAt: '', ...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resourceSuggestions', () => {
|
||||
it('flags downed and bloodied PCs', () => {
|
||||
const s = resourceSuggestions([pc('Down', 0, 20), pc('Hurt', 5, 20), pc('Fine', 20, 20)]);
|
||||
expect(s.find((x) => x.id === 'down-Down')?.severity).toBe('danger');
|
||||
expect(s.find((x) => x.id === 'bloodied-Hurt')?.action?.type).toBe('longRest');
|
||||
expect(s.some((x) => x.id === 'bloodied-Fine')).toBe(false);
|
||||
});
|
||||
it('suggests rest when the party is low overall', () => {
|
||||
const s = resourceSuggestions([pc('A', 4, 20), pc('B', 5, 20)]);
|
||||
expect(s.some((x) => x.id === 'party-low')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planningSuggestions', () => {
|
||||
const note = (title: string, body = ''): Note => ({ id: title, campaignId: 'c', title, body, tags: [], createdAt: '', updatedAt: '' });
|
||||
it('surfaces dangling wiki links as create-note actions', () => {
|
||||
const s = planningSuggestions([note('Home', 'Go to [[Castle]]')], []);
|
||||
const d = s.find((x) => x.title.includes('Castle'));
|
||||
expect(d?.action).toMatchObject({ type: 'createNote', title: 'Castle' });
|
||||
});
|
||||
it('does not flag links that resolve', () => {
|
||||
const s = planningSuggestions([note('Home', 'See [[Castle]]'), note('Castle')], []);
|
||||
expect(s.some((x) => x.title.includes('Missing note'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combatSuggestions', () => {
|
||||
const enc = (over: Partial<Encounter> = {}): Encounter => ({
|
||||
id: 'e', campaignId: 'c', name: 'E', status: 'active', round: 2, turnIndex: 0,
|
||||
combatants: [], log: [], createdAt: '', updatedAt: '', ...over,
|
||||
});
|
||||
it('returns nothing when no active combat', () => {
|
||||
expect(combatSuggestions(undefined)).toEqual([]);
|
||||
expect(combatSuggestions(enc({ status: 'planning' }))).toEqual([]);
|
||||
});
|
||||
it('reports the current turn and downed combatants', () => {
|
||||
const e = enc({
|
||||
combatants: [
|
||||
{ id: 'a', name: 'Hero', kind: 'pc', initiative: 10, initBonus: 0, ac: 12, hp: { current: 5, max: 10, temp: 0 }, conditions: [], notes: '' },
|
||||
{ id: 'b', name: 'Goblin', kind: 'monster', initiative: 8, initBonus: 0, ac: 13, hp: { current: 0, max: 7, temp: 0 }, conditions: [], notes: '' },
|
||||
],
|
||||
});
|
||||
const s = combatSuggestions(e);
|
||||
expect(s.some((x) => x.title.includes("Hero's turn"))).toBe(true);
|
||||
expect(s.find((x) => x.id === 'down-combatants')?.detail).toContain('Goblin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSuggestedEncounter', () => {
|
||||
it('reaches at least the target difficulty for 5e', () => {
|
||||
const pool = [{ cr: 0.25 }, { cr: 0.5 }, { cr: 1 }, { cr: 2 }];
|
||||
const chosen = buildSuggestedEncounter('5e', [3, 3, 3, 3], pool, 'Medium');
|
||||
expect(chosen.length).toBeGreaterThan(0);
|
||||
expect(chosen.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
it('returns nothing when the pool has no eligible creatures', () => {
|
||||
expect(buildSuggestedEncounter('5e', [1, 1, 1, 1], [{ cr: 25 }], 'Hard')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Character, Encounter, Note, Quest } from '@/lib/schemas';
|
||||
import { extractWikiLinks } from '@/lib/wikilinks';
|
||||
|
||||
export type Severity = 'info' | 'warn' | 'danger';
|
||||
|
||||
export type SuggestAction =
|
||||
| { type: 'goto'; to: string; params?: Record<string, string>; label: string }
|
||||
| { type: 'longRest'; characterId: string; label: string }
|
||||
| { type: 'createNote'; title: string; label: string };
|
||||
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
category: 'resources' | 'planning' | 'combat';
|
||||
severity: Severity;
|
||||
title: string;
|
||||
detail?: string;
|
||||
action?: SuggestAction;
|
||||
}
|
||||
|
||||
/** Party readiness: downed, bloodied, and expended-resource nudges. */
|
||||
export function resourceSuggestions(characters: Character[]): Suggestion[] {
|
||||
const pcs = characters.filter((c) => c.kind === 'pc');
|
||||
const out: Suggestion[] = [];
|
||||
|
||||
for (const c of pcs) {
|
||||
if (c.hp.current <= 0) {
|
||||
out.push({
|
||||
id: `down-${c.id}`, category: 'resources', severity: 'danger',
|
||||
title: `${c.name} is down`, detail: 'At 0 HP.',
|
||||
action: { type: 'goto', to: '/characters/$characterId', params: { characterId: c.id }, label: 'Open sheet' },
|
||||
});
|
||||
} else if (c.hp.max > 0 && c.hp.current < c.hp.max * 0.5) {
|
||||
out.push({
|
||||
id: `bloodied-${c.id}`, category: 'resources', severity: 'warn',
|
||||
title: `${c.name} is bloodied`, detail: `${c.hp.current}/${c.hp.max} HP.`,
|
||||
action: { type: 'longRest', characterId: c.id, label: 'Long rest' },
|
||||
});
|
||||
}
|
||||
const spentSlots = c.spellcasting.slots.some((s) => s.current < s.max);
|
||||
if (spentSlots && c.hp.current > 0) {
|
||||
out.push({
|
||||
id: `slots-${c.id}`, category: 'resources', severity: 'info',
|
||||
title: `${c.name} has expended spell slots`,
|
||||
action: { type: 'longRest', characterId: c.id, label: 'Long rest' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (pcs.length > 0) {
|
||||
const totalFrac = pcs.reduce((s, c) => s + (c.hp.max > 0 ? c.hp.current / c.hp.max : 1), 0) / pcs.length;
|
||||
if (totalFrac < 0.5) {
|
||||
out.push({ id: 'party-low', category: 'resources', severity: 'warn', title: 'The party is running low', detail: 'Average HP is under half — a rest may be wise.' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Session-prep gaps: dangling wiki links, quest status, empty journal. */
|
||||
export function planningSuggestions(notes: Note[], quests: Quest[]): Suggestion[] {
|
||||
const out: Suggestion[] = [];
|
||||
const titles = new Set(notes.map((n) => n.title.toLowerCase()));
|
||||
const dangling = new Set<string>();
|
||||
for (const n of notes) {
|
||||
for (const link of extractWikiLinks(n.body)) {
|
||||
if (!titles.has(link.toLowerCase())) dangling.add(link);
|
||||
}
|
||||
}
|
||||
for (const title of dangling) {
|
||||
out.push({
|
||||
id: `dangling-${title}`, category: 'planning', severity: 'info',
|
||||
title: `Missing note: “${title}”`, detail: 'A wiki link points to a note that does not exist yet.',
|
||||
action: { type: 'createNote', title, label: 'Create note' },
|
||||
});
|
||||
}
|
||||
|
||||
const active = quests.filter((q) => q.status === 'active');
|
||||
if (active.length > 0) {
|
||||
out.push({
|
||||
id: 'active-quests', category: 'planning', severity: 'info',
|
||||
title: `${active.length} quest${active.length === 1 ? '' : 's'} in progress`,
|
||||
action: { type: 'goto', to: '/quests', label: 'Review quests' },
|
||||
});
|
||||
}
|
||||
if (notes.length === 0) {
|
||||
out.push({
|
||||
id: 'no-notes', category: 'planning', severity: 'info', title: 'No session notes yet',
|
||||
detail: 'Start a journal or lore wiki to keep track of the story.',
|
||||
action: { type: 'goto', to: '/notes', label: 'Open notes' },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Live combat hints from the active encounter. */
|
||||
export function combatSuggestions(encounter: Encounter | undefined): Suggestion[] {
|
||||
if (!encounter || encounter.status !== 'active') return [];
|
||||
const out: Suggestion[] = [];
|
||||
const current = encounter.combatants[encounter.turnIndex];
|
||||
if (current) {
|
||||
out.push({ id: 'turn', category: 'combat', severity: 'info', title: `Round ${encounter.round}: ${current.name}'s turn` });
|
||||
}
|
||||
const down = encounter.combatants.filter((c) => c.hp.current <= 0);
|
||||
if (down.length > 0) {
|
||||
out.push({
|
||||
id: 'down-combatants', category: 'combat', severity: 'warn',
|
||||
title: `${down.length} combatant${down.length === 1 ? '' : 's'} down`,
|
||||
detail: down.map((c) => c.name).join(', '),
|
||||
action: { type: 'goto', to: '/combat', label: 'Open combat' },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import type { Rng } from '@/lib/rng';
|
||||
import { createRng } from '@/lib/rng';
|
||||
import { computeBudget } from '@/lib/combat/budget';
|
||||
|
||||
export interface PoolMonster {
|
||||
cr?: number | undefined;
|
||||
level?: number | undefined;
|
||||
}
|
||||
|
||||
function shuffle<T>(arr: T[], rng: Rng): T[] {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = rng.int(0, i);
|
||||
[a[i], a[j]] = [a[j]!, a[i]!];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedily assemble monsters from a pool to reach (at least) the target
|
||||
* difficulty for the party, choosing level/CR-appropriate creatures. Pure given
|
||||
* an Rng. Returns the chosen pool entries (may repeat a creature).
|
||||
*/
|
||||
export function buildSuggestedEncounter<T extends PoolMonster>(
|
||||
system: SystemId,
|
||||
partyLevels: number[],
|
||||
pool: T[],
|
||||
difficulty: string,
|
||||
rng: Rng = createRng(),
|
||||
maxMonsters = 8,
|
||||
): T[] {
|
||||
const avg = partyLevels.length ? Math.round(partyLevels.reduce((a, b) => a + b, 0) / partyLevels.length) : 1;
|
||||
const thresholds = computeBudget(system, partyLevels, []).thresholds;
|
||||
const target = thresholds.find((t) => t.label.toLowerCase() === difficulty.toLowerCase())?.value ?? 0;
|
||||
if (target <= 0) return [];
|
||||
|
||||
const eligible = pool.filter((m) =>
|
||||
system === '5e'
|
||||
? m.cr !== undefined && m.cr <= avg + 1 && m.cr >= Math.max(0, avg - 6)
|
||||
: m.level !== undefined && m.level <= avg + 2 && m.level >= avg - 4,
|
||||
);
|
||||
if (eligible.length === 0) return [];
|
||||
|
||||
const order = shuffle(eligible, rng);
|
||||
const chosen: T[] = [];
|
||||
for (let i = 0; chosen.length < maxMonsters; i++) {
|
||||
chosen.push(order[i % order.length]!);
|
||||
if (computeBudget(system, partyLevels, chosen).ratingXp >= target) break;
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
Reference in New Issue
Block a user