Compare commits

...

32 Commits

Author SHA1 Message Date
5588108cb6 fix validation
All checks were successful
ci/woodpecker/cron/ci Pipeline was successful
2026-01-20 22:24:39 -05:00
e66df13edd cleanup title and formatting 2026-01-20 22:14:33 -05:00
96223b81e6 more tweaks
All checks were successful
ci/woodpecker/cron/ci Pipeline was successful
2026-01-19 22:37:53 -05:00
9332ac6f94 improvements
All checks were successful
ci/woodpecker/cron/ci Pipeline was successful
2026-01-18 23:02:18 -05:00
c54b1a6082 add capability to use default model if a default is provided via api
All checks were successful
ci/woodpecker/cron/ci Pipeline was successful
2026-01-16 22:18:31 -05:00
3b91ce3068 improve and fix ci stuff. cleanup debug
Some checks failed
ci/woodpecker/cron/ci Pipeline failed
2026-01-11 21:41:30 -05:00
c7bb0f04df fix model name 2026-01-11 20:55:17 -05:00
05526b06d6 playaround with debug to figure out ci failures 2026-01-11 20:17:42 -05:00
af447da042 update deps
Some checks failed
ci/woodpecker/cron/ci Pipeline failed
2026-01-10 21:54:32 -05:00
c48188792d cleanup and fix ci 2026-01-10 21:52:11 -05:00
1059eced53 fix ollama model env var mismatch
Some checks failed
ci/woodpecker/cron/ci Pipeline failed
2026-01-08 20:57:35 -05:00
Madison Grubb
96480a351f make it start working again
Some checks failed
ci/woodpecker/cron/ci Pipeline failed
2025-12-11 23:13:07 -05:00
dc9ec367a0 remove workflows that were test workflows
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/cron/ci Pipeline failed
2025-09-10 22:38:37 -04:00
799ee18dc2 cleanup locations. make treasure bold. try to add some flair to the images 2025-09-10 22:38:06 -04:00
277a3ba718 improve overall dungeon cohesiveness
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/cron/ci Pipeline was successful
2025-09-08 22:42:42 -04:00
Madison Grubb
a3c54b1c82 use sharp and improve prompting
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/cron/ci Pipeline was successful
2025-09-05 16:48:35 -04:00
Madison Grubb
be7534be8d smaller image, larger image would crash comfyui 2025-09-05 13:32:49 -04:00
Madison Grubb
23fae22735 make titles always uppercase. cleanup copy footer
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-05 13:21:06 -04:00
Madison Grubb
d436284476 improve gen
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-05 13:18:24 -04:00
Madison Grubb
800c9c488c lower cfg to 2.
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-05 09:49:54 -04:00
Madison Grubb
27dfed05ac improve image gen prompting. increase cfg from 1->3 to make prompt follow more aggressively
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/cron/ci Pipeline was successful
2025-09-04 23:09:57 -04:00
Madison Grubb
714d0351ea fix png compression 2025-09-04 23:02:28 -04:00
Madison Grubb
f0e9ebccb9 cleanup console logs a little bit
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-04 22:59:37 -04:00
Madison Grubb
fad007ab1f increase encounter count by 3
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-04 22:54:52 -04:00
Madison Grubb
438943b032 cleanup template more for imagen
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-04 22:52:23 -04:00
Madison Grubb
50e240f314 fix name of file
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-04 22:48:57 -04:00
Madison Grubb
df08a6bf42 add png compression to save space
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-09-04 22:48:11 -04:00
Madison Grubb
f51a5a6e0c shrink image more. improve prompts
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-04 22:38:03 -04:00
Madison Grubb
1e1bee6d05 add env var for comfyui in ci
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-04 22:18:14 -04:00
Madison Grubb
1e1d745e55 rework to allow for image gen
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-04 16:52:13 -04:00
af315783e0 cleanup everything lol
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/cron/ci Pipeline was successful
2025-09-01 16:53:37 -04:00
15ce02eec1 revamp release uploads 2025-09-01 15:38:55 -04:00
14 changed files with 2936 additions and 479 deletions

11
.gitignore vendored
View File

