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: ..." const valueMatch = line.match(/Difficulty:\s*(\d+)/i); if (valueMatch) data.system.difficulty = parseInt(valueMatch[1]); // Check for inline stuff (Old Format) if (line.includes("|")) { const parts = line.split("|").map(p => p.trim()); for (const part of parts) { if (part.startsWith("HP:")) { data.system.resources.hitPoints.max = parseInt(part.split(":")[1]); } else if (part.startsWith("Stress:")) { data.system.resources.stress.max = parseInt(part.split(":")[1]); } else if (part.startsWith("Thresholds:")) { const th = part.split(":")[1].split("/").map(t => parseInt(t.trim())); if (th[0]) data.system.damageThresholds.major = th[0]; if (th[1]) data.system.damageThresholds.severe = th[1]; } } } } else if (line.match(/^(Attack:|ATK:)/i)) { // "Attack: +1" or "ATK: +2 | Claws: Melee | ..." const bonusMatch = line.match(/(?:Attack|ATK):\s*([+-]?\d+)/i); if (bonusMatch) data.system.attack.roll.bonus = parseInt(bonusMatch[1]); // If split format e.g. "Attack: +1" and next line is "Claws: Melee..." if (!line.includes("|") && 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 (line.includes("|")) { // Inline formatting const parts = line.split("|").slice(1).join("|"); // remove ATK part DHImporter._parseAttackDetails(parts, 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") { const featureMatch = line.match(/^(.+?)\s+[–-]\s+(\w+)(?::\s*(.*))?$/); // "Name - Type" (Description on next line) OR "Name - Type: Description" 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 // Simple heuristic splitting let parts = line.split("|").map(p => p.trim()); if (parts.length === 1) { // Try splitting by : for name/range const firstColon = line.indexOf(":"); if (firstColon > -1) { const name = line.substring(0, firstColon).trim(); const rest = line.substring(firstColon + 1).trim(); const rangeMatch = rest.match(/^(\w+)\s+(.*)$/); if (rangeMatch) { parts = [name + ":" + rangeMatch[1], rangeMatch[2]]; } else { parts = [line]; } } } // Now parse parts roughly matching old logic // Part 0: Name: Range if (parts.length > 0) { const nameRange = parts[0].split(":"); data.system.attack.name = nameRange[0].trim(); if (nameRange[1]) data.system.attack.range = nameRange[1].trim().toLowerCase(); } // Part 1: Damage if (parts.length > 1) { const dmgStr = parts[1]; const dmgMatch = dmgStr.match(/^(.+?)\s+(\w+)$/); let formula = dmgStr; let type = "physical"; if (dmgMatch) { formula = dmgMatch[1]; type = dmgMatch[2].toLowerCase(); // Map short codes if (type === "phy") type = "physical"; else if (type === "mag") type = "magic"; } data.system.attack.damage.parts.push({ type: [type], applyTo: "hitPoints", value: { custom: { enabled: true, formula: formula }, multiplier: "flat", dice: "d6" } }); } } 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: {} } }; if (isAction) { const actionId = foundry.utils.randomID(); item.system.actions[actionId] = { _id: actionId, type: "attack", name: buffer.name, actionType: "action", img: "icons/svg/item-bag.svg", systemPath: "actions", chatDisplay: true }; } 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 delete data.foundFeatures; delete data.useFeatures; actorsToCreate.push(data); } await Actor.createDocuments(actorsToCreate); } }