From 9332ac6f94f605d143dcec3780566838a353df7f Mon Sep 17 00:00:00 2001 From: keligrubb Date: Sun, 18 Jan 2026 23:02:18 -0500 Subject: [PATCH] improvements --- dungeonGenerator.js | 294 ++++++++++++++++++++++++++++++++++++++------ dungeonTemplate.js | 116 +++++++++-------- ollamaClient.js | 2 +- 3 files changed, 322 insertions(+), 90 deletions(-) diff --git a/dungeonGenerator.js b/dungeonGenerator.js index 509fb7d..a720f59 100644 --- a/dungeonGenerator.js +++ b/dungeonGenerator.js @@ -23,32 +23,47 @@ function parseObjects(raw, type = "rooms") { const mapper = (entry) => { if (type === "encounters") { // For encounters, format is "Encounter Name: Location Name: details" - // We want to keep the encounter name separate and preserve the location:details structure const parts = entry.split(/:/); if (parts.length >= 3) { - // Format: "Encounter Name: Location Name: details" + const name = parts[0].trim(); + // Skip placeholder names + if (name.toLowerCase().includes('location name') || name.toLowerCase().includes('encounter name')) { + return null; + } return { - name: parts[0].trim(), - details: parts.slice(1).join(":").trim() // Keep "Location Name: details" + name: name, + details: parts.slice(1).join(":").trim() }; } else if (parts.length === 2) { - // Format: "Encounter Name: details" (fallback) + const name = parts[0].trim(); + if (name.toLowerCase().includes('location name') || name.toLowerCase().includes('encounter name')) { + return null; + } return { - name: parts[0].trim(), + name: name, details: parts[1].trim() }; } + return null; } // For other types, use original logic const [name, ...descParts] = entry.split(/[-–—:]/); + const cleanName = name.trim(); + // Skip placeholder names + if (cleanName.toLowerCase().includes('location name') || + cleanName.toLowerCase().includes('npc name') || + cleanName.toLowerCase().includes('treasure name') || + cleanName.toLowerCase().includes('actual ')) { + return null; + } const desc = descParts.join(" ").trim(); - const obj = { name: name.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 entry; + return null; }; - return cleanedRaw.split(/\n?\d+[).]\s+/).map(cleanText).filter(Boolean).map(mapper); + return cleanedRaw.split(/\n?\d+[).]\s+/).map(cleanText).filter(Boolean).map(mapper).filter(Boolean); } const parseEncounterText = (text, idx) => { @@ -264,25 +279,179 @@ function standardizeEncounterLocations(encounters, rooms) { return { encounters: fixedEncounters, fixes }; } +// Content validation functions +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}` : ''}`); + } + }); + + // Check descriptions + 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; +} + +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; +} + +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; +} + +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(); + let refs = 0; + dungeonData.npcs?.forEach(n => { + if (n.trait?.toLowerCase().includes(factionLower)) refs++; + }); + dungeonData.encounters?.forEach(e => { + if (e.details?.toLowerCase().includes(factionLower)) refs++; + }); + if (refs < 2) { + issues.push(`Faction "${factionName}" poorly integrated (${refs} references)`); + } + } + + return issues; +} + function validateAndFixContent(dungeonData) { const allFixes = []; + const allIssues = []; // Validate name consistency const nameFixes = validateNameConsistency(dungeonData); allFixes.push(...nameFixes); - // Standardize encounter locations + // Standardize encounter locations and add missing ones 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 doesn't start with a location, assign one + if (!encounter.details.match(/^[^:]+:\s/)) { + // Assign to a random room, or cycle through rooms + 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); } + // Run content validation + const completenessIssues = validateContentCompleteness(dungeonData); + const qualityIssues = validateContentQuality(dungeonData); + const structureIssues = validateContentStructure(dungeonData); + const coherenceIssues = validateNarrativeCoherence(dungeonData); + + allIssues.push(...completenessIssues, ...qualityIssues, ...structureIssues, ...coherenceIssues); + 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:"); + allIssues.forEach(issue => console.warn(` ⚠ ${issue}`)); + } else { + console.log("\n[Validation] Content quality checks passed"); + } + return dungeonData; } @@ -327,7 +496,11 @@ Example: ${coreConcepts} Write a single evocative paragraph describing the location. Maximum 2 sentences. Maximum 100 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. -CRITICAL: Hooks must be concise to fit in a single column on a one-page dungeon layout. Each hook must be a maximum of 35 words. + +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" @@ -345,13 +518,16 @@ ${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. -1. Entrance Room: Give it a name (max 5 words) and a description (30-40 words) that includes: +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 (35-45 words) that includes: +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 @@ -362,12 +538,36 @@ EXACT FORMAT REQUIRED - each room on its own numbered line: 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[).] /); // Split on "2. " to separate the two rooms const entranceRoom = parseObjects(entranceSection, "rooms")[0]; const climaxRoom = parseObjects(`1. ${climaxSection}`, "rooms")[0]; // Prepend "1. " to make parsing consistent + + // Fix placeholder names by extracting from description + const fixRoomName = (room) => { + if (!room) return room; + if (room.name && (room.name.toLowerCase().includes('room name') || room.name.toLowerCase() === 'room name')) { + // Extract room name from description (first 2-4 words before "Description" or similar) + 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 { + // Fallback: use first few words of description + const words = desc.split(/\s+/).slice(0, 4).join(' '); + room.name = words.replace(/^(The|A|An)\s+/i, '').trim(); + } + } + return room; + }; + + if (entranceRoom) fixRoomName(entranceRoom); + if (climaxRoom) fixRoomName(climaxRoom); + console.log("Entrance Room:", entranceRoom); console.log("Climax Room:", climaxRoom); @@ -383,7 +583,7 @@ 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 (30-40 words). Each room must include: +- **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 @@ -391,7 +591,10 @@ Generate the rest of the dungeon's content to fill the space between the entranc - Hidden aspects discoverable through interaction or investigation Format as "Name: description" using colons, NOT em-dashes. -- **Strictly 6 Encounters:** Numbered 1-6 (for d6 rolling). Name (max 6 words) and details (2-3 sentences MAX, approximately 30-50 words). Each encounter must: +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) @@ -402,7 +605,10 @@ Generate the rest of the dungeon's content to fill the space between the entranc - 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. -- **Strictly 4-5 NPCs:** Proper name (max 4 words) and a description (60-80 words). Each NPC must include: +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 @@ -412,7 +618,10 @@ Generate the rest of the dungeon's content to fill the space between the entranc - 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. -- **Strictly 4-5 Treasures:** Name (max 5 words) and a description (40-50 words). Each treasure must: +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 @@ -420,39 +629,43 @@ Generate the rest of the dungeon's content to fill the space between the entranc - Some should be cursed, have activation requirements, or serve dual purposes Format as "Name — Description" using em-dash. -- **Strictly 1 Random Events Table:** A d6 table (exactly 6 entries) with random events/wandering encounters. Each entry should: +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) with random events/wandering encounters. Each entry MUST: - Provide interesting complications or opportunities (not just combat) - Tie to the core concepts and dynamic element - Add replayability and surprise + - Be 15-20 words maximum Format as numbered 1-6 list under "Random Events:" label. CRITICAL: Each item must be on its own numbered line. DO NOT combine multiple items into a single numbered entry. -EXACT FORMAT REQUIRED: +EXACT FORMAT REQUIRED (DO NOT use placeholder names like "Location Name", "NPC Name", or "Treasure Name" - use actual creative names): Locations: -1. Location Name: Description text. -2. Location Name: Description text. -3. Location Name: Description text. +1. Actual Room Name: Description text. +2. Actual Room Name: Description text. +3. Actual Room Name: Description text. Encounters: -1. Encounter Name: Location Name: Details text. -2. Encounter Name: Location Name: Details text. -3. Encounter Name: Location Name: Details text. -4. Encounter Name: Location Name: Details text. -5. Encounter Name: Location Name: Details text. -6. Encounter Name: Location Name: Details text. +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. NPC Name: Description text. -2. NPC Name: Description text. -3. NPC Name: Description text. -4. NPC Name: Description text. +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. Treasure Name — Description text. -2. Treasure Name — Description text. -3. Treasure Name — Description text. -4. Treasure Name — Description text. +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 description. @@ -462,6 +675,8 @@ Random Events: 5. Event description. 6. 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. Output as five separate numbered lists with these exact labels: "Locations:", "Encounters:", "NPCs:", "Treasures:", and "Random Events:". Each item must be on its own line starting with a number. Do not combine items. 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" @@ -501,15 +716,18 @@ ${JSON.stringify({ title, flavor, hooksRumors, rooms, encounters, treasure, npcs 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 should: +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 20-30 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.`, +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); diff --git a/dungeonTemplate.js b/dungeonTemplate.js index 6959a91..4ff8d85 100644 --- a/dungeonTemplate.js +++ b/dungeonTemplate.js @@ -2,6 +2,18 @@ function pickRandom(arr) { return arr[Math.floor(Math.random() * arr.length)]; } +function escapeHtml(text) { + if (!text) return ''; + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return String(text).replace(/[&<>"']/g, m => map[m]); +} + export function dungeonTemplate(data) { const bodyFonts = [ "'Lora', serif", @@ -51,15 +63,15 @@ export function dungeonTemplate(data) { padding: 0; font-family: ${bodyFont}; color: #1a1a1a; - font-size: 0.65em; - line-height: 1.2em; + font-size: 0.75em; + line-height: 1.4em; } .content-page { height: 100vh; box-sizing: border-box; padding: 1.2cm; page-break-after: always; - overflow: hidden; + overflow: visible; break-inside: avoid; } h1 { @@ -79,7 +91,7 @@ export function dungeonTemplate(data) { font-family: ${quoteFont}; margin: 0.3em 0 0.6em; font-size: 0.85em; - line-height: 1.2em; + line-height: 1.4em; } .columns { display: grid; @@ -90,18 +102,19 @@ export function dungeonTemplate(data) { .col { display: flex; flex-direction: column; - gap: 0.15em; + gap: 0.3em; overflow-wrap: break-word; - word-break: break-word; + word-break: normal; + hyphens: auto; } .section-block { break-inside: avoid; page-break-inside: avoid; - margin-bottom: 0.25em; + margin-bottom: 0.4em; } h2 { font-family: ${headingFont}; - font-size: 0.95em; + font-size: 1.05em; margin: 0.2em 0 0.2em; color: #1a1a1a; border-bottom: 1px solid #1a1a1a; @@ -117,28 +130,28 @@ export function dungeonTemplate(data) { } .room h3 { margin: 0.15em 0 0.08em; - font-size: 0.85em; + font-size: 0.95em; font-weight: bold; color: #1a1a1a; } .room p { margin: 0 0 0.35em; - font-size: 0.8em; + font-size: 0.9em; font-weight: normal; - line-height: 1.25em; + line-height: 1.35em; } .encounter, .npc, .treasure, .plot-resolution { - margin: 0 0 0.3em; + margin: 0 0 0.35em; break-inside: avoid; page-break-inside: avoid; - font-size: 0.8em; - line-height: 1.25em; + font-size: 0.9em; + line-height: 1.35em; } .random-events { margin: 0.2em 0; break-inside: avoid; page-break-inside: avoid; - font-size: 0.8em; + font-size: 0.9em; } .random-events table { margin-top: 0.15em; @@ -150,7 +163,7 @@ export function dungeonTemplate(data) { width: 100%; border-collapse: collapse; margin: 0.2em 0; - font-size: 0.8em; + font-size: 0.85em; break-inside: avoid; page-break-inside: avoid; border: 1px solid #1a1a1a; @@ -165,12 +178,13 @@ export function dungeonTemplate(data) { font-weight: bold; } table td { - padding: 0.2em 0.3em; + 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; @@ -183,14 +197,15 @@ export function dungeonTemplate(data) { } .encounters-table td:nth-child(2) { font-weight: bold; - width: 25%; + 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.75em; - line-height: 1.2em; + font-size: 0.8em; + line-height: 1.3em; } .map-page { display: flex; @@ -213,15 +228,15 @@ export function dungeonTemplate(data) { } li { margin: 0.08em 0; - font-size: 0.8em; - line-height: 1.25em; + font-size: 0.9em; + line-height: 1.35em; }
-

${data.title}

- ${data.flavor ? `

${data.flavor}

` : ''} +

${escapeHtml(data.title)}

+ ${data.flavor ? `

${escapeHtml(data.flavor)}

` : ''}
@@ -229,7 +244,7 @@ export function dungeonTemplate(data) {

Hooks & Rumors

    - ${data.hooksRumors.map(hook => `
  • ${hook}
  • `).join('')} + ${data.hooksRumors.map(hook => `
  • ${escapeHtml(hook)}
  • `).join('')}
` : ''} @@ -242,7 +257,7 @@ export function dungeonTemplate(data) { ${data.randomEvents.map((event, index) => ` ${index + 1} - ${event} + ${escapeHtml(event)} `).join('')} @@ -255,8 +270,8 @@ export function dungeonTemplate(data) {

Locations

${data.rooms.map(room => `
-

${room.name}

-

${room.description}

+

${escapeHtml(room.name)}

+

${escapeHtml(room.description)}

`).join('')}
@@ -270,21 +285,20 @@ export function dungeonTemplate(data) { ${data.encounters.map((encounter, index) => { - // Truncate details to 2-3 sentences max to prevent overflow + // Truncate details to 4 sentences max to prevent overflow let details = encounter.details || ''; - // Remove location prefix from details if present (format: "Location Name: details") - details = details.replace(/^[^:]+:\s*/, ''); - // Split into sentences and keep only first 3 + // Keep location prefix in details (format: "Location Name: details") + // Split into sentences and keep only first 4 const sentences = details.match(/[^.!?]+[.!?]+/g) || [details]; - if (sentences.length > 3) { - details = sentences.slice(0, 3).join(' ').trim(); + if (sentences.length > 4) { + details = sentences.slice(0, 4).join(' ').trim(); } - // Also limit by character count as fallback (max ~250 chars) - if (details.length > 250) { - details = details.substring(0, 247).trim(); + // Also limit by character count as fallback (max ~350 chars) + if (details.length > 350) { + details = details.substring(0, 347).trim(); // Try to end at a sentence boundary const lastPeriod = details.lastIndexOf('.'); - if (lastPeriod > 200) { + if (lastPeriod > 280) { details = details.substring(0, lastPeriod + 1); } else { details += '...'; @@ -293,8 +307,8 @@ export function dungeonTemplate(data) { return ` - - + + `; }).join('')} @@ -310,7 +324,7 @@ export function dungeonTemplate(data) {

Treasure

${data.treasure.map(item => `
- ${typeof item === 'object' && item.name ? `${item.name} — ${item.description}` : item} + ${typeof item === 'object' && item.name ? `${escapeHtml(item.name)} — ${escapeHtml(item.description)}` : escapeHtml(item)}
`).join('')} @@ -321,7 +335,7 @@ export function dungeonTemplate(data) {

NPCs

${data.npcs.map(npc => `
- ${npc.name}: ${npc.trait} + ${escapeHtml(npc.name)}: ${escapeHtml(npc.trait)}
`).join('')} @@ -331,19 +345,19 @@ export function dungeonTemplate(data) {

Plot Resolutions

${data.plotResolutions.map(resolution => { - // Truncate to 1-2 sentences max to prevent overflow + // Truncate to 3 sentences max to prevent overflow let text = resolution || ''; - // Split into sentences and keep only first 2 + // Split into sentences and keep only first 3 const sentences = text.match(/[^.!?]+[.!?]+/g) || [text]; - if (sentences.length > 2) { - text = sentences.slice(0, 2).join(' ').trim(); + if (sentences.length > 3) { + text = sentences.slice(0, 3).join(' ').trim(); } - // Also limit by character count as fallback (max ~150 chars) - if (text.length > 150) { - text = text.substring(0, 147).trim(); + // Also limit by character count as fallback (max ~200 chars) + if (text.length > 200) { + text = text.substring(0, 197).trim(); // Try to end at a sentence boundary const lastPeriod = text.lastIndexOf('.'); - if (lastPeriod > 100) { + if (lastPeriod > 150) { text = text.substring(0, lastPeriod + 1); } else { text += '...'; @@ -351,7 +365,7 @@ export function dungeonTemplate(data) { } return `
- ${text} + ${escapeHtml(text)}
`; }).join('')} diff --git a/ollamaClient.js b/ollamaClient.js index 2273030..9bbaa17 100644 --- a/ollamaClient.js +++ b/ollamaClient.js @@ -22,7 +22,7 @@ export async function initializeModel() { console.log(`Using default model: ${model}`); } } - } catch (err) { + } catch { console.warn(`Could not fetch default model, using: ${OLLAMA_MODEL}`); } }
${index + 1}${encounter.name}${details}${escapeHtml(encounter.name)}${escapeHtml(details)}