@@ -1,3 +1,14 @@
*.pdf
*.png
.env
node_modules/**
# macOS dotfiles
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
.AppleDouble
.LSOverride
.env.example

View File

@@ -30,6 +30,8 @@ steps:
from_secret: OLLAMA_API_URL
OLLAMA_API_KEY:
from_secret: OLLAMA_API_KEY
COMFYUI_URL:
from_secret: COMFYUI_URL
commands:
- npm ci
- npm start
@@ -45,16 +47,32 @@ steps:
commands:
- pdf=$(ls *.pdf | head -n1)
- tag=$(date +%F)
# Create the release (ignore error if it already exists)
- |
curl -s -o /dev/null -w "%{http_code}" -X POST \
echo "Creating release for tag $tag..."
create_resp=$(curl -s -w "%{http_code}" -o /tmp/create.json -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$tag\", \"name\": \"$tag\", \"draft\": false, \"prerelease\": false}" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases || true
# Upload the PDF as a release asset
- |
curl -X POST \
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases)
echo "Create release HTTP status: $create_resp"
echo "Fetching release ID..."
release_id=$(curl -s \
-H "Authorization: token $GITEA_TOKEN" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/tags/$tag |
awk -F: '/"id"[ ]*:/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
echo "Release ID = $release_id"
echo "Checking if asset $pdf already exists..."
assets=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$release_id/assets)
echo "Assets response: $assets"
if echo "$assets" | grep -q "\"name\":\"$pdf\""; then
echo "Asset $pdf already uploaded, skipping."
exit 0
fi
echo "Uploading $pdf to release $release_id..."
upload_resp=$(curl -s -w "%{http_code}" -o /tmp/upload.json -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$pdf" \
"https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$tag/assets"
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$release_id/assets)
echo "Upload HTTP status: $upload_resp"
echo "Upload response: $(cat /tmp/upload.json)"

View File

@@ -22,13 +22,14 @@ Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon
- Node.js 22+
- Ollama server running and accessible
- Nextcloud (optional) for PDF uploads
- Gitea Releases (optional) for PDF uploads
- `.env` file with:
```env
OLLAMA_API_URL=http://localhost:3000/api/chat/completions
OLLAMA_API_KEY=your_api_key_here
````
COMFYUI_URL=http://192.168.1.124:8188
```
---
@@ -42,6 +43,27 @@ npm install
---
## API Configuration
The client automatically infers the API type from the endpoint URL, making it flexible for different deployment scenarios.
### Direct Ollama API
For direct Ollama API calls, set:
```env
OLLAMA_API_URL=http://localhost:11434/api/generate
```
### Open WebUI API
For Open WebUI API calls, set:
```env
OLLAMA_API_URL=http://localhost:3000/api/chat/completions
OLLAMA_API_KEY=your_open_webui_api_key
```
> Note: The API type is automatically inferred from the endpoint URL. If the URL contains `/api/chat/completions`, it uses Open WebUI API. If it contains `/api/generate`, it uses direct Ollama API. No `OLLAMA_API_TYPE` environment variable is required.
---
## Usage
1. Make sure your Ollama server is running and `.env` is configured.
@@ -63,7 +85,7 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
* Three-column layout:
* Column 1: Map, Adventure Hooks, Rumors
* Column 2: Keyed Rooms
* Column 2: Rooms
* Column 3: Encounters, Treasure, NPCs
---
@@ -77,4 +99,4 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
## License
PROPRIETARY
PROPRIETARY

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -2,147 +2,474 @@ function pickRandom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function escapeHtml(text) {
if (!text) return '';
const map = {
'&': '&',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return String(text).replace(/[&<>"']/g, m => map[m]);
}
export function dungeonTemplate(data) {
const bodyFonts = [
"'Libre Baskerville', serif",
"'Cardo', serif",
"'Lora', serif",
"'Merriweather', serif",
"'Fraunces', serif",
"'Source Serif 4', serif",
"'Lora', serif"
"'Libre Baskerville', serif",
"'Source Serif 4', 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",
"'New Rocker', system-ui",
"'UnifrakturCook', cursive",
"'IM Fell DW Pica', serif",
"'Cinzel', serif",
"'Cormorant Garamond', serif",
"'Special Elite', monospace"
"'Playfair Display', serif"
];
const quoteFonts = [
"'Walter Turncoat', cursive",
"'Uncial Antiqua', serif",
"'Beth Ellen', cursive",
"'Pinyon Script', cursive",
"'Dela Gothic One', sans-serif"
"'Playfair Display', serif",
"'Libre Baskerville', serif",
"'Merriweather', serif"
];
const bodyFont = pickRandom(bodyFonts);
const headingFont = pickRandom(headingFonts);
const tableFont = pickRandom(tableFonts);
const quoteFont = pickRandom(quoteFonts);
// Check if we have a map image to include
const hasMap = data.map && typeof data.map === 'string' && data.map.startsWith('data:image/');
return `
<!DOCTYPE html>
<html>
<head>
<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>
<meta charset="UTF-8">
<title>${data.title}</title>
<link
href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative&family=UnifrakturCook&family=New+Rocker&family=Metamorphous&family=Playfair+Display&family=Alegreya+Sans&family=Cabin&family=IBM+Plex+Sans&family=Cormorant+Garamond&family=Lora&family=Merriweather&family=Libre+Baskerville&family=Source+Serif+4&family=Walter+Turncoat&family=Uncial+Antiqua&family=Beth+Ellen&family=Pinyon+Script&display=swap"
rel="stylesheet">
<style>
@page {
size: A4 landscape;
margin: 0;
}
body {
margin: 0;
padding: 0;
font-family: ${bodyFont};
color: #1a1a1a;
font-size: 0.7em;
line-height: 1.35em;
}
.content-page {
height: 100vh;
box-sizing: border-box;
padding: 1.2cm;
page-break-after: always;
overflow: visible;
break-inside: avoid;
}
h1 {
font-family: ${headingFont};
text-align: center;
text-transform: uppercase;
font-size: 1.8em;
margin: 0.15em 0 0.2em;
color: #1a1a1a;
border-bottom: 2px solid #1a1a1a;
padding-bottom: 0.15em;
letter-spacing: 0.1em;
}
.flavor {
text-align: center;
font-style: italic;
font-family: ${quoteFont};
margin: 0.3em 0 0.6em;
font-size: 0.85em;
line-height: 1.35em;
}
.columns {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.4cm;
align-items: start;
}
.col {
display: flex;
flex-direction: column;
gap: 0.25em;
overflow-wrap: break-word;
word-break: normal;
hyphens: auto;
}
.section-block {
break-inside: avoid;
page-break-inside: avoid;
margin-bottom: 0.3em;
}
h2 {
font-family: ${headingFont};
font-size: 1.05em;
margin: 0.2em 0 0.2em;
color: #1a1a1a;
border-bottom: 1px solid #1a1a1a;
padding-bottom: 0.08em;
text-transform: uppercase;
letter-spacing: 0.05em;
break-inside: avoid;
page-break-inside: avoid;
}
.room {
break-inside: avoid;
page-break-inside: avoid;
}
.room h3 {
margin: 0.08em 0 0.03em;
font-size: 0.9em;
font-weight: bold;
color: #1a1a1a;
}
.room p {
margin: 0 0 0.15em;
font-size: 0.8em;
font-weight: normal;
line-height: 1.25em;
}
.encounter, .npc, .treasure, .plot-resolution {
margin: 0 0 0.25em;
break-inside: avoid;
page-break-inside: avoid;
font-size: 0.85em;
line-height: 1.3em;
}
.random-events {
margin: 0.2em 0;
break-inside: avoid;
page-break-inside: avoid;
font-size: 0.85em;
}
.random-events table {
margin-top: 0.15em;
}
.encounter strong, .npc strong, .treasure strong {
font-weight: bold;
}
table {
width: 100%;
border-collapse: collapse;
margin: 0.2em 0;
font-size: 0.85em;
break-inside: avoid;
page-break-inside: avoid;
border: 1px solid #1a1a1a;
}
table th {
font-family: ${headingFont};
text-align: left;
border-bottom: 1px solid #1a1a1a;
padding: 0.15em 0.3em;
font-size: 0.85em;
text-transform: uppercase;
font-weight: bold;
}
table td {
padding: 0.25em 0.4em;
vertical-align: top;
line-height: 1.3em;
border-bottom: 1px solid #1a1a1a;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: normal;
}
table tr:last-child td {
border-bottom: 1px solid #1a1a1a;
}
table td:first-child {
font-weight: bold;
width: 2em;
text-align: center;
border-right: 1px solid #1a1a1a;
}
.encounters-table td:nth-child(2) {
font-weight: bold;
min-width: 20%;
max-width: 30%;
padding-right: 0.5em;
border-right: 1px solid #1a1a1a;
}
.encounters-table td:nth-child(3) {
width: auto;
font-size: 0.8em;
line-height: 1.3em;
}
.map-page {
display: flex;
align-items: center;
justify-content: center;
page-break-before: always;
}
.map-container {
text-align: center;
margin: 1em 0;
}
.map-container img {
max-width: 100%;
max-height: calc(100vh - 3cm);
border: 1px solid #1a1a1a;
}
ul {
margin: 0.2em 0;
padding-left: 1.2em;
}
li {
margin: 0.08em 0;
font-size: 0.85em;
line-height: 1.3em;
}
</style>
</head>
<body>
<h1>${data.title}</h1>
<p class="flavor">${data.flavor}</p>
<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>Rumors</h2>
<ul>${data.rumors.map(r => `<li>${r}</li>`).join("")}</ul>
<div class="content-page">
<h1>${escapeHtml(data.title)}</h1>
${data.flavor ? `<p class="flavor">${escapeHtml(data.flavor)}</p>` : ''}
<div class="columns">
<div class="col">
${data.hooksRumors && data.hooksRumors.length > 0 ? `
<div class="section-block">
<h2>Hooks & Rumors</h2>
<ul>
${data.hooksRumors.map(hook => `<li>${escapeHtml(hook)}</li>`).join('')}
</ul>
</div>
` : ''}
${data.randomEvents && data.randomEvents.length > 0 ? `
<div class="section-block random-events">
<h2>Random Events (d6)</h2>
<table class="encounters-table">
<tbody>
${data.randomEvents.map((event, index) => {
// Handle both object format {name, description} and string format
let eventName = '';
let eventDesc = '';
if (typeof event === 'object' && event.name && event.description) {
eventName = event.name;
eventDesc = event.description;
} else if (typeof event === 'string') {
// Try to parse "Event Name: Description" format
const colonMatch = event.match(/^([^:]+):\s*(.+)$/);
if (colonMatch) {
eventName = colonMatch[1].trim();
eventDesc = colonMatch[2].trim();
} else {
// Fallback: use first few words as name, rest as description
const words = event.split(/\s+/);
if (words.length > 3) {
eventName = words.slice(0, 2).join(' ');
eventDesc = words.slice(2).join(' ');
} else {
eventName = `Event ${index + 1}`;
eventDesc = event;
}
}
} else {
eventName = `Event ${index + 1}`;
eventDesc = String(event || '');
}
// Truncate description to prevent overflow (similar to encounters)
if (eventDesc.length > 200) {
eventDesc = eventDesc.substring(0, 197).trim();
const lastPeriod = eventDesc.lastIndexOf('.');
if (lastPeriod > 150) {
eventDesc = eventDesc.substring(0, lastPeriod + 1);
} else {
eventDesc += '...';
}
}
return `
<tr>
<td>${index + 1}</td>
<td><strong>${escapeHtml(eventName)}</strong></td>
<td>${escapeHtml(eventDesc)}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
` : ''}
${data.rooms && data.rooms.length > 0 ? `
<div class="section-block">
<h2>Locations</h2>
${data.rooms.map(room => {
let desc = room.description || '';
// Truncate to 1 sentence max to prevent overflow
const sentences = desc.match(/[^.!?]+[.!?]+/g) || [desc];
if (sentences.length > 1) {
desc = sentences.slice(0, 1).join(' ').trim();
}
// Also limit by character count (~100 chars for tighter fit)
if (desc.length > 100) {
desc = desc.substring(0, 97).trim();
const lastPeriod = desc.lastIndexOf('.');
if (lastPeriod > 70) {
desc = desc.substring(0, lastPeriod + 1);
} else {
desc += '...';
}
}
return `
<div class="room">
<h3>${escapeHtml(room.name)}</h3>
<p>${escapeHtml(desc)}</p>
</div>
`;
}).join('')}
</div>
` : ''}
</div>
<div class="col">
${data.encounters && data.encounters.length > 0 ? `
<div class="section-block">
<h2>Encounters (d6)</h2>
<table class="encounters-table">
<tbody>
${data.encounters.map((encounter, index) => {
// Truncate details to 4 sentences max to prevent overflow
let details = encounter.details || '';
// Remove encounter name if it appears at start
if (details.toLowerCase().startsWith(encounter.name.toLowerCase())) {
details = details.substring(encounter.name.length).replace(/^:\s*/, '').trim();
}
// Remove location prefix if present (format: "Location Name: description")
// Handle multiple colons - strip the first one that looks like a location
const locationMatch = details.match(/^([^:]+):\s*(.+)$/);
if (locationMatch) {
const potentialLocation = locationMatch[1].trim();
// If it looks like a location name (capitalized, not too long), remove it
if (potentialLocation.length > 3 && potentialLocation.length < 50 && /^[A-Z]/.test(potentialLocation)) {
details = locationMatch[2].trim();
}
}
// Split into sentences and keep only first 4
const sentences = details.match(/[^.!?]+[.!?]+/g) || [details];
if (sentences.length > 4) {
details = sentences.slice(0, 4).join(' ').trim();
}
// Also limit by character count as fallback (max ~350 chars)
if (details.length > 350) {
details = details.substring(0, 347).trim();
// Try to end at a sentence boundary
const lastPeriod = details.lastIndexOf('.');
if (lastPeriod > 280) {
details = details.substring(0, lastPeriod + 1);
} else {
details += '...';
}
}
return `
<tr>
<td>${index + 1}</td>
<td><strong>${escapeHtml(encounter.name)}</strong></td>
<td>${escapeHtml(details)}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
` : ''}
${data.treasure && data.treasure.length > 0 ? `
<div class="section-block">
<h2>Treasure</h2>
${data.treasure.map(item => {
if (typeof item === 'object' && item.name && item.description) {
return `<div class="treasure"><strong>${escapeHtml(item.name)}</strong> — ${escapeHtml(item.description)}</div>`;
} else if (typeof item === 'string') {
// Handle string format "Name — Description"
const parts = item.split(/[—–-]/);
if (parts.length >= 2) {
return `<div class="treasure"><strong>${escapeHtml(parts[0].trim())}</strong> — ${escapeHtml(parts.slice(1).join(' ').trim())}</div>`;
}
return `<div class="treasure">${escapeHtml(item)}</div>`;
}
return '';
}).filter(Boolean).join('')}
</div>
` : ''}
</div>
<div class="col">
${data.npcs && data.npcs.length > 0 ? `
<div class="section-block">
<h2>NPCs</h2>
${data.npcs.map(npc => {
if (typeof npc === 'object' && npc.name && npc.trait) {
return `<div class="npc"><strong>${escapeHtml(npc.name)}</strong>: ${escapeHtml(npc.trait)}</div>`;
} else if (typeof npc === 'string') {
// Handle string format "Name: Description"
const parts = npc.split(/:/);
if (parts.length >= 2) {
return `<div class="npc"><strong>${escapeHtml(parts[0].trim())}</strong>: ${escapeHtml(parts.slice(1).join(':').trim())}</div>`;
}
return `<div class="npc">${escapeHtml(npc)}</div>`;
}
return '';
}).filter(Boolean).join('')}
</div>
` : ''}
${data.plotResolutions && data.plotResolutions.length > 0 ? `
<div class="section-block">
<h2>Plot Resolutions</h2>
${data.plotResolutions.map(resolution => {
// Truncate to 1 sentence max to prevent overflow (more aggressive)
let text = resolution || '';
// Split into sentences and keep only first 1
const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
if (sentences.length > 1) {
text = sentences.slice(0, 1).join(' ').trim();
}
// Also limit by character count as fallback (max ~120 chars for tighter fit)
if (text.length > 120) {
text = text.substring(0, 117).trim();
// Try to end at a sentence boundary
const lastPeriod = text.lastIndexOf('.');
if (lastPeriod > 90) {
text = text.substring(0, lastPeriod + 1);
} else {
text += '...';
}
}
return `
<div class="plot-resolution">
${escapeHtml(text)}
</div>
`;
}).join('')}
</div>
` : ''}
</div>
</div>
</div>
<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>
${hasMap ? `
<div class="content-page map-page">
<div class="map-container">
<img src="${data.map}" alt="Dungeon Map" />
</div>
</div>
` : ''}
</body>
</html>
`;
`;
}

