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;
+}