Compare commits
17 Commits
2025-10-25
...
2026-02-22
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e7369cd25 | |||
| 3ef8f05e1d | |||
| 9bd0ded5a6 | |||
| 83eee20b2c | |||
| 07128c3529 | |||
| 5588108cb6 | |||
| e66df13edd | |||
| 96223b81e6 | |||
| 9332ac6f94 | |||
| c54b1a6082 | |||
| 3b91ce3068 | |||
| c7bb0f04df | |||
| 05526b06d6 | |||
| af447da042 | |||
| c48188792d | |||
| 1059eced53 | |||
|
|
96480a351f |
16
.gitignore
vendored
16
.gitignore
vendored
@@ -1,4 +1,18 @@
|
||||
*.pdf
|
||||
*.png
|
||||
.env
|
||||
node_modules/**
|
||||
node_modules/
|
||||
|
||||
# Coverage and test artifacts
|
||||
coverage/
|
||||
|
||||
# macOS dotfiles
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
.env.example
|
||||
|
||||
19
.woodpecker/pr.yml
Normal file
19
.woodpecker/pr.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
workspace:
|
||||
base: /woodpecker
|
||||
path: pr
|
||||
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: node:24-slim
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run lint
|
||||
|
||||
- name: test-coverage
|
||||
image: node:24-slim
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run test:coverage
|
||||
@@ -1,30 +1,16 @@
|
||||
workspace:
|
||||
base: /woodpecker
|
||||
path: src
|
||||
path: release
|
||||
|
||||
when:
|
||||
- event: cron
|
||||
branch: main
|
||||
- event: pull_request
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: node:22
|
||||
when:
|
||||
event:
|
||||
- pull_request
|
||||
- push
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run lint
|
||||
|
||||
- name: generate-dungeon
|
||||
image: ghcr.io/puppeteer/puppeteer:latest
|
||||
when:
|
||||
event:
|
||||
- cron
|
||||
environment:
|
||||
OLLAMA_API_URL:
|
||||
from_secret: OLLAMA_API_URL
|
||||
@@ -38,9 +24,6 @@ steps:
|
||||
|
||||
- name: upload-to-gitea-release
|
||||
image: curlimages/curl:latest
|
||||
when:
|
||||
event:
|
||||
- cron
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: GITEA_TOKEN
|
||||
38
README.md
38
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_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.
|
||||
@@ -58,6 +79,19 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
- **`index.js`** – Entry point: loads env, initializes the Ollama model, runs dungeon generation, image generation, and PDF output.
|
||||
- **`src/`** – Application modules:
|
||||
- `dungeonGenerator.js` – LLM-backed dungeon content generation and validation.
|
||||
- `dungeonTemplate.js` – HTML template and layout for the PDF.
|
||||
- `ollamaClient.js` – Ollama/Open WebUI API client and text cleaning.
|
||||
- `imageGenerator.js` – Map image generation (Ollama + optional ComfyUI).
|
||||
- `generatePDF.js` – Puppeteer-based PDF generation from the template.
|
||||
- **`test/`** – Unit tests (`test/unit/`) and integration tests (`test/integration/`).
|
||||
|
||||
---
|
||||
|
||||
## Example Output
|
||||
|
||||
* `the-tomb-of-shadows.pdf`
|
||||
@@ -78,4 +112,4 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
|
||||
|
||||
## License
|
||||
|
||||
PROPRIETARY
|
||||
PROPRIETARY
|
||||
@@ -1,146 +0,0 @@
|
||||
import { callOllama } from "./ollamaClient.js";
|
||||
|
||||
// Utility: strip markdown artifacts and clean up extra whitespace
|
||||
function cleanText(str) {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/^#+\s*/gm, "")
|
||||
.replace(/\*\*(.*?)\*\*/g, "$1") // Removes bolding
|
||||
.replace(/[*_`]/g, "") // Removes other markdown
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseList(raw) {
|
||||
return raw
|
||||
.split(/\n?\d+[).]\s+/)
|
||||
.map(line => cleanText(line))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseObjects(raw, type = "rooms") {
|
||||
let cleanedRaw = raw.replace(/Intermediate Rooms:/i, "").replace(/Climax Room:/i, "").trim();
|
||||
return cleanedRaw
|
||||
.split(/\n?\d+[).]\s+/)
|
||||
.map(entry => cleanText(entry))
|
||||
.filter(Boolean)
|
||||
.map(entry => {
|
||||
const [name, ...descParts] = entry.split(/[-–—:]/);
|
||||
const desc = descParts.join(" ").trim();
|
||||
if (type === "rooms") return { name: name.trim(), description: desc };
|
||||
if (type === "encounters") return { name: name.trim(), details: desc };
|
||||
if (type === "npcs") return { name: name.trim(), trait: desc };
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateDungeon() {
|
||||
// Step 1: Titles
|
||||
const generatedTitlesRaw = await callOllama(
|
||||
`Generate 50 short, punchy dungeon titles (max 5 words each), numbered as a plain text list.
|
||||
Each title should come from a different style or theme. Make the set varied and evocative. For example:
|
||||
|
||||
- OSR / classic tabletop: gritty, mysterious, old-school
|
||||
- Mörk Borg: dark, apocalyptic, foreboding
|
||||
- Pulpy fantasy: adventurous, dramatic, larger-than-life
|
||||
- Mildly sci-fi: alien, technological, strange
|
||||
- Weird fantasy: uncanny, surreal, unsettling
|
||||
- Whimsical: fun, quirky, playful
|
||||
|
||||
Avoid repeating materials or adjectives. Absolutely do not use the words "Obsidian" or "Clockwork" in any title. Do not include explanations, markdown, or preambles. Do not include the style or theme in parenthesis. Only the 50 numbered titles.`,
|
||||
undefined, 5, "Step 1: Titles"
|
||||
);
|
||||
const generatedTitles = parseList(generatedTitlesRaw);
|
||||
console.log("Generated Titles:", generatedTitles);
|
||||
const title = generatedTitles[Math.floor(Math.random() * generatedTitles.length)];
|
||||
console.log("Selected title:", title);
|
||||
|
||||
// Step 2: Core Concepts
|
||||
const coreConceptsRaw = await callOllama(
|
||||
`For a dungeon titled "${title}", generate three core concepts: a central conflict, a primary faction, and a major environmental hazard or dynamic element.
|
||||
Output as a numbered list with bolded headings. Plain text only. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.
|
||||
Example:
|
||||
1. **Central Conflict:** The dungeon's power source is failing, causing reality to warp.
|
||||
2. **Primary Faction:** A group of rival cultists trying to seize control of the power source.
|
||||
3. **Dynamic Element:** Zones of temporal distortion that cause random, brief time shifts.`,
|
||||
undefined, 5, "Step 2: Core Concepts"
|
||||
);
|
||||
const coreConcepts = coreConceptsRaw;
|
||||
console.log("Core Concepts:", coreConcepts);
|
||||
|
||||
// Step 3: Flavor Text & Hooks
|
||||
const flavorHooksRaw = await callOllama(
|
||||
`Based on the title "${title}" and these core concepts:
|
||||
${coreConcepts}
|
||||
Write a single evocative paragraph describing the location. Maximum 2 sentences. Maximum 100 words. Then, generate 3 short adventure hooks or rumors.
|
||||
The hooks should reference the central conflict, faction, and dynamic element.
|
||||
Output two sections labeled "Description:" and "Hooks & Rumors:". Use a numbered list for the hooks. Plain text only. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
|
||||
undefined, 5, "Step 3: Flavor & Hooks"
|
||||
);
|
||||
const [flavorSection, hooksSection] = flavorHooksRaw.split(/Hooks & Rumors[:\n]/i);
|
||||
const flavor = cleanText(flavorSection.replace(/Description[:\n]*/i, ""));
|
||||
const hooksRumors = parseList(hooksSection || "");
|
||||
console.log("Flavor Text:", flavor);
|
||||
console.log("Hooks & Rumors:", hooksRumors);
|
||||
|
||||
// Step 4: Key Rooms
|
||||
const keyRoomsRaw = await callOllama(
|
||||
`Based on the title "${title}", description "${flavor}", and these core concepts:
|
||||
${coreConcepts}
|
||||
Generate two key rooms that define the dungeon's narrative arc.
|
||||
1. **Entrance Room:** Give it a name and a description that sets the tone and introduces the environmental hazard.
|
||||
2. **Climax Room:** Give it a name and a description that includes the primary faction and the central conflict.
|
||||
Output as two numbered items, plain text only. Do not use bolded headings. Do not include any intro or other text. Only the numbered list. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
|
||||
undefined, 5, "Step 4: Key Rooms"
|
||||
);
|
||||
const [entranceSection, climaxSection] = keyRoomsRaw.split(/\n?2[).] /); // Split on "2. " to separate the two rooms
|
||||
const entranceRoom = parseObjects(entranceSection, "rooms")[0];
|
||||
const climaxRoom = parseObjects(`1. ${climaxSection}`, "rooms")[0]; // Prepend "1. " to make parsing consistent
|
||||
console.log("Entrance Room:", entranceRoom);
|
||||
console.log("Climax Room:", climaxRoom);
|
||||
|
||||
// Step 5: Main Content (Locations, Encounters, NPCs, Treasures)
|
||||
const mainContentRaw = await callOllama(
|
||||
`Based on the following dungeon elements and the need for narrative flow:
|
||||
Title: "${title}"
|
||||
Description: "${flavor}"
|
||||
Core Concepts:
|
||||
${coreConcepts}
|
||||
Entrance Room: ${JSON.stringify(entranceRoom)}
|
||||
Climax Room: ${JSON.stringify(climaxRoom)}
|
||||
|
||||
Generate the rest of the dungeon's content to fill the space between the entrance and the climax.
|
||||
- **Strictly 3 Locations:** Each with a name and a short description (max 20 words). The description must be a single sentence. It should contain an environmental feature, a puzzle, or an element that connects to the core concepts or the final room.
|
||||
- **Strictly 4 Encounters:** Name and details. At least two encounters must be directly tied to the primary faction.
|
||||
- **Strictly 3 NPCs:** Proper name and a trait. One NPC should be a member of the primary faction, one should be a potential ally, and one should be a rival.
|
||||
- **Strictly 3 Treasures:** Name and a description that includes a danger or side-effect. Each treasure should be thematically tied to a specific encounter or room.
|
||||
Output as four separate numbered lists. Label the lists as "Locations:", "Encounters:", "NPCs:", and "Treasures:". Do not use any bolding, preambles, or extra text. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
|
||||
undefined, 5, "Step 5: Main Content"
|
||||
);
|
||||
const [intermediateRoomsSection, encountersSection, npcsSection, treasureSection] = mainContentRaw.split(/Encounters:|NPCs:|Treasures?:/i);
|
||||
const intermediateRooms = parseObjects(intermediateRoomsSection.replace(/Locations:/i, ""), "rooms");
|
||||
const rooms = [entranceRoom, ...intermediateRooms, climaxRoom];
|
||||
const encounters = parseObjects(encountersSection || "", "encounters");
|
||||
const npcs = parseObjects(npcsSection || "", "npcs");
|
||||
const treasure = parseList(treasureSection || "");
|
||||
console.log("Rooms:", rooms);
|
||||
console.log("Encounters:", encounters);
|
||||
console.log("NPCs:", npcs);
|
||||
console.log("Treasure:", treasure);
|
||||
|
||||
// Step 6: Player Choices and Consequences
|
||||
const plotResolutionsRaw = await callOllama(
|
||||
`Based on all of the following elements, suggest 3 possible, non-conflicting story climaxes or plot resolutions for adventurers exploring this location. Each resolution must provide a meaningful choice with a tangible consequence, directly related to the Central Conflict, the Primary Faction, or the NPCs.
|
||||
|
||||
Dungeon Elements:
|
||||
${JSON.stringify({ title, flavor, hooksRumors, rooms, encounters, treasure, npcs, coreConcepts }, null, 2)}
|
||||
|
||||
Start each item with phrases like "The adventurers could" or "The PCs might". Deepen the narrative texture and allow for roleplay and tactical creativity. Keep each item short (max 2 sentences). Output as a numbered list, plain text only. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
|
||||
undefined, 5, "Step 6: Plot Resolutions"
|
||||
);
|
||||
const plotResolutions = parseList(plotResolutionsRaw);
|
||||
console.log("Plot Resolutions:", plotResolutions);
|
||||
|
||||
console.log("\nDungeon generation complete!");
|
||||
return { title, flavor, map: "map.png", hooksRumors, rooms, encounters, treasure, npcs, plotResolutions };
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
function pickRandom(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
export function dungeonTemplate(data) {
|
||||
const bodyFonts = [
|
||||
"'Lora', serif",
|
||||
"'Merriweather', serif",
|
||||
"'Libre Baskerville', serif",
|
||||
"'Source Serif 4', serif"
|
||||
];
|
||||
|
||||
const headingFonts = [
|
||||
"'New Rocker', system-ui",
|
||||
"'UnifrakturCook', cursive",
|
||||
"'IM Fell DW Pica', serif",
|
||||
"'Cinzel', serif",
|
||||
"'Cormorant Garamond', serif",
|
||||
"'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",
|
||||
"'Merriweather', serif"
|
||||
];
|
||||
|
||||
const bodyFont = pickRandom(bodyFonts);
|
||||
const headingFont = pickRandom(headingFonts);
|
||||
const tableFont = pickRandom(tableFonts);
|
||||
const quoteFont = pickRandom(quoteFonts);
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${data.title}</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative&family=UnifrakturCook&family=New+Rocker&family=Metamorphous&family=Playfair+Display&family=Alegreya+Sans&family=Cabin&family=IBM+Plex+Sans&family=Cormorant+Garamond&family=Lora&family=Merriweather&family=Libre+Baskerville&family=Source+Serif+4&family=Walter+Turncoat&family=Uncial+Antiqua&family=Beth+Ellen&family=Pinyon+Script&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: ${bodyFont};
|
||||
color: #1a1a1a;
|
||||
font-size: 0.7em;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
.content-page {
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 1.5cm;
|
||||
page-break-after: always;
|
||||
overflow: hidden;
|
||||
break-inside: avoid;
|
||||
}
|
||||
h1 {
|
||||
font-family: ${headingFont};
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-size: 2em;
|
||||
margin: 0.2em 0 0.3em;
|
||||
color: #1a1a1a;
|
||||
border-bottom: 2px solid #1a1a1a;
|
||||
padding-bottom: 0.2em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.flavor {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
font-family: ${quoteFont};
|
||||
margin: 0.4em 0 0.8em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.5cm;
|
||||
align-items: start;
|
||||
}
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15em;
|
||||
}
|
||||
h2 {
|
||||
font-family: ${headingFont};
|
||||
font-size: 1.0em;
|
||||
margin: 0.3em 0 0.1em;
|
||||
color: #1a1a1a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
padding-bottom: 0.1em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
text-align: center;
|
||||
font-size: 0.65em;
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content-page">
|
||||
<h1>${data.title}</h1>
|
||||
<p class="flavor">${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("")}
|
||||
</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 => {
|
||||
const [name, ...descParts] = t.split(/[-–—:]/);
|
||||
const description = descParts.join(" ").trim();
|
||||
return `<li><b>${name.trim()}</b>: ${description}</li>`;
|
||||
}).join("")}</ul>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-page">
|
||||
<div class="map-image-container">
|
||||
<img src="${data.map}" alt="Dungeon Map">
|
||||
</div>
|
||||
<footer>Scrollsmith • © ${new Date().getFullYear()}</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@@ -3,5 +3,26 @@ import globals from "globals";
|
||||
import { defineConfig } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.node } },
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs}"],
|
||||
plugins: { js },
|
||||
extends: ["js/recommended"],
|
||||
languageOptions: { globals: globals.node },
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { varsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/**/*.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
describe: "readonly",
|
||||
it: "readonly",
|
||||
test: "readonly",
|
||||
expect: "readonly",
|
||||
vi: "readonly",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import puppeteer from "puppeteer";
|
||||
import { dungeonTemplate } from "./dungeonTemplate.js";
|
||||
|
||||
import fs from 'fs/promises';
|
||||
|
||||
export async function generatePDF(data, outputPath = "dungeon.pdf") {
|
||||
const browser = await puppeteer.launch({
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Convert image to base64
|
||||
const imageBuffer = await fs.readFile(data.map);
|
||||
const base64Image = `data:image/png;base64,${imageBuffer.toString("base64")}`;
|
||||
data.map = base64Image;
|
||||
|
||||
const html = dungeonTemplate(data);
|
||||
await page.setContent(html, { waitUntil: "networkidle0" });
|
||||
|
||||
await page.pdf({
|
||||
path: outputPath,
|
||||
format: "A4",
|
||||
landscape: true,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
console.log(`Dungeon PDF saved to ${outputPath}`);
|
||||
}
|
||||
23
index.js
23
index.js
@@ -1,18 +1,28 @@
|
||||
import 'dotenv/config';
|
||||
import { generateDungeon } from "./dungeonGenerator.js";
|
||||
import { generateDungeonImages } from "./imageGenerator.js";
|
||||
import { generatePDF } from "./generatePDF.js";
|
||||
import "dotenv/config";
|
||||
import { generateDungeon } from "./src/dungeonGenerator.js";
|
||||
import { generateDungeonImages } from "./src/imageGenerator.js";
|
||||
import { generatePDF } from "./src/generatePDF.js";
|
||||
import { OLLAMA_MODEL, initializeModel } from "./src/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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
const OLLAMA_API_URL = process.env.OLLAMA_API_URL;
|
||||
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY;
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// 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
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function callOllama(prompt, model = "gemma3n:e4b", retries = 5, stepName = "unknown") {
|
||||
const isUsingOpenWebUI = !!OLLAMA_API_KEY;
|
||||
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const promptCharCount = prompt.length;
|
||||
const promptWordCount = prompt.split(/\s+/).length;
|
||||
|
||||
console.log(`\n[${stepName}] Sending prompt (attempt ${attempt}/${retries})`);
|
||||
console.log(`Prompt: ${promptCharCount} chars, ~${promptWordCount} words`);
|
||||
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
if (isUsingOpenWebUI) {
|
||||
headers["Authorization"] = `Bearer ${OLLAMA_API_KEY}`;
|
||||
}
|
||||
|
||||
const body = isUsingOpenWebUI
|
||||
? {
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}
|
||||
: {
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const response = await fetch(OLLAMA_API_URL, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Ollama request failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const rawText = isUsingOpenWebUI
|
||||
? data.choices?.[0]?.message?.content
|
||||
: data.message?.content;
|
||||
|
||||
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`);
|
||||
|
||||
return cleaned;
|
||||
|
||||
} catch (err) {
|
||||
console.warn(`[${stepName}] Attempt ${attempt} failed: ${err.message}`);
|
||||
if (attempt === retries) throw err;
|
||||
const delay = 1000 * Math.pow(2, attempt) + Math.random() * 500;
|
||||
console.log(`Retrying in ${Math.round(delay / 1000)}s...`);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
2884
package-lock.json
generated
2884
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -4,7 +4,10 @@
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run --exclude '**/integration/**'",
|
||||
"test:coverage": "vitest run --coverage --exclude '**/integration/**'",
|
||||
"test:integration": "vitest run --config vitest.integration.config.js",
|
||||
"lint": "eslint .",
|
||||
"start": "node index.js"
|
||||
},
|
||||
@@ -17,8 +20,10 @@
|
||||
"sharp": "^0.34.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.34.0",
|
||||
"eslint": "^9.34.0",
|
||||
"globals": "^16.3.0"
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^10.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
3
renovate.json
Normal file
3
renovate.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
290
src/contentFixes.js
Normal file
290
src/contentFixes.js
Normal file
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
extractCanonicalNames,
|
||||
validateContentCompleteness,
|
||||
validateContentQuality,
|
||||
validateContentStructure,
|
||||
validateNarrativeCoherence,
|
||||
} from "./validation.js";
|
||||
|
||||
export function validateNameConsistency(dungeonData) {
|
||||
const canonicalNames = extractCanonicalNames(dungeonData);
|
||||
const fixes = [];
|
||||
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
canonicalNames.npcs.forEach(canonicalName => {
|
||||
if (dungeonData.flavor) {
|
||||
const original = dungeonData.flavor;
|
||||
dungeonData.flavor = dungeonData.flavor.replace(new RegExp(escapeRe(canonicalName), 'gi'), canonicalName);
|
||||
if (original !== dungeonData.flavor) fixes.push(`Fixed NPC name in flavor text: ${canonicalName}`);
|
||||
}
|
||||
if (dungeonData.hooksRumors) {
|
||||
dungeonData.hooksRumors = dungeonData.hooksRumors.map(hook => {
|
||||
const original = hook;
|
||||
const fixed = hook.replace(new RegExp(escapeRe(canonicalName), 'gi'), canonicalName);
|
||||
if (original !== fixed) fixes.push(`Fixed NPC name in hook: ${canonicalName}`);
|
||||
return fixed;
|
||||
});
|
||||
}
|
||||
if (dungeonData.encounters) {
|
||||
dungeonData.encounters.forEach(encounter => {
|
||||
if (encounter.details) {
|
||||
const original = encounter.details;
|
||||
encounter.details = encounter.details.replace(new RegExp(escapeRe(canonicalName), 'gi'), canonicalName);
|
||||
if (original !== encounter.details) fixes.push(`Fixed NPC name in encounter: ${canonicalName}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (dungeonData.plotResolutions) {
|
||||
dungeonData.plotResolutions = dungeonData.plotResolutions.map(resolution => {
|
||||
const original = resolution;
|
||||
const fixed = resolution.replace(new RegExp(escapeRe(canonicalName), 'gi'), canonicalName);
|
||||
if (original !== fixed) fixes.push(`Fixed NPC name in plot resolution: ${canonicalName}`);
|
||||
return fixed;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
canonicalNames.rooms.forEach(canonicalRoom => {
|
||||
if (dungeonData.encounters) {
|
||||
dungeonData.encounters.forEach(encounter => {
|
||||
if (encounter.details) {
|
||||
const original = encounter.details;
|
||||
encounter.details = encounter.details.replace(new RegExp(escapeRe(canonicalRoom), 'gi'), canonicalRoom);
|
||||
if (original !== encounter.details) fixes.push(`Fixed room name in encounter: ${canonicalRoom}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return fixes;
|
||||
}
|
||||
|
||||
export function standardizeEncounterLocations(encounters, rooms) {
|
||||
if (!encounters || !rooms) return { encounters, fixes: [] };
|
||||
const roomNames = rooms.map(r => r.name.trim());
|
||||
const fixes = [];
|
||||
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const fixedEncounters = encounters.map(encounter => {
|
||||
if (!encounter.details) return encounter;
|
||||
const standardized = roomNames.reduce((details, roomName) => {
|
||||
const roomNameRegex = new RegExp(`^${escapeRe(roomName)}\\s*:?\\s*`, 'i');
|
||||
if (!roomNameRegex.test(details)) return details;
|
||||
const hasColon = details.match(new RegExp(`^${escapeRe(roomName)}:`, 'i'));
|
||||
if (hasColon) return details;
|
||||
fixes.push(`Standardized location format for encounter: ${encounter.name}`);
|
||||
return details.replace(roomNameRegex, `${roomName}: `);
|
||||
}, encounter.details.trim());
|
||||
return standardized !== encounter.details ? { ...encounter, details: standardized } : encounter;
|
||||
});
|
||||
return { encounters: fixedEncounters, fixes };
|
||||
}
|
||||
|
||||
export function fixStructureIssues(dungeonData) {
|
||||
const fixes = [];
|
||||
if (dungeonData.rooms) {
|
||||
dungeonData.rooms.forEach((room, i) => {
|
||||
if (!room.name || !room.name.trim()) {
|
||||
const desc = room.description || '';
|
||||
const nameMatch = desc.match(/^([A-Z][^.!?]{5,30}?)(?:\s|\.|:)/);
|
||||
if (nameMatch) {
|
||||
room.name = nameMatch[1].trim();
|
||||
fixes.push(`Extracted room name from description: "${room.name}"`);
|
||||
} else {
|
||||
room.name = `Room ${i + 1}`;
|
||||
fixes.push(`Added default name for room ${i + 1}`);
|
||||
}
|
||||
}
|
||||
const words = room.name.split(/\s+/);
|
||||
if (words.length > 6) {
|
||||
const original = room.name;
|
||||
room.name = words.slice(0, 6).join(' ');
|
||||
fixes.push(`Truncated room name: "${original}" -> "${room.name}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (dungeonData.encounters) {
|
||||
dungeonData.encounters.forEach((encounter, i) => {
|
||||
if (!encounter.name || !encounter.name.trim()) {
|
||||
const details = encounter.details || '';
|
||||
const nameMatch = details.match(/^([^:]+):\s*(.+)$/);
|
||||
if (nameMatch) {
|
||||
encounter.name = nameMatch[1].trim();
|
||||
encounter.details = nameMatch[2].trim();
|
||||
fixes.push(`Extracted encounter name from details: "${encounter.name}"`);
|
||||
} else {
|
||||
encounter.name = `Encounter ${i + 1}`;
|
||||
fixes.push(`Added default name for encounter ${i + 1}`);
|
||||
}
|
||||
}
|
||||
const words = encounter.name.split(/\s+/);
|
||||
if (words.length > 6) {
|
||||
const original = encounter.name;
|
||||
encounter.name = words.slice(0, 6).join(' ');
|
||||
fixes.push(`Truncated encounter name: "${original}" -> "${encounter.name}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (dungeonData.npcs) {
|
||||
dungeonData.npcs.forEach((npc, i) => {
|
||||
if (!npc.name || !npc.name.trim()) {
|
||||
const trait = npc.trait || '';
|
||||
const nameMatch = trait.match(/^([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})(?:\s|:)/);
|
||||
if (nameMatch) {
|
||||
npc.name = nameMatch[1].trim();
|
||||
fixes.push(`Extracted NPC name from trait: "${npc.name}"`);
|
||||
} else {
|
||||
npc.name = `NPC ${i + 1}`;
|
||||
fixes.push(`Added default name for NPC ${i + 1}`);
|
||||
}
|
||||
}
|
||||
const words = npc.name.split(/\s+/);
|
||||
if (words.length > 4) {
|
||||
const original = npc.name;
|
||||
npc.name = words.slice(0, 4).join(' ');
|
||||
fixes.push(`Truncated NPC name: "${original}" -> "${npc.name}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return fixes;
|
||||
}
|
||||
|
||||
export function fixMissingContent(dungeonData) {
|
||||
const fixes = [];
|
||||
if (!dungeonData.npcs || dungeonData.npcs.length < 4) {
|
||||
if (!dungeonData.npcs) dungeonData.npcs = [];
|
||||
const factionName = dungeonData.coreConcepts?.match(/Primary Faction[:\s]+([^.]+)/i)?.[1]?.trim() || 'the primary faction';
|
||||
while (dungeonData.npcs.length < 4) {
|
||||
dungeonData.npcs.push({
|
||||
name: `NPC ${dungeonData.npcs.length + 1}`,
|
||||
trait: `A member of ${factionName.toLowerCase()} with unknown motives.`
|
||||
});
|
||||
fixes.push(`Added fallback NPC ${dungeonData.npcs.length}`);
|
||||
}
|
||||
}
|
||||
if (!dungeonData.encounters || dungeonData.encounters.length < 6) {
|
||||
if (!dungeonData.encounters) dungeonData.encounters = [];
|
||||
if (dungeonData.encounters.length > 0 && dungeonData.rooms?.length > 0) {
|
||||
const dynamicElement = dungeonData.coreConcepts?.match(/Dynamic Element[:\s]+([^.]+)/i)?.[1]?.trim() || 'strange occurrences';
|
||||
const conflict = dungeonData.coreConcepts?.match(/Central Conflict[:\s]+([^.]+)/i)?.[1]?.trim() || 'a threat';
|
||||
while (dungeonData.encounters.length < 6) {
|
||||
const roomIndex = dungeonData.encounters.length % dungeonData.rooms.length;
|
||||
const roomName = dungeonData.rooms[roomIndex]?.name || 'Unknown Location';
|
||||
const fallbackNames = [
|
||||
`${roomName} Guardian`, `${roomName} Threat`, `${roomName} Challenge`,
|
||||
`${dynamicElement.split(' ')[0]} Manifestation`, `${conflict.split(' ')[0]} Encounter`, `${roomName} Hazard`
|
||||
];
|
||||
dungeonData.encounters.push({
|
||||
name: fallbackNames[dungeonData.encounters.length % fallbackNames.length],
|
||||
details: `${roomName}: An encounter related to ${dynamicElement.toLowerCase()} occurs here.`
|
||||
});
|
||||
fixes.push(`Added fallback encounter: "${dungeonData.encounters[dungeonData.encounters.length - 1].name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dungeonData.treasure || dungeonData.treasure.length < 4) {
|
||||
if (!dungeonData.treasure) dungeonData.treasure = [];
|
||||
while (dungeonData.treasure.length < 4) {
|
||||
dungeonData.treasure.push({
|
||||
name: `Treasure ${dungeonData.treasure.length + 1}`,
|
||||
description: `A mysterious item found in the dungeon.`
|
||||
});
|
||||
fixes.push(`Added fallback treasure ${dungeonData.treasure.length}`);
|
||||
}
|
||||
}
|
||||
if (!dungeonData.randomEvents || dungeonData.randomEvents.length < 6) {
|
||||
if (!dungeonData.randomEvents) dungeonData.randomEvents = [];
|
||||
if (dungeonData.randomEvents.length > 0 && dungeonData.coreConcepts) {
|
||||
const dynamicElement = dungeonData.coreConcepts.match(/Dynamic Element[:\s]+([^.]+)/i)?.[1]?.trim() || 'strange occurrences';
|
||||
const conflict = dungeonData.coreConcepts.match(/Central Conflict[:\s]+([^.]+)/i)?.[1]?.trim() || 'a mysterious threat';
|
||||
const fallbackEvents = [
|
||||
{ name: 'Environmental Shift', description: `The ${dynamicElement.toLowerCase()} causes unexpected changes in the environment.` },
|
||||
{ name: 'Conflict Manifestation', description: `A sign of ${conflict.toLowerCase()} appears, requiring immediate attention.` },
|
||||
{ name: 'Dungeon Shift', description: `The dungeon shifts, revealing a previously hidden passage or danger.` },
|
||||
{ name: 'Faction Messenger', description: `An NPC from the primary faction appears with urgent information.` },
|
||||
{ name: 'Power Fluctuation', description: `The power source fluctuates, creating temporary hazards or opportunities.` },
|
||||
{ name: 'Echoes of the Past', description: `Echoes of past events manifest, providing clues or complications.` }
|
||||
];
|
||||
while (dungeonData.randomEvents.length < 6) {
|
||||
dungeonData.randomEvents.push(fallbackEvents[dungeonData.randomEvents.length % fallbackEvents.length]);
|
||||
fixes.push(`Added fallback random event: "${dungeonData.randomEvents[dungeonData.randomEvents.length - 1].name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dungeonData.plotResolutions || dungeonData.plotResolutions.length < 4) {
|
||||
if (!dungeonData.plotResolutions) dungeonData.plotResolutions = [];
|
||||
while (dungeonData.plotResolutions.length < 4) {
|
||||
dungeonData.plotResolutions.push(`The adventurers could resolve the central conflict through decisive action.`);
|
||||
fixes.push(`Added fallback plot resolution ${dungeonData.plotResolutions.length}`);
|
||||
}
|
||||
}
|
||||
return fixes;
|
||||
}
|
||||
|
||||
export function fixNarrativeCoherence(dungeonData) {
|
||||
const fixes = [];
|
||||
if (dungeonData.encounters && dungeonData.rooms) {
|
||||
const roomNames = dungeonData.rooms.map(r => r.name.trim().toLowerCase());
|
||||
dungeonData.encounters.forEach(encounter => {
|
||||
if (!encounter.details) return;
|
||||
const locationMatch = encounter.details.match(/^([^:]+):/);
|
||||
if (locationMatch) {
|
||||
const locName = locationMatch[1].trim().toLowerCase();
|
||||
const matches = roomNames.some(rn =>
|
||||
locName === rn || locName.includes(rn) || rn.includes(locName) ||
|
||||
locName.split(/\s+/).some(word => rn.includes(word))
|
||||
);
|
||||
if (!matches && roomNames.length > 0) {
|
||||
const roomIdx = Math.floor(Math.random() * roomNames.length);
|
||||
const roomName = dungeonData.rooms[roomIdx].name;
|
||||
encounter.details = encounter.details.replace(/^[^:]+:\s*/, `${roomName}: `);
|
||||
fixes.push(`Fixed unknown location in encounter "${encounter.name}" to "${roomName}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return fixes;
|
||||
}
|
||||
|
||||
export function validateAndFixContent(dungeonData) {
|
||||
const allFixes = [];
|
||||
const nameFixes = validateNameConsistency(dungeonData);
|
||||
allFixes.push(...nameFixes);
|
||||
const structureFixes = fixStructureIssues(dungeonData);
|
||||
allFixes.push(...structureFixes);
|
||||
if (dungeonData.encounters && dungeonData.rooms) {
|
||||
const roomNames = dungeonData.rooms.map(r => r.name.trim());
|
||||
dungeonData.encounters.forEach((encounter, idx) => {
|
||||
if (!encounter.details) return;
|
||||
if (!encounter.details.match(/^[^:]+:\s/)) {
|
||||
const roomIdx = idx % roomNames.length;
|
||||
const roomName = roomNames[roomIdx];
|
||||
encounter.details = `${roomName}: ${encounter.details}`;
|
||||
allFixes.push(`Added location "${roomName}" to encounter "${encounter.name}"`);
|
||||
}
|
||||
});
|
||||
const locationResult = standardizeEncounterLocations(dungeonData.encounters, dungeonData.rooms);
|
||||
dungeonData.encounters = locationResult.encounters;
|
||||
allFixes.push(...locationResult.fixes);
|
||||
}
|
||||
const coherenceFixes = fixNarrativeCoherence(dungeonData);
|
||||
allFixes.push(...coherenceFixes);
|
||||
const contentFixes = fixMissingContent(dungeonData);
|
||||
allFixes.push(...contentFixes);
|
||||
|
||||
const allIssues = [
|
||||
...validateContentCompleteness(dungeonData),
|
||||
...validateContentQuality(dungeonData),
|
||||
...validateContentStructure(dungeonData),
|
||||
...validateNarrativeCoherence(dungeonData),
|
||||
];
|
||||
if (allFixes.length > 0) {
|
||||
console.log("\n[Validation] Applied fixes:");
|
||||
allFixes.forEach(fix => console.log(` - ${fix}`));
|
||||
}
|
||||
if (allIssues.length > 0) {
|
||||
console.log("\n[Validation] Content quality issues found (not auto-fixable):");
|
||||
allIssues.forEach(issue => console.warn(` ⚠ ${issue}`));
|
||||
} else {
|
||||
console.log("\n[Validation] Content quality checks passed");
|
||||
}
|
||||
return dungeonData;
|
||||
}
|
||||
106
src/dungeonBuild.js
Normal file
106
src/dungeonBuild.js
Normal file
@@ -0,0 +1,106 @@
|
||||
export function deduplicateRoomsByName(rooms) {
|
||||
if (!rooms || rooms.length === 0) return [];
|
||||
const seenNames = new Set();
|
||||
return rooms.filter(room => {
|
||||
if (!room || !room.name) return false;
|
||||
const nameLower = room.name.toLowerCase().trim();
|
||||
if (seenNames.has(nameLower)) {
|
||||
console.warn(`Duplicate room name detected: "${room.name}", skipping duplicate`);
|
||||
return false;
|
||||
}
|
||||
seenNames.add(nameLower);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function padNpcsToMinimum(parsedNpcs, coreConcepts, minCount = 4) {
|
||||
const factionName = coreConcepts?.match(/Primary Faction[:\s]+([^.]+)/i)?.[1]?.trim() || 'the primary faction';
|
||||
if (!parsedNpcs || parsedNpcs.length >= minCount || parsedNpcs.length === 0) return parsedNpcs || [];
|
||||
const list = [...parsedNpcs];
|
||||
while (list.length < minCount) {
|
||||
list.push({
|
||||
name: `NPC ${list.length + 1}`,
|
||||
trait: `A member of ${factionName.toLowerCase()} with unknown motives.`
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export function buildEncountersList(parsedEncounters, rooms, coreConcepts) {
|
||||
const dynamicElement = coreConcepts?.match(/Dynamic Element[:\s]+([^.]+)/i)?.[1]?.trim() || 'strange occurrences';
|
||||
const conflict = coreConcepts?.match(/Central Conflict[:\s]+([^.]+)/i)?.[1]?.trim() || 'a threat';
|
||||
const fallbackNames = (roomName) => [
|
||||
`${roomName} Guardian`,
|
||||
`${roomName} Threat`,
|
||||
`${roomName} Challenge`,
|
||||
`${dynamicElement.split(' ')[0]} Manifestation`,
|
||||
`${conflict.split(' ')[0]} Encounter`,
|
||||
`${roomName} Hazard`
|
||||
];
|
||||
|
||||
if (parsedEncounters.length > 0 && parsedEncounters.length < 6) {
|
||||
return [
|
||||
...parsedEncounters,
|
||||
...Array.from({ length: 6 - parsedEncounters.length }, (_, i) => {
|
||||
const roomIndex = (parsedEncounters.length + i) % rooms.length;
|
||||
const roomName = rooms[roomIndex]?.name || 'Unknown Location';
|
||||
return {
|
||||
name: fallbackNames(roomName)[(parsedEncounters.length + i) % 6],
|
||||
details: `An encounter related to ${dynamicElement.toLowerCase()} occurs here.`
|
||||
};
|
||||
})
|
||||
];
|
||||
}
|
||||
if (parsedEncounters.length === 0) {
|
||||
return Array.from({ length: 6 }, (_, i) => {
|
||||
const roomName = rooms[i % rooms.length]?.name || 'Unknown Location';
|
||||
return { name: `${roomName} Encounter`, details: `An encounter related to ${dynamicElement.toLowerCase()} occurs here.` };
|
||||
});
|
||||
}
|
||||
return parsedEncounters;
|
||||
}
|
||||
|
||||
export function mergeRandomEventsWithFallbacks(parsedEvents, coreConcepts, maxCount = 6) {
|
||||
const dynamicElement = coreConcepts?.match(/Dynamic Element[:\s]+([^.]+)/i)?.[1]?.trim() || 'strange occurrences';
|
||||
const conflict = (coreConcepts?.match(/Central Conflict[:\s]+([^.]+)/i)?.[1]?.trim() || 'a mysterious threat').toLowerCase();
|
||||
const fallbackEvents = [
|
||||
{ name: 'Environmental Shift', description: `The ${dynamicElement.toLowerCase()} causes unexpected changes in the environment.` },
|
||||
{ name: 'Conflict Manifestation', description: `A sign of ${conflict} appears, requiring immediate attention.` },
|
||||
{ name: 'Dungeon Shift', description: `The dungeon shifts, revealing a previously hidden passage or danger.` },
|
||||
{ name: 'Faction Messenger', description: `An NPC from the primary faction appears with urgent information.` },
|
||||
{ name: 'Power Fluctuation', description: `The power source fluctuates, creating temporary hazards or opportunities.` },
|
||||
{ name: 'Echoes of the Past', description: `Echoes of past events manifest, providing clues or complications.` }
|
||||
];
|
||||
const truncated = (parsedEvents || []).slice(0, maxCount);
|
||||
if (truncated.length > 0 && truncated.length < maxCount) {
|
||||
return [
|
||||
...truncated,
|
||||
...Array.from({ length: maxCount - truncated.length }, (_, i) =>
|
||||
fallbackEvents[(truncated.length + i) % fallbackEvents.length])
|
||||
];
|
||||
}
|
||||
return truncated;
|
||||
}
|
||||
|
||||
export function limitIntermediateRooms(rooms, maxCount = 3) {
|
||||
if (rooms.length > maxCount) {
|
||||
console.warn(`Expected exactly ${maxCount} intermediate locations but got ${rooms.length}, limiting to first ${maxCount}`);
|
||||
}
|
||||
return rooms.slice(0, maxCount);
|
||||
}
|
||||
|
||||
export function fixRoomPlaceholderName(room) {
|
||||
if (!room) return room;
|
||||
if (room.name && (room.name.toLowerCase().includes('room name') || room.name.toLowerCase() === 'room name')) {
|
||||
const desc = room.description || '';
|
||||
const nameMatch = desc.match(/^([^:]+?)(?:\s+Description|\s*:)/i) || desc.match(/^([A-Z][^.!?]{5,40}?)(?:\s+is\s|\.)/);
|
||||
if (nameMatch) {
|
||||
room.name = nameMatch[1].trim().replace(/^(The|A|An)\s+/i, '').trim();
|
||||
room.description = desc.replace(new RegExp(`^${nameMatch[1]}\\s*(Description|:)?\\s*`, 'i'), '').trim();
|
||||
} else {
|
||||
const words = desc.split(/\s+/).slice(0, 4).join(' ');
|
||||
room.name = words.replace(/^(The|A|An)\s+/i, '').trim();
|
||||
}
|
||||
}
|
||||
return room;
|
||||
}
|
||||
323
src/dungeonGenerator.js
Normal file
323
src/dungeonGenerator.js
Normal file
@@ -0,0 +1,323 @@
|
||||
import { callOllama } from "./ollamaClient.js";
|
||||
import { cleanText } from "./textUtils.js";
|
||||
import {
|
||||
parseList,
|
||||
parseObjects,
|
||||
parseMainContentSections,
|
||||
parseRandomEventsRaw,
|
||||
splitCombinedEncounters,
|
||||
} from "./parsing.js";
|
||||
import {
|
||||
deduplicateRoomsByName,
|
||||
padNpcsToMinimum,
|
||||
buildEncountersList,
|
||||
mergeRandomEventsWithFallbacks,
|
||||
limitIntermediateRooms,
|
||||
fixRoomPlaceholderName,
|
||||
} from "./dungeonBuild.js";
|
||||
import { validateAndFixContent } from "./contentFixes.js";
|
||||
|
||||
export async function generateDungeon() {
|
||||
// Step 1: Titles
|
||||
const generatedTitles = await callOllama(
|
||||
`Generate 50 short, punchy dungeon titles (max 5 words each), numbered as a plain text list.
|
||||
Each title should come from a different style or theme. Make the set varied and evocative. For example:
|
||||
|
||||
- OSR / classic tabletop: gritty, mysterious, old-school
|
||||
- Mörk Borg: dark, apocalyptic, foreboding
|
||||
- Pulpy fantasy: adventurous, dramatic, larger-than-life
|
||||
- Mildly sci-fi: alien, technological, strange
|
||||
- Weird fantasy: uncanny, surreal, unsettling
|
||||
- Whimsical: fun, quirky, playful
|
||||
|
||||
CRITICAL: Ensure all spelling is correct. Double-check all words before outputting.
|
||||
Avoid repeating materials or adjectives. Absolutely do not use the words "Obsidian" or "Clockwork" in any title. Do not include explanations, markdown, or preambles. Do not include the style or theme in parenthesis. Only the 50 numbered titles. Do not include the theme in the name of the title (no parenthesis).`,
|
||||
undefined, 5, "Step 1: Titles"
|
||||
);
|
||||
console.log("Generated Titles:", generatedTitles);
|
||||
const titlesList = parseList(generatedTitles);
|
||||
const title = titlesList[Math.floor(Math.random() * titlesList.length)];
|
||||
console.log("Selected title:", title);
|
||||
|
||||
// Step 2: Core Concepts
|
||||
const coreConceptsRaw = await callOllama(
|
||||
`For a dungeon titled "${title}", generate three core concepts: a central conflict, a primary faction, and a major environmental hazard or dynamic element.
|
||||
Output as a numbered list with bolded headings. Plain text only. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.
|
||||
Example:
|
||||
1. **Central Conflict:** The dungeon's power source is failing, causing reality to warp.
|
||||
2. **Primary Faction:** A group of rival cultists trying to seize control of the power source.
|
||||
3. **Dynamic Element:** Zones of temporal distortion that cause random, brief time shifts.`,
|
||||
undefined, 5, "Step 2: Core Concepts"
|
||||
);
|
||||
const coreConcepts = coreConceptsRaw;
|
||||
console.log("Core Concepts:", coreConcepts);
|
||||
|
||||
// Step 3: Flavor Text & Hooks
|
||||
const flavorHooksRaw = await callOllama(
|
||||
`Based on the title "${title}" and these core concepts:
|
||||
${coreConcepts}
|
||||
Write a single evocative paragraph describing the location. Maximum 2 sentences. Maximum 50-60 words. Then, generate 4-5 short adventure hooks or rumors.
|
||||
The hooks should reference the central conflict, faction, and dynamic element. Hooks should suggest different approaches (stealth, diplomacy, force, exploration) and create anticipation.
|
||||
|
||||
EXAMPLE OF GOOD HOOK:
|
||||
"A merchant's cart was found abandoned near the entrance, its cargo of rare herbs scattered. The merchant's journal mentions strange lights in the depths and a warning about 'the watchers'."
|
||||
|
||||
CRITICAL: Hooks must be concise to fit in a single column on a one-page dungeon layout. Each hook must be 25-30 words maximum. Be specific with details, not vague.
|
||||
CRITICAL: Ensure all spelling is correct. Double-check all words, especially proper nouns and technical terms.
|
||||
Output two sections labeled "Description:" and "Hooks & Rumors:". Use a numbered list for the hooks. Plain text only. Do not use em-dashes (—) anywhere in the output. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
|
||||
undefined, 5, "Step 3: Flavor & Hooks"
|
||||
);
|
||||
const [flavorSection, hooksSection] = flavorHooksRaw.split(/Hooks & Rumors[:\n]/i);
|
||||
const rawFlavor = cleanText(flavorSection.replace(/Description[:\n]*/i, ""));
|
||||
const flavorWords = rawFlavor.split(/\s+/);
|
||||
const flavor = flavorWords.length > 60 ? flavorWords.slice(0, 60).join(' ') + '...' : rawFlavor;
|
||||
const hooksRumors = parseList(hooksSection || "").map(h => h.replace(/^[^:]+:\s*/, '').trim());
|
||||
console.log("Flavor Text:", flavor);
|
||||
console.log("Hooks & Rumors:", hooksRumors);
|
||||
|
||||
// Step 4: Key Rooms
|
||||
const keyRoomsRaw = await callOllama(
|
||||
`Based on the title "${title}", description "${flavor}", and these core concepts:
|
||||
${coreConcepts}
|
||||
Generate two key rooms that define the dungeon's narrative arc.
|
||||
CRITICAL: These rooms need rich environmental and tactical details with multiple interaction possibilities.
|
||||
|
||||
EXAMPLE OF GOOD ROOM DESCRIPTION:
|
||||
"Chamber of Echoes: Flickering torchlight casts dancing shadows across moss-covered walls. A constant dripping echoes from stalactites overhead, and the air smells of damp earth and ozone. Three stone pillars provide cover, while a raised dais in the center offers high ground. A rusted lever on the west wall controls a hidden portcullis. The floor is slick with moisture, making movement difficult."
|
||||
|
||||
1. Entrance Room: Give it a name (max 5 words) and a description (25-35 words) that MUST include:
|
||||
- Immediate observable features and environmental details (lighting, sounds, smells, textures, temperature, visibility)
|
||||
- Interactable elements that players can use (levers, objects, portals, mechanisms, environmental hazards)
|
||||
- Tactical considerations (cover, elevation, movement restrictions, line of sight)
|
||||
- Sets the tone and introduces the environmental hazard/dynamic element
|
||||
|
||||
2. Climax Room: Give it a name (max 5 words) and a description (25-35 words) that MUST include:
|
||||
- Connection to the primary faction and the central conflict
|
||||
- Rich environmental and tactical details
|
||||
- Multiple approach options or solutions
|
||||
- Tactical considerations and environmental factors that affect gameplay
|
||||
|
||||
EXACT FORMAT REQUIRED - each room on its own numbered line:
|
||||
1. Room Name: Description text here.
|
||||
2. Room Name: Description text here.
|
||||
|
||||
CRITICAL: Ensure all spelling is correct. Double-check all words before outputting.
|
||||
CRITICAL: Be specific and concrete. Avoid vague words like "some", "various", "several" without details.
|
||||
Output ONLY the two numbered items, one per line. Use colons (:) to separate room names from descriptions, not em-dashes. Do not use em-dashes (—) anywhere. Do not combine items. Do not use bolded headings. Do not include any intro or other text. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
|
||||
undefined, 5, "Step 4: Key Rooms"
|
||||
);
|
||||
const [entranceSection, climaxSection] = keyRoomsRaw.split(/\n?2[).] /);
|
||||
const entranceRoom = parseObjects(entranceSection, "rooms")[0];
|
||||
const climaxRoom = parseObjects(`1. ${climaxSection}`, "rooms")[0];
|
||||
|
||||
if (entranceRoom) fixRoomPlaceholderName(entranceRoom);
|
||||
if (climaxRoom) fixRoomPlaceholderName(climaxRoom);
|
||||
|
||||
console.log("Entrance Room:", entranceRoom);
|
||||
console.log("Climax Room:", climaxRoom);
|
||||
|
||||
// Step 5: Main Content (Locations, Encounters, NPCs, Treasures, Random Events)
|
||||
const mainContentRaw = await callOllama(
|
||||
`Based on the following dungeon elements and the need for narrative flow:
|
||||
Title: "${title}"
|
||||
Description: "${flavor}"
|
||||
Core Concepts:
|
||||
${coreConcepts}
|
||||
Entrance Room: ${JSON.stringify(entranceRoom)}
|
||||
Climax Room: ${JSON.stringify(climaxRoom)}
|
||||
|
||||
Generate the rest of the dungeon's content to fill the space between the entrance and the climax. CRITICAL: All content must fit on a single one-page dungeon layout with three columns. Keep descriptions rich and evocative with tactical/environmental details.
|
||||
|
||||
- **Strictly 3 Locations (EXACTLY 3, no more, no less):** Each with a name (max 6 words) and a description (25-35 words). Each room MUST include:
|
||||
- Rich environmental features that affect gameplay (lighting, sounds, smells, textures, temperature, visibility)
|
||||
- Interactable elements that players can use (levers, objects, portals, mechanisms, environmental hazards)
|
||||
- Multiple approaches or solutions to challenges in the room
|
||||
- Tactical considerations (cover, elevation, movement restrictions, line of sight)
|
||||
- Hidden aspects discoverable through interaction or investigation
|
||||
Format as "Name: description" using colons, NOT em-dashes.
|
||||
|
||||
EXAMPLE LOCATION:
|
||||
"Whispering Gallery: Dim phosphorescent fungi line the walls, casting an eerie green glow. The air hums with a low-frequency vibration that makes conversation difficult. Two collapsed pillars create natural cover, while a narrow ledge 10 feet up offers a sniper position. A hidden pressure plate near the entrance triggers a portcullis trap."
|
||||
|
||||
- **Strictly 6 Encounters:** Numbered 1-6 (for d6 rolling). Name (max 6 words) and details (2 sentences MAX, approximately 25-40 words). Each encounter MUST:
|
||||
- Start with the room/location name followed by a colon, then the details (e.g., "Location Name: Details text")
|
||||
- The location name must match one of the actual room names from this dungeon
|
||||
- Include environmental hazards/opportunities (cover, elevation, traps, interactable objects, terrain features)
|
||||
- Include tactical considerations (positioning, line of sight, escape routes, bottlenecks, high ground)
|
||||
- Offer multiple resolution options (combat, negotiation, stealth, puzzle-solving, environmental manipulation, timing-based solutions)
|
||||
- Include consequences and outcomes tied to player choices
|
||||
- Integrate with the environmental dynamic element from core concepts
|
||||
- At least two encounters must be directly tied to the primary faction
|
||||
Format as "Name: Location Name: details" using colons, NOT em-dashes. CRITICAL: Always start encounter details with the location name and a colon.
|
||||
|
||||
EXAMPLE ENCOUNTER:
|
||||
"Guardian Golem: Chamber of Echoes: The golem activates when the lever is pulled, blocking the exit. It's vulnerable to water damage from the dripping stalactites. Players can use the pillars for cover or try to disable it by breaking the rune on its back. If defeated peacefully, it reveals a hidden passage."
|
||||
|
||||
- **Strictly 4-5 NPCs:** Proper name (max 4 words) and a description (50-65 words). Each NPC MUST include:
|
||||
- Clear motivation or goal
|
||||
- Relationship to primary faction
|
||||
- How they can help or hinder the party
|
||||
- Quirks or memorable traits
|
||||
- Multiple interaction possibilities (negotiation, intimidation, help, betrayal)
|
||||
- One NPC should be a key figure tied to the central conflict
|
||||
- One should be a member of the primary faction, one should be a potential ally, one should be a rival
|
||||
Format as "Name: description" using colons, NOT em-dashes.
|
||||
|
||||
EXAMPLE NPC:
|
||||
"Kaelen the Warden: A former guard who was left behind when the faction retreated. He knows the secret passages but demands the party help him escape. He's paranoid and checks over his shoulder constantly. Can be bribed with food or convinced through shared stories of betrayal. Will turn on the party if he thinks they're working with the faction."
|
||||
|
||||
- **Strictly 4-5 Treasures:** Name (max 5 words) and a description (30-40 words). Each treasure MUST:
|
||||
- Include a clear danger or side-effect
|
||||
- Be connected to a specific encounter, NPC, or room
|
||||
- Have story significance beyond just value
|
||||
- Have potential for creative use beyond obvious purpose
|
||||
- Some should be cursed, have activation requirements, or serve dual purposes
|
||||
Format as "Name — Description" using em-dash.
|
||||
|
||||
EXAMPLE TREASURE:
|
||||
"Whispering Blade — This dagger amplifies the wielder's voice to a deafening roar when drawn. Found in the Guardian Golem's chamber, it was used to command the construct. The blade is cursed: each use permanently reduces the wielder's hearing. Can be used to shatter glass or stun enemies, but the curse cannot be removed."
|
||||
|
||||
- **Strictly 1 Random Events Table:** A d6 table (EXACTLY 6 entries, no more, no less) with random events/wandering encounters. Each entry MUST:
|
||||
- Have a short, evocative event name (max 4 words)
|
||||
- Provide interesting complications or opportunities (not just combat)
|
||||
- Tie to the core concepts and dynamic element
|
||||
- Add replayability and surprise
|
||||
- Description should be 15-20 words maximum
|
||||
- Be UNIQUE and DIFFERENT from each other (no duplicates or generic placeholders)
|
||||
- Be SPECIFIC to this dungeon's theme, conflict, and dynamic element
|
||||
Format as numbered 1-6 list under "Random Events:" label. Each event must be formatted as "Event Name: Description text" using colons, NOT em-dashes.
|
||||
|
||||
CRITICAL: Each item must be on its own numbered line. DO NOT combine multiple items into a single numbered entry.
|
||||
|
||||
EXACT FORMAT REQUIRED (DO NOT use placeholder names like "Location Name", "NPC Name", or "Treasure Name" - use actual creative names):
|
||||
Locations:
|
||||
1. Actual Room Name: Description text.
|
||||
2. Actual Room Name: Description text.
|
||||
3. Actual Room Name: Description text.
|
||||
|
||||
Encounters:
|
||||
1. Actual Encounter Name: Actual Room Name: Details text.
|
||||
2. Actual Encounter Name: Actual Room Name: Details text.
|
||||
3. Actual Encounter Name: Actual Room Name: Details text.
|
||||
4. Actual Encounter Name: Actual Room Name: Details text.
|
||||
5. Actual Encounter Name: Actual Room Name: Details text.
|
||||
6. Actual Encounter Name: Actual Room Name: Details text.
|
||||
|
||||
NPCs:
|
||||
1. Actual Character Name: Description text.
|
||||
2. Actual Character Name: Description text.
|
||||
3. Actual Character Name: Description text.
|
||||
4. Actual Character Name: Description text.
|
||||
|
||||
Treasures:
|
||||
1. Actual Item Name — Description text.
|
||||
2. Actual Item Name — Description text.
|
||||
3. Actual Item Name — Description text.
|
||||
4. Actual Item Name — Description text.
|
||||
|
||||
Random Events:
|
||||
1. Event Name: Event description.
|
||||
2. Event Name: Event description.
|
||||
3. Event Name: Event description.
|
||||
4. Event Name: Event description.
|
||||
5. Event Name: Event description.
|
||||
6. Event Name: Event description.
|
||||
|
||||
CRITICAL: Every name must be unique and creative. Never use generic placeholders like "Location Name", "NPC Name", "Encounter Name", or "Treasure Name". Use actual descriptive names that fit the dungeon's theme.
|
||||
|
||||
CRITICAL: Ensure all spelling is correct. Double-check all words, especially proper nouns, character names, and location names. Verify consistency of names across all sections.
|
||||
|
||||
CRITICAL: Location name matching - When writing encounters, the location name in the encounter details MUST exactly match one of the room names you've created (Entrance Room, Climax Room, or one of the 3 Locations). Double-check that every encounter location matches an actual room name.
|
||||
|
||||
CRITICAL: Avoid vague language - Do not use words like "some", "various", "several", "many", "few", "things", "stuff", "items", or "objects" without specific details. Be concrete and specific in all descriptions.
|
||||
|
||||
CRITICAL: All names required - Every room, encounter, NPC, and treasure MUST have a name. Do not leave names blank or use placeholders. If you cannot think of a name, create one based on the dungeon's theme.
|
||||
|
||||
CRITICAL: You MUST output exactly five separate sections with these exact labels on their own lines:
|
||||
"Locations:"
|
||||
"Encounters:"
|
||||
"NPCs:"
|
||||
"Treasures:"
|
||||
"Random Events:"
|
||||
|
||||
Each section must start with its label on its own line, followed by numbered items. Do NOT combine sections. Do NOT embed encounters in location descriptions. Each item must be on its own numbered line. Do not use any bolding, preambles, or extra text. Do not use em-dashes (—) in encounters or NPCs, only use colons for those sections. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
|
||||
undefined, 5, "Step 5: Main Content"
|
||||
);
|
||||
const { intermediateRoomsSection, encountersSection, npcsSection, treasureSection, randomEventsSection } =
|
||||
parseMainContentSections(mainContentRaw);
|
||||
|
||||
const intermediateRooms = parseObjects(intermediateRoomsSection.replace(/Locations:/i, ""), "rooms");
|
||||
const limitedIntermediateRooms = limitIntermediateRooms(intermediateRooms, 3);
|
||||
|
||||
const allRooms = [entranceRoom, ...limitedIntermediateRooms, climaxRoom].filter(Boolean);
|
||||
const rooms = deduplicateRoomsByName(allRooms);
|
||||
const parsedEncounters = splitCombinedEncounters(parseObjects(encountersSection || "", "encounters"));
|
||||
const parsedNpcs = parseObjects(npcsSection || "", "npcs");
|
||||
const treasure = parseObjects(treasureSection || "", "treasure");
|
||||
|
||||
const npcs = padNpcsToMinimum(parsedNpcs, coreConcepts, 4);
|
||||
const encounters = buildEncountersList(parsedEncounters, rooms, coreConcepts);
|
||||
|
||||
const randomEventsFilteredMapped = parseRandomEventsRaw(randomEventsSection || "");
|
||||
const randomEvents = mergeRandomEventsWithFallbacks(randomEventsFilteredMapped, coreConcepts, 6);
|
||||
|
||||
[[encounters, 6, 'encounters'], [npcs, 4, 'NPCs'], [treasure, 4, 'treasures'], [randomEvents, 6, 'random events']]
|
||||
.filter(([arr, expected]) => arr.length < expected && arr.length > 0)
|
||||
.forEach(([arr, expected, name]) => console.warn(`Expected at least ${expected} ${name} but got ${arr.length}`));
|
||||
console.log("Rooms:", rooms);
|
||||
console.log("Encounters:", encounters);
|
||||
console.log("NPCs:", npcs);
|
||||
console.log("Treasure:", treasure);
|
||||
console.log("Random Events:", randomEvents);
|
||||
|
||||
// Step 6: Player Choices and Consequences
|
||||
const factionName = coreConcepts.match(/Primary Faction[:\s]+([^.]+)/i)?.[1]?.trim() || 'the primary faction';
|
||||
const npcNamesList = npcs.map(n => n.name).join(", ");
|
||||
|
||||
const plotResolutionsRaw = await callOllama(
|
||||
`Based on all of the following elements, suggest 4-5 possible, non-conflicting story climaxes or plot resolutions for adventurers exploring this location. Each resolution must provide a meaningful choice with a tangible consequence, directly related to the Central Conflict, the Primary Faction, or the NPCs.
|
||||
|
||||
Dungeon Elements:
|
||||
${JSON.stringify({ title, flavor, hooksRumors, rooms, encounters, treasure, npcs, coreConcepts }, null, 2)}
|
||||
|
||||
CRITICAL: This content must fit in a single column on a one-page dungeon layout. Keep descriptions meaningful but concise.
|
||||
Start each item with phrases like "The adventurers could" or "The adventurers might". Do not use "PCs" or "player characters" - always use "adventurers" instead.
|
||||
|
||||
EXAMPLE PLOT RESOLUTION:
|
||||
"The adventurers could ally with the primary faction, gaining access to their resources but becoming enemies of the rival group. This choice unlocks new areas but closes off diplomatic solutions with other NPCs."
|
||||
|
||||
IMPORTANT: When referencing NPCs, use these exact names with correct spelling: ${npcNamesList}. When referencing the faction, use: ${factionName}. Ensure all names are spelled consistently and correctly.
|
||||
CRITICAL: Double-check all spelling before outputting. Verify all proper nouns match exactly as provided above.
|
||||
|
||||
Each resolution MUST:
|
||||
- Offer meaningful choice with clear consequences
|
||||
- Integrate NPCs, faction dynamics, and player actions
|
||||
- Include failure states or unexpected outcomes as options
|
||||
- Reflect different approaches players might take
|
||||
Keep each item to 1-2 sentences MAX (approximately 15-25 words). Be extremely concise. Output as a numbered list, plain text only. Do not use em-dashes (—) anywhere in the output. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
|
||||
undefined, 5, "Step 6: Plot Resolutions"
|
||||
);
|
||||
const plotResolutions = parseList(plotResolutionsRaw);
|
||||
console.log("Plot Resolutions:", plotResolutions);
|
||||
|
||||
// Step 7: Validation and Content Fixing
|
||||
console.log("\n[Validation] Running content validation and fixes...");
|
||||
const dungeonData = {
|
||||
title,
|
||||
flavor,
|
||||
map: "map.png",
|
||||
hooksRumors,
|
||||
rooms,
|
||||
encounters,
|
||||
treasure,
|
||||
npcs,
|
||||
plotResolutions,
|
||||
randomEvents,
|
||||
coreConcepts
|
||||
};
|
||||
|
||||
const validatedData = validateAndFixContent(dungeonData);
|
||||
|
||||
console.log("\nDungeon generation complete!");
|
||||
return validatedData;
|
||||
}
|
||||
421
src/dungeonTemplate.js
Normal file
421
src/dungeonTemplate.js
Normal file
@@ -0,0 +1,421 @@
|
||||
function pickRandom(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
export function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return String(text).replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
/** Truncate to at most maxSentences, then optionally by maxChars; return immutable result. */
|
||||
export function truncateText(text, maxSentences, maxChars) {
|
||||
const t = text || '';
|
||||
const sentences = t.match(/[^.!?]+[.!?]+/g) || [t];
|
||||
const afterSentences = sentences.length > maxSentences
|
||||
? sentences.slice(0, maxSentences).join(' ').trim()
|
||||
: t;
|
||||
if (afterSentences.length <= maxChars) return afterSentences;
|
||||
const trimmed = afterSentences.substring(0, maxChars - 3).trim();
|
||||
const lastPeriod = trimmed.lastIndexOf('.');
|
||||
return (lastPeriod > maxChars * 0.8 ? trimmed.substring(0, lastPeriod + 1) : trimmed + '...');
|
||||
}
|
||||
|
||||
/** Parse event (object or string) into { name, description } with optional truncation. */
|
||||
export function parseEventForDisplay(event, index) {
|
||||
const pair = typeof event === 'object' && event?.name != null && event?.description != null
|
||||
? { name: event.name, description: event.description }
|
||||
: typeof event === 'string'
|
||||
? (() => {
|
||||
const colonMatch = event.match(/^([^:]+):\s*(.+)$/);
|
||||
if (colonMatch) return { name: colonMatch[1].trim(), description: colonMatch[2].trim() };
|
||||
const words = event.split(/\s+/);
|
||||
return words.length > 3
|
||||
? { name: words.slice(0, 2).join(' '), description: words.slice(2).join(' ') }
|
||||
: { name: `Event ${index + 1}`, description: event };
|
||||
})()
|
||||
: { name: `Event ${index + 1}`, description: String(event || '') };
|
||||
const description = truncateText(pair.description, 999, 200);
|
||||
return { name: pair.name, description };
|
||||
}
|
||||
|
||||
export function dungeonTemplate(data) {
|
||||
const bodyFonts = [
|
||||
"'Lora', serif",
|
||||
"'Merriweather', serif",
|
||||
"'Libre Baskerville', serif",
|
||||
"'Source Serif 4', serif"
|
||||
];
|
||||
|
||||
const headingFonts = [
|
||||
"'New Rocker', system-ui",
|
||||
"'UnifrakturCook', cursive",
|
||||
"'IM Fell DW Pica', serif",
|
||||
"'Cinzel', serif",
|
||||
"'Cormorant Garamond', serif",
|
||||
"'Playfair Display', serif"
|
||||
];
|
||||
|
||||
const quoteFonts = [
|
||||
"'Playfair Display', serif",
|
||||
"'Libre Baskerville', serif",
|
||||
"'Merriweather', serif"
|
||||
];
|
||||
|
||||
const bodyFont = pickRandom(bodyFonts);
|
||||
const headingFont = pickRandom(headingFonts);
|
||||
const quoteFont = pickRandom(quoteFonts);
|
||||
|
||||
// Check if we have a map image to include
|
||||
const hasMap = data.map && typeof data.map === 'string' && data.map.startsWith('data:image/');
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${data.title}</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative&family=UnifrakturCook&family=New+Rocker&family=Metamorphous&family=Playfair+Display&family=Alegreya+Sans&family=Cabin&family=IBM+Plex+Sans&family=Cormorant+Garamond&family=Lora&family=Merriweather&family=Libre+Baskerville&family=Source+Serif+4&family=Walter+Turncoat&family=Uncial+Antiqua&family=Beth+Ellen&family=Pinyon+Script&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: ${bodyFont};
|
||||
color: #1a1a1a;
|
||||
font-size: 0.7em;
|
||||
line-height: 1.35em;
|
||||
}
|
||||
.content-page {
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 1.2cm;
|
||||
page-break-after: always;
|
||||
overflow: visible;
|
||||
break-inside: avoid;
|
||||
}
|
||||
h1 {
|
||||
font-family: ${headingFont};
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-size: 1.8em;
|
||||
margin: 0.15em 0 0.2em;
|
||||
color: #1a1a1a;
|
||||
border-bottom: 2px solid #1a1a1a;
|
||||
padding-bottom: 0.15em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.flavor {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
font-family: ${quoteFont};
|
||||
margin: 0.3em 0 0.6em;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.35em;
|
||||
}
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.4cm;
|
||||
align-items: start;
|
||||
}
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25em;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
hyphens: auto;
|
||||
}
|
||||
.section-block {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
h2 {
|
||||
font-family: ${headingFont};
|
||||
font-size: 1.05em;
|
||||
margin: 0.2em 0 0.2em;
|
||||
color: #1a1a1a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
padding-bottom: 0.08em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.room {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.room h3 {
|
||||
margin: 0.08em 0 0.03em;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.room p {
|
||||
margin: 0 0 0.15em;
|
||||
font-size: 0.8em;
|
||||
font-weight: normal;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
.encounter, .npc, .treasure, .plot-resolution {
|
||||
margin: 0 0 0.25em;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.3em;
|
||||
}
|
||||
.random-events {
|
||||
margin: 0.2em 0;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.random-events table {
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
.encounter strong, .npc strong, .treasure strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.2em 0;
|
||||
font-size: 0.85em;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
table th {
|
||||
font-family: ${headingFont};
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
padding: 0.15em 0.3em;
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
}
|
||||
table td {
|
||||
padding: 0.25em 0.4em;
|
||||
vertical-align: top;
|
||||
line-height: 1.3em;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
table tr:last-child td {
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
table td:first-child {
|
||||
font-weight: bold;
|
||||
width: 2em;
|
||||
text-align: center;
|
||||
border-right: 1px solid #1a1a1a;
|
||||
}
|
||||
.encounters-table td:nth-child(2) {
|
||||
font-weight: bold;
|
||||
min-width: 20%;
|
||||
max-width: 30%;
|
||||
padding-right: 0.5em;
|
||||
border-right: 1px solid #1a1a1a;
|
||||
}
|
||||
.encounters-table td:nth-child(3) {
|
||||
width: auto;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.3em;
|
||||
}
|
||||
.map-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
page-break-before: always;
|
||||
}
|
||||
.map-container {
|
||||
text-align: center;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.map-container img {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 3cm);
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
ul {
|
||||
margin: 0.2em 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
li {
|
||||
margin: 0.08em 0;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.3em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content-page">
|
||||
<h1>${escapeHtml(data.title)}</h1>
|
||||
${data.flavor ? `<p class="flavor">${escapeHtml(data.flavor)}</p>` : ''}
|
||||
|
||||
<div class="columns">
|
||||
<div class="col">
|
||||
${data.hooksRumors && data.hooksRumors.length > 0 ? `
|
||||
<div class="section-block">
|
||||
<h2>Hooks & Rumors</h2>
|
||||
<ul>
|
||||
${data.hooksRumors.map(hook => `<li>${escapeHtml(hook)}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${data.randomEvents && data.randomEvents.length > 0 ? `
|
||||
<div class="section-block random-events">
|
||||
<h2>Random Events (d6)</h2>
|
||||
<table class="encounters-table">
|
||||
<tbody>
|
||||
${data.randomEvents.map((event, index) => {
|
||||
const { name: eventName, description: eventDesc } = parseEventForDisplay(event, index);
|
||||
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 => {
|
||||
const desc = truncateText(room.description || '', 1, 100);
|
||||
return `
|
||||
<div class="room">
|
||||
<h3>${escapeHtml(room.name)}</h3>
|
||||
<p>${escapeHtml(desc)}</p>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
${data.encounters && data.encounters.length > 0 ? `
|
||||
<div class="section-block">
|
||||
<h2>Encounters (d6)</h2>
|
||||
<table class="encounters-table">
|
||||
<tbody>
|
||||
${data.encounters.map((encounter, index) => {
|
||||
const raw = (encounter.details || '').trim();
|
||||
const withoutName = raw.toLowerCase().startsWith(encounter.name.toLowerCase())
|
||||
? raw.substring(encounter.name.length).replace(/^:\s*/, '').trim()
|
||||
: raw;
|
||||
const locationMatch = withoutName.match(/^([^:]+):\s*(.+)$/);
|
||||
const withoutLocation = locationMatch
|
||||
? (() => {
|
||||
const potential = locationMatch[1].trim();
|
||||
if (potential.length > 3 && potential.length < 50 && /^[A-Z]/.test(potential)) {
|
||||
return locationMatch[2].trim();
|
||||
}
|
||||
return withoutName;
|
||||
})()
|
||||
: withoutName;
|
||||
const details = truncateText(withoutLocation, 4, 350);
|
||||
return `
|
||||
<tr>
|
||||
<td>${index + 1}</td>
|
||||
<td><strong>${escapeHtml(encounter.name)}</strong></td>
|
||||
<td>${escapeHtml(details)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${data.treasure && data.treasure.length > 0 ? `
|
||||
<div class="section-block">
|
||||
<h2>Treasure</h2>
|
||||
${data.treasure.map(item => {
|
||||
if (typeof item === 'object' && item.name && item.description) {
|
||||
return `<div class="treasure"><strong>${escapeHtml(item.name)}</strong> — ${escapeHtml(item.description)}</div>`;
|
||||
} else if (typeof item === 'string') {
|
||||
// Handle string format "Name — Description"
|
||||
const parts = item.split(/[—–-]/);
|
||||
if (parts.length >= 2) {
|
||||
return `<div class="treasure"><strong>${escapeHtml(parts[0].trim())}</strong> — ${escapeHtml(parts.slice(1).join(' ').trim())}</div>`;
|
||||
}
|
||||
return `<div class="treasure">${escapeHtml(item)}</div>`;
|
||||
}
|
||||
return '';
|
||||
}).filter(Boolean).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
${data.npcs && data.npcs.length > 0 ? `
|
||||
<div class="section-block">
|
||||
<h2>NPCs</h2>
|
||||
${data.npcs.map(npc => {
|
||||
if (typeof npc === 'object' && npc.name && npc.trait) {
|
||||
return `<div class="npc"><strong>${escapeHtml(npc.name)}</strong>: ${escapeHtml(npc.trait)}</div>`;
|
||||
} else if (typeof npc === 'string') {
|
||||
// Handle string format "Name: Description"
|
||||
const parts = npc.split(/:/);
|
||||
if (parts.length >= 2) {
|
||||
return `<div class="npc"><strong>${escapeHtml(parts[0].trim())}</strong>: ${escapeHtml(parts.slice(1).join(':').trim())}</div>`;
|
||||
}
|
||||
return `<div class="npc">${escapeHtml(npc)}</div>`;
|
||||
}
|
||||
return '';
|
||||
}).filter(Boolean).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${data.plotResolutions && data.plotResolutions.length > 0 ? `
|
||||
<div class="section-block">
|
||||
<h2>Plot Resolutions</h2>
|
||||
${data.plotResolutions.map(resolution => {
|
||||
const text = truncateText(resolution || '', 1, 120);
|
||||
return `
|
||||
<div class="plot-resolution">
|
||||
${escapeHtml(text)}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${hasMap ? `
|
||||
<div class="content-page map-page">
|
||||
<div class="map-container">
|
||||
<img src="${data.map}" alt="Dungeon Map" />
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
44
src/generatePDF.js
Normal file
44
src/generatePDF.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import puppeteer from "puppeteer";
|
||||
import { dungeonTemplate } from "./dungeonTemplate.js";
|
||||
import fs from "fs/promises";
|
||||
|
||||
export async function generatePDF(data, outputPath = "dungeon.pdf") {
|
||||
const browser = await puppeteer.launch({
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
const toBase64DataUrl = (buffer) =>
|
||||
`data:image/png;base64,${buffer.toString("base64")}`;
|
||||
|
||||
const readImageData = async (path) =>
|
||||
fs
|
||||
.readFile(path)
|
||||
.then(toBase64DataUrl)
|
||||
.catch(() => {
|
||||
console.warn(
|
||||
"Warning: Could not read image file, proceeding without map in PDF",
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
const imageData = data.map ? await readImageData(data.map) : null;
|
||||
const dataWithImage = imageData
|
||||
? { ...data, map: imageData }
|
||||
: (({ map, ...rest }) => rest)(data); // eslint-disable-line no-unused-vars
|
||||
|
||||
const html = dungeonTemplate(dataWithImage);
|
||||
await page.setContent(html, { waitUntil: "networkidle0" });
|
||||
|
||||
await page.pdf({
|
||||
path: outputPath,
|
||||
format: "A4",
|
||||
landscape: true,
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
console.log(`Dungeon PDF saved to ${outputPath}`);
|
||||
}
|
||||
@@ -2,8 +2,9 @@ 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";
|
||||
|
||||
@@ -62,7 +63,9 @@ Input:
|
||||
${flavor}
|
||||
|
||||
Output:`,
|
||||
"gemma3n:e4b", 3, "Generate Visual Prompt"
|
||||
OLLAMA_MODEL,
|
||||
3,
|
||||
"Generate Visual Prompt"
|
||||
);
|
||||
|
||||
const accentColor = selectRandomAccentColor();
|
||||
@@ -164,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];
|
||||
|
||||
@@ -203,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();
|
||||
@@ -228,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);
|
||||
|
||||
127
src/ollamaClient.js
Normal file
127
src/ollamaClient.js
Normal file
@@ -0,0 +1,127 @@
|
||||
import { cleanText } from "./textUtils.js";
|
||||
|
||||
const OLLAMA_API_URL = process.env.OLLAMA_API_URL;
|
||||
export let OLLAMA_MODEL = process.env.OLLAMA_MODEL || "gemma3:latest";
|
||||
|
||||
export async function initializeModel() {
|
||||
if (process.env.OLLAMA_MODEL) return;
|
||||
try {
|
||||
const apiUrl = process.env.OLLAMA_API_URL;
|
||||
const isOpenWebUI = apiUrl?.includes("/api/chat/completions");
|
||||
const baseUrl = apiUrl?.replace(/\/api\/.*$/, "");
|
||||
const url = isOpenWebUI ? `${baseUrl}/api/v1/models` : `${baseUrl}/api/tags`;
|
||||
const headers = isOpenWebUI && process.env.OLLAMA_API_KEY
|
||||
? { "Authorization": `Bearer ${process.env.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}`);
|
||||
}
|
||||
}
|
||||
|
||||
export { cleanText };
|
||||
|
||||
export function inferApiType(url) {
|
||||
if (!url) return "ollama-generate";
|
||||
if (url.includes("/api/chat/completions")) return "open-webui";
|
||||
if (url.includes("/api/chat")) return "ollama-chat";
|
||||
return "ollama-generate";
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function callOllamaBase(prompt, model, retries, stepName, apiType) {
|
||||
const isUsingOpenWebUI = apiType === "open-webui";
|
||||
const isUsingOllamaChat = apiType === "ollama-chat";
|
||||
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const promptCharCount = prompt.length;
|
||||
const promptWordCount = prompt.split(/\s+/).length;
|
||||
|
||||
console.log(
|
||||
`\n[${stepName}] Sending prompt (attempt ${attempt}/${retries})`,
|
||||
);
|
||||
console.log(
|
||||
`Prompt: ${promptCharCount} chars, ~${promptWordCount} words`,
|
||||
);
|
||||
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (isUsingOpenWebUI && process.env.OLLAMA_API_KEY) {
|
||||
headers["Authorization"] = `Bearer ${process.env.OLLAMA_API_KEY}`;
|
||||
}
|
||||
|
||||
const body = isUsingOpenWebUI || isUsingOllamaChat
|
||||
? { model, messages: [{ role: "user", content: prompt }] }
|
||||
: { model, prompt, stream: false };
|
||||
|
||||
const response = await fetch(OLLAMA_API_URL, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text().catch(() => null);
|
||||
const errorDetails = errorData ? `: ${errorData}` : "";
|
||||
throw new Error(
|
||||
`Ollama request failed: ${response.status} ${response.statusText}${errorDetails}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawText = isUsingOpenWebUI
|
||||
? data.choices?.[0]?.message?.content
|
||||
: isUsingOllamaChat
|
||||
? data.message?.content
|
||||
: data.response;
|
||||
|
||||
if (!rawText) throw new Error("No response from Ollama");
|
||||
|
||||
const cleaned = cleanText(rawText);
|
||||
console.log(
|
||||
`[${stepName}] Received: ${rawText.length} chars, ~${rawText.split(/\s+/).length} words`,
|
||||
);
|
||||
|
||||
return cleaned;
|
||||
} catch (err) {
|
||||
console.warn(`[${stepName}] Attempt ${attempt} failed: ${err.message}`);
|
||||
if (attempt === retries) throw err;
|
||||
const delay = 1000 * Math.pow(2, attempt) + Math.random() * 500;
|
||||
console.log(`Retrying in ${Math.round(delay / 1000)}s...`);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function callOllama(
|
||||
prompt,
|
||||
model = OLLAMA_MODEL,
|
||||
retries = 5,
|
||||
stepName = "unknown",
|
||||
) {
|
||||
const apiType = inferApiType(OLLAMA_API_URL);
|
||||
return callOllamaBase(prompt, model, retries, stepName, apiType);
|
||||
}
|
||||
|
||||
export async function callOllamaExplicit(
|
||||
prompt,
|
||||
model = OLLAMA_MODEL,
|
||||
retries = 5,
|
||||
stepName = "unknown",
|
||||
apiType = "ollama-generate",
|
||||
) {
|
||||
return callOllamaBase(prompt, model, retries, stepName, apiType);
|
||||
}
|
||||
234
src/parsing.js
Normal file
234
src/parsing.js
Normal file
@@ -0,0 +1,234 @@
|
||||
import { cleanText } from "./textUtils.js";
|
||||
|
||||
export function parseList(raw) {
|
||||
if (!raw) return [];
|
||||
const NUMBERED_ITEM_REGEX = /\d+[).]\s+([\s\S]+?)(?=\s*\d+[).]\s+|$)/g;
|
||||
const items = Array.from(raw.matchAll(NUMBERED_ITEM_REGEX))
|
||||
.map(match => match[1].trim())
|
||||
.filter(Boolean)
|
||||
.map(cleanText)
|
||||
.filter(Boolean);
|
||||
return items.length > 0
|
||||
? items
|
||||
: raw
|
||||
.split(/\n?\d+[).]\s+/)
|
||||
.map(line => cleanText(line))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseObjects(raw, type = "rooms") {
|
||||
const cleanedRaw = raw.replace(/Intermediate Rooms:/i, "").replace(/Climax Room:/i, "").trim();
|
||||
const mapper = (entry) => {
|
||||
if (type === "encounters") {
|
||||
const parts = entry.split(/:/);
|
||||
if (parts.length >= 3) {
|
||||
const name = parts[0].trim();
|
||||
if (name.toLowerCase().includes('location name') || name.toLowerCase().includes('encounter name')) return null;
|
||||
return { name, details: parts.slice(1).join(":").trim() };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
const name = parts[0].trim();
|
||||
if (name.toLowerCase().includes('location name') || name.toLowerCase().includes('encounter name')) return null;
|
||||
return { name, details: parts[1].trim() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (type === "treasure") {
|
||||
const parts = entry.split(/[—]/);
|
||||
if (parts.length >= 2) {
|
||||
const cleanName = parts[0].trim();
|
||||
if (cleanName.toLowerCase().includes('treasure name') || cleanName.toLowerCase().includes('actual ')) return null;
|
||||
const desc = parts.slice(1).join(' ').trim().replace(/^description\s*:?\s*/i, '').trim();
|
||||
return { name: cleanName, description: desc };
|
||||
}
|
||||
}
|
||||
const [name, ...descParts] = entry.split(/[-–—:]/);
|
||||
const cleanName = name.trim();
|
||||
if (cleanName.toLowerCase().includes('location name') ||
|
||||
cleanName.toLowerCase().includes('npc name') ||
|
||||
cleanName.toLowerCase().includes('treasure name') ||
|
||||
cleanName.toLowerCase().includes('actual ')) return null;
|
||||
const desc = type === "npcs"
|
||||
? descParts.join(" ").trim().replace(/^description\s*:?\s*/i, '').trim()
|
||||
: descParts.join(" ").trim();
|
||||
const obj = { name: cleanName };
|
||||
if (type === "rooms") return { ...obj, description: desc };
|
||||
if (type === "npcs") return { ...obj, trait: desc };
|
||||
if (type === "treasure") return { ...obj, description: desc };
|
||||
return null;
|
||||
};
|
||||
return cleanedRaw.split(/\n?\d+[).]\s+/).map(cleanText).filter(Boolean).map(mapper).filter(Boolean);
|
||||
}
|
||||
|
||||
export const parseEncounterText = (text, idx) => {
|
||||
const encounterMatch = text.match(/Encounter\s+(\d+)\s+(.+?)\s+(?:Room Name|Location)\s+(.+?)\s+Details\s+(.+)/i);
|
||||
if (encounterMatch) {
|
||||
const [, , name, location, details] = encounterMatch;
|
||||
return { name: name.trim(), details: `${location.trim()}: ${details.trim()}` };
|
||||
}
|
||||
const colonFormat = text.match(/Encounter\s+\d+\s+(.+?):\s*(.+?):\s*(.+)/i);
|
||||
if (colonFormat) {
|
||||
const [, name, location, details] = colonFormat;
|
||||
return { name: name.trim(), details: `${location.trim()}: ${details.trim()}` };
|
||||
}
|
||||
const match = text.match(/^(\d+)\s+(.+?)(?::\s*(.+))?$/);
|
||||
if (match) {
|
||||
const [, , name, details] = match;
|
||||
return name && details ? { name: name.trim(), details: details.trim() } : null;
|
||||
}
|
||||
const colonSplit = text.split(/[:]/);
|
||||
if (colonSplit.length > 1) {
|
||||
return {
|
||||
name: colonSplit[0].replace(/^\d+\s+|Encounter\s+\d+\s+/i, "").trim(),
|
||||
details: colonSplit.slice(1).join(":").trim()
|
||||
};
|
||||
}
|
||||
const nameMatch = text.match(/^\d+\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)/);
|
||||
if (nameMatch) {
|
||||
return {
|
||||
name: nameMatch[1],
|
||||
details: text.replace(/^\d+\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\s*/, "").trim()
|
||||
};
|
||||
}
|
||||
return { name: `Encounter ${idx + 1}`, details: text.replace(/^\d+\s+|Encounter\s+\d+\s+/i, "").trim() };
|
||||
};
|
||||
|
||||
export const splitCombinedEncounters = (encounters) => {
|
||||
if (encounters.length === 0) return [];
|
||||
const shouldSplit = encounters.length === 1 && (encounters[0].name === "1" || encounters[0].details?.match(/\d+\s+[A-Z]/) || encounters[0].details?.includes('Encounter'));
|
||||
if (!shouldSplit) return encounters;
|
||||
console.warn("Encounters appear combined, attempting to split...");
|
||||
const combinedText = encounters[0].details || "";
|
||||
const split = combinedText.split(/(?=Encounter\s+\d+|\d+\s+[A-Z][a-z])/i).filter(Boolean);
|
||||
return (split.length > 1 || (split.length === 1 && combinedText.length > 100))
|
||||
? split.map((text, idx) => parseEncounterText(text, idx)).filter(e => e?.name && e?.details?.length > 10)
|
||||
: encounters;
|
||||
};
|
||||
|
||||
function _splitCombinedNPCs(npcs) {
|
||||
const shouldSplit = npcs.length === 1 && npcs[0].trait?.length > 80;
|
||||
if (!shouldSplit) return npcs;
|
||||
console.warn("NPCs appear combined, attempting to split...");
|
||||
const split = npcs[0].trait.split(/(?=[A-Z][a-z]+\s+[A-Z][a-z]+\s*:)/).filter(Boolean);
|
||||
return split.length > 1
|
||||
? split.map(text => {
|
||||
const [name, ...traitParts] = text.split(/[:]/);
|
||||
return { name: name.trim(), trait: traitParts.join(":").trim() };
|
||||
}).filter(n => n.name && n.trait?.length > 10)
|
||||
: npcs;
|
||||
}
|
||||
|
||||
function parseTreasureText(text, idx, splitTreasures) {
|
||||
if (idx === splitTreasures.length - 1 && text.length < 40) {
|
||||
return { name: splitTreasures[idx - 1]?.split(/\s+/).slice(-2).join(" ") || `Treasure ${idx}`, description: text };
|
||||
}
|
||||
const dashSplit = text.split(/[—]/);
|
||||
if (dashSplit.length === 2) return { name: dashSplit[0].trim(), description: dashSplit[1].trim() };
|
||||
if (text.length < 30 && /^[A-Z]/.test(text)) return { name: text.trim(), description: "" };
|
||||
return null;
|
||||
}
|
||||
|
||||
function _splitCombinedTreasures(treasure) {
|
||||
const shouldSplit = treasure.length === 1 && treasure[0].description?.length > 60;
|
||||
if (!shouldSplit) return treasure;
|
||||
console.warn("Treasures appear combined, attempting to split...");
|
||||
const split = treasure[0].description.split(/\s+—\s+/).filter(Boolean);
|
||||
if (split.length <= 1) return treasure;
|
||||
const parsed = split.map((text, idx) => parseTreasureText(text, idx, split)).filter(t => t?.name && t?.description);
|
||||
if (parsed.length > 0) return parsed;
|
||||
const nameDescPairs = treasure[0].description.match(/([A-Z][^—]+?)\s+—\s+([^—]+?)(?=\s+[A-Z][^—]+\s+—|$)/g);
|
||||
return nameDescPairs
|
||||
? nameDescPairs.map(pair => {
|
||||
const match = pair.match(/([^—]+)\s+—\s+(.+)/);
|
||||
return match ? { name: match[1].trim(), description: match[2].trim() } : null;
|
||||
}).filter(t => t)
|
||||
: treasure;
|
||||
}
|
||||
|
||||
export function parseRandomEventsRaw(rawSection) {
|
||||
const parsed = parseList(rawSection || "");
|
||||
return parsed
|
||||
.filter(e =>
|
||||
e &&
|
||||
e.toLowerCase() !== 'a random event occurs' &&
|
||||
e.toLowerCase() !== 'a random event occurs.' &&
|
||||
!e.toLowerCase().includes('placeholder') &&
|
||||
e.length > 10
|
||||
)
|
||||
.map((e, index) => {
|
||||
const cleaned = e.replace(/^(Event\s+\d+[:\s]+|Random\s+Event[:\s]+|Random\s+Events?[:\s]+)/i, '').trim();
|
||||
const colonMatch = cleaned.match(/^([^:]+):\s*(.+)$/);
|
||||
if (colonMatch) {
|
||||
const name = colonMatch[1].trim();
|
||||
const description = colonMatch[2].trim();
|
||||
if (name.toLowerCase().includes('event name') || name.toLowerCase().includes('placeholder')) return null;
|
||||
return { name, description };
|
||||
}
|
||||
const words = cleaned.split(/\s+/);
|
||||
if (words.length > 3) {
|
||||
return { name: words.slice(0, 2).join(' '), description: words.slice(2).join(' ') };
|
||||
}
|
||||
return { name: `Event ${index + 1}`, description: cleaned };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseMainContentSections(mainContentRaw) {
|
||||
const initialSplit = mainContentRaw.split(/Encounters:|NPCs:|Treasures?:|Random Events:/i);
|
||||
const withRandom = (!initialSplit[4] && mainContentRaw.toLowerCase().includes('random'))
|
||||
? (() => {
|
||||
const randomMatch = mainContentRaw.match(/Random Events?[:\s]*\n?([^]*?)(?=Locations?:|Encounters?:|NPCs?:|Treasures?:|$)/i);
|
||||
return randomMatch ? [...initialSplit.slice(0, 4), randomMatch[1]] : initialSplit;
|
||||
})()
|
||||
: initialSplit;
|
||||
const withNpcs = (!withRandom[2] && mainContentRaw.toLowerCase().includes('npc'))
|
||||
? (() => {
|
||||
const npcMatch = mainContentRaw.match(/NPCs?[:\s]*\n?([^]*?)(?=Treasures?:|Random Events?:|Locations?:|Encounters?:|$)/i);
|
||||
return npcMatch ? [withRandom[0], withRandom[1], npcMatch[1], withRandom[3], withRandom[4]] : withRandom;
|
||||
})()
|
||||
: withRandom;
|
||||
|
||||
const inter = withNpcs[0];
|
||||
const enc = (withNpcs[1] || '').trim();
|
||||
if (enc || !inter.includes('Encounter')) {
|
||||
return {
|
||||
intermediateRoomsSection: inter,
|
||||
encountersSection: enc,
|
||||
npcsSection: withNpcs[2],
|
||||
treasureSection: withNpcs[3],
|
||||
randomEventsSection: withNpcs[4],
|
||||
};
|
||||
}
|
||||
const encounterMatches = inter.match(/Encounter\s+\d+[^]*?(?=Encounter\s+\d+|NPCs?:|Treasures?:|Random Events?:|Location \d+|$)/gi);
|
||||
if (!encounterMatches || encounterMatches.length === 0) {
|
||||
return {
|
||||
intermediateRoomsSection: inter,
|
||||
encountersSection: enc,
|
||||
npcsSection: withNpcs[2],
|
||||
treasureSection: withNpcs[3],
|
||||
randomEventsSection: withNpcs[4],
|
||||
};
|
||||
}
|
||||
const encountersSection = encounterMatches.map((m, i) => {
|
||||
const match = m.match(/Encounter\s+(\d+)\s+(.+?)\s+(?:Room Name|Location)\s+(.+?)\s+Details\s+(.+)/i);
|
||||
if (match) {
|
||||
const [, num, name, location, details] = match;
|
||||
return `${num}. ${name.trim()}: ${location.trim()}: ${details.trim().substring(0, 200)}`;
|
||||
}
|
||||
const simpleMatch = m.match(/Encounter\s+(\d+)\s+(.+?)\s+([A-Z][^:]+?)\s+Details\s+(.+)/i);
|
||||
if (simpleMatch) {
|
||||
const [, num, name, location, details] = simpleMatch;
|
||||
return `${num}. ${name.trim()}: ${location.trim()}: ${details.trim().substring(0, 200)}`;
|
||||
}
|
||||
return `${i + 1}. ${m.trim()}`;
|
||||
}).join('\n');
|
||||
const intermediateRoomsSection = inter.replace(/Encounter\s+\d+[^]*?(?=Encounter\s+\d+|NPCs?:|Treasures?:|Random Events?:|Location \d+|$)/gi, '');
|
||||
return {
|
||||
intermediateRoomsSection,
|
||||
encountersSection,
|
||||
npcsSection: withNpcs[2],
|
||||
treasureSection: withNpcs[3],
|
||||
randomEventsSection: withNpcs[4],
|
||||
};
|
||||
}
|
||||
|
||||
10
src/textUtils.js
Normal file
10
src/textUtils.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Strip markdown artifacts and normalize whitespace. Pure, no side effects. */
|
||||
export function cleanText(str) {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/^#+\s*/gm, "")
|
||||
.replace(/\*\*(.*?)\*\*/g, "$1")
|
||||
.replace(/[*_`]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
120
src/validation.js
Normal file
120
src/validation.js
Normal file
@@ -0,0 +1,120 @@
|
||||
export function extractCanonicalNames(dungeonData) {
|
||||
const names = { npcs: [], rooms: [], factions: [] };
|
||||
if (dungeonData.npcs) {
|
||||
dungeonData.npcs.forEach(npc => { if (npc.name) names.npcs.push(npc.name.trim()); });
|
||||
}
|
||||
if (dungeonData.rooms) {
|
||||
dungeonData.rooms.forEach(room => { if (room.name) names.rooms.push(room.name.trim()); });
|
||||
}
|
||||
if (dungeonData.coreConcepts) {
|
||||
const factionMatch = dungeonData.coreConcepts.match(/Primary Faction[:\s]+([^.]+)/i);
|
||||
if (factionMatch) names.factions.push(factionMatch[1].trim());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export function validateContentCompleteness(dungeonData) {
|
||||
const issues = [];
|
||||
const checks = [
|
||||
['title', 0, 'Missing title'],
|
||||
['flavor', 20, 'Flavor text too short'],
|
||||
['hooksRumors', 4, 'Expected at least 4 hooks'],
|
||||
['rooms', 5, 'Expected at least 5 rooms'],
|
||||
['encounters', 6, 'Expected at least 6 encounters'],
|
||||
['npcs', 4, 'Expected at least 4 NPCs'],
|
||||
['treasure', 4, 'Expected at least 4 treasures'],
|
||||
['randomEvents', 6, 'Expected 6 random events'],
|
||||
['plotResolutions', 4, 'Expected at least 4 plot resolutions']
|
||||
];
|
||||
checks.forEach(([key, min, msg]) => {
|
||||
const val = dungeonData[key];
|
||||
if (!val || (Array.isArray(val) ? val.length < min : val.trim().length < min)) {
|
||||
issues.push(`${msg}${Array.isArray(val) ? `, got ${val?.length || 0}` : ''}`);
|
||||
}
|
||||
});
|
||||
dungeonData.rooms?.forEach((r, i) => {
|
||||
if (!r.description || r.description.trim().length < 20) {
|
||||
issues.push(`Room ${i + 1} (${r.name}) description too short`);
|
||||
}
|
||||
});
|
||||
dungeonData.encounters?.forEach((e, i) => {
|
||||
if (!e.details || e.details.trim().length < 30) {
|
||||
issues.push(`Encounter ${i + 1} (${e.name}) details too short`);
|
||||
}
|
||||
});
|
||||
dungeonData.npcs?.forEach((n, i) => {
|
||||
if (!n.trait || n.trait.trim().length < 30) {
|
||||
issues.push(`NPC ${i + 1} (${n.name}) description too short`);
|
||||
}
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateContentQuality(dungeonData) {
|
||||
const issues = [];
|
||||
const vagueWords = /\b(some|various|several|many|few|things|stuff|items|objects)\b/gi;
|
||||
const checkVague = (text, ctx) => {
|
||||
if (!text) return;
|
||||
const matches = text.match(vagueWords);
|
||||
if (matches?.length > 2) {
|
||||
issues.push(`${ctx} contains vague language: "${matches.slice(0, 3).join('", "')}"`);
|
||||
}
|
||||
};
|
||||
checkVague(dungeonData.flavor, 'Flavor text');
|
||||
dungeonData.rooms?.forEach(r => checkVague(r.description, `Room "${r.name}"`));
|
||||
dungeonData.encounters?.forEach(e => checkVague(e.details, `Encounter "${e.name}"`));
|
||||
dungeonData.npcs?.forEach(n => checkVague(n.trait, `NPC "${n.name}"`));
|
||||
dungeonData.rooms?.forEach(r => {
|
||||
if (r.description?.length < 50) {
|
||||
issues.push(`Room "${r.name}" description too short`);
|
||||
}
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateContentStructure(dungeonData) {
|
||||
const issues = [];
|
||||
dungeonData.rooms?.forEach((r, i) => {
|
||||
if (!r.name?.trim()) issues.push(`Room ${i + 1} missing name`);
|
||||
if (r.name?.split(/\s+/).length > 6) issues.push(`Room "${r.name}" name too long`);
|
||||
});
|
||||
dungeonData.encounters?.forEach((e, i) => {
|
||||
if (!e.name?.trim()) issues.push(`Encounter ${i + 1} missing name`);
|
||||
if (e.name?.split(/\s+/).length > 6) issues.push(`Encounter "${e.name}" name too long`);
|
||||
if (e.details && !e.details.match(/^[^:]+:\s/)) {
|
||||
issues.push(`Encounter "${e.name}" details missing location prefix`);
|
||||
}
|
||||
});
|
||||
dungeonData.npcs?.forEach((n, i) => {
|
||||
if (!n.name?.trim()) issues.push(`NPC ${i + 1} missing name`);
|
||||
if (n.name?.split(/\s+/).length > 4) issues.push(`NPC "${n.name}" name too long`);
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateNarrativeCoherence(dungeonData) {
|
||||
const issues = [];
|
||||
const factionMatch = dungeonData.coreConcepts?.match(/Primary Faction[:\s]+([^.]+)/i);
|
||||
const factionName = factionMatch?.[1]?.trim();
|
||||
if (dungeonData.encounters && dungeonData.rooms) {
|
||||
const roomNames = dungeonData.rooms.map(r => r.name.trim().toLowerCase());
|
||||
dungeonData.encounters.forEach(e => {
|
||||
const locMatch = e.details?.match(/^([^:]+):/);
|
||||
if (locMatch) {
|
||||
const locName = locMatch[1].trim().toLowerCase();
|
||||
if (!roomNames.some(rn => locName.includes(rn) || rn.includes(locName))) {
|
||||
issues.push(`Encounter "${e.name}" references unknown location "${locMatch[1]}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (factionName) {
|
||||
const factionLower = factionName.toLowerCase();
|
||||
const refs = (dungeonData.npcs?.filter(n => n.trait?.toLowerCase().includes(factionLower)).length ?? 0)
|
||||
+ (dungeonData.encounters?.filter(e => e.details?.toLowerCase().includes(factionLower)).length ?? 0);
|
||||
if (refs < 2) {
|
||||
issues.push(`Faction "${factionName}" poorly integrated (${refs} references)`);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
92
test/integration/dungeonGeneration.test.js
Normal file
92
test/integration/dungeonGeneration.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { generateDungeon } from "../../src/dungeonGenerator.js";
|
||||
import { generatePDF } from "../../src/generatePDF.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const hasOllama = !!process.env.OLLAMA_API_URL;
|
||||
|
||||
describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => {
|
||||
let dungeonData;
|
||||
|
||||
beforeAll(async () => {
|
||||
dungeonData = await generateDungeon();
|
||||
}, 120000);
|
||||
|
||||
it("generates dungeon data", () => {
|
||||
expect(dungeonData).toBeDefined();
|
||||
});
|
||||
|
||||
it("title is 2-4 words, no colons", () => {
|
||||
expect(dungeonData.title).toBeTruthy();
|
||||
const words = dungeonData.title.split(/\s+/);
|
||||
expect(words.length).toBeGreaterThanOrEqual(2);
|
||||
expect(words.length).toBeLessThanOrEqual(4);
|
||||
expect(dungeonData.title).not.toContain(":");
|
||||
});
|
||||
|
||||
it("flavor text is ≤60 words", () => {
|
||||
expect(dungeonData.flavor).toBeTruthy();
|
||||
const words = dungeonData.flavor.split(/\s+/);
|
||||
expect(words.length).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it("hooks have no title prefixes", () => {
|
||||
expect(dungeonData.hooksRumors).toBeDefined();
|
||||
dungeonData.hooksRumors.forEach((hook) => {
|
||||
expect(hook).not.toMatch(/^[^:]+:\s/);
|
||||
});
|
||||
});
|
||||
|
||||
it("has exactly 6 random events", () => {
|
||||
expect(dungeonData.randomEvents).toBeDefined();
|
||||
expect(dungeonData.randomEvents.length).toBe(6);
|
||||
});
|
||||
|
||||
it("encounter details do not start with encounter name", () => {
|
||||
expect(dungeonData.encounters).toBeDefined();
|
||||
dungeonData.encounters.forEach((encounter) => {
|
||||
if (encounter.details) {
|
||||
const detailsLower = encounter.details.toLowerCase();
|
||||
const nameLower = encounter.name.toLowerCase();
|
||||
expect(detailsLower.startsWith(nameLower)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("treasure descriptions do not start with 'description'", () => {
|
||||
expect(dungeonData.treasure).toBeDefined();
|
||||
dungeonData.treasure.forEach((item) => {
|
||||
if (typeof item === "object" && item.description) {
|
||||
expect(item.description.toLowerCase().startsWith("description")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("NPC traits do not start with 'description'", () => {
|
||||
expect(dungeonData.npcs).toBeDefined();
|
||||
dungeonData.npcs.forEach((npc) => {
|
||||
if (npc.trait) {
|
||||
expect(npc.trait.toLowerCase().startsWith("description")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("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);
|
||||
const pdfText = pdfBuffer.toString("binary");
|
||||
const pageCount = (pdfText.match(/\/Type\s*\/Page[^s]/g) || []).length;
|
||||
const expectedPages = dungeonData.map ? 2 : 1;
|
||||
expect(pageCount).toBeLessThanOrEqual(expectedPages);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(testPdfPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
178
test/unit/dungeonGenerator.generateDungeon.test.js
Normal file
178
test/unit/dungeonGenerator.generateDungeon.test.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../src/ollamaClient.js", () => ({ callOllama: vi.fn() }));
|
||||
|
||||
const { callOllama } = await import("../../src/ollamaClient.js");
|
||||
const { generateDungeon } = await import("../../src/dungeonGenerator.js");
|
||||
|
||||
describe("generateDungeon (mocked Ollama)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(callOllama)
|
||||
.mockResolvedValueOnce(
|
||||
"1. Dark Hall\n2. Lost Mines\n3. Shadow Keep"
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
"Central Conflict: The power source fails. Primary Faction: The Guard. Dynamic Element: Temporal rifts."
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
"Description:\nA dark place under the earth.\nHooks & Rumors:\n1. A merchant vanished near the entrance.\n2. Strange lights in the depths.\n3. The Guard seeks the artifact.\n4. Rifts cause brief time skips."
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
"1. Entrance Hall: A dark entrance with torches and damp walls. Pillars offer cover. The air smells of earth.\n2. Climax Chamber: The final room where the power source pulses. The Guard holds the artifact. Multiple approaches possible."
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
`Locations:
|
||||
1. Corridor: A long corridor with flickering lights.
|
||||
2. Chamber: A side chamber with debris.
|
||||
3. Shrine: A small shrine to the old gods.
|
||||
|
||||
Encounters:
|
||||
1. Patrol: Hall: Guard patrol passes through.
|
||||
2. Rift: Corridor: A temporal rift causes disorientation.
|
||||
3. Ambush: Chamber: Bandits lie in wait.
|
||||
4. Guardian: Shrine: A warden challenges intruders.
|
||||
5. Boss: Climax Chamber: The leader defends the artifact.
|
||||
6. Trap: Corridor: A pressure plate triggers darts.
|
||||
|
||||
NPCs:
|
||||
1. Captain: Leader of the Guard, stern and duty-bound.
|
||||
2. Scout: Young scout, curious about the rifts.
|
||||
3. Priest: Keeper of the shrine, knows old lore.
|
||||
4. Merchant: Survivor who lost his cargo.
|
||||
|
||||
Treasures:
|
||||
1. Artifact: The power source core.
|
||||
2. Journal: Captain's log with tactical notes.
|
||||
3. Key: Opens the climax chamber.
|
||||
4. Gem: A glowing temporal crystal.
|
||||
|
||||
Random Events:
|
||||
1. Rift Shift: Time skips forward one hour.
|
||||
2. Guard Patrol: A patrol approaches.
|
||||
3. Echo: Voices from the past echo.
|
||||
4. Light Flicker: Lights go out for a moment.
|
||||
5. Distant Cry: Someone calls for help.
|
||||
6. Dust Fall: Ceiling dust falls, revealing a hidden symbol.`
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
"1. The adventurers could ally with the Guard and secure the artifact.\n2. They might destroy the source and end the rifts.\n3. They could bargain with the faction for passage.\n4. They might flee and seal the entrance."
|
||||
);
|
||||
});
|
||||
|
||||
it("returns dungeon data with all required fields", async () => {
|
||||
const result = await generateDungeon();
|
||||
expect(result).toBeDefined();
|
||||
expect(result.title).toBeTruthy();
|
||||
expect(result.flavor).toBeTruthy();
|
||||
expect(result.hooksRumors).toBeDefined();
|
||||
expect(Array.isArray(result.rooms)).toBe(true);
|
||||
expect(Array.isArray(result.encounters)).toBe(true);
|
||||
expect(Array.isArray(result.npcs)).toBe(true);
|
||||
expect(Array.isArray(result.treasure)).toBe(true);
|
||||
expect(Array.isArray(result.randomEvents)).toBe(true);
|
||||
expect(Array.isArray(result.plotResolutions)).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
});
|
||||
|
||||
describe("generateDungeon with fewer items (mocked Ollama)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(callOllama)
|
||||
.mockResolvedValueOnce("1. Dark Hall\n2. Lost Mines")
|
||||
.mockResolvedValueOnce("Central Conflict: War. Primary Faction: Guard. Dynamic Element: Magic.")
|
||||
.mockResolvedValueOnce("Description: A place.\nHooks & Rumors:\n1. One.\n2. Two.\n3. Three.\n4. Four.")
|
||||
.mockResolvedValueOnce("1. Entrance: First room.\n2. Climax: Final room.")
|
||||
.mockResolvedValueOnce(
|
||||
`Locations:
|
||||
1. Corridor: A corridor.
|
||||
2. Chamber: A chamber.
|
||||
|
||||
Encounters:
|
||||
1. Patrol: Corridor: A patrol.
|
||||
2. Ambush: Chamber: Bandits.
|
||||
|
||||
NPCs:
|
||||
1. Captain: Leader.
|
||||
2. Scout: Scout.
|
||||
|
||||
Treasures:
|
||||
1. Gold: Coins.
|
||||
2. Gem: A gem.
|
||||
|
||||
Random Events:
|
||||
1. Rift Shift: Time skips.
|
||||
2. Guard Patrol: Patrol approaches.`
|
||||
)
|
||||
.mockResolvedValueOnce("1. The adventurers could win.\n2. They might flee.");
|
||||
});
|
||||
|
||||
it("pads random events and encounters when step 5 returns fewer than 6", async () => {
|
||||
const result = await generateDungeon();
|
||||
expect(result.randomEvents.length).toBe(6);
|
||||
expect(result.encounters.length).toBe(6);
|
||||
expect(result.npcs.length).toBeGreaterThanOrEqual(4);
|
||||
}, 10000);
|
||||
|
||||
it("builds six encounters from scratch when step 5 returns none", async () => {
|
||||
vi.mocked(callOllama)
|
||||
.mockResolvedValueOnce("1. Dark Hall\n2. Lost Mines")
|
||||
.mockResolvedValueOnce("Central Conflict: War. Primary Faction: Guard. Dynamic Element: Magic.")
|
||||
.mockResolvedValueOnce("Description: A place.\nHooks & Rumors:\n1. One.\n2. Two.\n3. Three.\n4. Four.")
|
||||
.mockResolvedValueOnce("1. Entrance: First room.\n2. Climax: Final room.")
|
||||
.mockResolvedValueOnce(
|
||||
`Locations:
|
||||
1. Corridor: A corridor.
|
||||
2. Chamber: A chamber.
|
||||
|
||||
Encounters:
|
||||
|
||||
NPCs:
|
||||
1. Captain: Leader.
|
||||
|
||||
Treasures:
|
||||
1. Gold: Coins.
|
||||
|
||||
Random Events:
|
||||
1. Rift: Time skips.`
|
||||
)
|
||||
.mockResolvedValueOnce("1. The adventurers could win.");
|
||||
const result = await generateDungeon();
|
||||
expect(result.encounters.length).toBe(6);
|
||||
expect(result.encounters.every((e) => e.name && e.details)).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it("handles random events with no colon and short text (fallback name)", async () => {
|
||||
vi.mocked(callOllama)
|
||||
.mockResolvedValueOnce("1. Dark Hall\n2. Lost Mines")
|
||||
.mockResolvedValueOnce("Central Conflict: War. Primary Faction: Guard. Dynamic Element: Magic.")
|
||||
.mockResolvedValueOnce("Description: A place.\nHooks & Rumors:\n1. One.\n2. Two.\n3. Three.\n4. Four.")
|
||||
.mockResolvedValueOnce("1. Entrance: First room.\n2. Climax: Final room.")
|
||||
.mockResolvedValueOnce(
|
||||
`Locations:
|
||||
1. Corridor: A corridor.
|
||||
2. Chamber: A chamber.
|
||||
|
||||
Encounters:
|
||||
1. Patrol: Corridor: A patrol.
|
||||
2. Ambush: Chamber: Bandits.
|
||||
|
||||
NPCs:
|
||||
1. Captain: Leader.
|
||||
2. Scout: Scout.
|
||||
|
||||
Treasures:
|
||||
1. Gold: Coins.
|
||||
2. Gem: A gem.
|
||||
|
||||
Random Events:
|
||||
1. One two three
|
||||
2. Event Name: Placeholder event
|
||||
3. Rift Shift Time Skips Forward One Hour
|
||||
4. Rift Shift: Time skips forward one hour.`
|
||||
)
|
||||
.mockResolvedValueOnce("1. The adventurers could win.\n2. They might flee.");
|
||||
const result = await generateDungeon();
|
||||
expect(result.randomEvents.length).toBe(6);
|
||||
expect(result.randomEvents.some((e) => e.name && e.description)).toBe(true);
|
||||
}, 10000);
|
||||
});
|
||||
1088
test/unit/dungeonGenerator.test.js
Normal file
1088
test/unit/dungeonGenerator.test.js
Normal file
File diff suppressed because it is too large
Load Diff
227
test/unit/dungeonTemplate.test.js
Normal file
227
test/unit/dungeonTemplate.test.js
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
escapeHtml,
|
||||
truncateText,
|
||||
parseEventForDisplay,
|
||||
dungeonTemplate,
|
||||
} from "../../src/dungeonTemplate.js";
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(escapeHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for null/undefined-like", () => {
|
||||
expect(escapeHtml(null)).toBe("");
|
||||
expect(escapeHtml(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("escapes & < > \" '", () => {
|
||||
expect(escapeHtml("&")).toBe("&");
|
||||
expect(escapeHtml("<")).toBe("<");
|
||||
expect(escapeHtml(">")).toBe(">");
|
||||
expect(escapeHtml('"')).toBe(""");
|
||||
expect(escapeHtml("'")).toBe("'");
|
||||
expect(escapeHtml('<script>&"\'</script>')).toBe(
|
||||
"<script>&"'</script>"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves normal text unchanged", () => {
|
||||
expect(escapeHtml("Hello World")).toBe("Hello World");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateText", () => {
|
||||
it("returns empty for empty input", () => {
|
||||
expect(truncateText("", 1, 100)).toBe("");
|
||||
});
|
||||
|
||||
it("returns text when within sentence and char limits", () => {
|
||||
const one = "One sentence.";
|
||||
expect(truncateText(one, 1, 100)).toBe(one);
|
||||
});
|
||||
|
||||
it("truncates to maxSentences", () => {
|
||||
const three = "First. Second. Third.";
|
||||
expect(truncateText(three, 1, 500)).toBe("First.");
|
||||
expect(truncateText(three, 2, 500)).toContain("First.");
|
||||
expect(truncateText(three, 2, 500)).toContain("Second.");
|
||||
});
|
||||
|
||||
it("truncates by maxChars and ends at sentence boundary when possible", () => {
|
||||
const long = "A short bit. Then a much longer sentence that goes past the limit we set.";
|
||||
const out = truncateText(long, 99, 30);
|
||||
expect(out.length).toBeLessThanOrEqual(33);
|
||||
expect(out === "A short bit." || out.endsWith("...")).toBe(true);
|
||||
});
|
||||
|
||||
it("appends ... when no sentence boundary near end", () => {
|
||||
const noPeriod = "No period here and more text";
|
||||
expect(truncateText(noPeriod, 1, 15)).toMatch(/\.\.\.$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseEventForDisplay", () => {
|
||||
it("returns object name and description when given object", () => {
|
||||
const event = { name: "Event A", description: "Something happened." };
|
||||
const got = parseEventForDisplay(event, 0);
|
||||
expect(got.name).toBe("Event A");
|
||||
expect(got.description).toContain("Something");
|
||||
});
|
||||
|
||||
it('parses "Name: Description" string', () => {
|
||||
const got = parseEventForDisplay("Fire: The room catches fire.", 0);
|
||||
expect(got.name).toBe("Fire");
|
||||
expect(got.description).toContain("catches fire");
|
||||
});
|
||||
|
||||
it("splits string without colon into first two words as name, rest as description", () => {
|
||||
const got = parseEventForDisplay("One Two Three Four", 0);
|
||||
expect(got.name).toBe("One Two");
|
||||
expect(got.description).toBe("Three Four");
|
||||
});
|
||||
|
||||
it("uses fallback Event N and full string for short string", () => {
|
||||
const got = parseEventForDisplay("Hi", 2);
|
||||
expect(got.name).toBe("Event 3");
|
||||
expect(got.description).toBe("Hi");
|
||||
});
|
||||
|
||||
it("handles non-string non-object with index", () => {
|
||||
const got = parseEventForDisplay(null, 1);
|
||||
expect(got.name).toBe("Event 2");
|
||||
expect(got.description).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dungeonTemplate", () => {
|
||||
it("produces HTML with title and main sections for minimal data", () => {
|
||||
const data = {
|
||||
title: "Test Dungeon",
|
||||
flavor: "A dark place.",
|
||||
hooksRumors: ["Hook one.", "Hook two."],
|
||||
rooms: [{ name: "Room 1", description: "A room." }],
|
||||
encounters: [{ name: "Encounter 1", details: "Hall: Something happens." }],
|
||||
npcs: [{ name: "NPC 1", trait: "A guard." }],
|
||||
treasure: [{ name: "Gold", description: "Shiny." }],
|
||||
randomEvents: [{ name: "Event 1", description: "Something." }],
|
||||
plotResolutions: ["Resolution one."],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("Test Dungeon");
|
||||
expect(html).toContain("A dark place.");
|
||||
expect(html).toContain("Room 1");
|
||||
expect(html).toContain("Encounter 1");
|
||||
expect(html).toContain("NPC 1");
|
||||
expect(html).toContain("Gold");
|
||||
expect(html).toContain("Event 1");
|
||||
expect(html).toContain("Resolution one.");
|
||||
expect(html).toContain("<!DOCTYPE html>");
|
||||
});
|
||||
|
||||
it("includes map page when data.map is data URL", () => {
|
||||
const data = {
|
||||
title: "With Map",
|
||||
flavor: "Flavor.",
|
||||
map: "data:image/png;base64,abc123",
|
||||
hooksRumors: ["H1"],
|
||||
rooms: [],
|
||||
encounters: [],
|
||||
npcs: [],
|
||||
treasure: [],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("map-page");
|
||||
expect(html).toContain("data:image/png;base64,abc123");
|
||||
});
|
||||
|
||||
it("omits flavor paragraph when flavor is empty", () => {
|
||||
const data = {
|
||||
title: "No Flavor",
|
||||
flavor: "",
|
||||
hooksRumors: ["H1"],
|
||||
rooms: [],
|
||||
encounters: [],
|
||||
npcs: [],
|
||||
treasure: [],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("No Flavor");
|
||||
expect(html).not.toMatch(/<p class="flavor">/);
|
||||
});
|
||||
|
||||
it("renders treasure as string Name — Desc", () => {
|
||||
const data = {
|
||||
title: "T",
|
||||
flavor: "F",
|
||||
hooksRumors: [],
|
||||
rooms: [],
|
||||
encounters: [],
|
||||
npcs: [],
|
||||
treasure: ["Gold — Shiny coins."],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("Gold");
|
||||
expect(html).toContain("Shiny coins");
|
||||
});
|
||||
|
||||
it("renders NPC as string Name: Trait", () => {
|
||||
const data = {
|
||||
title: "T",
|
||||
flavor: "F",
|
||||
hooksRumors: [],
|
||||
rooms: [],
|
||||
encounters: [],
|
||||
npcs: ["Guard: A stern guard."],
|
||||
treasure: [],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("Guard");
|
||||
expect(html).toContain("stern guard");
|
||||
});
|
||||
|
||||
it("strips location prefix from encounter details when it looks like a location name", () => {
|
||||
const data = {
|
||||
title: "T",
|
||||
flavor: "F",
|
||||
hooksRumors: [],
|
||||
rooms: [{ name: "Grand Hall", description: "Big." }],
|
||||
encounters: [
|
||||
{ name: "E1", details: "Grand Hall: The fight happens here in the hall." },
|
||||
],
|
||||
npcs: [],
|
||||
treasure: [],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("The fight happens here");
|
||||
});
|
||||
|
||||
it("renders encounter details without name when details start with encounter name", () => {
|
||||
const data = {
|
||||
title: "T",
|
||||
flavor: "F",
|
||||
hooksRumors: [],
|
||||
rooms: [],
|
||||
encounters: [
|
||||
{ name: "Goblin Attack", details: "Goblin Attack: They strike." },
|
||||
],
|
||||
npcs: [],
|
||||
treasure: [],
|
||||
randomEvents: [],
|
||||
plotResolutions: [],
|
||||
};
|
||||
const html = dungeonTemplate(data);
|
||||
expect(html).toContain("They strike");
|
||||
});
|
||||
});
|
||||
263
test/unit/ollamaClient.test.js
Normal file
263
test/unit/ollamaClient.test.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
cleanText,
|
||||
inferApiType,
|
||||
callOllama,
|
||||
callOllamaExplicit,
|
||||
initializeModel,
|
||||
OLLAMA_MODEL,
|
||||
} from "../../src/ollamaClient.js";
|
||||
|
||||
describe("cleanText", () => {
|
||||
it("strips markdown headers", () => {
|
||||
expect(cleanText("# Title")).toBe("Title");
|
||||
expect(cleanText("## Sub")).toBe("Sub");
|
||||
});
|
||||
|
||||
it("replaces bold with plain text", () => {
|
||||
expect(cleanText("**bold**")).toBe("bold");
|
||||
});
|
||||
|
||||
it("removes asterisks and underscores", () => {
|
||||
expect(cleanText("*a* _b_")).toBe("a b");
|
||||
});
|
||||
|
||||
it("collapses whitespace to single spaces and trims", () => {
|
||||
expect(cleanText(" a b \n c ")).toBe("a b c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferApiType", () => {
|
||||
it("returns ollama-generate for null/undefined/empty string", () => {
|
||||
expect(inferApiType(null)).toBe("ollama-generate");
|
||||
expect(inferApiType(undefined)).toBe("ollama-generate");
|
||||
expect(inferApiType("")).toBe("ollama-generate");
|
||||
});
|
||||
|
||||
it("returns open-webui for URL with /api/chat/completions", () => {
|
||||
expect(inferApiType("http://host/api/chat/completions")).toBe("open-webui");
|
||||
});
|
||||
|
||||
it("returns ollama-chat for URL with /api/chat", () => {
|
||||
expect(inferApiType("http://host/api/chat")).toBe("ollama-chat");
|
||||
});
|
||||
|
||||
it("returns ollama-generate for plain base URL", () => {
|
||||
expect(inferApiType("http://localhost:11434")).toBe("ollama-generate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callOllama (mocked fetch)", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalEnv = process.env.OLLAMA_API_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.OLLAMA_API_URL = "http://localhost:11434";
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OLLAMA_API_URL = originalEnv;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns cleaned text from ollama-generate response", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ response: "**Hello** world" }),
|
||||
});
|
||||
const result = await callOllama("Hi", undefined, 1, "test");
|
||||
expect(result).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Error",
|
||||
text: () => Promise.resolve("server error"),
|
||||
});
|
||||
await expect(callOllama("Hi", undefined, 1, "test")).rejects.toThrow("Ollama request failed");
|
||||
});
|
||||
|
||||
it("throws on non-ok response when response.text() rejects", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
statusText: "Bad Gateway",
|
||||
text: () => Promise.reject(new Error("body read error")),
|
||||
});
|
||||
await expect(callOllama("Hi", undefined, 1, "test")).rejects.toThrow("Ollama request failed");
|
||||
});
|
||||
|
||||
it("retries on failure then succeeds", async () => {
|
||||
vi.mocked(globalThis.fetch)
|
||||
.mockRejectedValueOnce(new Error("network error"))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ response: "Retry ok" }),
|
||||
});
|
||||
const result = await callOllama("Hi", undefined, 2, "test");
|
||||
expect(result).toBe("Retry ok");
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("callOllamaExplicit (mocked fetch)", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalUrl = process.env.OLLAMA_API_URL;
|
||||
const originalKey = process.env.OLLAMA_API_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OLLAMA_API_URL = originalUrl;
|
||||
process.env.OLLAMA_API_KEY = originalKey;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns content from open-webui response shape", async () => {
|
||||
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "**Open** answer" } }],
|
||||
}),
|
||||
});
|
||||
const result = await callOllamaExplicit(
|
||||
"Hi",
|
||||
"model",
|
||||
1,
|
||||
"test",
|
||||
"open-webui"
|
||||
);
|
||||
expect(result).toBe("Open answer");
|
||||
});
|
||||
|
||||
it("sends Authorization header when open-webui and OLLAMA_API_KEY set", async () => {
|
||||
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
|
||||
process.env.OLLAMA_API_KEY = "secret-key";
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "ok" } }],
|
||||
}),
|
||||
});
|
||||
await callOllamaExplicit("Hi", "model", 1, "test", "open-webui");
|
||||
const [, opts] = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
expect(opts?.headers?.Authorization).toBe("Bearer secret-key");
|
||||
});
|
||||
|
||||
it("returns content from ollama-chat response shape", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ message: { content: "Chat **reply**" } }),
|
||||
});
|
||||
const result = await callOllamaExplicit(
|
||||
"Hi",
|
||||
"model",
|
||||
1,
|
||||
"test",
|
||||
"ollama-chat"
|
||||
);
|
||||
expect(result).toBe("Chat reply");
|
||||
});
|
||||
|
||||
it("throws when response has no content", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
await expect(
|
||||
callOllamaExplicit("Hi", "model", 1, "test", "ollama-generate")
|
||||
).rejects.toThrow("No response from Ollama");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initializeModel (mocked fetch)", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalEnv = process.env.OLLAMA_API_URL;
|
||||
const originalOllamaModel = process.env.OLLAMA_MODEL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.OLLAMA_API_URL = "http://localhost:11434";
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.OLLAMA_API_URL = originalEnv;
|
||||
process.env.OLLAMA_MODEL = originalOllamaModel;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("does not fetch when OLLAMA_MODEL is set", async () => {
|
||||
process.env.OLLAMA_MODEL = "existing-model";
|
||||
await initializeModel();
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves OLLAMA_MODEL unchanged when fetch returns not ok", async () => {
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
const before = OLLAMA_MODEL;
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
await initializeModel();
|
||||
expect(OLLAMA_MODEL).toBe(before);
|
||||
});
|
||||
|
||||
it("fetches /api/tags when OLLAMA_MODEL not set", async () => {
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
process.env.OLLAMA_API_URL = "http://localhost:11434";
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ models: [{ name: "test-model" }] }),
|
||||
});
|
||||
await initializeModel();
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
const [url, opts] = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/tags$/);
|
||||
expect(opts?.method || "GET").toBe("GET");
|
||||
});
|
||||
|
||||
it("fetches /api/v1/models when URL has open-webui path and sets model from data.data id", async () => {
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [{ id: "webui-model" }] }),
|
||||
});
|
||||
await initializeModel();
|
||||
const [url] = vi.mocked(globalThis.fetch).mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/v1\/models$/);
|
||||
expect(OLLAMA_MODEL).toBe("webui-model");
|
||||
});
|
||||
|
||||
it("sets model from data.data[0].name when id missing", async () => {
|
||||
process.env.OLLAMA_MODEL = "";
|
||||
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [{ name: "webui-model-name" }] }),
|
||||
});
|
||||
await initializeModel();
|
||||
expect(OLLAMA_MODEL).toBe("webui-model-name");
|
||||
});
|
||||
|
||||
it("catches fetch failure and warns", async () => {
|
||||
vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("network"));
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
await initializeModel();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Could not fetch default model"));
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
29
vitest.config.js
Normal file
29
vitest.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: ["**/node_modules/**", "**/integration/**", "**/integration.test.js"],
|
||||
environment: "node",
|
||||
},
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "text-summary"],
|
||||
include: [
|
||||
"src/textUtils.js",
|
||||
"src/parsing.js",
|
||||
"src/validation.js",
|
||||
"src/dungeonBuild.js",
|
||||
"src/contentFixes.js",
|
||||
"src/dungeonGenerator.js",
|
||||
"src/dungeonTemplate.js",
|
||||
"src/ollamaClient.js",
|
||||
],
|
||||
exclude: ["test/**", "**/*.config.js", "index.js"],
|
||||
thresholds: {
|
||||
statements: 85,
|
||||
branches: 85,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
});
|
||||
10
vitest.integration.config.js
Normal file
10
vitest.integration.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/integration/**/*.test.js"],
|
||||
exclude: ["**/node_modules/**"],
|
||||
environment: "node",
|
||||
testTimeout: 120000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user