1110 lines
41 KiB
JavaScript
1110 lines
41 KiB
JavaScript
const MODULE_ID = "sla-mothership-compendium";
|
|
const SYSTEM_ID = "sla-mothership";
|
|
const ROOT_FOLDER_NAME = "SLA Mothership";
|
|
|
|
const DEFAULT_ICONS = {
|
|
item: "icons/svg/item-bag.svg",
|
|
ammo: "icons/weapons/ammunition/shot-round.webp",
|
|
drug: "icons/consumables/potions/potion-round-corked-red.webp",
|
|
weapon: "icons/svg/sword.svg",
|
|
armor: "icons/svg/shield.svg",
|
|
ability: "icons/svg/explosion.svg",
|
|
skill: "icons/svg/book.svg",
|
|
species: "icons/svg/mystery-man.svg",
|
|
package: "icons/svg/d20.svg",
|
|
condition: "icons/svg/aura.svg"
|
|
};
|
|
|
|
const FOLDER_SPECS = [
|
|
["skills", "Skills", "#235f80"],
|
|
["weapons", "Weapons", "#7b2525"],
|
|
["armour", "Armour", "#4c5f78"],
|
|
["ammo", "Ammunition", "#6b4f2f"],
|
|
["drugs", "Drugs", "#6b4f3f"],
|
|
["gear", "Gear", "#3f6b56"],
|
|
["conditions", "Conditions", "#6d5a2f"],
|
|
["species", "Species", "#5f4d7b"],
|
|
["packages", "Training Packages", "#7b6a2d"],
|
|
["ebb", "Ebb Abilities", "#5c2470"]
|
|
];
|
|
|
|
const AMMO_CALIBRES = [
|
|
{ key: "3mm", label: "3mm", cost: 1, group: "Handgun", typical: "FEN 603, machine pistols" },
|
|
{ key: "5mm", label: "5mm", cost: 2, group: "Handgun", typical: "Compact pistols, light SMGs" },
|
|
{ key: "8mm", label: "8mm", cost: 4, group: "Handgun", typical: "Heavy pistols (GA 50 class)" },
|
|
{ key: "10mm-pistol", label: "10mm (pistol)", cost: 5, group: "Handgun", typical: "Heavy autos, SMG sidearms" },
|
|
{ key: "12mm", label: "12mm", cost: 8, group: "Handgun", typical: "Magnum pistols, hand cannons" },
|
|
{ key: "3mm-smg", label: "3mm SMG", cost: 1, group: "SMG / PDW", typical: "Micro-SMGs, machine pistols" },
|
|
{ key: "5mm-smg", label: "5mm SMG", cost: 2, group: "SMG / PDW", typical: "FEN 204 light loads" },
|
|
{ key: "10mm-smg", label: "10mm SMG", cost: 5, group: "SMG / PDW", typical: "Heavy SMGs" },
|
|
{ key: "5mm-rifle", label: "5mm rifle", cost: 3, group: "Rifle / Carbine", typical: "Light carbines, PDWs" },
|
|
{ key: "7mm-rifle", label: "7mm rifle", cost: 6, group: "Rifle / Carbine", typical: "FEN AR-class rifles" },
|
|
{ key: "8mm-rifle", label: "8mm rifle", cost: 8, group: "Rifle / Carbine", typical: "Battle rifles, DMRs, sniper" },
|
|
{ key: "10mm-rifle", label: "10mm rifle", cost: 12, group: "Rifle / Carbine", typical: "Heavy battle rifles" },
|
|
{ key: "12-gauge", label: "12-gauge", cost: 4, group: "Shotgun", typical: "Combat buck/slug shells" },
|
|
{ key: "10-gauge", label: "10-gauge", cost: 6, group: "Shotgun", typical: "Heavy buck/slug shells" },
|
|
{ key: "12-gauge-he", label: "12-gauge HE", cost: 10, group: "Shotgun", typical: "HE shotgun shells" },
|
|
{ key: "10-gauge-he", label: "10-gauge HE", cost: 14, group: "Shotgun", typical: "Heavy HE shells" },
|
|
{ key: "20mm-hedp", label: "20mm HEDP", cost: 60, group: "Heavy / Launcher", typical: "Autocannons, heavy rifles" },
|
|
{ key: "25mm-he", label: "25mm HE", cost: 80, group: "Heavy / Launcher", typical: "Autocannons, vehicle weapons" },
|
|
{ key: "30mm-heat", label: "30mm HEAT", cost: 120, group: "Heavy / Launcher", typical: "Vehicle armour-killer rounds" },
|
|
{ key: "40mm-he-grenade", label: "40mm HE grenade", cost: 40, group: "Heavy / Launcher", typical: "MILA / underslung HE" },
|
|
{ key: "40mm-smoke-gas", label: "40mm smoke / gas", cost: 25, group: "Heavy / Launcher", typical: "Obscurant and riot control" }
|
|
];
|
|
|
|
const AMMO_TAGS = [
|
|
{ key: "STD", label: "STD", multiplier: 1, summary: "Standard load." },
|
|
{ key: "AP", label: "AP", multiplier: 2, summary: "Armour-piercing load." },
|
|
{ key: "HE", label: "HE", multiplier: 3, summary: "High explosive load." },
|
|
{ key: "HEAP", label: "HEAP", multiplier: 4, summary: "High explosive armour-piercing load." }
|
|
];
|
|
|
|
const SLA_PANIC_ENTRIES = [
|
|
{ title: "Ad Spike", effect: "Your pulse kicks hard. Suffer -5 on the next roll unless an ally grounds you or you take cover." },
|
|
{ title: "Tunnel Vision", effect: "You fixate on the loudest threat. Your next action must address it or suffer Disadvantage." },
|
|
{ title: "Corporate Flinch", effect: "Training and dread collide. Lose your reaction until the start of your next turn." },
|
|
{ title: "Broadcast Slip", effect: "You mutter, shout, or open comms at the wrong moment. Nearby hostiles or listeners get your position." },
|
|
{ title: "Shakes", effect: "Hands go unreliable. Physical or firearm rolls are at -10 until the end of the next round." },
|
|
{ title: "Freeze", effect: "You lock up for a beat. Lose your next half-action or equivalent moment of tempo." },
|
|
{ title: "Bad Read", effect: "You misjudge the situation. The GM gives one immediate false assumption that feels true until contradicted." },
|
|
{ title: "Overcompensate", effect: "You move too hard or too fast. End up exposed, off-line, or out of formation." },
|
|
{ title: "Stress Leak", effect: "Gain 1d5 Stress as the scene starts to get inside your head." },
|
|
{ title: "Discipline Break", effect: "Your next roll is at Disadvantage unless a squad leader, handler, or ally snaps you back into line." },
|
|
{ title: "Wild Action", effect: "You fire, strike, run, or blurt before thinking. The GM decides the most dangerous impulsive version of your intent." },
|
|
{ title: "Collateral Opening", effect: "You create a gap in the line. An ally loses cover or a hostile gains an immediate opportunity." },
|
|
{ title: "Gear Fumble", effect: "Drop, jam, or misplace a critical piece of kit. Spend an action to recover it if possible." },
|
|
{ title: "Crack in Persona", effect: "The mask slips. Any social standing, media poise, or intimidation you were leaning on collapses for the scene." },
|
|
{ title: "Short Spiral", effect: "Gain 1d10 Stress and immediately make a Fear Save. On a failure, escalate one result higher on this table." },
|
|
{ title: "Target Blindness", effect: "You cannot willingly approach the source of panic next round without a successful Fear Save." },
|
|
{ title: "Break and Run", effect: "You bolt for the nearest hard cover, exit, or authority figure and cannot attack until you recover." },
|
|
{ title: "Friendly Threat", effect: "Everyone nearby reads you as unstable. Allies in close range take -5 on their next roll unless they disengage from you." },
|
|
{ title: "Flux-Sick Collapse", effect: "Drop prone or to one knee and lose your next full action. Ebb users must also make a Fear / Flux Save." },
|
|
{ title: "Sector Nine", effect: "You fully lose operational discipline. The GM takes your next action, then you gain 1 wound or 2d10 Stress, whichever fits the scene better." }
|
|
];
|
|
|
|
let cachedData = null;
|
|
|
|
Hooks.once("init", () => {
|
|
game.slaMothershipCompendium = {
|
|
seedWorld: (options = {}) => seedWorld(options),
|
|
setupWorld: (options = {}) => ensureWorldSetup(options)
|
|
};
|
|
|
|
game.settings.register(MODULE_ID, "autoSeedWorld", {
|
|
name: "SlaMothershipCompendium.AutoSeed",
|
|
hint: "SlaMothershipCompendium.AutoSeedHint",
|
|
scope: "world",
|
|
config: true,
|
|
type: Boolean,
|
|
default: true
|
|
});
|
|
|
|
game.settings.register(MODULE_ID, "seededVersion", {
|
|
scope: "world",
|
|
config: false,
|
|
type: String,
|
|
default: ""
|
|
});
|
|
|
|
game.settings.register(MODULE_ID, "setupVersion", {
|
|
scope: "world",
|
|
config: false,
|
|
type: String,
|
|
default: ""
|
|
});
|
|
});
|
|
|
|
Hooks.once("ready", async () => {
|
|
if (game.system.id !== SYSTEM_ID || !game.user?.isGM) return;
|
|
if (!game.settings.get(MODULE_ID, "autoSeedWorld")) return;
|
|
|
|
const moduleVersion = game.modules.get(MODULE_ID)?.version ?? "0.0.0";
|
|
const seededVersion = game.settings.get(MODULE_ID, "seededVersion");
|
|
const setupVersion = game.settings.get(MODULE_ID, "setupVersion");
|
|
if (seededVersion === moduleVersion && setupVersion === moduleVersion) return;
|
|
|
|
try {
|
|
if (seededVersion !== moduleVersion) {
|
|
await seedWorld({ overwrite: false, notify: true });
|
|
await game.settings.set(MODULE_ID, "seededVersion", moduleVersion);
|
|
}
|
|
if (setupVersion !== moduleVersion) {
|
|
await ensureWorldSetup({ overwrite: false, notify: true });
|
|
await game.settings.set(MODULE_ID, "setupVersion", moduleVersion);
|
|
}
|
|
} catch (err) {
|
|
console.error(`${MODULE_ID} | Auto-seed failed`, err);
|
|
}
|
|
});
|
|
|
|
async function seedWorld({ overwrite = false, notify = true } = {}) {
|
|
if (game.system.id !== SYSTEM_ID) {
|
|
ui.notifications.warn("SLA Mothership Companion can only seed SLA Mothership worlds.");
|
|
return { ok: false, reason: "wrong-system" };
|
|
}
|
|
if (!game.user?.isGM) {
|
|
ui.notifications.warn("Only a GM can seed SLA Mothership content.");
|
|
return { ok: false, reason: "not-gm" };
|
|
}
|
|
|
|
const data = await loadData();
|
|
const folders = await ensureFolders();
|
|
const existing = buildExistingIndex();
|
|
const summary = { created: 0, updated: 0, skipped: 0 };
|
|
|
|
const payloads = [
|
|
...data.skills.skills.map((entry) => buildSkill(entry, folders.skills, data.iconMap.skills)),
|
|
...data.species.species.map((entry) => buildSpecies(entry, folders.species, data.iconMap.species)),
|
|
...data.packages.packages.map((entry) => buildTrainingPackage(entry, folders.packages, data.iconMap.ebb)),
|
|
...data.equipment.weapons.map((entry) => buildWeapon(entry, folders.weapons, data.iconMap.weapons)),
|
|
...data.equipment.armour.map((entry) => buildArmour(entry, folders.armour, data.iconMap.armor)),
|
|
...AMMO_CALIBRES.flatMap((entry) => AMMO_TAGS.map((tag) => buildAmmoGear(entry, tag, folders.ammo))),
|
|
...data.drugs.drugs.map((entry) => buildDrug(entry, folders.drugs)),
|
|
...flattenGeneralEquipment(data.generalEquipment).map((entry) => buildGear(entry, folders.gear, data.iconMap.gear)),
|
|
...data.conditions.conditions.map((entry) => buildCondition(entry, folders.conditions)),
|
|
...data.ebb.abilities.map((entry) => buildEbbAbility(entry, folders.ebb, data.iconMap.ebb))
|
|
];
|
|
|
|
for (const payload of payloads) {
|
|
const existingDoc = existing.get(payload.flags?.[SYSTEM_ID]?.slaSeed?.key) ?? null;
|
|
if (!existingDoc) {
|
|
await Item.create(payload);
|
|
summary.created += 1;
|
|
continue;
|
|
}
|
|
if (!overwrite) {
|
|
summary.skipped += 1;
|
|
continue;
|
|
}
|
|
await existingDoc.update({
|
|
name: payload.name,
|
|
type: payload.type,
|
|
img: payload.img,
|
|
folder: payload.folder,
|
|
system: payload.system,
|
|
[`flags.${SYSTEM_ID}.slaSeed`]: payload.flags?.[SYSTEM_ID]?.slaSeed ?? {}
|
|
});
|
|
summary.updated += 1;
|
|
}
|
|
|
|
if (notify) {
|
|
ui.notifications.info(
|
|
`SLA Mothership seeded: ${summary.created} created, ${summary.updated} updated, ${summary.skipped} unchanged.`
|
|
);
|
|
}
|
|
return { ok: true, summary };
|
|
}
|
|
|
|
async function ensureWorldSetup({ overwrite = false, notify = true } = {}) {
|
|
if (game.system.id !== SYSTEM_ID || !game.user?.isGM) {
|
|
return { ok: false, reason: "unavailable" };
|
|
}
|
|
|
|
const tableSummary = await ensureSlaPanicTables({ overwrite });
|
|
const macroSummary = await ensureMacros({ overwrite });
|
|
const squadSummary = game.slaMothership?.createStarterSquad
|
|
? await game.slaMothership.createStarterSquad({ overwrite, notify: false })
|
|
: { created: 0, updated: 0 };
|
|
|
|
if (notify) {
|
|
ui.notifications.info(
|
|
`SLA Mothership tools ready: ${tableSummary.created + tableSummary.updated} panic tables prepared, ${macroSummary.created + macroSummary.updated} macros prepared, ${squadSummary.created + squadSummary.updated} starter operatives prepared.`
|
|
);
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
tables: tableSummary,
|
|
macros: macroSummary,
|
|
squad: squadSummary
|
|
};
|
|
}
|
|
|
|
async function loadData() {
|
|
if (cachedData) return cachedData;
|
|
|
|
const readJson = async (name) => {
|
|
const response = await fetch(`modules/${MODULE_ID}/sla-data/${name}`);
|
|
if (!response.ok) throw new Error(`Failed loading ${name}`);
|
|
return response.json();
|
|
};
|
|
|
|
cachedData = {
|
|
skills: await readJson("skills.json"),
|
|
species: await readJson("species.json"),
|
|
packages: await readJson("training-packages.json"),
|
|
equipment: await readJson("equipment.json"),
|
|
drugs: await readJson("drugs.json"),
|
|
generalEquipment: await readJson("general-equipment.json"),
|
|
conditions: await readJson("conditions.json"),
|
|
ebb: await readJson("ebb-abilities.json"),
|
|
iconMap: await readJson("icon-map.json")
|
|
};
|
|
|
|
return cachedData;
|
|
}
|
|
|
|
async function ensureFolders() {
|
|
let root = game.folders.find((folder) => folder.type === "Item" && folder.name === ROOT_FOLDER_NAME && !folder.folder);
|
|
if (!root) {
|
|
root = await Folder.create({ name: ROOT_FOLDER_NAME, type: "Item", color: "#163447" });
|
|
}
|
|
|
|
const folderMap = {};
|
|
for (const [key, label, color] of FOLDER_SPECS) {
|
|
let folder = game.folders.find((entry) => entry.type === "Item" && entry.name === label && entry.folder?.id === root.id);
|
|
if (!folder) {
|
|
folder = await Folder.create({ name: label, type: "Item", folder: root.id, color });
|
|
}
|
|
folderMap[key] = folder;
|
|
}
|
|
return folderMap;
|
|
}
|
|
|
|
function buildExistingIndex() {
|
|
const map = new Map();
|
|
for (const item of game.items ?? []) {
|
|
const key = item.flags?.[SYSTEM_ID]?.slaSeed?.key;
|
|
if (key) map.set(String(key), item);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
async function ensureMacros({ overwrite = false } = {}) {
|
|
let folder = game.folders.find((entry) => entry.type === "Macro" && entry.name === "SLA Mothership Tools" && !entry.folder);
|
|
if (!folder) {
|
|
folder = await Folder.create({ name: "SLA Mothership Tools", type: "Macro", color: "#163447" });
|
|
}
|
|
|
|
const macros = [
|
|
{
|
|
name: "SLA World Tools",
|
|
img: "icons/tools/hand/wrench-blue.webp",
|
|
command: "game.slaMothership.openTools();"
|
|
},
|
|
{
|
|
name: "Generate Random Operative",
|
|
img: "icons/svg/dice-target.svg",
|
|
command: "game.slaMothership.createRandomOperative();"
|
|
},
|
|
{
|
|
name: "Fear / Flux Save",
|
|
img: "icons/svg/d10-grey.svg",
|
|
command: "game.user.character?.triggerSlaFluxPanic({ source: 'Macro Flux control check', panicOnFailure: false });"
|
|
},
|
|
{
|
|
name: "Gain 1 Flux",
|
|
img: "icons/magic/control/debuff-energy-hold-pink.webp",
|
|
command: "game.user.character?.update({'system.sla.flux.value': Number(game.user.character.system.sla.flux.value || 0) + 1}); game.user.character?.updateSlaFluxState();"
|
|
},
|
|
{
|
|
name: "Flux Panic",
|
|
img: "icons/svg/terror.svg",
|
|
command: "game.user.character?.triggerSlaFluxPanic({ source: 'Macro Flux panic', panicOnFailure: true, forcePanic: true });"
|
|
},
|
|
{
|
|
name: "Lose 1 Flux",
|
|
img: "icons/magic/control/debuff-energy-hold-teal.webp",
|
|
command: "game.user.character?.update({'system.sla.flux.value': Math.max(0, Number(game.user.character.system.sla.flux.value || 0) - 1)}); game.user.character?.updateSlaFluxState();"
|
|
}
|
|
];
|
|
|
|
let created = 0;
|
|
let updated = 0;
|
|
|
|
for (const spec of macros) {
|
|
const existing = game.macros.find((macro) => macro.folder?.id === folder.id && macro.name === spec.name);
|
|
const payload = {
|
|
name: spec.name,
|
|
type: "script",
|
|
scope: "global",
|
|
img: spec.img,
|
|
command: spec.command,
|
|
folder: folder.id,
|
|
flags: {
|
|
[SYSTEM_ID]: {
|
|
slaMacro: true
|
|
}
|
|
}
|
|
};
|
|
|
|
if (!existing) {
|
|
await Macro.create(payload);
|
|
created += 1;
|
|
continue;
|
|
}
|
|
if (!overwrite) continue;
|
|
await existing.update(payload);
|
|
updated += 1;
|
|
}
|
|
|
|
return { created, updated };
|
|
}
|
|
|
|
function buildSkill(entry, folder, iconMap) {
|
|
const key = `skill:${slug(entry.name)}`;
|
|
const ebbOnly = ["Biofeedback", "Formulate", "Ebb (Core)"].includes(entry.name) || String(entry.name ?? "").startsWith("Ebb ");
|
|
const attributeTags = Array.isArray(entry.attributes) ? entry.attributes : [];
|
|
return {
|
|
name: entry.name,
|
|
type: "skill",
|
|
img: resolveIcon(entry.name, iconMap, DEFAULT_ICONS.skill),
|
|
folder: folder.id,
|
|
system: {
|
|
description: `
|
|
<p><strong>Category:</strong> ${entry.categoryRef ?? "General"}</p>
|
|
<p><strong>SLA BRP base:</strong> ${entry.base ?? 0}%.</p>
|
|
${attributeTags.length ? `<p><strong>Stat use:</strong> ${attributeTags.join(", ")}.</p>` : ""}
|
|
${entry.combat ? "<p><strong>Use:</strong> Combat-linked skill. Roll it when the attack, defence, or violent action maps to this discipline.</p>" : ""}
|
|
${ebbOnly ? "<p><strong>Restriction:</strong> Ebb-only discipline. Remove it from non-Ebon and non-Brain Waster operatives.</p>" : ""}
|
|
${entry.notes ? entry.notes.split(/\n+/).map(line => `<p>${line.trim()}</p>`).join("") : ""}
|
|
`,
|
|
rank: "SLA",
|
|
bonus: Number(entry.base ?? 0),
|
|
prerequisite_ids: [],
|
|
sla: {
|
|
category: entry.categoryRef ?? "",
|
|
skillFamily: entry.categoryRef ?? "",
|
|
baseBonus: Number(entry.base ?? 0),
|
|
combat: Boolean(entry.combat),
|
|
attributes: Array.isArray(entry.attributes) ? entry.attributes : [],
|
|
ebbOnly,
|
|
source: "SLA skill import"
|
|
}
|
|
},
|
|
flags: { [SYSTEM_ID]: { slaSeed: { key, kind: "skill" } } }
|
|
};
|
|
}
|
|
|
|
function buildSpecies(entry, folder, iconMap) {
|
|
const key = `species:${slug(entry.name)}`;
|
|
const bonuses = (entry.skillBonuses ?? [])
|
|
.map((bonus) => `<li>${bonus.skillRef}: +${bonus.bonus}%</li>`)
|
|
.join("");
|
|
const choices = (entry.choiceBonuses ?? [])
|
|
.map((bonus) => `<li>Choose ${bonus.count} skills for +${bonus.bonus}% each.</li>`)
|
|
.join("");
|
|
const notes = (entry.notes ?? []).map((note) => `<li>${note}</li>`).join("");
|
|
const specialRules = (entry.specialRules ?? [])
|
|
.map((rule) => `<li><strong>${rule.label}</strong>: ${rule.summary}</li>`)
|
|
.join("");
|
|
|
|
return {
|
|
name: entry.name,
|
|
type: "item",
|
|
img: resolveIcon(entry.name, iconMap, DEFAULT_ICONS.species),
|
|
folder: folder.id,
|
|
system: {
|
|
quantity: 1,
|
|
weight: 0,
|
|
cost: 0,
|
|
description: `
|
|
<p><strong>Starter package:</strong> ${entry.starterPackage ?? "None"}</p>
|
|
<p><strong>Starting SCL:</strong> ${entry.startingScl ?? "N/A"}</p>
|
|
${entry.identity ? `<p><strong>Identity:</strong> ${entry.identity}</p>` : ""}
|
|
${entry.fieldPressure ? `<p><strong>Field Pressure:</strong> ${entry.fieldPressure}</p>` : ""}
|
|
${entry.mothershipDescription ? `<p><strong>Mothership Template:</strong> ${entry.mothershipDescription}</p>` : ""}
|
|
${renderMothershipProfile(entry.mothershipStats)}
|
|
${bonuses ? `<p><strong>Skill bonuses</strong></p><ul>${bonuses}</ul>` : ""}
|
|
${choices ? `<p><strong>Choice bonuses</strong></p><ul>${choices}</ul>` : ""}
|
|
${specialRules ? `<p><strong>Special Rules</strong></p><ul>${specialRules}</ul>` : ""}
|
|
${notes ? `<p><strong>Notes</strong></p><ul>${notes}</ul>` : ""}
|
|
`,
|
|
sla: {
|
|
category: "Species",
|
|
power: "",
|
|
manufacturer: "",
|
|
rarity: "",
|
|
source: "SLA species import"
|
|
}
|
|
},
|
|
flags: { [SYSTEM_ID]: { slaSeed: { key, kind: "species" } } }
|
|
};
|
|
}
|
|
|
|
function buildTrainingPackage(entry, folder, iconMap) {
|
|
const key = `package:${slug(entry.name)}`;
|
|
const skills = (entry.skills ?? []).map((skill) => `<li>${skill}</li>`).join("");
|
|
return {
|
|
name: entry.name,
|
|
type: "item",
|
|
img: resolveIcon(entry.name, iconMap, DEFAULT_ICONS.package),
|
|
folder: folder.id,
|
|
system: {
|
|
quantity: 1,
|
|
weight: 0,
|
|
cost: 0,
|
|
description: `
|
|
<p><strong>Wealth:</strong> ${entry.wealth ?? "Varies"}</p>
|
|
<p><strong>Skill package</strong></p>
|
|
<ul>${skills}</ul>
|
|
`,
|
|
sla: {
|
|
category: "Training Package",
|
|
power: "",
|
|
manufacturer: "",
|
|
rarity: "",
|
|
source: "SLA training package import"
|
|
}
|
|
},
|
|
flags: { [SYSTEM_ID]: { slaSeed: { key, kind: "package" } } }
|
|
};
|
|
}
|
|
|
|
function buildWeaponFireModes(entry) {
|
|
const shotsPerFire = Math.max(1, Number(entry.rof ?? 1) || 1);
|
|
const special = String(entry.special ?? "").toLowerCase();
|
|
const rofText = String(entry.rofText ?? "").toLowerCase();
|
|
const modes = deriveWeaponModeDefaults(shotsPerFire, special, rofText);
|
|
|
|
const uniqueModes = modes.filter((mode, index, list) =>
|
|
list.findIndex((entry) => entry.label === mode.label && entry.shots === mode.shots) === index
|
|
);
|
|
|
|
return {
|
|
fireModes: uniqueModes.map((mode) => `${mode.label}:${mode.shots}`).join(", "),
|
|
currentFireMode: uniqueModes[0]?.label ?? "Single",
|
|
shotsPerFire: uniqueModes[0]?.shots ?? 1
|
|
};
|
|
}
|
|
|
|
function deriveAmmoCalibre(entry) {
|
|
const text = `${entry.name ?? ""} ${entry.ammoText ?? ""} ${(entry.notes ?? []).join(" ")}`.toLowerCase();
|
|
const rules = [
|
|
[/fen\s*603\s*heavy|heavy pistol|ga\s*50|finisher/, "8mm"],
|
|
[/fen\s*603|auto[\s-]*pistol/, "3mm"],
|
|
[/shiver pistol|fen\s*401/, "5mm"],
|
|
[/gunhead|fen\s*204/, "5mm-smg"],
|
|
[/fen\s*209|machine pistol/, "3mm-smg"],
|
|
[/fen\s*ar|assault rifle/, "7mm-rifle"],
|
|
[/sniper|fen\s*981/, "8mm-rifle"],
|
|
[/urban carbine|fen\s*701|carbine/, "5mm-rifle"],
|
|
[/street sweeper|auto-shotgun|kps|mangler|shotgun/, "12-gauge"],
|
|
[/reaper|lmg/, "7mm-rifle"],
|
|
[/mila|40mm/, "40mm-he-grenade"]
|
|
];
|
|
|
|
for (const [rule, key] of rules) {
|
|
if (rule.test(text)) {
|
|
return AMMO_CALIBRES.find((entry) => entry.key === key) ?? { label: "Generic", cost: 1 };
|
|
}
|
|
}
|
|
|
|
return { label: "Generic", cost: 1 };
|
|
}
|
|
|
|
function buildAmmoIconPath(calibreKey, tagKey) {
|
|
return `systems/sla-mothership/images/icons/sla-ammo/${calibreKey}__${tagKey}.png`;
|
|
}
|
|
|
|
function buildAmmoGear(entry, tag, folder) {
|
|
const isStd = tag.key === "STD";
|
|
const key = isStd ? `ammo:${slug(entry.label)}` : `ammo:${slug(entry.label)}:${tag.key.toLowerCase()}`;
|
|
const roundCost = Math.round(entry.cost * Number(tag.multiplier ?? 1));
|
|
return {
|
|
name: `Ammo: ${entry.label} [${tag.label}]`,
|
|
type: "item",
|
|
img: buildAmmoIconPath(entry.key, tag.key),
|
|
folder: folder.id,
|
|
system: {
|
|
quantity: 1,
|
|
weight: 0,
|
|
cost: roundCost,
|
|
description: `
|
|
<p><strong>Calibre:</strong> ${entry.label}</p>
|
|
<p><strong>Group:</strong> ${entry.group}</p>
|
|
<p><strong>Ammo type:</strong> ${tag.label}</p>
|
|
<p><strong>Cost per round:</strong> ${roundCost} cR</p>
|
|
<p><strong>Typical use:</strong> ${entry.typical}</p>
|
|
<p>${tag.summary} This item uses the SLA BRP ammo cost model for Mothership.</p>
|
|
`,
|
|
sla: {
|
|
category: "Ammunition",
|
|
calibre: entry.label,
|
|
ammoTag: tag.key,
|
|
source: "SLA ammo catalogue import"
|
|
}
|
|
},
|
|
flags: { [SYSTEM_ID]: { slaSeed: { key, kind: "ammo" } } }
|
|
};
|
|
}
|
|
|
|
function buildWeapon(entry, folder, iconMap) {
|
|
const key = `weapon:${slug(entry.name)}`;
|
|
const shots = Math.max(0, Number(entry.ammo ?? 0) || 0);
|
|
const useAmmo = shots > 0;
|
|
const calibre = deriveAmmoCalibre(entry);
|
|
const reserveStd = Math.max(0, Number(entry.ammoReserveStd ?? (useAmmo ? shots * 3 : 0)) || 0);
|
|
const reserveAp = Math.max(0, Number(entry.ammoReserveAp ?? 0) || 0);
|
|
const reserveHe = Math.max(0, Number(entry.ammoReserveHe ?? 0) || 0);
|
|
const reserveHeap = Math.max(0, Number(entry.ammoReserveHeap ?? 0) || 0);
|
|
const reserveAmmo = reserveStd + reserveAp + reserveHe + reserveHeap;
|
|
const fireModeConfig = buildWeaponFireModes(entry);
|
|
return {
|
|
name: entry.name,
|
|
type: "weapon",
|
|
img: resolveIcon(entry.name, iconMap, DEFAULT_ICONS.weapon),
|
|
folder: folder.id,
|
|
system: {
|
|
antiArmor: /ap|heap/i.test(`${entry.damageType ?? ""} ${entry.special ?? ""}`),
|
|
damage: toDice(entry.damage),
|
|
ammo: reserveAmmo,
|
|
shots: Math.max(shots, 1),
|
|
curShots: shots,
|
|
shotsPerFire: fireModeConfig.shotsPerFire,
|
|
useAmmo,
|
|
ammoType: useAmmo ? calibre.label : "",
|
|
ammoCalibre: useAmmo ? calibre.label : "",
|
|
ammoBaseCost: useAmmo ? calibre.cost : 0,
|
|
ammoTag: entry.ammoTag ?? "STD",
|
|
ammoLoadedType: entry.ammoLoadedType ?? entry.ammoTag ?? "STD",
|
|
ammoAllowStd: true,
|
|
ammoAllowAp: true,
|
|
ammoAllowHe: true,
|
|
ammoAllowHeap: true,
|
|
ammoReserve: reserveAmmo,
|
|
ammoReserveStd: reserveStd,
|
|
ammoReserveAp: reserveAp,
|
|
ammoReserveHe: reserveHe,
|
|
ammoReserveHeap: reserveHeap,
|
|
wound: "",
|
|
critDmg: "",
|
|
woundEffect: "",
|
|
bonus: 0,
|
|
weight: encToKg(entry.enc),
|
|
carryState: "equipped",
|
|
equipped: true,
|
|
cost: priceBand(entry.price),
|
|
ranges: {
|
|
short: 0,
|
|
medium: 0,
|
|
long: 0,
|
|
value: entry.range ?? entry.reach ?? ""
|
|
},
|
|
settings: {
|
|
firstEdition: true
|
|
},
|
|
description: `
|
|
<p><strong>Skill:</strong> ${entry.skillRef ?? "Combat"}${entry.skillRefAlt ? ` / ${entry.skillRefAlt}` : ""}</p>
|
|
<p><strong>Type:</strong> ${entry.weaponType ?? "Weapon"} | <strong>Damage type:</strong> ${entry.damageType ?? "standard"}</p>
|
|
<p><strong>Special:</strong> ${entry.special ?? "None"}</p>
|
|
${renderList("Notes", entry.notes ?? [])}
|
|
`,
|
|
sla: {
|
|
weaponType: entry.weaponType ?? "",
|
|
skillRef: entry.skillRef ?? "",
|
|
skillRefAlt: entry.skillRefAlt ?? "",
|
|
damageType: entry.damageType ?? "",
|
|
fireModes: fireModeConfig.fireModes,
|
|
currentFireMode: fireModeConfig.currentFireMode,
|
|
hands: entry.hands ?? "",
|
|
reach: entry.reach ?? "",
|
|
special: entry.special ?? "",
|
|
rofText: entry.rofText ?? "",
|
|
enc: Number(entry.enc ?? 0) || 0,
|
|
price: entry.price ?? "",
|
|
notes: entry.notes ?? [],
|
|
damageMultiplier: Number(entry.damageMultiplier ?? 1) || 1
|
|
}
|
|
},
|
|
flags: { [SYSTEM_ID]: { slaSeed: { key, kind: "weapon" } } }
|
|
};
|
|
}
|
|
|
|
function buildArmour(entry, folder, iconMap) {
|
|
const key = `armour:${slug(entry.name)}`;
|
|
const armorPoints = Math.max(0, Number(entry.av1 ?? 0) || 0);
|
|
const physMod = Number(entry.physmod ?? 0) || 0;
|
|
return {
|
|
name: entry.name,
|
|
type: "armor",
|
|
img: resolveIcon(entry.name, iconMap, DEFAULT_ICONS.armor),
|
|
folder: folder.id,
|
|
system: {
|
|
armorPoints,
|
|
damageReduction: Math.max(0, Math.floor(armorPoints / 2)),
|
|
speed: physMod ? `${physMod}% physical` : (entry.burden ?? ""),
|
|
oxygenMax: 0,
|
|
oxygenCurrent: 0,
|
|
weight: encToKg(entry.enc),
|
|
carryState: "equipped",
|
|
equipped: true,
|
|
cost: priceBand(entry.price),
|
|
description: `
|
|
<p><strong>Coverage:</strong> ${entry.coverage ?? "General"}</p>
|
|
<p><strong>Burden:</strong> ${entry.burden ?? "standard"}</p>
|
|
${renderList("Notes", entry.notes ?? [])}
|
|
`,
|
|
sla: {
|
|
burden: entry.burden ?? "",
|
|
coverage: entry.coverage ?? "",
|
|
physmod: physMod,
|
|
stealthmod: Number(entry.stealthmod ?? 0) || 0,
|
|
enc: Number(entry.enc ?? 0) || 0,
|
|
price: entry.price ?? "",
|
|
locationAV: entry.locationAV ?? {},
|
|
notes: entry.notes ?? []
|
|
}
|
|
},
|
|
flags: { [SYSTEM_ID]: { slaSeed: { key, kind: "armour" } } }
|
|
};
|
|
}
|
|
|
|
function buildDrug(entry, folder) {
|
|
const key = `drug:${slug(entry.id ?? entry.name)}`;
|
|
const addictionThreshold = Number(entry.addiction?.threshold ?? 0) || 0;
|
|
const notes = renderList("Notes", entry.notes ?? []);
|
|
return {
|
|
name: entry.name,
|
|
type: "drug",
|
|
img: entry.img || DEFAULT_ICONS.drug,
|
|
folder: folder.id,
|
|
system: {
|
|
quantity: 1,
|
|
weight: 0,
|
|
carryState: "carried",
|
|
cost: Number(entry.cost ?? 0) || 0,
|
|
description: `
|
|
<p><strong>Category:</strong> ${entry.category ?? "Drug"}</p>
|
|
<p><strong>Rating:</strong> ${entry.rating ?? 0} | <strong>Duration:</strong> ${entry.durationLabel ?? "Instant"}</p>
|
|
<p><strong>Active:</strong> ${entry.activeSummary ?? "See GM guidance."}</p>
|
|
<p><strong>Withdrawal:</strong> ${entry.withdrawalSummary ?? "No withdrawal profile recorded."}</p>
|
|
<p><strong>Addiction threshold:</strong> ${addictionThreshold ? `${addictionThreshold} failed Body Saves` : "None"}</p>
|
|
${notes}
|
|
`,
|
|
sla: {
|
|
category: entry.category ?? "Drug",
|
|
drugId: entry.id ?? slug(entry.name),
|
|
rating: Number(entry.rating ?? 0) || 0,
|
|
durationLabel: String(entry.durationLabel ?? ""),
|
|
addictionFactor: addictionThreshold,
|
|
source: "SLA drug import"
|
|
}
|
|
},
|
|
flags: {
|
|
[SYSTEM_ID]: {
|
|
slaSeed: { key, kind: "drug" },
|
|
slaDrug: { id: entry.id ?? slug(entry.name) }
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function buildCondition(entry, folder) {
|
|
const key = `condition:${slug(entry.name)}`;
|
|
const statMods = buildConditionStatMods(entry.statMods);
|
|
const skillMods = Array.isArray(entry.skillMods)
|
|
? entry.skillMods
|
|
.map((mod) => ({
|
|
skill: String(mod?.skill ?? "").trim(),
|
|
value: Number(mod?.value ?? 0) || 0
|
|
}))
|
|
.filter((mod) => mod.skill && mod.value)
|
|
: [];
|
|
|
|
return {
|
|
name: entry.name,
|
|
type: "condition",
|
|
img: entry.img || DEFAULT_ICONS.condition,
|
|
folder: folder.id,
|
|
system: {
|
|
severity: Math.max(0, Number(entry.severity ?? 1) || 1),
|
|
description: `
|
|
<p><strong>Category:</strong> ${entry.category ?? "Condition"}</p>
|
|
<p><strong>Effect:</strong> ${entry.effectSummary ?? entry.description ?? "Condition effect not recorded."}</p>
|
|
${entry.severityScale ? `<p><strong>Severity:</strong> ${entry.severityScale}</p>` : ""}
|
|
${entry.treatment ? `<p><strong>Treatment:</strong> ${entry.treatment}</p>` : ""}
|
|
${entry.description ? `<p>${entry.description}</p>` : ""}
|
|
`,
|
|
treatment: {
|
|
value: 0,
|
|
html: ""
|
|
},
|
|
sla: {
|
|
category: String(entry.category ?? "Condition"),
|
|
effectSummary: String(entry.effectSummary ?? "").trim(),
|
|
severityScale: String(entry.severityScale ?? "").trim(),
|
|
treatmentGuidance: String(entry.treatment ?? "").trim(),
|
|
scaleWithSeverity: entry.scaleWithSeverity !== false,
|
|
statMods,
|
|
skillMods,
|
|
source: "SLA / Mothership condition import"
|
|
}
|
|
},
|
|
flags: { [SYSTEM_ID]: { slaSeed: { key, kind: "condition" } } }
|
|
};
|
|
}
|
|
|
|
function buildGear(entry, folder, iconMap) {
|
|
const key = `gear:${slug(`${entry.categoryKey}-${entry.name}`)}`;
|
|
return {
|
|
name: entry.name,
|
|
type: "item",
|
|
img: resolveIcon(entry.name, iconMap, DEFAULT_ICONS.item),
|
|
folder: folder.id,
|
|
system: {
|
|
quantity: 1,
|
|
weight: parseNumber(entry.weight),
|
|
carryState: "carried",
|
|
cost: parseNumber(entry.cost),
|
|
description: `
|
|
<p><strong>Category:</strong> ${entry.categoryTitle}</p>
|
|
<p><strong>Power:</strong> ${entry.power ?? "N/A"}</p>
|
|
<p>${entry.description ?? ""}</p>
|
|
${renderList("Stats", entry.stats ?? [])}
|
|
<p><strong>SLA note:</strong> ${entry.brp ?? "Reference item."}</p>
|
|
`,
|
|
sla: {
|
|
category: entry.categoryTitle,
|
|
power: entry.power ?? "",
|
|
manufacturer: "",
|
|
rarity: "",
|
|
source: "SLA general equipment import"
|
|
}
|
|
},
|
|
flags: { [SYSTEM_ID]: { slaSeed: { key, kind: "gear" } } }
|
|
};
|
|
}
|
|
|
|
function buildConditionStatMods(mods = {}) {
|
|
const result = {
|
|
strength: 0,
|
|
speed: 0,
|
|
intellect: 0,
|
|
combat: 0,
|
|
sanity: 0,
|
|
fear: 0,
|
|
body: 0
|
|
};
|
|
|
|
for (const key of Object.keys(result)) {
|
|
result[key] = Number(mods?.[key] ?? 0) || 0;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function buildEbbAbility(entry, folder, iconMap) {
|
|
const key = `ebb:${slug(entry.name)}`;
|
|
const basicTier = entry.tiers?.[0] ?? {};
|
|
return {
|
|
name: entry.name,
|
|
type: "ability",
|
|
img: resolveIcon(entry.name, iconMap, DEFAULT_ICONS.ability),
|
|
folder: folder.id,
|
|
system: {
|
|
text: entry.effect ?? entry.name,
|
|
description: `
|
|
<p><strong>Skill:</strong> ${entry.skillRef ?? "Ebb"}</p>
|
|
<p><strong>Core:</strong> ${entry.coreSkillRef ?? "Ebb (Core)"}</p>
|
|
<p><strong>Range:</strong> ${entry.range ?? "Self"} | <strong>Duration:</strong> ${entry.duration ?? "Instant"}</p>
|
|
<p>${entry.effect ?? ""}</p>
|
|
${renderTierList(entry.tiers ?? [])}
|
|
${renderList("Notes", entry.notes ?? [])}
|
|
`,
|
|
roll: toDice(basicTier.damage ?? basicTier.healing ?? ""),
|
|
sla: {
|
|
abilityType: "ebb",
|
|
discipline: entry.id ?? slug(entry.name),
|
|
skillRef: entry.skillRef ?? "",
|
|
coreSkillRef: entry.coreSkillRef ?? "Ebb (Core)",
|
|
impact: entry.impact ?? "",
|
|
skillCategoryRef: entry.skillCategoryRef ?? "",
|
|
fluxCost: Number(basicTier.cost ?? entry.pppl ?? 1) || 1,
|
|
tier: basicTier.id ?? "basic",
|
|
range: entry.range ?? "",
|
|
duration: entry.duration ?? "",
|
|
attack: Boolean(entry.attack),
|
|
panicSave: "fear",
|
|
rollOnUse: true,
|
|
notes: entry.notes ?? [],
|
|
tiers: entry.tiers ?? []
|
|
}
|
|
},
|
|
flags: { [SYSTEM_ID]: { slaSeed: { key, kind: "ebb" } } }
|
|
};
|
|
}
|
|
|
|
function flattenGeneralEquipment(data) {
|
|
return (data.categories ?? []).flatMap((category) =>
|
|
(category.items ?? []).map((item) => ({
|
|
...item,
|
|
categoryKey: category.key,
|
|
categoryTitle: category.title
|
|
}))
|
|
);
|
|
}
|
|
|
|
function resolveIcon(name, iconMap, fallback) {
|
|
const key = slug(name).replace(/-/g, "");
|
|
if (iconMap?.[key]) return iconMap[key];
|
|
|
|
const aliases = {
|
|
athletics: "icons/skills/movement/feet-winged-boots-brown.webp",
|
|
brawl: "icons/skills/melee/unarmed-punch-fist-yellow-red.webp",
|
|
techcomputersai: "techcomputersandai",
|
|
craftspecialty: "craft",
|
|
meleeblade2h: "systems/sla-mothership/images/sla-assets/Skills/Melee 2H Blade.png",
|
|
firearmautosupport: "firearmauto",
|
|
ebbthermalblue: "ebbbluethermal",
|
|
ebbsenses: "systems/sla-mothership/images/sla-assets/Ebb/Senses.png",
|
|
ebbprotect: "systems/sla-mothership/images/sla-assets/Ebb/Protect.png",
|
|
ebbheal: "systems/sla-mothership/images/sla-assets/Ebb/Ebb Heal.png",
|
|
streetclothesleatherjacket: "streetcloths",
|
|
fullshiverarmour: "shiver",
|
|
riotshield: "systems/sla-mothership/images/sla-assets/Armor/2nd try/Riot Shield.png",
|
|
bodblockerpp644: "pp644bodyblocker",
|
|
klippomultibandcommunicator: "klippomultibandcomm",
|
|
vidphoneportable: "vidphoneportable",
|
|
boopaaudio: "boopaaudio",
|
|
uvmultispectrumtorch: "uvmultispectrumtorch",
|
|
boopamedicalkitstandard: "medikitdisposable",
|
|
boopamedicalkitadvanced: "boopamedikitadvanced",
|
|
flashbanggrenade: "flashbang",
|
|
grapplegun: "grapplegun",
|
|
climbingharnessgear: "climbingharness",
|
|
restraintcuffsheavyduty: "restraintcuffs",
|
|
standardcombatknife: "slacombatknifestandard",
|
|
collapsiblebaton: "colapsablebaton",
|
|
itbmutilatorsword: "vibrosabre",
|
|
vibroblade: "vibrosabre",
|
|
chainaxe: "chainaxe1",
|
|
chainsawimprovised: "chainsaw1",
|
|
shaktarclaws: "shacktarclaws",
|
|
kpsmanglershotgun: "kpsmangler",
|
|
fen10streetsweeperautoshotgun: "fen10streetsweeper",
|
|
milagrenadelauncher: "milagrenagelauncher",
|
|
fragmentationgrenade: "fraggrenade",
|
|
stungrenade: "systems/sla-mothership/images/sla-assets/Gear/FlashBang .png"
|
|
};
|
|
|
|
const alias = aliases[key];
|
|
if (alias?.includes("/")) return alias;
|
|
if (alias && iconMap?.[alias]) return iconMap[alias];
|
|
|
|
const fuzzy = Object.entries(iconMap ?? {}).find(([candidate]) => candidate.includes(key) || key.includes(candidate));
|
|
return fuzzy?.[1] ?? fallback;
|
|
}
|
|
|
|
async function ensureSlaPanicTables({ overwrite = false } = {}) {
|
|
const folder = await ensureRollTableFolder("SLA Mothership Tables", "#8c3428");
|
|
const specs = [
|
|
{
|
|
key: "sla-panic-1e-stress",
|
|
name: "Panic Check (SLA 1E Stress)",
|
|
formula: "1d20",
|
|
results: buildPanicResults("1d20")
|
|
},
|
|
{
|
|
key: "sla-panic-0e-stress",
|
|
name: "Panic Check (SLA 0E Stress)",
|
|
formula: "2d10",
|
|
results: buildPanicResults("2d10")
|
|
},
|
|
{
|
|
key: "sla-panic-1e-calm",
|
|
name: "Panic Check (SLA 1E Calm)",
|
|
formula: "1d100",
|
|
results: buildPanicResults("1d100")
|
|
},
|
|
{
|
|
key: "sla-panic-0e-calm",
|
|
name: "Panic Check (SLA 0E Calm)",
|
|
formula: "1d100",
|
|
results: buildPanicResults("1d100")
|
|
}
|
|
];
|
|
|
|
let created = 0;
|
|
let updated = 0;
|
|
const ids = {};
|
|
for (const spec of specs) {
|
|
const existing = game.tables.find((table) =>
|
|
table.folder?.id === folder.id
|
|
&& (table.getFlag(SYSTEM_ID, "slaTableKey") === spec.key || table.name === spec.name)
|
|
);
|
|
|
|
const payload = {
|
|
name: spec.name,
|
|
description: "SLA-flavoured panic fallout for operatives under extreme pressure.",
|
|
formula: spec.formula,
|
|
replacement: true,
|
|
displayRoll: true,
|
|
img: "icons/svg/terror.svg",
|
|
folder: folder.id,
|
|
results: spec.results,
|
|
flags: {
|
|
[SYSTEM_ID]: {
|
|
slaTableKey: spec.key
|
|
}
|
|
}
|
|
};
|
|
const { results, ...tableData } = payload;
|
|
|
|
let table = existing;
|
|
if (!table) {
|
|
table = await RollTable.create(payload);
|
|
created += 1;
|
|
} else if (overwrite || !table.results?.size) {
|
|
await table.update(tableData);
|
|
if (table.results?.size) {
|
|
await table.deleteEmbeddedDocuments("TableResult", [...table.results.keys()]);
|
|
}
|
|
await table.createEmbeddedDocuments("TableResult", results);
|
|
table = game.tables.get(table.id) ?? table;
|
|
updated += 1;
|
|
}
|
|
ids[spec.key] = table.id;
|
|
}
|
|
|
|
const settings = [
|
|
["table1ePanicStressNormal", ids["sla-panic-1e-stress"]],
|
|
["table1ePanicStressAndroid", ids["sla-panic-1e-stress"]],
|
|
["table0ePanicStressNormal", ids["sla-panic-0e-stress"]],
|
|
["table0ePanicStressAndroid", ids["sla-panic-0e-stress"]],
|
|
["table1ePanicCalmNormal", ids["sla-panic-1e-calm"]],
|
|
["table1ePanicCalmAndroid", ids["sla-panic-1e-calm"]],
|
|
["table0ePanicCalmNormal", ids["sla-panic-0e-calm"]],
|
|
["table0ePanicCalmAndroid", ids["sla-panic-0e-calm"]]
|
|
];
|
|
|
|
for (const [settingKey, tableId] of settings) {
|
|
const current = game.settings.get(SYSTEM_ID, settingKey);
|
|
if (overwrite || !current || current !== tableId) {
|
|
await game.settings.set(SYSTEM_ID, settingKey, tableId);
|
|
}
|
|
}
|
|
|
|
return { created, updated, ids };
|
|
}
|
|
|
|
async function ensureRollTableFolder(name, color = "#163447") {
|
|
let folder = game.folders.find((entry) => entry.type === "RollTable" && entry.name === name && !entry.folder);
|
|
if (!folder) {
|
|
folder = await Folder.create({ name, type: "RollTable", color });
|
|
}
|
|
return folder;
|
|
}
|
|
|
|
function buildPanicResults(formula = "1d20") {
|
|
const mode = String(formula).toLowerCase();
|
|
if (mode === "1d100") {
|
|
return SLA_PANIC_ENTRIES.map((entry, index) => ({
|
|
type: CONST.TABLE_RESULT_TYPES.TEXT,
|
|
text: `<strong>${entry.title}:</strong> ${entry.effect}`,
|
|
range: [index * 5 + 1, index * 5 + 5],
|
|
weight: 1,
|
|
drawn: false,
|
|
img: "icons/svg/terror.svg"
|
|
}));
|
|
}
|
|
|
|
if (mode === "2d10") {
|
|
return SLA_PANIC_ENTRIES.slice(1).map((entry, index) => ({
|
|
type: CONST.TABLE_RESULT_TYPES.TEXT,
|
|
text: `<strong>${entry.title}:</strong> ${entry.effect}`,
|
|
range: [index + 2, index + 2],
|
|
weight: 1,
|
|
drawn: false,
|
|
img: "icons/svg/terror.svg"
|
|
}));
|
|
}
|
|
|
|
return SLA_PANIC_ENTRIES.map((entry, index) => ({
|
|
type: CONST.TABLE_RESULT_TYPES.TEXT,
|
|
text: `<strong>${entry.title}:</strong> ${entry.effect}`,
|
|
range: [index + 1, index + 1],
|
|
weight: 1,
|
|
drawn: false,
|
|
img: "icons/svg/terror.svg"
|
|
}));
|
|
}
|
|
|
|
function deriveWeaponModeDefaults(shotsPerFire = 1, special = "", rofText = "") {
|
|
const modes = [{ label: "Single", shots: 1 }];
|
|
const text = `${special} ${rofText}`.toLowerCase();
|
|
|
|
if (text.includes("2 shots") || text.includes("2/round")) {
|
|
modes.push({ label: "Double", shots: 2 });
|
|
}
|
|
if (text.includes("burst") || (special.includes("auto") && shotsPerFire >= 3)) {
|
|
modes.push({ label: "Burst", shots: Math.max(3, shotsPerFire) });
|
|
} else if (shotsPerFire === 2) {
|
|
modes.push({ label: "Double", shots: 2 });
|
|
} else if (shotsPerFire > 2) {
|
|
modes.push({ label: "Burst", shots: shotsPerFire });
|
|
}
|
|
if (text.includes("full auto") || special.includes("auto")) {
|
|
modes.push({ label: "Auto", shots: Math.max(text.includes("full auto") ? 6 : shotsPerFire, 3) });
|
|
}
|
|
|
|
return modes.filter((mode, index, list) =>
|
|
list.findIndex((entry) => entry.label === mode.label && entry.shots === mode.shots) === index
|
|
);
|
|
}
|
|
|
|
function renderList(title, items) {
|
|
if (!items?.length) return "";
|
|
return `<p><strong>${title}</strong></p><ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
|
|
}
|
|
|
|
function renderMothershipProfile(stats) {
|
|
if (!stats || typeof stats !== "object") return "";
|
|
const labels = {
|
|
strength: "Strength",
|
|
speed: "Speed",
|
|
intellect: "Intellect",
|
|
combat: "Combat",
|
|
sanity: "Sanity Save",
|
|
fear: "Fear Save",
|
|
body: "Body Save"
|
|
};
|
|
const rows = Object.entries(labels)
|
|
.filter(([key]) => stats[key])
|
|
.map(([key, label]) => `<li><strong>${label}:</strong> ${stats[key]}</li>`)
|
|
.join("");
|
|
if (!rows) return "";
|
|
return `<p><strong>Mothership Profile</strong></p><ul>${rows}</ul>`;
|
|
}
|
|
|
|
function renderTierList(tiers) {
|
|
if (!tiers?.length) return "";
|
|
const rows = tiers
|
|
.map((tier) => {
|
|
const details = [tier.damage, tier.healing, tier.effect, tier.duration].filter(Boolean).join(" | ");
|
|
return `<li><strong>${tier.label ?? tier.id}</strong> (${tier.cost ?? 0} Flux): ${details}</li>`;
|
|
})
|
|
.join("");
|
|
return `<p><strong>Tiers</strong></p><ul>${rows}</ul>`;
|
|
}
|
|
|
|
function priceBand(value) {
|
|
const parsed = parseNumber(value);
|
|
if (parsed >= 500) return 500;
|
|
if (parsed >= 250) return 250;
|
|
if (parsed >= 100) return 100;
|
|
if (parsed >= 50) return 50;
|
|
if (parsed > 0) return parsed;
|
|
return 0;
|
|
}
|
|
|
|
function parseNumber(value) {
|
|
const match = String(value ?? "").match(/-?\d+(\.\d+)?/);
|
|
return match ? Number(match[0]) : 0;
|
|
}
|
|
|
|
function encToKg(value) {
|
|
return Math.round((parseNumber(value) * 1.5) * 10) / 10;
|
|
}
|
|
|
|
function toDice(value) {
|
|
return String(value ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
function slug(value) {
|
|
return String(value ?? "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|