Files
scrollsmith/src/parsing.js
T
2026-02-21 22:19:21 -05:00

235 lines
10 KiB
JavaScript

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