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)); } // --- Utility: strip markdown artifacts --- function cleanText(str) { return str .replace(/^#+\s*/gm, "") // remove headers .replace(/\*\*(.*?)\*\*/g, "$1") // remove bold .replace(/[*_`]/g, "") // remove stray formatting .replace(/\s+/g, " ") // normalize whitespace .trim(); } async function callOllama(prompt, model = "gemma3n:e4b", retries = 5, stepName = "unknown") { for (let attempt = 1; attempt <= retries; attempt++) { try { const promptCharCount = prompt.length; const promptWordCount = prompt.split(/\s+/).length; console.log(`\n๐Ÿ“ค [${stepName}] Sending prompt (attempt ${attempt}/${retries})`); console.log(` Prompt: ${promptCharCount} chars, ~${promptWordCount} 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 request failed: ${response.status} ${response.statusText}`); const data = await response.json(); const rawText = data.choices?.[0]?.message?.content; if (!rawText) throw new Error("No response from Ollama"); const cleaned = cleanText(rawText); console.log(`โœ… [${stepName}] Received: ${rawText.length} chars, ~${rawText.split(/\s+/).length} words`); console.log(`--- Raw output ---\n${rawText}\n`); console.log(`--- Cleaned output ---\n${cleaned}\n`); return cleaned; } catch (err) { console.warn(`โš ๏ธ [${stepName}] Attempt ${attempt} failed: ${err.message}`); if (attempt === retries) throw err; const delay = 1000 * Math.pow(2, attempt) + Math.random() * 500; console.log(` Retrying in ${Math.round(delay / 1000)}s...`); await sleep(delay); } } } function parseList(raw) { return raw .split(/\n|(?=\d+[).]\s)/g) .map(line => cleanText(line.replace(/^\d+[).\s-]*/, ""))) .filter(Boolean); } function parseObjects(raw, type = "rooms") { return raw .split("\n") .map(line => cleanText(line.replace(/^\d+[).\s-]*/, ""))) .filter(Boolean) .map(entry => { const [name, ...descParts] = entry.split(/[-โ€“โ€”:]/); const desc = descParts.join(" ").trim(); if (type === "rooms") return { name: name.trim(), description: desc }; if (type === "encounters") return { name: name.trim(), details: desc }; if (type === "npcs") return { name: name.trim(), trait: desc }; return entry; }); } export async function generateDungeon() { console.log("๐Ÿ—๏ธ Starting compact dungeon generation with debug logs...\n"); // --- Step 1: Titles --- const titles10Raw = await callOllama( `Generate 10 short, punchy dungeon titles (max 5 words each), numbered as a plain text list. Each title should come from a different style or theme. Make the set varied and evocative. For example: - OSR / classic tabletop: gritty, mysterious, old-school - Mรถrk Borg: dark, apocalyptic, foreboding - Pulpy fantasy: adventurous, dramatic, larger-than-life - Mildly sci-fi: alien, technological, strange - Weird fantasy: uncanny, surreal, unsettling - Whimsical: fun, quirky, playful Avoid repeating materials or adjectives. Do not include explanations, markdown, or preambles. Only the 10 numbered titles.`, undefined, 5, "Step 1: Titles" ); const titles10 = parseList(titles10Raw, 30); console.log("๐Ÿ”น Parsed titles10:", titles10); // --- Step 2: Narrow to 5 --- const titles5Raw = await callOllama( `Here are 10 dungeon titles: ${titles10.join("\n")} Select the 3 most interesting titles from the above list and create 2 additional unique titles. Output exactly 5 titles as a numbered list, plain text only. No explanations.`, undefined, 5, "Step 2: Narrow Titles" ); const titles5 = parseList(titles5Raw, 30); console.log("๐Ÿ”น Parsed titles5:", titles5); // --- Step 3: Final title --- const bestTitleRaw = await callOllama( `From the following 5 dungeon titles, select the one that sounds the most fun to play. Output only the title, no explanation, no numbering, no extra text: ${titles5.join("\n")}`, undefined, 5, "Step 3: Final Title" ); const title = cleanText(bestTitleRaw.split("\n")[0]); console.log("๐Ÿ”น Selected title:", title); // --- Step 4: Flavor text --- const flavorRaw = await callOllama( `Write a single evocative paragraph describing the dungeon titled "${title}". Do not include hooks, NPCs, treasure, or instructions. Output plain text only, one paragraph.`, undefined, 5, "Step 4: Flavor" ); const flavor = flavorRaw; console.log("๐Ÿ”น Flavor text:", flavor); // --- Step 5: Hooks & Rumors --- const hooksRumorsRaw = await callOllama( `Based only on this dungeon flavor: ${flavor} Generate 3 adventure hooks (one sentence each) and 3 rumors (one sentence each). Output numbered lists only, plain text. Maximum 120 characters per item. No explanations or extra text. Format as: Hooks: 1. ... 2. ... 3. ... Rumors: 1. ... 2. ... 3. ...` , undefined, 5, "Step 5: Hooks & Rumors" ); const [hooksSection, rumorsSection] = hooksRumorsRaw.split(/Rumors[:\n]/i); const hooks = parseList(hooksSection.replace(/Hooks[:\n]*/i, ""), 120); const rumors = parseList(rumorsSection || "", 120); console.log("๐Ÿ”น Hooks:", hooks); console.log("๐Ÿ”น Rumors:", rumors); // --- Step 6: Rooms & Encounters --- const roomsEncountersRaw = await callOllama( `Using the flavor, hooks, and rumors: Flavor: ${flavor} Hooks: ${hooks.join("\n")} Rumors: ${rumors.join("\n")} Generate 5 rooms (name + short description) and 3 encounters (name + details). Output two numbered lists, labeled "Rooms:" and "Encounters:". Plain text only. No extra explanation.`, undefined, 5, "Step 6: Rooms & Encounters" ); const [roomsSection, encountersSection] = roomsEncountersRaw.split(/Encounters[:\n]/i); const rooms = parseObjects(roomsSection.replace(/Rooms[:\n]*/i, ""), "rooms", 120); const encounters = parseObjects(encountersSection || "", "encounters", 120); console.log("๐Ÿ”น Rooms:", rooms); console.log("๐Ÿ”น Encounters:", encounters); // --- Step 7: Treasure & NPCs --- const treasureNpcsRaw = await callOllama( `Based only on these rooms and encounters: ${JSON.stringify({ rooms, encounters }, null, 2)} Generate 3 treasures and 3 NPCs (name + trait, max 2 sentences each). Output numbered lists labeled "Treasure:" and "NPCs:". Plain text only, no extra text.`, undefined, 5, "Step 7: Treasure & NPCs" ); const [treasureSection, npcsSection] = treasureNpcsRaw.split(/NPCs[:\n]/i); const treasure = parseList(treasureSection.replace(/Treasure[:\n]*/i, ""), 120); const npcs = parseObjects(npcsSection || "", "npcs", 120); console.log("๐Ÿ”น Treasure:", treasure); console.log("๐Ÿ”น NPCs:", npcs); console.log("\n๐ŸŽ‰ Dungeon generation complete!"); return { title, flavor, map: "map.png", hooks, rumors, rooms, encounters, treasure, npcs }; }