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