213 lines
7.9 KiB
JavaScript
213 lines
7.9 KiB
JavaScript
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+/)
|
|
.map(line => cleanText(line))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseObjects(raw, type = "rooms") {
|
|
return raw
|
|
.split(/\n?\d+[).]\s+/)
|
|
.map(entry => cleanText(entry))
|
|
.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. Avoid the words "obsidian" and "clockwork". 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")}
|
|
|
|
Randomly select 3 of the titles from the above list and create 2 additional unique titles. Avoid the words "obsidian" and "clockwork".
|
|
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, randomly select only one of them.
|
|
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. Maximum 4 sentences.`,
|
|
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 short adventure hooks or rumors (mix them naturally).
|
|
Output as a single numbered list, plain text only.
|
|
Maximum 2 sentences per item. No explanations or extra text.`,
|
|
undefined, 5, "Step 5: Hooks & Rumors"
|
|
);
|
|
const hooksRumors = parseList(hooksRumorsRaw, 120);
|
|
console.log("🔹 Hooks & Rumors:", hooksRumors);
|
|
|
|
// --- Step 6: Rooms & Encounters ---
|
|
const roomsEncountersRaw = await callOllama(
|
|
`Using the flavor and these hooks/rumors:
|
|
|
|
Flavor:
|
|
${flavor}
|
|
|
|
Hooks & Rumors:
|
|
${hooksRumors.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(/Treasures?[:\n]*/i, ""), 120);
|
|
const npcs = parseObjects(npcsSection || "", "npcs", 120);
|
|
console.log("🔹 Treasure:", treasure);
|
|
console.log("🔹 NPCs:", npcs);
|
|
|
|
// --- Step 8: Plot Resolutions ---
|
|
const plotResolutionsRaw = await callOllama(
|
|
`Based on the following dungeon flavor and story hooks:
|
|
|
|
Flavor:
|
|
${flavor}
|
|
|
|
Hooks & Rumors:
|
|
${hooksRumors.join("\n")}
|
|
|
|
Major NPCs / Encounters:
|
|
${[...npcs.map(n => n.name), ...encounters.map(e => e.name)].join(", ")}
|
|
|
|
Suggest 3 possible, non-conflicting story climaxes or plot resolutions for adventurers exploring this dungeon.
|
|
These are prompts and ideas for brainstorming the dungeon's ending, not fixed outcomes.
|
|
Start each item with phrases like "The adventurers could..." or "The PCs might..." to emphasize their hypothetical nature.
|
|
Keep each item short (max 2 sentences). Output as a numbered list, plain text only.`,
|
|
undefined, 5, "Step 8: Plot Resolutions"
|
|
);
|
|
const plotResolutions = parseList(plotResolutionsRaw, 180);
|
|
console.log("🔹 Plot Resolutions:", plotResolutions);
|
|
|
|
console.log("\n🎉 Dungeon generation complete!");
|
|
return { title, flavor, map: "map.png", hooksRumors, rooms, encounters, treasure, npcs, plotResolutions };
|
|
}
|