View File

@@ -1,22 +0,0 @@
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}`);
}

44
generatePDF.js Normal file
View File

@@ -0,0 +1,44 @@
import puppeteer from "puppeteer";
import { dungeonTemplate } from "./dungeonTemplate.js";
import fs from "fs/promises";
export async function generatePDF(data, outputPath = "dungeon.pdf") {
const browser = await puppeteer.launch({
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
const toBase64DataUrl = (buffer) =>
`data:image/png;base64,${buffer.toString("base64")}`;
const readImageData = async (path) =>
fs
.readFile(path)
.then(toBase64DataUrl)
.catch(() => {
console.warn(
"Warning: Could not read image file, proceeding without map in PDF",
);
return null;
});
const imageData = data.map ? await readImageData(data.map) : null;
const dataWithImage = imageData
? { ...data, map: imageData }
: (({ map, ...rest }) => rest)(data); // eslint-disable-line no-unused-vars
const html = dungeonTemplate(dataWithImage);
await page.setContent(html, { waitUntil: "networkidle0" });
await page.pdf({
path: outputPath,
format: "A4",
landscape: true,
printBackground: true,
preferCSSPageSize: true,
});
await browser.close();
console.log(`Dungeon PDF saved to ${outputPath}`);
}

260
imageGenerator.js Normal file
View File

@@ -0,0 +1,260 @@
import sharp from 'sharp';
import path from "path";
import { mkdir, writeFile } from "fs/promises";
import { fileURLToPath } from "url";
import { callOllama, OLLAMA_MODEL } from "./ollamaClient.js";
const COMFYUI_ENABLED = process.env.COMFYUI_ENABLED !== 'false';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COMFYUI_URL = process.env.COMFYUI_URL || "http://localhost:8188";
// Drawing style prefix
const STYLE_PREFIX = `clean line art, minimalist sketch, concept art, black and white line drawing, lots of white space, sparse shading, simple black hatching, very low detail`;
const ACCENT_COLORS = ["red", "blue", "yellow", "green", "purple", "orange"];
function selectRandomAccentColor() {
return ACCENT_COLORS[Math.floor(Math.random() * ACCENT_COLORS.length)];
}
async function upscaleImage(inputPath, outputPath, width, height) {
try {
await sharp(inputPath)
.resize(width, height, { kernel: 'lanczos3' })
.blur(0.3)
.sharpen({
sigma: 1,
flat: 1,
jagged: 2,
})
.png({
compressionLevel: 9,
adaptiveFiltering: true,
palette: true
})
.toFile(outputPath);
console.log(`Upscaled + compressed PNG saved: ${outputPath}`);
return outputPath;
} catch (err) {
console.error("Error during upscaling:", err.message);
return null;
}
}
// 1. Generate engineered visual prompt
async function generateVisualPrompt(flavor) {
const rawPrompt = await callOllama(
`You are a prompt engineer specializing in visual prompts for AI image generation. Your goal is to translate fantasy flavor text into a sparse, minimalist scene description.
Your output must be a simple list of visual tags describing only the most essential elements of the scene. Focus on the core subject and mood.
Rules:
- Describe a sparse scene with a single focal point or landscape.
- Use only 3-5 key descriptive phrases or tags.
- The entire output should be very short, 20-50 words maximum.
- Do NOT repeat wording from the input.
- Describe only the visual elements of the image. Focus on colors, shapes, textures, and spatial relationships.
- Exclude any references to style, medium, camera effects, sounds, hypothetical scenarios, or physical sensations.
- Avoid describing fine details; focus on large forms and the overall impression.
- Do NOT include phrases like “an image of” or “a scene showing”.
- Do NOT include the word "Obsidian" or "obsidian" at all.
Input:
${flavor}
Output:`,
OLLAMA_MODEL,
3,
"Generate Visual Prompt"
);
const accentColor = selectRandomAccentColor();
return `${STYLE_PREFIX}, on white paper, monochrome with a single accent of ${accentColor}, ${rawPrompt.trim().replace(/\n/g, " ")}`;
}
// 2. Save image buffer
async function saveImage(buffer, filename) {
const filepath = path.join(__dirname, filename);
await mkdir(__dirname, { recursive: true });
await writeFile(filepath, buffer);
console.log(`Saved image: ${filepath}`);
return filepath;
}
// 3. Build workflow payload
function buildComfyWorkflow(promptText, negativeText = "") {
return {
"3": {
"inputs": {
"seed": Math.floor(Math.random() * 100000),
"steps": 4,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0]
},
"class_type": "KSampler"
},
"4": {
"inputs": {
"unet_name": "flux1-schnell-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn"
},
"class_type": "UNETLoader"
},
"5": {
"inputs": {
"width": 728,
"height": 512,
"batch_size": 1
},
"class_type": "EmptyLatentImage"
},
"6": {
"inputs": {
"text": promptText,
"clip": ["10", 0]
},
"class_type": "CLIPTextEncode"
},
"7": {
"inputs": {
"text": negativeText,
"clip": ["10", 0]
},
"class_type": "CLIPTextEncode"
},
"10": {
"inputs": {
"clip_name1": "clip_l.safetensors",
"clip_name2": "t5xxl_fp8_e4m3fn.safetensors",
"type": "flux"
},
"class_type": "DualCLIPLoader"
},
"11": {
"inputs": {
"vae_name": "ae.safetensors"
},
"class_type": "VAELoader"
},
"8": {
"inputs": {
"samples": ["3", 0],
"vae": ["11", 0]
},
"class_type": "VAEDecode"
},
"9": {
"inputs": {
"filename_prefix": "ComfyUI_Flux",
"images": ["8", 0]
},
"class_type": "SaveImage"
}
};
}
// 4a. Wait for ComfyUI to finish image generation
async function waitForImage(promptId, timeout = 900000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const res = await fetch(`${COMFYUI_URL}/history`);
if (!res.ok) {
throw new Error(`ComfyUI history request failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();
const historyEntry = data[promptId];
if (historyEntry?.outputs) {
const images = Object.values(historyEntry.outputs).flatMap(o => o.images || []);
if (images.length > 0) return images.map(i => i.filename);
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
throw new Error("Timed out waiting for ComfyUI image result.");
}
// 4b. Download image from ComfyUI server
async function downloadImage(filename, localFilename) {
const url = `${COMFYUI_URL}/view?filename=${filename}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch image: ${res.statusText}`);
const buffer = Buffer.from(await res.arrayBuffer());
return await saveImage(buffer, localFilename);
}
// 4c. Submit prompt and handle full image pipeline
async function generateImageViaComfyUI(prompt, filename) {
const negativePrompt = `heavy shading, deep blacks, dark, gritty, shadow-filled, chiaroscuro, scratchy lines, photorealism, hyper-realistic, high detail, 3D render, CGI, polished, smooth shading, detailed textures, noisy, cluttered, blurry, text, logo, signature, watermark, artist name, branding, ugly, deformed, unnatural patterns, perfect curves, repetitive textures`;
const workflow = buildComfyWorkflow(prompt, negativePrompt);
try {
console.log("Submitting prompt to ComfyUI...");
const res = await fetch(`${COMFYUI_URL}/prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: workflow })
});
if (!res.ok) {
throw new Error(`ComfyUI error: ${res.status} ${res.statusText}`);
}
const { prompt_id } = await res.json();
console.log("Waiting for image result...");
const filenames = await waitForImage(prompt_id);
if (filenames.length === 0) throw new Error("No image generated");
const comfyFilename = filenames[0];
console.log("Downloading image...");
const filepath = await downloadImage(comfyFilename, filename);
return filepath;
} catch (err) {
console.error("Error generating image:", err.message);
return null;
}
}
// 5. Main export
export async function generateDungeonImages({ flavor }) {
console.log("Generating dungeon image...");
if (!COMFYUI_ENABLED) {
console.log("ComfyUI image generation disabled via .env; using existing upscaled image.");
return path.join(__dirname, "dungeon_upscaled.png");
}
const finalPrompt = await generateVisualPrompt(flavor);
console.log("Engineered visual prompt:\n", finalPrompt);
const baseFilename = `dungeon.png`;
const upscaledFilename = `dungeon_upscaled.png`;
const filepath = await generateImageViaComfyUI(finalPrompt, baseFilename);
if (!filepath) {
throw new Error("Failed to generate dungeon image.");
}
// Upscale 2x (half of A4 at 300dpi)
const upscaledPath = await upscaleImage(filepath, upscaledFilename, 1456, 1024);
if (!upscaledPath) {
throw new Error("Failed to upscale dungeon image.");
}
return upscaledPath;
}

View File

@@ -1,31 +1,45 @@
import 'dotenv/config';
import { generateDungeonPDF } from "./generateDungeon.js";
import "dotenv/config";
import { generateDungeon } from "./dungeonGenerator.js";
import { generateDungeonImages } from "./imageGenerator.js";
import { generatePDF } from "./generatePDF.js";
import { OLLAMA_MODEL, initializeModel } from "./ollamaClient.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
.replace(/[^a-z0-9]+/g, "-") // replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, ""); // trim leading/trailing hyphens
}
(async () => {
try {
// Generate dungeon JSON from Ollama
if (!process.env.OLLAMA_API_URL) {
throw new Error("OLLAMA_API_URL environment variable is required");
}
console.log("Using Ollama API URL:", process.env.OLLAMA_API_URL);
// Initialize model (will fetch default from API or use fallback)
await initializeModel();
console.log("Using Ollama model:", OLLAMA_MODEL);
// Generate the dungeon data
const dungeonData = await generateDungeon();
// Optional: replace the map placeholder with your local map path
// dungeonData.map = "/absolute/path/to/dungeon-map.png";
// Generate dungeon map image (uses dungeonData.flavor)
const mapPath = await generateDungeonImages(dungeonData);
// Generate a safe filename based on the dungeon's title
dungeonData.map = mapPath;
// Generate PDF filename based on the title
const filename = `${slugify(dungeonData.title)}.pdf`;
// Generate PDF
await generateDungeonPDF(dungeonData, filename);
// Generate the PDF using full dungeon data (including map)
await generatePDF(dungeonData, filename);
console.log(`Dungeon PDF successfully generated: ${filename}`);
} catch (err) {
console.error("Error generating dungeon:", err);
process.exit(1);
}
})();

137
ollamaClient.js Normal file
View File

@@ -0,0 +1,137 @@
const OLLAMA_API_URL = process.env.OLLAMA_API_URL;
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY;
export let OLLAMA_MODEL = process.env.OLLAMA_MODEL || "gemma3:latest";
export async function initializeModel() {
if (process.env.OLLAMA_MODEL) return;
try {
const isOpenWebUI = OLLAMA_API_URL?.includes("/api/chat/completions");
const baseUrl = OLLAMA_API_URL?.replace(/\/api\/.*$/, "");
const url = isOpenWebUI ? `${baseUrl}/api/v1/models` : `${baseUrl}/api/tags`;
const headers = isOpenWebUI && OLLAMA_API_KEY
? { "Authorization": `Bearer ${OLLAMA_API_KEY}` }
: {};
const res = await fetch(url, { headers });
if (res.ok) {
const data = await res.json();
const model = isOpenWebUI
? data.data?.[0]?.id || data.data?.[0]?.name
: data.models?.[0]?.name;
if (model) {
OLLAMA_MODEL = model;
console.log(`Using default model: ${model}`);
}
}
} catch {
console.warn(`Could not fetch default model, using: ${OLLAMA_MODEL}`);
}
}
function cleanText(str) {
return str
.replace(/^#+\s*/gm, "")
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/[*_`]/g, "")
.replace(/\s+/g, " ")
.trim();
}
function inferApiType(url) {
if (!url) return "ollama-generate";
if (url.includes("/api/chat/completions")) return "open-webui";
if (url.includes("/api/chat")) return "ollama-chat";
return "ollama-generate";
}
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function callOllamaBase(prompt, model, retries, stepName, apiType) {
const isUsingOpenWebUI = apiType === "open-webui";
const isUsingOllamaChat = apiType === "ollama-chat";
for (let attempt = 1; attempt <= retries; attempt++) {
try {
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 headers = { "Content-Type": "application/json" };
if (isUsingOpenWebUI && OLLAMA_API_KEY) {
headers["Authorization"] = `Bearer ${OLLAMA_API_KEY}`;
}
const body = isUsingOpenWebUI || isUsingOllamaChat
? { model, messages: [{ role: "user", content: prompt }] }
: { model, prompt, stream: false };
const response = await fetch(OLLAMA_API_URL, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
let errorDetails = "";
try {
const errorData = await response.text();
errorDetails = errorData ? `: ${errorData}` : "";
} catch {
// Ignore errors reading error response
}
throw new Error(
`Ollama request failed: ${response.status} ${response.statusText}${errorDetails}`,
);
}
const data = await response.json();
const rawText = isUsingOpenWebUI
? data.choices?.[0]?.message?.content
: isUsingOllamaChat
? data.message?.content
: data.response;
if (!rawText) throw new Error("No response from Ollama");
const cleaned = cleanText(rawText);
console.log(
`[${stepName}] Received: ${rawText.length} chars, ~${rawText.split(/\s+/).length} words`,
);
return cleaned;
} catch (err) {
console.warn(`[${stepName}] Attempt ${attempt} failed: ${err.message}`);
if (attempt === retries) throw err;
const delay = 1000 * Math.pow(2, attempt) + Math.random() * 500;
console.log(`Retrying in ${Math.round(delay / 1000)}s...`);
await sleep(delay);
}
}
}
export async function callOllama(
prompt,
model = OLLAMA_MODEL,
retries = 5,
stepName = "unknown",
) {
const apiType = inferApiType(OLLAMA_API_URL);
return callOllamaBase(prompt, model, retries, stepName, apiType);
}
export async function callOllamaExplicit(
prompt,
model = OLLAMA_MODEL,
retries = 5,
stepName = "unknown",
apiType = "ollama-generate",
) {
return callOllamaBase(prompt, model, retries, stepName, apiType);
}

