dh-importer/scripts/importer.js

585 lines
25 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",
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 + "<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;
} 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: `<p><strong>${buffer.typeHint}</strong>: ${buffer.description}</p>`,
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<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",
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("<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
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);
}
}
}
}
}