initial commit
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

This commit is contained in:
2025-08-29 22:56:40 -04:00
commit ed95dd08a2
11 changed files with 2540 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.pdf
.env
node_modules/**

48
.woodpecker/ci.yml Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

126
dungeonGenerator.js Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View 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"
}
}