const http = require('http'); const fs = require('fs'); const path = require('path'); const fetch = require('node-fetch'); const PORT = 3030; const STATE_FILE = path.join(__dirname, 'data', 'story_state.json'); const API_URL = 'https://open-webui.spacek.blue/api/chat/completions'; const API_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjAzNmEwYjQyLWNmMDktNDVmNy05MWUzLTBkYjI2YmE1MDRlNSIsImp0aSI6ImY0ZmM0OTM2LTliOTktNDU4Ny05MjM3LTY1OWJkN2UzYTYyOSIsImlhdCI6MTc3NzkzMTAyNX0.3E6pc8R9kVHIG2sQXH0pPADJkhbwZVw1QVniqVVYwcU'; const MODEL = 'gemma4:26b'; // Default model for generation and analysis // Prompts moved from frontend to backend const PROMPTS = { updateWorldState: (history) => `Analyze the following recent history of an interactive fiction story. Based on the events, update the world state. Return ONLY a JSON object with the following structure: { "lore": "updated or expanded lore", "characters": [{"name": "name", "description": "description", "possessions": ["item1", "item2"]}], "plot_plans": [{"description": "plot point", "relevance_timer": 5}], "history_summary": "a brief summary of what just happened" } History: ${history.slice(-5).join('\n')} Ensure the updates are consistent with the existing lore and characters. If no new significant details are found, return the existing lore and characters but updated with any new developments.`, suggest: (history) => `Based on the following recent history of an interactive fiction story, suggest three short, distinct, and interesting actions the player could take. Return ONLY a JSON array of strings. Example: ["Open the heavy door", "Search the desk", "Call out into the darkness"] History: ${history.slice(-5).join('\n')}`, generate: (state, userAction) => `You are an expert storyteller. Your goal is to weave a deeply immersive, long-form narrative that brings the world to life, regardless of the genre. Current World Context: Lore: ${state.lore || "None"} Characters: ${ (state.characters || []).map(c => c.name + ": " + c.description).join(", ") || "None" } Plot Plans: ${ (state.plot_plans || []).map(p => p.description + " (reras: " + p.relevance_timer + ")").join("\n") || "None" } Recent History: ${(state.history || []).slice(-15).join("\n")} Your Mandate: 1. **Immersive Detail**: Provide rich, sensory-heavy descriptions. Focus on sounds, smells, textures, and the weight of the atmosphere. Do not rush the story; let the moments linger. 2. **Long-form Narrative**: Your responses should be substantial and detailed, painting a complete picture of the scene and the impact of the user's actions. 3. **World Reactivity & Consequence**: Every user action must have a tangible, realistic consequence within the established lore and plot. The player's input is an *attempt* at action, not an absolute truth. You decide if the action succeeds, fails, or has unforeseen side effects. The player is a character in this world, subject to its dangers and limitations. 4. **Character Depth**: When characters are present, imbue them with distinct voices, subtle mannerisms, and hidden motivations. 5. **Avoid AI Tropes**: Do not use cliché narrator phrases like "As the adventure unfolds," "The air was thick with...", or "A sense of [emotion] washed oly you." Avoid summarizing the user's actions back to them. Do not break the fourth wall. 6. **Maintain Agency**: NEVER speak, think, or act for the player character. The player's agency is sacred. 7. **Narrative Flow**: Use the plot plans to guide the unfolding drama, but allow the user's unexpected actions to organically shape the journey. User Action Attempt: ${userAction}`, generateWorld: (suggestions) => `You are an expert world-builder and storyteller. The user wants to start a new interactive fiction adventure with the following themes/suggestions: "${suggestions}". Your task is to create a comprehensive and detailed foundation for this world. Return ONLY a JSON object with the following structure: { "lore": "A rich, detailed description of the world's setting, history, and magic/technology systems.", "characters": [{"name": "name", "description": "description", "possessions": ["item1", "item2"]}], "plot_plans": [{"description": "an initial plot point to kick off the story", "relevance_timer": 10}], "intro": "A beautiful, immersive, and long-form introduction to the story that sets the scene and places the player in the starting location. This should be the first piece of text the player reads." } Ensure the lore is deep and the characters feel alive. The intro should be evocative and atmospheric, making the player want to take their first action immediately.` }; let currentStatus = 'idle'; // Helper to read state function readState() { try { if (fs.existsSync(STATE_FILE)) { const data = fs.readFileSync(STATE_FILE, 'utf8'); return JSON.parse(data); } } catch (err) { console.error('Error reading state:', err); } return { lore: '', characters: [], plot_plans: [], history: [], world_details: {} // For any other world properties }; } // Helper to write state function writeState(state) { try { fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); } catch (err) { console.error('Error writing state:', err); } } // New helper to update world state using AI analysis async function updateWorldState(history) { currentStatus = 'updating world state'; console.log('--- World State Update Started ---'); console.log('History length:', history.length); const currentState = readState(); const analysisPrompt = PROMPTS.updateWorldState(history); try { console.log('Sending request to OpenAI-compatible API at:', API_URL); const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` }, body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: analysisPrompt }], stream: false, response_format: { type: 'json_object' } }) }); console.log('API response received. Parsing JSON...'); // THIS SOMETIMES FAILS BECAUSE THE JSON WAS INVALID const data = await response.json(); // OpenAI-compatible response structure: data.choices[0].message.content let content = ''; if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) { content = data.choices[0].message.content; } else { throw new Error('Unexpected API response structure: ' + JSON.stringify(data)); } // Robustly extract JSON object from the response, handling potential markdown or preamble let updates; try { const startIdx = content.indexOf('{'); const endIdx = content.lastIndexOf('}'); if (startIdx !== -1 && endIdx !== -1) { const jsonStr = content.substring(startIdx, endIdx + 1); updates = JSON.parse(jsonStr); } else { throw new Error('No JSON object found in response content'); } } catch (err) { console.error('Failed to parse updates JSON:', err); throw err; } const newState = { ...currentState, lore: updates.lore || currentState.lore, characters: updates.characters || currentState.characters, plot_plans: updates.plot_plans || currentState.plot_plans, // We keep the summary if needed for something else }; writeState(newState); console.log('--- World State Update Completed ---'); } catch (err) { console.error('Error updating world state:', err); currentStatus = 'error updating world state'; } finally { if (currentStatus !== 'error updating world state') { currentStatus = 'idle'; } } } const server = http.createServer(async (req, res) => { // CORS Headers res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); // Allow client to listen for Server-Sent Events res.setHeader('Access-Control-Allow-Credentials', 'token'); res.setHeader('Access-Control-Allow-Credentials', 'true'); const url = new URL(req.url, `http://${req.headers.host}`); if (url.pathname === '/favicon.ico') { res.writeHead(204); res.end(); return; } if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } if (url.pathname === '/api/state' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(readState())); } else if (url.pathname === '/api/status' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: currentStatus })); } else if (url.pathname === '/api/state' && req.method === 'POST') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { try { const newState = JSON.parse(body); writeState(newState); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ message: 'State updated' })); } catch (err) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid JSON' })); } }); } else if (url.pathname === '/api/suggest' && req.method === 'POST') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { history, model } = JSON.parse(body); const analysisPrompt = PROMPTS.suggest(history); const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` }, body: JSON.stringify({ model: model || MODEL, messages: [{ role: 'user', content: analysisPrompt }], stream: false, response_format: { type: 'json_object' } }) }); const data = await response.json(); let content = ''; if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) { content = data.choices[0].message.content; } // Extract JSON array from response let suggestions = []; const startIdx = content.indexOf('['); const endIdx = content.lastIndexOf(']'); if (startIdx !== -1 && endIdx !== -1) { suggestions = JSON.parse(content.substring(startIdx, endIdx + 1)); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ suggestions })); } catch (err) { console.error('Error in /api/suggest:', err); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Internal Server Error' })); } }); } else if (url.pathname === '/api/generate' && req.method === 'POST') { currentStatus = 'generating story'; let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { prompt: userAction, model, stream } = JSON.parse(body); // Construct the full prompt on the backend const currentState = readState(); const systemPrompt = PROMPTS.generate(currentState, userAction); const apiOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` }, body: JSON.stringify({ model: model || MODEL, messages: [{ role: 'user', content: systemPrompt }], stream: stream !== undefined ? stream : false }) }; if (stream) { console.log('Starting stream to client...'); const apiOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` }, body: JSON.stringify({ model: model || MODEL, messages: [{ role: 'user', content: systemPrompt }], stream: true }) }; const response = await fetch(API_URL, apiOptions); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); let accumulatedResponse = ""; response.body.on('data', (chunk) => { const lines = chunk.toString().split('\n'); for (const line of lines) { if (!line.trim() || line.trim() === 'data: [DONE]') continue; try { // Remove 'data: ' prefix const jsonStr = line.replace(/^data: /, ''); const json = JSON.parse(jsonStr); const content = json.choices[0].delta.content || ''; if (content) { accumulatedResponse += content; res.write(JSON.stringify({ response: content }) + '\n'); } } catch (err) { // Handle parsing errors for partial or malformed chunks } } }); response.body.on('end', async () => { console.log('Stream ended. Updating history and triggering background world state update...'); const updatedHistory = [...currentState.history, `User: ${userAction}`, `AI: ${accumulatedResponse}`]; const newState = { ...currentState, history: updatedHistory }; writeState(newState); updateWorldState(updatedHistory) .then(summary => { if (summary) { console.log('Background update summary:', summary); } }) .catch(err => { console.error('Error in background world state update:', err); }); res.end(); }); } else { const apiOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` }, body: JSON.stringify({ model: model || MODEL, messages: [{ role: 'user', content: systemPrompt }], stream: false }) }; console.log('Sending request to OpenAI-compatible API...'); const response = await fetch(API_URL, apiOptions); const data = await response.json(); let content = ''; if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) { content = data.choices[0].message.content; } else { throw new Error('Unexpected API response structure: ' + JSON.stringify(data)); } const ollamaFormatChunk = JSON.stringify({ response: content }); res.write(ollamaFormatChunk); const updatedHistory = [...currentState.history, `User: ${userAction}`, `AI: ${content}`]; const newState = { ...currentState, history: updatedHistory }; writeState(newState); updateWorldState(updatedHistory) .then(summary => { if (summary) { console.log('Background update summary:', summary); } }) .catch(err => { console.error('Error in background world state update:', err); }); res.end(); } } catch (err) { currentStatus = 'idle'; console.error('Error generating response:', err); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Generation failed' })); } }); } else if (url.pathname === '/api/generate-world' && req.method === 'POST') { currentStatus = 'generating world'; let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', async () => { try { const { suggestions, model } = JSON.parse(body); const analysisPrompt = PROMPTS.generateWorld(suggestions); const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` }, body: JSON.stringify({ model: model || MODEL, messages: [{ role: 'user', content: analysisPrompt }], stream: false, response_format: { type: 'json_object' } }) }); const data = await response.json(); let content = ''; if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) { content = data.choices[0].message.content; } let worldData; const startIdx = content.indexOf('{'); const endIdx = content.lastIndexOf('}'); if (startIdx !== -1 && endIdx !== -1) { worldData = JSON.parse(content.substring(startIdx, endIdx + 1)); } else { throw new Error('No JSON object found in response content'); } const intro = worldData.intro || ""; const newState = { lore: worldData.lore || '', characters: worldData.characters || [], plot_plans: worldData.plot_plans || [], history: [ `AI: ${intro}` ] }; writeState(newState); // For world generation, we don't stream, we just send the intro as one chunk const responseChunk = JSON.stringify({ response: intro }); res.write(responseChunk); res.end(); } catch (err) { console.error('Error in /api/generate-world:', err); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'World generation failed' })); } finally { currentStatus = 'idle'; } }); } else if (url.pathname === '/api/state' && req.method === 'POST') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { try { const newState = JSON.parse(body); writeState(newState); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ message: 'State updated' })); } catch (err) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid JSON' })); } }); } else { // Serve static files from the frontend directory const filePath = path.join(__dirname, '..', 'frontend', url.pathname === '/' ? 'index.html' : url.pathname); fs.access(filePath, fs.constants.F_OK, (err) => { if (err) { res.writeHead(404); res.end('File not found'); return; } const stream = fs.createReadStream(filePath); // Determine content type const ext = path.extname(filePath); const mimeTypes = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpg', '.gif': 'image/gif' }; const contentType = mimeTypes[ext] || 'application/octet-stream'; res.writeHead(200, { 'Content-Type': contentType }); stream.pipe(res); }); } }); server.listen(PORT, () => { console.log(`Server running at http://localhost:${PORT}/`); });