585 lines
25 KiB
JavaScript
585 lines
25 KiB
JavaScript
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|