feat(story-status): L1 prompt enrichment + /character status command (Story 10.1 pt2)
Feature E is live: the LLM now sees each player's class/race/level + active story status every turn, and the DM can set/clear/show story status via a slash command. - assembleContext: now async — fetches each player's character profile (class/race/level from the registry) + active story status per turn, passes the L1 enrichment to buildSystemPrompt → buildPlayersBlock. - buildPlayersBlock: renders 'dndName (class race level) [pronouns] — status: sick, cursed' (was just 'dndName (pronouns)'). PlayerEnrichment type. - runLLMTurn: awaits assembleContext; sendTyping moved before the await so the typing indicator starts immediately (before the context fetch). - /character status set|clear|show @user [label]: DM command (gated by isAllowedUser) — sets/clears story status with setter:'dm' (DM > LLM), or shows the active statuses. Ephemeral confirmations. Tests: contextAssembler (mocked registry + status store, all async), promptBuilder players-block (new format). 511 unit pass; tsc clean. Story 10.1 complete (Feature E L1 + character_status + story status shipped). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import type {
|
||||
ModalSubmitInteraction,
|
||||
} from 'discord.js';
|
||||
import { characterRegistry } from '../../session/characterRegistry.js';
|
||||
import { setStoryStatus, clearStoryStatus, getStoryStatus } from '../../session/storyStatusStore.js';
|
||||
import { sendWelcomeDM } from '../lib/welcomeDM.js';
|
||||
import {
|
||||
searchActors, filterPlayerActors, giveItem,
|
||||
@@ -61,6 +62,17 @@ export const data = new SlashCommandBuilder()
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub.setName('clear').setDescription('Delete your character profile'),
|
||||
)
|
||||
.addSubcommand(sub =>
|
||||
sub
|
||||
.setName('status')
|
||||
.setDescription('Set, clear, or show a story-driven status on a character (DM)')
|
||||
.addStringOption(o =>
|
||||
o.setName('action').setDescription('set, clear, or show').setRequired(true)
|
||||
.addChoices({ name: 'set', value: 'set' }, { name: 'clear', value: 'clear' }, { name: 'show', value: 'show' }),
|
||||
)
|
||||
.addUserOption(o => o.setName('user').setDescription("The character's player").setRequired(true))
|
||||
.addStringOption(o => o.setName('label').setDescription('The status label (e.g. sick, cursed, disguised)').setRequired(false)),
|
||||
);
|
||||
|
||||
export async function execute(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
@@ -97,7 +109,13 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise
|
||||
}
|
||||
} else {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'show') {
|
||||
if (sub === 'status') {
|
||||
if (!isAllowedUser(interaction)) {
|
||||
await interaction.reply({ content: 'Only a DM can manage story status.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await handleStatus(interaction, guildId);
|
||||
} else if (sub === 'show') {
|
||||
await handleShow(interaction, guildId);
|
||||
} else if (sub === 'view') {
|
||||
await handleView(interaction, guildId);
|
||||
@@ -556,3 +574,43 @@ export async function handleGiveModal(interaction: ModalSubmitInteraction): Prom
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /character status — DM sets/clears/shows a story-driven status (Feature E)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleStatus(interaction: ChatInputCommandInteraction, guildId: string): Promise<void> {
|
||||
const action = interaction.options.getString('action', true) as 'set' | 'clear' | 'show';
|
||||
const target = interaction.options.getUser('user', true);
|
||||
const label = interaction.options.getString('label') ?? undefined;
|
||||
|
||||
if (action === 'show') {
|
||||
const statuses = await getStoryStatus(guildId, target.id);
|
||||
const list = statuses.length
|
||||
? statuses.map(s => `• ${s.label} (set by ${s.setter})`).join('\n')
|
||||
: 'No active story statuses.';
|
||||
await interaction.reply({ content: `**${target.username}** — story status:\n${list}`, ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!label) {
|
||||
await interaction.reply({ content: 'A label is required for set/clear.', ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'set') {
|
||||
const acted = await setStoryStatus(guildId, target.id, label, 'dm');
|
||||
await interaction.reply({
|
||||
content: acted ? `✅ Set story status **${label}** on ${target.username}.` : `Could not set "${label}" on ${target.username}.`,
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// clear
|
||||
const acted = await clearStoryStatus(guildId, target.id, label, 'dm');
|
||||
await interaction.reply({
|
||||
content: acted ? `✅ Cleared story status **${label}** from ${target.username}.` : `Could not clear "${label}" from ${target.username}.`,
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -298,11 +298,11 @@ export async function runLLMTurn(
|
||||
thread: ThreadChannel | TextChannel,
|
||||
_client: Client,
|
||||
): Promise<void> {
|
||||
const context = assembleContext(session);
|
||||
|
||||
void thread.sendTyping();
|
||||
const typingInterval = setInterval(() => void thread.sendTyping(), 8_000);
|
||||
|
||||
const context = await assembleContext(session);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await callLLM(context);
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import type { SessionState, ChatMessage } from '../types/index.js';
|
||||
import { trimHistory } from '../lib/historyTrim.js';
|
||||
import { buildSystemPrompt } from './promptBuilder.js';
|
||||
import { buildSystemPrompt, type PlayerEnrichment } from './promptBuilder.js';
|
||||
import { characterRegistry } from '../session/characterRegistry.js';
|
||||
import { getStoryStatus } from '../session/storyStatusStore.js';
|
||||
|
||||
export function assembleContext(session: SessionState): ChatMessage[] {
|
||||
const systemPrompt = buildSystemPrompt(session.spec, session.npcMemories, session.resolvedContext, session.players);
|
||||
// Assemble the full message array for an LLM turn: the system prompt (built
|
||||
// from the spec + L1 character enrichment) followed by pinned + trimmed history.
|
||||
// Now async — fetches each player's character profile (class/race/level) and
|
||||
// active story status (Feature E) so the LLM sees accurate, current character
|
||||
// state every turn.
|
||||
export async function assembleContext(session: SessionState): Promise<ChatMessage[]> {
|
||||
const enrichment: Record<string, PlayerEnrichment> = {};
|
||||
for (const discordId of Object.keys(session.players)) {
|
||||
const profile = await characterRegistry.get(session.guildId, discordId).catch(() => null);
|
||||
const statuses = await getStoryStatus(session.guildId, discordId);
|
||||
enrichment[discordId] = {
|
||||
characterClass: profile?.characterClass,
|
||||
race: profile?.race,
|
||||
level: profile?.level,
|
||||
statuses: statuses.map(s => s.label),
|
||||
};
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(
|
||||
session.spec,
|
||||
session.npcMemories,
|
||||
session.resolvedContext,
|
||||
session.players,
|
||||
enrichment,
|
||||
);
|
||||
const pinned = session.history.filter(m => m.pinned);
|
||||
const sliding = session.history.filter(m => !m.pinned);
|
||||
const trimmed = trimHistory(sliding);
|
||||
@@ -13,4 +38,4 @@ export function assembleContext(session: SessionState): ChatMessage[] {
|
||||
...pinned,
|
||||
...trimmed,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,26 @@
|
||||
import type { EncounterSpec, NpcPersona, Player } from '../types/index.js';
|
||||
import { buildToolManifest } from './toolDispatcher.js';
|
||||
|
||||
export interface PlayerEnrichment {
|
||||
characterClass?: string;
|
||||
race?: string;
|
||||
level?: number;
|
||||
statuses: string[]; // active story-status labels (Feature E)
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(
|
||||
spec: EncounterSpec,
|
||||
npcMemories: Record<string, string> = {},
|
||||
resolvedContext: Record<string, string> = {},
|
||||
players: Record<string, Player> = {},
|
||||
enrichment: Record<string, PlayerEnrichment> = {},
|
||||
): string {
|
||||
return [
|
||||
buildNarratorBlock(),
|
||||
buildToneBlock(spec),
|
||||
buildSportsmanshipBlock(spec.sportsmanshipRules),
|
||||
buildNpcsBlock(spec.npcs, npcMemories),
|
||||
buildPlayersBlock(players),
|
||||
buildPlayersBlock(players, enrichment),
|
||||
buildSettingBlock(spec),
|
||||
buildResolvedContextBlock(resolvedContext),
|
||||
buildSkillChecksBlock(spec.skillChecks),
|
||||
@@ -48,12 +56,22 @@ Your responsibilities:
|
||||
</narrator_identity>`;
|
||||
}
|
||||
|
||||
function buildPlayersBlock(players: Record<string, Player>): string {
|
||||
function buildPlayersBlock(players: Record<string, Player>, enrichment: Record<string, PlayerEnrichment> = {}): string {
|
||||
const entries = Object.values(players);
|
||||
if (entries.length === 0) return '';
|
||||
|
||||
const lines = entries
|
||||
.map(p => ` - ${p.dndName}${p.pronouns ? ` (${p.pronouns})` : ''}`)
|
||||
.map(p => {
|
||||
const e = enrichment[p.discordId];
|
||||
const tags: string[] = [];
|
||||
if (e?.characterClass) tags.push(e.characterClass);
|
||||
if (e?.race) tags.push(e.race);
|
||||
if (e?.level) tags.push(`level ${e.level}`);
|
||||
const tagStr = tags.length ? ` (${tags.join(' ')})` : '';
|
||||
const pronounsStr = p.pronouns ? ` [${p.pronouns}]` : '';
|
||||
const statusStr = e?.statuses.length ? ` — status: ${e.statuses.join(', ')}` : '';
|
||||
return ` - ${p.dndName}${tagStr}${pronounsStr}${statusStr}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `<players>
|
||||
@@ -61,6 +79,7 @@ Active player characters in this encounter:
|
||||
${lines}
|
||||
|
||||
Use the specified pronouns when referring to these characters in narration.
|
||||
Story status reflects lasting conditions (sick, cursed, disguised, etc.) — narrate their effects on the character.
|
||||
</players>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { vi, describe, it, expect } from 'vitest';
|
||||
|
||||
// assembleContext now fetches each player's character profile + story status
|
||||
// (Feature E L1 enrichment). Mock both so the test doesn't touch Redis.
|
||||
vi.mock('../../src/session/characterRegistry.js', () => ({
|
||||
characterRegistry: { get: vi.fn().mockResolvedValue(null) },
|
||||
}));
|
||||
vi.mock('../../src/session/storyStatusStore.js', () => ({
|
||||
getStoryStatus: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
import { assembleContext } from '../../src/harness/contextAssembler.js';
|
||||
import { mockSession, mockSpec } from '../fixtures/spec.js';
|
||||
import { mockSession } from '../fixtures/spec.js';
|
||||
import type { SessionState, ChatMessage } from '../../src/types/index.js';
|
||||
|
||||
function makeMessage(role: ChatMessage['role'], content: string, pinned = false): ChatMessage {
|
||||
@@ -8,17 +18,17 @@ function makeMessage(role: ChatMessage['role'], content: string, pinned = false)
|
||||
}
|
||||
|
||||
describe('assembleContext', () => {
|
||||
it('puts the system message first', () => {
|
||||
const context = assembleContext(mockSession);
|
||||
it('puts the system message first', async () => {
|
||||
const context = await assembleContext(mockSession);
|
||||
expect(context[0].role).toBe('system');
|
||||
});
|
||||
|
||||
it('includes the system prompt content', () => {
|
||||
const context = assembleContext(mockSession);
|
||||
it('includes the system prompt content', async () => {
|
||||
const context = await assembleContext(mockSession);
|
||||
expect(context[0].content).toContain('narrator');
|
||||
});
|
||||
|
||||
it('always includes pinned messages after system', () => {
|
||||
it('always includes pinned messages after system', async () => {
|
||||
const session: SessionState = {
|
||||
...mockSession,
|
||||
history: [
|
||||
@@ -27,13 +37,13 @@ describe('assembleContext', () => {
|
||||
makeMessage('assistant', 'LLM response.'),
|
||||
],
|
||||
};
|
||||
const context = assembleContext(session);
|
||||
const context = await assembleContext(session);
|
||||
const pinned = context.filter(m => m.pinned && m.role !== 'system');
|
||||
expect(pinned).toHaveLength(1);
|
||||
expect(pinned[0].content).toBe('Opening narrative.');
|
||||
});
|
||||
|
||||
it('includes sliding history messages', () => {
|
||||
it('includes sliding history messages', async () => {
|
||||
const session: SessionState = {
|
||||
...mockSession,
|
||||
history: [
|
||||
@@ -41,44 +51,39 @@ describe('assembleContext', () => {
|
||||
makeMessage('assistant', 'Narrator responds.'),
|
||||
],
|
||||
};
|
||||
const context = assembleContext(session);
|
||||
const context = await assembleContext(session);
|
||||
const nonSystem = context.filter(m => !m.pinned);
|
||||
expect(nonSystem.some(m => m.content === 'Player says something.')).toBe(true);
|
||||
});
|
||||
|
||||
it('injects NPC memory into the system prompt', () => {
|
||||
it('injects NPC memory into the system prompt', async () => {
|
||||
const session: SessionState = {
|
||||
...mockSession,
|
||||
npcMemories: {
|
||||
'npc-one': 'Past encounters witnessed:\n - [2026-01-01] Tavern Brawl: A fight broke out.',
|
||||
},
|
||||
};
|
||||
const context = assembleContext(session);
|
||||
const context = await assembleContext(session);
|
||||
expect(context[0].content).toContain('Tavern Brawl');
|
||||
});
|
||||
|
||||
it('drops oldest non-pinned pairs when history exceeds budget', () => {
|
||||
// Use natural language so BPE tokenisation produces realistic token counts.
|
||||
// Repeated single characters compress to almost nothing in BPE.
|
||||
it('drops oldest non-pinned pairs when history exceeds budget', async () => {
|
||||
const bigContent = 'the quick brown fox jumps over the lazy dog. '.repeat(100);
|
||||
const history: ChatMessage[] = [
|
||||
makeMessage('assistant', 'Opening narrative pinned.', true),
|
||||
];
|
||||
// 200 pairs × ~1 000 tokens each ≈ 200 000 tokens >> 114 500 budget
|
||||
for (let i = 0; i < 200; i++) {
|
||||
history.push(makeMessage('user', `${bigContent} turn ${i}`));
|
||||
history.push(makeMessage('assistant', `${bigContent} response ${i}`));
|
||||
}
|
||||
|
||||
const session: SessionState = { ...mockSession, history };
|
||||
const context = assembleContext(session);
|
||||
const context = await assembleContext(session);
|
||||
|
||||
// Pinned message must survive trimming
|
||||
const pinnedInContext = context.filter(m => m.pinned && m.role !== 'system');
|
||||
expect(pinnedInContext).toHaveLength(1);
|
||||
|
||||
// Sliding window should be well under the 400 we pushed in
|
||||
const sliding = context.filter(m => !m.pinned);
|
||||
expect(sliding.length).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -124,8 +124,10 @@ describe('buildSystemPrompt — players block', () => {
|
||||
};
|
||||
const prompt = buildSystemPrompt(mockSpec, {}, {}, players);
|
||||
expect(prompt).toContain('<players>');
|
||||
expect(prompt).toContain('Vex (she/her)');
|
||||
expect(prompt).toContain('Thorin (he/him)');
|
||||
expect(prompt).toContain('Vex');
|
||||
expect(prompt).toContain('she/her');
|
||||
expect(prompt).toContain('Thorin');
|
||||
expect(prompt).toContain('he/him');
|
||||
});
|
||||
|
||||
it('lists players without pronouns without a parenthetical', () => {
|
||||
|
||||
Reference in New Issue
Block a user