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:
+189
-15
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user