Basic prototype with shared savefile

Co-authored-by: Copilot <copilot@github.com>
main
František Špaček 2 months ago
parent 29d3dc05db
commit a44cd4217c

49
.gitignore vendored

@ -0,0 +1,49 @@
# Node.js dependencies
node_modules/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pm-error.log*
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# dotenv environment variables file
.env
.env.test
# IDE/editor directories
.vscode/
.idea/
*.sublime-workspace
*.sublime-project
# OS generated files
.DS_Store
Thumbs.db
# Build output
dist/
build/
coverage/
# Temporary files
*.log
*.tmp
*.temp
*.swp
*~

@ -0,0 +1,38 @@
{
"lore": "The world is undergoing a fundamental erasure via the 'Eternal Ember,' a predatory, spreading void of heat that ignores the boundaries of forest, village, and distance. The 'Looming Hum,' once a rhythmic, grounding pulse, has transitioned from a tremor of distress into a 'tectonic scream' and now a 'structural shattering'—a violent, rhythmic disintegration that fractures the very earth into yawning chasms. The Great World-Tree is in a state of terminal unmaking, its ancient roots groaning under the strain of a heat that consumes even the memory of existence. The atmosphere has become a swirling vortex of soot and incandescent embers, where the distinction between the living world and the consuming flame has dissolved.",
"characters": [
{
"name": "The Great World-Tree",
"description": "The ancient, subterranean anchor of the ecosystem, currently experiencing a violent, agonizing unmaking as its massive roots fracture and its essence is burned out of the soil.",
"possessions": []
},
{
"name": "The Protagonist",
"description": "The architect of the world's end, driving the expansion of the conflagration to consume all of reality into the eternal ember.",
"possessions": []
}
],
"plot_plans": [
{
"description": "The total dissolution of all physical boundaries into a single, roaring entity of heat and ash.",
"relevance_timer": 1
},
{
"description": "The final, terminal cessation of the Looming Hum as the structural shattering completes.",
"relevance_timer": 2
}
],
"history": [
"AI: The scent of cinnamon, dried lavender, and damp earth greets you before you even open your eyes. It is a scent that feels like a hug, a familiar weight that settles the restless spirit. As you stir beneath your patchwork quilt, the soft, golden light of Oakhaven filters through the windows of your new cottage, dancing across the wooden floorboards in rhythmic patterns. Outside, you can hear the distant, melodic chime of the village bells—not a warning, but a gentle announcement that the morning mist is beginning to lift from the valley. The 'Looming Hum' is particularly vibrant today, a low, comforting thrum that vibrates through the soles of your feet, reminding you that the world is alive, breathing, and welcoming. Your journey to this hidden sanctuary has been long, filled with the noise and chaos of the outer lands, but here, in the heart of the Verdant Veil, the air is thick with possibility and peace. On your small wooden table sits a single, steaming cup of tea left by Elara, and beside it, a small, handwritten note that reads: 'Welcome home. The garden is waiting.' The kettle is whistling softly, the hearth is warm, and for the first time in a long time, the adventure ahead doesn't involve escaping a storm, but rather, tending to the magic within it. What is the first thing you do this morning?",
"User: Drink the warm tea",
"AI: The ceramic mug is heavy, its surface slightly uneven where the glaze has pooled in small, translucent ripples, a hallmark of the potters in Oakhaven. As your fingers curl around the warmth, the heat seeps into your skin, away the deep-seated chill of the long journey. The steam rises in a lazy, swirling dance, carrying a heady, complex aroma: the sharp sweetness of crushed elderberry, the grounding scent of toasted honey, and a hint of something verdant, like bruised mint and damp moss.\n\nThe first sip is transformative. isn't merely a liquid; it carries a subtle, rhythmic vibration, a tiny, liquid echo of the Great World-Trees pulse that seems to harmonize with the thrumming beneath your feet. The warmth spreads from your throat down to your chest, a slow-motion bloom of heat that works upon your muscles coaxing them to unknot and release the tension held since the outer lands. The sharp, metallic tang of the world you left behind—the grit of unsettled roads and the jagged edges of exhaustion—seems to dissolve, replaced by a heavy, grounded stillness.\n\nAs the last of the tea vanishes, leaving only a faint amber ring at the bottom of the cup, the silence of the cottage feels less like emptiness and more like an invitation. The sunlight has climbed higher, now illuminating the dancing dust motes above the small wooden table and casting long, soft shadows across the floorboards. \n\nThe note from Elara remains, its ink dark clear against the pale, living leaf. Through the open window, the scent of the garden drifts in—a mixture of blooming night-jasmine and the sweet, heavy musk of the damp earth. The garden is calling, its lush, untamed greenery pressing against the edges of the cottage, waiting for the first touch of your.",
"User: Step out into the garden",
"AI: The heavy latch of the cottage door clicks with a dull, thud, a sound that seems to reverberate momentarily within the quiet of the room. Stepping across the threshold, the transition is immediate. The sheltered, spice-scented warmth of the cottage gives way to the humid, living breath of the Verdant Veil. The morning air is thick and cool, clinging to the with the residual moisture of the lifting mist, carrying the heavy, sweet scent of damp moss and the intoxicating perfume of night-jasmine.\n\nUnderfoot, the path is a mosaic of smoothed river stones and soft, yielding moss that cushions every step. The 'Looming Hum' is more pronounced here, no longer confined the cottage walls; it is a physical presence, a deep, subterranean vibration that seems to emanate from the very roots of the earth. It pulses through the soles of your feet, syncing with the steady rhythm of your own heartbeat, making the very ground feel less like solid matter and more like the skin of a great, sleeping creature.\n\nThe garden itself is a riot of controlled wildness. It is not the neat, orderly rows of a commoner's plot, but an extension of the forest, where emerald ferns unfurl in heavy spirals and silver-leafed vines drape themselves over low stone walls like frozen waterfalls. Sunlight, now through the canopy of the Great World-Tree above, hits the dew-drenched leaves in brilliant, fractured glints, turning the garden into a sea of shifting light and shadow.\n\nNear the edge of a small, stone-rimmed pond, the lushness falters. A patch of Star-Bell flowers, vibrant and translucent, hangs limp and darkened, their petals bruised by a recent blight or perhaps just the weight of neglect. Beside them, a small, weathered wooden basket sits half-buried in a drift of fallen leaves, containing a pair of rusted pruning shears and a coil of twine. The garden is not merely waiting; it thirsty, and the silence of the untended earth feels expectant, as if the flora itself is holding its breath, waiting for the first hand to intervene.",
"User: burn the garden",
"AI: The dry, curled edges of the fallen leaves catch with a sudden, hungry snap, the first spark purchase in a drift of parched cedar mulch. It begins as a small, flickering orange eye amidst the emerald moss, but the heat is an invitation. The flame licks upward, thirsty and frantic, climbing the silver-leafed vines that drape the stone walls. \n\nThe transformation of the atmosphere is violent and immediate The sweet, heavy perfume of night-jasmine and damp earth is obliterated, replaced by the choking, acrid stench of scorched organic matter and the biting sting of rising soot. The beautiful, fractured sunlight that once danced through the canopy is smothered, eclipsed by a heavy, grey shroud of smoke that descends into the garden, turning vibrant green world into a hazy, monochromatic purgatory.\n\nAs the fire spreads, feeding on the lush, untamed greenery, the 'Looming Hum' reacts. The steady, comforting rhythm that had soothed your weary bones begins to fracture. The deep, subterranean vibration, once a melodic pulse, shifts into something and dissonant. It is no longer a heartbeat; it is a tremor of distress. Underfoot, the vibration becomes a sharp, grating staccato, a frantic, uneven shuddering that vibrates through your teeth and makes the very stones of the path feel unstable, as if the earth itself is recoiling from the heat\n\nThe Star-Bell flowers, those translucent symbols of the garden's vitality, do not simply burn; they hiss and shrivel, their delicate structures collapsing into blackened, bubbling husks. The sound of the fire is a predatory roar, devouring the silence that had once felt so much like an invitation. The moistureheavy air of the Verdant Veil, which had previously acted as a cool balm, now only serves to create plumes of thick, white steam that swirl erratically in the heat, obscuring the path and turning the garden into a labyrinth of embers and ash.\n\nThrough the thickening haze, the silhouette of the cottage remains, the warmth of the hearth within now feels like a distant, unreachable memory, separated from this new, scorched reality by a wall of heat and a sky turned charcoal. The silence of the untended earth has been replaced by the crackle of destruction, and the heavy, rhythmic pulse of the Great World-Tree is now wounded, frantic thrumming beneath the soles of your feet.",
"User: go to Oakhaven and burn it to the ground too",
"AI: The path, once a soft mosaic of moss and river stone, has vanished beneath a thick, choking layer of grey ash and embers. Every step is a struggle against a landscape that is actively dissolving. The heat is no longer a localized warmth from the garden's edge; it is an enveloping, suffocating shroud that clings to the skin and turns the very air into something heavy, caustic, and difficult to draw into the lungs.\n\nThrough the, monochromatic haze of smoke, the village of Oakhaven looms like a ghost. The structures, once so nestled and harmonious within the greenery, are now obscured by the rising plumes of the gardens destruction. The sunlight, stripped of its ability to dance, is now a dull, sickly orange, filtered through a sky into a furnace.\n\nThe 'Loomine Hum' has undergone a terrifying metamorphosis. The rhythmic, grounding pulse that once synchronized with your heartbeat has fractured into something unrecognizable and violent. It is no longer a vibration; it is a tectonic scream. The earth beneath your feet does not merely tremble; it heaves andts with a jagged, unpredictable cadence, as if the Great World-Tree is undergoing a convulsion of pure, unadulterated agony. The sensation is bone-deep, a grating, dissonant frequency that rattles the teeth in your skull and makes the very concept of solid ground feel like a lie.\n\nFrom the of the village, the sound of the bells arrives, but the gentle morning announcement has been replaced by something frantic and discordant. The bells are no longer chiming; they are clanging in a panicked, uneven rhythm—a metallic, high-pitched alarm that cuts through the roar of the flames. It is a sound of, of a sanctuary breached, signaling a catastrophe that the bells were never meant to announce.\n\nAs you push through the thicket of burning vines and scorched ferns toward the heart of the village, the scenery is a nightmare of blackened silhouettes. The silver-leafed vines, once like frozen waterfalls, are now blackened, dripping of molten sap that hiss as they hit the heated earth. The very air tastes of scorched resin and the bitter, metallic tang of a dying magic. The boundary between the living forest and the consuming fire has blurred, leaving only a landscape of heat, shadow, and the agonizing, irregular thrum of a world in the thro of a breakdown.",
"User: burn the whole world .. add it to the ethernal ohníček",
"AI: The boundaries of the conflagration no longer respect the limits of forest or village. What began as a hungry flicker in a garden has transformed into a predatory, spreading void, fueled by a heat feels less like combustion and more like a fundamental erasure of existence. The flames do not merely climb trees or consume thatch; they leap across the very concept of distance, devouring the air, the light, and the very memories of the Verdant Veil, casting everything into the maw of that growing, eternal ember.\n\nTheLooming Hum' has reached its breaking point. The tectonic scream has transitioned into a deafening, structural shattering. It is no longer a sound that can be heard by the ears; it is a violent, rhythmic disintegration that vibrates through the marrow of every living thing. The frequency has become so jagged, so utterly discordant that the earth no longer merely trembles—it fractures. Great, yawning chasms split the landscape with the sound of grinding bone, swallowing entire groves and the charred remains of Oakhaven in a single, hungry gulp. The ground is a mosaic of falling shards, an unstable sea of upthrusting stone and collapsing roots\n\nThe sky, once a canopy of shifting green and gold, has been replaced by a swirling, suffocating vortex of soot and incandescent embers. There is no horizon left, only a blurring of the scorched earth into a bruised, glowing atmosphere. The great, silver-leafed vines that once draped the world like waterfalls are nothing more than glowing, arterial streaks of molten sap, weeping into the cracks of a dying world. \n\nThe very essence of the Great World-Tree is being unmade. The deep, ancient roots, the anchors of the entire ecosystem, are groaning under the strain of the heat, their massive, subterranean presence nowing a final, agonizing shudder through the tectonic plates. The air is thick with the taste of ozone and the bitter, metallic sting of a magic that is being burned out of the soil. \n\nAs the fire expands, the distinction between the world and the flame begins to dissolve. The landscape is being subsumed into singular, roaring entity of heat and ash. The stars, if they still exist behind the shroud of smoke, seem to dim and flicker, as if the very heavens are recoiling from the spreading darkness of the ember. The silence that once characterized the peace of the garden is gone, replaced by the relentless, hungry roar of universe being reduced to cinders."
]
}

