const OLLAMA_API_URL = process.env.OLLAMA_API_URL; const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY; async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function callOllama(prompt, model = "gemma3n:e4b", retries = 6, stepName = "unknown") { for (let attempt = 1; attempt <= retries; attempt++) { try { console.log(`\nšŸ“¤ [${stepName}] Sending prompt (attempt ${attempt}/${retries})...`); console.log(` Prompt length: ${prompt.length} chars, ~${prompt.split(/\s+/).length} words`); const response = await fetch(OLLAMA_API_URL, { method: "POST", headers: { "Authorization": `Bearer ${OLLAMA_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model, messages: [{ role: "user", content: prompt }], }), }); if (!response.ok) { throw new Error(`Ollama API request failed: ${response.status} ${response.statusText}`); } const data = await response.json(); const text = data.choices?.[0]?.message?.content; if (!text) throw new Error("No response from Ollama"); console.log(`āœ… [${stepName}] Success — received ${text.length} chars`); return text.trim(); } catch (err) { console.warn(`āš ļø [${stepName}] Failed (attempt ${attempt}/${retries}): ${err.message}`); if (attempt === retries) throw err; const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000; console.log(` Retrying in ${Math.round(delay / 1000)}s...`); await sleep(delay); } } } function parseList(raw) { return raw .split("\n") .map(line => line.replace(/^\d+[\).\s-]*/, "").trim()) .filter(line => line.length > 0); } function parseObjects(raw, type = "rooms") { return raw .split("\n") .map(line => line.replace(/^\d+[\).\s-]*/, "").trim()) .filter(line => line.length > 0) .map(entry => { if (type === "rooms") { const [name, ...descParts] = entry.split(/[-–—:]/); return { name: name.trim(), description: descParts.join(" ").trim() }; } if (type === "encounters") { const [name, ...detailParts] = entry.split(/[-–—:]/); return { name: name.trim(), details: detailParts.join(" ").trim() }; } if (type === "npcs") { const [name, ...traitParts] = entry.split(/[-–—:]/); return { name: name.trim(), trait: traitParts.join(" ").trim() }; } return entry; }); } export async function generateDungeon() { console.log("šŸ—ļø Starting staged dungeon generation...\n"); // Step 1: Generate 10 titles const titlesRaw = await callOllama( "Generate 10 unique creative dungeon titles. Output as a numbered list only.", "gemma3n:e4b", 6, "Step 1: Titles (10)" ); const titles10 = parseList(titlesRaw); // Step 2: Pick top 3, add 2 new const titles3plusRaw = await callOllama( `Here are 10 dungeon titles:\n${titles10.join("\n")}\n\nSelect the 3 strongest titles and add 2 new original ones. Output 5 titles as a numbered list.`, "gemma3n:e4b", 6, "Step 2: Narrow (5)" ); const titles5 = parseList(titles3plusRaw); // Step 3: Pick single best const bestTitleRaw = await callOllama( `Here are 5 dungeon titles:\n${titles5.join("\n")}\n\nPick the single strongest title. Output only the title.`, "gemma3n:e4b", 6, "Step 3: Final Title" ); const title = bestTitleRaw.split("\n")[0].trim(); // Step 4: Flavor text const flavor = await callOllama( `Write one evocative paragraph of flavor text for a dungeon titled "${title}". Do not include hooks, NPCs, or treasure.`, "gemma3n:e4b", 6, "Step 4: Flavor" ); // Step 5: Hooks & rumors const hooksRumorsRaw = await callOllama( `Based only on this dungeon flavor:\n${flavor}\n\nGenerate 3 adventure hooks and 3 rumors. Output as two numbered lists: first "Hooks", then "Rumors".`, "gemma3n:e4b", 6, "Step 5: Hooks & Rumors" ); const [hooksSection, rumorsSection] = hooksRumorsRaw.split(/Rumors[:\n]/i); const hooks = parseList(hooksSection.replace(/Hooks[:\n]*/i, "")); const rumors = parseList(rumorsSection || ""); // Step 6: Rooms & encounters const roomsEncountersRaw = await callOllama( `Based on this flavor:\n${flavor}\n\nAnd these hooks and rumors:\n${hooks.join("\n")}\n${rumors.join("\n")}\n\nGenerate:\n- 5 rooms with names and short descriptions\n- 3 encounters with names and details.\nOutput as two numbered lists labeled "Rooms" and "Encounters".`, "gemma3n:e4b", 6, "Step 6: Rooms & Encounters" ); const [roomsSection, encountersSection] = roomsEncountersRaw.split(/Encounters[:\n]/i); const rooms = parseObjects(roomsSection.replace(/Rooms[:\n]*/i, ""), "rooms"); const encounters = parseObjects(encountersSection || "", "encounters"); // Step 7: Treasure & NPCs const treasureNpcsRaw = await callOllama( `Based only on these rooms and encounters:\n${JSON.stringify({ rooms, encounters }, null, 2)}\n\nGenerate:\n- 3 treasures (list)\n- 3 NPCs (name + trait).\nOutput as two numbered lists labeled "Treasure" and "NPCs".`, "gemma3n:e4b", 6, "Step 7: Treasure & NPCs" ); const [treasureSection, npcsSection] = treasureNpcsRaw.split(/NPCs[:\n]/i); const treasure = parseList(treasureSection.replace(/Treasure[:\n]*/i, "")); const npcs = parseObjects(npcsSection || "", "npcs"); // Step 8: Assemble JSON in code const dungeon = { title, flavor: flavor.trim(), map: "map.png", hooks, rumors, rooms, encounters, treasure, npcs }; console.log("\nšŸŽ‰ Dungeon generation complete!"); return dungeon; }