completely rework generation to make it more flavorful and fast.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
when:
|
||||
- event: cron
|
||||
branch: main
|
||||
cron: "0 2 * * *"
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Scrollsmith
|
||||
|
||||
[](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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${data.title}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Uncial+Antiqua&family=EB+Garamond&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 0; /* remove outer white margins */
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 1cm;
|
||||
background: #d6c5a3; /* light parchment-like color, no image */
|
||||
font-family: 'EB Garamond', serif;
|
||||
color: #2b2118;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Uncial Antiqua', cursive;
|
||||
text-align: center;
|
||||
font-size: 2.6em;
|
||||
margin: 0.2em 0 0.3em;
|
||||
color: #3e1f0e;
|
||||
border-bottom: 2px solid #3e1f0e;
|
||||
padding-bottom: 0.2em;
|
||||
}
|
||||
.flavor {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
margin: 0.5em 0 1em;
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 1cm;
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: 'Uncial Antiqua', cursive;
|
||||
font-size: 1.3em;
|
||||
margin: 0.5em 0 0.3em;
|
||||
color: #3e1f0e;
|
||||
border-bottom: 1px solid #3e1f0e;
|
||||
}
|
||||
|
||||
.map img {
|
||||
max-width: 100%;
|
||||
border: 3px solid #3e1f0e;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.room h3 {
|
||||
margin: 0.3em 0 0.1em;
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9em;
|
||||
margin: 0.5em 0;
|
||||
background: rgba(255, 255, 255, 0.85); /* slight overlay for readability */
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #3e1f0e;
|
||||
padding: 0.3em;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: #d9c6a5;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0.3em 0 0.6em 1em;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
color: #5a4632;
|
||||
margin-top: 0.5em;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<title>${data.title}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville&family=Lora&family=Cinzel+Decorative&family=MedievalSharp&family=Alegreya+Sans+SC&family=Alegreya+Sans&family=Cabin&family=Walter+Turncoat&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 0; }
|
||||
body {
|
||||
margin: 0; padding: 1cm;
|
||||
background: #d6c5a3;
|
||||
font-family: ${bodyFont};
|
||||
color: #2b2118;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.3em;
|
||||
}
|
||||
h1 {
|
||||
font-family: ${headingFont};
|
||||
text-align: center;
|
||||
font-size: 2em;
|
||||
margin: 0.2em 0 0.3em;
|
||||
color: #3e1f0e;
|
||||
border-bottom: 2px solid #3e1f0e;
|
||||
padding-bottom: 0.2em;
|
||||
}
|
||||
.flavor {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
font-family: ${quoteFont};
|
||||
margin: 0.4em 0 1em;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.8cm;
|
||||
align-items: start;
|
||||
}
|
||||
.col {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 0.4em;
|
||||
}
|
||||
h2 {
|
||||
font-family: ${headingFont};
|
||||
font-size: 1em;
|
||||
margin: 0.3em 0 0.1em;
|
||||
color: #3e1f0e;
|
||||
border-bottom: 1px solid #3e1f0e;
|
||||
padding-bottom: 0.1em;
|
||||
}
|
||||
.map img { max-width: 100%; border: 2px solid #3e1f0e; border-radius: 0.2cm; }
|
||||
.room h3 { margin: 0.2em 0 0.05em; font-size: 0.95em; font-weight: bold; }
|
||||
.room p { text-align: justify; word-wrap: break-word; margin: 0.1em 0 0.3em; }
|
||||
ul { padding-left: 1em; margin: 0.1em 0 0.4em; }
|
||||
li { margin-bottom: 0.2em; }
|
||||
table { width: 100%; border-collapse: collapse; font-family: ${tableFont}; font-size: 0.8em; }
|
||||
th, td { border: 1px solid #3e1f0e; padding: 0.2em; text-align: left; vertical-align: top; }
|
||||
th { background: #d9c6a5; }
|
||||
table tr:hover { background: rgba(62,31,14,0.05); }
|
||||
footer {
|
||||
text-align: center; font-size: 0.7em; color: #5a4632; margin-top: 0.5em; font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${data.title}</h1>
|
||||
<p class="flavor">${data.flavor}</p>
|
||||
<h1>${data.title}</h1>
|
||||
<p class="flavor">${data.flavor}</p>
|
||||
|
||||
<div class="columns">
|
||||
<!-- Column 1 -->
|
||||
<div class="col">
|
||||
<h2>Map</h2>
|
||||
<div class="map">
|
||||
<img src="file://${data.map}" alt="Dungeon Map">
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="col">
|
||||
<h2>Map</h2>
|
||||
<div class="map"><img src="file://${data.map}" alt="Dungeon Map"></div>
|
||||
|
||||
<h2>Adventure Hooks</h2>
|
||||
<ul>
|
||||
${data.hooks.map(h => `<li>${h}</li>`).join("")}
|
||||
</ul>
|
||||
<h2>Adventure Hooks</h2>
|
||||
<ul>${data.hooks.map(h => `<li>${h}</li>`).join("")}</ul>
|
||||
|
||||
<h2>Rumors</h2>
|
||||
<ul>
|
||||
${data.rumors.map(r => `<li>${r}</li>`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Column 2 -->
|
||||
<div class="col">
|
||||
<h2>Keyed Rooms</h2>
|
||||
${data.rooms.map((room, i) => `
|
||||
<div class="room">
|
||||
<h3>${i+1}. ${room.name}</h3>
|
||||
<p>${room.description}</p>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
|
||||
<!-- Column 3 -->
|
||||
<div class="col">
|
||||
<h2>Encounters</h2>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Details</th></tr>
|
||||
${data.encounters.map(e => `
|
||||
<tr>
|
||||
<td>${e.name}</td>
|
||||
<td>${e.details}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</table>
|
||||
|
||||
<h2>Treasure</h2>
|
||||
<ul>
|
||||
${data.treasure.map(t => `<li>${t}</li>`).join("")}
|
||||
</ul>
|
||||
|
||||
<h2>NPCs</h2>
|
||||
<ul>
|
||||
${data.npcs.map(n => `<li><b>${n.name}</b>: ${n.trait}</li>`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
<h2>Rumors</h2>
|
||||
<ul>${data.rumors.map(r => `<li>${r}</li>`).join("")}</ul>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Generated with DungeonBuilder • © ${new Date().getFullYear()}
|
||||
</footer>
|
||||
<div class="col">
|
||||
<h2>Keyed Rooms</h2>
|
||||
${data.rooms.map((room, i) => `<div class="room"><h3>${i + 1}. ${room.name}</h3><p>${room.description}</p></div>`).join("")}
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h2>Encounters</h2>
|
||||
<table><tr><th>Name</th><th>Details</th></tr>
|
||||
${data.encounters.map(e => `<tr><td>${e.name}</td><td>${e.details}</td></tr>`).join("")}
|
||||
</table>
|
||||
|
||||
<h2>Treasure</h2>
|
||||
<ul>${data.treasure.map(t => `<li>${t}</li>`).join("")}</ul>
|
||||
|
||||
<h2>NPCs</h2>
|
||||
<ul>${data.npcs.map(n => `<li><b>${n.name}</b>: ${n.trait}</li>`).join("")}</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>Generated with Scrollsmith • © ${new Date().getFullYear()}</footer>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user