From 77b2794eb156d77e4449bca207ed47b07c687e1a Mon Sep 17 00:00:00 2001 From: Alexej Wolff Date: Wed, 6 May 2026 21:57:22 +0200 Subject: [PATCH] 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 --- server/index.js | 10 +- src/pages/GamePage.tsx | 32 ++++++ src/services/deepseek.ts | 204 ++++++++++++++++++++++++++++++++++++--- src/types/index.ts | 24 +++++ 4 files changed, 251 insertions(+), 19 deletions(-) diff --git a/server/index.js b/server/index.js index aaf04b6..5c8fff2 100644 --- a/server/index.js +++ b/server/index.js @@ -143,8 +143,8 @@ function pickAllowedFields(obj, allowedFields) { const DEEPSEEK_LIMITS = { MAX_TOKENS_LIMIT: 4096, MAX_MESSAGES: 100, - MAX_MESSAGE_LENGTH: 32000, // ~8k tokens per message - MAX_TOTAL_LENGTH: 128000, // ~32k tokens total + MAX_MESSAGE_LENGTH: 50000, // ~12k tokens per message (increased for long context) + MAX_TOTAL_LENGTH: 200000, // ~50k tokens total (increased for world state + summary) RATE_LIMIT_WINDOW_MS: 60 * 1000, // 1 minute RATE_LIMIT_MAX_REQUESTS: 20, // 20 requests per minute per user }; @@ -1196,6 +1196,7 @@ app.post("/api/deepseek/chat", requireAuth, async (req, res) => { // Validation const validationErrors = validateDeepSeekRequest(req.body); if (validationErrors.length > 0) { + console.warn("DeepSeek chat validation failed:", validationErrors); return res .status(400) .json({ error: "Validation failed", details: validationErrors }); @@ -1206,7 +1207,7 @@ app.post("/api/deepseek/chat", requireAuth, async (req, res) => { return res.status(500).json({ error: "DeepSeek API key not configured" }); } - const { messages, temperature = 0.8, max_tokens = 1000 } = req.body; + const { temperature = 0.8, max_tokens = 1000 } = req.body; const sanitizedMessages = sanitizeDeepSeekMessages(messages); const clampedMaxTokens = Math.min( max_tokens, @@ -1256,6 +1257,7 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => { // Validation const validationErrors = validateDeepSeekRequest(req.body); if (validationErrors.length > 0) { + console.warn("DeepSeek stream validation failed:", validationErrors); return res .status(400) .json({ error: "Validation failed", details: validationErrors }); @@ -1266,7 +1268,7 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => { return res.status(500).json({ error: "DeepSeek API key not configured" }); } - const { messages, temperature = 0.8, max_tokens = 1000 } = req.body; + const { temperature = 0.8, max_tokens = 1000 } = req.body; const sanitizedMessages = sanitizeDeepSeekMessages(messages); const clampedMaxTokens = Math.min( max_tokens, diff --git a/src/pages/GamePage.tsx b/src/pages/GamePage.tsx index 3cd6bd1..e202165 100644 --- a/src/pages/GamePage.tsx +++ b/src/pages/GamePage.tsx @@ -9,6 +9,8 @@ import { sendMessage, generateStorySummary, extractKeyEvents, + updateWorldState, + shouldUpdateWorldState, } from "../services/deepseek"; import type { Story, @@ -359,11 +361,26 @@ export default function GamePage() { ); } + // Update world state for better character tracking + let newWorldState = session.worldState; + if (shouldUpdateWorldState(allMessages.length, session.worldState)) { + try { + newWorldState = await updateWorldState( + story, + allMessages, + session.worldState, + ); + } catch (e) { + console.warn("Failed to update world state:", e); + } + } + const finalSession: GameSession = { ...session, messages: allMessages, keyEvents: newKeyEvents, storySummary: newSummary, + worldState: newWorldState, }; await apiSaveSession(story.id, currentSessionId, finalSession); @@ -545,9 +562,24 @@ export default function GamePage() { assistantMessage, ]; + // Update world state after edit regeneration + let newWorldState = session.worldState; + if (shouldUpdateWorldState(allMessages.length, session.worldState)) { + try { + newWorldState = await updateWorldState( + story, + allMessages, + session.worldState, + ); + } catch (e) { + console.warn("Failed to update world state:", e); + } + } + const finalSession: GameSession = { ...session, messages: allMessages, + worldState: newWorldState, }; await apiSaveSession(story.id, currentSessionId, finalSession); diff --git a/src/services/deepseek.ts b/src/services/deepseek.ts index e5027cb..ad9f537 100644 --- a/src/services/deepseek.ts +++ b/src/services/deepseek.ts @@ -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 { 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 { + 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": , + "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; +} diff --git a/src/types/index.ts b/src/types/index.ts index 1ffb88d..61ed28b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -69,6 +69,28 @@ export interface MessageVersion { aiResponse?: string; // соответствующий ответ ИИ для этой версии } +// Состояние персонажа в мире (для памяти ИИ) +export interface CharacterState { + location: string; // Где персонаж сейчас + lastSeenMessage: number; // Номер сообщения когда последний раз видели + status: string; // Что делает ("Едет в город", "Ждёт у таверны") + notes?: string; // Дополнительные заметки +} + +// Состояние мира (для памяти ИИ о локациях персонажей) +export interface WorldState { + // Текущая сцена игрока + currentScene: { + location: string; // Текущая локация ГГ + presentCharacters: string[]; // Кто присутствует в сцене + situation: string; // Краткое описание ситуации + }; + // Состояние других персонажей (не в сцене) + characters: Record; + // Последнее обновление + lastUpdated: number; // Номер сообщения +} + export interface ChatMessage { id: string; role: "user" | "assistant" | "system"; @@ -90,6 +112,8 @@ export interface GameSession { inventory: string[]; questProgress: Record; }; + // Состояние мира (для памяти о локациях персонажей) + worldState?: WorldState; // Сводка важных событий для контекста AI storySummary?: string; // Ключевые события (для краткой памяти)