This commit is contained in:
@@ -5,9 +5,12 @@ async function sleep(ms) {
|
|||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callOllama(prompt, model = "gemma3n:e4b", retries = 6) {
|
async function callOllama(prompt, model = "gemma3n:e4b", retries = 6, stepName = "unknown") {
|
||||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
try {
|
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, {
|
const response = await fetch(OLLAMA_API_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -27,103 +30,133 @@ async function callOllama(prompt, model = "gemma3n:e4b", retries = 6) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const text = data.choices?.[0]?.message?.content;
|
const text = data.choices?.[0]?.message?.content;
|
||||||
if (!text) throw new Error("No response from Ollama");
|
if (!text) throw new Error("No response from Ollama");
|
||||||
return text;
|
|
||||||
|
console.log(`✅ [${stepName}] Success — received ${text.length} chars`);
|
||||||
|
return text.trim();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`⚠️ Ollama call failed (attempt ${attempt}/${retries}): ${err.message}`);
|
console.warn(`⚠️ [${stepName}] Failed (attempt ${attempt}/${retries}): ${err.message}`);
|
||||||
if (attempt === retries) throw err;
|
if (attempt === retries) throw err;
|
||||||
|
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
|
||||||
// Exponential backoff with jitter
|
console.log(` Retrying in ${Math.round(delay / 1000)}s...`);
|
||||||
const delay = Math.pow(2, attempt) * 1000; // 2^attempt seconds
|
await sleep(delay);
|
||||||
const jitter = Math.random() * 1000; // up to 1 second extra
|
|
||||||
await sleep(delay + jitter);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseList(raw) {
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.map(line => line.replace(/^\d+[\).\s-]*/, "").trim())
|
||||||
|
.filter(line => line.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
function parseObjects(raw, type = "rooms") {
|
||||||
* Three-pass dungeon generation with full resiliency and JSON retry
|
return raw
|
||||||
*/
|
.split("\n")
|
||||||
export async function generateDungeon() {
|
.map(line => line.replace(/^\d+[\).\s-]*/, "").trim())
|
||||||
console.log("🏗️ Starting dungeon generation...");
|
.filter(line => line.length > 0)
|
||||||
|
.map(entry => {
|
||||||
let draft, refined, jsonText;
|
if (type === "rooms") {
|
||||||
|
const [name, ...descParts] = entry.split(/[-–—:]/);
|
||||||
// Draft Pass
|
return { name: name.trim(), description: descParts.join(" ").trim() };
|
||||||
try {
|
|
||||||
console.log("📝 Draft pass...");
|
|
||||||
const draftPrompt = `
|
|
||||||
Generate a Dungeons & Dragons one-page dungeon concept.
|
|
||||||
Include a title, flavor text, hooks, rumors, rooms, encounters, treasure, and NPCs.
|
|
||||||
Output in readable text (not JSON). Focus on interesting ideas and adventure hooks.
|
|
||||||
`;
|
|
||||||
draft = await callOllama(draftPrompt);
|
|
||||||
console.log("✅ Draft pass complete.");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("❌ Draft pass failed:", err);
|
|
||||||
throw new Error("Dungeon generation failed at draft step");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refine Pass
|
|
||||||
try {
|
|
||||||
console.log("🔧 Refine pass...");
|
|
||||||
const refinePrompt = `
|
|
||||||
Here is a draft dungeon description:
|
|
||||||
|
|
||||||
${draft}
|
|
||||||
|
|
||||||
Please carefully proofread this dungeon and improve it for professionalism and clarity:
|
|
||||||
- Fix any spelling, grammar, or phrasing issues
|
|
||||||
- Flesh out any vague or unclear descriptions
|
|
||||||
- Add richer flavor text to rooms, encounters, and NPCs
|
|
||||||
- Ensure all hooks, rumors, and treasures are compelling and well-explained
|
|
||||||
- Make the dungeon read as a polished, professional one-page adventure
|
|
||||||
|
|
||||||
Keep the output as readable text format, not JSON.
|
|
||||||
`;
|
|
||||||
refined = await callOllama(refinePrompt);
|
|
||||||
console.log("✅ Refine pass complete.");
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("⚠️ Refine pass failed, using draft as fallback:", err.message);
|
|
||||||
refined = draft;
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON Pass with retries
|
|
||||||
const jsonPrompt = `
|
|
||||||
Convert the following improved dungeon description into strictly valid JSON.
|
|
||||||
Use the following fields:
|
|
||||||
- title
|
|
||||||
- flavor
|
|
||||||
- map (just a placeholder string like "map.png")
|
|
||||||
- hooks (array of strings)
|
|
||||||
- rumors (array of strings)
|
|
||||||
- rooms (array of objects with "name" and "description")
|
|
||||||
- encounters (array of objects with "name" and "details")
|
|
||||||
- treasure (array of strings)
|
|
||||||
- npcs (array of objects with "name" and "trait")
|
|
||||||
Do not include any commentary, only output valid JSON.
|
|
||||||
|
|
||||||
Dungeon description:
|
|
||||||
${refined}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const maxJsonRetries = 5;
|
|
||||||
for (let attempt = 1; attempt <= maxJsonRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
console.log(`📦 JSON pass (attempt ${attempt}/${maxJsonRetries})...`);
|
|
||||||
jsonText = await callOllama(jsonPrompt, "gemma3n:e4b", 6);
|
|
||||||
const cleaned = jsonText.replace(/```json|```/g, "").trim();
|
|
||||||
const result = JSON.parse(cleaned);
|
|
||||||
console.log("🎉 Dungeon generation complete!");
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`⚠️ JSON parse failed (attempt ${attempt}/${maxJsonRetries}): ${err.message}`);
|
|
||||||
if (attempt === maxJsonRetries) {
|
|
||||||
console.error("❌ JSON pass ultimately failed. Raw output:", jsonText);
|
|
||||||
throw new Error("Dungeon generation failed at JSON step");
|
|
||||||
}
|
}
|
||||||
await sleep(1000 * attempt);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user