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