diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 778e8f5..bd9d8f2 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -1,7 +1,6 @@ when: - event: cron branch: main - cron: "0 2 * * *" - event: pull_request - event: push branch: main diff --git a/README.md b/README.md index 39ffc5b..04c3b6b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Scrollsmith +[![status-badge](https://ci.keligrubb.com/api/badges/2/status.svg)](https://ci.keligrubb.com/repos/2) + Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon PDFs automatically. It uses an Ollama LLM server to create dungeon content, proofreads and refines it, then formats it into a structured PDF with maps, rooms, encounters, treasure, and NPCs. --- diff --git a/dungeonGenerator.js b/dungeonGenerator.js index b3cd184..ec0083a 100644 --- a/dungeonGenerator.js +++ b/dungeonGenerator.js @@ -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 }; } diff --git a/dungeonTemplate.js b/dungeonTemplate.js index 36609cf..095365a 100644 --- a/dungeonTemplate.js +++ b/dungeonTemplate.js @@ -1,164 +1,147 @@ +function pickRandom(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + export function dungeonTemplate(data) { + const bodyFonts = [ + "'Libre Baskerville', serif", + "'Cardo', serif", + "'Merriweather', serif", + "'Fraunces', serif", + "'Source Serif 4', serif", + "'Lora', serif" + ]; + + const headingFonts = [ + "'Cinzel Decorative', cursive", + "'MedievalSharp', cursive", + "'Metamorphous', cursive", + "'Playfair Display', serif", + "'Alegreya Sans SC', sans-serif" + ]; + + const tableFonts = [ + "'Alegreya Sans', sans-serif", + "'Cabin', sans-serif", + "'IBM Plex Sans', sans-serif", + "'Cormorant Garamond', serif", + "'Special Elite', monospace" + ]; + + const quoteFonts = [ + "'Walter Turncoat', cursive", + "'Uncial Antiqua', serif", + "'Beth Ellen', cursive", + "'Pinyon Script', cursive", + "'Dela Gothic One', sans-serif" + ]; + + const bodyFont = pickRandom(bodyFonts); + const headingFont = pickRandom(headingFonts); + const tableFont = pickRandom(tableFonts); + const quoteFont = pickRandom(quoteFonts); + return ` - - ${data.title} - - + +${data.title} + + -

${data.title}

-

${data.flavor}

+

${data.title}

+

${data.flavor}

-
- -
-

Map

-
- Dungeon Map -
+
+
+

Map

+
Dungeon Map
-

Adventure Hooks

-
    - ${data.hooks.map(h => `
  • ${h}
  • `).join("")} -
+

Adventure Hooks

+
    ${data.hooks.map(h => `
  • ${h}
  • `).join("")}
-

Rumors

-
    - ${data.rumors.map(r => `
  • ${r}
  • `).join("")} -
-
- - -
-

Keyed Rooms

- ${data.rooms.map((room, i) => ` -
-

${i+1}. ${room.name}

-

${room.description}

-
- `).join("")} -
- - -
-

Encounters

- - - ${data.encounters.map(e => ` - - - - - `).join("")} -
NameDetails
${e.name}${e.details}
- -

Treasure

-
    - ${data.treasure.map(t => `
  • ${t}
  • `).join("")} -
- -

NPCs

-
    - ${data.npcs.map(n => `
  • ${n.name}: ${n.trait}
  • `).join("")} -
-
+

Rumors

+
    ${data.rumors.map(r => `
  • ${r}
  • `).join("")}
-
- Generated with DungeonBuilder β€’ Β© ${new Date().getFullYear()} -
+
+

Keyed Rooms

+ ${data.rooms.map((room, i) => `

${i + 1}. ${room.name}

${room.description}

`).join("")} +
+ +
+

Encounters

+ + ${data.encounters.map(e => ``).join("")} +
NameDetails
${e.name}${e.details}
+ +

Treasure

+
    ${data.treasure.map(t => `
  • ${t}
  • `).join("")}
+ +

NPCs

+
    ${data.npcs.map(n => `
  • ${n.name}: ${n.trait}
  • `).join("")}
+
+
+ + `;