';
content += `
Rolled: ${dieVal} + ${stress} Stress = ${rawTotal}
`;
if (panicReduction > 0) {
content += `
Drug Panic Reduction: -${panicReduction} => ${total}
`;
}
if (total <= 6) {
content += '
✓ Held it together! (No panic effect)
';
} else {
const panicEffect = this._getPanicEffect(total);
const appliedChanges = await this._applyPanicConsequences(actor, panicEffect);
content += `
${panicEffect.name} : ${panicEffect.text}
`;
if (appliedChanges.length > 0) {
content += `
Auto-applied: ${appliedChanges.join(' | ')}
`;
}
if (panicEffect?.autoMentalCritical) {
content += '
Auto: Rolling Mental Critical Injury.
';
}
}
content += '
';
ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
content: content,
rolls: [roll]
});
// Apply condition based on panic severity
const panicLevel = total || 0;
if (panicLevel >= 10) {
await _applyStatusCondition(actor, 'sla-panicking', 'Panicking', 'icons/svg/terror.svg');
ui.notifications.warn(`${actor.name} is PANICKING! (-2 dice to all rolls)`);
} else if (panicLevel >= 7) {
await _applyStatusCondition(actor, 'sla-shaken', 'Shaken', 'icons/svg/terror.svg');
ui.notifications.warn(`${actor.name} is SHAKEN! (-1 die to all rolls)`);
}
if (total > 6) {
const panicEffect = this._getPanicEffect(total);
if (panicEffect?.autoMentalCritical) {
await this._rollMentalCritical(actor);
}
}
}
async _rollInitiative(useMobility = true, preferredCombatantId = null, preferredCombatId = null) {
// ── Guard: prevent double-calls within 2 seconds (DSN/tracker re-render race) ─
const lockKey = `_initLock_${this.actor?.id}`;
if (window[lockKey]) return;
window[lockKey] = true;
setTimeout(() => { delete window[lockKey]; }, 2000);
const actor = this.actor;
const system = actor.system;
// ── Mode: AGI + Mobility OR WIT + Observation ──────────────────────────
const attrKey = useMobility ? "agility" : "wits";
const skillKey = useMobility ? "mobility" : "observation";
const modeLabel = useMobility ? "AGI + Mobility" : "WIT + Observation";
const attrVal = parseInt(system.attributes?.[attrKey]?.value) || 0;
const skillVal = parseInt(system.skills?.[skillKey]?.value) || 0;
// Base score is fixed — attribute + skill value
const baseScore = attrVal + skillVal;
// ── Condition penalties reduce the roll pool (conditions affect all rolls) ─
const initCondMods = _getConditionModifiers(actor, { attribute: attrKey, skill: skillKey });
// ── Roll the same pool for the bonus successes (no stress dice) ──────────
const pool = Math.max(1, baseScore + initCondMods.bonus);
const roll = new Roll(`${pool}d6`);
await roll.evaluate();
if (game.dice3d) {
await dsnSafeShow(roll);
}
const rollSuccesses = roll.dice[0].results.filter(r => r.result === 6).length;
// ── Specialty bonuses ────────────────────────────────────────────────────
let specialtyBonus = 0;
const specialtyBreakdown = [];
for (const item of actor.items) {
if (item.type !== "specialty") continue;
// Fast Reflexes: +2 to initiative
if (item.name === "Fast Reflexes") {
specialtyBonus += 2;
specialtyBreakdown.push("Fast Reflexes +2");
continue;
}
// Any specialty with an explicit initiativeBonus field
const bonus = parseInt(item.system?.initiativeBonus) || 0;
if (bonus !== 0) {
specialtyBonus += bonus;
specialtyBreakdown.push(`${item.name} ${bonus > 0 ? '+' : ''}${bonus}`);
}
}
// ── Gear modifiers (equipped items with initiativeMod) ───────────────────
let gearBonus = 0;
const gearBreakdown = [];
for (const item of actor.items) {
const mod = this._clampInitiativeMod(item?.system?.initiativeMod);
if (mod === 0) continue;
const isEquipped = item?.system?.equipped === true || item?.system?.isEquipped === true;
if (!isEquipped) continue;
gearBonus += mod;
gearBreakdown.push(`${item.name} ${mod > 0 ? '+' : ''}${mod}`);
}
// ── Active effect modifiers ──────────────────────────────────────────────
for (const e of (actor.effects || [])) {
const effMod = e.getFlag('slazero2', 'initiativeMod');
if (effMod) {
const em = Number(effMod) || 0;
if (em !== 0) {
gearBonus += em;
gearBreakdown.push(`${e.name} ${em > 0 ? '+' : ''}${em}`);
}
}
}
// ── Drug modifiers ───────────────────────────────────────────────────────
const drugMods = this._getDrugRollModifiers(actor, { attribute: attrKey, skill: skillKey });
if (drugMods.total !== 0) {
gearBonus += drugMods.total;
gearBreakdown.push(`Drugs ${drugMods.total > 0 ? '+' : ''}${drugMods.total}`);
}
// ── Final initiative = base + roll successes + bonuses ───────────────────
const finalInit = baseScore + rollSuccesses + specialtyBonus + gearBonus;
// ── Tie-breaker d6 (silent — no Roll object so Dice So Nice doesn't animate it) ─
const tieVal = Math.floor(Math.random() * 6) + 1;
// ── Store on actor + push to combat tracker (single write, no yze flags) ─
await actor.update({ "system.combat.initiative": finalInit });
let combat = (preferredCombatId ? game.combats?.get(preferredCombatId) : null) || game.combat || null;
let combatant = null;
if (preferredCombatantId && combat) {
combatant = combat.combatants.get(preferredCombatantId) || null;
}
if (!combatant) {
const ctx = await this._ensureActorInEncounter(actor, preferredCombatId);
combat = ctx?.combat || combat;
combatant = ctx?.combatant || (combat?.getCombatantByActor(actor.id) ?? null);
}
if (combatant) {
// Single update: set initiative + sync yze-combat display value in one write
await combatant.update({
initiative: finalInit,
"flags.yze-combat.cardValue": finalInit
});
}
if (combat) {
if (typeof combat.setupTurns === "function") combat.setupTurns();
ui.combat?.render(false);
}
// ── Chat card ────────────────────────────────────────────────────────────
const diceHTML = roll.dice[0].results.map(r =>
`
⚡ Initiative — ${modeLabel}
Base: ${attrLabel} ${attrVal} + ${skillLabel} ${skillVal} = ${baseScore}
Roll (${pool}d6): ${diceHTML}
Successes: +${rollSuccesses}
${totalBonus !== 0 ? `
Bonuses: ${totalBonus > 0 ? '+' : ''}${totalBonus} (${bonusLine})
` : ''}
${initCondMods.bonus !== 0 ? `
Conditions: ${initCondMods.bonus} (${initCondMods.breakdown.join(', ')})
` : ''}
Final Initiative: ${finalInit}
Tie-breaker d6: ${tieVal}
`;
// No rolls:[roll] here — the card already shows individual die faces,
// and omitting it prevents DSN from showing the dice a second time via
// the chat message creation hook.
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
flavor: `${actor.name} — Initiative`,
content
});
return { roll, finalInit, tieVal };
}
/**
* Ensure actor is present in the current scene encounter if a token is available.
* If there is no encounter on scene, create one.
* @param {Actor} actor
* @returns {Promise<{combat: Combat|null, combatant: Combatant|null}>}
* @private
*/
async _ensureActorInEncounter(actor, preferredCombatId = null) {
const scene = game.scenes?.current || canvas?.scene;
if (!scene) return { combat: game.combat || null, combatant: game.combat?.getCombatantByActor(actor.id) || null };
let tokenDoc = null;
const controlled = canvas?.tokens?.controlled?.find(t => t.actor?.id === actor.id);
if (controlled?.document) {
tokenDoc = controlled.document;
} else {
const placeable = canvas?.tokens?.placeables?.find(t => t.actor?.id === actor.id);
tokenDoc = placeable?.document || null;
}
if (!tokenDoc) {
return { combat: game.combat || null, combatant: game.combat?.getCombatantByActor(actor.id) || null };
}
let combat = preferredCombatId ? (game.combats?.get(preferredCombatId) || null) : null;
if (!combat) combat = game.combat;
if (!combat || combat.scene?.id !== scene.id) {
combat = game.combats?.find(c => c.scene?.id === scene.id && c.active)
|| game.combats?.find(c => c.scene?.id === scene.id)
|| null;
}
if (!combat) {
combat = await Combat.create({ scene: scene.id, active: true });
}
let combatant = combat.getCombatantByToken(tokenDoc.id) || combat.getCombatantByActor(actor.id);
if (!combatant) {
const actorInit = Number(actor.system?.combat?.initiative ?? 0) || 0;
const created = await combat.createEmbeddedDocuments("Combatant", [{
tokenId: tokenDoc.id,
actorId: actor.id,
hidden: tokenDoc.hidden === true,
initiative: actorInit,
flags: {
"yze-combat": {
cardValue: actorInit,
cardName: "INIT"
}
}
}]);
combatant = created?.[0] || combat.getCombatantByToken(tokenDoc.id) || null;
}
return { combat, combatant };
}
/**
* Roll critical failure table for weapon attacks
* @param {Actor} actor
* @param {boolean} isRanged
* @private
*/
async _rollWeaponCriticalFail(actor, isRanged = true) {
const tableName = isRanged
? "Ranged Weapon Critical Fail (2-12)"
: "Melee Weapon Critical Fail (2-12)";
const table = game.tables.getName(tableName);
if (!table) {
ui.notifications.warn(`Critical fail table not found: ${tableName}`);
return;
}
const roll = await (new Roll("2d6")).evaluate();
if (game.dice3d) {
await dsnSafeShow(roll);
}
await table.draw({ roll, displayChat: true });
}
/**
* Split roll results by normal vs stress dice counts
* @param {Roll} roll
* @param {object} counts
* @param {number} counts.normalDice
* @param {number} counts.stressDice
* @returns {{normalResults: number[], stressResults: number[]}}
* @private
*/
_splitRollResultsByType(roll, { normalDice = 0, stressDice = 0 }) {
const diceTerms = roll.dice || [];
let normalResults = [];
let stressResults = [];
if (normalDice > 0 && stressDice > 0) {
normalResults = (diceTerms[0]?.results || []).map(r => r.result);
stressResults = (diceTerms[1]?.results || []).map(r => r.result);
} else if (normalDice > 0) {
normalResults = (diceTerms[0]?.results || []).map(r => r.result);
} else if (stressDice > 0) {
stressResults = (diceTerms[0]?.results || []).map(r => r.result);
}
return { normalResults, stressResults };
}
/**
* Build a unified dice array with type annotations for chat rendering
* @param {number[]} normalResults
* @param {number[]} stressResults
* @returns {{result:number, type:string}[]}
* @private
*/
_buildDiceArray(normalResults = [], stressResults = []) {
return [
...normalResults.map(result => ({ result, type: 'normal' })),
...stressResults.map(result => ({ result, type: 'stress' }))
];
}
/**
* Build Dice So Nice options to color stress dice
* @param {Roll} roll
* @param {number} normalDice
* @param {number} stressDice
* @returns {object}
* @private
*/
_applyDiceSoNiceColors(roll, normalDice = 0, stressDice = 0) {
const diceTerms = roll.dice || [];
if (diceTerms.length === 0) return;
let normalIndex = null;
let stressIndex = null;
if (normalDice > 0 && stressDice > 0) {
normalIndex = 0;
stressIndex = 1;
} else if (normalDice > 0) {
normalIndex = 0;
} else if (stressDice > 0) {
stressIndex = 0;
}
// Normal dice → yellow Alien RPG d6 (skull on 1, blank 2-5, success on 6)
if (normalIndex !== null && diceTerms[normalIndex]) {
diceTerms[normalIndex].options = diceTerms[normalIndex].options || {};
diceTerms[normalIndex].options.appearance = { system: "slazero2" };
}
// Stress dice → black Alien RPG d6 (blank 1-5, success on 6)
if (stressIndex !== null && diceTerms[stressIndex]) {
diceTerms[stressIndex].options = diceTerms[stressIndex].options || {};
diceTerms[stressIndex].options.appearance = { system: "ze-stress-dice" };
}
}
/**
* Derive immediate self-only stat changes from panic effect text
* @param {string} text
* @returns {object|null}
* @private
*/
_derivePanicSelfChangesFromText(text = "") {
const changes = {};
const sentences = String(text)
.split(".")
.map(s => s.trim())
.filter(Boolean);
for (const sentence of sentences) {
// Skip non-immediate or non-self effects.
if (/(allies|ally|if you refuse|per round|until end of scene|gm chooses|choose:)/i.test(sentence)) {
continue;
}
let match = sentence.match(/\bGain\s*\+?(\d+)\s*(Stress|Resolve|Health)\b/i);
if (match) {
const value = Number(match[1]) || 0;
const stat = String(match[2] || "").toLowerCase();
if (value > 0 && stat) changes[stat] = (changes[stat] || 0) + value;
continue;
}
match = sentence.match(/\b(Lose|Suffer)\s+(\d+)\s*(Stress|Resolve|Health)\b/i);
if (match) {
const value = Number(match[2]) || 0;
const stat = String(match[3] || "").toLowerCase();
if (value > 0 && stat) changes[stat] = (changes[stat] || 0) - value;
}
}
return Object.keys(changes).length ? changes : null;
}
/**
* Get panic effect for a given total
* @param {number} total - The panic roll total
* @returns {Object} Panic effect details
* @private
*/
_getPanicEffect(total) {
const effects = {
7: { name: "Nervous Twitch", text: "Gain +1 Stress. All allies in Short range gain +1 Stress as your tension spikes the squad.", directive: "" },
8: { name: "Tremble", text: "Until end of scene: -2 dice to all AGI-based rolls (Marksmanship, Mobility, Stealth).", directive: "tremble" },
9: { name: "Fumble/Jam", text: "GM chooses one: drop held item; weapon jams (1 Fast action to clear); comms/camera mishandled (next related roll -2 dice).", directive: "" },
10: { name: "Freeze", text: "Lose your next action. You may defend normally but cannot Push again until you act.", directive: "freeze" },
11: { name: "Seek Cover", text: "Use next action moving to cover/safer position. If you refuse, take +1 Stress per round.", directive: "seekCover" },
12: { name: "Outburst on Camera", text: "You shout/scream/swear. Lose next action; position revealed (enemies +2 dice to locate).", directive: "" },
13: { name: "Flight Response", text: "Must retreat from threat for at least 1 round or one zone away. Cannot take Slow actions during retreat.", directive: "flight" },
14: { name: "Operative Breakdown", text: "Choose: BERSERK (attack nearest creature next action, friend/foe) OR CATATONIC (incapacitated D6 rounds). Allies who witness gain +1 Stress.", directive: "breakdown" },
15: { name: "Mind Fracture", text: "Immediately suffer a Mental Critical Injury (rolling on Mental Critical Table now).", directive: "", autoMentalCritical: true }
};
const effectTotal = Math.min(total, 15);
const effect = effects[effectTotal] || effects[15];
const derivedChanges = this._derivePanicSelfChangesFromText(effect?.text || "");
if (derivedChanges) {
return { ...effect, selfChanges: { ...(effect.selfChanges || {}), ...derivedChanges } };
}
return effect;
}
/**
* Roll Mental Critical Injury (D66)
* @param {Actor} actor - The actor suffering the critical
* @private
*/
async _rollMentalCritical(actor) {
// Roll D66 as single Roll so Dice So Nice animates both dice together
const d66Roll = new Roll('1d6 + 1d6', {});
await d66Roll.evaluate();
// Apply alien dice skin — force slazero2 system via appearance
for (const term of (d66Roll.dice || [])) {
term.options = term.options || {};
term.options.appearance = { system: "slazero2" };
}
const tens = d66Roll.dice[0].results[0].result;
const ones = d66Roll.dice[1].results[0].result;
const key = `${tens}.${ones}`;
// Get critical from lookup
const critical = this._getMentalCritical(key);
// Add to actor's critical injuries
const injuries = actor.system.criticalInjuries.mental || [];
injuries.push({
key: key,
name: critical.name,
lethal: critical.lethal,
timeLimit: critical.timeLimit || "",
effects: critical.effects,
recovery: critical.recovery,
timestamp: Date.now()
});
await actor.update({ 'system.criticalInjuries.mental': injuries });
// Create chat card
let content = '';
content += `
Rolled: ${tens} ${ones} → ${tens}.${ones}
`;
content += `
${critical.name}`;
if (critical.lethal) {
content += ' ☠️ LETHAL ';
}
content += ' ';
if (critical.timeLimit) {
content += `
Time Limit: ${critical.timeLimit}
`;
}
content += `
Effects: ${critical.effects}
`;
content += `
Recovery: ${critical.recovery}
`;
content += '
';
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
flavor: `';
content += `
Rolled: ${tens} ${ones} → ${tens}.${ones}
`;
content += `
${critical.name}`;
if (critical.lethal) {
content += ' ☠️ LETHAL ';
}
content += ' ';
if (critical.timeLimit) {
content += `
Time Limit: ${critical.timeLimit}
`;
}
content += `
Effects: ${critical.effects}
`;
content += `
Healing: ${critical.healing}
`;
content += '
';
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
flavor: `}
*/
// ── Crit dedup guard ──────────────────────────────────────────────────────────
// Tracks when a crit was last fired per actor so auto-crit and manual button
// can't both fire in quick succession for the same event.
const _critLastFired = new Map(); // actorId → { physical?: ms, mental?: ms }
function _critCooldownActive(actorId, kind, windowMs = 8000) {
const entry = _critLastFired.get(actorId) || {};
const last = entry[kind] || 0;
return (Date.now() - last) < windowMs;
}
function _critRecordFired(actorId, kind) {
const entry = _critLastFired.get(actorId) || {};
entry[kind] = Date.now();
_critLastFired.set(actorId, entry);
}
async function runAutoCritical(actor, kind) {
// Skip if a crit of this kind was already rolled very recently for this actor
if (_critCooldownActive(actor.id, kind)) {
console.warn(`SLA Zero Two | Auto-crit (${kind}) skipped for ${actor.name} — already fired within cooldown window.`);
return;
}
_critRecordFired(actor.id, kind);
const sheet = actor?.sheet;
if (kind === "physical") {
if (sheet && typeof sheet._rollPhysicalCritical === "function") {
await sheet._rollPhysicalCritical(actor);
return;
}
await ZeroEngineActorSheet.prototype._rollPhysicalCritical.call({
_getPhysicalCritical: ZeroEngineActorSheet.prototype._getPhysicalCritical
}, actor);
return;
}
if (sheet && typeof sheet._rollMentalCritical === "function") {
await sheet._rollMentalCritical(actor);
return;
}
await ZeroEngineActorSheet.prototype._rollMentalCritical.call({
_getMentalCritical: ZeroEngineActorSheet.prototype._getMentalCritical
}, actor);
}
// Racial derived-stat modifiers — mirrors SLA_RACES healthMod/resolveMod.
// v2.0.1: these were all zero in Zero Engine 1.7.6, silently dropping every
// racial Health/Resolve bonus from actual play. Keys are canonical race keys.
const RACE_DERIVED_MODS = {
human: { health: 0, resolve: 0 },
ebon: { health: 0, resolve: 1 },
brainwaster: { health: 0, resolve: 0 },
stormer: { health: 2, resolve: 0 },
shaktar: { health: 1, resolve: 0 },
wraithraider: { health: 0, resolve: 1 },
frother: { health: 1, resolve: -1 },
vevaphon: { health: 2, resolve: -1 }
};
/** Canonicalize a stored race string to a RACE_DERIVED_MODS / SLA_RACES key */
function canonicalRaceKey(raw) {
const k = String(raw || "").toLowerCase().replace(/[\s-]/g, "").replace("313s", "").trim();
if (k === "wraith") return "wraithraider";
return k;
}
const TRAINING_DERIVED_MODS = {
scout: { health: 0, resolve: 0 },
investigator: { health: 0, resolve: 0 },
corporate: { health: 0, resolve: 0 },
medic: { health: 0, resolve: 0 },
"ebon-investigator": { health: 0, resolve: 0 },
"brainwaster-operative": { health: 0, resolve: 0 }
};
function toNumber(value, fallback = 0) {
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
function clampNumber(value, min, max) {
return Math.max(min, Math.min(max, toNumber(value, min)));
}
function getEffectDerivedMod(effect, stat) {
if (!effect) return 0;
const direct = toNumber(effect.getFlag("slazero2", `${stat}Mod`), 0);
const drug = toNumber(effect.getFlag("slazero2", `drug${stat === "health" ? "Health" : "Resolve"}Mod`), 0);
const legacy = toNumber(effect.getFlag("sla-yze", `${stat}Mod`), 0);
return direct + drug + legacy;
}
function getSpecialtyDerivedMod(item, stat) {
if (!item || item.type !== "specialty") return 0;
if (item.system?.isActive === false) return 0;
const explicit = toNumber(item.system?.[`${stat}Mod`], 0);
if (explicit !== 0) return explicit;
// Back-compat: existing True Grit entries may not have explicit numeric fields yet.
if (stat === "health") {
const name = String(item.name || "").trim().toLowerCase();
if (name === "true grit") return 1;
const summary = String(item.system?.effects || "").toLowerCase();
if (summary.includes("maximum health")) return 1;
}
return 0;
}
function calculateHealthResolve(actor) {
const attrs = actor?.system?.attributes || {};
const raceKey = canonicalRaceKey(actor?.system?.race);
const trainingKey = String(actor?.system?.training || "").trim().toLowerCase();
const baseHealth = toNumber(attrs?.strength?.value, 0) + toNumber(attrs?.agility?.value, 0);
const baseResolve = toNumber(attrs?.wits?.value, 0) + toNumber(attrs?.empathy?.value, 0);
const raceMods = RACE_DERIVED_MODS[raceKey] || { health: 0, resolve: 0 };
const trainingMods = TRAINING_DERIVED_MODS[trainingKey] || { health: 0, resolve: 0 };
let equipmentHealth = 0;
let equipmentResolve = 0;
let specialtyHealth = 0;
let specialtyResolve = 0;
for (const item of actor.items || []) {
if (item.type === "armor" || item.type === "equipment") {
const equipped = item.system?.equipped === true || item.system?.isEquipped === true;
if (!equipped) continue;
equipmentHealth += toNumber(item.system?.healthMod, 0);
equipmentResolve += toNumber(item.system?.resolveMod, 0);
continue;
}
if (item.type === "specialty") {
specialtyHealth += getSpecialtyDerivedMod(item, "health");
specialtyResolve += getSpecialtyDerivedMod(item, "resolve");
}
}
let drugHealth = 0;
let drugResolve = 0;
for (const item of actor.items || []) {
if (!(item.type === "equipment" && item.system?.isDrug === true)) continue;
if (item.system?.active) {
drugHealth += toNumber(item.system?.healthMod, 0);
drugResolve += toNumber(item.system?.resolveMod, 0);
} else if (item.system?.withdrawalActive) {
drugHealth += toNumber(item.system?.wdHealthMod, 0);
drugResolve += toNumber(item.system?.wdResolveMod, 0);
}
}
// Back-compat path: allow Active Effects to still contribute temporary modifiers.
for (const effect of actor.effects || []) {
drugHealth += getEffectDerivedMod(effect, "health");
drugResolve += getEffectDerivedMod(effect, "resolve");
}
const healthMax = Math.max(0, baseHealth + raceMods.health + trainingMods.health + equipmentHealth + specialtyHealth + drugHealth);
const resolveMax = Math.max(0, baseResolve + raceMods.resolve + trainingMods.resolve + equipmentResolve + specialtyResolve + drugResolve);
return {
healthMax,
resolveMax,
breakdown: {
health: {
base: baseHealth,
race: raceMods.health,
training: trainingMods.health,
equipment: equipmentHealth,
specialty: specialtyHealth,
drug: drugHealth
},
resolve: {
base: baseResolve,
race: raceMods.resolve,
training: trainingMods.resolve,
equipment: equipmentResolve,
specialty: specialtyResolve,
drug: drugResolve
}
}
};
}
function buildDerivedRecalcUpdate(actor) {
if (!actor || actor.type !== "character") return null;
const calc = calculateHealthResolve(actor);
const currentHealth = getDerivedValue(actor, "health");
const currentResolve = getDerivedValue(actor, "resolve");
const currentHealthMax = toNumber(actor?.system?.derivedStats?.health?.max, currentHealth);
const currentResolveMax = toNumber(actor?.system?.derivedStats?.resolve?.max, currentResolve);
const nextHealthMax = Math.max(0, calc.healthMax);
const nextResolveMax = Math.max(0, calc.resolveMax);
const nextHealth = clampNumber(currentHealth, 0, nextHealthMax);
const nextResolve = clampNumber(currentResolve, 0, nextResolveMax);
const updates = {};
if (currentHealthMax !== nextHealthMax) updates["system.derivedStats.health.max"] = nextHealthMax;
if (currentResolveMax !== nextResolveMax) updates["system.derivedStats.resolve.max"] = nextResolveMax;
if (currentHealth !== nextHealth) updates["system.derivedStats.health.value"] = nextHealth;
if (currentResolve !== nextResolve) updates["system.derivedStats.resolve.value"] = nextResolve;
// Clear stale broken flag — if current HP is above 0, broken is no longer valid.
// This prevents the legacy -2 dice penalty persisting on healed characters.
if (actor.system?.derivedStats?.broken === true && nextHealth > 0) {
updates["system.derivedStats.broken"] = false;
}
// NOTE: breakdown is NOT in template.json — do not persist it or Foundry v14
// schema validation will reset the entire derivedStats object (nuking stress, etc.)
// Compute breakdown in getData() for display purposes only.
return Object.keys(updates).length > 0 ? updates : null;
}
async function recalcDerivedStats(actor, updateOptions = {}) {
const updates = buildDerivedRecalcUpdate(actor);
if (!updates) return;
await actor.update(updates, { _zeDerivedRecalcInternal: true, ...updateOptions });
}
function shouldRecalcForActorUpdate(change) {
if (!change || !change.system) return false;
return [
"system.attributes.strength.value",
"system.attributes.agility.value",
"system.attributes.wits.value",
"system.attributes.empathy.value",
"system.race",
"system.training"
].some(path => foundry.utils.hasProperty(change, path));
}
Hooks.on("preUpdateActor", (actor, change, options) => {
if (options?._zeDerivedRecalcInternal) return;
options._zePrevHealth = getDerivedValue(actor, "health");
options._zePrevResolve = getDerivedValue(actor, "resolve");
});
Hooks.on("createActor", async (actor, options) => {
if (actor?.type !== "character") return;
if (options?._zeDerivedRecalcInternal) return;
await recalcDerivedStats(actor);
});
Hooks.on("updateActor", async (actor, change, options) => {
if (actor?.type !== "character") return;
if (options?._zeDerivedRecalcInternal) return;
// Also clear stale broken flag whenever HP is updated to > 0
const hpChanged = foundry.utils.hasProperty(change, "system.derivedStats.health.value") ||
foundry.utils.hasProperty(change, "system.derivedStats.health");
if (hpChanged && actor.system?.derivedStats?.broken === true) {
const currentHp = Number(
typeof actor.system.derivedStats.health === "object"
? actor.system.derivedStats.health?.value
: actor.system.derivedStats.health
) || 0;
if (currentHp > 0) {
await actor.update({ "system.derivedStats.broken": false }, { _zeDerivedRecalcInternal: true });
}
}
if (!shouldRecalcForActorUpdate(change)) return;
await recalcDerivedStats(actor);
// When race changes, auto-add any missing racial specialties for the new race
if (foundry.utils.hasProperty(change, "system.race") && game.user?.isGM) {
try { await _ensureRacialAbilities(actor); } catch(_) {}
}
});
Hooks.on("createItem", async (item, options) => {
const actor = item?.actor;
if (actor?.type !== "character") return;
if (options?._zeDerivedRecalcInternal) return;
if (!["armor", "equipment", "specialty"].includes(item.type)) return;
await recalcDerivedStats(actor);
});
Hooks.on("updateItem", async (item, change, options) => {
const actor = item?.actor;
if (actor?.type !== "character") return;
if (options?._zeDerivedRecalcInternal) return;
if (!["armor", "equipment", "specialty"].includes(item.type)) return;
await recalcDerivedStats(actor);
});
Hooks.on("deleteItem", async (item, options) => {
const actor = item?.actor;
if (actor?.type !== "character") return;
if (options?._zeDerivedRecalcInternal) return;
if (!["armor", "equipment", "specialty"].includes(item.type)) return;
await recalcDerivedStats(actor);
});
Hooks.on("createActiveEffect", async (effect, options) => {
const actor = effect?.parent;
if (actor?.documentName !== "Actor" || actor.type !== "character") return;
if (options?._zeDerivedRecalcInternal) return;
await recalcDerivedStats(actor);
});
Hooks.on("updateActiveEffect", async (effect, change, options) => {
const actor = effect?.parent;
if (actor?.documentName !== "Actor" || actor.type !== "character") return;
if (options?._zeDerivedRecalcInternal) return;
await recalcDerivedStats(actor);
});
Hooks.on("deleteActiveEffect", async (effect, options) => {
const actor = effect?.parent;
if (actor?.documentName !== "Actor" || actor.type !== "character") return;
if (options?._zeDerivedRecalcInternal) return;
await recalcDerivedStats(actor);
});
Hooks.on("updateActor", async (actor, change, options) => {
if (actor?.type !== "character") return;
if (options?._zeDerivedRecalcInternal) return;
if (options?._zeAutoCriticalInternal) return;
const prevHealth = Number(options?._zePrevHealth);
const prevResolve = Number(options?._zePrevResolve);
if (!Number.isFinite(prevHealth) && !Number.isFinite(prevResolve)) return;
const nextHealth = getDerivedValue(actor, "health");
const nextResolve = getDerivedValue(actor, "resolve");
const healthBroke = Number.isFinite(prevHealth) && prevHealth > 0 && nextHealth <= 0;
const resolveBroke = Number.isFinite(prevResolve) && prevResolve > 0 && nextResolve <= 0;
if (!healthBroke && !resolveBroke) return;
const updateOptions = { _zeAutoCriticalInternal: true };
if (actor.system?.derivedStats?.broken !== true) {
await actor.update({ "system.derivedStats.broken": true }, updateOptions);
}
if (healthBroke) {
ui.notifications.warn(`${actor.name} is BROKEN and suffers a Physical Critical Injury!`);
await runAutoCritical(actor, "physical");
}
if (resolveBroke) {
ui.notifications.warn(`${actor.name} is BROKEN and suffers a Mental Critical Injury!`);
await runAutoCritical(actor, "mental");
}
});
// Old showPushDialog function removed - now using automatic Push mechanics
// ══════════════════════════════════════════════════════════════════════════════
// SLA INDUSTRIES — CHARACTER GENERATORS (appended to slazero2.mjs)
// PC Creator (multi-step wizard, 7 races, 8 training packages)
// NPC Creator (quick + full modes, 10 archetypes)
// ══════════════════════════════════════════════════════════════════════════════
// ─── RACE DATA ───────────────────────────────────────────────────────────────
const SLA_RACES = {
human: {
label: 'Human', icon: '👤',
flavor: 'The backbone of SLA Industries. Born, raised, and usually died on Mort. Adaptable, expendable, and everywhere.',
baseAttrs: { strength: 2, agility: 2, wits: 2, empathy: 2 },
attrPoints: 6,
attrCaps: { strength: 5, agility: 5, wits: 5, empathy: 5 },
skillPoints: 10, skillCap: 3,
isEbbUser: false, healthMod: 0, resolveMod: 0
},
ebon: {
label: 'Ebon', icon: '🔮',
flavor: 'Born from the Ebb itself. Their minds reach where others cannot. SLA prizes them — and fears what they might become.',
baseAttrs: { strength: 2, agility: 2, wits: 3, empathy: 3 },
attrPoints: 4,
attrCaps: { strength: 4, agility: 4, wits: 5, empathy: 5 },
skillPoints: 10, skillCap: 3,
isEbbUser: true, healthMod: 0, resolveMod: 1
},
brainwaster: {
label: 'Brain Waster', icon: '🧠',
flavor: 'Ebon mutation gone raw. The Ebb tears through them and takes pieces every time. Brilliantly, terrifyingly unstable.',
baseAttrs: { strength: 2, agility: 2, wits: 4, empathy: 3 },
attrPoints: 3,
attrCaps: { strength: 3, agility: 4, wits: 5, empathy: 5 },
skillPoints: 10, skillCap: 3,
isEbbUser: true, healthMod: 0, resolveMod: 0
},
stormer: {
label: 'Stormer (313-S Malice)', icon: '⚡',
flavor: 'Vatgrown for war. A Stormer does not stop until the objective is complete — or it is dead. Usually the objective.',
baseAttrs: { strength: 4, agility: 3, wits: 2, empathy: 2 },
attrPoints: 3,
attrCaps: { strength: 5, agility: 5, wits: 3, empathy: 3 },
skillPoints: 8, skillCap: 3,
isEbbUser: false, healthMod: 2, resolveMod: 0
},
shaktar: {
label: 'Shaktar', icon: '🦂',
flavor: 'Alien warrior caste. Honour is their law; violence is their art. Each kill is recorded. Each debt is paid.',
baseAttrs: { strength: 3, agility: 3, wits: 2, empathy: 2 },
attrPoints: 4,
attrCaps: { strength: 5, agility: 5, wits: 4, empathy: 4 },
skillPoints: 9, skillCap: 3,
isEbbUser: false, healthMod: 1, resolveMod: 0
},
wraithraider: {
label: 'Wraith Raider', icon: '👁️',
flavor: 'They move through walls of shadow and kill without a sound. You never see them first. If you see them at all.',
baseAttrs: { strength: 2, agility: 4, wits: 2, empathy: 2 },
attrPoints: 4,
attrCaps: { strength: 4, agility: 5, wits: 5, empathy: 4 },
skillPoints: 10, skillCap: 3,
isEbbUser: false, healthMod: 0, resolveMod: 1
},
frother: {
label: 'Frother', icon: '💊',
flavor: 'The battle chemicals make them something else entirely. Pain is irrelevant. Fear is gone. Only the job remains.',
baseAttrs: { strength: 4, agility: 2, wits: 2, empathy: 2 },
attrPoints: 4,
attrCaps: { strength: 5, agility: 4, wits: 4, empathy: 3 },
skillPoints: 8, skillCap: 3,
isEbbUser: false, healthMod: 1, resolveMod: -1
},
vevaphon: {
label: 'Stormer Vevaphon',
icon: '🔀',
flavor: "Sleight Industries' cancelled polymorphic bioweapon line. Shape-shifting combat chassis with three selectable morph forms. Every shift costs Instability — and enough shifts will shatter whoever they were.",
baseAttrs: { strength: 3, agility: 3, wits: 2, empathy: 2 },
attrPoints: 5,
attrCaps: { strength: 5, agility: 5, wits: 4, empathy: 3 },
skillPoints: 8, skillCap: 3,
isEbbUser: false, healthMod: 2, resolveMod: -1
}
};
// ─── TRAINING PACKAGES ───────────────────────────────────────────────────────
const SLA_TRAINING = {
soldier: {
label: 'Soldier', icon: '🎖️',
flavor: 'Front-line combat. Point, shoot, advance.',
skills: { melee: 2, marksmanship: 2, stamina: 1 },
specialty: 'Hard Hitter',
armor: 'CAF Armor', weapons: ['pistol', 'rifle']
},
scout: {
label: 'Scout', icon: '🎯',
flavor: 'Reconnaissance and rapid infiltration.',
skills: { stealth: 2, mobility: 2, observation: 1 },
specialty: 'Fast Reflexes',
armor: 'Scout Armor', weapons: ['pistol', 'smg']
},
medic: {
label: 'Combat Medic', icon: '🩺',
flavor: 'Keeping the squad alive under fire.',
skills: { healing: 2, insight: 1, observation: 2 },
specialty: 'Field Surgeon',
armor: 'Downtown Jacket', weapons: ['pistol'], gear: ['Medical Kit']
},
tech: {
label: 'Technical Operative', icon: '🔧',
flavor: 'Field engineering, hacking, and repairs.',
skills: { crafting: 3, observation: 1, survival: 1 },
specialty: 'Inquisitive',
armor: 'Downtown Jacket', weapons: ['pistol'], gear: ['Tool Kit']
},
negotiator: {
label: 'Negotiator', icon: '🗣️',
flavor: 'Information, leverage, and social control.',
skills: { persuasion: 2, insight: 2, observation: 1 },
specialty: 'Gut Feeling',
armor: 'Downtown Jacket', weapons: ['pistol'], gear: ['Encrypted Comms']
},
hunter: {
label: 'Hunter', icon: '🏹',
flavor: 'Track, observe, eliminate. Quietly.',
skills: { marksmanship: 2, stealth: 2, observation: 1 },
specialty: 'Sniper',
armor: 'Scout Armor', weapons: ['rifle', 'pistol']
},
brawler: {
label: 'Brawler', icon: '👊',
flavor: 'Close quarters. No hesitation. Often Frother or Stormer.',
skills: { force: 2, melee: 2, stamina: 1 },
specialty: 'True Grit',
armor: 'CAF Armor', weapons: ['heavy_melee', 'pistol']
},
ebb_user: {
label: 'Ebb Channeller', icon: '✨',
flavor: 'The Ebb flows through and burns bright. Ebon and Brain Waster only.',
skills: { ebb: 3, insight: 1, persuasion: 1 },
specialty: 'Ebb Sensitive',
armor: 'Downtown Jacket', weapons: ['pistol'],
// Race-differentiated starting formulae (selected at creation time)
ebbEbon: [
'Empathic Pulse', // awareness — sense emotions
'Danger Sense', // awareness — can't be surprised
'Mind-Link', // communicate — silent mental comms
'Calm', // heal — reduce stress
'Ebb Shield', // protect — defensive reaction
'Ebb Sight', // senses — detect Ebb users
'Surge', // enhance — attribute boost
],
ebbBrainwaster: [
'Ebb Bolt', // blast — direct damage
'Heat Touch', // thermal — melee Ebb damage
'Surge', // enhance — combat boost
'Push', // telekinesis — hurl targets
'Empathic Pulse', // awareness — sense surroundings
'Ebb Shield', // protect — defensive
'Iron Flesh', // enhance — automatic armour
],
// Legacy fallback if race not detected
ebb: ['Empathic Pulse', 'Ebb Bolt', 'Ebb Shield', 'Calm', 'Surge', 'Ebb Sight'],
ebbOnly: true
}
};
// ─── NPC ARCHETYPES ──────────────────────────────────────────────────────────
const SLA_NPC_TYPES = {
civilian: { label: 'Civilian', threat: 1, attrs: {strength:2,agility:2,wits:2,empathy:2}, skills: {observation:1}, hp: 4, armor: 0, weapons: [] },
gang_grunt: { label: 'Gang Grunt', threat: 1, attrs: {strength:3,agility:2,wits:2,empathy:2}, skills: {melee:2,force:1}, hp: 5, armor: 1, weapons: ['pistol','knife'] },
shiver: { label: 'Shiver (Patrol)', threat: 2, attrs: {strength:3,agility:3,wits:2,empathy:2}, skills: {marksmanship:2,melee:1,observation:1}, hp: 6, armor: 2, weapons: ['pistol','baton'] },
operative_green: { label: 'SLA Operative (Green)', threat: 2, attrs: {strength:2,agility:3,wits:2,empathy:2}, skills: {marksmanship:2,mobility:1,observation:1}, hp: 5, armor: 2, weapons: ['pistol','knife'] },
carrien: { label: 'Carrien', threat: 2, attrs: {strength:3,agility:4,wits:1,empathy:1}, skills: {melee:3,mobility:2}, hp: 7, armor: 1, weapons: ['claws'] },
operative_veteran: { label: 'SLA Operative (Veteran)', threat: 3, attrs: {strength:3,agility:3,wits:3,empathy:2}, skills: {marksmanship:3,melee:2,stealth:2,observation:2}, hp: 8, armor: 3, weapons: ['rifle','pistol','knife'] },
ebb_operative: { label: 'Ebb Operative (Ebon)', threat: 3, attrs: {strength:2,agility:2,wits:4,empathy:4}, skills: {ebb:3,insight:3,observation:2}, hp: 6, armor: 1, weapons: ['pistol'], flux: 4, isEbb: true },
dept_agent: { label: 'Dept. of Propaganda Agent', threat:3, attrs: {strength:2,agility:3,wits:4,empathy:3}, skills: {persuasion:3,insight:3,stealth:2}, hp: 6, armor: 2, weapons: ['pistol'] },
dark_finder: { label: 'Dark Finder', threat: 4, attrs: {strength:4,agility:3,wits:3,empathy:2}, skills: {marksmanship:4,melee:3,stealth:3,observation:3}, hp: 10, armor: 4, weapons: ['rifle','pistol','blade'] },
manchine: { label: 'Manchine', threat: 5, attrs: {strength:5,agility:4,wits:3,empathy:1}, skills: {melee:4,marksmanship:3,force:4,stamina:4}, hp: 14, armor: 5, weapons: ['heavy_weapon','claws'] }
};
// ─── NAME TABLES ─────────────────────────────────────────────────────────────
const SLA_NAMES = {
human: {
first: ['Mace','Nadia','Cole','Petra','Sloane','Vex','Mira','Dax','Sable','Reece','Lyra','Jet','Kade','Dani','Rex','Aura','Finn','Zara','Grim','Nova'],
last: ['Falke','Dredd','Voss','Caine','Rourke','Mace','Cross','Kane','Stern','Vane','Frost','Ryker','Blane','Dusk','Shard','Hex','Vayne','Crowe','Ash','Lorne']
},
ebon: {
first: ['Aelith','Cassia','Vehn','Sorai','Talen','Myren','Eshara','Quelith','Davan','Silae'],
last: ['Vel Auris','Sol Keth','Mir Daan','Thas Elen','Quor Veth','Sil Arren','Tel Mora','Van Eres']
},
brainwaster: {
first: ['Flux','Scorch','Null','Vex','Wraith','Bleed','Crux','Static','Glitch','Spike'],
last: ['Mindless','the Hollow','No-Name','Unbound','Fracture','Sparks','the Lost','Deadwire']
},
stormer: {
first: ['Slab','Iron','Breach','Razr','Blunt','Crush','Vance','Gouge','Wreck','Spike'],
last: ['313','7-Niner','Alpha-4','Echo-3','Prime','Null-Six','Striker','Unit-9','Kilo','Zero']
},
shaktar: {
first: ["Kah'tak","Drex'ahl","Va'shari","Mul'keth","Kar'vaan","Esh'tara","Zek'hal","Quin'dar"],
last: ["ek Valkor","ek Shath","ek Duran","ek Morvath","ek Shal","ek Tyran"]
},
wraithraider: {
first: ['Shade','Whisper','Veil','Null','Shroud','Dusk','Mist','Ash','Fade','Echo'],
last: ['of the Void','of the Dark','the Unseen','of the Pale','the Silent','of Shadows']
},
frother: {
first: ['Bloody','Mad','Frenzy','Bersk','Rage','Bonehead','Wrecking','Rampant','Feral','Savage'],
last: ['McRage','O\'Kill','McGore','Killmore','Deathmarch','McCrash','Bloodstock','Warface']
},
generic: {
first: ['Operator','Agent','Unit','Ghost','Vector','Cipher','Reaper','Echo','Phantom','Sigma'],
last: ['Null','Seven','Prime','Alpha','Zero','Delta','Omega','Kilo','Bravo','Echo']
}
};
function _slaRandomName(raceKey) {
const table = SLA_NAMES[raceKey] || SLA_NAMES.generic;
const first = table.first[Math.floor(Math.random() * table.first.length)];
const last = table.last[Math.floor(Math.random() * table.last.length)];
return `${first} ${last}`;
}
// ─── GEAR BUILDERS ───────────────────────────────────────────────────────────
// ── Inline Ebb formula data ───────────────────────────────────────────────────
// Used when the compendium pack is unavailable (JSON packs not compiled to LDB).
// Keyed by display name — must match names used in training.ebbEbon / ebbBrainwaster.
const EBB_FORMULA_INLINE = {
// ── AWARENESS ───────────────────────────────────────────────────────────────
'Empathic Pulse': { discipline:'awareness', fluxCost:1, successes:1, range:'Short', duration:'Instant', effect:'Sense the dominant emotion of every living being within Short range. On 2+ successes, identify specific individuals.', img:'systems/slazero2/assets/icons/ebb/ebb-awareness.svg' },
'Danger Sense': { discipline:'awareness', fluxCost:2, successes:1, range:'Self', duration:'1 shift', effect:'Cannot be surprised for 1 shift. Gain +2 dice to Initiative rolls.', img:'systems/slazero2/assets/icons/ebb/ebb-awareness.svg' },
'Precognition': { discipline:'awareness', fluxCost:2, successes:2, range:'Self', duration:'Instant', effect:'Ask the GM one yes/no question about what will happen in the next stretch.', img:'systems/slazero2/assets/icons/ebb/ebb-awareness.svg' },
// ── BLAST ────────────────────────────────────────────────────────────────────
'Ebb Bolt': { discipline:'blast', fluxCost:1, successes:1, range:'Short', duration:'Instant', effect:'Strike one target for damage equal to successes. Armour applies. Counts as ranged attack.', img:'systems/slazero2/assets/icons/ebb/ebb-blast.svg' },
'Hellfire': { discipline:'blast', fluxCost:2, successes:2, range:'Medium', duration:'Instant', effect:'Blast one target for successes + 2 damage. On 3+ successes, target is knocked down.', img:'systems/slazero2/assets/icons/ebb/ebb-blast.svg' },
'Ebb Storm': { discipline:'blast', fluxCost:3, successes:2, range:'Short', duration:'Instant', effect:'Ebb detonates at Short range. All targets take successes damage. Armour applies.', img:'systems/slazero2/assets/icons/ebb/ebb-blast.svg' },
// ── COMMUNICATE ──────────────────────────────────────────────────────────────
'Mind-Link': { discipline:'communicate', fluxCost:1, successes:1, range:'Short', duration:'1 scene', effect:'Establish silent two-way mental communication with one willing target.', img:'systems/slazero2/assets/icons/ebb/ebb-communicate.svg' },
'Broadcast': { discipline:'communicate', fluxCost:2, successes:1, range:'Long', duration:'Instant', effect:'Project one simple message, image, or emotion to all living beings within Long range.', img:'systems/slazero2/assets/icons/ebb/ebb-communicate.svg' },
'Compel': { discipline:'communicate', fluxCost:2, successes:3, range:'Short', duration:'1 round', effect:'Force one target to perform a simple action (flee, drop weapon, kneel). Target resists with Wits.', img:'systems/slazero2/assets/icons/ebb/ebb-communicate.svg' },
// ── ENHANCE ──────────────────────────────────────────────────────────────────
'Surge': { discipline:'enhance', fluxCost:1, successes:1, range:'Self', duration:'1 stretch', effect:'Add +2 dice to all rolls using one chosen attribute for 1 stretch.', img:'systems/slazero2/assets/icons/ebb/ebb-enhance.svg' },
'Iron Flesh': { discipline:'enhance', fluxCost:2, successes:2, range:'Self', duration:'1 stretch', effect:'Gain 2 automatic armour (stacks with worn armour) for 1 stretch.', img:'systems/slazero2/assets/icons/ebb/ebb-enhance.svg' },
'Heightened State':{ discipline:'enhance', fluxCost:3, successes:2, range:'Self', duration:'1 stretch', effect:'+1 die to all rolls, reduce all incoming damage by 1 for 1 stretch.', img:'systems/slazero2/assets/icons/ebb/ebb-enhance.svg' },
// ── HEAL ─────────────────────────────────────────────────────────────────────
'Mend': { discipline:'heal', fluxCost:1, successes:1, range:'Engaged', duration:'Instant', effect:'Restore Health equal to successes (max 2) to self or a touched target.', img:'systems/slazero2/assets/icons/ebb/ebb-heal.svg' },
'Restore': { discipline:'heal', fluxCost:2, successes:2, range:'Engaged', duration:'Instant', effect:'Restore Health equal to successes + 1. On 3+ successes, also clear one critical injury.', img:'systems/slazero2/assets/icons/ebb/ebb-heal.svg' },
'Calm': { discipline:'heal', fluxCost:1, successes:1, range:'Short', duration:'Instant', effect:"Reduce one target's Stress by 1. If they are Broken from Stress, remove the Broken state.", img:'systems/slazero2/assets/icons/ebb/ebb-heal.svg' },
// ── PROTECT ──────────────────────────────────────────────────────────────────
'Ebb Shield': { discipline:'protect', fluxCost:1, successes:1, range:'Self', duration:'1 round', effect:'Roll 2d6 against the next incoming attack this round; each 6 reduces damage by 1.', img:'systems/slazero2/assets/icons/ebb/ebb-protect.svg' },
'Ward': { discipline:'protect', fluxCost:2, successes:2, range:'Short', duration:'1 stretch', effect:'One target within Short range gains 1 automatic armour for 1 stretch.', img:'systems/slazero2/assets/icons/ebb/ebb-protect.svg' },
'Phase Shell': { discipline:'protect', fluxCost:3, successes:3, range:'Self', duration:'1 round', effect:'All physical damage is reduced by 3 this round. Ebb attacks bypass this.', img:'systems/slazero2/assets/icons/ebb/ebb-protect.svg' },
// ── REALITY FOLD ─────────────────────────────────────────────────────────────
'Blink': { discipline:'realityFold', fluxCost:2, successes:2, range:'Short', duration:'Instant', effect:'Instantly teleport to any visible point within Short range.', img:'systems/slazero2/assets/icons/ebb/ebb-realityfold.svg' },
'Fold Space': { discipline:'realityFold', fluxCost:3, successes:3, range:'Long', duration:'1 round', effect:'Two chosen points within range become adjacent for 1 round.', img:'systems/slazero2/assets/icons/ebb/ebb-realityfold.svg' },
'Phase Through': { discipline:'realityFold', fluxCost:3, successes:2, range:'Engaged', duration:'Instant', effect:'Pass through one non-living solid object (wall, door, hull).', img:'systems/slazero2/assets/icons/ebb/ebb-realityfold.svg' },
// ── SENSES ───────────────────────────────────────────────────────────────────
'Ebb Sight': { discipline:'senses', fluxCost:1, successes:1, range:'Long', duration:'1 stretch', effect:'Perceive Ebb energy, active formulae, and Ebb users within Long range.', img:'systems/slazero2/assets/icons/ebb/ebb-senses.svg' },
"Mind's Eye": { discipline:'senses', fluxCost:1, successes:1, range:'Self', duration:'1 stretch', effect:'+3 dice to all Observation rolls. Cannot be ambushed.', img:'systems/slazero2/assets/icons/ebb/ebb-senses.svg' },
'Remote View': { discipline:'senses', fluxCost:2, successes:2, range:'Long', duration:'1 stretch', effect:'Project senses to any location within Long range that you have visited before.', img:'systems/slazero2/assets/icons/ebb/ebb-senses.svg' },
// ── TELEKINESIS ──────────────────────────────────────────────────────────────
'Push': { discipline:'telekinesis', fluxCost:1, successes:1, range:'Short', duration:'Instant', effect:'Hurl one target or object with mental force, moving them up to 3m per success. Counts as ranged attack.', img:'systems/slazero2/assets/icons/ebb/ebb-telekinesis.svg' },
'Grip': { discipline:'telekinesis', fluxCost:2, successes:2, range:'Short', duration:'1 round', effect:'Hold one target immobile for 1 round. They can still act but cannot move.', img:'systems/slazero2/assets/icons/ebb/ebb-telekinesis.svg' },
'Levitate': { discipline:'telekinesis', fluxCost:2, successes:1, range:'Self', duration:'1 stretch', effect:'Float at any height, moving in any direction at normal speed.', img:'systems/slazero2/assets/icons/ebb/ebb-telekinesis.svg' },
// ── THERMAL ──────────────────────────────────────────────────────────────────
'Heat Touch': { discipline:'thermal', fluxCost:1, successes:1, range:'Engaged', duration:'Instant', effect:'Deal damage equal to successes + 1 to a touched target. On 3+ successes, target catches fire.', img:'systems/slazero2/assets/icons/ebb/ebb-thermal.svg' },
'Flame Burst': { discipline:'thermal', fluxCost:2, successes:1, range:'Short', duration:'Instant', effect:'Deal successes + 2 damage to one target. On 3+ successes, target catches fire.', img:'systems/slazero2/assets/icons/ebb/ebb-thermal.svg' },
'Freeze': { discipline:'thermal', fluxCost:2, successes:2, range:'Short', duration:'1 round', effect:'Target takes 1 damage and suffers -2 dice to all Agility rolls for 1 round.', img:'systems/slazero2/assets/icons/ebb/ebb-thermal.svg' },
};
/** Build an embedded Ebb item document from the inline table */
function _slaEbbFormulaData(name) {
const f = EBB_FORMULA_INLINE[name];
if (!f) return null;
return {
name,
type: 'ebb',
img: f.img || 'systems/slazero2/assets/icons/ebb/ebb-blast.svg',
system: {
description: f.effect || '',
discipline: f.discipline,
fluxCost: f.fluxCost,
successes: f.successes,
effect: f.effect || '',
catastrophe: '',
duration: f.duration || '',
range: f.range || 'Short',
}
};
}
function _slaWeaponData(type) {
const weapons = {
pistol: { name: 'Blitzer 10mm Pistol', weaponType:'Pistol', category:'ranged', damage:2, range:'Short', rof:2, ap:0, magazine:15, ammo:15, fireModes:['single'], cost:300,
img: 'icons/weapons/guns/gun-pistol-flintlock-metal.webp' },
rifle: { name: 'M300 Assault Rifle', weaponType:'Rifle', category:'ranged', damage:3, range:'Long', rof:3, ap:1, magazine:30, ammo:30, fireModes:['single','burst','auto'], cost:600,
img: 'icons/weapons/guns/rifle-bayonet.webp' },
smg: { name: 'Blitzer SMG', weaponType:'SMG', category:'ranged', damage:2, range:'Short', rof:3, ap:0, magazine:30, ammo:30, fireModes:['single','auto'], cost:450,
img: 'icons/weapons/guns/gun-pistol-flintlock-metal.webp' },
heavy_melee: { name: 'Power Blade', weaponType:'Blade', category:'melee', damage:3, range:'Engaged', rof:1, ap:2, magazine:0, ammo:0, fireModes:[], cost:400,
img: 'icons/weapons/swords/greatsword.webp' },
knife: { name: 'Combat Knife', weaponType:'Knife', category:'melee', damage:2, range:'Engaged', rof:2, ap:0, magazine:0, ammo:0, fireModes:[], cost:80,
img: 'icons/weapons/daggers/dagger-straight-sharp.webp' },
baton: { name: 'Shiver Baton', weaponType:'Baton', category:'melee', damage:2, range:'Engaged', rof:2, ap:0, magazine:0, ammo:0, fireModes:[], cost:50,
img: 'icons/weapons/clubs/club-simple-black.webp' },
claws: { name: 'Claws', weaponType:'Natural', category:'melee', damage:3, range:'Engaged', rof:2, ap:0, magazine:0, ammo:0, fireModes:[], cost:0,
img: 'icons/skills/melee/unarmed-punch-fist.webp' },
blade: { name: 'Mono-Edge Blade', weaponType:'Blade', category:'melee', damage:3, range:'Engaged', rof:2, ap:2, magazine:0, ammo:0, fireModes:[], cost:350,
img: 'icons/weapons/swords/sword-katana-gleaming.webp' },
heavy_weapon: { name: 'Heavy Autocannon', weaponType:'HW', category:'ranged', damage:5, range:'Long', rof:2, ap:3, magazine:20, ammo:20, fireModes:['burst','auto'], cost:2500,
img: 'icons/weapons/guns/gun-blunderbuss.webp' },
};
const base = weapons[type] || weapons.pistol;
const { img, ...sys } = base;
return { name: base.name, type: 'weapon', img: img || 'icons/svg/item.svg',
system: { ...sys, description: '', equipped: true, gearBonus:0, initiativeMod:0, ammoType:'standard', autoAmmoUse:8 } };
}
function _slaArmorData(name) {
const armors = {
'Downtown Jacket': { armorDice:1, armorAuto:0, cost:150, img:'icons/equipment/chest/vest-simple-leather-brown.webp' },
'CAF Armor': { armorDice:2, armorAuto:0, cost:400, img:'icons/equipment/chest/breastplate-banded-steel.webp' },
'Scout Armor': { armorDice:3, armorAuto:0, cost:800, img:'icons/equipment/chest/breastplate-layered-steel-grey.webp' },
'HARD Armor': { armorDice:4, armorAuto:0, cost:1500, img:'icons/equipment/chest/breastplate-scale-grey.webp' },
'Exo-Rig Armor': { armorDice:5, armorAuto:0, cost:3000, img:'icons/equipment/chest/breastplate-segmented-grey.webp' },
'Squad Shielded Power Armor': { armorDice:6, armorAuto:2, cost:8000, img:'icons/equipment/chest/breastplate-embossed-silver.webp' }
};
const base = armors[name] || armors['Downtown Jacket'];
const { img, ...sys } = base;
return { name, type: 'armor', img: img || 'icons/svg/shield.svg',
system: { ...sys, description:'', armorRating: sys.armorDice, statMod:0, skillMod:0, statModTarget:'', skillModTarget:'', healthMod:0, resolveMod:0, initiativeMod:0, equipped:true } };
}
function _slaSpecialtyData(name) {
const specialties = {
'Hard Hitter': { effects:'+1 Melee damage (sacrifice fast action)', img:'icons/skills/melee/strike-slashing-yellow.webp' },
'Fast Reflexes': { effects:'Draw 2 initiative cards, choose the better one', img:'icons/skills/movement/feet-winged-boots-glowing-yellow.webp' },
'Field Surgeon': { effects:'+1 Healing (critical injuries only)', img:'icons/tools/hand/pliers-pointed-red.webp' },
'Inquisitive': { effects:'Push WIT rolls twice per roll', img:'icons/skills/social/theft-pickpocket-eyes-green.webp' },
'Gut Feeling': { effects:'Use EMP for Observation (threat detection only)', img:'icons/magic/perception/eye-ringed-glow-angry-small-red.webp' },
'Sniper': { effects:'+2 Marksmanship (Long+ range, concealment required)', img:'icons/skills/ranged/aim-reticle-dark.webp' },
'True Grit': { effects:'+1 maximum Health', img:'icons/magic/life/heart-cross-strong-red.webp' },
'Ebb Sensitive': { effects:'Sense active Ebb users within ~10m radius', img:'icons/magic/perception/orb-eye-scrying.webp' },
};
const base = specialties[name] || { effects: name, img: 'icons/svg/aura.svg' };
const { img, ...rest } = base;
return { name, type: 'specialty', img: img || 'icons/svg/aura.svg',
system: { ...rest, description:'', category:'general', prerequisites:'', package:'', healthMod:0, resolveMod:0, isActive:true } };
}
function _slaGearData(name) {
// Map common gear names to appropriate icons
const gearIcons = {
'Medikit': 'icons/tools/medical/syringe-drug-blue.webp',
'Medkit': 'icons/tools/medical/syringe-drug-blue.webp',
'Comms': 'icons/tools/navigation/compass-brass.webp',
'Radio': 'icons/tools/navigation/compass-brass.webp',
'Binoculars': 'icons/tools/exploration/binoculars.webp',
'Handcuffs': 'icons/tools/misc/chain-grey.webp',
'Grapple': 'icons/tools/misc/rope-black.webp',
'Rope': 'icons/tools/misc/rope-black.webp',
'Flashlight': 'icons/tools/navigation/torch-black.webp',
'Scanner': 'icons/tools/scribal/spectacles.webp',
'Toolkit': 'icons/tools/hand/wrench.webp',
};
const img = gearIcons[name] || 'systems/slazero2/assets/icons/cardboard-box-closed.svg';
return { name, type: 'equipment', img,
system: { description:'', quantity:1, cost:0, weight:1, isDrug:false, active:false, activeDuration:'', activeLabel:'', activeNotes:'', equipped:true, initiativeMod:0 } };
}
// ══════════════════════════════════════════════════════════════════════════════
// PC CREATOR APPLICATION
// ══════════════════════════════════════════════════════════════════════════════
class SLAPCCreator extends Application {
constructor(options = {}) {
super(options);
this._step = 1;
this._race = null;
this._training = null;
this._attrs = {};
this._skills = {};
this._attrFreePoints = 0;
this._skillPoints = 0;
this._name = '';
this._scl = 10;
this._credits = 500;
this._bio = '';
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'sla-pc-creator',
title: 'SLA Industries — Operative Generator',
template: null,
width: 680,
height: 620,
resizable: true,
classes: ['slazero2', 'sla-generator']
});
}
async _renderInner() {
// Build full HTML inline — no external template needed
const el = document.createElement('div');
el.innerHTML = this._buildHTML();
return $(el);
}
activateListeners(html) {
super.activateListeners(html);
this._bindStep1(html);
}
_getEl(html, sel) {
return (html instanceof jQuery ? html[0] : html).querySelector(sel);
}
_getEls(html, sel) {
return [...(html instanceof jQuery ? html[0] : html).querySelectorAll(sel)];
}
// ── STEP 1: Race ────────────────────────────────────────────────────────────
_bindStep1(html) {
this._showStep(html, 1);
this._getEls(html, '.race-card').forEach(card => {
card.addEventListener('click', () => {
this._getEls(html, '.race-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
this._race = card.dataset.race;
this._getEl(html, '#gen-next-1').disabled = false;
});
});
this._getEl(html, '#gen-next-1').addEventListener('click', () => {
if (!this._race) return;
this._step = 2;
this._bindStep2(html);
});
}
// ── STEP 2: Training ────────────────────────────────────────────────────────
_bindStep2(html) {
this._showStep(html, 2);
const raceData = SLA_RACES[this._race];
this._getEls(html, '.training-card').forEach(card => {
const pkg = SLA_TRAINING[card.dataset.training];
if (pkg.ebbOnly && !raceData.isEbbUser) {
card.classList.add('disabled');
card.title = 'Requires Ebon or Brain Waster';
return;
}
card.addEventListener('click', () => {
this._getEls(html, '.training-card:not(.disabled)').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
this._training = card.dataset.training;
this._getEl(html, '#gen-next-2').disabled = false;
});
});
this._getEl(html, '#gen-back-2').addEventListener('click', () => { this._step = 1; this._bindStep1(html); });
this._getEl(html, '#gen-next-2').addEventListener('click', () => {
if (!this._training) return;
// Init attributes from race + apply training skill bonus (done at creation)
const race = SLA_RACES[this._race];
this._attrs = { ...race.baseAttrs };
this._attrFreePoints = race.attrPoints;
// Apply training skill bonuses to skill pools
const trainSkills = { ...SLA_TRAINING[this._training].skills };
this._skills = { force:0, melee:0, stamina:0, marksmanship:0, mobility:0, stealth:0, crafting:0, observation:0, survival:0, healing:0, insight:0, persuasion:0, ebb:0 };
for (const [sk, val] of Object.entries(trainSkills)) {
this._skills[sk] = (this._skills[sk] || 0) + val;
}
this._skillPoints = race.skillPoints;
this._step = 3;
this._bindStep3(html);
});
}
// ── STEP 3: Attributes ──────────────────────────────────────────────────────
_bindStep3(html) {
this._showStep(html, 3);
this._renderAttrTable(html);
this._getEl(html, '#gen-back-3').addEventListener('click', () => { this._step = 2; this._bindStep2(html); });
this._getEl(html, '#gen-next-3').addEventListener('click', (ev) => {
const race = SLA_RACES[this._race];
const left = this._attrFreePoints - Object.entries(this._attrs).reduce((a,[k,v]) => a+(v-race.baseAttrs[k]),0);
if (left > 0 && !ev.currentTarget.dataset.confirmed) {
ev.currentTarget.dataset.confirmed = "1";
ev.currentTarget.textContent = `${left} point${left!==1?'s':''} unspent — Next anyway?`;
setTimeout(() => { delete ev.target?.dataset?.confirmed; }, 4000);
return;
}
this._step = 4; this._bindStep4(html);
});
}
_renderAttrTable(html) {
const race = SLA_RACES[this._race];
const container = this._getEl(html, '#attr-rows');
container.innerHTML = '';
let remaining = this._attrFreePoints - Object.entries(this._attrs).reduce((acc, [k,v]) => acc + (v - race.baseAttrs[k]), 0);
const render = () => {
remaining = this._attrFreePoints - Object.entries(this._attrs).reduce((acc, [k,v]) => acc + (v - race.baseAttrs[k]), 0);
this._getEl(html, '#attr-points-left').textContent = remaining;
this._getEl(html, '#gen-next-3').disabled = remaining < 0;
const hp = this._attrs.strength + this._attrs.agility + race.healthMod;
const rp = this._attrs.wits + this._attrs.empathy + race.resolveMod;
this._getEl(html, '#attr-hp-preview').textContent = hp;
this._getEl(html, '#attr-rp-preview').textContent = rp;
};
const ATTR_LABELS = { strength:'Strength (STR)', agility:'Agility (AGI)', wits:'Wits (WIT)', empathy:'Empathy (EMP)' };
for (const [attr, baseVal] of Object.entries(race.baseAttrs)) {
const cap = race.attrCaps[attr];
const row = document.createElement('div');
row.className = 'attr-row';
row.innerHTML = `
${ATTR_LABELS[attr]}
base ${baseVal}
−
${this._attrs[attr]}
+
/ ${cap} `;
container.appendChild(row);
row.querySelector('.attr-minus').addEventListener('click', () => {
if (this._attrs[attr] > baseVal) { this._attrs[attr]--; row.querySelector(`#av-${attr}`).textContent = this._attrs[attr]; render(); }
});
row.querySelector('.attr-plus').addEventListener('click', () => {
const curRemaining = this._attrFreePoints - Object.entries(this._attrs).reduce((a,[k,v]) => a+(v-race.baseAttrs[k]),0);
if (this._attrs[attr] < cap && curRemaining > 0) { this._attrs[attr]++; row.querySelector(`#av-${attr}`).textContent = this._attrs[attr]; render(); }
});
}
render();
}
// ── STEP 4: Skills ──────────────────────────────────────────────────────────
_bindStep4(html) {
this._showStep(html, 4);
this._renderSkillTable(html);
this._getEl(html, '#gen-back-4').addEventListener('click', () => { this._step = 3; this._bindStep3(html); });
this._getEl(html, '#gen-next-4').addEventListener('click', (ev) => {
const trainSkills = SLA_TRAINING[this._training]?.skills || {};
const spent = Object.entries(this._skills).reduce((a,[k,v]) => a + Math.max(0, v - (trainSkills[k]||0)), 0);
const left = Math.max(0, this._skillPoints - spent);
if (left > 0 && !ev.currentTarget.dataset.confirmed) {
ev.currentTarget.dataset.confirmed = "1";
ev.currentTarget.textContent = `${left} point${left!==1?'s':''} unspent — Next anyway?`;
setTimeout(() => { delete ev.target?.dataset?.confirmed; }, 4000);
return;
}
this._step = 5; this._bindStep5(html);
});
}
_renderSkillTable(html) {
const race = SLA_RACES[this._race];
const trainSkills = SLA_TRAINING[this._training].skills;
const SKILL_GROUPS = [
{ label:'Strength', attr:'STR', skills:['force','melee','stamina'] },
{ label:'Agility', attr:'AGI', skills:['marksmanship','mobility','stealth'] },
{ label:'Wits', attr:'WIT', skills:['crafting','observation','survival'] },
{ label:'Empathy', attr:'EMP', skills:['healing','insight','persuasion','ebb'] }
];
const container = this._getEl(html, '#skill-rows');
container.innerHTML = '';
let spent = 0;
const render = () => {
spent = Object.entries(this._skills).reduce((a,[k,v]) => a + Math.max(0, v - (trainSkills[k]||0)), 0);
this._getEl(html, '#skill-points-left').textContent = Math.max(0, this._skillPoints - spent);
this._getEl(html, '#gen-next-4').disabled = spent > this._skillPoints;
};
for (const group of SKILL_GROUPS) {
const groupEl = document.createElement('div');
groupEl.className = 'skill-group';
groupEl.innerHTML = ``;
for (const sk of group.skills) {
if (sk === 'ebb' && !race.isEbbUser) continue;
const fromTraining = trainSkills[sk] || 0;
const row = document.createElement('div');
row.className = 'skill-gen-row';
row.innerHTML = `
${sk.charAt(0).toUpperCase()+sk.slice(1)}
${fromTraining ? `+${fromTraining} training ` : ' '}
−
${this._skills[sk] || 0}
+
/ ${race.skillCap + fromTraining} `;
groupEl.appendChild(row);
row.querySelector('.sg-minus').addEventListener('click', () => {
const minVal = fromTraining;
if (this._skills[sk] > minVal) { this._skills[sk]--; row.querySelector(`#sv-${sk}`).textContent = this._skills[sk]; render(); }
});
row.querySelector('.sg-plus').addEventListener('click', () => {
const curSpent = Object.entries(this._skills).reduce((a,[k,v]) => a + Math.max(0, v - (trainSkills[k]||0)), 0);
const cap = race.skillCap + fromTraining;
if (this._skills[sk] < cap && curSpent < this._skillPoints) { this._skills[sk]++; row.querySelector(`#sv-${sk}`).textContent = this._skills[sk]; render(); }
});
}
container.appendChild(groupEl);
}
render();
}
// ── STEP 5: Details ─────────────────────────────────────────────────────────
_bindStep5(html) {
this._showStep(html, 5);
const nameInput = this._getEl(html, '#char-name');
const sclInput = this._getEl(html, '#char-scl');
const credInput = this._getEl(html, '#char-creds');
const bioInput = this._getEl(html, '#char-bio');
nameInput.value = this._name || _slaRandomName(this._race);
sclInput.value = this._scl;
credInput.value = this._credits;
bioInput.value = this._bio;
this._getEl(html, '#random-name-btn').addEventListener('click', () => { nameInput.value = _slaRandomName(this._race); });
this._getEl(html, '#gen-back-5').addEventListener('click', () => { this._step = 4; this._bindStep4(html); });
this._getEl(html, '#gen-next-5').addEventListener('click', () => {
this._name = nameInput.value.trim() || _slaRandomName(this._race);
this._scl = parseInt(sclInput.value) || 10;
this._credits = parseInt(credInput.value) || 500;
this._bio = bioInput.value;
this._step = 6;
this._bindStep6(html);
});
}
// ── STEP 6: Summary & Create ────────────────────────────────────────────────
_bindStep6(html) {
this._showStep(html, 6);
const race = SLA_RACES[this._race];
const training = SLA_TRAINING[this._training];
const summary = this._getEl(html, '#summary-content');
const attrStr = Object.entries(this._attrs).map(([k,v])=>`${k.slice(0,3).toUpperCase()} ${v}`).join(' | ');
const skillStr = Object.entries(this._skills).filter(([,v])=>v>0).map(([k,v])=>`${k.charAt(0).toUpperCase()+k.slice(1)} ${v}`).join(', ') || 'none';
const hp = this._attrs.strength + this._attrs.agility + race.healthMod;
const rp = this._attrs.wits + this._attrs.empathy + race.resolveMod;
summary.innerHTML = `
Name ${this._name}
Race ${race.label}
Training ${training.label}
SCL ${this._scl}
Credits ${this._credits}
Attributes ${attrStr}
HP / Resolve ${hp} / ${rp}
Skills ${skillStr}
Specialty ${training.specialty}
Armor ${training.armor}
Weapons ${(training.weapons||[]).join(', ') || '—'}
${race.isEbbUser && (training.ebbEbon || training.ebb) ? (() => {
const list = this._race === 'ebon' && training.ebbEbon ? training.ebbEbon
: this._race === 'brainwaster' && training.ebbBrainwaster ? training.ebbBrainwaster
: (training.ebb || []);
const ebbVal = Number(this._skills.ebb) || 0;
return `Starting Flux ${Math.max(1, ebbVal + 1)}
Ebb Formulae (${list.length}) ${list.join(', ')}
`;
})() : ''}`;
this._getEl(html, '#gen-back-6').addEventListener('click', () => { this._step = 5; this._bindStep5(html); });
this._getEl(html, '#gen-create-btn').addEventListener('click', () => this._createPC());
}
// ── CREATE ACTOR ─────────────────────────────────────────────────────────────
async _createPC() {
const race = SLA_RACES[this._race];
const training = SLA_TRAINING[this._training];
const hp = this._attrs.strength + this._attrs.agility + race.healthMod;
const rp = this._attrs.wits + this._attrs.empathy + race.resolveMod;
// Starting flux = Ebb skill + 1 (minimum 1 for Ebb users, 0 for non-Ebb)
const ebbSkillVal = Number(this._skills.ebb) || 0;
const flux = race.isEbbUser ? Math.max(1, ebbSkillVal + 1) : 0;
// Build attributes in the nested object format the system expects
const attributes = {};
for (const [k, v] of Object.entries(this._attrs)) {
attributes[k] = { value: v };
}
// Build skills
const skills = {};
for (const [k, v] of Object.entries(this._skills)) {
const ATTR_MAP = { force:'strength',melee:'strength',stamina:'strength',marksmanship:'agility',mobility:'agility',stealth:'agility',crafting:'wits',observation:'wits',survival:'wits',healing:'empathy',insight:'empathy',persuasion:'empathy',ebb:'empathy' };
skills[k] = { value: v, attribute: ATTR_MAP[k], label: k.charAt(0).toUpperCase()+k.slice(1) };
}
const actorData = {
name: this._name,
type: 'character',
system: {
race: this._race,
archetype: this._training,
training: this._training,
details: { scl: this._scl, credits: this._credits, age: '', height: '', weight: '' },
attributes,
skills,
derivedStats: {
health: { value: hp, max: hp },
resolve: { value: rp, max: rp },
broken: false
},
stress: 0,
flux: { value: flux, max: flux },
carryLimit: 4,
biography: this._bio,
specialties: training.specialty || ''
}
};
let actor;
try {
actor = await Actor.create(actorData);
} catch(err) {
ui.notifications.error(`Failed to create actor: ${err.message}`);
return;
}
// Embed items: armor, weapons, specialty, gear, ebb formulae
const embeds = [];
if (training.armor) embeds.push(_slaArmorData(training.armor));
for (const w of (training.weapons || [])) embeds.push(_slaWeaponData(w));
if (training.specialty) embeds.push(_slaSpecialtyData(training.specialty));
for (const g of (training.gear || [])) embeds.push(_slaGearData(g));
if (race.isEbbUser && (training.ebb || training.ebbEbon || training.ebbBrainwaster)) {
// Pick the race-appropriate formula list
let formulaList;
if (this._race === 'ebon' && training.ebbEbon) {
formulaList = training.ebbEbon;
} else if (this._race === 'brainwaster' && training.ebbBrainwaster) {
formulaList = training.ebbBrainwaster;
} else {
formulaList = training.ebb || [];
}
// Build formulae from inline data (JSON packs are not compiled to LDB in Foundry v12+)
// Fall back to pack lookup only if the inline table is missing an entry.
const ebbPack = game.packs?.get('slazero2.ebb-formulae');
let packIdx = null;
try { if (ebbPack) packIdx = await ebbPack.getIndex(); } catch(_) {}
for (const formulaName of formulaList) {
const inline = _slaEbbFormulaData(formulaName);
if (inline) {
embeds.push(inline);
continue;
}
// Try world items as second fallback
const worldItem = game.items?.find(i => i.type === 'ebb' && i.name === formulaName);
if (worldItem) { embeds.push(worldItem.toObject()); continue; }
// Try compiled pack as last resort
if (packIdx) {
const entry = packIdx.find(e => e.name === formulaName);
if (entry) {
try {
const doc = await ebbPack.getDocument(entry._id);
embeds.push(doc.toObject());
} catch(_) {}
}
}
}
}
if (embeds.length) {
try { await actor.createEmbeddedDocuments('Item', embeds); } catch(e) { console.warn('SLA Zero Two | Could not embed starter items:', e); }
}
ui.notifications.info(`✓ ${this._name} created as an Operative!`);
actor.sheet?.render(true);
this.close();
}
// ── UI UTILITIES ─────────────────────────────────────────────────────────────
_showStep(html, n) {
const root = html instanceof jQuery ? html[0] : html;
root.querySelectorAll('.gen-step').forEach(s => s.style.display = 'none');
const step = root.querySelector(`#gen-step-${n}`);
if (step) step.style.display = 'flex';
// Update progress bar
root.querySelectorAll('.gen-progress-dot').forEach((d, i) => {
d.classList.toggle('active', i < n);
d.classList.toggle('current', i === n - 1);
});
}
_buildHTML() {
const raceCards = Object.entries(SLA_RACES).map(([key, r]) => `
${r.icon}
${r.label}
STR${r.baseAttrs.strength} AGI${r.baseAttrs.agility} WIT${r.baseAttrs.wits} EMP${r.baseAttrs.empathy}
${r.flavor}
${r.isEbbUser ? '
⚡ Ebb User
' : ''}
`).join('');
const trainingCards = Object.entries(SLA_TRAINING).map(([key, t]) => `
${t.icon}
${t.label}
${t.flavor}
${Object.entries(t.skills).map(([s,v])=>`${s} +${v}`).join(', ')}
${t.ebbOnly ? '
Ebb users only
' : ''}
`).join('');
return `
Step 1 — Select Race
${raceCards}
Next →
Step 2 — Training Package
${trainingCards}
← Back
Next →
Step 3 — Distribute Attributes
Distribute free points within race caps. Min 2, Max 5.
Free Points Remaining: ?
HP = STR+AGI = ? |
Resolve = WIT+EMP = ?
← Back
Next →
Step 4 — Distribute Skills
Training bonuses are pre-applied. Spend remaining free points (max 3 per skill at creation).
Skill Points Remaining: ?
← Back
Next →
Step 5 — Character Details
← Back
Review →
Step 6 — Review & Create
← Back
⚡ Create Operative
`;
}
}
// ══════════════════════════════════════════════════════════════════════════════
// NPC CREATOR APPLICATION
// ══════════════════════════════════════════════════════════════════════════════
const NPC_PERSONALITIES = ['Aggressive','Calculating','Paranoid','Loyal','Cynical','Idealistic','Greedy','Fearless','Broken','Ruthless','Haunted','Fanatic'];
const NPC_MOTIVATIONS = ['Credits','Survival','Revenge','Ideology','Family','Fame','Fear of SLA','Promotion','Addiction','Love','Duty','Unknown'];
const NPC_ROLES = ['Enforcer','Informant','Black Market Dealer','Sector Gang Leader','Investigator','Corporate Spy','Ebb Cult Member','Renegade Operative','Cannibal Sector Survivor','Shiver Sergeant'];
class SLANPCCreator extends Application {
constructor(options = {}) {
super(options);
this._mode = 'quick';
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'sla-npc-creator',
title: 'SLA Industries — NPC Generator',
template: null,
width: 560,
height: 580,
resizable: true,
classes: ['slazero2', 'sla-generator']
});
}
async _renderInner() {
const el = document.createElement('div');
el.innerHTML = this._buildHTML();
return $(el);
}
activateListeners(html) {
super.activateListeners(html);
const root = html instanceof jQuery ? html[0] : html;
// Mode toggle
root.querySelectorAll('.mode-tab').forEach(tab => {
tab.addEventListener('click', () => {
root.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this._mode = tab.dataset.mode;
root.querySelector('#full-fields').style.display = this._mode === 'full' ? 'block' : 'none';
});
});
// Random fill button
root.querySelector('#npc-randomize').addEventListener('click', () => this._randomFill(root));
// Create button
root.querySelector('#npc-create-btn').addEventListener('click', () => this._createNPC(root));
// Threat slider → update label
const threatSlider = root.querySelector('#npc-threat');
const threatLabel = root.querySelector('#npc-threat-label');
if (threatSlider) {
threatSlider.addEventListener('input', () => { threatLabel.textContent = threatSlider.value; });
}
}
_randomFill(root) {
// Pick random archetype
const types = Object.keys(SLA_NPC_TYPES);
const key = types[Math.floor(Math.random() * types.length)];
root.querySelector('#npc-archetype').value = key;
const archetype = SLA_NPC_TYPES[key];
root.querySelector('#npc-threat').value = archetype.threat;
root.querySelector('#npc-threat-label').textContent = archetype.threat;
// Random name
const nameTable = archetype.isEbb ? 'ebon' : 'generic';
root.querySelector('#npc-name').value = _slaRandomName(nameTable);
if (this._mode === 'full') {
root.querySelector('#npc-personality').value = NPC_PERSONALITIES[Math.floor(Math.random()*NPC_PERSONALITIES.length)];
root.querySelector('#npc-motivation').value = NPC_MOTIVATIONS[Math.floor(Math.random()*NPC_MOTIVATIONS.length)];
root.querySelector('#npc-role').value = NPC_ROLES[Math.floor(Math.random()*NPC_ROLES.length)];
root.querySelector('#npc-notes').value = `${root.querySelector('#npc-name').value} is a ${root.querySelector('#npc-personality').value.toLowerCase()} ${archetype.label.toLowerCase()} driven by ${root.querySelector('#npc-motivation').value.toLowerCase()}.`;
}
}
async _createNPC(root) {
const archetypeKey = root.querySelector('#npc-archetype').value;
const archetype = SLA_NPC_TYPES[archetypeKey];
const threat = parseInt(root.querySelector('#npc-threat').value) || archetype.threat;
let npcName = root.querySelector('#npc-name').value.trim();
if (!npcName) npcName = `${archetype.label} #${Math.floor(Math.random()*900+100)}`;
// Scale stats by threat delta
const delta = threat - archetype.threat;
const attrs = {};
for (const [k, v] of Object.entries(archetype.attrs)) {
attrs[k] = Math.max(2, Math.min(5, v + delta));
}
const hp = archetype.hp + delta * 2;
const armorVal = Math.max(0, Math.min(6, archetype.armor + delta));
// Build notes
let notes = '';
if (this._mode === 'full') {
const personality = root.querySelector('#npc-personality')?.value || '';
const motivation = root.querySelector('#npc-motivation')?.value || '';
const role = root.querySelector('#npc-role')?.value || '';
const extraNotes = root.querySelector('#npc-notes')?.value || '';
if (personality || motivation || role) {
notes = `Personality: ${personality}\nMotivation: ${motivation}\nRole: ${role}\n\n${extraNotes}`;
} else {
notes = extraNotes;
}
}
// NPC uses simplified system fields
const actorData = {
name: npcName,
type: 'npc',
system: {
biography: notes,
threat,
attributes: { ...attrs },
derivedStats: {
health: { value: hp, max: hp },
resolve: { value: Math.max(2, attrs.wits + attrs.empathy), max: Math.max(2, attrs.wits + attrs.empathy) }
},
skills: { ...archetype.skills },
armor: armorVal,
damage: Math.max(2, attrs.strength),
flux: archetype.flux ? { value: archetype.flux, max: archetype.flux } : { value: 0, max: 0 }
}
};
let actor;
try {
actor = await Actor.create(actorData);
} catch(err) {
ui.notifications.error(`Failed to create NPC: ${err.message}`);
return;
}
// Embed weapons
const embeds = [];
for (const w of (archetype.weapons || [])) {
if (w !== 'claws') embeds.push(_slaWeaponData(w));
}
if (embeds.length) {
try { await actor.createEmbeddedDocuments('Item', embeds); } catch(_) {}
}
// Threat descriptor
const threatNames = {1:'Mook',2:'Standard',3:'Tough',4:'Elite',5:'Boss',6:'Apex'};
ui.notifications.info(`✓ ${npcName} (${archetype.label} — Threat ${threat}: ${threatNames[threat] || 'Unknown'}) created.`);
actor.sheet?.render(true);
this.close();
}
_buildHTML() {
const archetypeOptions = Object.entries(SLA_NPC_TYPES).map(([key, a]) =>
`${a.label} (Threat ${a.threat}) `).join('');
return `
`;
}
}
// ══════════════════════════════════════════════════════════════════════════════
// SLA GM FINANCE LEDGER
// GM-only tool: lists all Player Character actors, shows current balance,
// weekly income/expenses, net weekly.
// Calculate button: newCredits = credits + income - expenses, then zeros
// all income and expense fields on the actor.
// ══════════════════════════════════════════════════════════════════════════════
class SLAGMFinanceTool extends Application {
constructor(...args) {
super(...args);
this._actorHookId = null;
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'sla-gm-finance-tool',
title: 'GM Finance Ledger',
width: 680,
height: 'auto',
resizable: true,
classes: ['slazero2', 'gm-finance-tool']
});
}
// ── Auto-refresh when any actor is updated ────────────────────────────────
async _render(force, options) {
await super._render(force, options);
if (!this._actorHookId) {
this._actorHookId = Hooks.on('updateActor', () => {
if (this.rendered) this.render(false);
});
}
}
async close(options) {
if (this._actorHookId) {
Hooks.off('updateActor', this._actorHookId);
this._actorHookId = null;
}
return super.close(options);
}
// ── Read credits from the actor's raw source data ─────────────────────────
// _source bypasses any DataModel computed values and gives the raw DB value.
_readCredits(actor) {
let raw = actor._source?.system?.details?.credits;
if (raw === undefined || raw === null)
raw = actor.system?.details?.credits;
if (raw === undefined || raw === null)
raw = foundry.utils.getProperty(actor, 'system.details.credits');
// Fix "500,500" string from duplicate-named form inputs
if (typeof raw === 'string' && raw.includes(','))
raw = raw.split(',')[0];
const n = Number(raw);
return isNaN(n) ? 0 : n;
}
// ── Read a finances sub-value safely ─────────────────────────────────────
_readFinance(actor, path) {
let raw = foundry.utils.getProperty(actor._source ?? {}, `system.${path}`)
?? foundry.utils.getProperty(actor, `system.${path}`);
const n = Number(raw);
return isNaN(n) ? 0 : n;
}
// ── Build per-actor data row ──────────────────────────────────────────────
_getPCData() {
return (game.actors ?? [])
.filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter)
.map(a => {
const salary = this._readFinance(a, 'finances.income.salary');
const bpn = this._readFinance(a, 'finances.income.bpnReward');
const incOther = this._readFinance(a, 'finances.income.other');
const accom = this._readFinance(a, 'finances.expenses.accommodation');
const drugs = this._readFinance(a, 'finances.expenses.drugs');
const subs = this._readFinance(a, 'finances.expenses.subscriptions');
const expOther = this._readFinance(a, 'finances.expenses.other');
const btax = this._readFinance(a, 'finances.expenses.bulletTax');
const weeklyIncome = salary + bpn + incOther;
const weeklyExpenses = accom + drugs + subs + expOther + btax;
const netWeekly = weeklyIncome - weeklyExpenses;
const credits = this._readCredits(a);
return {
id: a.id, name: a.name,
credits, weeklyIncome, weeklyExpenses, netWeekly
};
});
}
// ── Apply week: credits += income - expenses, then zero all fields ─────────
async _applyWeek(actorId) {
if (!game.user?.isGM) return;
const actor = game.actors.get(actorId);
if (!actor) return;
const salary = this._readFinance(actor, 'finances.income.salary');
const bpn = this._readFinance(actor, 'finances.income.bpnReward');
const incOther = this._readFinance(actor, 'finances.income.other');
const accom = this._readFinance(actor, 'finances.expenses.accommodation');
const drugs = this._readFinance(actor, 'finances.expenses.drugs');
const subs = this._readFinance(actor, 'finances.expenses.subscriptions');
const expOther = this._readFinance(actor, 'finances.expenses.other');
const btax = this._readFinance(actor, 'finances.expenses.bulletTax');
const income = salary + bpn + incOther;
const expenses = accom + drugs + subs + expOther + btax;
const current = this._readCredits(actor);
const newBal = current + income - expenses;
// {render: false} stops Foundry from auto-submitting open character sheets
// before they re-render, which was causing stale form values (e.g. 500)
// to be merged with the new value (e.g. 852) into a corrupt "852,500" string.
await actor.update({
'system.details.credits': newBal,
'system.finances.income.salary': 0,
'system.finances.income.bpnReward': 0,
'system.finances.income.other': 0,
'system.finances.expenses.accommodation': 0,
'system.finances.expenses.drugs': 0,
'system.finances.expenses.subscriptions': 0,
'system.finances.expenses.other': 0,
'system.finances.expenses.bulletTax': 0,
'system.finances.ammoSpentSession': 0,
'system.finances.debt': 0
}, { render: false });
// Now re-render any open sheets for this actor cleanly from fresh data.
for (const app of Object.values(actor.apps ?? {})) {
app.render(false);
}
ui.notifications.info(
`${actor.name}: ${current}c + ${income}c income − ${expenses}c expenses = ${newBal}c`
);
}
// ── Render the table ──────────────────────────────────────────────────────
async _renderInner(_data) {
const pcs = this._getPCData();
let rows = '';
if (pcs.length === 0) {
rows = `
No Player Characters found.
Tick Player Character on each actor's Biography tab.
`;
} else {
for (const pc of pcs) {
const netClass = pc.netWeekly >= 0 ? 'net-positive' : 'net-negative';
const netSign = pc.netWeekly >= 0 ? '+' : '';
const newBal = pc.credits + pc.netWeekly;
rows += `
${pc.name}
${pc.credits}c
${pc.weeklyIncome}c
${pc.weeklyExpenses}c
${netSign}${pc.netWeekly}c
Calculate
`;
}
}
const html = `
Character
Balance
Income
Expenses
Net
${rows}
`;
return $(html);
}
activateListeners(html) {
super.activateListeners(html);
const root = html instanceof jQuery ? html[0] : html;
root.querySelectorAll('.gm-finance-calc-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
await this._applyWeek(btn.dataset.actorId);
// render() fires automatically via the updateActor hook
});
});
root.querySelector('.gm-finance-calc-all-btn')?.addEventListener('click', async () => {
const pcs = this._getPCData();
for (const pc of pcs) await this._applyWeek(pc.id);
});
}
}
// ══════════════════════════════════════════════════════════════════════════════
// SLA GROUP NPC GENERATOR
// GM tool: quickly generate and track groups of Carrions, Serial Killers,
// Monstarets or Machines. Compact editable stat cards, HP tracking,
// reroll per card, and one-click export to World actors.
// ══════════════════════════════════════════════════════════════════════════════
const SLA_NPC_GROUP_ARCHETYPES = {
streetgang: {
label: 'Street Gang', color: '#cc8800', icon: 'fas fa-street-view',
nameParts: ['','','','Big','Mad','Dead','Crazy','One-Eye'],
nameBase: ['Sloper','Ganger','Thug','Punk','Ratter','Knife-Boy','Street Dog','Scrapper','Slum Rat'],
stats: { str:[2,3], agi:[2,3], wit:[1,2], emp:[1,3] },
health:[2,4], armor:[0,2], damage:[1,3], threat:[1,2],
armorPool:[
'None','None','None',
'Street Leathers (ARM 1)','Street Leathers (ARM 1)',
'Padded Jacket (ARM 1)',
'Scavenged Vest (ARM 2)',
'Old Combat Jacket (ARM 2)',
],
weaponPool:[
// Slot 1 — always a melee weapon
[
{name:'Lead Pipe', damage:2,ap:0,range:'Engaged'},
{name:'Knife', damage:1,ap:1,range:'Engaged'},
{name:'Baseball Bat', damage:2,ap:0,range:'Engaged'},
{name:'Crowbar', damage:2,ap:1,range:'Engaged'},
{name:'Machete', damage:2,ap:1,range:'Engaged'},
{name:'Broken Bottle',damage:1,ap:0,range:'Engaged'},
{name:'Chain', damage:1,ap:0,range:'Engaged'},
{name:'Hatchet', damage:2,ap:1,range:'Engaged'},
{name:'Knuckleduster',damage:1,ap:0,range:'Engaged',note:'bonus: free grab on hit'},
],
// Slot 2 — 40% chance of a ranged weapon (nulls reduce probability)
[
null,null,null,null,null,null,
{name:'Cheap Pistol', damage:2,ap:0,range:'Short'},
{name:'Sawn-Off Shotgun', damage:3,ap:0,range:'Engaged'},
{name:'Crossbow', damage:2,ap:1,range:'Medium'},
{name:'Cheap SMG', damage:2,ap:0,range:'Short',note:'ROF 3'},
],
],
abilities:[
'Gang Up — when 2+ attack same target, one gets +1 die',
'Street Smarts — +1 Observation die in urban environments',
'Intimidation — Force roll success triggers Panic check in civilians',
'Mob Mentality — +1 die when within 5m of 2+ allies',
'Dirty Fighter — free grab attempt on any melee hit',
'Numbers Game — group of 5+ gains +2 morale threshold',
'Scavenger — minor action: grab a nearby dropped weapon or item',
]
},
carrion: {
label: 'Carrion', color: '#cc4422', icon: 'fas fa-biohazard',
nameParts: ['Bloated','Feral','Stalking','Gnawing','Lurching','Rabid','Rotting','Hulking',''],
nameBase: ['Carrion','Carrion','Carrien','Feeder','Gnawer','Pack Carrion','Shambler'],
stats: { str:[2,4], agi:[1,2], wit:[1,1], emp:[1,1] },
health:[3,6], armor:[0,1], damage:[2,3], threat:[1,2],
armorPool:['None','None','None','Matted Flesh (ARM 1)'],
naturalWeapons:[
{name:'Bite', damage:2, ap:1, note:'ignores 1 armour; target STR check or Bleeding'},
{name:'Claws', damage:1, ap:0, note:'always counts as armed; can rake two targets'},
],
abilities:[
'Pack Aggression — +1 die per Carrion adjacent to the same target',
'Mindless — immune to Panic and all psychological/social effects',
'Hard to Kill — rolls Physical Critical even at 0 HP (once per fight)',
'Swarm — 3+ Carrion attacking same target simultaneously: +2 damage total',
'Contagion — bite victims must pass STR check or contract Rot Fever',
'Shamble — moves half speed but ignores difficult terrain',
'Death Rattle — on death, adjacent targets must make a Morale check',
]
},
serialkiller: {
label: 'Serial Killer', color: '#882244', icon: 'fas fa-skull',
nameParts: ['The','The','The',''],
nameBase: ['Butcher','Flenser','Ghost','Surgeon','Reaper','Cutter','Shadow','Wraith','Stalker','Hollow','Hunter'],
stats: { str:[2,4], agi:[2,4], wit:[2,3], emp:[1,2] },
health:[3,5], armor:[0,2], damage:[3,4], threat:[3,4],
armorPool:[
'None','None','None',
'Leather Coat (ARM 1)','Leather Coat (ARM 1)',
'Tactical Vest (ARM 2)',
'Scavenged Body Armour (ARM 2)',
],
weaponPool:[
// Slot 1 — signature melee weapon (always present)
[
{name:'Hunting Knife', damage:2,ap:1,range:'Engaged'},
{name:'Butcher Cleaver', damage:3,ap:0,range:'Engaged'},
{name:'Surgical Scalpel', damage:2,ap:2,range:'Engaged',note:'ignores 2 ARM'},
{name:'Combat Knife', damage:2,ap:1,range:'Engaged'},
{name:'Chainsaw', damage:4,ap:1,range:'Engaged',note:'Loud — breaks Stealth'},
{name:'Hatchet', damage:3,ap:1,range:'Engaged'},
{name:'Piano Wire', damage:2,ap:0,range:'Engaged',note:'Grapple: auto 2 DMG/turn'},
{name:'Bone Saw', damage:3,ap:2,range:'Engaged'},
{name:'Stiletto', damage:2,ap:2,range:'Engaged',note:'ignores soft armour'},
],
// Slot 2 — 30% chance of secondary ranged weapon
[
null,null,null,null,null,null,null,
{name:'Pistol', damage:2,ap:0,range:'Short'},
{name:'Sniper Rifle',damage:3,ap:2,range:'Long',note:'ignores cover'},
{name:'Crossbow', damage:2,ap:1,range:'Medium',note:'Silent'},
],
],
abilities:[
'Signature Weapon — +1 die when using preferred weapon type',
'Predator Focus — ignores cover against a chosen target',
'Surprise Strike — first attack each combat: +2 damage',
'Psychotic Endurance — ignores one level of injury penalties',
'Brutal Efficiency — 3+ successes on attack: target is Stunned',
'Stealth — 3 free Stealth dice when ambushing from concealment',
'Trophies — each kill this session: cumulative +1 to next damage roll',
'Evasion — once per round: negate one melee hit on a 4+',
]
},
monstaret: {
label: 'Monstaret', color: '#226622', icon: 'fas fa-dragon',
// Subtypes define the full stat profile, natural weapons and abilities
subtypes:[
{
name:'Fang Beast',
modifiers:['Alpha ','Juvenile ','Mutant ',''],
stats:{str:[4,5],agi:[3,4],wit:[1,1],emp:[1,1]},
health:[7,10], armor:[1,2], damage:[3,4], threat:[3,4],
naturalWeapons:[
{name:'Bite', damage:3,ap:2,note:'target STR check or Bleeding (1 DMG/turn)'},
{name:'Claw Swipe', damage:2,ap:0,note:'can hit 2 adjacent targets in one action'},
],
abilities:[
'Pack Hunter — +1 die when 2+ Fang Beasts are present',
'Pounce — charge up to 10m as a free move before attacking',
'Bleeding Bite — on hit: target gains Bleeding condition (1 DMG/turn)',
'Frenzy — at ≤ half HP, gains an additional attack per round',
]
},
{
name:'Rend Beast',
modifiers:['Ancient ','Elder ','Corrupted ',''],
stats:{str:[5,6],agi:[1,2],wit:[1,1],emp:[1,1]},
health:[9,13], armor:[2,4], damage:[4,5], threat:[4,5],
naturalWeapons:[
{name:'Rending Claws',damage:4,ap:1,note:'3+ successes: Rend — ongoing 1 DMG/turn'},
{name:'Crushing Grip',damage:2,ap:0,note:'target Grappled; STR vs STR to escape'},
],
abilities:[
'Unstoppable — not stopped by barriers; pushes through obstacles',
'Rending Strike — 3+ successes: target gains Bleeding condition',
'Toughened Hide — ignore first 1 damage from each attack',
'Slow — only moves Short range per turn but cannot be tripped',
]
},
{
name:'Pit-Thing',
modifiers:['Corrupted ','Mutant ','',''],
stats:{str:[3,4],agi:[3,4],wit:[1,1],emp:[1,1]},
health:[5,8], armor:[1,2], damage:[2,3], threat:[3,4],
naturalWeapons:[
{name:'Acid Spit', damage:2,ap:3,note:'range Short; hit: −1 ARM permanently'},
{name:'Bite', damage:2,ap:1,note:'acid contact: attacker takes 1 DMG on hit'},
],
abilities:[
'Acid Resistance — immune to acid and corrosive effects',
'Wall Crawl — moves freely on vertical and overhead surfaces',
'Spit Barrage — acid spit at 2 targets per round (−1 die each)',
'Dissolve Armour — each acid hit permanently reduces target ARM by 1',
]
},
{
name:'Lurker',
modifiers:['Shadow ','Deep ','',''],
stats:{str:[3,4],agi:[3,5],wit:[1,2],emp:[1,1]},
health:[5,8], armor:[0,2], damage:[2,3], threat:[3,4],
naturalWeapons:[
{name:'Tentacle Lash',damage:2,ap:0,note:'reach 5m; 3+ successes: target Grappled'},
{name:'Constrict', damage:3,ap:0,note:'only vs Grappled targets; auto each turn'},
],
abilities:[
'Camouflage — 4 free Stealth dice; invisible unless moving',
'Ambush — first attack from hidden: +3 dice',
'Multi-Grab — can Grapple up to 3 targets (one tentacle each)',
'Squeeze — Constrict deals +1 DMG per round target remains Grappled',
]
},
{
name:'Howler',
modifiers:['Alpha ','Pack ','',''],
stats:{str:[3,4],agi:[4,5],wit:[1,1],emp:[1,1]},
health:[4,7], armor:[0,1], damage:[2,3], threat:[3,4],
naturalWeapons:[
{name:'Sonic Screech',damage:2,ap:0,note:'all in Short range; 2+ successes: Stunned'},
{name:'Claw', damage:2,ap:0,note:'fast — can attack twice per round'},
],
abilities:[
'Screech — area attack within Short range; all targets check vs Stun',
'Speed — always acts initiative phase 1; attacks twice per round',
'Pack Call — if alive after round 1, summons 1d3 additional Howlers',
'Fragile — only ARM 0–1 but extremely agile and hard to hit',
]
},
{
name:'Crawler',
modifiers:['Giant ','Armoured ','Brood ',''],
stats:{str:[4,5],agi:[2,3],wit:[1,1],emp:[1,1]},
health:[7,11], armor:[2,4], damage:[3,4], threat:[3,4],
naturalWeapons:[
{name:'Mandibles',damage:3,ap:1,note:'STR check or Paralysed for 1 round'},
{name:'Leg Spike',damage:2,ap:0,note:'free attack against rear arc targets'},
],
abilities:[
'Predatory Instinct — always acts in initiative phase 1',
'Paralytic Bite — on hit: target STR check or Paralysed 1 round',
'Carapace — ARM 2 on top/sides; 0 from below; called shots can bypass',
'Many-Legged — immune to trip/knockdown effects',
]
},
]
},
machine: {
label: 'Machine', color: '#334488', icon: 'fas fa-robot',
subtypes:[
{
name:'Combat Unit',
designation:['MK-','Unit-',''],
stats:{str:[4,5],agi:[2,3],wit:[3,4],emp:[0,0]},
health:[6,10], armor:[3,5], damage:[3,4], threat:[3,4],
integratedWeapons:[
{name:'Integrated Rifle', damage:3,ap:1,range:'Long', note:'ROF 1'},
{name:'Combat Blade', damage:3,ap:1,range:'Engaged'},
],
abilities:[
'Targeting — re-roll one miss die per attack',
'Armoured Shell — natural ARM 3 (already in stats)',
'Threat Assessment — always knows current HP of visible targets',
'Suppression Protocol — target that failed to hit: −1 die next attack',
]
},
{
name:'Scout Drone',
designation:['DES-','SD-',''],
stats:{str:[2,3],agi:[4,5],wit:[3,4],emp:[0,0]},
health:[3,5], armor:[1,2], damage:[2,3], threat:[2,3],
integratedWeapons:[
{name:'Shock Taser', damage:2,ap:0,range:'Engaged',note:'Stunned on hit'},
{name:'Dart Pistol', damage:1,ap:0,range:'Short', note:'sedative: STR check or unconscious'},
],
abilities:[
'Flight — ignores ground terrain; moves in full 3D space',
'Sensor Suite — detects all living targets within 20m regardless of cover',
'Relay — allies gain +1 die against targets the drone has acquired',
'Emergency Protocol — at ≤2 HP: transmits distress signal (reinforcements in 3 rounds)',
]
},
{
name:'Heavy Assault Unit',
designation:['HAU-','MK-',''],
stats:{str:[5,6],agi:[1,2],wit:[2,3],emp:[0,0]},
health:[9,14], armor:[5,7], damage:[4,6], threat:[4,5],
integratedWeapons:[
{name:'Minigun', damage:3,ap:0,range:'Medium',note:'ROF 6; suppression-fire area attack'},
{name:'Flamethrower', damage:4,ap:2,range:'Short', note:'On Fire condition; Short cone area'},
{name:'Armour Fist', damage:5,ap:2,range:'Engaged',note:'knockback 5m on hit'},
],
abilities:[
'Weapon Array — fires two weapons at different targets simultaneously',
'Overload — on death: 4 damage to all within 5m',
'Juggernaut — immune to Grapple; ignores terrain and barriers',
'Targeting Computer — re-rolls all miss dice once per attack',
]
},
{
name:'Maintenance Unit',
designation:['MU-','SYS-',''],
stats:{str:[3,4],agi:[2,3],wit:[3,4],emp:[0,0]},
health:[4,7], armor:[2,3], damage:[2,3], threat:[2,3],
integratedWeapons:[
{name:'Cutting Laser',damage:2,ap:3,range:'Engaged',note:'fully ignores armour AP'},
{name:'Shock Wrench', damage:2,ap:0,range:'Engaged',note:'EMP: short-circuits electronics'},
],
abilities:[
'Field Repairs — restore 2 HP to another Machine unit as a full action',
'Structural Knowledge — +2 AP vs armoured targets (joint strikes)',
'EMP Pulse — once per combat: all electronics within 5m offline for 1 round',
'Diagnostic Mode — immune to Blinded and Stunned conditions',
]
},
]
}
};
class SLAGroupNPCTool extends Application {
constructor(...args) {
super(...args);
this._npcs = [];
this._selectedType = 'carrion';
this._count = 4;
this._root = null;
this._threatLevel = 3; // 1 = baseline, 10 = elite. Scale: AGI/initiative focus.
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'sla-group-npc-tool',
title: 'Group NPC Generator',
width: 860,
height: 600,
resizable: true,
classes: ['slazero2', 'group-npc-tool']
});
}
// ── Utilities ─────────────────────────────────────────────────────────────
_rng(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
_pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
_threatLevelLabel(lvl) {
const names = ['','Baseline','Quick','Alert','Dangerous','Veteran','Hardened','Elite','Apex','Legendary','Mythic'];
return `${lvl} — ${names[lvl] ?? lvl}`;
}
_hexRgb(hex) {
const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return r ? `${parseInt(r[1],16)},${parseInt(r[2],16)},${parseInt(r[3],16)}` : '128,128,128';
}
_esc(s) { return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/ Math.random() - 0.5);
abilities = pool.slice(0, 2);
armorDesc = `Built-in ARM ${armor}`;
} else {
// --- Standard flat generation (Street Gang, Carrion, Serial Killer) ---
const prefix = this._pick(arch.nameParts);
const base = this._pick(arch.nameBase);
name = prefix ? `${prefix}${prefix.endsWith('-') ? '' : ' '}${base}` : base;
str = this._rng(...arch.stats.str);
agi = this._rng(...arch.stats.agi);
wit = this._rng(...arch.stats.wit);
emp = this._rng(...arch.stats.emp);
hpMax = this._rng(...arch.health);
armor = this._rng(...arch.armor);
damage = this._rng(...arch.damage);
threat = this._rng(...arch.threat);
if (arch.naturalWeapons) naturalWeapons = [...arch.naturalWeapons];
if (arch.weaponPool) {
for (const slot of arch.weaponPool) {
const pick = this._pick(slot);
if (pick) weapons.push(pick);
}
}
const pool = [...(arch.abilities ?? [])].sort(() => Math.random() - 0.5);
abilities = pool.slice(0, 2);
if (arch.armorPool) {
const ap = this._pick(arch.armorPool);
armorDesc = ap ?? 'None';
} else {
armorDesc = armor > 0 ? `ARM ${armor}` : 'None';
}
}
const npc = {
id: foundry.utils.randomID(), typeKey,
typeLabel: arch.label,
color: arch.color, icon: arch.icon,
name, str, agi, wit, emp,
hp: hpMax, hpMax, armor, damage, threat,
weapons, naturalWeapons, armorDesc,
abilities, notes: '', defeated: false
};
return this._applyThreatBonus(npc);
}
// ── Threat-level scaling ───────────────────────────────────────────────────
// Scales AGI (initiative) as the primary target; STR, HP, ARM, DMG also
// creep up at higher levels. Level 1 = baseline (no stat changes).
// initPool is ALWAYS set: max(5, agi) + threatLevel gives at least 6d6 at
// level 1 so every NPC can match players on initiative.
_applyThreatBonus(npc) {
const lvl = Math.max(1, Math.min(10, this._threatLevel ?? 1));
npc.threatBonus = lvl;
if (lvl > 1) {
// Per-level bonus table: [agiBonus, strBonus, witBonus, hpPct, armorBonus, dmgBonus]
const TABLE = [
[0, 0, 0, 0.00, 0, 0], // 1 — baseline (unused; only entered for lvl > 1)
[1, 0, 0, 0.00, 0, 0], // 2 — quick
[1, 0, 1, 0.00, 0, 0], // 3 — alert
[2, 1, 1, 0.00, 0, 0], // 4 — dangerous
[2, 1, 1, 0.25, 0, 1], // 5 — veteran
[3, 2, 1, 0.25, 1, 1], // 6 — hardened
[3, 2, 2, 0.50, 1, 1], // 7 — elite
[4, 3, 2, 0.50, 1, 2], // 8 — apex
[4, 3, 3, 0.75, 2, 2], // 9 — legendary
[5, 4, 3, 1.00, 2, 3], // 10 — mythic
];
const [agiB, strB, witB, hpPct, armB, dmgB] = TABLE[lvl - 1];
npc.agi = Math.min(8, npc.agi + agiB);
npc.str = Math.min(8, npc.str + strB);
npc.wit = Math.min(8, npc.wit + witB);
npc.armor = Math.min(8, npc.armor + armB);
npc.damage = Math.min(8, npc.damage + dmgB);
if (hpPct > 0) {
const extra = Math.ceil(npc.hpMax * hpPct);
npc.hpMax += extra;
npc.hp += extra;
}
}
// Initiative dice pool: base AGI (floor 5) + one bonus die per threat level.
// A level-1 Carrion (AGI 2) → max(2,5)+1 = 6d6; level-3 Carrion → max(3,5)+3 = 8d6.
// This guarantees NPCs can contest player initiative at all threat tiers.
npc.initPool = Math.max(5, npc.agi) + lvl;
return npc;
}
// ── Icon helpers — SLA-specific art ─────────────────────────────────────
static _SLA = 'systems/slazero2/assets/npc-art';
/** Portrait icon for the NPC actor based on type */
_getNPCIcon(typeKey) {
const p = SLAGroupNPCTool._SLA + '/portraits';
return {
streetgang: `${p}/streetgang.png`,
carrion: `${p}/carrion.png`,
serialkiller: `${p}/serialkiller.png`,
monstaret: `${p}/monstaret.png`,
machine: `${p}/machine.png`,
}[typeKey] ?? `${p}/enemy-generic.png`;
}
/** Choose weapon item icon from SLA art */
_getWeaponIcon(weapon) {
const n = (weapon.name || '').toLowerCase();
const r = (weapon.range || '').toLowerCase();
const W = SLAGroupNPCTool._SLA + '/weapons';
// Natural
if (n.includes('bite') || n.includes('mandible') || n.includes('constrict') || n.includes('grip'))
return `${W}/natural-weapon.png`;
if (n.includes('claw') || n.includes('rend') || n.includes('tentacle') || n.includes('talon'))
return `${W}/claws.png`;
if (n.includes('spit') || n.includes('acid') || n.includes('screech') || n.includes('sonic'))
return 'icons/svg/aura.svg';
// Ranged
if (n.includes('sniper') || n.includes('marksman'))
return `${W}/sniper.png`;
if (n.includes('rifle') || n.includes('minigun') || n.includes('autocannon') || n.includes('lmg') || n.includes('flamethrower'))
return `${W}/rifle.png`;
if (n.includes('shotgun') || n.includes('street sweeper') || n.includes('sawn'))
return `${W}/shotgun.png`;
if (n.includes('smg') || n.includes('submachine'))
return `${W}/smg.png`;
if (n.includes('pistol') || n.includes('handgun') || n.includes('dart') || n.includes('taser') || n.includes('shock') || n.includes('laser'))
return `${W}/pistol.png`;
if (n.includes('crossbow'))
return `${W}/pistol.png`;
// Melee — blunt
if (n.includes('pipe') || n.includes('bat') || n.includes('baton') || n.includes('chain') || n.includes('crowbar') || n.includes('knuckle') || n.includes('wrench') || n.includes('hammer'))
return `${W}/baton.png`;
// Melee — cutting
if (n.includes('chainsaw') || n.includes('saw'))
return `${W}/chainsaw.png`;
if (n.includes('hatchet') || n.includes('axe') || n.includes('cleaver'))
return `${W}/hatchet.png`;
if (n.includes('machete') || n.includes('sabre') || n.includes('sword') || n.includes('blade') || n.includes('vibro'))
return `${W}/sabre.png`;
if (n.includes('wire') || n.includes('stiletto') || n.includes('scalpel') || n.includes('bone saw'))
return `${W}/combat-knife.png`;
if (n.includes('knife') || n.includes('dagger'))
return `${W}/knife.png`;
if (n.includes('fist') || n.includes('punch') || n.includes('slam'))
return `${W}/natural-weapon.png`;
// Fallback by range
if (r && r !== 'engaged') return `${W}/pistol.png`;
return `${W}/knife.png`;
}
/** Choose ability/specialty icon — SLA trait art where possible */
_getAbilityIcon(text) {
const t = (text || '').toLowerCase();
const A = SLAGroupNPCTool._SLA + '/abilities';
if (t.includes('stealth') || t.includes('camouflage') || t.includes('hidden') || t.includes('ambush'))
return 'icons/svg/cowled.svg';
if (t.includes('acid') || t.includes('contagion') || t.includes('venom') || t.includes('poison') || t.includes('dissolve'))
return `${A}/contagion.png`;
if (t.includes('fire') || t.includes('burn') || t.includes('flame'))
return 'icons/svg/fire.svg';
if (t.includes('stun') || t.includes('emp') || t.includes('shock') || t.includes('paralys'))
return 'icons/svg/daze.svg';
if (t.includes('bleed') || t.includes('rend') || t.includes('gore') || t.includes('bite'))
return 'icons/svg/blood.svg';
if (t.includes('heal') || t.includes('repair') || t.includes('recover') || t.includes('diagnos'))
return `${A}/regeneration.png`;
if (t.includes('pack') || t.includes('swarm') || t.includes('gang') || t.includes('mob') || t.includes('flank'))
return `${A}/enemy.png`;
if (t.includes('frenzy') || t.includes('unstoppable') || t.includes('brutal') || t.includes('hard hit'))
return `${A}/hard-hitting.png`;
if (t.includes('fast') || t.includes('speed') || t.includes('sprint') || t.includes('leap') || t.includes('pounce'))
return `${A}/fast-reflexes.png`;
if (t.includes('intimidat') || t.includes('menac') || t.includes('fear') || t.includes('screech'))
return `${A}/menacing.png`;
if (t.includes('psycho') || t.includes('endurance') || t.includes('mindless') || t.includes('ignore'))
return `${A}/psychosis.png`;
return `${A}/attack.png`;
}
/** Find or create an Actor folder by name, optionally under a parent */
async _ensureFolder(name, parentId = null) {
let folder = (game.folders ?? []).find(f =>
f.type === 'Actor' && f.name === name && (f.folder?.id ?? null) === parentId
);
if (!folder) {
try {
const colors = {
'Generated NPCs': '#442211',
'Street Gang': '#996611',
'Carrion': '#883311',
'Serial Killer': '#661133',
'Monstaret': '#224411',
'Machine': '#112244',
};
folder = await Folder.create({
name, type: 'Actor',
folder: parentId,
sorting: 'a',
color: colors[name] ?? '#333333'
});
} catch(e) {
console.warn(`SLA Zero Two | Could not create folder "${name}":`, e);
}
}
return folder ?? null;
}
// ── Save in-progress edits before re-render ───────────────────────────────
_saveState() {
const root = this._root;
if (!root) return;
root.querySelectorAll('.gnpc-name-input').forEach(el => {
const n = this._npcs.find(x => x.id === el.dataset.npcId);
if (n) n.name = el.value;
});
root.querySelectorAll('.gnpc-stat-input').forEach(el => {
const n = this._npcs.find(x => x.id === el.dataset.npcId);
if (n && el.dataset.field) n[el.dataset.field] = parseInt(el.value) || 0;
});
root.querySelectorAll('.gnpc-notes-input').forEach(el => {
const n = this._npcs.find(x => x.id === el.dataset.npcId);
if (n) n.notes = el.value;
});
}
// ── Build one NPC card ────────────────────────────────────────────────────
_buildCard(npc) {
const rgb = this._hexRgb(npc.color);
const hpPct = npc.hpMax > 0 ? Math.max(0, Math.min(100, (npc.hp / npc.hpMax) * 100)) : 0;
const hpColor = hpPct > 60 ? '#44cc66' : hpPct > 30 ? '#ffaa00' : '#cc3333';
const defeated = npc.defeated ? ' gnpc-defeated' : '';
const bgStyle = `border-left:3px solid ${npc.color};background:rgba(${rgb},0.07)`;
// Natural weapons (creatures only)
const natHtml = (npc.naturalWeapons ?? []).map(w => {
const detail = `DMG ${w.damage}${w.ap ? ` AP ${w.ap}` : ''}${w.note ? ` — ${w.note}` : ''}`;
return `${this._esc(w.name)} ${detail}
`;
}).join('');
// Conventional / integrated weapons
const wepHtml = (npc.weapons ?? []).map(w => {
const detail = `DMG ${w.damage}${w.ap ? ` AP ${w.ap}` : ''}${w.range ? ` [${w.range}]` : ''}${w.note ? ` — ${w.note}` : ''}`;
return `${this._esc(w.name)} ${detail}
`;
}).join('');
// Abilities
const abils = (npc.abilities ?? []).map(a =>
` ${this._esc(a)}
`
).join('');
const armorLine = npc.armorDesc && npc.armorDesc !== 'None'
? ` ${this._esc(npc.armorDesc)}
` : '';
return `
${npc.typeLabel}
T${npc.threat}
${(npc.threatBonus ?? 1) > 1 ? `
⚡${npc.threatBonus} ` : ''}
INIT
${npc.initPool}d6
${armorLine}
${natHtml}${wepHtml}
${abils}
`;
}
// ── Full render ───────────────────────────────────────────────────────────
async _renderInner(_data) {
const types = Object.keys(SLA_NPC_GROUP_ARCHETYPES);
const typeBtns = types.map(k => {
const a = SLA_NPC_GROUP_ARCHETYPES[k];
const act = k === this._selectedType;
const rgb = this._hexRgb(a.color);
return `
${a.label} `;
}).join('');
const countVal = this._count;
const alive = this._npcs.filter(n => !n.defeated).length;
const defeated = this._npcs.filter(n => n.defeated).length;
const cards = this._npcs.map(n => this._buildCard(n)).join('');
const empty = `Choose a type and press Generate to create NPCs.
`;
const footer = this._npcs.length ? `
` : '';
return $(`
${this._npcs.length ? cards : empty}
${footer}
`);
}
// ── Listeners ─────────────────────────────────────────────────────────────
activateListeners(html) {
super.activateListeners(html);
const root = html instanceof jQuery ? html[0] : html;
this._root = root;
// Type tab selection
root.querySelectorAll('.gnpc-type-btn').forEach(btn =>
btn.addEventListener('click', () => {
this._saveState();
this._selectedType = btn.dataset.type;
this.render(false);
})
);
// Count selector (number input, 1–10)
root.querySelector('.gnpc-count-sel')?.addEventListener('input', e => {
this._count = Math.max(1, Math.min(10, parseInt(e.target.value) || 1));
});
// Threat level slider (1–10) — live label + track-fill update, no re-render
const threatSlider = root.querySelector('.gnpc-threat-slider');
const _updateSliderFill = (el, lvl) => {
const pct = ((lvl - 1) / 9 * 100).toFixed(1);
el.style.background = `linear-gradient(to right, #00ccff 0%, #00ccff ${pct}%, #333 ${pct}%, #333 100%)`;
};
if (threatSlider) {
_updateSliderFill(threatSlider, this._threatLevel);
threatSlider.addEventListener('input', e => {
this._threatLevel = Math.max(1, Math.min(10, parseInt(e.target.value) || 1));
_updateSliderFill(e.target, this._threatLevel);
const valEl = root.querySelector('.gnpc-threat-val');
if (valEl) valEl.textContent = this._threatLevelLabel(this._threatLevel);
});
}
// Generate (replace all)
root.querySelector('.gnpc-gen-btn')?.addEventListener('click', () => {
this._npcs = [];
for (let i = 0; i < this._count; i++) {
const n = this._generateOne(this._selectedType);
if (n) this._npcs.push(n);
}
this.render(false);
});
// Add more (append)
root.querySelector('.gnpc-add-btn')?.addEventListener('click', () => {
this._saveState();
for (let i = 0; i < this._count; i++) {
const n = this._generateOne(this._selectedType);
if (n) this._npcs.push(n);
}
this.render(false);
});
// Clear all
root.querySelector('.gnpc-clr-btn')?.addEventListener('click', () => {
this._npcs = [];
this.render(false);
});
// Defeat toggle
root.querySelectorAll('.gnpc-defeat-btn').forEach(btn =>
btn.addEventListener('click', () => {
this._saveState();
const n = this._npcs.find(x => x.id === btn.dataset.npcId);
if (n) { n.defeated = !n.defeated; this.render(false); }
})
);
// Initiative roll — pool = max(5, agi) + threatLevel; count 6s as successes
root.querySelectorAll('.gnpc-init-btn').forEach(btn =>
btn.addEventListener('click', () => {
const n = this._npcs.find(x => x.id === btn.dataset.npcId);
if (!n) return;
const pool = n.initPool ?? Math.max(5, n.agi) + (n.threatBonus ?? 1);
const rolls = Array.from({ length: pool }, () => Math.ceil(Math.random() * 6));
const successes = rolls.filter(d => d === 6).length;
// Update inline result display (no full re-render)
const resultEl = root.querySelector(`.gnpc-init-result[data-npc-id="${n.id}"]`);
if (resultEl) {
const diceHtml = rolls.map(d =>
`${d} `
).join('');
resultEl.innerHTML = `${diceHtml}${successes}✓ `;
}
// Post result to chat
const diceStr = rolls.map(d => d === 6 ? `${d} ` : `${d}`).join(' ');
ChatMessage.create({
speaker: { alias: n.name },
content: `
${n.name} — Initiative (${pool}d6)
${diceStr}
${successes} success${successes !== 1 ? 'es' : ''}
`,
type: CONST.CHAT_MESSAGE_TYPES?.OTHER ?? 0,
});
})
);
// Reroll one
root.querySelectorAll('.gnpc-reroll-btn').forEach(btn =>
btn.addEventListener('click', () => {
this._saveState();
const idx = this._npcs.findIndex(x => x.id === btn.dataset.npcId);
if (idx < 0) return;
const fresh = this._generateOne(this._npcs[idx].typeKey);
if (fresh) { fresh.notes = this._npcs[idx].notes; this._npcs[idx] = fresh; }
this.render(false);
})
);
// Remove one
root.querySelectorAll('.gnpc-remove-btn').forEach(btn =>
btn.addEventListener('click', () => {
this._saveState();
this._npcs = this._npcs.filter(x => x.id !== btn.dataset.npcId);
this.render(false);
})
);
// HP ± buttons (no full re-render — update DOM directly)
root.querySelectorAll('.gnpc-hp-down').forEach(btn =>
btn.addEventListener('click', () => {
const n = this._npcs.find(x => x.id === btn.dataset.npcId);
if (!n) return;
n.hp = Math.max(0, n.hp - 1);
const card = root.querySelector(`.gnpc-card[data-npc-id="${n.id}"]`);
if (card) {
card.querySelector('.gnpc-hp-val').value = n.hp;
const pct = n.hpMax > 0 ? Math.max(0, (n.hp / n.hpMax) * 100) : 0;
const color = pct > 60 ? '#44cc66' : pct > 30 ? '#ffaa00' : '#cc3333';
const bar = card.querySelector('.gnpc-hp-bar');
if (bar) { bar.style.width = `${pct}%`; bar.style.background = color; }
}
})
);
root.querySelectorAll('.gnpc-hp-up').forEach(btn =>
btn.addEventListener('click', () => {
const n = this._npcs.find(x => x.id === btn.dataset.npcId);
if (!n) return;
n.hp = Math.min(n.hpMax, n.hp + 1);
const card = root.querySelector(`.gnpc-card[data-npc-id="${n.id}"]`);
if (card) {
card.querySelector('.gnpc-hp-val').value = n.hp;
const pct = n.hpMax > 0 ? Math.max(0, (n.hp / n.hpMax) * 100) : 0;
const color = pct > 60 ? '#44cc66' : pct > 30 ? '#ffaa00' : '#cc3333';
const bar = card.querySelector('.gnpc-hp-bar');
if (bar) { bar.style.width = `${pct}%`; bar.style.background = color; }
}
})
);
// Reset all HP to max
root.querySelector('.gnpc-reset-hp-btn')?.addEventListener('click', () => {
this._saveState();
this._npcs.forEach(n => { n.hp = n.hpMax; n.defeated = false; });
this.render(false);
});
// Inline stat edits — save to state on input (no re-render)
root.querySelectorAll('.gnpc-stat-input').forEach(el =>
el.addEventListener('input', () => {
const n = this._npcs.find(x => x.id === el.dataset.npcId);
if (n && el.dataset.field) n[el.dataset.field] = parseInt(el.value) || 0;
})
);
root.querySelectorAll('.gnpc-name-input').forEach(el =>
el.addEventListener('input', () => {
const n = this._npcs.find(x => x.id === el.dataset.npcId);
if (n) n.name = el.value;
})
);
root.querySelectorAll('.gnpc-notes-input').forEach(el =>
el.addEventListener('input', () => {
const n = this._npcs.find(x => x.id === el.dataset.npcId);
if (n) n.notes = el.value;
})
);
// Create as World Actors — folder + icons + embedded items
root.querySelector('.gnpc-create-btn')?.addEventListener('click', async () => {
if (!game.user?.isGM) return;
this._saveState();
// Get/create folder hierarchy: Generated NPCs > Type
const rootFolder = await this._ensureFolder('Generated NPCs');
const typeLabel = SLA_NPC_GROUP_ARCHETYPES[this._selectedType]?.label ?? 'NPCs';
const typeFolder = await this._ensureFolder(typeLabel, rootFolder?.id ?? null);
const folderId = typeFolder?.id ?? rootFolder?.id ?? null;
let created = 0;
for (const npc of this._npcs) {
try {
// Biography
const bio = [`Type: ${npc.typeLabel} | Threat: ${npc.threat}`];
if (npc.armorDesc && npc.armorDesc !== 'None') bio.push(`Armour: ${npc.armorDesc}`);
const allWep = [...(npc.naturalWeapons ?? []), ...(npc.weapons ?? [])];
if (allWep.length) {
bio.push('\nWeapons:');
allWep.forEach(w => bio.push(` ${w.name} — DMG ${w.damage}${w.ap ? ` AP ${w.ap}` : ''}${w.range ? ` [${w.range}]` : ''}${w.note ? ` — ${w.note}` : ''}`));
}
if (npc.abilities?.length) {
bio.push('\nAbilities:');
npc.abilities.forEach(a => bio.push(` ${a}`));
}
if (npc.notes) bio.push(`\nNotes:\n ${npc.notes}`);
// Create actor with portrait icon
const actorDoc = await Actor.create({
name: npc.name, type: 'npc',
img: this._getNPCIcon(npc.typeKey),
folder: folderId,
system: {
biography: bio.join('\n'),
threat: npc.threat,
attributes: { strength: npc.str, agility: npc.agi, wits: npc.wit, empathy: npc.emp },
health: { value: npc.hp, max: npc.hpMax },
armor: npc.armor,
damage: npc.damage
}
});
if (!actorDoc) continue;
// Build embedded items
const embeds = [];
// Weapon items
for (const w of allWep) {
const isRanged = w.range && w.range !== 'Engaged';
const rofMatch = w.note?.match(/ROF\s*(\d+)/i);
embeds.push({
name: w.name, type: 'weapon',
img: this._getWeaponIcon(w),
system: {
description: w.note || '', weaponType: w.name,
category: isRanged ? 'ranged' : 'melee',
damage: w.damage, range: w.range || 'Engaged', ap: w.ap || 0,
gearBonus: 0, rof: rofMatch ? parseInt(rofMatch[1]) : 1,
magazine: isRanged ? 12 : 0, ammo: isRanged ? 12 : 0,
fireModes: isRanged ? ['single'] : [], equipped: true,
ammoType: 'standard', autoAmmoUse: 8, initiativeMod: 0, ammoEmpty: false
}
});
}
// Armor item
if (npc.armor > 0 && npc.armorDesc && npc.armorDesc !== 'None' && !npc.armorDesc.startsWith('Built-in')) {
const armorName = npc.armorDesc.replace(/\s*\(ARM \d+\)/, '').trim() || 'Armour';
const ART = SLAGroupNPCTool._SLA + '/armor';
const armorImg = npc.armor >= 5 ? `${ART}/exo.png`
: npc.armor >= 3 ? `${ART}/heavy.png`
: npc.armorDesc.toLowerCase().includes('street') ? `${ART}/street.png`
: `${ART}/light.png`;
embeds.push({
name: armorName, type: 'armor',
img: armorImg,
system: {
description: npc.armorDesc, armorDice: npc.armor, armorAuto: 0,
armorRating: npc.armor, statMod: 0, skillMod: 0, statModTarget: '',
skillModTarget: '', healthMod: 0, resolveMod: 0, initiativeMod: 0, equipped: true
}
});
}
// Specialty items for abilities
for (const ability of (npc.abilities ?? [])) {
const title = ability.match(/^([^—\-]+)/)?.[1]?.trim() ?? ability.slice(0, 30);
embeds.push({
name: title, type: 'specialty',
img: this._getAbilityIcon(ability),
system: {
description: ability, effects: ability, category: 'general',
package: npc.typeLabel, prerequisites: '', isActive: true,
healthMod: 0, resolveMod: 0
}
});
}
if (embeds.length) {
try { await actorDoc.createEmbeddedDocuments('Item', embeds); }
catch(e) { console.warn(`SLA Zero Two | Group NPC: embed failed for "${npc.name}"`, e); }
}
created++;
} catch(err) {
console.error(`SLA Zero Two | Group NPC: failed to create "${npc.name}"`, err);
}
}
ui.notifications.info(`Created ${created} NPC${created !== 1 ? 's' : ''} in folder: Generated NPCs / ${typeLabel}`);
});
}
}
// ── GM PC STATUS WINDOW ───────────────────────────────────────────────────────
/**
* SLAGMStatusWindow — a draggable, auto-refreshing window showing all PC
* conditions at a glance. Open via the scene-controls clipboard button.
* The GM can also click "Post to Chat" to whisper the same report.
*/
class SLAGMStatusWindow extends Application {
constructor(...args) {
super(...args);
this._hooks = [];
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'sla-gm-status-window',
title: '⚡ PC Status Board',
width: 540,
height: 'auto',
resizable: true,
classes: ['slazero2', 'gm-status-window']
});
}
/** Singleton open / focus */
static open() {
if (!SLAGMStatusWindow._inst) SLAGMStatusWindow._inst = new SLAGMStatusWindow();
SLAGMStatusWindow._inst.render(true);
return SLAGMStatusWindow._inst;
}
_pcActors() {
return (game.actors ?? []).filter(a =>
a.type === 'character' && a.system?.details?.isPlayerCharacter
);
}
_activeConditions(actor) {
const statuses = actor.statuses ?? new Set();
return SLA_CONDITIONS.filter(c => statuses.has(c.id));
}
_hpColor(cur, max) {
const pct = max > 0 ? cur / max : 1;
if (pct > 0.5) return '#44cc66';
if (pct > 0.25) return '#ffaa00';
return '#cc2222';
}
async _renderInner() {
const pcs = this._pcActors();
const rows = pcs.map(actor => {
const sys = actor.system;
const hp = sys.derivedStats?.health ?? { value: 0, max: 0 };
const res = sys.derivedStats?.resolve ?? { value: 0, max: 0 };
const stress = sys.derivedStats?.stress ?? 0;
const showOff = sys.details?.showingOff ?? 0;
const conds = this._activeConditions(actor);
const img = actor.img ?? 'icons/svg/mystery-man.svg';
const condTags = conds.length ? conds.map(c => {
const allMod = c.diceModifiers?.all ?? 0;
const penalty = allMod ? ` (${allMod > 0 ? '+' : ''}${allMod} all)` : '';
return `
${c.label}${penalty}
✕
`;
}).join('') : '— clean — ';
const stressColor = stress === 0 ? '#44cc66' : stress <= 2 ? '#ffaa00' : '#cc2222';
const soColor = showOff >= 20 ? '#ff4444' : showOff >= 10 ? '#ff9900' : showOff > 0 ? '#ffcc00' : '#888';
return `
${actor.name}
❤ ${hp.value} /${hp.max}
🔵 ${res.value} /${res.max}
⚡ ${stress}
${showOff > 0 ? `✨ ${showOff}pts ` : ''}
${condTags}
`;
}).join('');
const noPC = pcs.length === 0
? 'No player characters found.
'
: '';
const html = ``;
const el = document.createElement('div');
el.innerHTML = html;
return $(el);
}
async _render(force, options) {
await super._render(force, options);
// Register auto-refresh hooks once
if (!this._hooks.length) {
const refresh = () => { if (this.rendered) this.render(false); };
this._hooks.push(
Hooks.on('updateActor', refresh),
Hooks.on('createActiveEffect', refresh),
Hooks.on('deleteActiveEffect', refresh),
Hooks.on('updateActiveEffect', refresh)
);
}
}
async close(options) {
this._hooks.forEach((id, i) => {
const evts = ['updateActor','createActiveEffect','deleteActiveEffect','updateActiveEffect'];
Hooks.off(evts[i], id);
});
this._hooks = [];
SLAGMStatusWindow._inst = null;
return super.close(options);
}
activateListeners(html) {
super.activateListeners(html);
// Open sheet on portrait or name click
html[0].querySelectorAll('.gsw-portrait, .gsw-actor-name').forEach(el => {
el.addEventListener('click', () => {
game.actors.get(el.dataset.actorId)?.sheet?.render(true);
});
});
// Remove condition
html[0].querySelectorAll('.gsw-remove-cond').forEach(btn => {
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
const actor = game.actors.get(btn.dataset.actorId);
if (!actor) return;
const condId = btn.dataset.condId;
// Remove matching active effects
const toDelete = actor.effects.filter(e =>
e.statuses?.has(condId) || e.flags?.core?.statusId === condId
).map(e => e.id);
if (toDelete.length) await actor.deleteEmbeddedDocuments('ActiveEffect', toDelete);
else await actor.toggleStatusEffect(condId, { active: false });
});
});
// Post to chat
html[0].querySelector('.gsw-post-chat')?.addEventListener('click', () => _broadcastPCConditions());
}
}
SLAGMStatusWindow._inst = null;
// ── 💳 CREDIT DISTRIBUTION ────────────────────────────────────────────────────────────
class SLACreditDistributionTool extends Application {
constructor(...args) {
super(...args);
this._hooks = [];
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'sla-credit-tool',
title: '💳 Credit Distribution',
width: 560,
height: 'auto',
resizable: true,
classes: ['slazero2', 'credit-tool-window']
});
}
static open() {
if (!SLACreditDistributionTool._inst) SLACreditDistributionTool._inst = new SLACreditDistributionTool();
SLACreditDistributionTool._inst.render(true);
return SLACreditDistributionTool._inst;
}
_pcActors() {
return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter);
}
_calcIncome(actor) {
const fin = actor.system?.finances?.income ?? {};
return (fin.salary ?? 0) + (fin.bpnReward ?? 0) + (fin.other ?? 0);
}
_calcExpenses(actor) {
const exp = actor.system?.finances?.expenses ?? {};
return (exp.accommodation ?? 0) + (exp.drugs ?? 0) + (exp.subscriptions ?? 0) + (exp.other ?? 0) + (exp.bulletTax ?? 0);
}
async _renderInner() {
const pcs = this._pcActors();
// Build rows for each PC
const rows = pcs.map(actor => {
const credits = actor.system?.details?.credits ?? 0;
const scl = actor.system?.details?.scl ?? 0;
const income = this._calcIncome(actor);
const expenses = this._calcExpenses(actor);
const debt = actor.system?.finances?.debt ?? 0;
const net = income - expenses;
const netClass = net >= 0 ? 'ctw-positive' : 'ctw-negative';
const debtClass = debt > 0 ? 'ctw-debt' : '';
const imgSrc = actor.img ?? 'icons/svg/mystery-man.svg';
return `
${actor.name}
SCL ${scl}
${income}
${expenses}
${debt}
${net}
Save
`;
}).join('');
const emptyMsg = pcs.length === 0
? `No player characters found. Mark actors as Player Characters on their sheets. `
: '';
const html = `
`;
return $(html);
}
async _render(force, options) {
await super._render(force, options);
if (!this._hooks.length) {
const refresh = () => { if (this.rendered) this.render(false); };
this._hooks.push(
Hooks.on('updateActor', refresh)
);
}
}
async close(options) {
this._hooks.forEach((id) => {
Hooks.off('updateActor', id);
});
this._hooks = [];
SLACreditDistributionTool._inst = null;
return super.close(options);
}
activateListeners(html) {
super.activateListeners(html);
// --- Save individual credits ---
html.find('.ctw-btn-save').on('click', async (ev) => {
const actorId = ev.currentTarget.dataset.actorId;
const actor = game.actors.get(actorId);
if (!actor) return ui.notifications.error(`Actor not found: ${actorId}`);
const input = html.find(`#credits-input-${actorId}`);
const newValue = parseInt(input.val(), 10);
if (isNaN(newValue) || newValue < 0) {
return ui.notifications.error(`Invalid credit value for ${actor.name}.`);
}
try {
await actor.update({ 'system.details.credits': newValue });
ui.notifications.info(`${actor.name}: credits set to ¢${newValue}.`);
} catch (err) {
console.error('SLACreditDistributionTool | save error', err);
ui.notifications.error(`Failed to update credits for ${actor.name}.`);
}
});
// --- Pay All PCs ---
html.find('#ctw-pay-all-btn').on('click', async () => {
const pcs = this._pcActors();
if (pcs.length === 0) return ui.notifications.error('No player characters found.');
const amount = parseInt(html.find('#ctw-bulk-amount').val(), 10);
if (isNaN(amount) || amount <= 0) {
return ui.notifications.error('Enter a valid amount greater than 0.');
}
const payType = html.find('input[name="ctw-pay-type"]:checked').val();
let successCount = 0;
for (const actor of pcs) {
try {
if (payType === 'custom') {
// Custom: add directly to credits balance
const currentCredits = actor.system?.details?.credits ?? 0;
await actor.update({ 'system.details.credits': currentCredits + amount });
} else if (payType === 'salary') {
await actor.update({ 'system.finances.income.salary': amount });
} else if (payType === 'bpnReward') {
await actor.update({ 'system.finances.income.bpnReward': amount });
}
successCount++;
} catch (err) {
console.error(`SLACreditDistributionTool | pay error for ${actor.name}`, err);
ui.notifications.error(`Failed to pay ${actor.name}.`);
}
}
if (successCount > 0) {
const label = payType === 'custom' ? `¢${amount} credits` : `${payType} set to ¢${amount}`;
ui.notifications.info(`Paid ${successCount} PC(s): ${label}.`);
}
});
// --- Deduct All Expenses ---
html.find('#ctw-deduct-expenses-btn').on('click', async () => {
const pcs = this._pcActors();
if (pcs.length === 0) return ui.notifications.error('No player characters found.');
let successCount = 0;
for (const actor of pcs) {
try {
const currentCredits = actor.system?.details?.credits ?? 0;
const totalExpenses = this._calcExpenses(actor);
const newCredits = Math.max(0, currentCredits - totalExpenses);
await actor.update({ 'system.details.credits': newCredits });
successCount++;
} catch (err) {
console.error(`SLACreditDistributionTool | deduct error for ${actor.name}`, err);
ui.notifications.error(`Failed to deduct expenses for ${actor.name}.`);
}
}
if (successCount > 0) {
ui.notifications.info(`Expenses deducted from ${successCount} PC(s).`);
}
});
}
}
SLACreditDistributionTool._inst = null;
// ── 📒 SHIFT LEDGER ────────────────────────────────────────────────────────────
class SLAShiftLedger extends Application {
constructor(...args) {
super(...args);
this._hooks = [];
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'sla-shift-ledger',
title: '📒 Shift Ledger',
width: 700,
height: 'auto',
resizable: true,
classes: ['slazero2', 'shift-ledger-window']
});
}
static open() {
if (!SLAShiftLedger._inst) SLAShiftLedger._inst = new SLAShiftLedger();
SLAShiftLedger._inst.render(true);
return SLAShiftLedger._inst;
}
_pcActors() {
return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter);
}
_calcNet(actor) {
const fin = actor.system?.finances ?? {};
const inc = fin.income ?? {};
const exp = fin.expenses ?? {};
const totalIncome = (inc.salary || 0) + (inc.bpnReward || 0) + (inc.other || 0);
const totalExpenses = (exp.accommodation || 0) + (exp.drugs || 0) + (exp.subscriptions || 0) + (exp.other || 0) + (exp.bulletTax || 0);
return { totalIncome, totalExpenses, net: totalIncome - totalExpenses };
}
async _processShift(actor) {
const { totalIncome, totalExpenses } = this._calcNet(actor);
const currentCredits = actor.system?.details?.credits ?? 0;
const currentDebt = actor.system?.finances?.debt ?? 0;
let newCredits = currentCredits + totalIncome - totalExpenses;
let newDebt = currentDebt;
if (newCredits < 0) {
newDebt = currentDebt + Math.abs(newCredits);
newCredits = 0;
}
try {
await actor.update({
'system.details.credits': newCredits,
'system.finances.debt': newDebt
});
ui.notifications.info(`Shift processed for ${actor.name}: ${newCredits}¢ credits, ${newDebt}¢ debt.`);
} catch (err) {
console.error('SLAShiftLedger | Failed to process shift for', actor.name, err);
ui.notifications.error(`Failed to process shift for ${actor.name}. See console for details.`);
}
}
async _processAllShifts() {
const pcs = this._pcActors();
if (!pcs.length) {
ui.notifications.error('No player characters found to process.');
return;
}
for (const actor of pcs) {
await this._processShift(actor);
}
ui.notifications.info(`Shift processed for all ${pcs.length} player character(s).`);
}
async _exportToChat() {
const pcs = this._pcActors();
if (!pcs.length) {
ui.notifications.error('No player characters found to export.');
return;
}
let lines = ['📒 Shift Ledger Summary '];
lines.push('Character SCL Credits Income Expenses Net Debt ');
let totalCredits = 0, totalIncome = 0, totalExpenses = 0, totalDebt = 0;
for (const actor of pcs) {
const { totalIncome: inc, totalExpenses: exp, net } = this._calcNet(actor);
const credits = actor.system?.details?.credits ?? 0;
const scl = actor.system?.details?.scl ?? '–';
const debt = actor.system?.finances?.debt ?? 0;
totalCredits += credits;
totalIncome += inc;
totalExpenses += exp;
totalDebt += debt;
const netColor = net >= 0 ? '#44cc66' : '#cc2222';
const debtColor = debt > 0 ? '#cc2222' : 'inherit';
lines.push(
`` +
`${actor.name} ` +
`${scl} ` +
`${credits}¢ ` +
`+${inc}¢ ` +
`-${exp}¢ ` +
`${net >= 0 ? '+' : ''}${net}¢ ` +
`${debt}¢ ` +
` `
);
}
const groupNet = totalIncome - totalExpenses;
const groupNetColor = groupNet >= 0 ? '#44cc66' : '#cc2222';
lines.push(
`` +
`GROUP TOTALS ` +
`${totalCredits}¢ ` +
`+${totalIncome}¢ ` +
`-${totalExpenses}¢ ` +
`${groupNet >= 0 ? '+' : ''}${groupNet}¢ ` +
`${totalDebt}¢ ` +
` `
);
lines.push('
');
try {
await ChatMessage.create({
content: lines.join(''),
whisper: [game.user.id],
speaker: { alias: 'Shift Ledger' }
});
ui.notifications.info('Shift ledger exported to chat (GM only).');
} catch (err) {
console.error('SLAShiftLedger | Failed to export to chat:', err);
ui.notifications.error('Failed to export ledger to chat. See console for details.');
}
}
async _renderInner() {
const pcs = this._pcActors();
// ── Aggregate totals ──────────────────────────────────────────────────────
let aggCredits = 0, aggIncome = 0, aggExpenses = 0, aggDebt = 0;
for (const actor of pcs) {
const { totalIncome, totalExpenses } = this._calcNet(actor);
aggCredits += actor.system?.details?.credits ?? 0;
aggIncome += totalIncome;
aggExpenses += totalExpenses;
aggDebt += actor.system?.finances?.debt ?? 0;
}
const aggNet = aggIncome - aggExpenses;
// ── Summary bar ──────────────────────────────────────────────────────────
const summaryBar = `
Group Credits
${aggCredits}¢
Total Income
+${aggIncome}¢
Total Expenses
-${aggExpenses}¢
Net This Shift
${aggNet >= 0 ? '+' : ''}${aggNet}¢
Total Debt
${aggDebt}¢
`;
// ── Per-PC cards ─────────────────────────────────────────────────────────
let pcCards = '';
if (pcs.length === 0) {
pcCards = 'No player characters found. Mark actors as Player Characters in their sheet details.
';
} else {
for (const actor of pcs) {
const det = actor.system?.details ?? {};
const fin = actor.system?.finances ?? {};
const inc = fin.income ?? {};
const exp = fin.expenses ?? {};
const { totalIncome, totalExpenses, net } = this._calcNet(actor);
const credits = det.credits ?? 0;
const scl = det.scl ?? '–';
const debt = fin.debt ?? 0;
pcCards += `
Income
Salary
${inc.salary ?? 0}¢
BPN Reward
${inc.bpnReward ?? 0}¢
Other
${inc.other ?? 0}¢
Total Income
+${totalIncome}¢
Expenses
Accommodation
${exp.accommodation ?? 0}¢
Drugs
${exp.drugs ?? 0}¢
Subscriptions
${exp.subscriptions ?? 0}¢
Other
${exp.other ?? 0}¢
Bullet Tax
${exp.bulletTax ?? 0}¢
Total Expenses
-${totalExpenses}¢
Summary
Net Balance
${net >= 0 ? '+' : ''}${net}¢
Debt
${debt}¢
Process Shift
`;
}
}
// ── Global action bar ────────────────────────────────────────────────────
const actionBar = `
⚙ Process All Shifts
💬 Export to Chat
`;
const html = `
${summaryBar}
${pcCards}
${actionBar}
`;
return $(html);
}
async _render(force, options) {
await super._render(force, options);
if (!this._hooks.length) {
const refresh = () => { if (this.rendered) this.render(false); };
this._hooks.push(Hooks.on('updateActor', refresh));
}
}
async close(options) {
this._hooks.forEach(id => Hooks.off('updateActor', id));
this._hooks = [];
SLAShiftLedger._inst = null;
return super.close(options);
}
activateListeners(html) {
super.activateListeners(html);
// Per-PC process shift buttons
html.find('.slw-btn--process').on('click', async (ev) => {
const actorId = ev.currentTarget.dataset.actorId;
const actor = game.actors.get(actorId);
if (!actor) {
ui.notifications.error('Actor not found.');
return;
}
await this._processShift(actor);
});
// Process all shifts button
html.find('#slw-process-all').on('click', async () => {
await this._processAllShifts();
});
// Export to chat button
html.find('#slw-export-chat').on('click', async () => {
await this._exportToChat();
});
}
}
SLAShiftLedger._inst = null;
// ── ☠ NPC THREAT BOARD ────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────────────────────────
// SLANPCThreatBoard — GM tactical overview of all NPC actors
// ──────────────────────────────────────────────────────────────────────────────
function threatLabel(val) {
if (!val || val <= 2) return { label: 'Low', cls: 'threat-low' };
if (val <= 3) return { label: 'Med', cls: 'threat-med' };
if (val <= 5) return { label: 'High', cls: 'threat-high' };
return { label: 'Elite', cls: 'threat-elite' };
}
function hpColor(cur, max) {
const pct = max > 0 ? cur / max : 1;
if (pct > 0.50) return '#44cc66';
if (pct > 0.25) return '#ffaa00';
return '#cc2222';
}
class SLANPCThreatBoard extends Application {
constructor(...args) {
super(...args);
this._hooks = [];
this._filterThreat = 'all'; // 'all' | 'low' | 'med' | 'high' | 'elite'
this._filterScene = false; // show only actors present in the active scene
this._showDead = false; // include dead actors
this._sortBy = 'threat'; // 'name' | 'threat' | 'health'
}
// ── AppV1 config ─────────────────────────────────────────────────────────────
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'sla-npc-threat-board',
title: '☠ NPC Threat Board',
width: 640,
height: 500,
resizable: true,
classes: ['slazero2', 'npc-threat-board']
});
}
static open() {
if (!SLANPCThreatBoard._inst) SLANPCThreatBoard._inst = new SLANPCThreatBoard();
SLANPCThreatBoard._inst.render(true);
return SLANPCThreatBoard._inst;
}
// ── Data helpers ──────────────────────────────────────────────────────────────
_npcActors() {
return (game.actors ?? []).filter(
a => a.type === 'npc' || (a.type === 'character' && !a.system?.details?.isPlayerCharacter)
);
}
/** Returns a Set of actorIds present in the currently active scene. */
_sceneActorIds() {
const ids = new Set();
const tokens = game.scenes?.active?.tokens ?? [];
for (const t of tokens) {
if (t.actorId) ids.add(t.actorId);
}
return ids;
}
// ── HTML builder ──────────────────────────────────────────────────────────────
async _renderInner() {
const sceneIds = this._sceneActorIds();
let actors = this._npcActors();
// ── Filter: scene ────────────────────────────────────────────────────────
if (this._filterScene) {
actors = actors.filter(a => sceneIds.has(a.id));
}
// ── Filter: dead ─────────────────────────────────────────────────────────
if (!this._showDead) {
actors = actors.filter(a => {
const dead = a.statuses?.has('dead') ?? false;
const hpVal = a.system?.health?.value ?? 0;
return !dead && hpVal > 0;
});
}
// ── Filter: threat tier ──────────────────────────────────────────────────
if (this._filterThreat !== 'all') {
actors = actors.filter(a => {
const t = a.system?.threat || 0;
const { cls } = threatLabel(t);
return cls === `threat-${this._filterThreat}`;
});
}
// ── Sort ─────────────────────────────────────────────────────────────────
if (this._sortBy === 'name') {
actors.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
} else if (this._sortBy === 'threat') {
actors.sort((a, b) => (b.system?.threat || 0) - (a.system?.threat || 0));
} else if (this._sortBy === 'health') {
actors.sort((a, b) => {
const pctA = (a.system?.health?.max || 0) > 0
? (a.system.health.value || 0) / a.system.health.max : 0;
const pctB = (b.system?.health?.max || 0) > 0
? (b.system.health.value || 0) / b.system.health.max : 0;
return pctA - pctB; // lowest HP first — most urgent
});
}
// ── Build card HTML ──────────────────────────────────────────────────────
const cards = actors.map(actor => {
const hpCur = actor.system?.health?.value ?? 0;
const hpMax = actor.system?.health?.max ?? 0;
const threat = actor.system?.threat ?? 0;
const str = actor.system?.attributes?.strength ?? 0;
const agi = actor.system?.attributes?.agility ?? 0;
const wit = actor.system?.attributes?.wits ?? 0;
const emp = actor.system?.attributes?.empathy ?? 0;
const dmg = actor.system?.damage ?? '—';
const armor = actor.system?.armor ?? 0;
const imgSrc = actor.img ?? 'icons/svg/mystery-man.svg';
const { label: tLabel, cls: tCls } = threatLabel(threat);
const hpPct = hpMax > 0 ? Math.max(0, Math.min(1, hpCur / hpMax)) : 0;
const hpBarPct = Math.round(hpPct * 100);
const barColor = hpColor(hpCur, hpMax);
const isDead = (actor.statuses?.has('dead') ?? false) || hpCur <= 0;
// Status dots (all active statuses other than 'dead')
const statuses = actor.statuses ? [...actor.statuses].filter(s => s !== 'dead') : [];
const statusDots = statuses.slice(0, 6).map(s =>
` `
).join('');
// Scene indicator
const inScene = sceneIds.has(actor.id)
? `◉ `
: '';
return `
${isDead ? '
💀
' : ''}
STR ${str}
AGI ${agi}
WIT ${wit}
EMP ${emp}
⚔ ${dmg}
🛡 ${armor}
${statusDots}
`;
}).join('');
const emptyMsg = actors.length === 0
? `No NPCs match the current filters.
`
: '';
// ── Filter / sort bar HTML ────────────────────────────────────────────────
const html = `
All
In Scene
Dead
All Threats
Low (1-2)
Med (3)
High (4-5)
Elite (6+)
Sort:
Name
Threat
Health%
${cards}
${emptyMsg}
`;
return $(html);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
async _render(force, options) {
await super._render(force, options);
if (!this._hooks.length) {
const refresh = () => { if (this.rendered) this.render(false); };
this._hooks.push(
Hooks.on('updateActor', refresh),
Hooks.on('createActor', refresh),
Hooks.on('deleteActor', refresh)
);
}
}
async close(options) {
const evts = ['updateActor', 'createActor', 'deleteActor'];
this._hooks.forEach((id, i) => Hooks.off(evts[i], id));
this._hooks = [];
SLANPCThreatBoard._inst = null;
return super.close(options);
}
// ── Listeners ─────────────────────────────────────────────────────────────────
activateListeners(html) {
super.activateListeners(html);
// ── Scene filter buttons ─────────────────────────────────────────────────
html.find('[data-filter-scene]').on('click', (ev) => {
this._filterScene = ev.currentTarget.dataset.filterScene === 'true';
this.render(false);
});
// ── Dead toggle ──────────────────────────────────────────────────────────
html.find('.ntb-toggle-dead').on('click', () => {
this._showDead = !this._showDead;
this.render(false);
});
// ── Threat filter buttons ────────────────────────────────────────────────
html.find('.ntb-threat-filter').on('click', (ev) => {
this._filterThreat = ev.currentTarget.dataset.threat || 'all';
this.render(false);
});
// ── Sort buttons ─────────────────────────────────────────────────────────
html.find('.ntb-sort-btn').on('click', (ev) => {
this._sortBy = ev.currentTarget.dataset.sort || 'threat';
this.render(false);
});
// ── HP decrement ─────────────────────────────────────────────────────────
html.find('.ntb-hp-dec').on('click', async (ev) => {
ev.stopPropagation();
const actorId = ev.currentTarget.dataset.actorId;
const actor = game.actors?.get(actorId);
if (!actor) return;
const cur = actor.system?.health?.value ?? 0;
const newVal = cur - 1;
try {
await actor.update({ 'system.health.value': newVal });
} catch (err) {
console.error('SLANPCThreatBoard | HP decrement error', err);
ui.notifications?.error(`Failed to update HP for ${actor.name}.`);
}
});
// ── HP increment ─────────────────────────────────────────────────────────
html.find('.ntb-hp-inc').on('click', async (ev) => {
ev.stopPropagation();
const actorId = ev.currentTarget.dataset.actorId;
const actor = game.actors?.get(actorId);
if (!actor) return;
const cur = actor.system?.health?.value ?? 0;
const hpMax = actor.system?.health?.max ?? 0;
const newVal = hpMax > 0 ? Math.min(hpMax, cur + 1) : cur + 1;
try {
await actor.update({ 'system.health.value': newVal });
} catch (err) {
console.error('SLANPCThreatBoard | HP increment error', err);
ui.notifications?.error(`Failed to update HP for ${actor.name}.`);
}
});
// ── Open actor sheet on card click ───────────────────────────────────────
html.find('.ntb-card').on('click', (ev) => {
// Don't open sheet if a HP button was clicked
if (ev.target.closest('.ntb-hp-btn')) return;
const actorId = ev.currentTarget.dataset.actorId;
const actor = game.actors?.get(actorId);
if (actor) actor.sheet.render(true);
});
}
}
SLANPCThreatBoard._inst = null;
// ── 📋 BPN TRACKER ────────────────────────────────────────────────────────────
class SLABPNTracker extends Application {
constructor(...args) {
super(...args);
this._hooks = [];
this._editMode = false;
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'sla-bpn-tracker',
title: '📋 BPN Tracker',
width: 520,
height: 'auto',
resizable: true,
classes: ['slazero2', 'bpn-tracker-window']
});
}
static open() {
if (!SLABPNTracker._inst) SLABPNTracker._inst = new SLABPNTracker();
SLABPNTracker._inst.render(true);
return SLABPNTracker._inst;
}
_pcActors() {
return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter);
}
_groupByBPN(actors) {
const groups = new Map();
for (const actor of actors) {
const code = actor.system?.bpn?.code?.trim() || '';
const key = code === '' ? '__unassigned__' : code;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(actor);
}
return groups;
}
_bpnTypeColors() {
return {
'Blue': { bg: '#1a3a6e', border: '#3399ff', text: '#88ccff' },
'White': { bg: '#3a3a3a', border: '#cccccc', text: '#ffffff' },
'Green': { bg: '#1a3a1a', border: '#44cc66', text: '#88ff88' },
'Red': { bg: '#3a1a1a', border: '#cc2222', text: '#ff6666' },
'Black': { bg: '#0d0d0d', border: '#555555', text: '#aaaaaa' },
'Silver': { bg: '#2a2a3a', border: '#aaaacc', text: '#ccccff' }
};
}
_buildTypeBadge(bpnType) {
const colors = this._bpnTypeColors();
const c = colors[bpnType] ?? { bg: '#222', border: '#666', text: '#ccc' };
return `${bpnType || 'Unknown'} `;
}
_buildStatusBadge(status) {
const cls = {
'Active': 'bpn-status-active',
'Pending': 'bpn-status-pending',
'Complete': 'bpn-status-complete',
'Failed': 'bpn-status-failed'
}[status] ?? 'bpn-status-pending';
return `${status || 'Pending'} `;
}
_buildMissionCard(code, actors) {
const rep = actors[0];
const bpn = rep.system?.bpn ?? {};
const bpnType = bpn.type || 'Unknown';
const status = bpn.status || 'Pending';
const reward = bpn.reward ?? 0;
const description = bpn.description || '';
const objectives = bpn.objectives || '';
const colors = this._bpnTypeColors();
const c = colors[bpnType] ?? { bg: '#1a1a1a', border: '#555', text: '#aaa' };
const actorIds = actors.map(a => a.id).join(',');
const pcBadges = actors.map(a =>
`${a.name} `
).join('');
const statusOptions = ['Pending', 'Active', 'Complete', 'Failed'].map(s =>
`${s} `
).join('');
const typeOptions = ['Blue', 'White', 'Green', 'Red', 'Black', 'Silver', 'Unknown'].map(t =>
`${t} `
).join('');
return `
Status
${statusOptions}
Type
${typeOptions}
💳 Reward: ${reward}¢
Pay Out
Description
Objectives
`;
}
_buildAssignPanel(actors) {
const pcCheckboxes = actors.map(a =>
`
${a.name}
`
).join('');
const typeOptions = ['Blue', 'White', 'Green', 'Red', 'Black', 'Silver'].map(t =>
`${t} `
).join('');
const statusOptions = ['Pending', 'Active', 'Complete', 'Failed'].map(s =>
`${s} `
).join('');
return `
Select PCs
${pcCheckboxes}
BPN Code
Type
${typeOptions}
Status
${statusOptions}
Reward (¢)
Description
Objectives
Assign BPN
`;
}
async _renderInner() {
const actors = this._pcActors();
const groups = this._groupByBPN(actors);
let cardsHtml = '';
// Render assigned BPNs first (all non-unassigned keys)
for (const [key, group] of groups.entries()) {
if (key === '__unassigned__') continue;
cardsHtml += this._buildMissionCard(key, group);
}
// Render unassigned section
const unassigned = groups.get('__unassigned__') ?? [];
let unassignedHtml = '';
if (unassigned.length > 0) {
const badges = unassigned.map(a => `${a.name} `).join('');
unassignedHtml = `
`;
}
const assignPanel = this._buildAssignPanel(actors);
const html = `
${cardsHtml || '
No active BPNs. Assign one below.
'}
${unassignedHtml}
${assignPanel}
`;
return $(html);
}
async _render(force, options) {
await super._render(force, options);
if (!this._hooks.length) {
const refresh = () => { if (this.rendered) this.render(false); };
this._hooks.push(Hooks.on('updateActor', refresh));
this._hooks.push(Hooks.on('createActor', refresh));
this._hooks.push(Hooks.on('deleteActor', refresh));
}
}
async close(options) {
this._hooks.forEach(id => Hooks.off('updateActor', id));
this._hooks = [];
SLABPNTracker._inst = null;
return super.close(options);
}
activateListeners(html) {
super.activateListeners(html);
// Status change — update all PCs on this BPN
html.on('change', '.bpn-status-select', async (ev) => {
const card = ev.currentTarget.closest('.bpn-mission-card');
const actorIds = card.dataset.actorIds.split(',').filter(Boolean);
const newStatus = ev.currentTarget.value;
for (const id of actorIds) {
const actor = game.actors.get(id);
if (!actor) continue;
try {
await actor.update({ 'system.bpn.status': newStatus });
} catch (err) {
console.error(`BPN Tracker | Failed to update status for ${actor.name}:`, err);
}
}
// Update the badge in-place without full re-render
const badge = card.querySelector('.bpn-status-badge');
if (badge) {
badge.className = 'bpn-status-badge ' + ({
'Active': 'bpn-status-active',
'Pending': 'bpn-status-pending',
'Complete': 'bpn-status-complete',
'Failed': 'bpn-status-failed'
}[newStatus] ?? 'bpn-status-pending');
badge.textContent = newStatus;
}
ui.notifications.info(`BPN status updated to "${newStatus}".`);
});
// Type change — update all PCs on this BPN
html.on('change', '.bpn-type-select', async (ev) => {
const card = ev.currentTarget.closest('.bpn-mission-card');
const actorIds = card.dataset.actorIds.split(',').filter(Boolean);
const newType = ev.currentTarget.value;
const colors = this._bpnTypeColors();
const c = colors[newType] ?? { bg: '#222', border: '#666', text: '#ccc' };
for (const id of actorIds) {
const actor = game.actors.get(id);
if (!actor) continue;
try {
await actor.update({ 'system.bpn.type': newType });
} catch (err) {
console.error(`BPN Tracker | Failed to update type for ${actor.name}:`, err);
}
}
// Update badge and border color in-place
const badge = card.querySelector('.bpn-type-badge');
if (badge) {
badge.style.background = c.bg;
badge.style.borderColor = c.border;
badge.style.color = c.text;
badge.textContent = newType;
}
card.style.borderLeftColor = c.border;
ui.notifications.info(`BPN type updated to "${newType}".`);
});
// Pay Out — add reward to each PC's credits
html.on('click', '[data-action="payout"]', async (ev) => {
const card = ev.currentTarget.closest('.bpn-mission-card');
const actorIds = card.dataset.actorIds.split(',').filter(Boolean);
let paid = 0;
for (const id of actorIds) {
const actor = game.actors.get(id);
if (!actor) continue;
const reward = actor.system?.bpn?.reward ?? 0;
const cur = actor.system?.details?.credits ?? 0;
try {
await actor.update({ 'system.details.credits': cur + reward });
paid++;
} catch (err) {
console.error(`BPN Tracker | Failed to pay out reward to ${actor.name}:`, err);
}
}
ui.notifications.info(`Reward paid out to ${paid} PC(s).`);
});
// Save description + objectives back to all PCs on this BPN
html.on('click', '[data-action="save-bpn"]', async (ev) => {
const card = ev.currentTarget.closest('.bpn-mission-card');
const actorIds = card.dataset.actorIds.split(',').filter(Boolean);
const description = card.querySelector('.bpn-description').value;
const objectives = card.querySelector('.bpn-objectives').value;
for (const id of actorIds) {
const actor = game.actors.get(id);
if (!actor) continue;
try {
await actor.update({
'system.bpn.description': description,
'system.bpn.objectives': objectives
});
} catch (err) {
console.error(`BPN Tracker | Failed to save BPN fields for ${actor.name}:`, err);
}
}
ui.notifications.info('BPN description and objectives saved.');
});
// Clear BPN — remove code and status from all PCs on this BPN
html.on('click', '[data-action="clear-bpn"]', async (ev) => {
const card = ev.currentTarget.closest('.bpn-mission-card');
const actorIds = card.dataset.actorIds.split(',').filter(Boolean);
for (const id of actorIds) {
const actor = game.actors.get(id);
if (!actor) continue;
try {
await actor.update({ 'system.bpn.code': '', 'system.bpn.status': '' });
} catch (err) {
console.error(`BPN Tracker | Failed to clear BPN for ${actor.name}:`, err);
}
}
ui.notifications.info('BPN cleared from assigned PCs.');
if (this.rendered) this.render(false);
});
// Assign BPN — write BPN data to selected PCs
html.on('click', '[data-action="assign-bpn"]', async (ev) => {
const panel = ev.currentTarget.closest('.bpn-assign-panel');
const selectedIds = [...panel.querySelectorAll('.bpn-assign-pc-check:checked')].map(el => el.value);
if (selectedIds.length === 0) {
ui.notifications.warn('No PCs selected for BPN assignment.');
return;
}
const code = panel.querySelector('.bpn-assign-code').value.trim();
if (!code) {
ui.notifications.warn('Please enter a BPN code.');
return;
}
const bpnType = panel.querySelector('.bpn-assign-type').value;
const status = panel.querySelector('.bpn-assign-status').value;
const reward = Number(panel.querySelector('.bpn-assign-reward').value) || 0;
const description = panel.querySelector('.bpn-assign-desc').value;
const objectives = panel.querySelector('.bpn-assign-obj').value;
for (const id of selectedIds) {
const actor = game.actors.get(id);
if (!actor) continue;
try {
await actor.update({
'system.bpn.code': code,
'system.bpn.type': bpnType,
'system.bpn.status': status,
'system.bpn.reward': reward,
'system.bpn.description': description,
'system.bpn.objectives': objectives
});
} catch (err) {
console.error(`BPN Tracker | Failed to assign BPN to ${actor.name}:`, err);
}
}
ui.notifications.info(`BPN "${code}" assigned to ${selectedIds.length} PC(s).`);
// Clear the form
panel.querySelector('.bpn-assign-code').value = '';
panel.querySelector('.bpn-assign-reward').value = '0';
panel.querySelector('.bpn-assign-desc').value = '';
panel.querySelector('.bpn-assign-obj').value = '';
panel.querySelectorAll('.bpn-assign-pc-check').forEach(el => el.checked = false);
if (this.rendered) this.render(false);
});
}
}
SLABPNTracker._inst = null;
// ── ⚡ CONDITION APPLICATOR ────────────────────────────────────────────────────────────
class SLAConditionApplicator extends Application {
constructor(...args) {
super(...args);
this._hooks = [];
this._selectedActorId = null;
this._allPCsMode = false;
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'sla-condition-applicator',
title: '⚡ Condition Applicator',
width: 600,
height: 520,
resizable: true,
classes: ['slazero2', 'condition-applicator-window']
});
}
static open() {
if (!SLAConditionApplicator._inst) SLAConditionApplicator._inst = new SLAConditionApplicator();
SLAConditionApplicator._inst.render(true);
return SLAConditionApplicator._inst;
}
_pcActors() {
return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter);
}
async _renderInner() {
const pcs = this._pcActors();
// Default selected actor to first PC if none set or stale
if (!this._selectedActorId || !pcs.find(a => a.id === this._selectedActorId)) {
this._selectedActorId = pcs.length ? pcs[0].id : null;
}
const selActor = pcs.find(a => a.id === this._selectedActorId) ?? null;
// --- Build PC roster rows ---
let pcRowsHtml = '';
for (const actor of pcs) {
const activeConditions = SLA_CONDITIONS.filter(c => actor.statuses?.has(c.id));
const dotBadges = activeConditions.map(c =>
` `
).join('');
const isSelected = actor.id === this._selectedActorId && !this._allPCsMode;
pcRowsHtml += `
${actor.name}
${dotBadges}
`;
}
if (!pcRowsHtml) {
pcRowsHtml = 'No PC actors found.
';
}
// --- Build condition grid tiles ---
let condTilesHtml = '';
for (const cond of SLA_CONDITIONS) {
let isActive = false;
if (this._allPCsMode) {
// Active if ALL PCs have it (useful visual: show partial state too)
const pcCount = pcs.length;
const hasCount = pcs.filter(a => a.statuses?.has(cond.id)).length;
if (hasCount === pcCount && pcCount > 0) isActive = true;
} else if (selActor) {
isActive = selActor.statuses?.has(cond.id) ?? false;
}
// Build dice modifier badge text
let modBadge = '';
if (cond.diceModifiers && Object.keys(cond.diceModifiers).length) {
const parts = Object.entries(cond.diceModifiers).map(([key, val]) => {
const sign = val >= 0 ? '+' : '−';
const abs = Math.abs(val);
return `${sign}${abs} ${key}`;
});
modBadge = `${parts.join(' ')} `;
}
const descAttr = cond.description ? ` title="${cond.description.replace(/"/g, '"')}"` : '';
condTilesHtml += `
${modBadge}
${cond.label}
`;
}
if (!condTilesHtml) {
condTilesHtml = 'No conditions defined.
';
}
// --- Active conditions status bar ---
let statusBarHtml = '';
if (this._allPCsMode) {
statusBarHtml = 'All PCs mode active ';
} else if (selActor) {
const activeConds = SLA_CONDITIONS.filter(c => selActor.statuses?.has(c.id));
if (activeConds.length) {
statusBarHtml = activeConds.map(c =>
` ${c.label} `
).join('');
} else {
statusBarHtml = 'No active conditions ';
}
} else {
statusBarHtml = 'No PC selected ';
}
const selectedLabel = this._allPCsMode
? 'All PCs'
: (selActor ? selActor.name : '—');
const html = `
`;
return $(html);
}
async _render(force, options) {
await super._render(force, options);
if (!this._hooks.length) {
const refresh = () => { if (this.rendered) this.render(false); };
this._hooks.push(
Hooks.on('updateActor', refresh),
Hooks.on('createActiveEffect', refresh),
Hooks.on('deleteActiveEffect', refresh),
Hooks.on('updateActiveEffect', refresh)
);
}
}
async close(options) {
const evts = ['updateActor', 'createActiveEffect', 'deleteActiveEffect', 'updateActiveEffect'];
this._hooks.forEach((id, i) => Hooks.off(evts[i], id));
this._hooks = [];
SLAConditionApplicator._inst = null;
return super.close(options);
}
activateListeners(html) {
super.activateListeners(html);
// PC row click — select actor
html.find('.cap-pc-row').on('click', (ev) => {
const actorId = ev.currentTarget.dataset.actorId;
if (actorId) {
this._selectedActorId = actorId;
this._allPCsMode = false;
this.render(false);
}
});
// Condition tile click — toggle condition
html.find('.cap-cond-tile').on('click', async (ev) => {
const condId = ev.currentTarget.dataset.condId;
const isActive = ev.currentTarget.dataset.active === 'true';
const pcs = this._pcActors();
if (this._allPCsMode) {
for (const actor of pcs) {
try {
await actor.toggleStatusEffect(condId, { active: !isActive });
} catch (err) {
console.error(`SLAConditionApplicator | Failed to toggle ${condId} on ${actor.name}:`, err);
ui.notifications.warn(`Could not toggle condition on ${actor.name}.`);
}
}
const verb = isActive ? 'Removed' : 'Applied';
const condLabel = SLA_CONDITIONS.find(c => c.id === condId)?.label ?? condId;
ui.notifications.info(`${verb} "${condLabel}" on all PCs.`);
} else {
const selActor = pcs.find(a => a.id === this._selectedActorId);
if (!selActor) {
ui.notifications.warn('No PC selected.');
return;
}
try {
await selActor.toggleStatusEffect(condId, { active: !isActive });
const verb = isActive ? 'Removed' : 'Applied';
const condLabel = SLA_CONDITIONS.find(c => c.id === condId)?.label ?? condId;
ui.notifications.info(`${verb} "${condLabel}" on ${selActor.name}.`);
} catch (err) {
console.error(`SLAConditionApplicator | Failed to toggle ${condId} on ${selActor.name}:`, err);
ui.notifications.warn(`Could not toggle condition on ${selActor.name}.`);
}
}
});
// Toggle All PCs mode
html.find('[data-action="toggle-all-pcs"]').on('click', () => {
this._allPCsMode = !this._allPCsMode;
this.render(false);
});
// Clear All conditions
html.find('[data-action="clear-all"]').on('click', async () => {
const pcs = this._pcActors();
const targets = this._allPCsMode
? pcs
: pcs.filter(a => a.id === this._selectedActorId);
if (!targets.length) {
ui.notifications.warn('No PC selected.');
return;
}
for (const actor of targets) {
for (const cond of SLA_CONDITIONS) {
if (actor.statuses?.has(cond.id)) {
try {
await actor.toggleStatusEffect(cond.id, { active: false });
} catch (err) {
console.error(`SLAConditionApplicator | Failed to clear ${cond.id} on ${actor.name}:`, err);
}
}
}
}
const targetLabel = this._allPCsMode ? 'all PCs' : (targets[0]?.name ?? 'selected PC');
ui.notifications.info(`Cleared all conditions from ${targetLabel}.`);
});
}
}
SLAConditionApplicator._inst = null;
// ── ACTOR DIRECTORY BUTTONS ──────────────────────────────────────────────────
Hooks.on('renderActorDirectory', (app, html) => {
const root = html instanceof jQuery ? html[0] : html;
const actions = root.querySelector('.header-actions') || root.querySelector('.directory-header');
if (!actions) return;
// Avoid duplicates on re-render
if (actions.querySelector('.sla-gen-pc-btn')) return;
const pcBtn = document.createElement('button');
pcBtn.className = 'sla-gen-pc-btn';
pcBtn.innerHTML = ' Generate PC';
pcBtn.style.cssText = 'font-size:11px;padding:2px 7px;margin-left:4px;background:rgba(255,102,0,0.2);border:1px solid #ff6600;color:#ff6600;border-radius:3px;cursor:pointer;';
pcBtn.addEventListener('click', () => new SLAPCCreator().render(true));
const npcBtn = document.createElement('button');
npcBtn.className = 'sla-gen-npc-btn';
npcBtn.innerHTML = ' Generate NPC';
npcBtn.style.cssText = 'font-size:11px;padding:2px 7px;margin-left:4px;background:rgba(0,200,200,0.1);border:1px solid #00aacc;color:#00aacc;border-radius:3px;cursor:pointer;';
npcBtn.addEventListener('click', () => new SLANPCCreator().render(true));
// GM-only Finance Ledger button
if (game.user?.isGM) {
const finBtn = document.createElement('button');
finBtn.className = 'sla-gm-finance-btn';
finBtn.innerHTML = ' Finance Ledger';
finBtn.style.cssText = 'font-size:11px;padding:2px 7px;margin-left:4px;background:rgba(255,200,0,0.15);border:1px solid #ffcc00;color:#ffcc00;border-radius:3px;cursor:pointer;';
finBtn.addEventListener('click', () => {
if (!game.slaGMFinanceTool) game.slaGMFinanceTool = new SLAGMFinanceTool();
game.slaGMFinanceTool.render(true);
});
actions.append(finBtn);
const grpBtn = document.createElement('button');
grpBtn.className = 'sla-group-npc-btn';
grpBtn.innerHTML = ' Group NPCs';
grpBtn.style.cssText = 'font-size:11px;padding:2px 7px;margin-left:4px;background:rgba(100,200,80,0.15);border:1px solid #66cc44;color:#66cc44;border-radius:3px;cursor:pointer;';
grpBtn.addEventListener('click', () => {
if (!game.slaGroupNPCTool) game.slaGroupNPCTool = new SLAGroupNPCTool();
game.slaGroupNPCTool.render(true);
});
actions.append(grpBtn);
// ── SLA GM Tool buttons ─────────────────────────────────────────────────
const gmToolDefs = [
{ cls: 'sla-dir-status-btn', icon: 'fas fa-clipboard-list', label: 'PC Status', color: '#e3e300', fn: () => SLAGMStatusWindow.open() },
{ cls: 'sla-dir-credit-btn', icon: 'fas fa-credit-card', label: 'Credits', color: '#e3e300', fn: () => SLACreditDistributionTool.open() },
{ cls: 'sla-dir-ledger-btn', icon: 'fas fa-book-open', label: 'Ledger', color: '#aa88ff', fn: () => SLAShiftLedger.open() },
{ cls: 'sla-dir-threat-btn', icon: 'fas fa-skull', label: 'Threats', color: '#ff4444', fn: () => SLANPCThreatBoard.open() },
{ cls: 'sla-dir-bpn-btn', icon: 'fas fa-clipboard-check', label: 'BPN', color: '#ffcc00', fn: () => SLABPNTracker.open() },
{ cls: 'sla-dir-condapp-btn', icon: 'fas fa-bolt', label: 'Conditions', color: '#ff6b35', fn: () => SLAConditionApplicator.open() },
];
for (const def of gmToolDefs) {
if (actions.querySelector(`.${def.cls}`)) continue;
const btn = document.createElement('button');
btn.className = def.cls;
btn.innerHTML = ` ${def.label}`;
btn.style.cssText = `font-size:11px;padding:2px 6px;margin-left:3px;background:rgba(0,0,0,0.3);border:1px solid ${def.color};color:${def.color};border-radius:3px;cursor:pointer;`;
btn.addEventListener('click', def.fn);
actions.append(btn);
}
}
actions.append(pcBtn, npcBtn);
});
// ══════════════════════════════════════════════════════════════════════════════
// SLA INDUSTRIES — RACIAL ABILITIES
// Auto-embedded specialty items with `category: "racial"` and `racialBonuses`
// array driving automatic dice-pool modifiers via _getRacialDiceBonus().
// ══════════════════════════════════════════════════════════════════════════════
const SLA_RACIAL_ABILITY_DATA = {
stormer: [
{
name: "Natural Weapons (Claws & Teeth)",
type: "specialty",
img: "systems/slazero2/assets/racial/natural-weapons.svg",
system: {
category: "racial",
description: "Vatgrown bio-weapons. Always counts as armed. +1 die on all melee attack rolls.",
effects: "+1 die on melee rolls. Always treated as armed in close combat.",
racialBonuses: [{ skill: "melee", bonus: 1 }],
prerequisites: "Stormer 313-S Malice",
isActive: true, healthMod: 0, resolveMod: 0
}
},
{
name: "Stormer Regeneration",
type: "specialty",
img: "systems/slazero2/assets/racial/regen.svg",
system: {
category: "racial",
description: "Enhanced biology heals wound tissue rapidly. Recover 1 HP per hour of rest.",
effects: "Recover 1 HP per hour of rest. Does not function at 0 HP.",
racialBonuses: [],
prerequisites: "Stormer 313-S Malice",
isActive: true, healthMod: 0, resolveMod: 0,
regenHpPerHour: 1
}
}
],
shaktar: [
{
name: "Battle Claws",
type: "specialty",
img: "systems/slazero2/assets/racial/battle-claws.svg",
system: {
category: "racial",
description: "Retractable duranium-hard claws. Always armed. +1 die on melee rolls.",
effects: "+1 die on all melee attack rolls. Always treated as armed.",
racialBonuses: [{ skill: "melee", bonus: 1 }],
prerequisites: "Shaktar",
isActive: true, healthMod: 0, resolveMod: 0
}
},
{
name: "Warrior Caste Training",
type: "specialty",
img: "systems/slazero2/assets/racial/warrior-caste.svg",
system: {
category: "racial",
description: "Generations of ritual melee combat. +1 additional die on melee rolls when wielding any melee weapon.",
effects: "+1 die on melee rolls when using a melee weapon (stacks with Battle Claws for +2 total).",
racialBonuses: [{ skill: "melee", bonus: 1 }],
prerequisites: "Shaktar",
isActive: true, healthMod: 0, resolveMod: 0
}
}
],
wraithraider: [
{
name: "Shaper Senses",
type: "specialty",
img: "systems/slazero2/assets/racial/shaper-senses.svg",
system: {
category: "racial",
description: "Heightened multi-spectrum perception. +2 dice on Observation rolls. +1 die on Stealth rolls.",
effects: "+2 dice on Observation rolls. +1 die on Stealth rolls.",
racialBonuses: [
{ skill: "observation", bonus: 2 },
{ skill: "stealth", bonus: 1 }
],
prerequisites: "Wraith Raider",
isActive: true, healthMod: 0, resolveMod: 0
}
},
{
name: "Wraith Teeth & Claw",
type: "specialty",
img: "systems/slazero2/assets/racial/natural-weapons.svg",
system: {
category: "racial",
description: "Bone-hard teeth and razor claws grown through decades of cold-world adaptation. Natural melee weapons — Damage 2, AP 1, ROF 2. Always counts as armed.",
effects: "Natural weapon: DMG 2, AP 1, ROF 2. Always armed.",
racialBonuses: [],
prerequisites: "Wraith Raider",
isActive: true, healthMod: 0, resolveMod: 0,
isNaturalWeapon: true, naturalWeaponDamage: 2, naturalWeaponAP: 1, naturalWeaponROF: 2
}
}
],
frother: [
{
name: "Blade Proficiency",
type: "specialty",
img: "systems/slazero2/assets/racial/blade-proficiency.svg",
system: {
category: "racial",
description: "Frothers are culturally and chemically attuned to bladed combat. +2 dice on melee rolls with bladed or sword-type weapons.",
effects: "+2 dice on melee rolls when using bladed weapons (swords, knives, power blades).",
racialBonuses: [{ skill: "melee", bonus: 2, weaponTag: "blade" },
{ skill: "melee", bonus: 2, weaponTag: "knife" }],
prerequisites: "Frother",
isActive: true, healthMod: 0, resolveMod: 0
}
},
{
name: "Drug Dependency",
type: "specialty",
img: "systems/slazero2/assets/racial/drug-dependency.svg",
system: {
category: "racial",
description: "A Frother's biochemistry depends on combat stimulants. Without an active drug: −1 die to all rolls (withdrawal).",
effects: "−1 to all rolls when no drug is active (withdrawal). Drug bonuses apply normally when active.",
racialBonuses: [],
prerequisites: "Frother",
isActive: true, healthMod: 0, resolveMod: 0,
drugDependent: true
}
}
],
human: [
{
name: "Social Versatility",
type: "specialty",
img: "systems/slazero2/assets/racial/social-versatility.svg",
system: {
category: "racial",
description: "Humans navigate SLA's corporate social structures with natural ease. +1 die on Persuasion and Insight rolls.",
effects: "+1 die on Persuasion rolls. +1 die on Insight rolls.",
racialBonuses: [
{ skill: "persuasion", bonus: 1 },
{ skill: "insight", bonus: 1 }
],
prerequisites: "Human",
isActive: true, healthMod: 0, resolveMod: 0
}
}
],
vevaphon: [
{
name: "Brute Form",
type: "specialty", img: "systems/slazero2/assets/racial/warrior-caste.svg",
system: {
category: "racial", description: "Dense armoured combat chassis. +2 STR, +2 BODY equivalent (+2 Health max, ignores knockdown). Cannot be disguised. Costs 1 Instability to activate.",
effects: "+2 effective Strength dice, +2 Health max, Knockdown immune. Costs 1 Instability.",
racialBonuses: [{ skill: "force", bonus: 1 }, { skill: "melee", bonus: 1 }],
prerequisites: "Vevaphon", isActive: false, healthMod: 2, resolveMod: 0
}
},
{
name: "Stalker Form",
type: "specialty", img: "systems/slazero2/assets/racial/shaper-senses.svg",
system: {
category: "racial", description: "Lean predatory chassis for silence and ambush. +2 Stealth dice, +1 Agility effective. Cannot wear armour while active. Costs 1 Instability to activate.",
effects: "+2 Stealth, +1 Agility dice. Cannot wear armour. Costs 1 Instability.",
racialBonuses: [{ skill: "stealth", bonus: 2 }, { skill: "mobility", bonus: 1 }],
prerequisites: "Vevaphon", isActive: false, healthMod: 0, resolveMod: 0
}
},
{
name: "Raptor Form",
type: "specialty", img: "systems/slazero2/assets/racial/battle-claws.svg",
system: {
category: "racial", description: "Strike-optimised chassis with natural weapons. +2 Melee dice, +1 Marksmanship (close range only). Critical hits deal +1 damage. Costs 1 Instability to activate.",
effects: "+2 Melee, +1 Marksmanship (close range). Crits deal +1 damage. Costs 1 Instability.",
racialBonuses: [{ skill: "melee", bonus: 2 }, { skill: "marksmanship", bonus: 1 }],
prerequisites: "Vevaphon", isActive: false, healthMod: 0, resolveMod: 0
}
},
{
name: "Morphic Strike",
type: "specialty", img: "systems/slazero2/assets/racial/natural-weapons.svg",
system: {
category: "racial", description: "Natural biogenetic weapon formed from the Vevaphon's own mass. Damage 2, AP 1, ROF 2. Always available regardless of morph form. Counts as melee weapon for skill rolls.",
effects: "Natural weapon: DMG 2, AP 1, ROF 2. Always available.",
racialBonuses: [],
prerequisites: "Vevaphon", isActive: true, healthMod: 0, resolveMod: 0,
isNaturalWeapon: true, naturalWeaponDamage: 2, naturalWeaponAP: 1, naturalWeaponROF: 2
}
}
]
};
// ─── Auto-embed racial abilities ─────────────────────────────────────────────
async function _ensureRacialAbilities(actor) {
if (!actor || actor.type !== "character") return;
const race = String(actor.system?.race || "")
.toLowerCase()
.replace(/[\s-]/g, "")
.replace("313-s", "")
.trim();
const abilities = SLA_RACIAL_ABILITY_DATA[race];
if (!abilities || !abilities.length) return;
const toCreate = [];
const toUpdate = [];
for (const ability of abilities) {
const existing = actor.items.find(
i => i.type === "specialty" && i.name === ability.name
);
if (!existing) {
toCreate.push(ability);
} else {
const patch = {};
if (ability.img && existing.img !== ability.img) patch.img = ability.img;
// Patch natural weapon fields if missing or stale
if (ability.system?.isNaturalWeapon && !existing.system?.isNaturalWeapon) {
patch["system.isNaturalWeapon"] = true;
patch["system.naturalWeaponDamage"] = ability.system.naturalWeaponDamage ?? 2;
patch["system.naturalWeaponAP"] = ability.system.naturalWeaponAP ?? 1;
patch["system.naturalWeaponROF"] = ability.system.naturalWeaponROF ?? 2;
}
if (Object.keys(patch).length) toUpdate.push({ _id: existing.id, ...patch });
}
}
if (toUpdate.length) {
try { await actor.updateEmbeddedDocuments("Item", toUpdate); } catch(_) {}
}
if (!toCreate.length) return;
try {
await actor.createEmbeddedDocuments("Item", toCreate);
ui.notifications.info(
`${actor.name}: racial abilities embedded (${toCreate.map(a => a.name).join(", ")})`
);
} catch (err) {
console.warn("SLA Zero Two | Failed to embed racial abilities:", err);
}
}
// ─── Hooks ───────────────────────────────────────────────────────────────────
// Auto-embed when a new actor is created
Hooks.on("createActor", async (actor) => {
if (!game.user?.isGM) return;
await _ensureRacialAbilities(actor);
});
// On boot, ensure all existing character actors have their racial abilities
Hooks.once("ready", function() {
if (!game.user?.isGM) return;
(async () => {
let count = 0;
for (const actor of game.actors ?? []) {
if (actor.type !== "character") continue;
const race = String(actor.system?.race || "").toLowerCase();
if (!race || race === "undefined") continue;
const before = actor.items.filter(i => i.system?.category === "racial").length;
await _ensureRacialAbilities(actor);
const after = actor.items.filter(i => i.system?.category === "racial").length;
if (after > before) count++;
}
if (count > 0) console.log(`SLA Zero Two | Embedded racial abilities on ${count} actor(s).`);
})();
});
// Stormer Regeneration: heal 1 HP every real-time hour (GM only)
// Uses in-game time advancement via updateWorldTime hook
Hooks.on("updateWorldTime", async (worldTime, delta) => {
if (!game.user?.isGM) return;
// Only trigger on meaningful time advances (≥ 3600 seconds = 1 hour)
if (Math.abs(delta) < 3600) return;
const hoursElapsed = Math.floor(Math.abs(delta) / 3600);
if (hoursElapsed < 1) return;
for (const actor of game.actors ?? []) {
if (actor.type !== "character") continue;
if (!actor.items.some(i => i.name === "Stormer Regeneration" && i.type === "specialty")) continue;
const hp = Number(actor.system?.derivedStats?.health?.value ?? 0);
const hpMax = Number(actor.system?.derivedStats?.health?.max ?? 0);
if (hp <= 0 || hp >= hpMax) continue;
const heal = Math.min(hoursElapsed, hpMax - hp);
const newHp = hp + heal;
await actor.update({ "system.derivedStats.health.value": newHp });
ChatMessage.create({
speaker: { alias: actor.name },
content: `⚡ Stormer Regeneration
${actor.name} regenerates +${heal} HP (${hp} → ${newHp}/${hpMax}).
`
});
}
});
// ══════════════════════════════════════════════════════════════════════════════
// SLA INDUSTRIES — AMMO SYSTEM
// Caliber-based ammunition with Standard / AP / HE rounds.
// Bullet Tax: credits are deducted when rounds are fired.
// ══════════════════════════════════════════════════════════════════════════════
const SLA_AMMO_CATALOG = {
// ── Pistol calibres ──────────────────────────────────────────────────────
"10mm": {
label: "10mm Pistol",
fitWeaponTypes: ["pistol", "pistol smg"],
magazineDefault: 15,
rounds: {
standard: { name: "10mm Standard", costPerRound: 5, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Standard ball round. Reliable, cheap." },
ap: { name: "10mm Armor Piercing", costPerRound: 15, apBonus: 2, damageMod: 0, blastRadius: 0, description: "+2 AP. Penetrates light corporate armor." },
he: { name: "10mm High Explosive", costPerRound: 35, apBonus: 0, damageMod: 1, blastRadius: 0, description: "+1 damage on impact. Fragmentation effect." }
}
},
".44": {
label: ".44 Revolver",
fitWeaponTypes: ["revolver"],
magazineDefault: 6,
rounds: {
standard: { name: ".44 Standard", costPerRound: 8, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Standard heavy revolver round." },
ap: { name: ".44 Armor Piercing", costPerRound: 24, apBonus: 2, damageMod: 0, blastRadius: 0, description: "+2 AP. Hardened penetrator." },
he: { name: ".44 High Explosive", costPerRound: 45, apBonus: 0, damageMod: 1, blastRadius: 0, description: "+1 damage. Cavity round." }
}
},
".454": {
label: ".454 Heavy Pistol",
fitWeaponTypes: ["pistol"],
magazineDefault: 12,
rounds: {
standard: { name: ".454 Standard", costPerRound: 10, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Large-bore semi-auto pistol round." },
ap: { name: ".454 Armor Piercing", costPerRound: 28, apBonus: 2, damageMod: 0, blastRadius: 0, description: "+2 AP. Corporate armor buster." },
he: { name: ".454 High Explosive", costPerRound: 50, apBonus: 0, damageMod: 1, blastRadius: 0, description: "+1 damage. Devastating at close range." }
}
},
".50": {
label: ".50 Heavy Pistol",
fitWeaponTypes: ["pistol"],
magazineDefault: 8,
rounds: {
standard: { name: ".50 Standard", costPerRound: 12, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Anti-armor pistol cartridge." },
ap: { name: ".50 Armor Piercing", costPerRound: 35, apBonus: 3, damageMod: 0, blastRadius: 0, description: "+3 AP. Punches through vehicle glass." },
he: { name: ".50 High Explosive", costPerRound: 60, apBonus: 0, damageMod: 2, blastRadius: 0, description: "+2 damage. Explosive tip." }
}
},
// ── SMG calibres ─────────────────────────────────────────────────────────
"9mm": {
label: "9mm SMG",
fitWeaponTypes: ["smg", "machine pistol"],
magazineDefault: 30,
rounds: {
standard: { name: "9mm Standard", costPerRound: 4, apBonus: 0, damageMod: 0, blastRadius: 0, description: "High-velocity SMG round." },
ap: { name: "9mm Armor Piercing", costPerRound: 12, apBonus: 1, damageMod: 0, blastRadius: 0, description: "+1 AP. Drill-tip penetrator." },
he: { name: "9mm High Explosive", costPerRound: 28, apBonus: 0, damageMod: 1, blastRadius: 0, description: "+1 damage. Micro-charge detonation." }
}
},
"3mm": {
label: "3mm Machine Pistol",
fitWeaponTypes: ["machine pistol"],
magazineDefault: 20,
rounds: {
standard: { name: "3mm Standard", costPerRound: 3, apBonus: 0, damageMod: 0, blastRadius: 0, description: "High-rate tiny caliber round. Cheap, disposable." },
ap: { name: "3mm Armor Piercing", costPerRound: 9, apBonus: 1, damageMod: 0, blastRadius: 0, description: "+1 AP. Needle penetrator tip." },
he: { name: "3mm High Explosive", costPerRound: 20, apBonus: 0, damageMod: 1, blastRadius: 0, description: "+1 damage. Micro-explosive." }
}
},
// ── Assault/Carbine rifle calibres ────────────────────────────────────────
"5.56mm": {
label: "5.56mm NATO",
fitWeaponTypes: ["rifle", "assault rifle", "carbine"],
magazineDefault: 30,
rounds: {
standard: { name: "5.56mm Standard", costPerRound: 8, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Standard assault rifle round." },
ap: { name: "5.56mm Armor Piercing", costPerRound: 22, apBonus: 3, damageMod: 0, blastRadius: 0, description: "+3 AP. Military-grade tungsten tip." },
he: { name: "5.56mm High Explosive", costPerRound: 45, apBonus: 0, damageMod: 2, blastRadius: 1, description: "+2 damage, 1m blast. Detonates on impact." }
}
},
"7mm": {
label: "7mm SLA Carbine",
fitWeaponTypes: ["carbine", "lmg"],
magazineDefault: 24,
rounds: {
standard: { name: "7mm Standard", costPerRound: 10, apBonus: 0, damageMod: 0, blastRadius: 0, description: "SLA-standard intermediate carbine round." },
ap: { name: "7mm Armor Piercing", costPerRound: 28, apBonus: 3, damageMod: 0, blastRadius: 0, description: "+3 AP. Composite penetrator." },
he: { name: "7mm High Explosive", costPerRound: 55, apBonus: 0, damageMod: 2, blastRadius: 1, description: "+2 damage, 1m blast." }
}
},
// ── Battle rifle / Sniper ─────────────────────────────────────────────────
"7.62mm": {
label: "7.62mm Battle Rifle",
fitWeaponTypes: ["battle rifle", "lmg", "sniper"],
magazineDefault: 20,
rounds: {
standard: { name: "7.62mm Standard", costPerRound: 12, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Heavy battle rifle cartridge." },
ap: { name: "7.62mm Armor Piercing", costPerRound: 35, apBonus: 4, damageMod: 0, blastRadius: 0, description: "+4 AP. Penetrates medium body armor." },
he: { name: "7.62mm High Explosive", costPerRound: 70, apBonus: 0, damageMod: 3, blastRadius: 2, description: "+3 damage, 2m blast. Anti-vehicle." }
}
},
"8mm": {
label: "8mm Sniper",
fitWeaponTypes: ["sniper rifle", "sniper"],
magazineDefault: 10,
rounds: {
standard: { name: "8mm Standard", costPerRound: 14, apBonus: 0, damageMod: 0, blastRadius: 0, description: "High-precision sniper cartridge." },
ap: { name: "8mm Armor Piercing", costPerRound: 40, apBonus: 4, damageMod: 0, blastRadius: 0, description: "+4 AP. Long-range penetration round." },
he: { name: "8mm High Explosive", costPerRound: 75, apBonus: 0, damageMod: 3, blastRadius: 0, description: "+3 damage. Detonates inside target." }
}
},
// ── Shotgun ───────────────────────────────────────────────────────────────
"12g": {
label: "12-Gauge Shotgun",
fitWeaponTypes: ["shotgun"],
magazineDefault: 8,
rounds: {
standard: { name: "12g Buckshot", costPerRound: 18, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Multi-pellet scatter. Devastating close range." },
ap: { name: "12g Sabot Slug", costPerRound: 45, apBonus: 2, damageMod: 0, blastRadius: 0, description: "+2 AP. Single hardened slug." },
he: { name: "12g Frag Shell", costPerRound: 80, apBonus: 0, damageMod: 2, blastRadius: 1, description: "+2 damage, 1m blast. Explosive round." }
}
},
// ── Heavy weapon calibres ─────────────────────────────────────────────────
"12.7mm": {
label: "12.7mm Heavy Autocannon",
fitWeaponTypes: ["hw", "heavy weapon", "autocannon"],
magazineDefault: 20,
rounds: {
standard: { name: "12.7mm Standard", costPerRound: 25, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Heavy caliber autocannon round." },
ap: { name: "12.7mm Armor Piercing", costPerRound: 65, apBonus: 5, damageMod: 0, blastRadius: 0, description: "+5 AP. Penetrates vehicle hull." },
he: { name: "12.7mm High Explosive", costPerRound: 120, apBonus: 0, damageMod: 4, blastRadius: 3, description: "+4 damage, 3m blast. Anti-materiel." }
}
},
// ── Specialist calibres ───────────────────────────────────────────────────
"40mm": {
label: "40mm Grenade",
fitWeaponTypes: ["grenade", "launcher"],
magazineDefault: 6,
rounds: {
standard: { name: "40mm Fragmentation", costPerRound: 80, apBonus: 0, damageMod: 0, blastRadius: 3, description: "Standard fragmentation grenade round. 3m blast." },
ap: { name: "40mm HEAT", costPerRound: 180, apBonus: 5, damageMod: 2, blastRadius: 1, description: "+5 AP, +2 damage. High-explosive anti-tank." },
he: { name: "40mm Incendiary", costPerRound: 140, apBonus: 0, damageMod: 3, blastRadius: 4, description: "+3 damage, 4m blast. Burns on contact." }
}
},
"Rail": {
label: "Rail Cannon Slug",
fitWeaponTypes: ["rail gun"],
magazineDefault: 5,
rounds: {
standard: { name: "Rail Slug Standard", costPerRound: 50, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Electromagnetically accelerated tungsten slug." },
ap: { name: "Rail Slug AP", costPerRound: 120, apBonus: 6, damageMod: 0, blastRadius: 0, description: "+6 AP. Penetrates any personal armor." },
he: { name: "Rail Slug Explosive", costPerRound: 200, apBonus: 0, damageMod: 5, blastRadius: 2, description: "+5 damage, 2m blast. Catastrophic terminal effect." }
}
}
};
// Weapon type → default caliber mapping (matches weaponType field)
// Used as fallback only — explicit caliber on the item always takes priority.
const SLA_WEAPON_DEFAULT_CALIBER = {
"Revolver": ".44",
"Pistol": "10mm",
"Machine Pistol": "3mm",
"SMG": "9mm",
"Carbine": "7mm",
"Rifle": "5.56mm",
"Assault Rifle": "5.56mm",
"Battle Rifle": "7.62mm",
"Sniper": "7.62mm",
"Sniper Rifle": "8mm",
"LMG": "7mm",
"HW": "12.7mm",
"Heavy Weapon": "12.7mm",
"Autocannon": "12.7mm",
"Shotgun": "12g",
"Launcher": "40mm",
"Grenade": "40mm",
"Rail Gun": "Rail"
};
/**
* Get the cost per round for a weapon's current round type.
* Reads `caliber` and `roundType` from weaponData.
* Falls back to default caliber for weaponType if caliber not set.
*/
function _getAmmoCostForWeapon(weaponData) {
const caliber = weaponData.caliber
|| SLA_WEAPON_DEFAULT_CALIBER[weaponData.weaponType]
|| null;
if (!caliber) return 0;
const catalog = SLA_AMMO_CATALOG[caliber];
if (!catalog) return 0;
const roundType = (weaponData.roundType || weaponData.ammoType || "standard").toLowerCase();
const rd = catalog.rounds[roundType] || catalog.rounds.standard;
return rd ? rd.costPerRound : 0;
}
/**
* Get AP and damage modifiers from the current round type.
*/
function _getAmmoModifiers(weaponData) {
const caliber = weaponData.caliber
|| SLA_WEAPON_DEFAULT_CALIBER[weaponData.weaponType]
|| null;
if (!caliber) return { apBonus: 0, damageMod: 0, blastRadius: 0 };
const catalog = SLA_AMMO_CATALOG[caliber];
if (!catalog) return { apBonus: 0, damageMod: 0, blastRadius: 0 };
const roundType = (weaponData.roundType || weaponData.ammoType || "standard").toLowerCase();
const rd = catalog.rounds[roundType] || catalog.rounds.standard;
return rd ? { apBonus: rd.apBonus, damageMod: rd.damageMod, blastRadius: rd.blastRadius } : { apBonus: 0, damageMod: 0, blastRadius: 0 };
}
/**
* Deduct bullet tax from actor credits and update session ammo log.
* Bullet tax = 2× the base round cost (ammo purchase price + field levy).
* Called immediately when a weapon fires.
* Two separate update() calls so a finances schema issue never blocks the credit deduction.
*/
async function _applyBulletTax(actor, weaponData, roundsFired) {
if (!actor || roundsFired <= 0) return 0;
const costPerRound = _getAmmoCostForWeapon(weaponData);
if (costPerRound <= 0) return 0;
// Bullet tax is DOUBLED: base cost (ammo) + tax of equal value = 2×
const totalCost = costPerRound * roundsFired * 2;
// Fall back to 500 (template default) not 0 — actors created before the
// credits field existed have undefined here, not zero.
const storedCredits = actor.system?.details?.credits;
const currentCredits = (storedCredits !== undefined && storedCredits !== null)
? Number(storedCredits) : 500;
const newCredits = Math.max(0, currentCredits - totalCost);
// ── Credit deduction — critical, standalone update ──────────────────────
try {
await actor.update({ "system.details.credits": newCredits });
console.log(`SLA Zero Two | Bullet Tax: ${actor.name} credits ${currentCredits}¢ → ${newCredits}¢ (−${totalCost}¢)`);
} catch (err) {
console.error("SLA Zero Two | Bullet Tax credit deduction failed:", err);
return 0;
}
// ── Finances tracking — non-critical, separate update ───────────────────
try {
const sessionSpent = Number(actor.system?.finances?.ammoSpentSession ?? 0) + totalCost;
const bulletTaxRun = Number(actor.system?.finances?.expenses?.bulletTax ?? 0) + totalCost;
await actor.update({
"system.finances.ammoSpentSession": sessionSpent,
"system.finances.expenses.bulletTax": bulletTaxRun
});
} catch (_) {
// Finances tracking is optional — ignore failures silently
}
return totalCost;
}
/**
* Build a complete ammo stock for a weapon.
* Returns array of ammo item data objects (all 3 round types).
*/
function _buildAmmoStockForWeapon(weaponName, caliber) {
const catalog = SLA_AMMO_CATALOG[caliber];
if (!catalog) return [];
const mag = catalog.magazineDefault;
return Object.entries(catalog.rounds).map(([type, rd]) => ({
name: rd.name,
type: "ammo",
system: {
description: rd.description,
caliber,
roundType: type,
quantity: type === "standard" ? mag * 3 : mag, // 3 mags standard, 1 each for AP/HE
costPerRound: rd.costPerRound,
apBonus: rd.apBonus,
damageMod: rd.damageMod,
blastRadius: rd.blastRadius,
weight: 0
}
}));
}
// ── Hook: Auto-set caliber on weapons when created or updated ─────────────────
Hooks.on("createItem", async (item, _options, userId) => {
if (game.user?.id !== userId) return;
if (item.type !== "weapon") return;
const wt = item.system?.weaponType || "";
if (!item.system?.caliber && SLA_WEAPON_DEFAULT_CALIBER[wt]) {
const caliber = SLA_WEAPON_DEFAULT_CALIBER[wt];
const catalog = SLA_AMMO_CATALOG[caliber];
if (!catalog) return;
await item.update({
"system.caliber": caliber,
"system.roundType": "standard",
"system.roundCostStd": catalog.rounds.standard.costPerRound,
"system.roundCostAP": catalog.rounds.ap.costPerRound,
"system.roundCostHE": catalog.rounds.he.costPerRound
});
}
});
// ── Chat helper: show bullet tax in weapon result messages ────────────────────
function _formatBulletTaxLine(totalCost, roundsFired, costPerRound, roundType, caliber) {
if (!totalCost) return '';
const label = caliber ? `${caliber} ${roundType}` : roundType;
return `💸 Bullet Tax: ${roundsFired}× ${label} @ ${costPerRound}c = −${totalCost}c
`;
}
// ══════════════════════════════════════════════════════════════════════════════
// ZERO ENGINE — CHARACTER SHEET MARK 2
// ══════════════════════════════════════════════════════════════════════════════
class ZeroEngineActorSheetMk2 extends ZeroEngineActorSheet {
/** Override the base class's hardcoded template path */
get template() {
return "systems/slazero2/templates/actor/character-sheet-mk2.hbs";
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["slazero2", "sheet", "actor"],
template: "systems/slazero2/templates/actor/character-sheet-mk2.hbs",
width: 760,
height: 720,
tabs: [],
resizable: true
});
}
async getData(options = {}) {
const context = await super.getData(options);
// ── Safe finances defaults ─────────────────────────────────────────────
if (!context.system.finances) {
context.system.finances = {
income: { salary: 0, bpnReward: 0, other: 0 },
expenses: { accommodation: 0, drugs: 0, subscriptions: 0, other: 0, bulletTax: 0 },
debt: 0, ammoSpentSession: 0
};
}
const inc = context.system.finances.income || {};
const exp = context.system.finances.expenses || {};
context.weeklyIncome = (inc.salary||0) + (inc.bpnReward||0) + (inc.other||0);
context.weeklyExpenses = (exp.accommodation||0) + (exp.drugs||0) + (exp.subscriptions||0) + (exp.other||0) + (exp.bulletTax||0);
context.weeklyNet = context.weeklyIncome - context.weeklyExpenses;
context.weeklyNetSign = context.weeklyNet >= 0 ? '+' : '';
context.weeklyNetColor = context.weeklyNet >= 0 ? '#44cc66' : '#cc1111';
// ── Attribute groups — each attribute with its 3 skills underneath ───
const ATTR_SKILL_MAP = {
strength: { label: 'STR', colorClass: 'str-color', skills: ['force','melee','stamina'] },
agility: { label: 'AGI', colorClass: 'agi-color', skills: ['marksmanship','mobility','stealth'] },
wits: { label: 'WIT', colorClass: 'wit-color', skills: ['crafting','observation','survival'] },
empathy: { label: 'EMP', colorClass: 'emp-color', skills: ['healing','insight','persuasion'] }
};
context.attributeGroups = Object.entries(ATTR_SKILL_MAP).map(([attrKey, cfg]) => {
const attrVal = context.system.attributes?.[attrKey]?.value || 2;
return {
key: attrKey, label: cfg.label, colorClass: cfg.colorClass, value: attrVal,
skills: cfg.skills.map(sk => {
const skill = context.system.skills?.[sk] || { value: 0, label: sk };
return {
key: sk, label: skill.label || sk, value: skill.value || 0,
attribute: attrKey,
dots: Array.from({length: 5}, (_, i) => ({ filled: i < (skill.value || 0) }))
};
})
};
});
// ── Flat skills list (for ebb skill separately) ───────────────────────
const skillDotCount = 5;
context.skillsWithDots = Object.entries(context.system.skills || {}).map(([key, skill]) => ({
key, label: skill.label || key, value: skill.value || 0, attribute: skill.attribute || '',
dots: Array.from({length: skillDotCount}, (_, i) => ({ filled: i < (skill.value || 0) }))
}));
context.attributeList = Object.entries(context.system.attributes || {}).map(([key, attr]) => ({
key, value: attr.value || 2, label: key.toUpperCase().slice(0, 3)
}));
// ── Weapon shot options for each weapon ────────────────────────────────
context.weaponItems = (context.items || []).filter(i => i.type === 'weapon').map(w => {
const fireModes = Array.isArray(w.system?.fireModes) ? w.system.fireModes : ['single'];
const shots = _getShotOptions(fireModes);
const caliber = w.system?.caliber || SLA_WEAPON_DEFAULT_CALIBER?.[w.system?.weaponType] || '';
const roundType = w.system?.roundType || 'standard';
const costStd = w.system?.roundCostStd || 0;
const costAP = w.system?.roundCostAP || 0;
const costHE = w.system?.roundCostHE || 0;
const currentCost = roundType === 'ap' ? costAP : roundType === 'he' ? costHE : costStd;
return { ...w, caliber, roundType, currentCost, multiShot: shots.length > 1, shotOptions: shots };
});
return context;
}
activateListeners(html) {
super.activateListeners(html);
const root = html instanceof jQuery ? html[0] : html;
// ── Tab switching (new template uses .mk2-nav-tab / .mk2-panel) ──────────
// Tab switching — template uses .mk2-tab, panels use data-panel matching data-tab
root.querySelectorAll('.mk2-tab').forEach(tab => {
tab.addEventListener('click', () => {
root.querySelectorAll('.mk2-tab').forEach(t => t.classList.remove('active'));
root.querySelectorAll('.mk2-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
const panel = root.querySelector(`.mk2-panel[data-panel="${tab.dataset.tab}"]`);
if (panel) panel.classList.add('active');
});
});
// ── Round type selector ────────────────────────────────────────────────
root.querySelectorAll('.round-select').forEach(btn => {
btn.addEventListener('click', async () => {
const itemId = btn.dataset.itemId;
const round = btn.dataset.round;
const item = this.actor.items.get(itemId);
if (!item) return;
await item.update({ "system.roundType": round });
});
});
// ── Mk2 critical buttons ───────────────────────────────────────────────
root.querySelector('.physical-critical-btn')?.addEventListener('click', ev => {
ev.preventDefault(); this._rollPhysicalCritical(ev);
});
root.querySelector('.mental-critical-btn')?.addEventListener('click', ev => {
ev.preventDefault(); this._rollMentalCritical(ev);
});
// ── Ammo quantity inline edit ──────────────────────────────────────────
root.querySelectorAll('.ammo-qty-input').forEach(input => {
input.addEventListener('change', async () => {
const itemId = input.dataset.itemId;
const item = this.actor.items.get(itemId);
if (!item) return;
await item.update({ "system.quantity": parseInt(input.value) || 0 });
});
});
// ── Net income live update ─────────────────────────────────────────────
const netEl = root.querySelector('#mk2-net-income');
const updateNet = () => {
if (!netEl) return;
const salary = parseInt(root.querySelector('[name="system.finances.income.salary"]')?.value) || 0;
const bpn = parseInt(root.querySelector('[name="system.finances.income.bpnReward"]')?.value) || 0;
const other = parseInt(root.querySelector('[name="system.finances.income.other"]')?.value) || 0;
const acc = parseInt(root.querySelector('[name="system.finances.expenses.accommodation"]')?.value) || 0;
const drugs = parseInt(root.querySelector('[name="system.finances.expenses.drugs"]')?.value) || 0;
const subs = parseInt(root.querySelector('[name="system.finances.expenses.subscriptions"]')?.value) || 0;
const othExp = parseInt(root.querySelector('[name="system.finances.expenses.other"]')?.value) || 0;
const net = (salary + bpn + other) - (acc + drugs + subs + othExp);
netEl.textContent = `${net >= 0 ? '+' : ''}${net}c`;
netEl.style.color = net >= 0 ? '#44cc66' : '#cc1111';
};
root.querySelectorAll('[name^="system.finances"]').forEach(el => {
el.addEventListener('input', updateNet);
});
updateNet();
}
}
// (Mk2 sheet registration moved to the main init hook above)
// ══════════════════════════════════════════════════════════════════════════════
// SLA INDUSTRIES — COMBAT CONDITIONS + MULTI-SHOT SYSTEM
// ══════════════════════════════════════════════════════════════════════════════
// ─── CONDITION DEFINITIONS ───────────────────────────────────────────────────
const SLA_CONDITIONS = [
{
id: "sla-bleeding",
label: "Bleeding",
icon: "icons/svg/blood.svg",
hpPerTurn: 1,
diceModifiers: {},
description: "Lose 1 HP at the start of each turn. Cleared by any healing."
},
{
id: "sla-stunned",
label: "Stunned",
icon: "icons/svg/daze.svg",
hpPerTurn: 0,
diceModifiers: { all: -3 },
description: "-3 dice to all rolls. Clears at end of turn."
},
{
id: "sla-pinned",
label: "Pinned",
icon: "icons/svg/target.svg",
hpPerTurn: 0,
diceModifiers: { melee: -2, marksmanship: -2, mobility: -2 },
description: "-2 dice on attacks and mobility. Requires cover action to clear."
},
{
id: "sla-suppressed",
label: "Suppressed",
icon: "icons/svg/cowled.svg",
hpPerTurn: 0,
diceModifiers: { all: -2 },
description: "-2 dice to all actions. Cleared by passing a STAMINA roll."
},
{
id: "sla-on-fire",
label: "On Fire",
icon: "icons/svg/fire.svg",
hpPerTurn: 2,
escalating: true,
diceModifiers: { all: -1 },
description: "Lose 2+ HP/turn (escalates). -1 die to all rolls. Requires action to extinguish."
},
{
id: "sla-blinded",
label: "Blinded",
icon: "icons/svg/blind.svg",
hpPerTurn: 0,
diceModifiers: { marksmanship: -3, melee: -2, observation: -3 },
description: "-3 dice on ranged attacks and observation. -2 on melee."
},
{
id: "sla-deafened",
label: "Deafened",
icon: "icons/svg/deaf.svg",
hpPerTurn: 0,
diceModifiers: { observation: -1 },
description: "-1 die to observation. Cannot hear audio cues."
},
{
id: "sla-broken",
label: "Broken",
icon: "icons/svg/skull.svg",
hpPerTurn: 0,
diceModifiers: { all: -2 },
description: "-2 dice to all rolls. Must roll STAMINA to act. Cleared by healing to above 0 HP."
},
{
id: "sla-winded",
label: "Winded",
icon: "icons/svg/daze.svg",
hpPerTurn: 0,
diceModifiers: { all: -1 },
description: "Winded — -1 die to all rolls. Clears at end of round."
},
{
id: "sla-broken-arm",
label: "Broken Arm",
icon: "icons/svg/blood.svg",
hpPerTurn: 0,
diceModifiers: { melee: -2, marksmanship: -1, force: -1 },
description: "Broken arm — cannot use that arm; -2 Melee, -1 Marksmanship, -1 Force."
},
{
id: "sla-broken-leg",
label: "Broken Leg",
icon: "icons/svg/blood.svg",
hpPerTurn: 0,
diceModifiers: { mobility: -2, stealth: -1 },
description: "Broken leg — cannot run; -2 Mobility, -1 Stealth."
},
{
id: "sla-concussed",
label: "Concussed",
icon: "icons/svg/daze.svg",
hpPerTurn: 0,
diceModifiers: { all: -1, observation: -1, wits: -1 },
description: "Concussed — -1 die to all rolls, extra -1 to Observation/Wits-based rolls."
},
{
id: "sla-gut-wound",
label: "Gut Wound",
icon: "icons/svg/blood.svg",
hpPerTurn: 0,
diceModifiers: { all: -2 },
description: "Gut wound — severe pain, -2 dice to all rolls."
},
{
id: "sla-panicking",
label: "Panicking",
icon: "icons/svg/terror.svg",
hpPerTurn: 0,
diceModifiers: { all: -2 },
description: "Panicking — cannot act normally. -2 dice to all rolls."
},
{
id: "sla-shaken",
label: "Shaken",
icon: "icons/svg/terror.svg",
hpPerTurn: 0,
diceModifiers: { all: -1 },
description: "Shaken — rattled by horror or trauma. -1 die to all rolls."
}
];
// Conditions are registered in the main init hook (search for "SLA_CONDITIONS" above this line)
// ─── CONDITION MODIFIER HELPER ────────────────────────────────────────────────
/**
* Play a short "ta-da" fanfare using the Web Audio API — no external files needed.
*/
function _playShowingOffFanfare() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
// Ascending major arpeggio: C5 E5 G5 C6
const notes = [523.25, 659.25, 783.99, 1046.5];
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.value = freq;
const t = ctx.currentTime + i * 0.13;
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(0.35, t + 0.04);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
osc.start(t);
osc.stop(t + 0.30);
});
} catch(_) { /* WebAudio unavailable — silent fallback */ }
}
/**
* Post a whispered GM report of every PC's current conditions, HP, Resolve, and Stress.
* Called hourly from the ready hook and on demand from the STATUS button on any sheet.
*/
async function _broadcastPCConditions() {
if (!game.user?.isGM) return;
const pcs = (game.actors ?? []).filter(a =>
a.type === 'character' && a.system?.details?.isPlayerCharacter === true
);
if (pcs.length === 0) {
ui.notifications.info('SLA Zero Two | No PCs found for condition report.');
return;
}
const condLabels = Object.fromEntries(
SLA_CONDITIONS.map(c => [c.id, { label: c.label, desc: c.description }])
);
let rows = '';
for (const pc of pcs) {
const hp = Number(pc.system.derivedStats?.health?.value ?? 0);
const hpMax = Number(pc.system.derivedStats?.health?.max ?? 0);
const res = Number(pc.system.derivedStats?.resolve?.value ?? 0);
const resMax = Number(pc.system.derivedStats?.resolve?.max ?? 0);
const stress = Number(pc.system.derivedStats?.stress ?? 0);
const soTally = Number(pc.system.details?.showingOff ?? 0);
const hpColour = hp <= 0 ? '#ff2222' : hp <= hpMax * 0.3 ? '#ff8800' : '#44cc66';
const resColour = res <= 0 ? '#ff2222' : res <= resMax * 0.3 ? '#ff8800' : '#44ccff';
const stressColour = stress >= 8 ? '#ff2222' : stress >= 5 ? '#ff8800' : '#aaaaaa';
const soColour = soTally >= 20 ? '#ff2222' : soTally >= 10 ? '#ff8800' : soTally > 0 ? '#ffcc00' : '#555';
const activeConds = [...(pc.statuses ?? new Set())]
.filter(id => condLabels[id])
.map(id => `${condLabels[id].label} (${condLabels[id].desc}) `)
.join(', ');
rows += `
${pc.name}
❤ HP ${hp}/${hpMax}
🧠 Resolve ${res}/${resMax}
⚡ Stress ${stress}
✨ Showing Off ${soTally}
${activeConds || 'No active conditions '}
`;
}
const now = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
await ChatMessage.create({
speaker: { alias: '⚠ SLA Zero Two — PC Status Report' },
content: `
Status Report — ${now} — ${pcs.length} PC${pcs.length !== 1 ? 's' : ''}
${rows}
`,
whisper: [game.user.id]
});
}
/**
* Apply a status condition to an actor reliably.
* Tries actor.toggleStatusEffect first (Foundry v14 API). If that fails or
* the condition still isn't in actor.statuses afterward, falls back to creating
* an ActiveEffect directly so conditions always land even if toggleStatusEffect
* is unavailable or throws.
* @param {Actor} actor
* @param {string} condId e.g. "sla-panicking"
* @param {string} [label] display name fallback
* @param {string} [icon] icon path fallback
*/
async function _applyStatusCondition(actor, condId, label, icon) {
// Skip if already active
if (actor.statuses?.has(condId)) return;
// Try the standard API first
let applied = false;
try {
await actor.toggleStatusEffect(condId, { active: true });
applied = actor.statuses?.has(condId) ?? false;
} catch(e) {
console.warn(`SLA Zero Two | toggleStatusEffect failed for "${condId}":`, e);
}
if (!applied) {
// Fallback: look up from SLA_CONDITIONS or use provided label/icon
const condDef = SLA_CONDITIONS.find(c => c.id === condId);
const effectName = condDef?.label ?? label ?? condId;
const effectIcon = condDef?.icon ?? icon ?? 'icons/svg/aura.svg';
try {
await actor.createEmbeddedDocuments('ActiveEffect', [{
name: effectName,
img: effectIcon,
icon: effectIcon,
statuses: [condId],
disabled: false
}]);
console.log(`SLA Zero Two | Applied "${condId}" via fallback ActiveEffect on ${actor.name}`);
} catch(e2) {
console.error(`SLA Zero Two | Could not apply condition "${condId}" on ${actor.name}:`, e2);
}
}
}
/**
* Returns dice-pool penalty from any active SLA conditions on this actor.
* Checks actor.statuses (Foundry v14 Set) and system.derivedStats.broken.
*/
function _getConditionModifiers(actor, { skill = "", attribute = "" } = {}) {
const statuses = actor.statuses ?? new Set();
let bonus = 0;
const breakdown = [];
const sk = String(skill || "").toLowerCase();
const at = String(attribute || "").toLowerCase();
// ── Built-in SLA conditions ─────────────────────────────────────────────────
// Bug fix (v1.7.6): "all" and skill/attribute-specific modifiers now STACK
// (e.g. Concussed = -1 all + -1 observation = -2 to Observation rolls).
// Attribute keys (e.g. "wits") are now respected too.
for (const cond of SLA_CONDITIONS) {
if (!statuses.has(cond.id)) continue;
const mods = cond.diceModifiers || {};
if (mods.all) {
bonus += mods.all;
breakdown.push(`${cond.label} ${mods.all}`);
}
if (sk && mods[sk] !== undefined && mods[sk] !== null) {
bonus += mods[sk];
breakdown.push(`${cond.label} (${sk}) ${mods[sk]}`);
}
if (at && mods[at] !== undefined && mods[at] !== null) {
bonus += mods[at];
breakdown.push(`${cond.label} (${at}) ${mods[at]}`);
}
}
// ── Active Effects: read custom dice bonus changes ──────────────────────────
// Users can attach an ActiveEffect with a change like:
// key: "flags.slazero2.diceBonus.all" value: 2 (any roll)
// key: "flags.slazero2.diceBonus.skills.melee" value: -1 (melee only)
// key: "flags.slazero2.diceBonus.attributes.wits" value: 1 (wits only)
// Mode is ignored — value is always added. This lets the GM grant "+2 dice"
// buffs from arbitrary effects without editing code.
for (const effect of actor.effects ?? []) {
if (effect.disabled) continue;
const changes = effect.changes ?? [];
for (const ch of changes) {
const key = String(ch.key || "").toLowerCase();
const val = Number(ch.value);
if (!Number.isFinite(val) || val === 0) continue;
if (!key.startsWith("flags.slazero2.dicebonus.")) continue;
const rest = key.substring("flags.slazero2.dicebonus.".length);
let applies = false;
if (rest === "all") applies = true;
else if (rest === `skills.${sk}` && sk) applies = true;
else if (rest === `attributes.${at}` && at) applies = true;
if (applies) {
bonus += val;
breakdown.push(`${effect.name || "Effect"} ${val > 0 ? "+" : ""}${val}`);
}
}
}
// Legacy broken flag — only apply while CURRENT HP is actually 0
// (The flag used to persist forever after healing, causing a permanent -2 penalty)
if (actor.system?.derivedStats?.broken && !statuses.has("sla-broken")) {
const currentHp = Number(
typeof actor.system.derivedStats.health === "object"
? actor.system.derivedStats.health?.value
: actor.system.derivedStats.health
) || 0;
if (currentHp <= 0) {
bonus -= 2;
breakdown.push("Broken −2");
}
// If HP > 0 the broken flag is stale — ignore it (cleared on next recalc)
}
return { bonus, breakdown };
}
// ─── AUTO-DAMAGE FROM CONDITIONS ──────────────────────────────────────────────
// v1.7.6: Per-round damage from Bleeding / On Fire no longer depends on the
// combatant being on their turn. Conditions tick every ROUND_TICK_MS for any
// actor in the world that has them, whether combat is active or not. The
// previous combatTurnChange hook only fired inside combat, so a bleeding PC
// who walked out of the encounter would magically stop bleeding.
//
// Behaviour:
// • Ticks while the game is UNPAUSED (pause the game to halt bleed/fire)
// • Runs on the active GM client only (single source of truth, no dupes)
// • Default cadence: one YZE round = 10 real seconds
// • Both PCs and NPCs are damaged so combat encounters still tick down
const ROUND_TICK_MS = 10 * 1000;
let _zeConditionTickHandle = null;
async function _tickConditionsForActor(actor) {
if (!actor || (actor.type !== "character" && actor.type !== "npc")) return;
const statuses = actor.statuses ?? new Set();
const hasBleed = statuses.has("sla-bleeding");
const hasFire = statuses.has("sla-on-fire");
if (!hasBleed && !hasFire) return;
const updates = {};
const messages = [];
let hp = Number(actor.system?.derivedStats?.health?.value ?? 0);
if (hasBleed) {
const newHp = Math.max(0, hp - 1);
if (newHp !== hp) {
messages.push(`🩸 ${actor.name} bleeds — ${hp} → ${newHp} HP`);
hp = newHp;
updates["system.derivedStats.health.value"] = newHp;
if (newHp <= 0) messages.push(`⚠ ${actor.name} is at 0 HP!`);
}
}
if (hasFire) {
const fireLvl = Number(actor.getFlag("slazero2", "fireLevel") ?? 1);
const dmg = fireLvl + 1;
const newHp = Math.max(0, hp - dmg);
if (newHp !== hp) {
messages.push(`🔥 ${actor.name} burns (level ${fireLvl}) — ${hp} → ${newHp} HP`);
hp = newHp;
updates["system.derivedStats.health.value"] = newHp;
}
try { await actor.setFlag("slazero2", "fireLevel", Math.min(fireLvl + 1, 5)); }
catch(_) { /* token actor without persistent flags */ }
}
if (Object.keys(updates).length > 0) {
try { await actor.update(updates); }
catch(e) { console.warn("SLA Zero Two | tick condition update failed:", e); }
}
if (messages.length > 0) {
ChatMessage.create({
speaker: { alias: "SLA Conditions" },
content: `${messages.join(' ')}
`
});
}
}
async function _tickAllConditions() {
// Only the active GM should run this — prevents N-GM duplicate damage
if (!game.user?.isGM) return;
const activeGM = game.users?.activeGM ?? game.users?.find(u => u.isGM && u.active);
if (activeGM && activeGM.id !== game.user.id) return;
if (game.paused) return;
for (const actor of game.actors ?? []) {
await _tickConditionsForActor(actor);
}
}
Hooks.once("ready", () => {
if (_zeConditionTickHandle) return;
_zeConditionTickHandle = setInterval(() => {
_tickAllConditions().catch(e => console.warn("SLA Zero Two | condition tick error:", e));
}, ROUND_TICK_MS);
console.log(`SLA Zero Two | Condition tick started — ${ROUND_TICK_MS / 1000}s/round, runs while game unpaused`);
});
// Clear fire level when fire is extinguished
Hooks.on("updateActor", async (actor, changes) => {
if (!game.user?.isGM) return;
// If fire condition removed, reset fire level
const statuses = actor.statuses ?? new Set();
if (!statuses.has("sla-on-fire")) {
const lvl = actor.getFlag("slazero2", "fireLevel");
if (lvl) await actor.unsetFlag("slazero2", "fireLevel");
}
});
// ─── CONDITION APPLICATION UI ─────────────────────────────────────────────────
/**
* Add condition buttons to the token HUD.
* This supplements Foundry's built-in status icon system.
*/
Hooks.on("renderTokenHUD", (hud, html, data) => {
// Foundry's built-in status icons will handle the SLA conditions
// since we registered them in CONFIG.statusEffects.
// No additional code needed here — the token HUD will show them.
});
// ══════════════════════════════════════════════════════════════════════════════
// MULTI-SHOT SYSTEM
// Allows firing multiple rounds for bonus dice at cost of ammo + bullet tax.
// Shot count capped by weapon fire mode capability.
// ══════════════════════════════════════════════════════════════════════════════
const SHOT_OPTIONS = [
{ shots: 1, label: "1 Shot (single)", diceBonus: 0, requiresMode: ["single","semi","burst","auto"] },
{ shots: 2, label: "2 Shots (+1 die)", diceBonus: 1, requiresMode: ["semi","burst","auto"] },
{ shots: 3, label: "3 Shots (+2 dice)", diceBonus: 2, requiresMode: ["burst","auto"] },
{ shots: 6, label: "6 Shots (+3 dice)", diceBonus: 3, requiresMode: ["auto"] },
];
/**
* Get which shot options are available for a weapon based on its fire modes.
*/
function _getShotOptions(fireModes) {
const modes = (Array.isArray(fireModes) ? fireModes : [fireModes]).map(m => String(m).toLowerCase());
const hasAuto = modes.some(m => m === "auto" || m === "full_auto" || m === "full");
const hasBurst = modes.some(m => m === "burst");
const hasSemi = modes.some(m => m === "semi" || m === "semi_auto");
const hasSingle = modes.some(m => m === "single");
const caps = [];
if (hasSingle || !hasSemi && !hasBurst && !hasAuto) caps.push("single");
if (hasSemi || hasBurst || hasAuto) caps.push("semi");
if (hasBurst || hasAuto) caps.push("burst");
if (hasAuto) caps.push("auto");
return SHOT_OPTIONS.filter(opt =>
opt.requiresMode.some(req => caps.includes(req))
);
}
// ══════════════════════════════════════════════════════════════════════════════
// SLA INDUSTRIES — GAME PAUSED ANIMATION
// Animated SVG overlay: dual rotating rings, SLA logo, scan-line, flicker
// ══════════════════════════════════════════════════════════════════════════════
let _zePauseRefreshHandle = null;
function _zeInjectPauseAnimation() {
document.body?.classList.add("ze-pause-active");
if (document.querySelector('.ze-pause-root')) return;
const html = `
SLA
OPERATIVE STATUS
STAND BY
SLA INDUSTRIES · MORT CITY GRID
`;
document.body?.insertAdjacentHTML('beforeend', html);
}
function _zeRemovePauseAnimation() {
document.querySelector('.ze-pause-root')?.remove();
document.body?.classList.remove('ze-pause-active');
}
function _zeQueuePauseRefresh(delay = 0) {
window.clearTimeout(_zePauseRefreshHandle);
_zePauseRefreshHandle = window.setTimeout(() => {
if (!game?.paused) return;
_zeInjectPauseAnimation();
}, delay);
}
Hooks.once('ready', () => {
if (game.paused) {
_zeQueuePauseRefresh(0);
_zeQueuePauseRefresh(60);
_zeQueuePauseRefresh(180);
}
});
Hooks.on('pauseGame', (paused) => {
if (paused) {
requestAnimationFrame(() => requestAnimationFrame(() => _zeInjectPauseAnimation()));
_zeQueuePauseRefresh(60);
_zeQueuePauseRefresh(200);
} else {
_zeRemovePauseAnimation();
}
});
// ══════════════════════════════════════════════════════════════════════════════
// SLA ZERO TWO — FIRST-RUN WORLD SETUP (v2.0.1)
// A brand-new world should greet the GM like SLA Industries, not a blank
// Foundry install: a branded landing scene and a Start Here briefing journal.
// ══════════════════════════════════════════════════════════════════════════════
Hooks.once("ready", async () => {
if (!game.user?.isGM) return;
const FLAG_KEY = "firstRunSetupDone";
if (game.settings.storage.get("world")) { /* settings ready */ }
try {
game.settings.register("slazero2", FLAG_KEY, {
scope: "world", config: false, type: Boolean, default: false
});
} catch (_) { /* already registered */ }
if (game.settings.get("slazero2", FLAG_KEY)) return;
// ── Start Here journal ────────────────────────────────────────────────────
if (!game.journal.getName("SLA ZERO TWO — START HERE")) {
await JournalEntry.create({
name: "SLA ZERO TWO — START HERE",
pages: [{
name: "Operative Briefing — Read First",
type: "text",
title: { show: false },
text: { format: 1, content: `
WELCOME TO THE WORLD OF PROGRESS
You are a Gamemaster of SLA Zero Two. This page is your first shift briefing.
1. Recommended module
Enable Dice So Nice (Game Settings → Manage Modules) for the custom SLA Zero Two 3D dice —
hazard-yellow Operative dice and black-chrome Stress dice with the blood-red bane face.
2. Build the squad
In the Actors tab, press Generate PC . The wizard walks each player through race,
training package, attributes, skills, and a name. Set each finished PC's Player Character flag
(Biography tab) so the GM status board tracks them.
3. Build the opposition
Group NPCs stamps whole gangs with stats, art, weapons and folders — set the 1–10 threat slider
to the fight you want. Generate NPC makes single archetypes.
4. Hand out work
The BPN button tracks the active Blueprint News file. Credits flow through
Credits / Finance Ledger ; every round fired auto-bills the
Bullet Tax (rounds × cost × 2 — ammunition is billed, not counted).
5. Run the fight
Initiative is on each sheet (AGI+MOB or WIT+OBS ). Attacks live on the
Equipment tab — fire modes, ammo types, range, scope. Damage prompts the defender's armour roll.
Conditions tick automatically. When Health or Resolve breaks, the d66 critical roll is automatic.
The cameras are always on. Encourage Showing Off. Make them pay for it.
` }
}]
});
console.log("SLA Zero Two | Start Here journal created");
}
// ── Branded landing scene ─────────────────────────────────────────────────
if (game.scenes.size === 0 || !game.scenes.find(s => s.name === "Mort — Downtown Grid")) {
const scene = await Scene.create({
name: "Mort — Downtown Grid",
background: { src: "systems/slazero2/assets/SLA_Assets/Maps/slaguru6666_The_words_SLA_INDUSTRIES_in_neon_80s_grafitti_on__bbbf7678-0361-4632-8a3f-4134e9a764dc_1.png" },
width: 1920, height: 1080,
padding: 0,
grid: { type: 0 },
tokenVision: false,
fog: { exploration: false },
backgroundColor: "#0a0a0a",
navigation: true
});
if (game.scenes.size === 1) await scene.activate();
console.log("SLA Zero Two | Landing scene created");
}
await game.settings.set("slazero2", FLAG_KEY, true);
});