Files
zalbot/promptBuilder.ts
Kaysser Kayyali 9dc6e8e1a3 Initial commit — Mardonar encounter engine with UX improvements
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>
2026-05-30 04:51:21 +00:00

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