';
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 game.dice3d.showForRoll(roll, game.user, true);
}
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('zero-engine', '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 game.dice3d.showForRoll(roll, game.user, true);
}
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: "zero-engine" };
}
// 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 zero-engine system via appearance
for (const term of (d66Roll.dice || [])) {
term.options = term.options || {};
term.options.appearance = { system: "zero-engine" };
}
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(`Zero Engine | 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);
}
const RACE_DERIVED_MODS = {
human: { health: 0, resolve: 0 },
ebon: { health: 0, resolve: 0 },
brainwaster: { health: 0, resolve: 0 },
stormer: { health: 0, resolve: 0 },
shaktar: { health: 0, resolve: 0 },
wraith: { health: 0, resolve: 0 },
frother: { health: 0, resolve: 0 }
};
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("zero-engine", `${stat}Mod`), 0);
const drug = toNumber(effect.getFlag("zero-engine", `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 = String(actor?.system?.race || "").trim().toLowerCase();
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 zero-engine.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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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: ['zero-engine', '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', () => { 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', () => { 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('zero-engine.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('Zero Engine | 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: ['zero-engine', '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: ['zero-engine', '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: ['zero-engine', '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/zero-engine/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(`Zero Engine | 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(`Zero Engine | Group NPC: embed failed for "${npc.name}"`, e); }
}
created++;
} catch(err) {
console.error(`Zero Engine | 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: ['zero-engine', '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: ['zero-engine', '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: ['zero-engine', '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: ['zero-engine', '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: ['zero-engine', '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: ['zero-engine', '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: '#00d4ff', fn: () => SLAGMStatusWindow.open() },
{ cls: 'sla-dir-credit-btn', icon: 'fas fa-credit-card', label: 'Credits', color: '#00d4ff', 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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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/zero-engine/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("Zero Engine | 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(`Zero Engine | 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(`Zero Engine | Bullet Tax: ${actor.name} credits ${currentCredits}¢ → ${newCredits}¢ (−${totalCost}¢)`);
} catch (err) {
console.error("Zero Engine | 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/zero-engine/templates/actor/character-sheet-mk2.hbs";
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
classes: ["zero-engine", "sheet", "actor"],
template: "systems/zero-engine/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('Zero Engine | 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: '⚠ Zero Engine — 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(`Zero Engine | 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(`Zero Engine | Applied "${condId}" via fallback ActiveEffect on ${actor.name}`);
} catch(e2) {
console.error(`Zero Engine | 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 = [];
for (const cond of SLA_CONDITIONS) {
if (!statuses.has(cond.id)) continue;
const mods = cond.diceModifiers || {};
// "all" modifier applies to everything
if (mods.all) {
bonus += mods.all;
breakdown.push(`${cond.label} ${mods.all}`);
continue;
}
// skill-specific modifier
if (skill && mods[skill] !== undefined) {
bonus += mods[skill];
breakdown.push(`${cond.label} ${mods[skill]}`);
}
}
// 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 EACH COMBAT TURN ────────────────────────────
Hooks.on("combatTurnChange", async (combat, _prior, current) => {
if (!game.user?.isGM) return;
const combatant = combat.combatants.get(current.combatantId);
if (!combatant?.actor) return;
const actor = combatant.actor;
const statuses = actor.statuses ?? new Set();
const updates = {};
const messages = [];
// Bleeding: -1 HP
if (statuses.has("sla-bleeding")) {
const hp = Number(actor.system?.derivedStats?.health?.value ?? 0);
const newHp = Math.max(0, hp - 1);
updates["system.derivedStats.health.value"] = newHp;
messages.push(`🩸 ${actor.name} bleeds — ${hp} → ${newHp} HP`);
if (newHp <= 0) messages.push(`⚠ ${actor.name} is at 0 HP!`);
}
// On Fire: -2 HP (escalating tracked via a flag)
if (statuses.has("sla-on-fire")) {
const fireLvl = Number(actor.getFlag("zero-engine", "fireLevel") ?? 1);
const hp = Number((updates["system.derivedStats.health.value"] ?? actor.system?.derivedStats?.health?.value) ?? 0);
const dmg = fireLvl + 1;
const newHp = Math.max(0, hp - dmg);
updates["system.derivedStats.health.value"] = newHp;
await actor.setFlag("zero-engine", "fireLevel", Math.min(fireLvl + 1, 5));
messages.push(`🔥 ${actor.name} burns (level ${fireLvl}) — ${hp} → ${newHp} HP`);
}
if (Object.keys(updates).length > 0) {
await actor.update(updates);
}
if (messages.length > 0) {
ChatMessage.create({
speaker: { alias: "Combat Conditions" },
content: `${messages.join(' ')}
`
});
}
});
// 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("zero-engine", "fireLevel");
if (lvl) await actor.unsetFlag("zero-engine", "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();
}
});