import { fromIdUuid } from "../mosh.js";
import { slaDebug, slaWarn } from "../logger.js";
import { SLADrugSystem } from "../sla-drug-system.js";
import { SLA_SPECIES_BALANCE_VERSION, getSlaSpeciesStatAdjustments } from "../sla-species-balance.js";
import { buildSlaTraumaResponseText } from "../sla-trauma.js";
// Global event delegation to handle interactive range slider value updates
// in a way that is fully compatible with Foundry VTT's HTML sanitization.
if (typeof document !== "undefined") {
document.addEventListener("input", (event) => {
const target = event.target;
if (target && target.tagName === "INPUT" && target.name === "rollModifier") {
const value = Number(target.value) || 0;
const label = target.closest(".resource")?.querySelector(".slider-value");
if (label) {
label.innerText = (value >= 0 ? "+" : "") + value;
}
}
});
}
const SLA_AMMO_TYPES = {
STD: { label: "Standard", multiplier: 1, damageBonus: "", woundEffect: "", summary: "Standard ammunition." },
AP: { label: "AP", multiplier: 2, damageBonus: "-1", woundEffect: "Gunshot [-]", summary: "Armour-piercing ammunition. Treat target armour as halved and reduce damage by 1." },
HE: { label: "HE", multiplier: 3, damageBonus: "1d5", woundEffect: "Fire & Explosives", summary: "High explosive ammunition. Add 1d5 damage and use explosive wound effects." },
HEAP: { label: "HEAP", multiplier: 4, damageBonus: "1d5", woundEffect: "Fire & Explosives [+]", summary: "High explosive armour-piercing ammunition. Add 1d5 damage and treat target armour as halved." }
};
const SLA_AMMO_RESERVE_KEYS = {
STD: "ammoReserveStd",
AP: "ammoReserveAp",
HE: "ammoReserveHe",
HEAP: "ammoReserveHeap"
};
const SLA_WOUND_EFFECTS = {
bleeding: { key: "bleeding", label: "Bleeding", settingKey: "table1eWoundBleeding" },
blunt_force: { key: "blunt_force", label: "Blunt Force", settingKey: "table1eWoundBluntForce" },
fire_explosives: { key: "fire_explosives", label: "Fire & Explosives", settingKey: "table1eWoundFireExplosives" },
gore_massive: { key: "gore_massive", label: "Gore & Massive", settingKey: "table1eWoundGoreMassive" },
gunshot: { key: "gunshot", label: "Gunshot", settingKey: "table1eWoundGunshot" }
};
const SLA_WOUND_EFFECT_ALIASES = {
bleeding: "bleeding",
blunt_force: "blunt_force",
fire_explosives: "fire_explosives",
fire_and_explosives: "fire_explosives",
gore_massive: "gore_massive",
gore_and_massive: "gore_massive",
gunshot: "gunshot"
};
function normalizeSlaWoundEffectKey(value = "") {
return String(value ?? "")
.trim()
.toLowerCase()
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
function parseSlaWoundEffect(rawEffect = "") {
const original = String(rawEffect ?? "").trim();
if (!original) return null;
let label = original;
let modifier = "standard";
if (/\[\+\]\s*$/.test(label)) {
modifier = "adv";
label = label.replace(/\s*\[\+\]\s*$/, "").trim();
} else if (/\[-\]\s*$/.test(label)) {
modifier = "dis";
label = label.replace(/\s*\[-\]\s*$/, "").trim();
}
const normalized = normalizeSlaWoundEffectKey(label);
const resolvedKey = SLA_WOUND_EFFECT_ALIASES[normalized] ?? normalized;
const entry = SLA_WOUND_EFFECTS[resolvedKey];
if (!entry) {
return {
raw: original,
label,
modifier,
displayLabel: original,
unknown: true
};
}
const modifierSuffix =
modifier === "adv" ? " [+]" :
modifier === "dis" ? " [-]" :
"";
return {
...entry,
raw: original,
modifier,
displayLabel: `${entry.label}${modifierSuffix}`,
unknown: false
};
}
function renderSlaWoundEffectMarkup(rawEffect = "") {
const parsed = parseSlaWoundEffect(rawEffect);
if (!parsed) return "";
if (parsed.unknown) return foundry.utils.escapeHTML(parsed.displayLabel);
const label = foundry.utils.escapeHTML(parsed.displayLabel);
const title = foundry.utils.escapeHTML(`Roll ${parsed.displayLabel} wound table`);
return ``;
}
function getSlaTableRollFormula(tableData) {
const formula = String(tableData?.formula ?? "1d10").trim();
return formula.replace(/\s*-\s*1\b/g, "").trim() || "1d10";
}
function getSlaWoundTableLabelFromSettingKey(settingKey = "") {
return Object.values(SLA_WOUND_EFFECTS).find((entry) => entry.settingKey === settingKey)?.label ?? null;
}
function normalizeSlaConditionLabel(value = "") {
return String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
async function resolveRollTableReference(reference) {
if (!reference) return null;
const direct = await fromIdUuid(reference, { type: "RollTable" });
if (direct) return direct;
const worldTable =
game.tables?.get?.(reference) ||
game.tables?.find?.((table) => table.name === reference);
if (worldTable) return worldTable;
for (const pack of game.packs ?? []) {
if (pack.metadata?.type !== "RollTable") continue;
const index = typeof pack.getIndex === "function"
? await pack.getIndex()
: pack.index;
const indexMatch = index?.find?.((entry) =>
entry._id === reference ||
entry.uuid === reference ||
entry.name === reference
);
if (indexMatch?._id) {
const document = await pack.getDocument(indexMatch._id);
if (document) return document;
}
}
return null;
}
async function resolveSlaWoundTableBySetting(settingKey = "") {
const configured = game.settings.get("sla-mothership", settingKey);
const direct = await resolveRollTableReference(configured);
if (direct) return direct;
const label = getSlaWoundTableLabelFromSettingKey(settingKey);
if (!label) return null;
const fallbackNames = [
`${label} Wound`,
label.replace(/&/g, "and"),
`${label.replace(/&/g, "and")} Wound`
];
for (const name of fallbackNames) {
const resolved = await resolveRollTableReference(name);
if (resolved) return resolved;
}
return null;
}
const SLA_SKILL_IMAGE_OVERRIDES = {
athletics: "systems/sla-mothership/images/sla-assets/Skills/Athletics.png",
brawl: "systems/sla-mothership/images/sla-assets/Skills/Brawl.png"
};
function getSlaSkillRankLabel(bonus = 0) {
const value = Number(bonus ?? 0) || 0;
if (value >= 20) return "Master";
if (value >= 15) return "Expert";
if (value >= 10) return "Trained";
if (value > 0) return "Basic";
return "Untrained";
}
const SLA_SKILL_REFERENCE_ALIASES = {
brawl: ["brawl"],
meleeweaponknife: ["meleeblade1h", "brawl"],
meleeweaponclub: ["meleeclub", "brawl"],
meleeweapon2hsword: ["meleeblade2h"],
meleeweaponsword: ["meleeblade1h"],
meleeweaponaxe: ["meleeaxe"],
meleeweaponany: ["meleeblade1h", "meleeblade2h", "meleeclub", "meleeaxe", "brawl"],
naturalweaponclaws: ["brawl"],
shieldriot: ["brawl"],
throw: ["throw"],
firearmpistol: ["firearmpistol"],
firearmrifle: ["firearmrifleshotgun"],
firearmshotgun: ["firearmrifleshotgun"],
firearmrifleshotgun: ["firearmrifleshotgun"],
firearmsmg: ["firearmsmg", "firearmautosupport"],
firearmauto: ["firearmautosupport", "firearmsmg"],
firearmheavy: ["firearmautosupport"],
firearmautosupport: ["firearmautosupport", "firearmsmg"]
};
const SLA_SKILL_METADATA_BY_KEY = {
slainfo: { category: "Mental", combat: false, attributes: ["intellect"] },
drivecivilian: { category: "Technical", combat: false, attributes: ["speed", "intellect"] },
drivemilitary: { category: "Technical", combat: false, attributes: ["speed", "intellect"] },
pilot: { category: "Technical", combat: false, attributes: ["speed", "intellect"] },
techcomputersai: { category: "Technical", combat: false, attributes: ["intellect"] },
techelectronics: { category: "Technical", combat: false, attributes: ["intellect"] },
techmechanical: { category: "Technical", combat: false, attributes: ["intellect"] },
techmilitary: { category: "Technical", combat: false, attributes: ["intellect"] },
bureaucracy: { category: "Mental", combat: false, attributes: ["intellect"] },
medical: { category: "Mental", combat: false, attributes: ["intellect"] },
psychology: { category: "Mental", combat: false, attributes: ["intellect"] },
tactics: { category: "Mental", combat: false, attributes: ["intellect"] },
computersystems: { category: "Technical", combat: false, attributes: ["intellect"] },
persuade: { category: "Communication", combat: false, attributes: ["intellect"] },
fasttalk: { category: "Communication", combat: false, attributes: ["intellect"] },
intimidate: { category: "Communication", combat: false, attributes: ["intellect"] },
streetwise: { category: "Communication", combat: false, attributes: ["intellect"] },
command: { category: "Communication", combat: false, attributes: ["intellect"] },
craftspecialty: { category: "Mental", combat: false, attributes: ["intellect"] },
spothidden: { category: "Perception", combat: false, attributes: ["speed", "intellect"] },
listen: { category: "Perception", combat: false, attributes: ["speed", "intellect"] },
athletics: { category: "Physical", combat: false, attributes: ["strength", "speed"] },
climb: { category: "Physical", combat: false, attributes: ["strength", "speed"] },
swim: { category: "Physical", combat: false, attributes: ["strength", "speed"] },
dodge: { category: "Physical", combat: false, attributes: ["speed"] },
stealth: { category: "Physical", combat: false, attributes: ["speed"] },
brawl: { category: "Combat", combat: true, attributes: ["combat", "strength"] },
meleeblade1h: { category: "Combat", combat: true, attributes: ["combat", "strength"] },
meleeblade2h: { category: "Combat", combat: true, attributes: ["combat", "strength"] },
meleeclub: { category: "Combat", combat: true, attributes: ["combat", "strength"] },
meleeaxe: { category: "Combat", combat: true, attributes: ["combat", "strength"] },
throw: { category: "Combat", combat: true, attributes: ["combat", "strength", "speed"] },
firearmpistol: { category: "Combat", combat: true, attributes: ["combat"] },
firearmrifleshotgun: { category: "Combat", combat: true, attributes: ["combat"] },
firearmsmg: { category: "Combat", combat: true, attributes: ["combat"] },
firearmautosupport: { category: "Combat", combat: true, attributes: ["combat"] },
biofeedback: { category: "Mental", combat: false, attributes: ["intellect"] },
formulate: { category: "Mental", combat: false, attributes: ["intellect"] },
ebbcore: { category: "Mental", combat: false, attributes: ["intellect"] },
ebbawareness: { category: "Perception", combat: false, attributes: ["speed", "intellect"] },
ebbblast: { category: "Combat", combat: true, attributes: ["combat", "intellect"] },
ebbthermalred: { category: "Combat", combat: true, attributes: ["combat", "intellect"] },
ebbthermalblue: { category: "Combat", combat: true, attributes: ["combat", "intellect"] },
ebbtelekinesis: { category: "Mental", combat: false, attributes: ["strength", "intellect"] },
ebbcommunicate: { category: "Mental", combat: false, attributes: ["intellect"] },
ebbsenses: { category: "Perception", combat: false, attributes: ["speed", "intellect"] },
ebbprotect: { category: "Mental", combat: false, attributes: ["intellect"] },
ebbheal: { category: "Mental", combat: false, attributes: ["intellect"] },
ebbrealityfold: { category: "Mental", combat: false, attributes: ["intellect"] }
};
function normalizeSlaSkillKey(value = "") {
return String(value ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "");
}
function getSlaSkillMetadataForName(name = "", fallbackCategory = "") {
const key = normalizeSlaSkillKey(name);
const profile = SLA_SKILL_METADATA_BY_KEY[key];
if (profile) return profile;
if (fallbackCategory) {
return {
category: fallbackCategory,
combat: String(fallbackCategory).trim().toLowerCase() === "combat",
attributes: []
};
}
return null;
}
const SLA_CRITICAL_FAILURE_TABLES = {
firearm: {
label: "Firearm Critical Failure",
entries: [
{ title: "Misfeed", effect: "The shot sputters out. Clear the chamber before this weapon can fire again." },
{ title: "Dry Click", effect: "You burn the action on a bad feed. Lose the next half-action or equivalent moment of tempo." },
{ title: "Hot Brass", effect: "You flinch from venting brass and powder. Take Disadvantage on your next firearm roll this round." },
{ title: "Sight Knock", effect: "The weapon bucks off line. Your next aimed shot loses any range or aim benefit." },
{ title: "Magazine Slip", effect: "The magazine drops or partially seats. Spend an action to slam it home." },
{ title: "Safety Snag", effect: "Harness, sling, or safety catches at the wrong time. You cannot use reactions with this weapon until reset." },
{ title: "Overheat", effect: "The chamber runs dangerously hot. Firing again before cooling inflicts 1d5 damage to the weapon hand." },
{ title: "Wild Burst", effect: "The muzzle climbs hard. An ally or nearby asset is exposed unless the GM rules otherwise." },
{ title: "Jam", effect: "The weapon locks up hard. Full reload and field-clear procedure required before it works again." },
{ title: "Catastrophic Flashback", effect: "The shot backfires brutally. Suffer 1d5 damage and the weapon is unusable until repaired." }
]
},
melee: {
label: "Melee Critical Failure",
entries: [
{ title: "Bad Footing", effect: "You overcommit and lose ground. The target gains Advantage on the next opposed melee exchange." },
{ title: "Open Guard", effect: "Your defence opens wide. You cannot parry, block, or react effectively until your next turn." },
{ title: "Weapon Check", effect: "Your weapon binds or bounces free. Spend your next action to recover proper grip and stance." },
{ title: "Glancing Blow", effect: "The hit skids off cover or armour. You deal no damage and your position is compromised." },
{ title: "Off-Balance Spin", effect: "Momentum carries you through. Move one step past the target or fall prone, GM's call." },
{ title: "Hand Shock", effect: "The impact numbs your arm. Take -10 on Strength and Combat checks until the end of the next round." },
{ title: "Dropped Weapon", effect: "Your weapon flies loose or falls at your feet." },
{ title: "Friendly Opening", effect: "You expose an ally's line. The nearest ally loses cover or must reposition immediately." },
{ title: "Pulled Muscle", effect: "You wrench something important. Take 1d5 damage and all melee attacks are at Disadvantage until treated." },
{ title: "Weapon Break", effect: "The weapon chips, bends, or snaps. It is unusable until repaired or replaced." }
]
},
strength: {
label: "Strength Critical Failure",
entries: [
{ title: "Strain", effect: "You pull something ugly. Take 1d5 damage and lose brute-force follow-through this scene." },
{ title: "Dropped Load", effect: "Whatever you were hauling, holding, or forcing slips free in the worst possible way." },
{ title: "Brace Break", effect: "Your leverage collapses. The obstacle, door, or target gains a clear advantage over you." },
{ title: "Noisy Impact", effect: "The effort is loud and obvious. Nearby threats are alerted." },
{ title: "Tool Snap", effect: "Improvised kit, handle, or strap gives way. The relevant tool is damaged or lost." },
{ title: "Bad Lift", effect: "You overextend. Take -10 on the next Strength or Combat roll." },
{ title: "Pinned Position", effect: "You end up trapped under the task. Spend an action to free yourself before trying again." },
{ title: "Collateral Damage", effect: "You break the wrong thing. The environment or objective takes an ugly hit." },
{ title: "Hold Lost", effect: "Grip fails completely. The target breaks loose or the object drops to a worse position." },
{ title: "Backlash", effect: "The force rebounds into you. Suffer 1d10 Stress or 1d5 damage, GM's call for the scene." }
]
},
speed: {
label: "Speed Critical Failure",
entries: [
{ title: "Slip", effect: "You lose traction and go down or collide with the environment." },
{ title: "Bad Timing", effect: "You move a second too early. An opponent or hazard acts before you can recover." },
{ title: "Noise Spike", effect: "The attempt is fast but loud. Stealth is blown." },
{ title: "Wrong Cover", effect: "You dive into poor cover and lose any defensive benefit." },
{ title: "Dropped Kit", effect: "Essential gear spills or is left behind during the scramble." },
{ title: "Twist", effect: "You wrench ankle, knee, or hip. Take -10 on Speed checks until you rest." },
{ title: "Crossed Line", effect: "You overrun the mark and end up exposed to return fire or direct danger." },
{ title: "Friendly Collision", effect: "You crash through a teammate's lane and break squad discipline." },
{ title: "Stall Out", effect: "Momentum dies completely. Lose your next movement action." },
{ title: "Full Spill", effect: "You crash hard. Take 1d5 damage and end prone or hanging in the open." }
]
},
intellect: {
label: "Intellect Critical Failure",
entries: [
{ title: "False Read", effect: "You misread the situation and the GM gives a dangerously wrong first impression." },
{ title: "Terminal Mistake", effect: "You input or interpret the wrong detail. The task gets harder before it gets better." },
{ title: "Bad Assumption", effect: "You lock onto the wrong theory. Another attempt on this problem is at Disadvantage until new evidence appears." },
{ title: "Corrupted Data", effect: "You overwrite, erase, or contaminate useful intel." },
{ title: "Security Trace", effect: "Your inquiry leaves a footprint. Someone knows you were here." },
{ title: "Slow Realisation", effect: "You lose vital time. The scene clock advances or pressure escalates." },
{ title: "Protocol Breach", effect: "You trigger a lockout, alarm, or bureaucratic complication." },
{ title: "Miscommunication", effect: "Your explanation sends the team in the wrong direction for a beat." },
{ title: "Feedback Headache", effect: "The mental load hits hard. Gain 1 Stress." },
{ title: "Cascade Failure", effect: "The whole approach collapses. You need a new angle, tool, or specialist before retrying." }
]
}
};
const SLA_CRITICAL_SUCCESS_OPTIONS = {
firearm: [
"Choose one: roll the weapon's damage twice and keep the better result.",
"Choose one: ignore half the target's armour or cover on this hit.",
"Choose one: pin, disarm, or force the target to move exactly where the shot drives them."
],
melee: [
"Choose one: double the final damage after all normal modifiers.",
"Choose one: force the target prone, back a zone, or off balance for the next exchange.",
"Choose one: disarm, grapple, or open a free follow-up attack for an ally in reach."
],
strength: [
"Choose one: complete the task faster than expected and create a clean follow-up opening.",
"Choose one: force the obstacle, restraint, or target to yield so completely it cannot be reused immediately.",
"Choose one: add a decisive physical edge such as pinning, breaking, or hurling the target clear."
],
speed: [
"Choose one: gain immediate positional advantage, cover, or initiative over everyone else in the scene.",
"Choose one: stay silent and leave no obvious trace of movement.",
"Choose one: complete the action and still keep momentum for a second linked move or interaction."
],
intellect: [
"Choose one: gain an extra true detail, hidden pattern, or exploitable weakness beyond the original ask.",
"Choose one: solve the problem cleanly and reduce the time, cost, or fallout of the action.",
"Choose one: create a lasting procedural edge such as bypass access, a reusable exploit, or a stronger briefing."
]
};
/**
* Extend the base Actor entity by defining a custom roll data structure which is ideal for the Simple system.
* @extends {Actor}
*/
export class MothershipActor extends Actor {
// augment the basic actor data with additional dynamic data
prepareDerivedData() {
super.prepareDerivedData();
const system = this.system;
// ----- per-type derived -----
if (this.type === "character") this._deriveCharacter();
else if (this.type === "creature") this._deriveCreature();
else if (this.type === "ship") this._deriveShip();
}
// Prepare Character type specific data
_deriveCharacter() {
const system = this.system;
//armor points + damage reduction
//init vars
let armorPoints = 0;
let damageReduction = 0;
//count values
for (const armor of this.items.filter(i => i.type === "armor")) {
if (armor.system?.equipped) {
armorPoints += Number(armor.system.armorPoints ?? 0);
damageReduction += Number(armor.system.damageReduction ?? 0);
}
}
//set values
system.stats.armor.mod = armorPoints;
system.stats.armor.total = armorPoints + Number(system.stats.armor.value ?? 0);
system.stats.armor.damageReduction = damageReduction;
//net hp
//init vars
let netHPvalue = 0;
let netHPmax = 0;
//check if actor has netHP
system.netHP ??= { value: 0, min: 0, max: 0, label: "Net HP"};
//set values
system.netHP.value = ((Number(system.hits.max ?? 0)-Number(system.hits.value ?? 0)-1) * Number(system.health.max ?? 0)) + Number(system.health.value ?? 0);
system.netHP.max = Number(system.health.max ?? 0) * Number(system.hits.max ?? 0);
//bleeding
//init vars
let bleedingValue = 0;
//count values
for (const condition of this.items.filter(i => i.type === "condition")) {
if (condition.name === 'Bleeding') {
bleedingValue += Number(condition.system.severity ?? 0);
}
}
//set values
system.bleeding.value = Number(bleedingValue ?? 0);
}
// Prepare Creature type specific data
_deriveCreature() {
const system = this.system;
//armor points + damage reduction
//init vars
let armorPoints = 0;
let damageReduction = 0;
//count values
for (const armor of this.items.filter(i => i.type === "armor")) {
if (armor.system?.equipped) {
armorPoints += Number(armor.system.armorPoints ?? 0);
damageReduction += Number(armor.system.damageReduction ?? 0);
}
}
//set values
system.stats.armor.mod = armorPoints;
system.stats.armor.total = armorPoints + Number(system.stats.armor.value ?? 0);
system.stats.armor.damageReduction = damageReduction;
//net hp
//check if actor has netHP
system.netHP ??= { value: 0, min: 0, max: 0, label: "Net HP"};
//set values
system.netHP.value = ((Number(system.hits.max ?? 0)-Number(system.hits.value ?? 0)-1) * Number(system.health.max ?? 0)) + Number(system.health.value ?? 0);
system.netHP.max = Number(system.health.max ?? 0) * Number(system.hits.max ?? 0);
//notes
//check if actor has notes
system.notes ??= "";
//bleeding
//init vars
let bleedingValue = 0;
//count values
for (const condition of this.items.filter(i => i.type === "condition")) {
if (condition.name === 'Bleeding') {
bleedingValue += Number(condition.system.severity ?? 0);
}
}
//set values
system.bleeding.value = Number(bleedingValue ?? 0);
//keep combat updates for swarm types
if (system.swarm && system.swarm.enabled){
system.stats.combat.value = system.swarm.combat.value * ( system.hits.max - system.hits.value );
}
}
// Prepare Ship type specific data
_deriveShip() {
//nothing needed yet
}
async useSlaDrug(itemId) {
return SLADrugSystem.useDrug({ actor: this, itemId });
}
async closeSlaDrug(itemId = "", drug = "") {
return SLADrugSystem.closeDrug({ actor: this, itemId, drug });
}
//central flavor text library for all chat messages
getFlavorText(type, context, action) {
//replace 'stress' with calm if the setting is active
if (game.settings.get("sla-mothership", "useCalm") && context === 'stress') {
context = 'calm';
}
let systemclass = "human";
if (this.type === 'character' && this.system.class && this.system.class.value.toLowerCase() === "android") {
systemclass = "android";
//todo: get the class item for the character to check the "is robotic" flag
}
let locString = `Mosh.${type}.${context}.${action}.${systemclass}`;
//check to see if this address exists in the library, return the action parameter if not
if(game.i18n.has(locString, true)){ // You can pass false as the second argument to ignore english-language fallback.
//log what was done
slaDebug(`Retrieved flavor text for ${locString}`);
//return class appropriate text
return game.i18n.localize(locString);
} else {
//log what was done
slaDebug(`Using language: ${game.i18n.language}`);
slaDebug(`Retrieved flavor text for ${locString}, which did not have an entry`);
slaDebug(`Using language: ${game.i18n.lang}`);
//return what we were asked
return action;
}
}
renderSlaWoundEffect(rawEffect = "") {
return renderSlaWoundEffectMarkup(rawEffect);
}
async rollSlaWoundEffect(effectKeyOrLabel, modifier = "standard") {
const effectInput = modifier && String(effectKeyOrLabel ?? "").trim() && !String(effectKeyOrLabel).includes("[")
? `${String(effectKeyOrLabel).trim()}${modifier === "adv" ? " [+]" : modifier === "dis" ? " [-]" : ""}`
: effectKeyOrLabel;
const parsed = parseSlaWoundEffect(effectInput);
if (!parsed || parsed.unknown) {
ui.notifications.warn(`No valid wound effect could be resolved from "${effectKeyOrLabel}".`);
return null;
}
const tableId = game.settings.get("sla-mothership", parsed.settingKey);
if (!tableId) {
ui.notifications.warn(`No wound table is configured for ${parsed.label}.`);
return null;
}
const tableData = await resolveSlaWoundTableBySetting(parsed.settingKey);
if (!tableData) {
ui.notifications.warn(`The ${parsed.label} wound table could not be found.`);
return null;
}
const modifierSuffix =
parsed.modifier === "adv" ? " [+]" :
parsed.modifier === "dis" ? " [-]" :
"";
const rollString = `${getSlaTableRollFormula(tableData)}${modifierSuffix}`;
return this.rollTable(tableData.uuid ?? tableData.id ?? tableId, rollString, "low", true, false, null, null, {
suppressActorWound: true
});
}
//central roll parsing function | TAKES '1d10 [+]','low' | RETURNS '{1d10,1d10}kh'
parseRollString(rollString, aimFor) {
//init vars
let rollDice = ``;
let rollTemplate = ``;
let rollStringParsed = ``;
//translate rollString into foundry roll string format
if (rollString.includes('[')) {
//extract dice needed
rollDice = rollString.substr(0, rollString.indexOf('[')).trim().concat(',', rollString.substr(0, rollString.indexOf('[')).trim());
//set template based on adv or dis
if (rollString.includes('[-]')) {
//use appropriate keep setting
if (aimFor === 'low') {
rollTemplate = '{[diceSet]}kh';
} else {
rollTemplate = '{[diceSet]}kl';
}
} else if (rollString.includes('[+]')) {
//use appropriate keep setting
if (aimFor === 'low') {
rollTemplate = '{[diceSet]}kl';
} else {
rollTemplate = '{[diceSet]}kh';
}
}
//make foundry roll string
rollStringParsed = rollTemplate.replace('[diceSet]', rollDice);
} else {
rollStringParsed = rollString;
}
//log what was done
slaDebug(`Parsed '${rollString}' aiming '${aimFor}' into '${rollStringParsed}'`);
//return string in foundry format
return rollStringParsed;
}
clampSlaRollTarget(rollTarget, rollString = "") {
const numericTarget = Number(rollTarget);
if (!Number.isFinite(numericTarget)) return rollTarget;
const usesD100 = String(rollString ?? "").includes("1d100");
if (!usesD100) return numericTarget;
return Math.min(98, numericTarget);
}
isSlaAutomaticFailure(total, rollString = "") {
const numericTotal = Number(total);
if (!Number.isFinite(numericTotal)) return false;
const usesD100 = String(rollString ?? "").includes("1d100");
return usesD100 && numericTotal === 99;
}
getSlaChatVariant(context = {}) {
const actorClass = String(this.system?.class?.value ?? "").trim();
const employer = String(this.system?.sla?.employer?.value ?? "").trim();
const sector = String(this.system?.sla?.sector?.value ?? "").trim();
const skill = String(context?.skill ?? "").trim();
const skillCategory = String(context?.skillCategory ?? "").trim();
const tableName = String(context?.tableName ?? "").trim();
const header = String(context?.msgHeader ?? "").trim();
const weaponName = String(context?.weapon?.name ?? "").trim();
const itemName = String(context?.item?.name ?? "").trim();
const extra = String(context?.extra ?? "").trim();
const haystack = [
actorClass,
employer,
sector,
skill,
skillCategory,
tableName,
header,
weaponName,
itemName,
extra
].join(" ").toLowerCase();
if (context?.isSlaEbbSkill || /\bebb\b|ebon|brain\swaste|flux|telekinesis|blast|awareness/.test(haystack)) {
return { key: "ebb", label: "Ebb", emblem: "EBB" };
}
if (/\bdark\s?night\b/.test(haystack)) {
return { key: "darknight", label: "DarkNight", emblem: "DN" };
}
if (/\bshiver\b/.test(haystack)) {
return { key: "shiver", label: "Shiver", emblem: "SV" };
}
if (/\bmort\b|mortuary|undertaker|corpse|cadaver/.test(haystack)) {
return { key: "mort", label: "Mort", emblem: "MT" };
}
if (/\btek\b|technical|engineer|engineering|medic|medical|science|research/.test(haystack)) {
return { key: "tek", label: "Tek", emblem: "TK" };
}
if (/\bops\b|operations|opscon|ops\scon|operative|squad/.test(haystack)) {
return { key: "ops", label: "Ops", emblem: "OP" };
}
return { key: "sla", label: "SLA", emblem: "SLA" };
}
getSlaSeverityMeta({ severity = null, tableName = "", specialRoll = "", tableResultNumber = null } = {}) {
const rawSeverity = Number(severity);
if (Number.isFinite(rawSeverity)) {
if (rawSeverity >= 5) return { key: "critical", label: "Critical", cssClass: "sla-severity-critical" };
if (rawSeverity >= 3) return { key: "severe", label: "Severe", cssClass: "sla-severity-severe" };
return { key: "minor", label: "Minor", cssClass: "sla-severity-minor" };
}
const name = String(tableName ?? "").toLowerCase();
const special = String(specialRoll ?? "").toLowerCase();
const numericResult = Number(tableResultNumber);
if (!Number.isFinite(numericResult)) {
return { key: "minor", label: "Minor", cssClass: "sla-severity-minor" };
}
const isPanic = special === "paniccheck" || name.includes("panic");
const isWound = name.includes("wound");
if (isPanic) {
if (numericResult >= 16) return { key: "critical", label: "Catastrophic", cssClass: "sla-severity-critical" };
if (numericResult >= 9) return { key: "severe", label: "Severe", cssClass: "sla-severity-severe" };
return { key: "minor", label: "Minor", cssClass: "sla-severity-minor" };
}
if (isWound) {
if (numericResult >= 8) return { key: "critical", label: "Critical", cssClass: "sla-severity-critical" };
if (numericResult >= 4) return { key: "severe", label: "Severe", cssClass: "sla-severity-severe" };
return { key: "minor", label: "Minor", cssClass: "sla-severity-minor" };
}
return { key: "minor", label: "Minor", cssClass: "sla-severity-minor" };
}
async addSlaActivityLog(entry, { maxEntries = 40 } = {}) {
const text = String(entry ?? "").trim();
if (!text || this.type !== "character") return null;
const timestamp = new Date().toLocaleString("en-GB", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
const currentLog = String(this.system?.sla?.activityLog?.value ?? "").trim();
const existingLines = currentLog ? currentLog.split("\n").filter(Boolean) : [];
const nextLines = [`[${timestamp}] ${text}`, ...existingLines].slice(0, Math.max(1, maxEntries));
await this.update({ "system.sla.activityLog.value": nextLines.join("\n") });
return nextLines[0];
}
//central roll parsing function | TAKES '1d100',[Foundry roll object],true,true,41,'<' | RETURNS enriched Foundry roll object
parseRollResult(rollString, rollResult, zeroBased, checkCrit, rollTarget, comparison, specialRoll) {
//init vars
rollTarget = this.clampSlaRollTarget(rollTarget, rollString);
let doubles = new Set([0, 11, 22, 33, 44, 55, 66, 77, 88, 99]);
let enrichedRollResult = rollResult;
let rollFormula = enrichedRollResult.formula;
let rollAim = rollFormula.substr(rollFormula.indexOf("}") + 1, 2);
let useCalm = game.settings.get('sla-mothership', 'useCalm');
let die0value = 999;
let die1value = 999;
let die0success = false;
let die1success = false;
let die0crit = false;
let die1crit = false;
let newTotal = 999;
let diceFormula = ``;
let compareIcon = ``;
let outcome = ``;
let outcomeHtml = ``;
let diceIcon = ``;
let diceBlock = ``;
let critHighlight = ``;
let rollHtml = ``;
//init new fields in enriched roll result
enrichedRollResult.critical = false;
enrichedRollResult.success = false;
enrichedRollResult.isCritSuccess = false;
enrichedRollResult.isCritFailure = false;
enrichedRollResult.critClass = "";
enrichedRollResult.outcomeHtml = ``;
enrichedRollResult.rollHtml = ``;
//alter roll result object
//change data point: change each 100 or 10 result to zero
if (zeroBased) {
//1d10 changes
if (rollString.substr(0, rollString.indexOf("[")).trim() === '1d10' || rollString === '1d10' || rollString.substr(0, rollString.indexOf("[")).trim() === '-1d10' || rollString === '-1d10') {
//loop through dice
enrichedRollResult.dice.forEach(function (roll) {
//loop through each result
roll.results.forEach(function (die) {
//change any 10s to 0s
if (die.result === 10 || die.result === -10) {
die.result = 0;
}
});
});
//1d100 changes
} else if (rollString.substr(0, rollString.indexOf("[")).trim() === '1d100' || rollString === '1d100' || rollString.substr(0, rollString.indexOf("[")).trim() === '-1d100' || rollString === '-1d100') {
//loop through dice
enrichedRollResult.dice.forEach(function (roll) {
//loop through each result
roll.results.forEach(function (die) {
//change any 100s to 0s
if (die.result === 100 || die.result === -100) {
die.result = 0;
}
});
});
}
}
//set roll A and B
if (enrichedRollResult.dice[0]) {
die0value = enrichedRollResult.dice[0].results[0].result;
}
if (enrichedRollResult.dice[1]) {
die1value = enrichedRollResult.dice[1].results[0].result;
}
//do we need to pick a winner?
if (rollString.includes("[")) {
//set whether each die succeeded
//die 0
if (comparison === '<' && die0value < rollTarget && !this.isSlaAutomaticFailure(die0value, rollString)) {
die0success = true;
}
if (comparison === '<=' && die0value <= rollTarget && !this.isSlaAutomaticFailure(die0value, rollString)) {
die0success = true;
}
if (comparison === '>' && die0value > rollTarget && !this.isSlaAutomaticFailure(die0value, rollString)) {
die0success = true;
}
if (comparison === '>=' && die0value >= rollTarget && !this.isSlaAutomaticFailure(die0value, rollString)) {
die0success = true;
}
//die 1
if (comparison === '<' && die1value < rollTarget && !this.isSlaAutomaticFailure(die1value, rollString)) {
die1success = true;
}
if (comparison === '<=' && die1value <= rollTarget && !this.isSlaAutomaticFailure(die1value, rollString)) {
die1success = true;
}
if (comparison === '>' && die1value > rollTarget && !this.isSlaAutomaticFailure(die1value, rollString)) {
die1success = true;
}
if (comparison === '>=' && die1value >= rollTarget && !this.isSlaAutomaticFailure(die1value, rollString)) {
die1success = true;
}
//set whether each die are a crit
//die 0
if (checkCrit && doubles.has(die0value)) {
die0crit = true;
}
//die 1
if (checkCrit && doubles.has(die1value)) {
die1crit = true;
}
//if [-] pick a new worst number
if (rollString.includes("[-]")) {
//if we are trying to keep the highest
if (rollAim === 'kh') {
//set default result value to the highest value
newTotal = Math.max(die0value, die1value);
//if both are a success and only dice 0 is a crit: don't pick the crit
if (die0success && die1success && die0crit && !die1crit) {
newTotal = die1value;
}
//if both are a success and only dice 1 is a crit: don't pick the crit
if (die0success && die1success && !die0crit && die1crit) {
newTotal = die0value;
}
//if both are a failure and only dice 0 is a crit: pick the crit
if (!die0success && !die1success && die0crit && !die1crit) {
newTotal = die0value;
}
//if both are a failure and only dice 1 is a crit: pick the crit
if (!die0success && !die1success && !die0crit && die1crit) {
newTotal = die1value;
}
//if this is a panic check and both are a failure: pick the worst
if (specialRoll === 'panicCheck' && !useCalm && !die0success && !die1success) {
newTotal = Math.max(die0value, die1value);
}
}
//if we are trying to keep the lowest
if (rollAim === 'kl') {
//set default result value to the lowest value
newTotal = Math.min(die0value, die1value);
//if both are a success and only dice 0 is a crit: don't pick the crit
if (die0success && die1success && die0crit && !die1crit) {
newTotal = die1value;
}
//if both are a success and only dice 1 is a crit: don't pick the crit
if (die0success && die1success && !die0crit && die1crit) {
newTotal = die0value;
}
//if both are a failure and only dice 0 is a crit: pick the crit
if (!die0success && !die1success && die0crit && !die1crit) {
newTotal = die0value;
}
//if both are a failure and only dice 1 is a crit: pick the crit
if (!die0success && !die1success && !die0crit && die1crit) {
newTotal = die1value;
}
//if this is a panic check and both are a failure: pick the worst
if (specialRoll === 'panicCheck' && !useCalm && !die0success && !die1success) {
newTotal = Math.max(die0value, die1value);
}
}
}
//if [+] pick a new best number
if (rollString.includes("[+]")) {
//if we are trying to keep the highest
if (rollAim === 'kh') {
//set default result value to the highest value
newTotal = Math.max(die0value, die1value);
//if both are a success and only dice 0 is a crit: pick the crit
if (die0success && die1success && die0crit && !die1crit) {
newTotal = die0value;
}
//if both are a success and only dice 1 is a crit: pick the crit
if (die0success && die1success && !die0crit && die1crit) {
newTotal = die1value;
}
//if both are a failure and only dice 0 is a crit: don't pick the crit
if (!die0success && !die1success && die0crit && !die1crit) {
newTotal = die1value;
}
//if both are a failure and only dice 1 is a crit: don't pick the crit
if (!die0success && !die1success && !die0crit && die1crit) {
newTotal = die0value;
}
//if this is a panic check and both are a failure: pick the best
if (specialRoll === 'panicCheck' && !useCalm && !die0success && !die1success) {
newTotal = Math.min(die0value, die1value);
}
}
//if we are trying to keep the lowest
if (rollAim === 'kl') {
//set default result value to the lowest value
newTotal = Math.min(die0value, die1value);
//if both are a success and only dice 0 is a crit: pick the crit
if (die0success && die1success && die0crit && !die1crit) {
newTotal = die0value;
}
//if both are a success and only dice 1 is a crit: pick the crit
if (die0success && die1success && !die0crit && die1crit) {
newTotal = die1value;
}
//if both are a failure and only dice 0 is a crit: don't pick the crit
if (!die0success && !die1success && die0crit && !die1crit) {
newTotal = die1value;
}
//if both are a failure and only dice 1 is a crit: don't pick the crit
if (!die0success && !die1success && !die0crit && die1crit) {
newTotal = die0value;
}
//if this is a panic check and both are a failure: pick the best
if (specialRoll === 'panicCheck' && !useCalm && !die0success && !die1success) {
newTotal = Math.min(die0value, die1value);
}
}
}
//we don't need to pick a winner
} else {
//set result value to the only die
newTotal = die0value;
}
//set final total value - apply negative for negative rolls
if (rollString.substr(0, 1) === '-') {
enrichedRollResult._total = newTotal * -1;
} else {
enrichedRollResult._total = newTotal;
}
//enrich roll result object
//add data point: detect critical
if (checkCrit) {
//check for crit
if (doubles.has(enrichedRollResult.total)) {
enrichedRollResult.critical = true;
} else {
enrichedRollResult.critical = false;
}
}
//add data point: detect success/failure
if (rollTarget || rollTarget === 0) {
//check for auto failure
if (this.isSlaAutomaticFailure(enrichedRollResult.total, rollString)) {
// result 99 on d100 is always a failure
enrichedRollResult.success = false;
} else {
//compare values based on compararison setting
if (comparison === '<') {
//check against being under the target
if (enrichedRollResult.total < rollTarget) {
enrichedRollResult.success = true;
} else {
enrichedRollResult.success = false;
}
} else if (comparison === '<=') {
//check against being under or equal to the target
if (enrichedRollResult.total <= rollTarget) {
enrichedRollResult.success = true;
} else {
enrichedRollResult.success = false;
}
} else if (comparison === '>') {
//check against being over the target
if (enrichedRollResult.total > rollTarget) {
enrichedRollResult.success = true;
} else {
enrichedRollResult.success = false;
}
} else if (comparison === '>=') {
//check against being over or equal to the target
if (enrichedRollResult.total >= rollTarget) {
enrichedRollResult.success = true;
} else {
enrichedRollResult.success = false;
}
}
}
enrichedRollResult.isCritSuccess = Boolean(enrichedRollResult.critical && enrichedRollResult.success);
enrichedRollResult.isCritFailure = Boolean(enrichedRollResult.critical && !enrichedRollResult.success);
enrichedRollResult.critClass = enrichedRollResult.isCritSuccess
? "sla-crit-success"
: enrichedRollResult.isCritFailure
? "sla-crit-failure"
: "";
//add data point: outcome HTML
//prepare outcome
//success
if (enrichedRollResult.success) {
outcome = `SUCCESS!`;
} else {
outcome = `FAILURE!`;
}
//crit
if (enrichedRollResult.critical) {
outcome = `CRITICAL ` + outcome;
}
//make HTML
outcomeHtml = `
${outcome}
`;
//update final roll html string
enrichedRollResult.outcomeHtml = outcomeHtml;
}
//add data point: interactive roll HTML
//prepare variables
//make comparison icon
if (comparison === '<') {
compareIcon = '';
} else if (comparison === '<=') {
compareIcon = '';
} else if (comparison === '>') {
compareIcon = '';
} else if (comparison === '>=') {
compareIcon = '';
}
//prepare formula
if (rollTarget || rollTarget === 0) {
//show dice against target
diceFormula = rollString + ' ' + compareIcon + ' ' + rollTarget;
} else {
//just show the dice
diceFormula = rollString;
}
//prepare dice block
//loop through rolls
enrichedRollResult.dice.forEach(function (roll) {
//add header for this roll
diceBlock = diceBlock + `
`;
//add formula and result for this roll
diceBlock = diceBlock + `
${roll.formula}${roll.total.toString()}
`;
//loop through dice
roll.results.forEach(function (die) {
//set highlight if crit is asked for
if (checkCrit) {
//check for crit
if (doubles.has(die.result)) {
//check for success
if (rollTarget) {
//check for auto failure
if (die.result >= 90) {
//result >= 90 is a failure, no highlight needed
critHighlight = ' min';
} else {
//check against beating the target
if (comparison === '<' && die.result < rollTarget) {
critHighlight = ' max';
} else if (comparison === '<=' && die.result <= rollTarget) {
critHighlight = ' max';
} else if (comparison === '>' && die.result > rollTarget) {
critHighlight = ' max';
} else if (comparison === '>=' && die.result >= rollTarget) {
critHighlight = ' max';
} else {
critHighlight = ' min';
}
}
}
} else {
//no highlight needed
critHighlight = '';
}
} else {
//no highlight needed
critHighlight = '';
}
//prepare dice icon
if (roll.faces === 100 || roll.faces === 5) {
diceIcon = `10`;
} else {
diceIcon = roll.faces.toString();
}
//add formula and result for this die
diceBlock = diceBlock + `
${die.result.toString()}
`;
});
//add footer for this roll
diceBlock = diceBlock + `
`;
});
//set final roll variables in to template
rollHtml = `
${diceFormula}
${diceBlock}
${enrichedRollResult.total}
`;
//update final roll html string
enrichedRollResult.rollHtml = rollHtml;
//log what was done
slaDebug(`Enriched roll result. rollString: ${rollString}, rollResult: ${rollResult}, zeroBased: ${zeroBased}, checkCrit: ${checkCrit}, rollTarget: ${rollTarget}, comparison: ${comparison}, specialRoll: ${specialRoll}`);
slaDebug(enrichedRollResult);
//return the enriched roll result object
return enrichedRollResult;
}
//A script to return the data from a table.
async getRollTableData(tableId){
let tableData = await fromIdUuid(tableId,{type:"RollTable"});
//get table name
let tableName = tableData.name;
//get table name
let tableImg = tableData.img;
//get table result
let tableDie = tableData.formula.replace('-1', '');
return tableData;
}
//central table rolling function | TAKES 'W36WFIpCfMknKgHy','1d10','low',true,true,41,'<' | RETURNS chat message showing roll table result
async rollTable(tableId, rollString, aimFor, zeroBased, checkCrit, rollAgainst, comparison, options = {}) {
//init vars
let currentLocation = '';
let tableLocation = '';
let messageTemplate = ``;
let messageContent = ``;
let msgDesc = ``;
let flavorText = ``;
let woundText = ``;
let tableResultType = ``;
let tableResultEdited = ``;
let tableResultFooter = ``;
let chatId = foundry.utils.randomID();
let rollTarget = null;
let valueAddress = [];
let specialRoll = null;
let firstEdition = game.settings.get('sla-mothership', 'firstEdition');
let useCalm = game.settings.get('sla-mothership', 'useCalm');
let androidPanic = game.settings.get('sla-mothership', 'androidPanic');
let tableResultNumber = null;
let secondRoll = false;
let rollResult2 = null;
let parsedRollResult2 = null;
const suppressActorWound = Boolean(options?.suppressActorWound);
//customize this roll if its a unique use-case
//panic check
if (tableId === 'panicCheck') {
//set special roll value for use later
specialRoll = tableId;
//assign variables depending on settings
if (firstEdition) {
if (androidPanic && this.system.class.value.toLowerCase() === 'android') {
if (useCalm) {
tableId = game.settings.get('sla-mothership', 'table1ePanicCalmAndroid');
aimFor = 'low';
zeroBased = true;
checkCrit = true;
rollAgainst = 'system.other.stress.value';
comparison = '<';
} else {
tableId = game.settings.get('sla-mothership', 'table1ePanicStressAndroid');
aimFor = 'high';
zeroBased = false;
checkCrit = false;
rollAgainst = 'system.other.stress.value';
comparison = '>';
}
} else {
if (useCalm) {
tableId = game.settings.get('sla-mothership', 'table1ePanicCalmNormal');
aimFor = 'low';
zeroBased = true;
checkCrit = true;
rollAgainst = 'system.other.stress.value';
comparison = '<';
} else {
tableId = game.settings.get('sla-mothership', 'table1ePanicStressNormal');
aimFor = 'high';
zeroBased = false;
checkCrit = false;
rollAgainst = 'system.other.stress.value';
comparison = '>';
}
}
} else {
if (androidPanic && this.system.class.value.toLowerCase() === 'android') {
if (useCalm) {
tableId = game.settings.get('sla-mothership', 'table0ePanicCalmAndroid');
aimFor = 'low';
zeroBased = true;
checkCrit = true;
rollAgainst = 'system.other.stress.value';
comparison = '<';
} else {
tableId = game.settings.get('sla-mothership', 'table0ePanicStressAndroid');
aimFor = 'high';
zeroBased = false;
checkCrit = false;
rollAgainst = 'system.other.stress.value';
comparison = '>';
}
} else {
if (useCalm) {
tableId = game.settings.get('sla-mothership', 'table0ePanicCalmNormal');
aimFor = 'low';
zeroBased = true;
checkCrit = true;
rollAgainst = 'system.other.stress.value';
comparison = '<';
} else {
tableId = game.settings.get('sla-mothership', 'table0ePanicStressNormal');
aimFor = 'high';
zeroBased = false;
checkCrit = false;
rollAgainst = 'system.other.stress.value';
comparison = '>';
}
}
}
//assign rollString if its a partial
if (rollString === '[-]' || rollString === '' || rollString === '[+]') {
//if 1e and no calm, then 1d20
if (firstEdition && !useCalm) {
rollString = '1d20' + rollString;
}
//if 0e and no calm, then 2d10
if (!firstEdition && !useCalm) {
rollString = '2d10' + rollString;
}
//if calm, then 1d100
if (useCalm) {
rollString = '1d100' + rollString;
}
}
}
//maintenance check
if (tableId === 'maintenanceCheck') {
//set special roll value for use later
specialRoll = tableId;
//assign variables
tableId = game.settings.get('sla-mothership', 'table1eMaintenance');
zeroBased = true;
checkCrit = true;
rollAgainst = 'system.stats.systems.value';
comparison = '<';
}
//bounce this request away if certain parameters are NULL
//if rollString is STILL blank, redirect player to choose the roll
if (!rollString) {
//init vars
let rollDie = '';
//set rollDie
//if 1e and no calm, then 1d20
if (firstEdition) {
rollDie = '1d20';
}
//if 0e and no calm, then 2d10
if (!firstEdition) {
rollDie = '2d10';
}
//if calm, then 1d100
if (useCalm) {
rollDie = '1d100';
}
//run the choose attribute function
let chosenRollType = await this.chooseAdvantage( game.i18n.localize("Mosh.PanicCheck"), rollDie);
//set variables
rollString = chosenRollType[0];
}
let tableData = await resolveRollTableReference(tableId);
if (!tableData) {
ui.notifications.error("The selected wound or panic table could not be found. Please check the table settings.");
slaWarn("rollTable could not resolve table reference", tableId);
return null;
}
//get current compendium
//get table name
let tableName = tableData.name ?? "Roll Table";
//get table name
let tableImg = tableData.img ?? "";
//get table result
let tableDie = String(tableData.formula ?? "1d100").replace('-1', '');
//if rollString is STILL blank, redirect player to choose the roll
if (!rollString) {
//run the choose attribute function
let chosenRollType = await this.chooseAdvantage(tableName, tableDie);
//set variables
rollString = chosenRollType[0];
}
//table specific customizations
//if a table has details in parenthesis, lets remove them
if (tableName.includes(' (')) {
//extract dice needed
tableName = tableName.substr(0, tableName.indexOf(' ('));
}
//if a wound table, add a wound to the player and prepare text for the final message
if (tableName.slice(-5) === 'Wound' && !suppressActorWound) {
let addWound = await this.modifyActor('system.hits.value', 1, null, false);
woundText = addWound[1];
}
//pull stat to roll against, if needed
if (rollAgainst || rollAgainst === 0) {
//turn string address into array
valueAddress = rollAgainst.split('.');
//set rollTarget
rollTarget = valueAddress.reduce((a, v) => a[v], this);
}
//roll the dice
//parse the roll string
let parsedRollString = this.parseRollString(rollString, aimFor);
if (game.settings.get('sla-mothership', 'panicDieTheme') != "") { //We're going to check if the theme field is blank. Otherwise, don't use this.
//set panic die color
let dsnTheme = game.settings.get('sla-mothership', 'panicDieTheme');
//apply theme if this is a panic check
if (tableName === 'Panic Check') {
parsedRollString = parsedRollString + '[' + dsnTheme + ']';
}
}
//roll the dice
let rollResult = await new Roll(parsedRollString).evaluate();
//interpret the results
let parsedRollResult = this.parseRollResult(rollString, rollResult, zeroBased, checkCrit, rollTarget, comparison, specialRoll);
//if this is a panic check, we may need to roll again OR add modifiers to our result total
//roll a second die if needed
if (!parsedRollResult.success && specialRoll === 'maintenanceCheck' && !firstEdition && !useCalm) {
//determine the rollString
let rollString2 = '2d10';
//add modifiers if needed
//0e modifier: + Stress - Resolve
if (specialRoll === 'maintenanceCheck' && !firstEdition && !useCalm) {
rollString2 = rollString2 + ' + ' + this.system.other.stress.value + ' - ' + this.system.other.resolve.value
}
//Calm modifier: + Stress - Resolve
if (specialRoll === 'panicCheck' && useCalm) {
rollString2 = rollString2 + ' + ' + this.system.other.resolve.value
}
//roll second dice
rollResult2 = await new Roll(rollString2).evaluate();
//roll second set of dice
parsedRollResult2 = this.parseRollResult(rollString2, rollResult2, false, false, null, null, specialRoll);
//set marker for HTML
secondRoll = true;
//set table result number
tableResultNumber = parsedRollResult2.total
}
//if this is a maintenance check, we need to roll again if a failure
//roll a second die if needed
if (!parsedRollResult.success && specialRoll === 'maintenanceCheck' && firstEdition) {
//determine the rollString
let rollString2 = '1d100';
//roll second dice
rollResult2 = await new Roll(rollString2).evaluate();
//roll second set of dice
parsedRollResult2 = this.parseRollResult(rollString2, rollResult2, true, false, null, null, specialRoll);
//set marker for HTML
secondRoll = true;
//set table result number
tableResultNumber = parsedRollResult2.total;
//log second die
console.log(`Rolled second die`);
}
//set table result number if null
if (!tableResultNumber) {
tableResultNumber = parsedRollResult.total;
}
//fetch the table result
let tableResult = tableData.getResultsForRoll(tableResultNumber).map((result) => ({
...result,
description: result.description ?? result.text ?? ""
}));
if (!tableResult.length) {
ui.notifications.warn(`No result was found on ${tableName} for roll ${tableResultNumber}.`);
return null;
}
//make any custom changes to chat message
//panic check #19 customiziation
if (tableName === 'Panic Check' && tableResultNumber === 19) {
if (this.system.class.value.toLowerCase() === 'android') {
tableResultEdited = tableResult[0].description.replace(game.i18n.localize("Mosh.HEARTATTACKSHORTCIRCUITANDROIDS"), game.i18n.localize("Mosh.SHORTCIRCUIT"));
} else {
tableResultEdited = tableResult[0].description.replace(game.i18n.localize("Mosh.HEARTATTACKSHORTCIRCUITANDROIDS"), game.i18n.localize("Mosh.HEARTATTACK"));
}
}
//assign message description text
msgDesc = this.getFlavorText('table', tableName.replaceAll('& ', '').replaceAll(' ', '_').toLowerCase(), 'roll');
//assign flavor text
//get main flavor text
flavorText = this.getFlavorText('table', tableName.replaceAll('& ', '').replaceAll(' ', '_').toLowerCase(), 'success');
//append 0e crit success effect
if (!firstEdition && !useCalm && parsedRollResult.success && parsedRollResult.critical) {
flavorText = flavorText + game.i18n.localize("Mosh.Relieve1Stressqbq694JMbXeZrHj");
}
//append Calm effects for Critical Panic Success
if (useCalm && parsedRollResult.success && parsedRollResult.critical) {
flavorText = flavorText + game.i18n.localize("Mosh.Gain1d10Calmk2TtLFOG9mGaWVx31d10Calm");
}
//append Calm effects for Critical Panic Failure
if (useCalm && !parsedRollResult.success && parsedRollResult.critical) {
tableResultFooter = `
`,
buttons: [
{
label: game.i18n.localize("Mosh.Reload"),
action: `action_reload`,
callback: () => this.promptWeaponReload(itemId),
icon: `fas fa-check`
},
{
label: `Cancel`,
action: `action_cancel`,
callback: () => {},
icon: `fas fa-times`
}
]
};
//render dialog
const dialog = new foundry.applications.api.DialogV2(dialogData).render({force: true});
});
//log what was done
slaDebug(`Asked for reload.`);
}
//tell the player we are out of ammo
async outOfAmmo() {
//wrap the whole thing in a promise, so that it waits for the form to be interacted with
return new Promise(async (resolve) => {
//create final dialog data
const dialogData = {
window: {
title: game.i18n.localize("Mosh.WeaponIssue")
},
classes: ["macro-popup-dialog"],
content: `
` + game.i18n.localize("Mosh.OutOfAmmo") + `
`,
buttons: [
{
label: game.i18n.localize("Mosh.OK"),
action: `action_okay`,
callback: () => {},
icon: 'fas fa-check'
}
]
};
//render dialog
const dialog = new foundry.applications.api.DialogV2(dialogData).render({force: true});
});
//log what was done
slaDebug(`Told user they are out of ammo.`);
}
//reload the players weapon
async reloadWeapon(itemId, ammoTag = null) {
//init vars
let messageTemplate = ``;
let messageContent = ``;
let msgBody = ``;
let chatId = foundry.utils.randomID();
//dupe item to work with
var item;
item = foundry.utils.duplicate(this.getEmbeddedDocument('Item', itemId));
//reload
if (!item.system.useAmmo) {
//exit function (it should not be possible to get here)
return;
} else {
const currentLoadedTag = this.normalizeAmmoTag(item.system.ammoLoadedType ?? item.system.ammoTag ?? "STD");
const selectedAmmoTag = this.normalizeAmmoTag(ammoTag ?? currentLoadedTag);
const changingAmmo = selectedAmmoTag !== currentLoadedTag;
//are we at full shots already?
if (item.system.curShots === item.system.shots && !changingAmmo) {
//log what was done
slaDebug(`Can't reload, already at full shots.`);
//exit function (it should not be possible to get here)
return;
//are we out of ammo?
} else if (!this.getWeaponReserveForTag(item, selectedAmmoTag)) {
//tell player we are out of ammo
let t = await this.outOfAmmo();
//log what was done
slaDebug(`Can't reload, no ammo left.`);
//exit function
return;
} else {
const reloadOutcome = await this.reloadWeaponData(item, selectedAmmoTag);
if (!reloadOutcome) {
let t = await this.outOfAmmo();
return;
}
await this.updateEmbeddedDocuments('Item', [item]);
const calibre = this.getWeaponAmmoCalibre(item) || item.system.ammoType || "Unknown calibre";
const swappedText = reloadOutcome.swapped
? ` Swapped out ${reloadOutcome.returnedRounds} round${reloadOutcome.returnedRounds === 1 ? "" : "s"} of ${currentLoadedTag}.`
: "";
msgBody = `${game.i18n.localize("Mosh.WeaponReloaded")} ${reloadOutcome.reloadAmount} round${reloadOutcome.reloadAmount === 1 ? "" : "s"} of ${calibre} ${selectedAmmoTag}. Magazine ${reloadOutcome.totalLoaded}/${item.system.shots}. Reserve ${reloadOutcome.reserveAfter}.${swappedText}`;
}
}
//generate chat message
//prepare data
let messageData = {
actor: this,
item: item,
msgBody: msgBody
};
//prepare template
messageTemplate = 'systems/sla-mothership/templates/chat/reload.html';
//render template
messageContent = await foundry.applications.handlebars.renderTemplate(messageTemplate, messageData);
//push message
ChatMessage.create({
id: chatId,
user: game.user.id,
speaker: {
actor: this.id,
token: this.token,
alias: this.name
},
content: messageContent
}, {
keepId: true
});
//log what was done
slaDebug(`Reloaded weapon.`);
}
//make the player take bleeding damage
async takeBleedingDamage() {
//init vars
let chatId = foundry.utils.randomID();
//determine bleeding amount
let healthLost = this.items.getName("Bleeding").system.severity * -1;
//run the function for the player's 'Selected Character'
let modification = await this.modifyActor('system.health.value', healthLost, null, false);
//get flavor text
let msgFlavor = this.getFlavorText('item', 'condition', 'bleed');
let msgOutcome = modification[1];
let healthLostText = game.i18n.localize("Mosh.attribute.health.decreaseHeader.human")
//create chat message text
let messageContent = `
${healthLostText}
${msgFlavor}
${msgOutcome}
`;
//push message
ChatMessage.create({
id: chatId,
user: game.user.id,
speaker: {
actor: this.id,
token: this.token,
alias: this.name
},
content: messageContent
}, {
keepId: true
});
//log what was done
console.log(`Took bleeding damage.`);
}
//make the player take radiation damage
async takeRadiationDamage() {
//init vars
let chatId = foundry.utils.randomID();
//reduce all stats and saves by 1
this.modifyActor('system.stats.strength.value', -1, null, false);
this.modifyActor('system.stats.speed.value', -1, null, false);
this.modifyActor('system.stats.intellect.value', -1, null, false);
this.modifyActor('system.stats.combat.value', -1, null, false);
this.modifyActor('system.stats.sanity.value', -1, null, false);
this.modifyActor('system.stats.fear.value', -1, null, false);
this.modifyActor('system.stats.body.value', -1, null, false);
//get flavor text
let msgFlavor = this.getFlavorText('item', 'condition', 'radiation');
let msgOutcome = game.i18n.localize('Mosh.AllStatsAndSavesDecreasedBy');
msgOutcome += ` 1.`;
let radiationDamage = game.i18n.localize('Mosh.RadiationDamage');
//create chat message text
let messageContent = `
${radiationDamage}
${msgFlavor}
${msgOutcome}
`;
//push message
ChatMessage.create({
id: chatId,
user: game.user.id,
speaker: {
actor: this.id,
token: this.token,
alias: this.name
},
content: messageContent
}, {
keepId: true
});
//log what was done
console.log(`Took radiation damage.`);
}
//make the player take radiation damage
async takeCryoDamage(rollString) {
//init vars
let chatId = foundry.utils.randomID();
//roll the dice
//parse the roll string
let parsedRollString = this.parseRollString(rollString, 'low');
//roll the dice
let rollResult = await new Roll(parsedRollString).evaluate();
//interpret the results
let parsedRollResult = this.parseRollResult(rollString, rollResult, false, false, null, null, null);
//reduce all stats and saves by roll result
this.modifyActor('system.stats.strength.value', parsedRollResult.total, null, false);
this.modifyActor('system.stats.speed.value', parsedRollResult.total, null, false);
this.modifyActor('system.stats.intellect.value', parsedRollResult.total, null, false);
this.modifyActor('system.stats.combat.value', parsedRollResult.total, null, false);
this.modifyActor('system.stats.sanity.value', parsedRollResult.total, null, false);
this.modifyActor('system.stats.fear.value', parsedRollResult.total, null, false);
this.modifyActor('system.stats.body.value', parsedRollResult.total, null, false);
//get flavor text
let msgFlavor = this.getFlavorText('item', 'condition', 'cryo');
let msgOutcome = game.i18n.localize('Mosh.AllStatsAndSavesDecreasedBy');
msgOutcome += ` ` + Math.abs(parsedRollResult.total).toString() + `.`;
let cryoDamage = game.i18n.localize("Mosh.CryofreezeDamage")
//create chat message text
let messageContent = `
${cryoDamage}
${msgFlavor}
${msgOutcome}
`;
//push message
ChatMessage.create({
id: chatId,
user: game.user.id,
speaker: {
actor: this.id,
token: this.token,
alias: this.name
},
content: messageContent
}, {
keepId: true
});
//log what was done
console.log(`Took cryofreeze damage.`);
}
//ask the player to choose cover
async chooseCover() {
//wrap the whole thing in a promise, so that it waits for the form to be interacted with
return new Promise(async (resolve) => {
//init vars
let none_checked = ``;
let insignificant_checked = ``;
let light_checked = ``;
let heavy_checked = ``;
//fetch character AP/DR/cover
let curAP = this.system.stats.armor.mod;
let curDR = this.system.stats.armor.damageReduction;
let curCover = this.system.stats.armor.cover;
//set checkbox to current cover + adjust curAP/DR
if (curCover === 'none') {
none_checked = `checked`;
}
if (curCover === 'insignificant') {
insignificant_checked = `checked`;
}
if (curCover === 'light') {
light_checked = `checked`;
}
if (curCover === 'heavy') {
heavy_checked = `checked`;
}
//create pop-up HTML
let msgContent = await foundry.applications.handlebars.renderTemplate('systems/sla-mothership/templates/dialogs/choose-cover-dialog.html', {
curDR:curDR,
curAP:curAP,
none_checked: none_checked,
insignificant_checked:insignificant_checked,
light_checked:light_checked,
heavy_checked:heavy_checked,
curCover:curCover
});
//create final dialog data
const dialogData = {
window: {title: game.i18n.localize("Mosh.Cover")},
classes: ["macro-popup-dialog"],
position: {width: 600},
content: msgContent,
buttons: [
{
label: game.i18n.localize("Mosh.OK"),
action: `action_okay`,
callback: (event, button, dialog) => {
this.update({
'system.stats.armor.cover': button.form.querySelector("input[name='cover']:checked")?.getAttribute("value")
});
console.log(`User's cover is now:${button.form.querySelector("input[name='cover']:checked")?.getAttribute("value")}`);
},
icon: 'fas fa-check'
}
]
};
//render dialog
const dialog = new foundry.applications.api.DialogV2(dialogData).render({force: true});
});
}
async chooseSlaWoundTable() {
return new Promise(async (resolve) => {
const woundTables = [
{ key: 'table1eWoundBluntForce', label: 'Blunt Force' },
{ key: 'table1eWoundBleeding', label: 'Bleeding' },
{ key: 'table1eWoundGunshot', label: 'Gunshot' },
{ key: 'table1eWoundFireExplosives', label: 'Fire & Explosives' },
{ key: 'table1eWoundGoreMassive', label: 'Gore & Massive' }
];
const dialogData = {
window: { title: 'Choose Wound Table' },
classes: ['macro-popup-dialog'],
position: { width: 660, height: 260 },
content: `
Select the wound table to roll for this operative.
`,
buttons: woundTables.map((entry) => ({
label: entry.label,
action: entry.key,
callback: async () => {
const tableId = game.settings.get('sla-mothership', entry.key);
if (!tableId) {
ui.notifications.warn(`No wound table is configured for ${entry.label}.`);
return resolve(null);
}
const tableData = await resolveSlaWoundTableBySetting(entry.key);
if (!tableData) {
ui.notifications.warn(`The ${entry.label} wound table could not be found.`);
return resolve(null);
}
await this.rollTable(tableData.uuid ?? tableData.id ?? tableId, null, null, null, null, null, null);
resolve(entry.key);
}
}))
};
dialogData.buttons.push({
label: 'Cancel',
action: 'cancel',
callback: () => resolve(null)
});
new foundry.applications.api.DialogV2(dialogData).render({ force: true });
});
}
//activate ship's distress signal
async distressSignal() {
//wrap the whole thing in a promise, so that it waits for the form to be interacted with
return new Promise(async (resolve) => {
//create pop-up HTML
let msgContent = await foundry.applications.handlebars.renderTemplate('systems/sla-mothership/templates/dialogs/distres-signal-dialog.html');
//create final dialog data
const dialogData = {
window: {title: game.i18n.localize("Mosh.DistressSignal")},
classes: ["macro-popup-dialog"],
position: {width: 600,height: 265},
content: msgContent,
buttons: [
{
label: game.i18n.localize("Mosh.Advantage"),
action: `action_advantage`,
callback: () => this.rollTable(game.settings.get('sla-mothership', 'table1eDistressSignal'), `1d10 [+]`, `low`, true, false, null, null),
icon: `fas fa-angle-double-up`
},
{
label: game.i18n.localize("Mosh.Normal"),
action: `action_normal`,
callback: () => this.rollTable(game.settings.get('sla-mothership', 'table1eDistressSignal'), `1d10`, `low`, true, false, null, null),
icon: `fas fa-minus`
},
{
label: game.i18n.localize("Mosh.Disadvantage"),
action: `action_disadvantage`,
callback: () => this.rollTable(game.settings.get('sla-mothership', 'table1eDistressSignal'), `1d10 [-]`, `low`, true, false, null, null),
icon: `fas fa-angle-double-down`
}
]
};
//render dialog
const dialog = new foundry.applications.api.DialogV2(dialogData).render({force: true});
});
}
//activate ship's distress signal
async maintenanceCheck() {
//wrap the whole thing in a promise, so that it waits for the form to be interacted with
return new Promise(async (resolve) => {
//create pop-up HTML
let msgContent = `
`;
//create final dialog data
const dialogData = {
window: {title: game.i18n.localize("Mosh.MaintenanceCheck")},
classes: ["macro-popup-dialog"],
position: {width: 600,height: 265},
content: msgContent,
buttons: [
{
label: game.i18n.localize("Mosh.Advantage"),
action: `action_advantage`,
callback: () => this.rollTable(`maintenanceCheck`, `1d100 [+]`, `low`, null, null, null, null),
icon: `fas fa-angle-double-up`
},
{
label: game.i18n.localize("Mosh.Normal"),
action: `action_normal`,
callback: () => this.rollTable(`maintenanceCheck`, `1d100`, `low`, null, null, null, null),
icon: `fas fa-minus`
},
{
label: game.i18n.localize("Mosh.Disadvantage"),
action: `action_disadvantage`,
callback: () => this.rollTable(`maintenanceCheck`, `1d100 [-]`, `low`, null, null, null, null),
icon: `fas fa-angle-double-down`
}
]
};
//render dialog
const dialog = new foundry.applications.api.DialogV2(dialogData).render({force: true});
});
}
//activate ship's distress signal
async bankruptcySave() {
//wrap the whole thing in a promise, so that it waits for the form to be interacted with
return new Promise(async (resolve) => {
//create pop-up HTML
let msgContent = `
`;
//create final dialog data
const dialogData = {
window: {title: game.i18n.localize("Mosh.BankrupcySave")},
classes: ["macro-popup-dialog"],
position: {width: 600,height: 265},
content: msgContent,
buttons: [
{
label: game.i18n.localize("Mosh.Advantage"),
action: `action_advantage`,
callback: () => this.rollCheck(`1d100 [+]`, `low`, `bankruptcySave`, null, null, null),
icon: `fas fa-angle-double-up`
},
{
label: game.i18n.localize("Mosh.Normal"),
action: `action_normal`,
callback: () => this.rollCheck(`1d100`, `low`, `bankruptcySave`, null, null, null),
icon: `fas fa-minus`
},
{
label: game.i18n.localize("Mosh.Disadvantage"),
action: `action_disadvantage`,
callback: () => this.rollCheck(`1d100 [-]`, `low`, `bankruptcySave`, null, null, null),
icon: `fas fa-angle-double-down`
}
]
};
//render dialog
new foundry.applications.api.DialogV2(dialogData).render({force: true});
});
}
//activate ship's distress signal
async moraleCheck() {
//wrap the whole thing in a promise, so that it waits for the form to be interacted with
return new Promise(async (resolve) => {
//create pop-up HTML
let moraleCheck = game.i18n.localize("Mosh.MoraleCheck")
let moraleCheckDescription = game.i18n.localize("Mosh.MoraleCheckDescription")
let msgContent = `