feat: WorldState memory, increased context, better error handling

- Added WorldState types for character location tracking
- Increased RECENT_MESSAGES_COUNT from 6 to 15
- Increased server limits (50k/200k chars)
- Added language reminders to system prompts
- Better error logging for 400 errors
This commit is contained in:
Alexej Wolff
2026-05-06 21:57:22 +02:00
parent 0b62e73111
commit 77b2794eb1
4 changed files with 251 additions and 19 deletions
+189 -15
View File
@@ -5,13 +5,15 @@ import type {
ChatMessage,
PlayerCharacter,
GameSession,
WorldState,
} from "../types";
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
// Context settings
const RECENT_MESSAGES_COUNT = 6; // Last N messages for context
const SUMMARY_THRESHOLD = 15; // After how many messages to generate summary
const RECENT_MESSAGES_COUNT = 15; // Last N messages for context (increased for better scene continuity)
const SUMMARY_THRESHOLD = 20; // After how many messages to generate summary
const WORLD_STATE_UPDATE_INTERVAL = 5; // Update world state every N messages
interface DeepSeekMessage {
role: "system" | "user" | "assistant";
@@ -51,7 +53,17 @@ export async function sendMessage(
});
if (!response.ok) {
throw new Error(`DeepSeek API error: ${response.status}`);
let errorDetails = "";
try {
const errorData = await response.json();
errorDetails = errorData.details?.join(", ") || errorData.error || "";
} catch {
// Ignore parse errors
}
console.error("DeepSeek API error:", response.status, errorDetails);
throw new Error(
`DeepSeek API error: ${response.status}${errorDetails ? ` - ${errorDetails}` : ""}`,
);
}
const data: DeepSeekResponse = await response.json();
@@ -82,7 +94,17 @@ export async function sendMessageStream(
});
if (!response.ok) {
throw new Error(`DeepSeek API error: ${response.status}`);
let errorDetails = "";
try {
const errorData = await response.json();
errorDetails = errorData.details?.join(", ") || errorData.error || "";
} catch {
// Ignore parse errors
}
console.error("DeepSeek stream error:", response.status, errorDetails);
throw new Error(
`DeepSeek API error: ${response.status}${errorDetails ? ` - ${errorDetails}` : ""}`,
);
}
const reader = response.body?.getReader();
@@ -177,6 +199,8 @@ LANGUAGE: ${story.language}
GENRE: ${story.genre.join(", ")}
SETTING: ${settingInfo}
IMPORTANT: Respond ONLY in language: ${story.language}. Use proper grammar and spelling.
=== PLAYER CHARACTER ===
Name: ${player?.name || "Hero"}
Description: ${playerDescription}`;
@@ -262,12 +286,11 @@ ${story.plot}`;
}
/**
* Builds dynamic context (state + summary + rule reminders)
* Builds dynamic context (state + summary + world state + rule reminders)
*/
export function buildDynamicContext(
session: GameSession,
messageCount?: number,
hasCustomRules?: boolean,
): string {
const state = session.currentState;
const summary = session.storySummary || "The story just began.";
@@ -275,24 +298,29 @@ export function buildDynamicContext(
? session.keyEvents.slice(-5).join("\n- ")
: "No significant events yet.";
// World state for character tracking
const worldStateContext = formatWorldStateForContext(session.worldState);
// Add rule reminders after 10+ messages to prevent drift
// Skip if story has custom rules - they take priority
const ruleReminder =
messageCount && messageCount >= 10 && !hasCustomRules
messageCount && messageCount >= 10
? `
=== REMINDER ===
• Maintain scene continuity — remember current location, characters present, and ongoing actions
• Do NOT act for the player — only describe reactions and consequences
• Do NOT ask "What do you do?" — end with atmosphere, not questions
• Format dialogue: **"text"** (double asterisks = bold)
• React to player's words explicitly`
• React to player's words explicitly
• Use proper grammar and spelling in the story language
• Characters can only be where they logically should be based on their last known location`
: "";
return `
=== CURRENT STATE ===
Location: ${state.location}
=== PLAYER STATUS ===
Health: ${state.health}%
Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"}
${worldStateContext}
=== STORY SUMMARY ===
${summary}
@@ -328,9 +356,8 @@ export async function generateStoryResponse(
const worldContext = buildWorldContext(story);
// 3. Dynamic context (state + summary + rule reminders after 10+ messages)
const hasCustomRules = Boolean(story.narrativeRules?.trim());
const dynamicContext = session
? buildDynamicContext(session, chatHistory.length, hasCustomRules)
? buildDynamicContext(session, chatHistory.length)
: "";
// 4. Last N messages (not the full history!)
@@ -366,9 +393,8 @@ export async function generateStoryResponseStream(
): Promise<string> {
const styleRules = buildStyleRules(story, player);
const worldContext = buildWorldContext(story);
const hasCustomRules = Boolean(story.narrativeRules?.trim());
const dynamicContext = session
? buildDynamicContext(session, chatHistory.length, hasCustomRules)
? buildDynamicContext(session, chatHistory.length)
: "";
const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT);
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext;
@@ -532,3 +558,151 @@ The description should be intriguing and make the reader want to start the adven
return sendMessage(messages, 0.7);
}
/**
* Updates world state by analyzing recent messages
* Call this every WORLD_STATE_UPDATE_INTERVAL messages
*/
export async function updateWorldState(
story: Story,
messages: ChatMessage[],
currentWorldState?: WorldState,
): Promise<WorldState> {
const messageCount = messages.length;
// Get last N messages for analysis
const recentMessages = messages.slice(-10);
const conversationText = recentMessages
.map(
(m, i) =>
`[${messages.length - 10 + i + 1}] ${m.role === "user" ? "Player" : "Narrator"}: ${m.content}`,
)
.join("\n\n");
// Get story characters for reference
const storyCharacters = story.characters.map((c) => c.name).join(", ");
const currentStateJson = currentWorldState
? JSON.stringify(currentWorldState, null, 2)
: "null";
const prompt = `Analyze the recent story messages and update the world state.
STORY CHARACTERS: ${storyCharacters || "Not specified"}
PLAYER CHARACTER: Main character (MC/Hero)
CURRENT WORLD STATE:
${currentStateJson}
RECENT MESSAGES:
${conversationText}
Based on the messages, output ONLY a valid JSON object with this exact structure:
{
"currentScene": {
"location": "Current location of the player (be specific)",
"presentCharacters": ["List of character names currently in the scene with player"],
"situation": "Brief description of what's happening now (1 sentence)"
},
"characters": {
"CharacterName": {
"location": "Where this character is now",
"lastSeenMessage": <message number when last seen>,
"status": "What they're doing",
"notes": "Important details (optional)"
}
},
"lastUpdated": ${messageCount}
}
RULES:
- Only include characters that have appeared in the story
- If a character left the scene, track their destination
- presentCharacters = only those physically with the player RIGHT NOW
- Be consistent with previous state, update only what changed
- Output ONLY the JSON, no explanations`;
const worldStateMessages: DeepSeekMessage[] = [
{
role: "system",
content:
"You are a story state tracker. Analyze story messages and output world state as JSON. Be precise about character locations and movements. Output ONLY valid JSON.",
},
{ role: "user", content: prompt },
];
try {
const response = await sendMessage(worldStateMessages, 0.2, 800);
// Extract JSON from response (in case there's extra text)
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
console.warn("Failed to extract JSON from world state response");
return currentWorldState || createDefaultWorldState(messageCount);
}
const parsed = JSON.parse(jsonMatch[0]) as WorldState;
return parsed;
} catch (error) {
console.error("Failed to update world state:", error);
return currentWorldState || createDefaultWorldState(messageCount);
}
}
/**
* Creates a default world state
*/
function createDefaultWorldState(messageCount: number): WorldState {
return {
currentScene: {
location: "Unknown",
presentCharacters: [],
situation: "The story has just begun.",
},
characters: {},
lastUpdated: messageCount,
};
}
/**
* Checks if world state should be updated
*/
export function shouldUpdateWorldState(
messageCount: number,
currentWorldState?: WorldState,
): boolean {
if (!currentWorldState) return messageCount >= WORLD_STATE_UPDATE_INTERVAL;
const messagesSinceUpdate = messageCount - currentWorldState.lastUpdated;
return messagesSinceUpdate >= WORLD_STATE_UPDATE_INTERVAL;
}
/**
* Formats world state for inclusion in context
*/
export function formatWorldStateForContext(worldState?: WorldState): string {
if (!worldState) return "";
const { currentScene, characters } = worldState;
let result = `
=== CURRENT SCENE ===
Location: ${currentScene.location}
Present: ${currentScene.presentCharacters.length > 0 ? currentScene.presentCharacters.join(", ") : "No one nearby"}
Situation: ${currentScene.situation}`;
const characterEntries = Object.entries(characters);
if (characterEntries.length > 0) {
result += `
=== CHARACTER LOCATIONS ===`;
for (const [name, state] of characterEntries) {
// Skip characters in current scene
if (currentScene.presentCharacters.includes(name)) continue;
result += `
${name}: ${state.location} (${state.status})`;
}
}
return result;
}