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:
2026-06-21 03:00:15 +00:00
parent 848f9e2dcb
commit 9e4197fa4b
6 changed files with 141 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {