Compare commits

...

14 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
11 changed files with 2074 additions and 483 deletions

10
.gitignore vendored
View File

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

View File

@@ -29,7 +29,7 @@ Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon
OLLAMA_API_URL=http://localhost:3000/api/chat/completions
OLLAMA_API_KEY=your_api_key_here
COMFYUI_URL=http://192.168.1.124:8188
````
```
---
@@ -43,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.
@@ -78,4 +99,4 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
## License
PROPRIETARY
PROPRIETARY

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,18 @@ 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 = [
"'Lora', serif",
@@ -19,13 +31,6 @@ export function dungeonTemplate(data) {
"'Playfair Display', serif"
];
const tableFonts = [
"'Alegreya Sans', sans-serif",
"'Cabin', sans-serif",
"'IBM Plex Sans', sans-serif",
"'Cormorant Garamond', serif"
];
const quoteFonts = [
"'Playfair Display', serif",
"'Libre Baskerville', serif",
@@ -34,9 +39,11 @@ export function dungeonTemplate(data) {
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>
@@ -57,170 +64,412 @@ export function dungeonTemplate(data) {
font-family: ${bodyFont};
color: #1a1a1a;
font-size: 0.7em;
line-height: 1.25em;
line-height: 1.35em;
}
.content-page {
height: 100vh;
box-sizing: border-box;
padding: 1.5cm;
padding: 1.2cm;
page-break-after: always;
overflow: hidden;
overflow: visible;
break-inside: avoid;
}
h1 {
font-family: ${headingFont};
text-align: center;
text-transform: uppercase;
font-size: 2em;
margin: 0.2em 0 0.3em;
font-size: 1.8em;
margin: 0.15em 0 0.2em;
color: #1a1a1a;
border-bottom: 2px solid #1a1a1a;
padding-bottom: 0.2em;
padding-bottom: 0.15em;
letter-spacing: 0.1em;
}
.flavor {
text-align: center;
font-style: italic;
font-family: ${quoteFont};
margin: 0.4em 0 0.8em;
font-size: 0.9em;
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.5cm;
gap: 0.4cm;
align-items: start;
}
.col {
display: flex;
flex-direction: column;
gap: 0.15em;
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.0em;
margin: 0.3em 0 0.1em;
font-size: 1.05em;
margin: 0.2em 0 0.2em;
color: #1a1a1a;
border-bottom: 1px solid #1a1a1a;
padding-bottom: 0.1em;
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.2em 0 0.05em;
font-size: 0.95em;
margin: 0.08em 0 0.03em;
font-size: 0.9em;
font-weight: bold;
color: #1a1a1a;
}
.room p {
text-align: justify;
word-wrap: break-word;
margin: 0.1em 0 0.3em;
margin: 0 0 0.15em;
font-size: 0.8em;
font-weight: normal;
line-height: 1.25em;
}
ul {
padding-left: 1em;
margin: 0.1em 0 0.3em;
.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;
}
li {
margin-bottom: 0.2em;
.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;
font-family: ${tableFont};
font-size: 0.8em;
}
th,
td {
margin: 0.2em 0;
font-size: 0.85em;
break-inside: avoid;
page-break-inside: avoid;
border: 1px solid #1a1a1a;
padding: 0.2em;
}
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;
}
th {
background: #e0e0e0;
table tr:last-child td {
border-bottom: 1px solid #1a1a1a;
}
table tr:hover {
background: rgba(0, 0, 0, 0.05);
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 {
height: 210mm;
width: 297mm;
box-sizing: border-box;
padding: 1.5cm;
position: relative;
display: block;
}
.map-image-container {
position: absolute;
top: 1.5cm;
left: 1.5cm;
right: 1.5cm;
bottom: 3cm;
display: flex;
align-items: center;
justify-content: center;
page-break-before: always;
}
.map-page img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.map-page footer {
position: absolute;
bottom: 1.5cm;
left: 1.5cm;
right: 1.5cm;
.map-container {
text-align: center;
font-size: 0.65em;
color: #555;
font-style: italic;
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>
<div class="content-page">
<h1>${data.title}</h1>
<p class="flavor">${data.flavor}</p>
<h1>${escapeHtml(data.title)}</h1>
${data.flavor ? `<p class="flavor">${escapeHtml(data.flavor)}</p>` : ''}
<div class="columns">
<div class="col">
<h2>Adventure Hooks & Rumors</h2>
<ul>${data.hooksRumors.map(item => `<li>${item}</li>`).join("")}</ul>
<h2>Locations</h2>
${data.rooms.map((room, i) => `<div class="room">
<h3>${i + 1}. ${room.name}</h3>
<p>${room.description}</p>
</div>`).join("")}
${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">
<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>
${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">
<h2>NPCs</h2>
<ul>${data.npcs.map(n => `<li><b>${n.name}</b>: ${n.trait}</li>`).join("")}</ul>
<h2>Plot Resolutions</h2>
<ul>${data.plotResolutions.map(p => `<li>${p}</li>`).join("")}</ul>
${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="map-page">
<div class="map-image-container">
<img src="${data.map}" alt="Dungeon Map">
${hasMap ? `
<div class="content-page map-page">
<div class="map-container">
<img src="${data.map}" alt="Dungeon Map" />
</div>
</div>
<footer>Scrollsmith • © ${new Date().getFullYear()}</footer>
</div>
` : ''}
</body>
</html>
`;
`;
}

View File

@@ -1,21 +1,34 @@
import puppeteer from "puppeteer";
import { dungeonTemplate } from "./dungeonTemplate.js";
import fs from 'fs/promises';
import fs from "fs/promises";
export async function generatePDF(data, outputPath = "dungeon.pdf") {
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox']
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
// Convert image to base64
const imageBuffer = await fs.readFile(data.map);
const base64Image = `data:image/png;base64,${imageBuffer.toString("base64")}`;
data.map = base64Image;
const toBase64DataUrl = (buffer) =>
`data:image/png;base64,${buffer.toString("base64")}`;
const html = dungeonTemplate(data);
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({
@@ -23,7 +36,7 @@ export async function generatePDF(data, outputPath = "dungeon.pdf") {
format: "A4",
landscape: true,
printBackground: true,
preferCSSPageSize: true
preferCSSPageSize: true,
});
await browser.close();

View File

@@ -2,19 +2,31 @@ import sharp from 'sharp';
import path from "path";
import { mkdir, writeFile } from "fs/promises";
import { fileURLToPath } from "url";
import { callOllama } from "./ollamaClient.js";
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 sketch, black and white line drawing, lots of white space, sparse shading, very minimal shading, simple black hatching, very low detail, single accent color`;
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' })
.sharpen()
.blur(0.3)
.sharpen({
sigma: 1,
flat: 1,
jagged: 2,
})
.png({
compressionLevel: 9,
adaptiveFiltering: true,
@@ -51,10 +63,14 @@ Input:
${flavor}
Output:`,
"gemma3n:e4b", 3, "Generate Visual Prompt"
OLLAMA_MODEL,
3,
"Generate Visual Prompt"
);
return `${STYLE_PREFIX}, ${rawPrompt.trim().replace(/\n/g, " ")}`;
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
@@ -151,6 +167,9 @@ async function waitForImage(promptId, timeout = 900000) {
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];
@@ -190,7 +209,7 @@ async function generateImageViaComfyUI(prompt, filename) {
});
if (!res.ok) {
throw new Error(`ComfyUI error: ${res.statusText}`);
throw new Error(`ComfyUI error: ${res.status} ${res.statusText}`);
}
const { prompt_id } = await res.json();
@@ -215,6 +234,11 @@ async function generateImageViaComfyUI(prompt, filename) {
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);

View File

@@ -1,18 +1,28 @@
import 'dotenv/config';
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 {
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();
@@ -30,5 +40,6 @@ function slugify(text) {
console.log(`Dungeon PDF successfully generated: ${filename}`);
} catch (err) {
console.error("Error generating dungeon:", err);
process.exit(1);
}
})();

View File

@@ -1,47 +1,76 @@
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";
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
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}`);
}
}
// 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
.replace(/^#+\s*/gm, "")
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/[*_`]/g, "")
.replace(/\s+/g, " ")
.trim();
}
export async function callOllama(prompt, model = "gemma3n:e4b", retries = 5, stepName = "unknown") {
const isUsingOpenWebUI = !!OLLAMA_API_KEY;
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`);
console.log(
`\n[${stepName}] Sending prompt (attempt ${attempt}/${retries})`,
);
console.log(
`Prompt: ${promptCharCount} chars, ~${promptWordCount} words`,
);
const headers = { "Content-Type": "application/json" };
if (isUsingOpenWebUI) {
if (isUsingOpenWebUI && OLLAMA_API_KEY) {
headers["Authorization"] = `Bearer ${OLLAMA_API_KEY}`;
}
const body = isUsingOpenWebUI
? {
model,
messages: [{ role: "user", content: prompt }],
}
: {
model,
messages: [{ role: "user", content: prompt }],
stream: false,
};
const body = isUsingOpenWebUI || isUsingOllamaChat
? { model, messages: [{ role: "user", content: prompt }] }
: { model, prompt, stream: false };
const response = await fetch(OLLAMA_API_URL, {
method: "POST",
@@ -49,24 +78,34 @@ export async function callOllama(prompt, model = "gemma3n:e4b", retries = 5, ste
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`Ollama request failed: ${response.status} ${response.statusText}`);
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
: data.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`);
// console.log(`Raw output:\n${rawText}\n`);
// console.log(`Cleaned output:\n${cleaned}\n`);
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;
@@ -76,3 +115,23 @@ export async function callOllama(prompt, model = "gemma3n:e4b", retries = 5, ste
}
}
}
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);
}

606
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
"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"
},
@@ -19,6 +20,6 @@
"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
}
}
});
});