856
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
{
"name": "auto-dm",
"name": "scrollsmith",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test:integration": "node --test test/integration.test.js",
"lint": "eslint .",
"start": "node index.js"
},
@@ -13,11 +14,12 @@
"description": "",
"dependencies": {
"dotenv": "^17.2.1",
"puppeteer": "^24.17.1"
"puppeteer": "^24.17.1",
"sharp": "^0.34.3"
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"eslint": "^9.34.0",
"globals": "^16.3.0"
"globals": "^17.0.0"
}
}

91
test/integration.test.js Normal file
View File

@@ -0,0 +1,91 @@
import { test } from "node:test";
import assert from "node:assert";
import { generateDungeon } from "../dungeonGenerator.js";
import { generatePDF } from "../generatePDF.js";
import fs from "fs/promises";
import path from "path";
const OLLAMA_API_URL = process.env.OLLAMA_API_URL;
test("Integration tests", { skip: !OLLAMA_API_URL }, async (t) => {
let dungeonData;
await t.test("Generate dungeon", async () => {
dungeonData = await generateDungeon();
assert(dungeonData, "Dungeon data should be generated");
});
await t.test("Title is 2-4 words, no colons", () => {
assert(dungeonData.title, "Title should exist");
const words = dungeonData.title.split(/\s+/);
assert(words.length >= 2 && words.length <= 4, `Title should be 2-4 words, got ${words.length}: "${dungeonData.title}"`);
assert(!dungeonData.title.includes(":"), `Title should not contain colons: "${dungeonData.title}"`);
});
await t.test("Flavor text is ≤60 words", () => {
assert(dungeonData.flavor, "Flavor text should exist");
const words = dungeonData.flavor.split(/\s+/);
assert(words.length <= 60, `Flavor text should be ≤60 words, got ${words.length}`);
});
await t.test("Hooks have no title prefixes", () => {
assert(dungeonData.hooksRumors, "Hooks should exist");
dungeonData.hooksRumors.forEach((hook, i) => {
assert(!hook.match(/^[^:]+:\s/), `Hook ${i + 1} should not have title prefix: "${hook}"`);
});
});
await t.test("Exactly 6 random events", () => {
assert(dungeonData.randomEvents, "Random events should exist");
assert.strictEqual(dungeonData.randomEvents.length, 6, `Should have exactly 6 random events, got ${dungeonData.randomEvents.length}`);
});
await t.test("Encounter details don't include encounter name", () => {
assert(dungeonData.encounters, "Encounters should exist");
dungeonData.encounters.forEach((encounter) => {
if (encounter.details) {
const detailsLower = encounter.details.toLowerCase();
const nameLower = encounter.name.toLowerCase();
assert(!detailsLower.startsWith(nameLower), `Encounter "${encounter.name}" details should not start with encounter name: "${encounter.details}"`);
}
});
});
await t.test("Treasure uses em-dash format, no 'description' text", () => {
assert(dungeonData.treasure, "Treasure should exist");
dungeonData.treasure.forEach((item, i) => {
if (typeof item === "object" && item.description) {
assert(!item.description.toLowerCase().startsWith("description"), `Treasure ${i + 1} description should not start with 'description': "${item.description}"`);
}
});
});
await t.test("NPCs have no 'description' text", () => {
assert(dungeonData.npcs, "NPCs should exist");
dungeonData.npcs.forEach((npc, i) => {
if (npc.trait) {
assert(!npc.trait.toLowerCase().startsWith("description"), `NPC ${i + 1} trait should not start with 'description': "${npc.trait}"`);
}
});
});
await t.test("PDF fits on one page", async () => {
const testPdfPath = path.join(process.cwd(), "test-output.pdf");
try {
await generatePDF(dungeonData, testPdfPath);
const pdfBuffer = await fs.readFile(testPdfPath);
// Check PDF page count by counting "%%EOF" markers (rough estimate)
const pdfText = pdfBuffer.toString("binary");
const pageCount = (pdfText.match(/\/Type\s*\/Page[^s]/g) || []).length;
// Should be 1 page for content, or 2 if map exists
const expectedPages = dungeonData.map ? 2 : 1;
assert(pageCount <= expectedPages, `PDF should have ≤${expectedPages} page(s), got ${pageCount}`);
} finally {
try {
await fs.unlink(testPdfPath);
} catch {
// Ignore cleanup errors
}
}
});
});