This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*.pdf
|
||||||
|
.env
|
||||||
|
node_modules/**
|
||||||
48
.woodpecker/ci.yml
Normal file
48
.woodpecker/ci.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
when:
|
||||||
|
- event: cron
|
||||||
|
branch: main
|
||||||
|
cron: "0 2 * * *"
|
||||||
|
- event: pull_request
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: deps
|
||||||
|
image: node:22
|
||||||
|
commands:
|
||||||
|
- npm ci
|
||||||
|
caches:
|
||||||
|
- node_modules
|
||||||
|
|
||||||
|
- name: lint
|
||||||
|
image: node:22
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- pull_request
|
||||||
|
- push
|
||||||
|
commands:
|
||||||
|
- npm run lint
|
||||||
|
caches:
|
||||||
|
- node_modules
|
||||||
|
|
||||||
|
- name: generate-dungeon
|
||||||
|
image: node:22
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- cron
|
||||||
|
commands:
|
||||||
|
- npm start
|
||||||
|
caches:
|
||||||
|
- node_modules
|
||||||
|
|
||||||
|
- name: upload-nextcloud
|
||||||
|
image: curlimages/curl:latest
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- cron
|
||||||
|
environment:
|
||||||
|
NEXTCLOUD_URL: ${NEXTCLOUD_URL}
|
||||||
|
NEXTCLOUD_TOKEN: ${NEXTCLOUD_TOKEN}
|
||||||
|
commands:
|
||||||
|
- echo "not uploading yet..."
|
||||||
|
# - curl -X PUT -H "Authorization: Bearer $NEXTCLOUD_TOKEN" -H "Content-Type: application/pdf" --upload-file tomb-of-shadows.pdf "$NEXTCLOUD_URL/tomb-of-shadows-$(date +%F).pdf"
|
||||||
78
README.md
Normal file
78
README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Scrollsmith
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Three-pass dungeon generation**:
|
||||||
|
1. Draft: initial dungeon ideas
|
||||||
|
2. Refine: proofread, add flavor, fill in vague details
|
||||||
|
3. JSON conversion: output strictly valid JSON for PDF generation
|
||||||
|
- Automatically generates a PDF named after the dungeon title
|
||||||
|
- PDF layout includes three columns: map & hooks, rooms, encounters & treasure & NPCs
|
||||||
|
- Easy to integrate with a local Ollama server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- Ollama server running and accessible
|
||||||
|
- Nextcloud (optional) for PDF uploads
|
||||||
|
- `.env` file with:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OLLAMA_API_URL=http://localhost:3000/api/chat/completions
|
||||||
|
OLLAMA_API_KEY=your_api_key_here
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/scrollsmith.git
|
||||||
|
cd scrollsmith
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Make sure your Ollama server is running and `.env` is configured.
|
||||||
|
2. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
3. A PDF will be generated automatically. The filename matches the dungeon title.
|
||||||
|
|
||||||
|
Optional: update the map path in `index.js` if you have a local dungeon map.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
* `the-tomb-of-shadows.pdf`
|
||||||
|
* Three-column layout:
|
||||||
|
|
||||||
|
* Column 1: Map, Adventure Hooks, Rumors
|
||||||
|
* Column 2: Keyed Rooms
|
||||||
|
* Column 3: Encounters, Treasure, NPCs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
* Make sure your Ollama server is accessible and API key is valid.
|
||||||
|
* Dungeon generation can take a few minutes depending on your LLM response time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
PROPRIETARY
|
||||||
BIN
assets/parchment.jpg
Normal file
BIN
assets/parchment.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
126
dungeonGenerator.js
Normal file
126
dungeonGenerator.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callOllama(prompt, model = "gemma3:4b", retries = 3) {
|
||||||
|
let attempt = 0;
|
||||||
|
while (attempt < retries) {
|
||||||
|
try {
|
||||||
|
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 API 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");
|
||||||
|
return text;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
attempt++;
|
||||||
|
console.warn(`⚠️ Ollama call failed (attempt ${attempt}/${retries}): ${err.message}`);
|
||||||
|
if (attempt >= retries) throw err;
|
||||||
|
await sleep(1000 * attempt); // exponential backoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three-pass dungeon generation with full resiliency and JSON retry
|
||||||
|
*/
|
||||||
|
export async function generateDungeon() {
|
||||||
|
console.log("🏗️ Starting dungeon generation...");
|
||||||
|
|
||||||
|
let draft, refined, jsonText;
|
||||||
|
|
||||||
|
// Draft Pass
|
||||||
|
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 = 3;
|
||||||
|
for (let attempt = 1; attempt <= maxJsonRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
console.log(`📦 JSON pass (attempt ${attempt}/${maxJsonRetries})...`);
|
||||||
|
jsonText = await callOllama(jsonPrompt);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
dungeonTemplate.js
Normal file
165
dungeonTemplate.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
export function dungeonTemplate(data) {
|
||||||
|
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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Generated with DungeonBuilder • © ${new Date().getFullYear()}
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
7
eslint.config.js
Normal file
7
eslint.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import { defineConfig } from "eslint/config";
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.node } },
|
||||||
|
]);
|
||||||
22
generateDungeon.js
Normal file
22
generateDungeon.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import puppeteer from "puppeteer";
|
||||||
|
import { dungeonTemplate } from "./dungeonTemplate.js";
|
||||||
|
|
||||||
|
export async function generateDungeonPDF(data, outputPath = "dungeon.pdf") {
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
const html = dungeonTemplate(data);
|
||||||
|
await page.setContent(html, { waitUntil: "networkidle0" });
|
||||||
|
|
||||||
|
await page.pdf({
|
||||||
|
path: outputPath,
|
||||||
|
format: "A4",
|
||||||
|
landscape: true,
|
||||||
|
printBackground: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(`Dungeon PDF saved to ${outputPath}`);
|
||||||
|
}
|
||||||
31
index.js
Normal file
31
index.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { generateDungeonPDF } from "./generateDungeon.js";
|
||||||
|
import { generateDungeon } from "./dungeonGenerator.js";
|
||||||
|
|
||||||
|
// Utility to create a filesystem-safe filename from the dungeon title
|
||||||
|
function slugify(text) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-') // replace non-alphanumeric with hyphens
|
||||||
|
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Generate dungeon JSON from Ollama
|
||||||
|
const dungeonData = await generateDungeon();
|
||||||
|
|
||||||
|
// Optional: replace the map placeholder with your local map path
|
||||||
|
// dungeonData.map = "/absolute/path/to/dungeon-map.png";
|
||||||
|
|
||||||
|
// Generate a safe filename based on the dungeon's title
|
||||||
|
const filename = `${slugify(dungeonData.title)}.pdf`;
|
||||||
|
|
||||||
|
// Generate PDF
|
||||||
|
await generateDungeonPDF(dungeonData, filename);
|
||||||
|
|
||||||
|
console.log(`Dungeon PDF successfully generated: ${filename}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error generating dungeon:", err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
2037
package-lock.json
generated
Normal file
2037
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "auto-dm",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"start": "node index.js"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "SEE LICENSE IN README.md",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^17.2.1",
|
||||||
|
"puppeteer": "^24.17.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.34.0",
|
||||||
|
"eslint": "^9.34.0",
|
||||||
|
"globals": "^16.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user