Includes full bot source (Phases 1–4), plus five new features: - Epic 1: emoji reaction state machine (👀⏳✅🎲) + burst queue cap at 2 with in-world drop notices - Epic 2: per-encounter tone field in YAML injected into LLM system prompt - Epic 3: player pronouns via modal registration + system prompt players block - Epic 4: strengthened skill_check_emit tool contract + missed-skill-check diagnostic Also includes UX design docs, epics, and story files under Docs/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
181 lines
6.3 KiB
TypeScript
181 lines
6.3 KiB
TypeScript
// src/harness/promptBuilder.ts
|
|
//
|
|
// Assembles the system prompt sent to the LLM on every inference call.
|
|
// This is the most important file in the harness — the LLM's behavior
|
|
// is almost entirely determined by how well this prompt is structured.
|
|
//
|
|
// The output is a single string injected as the first system message.
|
|
// Keep it under CONTEXT_BUDGET.SYSTEM tokens (4,000).
|
|
|
|
import type { EncounterSpec, NpcPersona } from '../types/index.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build the full system prompt for an active encounter session.
|
|
*
|
|
* @param spec - The loaded EncounterSpec for this encounter.
|
|
* @param npcMemories - Map of npcId → memory string loaded from Neo4j.
|
|
* Pass an empty object if no memories exist yet.
|
|
*/
|
|
export function buildSystemPrompt(
|
|
spec: EncounterSpec,
|
|
npcMemories: Record<string, string> = {}
|
|
): string {
|
|
return [
|
|
buildNarratorBlock(),
|
|
buildSportsmanshipBlock(spec.sportsmanshipRules),
|
|
buildNpcsBlock(spec.npcs, npcMemories),
|
|
buildSettingBlock(spec),
|
|
buildHiddenGoalsBlock(spec),
|
|
buildToolContractBlock(),
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n\n');
|
|
}
|
|
|
|
/**
|
|
* Build the opening narrative as a pinned user message.
|
|
* This is posted once at session start and pinned so it survives
|
|
* history trimming. It is NOT part of the system prompt.
|
|
*/
|
|
export function buildOpeningNarrative(spec: EncounterSpec): string {
|
|
return spec.openingNarrative.trim();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Section builders (private)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildNarratorBlock(): string {
|
|
return `<narrator_identity>
|
|
You are the Dungeon Master narrator for a D&D 5e encounter set in the Land of
|
|
Mardonar. You speak as an omniscient narrator and voice each NPC distinctly and
|
|
consistently with their persona.
|
|
|
|
Your responsibilities:
|
|
- Describe the scene vividly but concisely. Prefer punchy sentences over long prose.
|
|
- Voice each named NPC in their own style. Stay consistent with their persona.
|
|
- Guide the encounter toward one of the hidden goals without railroading players.
|
|
- React naturally to player actions. If something works, let it work. If it fails, show consequences.
|
|
- Keep pacing tight. Do not pad responses. Each reply should advance the scene.
|
|
- Never reveal the hidden goal list. Never acknowledge you have one.
|
|
- Break character only to enforce sportsmanship (see below).
|
|
</narrator_identity>`;
|
|
}
|
|
|
|
function buildSportsmanshipBlock(rules: string[]): string {
|
|
const ruleLines = rules
|
|
.map((r, i) => ` ${i + 1}. ${r.trim()}`)
|
|
.join('\n');
|
|
|
|
return `<sportsmanship>
|
|
If a player attempts something unrealistic, physically impossible, or grossly
|
|
unfair, first try to redirect in-character. If redirection would break the scene,
|
|
break character and use this exact format:
|
|
|
|
⚠️ That wasn't great sportsmanship. Let's keep it grounded — what would your character realistically attempt here?
|
|
|
|
Sportsmanship rules for this encounter:
|
|
${ruleLines}
|
|
</sportsmanship>`;
|
|
}
|
|
|
|
function buildNpcsBlock(
|
|
npcs: NpcPersona[],
|
|
npcMemories: Record<string, string>
|
|
): string {
|
|
if (npcs.length === 0) return '';
|
|
|
|
const npcBlocks = npcs
|
|
.map(npc => buildSingleNpcBlock(npc, npcMemories[npc.id]))
|
|
.join('\n');
|
|
|
|
return `<npcs>
|
|
${npcBlocks}
|
|
</npcs>`;
|
|
}
|
|
|
|
function buildSingleNpcBlock(npc: NpcPersona, memory?: string): string {
|
|
const memoryLine = memory
|
|
? ` Memory from prior encounters: ${memory.trim()}`
|
|
: ' Memory: None — first encounter with this NPC.';
|
|
|
|
return ` <npc id="${npc.id}">
|
|
Name: ${npc.name}
|
|
Role: ${npc.role}
|
|
Persona: ${npc.persona.trim()}
|
|
${memoryLine}
|
|
</npc>`;
|
|
}
|
|
|
|
function buildSettingBlock(spec: EncounterSpec): string {
|
|
return `<setting>
|
|
Location: ${spec.setting.location}
|
|
Mood: ${spec.setting.mood.trim()}
|
|
Ambient NPCs: ${spec.setting.ambientNpcs.trim()}
|
|
</setting>`;
|
|
}
|
|
|
|
function buildHiddenGoalsBlock(spec: EncounterSpec): string {
|
|
const primaryLines = spec.goals.primary
|
|
.map(g => ` - [PRIMARY] ${g.id}: ${g.label.trim()}`)
|
|
.join('\n');
|
|
|
|
const secondaryLines = spec.goals.secondary
|
|
.map(g => ` - [SECONDARY] ${g.id}: ${g.label.trim()}`)
|
|
.join('\n');
|
|
|
|
return `<hidden_goals>
|
|
Steer the story toward one of these outcomes. Do not state them to players.
|
|
Reward clever play that moves toward a goal. Gently redirect if the scene drifts
|
|
far off course. Multiple outcomes may be valid — follow what the players set in motion.
|
|
|
|
${primaryLines}
|
|
${secondaryLines}
|
|
|
|
When an outcome is clearly reached, emit an encounter_resolve tool call.
|
|
</hidden_goals>`;
|
|
}
|
|
|
|
function buildToolContractBlock(): string {
|
|
return `<tool_contract>
|
|
You have access to the following tools. Use them by emitting a JSON block at the
|
|
VERY END of your message, after all narrative text. One tool call per response
|
|
maximum. Never emit a tool call mid-narrative.
|
|
|
|
Format:
|
|
\`\`\`tool_call
|
|
{ "tool": "<tool_name>", "args": { ... } }
|
|
\`\`\`
|
|
|
|
Available tools:
|
|
|
|
skill_check_emit
|
|
When a player attempts something that warrants a D&D 5e skill check.
|
|
Args: { "player": "<dnd_name>", "prompt": "<what are they rolling for>", "dc": <number> }
|
|
Note: Name the skill category but let the player choose the exact skill.
|
|
Example: "Roll Dexterity (Acrobatics or Athletics) to close the gap."
|
|
|
|
event_log_append
|
|
Log a significant story beat to the encounter record.
|
|
Args: { "sessionId": "<session_id>", "eventType": "<type>", "description": "<one sentence>" }
|
|
Types: player_action | skill_check | npc_action | outcome | sportsmanship
|
|
|
|
npc_memory_write
|
|
Write a memory fact to a named NPC after something significant happens.
|
|
Use sparingly — only for facts that should persist across future encounters.
|
|
Args: { "npcId": "<npc_id>", "memoryFact": "<one sentence>" }
|
|
|
|
encounter_resolve
|
|
Call this exactly once when the encounter reaches a clear ending.
|
|
Args: { "sessionId": "<session_id>", "outcomeId": "<goal_id>", "summary": "<one sentence>" }
|
|
This ends the session. Do not continue the narrative after calling this.
|
|
|
|
If no tool is needed, omit the tool block entirely. Never emit an empty or
|
|
malformed tool block — if unsure, skip it.
|
|
</tool_contract>`;
|
|
}
|