export class BaseNotes { name = ""; equal(other) { if (other == null) return false; // Weapons in 40k have unique names return this.name === other.name; } } /** A `selection` attached to a unit or model. */ export class Upgrade extends BaseNotes { cost = new Costs(); count = 1; getSelectionName() { return this.name; } toString() { let string = this.getSelectionName(); if (this.count > 1) string = `${this.count}x ${string}`; if (this.cost.hasValues()) string += ` ${this.cost.toString()}`; return string; } } /** A weapon `profile` that is under a `selection`. */ export class Weapon extends Upgrade { selectionName = ""; range = ""; type = ""; str = "user"; ap = ""; damage = ""; abilities = ""; /** * Name of this weapon's `selection`. This is different from name() because * name() is used for sorting and deduping weapon profiles. */ getSelectionName() { return this.selectionName || this.name; } } export class WoundTracker extends BaseNotes { name = ""; table = new Map(); } export class Explosion extends BaseNotes { name = ""; diceRoll = ""; distance = ""; mortalWounds = ""; } export class Psyker extends BaseNotes { cast = ""; deny = ""; powers = ""; other = ""; } export class PsychicPower extends BaseNotes { name = ""; manifest = 0; range = ""; details = ""; } export const UnitRole = { NONE: "NONE", // 40k Character: "Character", Battleline: "Battleline", DedicatedTransport: "TR", }; export const UnitRoleSorting = { Character: 0, Battleline: 1, DedicatedTransport: 2, NONE: 3, }; export class Model extends BaseNotes { count = 0; // Characteristics move = '0"'; ws = ""; bs = ""; str = 4; toughness = 4; wounds = 1; attacks = ""; leadership = 7; save = ""; rangedWeapons = []; meleeWeapons = []; upgrades = []; // TODO model upgrades (i.e. tau support systems) psyker = null; psychicPowers = []; explosions = []; equal(model) { if (model == null) return false; if ( this.name === model.name && this.count === model.count && this.rangedWeapons.length === model.rangedWeapons.length && this.meleeWeapons.length === model.meleeWeapons.length && this.upgrades.length === model.upgrades.length ) { for (let wi = 0; wi < this.rangedWeapons.length; wi++) { if (!this.rangedWeapons[wi].equal(model.rangedWeapons[wi])) { return false; } } for (let wi = 0; wi < this.meleeWeapons.length; wi++) { if (!this.meleeWeapons[wi].equal(model.meleeWeapons[wi])) { return false; } } for (let wi = 0; wi < this.upgrades.length; wi++) { if (!this.upgrades[wi].equal(model.upgrades[wi])) { return false; } } // TODO: check for the same psychic powers if (this.psyker != null || model.psyker != null) return false; return true; } return false; } nameAndGear() { let name = this.name; if ( this.meleeWeapons.length > 0 || this.rangedWeapons.length > 0 || this.upgrades.length > 0 ) { const gear = this.getDedupedWeaponsAndUpgrades(); name += ` (${gear.map((u) => u.toString()).join(", ")})`; } return name; } getDedupedWeaponsAndUpgrades() { const deduped = []; for (const upgrade of [ ...this.rangedWeapons, ...this.meleeWeapons, ...this.upgrades, ]) { if ( !deduped.some( (e) => upgrade.getSelectionName() === e.getSelectionName(), ) ) { deduped.push(upgrade); } } return deduped; } normalize() { this.rangedWeapons.sort(CompareWeapon); this.meleeWeapons.sort(CompareWeapon); this.upgrades.sort(CompareObj); this.normalizeUpgrades(this.rangedWeapons); this.normalizeUpgrades(this.meleeWeapons); this.normalizeUpgrades(this.upgrades); } normalizeUpgrades(upgrades) { for (let i = 0; i < upgrades.length - 1; i++) { const upgrade = upgrades[i]; if (upgrade.name === upgrades[i + 1].name) { upgrade.count += upgrades[i + 1].count; upgrade.cost.add(upgrades[i + 1].cost); upgrades.splice(i + 1, 1); i--; } } for (let upgrade of upgrades) { if (upgrade.count % this.count == 0) { upgrade.count /= this.count; upgrade.cost.points /= this.count; } } } } export class Unit extends BaseNotes { role = UnitRole.NONE; factions = new Set(); keywords = new Set(); abilities = {}; rules = new Map(); models = []; modelStats = []; modelList = []; rangedWeapons = []; meleeWeapons = []; spells = []; psykers = []; explosions = []; cost = new Costs(); woundTracker = []; nameWithExtraCosts() { const extraCosts = []; // Track extra costs like cabal points. for (const freeformCostType in this.cost.freeformValues) { if (this.cost.freeformValues[freeformCostType] === 0) continue; extraCosts.push( `${this.cost.freeformValues[freeformCostType]}${freeformCostType}`, ); } return extraCosts.length ? `${this.name} [${extraCosts.join(", ")}]` : this.name; } equal(unit) { if (unit == null) return false; if ( unit.name === this.name && unit.role === this.role && unit.models.length === this.models.length && unit.modelStats.length === this.modelStats.length ) { for (let mi = 0; mi < this.models.length; mi++) { if (!this.models[mi].equal(unit.models[mi])) { return false; } } for (let mi = 0; mi < this.modelStats.length; mi++) { if (!this.modelStats[mi].equal(unit.modelStats[mi])) { return false; } } // Check how to replace without lodash /* if (!.isEqual(this.abilities, unit.abilities)) { return false; } else if (!.isEqual(this.rules, unit.rules)) { return false; } */ return true; } return false; } normalize() { // Sort force units by role and name this.models.sort(CompareModel); this.modelStats = this.modelStats.sort((a, b) => { var wounds_order = (isNaN(parseInt(a.wounds)) ? -1 : parseInt(a.wounds)) - (isNaN(parseInt(b.wounds)) ? -1 : parseInt(b.wounds)); var leadership_order = parseInt(a.leadership) - parseInt(b.leadership); var move_order = parseInt(a.move) - parseInt(b.move); return ( -wounds_order || -leadership_order || -move_order || b.name.length - a.name.length ); }); for (let model of this.models) { model.normalize(); } for (let i = 0; i < this.models.length - 1; i++) { const model = this.models[i]; if (model.nameAndGear() === this.models[i + 1].nameAndGear()) { model.count++; this.models.splice(i + 1, 1); i--; } } for (let i = 0; i < this.modelStats.length - 1; i++) { const model = this.modelStats[i]; if (model.equal(this.modelStats[i + 1])) { this.modelStats.splice(i + 1, 1); i--; } } this.modelList = this.models.map( (model) => (model.count > 1 ? `${model.count}x ` : "") + model.nameAndGear(), ); this.rangedWeapons = this.models .map((m) => m.rangedWeapons) .reduce((acc, val) => acc.concat(val), []) .sort(CompareWeapon) .filter((weap, i, array) => weap.name !== array[i - 1]?.name); this.meleeWeapons = this.models .map((m) => m.meleeWeapons) .reduce((acc, val) => acc.concat(val), []) .sort(CompareWeapon) .filter((weap, i, array) => weap.name !== array[i - 1]?.name); this.spells.push( ...this.models .map((m) => m.psychicPowers) .reduce((acc, val) => acc.concat(val), []), ); this.psykers.push(...this.models.map((m) => m.psyker).filter((p) => p)); this.explosions.push( ...this.models .map((m) => m.explosions) .reduce((acc, val) => acc.concat(val), []), ); } } export class Force extends BaseNotes { catalog = ""; faction = "Unknown"; factionRules = new Map(); configurations = []; rules = new Map(); units = []; } export class Roster40k extends BaseNotes { cost = new Costs(); forces = []; gameType = ""; } export class Costs { commandPoints = 0; points = 0; freeformValues; hasValues() { return this.commandPoints !== 0 || this.points !== 0; } toString() { const values = []; if (this.points !== 0) values.push(`${this.points} pts`); if (this.commandPoints !== 0) values.push(`${this.commandPoints} CP`); return `[${values.join(" / ")}]`; } add(other) { this.commandPoints += other.commandPoints; this.points += other.points; for (const name in other.freeformValues) { this.addFreeformValue(name, other.freeformValues[name]); } } addFreeformValue(name, value) { if (!this.freeformValues) this.freeformValues = {}; const oldValue = this.freeformValues[name] || 0; this.freeformValues[name] = oldValue + value; } } export function Create40kRoster10th(doc, gameType) { // Determine roster type (game system). let info = doc.querySelector("roster"); if (info) { const roster = new Roster40k(); roster.gameType = gameType; const name = info.getAttributeNode("name")?.nodeValue; if (name) { roster.name = name; } else { roster.name = "40k Army Roster"; } ParseRosterPoints(doc, roster); ParseForces(doc, roster); return roster; } } function ParseRosterPoints(doc, roster) { let costs = doc.querySelectorAll("roster>costs>cost"); for (let cost of costs) { roster.cost.add(ParseCost(cost)); } } function ParseForces(doc, roster) { let forcesRoot = doc.querySelectorAll("roster>forces>force"); for (let root of forcesRoot) { if (root.hasAttribute("name") && root.hasAttribute("catalogueName")) { let f = new Force(); let which = root.getAttributeNode("name")?.nodeValue; let value = root.getAttributeNode("catalogueName")?.nodeValue; if (which) { f.name = which; } if (value) { f.catalog = value; } // TODO: Determine force faction and faction specific rules. // Only include the allegiance rules once. if (!DuplicateForce(f, roster)) { const rules = root.querySelectorAll("force>rules>rule"); for (let rule of rules) { ExtractRuleDescription(rule, f.rules); } } ParseSelections(root, f); roster.forces.push(f); } } } function ParseSelections(root, force) { let selections = root.querySelectorAll("force>selections>selection"); for (let selection of selections) { // What kind of selection is this let selectionName = selection.getAttributeNode("name")?.nodeValue; if (!selectionName) continue; if (selectionName.includes("Detachment Command Cost")) { // Ignore Detachment Command cost } else if ( selectionName === "Battle Size" || selectionName === "Gametype" ) { ParseConfiguration(selection, force); } else if (selection.querySelector('profile[typeName="Unit"]')) { const unit = ParseUnit(selection); force.units.push(unit); for (const entry of unit.rules.entries()) { force.rules.set(entry[0], entry[1]); } } else if (selection.getAttribute("type") === "upgrade") { ExtractRuleFromSelection(selection, force.rules); ParseConfiguration(selection, force); const props = selection.querySelectorAll("selections>selection"); for (let prop of props) { // sub-faction const name = prop.getAttribute("name"); if (name && prop.getAttribute("type") === "upgrade") { if (force.faction === "Unknown") { // pick the first upgrade we see force.faction = name; } ExtractRuleFromSelection(prop, force.factionRules); } } } else { console.log("** UNEXPECTED SELECTION **", selectionName, selection); } } for (const key of force.factionRules.keys()) { force.rules.delete(key); } // Sort force units by role and name# force.units.sort((a, b) => { if (UnitRoleSorting[a.role] > UnitRoleSorting[b.role]) return 1; else if (UnitRoleSorting[a.role] == UnitRoleSorting[b.role]) { if (a.name > b.name) return 1; else if (a.name == b.name) return 0; return -1; } return -1; }); } function ParseConfiguration(selection, force) { const name = selection.getAttribute("name"); if (!name) { return; } const category = selection.querySelector("category")?.getAttribute("name"); const subSelections = selection.querySelectorAll("selections>selection"); const details = []; let costs = GetSelectionCosts(selection); for (const sel of subSelections) { details.push(sel.getAttribute("name")); costs.add(GetSelectionCosts(sel)); } let configuration = !category || category === "Configuration" ? name : `${category} - ${name}`; if (details.length > 0) configuration += `: ${details.join(", ")}`; if (costs.hasValues()) configuration += ` ${costs.toString()}`; force.configurations.push(configuration); } function DuplicateForce(force, roster) { if (!roster || !force) return false; for (let f of roster.forces) { if (f.catalog === force.catalog) return true; } return false; } function ExtractRuleFromSelection(root, map) { const profiles = root.querySelectorAll("profiles>profile"); for (const profile of profiles) { // detachment rules const profileName = profile.getAttribute("name"); if (!profileName) continue; const profileType = profile.getAttribute("typeName"); if ( profileType === "Abilities" || profileType === "Dynastic Code" || profileType === "Household Tradition" ) { ParseProfileCharacteristics(profile, profileName, profileType, map); } } const rules = root.querySelectorAll("rules>rule"); for (const rule of rules) { ExtractRuleDescription(rule, map); } } function ExtractRuleDescription(rule, map) { const ruleName = rule.getAttribute("name"); const desc = rule.querySelector("description"); if (ruleName && desc?.textContent) { map.set(ruleName, desc.textContent); } } function LookupRole(roleText) { switch (roleText) { case "Character": return UnitRole.Character; case "Battleline": return UnitRole.Battleline; case "Dedicated Transport": return UnitRole.DedicatedTransport; } return UnitRole.NONE; } function ExpandBaseNotes(root, obj) { obj.name = root.getAttributeNode("name")?.nodeValue; return obj.name; } function ExtractNumberFromParent(root) { // Get parent node (a selection) to determine model count. if (root.parentElement && root.parentElement.parentElement) { const parentSelection = root.parentElement.parentElement; const countValue = parentSelection.getAttributeNode("number")?.nodeValue; if (countValue) { return +countValue; } } return 0; } function GetImmediateSelections(root) { // querySelectorAll(':scope > tagname') doesn't work with jsdom, so we hack // around it: https://github.com/jsdom/jsdom/issues/2998 const selections = []; for (const child of root.children) { if (child.tagName === "selections") { for (const subChild of child.children) { if (subChild.tagName === "selection") { selections.push(subChild); } } } } return selections; } function HasImmediateProfileWithTypeName(root, typeName) { // querySelectorAll(':scope > tagname') doesn't work with jsdom, so we hack // around it: https://github.com/jsdom/jsdom/issues/2998 for (const child of root.children) { if (child.tagName === "profiles") { for (const subChild of child.children) { if ( subChild.tagName === "profile" && subChild.getAttribute("typeName") === typeName ) { return true; } } } } return false; } function GetSelectionCosts(selection) { // querySelectorAll(':scope > tagname') doesn't work with jsdom, so we hack // around it: https://github.com/jsdom/jsdom/issues/2998 const costs = new Costs(); for (const child of selection.children) { if (child.tagName === "costs") { for (const subChild of child.children) { costs.add(ParseCost(subChild)); } } } return costs; } function ParseCost(cost) { const costs = new Costs(); const which = cost.getAttribute("name"); const value = cost.getAttribute("value"); if (which && value) { if (which === "pts") { costs.points += +value; } else if (which === "CP") { costs.commandPoints += +value; } else { costs.addFreeformValue(which, +value); } } return costs; } function ParseUnit(root) { let unit = new Unit(); const unitName = ExpandBaseNotes(root, unit); let categories = root.querySelectorAll("categories>category"); for (let cat of categories) { const catName = cat.getAttributeNode("name")?.nodeValue; if (catName) { const factPattern = "Faction: "; const factIndex = catName.lastIndexOf(factPattern); if (factIndex >= 0) { const factKeyword = catName.slice(factIndex + factPattern.length); unit.factions.add(factKeyword); } else { const roleText = catName.trim(); let unitRole = LookupRole(roleText); if (unitRole != UnitRole.NONE) { unit.role = unitRole; } unit.keywords.add(catName); } } } const seenProfiles = []; // First, find model stats. These have typeName=Unit. const modelStatsProfiles = Array.from( root.querySelectorAll('profile[typeName="Unit"],profile[typeName="Model"]'), ); ParseModelStatsProfiles(modelStatsProfiles, unit, unitName); seenProfiles.push(...modelStatsProfiles); // Next, look for selections with models. These usually have type="model", // but may have type="upgrade" containing a profile of type="Unit". const modelSelections = []; if (root.getAttribute("type") === "model") { modelSelections.push(root); // Single-model unit. } else { const immediateSelections = GetImmediateSelections(root); for (const selection of immediateSelections) { if ( selection.getAttribute("type") === "model" || HasImmediateProfileWithTypeName(selection, "Unit") ) { modelSelections.push(selection); } } // Some units are under a root selection with type="upgrade". if (modelSelections.length === 0) { modelSelections.push( ...Array.from(root.querySelectorAll('selection[type="model"]')), ); } // Some single-model units have type="unit" or type="upgrade". if ( modelSelections.length === 0 && HasImmediateProfileWithTypeName(root, "Unit") ) { modelSelections.push(root); } } // Now, parse the model -- profiles for stats, and selections for upgrades. for (const modelSelection of modelSelections) { const profiles = Array.from( modelSelection.querySelectorAll("profiles>profile"), ); const unseenProfiles = profiles.filter((e) => !seenProfiles.includes(e)); seenProfiles.push(...unseenProfiles); const model = new Model(); model.name = modelSelection.getAttribute("name") || "Unknown Model"; model.count = Number(modelSelection.getAttribute("number") || 1); unit.models.push(model); // Find stats for all profiles (weapons, powers, abilities, etc). ParseModelProfiles(profiles, model, unit); // Find all upgrades on the model. This may include weapons that were // parsed from profiles (above), so dedupe those in nameAndGear(). for (const upgradeSelection of modelSelection.querySelectorAll( 'selections>selection[type="upgrade"]', )) { // Ignore selections without abilities but with sub-selection upgrades, // since those sub-selections will be picked up individually. if ( upgradeSelection.querySelector( 'selections>selection[type="upgrade"]', ) && !HasImmediateProfileWithTypeName(upgradeSelection, "Abilities") ) continue; let upgradeName = upgradeSelection.getAttribute("name"); if (upgradeName) { const upgrade = new Upgrade(); upgrade.name = upgradeName; upgrade.cost = GetSelectionCosts(upgradeSelection); upgrade.count = Number(upgradeSelection.getAttribute("number")); model.upgrades.push(upgrade); } } } // Finally, look for profiles that are not under models. They may apply to // a) model loadouts, if it's selection with a Weapon (eg Immortals) // b) unit loadout, if it's a selection with an Ability (eg Bomb Squigs) // c) abilities for the unit, if it's not under a child selection let profiles = Array.from(root.querySelectorAll("profiles>profile")); let unseenProfiles = profiles.filter((e) => !seenProfiles.includes(e)); seenProfiles.push(...unseenProfiles); if (unseenProfiles.length > 0) { const unitUpgradesModel = new Model(); unitUpgradesModel.name = "Unit Upgrades"; ParseModelProfiles(unseenProfiles, unitUpgradesModel, unit); /* if (unitUpgradesModel.meleeWeapons.length > 0 && unit.models.length > 0) { // Apply weapons at the unit level to all models in the unit. for (const model of unit.models) { model.meleeWeapons.push(...unitUpgradesModel.meleeWeapons); } unitUpgradesModel.meleeWeapons.length = 0; // Clear the array. } if (unitUpgradesModel.rangedWeapons.length > 0 && unit.models.length > 0) { // Apply weapons at the unit level to all models in the unit. for (const model of unit.models) { model.rangedWeapons.push(...unitUpgradesModel.rangedWeapons); } unitUpgradesModel.rangedWeapons.length = 0; // Clear the array. } */ if (unitUpgradesModel.psychicPowers.length > 0) { // Add spells to the unit's spell list. However, we'll still need // to add spell upgrade selections to the upgrade list, below. unit.spells.push(...unitUpgradesModel.psychicPowers); unitUpgradesModel.psychicPowers.length = 0; } if (unitUpgradesModel.psyker) { unit.psykers.push(unitUpgradesModel.psyker); unitUpgradesModel.psyker = null; } if (unitUpgradesModel.explosions.length > 0) { unit.explosions.push(...unitUpgradesModel.explosions); unitUpgradesModel.explosions.length = 0; } // Look for any unit-level upgrade selections that we didn't catch // previously, and stuff them in the "Unit Upgrades" model. for (const selection of GetImmediateSelections(root)) { if (selection.getAttribute("type") !== "upgrade") continue; // Ignore model selections (which were already processed). if (modelSelections.includes(selection)) continue; // Ignore unit-level weapon selections; these were handled above. if (selection.querySelector('profiles>profile[typeName="Weapon"]')) continue; let name = selection.getAttribute("name"); if (!name) continue; const upgrade = new Upgrade(); upgrade.name = name; upgrade.cost = GetSelectionCosts(selection); upgrade.count = Number(selection.getAttribute("number")); unitUpgradesModel.upgrades.push(upgrade); } if ( unitUpgradesModel.rangedWeapons.length > 0 || unitUpgradesModel.meleeWeapons.length > 0 || unitUpgradesModel.upgrades.length > 0 ) { unit.models.push(unitUpgradesModel); } } // Only match costs->costs associated with the unit and not its children (model and weapon) costs. let costs = root.querySelectorAll("costs>cost"); for (let cost of costs) { unit.cost.add(ParseCost(cost)); } let rules = root.querySelectorAll("rules > rule"); for (let rule of rules) { if (rule.parentElement.parentElement.getAttribute("type") === "upgrade") { continue; } ExtractRuleDescription(rule, unit.rules); } unit.normalize(); return unit; } function ParseModelStatsProfiles(profiles, unit, unitName) { for (const profile of profiles) { const profileName = profile.getAttribute("name"); const profileType = profile.getAttribute("typeName"); if (!profileName || !profileType) return; const model = new Model(); model.name = profileName; unit.modelStats.push(model); ExpandBaseNotes(profile, model); const chars = profile.querySelectorAll("characteristics>characteristic"); for (const char of chars) { const charName = char.getAttribute("name"); if (!charName) continue; if (char.textContent) { switch (charName) { case "M": model.move = char.textContent; break; case "WS": model.ws = char.textContent; break; case "BS": model.bs = char.textContent; break; case "S": model.str = +char.textContent; break; case "T": model.toughness = +char.textContent; break; case "W": model.wounds = +char.textContent; break; case "A": model.attacks = char.textContent; break; case "LD": model.leadership = char.textContent; break; case "SV": model.save = char.textContent; break; case "OC": model.oc = char.textContent; break; } } } } } function ParseModelProfiles(profiles, model, unit) { for (const profile of profiles) { const profileName = profile.getAttribute("name"); const typeName = profile.getAttribute("typeName"); if (!profileName || !typeName) continue; if ( typeName === "Unit" || typeName === "Model" || profile.getAttribute("type") === "model" ) { // Do nothing; these were already handled. } else if (typeName === "Ranged Weapons") { const weapon = ParseWeaponProfile(profile); model.rangedWeapons.push(weapon); } else if (typeName === "Melee Weapons") { const weapon = ParseWeaponProfile(profile); model.meleeWeapons.push(weapon); } else if ( typeName.includes("Wound Track") || typeName.includes("Stat Damage") || typeName.includes(" Wounds") ) { const tracker = ParseWoundTrackerProfile(profile); unit.woundTracker.push(tracker); } else if (typeName == "Psychic Power") { const power = ParsePsychicPowerProfile(profile); model.psychicPowers.push(power); } else if (typeName == "Abilities") { if (!unit.abilities.Abilities) unit.abilities["Abilities"] = new Map(); ParseProfileCharacteristics( profile, profileName, typeName, unit.abilities.Abilities, ); } else { // Everything else, like Prayers and Warlord Traits. if (!unit.abilities[typeName]) unit.abilities[typeName] = new Map(); ParseProfileCharacteristics( profile, profileName, typeName, unit.abilities[typeName], ); } } } function ParseProfileCharacteristics(profile, profileName, typeName, map) { const chars = profile.querySelectorAll("characteristics>characteristic"); for (const char of chars) { if (!char.textContent) continue; const charName = char.getAttribute("name"); if (charName && chars.length > 1) { // Profiles with multiple characteristics need to distinguish them by name. map.set([profileName, charName.toString()].join(" - "), char.textContent); } else { // Profiles with a single characteristic can ignore the char name. if (!map) { debugger; } map.set(profileName, char.textContent); } } } function ParseWeaponProfile(profile) { const weapon = new Weapon(); ExpandBaseNotes(profile, weapon); weapon.count = ExtractNumberFromParent(profile); let chars = profile.querySelectorAll("characteristics>characteristic"); for (let char of chars) { let charName = char.getAttribute("name"); if (charName) { if (char.textContent) { switch (charName) { case "Range": weapon.range = char.textContent; break; case "Type": weapon.type = char.textContent; break; case "S": weapon.str = char.textContent; break; case "AP": weapon.ap = char.textContent; break; case "D": weapon.damage = char.textContent; break; case "Abilities": weapon.abilities = char.textContent; break; case "BS": weapon.bs = char.textContent; break; case "WS": weapon.ws = char.textContent; break; case "A": weapon.attacks = char.textContent; break; case "Keywords": weapon.type = char.textContent; break; } } } } // Keep track of the weapon's parent selection for its name, unless the // weapon is directly under the unit's profile. const selection = profile.parentElement?.parentElement; const selectionName = selection?.getAttribute("name"); if (selection?.getAttribute("type") === "upgrade" && selectionName) { weapon.selectionName = selectionName; weapon.cost = GetSelectionCosts(selection); } return weapon; } function ParseWoundTrackerProfile(profile) { let tracker = new WoundTracker(); ExpandBaseNotes(profile, tracker); let chars = profile.querySelectorAll("characteristics>characteristic"); for (let char of chars) { const charName = char.getAttribute("name"); if (charName) { if (char.textContent) { tracker.table.set(charName, char.textContent); } else { tracker.table.set(charName, "-"); } } } return tracker; } function ParsePsychicPowerProfile(profile) { const power = new PsychicPower(); ExpandBaseNotes(profile, power); const chars = profile.querySelectorAll("characteristics>characteristic"); for (let char of chars) { const charName = char.getAttribute("name"); if (charName && char.textContent) { switch (charName) { case "Range": power.range = char.textContent; break; case "Warp Charge": power.manifest = +char.textContent; break; case "Details": power.details = char.textContent; break; } } } return power; } function ParseExplosionProfile(profile) { const explosion = new Explosion(); ExpandBaseNotes(profile, explosion); const chars = profile.querySelectorAll("characteristics>characteristic"); for (const char of chars) { const charName = char.getAttribute("name"); if (charName && char.textContent) { switch (charName) { case "Dice Roll": explosion.diceRoll = char.textContent; break; case "Distance": explosion.distance = char.textContent; break; case "Mortal Wounds": explosion.mortalWounds = char.textContent; break; } } } return explosion; } function ParsePsykerProfile(profile) { const psyker = new Psyker(); ExpandBaseNotes(profile, psyker); const chars = profile.querySelectorAll("characteristics>characteristic"); for (const char of chars) { const charName = char.getAttribute("name"); if (charName && char.textContent) { switch (charName) { case "Cast": psyker.cast = char.textContent; break; case "Deny": psyker.deny = char.textContent; break; case "Powers Known": psyker.powers = char.textContent; break; case "Other": psyker.other = char.textContent; break; } } } return psyker; } function CompareObj(a, b) { return Compare(a.name, b.name); } function CompareModel(a, b) { if (a.name === b.name) { return Compare(a.nameAndGear(), b.nameAndGear()); } else if (a.name === "Unit Upgrades") { // "Unit Upgrades", a special model name, is always sorted last. return 1; } else if (b.name === "Unit Upgrades") { // "Unit Upgrades", a special model name, is always sorted last. return -1; } else { return Compare(a.name, b.name); } } export function CompareWeapon(a, b) { const aType = a.type.startsWith("Grenade") ? 2 : a.type.startsWith("Melee") ? 1 : 0; const bType = b.type.startsWith("Grenade") ? 2 : b.type.startsWith("Melee") ? 1 : 0; return aType - bType || a.name.localeCompare(b.name); } export function Compare(a, b) { if (a > b) return 1; else if (a == b) return 0; return -1; }