dh-importer/scripts/importer.js
2026-01-23 20:06:09 +01:00

469 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export class DHImporter {
/**
* Parse Adversary Text
* @param {string} text
* @returns {Array<Object>} 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",
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 + "<br>";
}
}
// 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; // System doesn't have minor usually?
// Wait, Daggerheart system model: major, severe. Minor is usually just 1 HP.
// But let's check input: "minor \n 1 HP \n 9". 9 is likely the threshold.
// However, data.system.damageThresholds usually only stores Major/Severe in Foundry system?
// Let's assume we ignore minor or map it if valid.
} 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")) {
// "1 HP" / "2 HP" lines often precede thresholds
// If we see "9" after "1 HP", check if "minor" was before that.
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();
// "Melee 1d8+3 phy" -> split by first space? or known ranges?
// Heuristic: Range is usually one word (Melee, Close, Far)
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.
return {
name: buffer.name,
type: "feature",
system: {
description: `<p><strong>${buffer.typeHint}</strong>: ${buffer.description}</p>`
}
};
}
/**
* Parse Environment Text
* @param {string} text
* @returns {Array<Object>}
*/
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",
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("<br>");
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 += "<br>" + 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);
}
}