Compare commits

...

27 Commits

Author SHA1 Message Date
Madison Grubb
03253c28e6 fix release
All checks were successful
PR / lint (pull_request) Successful in 15s
PR / test-coverage (pull_request) Successful in 21s
2026-03-04 12:36:18 -05:00
01d1b369b7 replace woodpecker with gitea actions (#5)
Some checks failed
Release / generate-dungeon (push) Failing after 47s
Release / upload-to-gitea-release (push) Has been skipped
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #5
2026-03-04 17:16:19 +00:00
5e7369cd25 Merge pull request 'Configure Renovate' (#1) from renovate/configure into main
Some checks failed
ci/woodpecker/cron/release Pipeline failed
Reviewed-on: #1
2026-02-22 03:27:38 +00:00
3ef8f05e1d Add renovate.json 2026-02-22 03:26:50 +00:00
9bd0ded5a6 improve ci 2026-02-21 22:25:02 -05:00
83eee20b2c add testing suite 2026-02-21 22:19:21 -05:00
07128c3529 cleanup the title generation naming
All checks were successful
ci/woodpecker/cron/ci Pipeline was successful
2026-01-22 22:08:27 -05:00
5588108cb6 fix validation
All checks were successful
ci/woodpecker/cron/ci Pipeline was successful
2026-01-20 22:24:39 -05:00
e66df13edd cleanup title and formatting 2026-01-20 22:14:33 -05:00
96223b81e6 more tweaks
All checks were successful
ci/woodpecker/cron/ci Pipeline was successful
2026-01-19 22:37:53 -05:00
9332ac6f94 improvements
All checks were successful
ci/woodpecker/cron/ci Pipeline was successful
2026-01-18 23:02:18 -05:00
c54b1a6082 add capability to use default model if a default is provided via api
All checks were successful
ci/woodpecker/cron/ci Pipeline was successful
2026-01-16 22:18:31 -05:00
3b91ce3068 improve and fix ci stuff. cleanup debug
Some checks failed
ci/woodpecker/cron/ci Pipeline failed
2026-01-11 21:41:30 -05:00
c7bb0f04df fix model name 2026-01-11 20:55:17 -05:00
05526b06d6 playaround with debug to figure out ci failures 2026-01-11 20:17:42 -05:00
af447da042 update deps
Some checks failed
ci/woodpecker/cron/ci Pipeline failed
2026-01-10 21:54:32 -05:00
c48188792d cleanup and fix ci 2026-01-10 21:52:11 -05:00
1059eced53 fix ollama model env var mismatch
Some checks failed
ci/woodpecker/cron/ci Pipeline failed
2026-01-08 20:57:35 -05:00
Madison Grubb
96480a351f make it start working again
Some checks failed
ci/woodpecker/cron/ci Pipeline failed
2025-12-11 23:13:07 -05:00
dc9ec367a0 remove workflows that were test workflows
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/cron/ci Pipeline failed
2025-09-10 22:38:37 -04:00
799ee18dc2 cleanup locations. make treasure bold. try to add some flair to the images 2025-09-10 22:38:06 -04:00
277a3ba718 improve overall dungeon cohesiveness
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/cron/ci Pipeline was successful
2025-09-08 22:42:42 -04:00
Madison Grubb
a3c54b1c82 use sharp and improve prompting
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/cron/ci Pipeline was successful
2025-09-05 16:48:35 -04:00
Madison Grubb
be7534be8d smaller image, larger image would crash comfyui 2025-09-05 13:32:49 -04:00
Madison Grubb
23fae22735 make titles always uppercase. cleanup copy footer
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-05 13:21:06 -04:00
Madison Grubb
d436284476 improve gen
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-05 13:18:24 -04:00
Madison Grubb
800c9c488c lower cfg to 2.
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-09-05 09:49:54 -04:00
31 changed files with 6712 additions and 808 deletions

43
.gitea/workflows/pr.yml Normal file
View File

@@ -0,0 +1,43 @@
name: PR
on:
pull_request:
jobs:
lint:
name: lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v6
- name: Setup Node
uses: https://github.com/actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
test-coverage:
name: test-coverage
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v6
- name: Setup Node
uses: https://github.com/actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Test with coverage
run: npm run test:coverage

View File

@@ -0,0 +1,85 @@
name: Release
on:
push:
branches: [main]
schedule:
- cron: "0 0 * * *"
jobs:
generate-dungeon:
name: generate-dungeon
runs-on: ubuntu-latest
container:
image: ghcr.io/puppeteer/puppeteer:latest
env:
OLLAMA_API_URL: https://ai.keligrubb.com/api/chat/completions
OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
COMFYUI_URL: http://192.168.1.124:8188
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v6
- name: Setup Node
uses: https://github.com/actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Generate dungeon PDF
run: npm start
- name: Upload PDF artifact
uses: https://github.com/actions/upload-artifact@v6
with:
name: release-pdf
path: "*.pdf"
upload-to-gitea-release:
name: upload-to-gitea-release
runs-on: ubuntu-latest
needs: generate-dungeon
env:
GITEA_TOKEN: ${{ secrets.SCROLLSMITH_GITEA_TOKEN }}
steps:
- name: Download PDF artifact
uses: https://github.com/actions/download-artifact@v8
with:
name: release-pdf
- name: Create release and upload PDF
run: |
api_base="https://git.keligrubb.com/api/v1/repos/${{ gitea.repository }}"
pdf=$(ls *.pdf | head -n1)
tag=$(date +%F)
echo "Creating release for tag $tag..."
create_resp=$(curl -s -w "%{http_code}" -o /tmp/create.json -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
"$api_base/releases")
echo "Create release HTTP status: $create_resp"
echo "Fetching release ID..."
release_id=$(curl -s \
-H "Authorization: token $GITEA_TOKEN" \
"$api_base/releases/tags/$tag" |
awk -F: '/"id"[ ]*:/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
echo "Release ID = $release_id"
echo "Checking if asset $pdf already exists..."
assets=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"$api_base/releases/$release_id/assets")
echo "Assets response: $assets"
if echo "$assets" | grep -q "\"name\":\"$pdf\""; then
echo "Asset $pdf already uploaded, skipping."
exit 0
fi
echo "Uploading $pdf to release $release_id..."
upload_resp=$(curl -s -w "%{http_code}" -o /tmp/upload.json -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$pdf" \
"$api_base/releases/$release_id/assets")
echo "Upload HTTP status: $upload_resp"
echo "Upload response: $(cat /tmp/upload.json)"

16
.gitignore vendored
View File

@@ -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

View File

@@ -1,78 +0,0 @@
workspace:
base: /woodpecker
path: src
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
OLLAMA_API_KEY:
from_secret: OLLAMA_API_KEY
COMFYUI_URL:
from_secret: COMFYUI_URL
commands:
- npm ci
- npm start
- name: upload-to-gitea-release
image: curlimages/curl:latest
when:
event:
- cron
environment:
GITEA_TOKEN:
from_secret: GITEA_TOKEN
commands:
- pdf=$(ls *.pdf | head -n1)
- tag=$(date +%F)
- |
echo "Creating release for tag $tag..."
create_resp=$(curl -s -w "%{http_code}" -o /tmp/create.json -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases)
echo "Create release HTTP status: $create_resp"
echo "Fetching release ID..."
release_id=$(curl -s \
-H "Authorization: token $GITEA_TOKEN" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/tags/$tag |
awk -F: '/"id"[ ]*:/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
echo "Release ID = $release_id"
echo "Checking if asset $pdf already exists..."
assets=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$release_id/assets)
echo "Assets response: $assets"
if echo "$assets" | grep -q "\"name\":\"$pdf\""; then
echo "Asset $pdf already uploaded, skipping."
exit 0
fi
echo "Uploading $pdf to release $release_id..."
upload_resp=$(curl -s -w "%{http_code}" -o /tmp/upload.json -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$pdf" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$release_id/assets)
echo "Upload HTTP status: $upload_resp"
echo "Upload response: $(cat /tmp/upload.json)"

View File

@@ -29,7 +29,7 @@ Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon
OLLAMA_API_URL=http://localhost:3000/api/chat/completions
OLLAMA_API_KEY=your_api_key_here
COMFYUI_URL=http://192.168.1.124:8188
````
```
---
@@ -43,6 +43,27 @@ npm install
---
## API Configuration
The client automatically infers the API type from the endpoint URL, making it flexible for different deployment scenarios.
### Direct Ollama API
For direct Ollama API calls, set:
```env
OLLAMA_API_URL=http://localhost:11434/api/generate
```
### Open WebUI API
For Open WebUI API calls, set:
```env
OLLAMA_API_URL=http://localhost:3000/api/chat/completions
OLLAMA_API_KEY=your_open_webui_api_key
```
> Note: The API type is automatically inferred from the endpoint URL. If the URL contains `/api/chat/completions`, it uses Open WebUI API. If it contains `/api/generate`, it uses direct Ollama API. No `OLLAMA_API_TYPE` environment variable is required.
---
## Usage
1. Make sure your Ollama server is running and `.env` is configured.
@@ -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

View File

@@ -1,165 +0,0 @@
import { callOllama } from "./ollamaClient.js";
// 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();
}
function parseList(raw) {
return raw
.split(/\n?\d+[).]\s+/)
.map(line => cleanText(line))
.filter(Boolean);
}
function parseObjects(raw, type = "rooms") {
return raw
.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() {
console.log("Starting compact dungeon generation with debug logs...\n");
// Step 1: Titles
const titles10Raw = await callOllama(
`Generate 10 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. Do not include any titles with the words "Obsidian" or "Clockwork". Do not include explanations, markdown, or preambles. Do not include the style or theme in parenthesis. Only the 10 numbered titles.`,
undefined, 5, "Step 1: Titles"
);
const titles10 = parseList(titles10Raw, 30);
console.log("Parsed titles10:", titles10);
// Step 2: Narrow to 5
const titles5Raw = await callOllama(
`Here are 10 dungeon titles:
${titles10.join("\n")}
Randomly select 3 of the titles from the above list and create 2 additional unique titles. Do not include any titles with the words "Obsidian" or "Clockwork".
Output exactly 5 titles as a numbered list, plain text only. No explanations.`,
undefined, 5, "Step 2: Narrow Titles"
);
const titles5 = parseList(titles5Raw, 30);
console.log("Parsed titles5:", titles5);
// Step 3: Final title
const bestTitleRaw = await callOllama(
`From the following 5 dungeon titles, randomly select only one of them.
Output only the title, no explanation, no numbering, no extra text:
${titles5.join("\n")}`,
undefined, 5, "Step 3: Final Title"
);
const title = cleanText(bestTitleRaw.split("\n")[0]);
console.log("Selected title:", title);
// Step 4: Flavor text
const flavorRaw = await callOllama(
`Write a single evocative paragraph describing the location titled "${title}".
Do not include hooks, NPCs, treasure, or instructions. Do not use bullet points or em-dashes. Output plain text only, one paragraph. Maximum 4 sentences.`,
undefined, 5, "Step 4: Flavor"
);
const flavor = flavorRaw;
console.log("Flavor text:", flavor);
// Step 5: Hooks & Rumors
const hooksRumorsRaw = await callOllama(
`Based only on this location's flavor:
${flavor}
Generate 3 short adventure hooks or rumors (mix them naturally).
Output as a single numbered list, plain text only. Do not use em-dashes.
Maximum 2 sentences per item. No explanations or extra text.`,
undefined, 5, "Step 5: Hooks & Rumors"
);
const hooksRumors = parseList(hooksRumorsRaw, 120);
console.log("Hooks & Rumors:", hooksRumors);
// Step 6: Rooms & Encounters
const roomsEncountersRaw = await callOllama(
`Using the flavor and these hooks/rumors:
Flavor:
${flavor}
Hooks & Rumors:
${hooksRumors.join("\n")}
Generate 5 rooms (name + short description) and 6 encounters (name + details).
Output two numbered lists, labeled "Rooms:" and "Encounters:". Plain text only. No extra explanation.`,
undefined, 5, "Step 6: Rooms & Encounters"
);
const [roomsSection, encountersSection] = roomsEncountersRaw.split(/Encounters[:\n]/i);
const rooms = parseObjects(roomsSection.replace(/Rooms[:\n]*/i, ""), "rooms", 120);
const encounters = parseObjects(encountersSection || "", "encounters", 120);
console.log("Rooms:", rooms);
console.log("Encounters:", encounters);
// Step 7: Treasure & NPCs
const treasureNpcsRaw = await callOllama(
`Based only on these rooms and encounters:
${JSON.stringify({ rooms, encounters }, null, 2)}
Generate 3 treasures and 3 NPCs (name + trait, max 2 sentences each).
Each NPC has a proper name, not just a title.
Treasure should sometimes include a danger or side-effect.
Output numbered lists labeled "Treasure:" and "NPCs:". Plain text only, no extra text.`,
undefined, 5, "Step 7: Treasure & NPCs"
);
const [treasureSection, npcsSection] = treasureNpcsRaw.split(/NPCs[:\n]/i);
const treasure = parseList(treasureSection.replace(/Treasures?[:\n]*/i, ""), 120);
const npcs = parseObjects(npcsSection || "", "npcs", 120);
console.log("Treasure:", treasure);
console.log("NPCs:", npcs);
// Step 8: Plot Resolutions
const plotResolutionsRaw = await callOllama(
`Based on the following location's flavor and story hooks:
Flavor:
${flavor}
Hooks & Rumors:
${hooksRumors.join("\n")}
Major NPCs / Encounters:
${[...npcs.map(n => n.name), ...encounters.map(e => e.name)].join(", ")}
Suggest 3 possible, non-conflicting story climaxes or plot resolutions for adventurers exploring this location.
These are prompts and ideas for brainstorming the story's ending, not fixed outcomes. Give the players meaningful choices and agency.
Start each item with phrases like "The adventurers could" or "The PCs might" to emphasize their hypothetical nature.
Deepen the narrative texture and allow roleplay and tactical creativity.
Keep each item short (max 2 sentences). Output as a numbered list, plain text only.`,
undefined, 5, "Step 8: Plot Resolutions"
);
const plotResolutions = parseList(plotResolutionsRaw, 180);
console.log("Plot Resolutions:", plotResolutions);
console.log("\nDungeon generation complete!");
return { title, flavor, map: "map.png", hooksRumors, rooms, encounters, treasure, npcs, plotResolutions };
}

View File

@@ -1,167 +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 = [
"'Cinzel', serif",
"'MedievalSharp', 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",
"'Uncial Antiqua', 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=MedievalSharp&family=Metamorphous&family=Playfair+Display&family=Alegreya+Sans&family=Cabin&family=IBM+Plex+Sans&family=Cormorant+Garamond&family=Lora&family=Merriweather&family=Libre+Baskerville&family=Source+Serif+4&family=Walter+Turncoat&family=Uncial+Antiqua&family=Beth+Ellen&family=Pinyon+Script&display=swap" rel="stylesheet">
<style>
@page { size: A4 landscape; margin: 0; }
body {
margin: 0; padding: 1.5cm;
background: #f5f5f5;
font-family: ${bodyFont};
color: #1a1a1a;
font-size: 0.7em;
line-height: 1.25em;
}
h1 {
font-family: ${headingFont};
text-align: center;
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 {
page-break-before: always;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5cm;
height: calc(100vh - 3cm);
box-sizing: border-box;
background: #f5f5f5;
}
.map-page img {
max-width: 100%;
max-height: 100%;
height: auto;
width: auto;
border-radius: 0.2cm;
object-fit: contain;
box-shadow:
0 0 20px 15px #f5f5f5 inset,
0 0 5px 2px rgba(0, 0, 0, 0.05);
}
footer {
text-align: center; font-size: 0.65em; color: #555; margin-top: 0.5em; font-style: italic;
}
</style>
</head>
<body>
<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 => `<li>${t}</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 class="map-page">
<img src="${data.map}" alt="Dungeon Map">
</div>
<footer>Generated with Scrollsmith • © ${new Date().getFullYear()}</footer>
</body>
</html>
`;
}

View File

@@ -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",
},
},
},
]);

View File

@@ -1,30 +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,
});
await browser.close();
console.log(`Dungeon PDF saved to ${outputPath}`);
}

View File

@@ -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);
}
})();

View File

@@ -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);
}
}
}

3112
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
},
@@ -13,11 +16,14 @@
"description": "",
"dependencies": {
"dotenv": "^17.2.1",
"puppeteer": "^24.17.1"
"puppeteer": "^24.17.1",
"sharp": "^0.34.3"
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"eslint": "^9.34.0",
"globals": "^16.3.0"
"@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
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

290
src/contentFixes.js Normal file
View 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
View 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
View 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
View 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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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
View File

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

View File

@@ -1,13 +1,45 @@
import sharp from 'sharp';
import path from "path";
import { mkdir, writeFile } from "fs/promises";
import { fileURLToPath } from "url";
import { callOllama } from "./ollamaClient.js";
import { callOllama, OLLAMA_MODEL } from "./ollamaClient.js";
const COMFYUI_ENABLED = process.env.COMFYUI_ENABLED !== 'false';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COMFYUI_URL = process.env.COMFYUI_URL || "http://localhost:8188";
// Drawing style prefix
const STYLE_PREFIX = `clean line art, minimalist sketch, concept art sketch, black and white line drawing, lots of white space, sparse shading, simple hatching, very low detail, subtle color accent`;
const STYLE_PREFIX = `clean line art, minimalist sketch, concept art, black and white line drawing, lots of white space, sparse shading, simple black hatching, very low detail`;
const ACCENT_COLORS = ["red", "blue", "yellow", "green", "purple", "orange"];
function selectRandomAccentColor() {
return ACCENT_COLORS[Math.floor(Math.random() * ACCENT_COLORS.length)];
}
async function upscaleImage(inputPath, outputPath, width, height) {
try {
await sharp(inputPath)
.resize(width, height, { kernel: 'lanczos3' })
.blur(0.3)
.sharpen({
sigma: 1,
flat: 1,
jagged: 2,
})
.png({
compressionLevel: 9,
adaptiveFiltering: true,
palette: true
})
.toFile(outputPath);
console.log(`Upscaled + compressed PNG saved: ${outputPath}`);
return outputPath;
} catch (err) {
console.error("Error during upscaling:", err.message);
return null;
}
}
// 1. Generate engineered visual prompt
async function generateVisualPrompt(flavor) {
@@ -17,22 +49,28 @@ async function generateVisualPrompt(flavor) {
Your output must be a simple list of visual tags describing only the most essential elements of the scene. Focus on the core subject and mood.
Rules:
- Describe a sparse scene with a single focal point or area.
- Describe a sparse scene with a single focal point or landscape.
- Use only 3-5 key descriptive phrases or tags.
- The entire output should be very short, 20-50 words maximum.
- Do NOT repeat wording from the input.
- Focus only on visual content, not style, medium, or camera effects.
- Describe only the visual elements of the image. Focus on colors, shapes, textures, and spatial relationships.
- Exclude any references to style, medium, camera effects, sounds, hypothetical scenarios, or physical sensations.
- Avoid describing fine details; focus on large forms and the overall impression.
- Do NOT include phrases like an image of or a scene showing.
- Do NOT include the word "Obsidian" or "obsidian" at all.
Input:
${flavor}
Output:`,
"gemma3n:e4b", 3, "Generate Visual Prompt"
OLLAMA_MODEL,
3,
"Generate Visual Prompt"
);
return `${STYLE_PREFIX}, ${rawPrompt.trim().replace(/\n/g, " ")}`;
const accentColor = selectRandomAccentColor();
return `${STYLE_PREFIX}, on white paper, monochrome with a single accent of ${accentColor}, ${rawPrompt.trim().replace(/\n/g, " ")}`;
}
// 2. Save image buffer
@@ -51,7 +89,7 @@ function buildComfyWorkflow(promptText, negativeText = "") {
"inputs": {
"seed": Math.floor(Math.random() * 100000),
"steps": 4,
"cfg": 3,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
@@ -71,8 +109,8 @@ function buildComfyWorkflow(promptText, negativeText = "") {
},
"5": {
"inputs": {
"width": 640,
"height": 448,
"width": 728,
"height": 512,
"batch_size": 1
},
"class_type": "EmptyLatentImage"
@@ -129,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];
@@ -156,7 +197,7 @@ async function downloadImage(filename, localFilename) {
// 4c. Submit prompt and handle full image pipeline
async function generateImageViaComfyUI(prompt, filename) {
const negativePrompt = `heavy shading, deep blacks, cross-hatching, dark, gritty, shadow-filled, chiaroscuro, scratchy lines, photorealism, hyper-realistic, high detail, 3D render, CGI, polished, smooth shading, detailed textures, noisy, cluttered, blurry, text, logo, signature, watermark`;
const negativePrompt = `heavy shading, deep blacks, dark, gritty, shadow-filled, chiaroscuro, scratchy lines, photorealism, hyper-realistic, high detail, 3D render, CGI, polished, smooth shading, detailed textures, noisy, cluttered, blurry, text, logo, signature, watermark, artist name, branding, ugly, deformed, unnatural patterns, perfect curves, repetitive textures`;
const workflow = buildComfyWorkflow(prompt, negativePrompt);
try {
@@ -168,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();
@@ -193,15 +234,27 @@ 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);
const filename = `dungeon.png`;
const filepath = await generateImageViaComfyUI(finalPrompt, filename);
const baseFilename = `dungeon.png`;
const upscaledFilename = `dungeon_upscaled.png`;
const filepath = await generateImageViaComfyUI(finalPrompt, baseFilename);
if (!filepath) {
throw new Error("Failed to generate dungeon image.");
}
return filepath;
// Upscale 2x (half of A4 at 300dpi)
const upscaledPath = await upscaleImage(filepath, upscaledFilename, 1456, 1024);
if (!upscaledPath) {
throw new Error("Failed to upscale dungeon image.");
}
return upscaledPath;
}

127
src/ollamaClient.js Normal file
View 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
View 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
View 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
View 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;
}

View 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);
});

View 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);
});

File diff suppressed because it is too large Load Diff

View 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("&amp;");
expect(escapeHtml("<")).toBe("&lt;");
expect(escapeHtml(">")).toBe("&gt;");
expect(escapeHtml('"')).toBe("&quot;");
expect(escapeHtml("'")).toBe("&#039;");
expect(escapeHtml('<script>&"\'</script>')).toBe(
"&lt;script&gt;&amp;&quot;&#039;&lt;/script&gt;"
);
});
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");
});
});

View 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
View 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,
},
},
});

View 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,
},
});