completely rework generation to make it more flavorful and fast.
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/cron/ci Pipeline failed

This commit is contained in:
2025-08-30 23:03:15 -04:00
parent 102710947b
commit fc4589384d
4 changed files with 271 additions and 242 deletions

View File

@@ -5,11 +5,24 @@ async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function callOllama(prompt, model = "gemma3n:e4b", retries = 6, stepName = "unknown") {
// --- 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 {
console.log(`\n📤 [${stepName}] Sending prompt (attempt ${attempt}/${retries})...`);
console.log(` Prompt length: ${prompt.length} chars, ~${prompt.split(/\s+/).length} words`);
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",
@@ -23,21 +36,24 @@ async function callOllama(prompt, model = "gemma3n:e4b", retries = 6, stepName =
}),
});
if (!response.ok) {
throw new Error(`Ollama API request failed: ${response.status} ${response.statusText}`);
}
if (!response.ok) throw new Error(`Ollama 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");
const rawText = data.choices?.[0]?.message?.content;
if (!rawText) throw new Error("No response from Ollama");
console.log(`✅ [${stepName}] Success — received ${text.length} chars`);
return text.trim();
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}] Failed (attempt ${attempt}/${retries}): ${err.message}`);
console.warn(`⚠️ [${stepName}] Attempt ${attempt} failed: ${err.message}`);
if (attempt === retries) throw err;
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
const delay = 1000 * Math.pow(2, attempt) + Math.random() * 500;
console.log(` Retrying in ${Math.round(delay / 1000)}s...`);
await sleep(delay);
}
@@ -46,117 +62,146 @@ async function callOllama(prompt, model = "gemma3n:e4b", retries = 6, stepName =
function parseList(raw) {
return raw
.split("\n")
.map(line => line.replace(/^\d+[).\s-]*/, "").trim())
.filter(line => line.length > 0);
.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 => line.replace(/^\d+[).\s-]*/, "").trim())
.filter(line => line.length > 0)
.map(line => cleanText(line.replace(/^\d+[).\s-]*/, "")))
.filter(Boolean)
.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() };
}
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 staged dungeon generation...\n");
console.log("🏗️ Starting compact dungeon generation with debug logs...\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)"
// --- 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(titlesRaw);
const titles10 = parseList(titles10Raw, 30);
console.log("🔹 Parsed titles10:", titles10);
// 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)"
// --- 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(titles3plusRaw);
const titles5 = parseList(titles5Raw, 30);
console.log("🔹 Parsed titles5:", titles5);
// Step 3: Pick single best
// --- Step 3: Final title ---
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();
`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:
// 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"
${titles5.join("\n")}`,
undefined, 5, "Step 3: Final Title"
);
const title = cleanText(bestTitleRaw.split("\n")[0]);
console.log("🔹 Selected title:", title);
// Step 5: Hooks & rumors
// --- 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:\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"
`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, ""));
const rumors = parseList(rumorsSection || "");
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
// --- 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"
`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");
const encounters = parseObjects(encountersSection || "", "encounters");
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
// --- 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"
`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, ""));
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
};
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 dungeon;
return { title, flavor, map: "map.png", hooks, rumors, rooms, encounters, treasure, npcs };
}