export class DHImporter { /** * Parse Adversary Text * @param {string} text * @returns {Array} Array of adversary data objects */ static parseAdversaries(text) { const blocks = text.split(/^-----$/gm).filter(b => b.trim()); const results = []; for (let block of blocks) { const lines = block.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l); if (lines.length === 0) continue; try { const data = { name: "Unknown Adversary", type: "adversary", img: "systems/daggerheart/assets/icons/documents/actors/dragon-head.svg", prototypeToken: { texture: { src: "systems/daggerheart/assets/icons/documents/actors/dragon-head.svg" } }, system: { tier: 1, type: "standard", resources: { hitPoints: { value: 0, max: 0 }, stress: { value: 0, max: 0 } }, damageThresholds: { major: 0, severe: 0 }, attack: { name: "Attack", type: "attack", roll: { type: "attack", bonus: 0 }, damage: { parts: [] } }, experiences: {}, motivesAndTactics: "" }, items: [] }; let currentSection = "header"; let featureBuffer = null; let lineIndex = 0; // --- Header Parsing --- // Name is line 0 if (lines.length > 0) { data.name = lines[0]; lineIndex = 1; } while (lineIndex < lines.length) { let line = lines[lineIndex]; // --- Section Detection --- if (line.match(/^Motives & Tactics[:]?$/i)) { currentSection = "motives"; lineIndex++; continue; } else if (line.match(/^Features[:]?$/i)) { currentSection = "features"; lineIndex++; continue; } else if (line.match(/^HP & Stress[:]?$/i)) { currentSection = "hp_stress"; lineIndex++; continue; } else if (line.match(/^Experience[:]?$/i)) { currentSection = "experience"; lineIndex++; // Check if the rest of the line has content (old format) const inlineExp = line.replace(/^Experience[:]?\s*/i, ""); if (inlineExp) { DHImporter._parseExperienceLine(inlineExp, data); } continue; } // --- Section Processing --- // HEADER / STATS (Before any specific section) if (currentSection === "header") { if (line.match(/Tier\s+(\d+)\s+(\w+)/i)) { const match = line.match(/Tier\s+(\d+)\s+(\w+)/i); data.system.tier = parseInt(match[1]); data.system.type = match[2].toLowerCase(); } else if (line.match(/^Difficulty:/i)) { // Can be "Difficulty: 14" or "Difficulty: 11 | Thresholds: ..." or "Difficulty: 12Thresholds: 5/9HP: 4Stress: 3" // Robust Regex Extraction const diffMatch = line.match(/Difficulty:\s*(\d+)/i); if (diffMatch) data.system.difficulty = parseInt(diffMatch[1]); const thMatch = line.match(/Thresholds:\s*(\d+)\/(\d+)/i); if (thMatch) { data.system.damageThresholds.major = parseInt(thMatch[1]); data.system.damageThresholds.severe = parseInt(thMatch[2]); } const hpMatch = line.match(/HP:\s*(\d+)/i); if (hpMatch) data.system.resources.hitPoints.max = parseInt(hpMatch[1]); const stressMatch = line.match(/Stress:\s*(\d+)/i); if (stressMatch) data.system.resources.stress.max = parseInt(stressMatch[1]); // Check for inline stuff (Old Format with Pipes) if (line.includes("|")) { const parts = line.split("|").map(p => p.trim()); for (const part of parts) { // Fallback for pipes if regex somehow missed or overwrote (unlikely but safe) // Actually the regex above is better. } } } else if (line.match(/^(Attack:|ATK:)/i)) { // "Attack: +1" or "ATK: +2 | Claws: Melee | ..." or "ATK: +2Claws: Melee1d6+2 phy" const bonusMatch = line.match(/(?:Attack|ATK):\s*([+-]?\d+)/i); if (bonusMatch) data.system.attack.roll.bonus = parseInt(bonusMatch[1]); // Clean the line to remove "ATK: +2" part let restOfLine = line.replace(/(?:Attack|ATK):\s*[+-]?\d+/, "").trim(); // If split format e.g. "Attack: +1" and next line is "Claws: Melee..." if ((!restOfLine || restOfLine.length === 0) && lineIndex + 1 < lines.length) { const nextLine = lines[lineIndex + 1]; if (nextLine.includes(":") && !nextLine.match(/^(Experience|Features|Motives|Difficulty)/)) { // Likely the weapon details: "Claws: Melee | 1d8+3 phy" DHImporter._parseAttackDetails(nextLine, data); lineIndex++; // Consume next line } } else if (restOfLine.length > 0) { // Inline formatting (smashed or piped) if (restOfLine.startsWith("|")) restOfLine = restOfLine.substring(1).trim(); DHImporter._parseAttackDetails(restOfLine, data); } } else if (line.match(/^Experience:/i)) { // Handled by section detection usually, but if missed: const inlineExp = line.replace(/^Experience:\s*/i, ""); if (inlineExp && inlineExp.trim() !== "—") { DHImporter._parseExperienceLine(inlineExp, data); } // If empty, next lines will be picked up by 'experience' section logic if we switched? // Actually, if we are here, we might have skipped section detect if regex didn't match perfectly. } else { // Description text usually if (!data.system.notes) data.system.notes = ""; data.system.notes += line + "
"; } } // MOTIVES else if (currentSection === "motives") { data.system.motivesAndTactics += line + " "; } // EXPERIENCE (Multi-line) else if (currentSection === "experience") { // "Ambusher +3" DHImporter._parseExperienceLine(line, data); } // HP & STRESS (Vertical) else if (currentSection === "hp_stress") { // Heuristic parsing for the copy-paste mess // Look for numbers that might be thresholds if (line.match(/^\d+$/)) { const val = parseInt(line); // Context matters. // If previous line was "minor" or "1 HP" (implied minor), val is minor. // If previous line was "HP:" or "STRESS:", val is that. const prevLine = lines[lineIndex - 1] || ""; const prevPrevLine = lines[lineIndex - 2] || ""; if (prevLine.match(/HP:/i)) { data.system.resources.hitPoints.max = val; } else if (prevLine.match(/STRESS:/i)) { data.system.resources.stress.max = val; } else if (prevLine.includes("minor") || prevPrevLine.includes("minor")) { data.system.damageThresholds.minor = val; } else if (prevLine.includes("major") || prevPrevLine.includes("major")) { data.system.damageThresholds.major = val; } else if (prevLine.includes("severe") || prevPrevLine.includes("severe")) { data.system.damageThresholds.severe = val; } else if (prevLine.includes("HP")) { if (prevPrevLine.includes("minor")) { /* ignore or store */ } if (prevPrevLine.includes("major")) data.system.damageThresholds.major = val; if (prevPrevLine.includes("severe")) data.system.damageThresholds.severe = val; } } } // FEATURES else if (currentSection === "features") { // "Name - Type" (Description on next line) OR "Name - Type: Description" // Supports hyphen (-), en-dash (–), em-dash (—) and optional surrounding spaces const featureMatch = line.match(/^(.+?)\s*[–—-]\s*(\w+)(?::\s*(.*))?$/); if (featureMatch) { if (featureBuffer) data.items.push(DHImporter._createFeatureItem(featureBuffer)); featureBuffer = { name: featureMatch[1].trim(), typeHint: featureMatch[2].trim(), description: featureMatch[3] || "" }; } else if (featureBuffer) { featureBuffer.description += (featureBuffer.description ? " " : "") + line; } } lineIndex++; } // Flush feature if (featureBuffer) data.items.push(DHImporter._createFeatureItem(featureBuffer)); results.push(data); } catch (e) { console.error("Failed to parse adversary block", e, block); } } return results; } static _parseAttackDetails(line, data) { // Claws: Melee | 1d8+3 phy OR Claws: Melee 1d8+3 phy OR Claws: Melee1d6+2 phy // 1. Extract Name let name = "Attack"; let rest = line; if (line.includes(":")) { const parts = line.split(":"); name = parts[0].trim(); rest = parts.slice(1).join(":").trim(); } data.system.attack.name = name; // 2. Extract Range // Ranges: Melee, Very Close, Close, Far, Very Far const rangeMatch = rest.match(/^(Melee|Very Close|Close|Far|Very Far)/i); if (rangeMatch) { let rangeVal = rangeMatch[1].toLowerCase(); if (rangeVal === "very close") rangeVal = "veryClose"; if (rangeVal === "very far") rangeVal = "veryFar"; data.system.attack.range = rangeVal; rest = rest.substring(rangeMatch[0].length).trim(); } // 3. Extract Damage // Formula + Type if (rest) { // Remove optional pipes rest = rest.replace(/^\|\s*/, ""); // "1d6+2 phy" const dmgMatch = rest.match(/^(.+?)\s+(\w+)$/); let formula = rest; let type = "physical"; if (dmgMatch) { formula = dmgMatch[1]; type = dmgMatch[2].toLowerCase(); if (type === "phy") type = "physical"; else if (type === "mag" || type === "magic") type = "magical"; } data.system.attack.damage.parts.push({ type: [type], applyTo: "hitPoints", value: { custom: { enabled: true, formula: formula }, multiplier: "flat", dice: "d6", flatMultiplier: 1 } }); } } static _parseExperienceLine(line, data) { // "Ambusher +3" const match = line.match(/(.+?)\s+([+-]?\d+)$/); if (match) { const currentCount = Object.keys(data.system.experiences).length; const key = "exp" + currentCount; data.system.experiences[key] = { name: match[1].trim(), value: parseInt(match[2]) }; } } /** * Helper to create feature item data */ static _createFeatureItem(buffer) { // In the future, we could map "Action" vs "Passive" to system fields if available. // For now, Daggerheart features are mostly just text descriptions, // sometimes with specific actions embedded. // We will put the type in the description. const isAction = buffer.typeHint?.toLowerCase() === "action"; const item = { name: buffer.name, type: "feature", img: "icons/svg/item-bag.svg", system: { description: `

${buffer.typeHint}: ${buffer.description}

`, actions: {}, featureForm: isAction ? "action" : "passive" } }; if (isAction) { const actionId = foundry.utils.randomID(); const action = { _id: actionId, type: "attack", name: buffer.name, actionType: "action", img: "icons/svg/item-bag.svg", systemPath: "actions", chatDisplay: true, cost: [], damage: { parts: [], includeBase: false, direct: false }, range: "", roll: { type: "attack", // Default to attack diceRolling: { multiplier: "flat", dice: "d6" } } }; // Parse Fear Cost if (buffer.description.match(/^spen[md]\s+(?:a|1)\s+fear/i)) { action.cost.push({ consumeOnSuccess: false, scalable: false, key: "fear", value: 1, itemId: null, step: null }); } // Parse Stress Cost if (buffer.description.match(/mark\s+(?:a|1)\s+stress/i)) { action.cost.push({ consumeOnSuccess: false, scalable: false, key: "stress", value: 1, itemId: null, step: null }); } // Parse Attack Trigger if (buffer.description.match(/make.*?attack/i)) { action.roll.type = "attack"; } // Parse Range const rangeMatch = buffer.description.match(/(Melee|Very Close|Close|Far|Very Far)/i); if (rangeMatch) { let rangeVal = rangeMatch[1].toLowerCase(); if (rangeVal === "very close") rangeVal = "veryClose"; if (rangeVal === "very far") rangeVal = "veryFar"; action.range = rangeVal; } // Parse Damage: "deal 3d4+10 direct physical damage" // Matches: context? + formula + optional direct + type + "damage" const damageMatch = buffer.description.match(/(?:deal|inflict|take)\s+(\d+(?:d\d+)?(?:[\s]*[\+\-][\s]*\d+)?)\s+(direct\s+)?(\w+)\s+damage/i); if (damageMatch) { // remove spaces in formula for cleaner data let formula = damageMatch[1].replace(/\s/g, ""); const isDirect = !!damageMatch[2]; // Captured "direct " let type = damageMatch[3].toLowerCase(); // Map short codes if (type === "phy") type = "physical"; else if (type === "mag" || type === "magic") type = "magical"; action.damage.direct = isDirect; action.damage.parts.push({ value: { custom: { enabled: true, formula: formula }, multiplier: "flat", dice: "d6", flatMultiplier: 1 // Default }, applyTo: "hitPoints", type: [type] }); } item.system.actions[actionId] = action; } return item; } /** * Parse Environment Text * @param {string} text * @returns {Array} */ static parseEnvironments(text) { const blocks = text.split(/^-----$/gm).filter(b => b.trim()); const results = []; for (let block of blocks) { const lines = block.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l); if (lines.length === 0) continue; try { const data = { name: lines[0], type: "environment", img: "systems/daggerheart/assets/icons/documents/actors/forest.svg", system: { tier: 1, type: "exploration", difficulty: 12, impulses: "", potentialAdversaries: { label: "" }, notes: "" }, items: [] }; let lineIndex = 1; // Tier 1 Exploration if (lineIndex < lines.length) { const tierMatch = lines[lineIndex].match(/Tier\s+(\d+)\s+(\w+)/i); if (tierMatch) { data.system.tier = parseInt(tierMatch[1]); data.system.type = tierMatch[2].toLowerCase(); lineIndex++; } } // Description let description = []; while (lineIndex < lines.length) { const line = lines[lineIndex]; if (line.match(/^(Impulses|Difficulty|Potential Adversaries|Features)/i)) { break; } description.push(line); lineIndex++; } data.system.notes = description.join("
"); let currentSection = "header"; let featureBuffer = null; while (lineIndex < lines.length) { const line = lines[lineIndex]; if (line.match(/^Impulses:/i)) { data.system.impulses = line.replace(/^Impulses:\s*/i, "").trim(); } else if (line.match(/^Difficulty:/i)) { data.system.difficulty = parseInt(line.split(":")[1].trim()); } else if (line.match(/^Potential Adversaries:/i)) { data.system.potentialAdversaries.label = line.replace(/^Potential Adversaries:\s*/i, "").trim(); } else if (line.match(/^Features/i)) { currentSection = "features"; } else if (currentSection === "features") { // Parsing Features: "Name - Type: Description" const featureMatch = line.match(/^(.+?)\s*[–—-]\s*(\w+):\s*(.*)/); if (featureMatch) { if (featureBuffer) { data.items.push(DHImporter._createFeatureItem(featureBuffer)); } featureBuffer = { name: featureMatch[1], typeHint: featureMatch[2], description: featureMatch[3] }; } else if (featureBuffer) { featureBuffer.description += "
" + line; } } lineIndex++; } if (featureBuffer) { data.items.push(DHImporter._createFeatureItem(featureBuffer)); } results.push(data); } catch (e) { console.error("Failed to parse environment block", e); } } return results; } static async checkExistingFeatures(dataList) { if (!dataList || dataList.length === 0) return dataList; const results = []; // Pre-fetch world packs content index for speed, or just search relevant packs // For simplicity, we search all packs const packs = game.packs.filter(p => p.documentName === "Item"); for (const actorData of dataList) { const enrichedActor = { ...actorData, foundFeatures: {} }; for (const item of actorData.items) { if (item.type !== "feature") continue; // Search for item by name let found = null; for (const pack of packs) { const index = pack.index.size > 0 ? pack.index : await pack.getIndex(); const entry = index.find(i => i.name === item.name); if (entry) { found = { uuid: entry.uuid, name: entry.name, pack: pack.metadata.label }; break; } } if (found) { enrichedActor.foundFeatures[item.name] = found; } } results.push(enrichedActor); } return results; } static async createDocuments(dataList, type) { if (!dataList || dataList.length === 0) return; const Actor = getDocumentClass("Actor"); const actorsToCreate = []; for (const data of dataList) { // Process Items based on 'useCompendium' flags or similar set by the UI // The UI should have modified data.items or we loop through and replace based on user choice const finalItems = []; for (const item of data.items) { if (data.useFeatures && data.useFeatures[item.name]) { // User selected to use existing feature const uuid = data.useFeatures[item.name]; const original = await fromUuid(uuid); if (original) { finalItems.push(original.toObject()); } else { finalItems.push(item); } } else { finalItems.push(item); } } data.items = finalItems; // Clean up temporary flags const shouldOpen = data.openSheet; delete data.foundFeatures; delete data.useFeatures; delete data.openSheet; actorsToCreate.push({ data, shouldOpen }); } const createdActors = await Actor.createDocuments(actorsToCreate.map(a => a.data)); // Open sheets if (createdActors && createdActors.length > 0) { for (let i = 0; i < createdActors.length; i++) { if (actorsToCreate[i].shouldOpen) { createdActors[i].sheet.render(true); } } } } }