Compare commits
5 Commits
2026-01-12
...
2026-01-22
| Author | SHA1 | Date | |
|---|---|---|---|
| 5588108cb6 | |||
| e66df13edd | |||
| 96223b81e6 | |||
| 9332ac6f94 | |||
| c54b1a6082 |
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",
|
||||||
@@ -51,15 +63,15 @@ export function dungeonTemplate(data) {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: ${bodyFont};
|
font-family: ${bodyFont};
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
font-size: 0.65em;
|
font-size: 0.7em;
|
||||||
line-height: 1.2em;
|
line-height: 1.35em;
|
||||||
}
|
}
|
||||||
.content-page {
|
.content-page {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 1.2cm;
|
padding: 1.2cm;
|
||||||
page-break-after: always;
|
page-break-after: always;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
@@ -79,7 +91,7 @@ export function dungeonTemplate(data) {
|
|||||||
font-family: ${quoteFont};
|
font-family: ${quoteFont};
|
||||||
margin: 0.3em 0 0.6em;
|
margin: 0.3em 0 0.6em;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
line-height: 1.2em;
|
line-height: 1.35em;
|
||||||
}
|
}
|
||||||
.columns {
|
.columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -90,18 +102,19 @@ export function dungeonTemplate(data) {
|
|||||||
.col {
|
.col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.15em;
|
gap: 0.25em;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
word-break: break-word;
|
word-break: normal;
|
||||||
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
.section-block {
|
.section-block {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.3em;
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
font-family: ${headingFont};
|
font-family: ${headingFont};
|
||||||
font-size: 0.95em;
|
font-size: 1.05em;
|
||||||
margin: 0.2em 0 0.2em;
|
margin: 0.2em 0 0.2em;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
border-bottom: 1px solid #1a1a1a;
|
border-bottom: 1px solid #1a1a1a;
|
||||||
@@ -116,29 +129,29 @@ export function dungeonTemplate(data) {
|
|||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
}
|
}
|
||||||
.room h3 {
|
.room h3 {
|
||||||
margin: 0.15em 0 0.08em;
|
margin: 0.08em 0 0.03em;
|
||||||
font-size: 0.85em;
|
font-size: 0.9em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
.room p {
|
.room p {
|
||||||
margin: 0 0 0.35em;
|
margin: 0 0 0.15em;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
line-height: 1.25em;
|
line-height: 1.25em;
|
||||||
}
|
}
|
||||||
.encounter, .npc, .treasure, .plot-resolution {
|
.encounter, .npc, .treasure, .plot-resolution {
|
||||||
margin: 0 0 0.3em;
|
margin: 0 0 0.25em;
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
font-size: 0.8em;
|
font-size: 0.85em;
|
||||||
line-height: 1.25em;
|
line-height: 1.3em;
|
||||||
}
|
}
|
||||||
.random-events {
|
.random-events {
|
||||||
margin: 0.2em 0;
|
margin: 0.2em 0;
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
font-size: 0.8em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
.random-events table {
|
.random-events table {
|
||||||
margin-top: 0.15em;
|
margin-top: 0.15em;
|
||||||
@@ -150,7 +163,7 @@ export function dungeonTemplate(data) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: 0.2em 0;
|
margin: 0.2em 0;
|
||||||
font-size: 0.8em;
|
font-size: 0.85em;
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
page-break-inside: avoid;
|
page-break-inside: avoid;
|
||||||
border: 1px solid #1a1a1a;
|
border: 1px solid #1a1a1a;
|
||||||
@@ -165,12 +178,13 @@ export function dungeonTemplate(data) {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
table td {
|
table td {
|
||||||
padding: 0.2em 0.3em;
|
padding: 0.25em 0.4em;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
line-height: 1.3em;
|
line-height: 1.3em;
|
||||||
border-bottom: 1px solid #1a1a1a;
|
border-bottom: 1px solid #1a1a1a;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
|
word-break: normal;
|
||||||
}
|
}
|
||||||
table tr:last-child td {
|
table tr:last-child td {
|
||||||
border-bottom: 1px solid #1a1a1a;
|
border-bottom: 1px solid #1a1a1a;
|
||||||
@@ -183,14 +197,15 @@ export function dungeonTemplate(data) {
|
|||||||
}
|
}
|
||||||
.encounters-table td:nth-child(2) {
|
.encounters-table td:nth-child(2) {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
width: 25%;
|
min-width: 20%;
|
||||||
|
max-width: 30%;
|
||||||
padding-right: 0.5em;
|
padding-right: 0.5em;
|
||||||
border-right: 1px solid #1a1a1a;
|
border-right: 1px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
.encounters-table td:nth-child(3) {
|
.encounters-table td:nth-child(3) {
|
||||||
width: auto;
|
width: auto;
|
||||||
font-size: 0.75em;
|
font-size: 0.8em;
|
||||||
line-height: 1.2em;
|
line-height: 1.3em;
|
||||||
}
|
}
|
||||||
.map-page {
|
.map-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -213,15 +228,15 @@ export function dungeonTemplate(data) {
|
|||||||
}
|
}
|
||||||
li {
|
li {
|
||||||
margin: 0.08em 0;
|
margin: 0.08em 0;
|
||||||
font-size: 0.8em;
|
font-size: 0.85em;
|
||||||
line-height: 1.25em;
|
line-height: 1.3em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="content-page">
|
<div class="content-page">
|
||||||
<h1>${data.title}</h1>
|
<h1>${escapeHtml(data.title)}</h1>
|
||||||
${data.flavor ? `<p class="flavor">${data.flavor}</p>` : ''}
|
${data.flavor ? `<p class="flavor">${escapeHtml(data.flavor)}</p>` : ''}
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -229,7 +244,7 @@ export function dungeonTemplate(data) {
|
|||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h2>Hooks & Rumors</h2>
|
<h2>Hooks & Rumors</h2>
|
||||||
<ul>
|
<ul>
|
||||||
${data.hooksRumors.map(hook => `<li>${hook}</li>`).join('')}
|
${data.hooksRumors.map(hook => `<li>${escapeHtml(hook)}</li>`).join('')}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
@@ -237,14 +252,54 @@ export function dungeonTemplate(data) {
|
|||||||
${data.randomEvents && data.randomEvents.length > 0 ? `
|
${data.randomEvents && data.randomEvents.length > 0 ? `
|
||||||
<div class="section-block random-events">
|
<div class="section-block random-events">
|
||||||
<h2>Random Events (d6)</h2>
|
<h2>Random Events (d6)</h2>
|
||||||
<table>
|
<table class="encounters-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
${data.randomEvents.map((event, index) => `
|
${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>
|
<tr>
|
||||||
<td>${index + 1}</td>
|
<td>${index + 1}</td>
|
||||||
<td>${event}</td>
|
<td><strong>${escapeHtml(eventName)}</strong></td>
|
||||||
|
<td>${escapeHtml(eventDesc)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`;
|
||||||
|
}).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,12 +308,30 @@ export function dungeonTemplate(data) {
|
|||||||
${data.rooms && data.rooms.length > 0 ? `
|
${data.rooms && data.rooms.length > 0 ? `
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h2>Locations</h2>
|
<h2>Locations</h2>
|
||||||
${data.rooms.map(room => `
|
${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">
|
<div class="room">
|
||||||
<h3>${room.name}</h3>
|
<h3>${escapeHtml(room.name)}</h3>
|
||||||
<p>${room.description}</p>
|
<p>${escapeHtml(desc)}</p>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`;
|
||||||
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -270,21 +343,33 @@ export function dungeonTemplate(data) {
|
|||||||
<table class="encounters-table">
|
<table class="encounters-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
${data.encounters.map((encounter, index) => {
|
${data.encounters.map((encounter, index) => {
|
||||||
// Truncate details to 2-3 sentences max to prevent overflow
|
// Truncate details to 4 sentences max to prevent overflow
|
||||||
let details = encounter.details || '';
|
let details = encounter.details || '';
|
||||||
// Remove location prefix from details if present (format: "Location Name: details")
|
// Remove encounter name if it appears at start
|
||||||
details = details.replace(/^[^:]+:\s*/, '');
|
if (details.toLowerCase().startsWith(encounter.name.toLowerCase())) {
|
||||||
// Split into sentences and keep only first 3
|
details = details.substring(encounter.name.length).replace(/^:\s*/, '').trim();
|
||||||
const sentences = details.match(/[^.!?]+[.!?]+/g) || [details];
|
|
||||||
if (sentences.length > 3) {
|
|
||||||
details = sentences.slice(0, 3).join(' ').trim();
|
|
||||||
}
|
}
|
||||||
// Also limit by character count as fallback (max ~250 chars)
|
// Remove location prefix if present (format: "Location Name: description")
|
||||||
if (details.length > 250) {
|
// Handle multiple colons - strip the first one that looks like a location
|
||||||
details = details.substring(0, 247).trim();
|
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
|
// Try to end at a sentence boundary
|
||||||
const lastPeriod = details.lastIndexOf('.');
|
const lastPeriod = details.lastIndexOf('.');
|
||||||
if (lastPeriod > 200) {
|
if (lastPeriod > 280) {
|
||||||
details = details.substring(0, lastPeriod + 1);
|
details = details.substring(0, lastPeriod + 1);
|
||||||
} else {
|
} else {
|
||||||
details += '...';
|
details += '...';
|
||||||
@@ -293,8 +378,8 @@ export function dungeonTemplate(data) {
|
|||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${index + 1}</td>
|
<td>${index + 1}</td>
|
||||||
<td><strong>${encounter.name}</strong></td>
|
<td><strong>${escapeHtml(encounter.name)}</strong></td>
|
||||||
<td>${details}</td>
|
<td>${escapeHtml(details)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
@@ -302,28 +387,44 @@ export function dungeonTemplate(data) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col">
|
|
||||||
${data.treasure && data.treasure.length > 0 ? `
|
${data.treasure && data.treasure.length > 0 ? `
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h2>Treasure</h2>
|
<h2>Treasure</h2>
|
||||||
${data.treasure.map(item => `
|
${data.treasure.map(item => {
|
||||||
<div class="treasure">
|
if (typeof item === 'object' && item.name && item.description) {
|
||||||
${typeof item === 'object' && item.name ? `<strong>${item.name}</strong> — ${item.description}` : item}
|
return `<div class="treasure"><strong>${escapeHtml(item.name)}</strong> — ${escapeHtml(item.description)}</div>`;
|
||||||
</div>
|
} else if (typeof item === 'string') {
|
||||||
`).join('')}
|
// 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>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
${data.npcs && data.npcs.length > 0 ? `
|
${data.npcs && data.npcs.length > 0 ? `
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h2>NPCs</h2>
|
<h2>NPCs</h2>
|
||||||
${data.npcs.map(npc => `
|
${data.npcs.map(npc => {
|
||||||
<div class="npc">
|
if (typeof npc === 'object' && npc.name && npc.trait) {
|
||||||
<strong>${npc.name}</strong>: ${npc.trait}
|
return `<div class="npc"><strong>${escapeHtml(npc.name)}</strong>: ${escapeHtml(npc.trait)}</div>`;
|
||||||
</div>
|
} else if (typeof npc === 'string') {
|
||||||
`).join('')}
|
// 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>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
@@ -331,19 +432,19 @@ export function dungeonTemplate(data) {
|
|||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<h2>Plot Resolutions</h2>
|
<h2>Plot Resolutions</h2>
|
||||||
${data.plotResolutions.map(resolution => {
|
${data.plotResolutions.map(resolution => {
|
||||||
// Truncate to 1-2 sentences max to prevent overflow
|
// Truncate to 1 sentence max to prevent overflow (more aggressive)
|
||||||
let text = resolution || '';
|
let text = resolution || '';
|
||||||
// Split into sentences and keep only first 2
|
// Split into sentences and keep only first 1
|
||||||
const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
|
const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
|
||||||
if (sentences.length > 2) {
|
if (sentences.length > 1) {
|
||||||
text = sentences.slice(0, 2).join(' ').trim();
|
text = sentences.slice(0, 1).join(' ').trim();
|
||||||
}
|
}
|
||||||
// Also limit by character count as fallback (max ~150 chars)
|
// Also limit by character count as fallback (max ~120 chars for tighter fit)
|
||||||
if (text.length > 150) {
|
if (text.length > 120) {
|
||||||
text = text.substring(0, 147).trim();
|
text = text.substring(0, 117).trim();
|
||||||
// Try to end at a sentence boundary
|
// Try to end at a sentence boundary
|
||||||
const lastPeriod = text.lastIndexOf('.');
|
const lastPeriod = text.lastIndexOf('.');
|
||||||
if (lastPeriod > 100) {
|
if (lastPeriod > 90) {
|
||||||
text = text.substring(0, lastPeriod + 1);
|
text = text.substring(0, lastPeriod + 1);
|
||||||
} else {
|
} else {
|
||||||
text += '...';
|
text += '...';
|
||||||
@@ -351,7 +452,7 @@ export function dungeonTemplate(data) {
|
|||||||
}
|
}
|
||||||
return `
|
return `
|
||||||
<div class="plot-resolution">
|
<div class="plot-resolution">
|
||||||
${text}
|
${escapeHtml(text)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
|
|||||||
5
index.js
5
index.js
@@ -2,7 +2,7 @@ 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 } from "./ollamaClient.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) {
|
||||||
@@ -18,6 +18,9 @@ function slugify(text) {
|
|||||||
throw new Error("OLLAMA_API_URL environment variable is required");
|
throw new Error("OLLAMA_API_URL environment variable is required");
|
||||||
}
|
}
|
||||||
console.log("Using Ollama API URL:", process.env.OLLAMA_API_URL);
|
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);
|
console.log("Using Ollama model:", OLLAMA_MODEL);
|
||||||
|
|
||||||
// Generate the dungeon data
|
// Generate the dungeon data
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
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 const OLLAMA_MODEL = process.env.OLLAMA_MODEL || "gemma3:latest";
|
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) {
|
function cleanText(str) {
|
||||||
return str
|
return str
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
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