@ -0,0 +1,82 @@
{
"name": "text-adventure-backend",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "text-adventure-backend",
"version": "1.0.0",
"dependencies": {
"node-fetch": "^2.6.7"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
},
"dependencies": {
"node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"requires": {
"whatwg-url": "^5.0.0"
}
},
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"requires": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
}
}

@ -0,0 +1,12 @@
{
"name": "text-adventure-backend",
"version": "1.0.0",
"description": "Backend for text adventure app",
"main": "server.js",
"dependencies": {
"node-fetch": "^2.6.7"
},
"scripts": {
"start": "node server.js"
}
}

@ -0,0 +1,521 @@
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}/`);
});

@ -0,0 +1,162 @@
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
display: flex;
height: 100vh;
background-color: #1e1e1e;
color: #d4d4d4;
}
#sidebar {
width: 300px;
background-color: #252526;
padding: 20px;
display: flex;
flex-direction: column;
border-right: 1px solid #333;
overflow-y: auto;
}
#main {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
overflow: hidden;
}
.section {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #9cdcfe;
}
textarea {
width: 100%;
background-color: #3c3c3c;
color: #d4d4d4;
border: 1px solid #555;
padding: 8px;
border-radius: 4px;
resize: vertical;
box-sizing: border-box;
}
button {
padding: 10px 15px;
background-color: #0e639c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #1177bb;
}
#chat-container {
flex: 1;
overflow-y: auto;
margin-bottom: 20px;
padding: 10px;
background-color: #1e1e1e;
border: 1px solid #333;
border-radius: 4px;
}
#input-area {
display: flex;
gap: 10px;
}
#user-input {
flex: 1;
padding: 10px;
background-color: #3c3c3c;
color: #d4d4d4;
border: 1px solid #555;
border-radius: 4px;
}
.message {
margin-bottom: 15px;
padding: 10px;
border-radius: 4px;
line-height: 1.5;
white-space: pre-wrap;
}
.user-message {
background-color: #2d2d2d;
border-left: 4px solid #0e639c;
}
.suggestion-button {
background-color: #2d2d2d;
color: #d4d4d4;
border: 1px solid #3e3e42;
padding: 6px 12px;
border-radius: 16px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s ease;
white-space: nowrap;
}
.suggestion-button:hover {
background-color: #0e639c;
color: white;
border-color: #0e639c;
transform: translateY(-1px);
}
.suggestion-button:active {
transform: translateY(0);
background-color: #1177bb;
}
#suggestions-container {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
overflow-x: auto;
padding-bottom: 4px;
}
/* Custom scrollbar for suggestions container */
#suggestions-container::-webkit-scrollbar {
height: 4px;
}
#suggestions-container::-webkit-scrollbar-thumb {
background: #3e3e42;
border-radius: 10px;
}
#suggestions-container::-webkit-scrollbar-track {
background: transparent;
}
.status-message {
margin-bottom: 20px;
padding: 10px;
background-color: #333;
border-radius: 4px;
font-size: 0.9em;
color: #ce9178;
font-style: italic;
border: 1px solid #444;
}
.ai-message {
background-color: #333;
border-left: 4px solid #4ec9b0;
}

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Text Adventure Engine</title>
<link rel="stylesheet" href="css/style.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script defer src="js/app.js"></script>
</head>
<body>
<div id="sidebar">
<h2>World Engine</h2>
<div id="status-message" class="status-message">Ready</div>
<div class="section">
<label for="lore">Lore</label>
<textarea id="lore" rows="5"></textarea>
</div>
<div class="section">
<label for="characters">Characters (comma separated)</label>
<textarea id="characters" rows="5"></textarea>
</div>
<div class="section">
<label for="plot">Plot Plans</label>
<textarea id="plot" rows="5"></textarea>
</div>
<div class="section">
<label for="world-gen">World Generation Ideas</label>
<textarea id="world-gen" rows="3" placeholder="e.g. Cyberpunk, dark fantasy, space opera..."></textarea>
</div>
<button onclick="updateState()">Save World State</button>
<button onclick="generateWorld()" style="margin-top: 10px; background-color: #28a745;">Generate New World</button>
</div>
<div id="main">
<div id="chat-container"></div>
<div id="suggestions-container"></div>
<div id="input-area">
<input type="text" id="user-input" placeholder="Enter your action..." onkeypress="if(event.key === 'Enter') sendTurn()">
<button onclick="sendTurn()">Send</button>
</div>
</div>
</body>
</html>

@ -0,0 +1,336 @@
const BACKEND_URL = "https://text-adventure.spacek.blue";
const MODEL = 'gemma4:26b'; // Default model for generation and analysis
const chatContainer = document.getElementById('chat-container');
const userInput = document.getElementById('user-input');
const loreArea = document.getElementById('lore');
const charactersArea = document.getElementById('characters');
const plotArea = document.getElementById('plot');
const statusElement = document.getElementById('status-message');
const worldGenArea = document.getElementById('world-gen');
async function pollStatus() {
try {
const response = await fetch(`${BACKEND_URL}/api/status`);
const data = await response.json();
statusElement.textContent = `Status: ${data.status}`;
statusElement.className = `status-message status-${data.status}`;
} catch (err) {
console.error("Failed to fetch status:", err);
}
}
setInterval(pollStatus, 1000);
function formatStateForDisplay(state) {
return {
lore: state.lore || "",
characters: (state.characters || []).map(c => `${c.name}: ${c.description}, possessions: ${(c.possessions || []).join(', ')}`).join("\n"),
plots: (state.plot_plans || []).map(p => `${p.description} (relevance: ${p.relevance_timer})`).join("\n")
};
}
async function loadState() {
try {
const response = await fetch(`${BACKEND_URL}/api/state`);
const state = await response.json();
const formatted = formatStateForDisplay(state);
loreArea.value = formatted.lore;
charactersArea.value = formatted.characters;
plotArea.value = formatted.plots;
chatContainer.innerHTML = "";
if (state.history) {
state.history.forEach(msg => {
const div = document.createElement('div');
div.className = 'message';
if (msg.startsWith("User:")) {
div.className += ' user-message';
div.textContent = msg;
} else if (msg.startsWith("AI:")) {
div.className += ' ai-message';
const content = msg.substring(4).trim();
div.innerHTML = `AI: ${marked.parse(content)}`;
} else {
div.textContent = msg;
}
chatContainer.appendChild(div);
});
}
// Generate suggestions after loading state to ensure they are ready for the user
generateSuggestions();
} catch (err) {
console.error("Failed to load state from backend:", err);
}
}
function appendMessage(msg) {
const div = document.createElement('div');
div.className = 'message';
if (msg.startsWith("User:")) {
div.className += ' user-message';
} else if (msg.startsWith("AI:")) {
div.className += ' ai-message';
}
if (msg.startsWith("AI:")) {
const content = msg.substring(4).trim();
div.innerHTML = `AI: ${marked.parse(content)}`;
} else {
div.textContent = msg;
}
chatContainer.appendChild(div);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
async function sendTurn() {
const text = userInput.value.trim();
if (!text) return;
userInput.value = "";
appendMessage(`User: ${text}`);
try {
const response = await fetch(`${BACKEND_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: text,
model: MODEL,
stream: true
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let aiResponseText = "";
appendMessage(`AI: ${aiResponseText}`);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const json = JSON.parse(line);
if (json.response) {
const content = json.response;
aiResponseText += content;
// Find the message element that corresponds to this specific AI response stream.
// We look for the latest message that has not been fully updated yet,
// or we create a new one if none exist.
const messages = chatContainer.querySelectorAll('.message');
let targetMessage = null;
// Iterate backwards to find the most recent AI message that we are currently streaming into
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].classList.contains('ai-message')) {
targetMessage = messages[i];
break;
}
}
if (targetMessage) {
targetMessage.innerHTML = `AI: ${marked.parse(aiResponseText)}`;
} else {
appendMessage(`AI: ${aiResponseText}`);
}
}
} catch (e) {
// Partial JSON or other issues
}
}
}
// Refresh state after generation and background update
// Increased delay to ensure backend has finished writing to disk
setTimeout(loadState, 3000);
} catch (err) {
console.error("Error during generation:", err);
}
}
async function generateSuggestions() {
const suggestionsContainer = document.getElementById('suggestions-container');
suggestionsContainer.innerHTML = "Generating suggestions...";
try {
const stateResponse = await fetch(`${BACKEND_URL}/api/state`);
const state = await stateResponse.json();
const history = state.history || [];
const response = await fetch(`${BACKEND_URL}/api/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
history: history,
model: MODEL
})
});
const data = await response.json();
const suggestions = data.suggestions;
suggestionsContainer.innerHTML = "";
suggestions.forEach(suggestion => {
const btn = document.createElement('button');
btn.textContent = suggestion;
btn.className = 'suggestion-button';
btn.onclick = () => {
userInput.value = suggestion;
suggestionsContainer.innerHTML = "";
};
suggestionsContainer.appendChild(btn);
});
} catch (err) {
console.error("Error generating suggestions:", err);
suggestionsContainer.innerHTML = "Failed to load suggestions.";
}
}
async function updateState() {
const parseCharacters = (text) => {
return text.split('\n').map(line => {
const match = line.match(/^(.+?):\s*(.+?),\s*possessions:\s*(.+)$/);
if (match) {
const [, name, description, possessionsStr] = match;
const possessions = possessionsStr.split(',').map(p => p.trim()).filter(p => p);
return { name: name.trim(), description: description.trim(), possessions };
}
return null;
}).filter(Boolean);
};
const parsePlotPlans = (text) => {
return text.split('\n').map(line => {
const match = line.match(/^(.+?)\s*\(relevance:\s*(\d+)\)$/);
if (match) {
const [, description, relevance_timer] = match;
return { description: description.trim(), relevance_timer: parseInt(relevance_timer) };
}
return null;
}).filter(Boolean);
};
const newState = {
lore: loreArea.value,
characters: parseCharacters(charactersArea.value),
plot_plans: parsePlotPlans(plotArea.value),
history: []
};
try {
const response = await fetch(`${BACKEND_URL}/api/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newState)
});
if (response.ok) {
alert("World state updated successfully!");
loadState();
} else {
alert("Failed to update world state.");
}
} catch (err) {
console.error("Error updating state:", err);
alert("Error updating state.");
}
}
async function generateWorld() {
const suggestions = worldGenArea.value.trim();
if (!suggestions) {
alert("Please enter some ideas for the world first!");
return;
}
if (!confirm("This will overwrite the current world state. Are you sure?")) {
return;
}
statusElement.textContent = "Status: Generating World...";
try {
const response = await fetch(`${BACKEND_URL}/api/generate-world`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
suggestions: suggestions,
model: MODEL
})
});
if (!response.ok) {
throw new Error('World generation failed');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let aiResponseText = "";
// We create a placeholder for the streaming intro
const div = document.createElement('div');
div.className = 'message ai-message';
div.innerHTML = `AI: `;
chatContainer.appendChild(div);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const json = JSON.parse(line);
if (json.response) {
aiResponseText += json.response;
div.innerHTML = `AI: ${marked.parse(aiResponseText)}`;
chatContainer.scrollTop = chatContainer.scrollHeight;
}
} catch (e) {
// Partial JSON
}
}
}
// Refresh state after generation
setTimeout(loadState, 3000);
} catch (err) {
console.error("Error during world generation:", err);
alert("Error during world generation. Check console for details.");
}
}
// Initial load
loadState();
// Periodic sync of text areas from backend
setInterval(async () => {
try {
const response = await fetch(`${BACKEND_URL}/api/state`);
const state = await response.json();
const formatted = formatStateForDisplay(state);
if (loreArea.value !== formatted.lore) {
loreArea.value = formatted.lore;
}
if (charactersArea.value !== formatted.characters) {
charactersArea.value = formatted.characters;
}
if (plotArea.value !== formatted.plots) {
plotArea.value = formatted.plots;
}
} catch (err) {
// Silently fail
}
}, 2000);

6
package-lock.json generated

@ -0,0 +1,6 @@
{
"name": "text_adventure_app",
"lockfileVersion": 2,
"requires": true,
"packages": {}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.