Compare commits
21 Commits
27dfed05ac
...
2026-02-11
| Author | SHA1 | Date | |
|---|---|---|---|
| 07128c3529 | |||
| 5588108cb6 | |||
| e66df13edd | |||
| 96223b81e6 | |||
| 9332ac6f94 | |||
| c54b1a6082 | |||
| 3b91ce3068 | |||
| c7bb0f04df | |||
| 05526b06d6 | |||
| af447da042 | |||
| c48188792d | |||
| 1059eced53 | |||
|
|
96480a351f | ||
| dc9ec367a0 | |||
| 799ee18dc2 | |||
| 277a3ba718 | |||
|
|
a3c54b1c82 | ||
|
|
be7534be8d | ||
|
|
23fae22735 | ||
|
|
d436284476 | ||
|
|
800c9c488c |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -2,3 +2,13 @@
|
|||||||
*.png
|
*.png
|
||||||
.env
|
.env
|
||||||
node_modules/**
|
node_modules/**
|
||||||
|
|
||||||
|
# macOS dotfiles
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
.env.example
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -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_URL=http://localhost:3000/api/chat/completions
|
||||||
OLLAMA_API_KEY=your_api_key_here
|
OLLAMA_API_KEY=your_api_key_here
|
||||||
COMFYUI_URL=http://192.168.1.124:8188
|
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
|
## Usage
|
||||||
|
|
||||||
1. Make sure your Ollama server is running and `.env` is configured.
|
1. Make sure your Ollama server is running and `.env` is configured.
|
||||||
|
|||||||
1235
dungeonGenerator.js
1235
dungeonGenerator.js
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,18 @@ function pickRandom(arr) {
|
|||||||
return arr[Math.floor(Math.random() * arr.length)];
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
return String(text).replace(/[&<>"']/g, m => map[m]);
|
||||||
|
}
|
||||||
|
|
||||||
export function dungeonTemplate(data) {
|
export function dungeonTemplate(data) {
|
||||||
const bodyFonts = [
|
const bodyFonts = [
|
||||||
"'Lora', serif",
|
"'Lora', serif",
|
||||||
@@ -11,156 +23,452 @@ export function dungeonTemplate(data) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const headingFonts = [
|
const headingFonts = [
|
||||||
|
"'New Rocker', system-ui",
|
||||||
|
"'UnifrakturCook', cursive",
|
||||||
|
"'IM Fell DW Pica', serif",
|
||||||
"'Cinzel', serif",
|
"'Cinzel', serif",
|
||||||
"'MedievalSharp', serif",
|
|
||||||
"'Cormorant Garamond', serif",
|
"'Cormorant Garamond', serif",
|
||||||
"'Playfair Display', serif"
|
"'Playfair Display', serif"
|
||||||
];
|
];
|
||||||
|
|
||||||
const tableFonts = [
|
|
||||||
"'Alegreya Sans', sans-serif",
|
|
||||||
"'Cabin', sans-serif",
|
|
||||||
"'IBM Plex Sans', sans-serif",
|
|
||||||
"'Cormorant Garamond', serif"
|
|
||||||
];
|
|
||||||
|
|
||||||
const quoteFonts = [
|
const quoteFonts = [
|
||||||
"'Playfair Display', serif",
|
"'Playfair Display', serif",
|
||||||
"'Uncial Antiqua', serif",
|
|
||||||
"'Libre Baskerville', serif",
|
"'Libre Baskerville', serif",
|
||||||
"'Merriweather', serif"
|
"'Merriweather', serif"
|
||||||
];
|
];
|
||||||
|
|
||||||
const bodyFont = pickRandom(bodyFonts);
|
const bodyFont = pickRandom(bodyFonts);
|
||||||
const headingFont = pickRandom(headingFonts);
|
const headingFont = pickRandom(headingFonts);
|
||||||
const tableFont = pickRandom(tableFonts);
|
|
||||||
const quoteFont = pickRandom(quoteFonts);
|
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 `
|
return `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>${data.title}</title>
|
<title>${data.title}</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative&family=MedievalSharp&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">
|
<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>
|
<style>
|
||||||
@page { size: A4 landscape; margin: 0; }
|
@page {
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0; padding: 1.5cm;
|
margin: 0;
|
||||||
background: #f5f5f5;
|
padding: 0;
|
||||||
font-family: ${bodyFont};
|
font-family: ${bodyFont};
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
line-height: 1.25em;
|
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 {
|
h1 {
|
||||||
font-family: ${headingFont};
|
font-family: ${headingFont};
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 2em;
|
text-transform: uppercase;
|
||||||
margin: 0.2em 0 0.3em;
|
font-size: 1.8em;
|
||||||
|
margin: 0.15em 0 0.2em;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
border-bottom: 2px solid #1a1a1a;
|
border-bottom: 2px solid #1a1a1a;
|
||||||
padding-bottom: 0.2em;
|
padding-bottom: 0.15em;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
.flavor {
|
.flavor {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-family: ${quoteFont};
|
font-family: ${quoteFont};
|
||||||
margin: 0.4em 0 0.8em;
|
margin: 0.3em 0 0.6em;
|
||||||
font-size: 0.9em;
|
font-size: 0.85em;
|
||||||
|
line-height: 1.35em;
|
||||||
}
|
}
|
||||||
.columns {
|
.columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
gap: 0.5cm;
|
gap: 0.4cm;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
.col {
|
.col {
|
||||||
display: flex; flex-direction: column;
|
display: flex;
|
||||||
gap: 0.15em;
|
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 {
|
h2 {
|
||||||
font-family: ${headingFont};
|
font-family: ${headingFont};
|
||||||
font-size: 1.0em;
|
font-size: 1.05em;
|
||||||
margin: 0.3em 0 0.1em;
|
margin: 0.2em 0 0.2em;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
border-bottom: 1px solid #1a1a1a;
|
border-bottom: 1px solid #1a1a1a;
|
||||||
padding-bottom: 0.1em;
|
padding-bottom: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
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;
|
||||||
}
|
}
|
||||||
.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.3em; }
|
|
||||||
li { margin-bottom: 0.2em; }
|
|
||||||
table { width: 100%; border-collapse: collapse; font-family: ${tableFont}; font-size: 0.8em; }
|
|
||||||
th, td { border: 1px solid #1a1a1a; padding: 0.2em; text-align: left; vertical-align: top; }
|
|
||||||
th { background: #e0e0e0; }
|
|
||||||
table tr:hover { background: rgba(0, 0, 0, 0.05); }
|
|
||||||
.map-page {
|
.map-page {
|
||||||
page-break-before: always;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 1.5cm;
|
page-break-before: always;
|
||||||
height: calc(100vh - 3cm);
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
}
|
||||||
.map-page img {
|
.map-container {
|
||||||
|
text-align: center;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
.map-container img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: calc(100vh - 3cm);
|
||||||
height: auto;
|
border: 1px solid #1a1a1a;
|
||||||
width: auto;
|
|
||||||
border-radius: 0.2cm;
|
|
||||||
object-fit: contain;
|
|
||||||
box-shadow:
|
|
||||||
0 0 20px 15px #f5f5f5 inset,
|
|
||||||
0 0 5px 2px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
}
|
||||||
footer {
|
ul {
|
||||||
text-align: center; font-size: 0.65em; color: #555; margin-top: 0.5em; font-style: italic;
|
margin: 0.2em 0;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin: 0.08em 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.3em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>${data.title}</h1>
|
<div class="content-page">
|
||||||
<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="columns">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2>Adventure Hooks & Rumors</h2>
|
${data.hooksRumors && data.hooksRumors.length > 0 ? `
|
||||||
<ul>${data.hooksRumors.map(item => `<li>${item}</li>`).join("")}</ul>
|
<div class="section-block">
|
||||||
|
<h2>Hooks & Rumors</h2>
|
||||||
<h2>Locations</h2>
|
<ul>
|
||||||
${data.rooms.map((room, i) => `<div class="room"><h3>${i + 1}. ${room.name}</h3><p>${room.description}</p></div>`).join("")}
|
${data.hooksRumors.map(hook => `<li>${escapeHtml(hook)}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<div class="col">
|
${data.randomEvents && data.randomEvents.length > 0 ? `
|
||||||
<h2>Encounters</h2>
|
<div class="section-block random-events">
|
||||||
<table><tr><th>Name</th><th>Details</th></tr>
|
<h2>Random Events (d6)</h2>
|
||||||
${data.encounters.map(e => `<tr><td>${e.name}</td><td>${e.details}</td></tr>`).join("")}
|
<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>
|
</table>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<h2>Treasure</h2>
|
${data.rooms && data.rooms.length > 0 ? `
|
||||||
<ul>${data.treasure.map(t => `<li>${t}</li>`).join("")}</ul>
|
<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>
|
||||||
|
|
||||||
<div class="col">
|
<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>
|
<h2>NPCs</h2>
|
||||||
<ul>${data.npcs.map(n => `<li><b>${n.name}</b>: ${n.trait}</li>`).join("")}</ul>
|
${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>
|
<h2>Plot Resolutions</h2>
|
||||||
<ul>${data.plotResolutions.map(p => `<li>${p}</li>`).join("")}</ul>
|
${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>
|
</div>
|
||||||
|
|
||||||
<div class="map-page">
|
${hasMap ? `
|
||||||
<img src="${data.map}" alt="Dungeon Map">
|
<div class="content-page map-page">
|
||||||
|
<div class="map-container">
|
||||||
|
<img src="${data.map}" alt="Dungeon Map" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<footer>Generated with Scrollsmith • © ${new Date().getFullYear()}</footer>
|
` : ''}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
import puppeteer from "puppeteer";
|
import puppeteer from "puppeteer";
|
||||||
import { dungeonTemplate } from "./dungeonTemplate.js";
|
import { dungeonTemplate } from "./dungeonTemplate.js";
|
||||||
|
import fs from "fs/promises";
|
||||||
import fs from 'fs/promises';
|
|
||||||
|
|
||||||
export async function generatePDF(data, outputPath = "dungeon.pdf") {
|
export async function generatePDF(data, outputPath = "dungeon.pdf") {
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
||||||
// Convert image to base64
|
const toBase64DataUrl = (buffer) =>
|
||||||
const imageBuffer = await fs.readFile(data.map);
|
`data:image/png;base64,${buffer.toString("base64")}`;
|
||||||
const base64Image = `data:image/png;base64,${imageBuffer.toString("base64")}`;
|
|
||||||
data.map = base64Image;
|
|
||||||
|
|
||||||
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.setContent(html, { waitUntil: "networkidle0" });
|
||||||
|
|
||||||
await page.pdf({
|
await page.pdf({
|
||||||
@@ -23,6 +36,7 @@ export async function generatePDF(data, outputPath = "dungeon.pdf") {
|
|||||||
format: "A4",
|
format: "A4",
|
||||||
landscape: true,
|
landscape: true,
|
||||||
printBackground: true,
|
printBackground: true,
|
||||||
|
preferCSSPageSize: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
@@ -1,13 +1,45 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { mkdir, writeFile } from "fs/promises";
|
import { mkdir, writeFile } from "fs/promises";
|
||||||
import { fileURLToPath } from "url";
|
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 __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const COMFYUI_URL = process.env.COMFYUI_URL || "http://localhost:8188";
|
const COMFYUI_URL = process.env.COMFYUI_URL || "http://localhost:8188";
|
||||||
|
|
||||||
// Drawing style prefix
|
// 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, simple hatching, very low detail, subtle color accent`;
|
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
|
// 1. Generate engineered visual prompt
|
||||||
async function generateVisualPrompt(flavor) {
|
async function generateVisualPrompt(flavor) {
|
||||||
@@ -17,22 +49,28 @@ async function generateVisualPrompt(flavor) {
|
|||||||
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.
|
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:
|
Rules:
|
||||||
- Describe a sparse scene with a single focal point or area.
|
- Describe a sparse scene with a single focal point or landscape.
|
||||||
- Use only 3-5 key descriptive phrases or tags.
|
- Use only 3-5 key descriptive phrases or tags.
|
||||||
- The entire output should be very short, 20-50 words maximum.
|
- The entire output should be very short, 20-50 words maximum.
|
||||||
- Do NOT repeat wording from the input.
|
- Do NOT repeat wording from the input.
|
||||||
- Focus only on visual content, not style, medium, or camera effects.
|
- 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.
|
- 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 phrases like “an image of” or “a scene showing”.
|
||||||
|
- Do NOT include the word "Obsidian" or "obsidian" at all.
|
||||||
|
|
||||||
Input:
|
Input:
|
||||||
${flavor}
|
${flavor}
|
||||||
|
|
||||||
Output:`,
|
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
|
// 2. Save image buffer
|
||||||
@@ -51,7 +89,7 @@ function buildComfyWorkflow(promptText, negativeText = "") {
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"seed": Math.floor(Math.random() * 100000),
|
"seed": Math.floor(Math.random() * 100000),
|
||||||
"steps": 4,
|
"steps": 4,
|
||||||
"cfg": 3,
|
"cfg": 1,
|
||||||
"sampler_name": "euler",
|
"sampler_name": "euler",
|
||||||
"scheduler": "simple",
|
"scheduler": "simple",
|
||||||
"denoise": 1,
|
"denoise": 1,
|
||||||
@@ -71,8 +109,8 @@ function buildComfyWorkflow(promptText, negativeText = "") {
|
|||||||
},
|
},
|
||||||
"5": {
|
"5": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"width": 640,
|
"width": 728,
|
||||||
"height": 448,
|
"height": 512,
|
||||||
"batch_size": 1
|
"batch_size": 1
|
||||||
},
|
},
|
||||||
"class_type": "EmptyLatentImage"
|
"class_type": "EmptyLatentImage"
|
||||||
@@ -129,6 +167,9 @@ async function waitForImage(promptId, timeout = 900000) {
|
|||||||
|
|
||||||
while (Date.now() - start < timeout) {
|
while (Date.now() - start < timeout) {
|
||||||
const res = await fetch(`${COMFYUI_URL}/history`);
|
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 data = await res.json();
|
||||||
const historyEntry = data[promptId];
|
const historyEntry = data[promptId];
|
||||||
|
|
||||||
@@ -156,7 +197,7 @@ async function downloadImage(filename, localFilename) {
|
|||||||
|
|
||||||
// 4c. Submit prompt and handle full image pipeline
|
// 4c. Submit prompt and handle full image pipeline
|
||||||
async function generateImageViaComfyUI(prompt, filename) {
|
async function generateImageViaComfyUI(prompt, filename) {
|
||||||
const negativePrompt = `heavy shading, deep blacks, cross-hatching, 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`;
|
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);
|
const workflow = buildComfyWorkflow(prompt, negativePrompt);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -168,7 +209,7 @@ async function generateImageViaComfyUI(prompt, filename) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
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();
|
const { prompt_id } = await res.json();
|
||||||
@@ -193,15 +234,27 @@ async function generateImageViaComfyUI(prompt, filename) {
|
|||||||
export async function generateDungeonImages({ flavor }) {
|
export async function generateDungeonImages({ flavor }) {
|
||||||
console.log("Generating dungeon image...");
|
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);
|
const finalPrompt = await generateVisualPrompt(flavor);
|
||||||
console.log("Engineered visual prompt:\n", finalPrompt);
|
console.log("Engineered visual prompt:\n", finalPrompt);
|
||||||
|
|
||||||
const filename = `dungeon.png`;
|
const baseFilename = `dungeon.png`;
|
||||||
const filepath = await generateImageViaComfyUI(finalPrompt, filename);
|
const upscaledFilename = `dungeon_upscaled.png`;
|
||||||
|
|
||||||
|
const filepath = await generateImageViaComfyUI(finalPrompt, baseFilename);
|
||||||
if (!filepath) {
|
if (!filepath) {
|
||||||
throw new Error("Failed to generate dungeon image.");
|
throw new Error("Failed to generate dungeon image.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath;
|
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
17
index.js
17
index.js
@@ -1,18 +1,28 @@
|
|||||||
import 'dotenv/config';
|
import "dotenv/config";
|
||||||
import { generateDungeon } from "./dungeonGenerator.js";
|
import { generateDungeon } from "./dungeonGenerator.js";
|
||||||
import { generateDungeonImages } from "./imageGenerator.js";
|
import { generateDungeonImages } from "./imageGenerator.js";
|
||||||
import { generatePDF } from "./generatePDF.js";
|
import { generatePDF } from "./generatePDF.js";
|
||||||
|
import { OLLAMA_MODEL, initializeModel } from "./ollamaClient.js";
|
||||||
|
|
||||||
// Utility to create a filesystem-safe filename from the dungeon title
|
// Utility to create a filesystem-safe filename from the dungeon title
|
||||||
function slugify(text) {
|
function slugify(text) {
|
||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-') // replace non-alphanumeric with hyphens
|
.replace(/[^a-z0-9]+/g, "-") // replace non-alphanumeric with hyphens
|
||||||
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
|
.replace(/^-+|-+$/g, ""); // trim leading/trailing hyphens
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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
|
// Generate the dungeon data
|
||||||
const dungeonData = await generateDungeon();
|
const dungeonData = await generateDungeon();
|
||||||
|
|
||||||
@@ -30,5 +40,6 @@ function slugify(text) {
|
|||||||
console.log(`Dungeon PDF successfully generated: ${filename}`);
|
console.log(`Dungeon PDF successfully generated: ${filename}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error generating dungeon:", err);
|
console.error("Error generating dungeon:", err);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
121
ollamaClient.js
121
ollamaClient.js
@@ -1,47 +1,76 @@
|
|||||||
const OLLAMA_API_URL = process.env.OLLAMA_API_URL;
|
const OLLAMA_API_URL = process.env.OLLAMA_API_URL;
|
||||||
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY;
|
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY;
|
||||||
|
export let OLLAMA_MODEL = process.env.OLLAMA_MODEL || "gemma3:latest";
|
||||||
|
|
||||||
async function sleep(ms) {
|
export async function initializeModel() {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
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) {
|
function cleanText(str) {
|
||||||
return str
|
return str
|
||||||
.replace(/^#+\s*/gm, "") // remove headers
|
.replace(/^#+\s*/gm, "")
|
||||||
.replace(/\*\*(.*?)\*\*/g, "$1") // remove bold
|
.replace(/\*\*(.*?)\*\*/g, "$1")
|
||||||
.replace(/[*_`]/g, "") // remove stray formatting
|
.replace(/[*_`]/g, "")
|
||||||
.replace(/\s+/g, " ") // normalize whitespace
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function callOllama(prompt, model = "gemma3n:e4b", retries = 5, stepName = "unknown") {
|
function inferApiType(url) {
|
||||||
const isUsingOpenWebUI = !!OLLAMA_API_KEY;
|
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++) {
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
try {
|
try {
|
||||||
const promptCharCount = prompt.length;
|
const promptCharCount = prompt.length;
|
||||||
const promptWordCount = prompt.split(/\s+/).length;
|
const promptWordCount = prompt.split(/\s+/).length;
|
||||||
|
|
||||||
console.log(`\n[${stepName}] Sending prompt (attempt ${attempt}/${retries})`);
|
console.log(
|
||||||
console.log(`Prompt: ${promptCharCount} chars, ~${promptWordCount} words`);
|
`\n[${stepName}] Sending prompt (attempt ${attempt}/${retries})`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Prompt: ${promptCharCount} chars, ~${promptWordCount} words`,
|
||||||
|
);
|
||||||
|
|
||||||
const headers = { "Content-Type": "application/json" };
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (isUsingOpenWebUI && OLLAMA_API_KEY) {
|
||||||
if (isUsingOpenWebUI) {
|
|
||||||
headers["Authorization"] = `Bearer ${OLLAMA_API_KEY}`;
|
headers["Authorization"] = `Bearer ${OLLAMA_API_KEY}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = isUsingOpenWebUI
|
const body = isUsingOpenWebUI || isUsingOllamaChat
|
||||||
? {
|
? { model, messages: [{ role: "user", content: prompt }] }
|
||||||
model,
|
: { model, prompt, stream: false };
|
||||||
messages: [{ role: "user", content: prompt }],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
model,
|
|
||||||
messages: [{ role: "user", content: prompt }],
|
|
||||||
stream: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(OLLAMA_API_URL, {
|
const response = await fetch(OLLAMA_API_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -49,24 +78,34 @@ export async function callOllama(prompt, model = "gemma3n:e4b", retries = 5, ste
|
|||||||
body: JSON.stringify(body),
|
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 data = await response.json();
|
||||||
|
|
||||||
const rawText = isUsingOpenWebUI
|
const rawText = isUsingOpenWebUI
|
||||||
? data.choices?.[0]?.message?.content
|
? data.choices?.[0]?.message?.content
|
||||||
: data.message?.content;
|
: isUsingOllamaChat
|
||||||
|
? data.message?.content
|
||||||
|
: data.response;
|
||||||
|
|
||||||
if (!rawText) throw new Error("No response from Ollama");
|
if (!rawText) throw new Error("No response from Ollama");
|
||||||
|
|
||||||
const cleaned = cleanText(rawText);
|
const cleaned = cleanText(rawText);
|
||||||
|
console.log(
|
||||||
console.log(`[${stepName}] Received: ${rawText.length} chars, ~${rawText.split(/\s+/).length} words`);
|
`[${stepName}] Received: ${rawText.length} chars, ~${rawText.split(/\s+/).length} words`,
|
||||||
console.log(`Raw output:\n${rawText}\n`);
|
);
|
||||||
console.log(`Cleaned output:\n${cleaned}\n`);
|
|
||||||
|
|
||||||
return cleaned;
|
return cleaned;
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[${stepName}] Attempt ${attempt} failed: ${err.message}`);
|
console.warn(`[${stepName}] Attempt ${attempt} failed: ${err.message}`);
|
||||||
if (attempt === retries) throw err;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
828
package-lock.json
generated
828
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"test:integration": "node --test test/integration.test.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"start": "node index.js"
|
"start": "node index.js"
|
||||||
},
|
},
|
||||||
@@ -13,11 +14,12 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"puppeteer": "^24.17.1"
|
"puppeteer": "^24.17.1",
|
||||||
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.34.0",
|
"@eslint/js": "^9.34.0",
|
||||||
"eslint": "^9.34.0",
|
"eslint": "^9.34.0",
|
||||||
"globals": "^16.3.0"
|
"globals": "^17.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
91
test/integration.test.js
Normal file
91
test/integration.test.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user