diff --git a/src/10th/Roster.jsx b/src/10th/Roster.jsx index 0336433..05b7bcb 100644 --- a/src/10th/Roster.jsx +++ b/src/10th/Roster.jsx @@ -822,7 +822,7 @@ const ModelStats = ({ modelStat, index, showName, abilities }) => { wounds = "/"; } - const hasInvul = checkAbilitiesForInvul(abilities, name); + const hasInvul = modelStat.invulnerableSave || checkAbilitiesForInvul(abilities, name); return (
diff --git a/src/App.jsx b/src/App.jsx index 20a3eb0..617d59d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,7 @@ import { Roster } from "./9th/Roster"; import { Roster as Roster10th } from "./10th/Roster"; import { useLocalStorage } from "./helpers/useLocalStorage"; import { parseJSON, stringifyJSON } from "./helpers/json"; +import { Create40kRoster11th } from "./roster40k-11th"; const throttle = (func, limit) => { let lastFunc; @@ -170,7 +171,7 @@ function App() { setError(""); } } else if (gameType == "Warhammer 40,000 11th Edition") { - roster = Create40kRoster10th(doc, gameType); + roster = Create40kRoster11th(doc, gameType); if (roster && roster.forces.length > 0) { setRoster(roster); setEdition(11); diff --git a/src/roster40k-10th.js b/src/roster40k-10th.js index ae2cf97..21f2ea0 100644 --- a/src/roster40k-10th.js +++ b/src/roster40k-10th.js @@ -101,6 +101,7 @@ export class Model extends BaseNotes { attacks = ""; leadership = 7; save = ""; + invulnerableSave = ""; rangedWeapons = []; meleeWeapons = []; @@ -870,37 +871,43 @@ function ParseModelStatsProfiles(profiles, unit, unitName) { const charName = char.getAttribute("name"); if (!charName) continue; - if (char.textContent) { + const charValue = char.textContent?.trim(); + if (charValue) { switch (charName) { case "M": - model.move = char.textContent; + model.move = charValue; break; case "WS": - model.ws = char.textContent; + model.ws = charValue; break; case "BS": - model.bs = char.textContent; + model.bs = charValue; break; case "S": - model.str = +char.textContent; + model.str = +charValue; break; case "T": - model.toughness = +char.textContent; + model.toughness = +charValue; break; case "W": - model.wounds = +char.textContent; + model.wounds = +charValue; break; case "A": - model.attacks = char.textContent; + model.attacks = charValue; break; case "LD": - model.leadership = char.textContent; + model.leadership = charValue; break; case "SV": - model.save = char.textContent; + case "Sv": + model.save = charValue; + break; + case "InSv": + case "Invulnerable Save": + if (charValue !== "-") model.invulnerableSave = charValue; break; case "OC": - model.oc = char.textContent; + model.oc = charValue; break; } } diff --git a/src/roster40k-11th.js b/src/roster40k-11th.js new file mode 100644 index 0000000..cd598ae --- /dev/null +++ b/src/roster40k-11th.js @@ -0,0 +1,1169 @@ +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 = ""; + invulnerableSave = ""; + + 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 Create40kRoster11th(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; + + const charValue = char.textContent?.trim(); + if (charValue) { + switch (charName) { + case "M": + model.move = charValue; + break; + case "WS": + model.ws = charValue; + break; + case "BS": + model.bs = charValue; + break; + case "S": + model.str = +charValue; + break; + case "T": + model.toughness = +charValue; + break; + case "W": + model.wounds = +charValue; + break; + case "A": + model.attacks = charValue; + break; + case "LD": + model.leadership = charValue; + break; + case "SV": + case "Sv": + model.save = charValue; + break; + case "InSv": + case "Invulnerable Save": + if (charValue !== "-") model.invulnerableSave = charValue; + break; + case "OC": + model.oc = charValue; + 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; +}