// SLA Zero Two System console.log('SLA Zero Two | Loading System'); const ZERO_ENGINE_CORE_COMBAT = { combatClass: CONFIG?.Combat?.documentClass ?? null, combatantClass: CONFIG?.Combatant?.documentClass ?? null, combatTrackerClass: CONFIG?.ui?.combat ?? null, captured: false }; /** * Show a Dice So Nice animation without ever blocking the roll pipeline. * DSN can stall (no canvas, GPU issues, module conflicts); rolls must not. */ async function dsnSafeShow(roll) { if (!game.dice3d) return; try { await Promise.race([ game.dice3d.showForRoll(roll, game.user, true), new Promise(resolve => setTimeout(resolve, 3500)) ]); } catch (err) { console.warn("SLA Zero Two | Dice So Nice display failed:", err); } } function restoreZeroEngineCoreCombatConfig() { const { combatClass, combatantClass, combatTrackerClass } = ZERO_ENGINE_CORE_COMBAT; if (combatClass) CONFIG.Combat.documentClass = combatClass; if (combatantClass) CONFIG.Combatant.documentClass = combatantClass; if (combatTrackerClass) CONFIG.ui.combat = combatTrackerClass; } function ensureCombatantCompatibilityMethods() { const applyCompat = (proto) => { if (!proto) return; if (typeof proto.getSpeedFromActor !== "function") { proto.getSpeedFromActor = function() { return 1; }; } if (typeof proto.getColor !== "function") { proto.getColor = function() { return "#efefef"; }; } }; applyCompat(CONFIG?.Combatant?.documentClass?.prototype); applyCompat(globalThis.Combatant?.prototype); applyCompat(foundry?.documents?.Combatant?.prototype); } /** * Year SLA Zero Two Dice Dialog * Allows modifying dice pool before rolling */ class YZEDiceDialog extends Dialog { constructor(baseDice, label, dialogData = {}, options = {}) { super(dialogData, options); this.baseDice = baseDice; this.label = label; } static async show(baseDice, label, formula = '', options = {}) { return new Promise((resolve) => { // ── Showing Off counter (shared between render + roll callback) ────────── let showingOff = 0; const normalizedFireModes = Array.isArray(options.fireModes) ? options.fireModes : (typeof options.fireModes === "string" ? options.fireModes.split(",").map(s => s.trim()).filter(Boolean) : []); const showFireMode = normalizedFireModes.length > 0; const showAmmoType = Array.isArray(options.ammoTypes) && options.ammoTypes.length > 0; const defaultFireMode = options.defaultFireMode || (showFireMode ? normalizedFireModes[0] : "single"); const defaultAmmoType = options.defaultAmmoType || (showAmmoType ? options.ammoTypes[0] : "standard"); const modeBonusMap = options.modeBonusMap || {}; const ammoBonusMap = options.ammoBonusMap || {}; const showAttackMods = !!options.showAttackMods; const shotOptions = Array.isArray(options.shotOptions) ? options.shotOptions : []; const showShotSelector = shotOptions.length > 1; const dialog = new Dialog({ title: `Roll ${label}`, content: `
${formula ? `
${formula}
` : ''}
${baseDice}d6
${baseDice}d6
${showShotSelector ? `
` : ''} ${showFireMode ? `
` : ''} ${showAmmoType ? `
` : ''} ${showAttackMods ? `
` : ''}
`, buttons: { roll: { icon: '', label: "Roll", callback: (html) => { const modifier = parseInt(html.find('[name="modifier"]').val()) || 0; const fireMode = html.find('[name="fireMode"]').val() || defaultFireMode; const ammoType = html.find('[name="ammoType"]').val() || defaultAmmoType; const rangeBand = html.find('[name="rangeBand"]').val() || "normal"; const useScope = html.find('[name="useScope"]').is(':checked'); const useAI = html.find('[name="useAI"]').is(':checked'); const modeBonus = parseInt(modeBonusMap[fireMode]) || 0; const ammoBonus = parseInt(ammoBonusMap[ammoType]) || 0; const rangeBonus = rangeBand === "point_blank" ? 1 : (rangeBand === "extreme" ? -2 : 0); const scopeBonus = (useScope && (rangeBand === "long" || rangeBand === "extreme")) ? 2 : 0; const aiBonus = useAI ? 2 : 0; const shotIdx = parseInt(html.find('[name="shotCount"]').val()) || 0; const chosenShot = shotOptions[shotIdx] || shotOptions[0] || { shots: 1, diceBonus: 0 }; const shotDiceBonus = chosenShot.diceBonus || 0; const shotsChosen = chosenShot.shots || 1; // Showing off reduces pool — minimum 1d6 const totalDice = Math.max(1, baseDice + modifier + modeBonus + ammoBonus + rangeBonus + scopeBonus + aiBonus + shotDiceBonus - showingOff); if (options.returnDetails) { resolve({ totalDice, fireMode, ammoType, modifier, rangeBand, useScope, useAI, rangeBonus, scopeBonus, aiBonus, shotsChosen, shotDiceBonus, showingOff }); } else { resolve(totalDice); } } }, cancel: { icon: '', label: "Cancel", callback: () => resolve(null) } }, default: "roll", render: (html) => { const modifierInput = html.find('[name="modifier"]'); const totalDisplay = html.find('#total-dice'); const soDisplay = html.find('#so-display'); const updateTotal = () => { const modifier = parseInt(modifierInput.val()) || 0; const fireMode = html.find('[name="fireMode"]').val() || defaultFireMode; const ammoType = html.find('[name="ammoType"]').val() || defaultAmmoType; const rangeBand = html.find('[name="rangeBand"]').val() || "normal"; const useScope = html.find('[name="useScope"]').is(':checked'); const useAI = html.find('[name="useAI"]').is(':checked'); const modeBonus = parseInt(modeBonusMap[fireMode]) || 0; const ammoBonus = parseInt(ammoBonusMap[ammoType]) || 0; const rangeBonus = rangeBand === "point_blank" ? 1 : (rangeBand === "extreme" ? -2 : 0); const scopeBonus = (useScope && (rangeBand === "long" || rangeBand === "extreme")) ? 2 : 0; const aiBonus = useAI ? 2 : 0; const total = Math.max(1, baseDice + modifier + modeBonus + ammoBonus + rangeBonus + scopeBonus + aiBonus - showingOff); totalDisplay.text(`${total}d6`); if (showingOff > 0) { totalDisplay.css('color', '#ffaa00'); soDisplay.html(`✨ Showing Off: ${showingOff} × (−${showingOff} dice)`); } }; modifierInput.on('input', updateTotal); html.find('[name="fireMode"]').on('change', updateTotal); html.find('[name="ammoType"]').on('change', updateTotal); html.find('[name="rangeBand"]').on('change', updateTotal); html.find('[name="useScope"]').on('change', updateTotal); html.find('[name="useAI"]').on('change', updateTotal); html.find('.modify-dice').on('click', (event) => { const modifier = parseInt(event.currentTarget.dataset.modifier); const currentValue = parseInt(modifierInput.val()) || 0; modifierInput.val(currentValue + modifier); updateTotal(); }); // ── SHOWING OFF button ───────────────────────────────────────────── html.find('#showing-off-btn').on('click', () => { // Check current pool would stay >= 1 const modifier = parseInt(modifierInput.val()) || 0; const rangeBonus = (() => { const rb = html.find('[name="rangeBand"]').val() || "normal"; return rb === "point_blank" ? 1 : (rb === "extreme" ? -2 : 0); })(); const currentTotal = baseDice + modifier + rangeBonus - showingOff; if (currentTotal <= 1) { ui.notifications.warn("Can't show off any more — pool is already at 1d6 minimum!"); return; } showingOff++; updateTotal(); _playShowingOffFanfare(); // Async side-effects — update actor tally + chat message (async () => { const actorId = options.actorId; const actorName = options.actorName ?? 'Someone'; let newTally = showingOff; // fallback if no actor if (actorId) { const actor = game.actors.get(actorId); if (actor) { const prev = Number(actor.system.details?.showingOff ?? 0); newTally = prev + 1; await actor.update({ 'system.details.showingOff': newTally }); } } const exclamations = ['!', ' AGAIN!', ' EVEN MORE!', ' OUTRAGEOUSLY!', ' SPECTACULARLY!', ' BRAZENLY!']; const excl = exclamations[Math.min(showingOff - 1, exclamations.length - 1)]; const starBar = '✨'.repeat(Math.min(showingOff, 5)); await ChatMessage.create({ speaker: { alias: actorName }, content: `
${starBar} SHOWING OFF${excl} ${starBar}
${actorName} is playing to the cameras!
This roll: −${showingOff} dice  |  Career total: ${newTally} pt${newTally !== 1 ? 's' : ''}
` }); // 20-point milestone — broadcast publicly if (actorId && newTally === 20) { await ChatMessage.create({ speaker: { alias: '⚠ SLA Zero Two' }, content: `
⚠ 20 POINTS OF SHOWING OFF ⚠
${actorName} has reached 20 points of showing off.
GM — time to make them pay for it.
` }); } })(); }); } }, { width: 420, classes: ["slazero2", "dialog"] }); dialog.render(true); }); } } /** * SLA Zero Two Item Sheet */ class ZeroEngineItemSheet extends foundry.appv1.sheets.ItemSheet { /** @override */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["slazero2", "sheet", "item"], width: 520, height: 480, tabs: [] }); } /** @override */ get template() { return `systems/slazero2/templates/item/item-sheet.hbs`; } /** @override */ getData() { const context = super.getData(); const itemData = this.item.toObject(false); context.system = itemData.system; context.flags = itemData.flags; context.editable = this.isEditable; return context; } } /** * SLA Zero Two Actor Sheet */ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { /** @override */ static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["slazero2", "sheet", "actor"], width: 1020, height: 800, tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "main" }] }); } /** @override */ get template() { if (this.actor.type === "character") { return `systems/slazero2/templates/actor/character-sheet.hbs`; } return `systems/slazero2/templates/actor/${this.actor.type}-sheet.hbs`; } /** @override */ getData() { const context = super.getData(); const actorData = this.actor.toObject(false); // Use the live prepared system data so that programmatic updates (e.g. stress // increments from a push roll) are reflected immediately on re-render. // toObject(false) returns persisted source data which can lag behind actor.system // in Foundry v14's optimistic-update model. context.system = this.actor.system; context.flags = actorData.flags; // Add rollData for convenience context.rollData = context.actor.getRollData(); // Gather owned specialty items for drag-and-drop display context.specialties = this.actor.items.filter(i => i.type === "specialty").map(i => i.toObject(false)); // Gather owned weapon items, split by category // A weapon is melee if: category === "melee" OR range is "Engaged" (YZE melee range indicator) const weapons = this.actor.items.filter(i => i.type === "weapon").map(i => i.toObject(false)); const isMeleeWeapon = w => w.system.category === "melee" || String(w.system.range ?? "").toLowerCase() === "engaged"; context.meleeWeapons = weapons.filter(w => isMeleeWeapon(w)); context.rangedWeapons = weapons.filter(w => !isMeleeWeapon(w)); context.naturalWeapons = this.actor.items .filter(i => i.type === "specialty" && i.system?.isNaturalWeapon === true) .map(i => i.toObject(false)); context.armors = this.actor.items.filter(i => i.type === "armor").map(i => i.toObject(false)); // Expose equipped armor name for header display context.equippedArmorName = context.armors[0]?.name || null; // Finances summary (used by both Mk1 and Mk2 sheets) context.isGM = game.user?.isGM ?? false; const ladCost = (this.actor.system.details?.ladAccount === true) ? 50 : 0; if (!this.actor.system.finances) { context.weeklyIncome = 0; context.weeklyExpenses = 0; context.weeklyNet = 0; context.weeklyNetSign = '+'; context.weeklyNetColor = '#44cc66'; } else { const _inc = this.actor.system.finances?.income || {}; const _exp = this.actor.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) + ladCost; context.weeklyNet = context.weeklyIncome - context.weeklyExpenses; context.weeklyNetSign = context.weeklyNet >= 0 ? '+' : ''; context.weeklyNetColor = context.weeklyNet >= 0 ? '#44cc66' : '#cc1111'; } context.gearItems = this.actor.items .filter(i => i.type === "equipment" && i.system?.isDrug !== true) .map(i => i.toObject(false)); context.drugs = this.actor.items .filter(i => i.type === "equipment" && i.system?.isDrug === true) .map(i => i.toObject(false)); context.drugAlerts = this._buildDrugAlerts(this.actor); // EBB user detection — Ebon and Brain Waster are ebb users const race = String(this.actor.system.race || "").toLowerCase(); // Flat string versions for dropdown selected-state matching in templates context.raceStr = race.replace(/[\s-]/g, "") === "wraith" ? "wraithraider" : race.replace(/[\s-]/g, ""); const trainingRaw = this.actor.system.training; context.trainingStr = String(Array.isArray(trainingRaw) ? (trainingRaw[0] || "") : (trainingRaw || "")).toLowerCase(); context.isEbbUser = (race === "ebon" || race === "brainwaster"); context.isBrainWaster = (race === "brainwaster"); context.isVevaphon = (race === "vevaphon"); if (context.isVevaphon) { const instab = Number(context.system.vevaphon?.instability ?? 0); context.instabilityPips = Array.from({ length: 12 }, (_, i) => ({ filled: i < instab, danger: i >= 9 })); } context.ebbFormulae = this.actor.items.filter(i => i.type === "ebb").map(i => i.toObject(false)); // Expose archetype label for Mk2 sheet header context.archetype = this.actor.system.archetype || (this.actor.system.training?.[0] ?? ""); // Flux max = Empathy attribute value (core YZE rule: ebb pool scales with Empathy) // Flux max = Ebb skill + 1 for Ebb users. Fall back to stored flux.max, then Empathy. const _ebbSkillForFlux = parseInt(this.actor.system.skills?.ebb?.value) || 0; const _storedFluxMax = parseInt(this.actor.system.flux?.max) || 0; context.computedFluxMax = _ebbSkillForFlux > 0 ? _ebbSkillForFlux + 1 : (_storedFluxMax > 0 ? _storedFluxMax : (parseInt(this.actor.system.attributes?.empathy?.value) || 2)); // Add config object for template context.config = { natures: { "human": "Human", "replicant": "Replicant" }, archetypes: { "operative": "Operative", "investigator": "Investigator", "scout": "Scout", "medic": "Medic", "tech": "Tech" }, Icons: { tabs: { stats: '', mods: '', combat: '', inventory: '', bio: '' } } }; context.stressMax = 10; // Active SLA conditions for display on sheet const SLA_COND_LABELS = { 'sla-bleeding': { label: 'Bleeding', description: 'Lose 1 HP per round', icon: 'fas fa-tint' }, 'sla-stunned': { label: 'Stunned', description: '-3 dice all rolls', icon: 'fas fa-dizzy' }, 'sla-pinned': { label: 'Pinned', description: '-2 dice attacks/mobility', icon: 'fas fa-anchor' }, 'sla-suppressed':{ label: 'Suppressed', description: '-2 dice all actions', icon: 'fas fa-shield-alt' }, 'sla-on-fire': { label: 'On Fire', description: 'HP drain per turn, escalating', icon: 'fas fa-fire' }, 'sla-exhausted': { label: 'Exhausted', description: '-2 dice all actions', icon: 'fas fa-bed' }, 'sla-broken': { label: 'Broken', description: 'HP at 0 — critical state', icon: 'fas fa-heart-broken' }, 'sla-winded': { label: 'Winded', description: '-1 die all rolls this round', icon: 'fas fa-wind' }, 'sla-broken-arm':{ label: 'Broken Arm', description: '-2 Melee, -1 Marks/Force', icon: 'fas fa-bone' }, 'sla-broken-leg':{ label: 'Broken Leg', description: '-2 Mobility, -1 Stealth', icon: 'fas fa-bone' }, 'sla-concussed': { label: 'Concussed', description: '-1 all, extra -1 WIT rolls', icon: 'fas fa-brain' }, 'sla-gut-wound': { label: 'Gut Wound', description: '-2 dice all rolls', icon: 'fas fa-user-injured' }, 'sla-panicking': { label: 'Panicking', description: '-2 dice all rolls', icon: 'fas fa-skull' }, 'sla-shaken': { label: 'Shaken', description: '-1 die all rolls', icon: 'fas fa-ghost' }, }; const statuses = this.actor.statuses ?? new Set(); context.activeConditions = [...statuses] .filter(id => SLA_COND_LABELS[id]) .map(id => ({ id, ...SLA_COND_LABELS[id] })); // Drug withdrawal flag for sheet display const hasDrugDep = this.actor.items.some( i => i.type === 'specialty' && (i.name === 'Drug Dependency' || i.system?.drugDependent === true) ); const anyDrugActive = this.actor.items.some( i => (i.system?.isDrug || i.type === 'drug' || i.system?.drugDependent === false) && i.system?.active === true ); context.drugWithdrawal = hasDrugDep && !anyDrugActive; return context; } /** @override */ activateListeners(html) { html.find(".init-agi").click(ev => { ev.preventDefault(); this._rollInitiative(true); }); html.find(".init-wit").click(ev => { ev.preventDefault(); this._rollInitiative(false); }); super.activateListeners(html); // Mirror inputs: inputs with data-mirrors="field" proxy their value to the canonical // hidden input (which has the name attr) so only one binding ever reaches the form. html.find('[data-mirrors]').on('input change', function() { const target = $(this).data('mirrors'); const canonical = html.find(`[name="${target}"]`); if (canonical.length) { canonical.val(this.value); canonical.trigger('change'); } }); // Everything below here is only needed if the sheet is editable if (!this.isEditable) return; // Add click handlers for rollable items html.find('.rollable').click(this._onRoll.bind(this)); // Attribute increment/decrement buttons (new template) html.find('.attr-btn').click(this._onAttributeChange.bind(this)); // Attribute roll buttons — both the header button and the die badge html.find('.attr-roll-btn, .ze-attr-roll-btn').click(this._onAttributeRoll.bind(this)); // Skill increment/decrement buttons (new template) html.find('.skill-increment, .skill-decrement').click(this._onSkillChange.bind(this)); // Skill roll — clicking the name link OR the existing .skill-roll elements html.find('.skill-roll, .ze-skill-name').click(this._onSkillRoll.bind(this)); // Critical Injury roll buttons html.find('.roll-physical-crit-btn').click(this._onRollPhysicalCrit.bind(this)); html.find('.roll-mental-crit-btn').click(this._onRollMentalCrit.bind(this)); // Manual Panic button html.find('.manual-panic-button').click(this._onManualPanic.bind(this)); html.find('.manual-armor-button').click(this._onManualArmorCheck.bind(this)); // Health / Resolve save rolls — click the vital label name html.find('.vital-save-label').click(this._onVitalSave.bind(this)); // Natural weapon attack roll (Morphic Strike, Wraith Teeth & Claw, etc.) html.find('.natural-weapon-roll-btn').click(this._onNaturalWeaponRoll.bind(this)); // Delete injury buttons html.find('.delete-injury-btn').click(this._onDeleteInjury.bind(this)); // Specialty item interactions - click name to open sheet html.find('.specialty-item .specialty-name').click(ev => { const itemId = ev.currentTarget.closest('.specialty-item').dataset.itemId; const item = this.actor.items.get(itemId); if (item) item.sheet.render(true); }); // Delete specialty from actor html.find('.delete-specialty-btn').click(async ev => { ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (item) { await item.delete(); ui.notifications.info(`${item.name} removed.`); } }); // Weapon name click → trigger the roll (same as the ATTACK button) html.find('.weapon-item .weapon-name').click(this._onWeaponRoll.bind(this)); // Roll weapon attack html.find('.weapon-roll-btn').click(this._onWeaponRoll.bind(this)); // REST button — clears weapon ammo lockouts html.find('.rest-btn').click(this._onRestClick.bind(this)); // Delete weapon from actor html.find('.delete-weapon-btn').click(async ev => { ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (item) { await item.delete(); ui.notifications.info(`${item.name} removed.`); } }); // Armor interactions - click name to open sheet html.find('.armor-item .armor-name').click(ev => { const itemId = ev.currentTarget.closest('.armor-item').dataset.itemId; const item = this.actor.items.get(itemId); if (item) item.sheet.render(true); }); // Roll armor check from specific armor row html.find('.armor-roll-btn').click(async ev => { ev.preventDefault(); ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; await this._promptAndRollArmorCheck(this.actor, itemId); }); // Delete armor from actor html.find('.delete-armor-btn').click(async ev => { ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (item) { await item.delete(); ui.notifications.info(`${item.name} removed.`); } }); // Gear interactions - click name to open sheet html.find('.gear-item .gear-name').click(ev => { const itemId = ev.currentTarget.closest('.gear-item').dataset.itemId; const item = this.actor.items.get(itemId); if (item) item.sheet.render(true); }); // Drug interactions - click name to open sheet html.find('.drug-item .drug-name').click(ev => { const itemId = ev.currentTarget.closest('.drug-item').dataset.itemId; const item = this.actor.items.get(itemId); if (item) item.sheet.render(true); }); // Delete gear from actor html.find('.delete-gear-btn').click(async ev => { ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (item) { await item.delete(); ui.notifications.info(`${item.name} removed.`); } }); // Activate drug (consumes one dose) html.find('.drug-activate-btn').click(async ev => { ev.preventDefault(); ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (!item) return; await this._activateDrug(item); }); // Stop active drug (triggers withdrawal if configured) html.find('.drug-off-btn').click(async ev => { ev.preventDefault(); ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (!item) return; // Two-stage OFF flow: // 1) Active drug -> enter withdrawal // 2) Withdrawal active -> clear withdrawal (fully off) if (item.system?.active) { await this._deactivateDrug(item); } else if (item.system?.withdrawalActive) { await this._clearDrugWithdrawal(item); } }); // Clear withdrawal state html.find('.drug-clear-withdrawal-btn').click(async ev => { ev.preventDefault(); ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (!item) return; await this._clearDrugWithdrawal(item); }); // Delete drug html.find('.delete-drug-btn').click(async ev => { ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (item) { await item.delete(); ui.notifications.info(`${item.name} removed.`); } }); // Equip/unequip toggle html.find('.toggle-equip-btn').click(async ev => { ev.preventDefault(); ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (!item) return; const equipped = item.system?.equipped === true; await item.update({ "system.equipped": !equipped }); }); // ── EBB FORMULA LISTENERS ───────────────────────────────────── // Roll an ebb formula (costs flux) html.find('.ebb-formula-roll-btn').click(this._onRollEbbFormula.bind(this)); // Open ebb formula item sheet html.find('.ebb-formula-edit-btn').click(ev => { ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (item) item.sheet.render(true); }); // Delete ebb formula from actor html.find('.ebb-formula-delete-btn').click(async ev => { ev.stopPropagation(); const itemId = ev.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (item) { await item.delete(); ui.notifications.info(`${item.name} removed.`); } }); // Recover 1 Flux (stretch rest) html.find('.ebb-flux-recover-btn').click(async ev => { ev.preventDefault(); const fluxMax = (parseInt(this.actor.system.skills?.ebb?.value) || 0) > 0 ? (parseInt(this.actor.system.skills.ebb.value) + 1) : (parseInt(this.actor.system.flux?.max) || parseInt(this.actor.system.attributes?.empathy?.value) || 2); const current = this.actor.system.flux?.value ?? 0; const next = Math.min(fluxMax, current + 1); if (next === current) { ui.notifications.info(`${this.actor.name}'s Flux is already full (${current}/${fluxMax}).`); return; } await this.actor.update({ "system.flux.value": next }); ui.notifications.info(`${this.actor.name} recovers 1 Flux (${next}/${fluxMax}).`); }); // Full Flux recovery (shift rest) html.find('.ebb-flux-full-btn').click(async ev => { ev.preventDefault(); const fluxMax = (parseInt(this.actor.system.skills?.ebb?.value) || 0) > 0 ? (parseInt(this.actor.system.skills.ebb.value) + 1) : (parseInt(this.actor.system.flux?.max) || parseInt(this.actor.system.attributes?.empathy?.value) || 2); await this.actor.update({ "system.flux.value": fluxMax }); ui.notifications.info(`${this.actor.name} fully restores Flux (${fluxMax}/${fluxMax}).`); }); // Clear condition button html.find('.condition-clear-btn').click(async ev => { ev.preventDefault(); ev.stopPropagation(); const condId = ev.currentTarget.dataset.conditionId; if (!condId) return; try { await this.actor.toggleStatusEffect(condId, { active: false }); } catch(e) { // Fallback: find and delete the matching ActiveEffect directly console.warn(`SLA Zero Two | toggleStatusEffect(false) failed for "${condId}", trying direct delete:`, e); const effect = this.actor.effects.find(ef => ef.statuses?.has(condId)); if (effect) await effect.delete(); } if (!this.actor.statuses?.has(condId)) { ui.notifications.info(`Condition cleared: ${condId.replace('sla-','')} on ${this.actor.name}`); } }); // Vevaphon morph form switching — both the Equipment tab panel AND the Specialties SHIFT buttons const _onMorphShift = async (ev) => { ev.preventDefault(); ev.stopPropagation(); const form = ev.currentTarget.dataset.form; // Ensure vevaphon data exists before updating if (!this.actor.system.vevaphon) { await this.actor.update({ 'system.vevaphon': { instability: 0, instabilityMax: 12, activeMorphForm: 'none' } }); } const instab = Number(this.actor.system.vevaphon?.instability ?? 0); if (form && form !== 'none') { const newInstab = Math.min(12, instab + 1); await this.actor.update({ 'system.vevaphon.activeMorphForm': form, 'system.vevaphon.instability': newInstab }); const formLabel = form.charAt(0).toUpperCase() + form.slice(1); ui.notifications.info(`${this.actor.name} shifts to ${formLabel} Form. Instability: ${newInstab}/12`); // Auto-roll instability check after every shift await this._rollVevaphonInstabilityCheck(newInstab); } else { await this.actor.update({ 'system.vevaphon.activeMorphForm': 'none' }); ui.notifications.info(`${this.actor.name} returns to base form.`); } }; html.find('.morph-shift-btn').click(_onMorphShift); html.find('.morph-form-btn').click(_onMorphShift); // Vevaphon instability roll html.find('.instability-roll-btn').click(async ev => { ev.preventDefault(); ev.stopPropagation(); await this._onRollVevaphonInstability(); }); } /** * Build top-of-sheet alerts for active drugs and withdrawal states * @param {Actor} actor * @returns {Array<{itemId:string,name:string,duration:string,state:string,summary:string}>} * @private */ _buildDrugAlerts(actor) { const alerts = []; for (const item of actor.items || []) { if (!(item.type === "equipment" && item.system?.isDrug === true)) continue; const state = item.system?.active ? "active" : (item.system?.withdrawalActive ? "withdrawal" : ""); if (!state) continue; const duration = state === "active" ? String(item.system?.activeDuration || "Duration n/a") : String(item.system?.withdrawalDuration || "Withdrawal duration n/a"); const summary = this._formatDrugStateSummary(item, state); alerts.push({ itemId: item.id, name: item.name, duration: duration, state: state, summary: summary || (state === "active" ? "Drug effect active." : "Withdrawal penalties active.") }); } return alerts; } /** * Read a skill modifier value from drug data object * @param {object} obj * @param {string} key * @returns {number} * @private */ _getDrugSkillModValue(obj, key) { const value = Number(obj?.[key] ?? 0); return Number.isFinite(value) ? value : 0; } /** * Build a compact summary string for active/withdrawal drug state * @param {Item} drugItem * @param {"active"|"withdrawal"} state * @returns {string} * @private */ _formatDrugStateSummary(drugItem, state = "active") { const s = drugItem?.system || {}; const isWithdrawal = state === "withdrawal"; const health = Number(isWithdrawal ? s.wdHealthMod : s.healthMod) || 0; const resolve = Number(isWithdrawal ? s.wdResolveMod : s.resolveMod) || 0; const stat = Number(isWithdrawal ? s.wdStatPhysicalMod : s.statPhysicalMod) || 0; const skillAll = Number(isWithdrawal ? s.wdSkillAllMod : s.skillAllMod) || 0; const panicReduction = Number(isWithdrawal ? s.wdPanicReduction : s.panicReduction) || 0; const injuryIgnore = Number(isWithdrawal ? s.wdInjuryPenaltyIgnore : s.injuryPenaltyIgnore) || 0; const healthHeal = (!isWithdrawal) ? (Number(s.healthHeal) || 0) : 0; const parts = []; if (healthHeal > 0) parts.push(`Heals ${healthHeal} HP`); if (health !== 0) parts.push(`Max Health ${health > 0 ? "+" : ""}${health}`); if (resolve !== 0) parts.push(`Resolve ${resolve > 0 ? "+" : ""}${resolve}`); if (stat !== 0) parts.push(`Physical ${stat > 0 ? "+" : ""}${stat}`); if (skillAll !== 0) parts.push(`All Skills ${skillAll > 0 ? "+" : ""}${skillAll}`); if (panicReduction > 0) parts.push(`Panic -${panicReduction}`); if (injuryIgnore > 0) parts.push(`Ignore injury penalties up to ${injuryIgnore}`); const skillObj = isWithdrawal ? s.wdSkillMods : s.skillMods; const namedSkills = []; for (const key of ["force", "melee", "stamina", "marksmanship", "mobility", "stealth", "crafting", "observation", "survival", "healing", "insight", "persuasion"]) { const value = this._getDrugSkillModValue(skillObj, key); if (value !== 0) namedSkills.push(`${key} ${value > 0 ? "+" : ""}${value}`); } if (namedSkills.length > 0) parts.push(namedSkills.join(", ")); return parts.join(" | "); } /** * Notify GM and table when drug state changes * @param {Item} item * @param {string} stateLabel * @param {string} duration * @private */ async _notifyDrugState(item, stateLabel, duration = "") { const actorName = this.actor?.name || "Unknown Actor"; const itemName = item?.name || "Drug"; const message = `${actorName}: ${itemName} ${stateLabel}${duration ? ` (${duration})` : ""}`; ui.notifications.info(message); if (game.user?.isGM) { ui.notifications.warn(`Drug alert - ${message}`); } const gmIds = game.users .filter(u => u.isGM) .map(u => u.id); if (gmIds.length > 0) { await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor: this.actor }), whisper: gmIds, content: `
Drug Alert: ${message}
` }); } } /** * Activate a drug item and consume one dose * @param {Item} item * @returns {Promise} * @private */ /** * Compute the current health/resolve "contribution" for this drug given a state. * Used to derive the delta when state transitions, so we can keep CURRENT HP in * sync with the max-HP swing (per Tim, drug +2 max also gives +2 current; when * the drug ends, both drop by 2). * @private */ _drugStatContribution(item, state) { if (state === "active") return { h: Number(item.system?.healthMod ?? 0) || 0, r: Number(item.system?.resolveMod ?? 0) || 0 }; if (state === "withdrawal") return { h: Number(item.system?.wdHealthMod ?? 0) || 0, r: Number(item.system?.wdResolveMod ?? 0) || 0 }; return { h: 0, r: 0 }; } /** * Apply a delta to current HP and Resolve, clamped to [0, max]. Called after a * drug state change so the player's "live" pool tracks the max swing. * @private */ async _applyCurrentStatDelta(hDelta, rDelta) { if (!this.actor || (!hDelta && !rDelta)) return; const updates = {}; if (hDelta) { const rawHp = this.actor.system?.derivedStats?.health; const curHp = Number((typeof rawHp === "object" ? rawHp?.value : rawHp) ?? 0); const hpMax = Number(this.actor.system?.derivedStats?.health?.max ?? 0); const newHp = Math.max(0, Math.min(hpMax, curHp + hDelta)); if (newHp !== curHp) updates["system.derivedStats.health.value"] = newHp; } if (rDelta) { const rawRes = this.actor.system?.derivedStats?.resolve; const curRes = Number((typeof rawRes === "object" ? rawRes?.value : rawRes) ?? 0); const resMax = Number(this.actor.system?.derivedStats?.resolve?.max ?? 0); const newRes = Math.max(0, Math.min(resMax, curRes + rDelta)); if (newRes !== curRes) updates["system.derivedStats.resolve.value"] = newRes; } if (Object.keys(updates).length > 0) await this.actor.update(updates); } async _activateDrug(item) { if (!item || !(item.type === "equipment" && item.system?.isDrug === true)) return; const qty = Math.max(0, Number(item.system?.quantity) || 0); if (qty <= 0) { ui.notifications.warn(`${item.name} has no remaining doses.`); return; } // Capture state BEFORE update so we can compute the contribution delta const oldState = item.system?.active ? "active" : item.system?.withdrawalActive ? "withdrawal" : "off"; const oldContrib = this._drugStatContribution(item, oldState); await item.update({ "system.quantity": qty - 1, "system.active": true, "system.withdrawalActive": false }); // ── Ensure max is recalculated before we mirror the swing to current ──── if (typeof recalcDerivedStats === "function") { try { await recalcDerivedStats(this.actor); } catch(_) {} } // ── Mirror max swing to current HP/Resolve ───────────────────────────── // e.g. drug.healthMod = +2 with old state "off" → +2 current HP (capped at new max). const newContrib = this._drugStatContribution(item, "active"); await this._applyCurrentStatDelta(newContrib.h - oldContrib.h, newContrib.r - oldContrib.r); // ── Deduct drug cost from credits ──────────────────────────────────────── const drugCost = Math.max(0, Number(item.system?.cost) || 0); if (drugCost > 0) { const currentCredits = Number(this.actor.system?.details?.credits ?? 0); const newCredits = Math.max(0, currentCredits - drugCost); await this.actor.update({ "system.details.credits": newCredits }); ui.notifications.info( `${item.name}: ${drugCost}¢ charged to ${this.actor.name}. Credits: ${currentCredits} → ${newCredits}` ); } const stressBonus = Math.max(0, Number(item.system?.stressRecoveryBonus) || 0); if (stressBonus > 0) { const currentStress = this._getStressValue(this.actor); const nextStress = Math.max(0, currentStress - stressBonus); if (nextStress !== currentStress) { await this.actor.update(this._buildStressUpdate(this.actor, nextStress)); } } // ── Instant HP heal (healthHeal): restore current HP, clamped at max ──── // NOTE: healthHeal NEVER raises max — only current. This is the "medkit" // path. Drugs that raise max should set healthMod, not healthHeal. const healthHeal = Math.max(0, Number(item.system?.healthHeal) || 0); if (healthHeal > 0 && this.actor) { const rawHp = this.actor.system?.derivedStats?.health; const currentHp = Number((typeof rawHp === "object" ? rawHp?.value : rawHp) ?? 0); const hpMax = Number(this.actor.system?.derivedStats?.health?.max ?? 0); if (hpMax > 0) { const newHp = Math.min(hpMax, currentHp + healthHeal); const actual = newHp - currentHp; if (actual > 0) { await this.actor.update({ "system.derivedStats.health.value": newHp }); ui.notifications.info(`${item.name}: healed ${actual} HP (${currentHp} → ${newHp} / ${hpMax})`); } } } await this._notifyDrugState(item, "is ACTIVE", String(item.system?.activeDuration || "")); } /** * Deactivate an active drug and start withdrawal state when defined * @param {Item} item * @returns {Promise} * @private */ async _deactivateDrug(item) { if (!item || !(item.type === "equipment" && item.system?.isDrug === true)) return; const s = item.system || {}; const hasWithdrawal = Boolean(s.withdrawalDuration) || Number(s.wdHealthMod || 0) !== 0 || Number(s.wdResolveMod || 0) !== 0 || Number(s.wdStatPhysicalMod || 0) !== 0 || Number(s.wdSkillAllMod || 0) !== 0 || Number(s.wdPanicReduction || 0) !== 0 || Object.values(s.wdSkillMods || {}).some(v => Number(v || 0) !== 0); // Capture state BEFORE update so we can compute the contribution delta const oldState = item.system?.active ? "active" : item.system?.withdrawalActive ? "withdrawal" : "off"; const oldContrib = this._drugStatContribution(item, oldState); await item.update({ "system.active": false, "system.withdrawalActive": hasWithdrawal }); // ── Ensure max is recalculated before we mirror the swing to current ──── if (typeof recalcDerivedStats === "function") { try { await recalcDerivedStats(this.actor); } catch(_) {} } // ── Mirror max swing to current HP/Resolve ───────────────────────────── // Drug ending (e.g. +2 active → 0) drops current HP by 2 (floor at 0). const newState = hasWithdrawal ? "withdrawal" : "off"; const newContrib = this._drugStatContribution(item, newState); await this._applyCurrentStatDelta(newContrib.h - oldContrib.h, newContrib.r - oldContrib.r); if (hasWithdrawal) { await this._notifyDrugState(item, "entered WITHDRAWAL", String(item.system?.withdrawalDuration || "")); const strainDamage = Math.max(0, Number(item.system?.wdPostUseStaminaDamage) || 0); if (strainDamage > 0) { await this._runDrugStaminaStrainCheck(item, strainDamage); } } else { await this._notifyDrugState(item, "is OFF", ""); } } /** * Clear a drug withdrawal state manually * @param {Item} item * @returns {Promise} * @private */ async _clearDrugWithdrawal(item) { if (!item || !(item.type === "equipment" && item.system?.isDrug === true)) return; // Capture state BEFORE update const oldState = item.system?.active ? "active" : item.system?.withdrawalActive ? "withdrawal" : "off"; const oldContrib = this._drugStatContribution(item, oldState); await item.update({ "system.withdrawalActive": false }); if (typeof recalcDerivedStats === "function") { try { await recalcDerivedStats(this.actor); } catch(_) {} } const newContrib = this._drugStatContribution(item, "off"); await this._applyCurrentStatDelta(newContrib.h - oldContrib.h, newContrib.r - oldContrib.r); await this._notifyDrugState(item, "withdrawal cleared", ""); } /** * Run post-drug stamina check and apply strain damage on failure * @param {Item} item * @param {number} failDamage * @returns {Promise} * @private */ async _runDrugStaminaStrainCheck(item, failDamage) { const str = Number(this.actor.system?.attributes?.strength?.value || 0); const stamina = Number(this.actor.system?.skills?.stamina?.value || 0); const pool = Math.max(0, str + stamina); if (pool <= 0) return; const roll = await (new Roll(`${pool}d6`)).evaluate(); if (game.dice3d) { await dsnSafeShow(roll); } const successes = (roll.dice?.[0]?.results || []).filter(r => r.result === 6).length; const failed = successes <= 0; let healthLine = ""; if (failed) { const currentHealth = this._getDerivedStatValue(this.actor, "health"); const nextHealth = Math.max(0, currentHealth - failDamage); const updates = this._buildDerivedStatUpdate(this.actor, "health", nextHealth); if (nextHealth <= 0) updates["system.derivedStats.broken"] = true; await this.actor.update(updates); healthLine = `
Failure: ${this.actor.name} takes ${failDamage} damage ignoring armor (${currentHealth} -> ${nextHealth} Health).
`; } else { healthLine = `
Success: No strain damage.
`; } await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor: this.actor }), content: `
${item.name} - Stamina Strain Check
Pool: ${pool}d6 | Successes: ${successes}
${healthLine}
` }); } /** * Handle attribute increment/decrement * @param {Event} event * @private */ async _onAttributeChange(event) { event.preventDefault(); event.stopPropagation(); const button = event.currentTarget; const action = button.dataset.action; const attribute = button.dataset.attribute; const currentValue = parseInt(this.actor.system.attributes[attribute].value) || 2; let newValue = currentValue; if (action === 'increment') { newValue = Math.min(5, currentValue + 1); } else if (action === 'decrement') { newValue = Math.max(2, currentValue - 1); } if (newValue !== currentValue) { await this.actor.update({ [`system.attributes.${attribute}.value`]: newValue }); } } /** * Handle skill increment/decrement * @param {Event} event * @private */ async _onSkillChange(event) { event.preventDefault(); event.stopPropagation(); const button = event.currentTarget; const isIncrement = button.classList.contains('skill-increment'); const skill = button.dataset.skill; const currentValue = parseInt(this.actor.system.skills[skill].value) || 0; let newValue = currentValue; if (isIncrement) { newValue = Math.min(5, currentValue + 1); } else { newValue = Math.max(0, currentValue - 1); } if (newValue !== currentValue) { await this.actor.update({ [`system.skills.${skill}.value`]: newValue }); } } /** * Handle skill roll button clicks * @param {Event} event * @private */ async _onSkillRoll(event) { event.preventDefault(); event.stopPropagation(); const button = event.currentTarget; const skill = button.dataset.skill; const attribute = button.dataset.attribute; const skillValue = parseInt(this.actor.system.skills?.[skill]?.value) || 0; const rawAttrRoll = this.actor.system.attributes?.[attribute]; const attributeValue = (typeof rawAttrRoll === 'object' ? parseInt(rawAttrRoll?.value) : parseInt(rawAttrRoll)) || 2; const armorMods = this._getArmorRollModifiers(this.actor, { attribute, skill }); const drugMods = this._getDrugRollModifiers(this.actor, { attribute, skill }); const racialMods = this._getRacialDiceBonus(this.actor, { skill, attribute }); const condMods = _getConditionModifiers(this.actor, { skill, attribute }); const totalDice = Math.max(0, attributeValue + skillValue + armorMods.total + drugMods.total + racialMods.bonus + condMods.bonus); const skillLabel = skill.charAt(0).toUpperCase() + skill.slice(1); const label = `${skillLabel} (${attribute})`; const formula = `${skillLabel} (${attribute} ${attributeValue} + Skill ${skillValue}` + `${armorMods.statMod !== 0 ? ` + Armor Stat ${armorMods.statMod}` : ''}` + `${armorMods.skillMod !== 0 ? ` + Armor Skill ${armorMods.skillMod}` : ''}` + `${drugMods.statMod !== 0 ? ` + Drug Stat ${drugMods.statMod}` : ''}` + `${drugMods.skillMod !== 0 ? ` + Drug Skill ${drugMods.skillMod}` : ''}` + `${racialMods.bonus !== 0 ? ` + Racial/Drug ${racialMods.bonus > 0 ? '+' : ''}${racialMods.bonus}` : ''}` + `${condMods.bonus !== 0 ? ` + Conditions ${condMods.bonus}` : ''}` + ` = ${totalDice}d6)`; return this._rollYZEDice(totalDice, label, formula, skill); } /** * Handle attribute roll button clicks * @param {Event} event * @private */ async _onAttributeRoll(event) { event.preventDefault(); event.stopPropagation(); const button = event.currentTarget; const attribute = button.dataset.attribute; const attributeValue = parseInt(this.actor.system.attributes[attribute]?.value) || 2; const armorMods = this._getArmorRollModifiers(this.actor, { attribute }); const drugMods = this._getDrugRollModifiers(this.actor, { attribute }); const racialMods = this._getRacialDiceBonus(this.actor, { attribute }); const condMods = _getConditionModifiers(this.actor, { attribute }); const totalDice = Math.max(0, attributeValue + armorMods.total + drugMods.total + racialMods.bonus + condMods.bonus); const label = attribute.charAt(0).toUpperCase() + attribute.slice(1); const formula = `${label} ${attributeValue}` + `${armorMods.statMod !== 0 ? ` + Armor Stat ${armorMods.statMod}` : ''}` + `${drugMods.statMod !== 0 ? ` + Drug Stat ${drugMods.statMod}` : ''}` + `${racialMods.bonus !== 0 ? ` + Racial/Drug ${racialMods.bonus > 0 ? '+' : ''}${racialMods.bonus}` : ''}` + `${condMods.bonus !== 0 ? ` + Conditions ${condMods.bonus}` : ''}` + ` = ${totalDice}d6`; return this._rollYZEDice(totalDice, label, formula, attribute); } /** * Handle Physical Critical Injury button click * @param {Event} event * @private */ async _onRollPhysicalCrit(event) { event.preventDefault(); event.stopPropagation(); // Guard: if auto-crit already fired recently, confirm before rolling again if (_critCooldownActive(this.actor.id, "physical")) { const roll = await Dialog.confirm({ title: "Critical Already Rolled", content: "

A Physical Critical was already rolled for this character recently (auto-triggered when HP hit 0). Roll another one?

", yes: () => true, no: () => false, defaultYes: false }); if (!roll) return; } _critRecordFired(this.actor.id, "physical"); await this.actor.update({ 'system.derivedStats.broken': true }); await this._rollPhysicalCritical(this.actor); ui.notifications.warn(`${this.actor.name} is BROKEN and suffers a Physical Critical Injury!`); } /** * Handle Mental Critical Injury button click * @param {Event} event * @private */ async _onRollMentalCrit(event) { event.preventDefault(); event.stopPropagation(); // Guard: if auto-crit already fired recently, confirm before rolling again if (_critCooldownActive(this.actor.id, "mental")) { const roll = await Dialog.confirm({ title: "Critical Already Rolled", content: "

A Mental Critical was already rolled for this character recently (auto-triggered when Resolve hit 0). Roll another one?

", yes: () => true, no: () => false, defaultYes: false }); if (!roll) return; } _critRecordFired(this.actor.id, "mental"); await this.actor.update({ 'system.derivedStats.broken': true }); await this._rollMentalCritical(this.actor); ui.notifications.warn(`${this.actor.name} is BROKEN and suffers a Mental Critical Injury!`); } /** * Roll an Ebb formula — spend flux, roll Empathy + Ebb skill, post result * YZE EBB rules: * - Roll Empathy dice + Ebb skill dice (6s = successes) * - Need formula.successes successes to fully activate * - Flux is spent on activation regardless of success * - Can push the roll (gains stress; Brain Wasters risk catastrophe) * @param {Event} event * @private */ async _onRollEbbFormula(event) { event.preventDefault(); event.stopPropagation(); const button = event.currentTarget; const itemId = button.dataset.itemId; const item = this.actor.items.get(itemId); if (!item) return; const formula = item.system; const fluxCost = Number(formula.fluxCost ?? 1); const currentFlux = this.actor.system.flux?.value ?? 0; const fluxMax = (parseInt(this.actor.system.skills?.ebb?.value) || 0) > 0 ? (parseInt(this.actor.system.skills.ebb.value) + 1) : (parseInt(this.actor.system.flux?.max) || parseInt(this.actor.system.attributes?.empathy?.value) || 2); // Check sufficient flux if (currentFlux < fluxCost) { ui.notifications.warn( `Not enough Flux to use ${item.name}! ` + `Needs ${fluxCost}, have ${currentFlux}/${fluxMax}.` ); return; } // Flux is deducted only when the roll is confirmed (see onConfirm below) // — cancelling the dialog must not burn Flux. const newFlux = Math.max(0, currentFlux - fluxCost); // Build dice pool: Empathy attribute + Ebb skill // Use parseInt() || 0 — robust against corrupt stored values ("3,3", "NaN", etc.) const empathy = parseInt(this.actor.system.attributes?.empathy?.value) || 2; const ebbSkill = parseInt(this.actor.system.skills?.ebb?.value) || 0; const condMods = _getConditionModifiers(this.actor, { attribute: 'empathy', skill: 'ebb' }); const totalDice = Math.max(0, empathy + ebbSkill + condMods.bonus) || 1; // fallback to 1 die minimum const requiredSuccesses = Number(formula.successes ?? 1); const disciplineLabel = formula.discipline ? formula.discipline.charAt(0).toUpperCase() + formula.discipline.slice(1) : "Ebb"; const label = `${item.name} [${disciplineLabel}]`; const formulaStr = `Empathy ${empathy} + Ebb ${ebbSkill}` + `${condMods.bonus !== 0 ? ` + Conditions ${condMods.bonus}` : ''}` + ` = ${totalDice}d6 | ` + `Flux: ${currentFlux} → ${newFlux} (cost ${fluxCost}) | Need ${requiredSuccesses} suc`; const ebbInfo = { itemId: item.id, itemName: item.name, effect: formula.effect || "", catastrophe: formula.catastrophe || "", requiredSuccesses, discipline: formula.discipline || "ebb", range: formula.range || "", duration: formula.duration || "" }; return this._rollYZEDice(totalDice, label, formulaStr, "empathy", null, { ebbInfo, onConfirm: async () => this.actor.update({ "system.flux.value": newFlux }) }); } /** * Auto-apply deterministic EBB effects to the actor * @param {object} ebbInfo - The ebb formula metadata * @param {number} successes - Number of successes rolled * @private */ async _applyEbbEffect(ebbInfo, successes) { if (!ebbInfo || successes < ebbInfo.requiredSuccesses) return; const actor = this.actor; const itemName = (ebbInfo.itemName || "").toLowerCase(); const discipline = (ebbInfo.discipline || "").toLowerCase(); if (discipline === "heal") { // v1.7.6: fix phantom write path — use derivedStats.health (the real schema). // Healing restores CURRENT HP only, never raises max. const rawHp = actor.system?.derivedStats?.health; const hpCurrent = Number((typeof rawHp === "object" ? rawHp?.value : rawHp) ?? 0); const hpMax = Number(actor.system?.derivedStats?.health?.max ?? 0) || hpCurrent; const stress = this._getStressValue(actor); if (itemName.includes("mend")) { // Mend: restore min(2, successes) HP const healAmt = Math.min(2, successes); const newHp = Math.min(hpMax, hpCurrent + healAmt); if (newHp > hpCurrent) { await actor.update({ "system.derivedStats.health.value": newHp }); ui.notifications.info(`${actor.name} | Mend: +${newHp - hpCurrent} HP restored (${newHp}/${hpMax}).`); } } else if (itemName.includes("restore")) { // Restore: heal successes + 1 HP const healAmt = successes + 1; const newHp = Math.min(hpMax, hpCurrent + healAmt); if (newHp > hpCurrent) { await actor.update({ "system.derivedStats.health.value": newHp }); ui.notifications.info(`${actor.name} | Restore: +${newHp - hpCurrent} HP restored (${newHp}/${hpMax}).`); } } else if (itemName.includes("calm")) { // Calm: reduce stress by 1 const newStress = Math.max(0, stress - 1); if (newStress < stress) { await actor.update(this._buildStressUpdate(actor, newStress)); ui.notifications.info(`${actor.name} | Calm: stress reduced to ${newStress}.`); } } } } /** * Handle a natural weapon attack roll from a specialty (Morphic Strike, Wraith Teeth & Claw, etc.) * Uses STR + Melee, same pipeline as a melee weapon attack, with the weapon's own DMG/AP/ROF. * @param {Event} event * @private */ async _onNaturalWeaponRoll(event) { event.preventDefault(); event.stopPropagation(); const itemId = event.currentTarget.dataset.itemId; const item = this.actor.items.get(itemId); if (!item) return; const s = item.system; const damage = Number(s.naturalWeaponDamage) || 2; const ap = Number(s.naturalWeaponAP) || 1; const rof = Number(s.naturalWeaponROF) || 2; const attrValue = parseInt(this.actor.system.attributes?.strength?.value) || 2; const skillValue = parseInt(this.actor.system.skills?.melee?.value) || 0; const armorMods = this._getArmorRollModifiers(this.actor, { attribute: 'strength', skill: 'melee' }); const drugMods = this._getDrugRollModifiers(this.actor, { attribute: 'strength', skill: 'melee' }); const racialMods = this._getRacialDiceBonus(this.actor, { skill: 'melee', attribute: 'strength' }); const condMods = _getConditionModifiers(this.actor, { attribute: 'strength', skill: 'melee' }); const baseDice = attrValue + skillValue + armorMods.total + drugMods.total + racialMods.bonus + condMods.bonus; const formulaStr = `STR ${attrValue} + Melee ${skillValue}` + `${armorMods.total !== 0 ? ` + Armor ${armorMods.total}` : ''}` + `${drugMods.total !== 0 ? ` + Drug ${drugMods.total}` : ''}` + `${racialMods.bonus !== 0 ? ` + Racial ${racialMods.bonus}` : ''}` + `${condMods.bonus !== 0 ? ` + Conditions ${condMods.bonus}` : ''}` + ` = ${baseDice}d6 | DMG ${damage}, AP ${ap}, ROF ${rof}`; const weaponInfo = { name: item.name, damage, ap, range: 'Engaged', rof, category: 'melee', weaponType: 'Natural' }; return this._rollYZEDice(baseDice, item.name, formulaStr, 'strength', weaponInfo); } /** * Handle Health or Resolve save roll — clicking the vital label name. * Rolls current value in plain d6 (no stress dice, no dialog). * A failure (zero successes) adds +1 Stress. * @param {Event} event * @private */ async _onVitalSave(event) { event.preventDefault(); event.stopPropagation(); const saveType = event.currentTarget.dataset.saveType; // "health" or "resolve" const label = saveType === "health" ? "Health Save" : "Resolve Save"; const current = Number(this.actor.system.derivedStats?.[saveType]?.value) || 0; if (current <= 0) { ui.notifications.warn(`${this.actor.name} has no ${saveType} remaining — cannot make a ${label}.`); return; } // Apply condition modifiers — conditions penalise all rolls including saves const condMods = _getConditionModifiers(this.actor); const pool = Math.max(0, current + condMods.bonus); const condNote = condMods.bonus !== 0 ? ` + Conditions ${condMods.bonus} = ${pool}d6` : ''; if (pool <= 0) { ui.notifications.warn(`${this.actor.name}: condition penalties reduce the ${label} pool to 0 — automatic failure. +1 Stress.`); const curStress = this._getStressValue(this.actor); const newStress = Math.min(10, curStress + 1); await this.actor.update(this._buildStressUpdate(this.actor, newStress)); await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor: this.actor }), flavor: `${this.actor.name} — ${label}`, content: `
AUTO-FAIL — 0 dice after condition penalties. +1 Stress (now ${newStress}).
`, }); return; } // Roll the conditioned pool in d6 — no stress dice, no dialog const roll = new Roll(`${pool}d6`); await roll.evaluate(); if (game.dice3d) await dsnSafeShow(roll); const results = roll.dice[0]?.results || []; const successes = results.filter(r => r.result === 6).length; const failed = successes === 0; // Build dice display using existing yze-die classes const diceHtml = results.map(r => { let cls = 'yze-die'; if (r.result === 6) cls += ' yze-success'; return `
${r.result}
`; }).join(''); let resultLine; if (failed) { const curStress = this._getStressValue(this.actor); const newStress = Math.min(10, curStress + 1); await this.actor.update(this._buildStressUpdate(this.actor, newStress)); resultLine = `
FAILED — no successes. +1 Stress (now ${newStress}).
`; } else { resultLine = `
SUCCESS — ${successes} success${successes !== 1 ? 'es' : ''}!
`; } await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor: this.actor }), flavor: `${this.actor.name} — ${label}
${current}d6${condNote}`, content: `
${diceHtml}
${resultLine}
`, style: CONST.CHAT_MESSAGE_STYLES?.ROLL, type: CONST.CHAT_MESSAGE_TYPES?.ROLL, roll: roll, rollMode: game.settings.get('core', 'rollMode') }); } /** * Handle Manual Panic button click * @param {Event} event * @private */ async _onManualPanic(event) { event.preventDefault(); event.stopPropagation(); // Roll Panic check await this._rollPanic(this.actor); } /** * Handle Manual Armor Check button click * @param {Event} event * @private */ async _onManualArmorCheck(event) { event.preventDefault(); event.stopPropagation(); await this._promptAndRollArmorCheck(this.actor); } /** * Handle delete injury button click * @param {Event} event * @private */ async _onDeleteInjury(event) { event.preventDefault(); event.stopPropagation(); const button = event.currentTarget; const injuryType = button.dataset.injuryType; // 'physical' or 'mental' const injuryIndex = parseInt(button.dataset.injuryIndex); // Get current injuries array const injuries = [...this.actor.system.criticalInjuries[injuryType]]; // Remove the injury at the specified index injuries.splice(injuryIndex, 1); // Update actor await this.actor.update({ [`system.criticalInjuries.${injuryType}`]: injuries }); ui.notifications.info(`Critical injury removed from ${this.actor.name}`); } /** * Handle double-click to edit attributes * @param {Event} event * @private */ _onEditAttribute(event) { event.stopPropagation(); const box = $(event.currentTarget); const input = box.find('input'); const valueDiv = box.find('.attr-value'); valueDiv.hide(); input.show().focus().select(); input.one('blur', () => { input.hide(); valueDiv.show(); }); } /** * Handle double-click to edit skills * @param {Event} event * @private */ _onEditSkill(event) { event.stopPropagation(); const row = $(event.currentTarget); const input = row.find('input'); const valueDiv = row.find('.skill-value'); valueDiv.hide(); input.show().focus().select(); input.one('blur', () => { input.hide(); valueDiv.show(); }); } /** * Handle clickable rolls * @param {Event} event The originating click event * @private */ async _onRoll(event) { event.preventDefault(); const element = event.currentTarget; const dataset = element.dataset; // Handle attribute rolls if (dataset.rollType === 'attribute') { const attribute = dataset.attribute; // Check if attribute exists and has a value if (!this.actor.system.attributes || !this.actor.system.attributes[attribute]) { ui.notifications.error(`Attribute ${attribute} not found. Please recreate this character with the updated system.`); return; } const attributeValue = this.actor.system.attributes[attribute].value || 2; const armorMods = this._getArmorRollModifiers(this.actor, { attribute }); const drugMods = this._getDrugRollModifiers(this.actor, { attribute }); const condMods = _getConditionModifiers(this.actor, { attribute }); const totalDice = Math.max(0, attributeValue + armorMods.total + drugMods.total + condMods.bonus); const label = attribute.charAt(0).toUpperCase() + attribute.slice(1); const formula = `${label} ${attributeValue}` + `${armorMods.statMod !== 0 ? ` + Armor Stat ${armorMods.statMod}` : ''}` + `${drugMods.statMod !== 0 ? ` + Drug Stat ${drugMods.statMod}` : ''}` + `${condMods.bonus !== 0 ? ` + Conditions ${condMods.bonus}` : ''}` + ` = ${totalDice}d6`; return this._rollYZEDice(totalDice, label, formula, attribute); } // Handle skill rolls if (dataset.rollType === 'skill') { const skill = dataset.skill; const attribute = dataset.attribute; // Check if skill and attribute exist if (!this.actor.system.skills || !this.actor.system.skills[skill]) { ui.notifications.error(`Skill ${skill} not found. Please recreate this character with the updated system.`); return; } if (!this.actor.system.attributes || !this.actor.system.attributes[attribute]) { ui.notifications.error(`Attribute ${attribute} not found. Please recreate this character with the updated system.`); return; } const skillValue = this.actor.system.skills[skill].value || 0; const attributeValue = this.actor.system.attributes[attribute].value || 2; const armorMods = this._getArmorRollModifiers(this.actor, { attribute, skill }); const drugMods = this._getDrugRollModifiers(this.actor, { attribute, skill }); const condMods = _getConditionModifiers(this.actor, { attribute, skill }); const totalDice = Math.max(0, attributeValue + skillValue + armorMods.total + drugMods.total + condMods.bonus); const attrLabel = attribute.charAt(0).toUpperCase() + attribute.slice(1); const skillLabel = skill.charAt(0).toUpperCase() + skill.slice(1); const label = `${skillLabel} (${attribute.substring(0, 3).toUpperCase()})`; const formula = `${attrLabel} ${attributeValue} + ${skillLabel} ${skillValue}` + `${armorMods.statMod !== 0 ? ` + Armor Stat ${armorMods.statMod}` : ''}` + `${armorMods.skillMod !== 0 ? ` + Armor Skill ${armorMods.skillMod}` : ''}` + `${drugMods.statMod !== 0 ? ` + Drug Stat ${drugMods.statMod}` : ''}` + `${drugMods.skillMod !== 0 ? ` + Drug Skill ${drugMods.skillMod}` : ''}` + `${condMods.bonus !== 0 ? ` + Conditions ${condMods.bonus}` : ''}` + ` = ${totalDice}d6`; return this._rollYZEDice(totalDice, label, formula, attribute); } } /** * Handle weapon attack roll * Ranged weapons use AGI + Marksmanship + Gear Bonus * Melee weapons use STR + Melee + Gear Bonus * @param {Event} event * @private */ async _onWeaponRoll(event) { event.preventDefault(); event.stopPropagation(); const button = event.currentTarget; // Roll button has data-item-id directly; weapon name span does not — walk up to .weapon-item const itemId = button.dataset.itemId ?? button.closest('.weapon-item')?.dataset.itemId; const item = this.actor.items.get(itemId); if (!item) return; const weaponData = item.system; const isMelee = (weaponData.category === "melee") || String(weaponData.range ?? "").toLowerCase() === "engaged"; // If ranged weapon is out of ammo (locked by a failed push), block firing if (!isMelee && weaponData.ammoEmpty) { ui.notifications.warn(`${item.name} is out of ammo — rest to restore ammunition.`); return; } // Determine attribute + skill const attribute = isMelee ? "strength" : "agility"; const skill = isMelee ? "melee" : "marksmanship"; // Attributes: characters store { value: N }, NPCs store a flat number. const rawAttr = this.actor.system.attributes?.[attribute]; const attrValue = (typeof rawAttr === 'object' ? parseInt(rawAttr?.value) : parseInt(rawAttr)) || 2; // Skills: NPC actors have no skills object — fall back to 0. const skillValue = parseInt(this.actor.system.skills?.[skill]?.value) || 0; const attrLabel = attribute.charAt(0).toUpperCase() + attribute.slice(1); const skillLabel = skill.charAt(0).toUpperCase() + skill.slice(1); const gearBonus = parseInt(weaponData.gearBonus) || 0; const armorMods = this._getArmorRollModifiers(this.actor, { attribute, skill }); const drugMods = this._getDrugRollModifiers(this.actor, { attribute, skill }); // Pass weaponType for Frother blade proficiency and similar weapon-gated racial bonuses const racialMods = this._getRacialDiceBonus(this.actor, { skill, attribute, weaponType: weaponData.weaponType || "" }); const condMods = _getConditionModifiers(this.actor, { skill, attribute }); let baseDice = attrValue + skillValue + gearBonus + armorMods.total + drugMods.total + racialMods.bonus + condMods.bonus; // Multi-shot vars — set from dialog result below (inside !isMelee block) let shotBonus = 0; let shotsChosen = 1; // Fire mode / ammo type (ranged only) let fireMode = "single"; let ammoType = "standard"; let modeBonusDice = 0; let ammoBonusDice = 0; let modeDamageBonus = 0; let ammoDamageBonus = 0; let ammoSpent = 0; let rangeBonus = 0; let scopeBonus = 0; let aiBonus = 0; if (!isMelee) { const rawFireModes = Array.isArray(weaponData.fireModes) ? weaponData.fireModes : (typeof weaponData.fireModes === "string" ? weaponData.fireModes.split(",").map(s => s.trim()).filter(Boolean) : (weaponData.fireModes ? [weaponData.fireModes] : ["single"])); // Derive allowed modes by capability: // auto -> single + burst + auto // burst -> single + burst // single -> single only (no selector) let fireModes = ["single"]; if (rawFireModes.includes("auto")) { fireModes = ["single", "burst", "auto"]; } else if (rawFireModes.includes("burst")) { fireModes = ["single", "burst"]; } const defaultFireMode = fireModes[0] || "single"; const allFireModes = Array.isArray(weaponData.fireModes) ? weaponData.fireModes : ["single"]; const dialogShotOptions = _getShotOptions(allFireModes); const dialogResult = await YZEDiceDialog.show( baseDice, `${item.name} Attack`, `${attrLabel} ${attrValue} + ${skillLabel} ${skillValue} + Gear ${gearBonus}`, { returnDetails: true, fireModes: fireModes.length > 1 ? fireModes : [], ammoTypes: ["standard", "ap", "he"], defaultFireMode: defaultFireMode, defaultAmmoType: weaponData.roundType || weaponData.ammoType || "standard", modeBonusMap: { single: 0, burst: 1, auto: 2 }, ammoBonusMap: { standard: 0, ap: 1, he: 0 }, showAttackMods: true, shotOptions: dialogShotOptions, actorId: this.actor?.id, actorName: this.actor?.name } ); if (!dialogResult) return; // Apply multi-shot results from dialog if (dialogResult.shotsChosen && dialogResult.shotsChosen > 1) { shotsChosen = dialogResult.shotsChosen; shotBonus = dialogResult.shotDiceBonus || 0; baseDice += shotBonus; } fireMode = dialogResult.fireMode || "single"; ammoType = dialogResult.ammoType || "standard"; const modifier = parseInt(dialogResult.modifier) || 0; const rangeBonus = parseInt(dialogResult.rangeBonus) || 0; const scopeBonus = parseInt(dialogResult.scopeBonus) || 0; const aiBonus = parseInt(dialogResult.aiBonus) || 0; baseDice = Math.max(0, baseDice + modifier); baseDice = Math.max(0, baseDice + rangeBonus + scopeBonus + aiBonus); } if (fireMode === "burst") { modeBonusDice = 1; modeDamageBonus = 1; ammoSpent = Math.max(3, shotsChosen); } else if (fireMode === "auto") { modeBonusDice = 2; modeDamageBonus = 2; ammoSpent = shotsChosen > 1 ? shotsChosen : (parseInt(weaponData.autoAmmoUse) || 8); } else { // Single/semi: ammo = shotsChosen (multi-shot dialog already set this) ammoSpent = shotsChosen; } if (ammoType === "ap") { ammoBonusDice = 1; } else if (ammoType === "he") { ammoDamageBonus = 1; } const totalDice = baseDice + modeBonusDice + ammoBonusDice; const label = `${item.name} Attack`; const formula = `${attrLabel} ${attrValue} + ${skillLabel} ${skillValue} + Gear ${gearBonus}` + `${armorMods.statMod !== 0 ? ` + Armor Stat ${armorMods.statMod}` : ''}` + `${armorMods.skillMod !== 0 ? ` + Armor Skill ${armorMods.skillMod}` : ''}` + `${drugMods.statMod !== 0 ? ` + Drug Stat ${drugMods.statMod}` : ''}` + `${drugMods.skillMod !== 0 ? ` + Drug Skill ${drugMods.skillMod}` : ''}` + `${racialMods.bonus !== 0 ? ` + Racial/Drug ${racialMods.bonus > 0 ? '+' : ''}${racialMods.bonus}` : ''}` + `${condMods.bonus !== 0 ? ` + Conditions ${condMods.bonus}` : ''}` + `${shotBonus > 0 ? ` + Multi-shot ×${shotsChosen} +${shotBonus}` : ''}` + `${modeBonusDice > 0 ? ` + Mode ${modeBonusDice}` : ''}` + `${ammoBonusDice > 0 ? ` + Ammo ${ammoBonusDice}` : ''}` + `${rangeBonus !== 0 ? ` + Range ${rangeBonus}` : ''}` + `${scopeBonus > 0 ? ` + Scope ${scopeBonus}` : ''}` + `${aiBonus > 0 ? ` + AI ${aiBonus}` : ''}` + ` = ${totalDice}d6`; // ── BULLET TAX: deduct credits for rounds fired (no ammo count tracking) ─ if (!isMelee) { const taxPaid = await _applyBulletTax(this.actor, weaponData, ammoSpent); if (taxPaid > 0) { const cpr = _getAmmoCostForWeapon(weaponData); ui.notifications.warn( `💸 Bullet Tax: ${ammoSpent} × ${cpr}c (×2) = −${taxPaid}c charged to ${this.actor.name}` ); } } // Weapon info to embed in the roll const apBase = parseInt(weaponData.ap) || 0; // Apply round-type AP bonus from ammo catalog const ammoMods = _getAmmoModifiers(weaponData); const apEffective = apBase + ammoMods.apBonus + (ammoType === "ap" ? 1 : 0); const weaponInfo = { itemId: item.id, name: item.name, damage: (parseInt(weaponData.damage) || 0) + ammoMods.damageMod, ap: apBase, apEffective: apEffective, range: weaponData.range || "—", rof: weaponData.rof || "—", category: isMelee ? "Melee" : "Ranged", weaponType: weaponData.weaponType || "", caliber: weaponData.caliber || SLA_WEAPON_DEFAULT_CALIBER[weaponData.weaponType] || "", roundType: weaponData.roundType || "standard", fireMode: fireMode, ammoType: ammoType, ammoSpent: isMelee ? 0 : ammoSpent, bulletTaxPaid: isMelee ? 0 : (ammoSpent * _getAmmoCostForWeapon(weaponData) * 2), modeBonusDice: modeBonusDice, ammoBonusDice: ammoBonusDice, modeDamageBonus: modeDamageBonus, ammoDamageBonus: ammoDamageBonus, rangeBonus: rangeBonus, scopeBonus: scopeBonus, aiBonus: aiBonus }; return this._rollYZEDice(totalDice, label, formula, attribute, weaponInfo, { skipDialog: !isMelee, preModifiedDice: totalDice }); } /** * Handle weapon reload button * Restores ammo to magazine capacity * @param {Event} event * @private */ async _onWeaponReload(event) { event.preventDefault(); event.stopPropagation(); const button = event.currentTarget; const itemId = button.dataset.itemId; const item = this.actor.items.get(itemId); if (!item) return; const magazine = parseInt(item.system.magazine) || 0; const currentAmmo = parseInt(item.system.ammo) || 0; if (currentAmmo >= magazine) { ui.notifications.info(`${item.name} is already fully loaded.`); return; } await item.update({ 'system.ammo': magazine }); ui.notifications.info(`${item.name} reloaded! (${magazine}/${magazine})`); // Post reload to chat await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor: this.actor }), content: `
${this.actor.name} reloads ${item.name} (${currentAmmo} → ${magazine})
` }); } /** * Handle REST button click — clears all weapon ammoEmpty lockouts * @param {Event} event * @private */ async _onRestClick(event) { event.preventDefault(); event.stopPropagation(); const actor = this.actor; // Also clear any stale broken flag on rest if (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 }); } } // Clear Vevaphon instability on rest if (actor.system?.vevaphon?.instability > 0) { await actor.update({ 'system.vevaphon.instability': 0 }); ui.notifications.info(`${actor.name}'s Instability cleared on rest.`); } const rangedWeapons = actor.items.filter(i => i.type === "weapon" && i.system.category !== "melee" && String(i.system.range ?? "").toLowerCase() !== "engaged"); const lockedWeapons = rangedWeapons.filter(i => i.system.ammoEmpty); if (lockedWeapons.length === 0) { ui.notifications.info(`${actor.name} rests — all weapons are already loaded.`); } else { for (const w of lockedWeapons) { await w.update({ 'system.ammoEmpty': false }); } const names = lockedWeapons.map(w => w.name).join(", "); ui.notifications.info(`${actor.name} rests — weapons restored: ${names}`); await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), content: `
🛏 ${actor.name} — RESTING
Weapons restored: ${names}
` }); } } // ── VEVAPHON INSTABILITY TABLE ─────────────────────────────────────────────── // Roll: 1d6 + current instability value. // Results below 7 mean the Vevaphon held together — no effect. // Table escalates from cosmetic glitches (7) to instant death (36). static VEVAPHON_INSTABILITY_TABLE = [ { min: 7, max: 7, name: "FLICKER", severity: "minor", effect: "Surface ripple. No mechanical effect. The form blurs for a moment." }, { min: 8, max: 8, name: "SKIN CRAWL", severity: "minor", effect: "-1 die to Persuasion and Insight until end of scene. Something is visibly wrong." }, { min: 9, max: 9, name: "DRIFT", severity: "minor", effect: "Proportions shift. -1 die to Stealth until rest." }, { min: 10, max: 10, name: "IDENTITY SLIP", severity: "minor", effect: "Forgets one personal detail. Make Wits roll (1 suc) or -1 die to all social rolls this scene." }, { min: 11, max: 11, name: "VOICE SHIFT", severity: "minor", effect: "Voice changes register and accent. Communications are misidentified as a different person." }, { min: 12, max: 12, name: "PAIN SPIKE", severity: "moderate", effect: "Realignment burns. +1 Stress immediately.", autoStress: 1 }, { min: 13, max: 13, name: "LIMB FAULT", severity: "moderate", effect: "One limb locks in wrong form. -2 dice to all physical rolls using that limb until end of scene." }, { min: 14, max: 14, name: "FORM BLEED", severity: "moderate", effect: "Active form partially degrades. Lose 1 bonus die from current form until next shift." }, { min: 15, max: 15, name: "SENSORY OVERLOAD", severity: "moderate", effect: "-2 dice to Observation and all Wits rolls for 1 round as input doubles." }, { min: 16, max: 16, name: "TISSUE REJECTION", severity: "moderate", effect: "Body fights the morph. Take 1 damage — armour does not apply.", autoDamage: 1 }, { min: 17, max: 17, name: "THREAT LOCK", severity: "serious", effect: "Fixated on nearest visible threat. Must make Wits (2 suc) to take any other action this round." }, { min: 18, max: 18, name: "FORM PANIC", severity: "serious", effect: "All morph bonuses collapse for 1 round as the body resets. +1 Stress.", autoStress: 1 }, { min: 19, max: 19, name: "MEMORY FRACTURE", severity: "serious", effect: "Mission brief lost from memory. Must be told again. -1 die to all rolls until re-briefed." }, { min: 20, max: 20, name: "PARTIAL COLLAPSE", severity: "serious", effect: "Torso and limbs lose cohesion briefly. Take 1d3 damage, -2 dice to all rolls for 1 stretch.", autoDamageRoll: "1d3" }, { min: 21, max: 21, name: "IDENTITY BLEED", severity: "serious", effect: "No longer recognises own callsign or squad. -2 dice to all social and coordination rolls until rest." }, { min: 22, max: 22, name: "UNCONTROLLED SHIFT", severity: "serious", effect: "Involuntary morph to a random form (GM rolls 1d3). +1 Instability immediately.", autoInstability: 1 }, { min: 23, max: 23, name: "TRAUMA RESPONSE", severity: "serious", effect: "Gain the Shaken condition (-1 die to all rolls).", applyCondition: "sla-shaken" }, { min: 24, max: 24, name: "PERMANENT SCAR", severity: "critical", effect: "Morph process leaves a permanent deformity. One GM-chosen attribute reduced by 1 until medically treated." }, { min: 25, max: 25, name: "FULL REVERSION", severity: "critical", effect: "Collapses to base form, all morph bonuses lost. Stunned for 1 round. Instability -1.", autoInstability: -1, revertForm: true }, { min: 26, max: 26, name: "PSYCHIC SCREAM", severity: "critical", effect: "Burst of biogenetic energy. All within Short range must make Wits (1 suc) save or +1 Stress." }, { min: 27, max: 27, name: "BODY HORROR", severity: "critical", effect: "The morph becomes grotesquely visible. Allies within Short range must make Resolve save or -1 die for 1 stretch." }, { min: 28, max: 28, name: "CASCADE SHIFT", severity: "critical", effect: "Rapid involuntary cycling through all forms. 1d3 damage, +1 Instability, -3 dice to all rolls for 1 round.", autoDamageRoll: "1d3", autoInstability: 1 }, { min: 29, max: 29, name: "DISSOLUTION RISK", severity: "critical", effect: "Body begins to lose coherence. At end of next scene, make Strength (2 suc) or take 1d6 damage." }, { min: 30, max: 30, name: "IDENTITY DEATH", severity: "catastrophic", effect: "All personal identity lost. Character acts on instinct only — GM controls for 1 full scene." }, { min: 31, max: 31, name: "ORGAN SHIFT", severity: "catastrophic", effect: "Internal organs begin migrating. 2 damage per round for 1d3 rounds. Only stopped by Healing (3 suc).", autoDamage: 2 }, { min: 32, max: 32, name: "FULL SYSTEM FAILURE",severity: "catastrophic", effect: "Incapacitated for 1 stretch. All form stat bonuses removed permanently until surgery." }, { min: 33, max: 33, name: "CATASTROPHIC CASCADE",severity:"catastrophic", effect: "Continuous involuntary form cycling. 1d6 damage per round, unable to act. Requires Healing (3 suc) to stabilise.", autoDamageRoll: "1d6" }, { min: 34, max: 34, name: "BIOGENETIC DETONATION",severity:"catastrophic",effect: "Explosive biogenetic discharge. 2d6 damage to self, 1d6 to everyone at Engaged range. Armour does not apply.", autoDamageRoll: "2d6" }, { min: 35, max: 35, name: "TERMINAL DISSOLUTION",severity:"lethal", effect: "Biogenetic matrix cannot maintain coherence. Incapacitated. Death in 1d6 rounds unless Healing (4 suc) succeeds." }, { min: 36, max: 99, name: "TOTAL COLLAPSE", severity: "lethal", effect: "The Vevaphon's biogenetic matrix fails completely. Instant death. The body dissolves into unstructured tissue." }, ]; /** * Auto-roll 1d6 + instability and apply instability table result. * Called automatically after every morph shift. * @param {number} currentInstability */ async _rollVevaphonInstabilityCheck(currentInstability) { const actor = this.actor; const roll = new Roll('1d6', {}); await roll.evaluate(); const dieVal = roll.dice[0].results[0].result; const total = dieVal + currentInstability; // Apply alien dice skin — force slazero2 system via appearance (bypasses user's saved DSN setting) for (const term of (roll.dice || [])) { term.options = term.options || {}; term.options.appearance = { system: 'slazero2' }; } if (total < 7) { // No effect — silent unless instability is high enough to be notable await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), flavor: `🔀 ${actor.name} — Instability Check`, content: `

Rolled ${dieVal} + Instability ${currentInstability} = ${total}

✓ Held together. No effect.

`, rolls: [roll] }); return; } // Find table entry const INST = ZeroEngineActorSheet.VEVAPHON_INSTABILITY_TABLE; const entry = INST.find(e => total >= e.min && total <= e.max) || INST[INST.length - 1]; const severityColour = { minor: '#88aaff', moderate: '#ffcc44', serious: '#ff8844', critical: '#ff4422', catastrophic: '#cc1111', lethal: '#ff0000' }[entry.severity] || '#aaa'; let content = `
`; content += `

Rolled ${dieVal} + Instability ${currentInstability} = ${total}

`; content += `

${entry.name}

`; content += `

${entry.effect}

`; // Auto-apply immediate mechanical effects const updates = {}; let notes = []; if (entry.autoStress) { const cur = Number(actor.system.derivedStats?.stress ?? 0); const upd = this._buildStressUpdate(actor, cur + entry.autoStress); Object.assign(updates, upd); notes.push(`+${entry.autoStress} Stress auto-applied`); } if (entry.autoDamage) { const hp = Number(actor.system.derivedStats?.health?.value ?? 0); const newHp = Math.max(0, hp - entry.autoDamage); updates['system.derivedStats.health.value'] = newHp; notes.push(`${entry.autoDamage} damage auto-applied`); } if (entry.autoDamageRoll) { const dmgRoll = new Roll(entry.autoDamageRoll, {}); await dmgRoll.evaluate(); const hp = Number(actor.system.derivedStats?.health?.value ?? 0); const newHp = Math.max(0, hp - dmgRoll.total); updates['system.derivedStats.health.value'] = newHp; notes.push(`${dmgRoll.total} damage auto-applied`); } if (entry.autoInstability) { const curInst = Number(actor.system.vevaphon?.instability ?? 0); const newInst = Math.max(0, Math.min(12, curInst + entry.autoInstability)); updates['system.vevaphon.instability'] = newInst; notes.push(`Instability ${entry.autoInstability > 0 ? '+' : ''}${entry.autoInstability} → ${newInst}`); } if (entry.revertForm) { updates['system.vevaphon.activeMorphForm'] = 'none'; notes.push('Reverted to base form'); } if (entry.applyCondition) { await _applyStatusCondition(actor, entry.applyCondition); notes.push(`Condition applied: ${entry.applyCondition.replace('sla-','')}`); } if (Object.keys(updates).length > 0) { await actor.update(updates); } if (notes.length > 0) { content += `

Auto-applied: ${notes.join(' | ')}

`; } content += '
'; await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), flavor: `🔀 ${actor.name} — Instability Check [${entry.severity.toUpperCase()}]`, content, rolls: [roll] }); } async _onRollVevaphonInstability() { const instab = Number(this.actor.system.vevaphon?.instability ?? 0); const actor = this.actor; if (instab < 6) { ui.notifications.info(`${actor.name}'s Instability is below 6 — no effects trigger yet.`); return; } const MINOR_EFFECTS = [ "Involuntary surface texturing — skin shifts mid-conversation. Social rolls at -1 die.", "Limb proportions drift. Fine motor tasks at -1 die until end of scene.", "Voice shifts register unexpectedly. Communications may be misidentified.", "Brief facial blurring. Anyone describing the Vevaphon gives contradictory reports.", "Pain from realignment. Gain +1 Stress.", "Form flickers. One random skill loses 1 die until the Vevaphon rests." ]; const PANIC_TABLE = [ { result: "Cascade Shift", effect: "Immediately shifts to a random morph form. Costs 1 additional Instability." }, { result: "Identity Bleed", effect: "Forgets assigned name and call sign until end of scene. Acts on instinct." }, { result: "Partial Lock", effect: "One limb freezes in wrong form. -2 dice to all physical rolls using that limb." }, { result: "Tissue Rejection", effect: "Takes 1d3 damage as the body fights itself. Armour does not apply." }, { result: "Threat Imprint", effect: "Locks on nearest visible target. Must roll Wits to perform any other action." }, { result: "Full Reversion", effect: "Collapses to base form, losing all morph bonuses. Recover 1 Instability." } ]; const roll = new Roll('1d6', {}); await roll.evaluate(); const result = roll.total; let content = '
'; if (instab >= 10) { // Check for Morph Panic (roll vs Resolve) const panicEntry = PANIC_TABLE[Math.min(result - 1, 5)]; content += `

⚠ MORPH PANIC CHECK (Instability ${instab}/12)

`; content += `

Rolled: ${result}

`; content += `

${panicEntry.result}: ${panicEntry.effect}

`; if (panicEntry.result === "Tissue Rejection") { const dmgRoll = new Roll('1d3', {}); await dmgRoll.evaluate(); const newHp = Math.max(0, Number(actor.system.derivedStats?.health?.value ?? 0) - dmgRoll.total); await actor.update({ 'system.derivedStats.health.value': newHp }); content += `

Auto-applied: ${dmgRoll.total} unarmoured damage.

`; } else if (panicEntry.result === "Full Reversion") { const newInstab = Math.max(0, instab - 1); await actor.update({ 'system.vevaphon.activeMorphForm': 'none', 'system.vevaphon.instability': newInstab }); content += `

Auto-applied: Reverted to base form. Instability reduced to ${newInstab}.

`; } else if (panicEntry.result === "Cascade Shift") { const forms = ['brute', 'stalker', 'raptor']; const randomForm = forms[Math.floor(Math.random() * forms.length)]; const newInstab = Math.min(12, instab + 1); await actor.update({ 'system.vevaphon.activeMorphForm': randomForm, 'system.vevaphon.instability': newInstab }); content += `

Auto-applied: Randomly shifted to ${randomForm} form. Instability: ${newInstab}/12.

`; } } else { // Minor instability effect const effect = MINOR_EFFECTS[(result - 1) % MINOR_EFFECTS.length]; content += `

Minor Instability Effect (Instability ${instab}/12)

`; content += `

Rolled: ${result}

`; content += `

${effect}

`; if (effect.includes('+1 Stress')) { const curStress = Number(actor.system.derivedStats?.stress ?? 0); await actor.update(this._buildStressUpdate(actor, curStress + 1)); } } content += '
'; await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), flavor: `🔀 ${actor.name} — Vevaphon Instability`, content, rolls: [roll] }); } /** * Read a derived stat while supporting scalar and legacy object shapes * @param {Actor} actor * @param {string} key * @returns {number} * @private */ _getDerivedStatValue(actor, key) { const raw = actor?.system?.derivedStats?.[key]; const value = Number((typeof raw === "object" ? raw?.value : raw) ?? 0); return Number.isFinite(value) ? value : 0; } /** * Read derived stat max for object-shaped values * @param {Actor} actor * @param {string} key * @returns {number|null} * @private */ _getDerivedStatMax(actor, key) { const raw = actor?.system?.derivedStats?.[key]; if (!raw || typeof raw !== "object") return null; const max = Number(raw?.max); return Number.isFinite(max) ? max : null; } /** * Build derived stat update payload while preserving existing data shape * @param {Actor} actor * @param {string} key * @param {number} value * @returns {object} * @private */ _buildDerivedStatUpdate(actor, key, value) { const nextValue = Number(value) || 0; const current = actor?.system?.derivedStats?.[key]; if (current && typeof current === "object") { return { [`system.derivedStats.${key}.value`]: nextValue }; } return { [`system.derivedStats.${key}`]: nextValue }; } /** * Clamp initiative modifier to allowed range * @param {number} value * @returns {number} * @private */ _clampInitiativeMod(value) { const mod = Number(value) || 0; return Math.max(-5, Math.min(5, mod)); } /** * Return active armor items; equipped armor is preferred if present * @param {Actor} actor * @returns {Item[]} * @private */ _getActiveArmorItems(actor) { const armors = (actor?.items || []).filter(i => i.type === "armor"); return armors.filter(i => i?.system?.equipped === true || i?.system?.isEquipped === true); } /** * Return drug items currently affecting the actor * @param {Actor} actor * @returns {Array<{item: Item, state: "active"|"withdrawal"}>} * @private */ _getCurrentDrugStates(actor) { const states = []; for (const item of actor?.items || []) { if (!(item.type === "equipment" && item.system?.isDrug === true)) continue; if (item.system?.active) { states.push({ item, state: "active" }); continue; } if (item.system?.withdrawalActive) { states.push({ item, state: "withdrawal" }); } } return states; } /** * Get aggregate drug modifiers for rolls * @param {Actor} actor * @param {object} opts * @param {string} [opts.attribute] * @param {string} [opts.skill] * @returns {{statMod:number, skillMod:number, total:number, injuryPenaltyIgnore:number}} * @private */ _getDrugRollModifiers(actor, { attribute = "", skill = "" } = {}) { const attrKey = String(attribute || "").toLowerCase(); const skillKey = String(skill || "").toLowerCase(); const isPhysicalAttribute = attrKey === "strength" || attrKey === "agility"; let statMod = 0; let skillMod = 0; let injuryPenaltyIgnore = 0; for (const state of this._getCurrentDrugStates(actor)) { const data = state.item.system || {}; const isWithdrawal = state.state === "withdrawal"; const statPhysical = Number(isWithdrawal ? data.wdStatPhysicalMod : data.statPhysicalMod) || 0; const skillAll = Number(isWithdrawal ? data.wdSkillAllMod : data.skillAllMod) || 0; const skillObj = isWithdrawal ? (data.wdSkillMods || {}) : (data.skillMods || {}); const specificSkill = Number(skillObj?.[skillKey] || 0); const injuryIgnore = Number(isWithdrawal ? data.wdInjuryPenaltyIgnore : data.injuryPenaltyIgnore) || 0; if (isPhysicalAttribute) statMod += statPhysical; if (skillKey) { skillMod += skillAll; skillMod += specificSkill; } injuryPenaltyIgnore += injuryIgnore; } return { statMod, skillMod, total: statMod + skillMod, injuryPenaltyIgnore }; } /** * Get panic reduction from active drugs * @param {Actor} actor * @returns {number} * @private */ _getDrugPanicReduction(actor) { let reduction = 0; for (const state of this._getCurrentDrugStates(actor)) { if (state.state !== "active") continue; reduction += Number(state.item.system?.panicReduction || 0); } return Math.max(0, reduction); } /** * Get racial dice-pool bonuses for a roll. * Reads `system.racialBonuses` array from specialty items of category "racial". * Also applies Frother Drug Dependency penalty (-1 all rolls when no drug active). * * @param {Actor} actor * @param {object} opts * @param {string} [opts.skill] - skill key being rolled (e.g. "melee") * @param {string} [opts.attribute] - attribute key being rolled * @param {string} [opts.weaponType] - weapon type string for weapon-gated bonuses (e.g. "Blade") * @returns {{ bonus: number, breakdown: string[] }} */ _getRacialDiceBonus(actor, { skill = "", attribute = "", weaponType = "" } = {}) { let bonus = 0; const breakdown = []; const sk = String(skill || "").toLowerCase(); const wt = String(weaponType || "").toLowerCase(); for (const item of actor.items) { if (item.type !== "specialty") continue; if (item.system?.category !== "racial") continue; const entries = item.system?.racialBonuses; if (!Array.isArray(entries)) continue; for (const entry of entries) { const entrySkill = String(entry.skill || "").toLowerCase(); if (entrySkill && sk && entrySkill !== sk) continue; // skill mismatch const tag = String(entry.weaponTag || "").toLowerCase(); if (tag) { if (!wt) continue; // need weapon context if (!wt.includes(tag) && !tag.includes(wt)) continue; // weapon type mismatch } const b = Number(entry.bonus) || 0; if (b === 0) continue; bonus += b; breakdown.push(`${item.name} ${b > 0 ? '+' : ''}${b}`); } } // Frother Drug Dependency: -1 to ALL rolls when no drug is active const hasDrugDep = actor.items.some( i => i.type === "specialty" && i.name === "Drug Dependency" ); if (hasDrugDep) { const drugActive = actor.items.some( i => (i.system?.isDrug || i.type === "drug") && i.system?.active === true ); if (!drugActive) { bonus -= 1; breakdown.push("Drug Withdrawal −1"); } } return { bonus, breakdown }; } /** * Get aggregate armor roll modifiers for physical rolls * @param {Actor} actor * @param {object} opts * @param {string} [opts.attribute] * @param {string} [opts.skill] * @returns {{statMod:number, skillMod:number, total:number}} * @private */ _getArmorRollModifiers(actor, { attribute = "", skill = "" } = {}) { const attrKey = String(attribute || "").toLowerCase(); const skillKey = String(skill || "").toLowerCase(); let statMod = 0; let skillMod = 0; for (const armor of this._getActiveArmorItems(actor)) { const statTarget = String(armor?.system?.statModTarget || "").toLowerCase(); const skillTarget = String(armor?.system?.skillModTarget || "").toLowerCase(); if (statTarget && statTarget === attrKey) { statMod += Number(armor?.system?.statMod || 0); } if (skillTarget && skillTarget === skillKey) { skillMod += Number(armor?.system?.skillMod || 0); } } return { statMod, skillMod, total: statMod + skillMod }; } /** * Get armor dice/auto values from item, with legacy fallback support * @param {Item} armorItem * @returns {{armorDice:number, autoArmor:number}} * @private */ _getArmorProtectionValues(armorItem) { const system = armorItem?.system || {}; const legacyArmor = Number(system.armorRating || system.armor || 0); const armorDice = Math.max(0, Number(system.armorDice ?? legacyArmor) || 0); const autoArmor = Math.max(0, Number(system.armorAuto ?? 0) || 0); return { armorDice, autoArmor }; } /** * Prompt for incoming damage/AP and roll armor check * @param {Actor} actor * @returns {Promise} * @private */ async _promptAndRollArmorCheck(actor, preselectedArmorId = null) { const allArmors = this._getActiveArmorItems(actor); const preselectedArmor = preselectedArmorId ? actor.items.get(preselectedArmorId) : null; if (preselectedArmor && !(preselectedArmor?.system?.equipped === true || preselectedArmor?.system?.isEquipped === true)) { ui.notifications.warn(`${preselectedArmor.name} is not equipped.`); return; } const armors = preselectedArmor ? [preselectedArmor] : allArmors; if (armors.length === 0) { ui.notifications.warn(`${actor.name} has no armor items.`); return; } const options = armors.map((a, idx) => { const prot = this._getArmorProtectionValues(a); return ``; }).join(""); const config = await new Promise(resolve => { new Dialog({ title: `${actor.name} - Armor Check`, content: `
${preselectedArmor ? `
${preselectedArmor.name} (${this._getArmorProtectionValues(preselectedArmor).armorDice}(${this._getArmorProtectionValues(preselectedArmor).autoArmor}))
` : `
`}
`, buttons: { roll: { label: "Roll Armor", callback: html => { const incomingDamage = Math.max(0, Number(html.find('[name="incomingDamage"]').val()) || 0); const ap = Math.max(0, Number(html.find('[name="ap"]').val()) || 0); const armorIndex = preselectedArmor ? 0 : Math.max(0, Number(html.find('[name="armorIndex"]').val()) || 0); resolve({ incomingDamage, ap, armorItem: armors[armorIndex] }); } }, cancel: { label: "Cancel", callback: () => resolve(null) } }, default: "roll", close: () => resolve(null) }, { classes: ["slazero2", "dialog"] }).render(true); }); if (!config) return; await this._rollArmorCheck(actor, config); } /** * Roll armor check and apply resulting health damage * @param {Actor} actor * @param {{incomingDamage:number, ap:number, armorItem:Item}} config * @returns {Promise} * @private */ async _rollArmorCheck(actor, { incomingDamage = 0, ap = 0, armorItem = null } = {}) { if (!armorItem) return; const { armorDice, autoArmor } = this._getArmorProtectionValues(armorItem); const incoming = Math.max(0, Number(incomingDamage) || 0); const apValue = Math.max(0, Number(ap) || 0); const effectiveDice = Math.max(0, armorDice - apValue); const afterAuto = Math.max(0, incoming - autoArmor); let armorSuccesses = 0; let armorRoll = null; if (effectiveDice > 0) { armorRoll = await (new Roll(`${effectiveDice}d6`)).evaluate(); if (game.dice3d) { await dsnSafeShow(armorRoll); } armorSuccesses = (armorRoll.dice?.[0]?.results || []).filter(r => r.result === 6).length; } const finalDamage = Math.max(0, afterAuto - armorSuccesses); const currentHealth = this._getDerivedStatValue(actor, "health"); const nextHealth = Math.max(0, currentHealth - finalDamage); const updates = this._buildDerivedStatUpdate(actor, "health", nextHealth); if (nextHealth <= 0) updates["system.derivedStats.broken"] = true; if (finalDamage > 0 || nextHealth <= 0) { await actor.update(updates); } const armorDieFaces = armorRoll ? (armorRoll.dice?.[0]?.results || []).map(r => `${r.result}` ).join('') : ''; const armorRollLine = effectiveDice > 0 ? `Armor roll: ${effectiveDice}d6 ${armorDieFaces} → ${armorSuccesses} success${armorSuccesses !== 1 ? "es" : ""}` : "Armor roll: 0d6 (no armor dice after AP)"; const content = `
Armor Check - ${armorItem.name}
Incoming damage: ${incoming}
Armor value: ${armorDice}(${autoArmor})
AP: ${apValue} (dice reduced to ${effectiveDice})
Auto armor: ${autoArmor} (damage after auto: ${afterAuto})
${armorRollLine}
Final damage: ${finalDamage}
Health: ${currentHealth} -> ${nextHealth}
`; await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), flavor: `${actor.name} - Armor Check`, content: content, rolls: armorRoll ? [armorRoll] : [] }); } /** * Read stress from actor data while supporting scalar and legacy object shapes * @param {Actor} actor * @returns {number} * @private */ _getStressValue(actor) { return Math.max(0, this._getDerivedStatValue(actor, "stress")); } /** * Build stress update payload while preserving existing data shape * @param {Actor} actor * @param {number} value * @returns {object} * @private */ _buildStressUpdate(actor, value) { return this._buildDerivedStatUpdate(actor, "stress", Math.max(0, Number(value) || 0)); } /** * Roll Year SLA Zero Two dice with dialog * @param {number} baseDice - Base number of dice to roll * @param {string} label - Label for the roll * @param {string} formula - Formula string to display * @param {string} attribute - Which attribute (for push cost) * @param {object|null} weaponInfo - Optional weapon data {name, damage, ap, range, rof, category, weaponType} * @private */ async _rollYZEDice(baseDice, label, formula = '', attribute = '', weaponInfo = null, rollOptions = {}) { let modifiedDice = baseDice; if (!rollOptions.skipDialog) { // Show dialog to modify dice pool modifiedDice = await YZEDiceDialog.show(baseDice, label, formula, { actorId: this.actor?.id, actorName: this.actor?.name }); if (modifiedDice === null) return; // Dialog was cancelled } else if (typeof rollOptions.preModifiedDice === "number") { modifiedDice = rollOptions.preModifiedDice; } // Costs (e.g. Flux) are only paid once the roll is confirmed if (typeof rollOptions.onConfirm === "function") { await rollOptions.onConfirm(); } const normalDice = Math.max(0, parseInt(modifiedDice) || 0); const stressDice = this._getStressValue(this.actor); // Roll normal + stress dice together (stress dice are always the last term) const stressTerm = stressDice > 0 ? ` + ${stressDice}d6` : ''; const roll = new Roll(`${normalDice}d6${stressTerm}`, {}); await roll.evaluate(); // Show Dice So Nice animation if available if (game.dice3d) { this._applyDiceSoNiceColors(roll, normalDice, stressDice); await dsnSafeShow(roll); } const ebbInfo = rollOptions.ebbInfo || null; const { normalResults, stressResults } = this._splitRollResultsByType(roll, { normalDice, stressDice }); const dice = this._buildDiceArray(normalResults, stressResults); const successes = dice.filter(d => d.result === 6).length; const stressBanes = dice.filter(d => d.type === 'stress' && d.result === 1).length; // Auto-apply ebb mechanical effects where possible if (ebbInfo) { await this._applyEbbEffect(ebbInfo, successes); } // Create custom chat message with YZE formatting const stressLabel = stressDice > 0 ? ` + Stress ${stressDice}` : ''; const stressTag = stressDice > 0 ? ` Stress: ${stressDice}` : ''; const formulaDisplay = formula ? `${formula}${stressLabel}${stressTag}` : `Rolling ${normalDice}d6${stressLabel}${stressTag}`; const _speaker = ChatMessage.getSpeaker({ actor: this.actor }); const chatData = { speaker: _speaker, flavor: `${label}
${formulaDisplay}`, content: await this._formatYZEResult(dice, successes, stressBanes, false, weaponInfo, ebbInfo), style: CONST.CHAT_MESSAGE_STYLES?.ROLL, type: CONST.CHAT_MESSAGE_TYPES?.ROLL, roll: roll, rollMode: game.settings.get('core', 'rollMode'), flags: { 'slazero2': { canPush: dice.some(d => d.result !== 6), pushed: false, normalResults: normalResults, stressResults: stressResults, normalDiceCount: normalDice, stressDiceCount: stressDice, originalSuccesses: successes, isAttack: !!weaponInfo, attackCategory: weaponInfo?.category || null, attribute: attribute, actorId: this.actor.id, tokenId: _speaker.token ?? null, sceneId: _speaker.scene ?? null, weaponInfo: weaponInfo, ebbInfo: ebbInfo } } }; return ChatMessage.create(chatData); } /** * Format YZE roll result for chat * @param {Array} dice - Array of dice results * @param {number} successes - Number of successes (6s) * @param {number} stressBanes - Number of stress banes (1s on stress dice) * @param {boolean} pushed - Whether this is a pushed result * @param {object|null} weaponInfo - Optional weapon data {name, damage, ap, range, rof, category, weaponType} * @private */ async _formatYZEResult(dice, successes, stressBanes, pushed = false, weaponInfo = null, ebbInfo = null) { let html = '
'; if (pushed) { html += '
⚡ PUSHED RESULT
'; } if (pushed && stressBanes > 0) { html += '
PANIC CHECK!
'; } // Show individual dice const stressDiceCount = dice.filter(d => d.type === 'stress').length; const normalDice = dice.filter(d => d.type !== 'stress'); const stressDice = dice.filter(d => d.type === 'stress'); html += `
`; normalDice.forEach(d => { const value = d.result; const dieType = d.type || 'normal'; let diceClass = 'yze-die'; if (dieType === 'stress') diceClass += ' yze-stress'; if (d.locked) diceClass += ' yze-locked'; if (value === 6) diceClass += ' yze-success'; html += `
${value}
`; }); if (stressDiceCount > 0) { html += '
'; html += '
STRESS DICE
'; stressDice.forEach(d => { const value = d.result; const dieType = d.type || 'stress'; let diceClass = 'yze-die'; if (dieType === 'stress') diceClass += ' yze-stress'; if (d.locked) diceClass += ' yze-locked'; if (value === 6) diceClass += ' yze-success'; else if (value === 1) diceClass += ' yze-bane'; html += `
${value}
`; }); } html += '
'; // Summary - always show full breakdown on pushed results html += '
'; html += `${successes} Success${successes !== 1 ? 'es' : ''}`; if (pushed || stressBanes > 0) { html += ` | ${stressBanes} Stress Bane${stressBanes !== 1 ? 's' : ''}`; if (!pushed && stressBanes > 0) { html += ` (no effect unless pushed or GM calls Panic)`; } } html += '
'; // Weapon damage section (shown when this is a weapon attack roll) if (weaponInfo) { const extraSuccesses = Math.max(0, successes - 1); // 1 success is spent to hit const modeDamageBonus = weaponInfo.modeDamageBonus || 0; const ammoDamageBonus = weaponInfo.ammoDamageBonus || 0; const totalDamage = weaponInfo.damage + extraSuccesses + modeDamageBonus + ammoDamageBonus; html += '
'; html += `
${weaponInfo.name}
`; html += '
'; if (successes > 0) { html += `
Total Damage${totalDamage}
`; html += `
(Base ${weaponInfo.damage}` + `${extraSuccesses > 0 ? ` + ${extraSuccesses} extra success${extraSuccesses !== 1 ? 'es' : ''}` : ''}` + `${modeDamageBonus > 0 ? ` + ${modeDamageBonus} mode` : ''}` + `${ammoDamageBonus > 0 ? ` + ${ammoDamageBonus} ammo` : ''}` + `)
`; } else { html += `
MISS
`; } if (weaponInfo.fireMode || weaponInfo.ammoType) { html += `
Mode: ${weaponInfo.fireMode || 'single'} | Ammo: ${weaponInfo.ammoType || 'standard'}
`; } if (weaponInfo.ammoSpent) { const calLabel = weaponInfo.caliber ? ` [${weaponInfo.caliber} ${weaponInfo.roundType || 'std'}]` : ''; const taxLine = weaponInfo.bulletTaxPaid > 0 ? `💸 Bullet Tax: −${weaponInfo.bulletTaxPaid}c` : ''; html += `
Rounds fired: ${weaponInfo.ammoSpent}${calLabel}${taxLine ? ' · ' + taxLine : ''}
`; } html += `
`; html += `AP ${weaponInfo.apEffective ?? weaponInfo.ap}`; html += `Range ${weaponInfo.range}`; html += `ROF ${weaponInfo.rof}`; html += `Type ${weaponInfo.category}`; if (weaponInfo.category === 'Ranged' && weaponInfo.magazine > 0) { html += `Ammo ${weaponInfo.ammo}/${weaponInfo.magazine}`; } html += `
`; html += '
'; html += '
'; } // EBB formula outcome block if (ebbInfo) { const ebbSucceeded = successes >= ebbInfo.requiredSuccesses; const discLabel = ebbInfo.discipline ? ebbInfo.discipline.charAt(0).toUpperCase() + ebbInfo.discipline.slice(1) : "Ebb"; html += `
`; html += `
`; html += `${discLabel}`; if (ebbSucceeded) { html += `✓ ACTIVATED`; } else { html += `✗ FAILED — need ${ebbInfo.requiredSuccesses} success${ebbInfo.requiredSuccesses !== 1 ? 'es' : ''}`; } html += `
`; if (ebbSucceeded && ebbInfo.effect) { html += `
${ebbInfo.effect}
`; if (ebbInfo.range || ebbInfo.duration) { html += `
`; if (ebbInfo.range) html += `Range ${ebbInfo.range} `; if (ebbInfo.duration) html += `Duration ${ebbInfo.duration}`; html += `
`; } } else if (!ebbSucceeded) { html += `
Formula failed to activate. Flux was spent.
`; } if (pushed && stressBanes > 0 && ebbInfo.catastrophe) { html += `
⚠ CATASTROPHE: ${ebbInfo.catastrophe}
`; } html += `
`; } // Push button (only shown if not pushed yet and not all 6s) if (!pushed && dice.some(d => d.result !== 6)) { html += '
'; html += ''; html += '
Keep 6s. Re-roll others. Stress 1s are locked. +1 Stress die on push.
'; html += '
'; } html += '
'; return html; } /** * Handle Push button clicks from chat * @param {ChatMessage} message - The chat message containing the roll * @private */ async _onPushRoll(message) { const flags = message.flags['slazero2']; if (!flags || flags.pushed) { ui.notifications.warn("This roll has already been pushed!"); return; } if (!flags.canPush) { ui.notifications.warn("This roll cannot be pushed!"); return; } // Resolve the correct actor — prefer the token actor (unlinked tokens have // separate data from the base world actor and must be updated via the token doc). let actor = null; if (flags.tokenId && flags.sceneId) { const scene = game.scenes?.get(flags.sceneId); const tokenDoc = scene?.tokens?.get(flags.tokenId); if (tokenDoc) actor = tokenDoc.actor; } if (!actor) actor = game.actors.get(flags.actorId); if (!actor) { ui.notifications.error("Actor not found!"); return; } const currentStress = this._getStressValue(actor); const updatedStress = currentStress + 1; // Get original dice results by type (fallback to legacy diceResults as normal dice) const originalNormal = Array.isArray(flags.normalResults) ? flags.normalResults : (Array.isArray(flags.diceResults) ? flags.diceResults : []); const originalStress = Array.isArray(flags.stressResults) ? flags.stressResults : []; // Lock 6s for all dice. Lock 1s for stress dice. const lockedNormalSixes = originalNormal.filter(d => d === 6); const lockedStressSixes = originalStress.filter(d => d === 6); const lockedStressOnes = originalStress.filter(d => d === 1); const normalRerollCount = originalNormal.filter(d => d !== 6).length; const stressRerollCount = originalStress.filter(d => d !== 6 && d !== 1).length; const addedStressDice = 1; // pushing adds +1 Stress die if ((normalRerollCount + stressRerollCount + addedStressDice) === 0) { ui.notifications.warn("All dice are 6s - nothing to re-roll!"); return; } // Roll new dice for non-locked results (normal + stress) const normalTerm = normalRerollCount > 0 ? `${normalRerollCount}d6` : ''; const stressTerm = (stressRerollCount + addedStressDice) > 0 ? `${stressRerollCount + addedStressDice}d6` : ''; const rerollFormula = [normalTerm, stressTerm].filter(Boolean).join(' + '); const reroll = new Roll(rerollFormula, {}); await reroll.evaluate(); // Show Dice So Nice animation if available if (game.dice3d) { this._applyDiceSoNiceColors(reroll, normalRerollCount, stressRerollCount + addedStressDice); await dsnSafeShow(reroll); } // Combine locked dice with new results const rerollResults = this._splitRollResultsByType(reroll, { normalDice: normalRerollCount, stressDice: stressRerollCount + addedStressDice }); const newNormalResults = [...lockedNormalSixes, ...rerollResults.normalResults]; const newStressResults = [...lockedStressSixes, ...lockedStressOnes, ...rerollResults.stressResults]; const newDice = [ ...newNormalResults.map(result => ({ result, type: 'normal' })), ...lockedStressOnes.map(result => ({ result, type: 'stress', locked: true })), ...lockedStressSixes.map(result => ({ result, type: 'stress' })), ...rerollResults.stressResults.map(result => ({ result, type: 'stress' })) ]; const successes = newDice.filter(d => d.result === 6).length; const stressBanes = newDice.filter(d => d.type === 'stress' && d.result === 1).length; const hasStressBane = stressBanes > 0; // Carry forward weaponInfo and ebbInfo if present const weaponInfo = flags.weaponInfo || null; const ebbInfo = flags.ebbInfo || null; // ── AMMO LOCKOUT: if a ranged weapon was pushed and a stress die shows 1, lock the weapon ─ if (weaponInfo && weaponInfo.category === "Ranged" && weaponInfo.itemId && stressBanes > 0) { const weaponItem = actor.items.get(weaponInfo.itemId); if (weaponItem) { await weaponItem.update({ 'system.ammoEmpty': true }); await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), content: `
🔒 ${weaponInfo.name} — OUT OF AMMO
A stress die came up 1 on the push — the weapon has run dry. Rest to restore ammunition.
` }); } } // Pushing adds +1 Stress immediately await actor.update(this._buildStressUpdate(actor, updatedStress)); const stressFlavor = `Stress increased: ${currentStress} → ${updatedStress} | +1 Stress die added`; const originalSuccesses = parseInt(flags.originalSuccesses) || 0; const totalSuccesses = originalSuccesses + successes; // Create pushed result chat card const chatData = { speaker: ChatMessage.getSpeaker({ actor }), flavor: `⚡ PUSHED ROLL
${stressFlavor}`, content: await this._formatYZEResult(newDice, successes, stressBanes, true, weaponInfo, ebbInfo), flags: { 'slazero2': { pushed: true, canPush: false, normalResults: newNormalResults, stressResults: newStressResults, actorId: actor.id, hasStressBanes: hasStressBane, originalSuccesses: originalSuccesses, totalSuccesses: totalSuccesses, weaponInfo: weaponInfo, ebbInfo: ebbInfo } } }; await ChatMessage.create(chatData); // Trigger Panic or Critical Failure if stress bane present (including locked 1s from initial roll) if (hasStressBane) { const isAttack = !!flags.isAttack; const attackCategory = (flags.attackCategory || weaponInfo?.category || '').toLowerCase(); const isRanged = attackCategory === 'ranged'; if (isAttack && totalSuccesses === 0) { ui.notifications.warn(`${actor.name} suffers a CRITICAL FAILURE!`); await this._rollWeaponCriticalFail(actor, isRanged); } else { ui.notifications.info(`${actor.name} rolled a Stress bane - Panic check triggered!`); await this._rollPanic(actor); } } } /** * Apply self-only panic consequences to actor resources * @param {Actor} actor * @param {object} panicEffect * @returns {Promise} * @private */ async _applyPanicConsequences(actor, panicEffect) { const changes = panicEffect?.selfChanges || {}; const labels = { health: "Health", resolve: "Resolve", stress: "Stress" }; const updates = {}; const applied = []; for (const key of ["health", "resolve", "stress"]) { const delta = Number(changes[key] || 0); if (!Number.isFinite(delta) || delta === 0) continue; const current = key === "stress" ? this._getStressValue(actor) : this._getDerivedStatValue(actor, key); let next = current + delta; if (key === "stress") { next = Math.max(0, next); } else { const max = this._getDerivedStatMax(actor, key); next = Math.max(0, next); if (max !== null) next = Math.min(next, max); } if (next === current) continue; Object.assign(updates, this._buildDerivedStatUpdate(actor, key, next)); applied.push(`${labels[key]} ${current} -> ${next} (${delta > 0 ? "+" : ""}${delta})`); } if (Object.keys(updates).length > 0) { await actor.update(updates); } return applied; } /** * Roll Panic check (1d6 + Stress) * @param {Actor} actor - The actor making the panic roll * @private */ async _rollPanic(actor) { const stress = this._getStressValue(actor); // Roll 1d6 + Stress const roll = new Roll(`1d6 + ${stress}`, {}); await roll.evaluate(); // Apply alien dice skin — force slazero2 system via appearance for (const term of (roll.dice || [])) { term.options = term.options || {}; term.options.appearance = { system: "slazero2" }; } const rawTotal = roll.total; const dieVal = roll.dice[0].results[0].result; const panicReduction = this._getDrugPanicReduction(actor); const total = panicReduction > 0 ? Math.max(7, rawTotal - panicReduction) : rawTotal; // Create panic result chat card let content = '
'; content += `

Rolled: ${dieVal} + ${stress} Stress = ${rawTotal}

`; if (panicReduction > 0) { content += `

Drug Panic Reduction: -${panicReduction} => ${total}

`; } if (total <= 6) { content += '
✓ Held it together! (No panic effect)
'; } else { const panicEffect = this._getPanicEffect(total); const appliedChanges = await this._applyPanicConsequences(actor, panicEffect); content += `
${panicEffect.name}: ${panicEffect.text}
`; if (appliedChanges.length > 0) { content += `
Auto-applied: ${appliedChanges.join(' | ')}
`; } if (panicEffect?.autoMentalCritical) { content += '
Auto: Rolling Mental Critical Injury.
'; } } content += '
'; ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), content: content, rolls: [roll] }); // Apply condition based on panic severity const panicLevel = total || 0; if (panicLevel >= 10) { await _applyStatusCondition(actor, 'sla-panicking', 'Panicking', 'icons/svg/terror.svg'); ui.notifications.warn(`${actor.name} is PANICKING! (-2 dice to all rolls)`); } else if (panicLevel >= 7) { await _applyStatusCondition(actor, 'sla-shaken', 'Shaken', 'icons/svg/terror.svg'); ui.notifications.warn(`${actor.name} is SHAKEN! (-1 die to all rolls)`); } if (total > 6) { const panicEffect = this._getPanicEffect(total); if (panicEffect?.autoMentalCritical) { await this._rollMentalCritical(actor); } } } async _rollInitiative(useMobility = true, preferredCombatantId = null, preferredCombatId = null) { // ── Guard: prevent double-calls within 2 seconds (DSN/tracker re-render race) ─ const lockKey = `_initLock_${this.actor?.id}`; if (window[lockKey]) return; window[lockKey] = true; setTimeout(() => { delete window[lockKey]; }, 2000); const actor = this.actor; const system = actor.system; // ── Mode: AGI + Mobility OR WIT + Observation ────────────────────────── const attrKey = useMobility ? "agility" : "wits"; const skillKey = useMobility ? "mobility" : "observation"; const modeLabel = useMobility ? "AGI + Mobility" : "WIT + Observation"; const attrVal = parseInt(system.attributes?.[attrKey]?.value) || 0; const skillVal = parseInt(system.skills?.[skillKey]?.value) || 0; // Base score is fixed — attribute + skill value const baseScore = attrVal + skillVal; // ── Condition penalties reduce the roll pool (conditions affect all rolls) ─ const initCondMods = _getConditionModifiers(actor, { attribute: attrKey, skill: skillKey }); // ── Roll the same pool for the bonus successes (no stress dice) ────────── const pool = Math.max(1, baseScore + initCondMods.bonus); const roll = new Roll(`${pool}d6`); await roll.evaluate(); if (game.dice3d) { await dsnSafeShow(roll); } const rollSuccesses = roll.dice[0].results.filter(r => r.result === 6).length; // ── Specialty bonuses ──────────────────────────────────────────────────── let specialtyBonus = 0; const specialtyBreakdown = []; for (const item of actor.items) { if (item.type !== "specialty") continue; // Fast Reflexes: +2 to initiative if (item.name === "Fast Reflexes") { specialtyBonus += 2; specialtyBreakdown.push("Fast Reflexes +2"); continue; } // Any specialty with an explicit initiativeBonus field const bonus = parseInt(item.system?.initiativeBonus) || 0; if (bonus !== 0) { specialtyBonus += bonus; specialtyBreakdown.push(`${item.name} ${bonus > 0 ? '+' : ''}${bonus}`); } } // ── Gear modifiers (equipped items with initiativeMod) ─────────────────── let gearBonus = 0; const gearBreakdown = []; for (const item of actor.items) { const mod = this._clampInitiativeMod(item?.system?.initiativeMod); if (mod === 0) continue; const isEquipped = item?.system?.equipped === true || item?.system?.isEquipped === true; if (!isEquipped) continue; gearBonus += mod; gearBreakdown.push(`${item.name} ${mod > 0 ? '+' : ''}${mod}`); } // ── Active effect modifiers ────────────────────────────────────────────── for (const e of (actor.effects || [])) { const effMod = e.getFlag('slazero2', 'initiativeMod'); if (effMod) { const em = Number(effMod) || 0; if (em !== 0) { gearBonus += em; gearBreakdown.push(`${e.name} ${em > 0 ? '+' : ''}${em}`); } } } // ── Drug modifiers ─────────────────────────────────────────────────────── const drugMods = this._getDrugRollModifiers(actor, { attribute: attrKey, skill: skillKey }); if (drugMods.total !== 0) { gearBonus += drugMods.total; gearBreakdown.push(`Drugs ${drugMods.total > 0 ? '+' : ''}${drugMods.total}`); } // ── Final initiative = base + roll successes + bonuses ─────────────────── const finalInit = baseScore + rollSuccesses + specialtyBonus + gearBonus; // ── Tie-breaker d6 (silent — no Roll object so Dice So Nice doesn't animate it) ─ const tieVal = Math.floor(Math.random() * 6) + 1; // ── Store on actor + push to combat tracker (single write, no yze flags) ─ await actor.update({ "system.combat.initiative": finalInit }); let combat = (preferredCombatId ? game.combats?.get(preferredCombatId) : null) || game.combat || null; let combatant = null; if (preferredCombatantId && combat) { combatant = combat.combatants.get(preferredCombatantId) || null; } if (!combatant) { const ctx = await this._ensureActorInEncounter(actor, preferredCombatId); combat = ctx?.combat || combat; combatant = ctx?.combatant || (combat?.getCombatantByActor(actor.id) ?? null); } if (combatant) { // Single update: set initiative + sync yze-combat display value in one write await combatant.update({ initiative: finalInit, "flags.yze-combat.cardValue": finalInit }); } if (combat) { if (typeof combat.setupTurns === "function") combat.setupTurns(); ui.combat?.render(false); } // ── Chat card ──────────────────────────────────────────────────────────── const diceHTML = roll.dice[0].results.map(r => `${r.result}` ).join(''); const attrLabel = attrKey.slice(0, 3).toUpperCase(); const skillLabel = skillKey.charAt(0).toUpperCase() + skillKey.slice(1); const totalBonus = specialtyBonus + gearBonus; const bonusLine = [...specialtyBreakdown, ...gearBreakdown].join(' · ') || '—'; const content = `
⚡ Initiative — ${modeLabel}
Base: ${attrLabel} ${attrVal} + ${skillLabel} ${skillVal} = ${baseScore}
Roll (${pool}d6): ${diceHTML}
Successes: +${rollSuccesses}
${totalBonus !== 0 ? `
Bonuses: ${totalBonus > 0 ? '+' : ''}${totalBonus} (${bonusLine})
` : ''} ${initCondMods.bonus !== 0 ? `
Conditions: ${initCondMods.bonus} (${initCondMods.breakdown.join(', ')})
` : ''}
Final Initiative: ${finalInit}
Tie-breaker d6: ${tieVal}
`; // No rolls:[roll] here — the card already shows individual die faces, // and omitting it prevents DSN from showing the dice a second time via // the chat message creation hook. await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), flavor: `${actor.name} — Initiative`, content }); return { roll, finalInit, tieVal }; } /** * Ensure actor is present in the current scene encounter if a token is available. * If there is no encounter on scene, create one. * @param {Actor} actor * @returns {Promise<{combat: Combat|null, combatant: Combatant|null}>} * @private */ async _ensureActorInEncounter(actor, preferredCombatId = null) { const scene = game.scenes?.current || canvas?.scene; if (!scene) return { combat: game.combat || null, combatant: game.combat?.getCombatantByActor(actor.id) || null }; let tokenDoc = null; const controlled = canvas?.tokens?.controlled?.find(t => t.actor?.id === actor.id); if (controlled?.document) { tokenDoc = controlled.document; } else { const placeable = canvas?.tokens?.placeables?.find(t => t.actor?.id === actor.id); tokenDoc = placeable?.document || null; } if (!tokenDoc) { return { combat: game.combat || null, combatant: game.combat?.getCombatantByActor(actor.id) || null }; } let combat = preferredCombatId ? (game.combats?.get(preferredCombatId) || null) : null; if (!combat) combat = game.combat; if (!combat || combat.scene?.id !== scene.id) { combat = game.combats?.find(c => c.scene?.id === scene.id && c.active) || game.combats?.find(c => c.scene?.id === scene.id) || null; } if (!combat) { combat = await Combat.create({ scene: scene.id, active: true }); } let combatant = combat.getCombatantByToken(tokenDoc.id) || combat.getCombatantByActor(actor.id); if (!combatant) { const actorInit = Number(actor.system?.combat?.initiative ?? 0) || 0; const created = await combat.createEmbeddedDocuments("Combatant", [{ tokenId: tokenDoc.id, actorId: actor.id, hidden: tokenDoc.hidden === true, initiative: actorInit, flags: { "yze-combat": { cardValue: actorInit, cardName: "INIT" } } }]); combatant = created?.[0] || combat.getCombatantByToken(tokenDoc.id) || null; } return { combat, combatant }; } /** * Roll critical failure table for weapon attacks * @param {Actor} actor * @param {boolean} isRanged * @private */ async _rollWeaponCriticalFail(actor, isRanged = true) { const tableName = isRanged ? "Ranged Weapon Critical Fail (2-12)" : "Melee Weapon Critical Fail (2-12)"; const table = game.tables.getName(tableName); if (!table) { ui.notifications.warn(`Critical fail table not found: ${tableName}`); return; } const roll = await (new Roll("2d6")).evaluate(); if (game.dice3d) { await dsnSafeShow(roll); } await table.draw({ roll, displayChat: true }); } /** * Split roll results by normal vs stress dice counts * @param {Roll} roll * @param {object} counts * @param {number} counts.normalDice * @param {number} counts.stressDice * @returns {{normalResults: number[], stressResults: number[]}} * @private */ _splitRollResultsByType(roll, { normalDice = 0, stressDice = 0 }) { const diceTerms = roll.dice || []; let normalResults = []; let stressResults = []; if (normalDice > 0 && stressDice > 0) { normalResults = (diceTerms[0]?.results || []).map(r => r.result); stressResults = (diceTerms[1]?.results || []).map(r => r.result); } else if (normalDice > 0) { normalResults = (diceTerms[0]?.results || []).map(r => r.result); } else if (stressDice > 0) { stressResults = (diceTerms[0]?.results || []).map(r => r.result); } return { normalResults, stressResults }; } /** * Build a unified dice array with type annotations for chat rendering * @param {number[]} normalResults * @param {number[]} stressResults * @returns {{result:number, type:string}[]} * @private */ _buildDiceArray(normalResults = [], stressResults = []) { return [ ...normalResults.map(result => ({ result, type: 'normal' })), ...stressResults.map(result => ({ result, type: 'stress' })) ]; } /** * Build Dice So Nice options to color stress dice * @param {Roll} roll * @param {number} normalDice * @param {number} stressDice * @returns {object} * @private */ _applyDiceSoNiceColors(roll, normalDice = 0, stressDice = 0) { const diceTerms = roll.dice || []; if (diceTerms.length === 0) return; let normalIndex = null; let stressIndex = null; if (normalDice > 0 && stressDice > 0) { normalIndex = 0; stressIndex = 1; } else if (normalDice > 0) { normalIndex = 0; } else if (stressDice > 0) { stressIndex = 0; } // Normal dice → yellow Alien RPG d6 (skull on 1, blank 2-5, success on 6) if (normalIndex !== null && diceTerms[normalIndex]) { diceTerms[normalIndex].options = diceTerms[normalIndex].options || {}; diceTerms[normalIndex].options.appearance = { system: "slazero2" }; } // Stress dice → black Alien RPG d6 (blank 1-5, success on 6) if (stressIndex !== null && diceTerms[stressIndex]) { diceTerms[stressIndex].options = diceTerms[stressIndex].options || {}; diceTerms[stressIndex].options.appearance = { system: "ze-stress-dice" }; } } /** * Derive immediate self-only stat changes from panic effect text * @param {string} text * @returns {object|null} * @private */ _derivePanicSelfChangesFromText(text = "") { const changes = {}; const sentences = String(text) .split(".") .map(s => s.trim()) .filter(Boolean); for (const sentence of sentences) { // Skip non-immediate or non-self effects. if (/(allies|ally|if you refuse|per round|until end of scene|gm chooses|choose:)/i.test(sentence)) { continue; } let match = sentence.match(/\bGain\s*\+?(\d+)\s*(Stress|Resolve|Health)\b/i); if (match) { const value = Number(match[1]) || 0; const stat = String(match[2] || "").toLowerCase(); if (value > 0 && stat) changes[stat] = (changes[stat] || 0) + value; continue; } match = sentence.match(/\b(Lose|Suffer)\s+(\d+)\s*(Stress|Resolve|Health)\b/i); if (match) { const value = Number(match[2]) || 0; const stat = String(match[3] || "").toLowerCase(); if (value > 0 && stat) changes[stat] = (changes[stat] || 0) - value; } } return Object.keys(changes).length ? changes : null; } /** * Get panic effect for a given total * @param {number} total - The panic roll total * @returns {Object} Panic effect details * @private */ _getPanicEffect(total) { const effects = { 7: { name: "Nervous Twitch", text: "Gain +1 Stress. All allies in Short range gain +1 Stress as your tension spikes the squad.", directive: "" }, 8: { name: "Tremble", text: "Until end of scene: -2 dice to all AGI-based rolls (Marksmanship, Mobility, Stealth).", directive: "tremble" }, 9: { name: "Fumble/Jam", text: "GM chooses one: drop held item; weapon jams (1 Fast action to clear); comms/camera mishandled (next related roll -2 dice).", directive: "" }, 10: { name: "Freeze", text: "Lose your next action. You may defend normally but cannot Push again until you act.", directive: "freeze" }, 11: { name: "Seek Cover", text: "Use next action moving to cover/safer position. If you refuse, take +1 Stress per round.", directive: "seekCover" }, 12: { name: "Outburst on Camera", text: "You shout/scream/swear. Lose next action; position revealed (enemies +2 dice to locate).", directive: "" }, 13: { name: "Flight Response", text: "Must retreat from threat for at least 1 round or one zone away. Cannot take Slow actions during retreat.", directive: "flight" }, 14: { name: "Operative Breakdown", text: "Choose: BERSERK (attack nearest creature next action, friend/foe) OR CATATONIC (incapacitated D6 rounds). Allies who witness gain +1 Stress.", directive: "breakdown" }, 15: { name: "Mind Fracture", text: "Immediately suffer a Mental Critical Injury (rolling on Mental Critical Table now).", directive: "", autoMentalCritical: true } }; const effectTotal = Math.min(total, 15); const effect = effects[effectTotal] || effects[15]; const derivedChanges = this._derivePanicSelfChangesFromText(effect?.text || ""); if (derivedChanges) { return { ...effect, selfChanges: { ...(effect.selfChanges || {}), ...derivedChanges } }; } return effect; } /** * Roll Mental Critical Injury (D66) * @param {Actor} actor - The actor suffering the critical * @private */ async _rollMentalCritical(actor) { // Roll D66 as single Roll so Dice So Nice animates both dice together const d66Roll = new Roll('1d6 + 1d6', {}); await d66Roll.evaluate(); // Apply alien dice skin — force slazero2 system via appearance for (const term of (d66Roll.dice || [])) { term.options = term.options || {}; term.options.appearance = { system: "slazero2" }; } const tens = d66Roll.dice[0].results[0].result; const ones = d66Roll.dice[1].results[0].result; const key = `${tens}.${ones}`; // Get critical from lookup const critical = this._getMentalCritical(key); // Add to actor's critical injuries const injuries = actor.system.criticalInjuries.mental || []; injuries.push({ key: key, name: critical.name, lethal: critical.lethal, timeLimit: critical.timeLimit || "", effects: critical.effects, recovery: critical.recovery, timestamp: Date.now() }); await actor.update({ 'system.criticalInjuries.mental': injuries }); // Create chat card let content = '
'; content += `

Rolled: ${tens}${ones} → ${tens}.${ones}

`; content += `

${critical.name}`; if (critical.lethal) { content += ' ☠️ LETHAL'; } content += '

'; if (critical.timeLimit) { content += `

Time Limit: ${critical.timeLimit}

`; } content += `

Effects: ${critical.effects}

`; content += `

Recovery: ${critical.recovery}

`; content += '
'; await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), flavor: `💔 MENTAL CRITICAL INJURY`, content: content, rolls: [d66Roll] }); // Auto-apply condition from this mental crit result const MENTAL_CRIT_CONDITIONS = { '1.1': ['sla-shaken'], '1.2': ['sla-shaken'], '1.3': ['sla-shaken'], '1.4': ['sla-shaken'], '1.5': ['sla-panicking'], '1.6': ['sla-panicking'], '2.1': ['sla-shaken'], '2.2': ['sla-panicking'], '2.3': ['sla-panicking'], '2.4': ['sla-shaken'], '2.5': ['sla-panicking'], '2.6': ['sla-panicking'], '3.1': ['sla-panicking'], '3.2': ['sla-panicking'], '3.3': ['sla-shaken'], '3.4': ['sla-panicking'], '3.5': ['sla-panicking'], '3.6': ['sla-panicking'], '4.1': ['sla-panicking'], '4.2': ['sla-panicking'], '4.3': ['sla-panicking'], '4.4': ['sla-panicking'], '4.5': ['sla-panicking'], '4.6': ['sla-panicking'], '5.1': ['sla-panicking'], '5.2': ['sla-panicking'], '5.3': ['sla-panicking'], '5.4': ['sla-panicking'], '5.5': ['sla-panicking'], '5.6': ['sla-panicking'], '6.1': [], '6.2': [], '6.3': [], '6.4': [], '6.5': [], '6.6': [] }; const mCondIds = MENTAL_CRIT_CONDITIONS[key] || []; for (const condId of mCondIds) { await _applyStatusCondition(actor, condId); } // If instant death if (critical.timeLimit === 'Instant') { ui.notifications.error(`${actor.name} has died from ${critical.name}!`); } } /** * Get mental critical injury details * @param {string} key - The D66 key (e.g., "3.4") * @returns {Object} Critical injury details * @private */ _getMentalCritical(key) { // Simplified lookup - in production, load from tables-data.json const criticals = { "1.1": { name: "Shaken", lethal: false, effects: "+1 Stress; -1 die to all rolls for 1 round.", recovery: "1 stretch" }, "1.2": { name: "Tunnel vision", lethal: false, effects: "-2 dice to Observation; you miss side details.", recovery: "1 shift" }, "1.3": { name: "Jitters", lethal: false, effects: "-1 die to AGI rolls until you rest a stretch.", recovery: "1 stretch" }, "1.4": { name: "Cold sweat", lethal: false, effects: "+1 Stress. -1 die on your next roll.", recovery: "1 stretch" }, "1.5": { name: "Freeze response", lethal: false, effects: "Lose your next action. May defend normally.", recovery: "1 round" }, "1.6": { name: "Camera fright", lethal: false, effects: "-2 dice while you know you are being filmed (on Mort: usually).", recovery: "1 shift" }, "2.1": { name: "Anxiety spiral", lethal: false, effects: "+1 Stress each shift until a safe rest stretch.", recovery: "D6 shifts" }, "2.2": { name: "Intrusive flashback", lethal: false, effects: "First action each scene is at -2 dice.", recovery: "D6 shifts" }, "2.3": { name: "Hypervigilance", lethal: false, effects: "Cannot rest to recover Stress; +1 Observation die (wired).", recovery: "D6 shifts" }, "2.4": { name: "Night terrors", lethal: false, effects: "Shift rest recovers no Resolve until treated.", recovery: "D6 shifts" }, "2.5": { name: "Paranoia", lethal: false, effects: "Treat every stranger as hostile; Persuasion rolls -2 dice.", recovery: "D6 shifts" }, "2.6": { name: "Compulsive ritual", lethal: false, effects: "Must complete your ritual before acting each scene or take +1 Stress.", recovery: "D6 shifts" }, "3.1": { name: "Rage trigger", lethal: false, effects: "When harmed or insulted: pass a Resolve save or retaliate next action.", recovery: "2D6 days" }, "3.2": { name: "Phobia (confined)", lethal: false, effects: "-2 dice while in tight spaces, tunnels, elevators.", recovery: "2D6 days" }, "3.3": { name: "Phobia (crowds)", lethal: false, effects: "-2 dice while pressed in a crowd; +1 Stress whenever you enter one.", recovery: "2D6 days" }, "3.4": { name: "Dissociation", lethal: false, effects: "On a failed roll, pass a Resolve save or lose your next action staring through the world.", recovery: "2D6 days" }, "3.5": { name: "Mutism", lethal: false, effects: "Cannot speak under pressure. Persuasion impossible; comms text-only.", recovery: "2D6 days" }, "3.6": { name: "Substance reliance", lethal: false, effects: "As Frother Drug Dependency: -1 die to all rolls with no drug active.", recovery: "2D6 days" }, "4.1": { name: "Major breakdown", lethal: false, effects: "Broken for D6 rounds; afterwards -2 dice to mental rolls for 1 day.", recovery: "D6 days" }, "4.2": { name: "Berserk fugue", lethal: false, effects: "Attack the nearest creature for D3 rounds — ally or enemy.", recovery: "D6 days" }, "4.3": { name: "Catatonia", lethal: false, effects: "Incapacitated D6 rounds; wake at 0 Stress.", recovery: "D6 days" }, "4.4": { name: "Identity confusion", lethal: false, effects: "Forget who you work for until rested. GM may redirect one action per scene.", recovery: "D6 days" }, "4.5": { name: "Hallucinations", lethal: false, effects: "GM narrates one false detail per scene; you believe it.", recovery: "D6 days" }, "4.6": { name: "Amnesia (recent)", lethal: false, effects: "Lose all memory of this shift, including the mission briefing.", recovery: "D6 days" }, "5.1": { name: "Permanent phobia", lethal: false, effects: "As Phobia, but only therapy or story resolution removes it.", recovery: "Permanent until treated" }, "5.2": { name: "Permanent tremor", lethal: false, effects: "-1 die to fine-motor tasks (Crafting, Healing, long-range shots). Permanent until treated.", recovery: "Permanent until treated" }, "5.3": { name: "Personality fracture", lethal: false, effects: "A second persona surfaces under Stress 5+; GM plays it for one action per scene.", recovery: "Permanent until treated" }, "5.4": { name: "Deathwish", lethal: false, effects: "You cannot voluntarily Seek Cover or retreat; Showing Off costs no dice (you no longer care).", recovery: "Permanent until treated" }, "5.5": { name: "Total burnout", lethal: false, effects: "Resolve max -1 until treated. LAD premiums double (flagged unstable).", recovery: "Permanent until treated" }, "5.6": { name: "Ebb-touched", lethal: false, effects: "You hear the Ebb. Non-Ebb users take +1 Stress in the presence of active formulae.", recovery: "Permanent until treated" }, "6.1": { name: "Heart seizure", lethal: true, timeLimit: "1 shift", effects: "Each shift make a Stamina roll; failure = death.", recovery: "—" }, "6.2": { name: "Stroke", lethal: true, timeLimit: "1 shift", effects: "-2 dice to everything. Each shift make a Stamina roll; failure = death.", recovery: "—" }, "6.3": { name: "Psychotic break", lethal: true, timeLimit: "1 day", effects: "GM controls the character. Medical intervention within a day or they are gone.", recovery: "—" }, "6.4": { name: "Aneurysm", lethal: true, timeLimit: "1 stretch", effects: "Collapse. Surgery within a stretch or death.", recovery: "—" }, "6.5": { name: "Total psychic collapse", lethal: true, timeLimit: "1 round", effects: "Resolve locked at 0. Stabilise (Healing, 2 successes) within a round or death.", recovery: "—" }, "6.6": { name: "Heart stops", lethal: true, timeLimit: "Instant", effects: "You die immediately.", recovery: "—" } }; return criticals[key] || { name: "Shaken", lethal: false, effects: "+1 Stress; -1 die to all rolls for 1 round.", recovery: "1 stretch" }; } /** * Roll Physical Critical Injury (D66) * @param {Actor} actor - The actor suffering the critical * @private */ async _rollPhysicalCritical(actor) { // Roll D66 as single Roll so Dice So Nice animates both dice together const d66Roll = new Roll('1d6 + 1d6', {}); await d66Roll.evaluate(); // Apply alien dice skin — force slazero2 system via appearance for (const term of (d66Roll.dice || [])) { term.options = term.options || {}; term.options.appearance = { system: "slazero2" }; } const tens = d66Roll.dice[0].results[0].result; const ones = d66Roll.dice[1].results[0].result; const key = `${tens}.${ones}`; // Get critical from lookup const critical = this._getPhysicalCritical(key); // Add to actor's critical injuries const injuries = actor.system.criticalInjuries.physical || []; injuries.push({ key: key, name: critical.name, lethal: critical.lethal, timeLimit: critical.timeLimit || "", effects: critical.effects, healing: critical.healing, timestamp: Date.now() }); await actor.update({ 'system.criticalInjuries.physical': 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 += `

Healing: ${critical.healing}

`; content += '
'; await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor }), flavor: `🩸 PHYSICAL CRITICAL INJURY`, content: content, rolls: [d66Roll] }); // Auto-apply condition from this crit result const PHYS_CRIT_CONDITIONS = { '1.1': ['sla-winded'], '1.2': ['sla-winded'], '1.3': ['sla-winded'], '1.4': ['sla-winded'], '1.5': ['sla-bleeding'], '1.6': ['sla-bleeding'], '2.1': ['sla-winded'], '2.2': ['sla-broken-arm'], '2.3': ['sla-bleeding'], '2.4': ['sla-broken-arm'], '2.5': ['sla-bleeding'], '2.6': ['sla-bleeding'], '3.1': ['sla-broken-leg'], '3.2': ['sla-broken-leg'], '3.3': ['sla-concussed'], '3.4': ['sla-broken-arm'], '3.5': ['sla-bleeding', 'sla-gut-wound'], '3.6': ['sla-gut-wound'], '4.1': ['sla-bleeding', 'sla-suppressed'], '4.2': ['sla-broken-arm'], '4.3': ['sla-bleeding'], '4.4': ['sla-gut-wound'], '4.5': ['sla-bleeding'], '4.6': ['sla-bleeding'], '5.1': ['sla-bleeding'], '5.2': ['sla-bleeding'], '5.3': ['sla-bleeding'], '5.4': ['sla-bleeding'], '5.5': ['sla-stunned'], '5.6': ['sla-stunned'], '6.1': [], '6.2': [], '6.3': [], '6.4': [], '6.5': [], '6.6': [] }; const condIds = PHYS_CRIT_CONDITIONS[key] || []; for (const condId of condIds) { await _applyStatusCondition(actor, condId); } // If instant death if (critical.timeLimit === 'Instant') { ui.notifications.error(`${actor.name} has died from ${critical.name}!`); } } /** * Get physical critical injury details * @param {string} key - The D66 key (e.g., "3.4") * @returns {Object} Critical injury details * @private */ _getPhysicalCritical(key) { // Simplified lookup - in production, load from tables-data.json const criticals = { "1.1": { name: "Winded", lethal: false, effects: "-1 die to all rolls; clears end of round.", healing: "1 round" }, "1.2": { name: "Bruised ribs", lethal: false, effects: "-1 die to STR rolls.", healing: "1 shift" }, "1.3": { name: "Bloody nose", lethal: false, effects: "Cosmetic and humiliating. -1 Persuasion until cleaned up.", healing: "1 stretch" }, "1.4": { name: "Sprained wrist", lethal: false, effects: "-1 die with two-handed weapons.", healing: "1 shift" }, "1.5": { name: "Shallow cut", lethal: false, effects: "Bleeding until bandaged (any healing action).", healing: "1 stretch" }, "1.6": { name: "Deep graze", lethal: false, effects: "Bleeding. -1 die on AGI rolls until treated.", healing: "1 shift" }, "2.1": { name: "Concussion (mild)", lethal: false, effects: "-1 die to WIT rolls.", healing: "D6 shifts" }, "2.2": { name: "Fractured hand", lethal: false, effects: "Broken Arm effects until splinted.", healing: "D6 shifts" }, "2.3": { name: "Gashed thigh", lethal: false, effects: "Bleeding. Movement halved until treated.", healing: "D6 shifts" }, "2.4": { name: "Broken arm", lethal: false, effects: "Arm unusable: -2 Melee, -1 Marksmanship, -1 Force.", healing: "D6 days" }, "2.5": { name: "Torn shoulder", lethal: false, effects: "Bleeding. -1 die on all STR and AGI rolls.", healing: "D6 shifts" }, "2.6": { name: "Lacerated back", lethal: false, effects: "Bleeding. Cannot wear rigid armour until treated.", healing: "D6 shifts" }, "3.1": { name: "Broken leg", lethal: false, effects: "Cannot run: -2 Mobility, -1 Stealth.", healing: "2D6 days" }, "3.2": { name: "Shattered kneecap", lethal: false, effects: "Broken Leg effects; -1 die on Mobility permanently until surgery.", healing: "2D6 days" }, "3.3": { name: "Concussion (severe)", lethal: false, effects: "Concussed: -1 all dice, extra -1 Observation/WIT.", healing: "2D6 days" }, "3.4": { name: "Crushed fingers", lethal: false, effects: "Broken Arm effects. Fine work (Crafting, Healing) at -2 dice.", healing: "2D6 days" }, "3.5": { name: "Punctured side", lethal: false, effects: "Bleeding + Gut Wound (-2 all dice) until stabilised.", healing: "2D6 days" }, "3.6": { name: "Ruptured gut", lethal: false, effects: "Gut Wound. Cannot benefit from drugs until treated.", healing: "2D6 days" }, "4.1": { name: "Sucking chest wound", lethal: false, effects: "Bleeding + Suppressed until stabilised; Stamina rolls at -2.", healing: "D6 weeks" }, "4.2": { name: "Shattered forearm", lethal: false, effects: "Broken Arm. Weapon dropped. Surgery required to fully heal.", healing: "D6 weeks" }, "4.3": { name: "Severed artery (arm)", lethal: false, effects: "Bleeding — lose 2 HP per turn instead of 1 until treated.", healing: "D6 weeks" }, "4.4": { name: "Perforated bowel", lethal: false, effects: "Gut Wound. Each shift untreated: Stamina roll or lose 1 HP.", healing: "D6 weeks" }, "4.5": { name: "Skull fracture", lethal: false, effects: "Bleeding + Concussed. KO'd on any further head hit.", healing: "D6 weeks" }, "4.6": { name: "Collapsed lung", lethal: false, effects: "Bleeding. Stamina and Mobility at -2 dice.", healing: "D6 weeks" }, "5.1": { name: "Internal bleeding", lethal: true, timeLimit: "1 day", effects: "Bleeding (hidden). Each shift: Stamina roll or lose 1 HP. Surgery ends it.", healing: "D6 weeks" }, "5.2": { name: "Crushed pelvis", lethal: true, timeLimit: "1 day", effects: "Bleeding. Cannot stand. Surgery within a day or death.", healing: "2D6 weeks" }, "5.3": { name: "Deep torso trauma", lethal: true, timeLimit: "1 shift", effects: "Bleeding. Stabilise (Healing, 2 successes) within a shift or death.", healing: "2D6 weeks" }, "5.4": { name: "Severed hand", lethal: false, effects: "Hand gone. Bleeding until cauterised. Cybernetics exist — for a price.", healing: "Permanent (prosthetic)" }, "5.5": { name: "Spinal damage", lethal: false, effects: "Stunned, then legs numb: Mobility 0 until surgery.", healing: "Permanent until surgery" }, "5.6": { name: "Throat wound", lethal: true, timeLimit: "1 stretch", effects: "Bleeding + cannot speak. Stabilise within a stretch or death.", healing: "2D6 weeks" }, "6.1": { name: "Severed artery (neck)", lethal: true, timeLimit: "1 round", effects: "Stabilise (Healing, 2 successes) within a round or death.", healing: "—" }, "6.2": { name: "Pierced heart", lethal: true, timeLimit: "1 round", effects: "Death next round unless a medic reaches you immediately.", healing: "—" }, "6.3": { name: "Crushed skull", lethal: true, timeLimit: "Instant", effects: "You die immediately. LAD collects the remains.", healing: "—" }, "6.4": { name: "Torn aorta", lethal: true, timeLimit: "Instant", effects: "You die immediately.", healing: "—" }, "6.5": { name: "Decapitating blow", lethal: true, timeLimit: "Instant", effects: "You die immediately. Closed-casket sponsorship footage.", healing: "—" }, "6.6": { name: "Obliterated", lethal: true, timeLimit: "Instant", effects: "You die immediately. There is not enough left for LAD.", healing: "—" } }; return criticals[key] || { name: "Winded", lethal: false, effects: "Lose next Fast action; -1 die to physical rolls for 1 round.", healing: "1 stretch" }; } } /** * Initialize the system */ Hooks.once('init', function() { console.log('SLA Zero Two | Initializing'); if (!ZERO_ENGINE_CORE_COMBAT.captured) { ZERO_ENGINE_CORE_COMBAT.combatClass = CONFIG.Combat?.documentClass ?? null; ZERO_ENGINE_CORE_COMBAT.combatantClass = CONFIG.Combatant?.documentClass ?? null; ZERO_ENGINE_CORE_COMBAT.combatTrackerClass = CONFIG.ui?.combat ?? null; ZERO_ENGINE_CORE_COMBAT.captured = true; } // Register Handlebars helpers for SLA Industries templates Handlebars.registerHelper('eq', function(a, b) { return a === b; }); Handlebars.registerHelper('gt', function(a, b) { return a > b; }); Handlebars.registerHelper('lt', function(a, b) { return a < b; }); Handlebars.registerHelper('lte', function(a, b) { return a <= b; }); Handlebars.registerHelper('contains', function(array, value) { if (!Array.isArray(array)) return false; return array.includes(value); }); Handlebars.registerHelper('concat', function(...args) { args.pop(); // Remove Handlebars options object return args.join(''); }); Handlebars.registerHelper('toUpperCase', function(str) { return String(str).toUpperCase(); }); Handlebars.registerHelper('capitalize', function(str) { if (!str) return ''; return String(str).charAt(0).toUpperCase() + String(str).slice(1); }); // Mk2 template helpers Handlebars.registerHelper('add', function(...args) { // last arg is Handlebars options hash — drop it const nums = args.slice(0, -1); return nums.reduce((sum, n) => sum + (Number(n) || 0), 0); }); Handlebars.registerHelper('minus', function(a, b) { return (Number(a) || 0) - (Number(b) || 0); }); Handlebars.registerHelper('times', function(n, options) { let out = ''; for (let i = 0; i < Number(n); i++) out += options.fn({ index: i }); return out; }); Handlebars.registerHelper('or', function(...args) { const opts = args[args.length - 1]; const vals = args.slice(0, -1); const result = vals.some(Boolean); // Block helper usage: {{#or a b}}...{{/or}} if (opts && typeof opts.fn === 'function') { return result ? opts.fn(this) : (typeof opts.inverse === 'function' ? opts.inverse(this) : ''); } // Inline helper usage: {{or a b}} return result; }); Handlebars.registerHelper('gte', function(a, b) { return Number(a) >= Number(b); }); Handlebars.registerHelper('not', function(val) { return !val; }); console.log('SLA Zero Two | Handlebars helpers registered'); // Register sheet application classes foundry.documents.collections.Actors.unregisterSheet("core", foundry.appv1.sheets.ActorSheet); foundry.documents.collections.Actors.registerSheet("slazero2", ZeroEngineActorSheet, { makeDefault: true, label: "SLA Zero Two Character Sheet" }); // Register item sheet foundry.documents.collections.Items.unregisterSheet("core", foundry.appv1.sheets.ItemSheet); foundry.documents.collections.Items.registerSheet("slazero2", ZeroEngineItemSheet, { makeDefault: true, label: "SLA Zero Two Item Sheet" }); // Register Mk2 dark sheet as an alternative (ZeroEngineActorSheetMk2 defined later in file; // Foundry calls this callback after the full script evaluates, so the class exists by then) foundry.documents.collections.Actors.registerSheet("slazero2", ZeroEngineActorSheetMk2, { types: ["character"], makeDefault: false, label: "SLA Zero Two — Mk2 (Dark)" }); console.log('SLA Zero Two | Actor and Item sheets registered'); // Register SLA combat conditions as Foundry status effects (token HUD icons) // SLA_CONDITIONS is defined later in the file but the module executes fully // before any Hook callbacks run, so the constant exists by the time this fires. Hooks.once("setup", () => { for (const cond of SLA_CONDITIONS) { const existing = CONFIG.statusEffects.find(e => e.id === cond.id); if (!existing) { CONFIG.statusEffects.push({ id: cond.id, name: cond.label, icon: cond.icon, label: cond.label }); } } console.log("SLA Zero Two | SLA combat conditions registered"); }); }); Hooks.once("setup", () => { // Apply after all module init hooks so card-based module overrides are reverted. restoreZeroEngineCoreCombatConfig(); ensureCombatantCompatibilityMethods(); }); /** * Ready hook */ // ── GM SCENE CONTROLS: SLA TOOLS ───────────────────────────────────────────── // Adds all SLA GM tool buttons to the scene controls (GM only). // Foundry v14: controls is an object; v13: controls is an array. Hooks.on("getSceneControlButtons", (controls) => { if (!game.user?.isGM) return; const SLA_TOOLS = [ { name: "sla-pc-status", title: "PC Status Board", icon: "fas fa-clipboard-list", fn: () => SLAGMStatusWindow.open() }, { name: "sla-credit", title: "Credit Distribution", icon: "fas fa-credit-card", fn: () => SLACreditDistributionTool.open() }, { name: "sla-ledger", title: "Shift Ledger", icon: "fas fa-book-open", fn: () => SLAShiftLedger.open() }, { name: "sla-threat", title: "NPC Threat Board", icon: "fas fa-skull", fn: () => SLANPCThreatBoard.open() }, { name: "sla-bpn", title: "BPN Tracker", icon: "fas fa-clipboard-check", fn: () => SLABPNTracker.open() }, { name: "sla-condapp", title: "Condition Applicator", icon: "fas fa-bolt", fn: () => SLAConditionApplicator.open() }, { name: "sla-bpn-gen", title: "Generate BPN", icon: "fas fa-file-signature", fn: () => SLABPNGenerator.open() }, ]; // Foundry v14: controls is a plain object keyed by group name if (controls && !Array.isArray(controls)) { const grp = controls.tokens ?? controls.token ?? null; if (grp) { grp.tools ??= {}; for (const t of SLA_TOOLS) { grp.tools[t.name] = { name: t.name, title: t.title, icon: t.icon, visible: true, button: true, toggle: false, onChange: t.fn }; } } return; } // Foundry v13 fallback: controls is an array const tokenGroup = controls.find(c => c.name === "token" || c.name === "tokens"); if (tokenGroup) { tokenGroup.tools ??= []; for (const t of SLA_TOOLS) { const entry = { name: t.name, title: t.title, icon: t.icon, visible: true, button: true, onChange: t.fn, onClick: t.fn }; if (Array.isArray(tokenGroup.tools)) tokenGroup.tools.push(entry); else tokenGroup.tools[t.name] = entry; } } }); Hooks.once('ready', function() { console.log('SLA Zero Two | Ready'); // Hourly PC condition report — whispered to GM (fires once per hour while Foundry is running) if (game.user?.isGM) { setInterval(() => _broadcastPCConditions(), 60 * 60 * 1000); } // ── ONE-TIME DATA SANITIZATION ──────────────────────────────────────── // Fix actors whose skill/flux values were corrupted by duplicate form inputs // (stored as "X,X" string). Safe to run every boot — skips clean actors. if (game.user?.isGM) { (async () => { for (const actor of game.actors ?? []) { const updates = {}; const skills = actor.system.skills ?? {}; for (const [key, skill] of Object.entries(skills)) { const raw = skill?.value; const isCorrupt = (typeof raw === 'string' && (raw.includes(',') || raw === 'NaN' || isNaN(Number(raw)))) || (typeof raw === 'number' && isNaN(raw)); if (isCorrupt) { updates[`system.skills.${key}.value`] = parseInt(raw) || 0; console.warn(`SLA Zero Two | Sanitizing corrupt skill ${key} on actor "${actor.name}": "${raw}" → ${updates[`system.skills.${key}.value`]}`); } } const rawFlux = actor.system.flux?.value; const fluxCorrupt = (typeof rawFlux === 'string' && (rawFlux.includes(',') || rawFlux === 'NaN' || isNaN(Number(rawFlux)))) || (typeof rawFlux === 'number' && isNaN(rawFlux)); if (fluxCorrupt) { updates['system.flux.value'] = parseInt(rawFlux) || 0; console.warn(`SLA Zero Two | Sanitizing corrupt flux on actor "${actor.name}": "${rawFlux}" → ${updates['system.flux.value']}`); } // Fix credits corrupted by the duplicate-input "500,500" bug const rawCredits = actor._source?.system?.details?.credits ?? actor.system?.details?.credits; const creditsCorrupt = (typeof rawCredits === 'string' && (rawCredits.includes(',') || rawCredits === 'NaN' || isNaN(Number(rawCredits)))) || (typeof rawCredits === 'number' && isNaN(rawCredits)); if (creditsCorrupt) { const fixed = typeof rawCredits === 'string' && rawCredits.includes(',') ? parseInt(rawCredits.split(',')[0]) || 0 : 0; updates['system.details.credits'] = fixed; console.warn(`SLA Zero Two | Sanitizing corrupt credits on actor "${actor.name}": "${rawCredits}" → ${fixed}`); } if (Object.keys(updates).length > 0) { await actor.update(updates); } } })(); } // Ensure ranged weapons have ammo/magazine/ammoType defaults (async () => { const disableYzeCardMode = async () => { if (!game.user?.isGM || !game.modules?.get("yze-combat")?.active) return; const safeSet = async (key, value) => { try { await game.settings.set("yze-combat", key, value); } catch (_err) {} }; await safeSet("initAutoDraw", false); await safeSet("initMessaging", false); await safeSet("initResetDeckOnCombatStart", false); await safeSet("resetEachRound", false); // Higher initiative should act first. await safeSet("initSortOrder", -1); }; await disableYzeCardMode(); const restoreStandardInitiative = async () => { const yzeCombatActive = game.modules?.get("yze-combat")?.active; if (!yzeCombatActive) return; restoreZeroEngineCoreCombatConfig(); ensureCombatantCompatibilityMethods(); for (const combat of game.combats ?? []) { const updates = []; for (const combatant of combat.combatants) { // Sync combatant initiative and yze-combat display to whatever is stored // on the actor (0 if never rolled). Use value-setting not key-deletion // to avoid deprecated "-=" Foundry v14 syntax. const actorInit = Number(combatant.actor?.system?.combat?.initiative ?? 0) || 0; updates.push({ _id: combatant.id, initiative: actorInit, "flags.yze-combat.cardValue": actorInit, "flags.yze-combat.cardName": "ZE INIT" }); } if (updates.length > 0) { await combat.updateEmbeddedDocuments("Combatant", updates); } } }; await restoreStandardInitiative(); // Force tracker to repaint so stale yze-combat values are replaced if (ui.combat) setTimeout(() => ui.combat.render(false), 500); const defaultMagazine = 10; const clampInitiativeMod = (value) => { const mod = Number(value) || 0; return Math.max(-5, Math.min(5, mod)); }; const clampDerivedMod = (value) => { const mod = Number(value) || 0; return Math.max(-10, Math.min(10, mod)); }; const updateWeapon = async (item) => { const data = item.system || {}; const isRanged = data.category === "ranged"; if (!isRanged) return; const updates = {}; if (!data.magazine || data.magazine <= 0) updates["system.magazine"] = defaultMagazine; const magValue = updates["system.magazine"] ?? data.magazine ?? 0; if (!data.ammo || data.ammo <= 0) updates["system.ammo"] = magValue; if (!data.ammoType) updates["system.ammoType"] = "standard"; if (!Array.isArray(data.fireModes) || data.fireModes.length === 0) updates["system.fireModes"] = ["single"]; if (data.autoAmmoUse === undefined || data.autoAmmoUse === null) updates["system.autoAmmoUse"] = 8; if (data.equipped === undefined || data.equipped === null) updates["system.equipped"] = false; if (Object.keys(updates).length > 0) { await item.update(updates); } }; const updateInitiativeMod = async (item) => { if (!["weapon", "armor", "equipment"].includes(item.type)) return; const data = item.system || {}; const current = data.initiativeMod; const next = clampInitiativeMod(current); if (current === undefined || current === null || Number(current) !== next) { await item.update({ "system.initiativeMod": next }); } if (item.system?.equipped === undefined || item.system?.equipped === null) { await item.update({ "system.equipped": false }); } }; const updateDerivedModFields = async (item) => { if (!["armor", "equipment", "specialty"].includes(item.type)) return; const data = item.system || {}; const currentHealth = data.healthMod; const currentResolve = data.resolveMod; const nextHealth = clampDerivedMod(currentHealth); const nextResolve = clampDerivedMod(currentResolve); const updates = {}; if (currentHealth === undefined || currentHealth === null || Number(currentHealth) !== nextHealth) { updates["system.healthMod"] = nextHealth; } if (currentResolve === undefined || currentResolve === null || Number(currentResolve) !== nextResolve) { updates["system.resolveMod"] = nextResolve; } if (Object.keys(updates).length > 0) { await item.update(updates); } }; const updateDrugModel = async (item) => { if (!(item.type === "equipment" && item.system?.isDrug === true)) return; const data = item.system || {}; const clampSmall = (value) => Math.max(-10, Math.min(10, Number(value) || 0)); const clampQty = (value) => Math.max(0, Number(value) || 0); const updates = {}; if (data.quantity === undefined || data.quantity === null || Number(data.quantity) < 0) updates["system.quantity"] = clampQty(data.quantity ?? 1); if (data.active === undefined || data.active === null) updates["system.active"] = false; if (data.withdrawalActive === undefined || data.withdrawalActive === null) updates["system.withdrawalActive"] = false; if (data.activeDuration === undefined || data.activeDuration === null) updates["system.activeDuration"] = "1 shift"; if (data.withdrawalDuration === undefined || data.withdrawalDuration === null) updates["system.withdrawalDuration"] = ""; const numericPaths = [ "healthMod", "healthHeal", "resolveMod", "statPhysicalMod", "skillAllMod", "panicReduction", "injuryPenaltyIgnore", "stressRecoveryBonus", "postUseStaminaDamage", "wdHealthMod", "wdResolveMod", "wdStatPhysicalMod", "wdSkillAllMod", "wdPanicReduction", "wdInjuryPenaltyIgnore", "wdStressRecoveryBonus", "wdPostUseStaminaDamage" ]; for (const key of numericPaths) { const next = clampSmall(data[key]); if (data[key] === undefined || data[key] === null || Number(data[key]) !== next) { updates[`system.${key}`] = next; } } const skillKeys = ["force", "melee", "stamina", "marksmanship", "mobility", "stealth", "crafting", "observation", "survival", "healing", "insight", "persuasion"]; for (const key of skillKeys) { const current = Number(data.skillMods?.[key] ?? 0); const next = clampSmall(current); if (data.skillMods?.[key] === undefined || current !== next) { updates[`system.skillMods.${key}`] = next; } const wdCurrent = Number(data.wdSkillMods?.[key] ?? 0); const wdNext = clampSmall(wdCurrent); if (data.wdSkillMods?.[key] === undefined || wdCurrent !== wdNext) { updates[`system.wdSkillMods.${key}`] = wdNext; } } if (Object.keys(updates).length > 0) { await item.update(updates); } }; const updateArmorModel = async (item) => { if (item.type !== "armor") return; const data = item.system || {}; const updates = {}; const legacyArmor = Number(data.armorRating || data.armor || 0); const normalizeStatTarget = (value, statMod) => { const raw = String(value || "").trim().toLowerCase(); if (raw === "strength" || raw === "agility" || raw === "") return raw; if (raw.includes("str")) return "strength"; if (raw.includes("agi")) return "agility"; // Legacy broad "physical" text: map to agility by default for mobility-heavy usage. if (raw.includes("physical")) return Number(statMod || 0) === 0 ? "" : "agility"; return ""; }; const normalizeSkillTarget = (value, skillMod) => { const raw = String(value || "").trim().toLowerCase(); if (raw === "force" || raw === "mobility" || raw === "stealth" || raw === "") return raw; if (raw.includes("force")) return "force"; if (raw.includes("mobility")) return "mobility"; if (raw.includes("stealth")) return "stealth"; return Number(skillMod || 0) === 0 ? "" : ""; }; if (data.armorDice === undefined || data.armorDice === null) updates["system.armorDice"] = Math.max(0, legacyArmor); if (data.armorAuto === undefined || data.armorAuto === null) updates["system.armorAuto"] = 0; if (data.statMod === undefined || data.statMod === null) updates["system.statMod"] = 0; if (data.skillMod === undefined || data.skillMod === null) updates["system.skillMod"] = 0; if (data.statModTarget === undefined || data.statModTarget === null) updates["system.statModTarget"] = ""; if (data.skillModTarget === undefined || data.skillModTarget === null) updates["system.skillModTarget"] = ""; if (data.healthMod === undefined || data.healthMod === null) updates["system.healthMod"] = 0; if (data.resolveMod === undefined || data.resolveMod === null) updates["system.resolveMod"] = 0; if (data.equipped === undefined || data.equipped === null) updates["system.equipped"] = false; const nextDice = Math.max(0, Number((updates["system.armorDice"] ?? data.armorDice ?? legacyArmor)) || 0); const nextAuto = Math.max(0, Number((updates["system.armorAuto"] ?? data.armorAuto ?? 0)) || 0); const nextStat = Math.max(-5, Math.min(5, Number((updates["system.statMod"] ?? data.statMod ?? 0)) || 0)); const nextSkill = Math.max(-5, Math.min(5, Number((updates["system.skillMod"] ?? data.skillMod ?? 0)) || 0)); const nextStatTarget = normalizeStatTarget((updates["system.statModTarget"] ?? data.statModTarget), nextStat); const nextSkillTarget = normalizeSkillTarget((updates["system.skillModTarget"] ?? data.skillModTarget), nextSkill); if (Number(data.armorDice ?? legacyArmor) !== nextDice) updates["system.armorDice"] = nextDice; if (Number(data.armorAuto ?? 0) !== nextAuto) updates["system.armorAuto"] = nextAuto; if (Number(data.statMod ?? 0) !== nextStat) updates["system.statMod"] = nextStat; if (Number(data.skillMod ?? 0) !== nextSkill) updates["system.skillMod"] = nextSkill; if (String(data.statModTarget ?? "") !== nextStatTarget) updates["system.statModTarget"] = nextStatTarget; if (String(data.skillModTarget ?? "") !== nextSkillTarget) updates["system.skillModTarget"] = nextSkillTarget; if (Number(data.armorRating ?? legacyArmor) !== nextDice) updates["system.armorRating"] = nextDice; if (Object.keys(updates).length > 0) { await item.update(updates); } }; for (const item of game.items) { await updateWeapon(item); await updateArmorModel(item); await updateInitiativeMod(item); await updateDerivedModFields(item); await updateDrugModel(item); } for (const actor of game.actors) { for (const item of actor.items) { await updateWeapon(item); await updateArmorModel(item); await updateInitiativeMod(item); await updateDerivedModFields(item); await updateDrugModel(item); } if (actor.type === "character") { await recalcDerivedStats(actor, { _zeAutoCriticalInternal: true }); } } // Ensure default armor templates exist as world items try { if (game.user?.isGM) { const armorResponse = await fetch("systems/slazero2/packs/armors.json"); if (armorResponse.ok) { const armorTemplates = await armorResponse.json(); for (const armorData of armorTemplates) { if (!armorData || armorData.type !== "armor" || !armorData.name) continue; const exists = game.items.some(i => i.type === "armor" && i.name === armorData.name); if (exists) continue; await Item.create(armorData); } } } } catch (err) { console.warn("SLA Zero Two | Failed to ensure default armor items", err); } // Ensure default drug templates exist as world items try { if (game.user?.isGM) { const drugResponse = await fetch("systems/slazero2/packs/drugs.json"); if (drugResponse.ok) { const drugTemplates = await drugResponse.json(); for (const drugData of drugTemplates) { if (!drugData || drugData.type !== "equipment" || !drugData.name) continue; const exists = game.items.some(i => i.type === "equipment" && i.system?.isDrug === true && i.name === drugData.name); if (exists) continue; await Item.create(drugData); } } } } catch (err) { console.warn("SLA Zero Two | Failed to ensure default drug items", err); } // Ensure default ebb formula items exist as world items (also patches img on existing items) try { if (game.user?.isGM) { const ebbResponse = await fetch("systems/slazero2/packs/ebb-formulae.json"); if (ebbResponse.ok) { const ebbFormulae = await ebbResponse.json(); for (const ebbData of ebbFormulae) { if (!ebbData || ebbData.type !== "ebb" || !ebbData.name) continue; const existing = game.items.find(i => i.type === "ebb" && i.name === ebbData.name); if (existing) { // Patch img if it doesn't match the pack (handles icon migrations) if (existing.img !== ebbData.img) { await existing.update({ img: ebbData.img }); } continue; } await Item.create(ebbData); } } } } catch (err) { console.warn("SLA Zero Two | Failed to ensure default ebb formula items", err); } // Ensure critical failure roll tables exist try { const response = await fetch("systems/slazero2/packs/tables-data.json"); if (response.ok) { const tablesData = await response.json(); const ensureTable = async (tableData) => { if (!tableData || !tableData.name || !Array.isArray(tableData.results)) return; if (game.tables.getName(tableData.name)) return; const results = tableData.results.map((r) => ({ type: CONST.TABLE_RESULT_TYPES.TEXT, text: r.text, range: r.range, weight: 1, drawn: false })); await RollTable.create({ name: tableData.name, formula: tableData.formula || "2d6", results: results }); }; await ensureTable(tablesData.rangedCriticalFail); await ensureTable(tablesData.meleeCriticalFail); } } catch (err) { console.warn("SLA Zero Two | Failed to ensure critical fail tables", err); } })(); }); Hooks.on("createCombatant", async (combatant, _data, _options, userId) => { if (game.user?.id !== userId) return; const actorInit = Number(combatant.actor?.system?.combat?.initiative ?? 0) || 0; const updates = { "flags.yze-combat.cardValue": actorInit }; if (Number(combatant.initiative ?? 0) !== actorInit) { updates.initiative = actorInit; } await combatant.update(updates); }); Hooks.on("renderCombatTracker", (app, html) => { const jq = html instanceof jQuery ? html : $(html); const viewedCombat = app?.viewed || game.combat; if (!viewedCombat) return; jq.find(".token-initiative .roll").each((_i, el) => { const btn = $(el); btn.off("click.zeroEngineNoCardInit"); btn.on("click.zeroEngineNoCardInit", ev => { ev.preventDefault(); ev.stopPropagation(); ui.notifications.info("Use AGI or WIT button for initiative."); return false; }); }); const rollForCombatant = async (combatant, useMobility) => { const actor = combatant?.actor; if (!actor) return; if (!game.user?.isGM && !actor.isOwner) return; const sheet = actor.sheet instanceof ZeroEngineActorSheet ? actor.sheet : new ZeroEngineActorSheet(actor); await sheet._rollInitiative(useMobility, combatant.id, viewedCombat.id); }; jq.find("li.combatant").each((_idx, li) => { const row = $(li); if (row.find(".ze-combatant-init-controls").length) return; const combatantId = String(row.data("combatant-id") || ""); if (!combatantId) return; const controls = $(`
${Number.isFinite(Number(viewedCombat.combatants.get(combatantId)?.initiative)) ? Number(viewedCombat.combatants.get(combatantId)?.initiative) : "-"}
`); const nameNode = row.find(".token-name").first(); if (nameNode.length) nameNode.append(controls); else row.append(controls); controls.find(".ze-row-init-btn").on("click", async ev => { ev.preventDefault(); ev.stopPropagation(); const mode = String($(ev.currentTarget).data("mode") || "agi"); const combatant = viewedCombat.combatants.get(combatantId); if (!combatant) return; await rollForCombatant(combatant, mode === "agi"); const refreshed = viewedCombat.combatants.get(combatantId); const nextVal = Number(refreshed?.initiative); controls.find(".ze-init-score").text(Number.isFinite(nextVal) ? String(nextVal) : "-"); app.render(false); }); }); }); // ══ SLA Zero Two — Dice So Nice: Alien RPG dice skin ══════════════════════════ // Normal pool dice: Alien yellow d6 (skull on 1, blank 2-5, success on 6). // Stress pool dice: Alien black d6 (blank 1-5, success on 6). // Two separate DSN systems so the presets don't overwrite each other. // Roll code forces the correct system via term.options.appearance — this // completely bypasses the user's saved DSN system preference. Hooks.once("diceSoNiceReady", (dice3d) => { // ── Colorsets ─────────────────────────────────────────────────────────────── dice3d.addColorset({ name: "ze-normal", description: "SLA Zero Two — Operative Dice (bone-white ink)", category: "SLA Zero Two", foreground: ["#f2f2ee"], background: ["#f2f2ee"], outline: "#000000", edge: "#b8b8b2", texture: "none", material: "metal", font: "SLA Borg UI", visibility: "visible" }); dice3d.addColorset({ name: "ze-stress", description: "SLA Zero Two — Stress Dice (black ink)", category: "SLA Zero Two", foreground: ["#f2f2ee"], background: ["#111111"], outline: "#e8e8e4", edge: "#222222", texture: "none", material: "metal", font: "SLA Borg UI", visibility: "visible" }); // ── Two systems — one per die type so presets don't overwrite each other ──── dice3d.addSystem({ id: "slazero2", name: "SLA Zero Two — Operative Dice" }, "preferred"); dice3d.addSystem({ id: "ze-stress-dice", name: "SLA Zero Two — Stress Dice" }); // ── Operative d6 (hazard yellow): numbered 1–5, hazard diamond on the 6 ──── // Face 1 is a plain numeral — on a push, 1s are what you pick up again. dice3d.addDicePreset({ type: "d6", labels: [ "systems/slazero2/assets/dice/sla/op1.png", "systems/slazero2/assets/dice/sla/op2.png", "systems/slazero2/assets/dice/sla/op3.png", "systems/slazero2/assets/dice/sla/op4.png", "systems/slazero2/assets/dice/sla/op5.png", "systems/slazero2/assets/dice/sla/op6.png", ], colorset: "ze-normal", system: "slazero2", }); // ── Stress d6 (black ink): WHITE SKULL FLASH-FRAME BANE on the 1, numbered 2–5, success 6 ─ dice3d.addDicePreset({ type: "d6", labels: [ "systems/slazero2/assets/dice/sla/st1-bane.png", "systems/slazero2/assets/dice/sla/st2.png", "systems/slazero2/assets/dice/sla/st3.png", "systems/slazero2/assets/dice/sla/st4.png", "systems/slazero2/assets/dice/sla/st5.png", "systems/slazero2/assets/dice/sla/st6.png", ], colorset: "ze-stress", system: "ze-stress-dice", }); }); /** * Handle Push button clicks in chat * Uses new automatic Push mechanics: re-roll all non-6s, Stress + Panic only if stress bane appears */ Hooks.on('renderChatMessageHTML', (message, html) => { // html is an HTMLElement in Foundry v14 — use native DOM, not jQuery html.querySelectorAll('.yze-push-button').forEach(btn => { btn.addEventListener('click', async (event) => { event.preventDefault(); const flags = message.flags?.['slazero2']; if (!flags || !flags.canPush || flags.pushed) { ui.notifications.warn('This roll cannot be pushed!'); return; } // Resolve actor via token (handles unlinked tokens correctly) let actor = null; if (flags.tokenId && flags.sceneId) { const scene = game.scenes?.get(flags.sceneId); const tokenDoc = scene?.tokens?.get(flags.tokenId); if (tokenDoc) actor = tokenDoc.actor; } if (!actor) actor = game.actors.get(flags.actorId); if (!actor || !actor.sheet) { ui.notifications.error('Actor not found!'); return; } await actor.sheet._onPushRoll(message); }); }); }); /** * Helper: read derived stat value from actor data (scalar or object shape) * @param {Actor} actor * @param {string} key * @returns {number} */ function getDerivedValue(actor, key) { const raw = actor?.system?.derivedStats?.[key]; const value = Number((typeof raw === "object" ? raw?.value : raw) ?? 0); return Number.isFinite(value) ? value : 0; } /** * Helper: trigger critical injury rolls without requiring rendered sheet state * @param {Actor} actor * @param {"physical"|"mental"} kind * @returns {Promise} */ // ── Crit dedup guard ────────────────────────────────────────────────────────── // Tracks when a crit was last fired per actor so auto-crit and manual button // can't both fire in quick succession for the same event. const _critLastFired = new Map(); // actorId → { physical?: ms, mental?: ms } function _critCooldownActive(actorId, kind, windowMs = 8000) { const entry = _critLastFired.get(actorId) || {}; const last = entry[kind] || 0; return (Date.now() - last) < windowMs; } function _critRecordFired(actorId, kind) { const entry = _critLastFired.get(actorId) || {}; entry[kind] = Date.now(); _critLastFired.set(actorId, entry); } async function runAutoCritical(actor, kind) { // Skip if a crit of this kind was already rolled very recently for this actor if (_critCooldownActive(actor.id, kind)) { console.warn(`SLA Zero Two | Auto-crit (${kind}) skipped for ${actor.name} — already fired within cooldown window.`); return; } _critRecordFired(actor.id, kind); const sheet = actor?.sheet; if (kind === "physical") { if (sheet && typeof sheet._rollPhysicalCritical === "function") { await sheet._rollPhysicalCritical(actor); return; } await ZeroEngineActorSheet.prototype._rollPhysicalCritical.call({ _getPhysicalCritical: ZeroEngineActorSheet.prototype._getPhysicalCritical }, actor); return; } if (sheet && typeof sheet._rollMentalCritical === "function") { await sheet._rollMentalCritical(actor); return; } await ZeroEngineActorSheet.prototype._rollMentalCritical.call({ _getMentalCritical: ZeroEngineActorSheet.prototype._getMentalCritical }, actor); } // Racial derived-stat modifiers — mirrors SLA_RACES healthMod/resolveMod. // v2.0.1: these were all zero in Zero Engine 1.7.6, silently dropping every // racial Health/Resolve bonus from actual play. Keys are canonical race keys. const RACE_DERIVED_MODS = { human: { health: 0, resolve: 0 }, ebon: { health: 0, resolve: 1 }, brainwaster: { health: 0, resolve: 0 }, stormer: { health: 2, resolve: 0 }, shaktar: { health: 1, resolve: 0 }, wraithraider: { health: 0, resolve: 1 }, frother: { health: 1, resolve: -1 }, vevaphon: { health: 2, resolve: -1 } }; /** Canonicalize a stored race string to a RACE_DERIVED_MODS / SLA_RACES key */ function canonicalRaceKey(raw) { const k = String(raw || "").toLowerCase().replace(/[\s-]/g, "").replace("313s", "").trim(); if (k === "wraith") return "wraithraider"; return k; } const TRAINING_DERIVED_MODS = { scout: { health: 0, resolve: 0 }, investigator: { health: 0, resolve: 0 }, corporate: { health: 0, resolve: 0 }, medic: { health: 0, resolve: 0 }, "ebon-investigator": { health: 0, resolve: 0 }, "brainwaster-operative": { health: 0, resolve: 0 } }; function toNumber(value, fallback = 0) { const num = Number(value); return Number.isFinite(num) ? num : fallback; } function clampNumber(value, min, max) { return Math.max(min, Math.min(max, toNumber(value, min))); } function getEffectDerivedMod(effect, stat) { if (!effect) return 0; const direct = toNumber(effect.getFlag("slazero2", `${stat}Mod`), 0); const drug = toNumber(effect.getFlag("slazero2", `drug${stat === "health" ? "Health" : "Resolve"}Mod`), 0); const legacy = toNumber(effect.getFlag("sla-yze", `${stat}Mod`), 0); return direct + drug + legacy; } function getSpecialtyDerivedMod(item, stat) { if (!item || item.type !== "specialty") return 0; if (item.system?.isActive === false) return 0; const explicit = toNumber(item.system?.[`${stat}Mod`], 0); if (explicit !== 0) return explicit; // Back-compat: existing True Grit entries may not have explicit numeric fields yet. if (stat === "health") { const name = String(item.name || "").trim().toLowerCase(); if (name === "true grit") return 1; const summary = String(item.system?.effects || "").toLowerCase(); if (summary.includes("maximum health")) return 1; } return 0; } function calculateHealthResolve(actor) { const attrs = actor?.system?.attributes || {}; const raceKey = canonicalRaceKey(actor?.system?.race); const trainingKey = String(actor?.system?.training || "").trim().toLowerCase(); const baseHealth = toNumber(attrs?.strength?.value, 0) + toNumber(attrs?.agility?.value, 0); const baseResolve = toNumber(attrs?.wits?.value, 0) + toNumber(attrs?.empathy?.value, 0); const raceMods = RACE_DERIVED_MODS[raceKey] || { health: 0, resolve: 0 }; const trainingMods = TRAINING_DERIVED_MODS[trainingKey] || { health: 0, resolve: 0 }; let equipmentHealth = 0; let equipmentResolve = 0; let specialtyHealth = 0; let specialtyResolve = 0; for (const item of actor.items || []) { if (item.type === "armor" || item.type === "equipment") { const equipped = item.system?.equipped === true || item.system?.isEquipped === true; if (!equipped) continue; equipmentHealth += toNumber(item.system?.healthMod, 0); equipmentResolve += toNumber(item.system?.resolveMod, 0); continue; } if (item.type === "specialty") { specialtyHealth += getSpecialtyDerivedMod(item, "health"); specialtyResolve += getSpecialtyDerivedMod(item, "resolve"); } } let drugHealth = 0; let drugResolve = 0; for (const item of actor.items || []) { if (!(item.type === "equipment" && item.system?.isDrug === true)) continue; if (item.system?.active) { drugHealth += toNumber(item.system?.healthMod, 0); drugResolve += toNumber(item.system?.resolveMod, 0); } else if (item.system?.withdrawalActive) { drugHealth += toNumber(item.system?.wdHealthMod, 0); drugResolve += toNumber(item.system?.wdResolveMod, 0); } } // Back-compat path: allow Active Effects to still contribute temporary modifiers. for (const effect of actor.effects || []) { drugHealth += getEffectDerivedMod(effect, "health"); drugResolve += getEffectDerivedMod(effect, "resolve"); } const healthMax = Math.max(0, baseHealth + raceMods.health + trainingMods.health + equipmentHealth + specialtyHealth + drugHealth); const resolveMax = Math.max(0, baseResolve + raceMods.resolve + trainingMods.resolve + equipmentResolve + specialtyResolve + drugResolve); return { healthMax, resolveMax, breakdown: { health: { base: baseHealth, race: raceMods.health, training: trainingMods.health, equipment: equipmentHealth, specialty: specialtyHealth, drug: drugHealth }, resolve: { base: baseResolve, race: raceMods.resolve, training: trainingMods.resolve, equipment: equipmentResolve, specialty: specialtyResolve, drug: drugResolve } } }; } function buildDerivedRecalcUpdate(actor) { if (!actor || actor.type !== "character") return null; const calc = calculateHealthResolve(actor); const currentHealth = getDerivedValue(actor, "health"); const currentResolve = getDerivedValue(actor, "resolve"); const currentHealthMax = toNumber(actor?.system?.derivedStats?.health?.max, currentHealth); const currentResolveMax = toNumber(actor?.system?.derivedStats?.resolve?.max, currentResolve); const nextHealthMax = Math.max(0, calc.healthMax); const nextResolveMax = Math.max(0, calc.resolveMax); const nextHealth = clampNumber(currentHealth, 0, nextHealthMax); const nextResolve = clampNumber(currentResolve, 0, nextResolveMax); const updates = {}; if (currentHealthMax !== nextHealthMax) updates["system.derivedStats.health.max"] = nextHealthMax; if (currentResolveMax !== nextResolveMax) updates["system.derivedStats.resolve.max"] = nextResolveMax; if (currentHealth !== nextHealth) updates["system.derivedStats.health.value"] = nextHealth; if (currentResolve !== nextResolve) updates["system.derivedStats.resolve.value"] = nextResolve; // Clear stale broken flag — if current HP is above 0, broken is no longer valid. // This prevents the legacy -2 dice penalty persisting on healed characters. if (actor.system?.derivedStats?.broken === true && nextHealth > 0) { updates["system.derivedStats.broken"] = false; } // NOTE: breakdown is NOT in template.json — do not persist it or Foundry v14 // schema validation will reset the entire derivedStats object (nuking stress, etc.) // Compute breakdown in getData() for display purposes only. return Object.keys(updates).length > 0 ? updates : null; } async function recalcDerivedStats(actor, updateOptions = {}) { const updates = buildDerivedRecalcUpdate(actor); if (!updates) return; await actor.update(updates, { _zeDerivedRecalcInternal: true, ...updateOptions }); } function shouldRecalcForActorUpdate(change) { if (!change || !change.system) return false; return [ "system.attributes.strength.value", "system.attributes.agility.value", "system.attributes.wits.value", "system.attributes.empathy.value", "system.race", "system.training" ].some(path => foundry.utils.hasProperty(change, path)); } Hooks.on("preUpdateActor", (actor, change, options) => { if (options?._zeDerivedRecalcInternal) return; options._zePrevHealth = getDerivedValue(actor, "health"); options._zePrevResolve = getDerivedValue(actor, "resolve"); }); Hooks.on("createActor", async (actor, options) => { if (actor?.type !== "character") return; if (options?._zeDerivedRecalcInternal) return; await recalcDerivedStats(actor); }); Hooks.on("updateActor", async (actor, change, options) => { if (actor?.type !== "character") return; if (options?._zeDerivedRecalcInternal) return; // Also clear stale broken flag whenever HP is updated to > 0 const hpChanged = foundry.utils.hasProperty(change, "system.derivedStats.health.value") || foundry.utils.hasProperty(change, "system.derivedStats.health"); if (hpChanged && actor.system?.derivedStats?.broken === true) { const currentHp = Number( typeof actor.system.derivedStats.health === "object" ? actor.system.derivedStats.health?.value : actor.system.derivedStats.health ) || 0; if (currentHp > 0) { await actor.update({ "system.derivedStats.broken": false }, { _zeDerivedRecalcInternal: true }); } } if (!shouldRecalcForActorUpdate(change)) return; await recalcDerivedStats(actor); // When race changes, auto-add any missing racial specialties for the new race if (foundry.utils.hasProperty(change, "system.race") && game.user?.isGM) { try { await _ensureRacialAbilities(actor); } catch(_) {} } }); Hooks.on("createItem", async (item, options) => { const actor = item?.actor; if (actor?.type !== "character") return; if (options?._zeDerivedRecalcInternal) return; if (!["armor", "equipment", "specialty"].includes(item.type)) return; await recalcDerivedStats(actor); }); Hooks.on("updateItem", async (item, change, options) => { const actor = item?.actor; if (actor?.type !== "character") return; if (options?._zeDerivedRecalcInternal) return; if (!["armor", "equipment", "specialty"].includes(item.type)) return; await recalcDerivedStats(actor); }); Hooks.on("deleteItem", async (item, options) => { const actor = item?.actor; if (actor?.type !== "character") return; if (options?._zeDerivedRecalcInternal) return; if (!["armor", "equipment", "specialty"].includes(item.type)) return; await recalcDerivedStats(actor); }); Hooks.on("createActiveEffect", async (effect, options) => { const actor = effect?.parent; if (actor?.documentName !== "Actor" || actor.type !== "character") return; if (options?._zeDerivedRecalcInternal) return; await recalcDerivedStats(actor); }); Hooks.on("updateActiveEffect", async (effect, change, options) => { const actor = effect?.parent; if (actor?.documentName !== "Actor" || actor.type !== "character") return; if (options?._zeDerivedRecalcInternal) return; await recalcDerivedStats(actor); }); Hooks.on("deleteActiveEffect", async (effect, options) => { const actor = effect?.parent; if (actor?.documentName !== "Actor" || actor.type !== "character") return; if (options?._zeDerivedRecalcInternal) return; await recalcDerivedStats(actor); }); Hooks.on("updateActor", async (actor, change, options) => { if (actor?.type !== "character") return; if (options?._zeDerivedRecalcInternal) return; if (options?._zeAutoCriticalInternal) return; const prevHealth = Number(options?._zePrevHealth); const prevResolve = Number(options?._zePrevResolve); if (!Number.isFinite(prevHealth) && !Number.isFinite(prevResolve)) return; const nextHealth = getDerivedValue(actor, "health"); const nextResolve = getDerivedValue(actor, "resolve"); const healthBroke = Number.isFinite(prevHealth) && prevHealth > 0 && nextHealth <= 0; const resolveBroke = Number.isFinite(prevResolve) && prevResolve > 0 && nextResolve <= 0; if (!healthBroke && !resolveBroke) return; const updateOptions = { _zeAutoCriticalInternal: true }; if (actor.system?.derivedStats?.broken !== true) { await actor.update({ "system.derivedStats.broken": true }, updateOptions); } if (healthBroke) { ui.notifications.warn(`${actor.name} is BROKEN and suffers a Physical Critical Injury!`); await runAutoCritical(actor, "physical"); } if (resolveBroke) { ui.notifications.warn(`${actor.name} is BROKEN and suffers a Mental Critical Injury!`); await runAutoCritical(actor, "mental"); } }); // Old showPushDialog function removed - now using automatic Push mechanics // ══════════════════════════════════════════════════════════════════════════════ // SLA INDUSTRIES — CHARACTER GENERATORS (appended to slazero2.mjs) // PC Creator (multi-step wizard, 7 races, 8 training packages) // NPC Creator (quick + full modes, 10 archetypes) // ══════════════════════════════════════════════════════════════════════════════ // ─── RACE DATA ─────────────────────────────────────────────────────────────── const SLA_RACES = { human: { art: 'systems/slazero2/assets/SLA_Assets/Species/Human.png', 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: { art: 'systems/slazero2/assets/SLA_Assets/Species/Ebon.png', 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: { art: 'systems/slazero2/assets/SLA_Assets/Species/Brain Waster.png', 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: { art: 'systems/slazero2/assets/SLA_Assets/Species/Stormer 313.png', 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: { art: 'systems/slazero2/assets/SLA_Assets/Species/Shaktar.png', 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: { art: 'systems/slazero2/assets/SLA_Assets/Species/Wraith Raider.png', 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: { art: 'systems/slazero2/assets/SLA_Assets/Species/Frother.png', 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: { art: 'systems/slazero2/assets/SLA_Assets/Species/Stormer 711.png', label: 'Stormer Vevaphon', icon: '🔀', flavor: "Sleight Industries' cancelled polymorphic bioweapon line. Shape-shifting combat chassis with three selectable morph forms. Every shift costs Instability — and enough shifts will shatter whoever they were.", baseAttrs: { strength: 3, agility: 3, wits: 2, empathy: 2 }, attrPoints: 5, attrCaps: { strength: 5, agility: 5, wits: 4, empathy: 3 }, skillPoints: 8, skillCap: 3, isEbbUser: false, healthMod: 2, resolveMod: -1 } }; // ─── TRAINING PACKAGES ─────────────────────────────────────────────────────── const SLA_TRAINING = { soldier: { label: 'Soldier', icon: '🎖️', flavor: 'Front-line combat. Point, shoot, advance.', skills: { melee: 2, marksmanship: 2, stamina: 1 }, specialty: 'Hard Hitter', armor: 'CAF Armor', weapons: ['pistol', 'rifle'] }, scout: { label: 'Scout', icon: '🎯', flavor: 'Reconnaissance and rapid infiltration.', skills: { stealth: 2, mobility: 2, observation: 1 }, specialty: 'Fast Reflexes', armor: 'Scout Armor', weapons: ['pistol', 'smg'] }, medic: { label: 'Combat Medic', icon: '🩺', flavor: 'Keeping the squad alive under fire.', skills: { healing: 2, insight: 1, observation: 2 }, specialty: 'Field Surgeon', armor: 'Downtown Jacket', weapons: ['pistol'], gear: ['Medical Kit'] }, tech: { label: 'Technical Operative', icon: '🔧', flavor: 'Field engineering, hacking, and repairs.', skills: { crafting: 3, observation: 1, survival: 1 }, specialty: 'Inquisitive', armor: 'Downtown Jacket', weapons: ['pistol'], gear: ['Tool Kit'] }, negotiator: { label: 'Negotiator', icon: '🗣️', flavor: 'Information, leverage, and social control.', skills: { persuasion: 2, insight: 2, observation: 1 }, specialty: 'Gut Feeling', armor: 'Downtown Jacket', weapons: ['pistol'], gear: ['Encrypted Comms'] }, hunter: { label: 'Hunter', icon: '🏹', flavor: 'Track, observe, eliminate. Quietly.', skills: { marksmanship: 2, stealth: 2, observation: 1 }, specialty: 'Sniper', armor: 'Scout Armor', weapons: ['rifle', 'pistol'] }, brawler: { label: 'Brawler', icon: '👊', flavor: 'Close quarters. No hesitation. Often Frother or Stormer.', skills: { force: 2, melee: 2, stamina: 1 }, specialty: 'True Grit', armor: 'CAF Armor', weapons: ['heavy_melee', 'pistol'] }, ebb_user: { label: 'Ebb Channeller', icon: '✨', flavor: 'The Ebb flows through and burns bright. Ebon and Brain Waster only.', skills: { ebb: 3, insight: 1, persuasion: 1 }, specialty: 'Ebb Sensitive', armor: 'Downtown Jacket', weapons: ['pistol'], // Race-differentiated starting formulae (selected at creation time) ebbEbon: [ 'Empathic Pulse', // awareness — sense emotions 'Danger Sense', // awareness — can't be surprised 'Mind-Link', // communicate — silent mental comms 'Calm', // heal — reduce stress 'Ebb Shield', // protect — defensive reaction 'Ebb Sight', // senses — detect Ebb users 'Surge', // enhance — attribute boost ], ebbBrainwaster: [ 'Ebb Bolt', // blast — direct damage 'Heat Touch', // thermal — melee Ebb damage 'Surge', // enhance — combat boost 'Push', // telekinesis — hurl targets 'Empathic Pulse', // awareness — sense surroundings 'Ebb Shield', // protect — defensive 'Iron Flesh', // enhance — automatic armour ], // Legacy fallback if race not detected ebb: ['Empathic Pulse', 'Ebb Bolt', 'Ebb Shield', 'Calm', 'Surge', 'Ebb Sight'], ebbOnly: true } }; // ─── NPC ARCHETYPES ────────────────────────────────────────────────────────── const SLA_NPC_TYPES = { civilian: { label: 'Civilian', threat: 1, attrs: {strength:2,agility:2,wits:2,empathy:2}, skills: {observation:1}, hp: 4, armor: 0, weapons: [] }, gang_grunt: { label: 'Gang Grunt', threat: 1, attrs: {strength:3,agility:2,wits:2,empathy:2}, skills: {melee:2,force:1}, hp: 5, armor: 1, weapons: ['pistol','knife'] }, shiver: { label: 'Shiver (Patrol)', threat: 2, attrs: {strength:3,agility:3,wits:2,empathy:2}, skills: {marksmanship:2,melee:1,observation:1}, hp: 6, armor: 2, weapons: ['pistol','baton'] }, operative_green: { label: 'SLA Operative (Green)', threat: 2, attrs: {strength:2,agility:3,wits:2,empathy:2}, skills: {marksmanship:2,mobility:1,observation:1}, hp: 5, armor: 2, weapons: ['pistol','knife'] }, carrien: { label: 'Carrien', threat: 2, attrs: {strength:3,agility:4,wits:1,empathy:1}, skills: {melee:3,mobility:2}, hp: 7, armor: 1, weapons: ['claws'] }, operative_veteran: { label: 'SLA Operative (Veteran)', threat: 3, attrs: {strength:3,agility:3,wits:3,empathy:2}, skills: {marksmanship:3,melee:2,stealth:2,observation:2}, hp: 8, armor: 3, weapons: ['rifle','pistol','knife'] }, ebb_operative: { label: 'Ebb Operative (Ebon)', threat: 3, attrs: {strength:2,agility:2,wits:4,empathy:4}, skills: {ebb:3,insight:3,observation:2}, hp: 6, armor: 1, weapons: ['pistol'], flux: 4, isEbb: true }, dept_agent: { label: 'Dept. of Propaganda Agent', threat:3, attrs: {strength:2,agility:3,wits:4,empathy:3}, skills: {persuasion:3,insight:3,stealth:2}, hp: 6, armor: 2, weapons: ['pistol'] }, dark_finder: { label: 'Dark Finder', threat: 4, attrs: {strength:4,agility:3,wits:3,empathy:2}, skills: {marksmanship:4,melee:3,stealth:3,observation:3}, hp: 10, armor: 4, weapons: ['rifle','pistol','blade'] }, manchine: { label: 'Manchine', threat: 5, attrs: {strength:5,agility:4,wits:3,empathy:1}, skills: {melee:4,marksmanship:3,force:4,stamina:4}, hp: 14, armor: 5, weapons: ['heavy_weapon','claws'] } }; // ─── NAME TABLES ───────────────────────────────────────────────────────────── const SLA_NAMES = { human: { first: ['Mace','Nadia','Cole','Petra','Sloane','Vex','Mira','Dax','Sable','Reece','Lyra','Jet','Kade','Dani','Rex','Aura','Finn','Zara','Grim','Nova'], last: ['Falke','Dredd','Voss','Caine','Rourke','Mace','Cross','Kane','Stern','Vane','Frost','Ryker','Blane','Dusk','Shard','Hex','Vayne','Crowe','Ash','Lorne'] }, ebon: { first: ['Aelith','Cassia','Vehn','Sorai','Talen','Myren','Eshara','Quelith','Davan','Silae'], last: ['Vel Auris','Sol Keth','Mir Daan','Thas Elen','Quor Veth','Sil Arren','Tel Mora','Van Eres'] }, brainwaster: { first: ['Flux','Scorch','Null','Vex','Wraith','Bleed','Crux','Static','Glitch','Spike'], last: ['Mindless','the Hollow','No-Name','Unbound','Fracture','Sparks','the Lost','Deadwire'] }, stormer: { first: ['Slab','Iron','Breach','Razr','Blunt','Crush','Vance','Gouge','Wreck','Spike'], last: ['313','7-Niner','Alpha-4','Echo-3','Prime','Null-Six','Striker','Unit-9','Kilo','Zero'] }, shaktar: { first: ["Kah'tak","Drex'ahl","Va'shari","Mul'keth","Kar'vaan","Esh'tara","Zek'hal","Quin'dar"], last: ["ek Valkor","ek Shath","ek Duran","ek Morvath","ek Shal","ek Tyran"] }, wraithraider: { first: ['Shade','Whisper','Veil','Null','Shroud','Dusk','Mist','Ash','Fade','Echo'], last: ['of the Void','of the Dark','the Unseen','of the Pale','the Silent','of Shadows'] }, frother: { first: ['Bloody','Mad','Frenzy','Bersk','Rage','Bonehead','Wrecking','Rampant','Feral','Savage'], last: ['McRage','O\'Kill','McGore','Killmore','Deathmarch','McCrash','Bloodstock','Warface'] }, generic: { first: ['Operator','Agent','Unit','Ghost','Vector','Cipher','Reaper','Echo','Phantom','Sigma'], last: ['Null','Seven','Prime','Alpha','Zero','Delta','Omega','Kilo','Bravo','Echo'] } }; function _slaRandomName(raceKey) { const table = SLA_NAMES[raceKey] || SLA_NAMES.generic; const first = table.first[Math.floor(Math.random() * table.first.length)]; const last = table.last[Math.floor(Math.random() * table.last.length)]; return `${first} ${last}`; } // ─── GEAR BUILDERS ─────────────────────────────────────────────────────────── // ── Inline Ebb formula data ─────────────────────────────────────────────────── // Used when the compendium pack is unavailable (JSON packs not compiled to LDB). // Keyed by display name — must match names used in training.ebbEbon / ebbBrainwaster. const EBB_FORMULA_INLINE = { // ── AWARENESS ─────────────────────────────────────────────────────────────── 'Empathic Pulse': { discipline:'awareness', fluxCost:1, successes:1, range:'Short', duration:'Instant', effect:'Sense the dominant emotion of every living being within Short range. On 2+ successes, identify specific individuals.', img:'systems/slazero2/assets/icons/ebb/ebb-awareness.svg' }, 'Danger Sense': { discipline:'awareness', fluxCost:2, successes:1, range:'Self', duration:'1 shift', effect:'Cannot be surprised for 1 shift. Gain +2 dice to Initiative rolls.', img:'systems/slazero2/assets/icons/ebb/ebb-awareness.svg' }, 'Precognition': { discipline:'awareness', fluxCost:2, successes:2, range:'Self', duration:'Instant', effect:'Ask the GM one yes/no question about what will happen in the next stretch.', img:'systems/slazero2/assets/icons/ebb/ebb-awareness.svg' }, // ── BLAST ──────────────────────────────────────────────────────────────────── 'Ebb Bolt': { discipline:'blast', fluxCost:1, successes:1, range:'Short', duration:'Instant', effect:'Strike one target for damage equal to successes. Armour applies. Counts as ranged attack.', img:'systems/slazero2/assets/icons/ebb/ebb-blast.svg' }, 'Hellfire': { discipline:'blast', fluxCost:2, successes:2, range:'Medium', duration:'Instant', effect:'Blast one target for successes + 2 damage. On 3+ successes, target is knocked down.', img:'systems/slazero2/assets/icons/ebb/ebb-blast.svg' }, 'Ebb Storm': { discipline:'blast', fluxCost:3, successes:2, range:'Short', duration:'Instant', effect:'Ebb detonates at Short range. All targets take successes damage. Armour applies.', img:'systems/slazero2/assets/icons/ebb/ebb-blast.svg' }, // ── COMMUNICATE ────────────────────────────────────────────────────────────── 'Mind-Link': { discipline:'communicate', fluxCost:1, successes:1, range:'Short', duration:'1 scene', effect:'Establish silent two-way mental communication with one willing target.', img:'systems/slazero2/assets/icons/ebb/ebb-communicate.svg' }, 'Broadcast': { discipline:'communicate', fluxCost:2, successes:1, range:'Long', duration:'Instant', effect:'Project one simple message, image, or emotion to all living beings within Long range.', img:'systems/slazero2/assets/icons/ebb/ebb-communicate.svg' }, 'Compel': { discipline:'communicate', fluxCost:2, successes:3, range:'Short', duration:'1 round', effect:'Force one target to perform a simple action (flee, drop weapon, kneel). Target resists with Wits.', img:'systems/slazero2/assets/icons/ebb/ebb-communicate.svg' }, // ── ENHANCE ────────────────────────────────────────────────────────────────── 'Surge': { discipline:'enhance', fluxCost:1, successes:1, range:'Self', duration:'1 stretch', effect:'Add +2 dice to all rolls using one chosen attribute for 1 stretch.', img:'systems/slazero2/assets/icons/ebb/ebb-enhance.svg' }, 'Iron Flesh': { discipline:'enhance', fluxCost:2, successes:2, range:'Self', duration:'1 stretch', effect:'Gain 2 automatic armour (stacks with worn armour) for 1 stretch.', img:'systems/slazero2/assets/icons/ebb/ebb-enhance.svg' }, 'Heightened State':{ discipline:'enhance', fluxCost:3, successes:2, range:'Self', duration:'1 stretch', effect:'+1 die to all rolls, reduce all incoming damage by 1 for 1 stretch.', img:'systems/slazero2/assets/icons/ebb/ebb-enhance.svg' }, // ── HEAL ───────────────────────────────────────────────────────────────────── 'Mend': { discipline:'heal', fluxCost:1, successes:1, range:'Engaged', duration:'Instant', effect:'Restore Health equal to successes (max 2) to self or a touched target.', img:'systems/slazero2/assets/icons/ebb/ebb-heal.svg' }, 'Restore': { discipline:'heal', fluxCost:2, successes:2, range:'Engaged', duration:'Instant', effect:'Restore Health equal to successes + 1. On 3+ successes, also clear one critical injury.', img:'systems/slazero2/assets/icons/ebb/ebb-heal.svg' }, 'Calm': { discipline:'heal', fluxCost:1, successes:1, range:'Short', duration:'Instant', effect:"Reduce one target's Stress by 1. If they are Broken from Stress, remove the Broken state.", img:'systems/slazero2/assets/icons/ebb/ebb-heal.svg' }, // ── PROTECT ────────────────────────────────────────────────────────────────── 'Ebb Shield': { discipline:'protect', fluxCost:1, successes:1, range:'Self', duration:'1 round', effect:'Roll 2d6 against the next incoming attack this round; each 6 reduces damage by 1.', img:'systems/slazero2/assets/icons/ebb/ebb-protect.svg' }, 'Ward': { discipline:'protect', fluxCost:2, successes:2, range:'Short', duration:'1 stretch', effect:'One target within Short range gains 1 automatic armour for 1 stretch.', img:'systems/slazero2/assets/icons/ebb/ebb-protect.svg' }, 'Phase Shell': { discipline:'protect', fluxCost:3, successes:3, range:'Self', duration:'1 round', effect:'All physical damage is reduced by 3 this round. Ebb attacks bypass this.', img:'systems/slazero2/assets/icons/ebb/ebb-protect.svg' }, // ── REALITY FOLD ───────────────────────────────────────────────────────────── 'Blink': { discipline:'realityFold', fluxCost:2, successes:2, range:'Short', duration:'Instant', effect:'Instantly teleport to any visible point within Short range.', img:'systems/slazero2/assets/icons/ebb/ebb-realityfold.svg' }, 'Fold Space': { discipline:'realityFold', fluxCost:3, successes:3, range:'Long', duration:'1 round', effect:'Two chosen points within range become adjacent for 1 round.', img:'systems/slazero2/assets/icons/ebb/ebb-realityfold.svg' }, 'Phase Through': { discipline:'realityFold', fluxCost:3, successes:2, range:'Engaged', duration:'Instant', effect:'Pass through one non-living solid object (wall, door, hull).', img:'systems/slazero2/assets/icons/ebb/ebb-realityfold.svg' }, // ── SENSES ─────────────────────────────────────────────────────────────────── 'Ebb Sight': { discipline:'senses', fluxCost:1, successes:1, range:'Long', duration:'1 stretch', effect:'Perceive Ebb energy, active formulae, and Ebb users within Long range.', img:'systems/slazero2/assets/icons/ebb/ebb-senses.svg' }, "Mind's Eye": { discipline:'senses', fluxCost:1, successes:1, range:'Self', duration:'1 stretch', effect:'+3 dice to all Observation rolls. Cannot be ambushed.', img:'systems/slazero2/assets/icons/ebb/ebb-senses.svg' }, 'Remote View': { discipline:'senses', fluxCost:2, successes:2, range:'Long', duration:'1 stretch', effect:'Project senses to any location within Long range that you have visited before.', img:'systems/slazero2/assets/icons/ebb/ebb-senses.svg' }, // ── TELEKINESIS ────────────────────────────────────────────────────────────── 'Push': { discipline:'telekinesis', fluxCost:1, successes:1, range:'Short', duration:'Instant', effect:'Hurl one target or object with mental force, moving them up to 3m per success. Counts as ranged attack.', img:'systems/slazero2/assets/icons/ebb/ebb-telekinesis.svg' }, 'Grip': { discipline:'telekinesis', fluxCost:2, successes:2, range:'Short', duration:'1 round', effect:'Hold one target immobile for 1 round. They can still act but cannot move.', img:'systems/slazero2/assets/icons/ebb/ebb-telekinesis.svg' }, 'Levitate': { discipline:'telekinesis', fluxCost:2, successes:1, range:'Self', duration:'1 stretch', effect:'Float at any height, moving in any direction at normal speed.', img:'systems/slazero2/assets/icons/ebb/ebb-telekinesis.svg' }, // ── THERMAL ────────────────────────────────────────────────────────────────── 'Heat Touch': { discipline:'thermal', fluxCost:1, successes:1, range:'Engaged', duration:'Instant', effect:'Deal damage equal to successes + 1 to a touched target. On 3+ successes, target catches fire.', img:'systems/slazero2/assets/icons/ebb/ebb-thermal.svg' }, 'Flame Burst': { discipline:'thermal', fluxCost:2, successes:1, range:'Short', duration:'Instant', effect:'Deal successes + 2 damage to one target. On 3+ successes, target catches fire.', img:'systems/slazero2/assets/icons/ebb/ebb-thermal.svg' }, 'Freeze': { discipline:'thermal', fluxCost:2, successes:2, range:'Short', duration:'1 round', effect:'Target takes 1 damage and suffers -2 dice to all Agility rolls for 1 round.', img:'systems/slazero2/assets/icons/ebb/ebb-thermal.svg' }, }; /** Build an embedded Ebb item document from the inline table */ function _slaEbbFormulaData(name) { const f = EBB_FORMULA_INLINE[name]; if (!f) return null; return { name, type: 'ebb', img: f.img || 'systems/slazero2/assets/icons/ebb/ebb-blast.svg', system: { description: f.effect || '', discipline: f.discipline, fluxCost: f.fluxCost, successes: f.successes, effect: f.effect || '', catastrophe: '', duration: f.duration || '', range: f.range || 'Short', } }; } function _slaWeaponData(type) { const weapons = { pistol: { name: 'Blitzer 10mm Pistol', weaponType:'Pistol', category:'ranged', damage:2, range:'Short', rof:2, ap:0, magazine:15, ammo:15, fireModes:['single'], cost:300, img: 'icons/weapons/guns/gun-pistol-flintlock-metal.webp' }, rifle: { name: 'M300 Assault Rifle', weaponType:'Rifle', category:'ranged', damage:3, range:'Long', rof:3, ap:1, magazine:30, ammo:30, fireModes:['single','burst','auto'], cost:600, img: 'icons/weapons/guns/rifle-bayonet.webp' }, smg: { name: 'Blitzer SMG', weaponType:'SMG', category:'ranged', damage:2, range:'Short', rof:3, ap:0, magazine:30, ammo:30, fireModes:['single','auto'], cost:450, img: 'icons/weapons/guns/gun-pistol-flintlock-metal.webp' }, heavy_melee: { name: 'Power Blade', weaponType:'Blade', category:'melee', damage:3, range:'Engaged', rof:1, ap:2, magazine:0, ammo:0, fireModes:[], cost:400, img: 'icons/weapons/swords/greatsword.webp' }, knife: { name: 'Combat Knife', weaponType:'Knife', category:'melee', damage:2, range:'Engaged', rof:2, ap:0, magazine:0, ammo:0, fireModes:[], cost:80, img: 'icons/weapons/daggers/dagger-straight-sharp.webp' }, baton: { name: 'Shiver Baton', weaponType:'Baton', category:'melee', damage:2, range:'Engaged', rof:2, ap:0, magazine:0, ammo:0, fireModes:[], cost:50, img: 'icons/weapons/clubs/club-simple-black.webp' }, claws: { name: 'Claws', weaponType:'Natural', category:'melee', damage:3, range:'Engaged', rof:2, ap:0, magazine:0, ammo:0, fireModes:[], cost:0, img: 'icons/skills/melee/unarmed-punch-fist.webp' }, blade: { name: 'Mono-Edge Blade', weaponType:'Blade', category:'melee', damage:3, range:'Engaged', rof:2, ap:2, magazine:0, ammo:0, fireModes:[], cost:350, img: 'icons/weapons/swords/sword-katana-gleaming.webp' }, heavy_weapon: { name: 'Heavy Autocannon', weaponType:'HW', category:'ranged', damage:5, range:'Long', rof:2, ap:3, magazine:20, ammo:20, fireModes:['burst','auto'], cost:2500, img: 'icons/weapons/guns/gun-blunderbuss.webp' }, }; const base = weapons[type] || weapons.pistol; const { img, ...sys } = base; return { name: base.name, type: 'weapon', img: img || 'icons/svg/item.svg', system: { ...sys, description: '', equipped: true, gearBonus:0, initiativeMod:0, ammoType:'standard', autoAmmoUse:8 } }; } function _slaArmorData(name) { const armors = { 'Downtown Jacket': { armorDice:1, armorAuto:0, cost:150, img:'icons/equipment/chest/vest-simple-leather-brown.webp' }, 'CAF Armor': { armorDice:2, armorAuto:0, cost:400, img:'icons/equipment/chest/breastplate-banded-steel.webp' }, 'Scout Armor': { armorDice:3, armorAuto:0, cost:800, img:'icons/equipment/chest/breastplate-layered-steel-grey.webp' }, 'HARD Armor': { armorDice:4, armorAuto:0, cost:1500, img:'icons/equipment/chest/breastplate-scale-grey.webp' }, 'Exo-Rig Armor': { armorDice:5, armorAuto:0, cost:3000, img:'icons/equipment/chest/breastplate-segmented-grey.webp' }, 'Squad Shielded Power Armor': { armorDice:6, armorAuto:2, cost:8000, img:'icons/equipment/chest/breastplate-embossed-silver.webp' } }; const base = armors[name] || armors['Downtown Jacket']; const { img, ...sys } = base; return { name, type: 'armor', img: img || 'icons/svg/shield.svg', system: { ...sys, description:'', armorRating: sys.armorDice, statMod:0, skillMod:0, statModTarget:'', skillModTarget:'', healthMod:0, resolveMod:0, initiativeMod:0, equipped:true } }; } function _slaSpecialtyData(name) { const specialties = { 'Hard Hitter': { effects:'+1 Melee damage (sacrifice fast action)', img:'icons/skills/melee/strike-slashing-yellow.webp' }, 'Fast Reflexes': { effects:'Draw 2 initiative cards, choose the better one', img:'icons/skills/movement/feet-winged-boots-glowing-yellow.webp' }, 'Field Surgeon': { effects:'+1 Healing (critical injuries only)', img:'icons/tools/hand/pliers-pointed-red.webp' }, 'Inquisitive': { effects:'Push WIT rolls twice per roll', img:'icons/skills/social/theft-pickpocket-eyes-green.webp' }, 'Gut Feeling': { effects:'Use EMP for Observation (threat detection only)', img:'icons/magic/perception/eye-ringed-glow-angry-small-red.webp' }, 'Sniper': { effects:'+2 Marksmanship (Long+ range, concealment required)', img:'icons/skills/ranged/aim-reticle-dark.webp' }, 'True Grit': { effects:'+1 maximum Health', img:'icons/magic/life/heart-cross-strong-red.webp' }, 'Ebb Sensitive': { effects:'Sense active Ebb users within ~10m radius', img:'icons/magic/perception/orb-eye-scrying.webp' }, }; const base = specialties[name] || { effects: name, img: 'icons/svg/aura.svg' }; const { img, ...rest } = base; return { name, type: 'specialty', img: img || 'icons/svg/aura.svg', system: { ...rest, description:'', category:'general', prerequisites:'', package:'', healthMod:0, resolveMod:0, isActive:true } }; } function _slaGearData(name) { // Map common gear names to appropriate icons const gearIcons = { 'Medikit': 'icons/tools/medical/syringe-drug-blue.webp', 'Medkit': 'icons/tools/medical/syringe-drug-blue.webp', 'Comms': 'icons/tools/navigation/compass-brass.webp', 'Radio': 'icons/tools/navigation/compass-brass.webp', 'Binoculars': 'icons/tools/exploration/binoculars.webp', 'Handcuffs': 'icons/tools/misc/chain-grey.webp', 'Grapple': 'icons/tools/misc/rope-black.webp', 'Rope': 'icons/tools/misc/rope-black.webp', 'Flashlight': 'icons/tools/navigation/torch-black.webp', 'Scanner': 'icons/tools/scribal/spectacles.webp', 'Toolkit': 'icons/tools/hand/wrench.webp', }; const img = gearIcons[name] || 'systems/slazero2/assets/icons/cardboard-box-closed.svg'; return { name, type: 'equipment', img, system: { description:'', quantity:1, cost:0, weight:1, isDrug:false, active:false, activeDuration:'', activeLabel:'', activeNotes:'', equipped:true, initiativeMod:0 } }; } // ══════════════════════════════════════════════════════════════════════════════ // PC CREATOR APPLICATION // ══════════════════════════════════════════════════════════════════════════════ class SLAPCCreator extends Application { constructor(options = {}) { super(options); this._step = 1; this._race = null; this._training = null; this._attrs = {}; this._skills = {}; this._attrFreePoints = 0; this._skillPoints = 0; this._name = ''; this._scl = 10; this._credits = 500; this._bio = ''; } static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: 'sla-pc-creator', title: 'SLA Industries — Operative Generator', template: null, width: 680, height: 620, resizable: true, classes: ['slazero2', 'sla-generator'] }); } async _renderInner() { // Build full HTML inline — no external template needed const el = document.createElement('div'); el.innerHTML = this._buildHTML(); return $(el); } activateListeners(html) { super.activateListeners(html); this._bindStep1(html); } _getEl(html, sel) { return (html instanceof jQuery ? html[0] : html).querySelector(sel); } _getEls(html, sel) { return [...(html instanceof jQuery ? html[0] : html).querySelectorAll(sel)]; } // ── STEP 1: Race ──────────────────────────────────────────────────────────── _bindStep1(html) { this._showStep(html, 1); this._getEls(html, '.race-card').forEach(card => { card.addEventListener('click', () => { this._getEls(html, '.race-card').forEach(c => c.classList.remove('selected')); card.classList.add('selected'); this._race = card.dataset.race; this._getEl(html, '#gen-next-1').disabled = false; }); }); this._getEl(html, '#gen-next-1').addEventListener('click', () => { if (!this._race) return; this._step = 2; this._bindStep2(html); }); } // ── STEP 2: Training ──────────────────────────────────────────────────────── _bindStep2(html) { this._showStep(html, 2); const raceData = SLA_RACES[this._race]; this._getEls(html, '.training-card').forEach(card => { const pkg = SLA_TRAINING[card.dataset.training]; if (pkg.ebbOnly && !raceData.isEbbUser) { card.classList.add('disabled'); card.title = 'Requires Ebon or Brain Waster'; return; } card.addEventListener('click', () => { this._getEls(html, '.training-card:not(.disabled)').forEach(c => c.classList.remove('selected')); card.classList.add('selected'); this._training = card.dataset.training; this._getEl(html, '#gen-next-2').disabled = false; }); }); this._getEl(html, '#gen-back-2').addEventListener('click', () => { this._step = 1; this._bindStep1(html); }); this._getEl(html, '#gen-next-2').addEventListener('click', () => { if (!this._training) return; // Init attributes from race + apply training skill bonus (done at creation) const race = SLA_RACES[this._race]; this._attrs = { ...race.baseAttrs }; this._attrFreePoints = race.attrPoints; // Apply training skill bonuses to skill pools const trainSkills = { ...SLA_TRAINING[this._training].skills }; this._skills = { force:0, melee:0, stamina:0, marksmanship:0, mobility:0, stealth:0, crafting:0, observation:0, survival:0, healing:0, insight:0, persuasion:0, ebb:0 }; for (const [sk, val] of Object.entries(trainSkills)) { this._skills[sk] = (this._skills[sk] || 0) + val; } this._skillPoints = race.skillPoints; this._step = 3; this._bindStep3(html); }); } // ── STEP 3: Attributes ────────────────────────────────────────────────────── _bindStep3(html) { this._showStep(html, 3); this._renderAttrTable(html); this._getEl(html, '#gen-back-3').addEventListener('click', () => { this._step = 2; this._bindStep2(html); }); this._getEl(html, '#gen-next-3').addEventListener('click', (ev) => { const race = SLA_RACES[this._race]; const left = this._attrFreePoints - Object.entries(this._attrs).reduce((a,[k,v]) => a+(v-race.baseAttrs[k]),0); if (left > 0 && !ev.currentTarget.dataset.confirmed) { ev.currentTarget.dataset.confirmed = "1"; ev.currentTarget.textContent = `${left} point${left!==1?'s':''} unspent — Next anyway?`; setTimeout(() => { delete ev.target?.dataset?.confirmed; }, 4000); return; } this._step = 4; this._bindStep4(html); }); } _renderAttrTable(html) { const race = SLA_RACES[this._race]; const container = this._getEl(html, '#attr-rows'); container.innerHTML = ''; let remaining = this._attrFreePoints - Object.entries(this._attrs).reduce((acc, [k,v]) => acc + (v - race.baseAttrs[k]), 0); const render = () => { remaining = this._attrFreePoints - Object.entries(this._attrs).reduce((acc, [k,v]) => acc + (v - race.baseAttrs[k]), 0); this._getEl(html, '#attr-points-left').textContent = remaining; this._getEl(html, '#gen-next-3').disabled = remaining < 0; const hp = this._attrs.strength + this._attrs.agility + race.healthMod; const rp = this._attrs.wits + this._attrs.empathy + race.resolveMod; this._getEl(html, '#attr-hp-preview').textContent = hp; this._getEl(html, '#attr-rp-preview').textContent = rp; }; const ATTR_LABELS = { strength:'Strength (STR)', agility:'Agility (AGI)', wits:'Wits (WIT)', empathy:'Empathy (EMP)' }; for (const [attr, baseVal] of Object.entries(race.baseAttrs)) { const cap = race.attrCaps[attr]; const row = document.createElement('div'); row.className = 'attr-row'; row.innerHTML = ` ${ATTR_LABELS[attr]} base ${baseVal} ${this._attrs[attr]} / ${cap}`; container.appendChild(row); row.querySelector('.attr-minus').addEventListener('click', () => { if (this._attrs[attr] > baseVal) { this._attrs[attr]--; row.querySelector(`#av-${attr}`).textContent = this._attrs[attr]; render(); } }); row.querySelector('.attr-plus').addEventListener('click', () => { const curRemaining = this._attrFreePoints - Object.entries(this._attrs).reduce((a,[k,v]) => a+(v-race.baseAttrs[k]),0); if (this._attrs[attr] < cap && curRemaining > 0) { this._attrs[attr]++; row.querySelector(`#av-${attr}`).textContent = this._attrs[attr]; render(); } }); } render(); } // ── STEP 4: Skills ────────────────────────────────────────────────────────── _bindStep4(html) { this._showStep(html, 4); this._renderSkillTable(html); this._getEl(html, '#gen-back-4').addEventListener('click', () => { this._step = 3; this._bindStep3(html); }); this._getEl(html, '#gen-next-4').addEventListener('click', (ev) => { const trainSkills = SLA_TRAINING[this._training]?.skills || {}; const spent = Object.entries(this._skills).reduce((a,[k,v]) => a + Math.max(0, v - (trainSkills[k]||0)), 0); const left = Math.max(0, this._skillPoints - spent); if (left > 0 && !ev.currentTarget.dataset.confirmed) { ev.currentTarget.dataset.confirmed = "1"; ev.currentTarget.textContent = `${left} point${left!==1?'s':''} unspent — Next anyway?`; setTimeout(() => { delete ev.target?.dataset?.confirmed; }, 4000); return; } this._step = 5; this._bindStep5(html); }); } _renderSkillTable(html) { const race = SLA_RACES[this._race]; const trainSkills = SLA_TRAINING[this._training].skills; const SKILL_GROUPS = [ { label:'Strength', attr:'STR', skills:['force','melee','stamina'] }, { label:'Agility', attr:'AGI', skills:['marksmanship','mobility','stealth'] }, { label:'Wits', attr:'WIT', skills:['crafting','observation','survival'] }, { label:'Empathy', attr:'EMP', skills:['healing','insight','persuasion','ebb'] } ]; const container = this._getEl(html, '#skill-rows'); container.innerHTML = ''; let spent = 0; const render = () => { spent = Object.entries(this._skills).reduce((a,[k,v]) => a + Math.max(0, v - (trainSkills[k]||0)), 0); this._getEl(html, '#skill-points-left').textContent = Math.max(0, this._skillPoints - spent); this._getEl(html, '#gen-next-4').disabled = spent > this._skillPoints; }; for (const group of SKILL_GROUPS) { const groupEl = document.createElement('div'); groupEl.className = 'skill-group'; groupEl.innerHTML = `
${group.label} (${group.attr})
`; for (const sk of group.skills) { if (sk === 'ebb' && !race.isEbbUser) continue; const fromTraining = trainSkills[sk] || 0; const row = document.createElement('div'); row.className = 'skill-gen-row'; row.innerHTML = ` ${sk.charAt(0).toUpperCase()+sk.slice(1)} ${fromTraining ? `+${fromTraining} training` : ''} ${this._skills[sk] || 0} / ${race.skillCap + fromTraining}`; groupEl.appendChild(row); row.querySelector('.sg-minus').addEventListener('click', () => { const minVal = fromTraining; if (this._skills[sk] > minVal) { this._skills[sk]--; row.querySelector(`#sv-${sk}`).textContent = this._skills[sk]; render(); } }); row.querySelector('.sg-plus').addEventListener('click', () => { const curSpent = Object.entries(this._skills).reduce((a,[k,v]) => a + Math.max(0, v - (trainSkills[k]||0)), 0); const cap = race.skillCap + fromTraining; if (this._skills[sk] < cap && curSpent < this._skillPoints) { this._skills[sk]++; row.querySelector(`#sv-${sk}`).textContent = this._skills[sk]; render(); } }); } container.appendChild(groupEl); } render(); } // ── STEP 5: Details ───────────────────────────────────────────────────────── _bindStep5(html) { this._showStep(html, 5); const nameInput = this._getEl(html, '#char-name'); const sclInput = this._getEl(html, '#char-scl'); const credInput = this._getEl(html, '#char-creds'); const bioInput = this._getEl(html, '#char-bio'); nameInput.value = this._name || _slaRandomName(this._race); sclInput.value = this._scl; credInput.value = this._credits; bioInput.value = this._bio; this._getEl(html, '#random-name-btn').addEventListener('click', () => { nameInput.value = _slaRandomName(this._race); }); this._getEl(html, '#gen-back-5').addEventListener('click', () => { this._step = 4; this._bindStep4(html); }); this._getEl(html, '#gen-next-5').addEventListener('click', () => { this._name = nameInput.value.trim() || _slaRandomName(this._race); this._scl = parseInt(sclInput.value) || 10; this._credits = parseInt(credInput.value) || 500; this._bio = bioInput.value; this._step = 6; this._bindStep6(html); }); } // ── STEP 6: Summary & Create ──────────────────────────────────────────────── _bindStep6(html) { this._showStep(html, 6); const race = SLA_RACES[this._race]; const training = SLA_TRAINING[this._training]; const summary = this._getEl(html, '#summary-content'); const attrStr = Object.entries(this._attrs).map(([k,v])=>`${k.slice(0,3).toUpperCase()} ${v}`).join(' | '); const skillStr = Object.entries(this._skills).filter(([,v])=>v>0).map(([k,v])=>`${k.charAt(0).toUpperCase()+k.slice(1)} ${v}`).join(', ') || 'none'; const hp = this._attrs.strength + this._attrs.agility + race.healthMod; const rp = this._attrs.wits + this._attrs.empathy + race.resolveMod; summary.innerHTML = `
Name${this._name}
Race${race.label}
Training${training.label}
SCL${this._scl}
Credits${this._credits}
Attributes${attrStr}
HP / Resolve${hp} / ${rp}
Skills${skillStr}
Specialty${training.specialty}
Armor${training.armor}
Weapons${(training.weapons||[]).join(', ') || '—'}
${race.isEbbUser && (training.ebbEbon || training.ebb) ? (() => { const list = this._race === 'ebon' && training.ebbEbon ? training.ebbEbon : this._race === 'brainwaster' && training.ebbBrainwaster ? training.ebbBrainwaster : (training.ebb || []); const ebbVal = Number(this._skills.ebb) || 0; return `
Starting Flux${Math.max(1, ebbVal + 1)}
Ebb Formulae (${list.length})${list.join(', ')}
`; })() : ''}`; this._getEl(html, '#gen-back-6').addEventListener('click', () => { this._step = 5; this._bindStep5(html); }); this._getEl(html, '#gen-create-btn').addEventListener('click', () => this._createPC()); } // ── CREATE ACTOR ───────────────────────────────────────────────────────────── async _createPC() { const race = SLA_RACES[this._race]; const training = SLA_TRAINING[this._training]; const hp = this._attrs.strength + this._attrs.agility + race.healthMod; const rp = this._attrs.wits + this._attrs.empathy + race.resolveMod; // Starting flux = Ebb skill + 1 (minimum 1 for Ebb users, 0 for non-Ebb) const ebbSkillVal = Number(this._skills.ebb) || 0; const flux = race.isEbbUser ? Math.max(1, ebbSkillVal + 1) : 0; // Build attributes in the nested object format the system expects const attributes = {}; for (const [k, v] of Object.entries(this._attrs)) { attributes[k] = { value: v }; } // Build skills const skills = {}; for (const [k, v] of Object.entries(this._skills)) { const ATTR_MAP = { force:'strength',melee:'strength',stamina:'strength',marksmanship:'agility',mobility:'agility',stealth:'agility',crafting:'wits',observation:'wits',survival:'wits',healing:'empathy',insight:'empathy',persuasion:'empathy',ebb:'empathy' }; skills[k] = { value: v, attribute: ATTR_MAP[k], label: k.charAt(0).toUpperCase()+k.slice(1) }; } const actorData = { name: this._name, type: 'character', system: { race: this._race, archetype: this._training, training: this._training, details: { scl: this._scl, credits: this._credits, age: '', height: '', weight: '' }, attributes, skills, derivedStats: { health: { value: hp, max: hp }, resolve: { value: rp, max: rp }, broken: false }, stress: 0, flux: { value: flux, max: flux }, carryLimit: 4, biography: this._bio, specialties: training.specialty || '' } }; let actor; try { actor = await Actor.create(actorData); } catch(err) { ui.notifications.error(`Failed to create actor: ${err.message}`); return; } // Embed items: armor, weapons, specialty, gear, ebb formulae const embeds = []; if (training.armor) embeds.push(_slaArmorData(training.armor)); for (const w of (training.weapons || [])) embeds.push(_slaWeaponData(w)); if (training.specialty) embeds.push(_slaSpecialtyData(training.specialty)); for (const g of (training.gear || [])) embeds.push(_slaGearData(g)); if (race.isEbbUser && (training.ebb || training.ebbEbon || training.ebbBrainwaster)) { // Pick the race-appropriate formula list let formulaList; if (this._race === 'ebon' && training.ebbEbon) { formulaList = training.ebbEbon; } else if (this._race === 'brainwaster' && training.ebbBrainwaster) { formulaList = training.ebbBrainwaster; } else { formulaList = training.ebb || []; } // Build formulae from inline data (JSON packs are not compiled to LDB in Foundry v12+) // Fall back to pack lookup only if the inline table is missing an entry. const ebbPack = game.packs?.get('slazero2.ebb-formulae'); let packIdx = null; try { if (ebbPack) packIdx = await ebbPack.getIndex(); } catch(_) {} for (const formulaName of formulaList) { const inline = _slaEbbFormulaData(formulaName); if (inline) { embeds.push(inline); continue; } // Try world items as second fallback const worldItem = game.items?.find(i => i.type === 'ebb' && i.name === formulaName); if (worldItem) { embeds.push(worldItem.toObject()); continue; } // Try compiled pack as last resort if (packIdx) { const entry = packIdx.find(e => e.name === formulaName); if (entry) { try { const doc = await ebbPack.getDocument(entry._id); embeds.push(doc.toObject()); } catch(_) {} } } } } if (embeds.length) { try { await actor.createEmbeddedDocuments('Item', embeds); } catch(e) { console.warn('SLA Zero Two | Could not embed starter items:', e); } } ui.notifications.info(`✓ ${this._name} created as an Operative!`); actor.sheet?.render(true); this.close(); } // ── UI UTILITIES ───────────────────────────────────────────────────────────── _showStep(html, n) { const root = html instanceof jQuery ? html[0] : html; root.querySelectorAll('.gen-step').forEach(s => s.style.display = 'none'); const step = root.querySelector(`#gen-step-${n}`); if (step) step.style.display = 'flex'; // Update progress bar root.querySelectorAll('.gen-progress-dot').forEach((d, i) => { d.classList.toggle('active', i < n); d.classList.toggle('current', i === n - 1); }); } _buildHTML() { const raceCards = Object.entries(SLA_RACES).map(([key, r]) => `
${r.art ? `${r.label}` : 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 `
Operative Generation — SLA Industries
${[1,2,3,4,5,6].map(n=>`
`).join('')}
Step 1 — Select Race
${raceCards}
`; } } // ══════════════════════════════════════════════════════════════════════════════ // NPC CREATOR APPLICATION // ══════════════════════════════════════════════════════════════════════════════ const NPC_PERSONALITIES = ['Aggressive','Calculating','Paranoid','Loyal','Cynical','Idealistic','Greedy','Fearless','Broken','Ruthless','Haunted','Fanatic']; const NPC_MOTIVATIONS = ['Credits','Survival','Revenge','Ideology','Family','Fame','Fear of SLA','Promotion','Addiction','Love','Duty','Unknown']; const NPC_ROLES = ['Enforcer','Informant','Black Market Dealer','Sector Gang Leader','Investigator','Corporate Spy','Ebb Cult Member','Renegade Operative','Cannibal Sector Survivor','Shiver Sergeant']; class SLANPCCreator extends Application { constructor(options = {}) { super(options); this._mode = 'quick'; } static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: 'sla-npc-creator', title: 'SLA Industries — NPC Generator', template: null, width: 560, height: 580, resizable: true, classes: ['slazero2', 'sla-generator'] }); } async _renderInner() { const el = document.createElement('div'); el.innerHTML = this._buildHTML(); return $(el); } activateListeners(html) { super.activateListeners(html); const root = html instanceof jQuery ? html[0] : html; // Mode toggle root.querySelectorAll('.mode-tab').forEach(tab => { tab.addEventListener('click', () => { root.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); this._mode = tab.dataset.mode; root.querySelector('#full-fields').style.display = this._mode === 'full' ? 'block' : 'none'; }); }); // Random fill button root.querySelector('#npc-randomize').addEventListener('click', () => this._randomFill(root)); // Create button root.querySelector('#npc-create-btn').addEventListener('click', () => this._createNPC(root)); // Threat slider → update label const threatSlider = root.querySelector('#npc-threat'); const threatLabel = root.querySelector('#npc-threat-label'); if (threatSlider) { threatSlider.addEventListener('input', () => { threatLabel.textContent = threatSlider.value; }); } } _randomFill(root) { // Pick random archetype const types = Object.keys(SLA_NPC_TYPES); const key = types[Math.floor(Math.random() * types.length)]; root.querySelector('#npc-archetype').value = key; const archetype = SLA_NPC_TYPES[key]; root.querySelector('#npc-threat').value = archetype.threat; root.querySelector('#npc-threat-label').textContent = archetype.threat; // Random name const nameTable = archetype.isEbb ? 'ebon' : 'generic'; root.querySelector('#npc-name').value = _slaRandomName(nameTable); if (this._mode === 'full') { root.querySelector('#npc-personality').value = NPC_PERSONALITIES[Math.floor(Math.random()*NPC_PERSONALITIES.length)]; root.querySelector('#npc-motivation').value = NPC_MOTIVATIONS[Math.floor(Math.random()*NPC_MOTIVATIONS.length)]; root.querySelector('#npc-role').value = NPC_ROLES[Math.floor(Math.random()*NPC_ROLES.length)]; root.querySelector('#npc-notes').value = `${root.querySelector('#npc-name').value} is a ${root.querySelector('#npc-personality').value.toLowerCase()} ${archetype.label.toLowerCase()} driven by ${root.querySelector('#npc-motivation').value.toLowerCase()}.`; } } async _createNPC(root) { const archetypeKey = root.querySelector('#npc-archetype').value; const archetype = SLA_NPC_TYPES[archetypeKey]; const threat = parseInt(root.querySelector('#npc-threat').value) || archetype.threat; let npcName = root.querySelector('#npc-name').value.trim(); if (!npcName) npcName = `${archetype.label} #${Math.floor(Math.random()*900+100)}`; // Scale stats by threat delta const delta = threat - archetype.threat; const attrs = {}; for (const [k, v] of Object.entries(archetype.attrs)) { attrs[k] = Math.max(2, Math.min(5, v + delta)); } const hp = archetype.hp + delta * 2; const armorVal = Math.max(0, Math.min(6, archetype.armor + delta)); // Build notes let notes = ''; if (this._mode === 'full') { const personality = root.querySelector('#npc-personality')?.value || ''; const motivation = root.querySelector('#npc-motivation')?.value || ''; const role = root.querySelector('#npc-role')?.value || ''; const extraNotes = root.querySelector('#npc-notes')?.value || ''; if (personality || motivation || role) { notes = `Personality: ${personality}\nMotivation: ${motivation}\nRole: ${role}\n\n${extraNotes}`; } else { notes = extraNotes; } } // NPC uses simplified system fields const actorData = { name: npcName, type: 'npc', system: { biography: notes, threat, attributes: { ...attrs }, derivedStats: { health: { value: hp, max: hp }, resolve: { value: Math.max(2, attrs.wits + attrs.empathy), max: Math.max(2, attrs.wits + attrs.empathy) } }, skills: { ...archetype.skills }, armor: armorVal, damage: Math.max(2, attrs.strength), flux: archetype.flux ? { value: archetype.flux, max: archetype.flux } : { value: 0, max: 0 } } }; let actor; try { actor = await Actor.create(actorData); } catch(err) { ui.notifications.error(`Failed to create NPC: ${err.message}`); return; } // Embed weapons const embeds = []; for (const w of (archetype.weapons || [])) { if (w !== 'claws') embeds.push(_slaWeaponData(w)); } if (embeds.length) { try { await actor.createEmbeddedDocuments('Item', embeds); } catch(_) {} } // Threat descriptor const threatNames = {1:'Mook',2:'Standard',3:'Tough',4:'Elite',5:'Boss',6:'Apex'}; ui.notifications.info(`✓ ${npcName} (${archetype.label} — Threat ${threat}: ${threatNames[threat] || 'Unknown'}) created.`); actor.sheet?.render(true); this.close(); } _buildHTML() { const archetypeOptions = Object.entries(SLA_NPC_TYPES).map(([key, a]) => ``).join(''); return `
NPC Generator — SLA Industries
⚡ Quick
📋 Full
1=Mook · 3=Tough · 5=Boss · 6=Apex
`; } } // ══════════════════════════════════════════════════════════════════════════════ // SLA GM FINANCE LEDGER // GM-only tool: lists all Player Character actors, shows current balance, // weekly income/expenses, net weekly. // Calculate button: newCredits = credits + income - expenses, then zeros // all income and expense fields on the actor. // ══════════════════════════════════════════════════════════════════════════════ class SLAGMFinanceTool extends Application { constructor(...args) { super(...args); this._actorHookId = null; } static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: 'sla-gm-finance-tool', title: 'GM Finance Ledger', width: 680, height: 'auto', resizable: true, classes: ['slazero2', 'gm-finance-tool'] }); } // ── Auto-refresh when any actor is updated ──────────────────────────────── async _render(force, options) { await super._render(force, options); if (!this._actorHookId) { this._actorHookId = Hooks.on('updateActor', () => { if (this.rendered) this.render(false); }); } } async close(options) { if (this._actorHookId) { Hooks.off('updateActor', this._actorHookId); this._actorHookId = null; } return super.close(options); } // ── Read credits from the actor's raw source data ───────────────────────── // _source bypasses any DataModel computed values and gives the raw DB value. _readCredits(actor) { let raw = actor._source?.system?.details?.credits; if (raw === undefined || raw === null) raw = actor.system?.details?.credits; if (raw === undefined || raw === null) raw = foundry.utils.getProperty(actor, 'system.details.credits'); // Fix "500,500" string from duplicate-named form inputs if (typeof raw === 'string' && raw.includes(',')) raw = raw.split(',')[0]; const n = Number(raw); return isNaN(n) ? 0 : n; } // ── Read a finances sub-value safely ───────────────────────────────────── _readFinance(actor, path) { let raw = foundry.utils.getProperty(actor._source ?? {}, `system.${path}`) ?? foundry.utils.getProperty(actor, `system.${path}`); const n = Number(raw); return isNaN(n) ? 0 : n; } // ── Build per-actor data row ────────────────────────────────────────────── _getPCData() { return (game.actors ?? []) .filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter) .map(a => { const salary = this._readFinance(a, 'finances.income.salary'); const bpn = this._readFinance(a, 'finances.income.bpnReward'); const incOther = this._readFinance(a, 'finances.income.other'); const accom = this._readFinance(a, 'finances.expenses.accommodation'); const drugs = this._readFinance(a, 'finances.expenses.drugs'); const subs = this._readFinance(a, 'finances.expenses.subscriptions'); const expOther = this._readFinance(a, 'finances.expenses.other'); const btax = this._readFinance(a, 'finances.expenses.bulletTax'); const weeklyIncome = salary + bpn + incOther; const weeklyExpenses = accom + drugs + subs + expOther + btax; const netWeekly = weeklyIncome - weeklyExpenses; const credits = this._readCredits(a); return { id: a.id, name: a.name, credits, weeklyIncome, weeklyExpenses, netWeekly }; }); } // ── Apply week: credits += income - expenses, then zero all fields ───────── async _applyWeek(actorId) { if (!game.user?.isGM) return; const actor = game.actors.get(actorId); if (!actor) return; const salary = this._readFinance(actor, 'finances.income.salary'); const bpn = this._readFinance(actor, 'finances.income.bpnReward'); const incOther = this._readFinance(actor, 'finances.income.other'); const accom = this._readFinance(actor, 'finances.expenses.accommodation'); const drugs = this._readFinance(actor, 'finances.expenses.drugs'); const subs = this._readFinance(actor, 'finances.expenses.subscriptions'); const expOther = this._readFinance(actor, 'finances.expenses.other'); const btax = this._readFinance(actor, 'finances.expenses.bulletTax'); const income = salary + bpn + incOther; const expenses = accom + drugs + subs + expOther + btax; const current = this._readCredits(actor); const newBal = current + income - expenses; // {render: false} stops Foundry from auto-submitting open character sheets // before they re-render, which was causing stale form values (e.g. 500) // to be merged with the new value (e.g. 852) into a corrupt "852,500" string. await actor.update({ 'system.details.credits': newBal, 'system.finances.income.salary': 0, 'system.finances.income.bpnReward': 0, 'system.finances.income.other': 0, 'system.finances.expenses.accommodation': 0, 'system.finances.expenses.drugs': 0, 'system.finances.expenses.subscriptions': 0, 'system.finances.expenses.other': 0, 'system.finances.expenses.bulletTax': 0, 'system.finances.ammoSpentSession': 0, 'system.finances.debt': 0 }, { render: false }); // Now re-render any open sheets for this actor cleanly from fresh data. for (const app of Object.values(actor.apps ?? {})) { app.render(false); } ui.notifications.info( `${actor.name}: ${current}c + ${income}c income − ${expenses}c expenses = ${newBal}c` ); } // ── Render the table ────────────────────────────────────────────────────── async _renderInner(_data) { const pcs = this._getPCData(); let rows = ''; if (pcs.length === 0) { rows = ` No Player Characters found.
Tick Player Character on each actor's Biography tab. `; } else { for (const pc of pcs) { const netClass = pc.netWeekly >= 0 ? 'net-positive' : 'net-negative'; const netSign = pc.netWeekly >= 0 ? '+' : ''; const newBal = pc.credits + pc.netWeekly; rows += ` ${pc.name} ${pc.credits}c ${pc.weeklyIncome}c ${pc.weeklyExpenses}c ${netSign}${pc.netWeekly}c `; } } const html = `
Calculate: credits = balance + income − expenses. All income & expense fields are then reset to 0.
${rows}
Character Balance Income Expenses Net
`; return $(html); } activateListeners(html) { super.activateListeners(html); const root = html instanceof jQuery ? html[0] : html; root.querySelectorAll('.gm-finance-calc-btn').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; await this._applyWeek(btn.dataset.actorId); // render() fires automatically via the updateActor hook }); }); root.querySelector('.gm-finance-calc-all-btn')?.addEventListener('click', async () => { const pcs = this._getPCData(); for (const pc of pcs) await this._applyWeek(pc.id); }); } } // ══════════════════════════════════════════════════════════════════════════════ // SLA GROUP NPC GENERATOR // GM tool: quickly generate and track groups of Carrions, Serial Killers, // Monstarets or Machines. Compact editable stat cards, HP tracking, // reroll per card, and one-click export to World actors. // ══════════════════════════════════════════════════════════════════════════════ const SLA_NPC_GROUP_ARCHETYPES = { streetgang: { label: 'Street Gang', color: '#cc8800', icon: 'fas fa-street-view', nameParts: ['','','','Big','Mad','Dead','Crazy','One-Eye'], nameBase: ['Sloper','Ganger','Thug','Punk','Ratter','Knife-Boy','Street Dog','Scrapper','Slum Rat'], stats: { str:[2,3], agi:[2,3], wit:[1,2], emp:[1,3] }, health:[2,4], armor:[0,2], damage:[1,3], threat:[1,2], armorPool:[ 'None','None','None', 'Street Leathers (ARM 1)','Street Leathers (ARM 1)', 'Padded Jacket (ARM 1)', 'Scavenged Vest (ARM 2)', 'Old Combat Jacket (ARM 2)', ], weaponPool:[ // Slot 1 — always a melee weapon [ {name:'Lead Pipe', damage:2,ap:0,range:'Engaged'}, {name:'Knife', damage:1,ap:1,range:'Engaged'}, {name:'Baseball Bat', damage:2,ap:0,range:'Engaged'}, {name:'Crowbar', damage:2,ap:1,range:'Engaged'}, {name:'Machete', damage:2,ap:1,range:'Engaged'}, {name:'Broken Bottle',damage:1,ap:0,range:'Engaged'}, {name:'Chain', damage:1,ap:0,range:'Engaged'}, {name:'Hatchet', damage:2,ap:1,range:'Engaged'}, {name:'Knuckleduster',damage:1,ap:0,range:'Engaged',note:'bonus: free grab on hit'}, ], // Slot 2 — 40% chance of a ranged weapon (nulls reduce probability) [ null,null,null,null,null,null, {name:'Cheap Pistol', damage:2,ap:0,range:'Short'}, {name:'Sawn-Off Shotgun', damage:3,ap:0,range:'Engaged'}, {name:'Crossbow', damage:2,ap:1,range:'Medium'}, {name:'Cheap SMG', damage:2,ap:0,range:'Short',note:'ROF 3'}, ], ], abilities:[ 'Gang Up — when 2+ attack same target, one gets +1 die', 'Street Smarts — +1 Observation die in urban environments', 'Intimidation — Force roll success triggers Panic check in civilians', 'Mob Mentality — +1 die when within 5m of 2+ allies', 'Dirty Fighter — free grab attempt on any melee hit', 'Numbers Game — group of 5+ gains +2 morale threshold', 'Scavenger — minor action: grab a nearby dropped weapon or item', ] }, carrion: { label: 'Carrion', color: '#cc4422', icon: 'fas fa-biohazard', nameParts: ['Bloated','Feral','Stalking','Gnawing','Lurching','Rabid','Rotting','Hulking',''], nameBase: ['Carrion','Carrion','Carrien','Feeder','Gnawer','Pack Carrion','Shambler'], stats: { str:[2,4], agi:[1,2], wit:[1,1], emp:[1,1] }, health:[3,6], armor:[0,1], damage:[2,3], threat:[1,2], armorPool:['None','None','None','Matted Flesh (ARM 1)'], naturalWeapons:[ {name:'Bite', damage:2, ap:1, note:'ignores 1 armour; target STR check or Bleeding'}, {name:'Claws', damage:1, ap:0, note:'always counts as armed; can rake two targets'}, ], abilities:[ 'Pack Aggression — +1 die per Carrion adjacent to the same target', 'Mindless — immune to Panic and all psychological/social effects', 'Hard to Kill — rolls Physical Critical even at 0 HP (once per fight)', 'Swarm — 3+ Carrion attacking same target simultaneously: +2 damage total', 'Contagion — bite victims must pass STR check or contract Rot Fever', 'Shamble — moves half speed but ignores difficult terrain', 'Death Rattle — on death, adjacent targets must make a Morale check', ] }, serialkiller: { label: 'Serial Killer', color: '#882244', icon: 'fas fa-skull', nameParts: ['The','The','The',''], nameBase: ['Butcher','Flenser','Ghost','Surgeon','Reaper','Cutter','Shadow','Wraith','Stalker','Hollow','Hunter'], stats: { str:[2,4], agi:[2,4], wit:[2,3], emp:[1,2] }, health:[3,5], armor:[0,2], damage:[3,4], threat:[3,4], armorPool:[ 'None','None','None', 'Leather Coat (ARM 1)','Leather Coat (ARM 1)', 'Tactical Vest (ARM 2)', 'Scavenged Body Armour (ARM 2)', ], weaponPool:[ // Slot 1 — signature melee weapon (always present) [ {name:'Hunting Knife', damage:2,ap:1,range:'Engaged'}, {name:'Butcher Cleaver', damage:3,ap:0,range:'Engaged'}, {name:'Surgical Scalpel', damage:2,ap:2,range:'Engaged',note:'ignores 2 ARM'}, {name:'Combat Knife', damage:2,ap:1,range:'Engaged'}, {name:'Chainsaw', damage:4,ap:1,range:'Engaged',note:'Loud — breaks Stealth'}, {name:'Hatchet', damage:3,ap:1,range:'Engaged'}, {name:'Piano Wire', damage:2,ap:0,range:'Engaged',note:'Grapple: auto 2 DMG/turn'}, {name:'Bone Saw', damage:3,ap:2,range:'Engaged'}, {name:'Stiletto', damage:2,ap:2,range:'Engaged',note:'ignores soft armour'}, ], // Slot 2 — 30% chance of secondary ranged weapon [ null,null,null,null,null,null,null, {name:'Pistol', damage:2,ap:0,range:'Short'}, {name:'Sniper Rifle',damage:3,ap:2,range:'Long',note:'ignores cover'}, {name:'Crossbow', damage:2,ap:1,range:'Medium',note:'Silent'}, ], ], abilities:[ 'Signature Weapon — +1 die when using preferred weapon type', 'Predator Focus — ignores cover against a chosen target', 'Surprise Strike — first attack each combat: +2 damage', 'Psychotic Endurance — ignores one level of injury penalties', 'Brutal Efficiency — 3+ successes on attack: target is Stunned', 'Stealth — 3 free Stealth dice when ambushing from concealment', 'Trophies — each kill this session: cumulative +1 to next damage roll', 'Evasion — once per round: negate one melee hit on a 4+', ] }, monstaret: { label: 'Monstaret', color: '#226622', icon: 'fas fa-dragon', // Subtypes define the full stat profile, natural weapons and abilities subtypes:[ { name:'Fang Beast', modifiers:['Alpha ','Juvenile ','Mutant ',''], stats:{str:[4,5],agi:[3,4],wit:[1,1],emp:[1,1]}, health:[7,10], armor:[1,2], damage:[3,4], threat:[3,4], naturalWeapons:[ {name:'Bite', damage:3,ap:2,note:'target STR check or Bleeding (1 DMG/turn)'}, {name:'Claw Swipe', damage:2,ap:0,note:'can hit 2 adjacent targets in one action'}, ], abilities:[ 'Pack Hunter — +1 die when 2+ Fang Beasts are present', 'Pounce — charge up to 10m as a free move before attacking', 'Bleeding Bite — on hit: target gains Bleeding condition (1 DMG/turn)', 'Frenzy — at ≤ half HP, gains an additional attack per round', ] }, { name:'Rend Beast', modifiers:['Ancient ','Elder ','Corrupted ',''], stats:{str:[5,6],agi:[1,2],wit:[1,1],emp:[1,1]}, health:[9,13], armor:[2,4], damage:[4,5], threat:[4,5], naturalWeapons:[ {name:'Rending Claws',damage:4,ap:1,note:'3+ successes: Rend — ongoing 1 DMG/turn'}, {name:'Crushing Grip',damage:2,ap:0,note:'target Grappled; STR vs STR to escape'}, ], abilities:[ 'Unstoppable — not stopped by barriers; pushes through obstacles', 'Rending Strike — 3+ successes: target gains Bleeding condition', 'Toughened Hide — ignore first 1 damage from each attack', 'Slow — only moves Short range per turn but cannot be tripped', ] }, { name:'Pit-Thing', modifiers:['Corrupted ','Mutant ','',''], stats:{str:[3,4],agi:[3,4],wit:[1,1],emp:[1,1]}, health:[5,8], armor:[1,2], damage:[2,3], threat:[3,4], naturalWeapons:[ {name:'Acid Spit', damage:2,ap:3,note:'range Short; hit: −1 ARM permanently'}, {name:'Bite', damage:2,ap:1,note:'acid contact: attacker takes 1 DMG on hit'}, ], abilities:[ 'Acid Resistance — immune to acid and corrosive effects', 'Wall Crawl — moves freely on vertical and overhead surfaces', 'Spit Barrage — acid spit at 2 targets per round (−1 die each)', 'Dissolve Armour — each acid hit permanently reduces target ARM by 1', ] }, { name:'Lurker', modifiers:['Shadow ','Deep ','',''], stats:{str:[3,4],agi:[3,5],wit:[1,2],emp:[1,1]}, health:[5,8], armor:[0,2], damage:[2,3], threat:[3,4], naturalWeapons:[ {name:'Tentacle Lash',damage:2,ap:0,note:'reach 5m; 3+ successes: target Grappled'}, {name:'Constrict', damage:3,ap:0,note:'only vs Grappled targets; auto each turn'}, ], abilities:[ 'Camouflage — 4 free Stealth dice; invisible unless moving', 'Ambush — first attack from hidden: +3 dice', 'Multi-Grab — can Grapple up to 3 targets (one tentacle each)', 'Squeeze — Constrict deals +1 DMG per round target remains Grappled', ] }, { name:'Howler', modifiers:['Alpha ','Pack ','',''], stats:{str:[3,4],agi:[4,5],wit:[1,1],emp:[1,1]}, health:[4,7], armor:[0,1], damage:[2,3], threat:[3,4], naturalWeapons:[ {name:'Sonic Screech',damage:2,ap:0,note:'all in Short range; 2+ successes: Stunned'}, {name:'Claw', damage:2,ap:0,note:'fast — can attack twice per round'}, ], abilities:[ 'Screech — area attack within Short range; all targets check vs Stun', 'Speed — always acts initiative phase 1; attacks twice per round', 'Pack Call — if alive after round 1, summons 1d3 additional Howlers', 'Fragile — only ARM 0–1 but extremely agile and hard to hit', ] }, { name:'Crawler', modifiers:['Giant ','Armoured ','Brood ',''], stats:{str:[4,5],agi:[2,3],wit:[1,1],emp:[1,1]}, health:[7,11], armor:[2,4], damage:[3,4], threat:[3,4], naturalWeapons:[ {name:'Mandibles',damage:3,ap:1,note:'STR check or Paralysed for 1 round'}, {name:'Leg Spike',damage:2,ap:0,note:'free attack against rear arc targets'}, ], abilities:[ 'Predatory Instinct — always acts in initiative phase 1', 'Paralytic Bite — on hit: target STR check or Paralysed 1 round', 'Carapace — ARM 2 on top/sides; 0 from below; called shots can bypass', 'Many-Legged — immune to trip/knockdown effects', ] }, ] }, machine: { label: 'Machine', color: '#334488', icon: 'fas fa-robot', subtypes:[ { name:'Combat Unit', designation:['MK-','Unit-',''], stats:{str:[4,5],agi:[2,3],wit:[3,4],emp:[0,0]}, health:[6,10], armor:[3,5], damage:[3,4], threat:[3,4], integratedWeapons:[ {name:'Integrated Rifle', damage:3,ap:1,range:'Long', note:'ROF 1'}, {name:'Combat Blade', damage:3,ap:1,range:'Engaged'}, ], abilities:[ 'Targeting — re-roll one miss die per attack', 'Armoured Shell — natural ARM 3 (already in stats)', 'Threat Assessment — always knows current HP of visible targets', 'Suppression Protocol — target that failed to hit: −1 die next attack', ] }, { name:'Scout Drone', designation:['DES-','SD-',''], stats:{str:[2,3],agi:[4,5],wit:[3,4],emp:[0,0]}, health:[3,5], armor:[1,2], damage:[2,3], threat:[2,3], integratedWeapons:[ {name:'Shock Taser', damage:2,ap:0,range:'Engaged',note:'Stunned on hit'}, {name:'Dart Pistol', damage:1,ap:0,range:'Short', note:'sedative: STR check or unconscious'}, ], abilities:[ 'Flight — ignores ground terrain; moves in full 3D space', 'Sensor Suite — detects all living targets within 20m regardless of cover', 'Relay — allies gain +1 die against targets the drone has acquired', 'Emergency Protocol — at ≤2 HP: transmits distress signal (reinforcements in 3 rounds)', ] }, { name:'Heavy Assault Unit', designation:['HAU-','MK-',''], stats:{str:[5,6],agi:[1,2],wit:[2,3],emp:[0,0]}, health:[9,14], armor:[5,7], damage:[4,6], threat:[4,5], integratedWeapons:[ {name:'Minigun', damage:3,ap:0,range:'Medium',note:'ROF 6; suppression-fire area attack'}, {name:'Flamethrower', damage:4,ap:2,range:'Short', note:'On Fire condition; Short cone area'}, {name:'Armour Fist', damage:5,ap:2,range:'Engaged',note:'knockback 5m on hit'}, ], abilities:[ 'Weapon Array — fires two weapons at different targets simultaneously', 'Overload — on death: 4 damage to all within 5m', 'Juggernaut — immune to Grapple; ignores terrain and barriers', 'Targeting Computer — re-rolls all miss dice once per attack', ] }, { name:'Maintenance Unit', designation:['MU-','SYS-',''], stats:{str:[3,4],agi:[2,3],wit:[3,4],emp:[0,0]}, health:[4,7], armor:[2,3], damage:[2,3], threat:[2,3], integratedWeapons:[ {name:'Cutting Laser',damage:2,ap:3,range:'Engaged',note:'fully ignores armour AP'}, {name:'Shock Wrench', damage:2,ap:0,range:'Engaged',note:'EMP: short-circuits electronics'}, ], abilities:[ 'Field Repairs — restore 2 HP to another Machine unit as a full action', 'Structural Knowledge — +2 AP vs armoured targets (joint strikes)', 'EMP Pulse — once per combat: all electronics within 5m offline for 1 round', 'Diagnostic Mode — immune to Blinded and Stunned conditions', ] }, ] } }; class SLAGroupNPCTool extends Application { constructor(...args) { super(...args); this._npcs = []; this._selectedType = 'carrion'; this._count = 4; this._root = null; this._threatLevel = 3; // 1 = baseline, 10 = elite. Scale: AGI/initiative focus. } static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: 'sla-group-npc-tool', title: 'Group NPC Generator', width: 860, height: 600, resizable: true, classes: ['slazero2', 'group-npc-tool'] }); } // ── Utilities ───────────────────────────────────────────────────────────── _rng(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } _pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } _threatLevelLabel(lvl) { const names = ['','Baseline','Quick','Alert','Dangerous','Veteran','Hardened','Elite','Apex','Legendary','Mythic']; return `${lvl} — ${names[lvl] ?? lvl}`; } _hexRgb(hex) { const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return r ? `${parseInt(r[1],16)},${parseInt(r[2],16)},${parseInt(r[3],16)}` : '128,128,128'; } _esc(s) { return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/ Math.random() - 0.5); abilities = pool.slice(0, 2); armorDesc = `Built-in ARM ${armor}`; } else { // --- Standard flat generation (Street Gang, Carrion, Serial Killer) --- const prefix = this._pick(arch.nameParts); const base = this._pick(arch.nameBase); name = prefix ? `${prefix}${prefix.endsWith('-') ? '' : ' '}${base}` : base; str = this._rng(...arch.stats.str); agi = this._rng(...arch.stats.agi); wit = this._rng(...arch.stats.wit); emp = this._rng(...arch.stats.emp); hpMax = this._rng(...arch.health); armor = this._rng(...arch.armor); damage = this._rng(...arch.damage); threat = this._rng(...arch.threat); if (arch.naturalWeapons) naturalWeapons = [...arch.naturalWeapons]; if (arch.weaponPool) { for (const slot of arch.weaponPool) { const pick = this._pick(slot); if (pick) weapons.push(pick); } } const pool = [...(arch.abilities ?? [])].sort(() => Math.random() - 0.5); abilities = pool.slice(0, 2); if (arch.armorPool) { const ap = this._pick(arch.armorPool); armorDesc = ap ?? 'None'; } else { armorDesc = armor > 0 ? `ARM ${armor}` : 'None'; } } const npc = { id: foundry.utils.randomID(), typeKey, typeLabel: arch.label, color: arch.color, icon: arch.icon, name, str, agi, wit, emp, hp: hpMax, hpMax, armor, damage, threat, weapons, naturalWeapons, armorDesc, abilities, notes: '', defeated: false }; return this._applyThreatBonus(npc); } // ── Threat-level scaling ─────────────────────────────────────────────────── // Scales AGI (initiative) as the primary target; STR, HP, ARM, DMG also // creep up at higher levels. Level 1 = baseline (no stat changes). // initPool is ALWAYS set: max(5, agi) + threatLevel gives at least 6d6 at // level 1 so every NPC can match players on initiative. _applyThreatBonus(npc) { const lvl = Math.max(1, Math.min(10, this._threatLevel ?? 1)); npc.threatBonus = lvl; if (lvl > 1) { // Per-level bonus table: [agiBonus, strBonus, witBonus, hpPct, armorBonus, dmgBonus] const TABLE = [ [0, 0, 0, 0.00, 0, 0], // 1 — baseline (unused; only entered for lvl > 1) [1, 0, 0, 0.00, 0, 0], // 2 — quick [1, 0, 1, 0.00, 0, 0], // 3 — alert [2, 1, 1, 0.00, 0, 0], // 4 — dangerous [2, 1, 1, 0.25, 0, 1], // 5 — veteran [3, 2, 1, 0.25, 1, 1], // 6 — hardened [3, 2, 2, 0.50, 1, 1], // 7 — elite [4, 3, 2, 0.50, 1, 2], // 8 — apex [4, 3, 3, 0.75, 2, 2], // 9 — legendary [5, 4, 3, 1.00, 2, 3], // 10 — mythic ]; const [agiB, strB, witB, hpPct, armB, dmgB] = TABLE[lvl - 1]; npc.agi = Math.min(8, npc.agi + agiB); npc.str = Math.min(8, npc.str + strB); npc.wit = Math.min(8, npc.wit + witB); npc.armor = Math.min(8, npc.armor + armB); npc.damage = Math.min(8, npc.damage + dmgB); if (hpPct > 0) { const extra = Math.ceil(npc.hpMax * hpPct); npc.hpMax += extra; npc.hp += extra; } } // Initiative dice pool: base AGI (floor 5) + one bonus die per threat level. // A level-1 Carrion (AGI 2) → max(2,5)+1 = 6d6; level-3 Carrion → max(3,5)+3 = 8d6. // This guarantees NPCs can contest player initiative at all threat tiers. npc.initPool = Math.max(5, npc.agi) + lvl; return npc; } // ── Icon helpers — SLA-specific art ───────────────────────────────────── static _SLA = 'systems/slazero2/assets/npc-art'; /** Portrait icon for the NPC actor based on type */ _getNPCIcon(typeKey) { const p = SLAGroupNPCTool._SLA + '/portraits'; return { streetgang: `${p}/streetgang.png`, carrion: `${p}/carrion.png`, serialkiller: `${p}/serialkiller.png`, monstaret: `${p}/monstaret.png`, machine: `${p}/machine.png`, }[typeKey] ?? `${p}/enemy-generic.png`; } /** Choose weapon item icon from SLA art */ _getWeaponIcon(weapon) { const n = (weapon.name || '').toLowerCase(); const r = (weapon.range || '').toLowerCase(); const W = SLAGroupNPCTool._SLA + '/weapons'; // Natural if (n.includes('bite') || n.includes('mandible') || n.includes('constrict') || n.includes('grip')) return `${W}/natural-weapon.png`; if (n.includes('claw') || n.includes('rend') || n.includes('tentacle') || n.includes('talon')) return `${W}/claws.png`; if (n.includes('spit') || n.includes('acid') || n.includes('screech') || n.includes('sonic')) return 'icons/svg/aura.svg'; // Ranged if (n.includes('sniper') || n.includes('marksman')) return `${W}/sniper.png`; if (n.includes('rifle') || n.includes('minigun') || n.includes('autocannon') || n.includes('lmg') || n.includes('flamethrower')) return `${W}/rifle.png`; if (n.includes('shotgun') || n.includes('street sweeper') || n.includes('sawn')) return `${W}/shotgun.png`; if (n.includes('smg') || n.includes('submachine')) return `${W}/smg.png`; if (n.includes('pistol') || n.includes('handgun') || n.includes('dart') || n.includes('taser') || n.includes('shock') || n.includes('laser')) return `${W}/pistol.png`; if (n.includes('crossbow')) return `${W}/pistol.png`; // Melee — blunt if (n.includes('pipe') || n.includes('bat') || n.includes('baton') || n.includes('chain') || n.includes('crowbar') || n.includes('knuckle') || n.includes('wrench') || n.includes('hammer')) return `${W}/baton.png`; // Melee — cutting if (n.includes('chainsaw') || n.includes('saw')) return `${W}/chainsaw.png`; if (n.includes('hatchet') || n.includes('axe') || n.includes('cleaver')) return `${W}/hatchet.png`; if (n.includes('machete') || n.includes('sabre') || n.includes('sword') || n.includes('blade') || n.includes('vibro')) return `${W}/sabre.png`; if (n.includes('wire') || n.includes('stiletto') || n.includes('scalpel') || n.includes('bone saw')) return `${W}/combat-knife.png`; if (n.includes('knife') || n.includes('dagger')) return `${W}/knife.png`; if (n.includes('fist') || n.includes('punch') || n.includes('slam')) return `${W}/natural-weapon.png`; // Fallback by range if (r && r !== 'engaged') return `${W}/pistol.png`; return `${W}/knife.png`; } /** Choose ability/specialty icon — SLA trait art where possible */ _getAbilityIcon(text) { const t = (text || '').toLowerCase(); const A = SLAGroupNPCTool._SLA + '/abilities'; if (t.includes('stealth') || t.includes('camouflage') || t.includes('hidden') || t.includes('ambush')) return 'icons/svg/cowled.svg'; if (t.includes('acid') || t.includes('contagion') || t.includes('venom') || t.includes('poison') || t.includes('dissolve')) return `${A}/contagion.png`; if (t.includes('fire') || t.includes('burn') || t.includes('flame')) return 'icons/svg/fire.svg'; if (t.includes('stun') || t.includes('emp') || t.includes('shock') || t.includes('paralys')) return 'icons/svg/daze.svg'; if (t.includes('bleed') || t.includes('rend') || t.includes('gore') || t.includes('bite')) return 'icons/svg/blood.svg'; if (t.includes('heal') || t.includes('repair') || t.includes('recover') || t.includes('diagnos')) return `${A}/regeneration.png`; if (t.includes('pack') || t.includes('swarm') || t.includes('gang') || t.includes('mob') || t.includes('flank')) return `${A}/enemy.png`; if (t.includes('frenzy') || t.includes('unstoppable') || t.includes('brutal') || t.includes('hard hit')) return `${A}/hard-hitting.png`; if (t.includes('fast') || t.includes('speed') || t.includes('sprint') || t.includes('leap') || t.includes('pounce')) return `${A}/fast-reflexes.png`; if (t.includes('intimidat') || t.includes('menac') || t.includes('fear') || t.includes('screech')) return `${A}/menacing.png`; if (t.includes('psycho') || t.includes('endurance') || t.includes('mindless') || t.includes('ignore')) return `${A}/psychosis.png`; return `${A}/attack.png`; } /** Find or create an Actor folder by name, optionally under a parent */ async _ensureFolder(name, parentId = null) { let folder = (game.folders ?? []).find(f => f.type === 'Actor' && f.name === name && (f.folder?.id ?? null) === parentId ); if (!folder) { try { const colors = { 'Generated NPCs': '#442211', 'Street Gang': '#996611', 'Carrion': '#883311', 'Serial Killer': '#661133', 'Monstaret': '#224411', 'Machine': '#112244', }; folder = await Folder.create({ name, type: 'Actor', folder: parentId, sorting: 'a', color: colors[name] ?? '#333333' }); } catch(e) { console.warn(`SLA Zero Two | Could not create folder "${name}":`, e); } } return folder ?? null; } // ── Save in-progress edits before re-render ─────────────────────────────── _saveState() { const root = this._root; if (!root) return; root.querySelectorAll('.gnpc-name-input').forEach(el => { const n = this._npcs.find(x => x.id === el.dataset.npcId); if (n) n.name = el.value; }); root.querySelectorAll('.gnpc-stat-input').forEach(el => { const n = this._npcs.find(x => x.id === el.dataset.npcId); if (n && el.dataset.field) n[el.dataset.field] = parseInt(el.value) || 0; }); root.querySelectorAll('.gnpc-notes-input').forEach(el => { const n = this._npcs.find(x => x.id === el.dataset.npcId); if (n) n.notes = el.value; }); } // ── Build one NPC card ──────────────────────────────────────────────────── _buildCard(npc) { const rgb = this._hexRgb(npc.color); const hpPct = npc.hpMax > 0 ? Math.max(0, Math.min(100, (npc.hp / npc.hpMax) * 100)) : 0; const hpColor = hpPct > 60 ? '#44cc66' : hpPct > 30 ? '#ffaa00' : '#cc3333'; const defeated = npc.defeated ? ' gnpc-defeated' : ''; const bgStyle = `border-left:3px solid ${npc.color};background:rgba(${rgb},0.07)`; // Natural weapons (creatures only) const natHtml = (npc.naturalWeapons ?? []).map(w => { const detail = `DMG ${w.damage}${w.ap ? ` AP ${w.ap}` : ''}${w.note ? ` — ${w.note}` : ''}`; return `
${this._esc(w.name)}${detail}
`; }).join(''); // Conventional / integrated weapons const wepHtml = (npc.weapons ?? []).map(w => { const detail = `DMG ${w.damage}${w.ap ? ` AP ${w.ap}` : ''}${w.range ? ` [${w.range}]` : ''}${w.note ? ` — ${w.note}` : ''}`; return `
${this._esc(w.name)}${detail}
`; }).join(''); // Abilities const abils = (npc.abilities ?? []).map(a => `
${this._esc(a)}
` ).join(''); const armorLine = npc.armorDesc && npc.armorDesc !== 'None' ? `
${this._esc(npc.armorDesc)}
` : ''; return `
${npc.typeLabel} T${npc.threat} ${(npc.threatBonus ?? 1) > 1 ? `⚡${npc.threatBonus}` : ''}
|
HP /
|
${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 ``; }).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 $(`
${typeBtns}
${this._npcs.length ? cards : empty}
${footer}
`); } // ── Listeners ───────────────────────────────────────────────────────────── activateListeners(html) { super.activateListeners(html); const root = html instanceof jQuery ? html[0] : html; this._root = root; // Type tab selection root.querySelectorAll('.gnpc-type-btn').forEach(btn => btn.addEventListener('click', () => { this._saveState(); this._selectedType = btn.dataset.type; this.render(false); }) ); // Count selector (number input, 1–10) root.querySelector('.gnpc-count-sel')?.addEventListener('input', e => { this._count = Math.max(1, Math.min(10, parseInt(e.target.value) || 1)); }); // Threat level slider (1–10) — live label + track-fill update, no re-render const threatSlider = root.querySelector('.gnpc-threat-slider'); const _updateSliderFill = (el, lvl) => { const pct = ((lvl - 1) / 9 * 100).toFixed(1); el.style.background = `linear-gradient(to right, #00ccff 0%, #00ccff ${pct}%, #333 ${pct}%, #333 100%)`; }; if (threatSlider) { _updateSliderFill(threatSlider, this._threatLevel); threatSlider.addEventListener('input', e => { this._threatLevel = Math.max(1, Math.min(10, parseInt(e.target.value) || 1)); _updateSliderFill(e.target, this._threatLevel); const valEl = root.querySelector('.gnpc-threat-val'); if (valEl) valEl.textContent = this._threatLevelLabel(this._threatLevel); }); } // Generate (replace all) root.querySelector('.gnpc-gen-btn')?.addEventListener('click', () => { this._npcs = []; for (let i = 0; i < this._count; i++) { const n = this._generateOne(this._selectedType); if (n) this._npcs.push(n); } this.render(false); }); // Add more (append) root.querySelector('.gnpc-add-btn')?.addEventListener('click', () => { this._saveState(); for (let i = 0; i < this._count; i++) { const n = this._generateOne(this._selectedType); if (n) this._npcs.push(n); } this.render(false); }); // Clear all root.querySelector('.gnpc-clr-btn')?.addEventListener('click', () => { this._npcs = []; this.render(false); }); // Defeat toggle root.querySelectorAll('.gnpc-defeat-btn').forEach(btn => btn.addEventListener('click', () => { this._saveState(); const n = this._npcs.find(x => x.id === btn.dataset.npcId); if (n) { n.defeated = !n.defeated; this.render(false); } }) ); // Initiative roll — pool = max(5, agi) + threatLevel; count 6s as successes root.querySelectorAll('.gnpc-init-btn').forEach(btn => btn.addEventListener('click', () => { const n = this._npcs.find(x => x.id === btn.dataset.npcId); if (!n) return; const pool = n.initPool ?? Math.max(5, n.agi) + (n.threatBonus ?? 1); const rolls = Array.from({ length: pool }, () => Math.ceil(Math.random() * 6)); const successes = rolls.filter(d => d === 6).length; // Update inline result display (no full re-render) const resultEl = root.querySelector(`.gnpc-init-result[data-npc-id="${n.id}"]`); if (resultEl) { const diceHtml = rolls.map(d => `${d}` ).join(''); resultEl.innerHTML = `${diceHtml}${successes}✓`; } // Post result to chat const diceStr = rolls.map(d => d === 6 ? `${d}` : `${d}`).join(' '); ChatMessage.create({ speaker: { alias: n.name }, content: `
${n.name} — Initiative (${pool}d6)
${diceStr}
${successes} success${successes !== 1 ? 'es' : ''}
`, type: CONST.CHAT_MESSAGE_TYPES?.OTHER ?? 0, }); }) ); // Reroll one root.querySelectorAll('.gnpc-reroll-btn').forEach(btn => btn.addEventListener('click', () => { this._saveState(); const idx = this._npcs.findIndex(x => x.id === btn.dataset.npcId); if (idx < 0) return; const fresh = this._generateOne(this._npcs[idx].typeKey); if (fresh) { fresh.notes = this._npcs[idx].notes; this._npcs[idx] = fresh; } this.render(false); }) ); // Remove one root.querySelectorAll('.gnpc-remove-btn').forEach(btn => btn.addEventListener('click', () => { this._saveState(); this._npcs = this._npcs.filter(x => x.id !== btn.dataset.npcId); this.render(false); }) ); // HP ± buttons (no full re-render — update DOM directly) root.querySelectorAll('.gnpc-hp-down').forEach(btn => btn.addEventListener('click', () => { const n = this._npcs.find(x => x.id === btn.dataset.npcId); if (!n) return; n.hp = Math.max(0, n.hp - 1); const card = root.querySelector(`.gnpc-card[data-npc-id="${n.id}"]`); if (card) { card.querySelector('.gnpc-hp-val').value = n.hp; const pct = n.hpMax > 0 ? Math.max(0, (n.hp / n.hpMax) * 100) : 0; const color = pct > 60 ? '#44cc66' : pct > 30 ? '#ffaa00' : '#cc3333'; const bar = card.querySelector('.gnpc-hp-bar'); if (bar) { bar.style.width = `${pct}%`; bar.style.background = color; } } }) ); root.querySelectorAll('.gnpc-hp-up').forEach(btn => btn.addEventListener('click', () => { const n = this._npcs.find(x => x.id === btn.dataset.npcId); if (!n) return; n.hp = Math.min(n.hpMax, n.hp + 1); const card = root.querySelector(`.gnpc-card[data-npc-id="${n.id}"]`); if (card) { card.querySelector('.gnpc-hp-val').value = n.hp; const pct = n.hpMax > 0 ? Math.max(0, (n.hp / n.hpMax) * 100) : 0; const color = pct > 60 ? '#44cc66' : pct > 30 ? '#ffaa00' : '#cc3333'; const bar = card.querySelector('.gnpc-hp-bar'); if (bar) { bar.style.width = `${pct}%`; bar.style.background = color; } } }) ); // Reset all HP to max root.querySelector('.gnpc-reset-hp-btn')?.addEventListener('click', () => { this._saveState(); this._npcs.forEach(n => { n.hp = n.hpMax; n.defeated = false; }); this.render(false); }); // Inline stat edits — save to state on input (no re-render) root.querySelectorAll('.gnpc-stat-input').forEach(el => el.addEventListener('input', () => { const n = this._npcs.find(x => x.id === el.dataset.npcId); if (n && el.dataset.field) n[el.dataset.field] = parseInt(el.value) || 0; }) ); root.querySelectorAll('.gnpc-name-input').forEach(el => el.addEventListener('input', () => { const n = this._npcs.find(x => x.id === el.dataset.npcId); if (n) n.name = el.value; }) ); root.querySelectorAll('.gnpc-notes-input').forEach(el => el.addEventListener('input', () => { const n = this._npcs.find(x => x.id === el.dataset.npcId); if (n) n.notes = el.value; }) ); // Create as World Actors — folder + icons + embedded items root.querySelector('.gnpc-create-btn')?.addEventListener('click', async () => { if (!game.user?.isGM) return; this._saveState(); // Get/create folder hierarchy: Generated NPCs > Type const rootFolder = await this._ensureFolder('Generated NPCs'); const typeLabel = SLA_NPC_GROUP_ARCHETYPES[this._selectedType]?.label ?? 'NPCs'; const typeFolder = await this._ensureFolder(typeLabel, rootFolder?.id ?? null); const folderId = typeFolder?.id ?? rootFolder?.id ?? null; let created = 0; for (const npc of this._npcs) { try { // Biography const bio = [`Type: ${npc.typeLabel} | Threat: ${npc.threat}`]; if (npc.armorDesc && npc.armorDesc !== 'None') bio.push(`Armour: ${npc.armorDesc}`); const allWep = [...(npc.naturalWeapons ?? []), ...(npc.weapons ?? [])]; if (allWep.length) { bio.push('\nWeapons:'); allWep.forEach(w => bio.push(` ${w.name} — DMG ${w.damage}${w.ap ? ` AP ${w.ap}` : ''}${w.range ? ` [${w.range}]` : ''}${w.note ? ` — ${w.note}` : ''}`)); } if (npc.abilities?.length) { bio.push('\nAbilities:'); npc.abilities.forEach(a => bio.push(` ${a}`)); } if (npc.notes) bio.push(`\nNotes:\n ${npc.notes}`); // Create actor with portrait icon const actorDoc = await Actor.create({ name: npc.name, type: 'npc', img: this._getNPCIcon(npc.typeKey), folder: folderId, system: { biography: bio.join('\n'), threat: npc.threat, attributes: { strength: npc.str, agility: npc.agi, wits: npc.wit, empathy: npc.emp }, health: { value: npc.hp, max: npc.hpMax }, armor: npc.armor, damage: npc.damage } }); if (!actorDoc) continue; // Build embedded items const embeds = []; // Weapon items for (const w of allWep) { const isRanged = w.range && w.range !== 'Engaged'; const rofMatch = w.note?.match(/ROF\s*(\d+)/i); embeds.push({ name: w.name, type: 'weapon', img: this._getWeaponIcon(w), system: { description: w.note || '', weaponType: w.name, category: isRanged ? 'ranged' : 'melee', damage: w.damage, range: w.range || 'Engaged', ap: w.ap || 0, gearBonus: 0, rof: rofMatch ? parseInt(rofMatch[1]) : 1, magazine: isRanged ? 12 : 0, ammo: isRanged ? 12 : 0, fireModes: isRanged ? ['single'] : [], equipped: true, ammoType: 'standard', autoAmmoUse: 8, initiativeMod: 0, ammoEmpty: false } }); } // Armor item if (npc.armor > 0 && npc.armorDesc && npc.armorDesc !== 'None' && !npc.armorDesc.startsWith('Built-in')) { const armorName = npc.armorDesc.replace(/\s*\(ARM \d+\)/, '').trim() || 'Armour'; const ART = SLAGroupNPCTool._SLA + '/armor'; const armorImg = npc.armor >= 5 ? `${ART}/exo.png` : npc.armor >= 3 ? `${ART}/heavy.png` : npc.armorDesc.toLowerCase().includes('street') ? `${ART}/street.png` : `${ART}/light.png`; embeds.push({ name: armorName, type: 'armor', img: armorImg, system: { description: npc.armorDesc, armorDice: npc.armor, armorAuto: 0, armorRating: npc.armor, statMod: 0, skillMod: 0, statModTarget: '', skillModTarget: '', healthMod: 0, resolveMod: 0, initiativeMod: 0, equipped: true } }); } // Specialty items for abilities for (const ability of (npc.abilities ?? [])) { const title = ability.match(/^([^—\-]+)/)?.[1]?.trim() ?? ability.slice(0, 30); embeds.push({ name: title, type: 'specialty', img: this._getAbilityIcon(ability), system: { description: ability, effects: ability, category: 'general', package: npc.typeLabel, prerequisites: '', isActive: true, healthMod: 0, resolveMod: 0 } }); } if (embeds.length) { try { await actorDoc.createEmbeddedDocuments('Item', embeds); } catch(e) { console.warn(`SLA Zero Two | Group NPC: embed failed for "${npc.name}"`, e); } } created++; } catch(err) { console.error(`SLA Zero Two | Group NPC: failed to create "${npc.name}"`, err); } } ui.notifications.info(`Created ${created} NPC${created !== 1 ? 's' : ''} in folder: Generated NPCs / ${typeLabel}`); }); } } // ── GM PC STATUS WINDOW ─────────────────────────────────────────────────────── /** * SLAGMStatusWindow — a draggable, auto-refreshing window showing all PC * conditions at a glance. Open via the scene-controls clipboard button. * The GM can also click "Post to Chat" to whisper the same report. */ class SLAGMStatusWindow extends Application { constructor(...args) { super(...args); this._hooks = []; } static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: 'sla-gm-status-window', title: '⚡ PC Status Board', width: 540, height: 'auto', resizable: true, classes: ['slazero2', 'gm-status-window'] }); } /** Singleton open / focus */ static open() { if (!SLAGMStatusWindow._inst) SLAGMStatusWindow._inst = new SLAGMStatusWindow(); SLAGMStatusWindow._inst.render(true); return SLAGMStatusWindow._inst; } _pcActors() { return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter ); } _activeConditions(actor) { const statuses = actor.statuses ?? new Set(); return SLA_CONDITIONS.filter(c => statuses.has(c.id)); } _hpColor(cur, max) { const pct = max > 0 ? cur / max : 1; if (pct > 0.5) return '#44cc66'; if (pct > 0.25) return '#ffaa00'; return '#cc2222'; } async _renderInner() { const pcs = this._pcActors(); const rows = pcs.map(actor => { const sys = actor.system; const hp = sys.derivedStats?.health ?? { value: 0, max: 0 }; const res = sys.derivedStats?.resolve ?? { value: 0, max: 0 }; const stress = sys.derivedStats?.stress ?? 0; const showOff = sys.details?.showingOff ?? 0; const conds = this._activeConditions(actor); const img = actor.img ?? 'icons/svg/mystery-man.svg'; const condTags = conds.length ? conds.map(c => { const allMod = c.diceModifiers?.all ?? 0; const penalty = allMod ? ` (${allMod > 0 ? '+' : ''}${allMod} all)` : ''; return ` ${c.label}${penalty} `; }).join('') : '— clean —'; const stressColor = stress === 0 ? '#44cc66' : stress <= 2 ? '#ffaa00' : '#cc2222'; const soColor = showOff >= 20 ? '#ff4444' : showOff >= 10 ? '#ff9900' : showOff > 0 ? '#ffcc00' : '#888'; return `
${actor.name}
❤ ${hp.value}/${hp.max} 🔵 ${res.value}/${res.max} ⚡ ${stress} ${showOff > 0 ? `✨ ${showOff}pts` : ''}
${condTags}
`; }).join(''); const noPC = pcs.length === 0 ? '
No player characters found.
' : ''; const html = `
Auto-refreshes on actor changes
${noPC}${rows}
`; const el = document.createElement('div'); el.innerHTML = html; return $(el); } async _render(force, options) { await super._render(force, options); // Register auto-refresh hooks once if (!this._hooks.length) { const refresh = () => { if (this.rendered) this.render(false); }; this._hooks.push( Hooks.on('updateActor', refresh), Hooks.on('createActiveEffect', refresh), Hooks.on('deleteActiveEffect', refresh), Hooks.on('updateActiveEffect', refresh) ); } } async close(options) { this._hooks.forEach((id, i) => { const evts = ['updateActor','createActiveEffect','deleteActiveEffect','updateActiveEffect']; Hooks.off(evts[i], id); }); this._hooks = []; SLAGMStatusWindow._inst = null; return super.close(options); } activateListeners(html) { super.activateListeners(html); // Open sheet on portrait or name click html[0].querySelectorAll('.gsw-portrait, .gsw-actor-name').forEach(el => { el.addEventListener('click', () => { game.actors.get(el.dataset.actorId)?.sheet?.render(true); }); }); // Remove condition html[0].querySelectorAll('.gsw-remove-cond').forEach(btn => { btn.addEventListener('click', async (ev) => { ev.stopPropagation(); const actor = game.actors.get(btn.dataset.actorId); if (!actor) return; const condId = btn.dataset.condId; // Remove matching active effects const toDelete = actor.effects.filter(e => e.statuses?.has(condId) || e.flags?.core?.statusId === condId ).map(e => e.id); if (toDelete.length) await actor.deleteEmbeddedDocuments('ActiveEffect', toDelete); else await actor.toggleStatusEffect(condId, { active: false }); }); }); // Post to chat html[0].querySelector('.gsw-post-chat')?.addEventListener('click', () => _broadcastPCConditions()); } } SLAGMStatusWindow._inst = null; // ── 💳 CREDIT DISTRIBUTION ──────────────────────────────────────────────────────────── class SLACreditDistributionTool extends Application { constructor(...args) { super(...args); this._hooks = []; } static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: 'sla-credit-tool', title: '💳 Credit Distribution', width: 560, height: 'auto', resizable: true, classes: ['slazero2', 'credit-tool-window'] }); } static open() { if (!SLACreditDistributionTool._inst) SLACreditDistributionTool._inst = new SLACreditDistributionTool(); SLACreditDistributionTool._inst.render(true); return SLACreditDistributionTool._inst; } _pcActors() { return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter); } _calcIncome(actor) { const fin = actor.system?.finances?.income ?? {}; return (fin.salary ?? 0) + (fin.bpnReward ?? 0) + (fin.other ?? 0); } _calcExpenses(actor) { const exp = actor.system?.finances?.expenses ?? {}; return (exp.accommodation ?? 0) + (exp.drugs ?? 0) + (exp.subscriptions ?? 0) + (exp.other ?? 0) + (exp.bulletTax ?? 0); } async _renderInner() { const pcs = this._pcActors(); // Build rows for each PC const rows = pcs.map(actor => { const credits = actor.system?.details?.credits ?? 0; const scl = actor.system?.details?.scl ?? 0; const income = this._calcIncome(actor); const expenses = this._calcExpenses(actor); const debt = actor.system?.finances?.debt ?? 0; const net = income - expenses; const netClass = net >= 0 ? 'ctw-positive' : 'ctw-negative'; const debtClass = debt > 0 ? 'ctw-debt' : ''; const imgSrc = actor.img ?? 'icons/svg/mystery-man.svg'; return ` ${actor.name} ${actor.name} SCL ${scl} ${income} ${expenses} ${debt} ${net} `; }).join(''); const emptyMsg = pcs.length === 0 ? `No player characters found. Mark actors as Player Characters on their sheets.` : ''; const html = `

Player Credit Ledger

${rows} ${emptyMsg}
Character Credits ¢ Income Expenses Debt Net

Bulk Distribution

`; return $(html); } async _render(force, options) { await super._render(force, options); if (!this._hooks.length) { const refresh = () => { if (this.rendered) this.render(false); }; this._hooks.push( Hooks.on('updateActor', refresh) ); } } async close(options) { this._hooks.forEach((id) => { Hooks.off('updateActor', id); }); this._hooks = []; SLACreditDistributionTool._inst = null; return super.close(options); } activateListeners(html) { super.activateListeners(html); // --- Save individual credits --- html.find('.ctw-btn-save').on('click', async (ev) => { const actorId = ev.currentTarget.dataset.actorId; const actor = game.actors.get(actorId); if (!actor) return ui.notifications.error(`Actor not found: ${actorId}`); const input = html.find(`#credits-input-${actorId}`); const newValue = parseInt(input.val(), 10); if (isNaN(newValue) || newValue < 0) { return ui.notifications.error(`Invalid credit value for ${actor.name}.`); } try { await actor.update({ 'system.details.credits': newValue }); ui.notifications.info(`${actor.name}: credits set to ¢${newValue}.`); } catch (err) { console.error('SLACreditDistributionTool | save error', err); ui.notifications.error(`Failed to update credits for ${actor.name}.`); } }); // --- Pay All PCs --- html.find('#ctw-pay-all-btn').on('click', async () => { const pcs = this._pcActors(); if (pcs.length === 0) return ui.notifications.error('No player characters found.'); const amount = parseInt(html.find('#ctw-bulk-amount').val(), 10); if (isNaN(amount) || amount <= 0) { return ui.notifications.error('Enter a valid amount greater than 0.'); } const payType = html.find('input[name="ctw-pay-type"]:checked').val(); let successCount = 0; for (const actor of pcs) { try { if (payType === 'custom') { // Custom: add directly to credits balance const currentCredits = actor.system?.details?.credits ?? 0; await actor.update({ 'system.details.credits': currentCredits + amount }); } else if (payType === 'salary') { await actor.update({ 'system.finances.income.salary': amount }); } else if (payType === 'bpnReward') { await actor.update({ 'system.finances.income.bpnReward': amount }); } successCount++; } catch (err) { console.error(`SLACreditDistributionTool | pay error for ${actor.name}`, err); ui.notifications.error(`Failed to pay ${actor.name}.`); } } if (successCount > 0) { const label = payType === 'custom' ? `¢${amount} credits` : `${payType} set to ¢${amount}`; ui.notifications.info(`Paid ${successCount} PC(s): ${label}.`); } }); // --- Deduct All Expenses --- html.find('#ctw-deduct-expenses-btn').on('click', async () => { const pcs = this._pcActors(); if (pcs.length === 0) return ui.notifications.error('No player characters found.'); let successCount = 0; for (const actor of pcs) { try { const currentCredits = actor.system?.details?.credits ?? 0; const totalExpenses = this._calcExpenses(actor); const newCredits = Math.max(0, currentCredits - totalExpenses); await actor.update({ 'system.details.credits': newCredits }); successCount++; } catch (err) { console.error(`SLACreditDistributionTool | deduct error for ${actor.name}`, err); ui.notifications.error(`Failed to deduct expenses for ${actor.name}.`); } } if (successCount > 0) { ui.notifications.info(`Expenses deducted from ${successCount} PC(s).`); } }); } } SLACreditDistributionTool._inst = null; // ── 📒 SHIFT LEDGER ──────────────────────────────────────────────────────────── class SLAShiftLedger extends Application { constructor(...args) { super(...args); this._hooks = []; } static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: 'sla-shift-ledger', title: '📒 Shift Ledger', width: 700, height: 'auto', resizable: true, classes: ['slazero2', 'shift-ledger-window'] }); } static open() { if (!SLAShiftLedger._inst) SLAShiftLedger._inst = new SLAShiftLedger(); SLAShiftLedger._inst.render(true); return SLAShiftLedger._inst; } _pcActors() { return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter); } _calcNet(actor) { const fin = actor.system?.finances ?? {}; const inc = fin.income ?? {}; const exp = fin.expenses ?? {}; const totalIncome = (inc.salary || 0) + (inc.bpnReward || 0) + (inc.other || 0); const totalExpenses = (exp.accommodation || 0) + (exp.drugs || 0) + (exp.subscriptions || 0) + (exp.other || 0) + (exp.bulletTax || 0); return { totalIncome, totalExpenses, net: totalIncome - totalExpenses }; } async _processShift(actor) { const { totalIncome, totalExpenses } = this._calcNet(actor); const currentCredits = actor.system?.details?.credits ?? 0; const currentDebt = actor.system?.finances?.debt ?? 0; let newCredits = currentCredits + totalIncome - totalExpenses; let newDebt = currentDebt; if (newCredits < 0) { newDebt = currentDebt + Math.abs(newCredits); newCredits = 0; } try { await actor.update({ 'system.details.credits': newCredits, 'system.finances.debt': newDebt }); ui.notifications.info(`Shift processed for ${actor.name}: ${newCredits}¢ credits, ${newDebt}¢ debt.`); } catch (err) { console.error('SLAShiftLedger | Failed to process shift for', actor.name, err); ui.notifications.error(`Failed to process shift for ${actor.name}. See console for details.`); } } async _processAllShifts() { const pcs = this._pcActors(); if (!pcs.length) { ui.notifications.error('No player characters found to process.'); return; } for (const actor of pcs) { await this._processShift(actor); } ui.notifications.info(`Shift processed for all ${pcs.length} player character(s).`); } async _exportToChat() { const pcs = this._pcActors(); if (!pcs.length) { ui.notifications.error('No player characters found to export.'); return; } let lines = ['

📒 Shift Ledger Summary

']; lines.push(''); 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( `` + `` + `` + `` + `` + `` + `` + `` + `` ); } const groupNet = totalIncome - totalExpenses; const groupNetColor = groupNet >= 0 ? '#44cc66' : '#cc2222'; lines.push( `` + `` + `` + `` + `` + `` + `` + `` ); lines.push('
CharacterSCLCreditsIncomeExpensesNetDebt
${actor.name}${scl}${credits}¢+${inc}¢-${exp}¢${net >= 0 ? '+' : ''}${net}¢${debt}¢
GROUP TOTALS${totalCredits}¢+${totalIncome}¢-${totalExpenses}¢${groupNet >= 0 ? '+' : ''}${groupNet}¢${totalDebt}¢
'); 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 += `
${actor.name} SCL ${scl} ${credits}¢
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}¢
`; } } // ── Global action bar ──────────────────────────────────────────────────── const actionBar = `
`; const html = `
${summaryBar}
${pcCards}
${actionBar}
`; return $(html); } async _render(force, options) { await super._render(force, options); if (!this._hooks.length) { const refresh = () => { if (this.rendered) this.render(false); }; this._hooks.push(Hooks.on('updateActor', refresh)); } } async close(options) { this._hooks.forEach(id => Hooks.off('updateActor', id)); this._hooks = []; SLAShiftLedger._inst = null; return super.close(options); } activateListeners(html) { super.activateListeners(html); // Per-PC process shift buttons html.find('.slw-btn--process').on('click', async (ev) => { const actorId = ev.currentTarget.dataset.actorId; const actor = game.actors.get(actorId); if (!actor) { ui.notifications.error('Actor not found.'); return; } await this._processShift(actor); }); // Process all shifts button html.find('#slw-process-all').on('click', async () => { await this._processAllShifts(); }); // Export to chat button html.find('#slw-export-chat').on('click', async () => { await this._exportToChat(); }); } } SLAShiftLedger._inst = null; // ── ☠ NPC THREAT BOARD ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────── // SLANPCThreatBoard — GM tactical overview of all NPC actors // ────────────────────────────────────────────────────────────────────────────── function threatLabel(val) { if (!val || val <= 2) return { label: 'Low', cls: 'threat-low' }; if (val <= 3) return { label: 'Med', cls: 'threat-med' }; if (val <= 5) return { label: 'High', cls: 'threat-high' }; return { label: 'Elite', cls: 'threat-elite' }; } function hpColor(cur, max) { const pct = max > 0 ? cur / max : 1; if (pct > 0.50) return '#44cc66'; if (pct > 0.25) return '#ffaa00'; return '#cc2222'; } class SLANPCThreatBoard extends Application { constructor(...args) { super(...args); this._hooks = []; this._filterThreat = 'all'; // 'all' | 'low' | 'med' | 'high' | 'elite' this._filterScene = false; // show only actors present in the active scene this._showDead = false; // include dead actors this._sortBy = 'threat'; // 'name' | 'threat' | 'health' } // ── AppV1 config ───────────────────────────────────────────────────────────── static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: 'sla-npc-threat-board', title: '☠ NPC Threat Board', width: 640, height: 500, resizable: true, classes: ['slazero2', 'npc-threat-board'] }); } static open() { if (!SLANPCThreatBoard._inst) SLANPCThreatBoard._inst = new SLANPCThreatBoard(); SLANPCThreatBoard._inst.render(true); return SLANPCThreatBoard._inst; } // ── Data helpers ────────────────────────────────────────────────────────────── _npcActors() { return (game.actors ?? []).filter( a => a.type === 'npc' || (a.type === 'character' && !a.system?.details?.isPlayerCharacter) ); } /** Returns a Set of actorIds present in the currently active scene. */ _sceneActorIds() { const ids = new Set(); const tokens = game.scenes?.active?.tokens ?? []; for (const t of tokens) { if (t.actorId) ids.add(t.actorId); } return ids; } // ── HTML builder ────────────────────────────────────────────────────────────── async _renderInner() { const sceneIds = this._sceneActorIds(); let actors = this._npcActors(); // ── Filter: scene ──────────────────────────────────────────────────────── if (this._filterScene) { actors = actors.filter(a => sceneIds.has(a.id)); } // ── Filter: dead ───────────────────────────────────────────────────────── if (!this._showDead) { actors = actors.filter(a => { const dead = a.statuses?.has('dead') ?? false; const hpVal = a.system?.health?.value ?? 0; return !dead && hpVal > 0; }); } // ── Filter: threat tier ────────────────────────────────────────────────── if (this._filterThreat !== 'all') { actors = actors.filter(a => { const t = a.system?.threat || 0; const { cls } = threatLabel(t); return cls === `threat-${this._filterThreat}`; }); } // ── Sort ───────────────────────────────────────────────────────────────── if (this._sortBy === 'name') { actors.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); } else if (this._sortBy === 'threat') { actors.sort((a, b) => (b.system?.threat || 0) - (a.system?.threat || 0)); } else if (this._sortBy === 'health') { actors.sort((a, b) => { const pctA = (a.system?.health?.max || 0) > 0 ? (a.system.health.value || 0) / a.system.health.max : 0; const pctB = (b.system?.health?.max || 0) > 0 ? (b.system.health.value || 0) / b.system.health.max : 0; return pctA - pctB; // lowest HP first — most urgent }); } // ── Build card HTML ────────────────────────────────────────────────────── const cards = actors.map(actor => { const hpCur = actor.system?.health?.value ?? 0; const hpMax = actor.system?.health?.max ?? 0; const threat = actor.system?.threat ?? 0; const str = actor.system?.attributes?.strength ?? 0; const agi = actor.system?.attributes?.agility ?? 0; const wit = actor.system?.attributes?.wits ?? 0; const emp = actor.system?.attributes?.empathy ?? 0; const dmg = actor.system?.damage ?? '—'; const armor = actor.system?.armor ?? 0; const imgSrc = actor.img ?? 'icons/svg/mystery-man.svg'; const { label: tLabel, cls: tCls } = threatLabel(threat); const hpPct = hpMax > 0 ? Math.max(0, Math.min(1, hpCur / hpMax)) : 0; const hpBarPct = Math.round(hpPct * 100); const barColor = hpColor(hpCur, hpMax); const isDead = (actor.statuses?.has('dead') ?? false) || hpCur <= 0; // Status dots (all active statuses other than 'dead') const statuses = actor.statuses ? [...actor.statuses].filter(s => s !== 'dead') : []; const statusDots = statuses.slice(0, 6).map(s => `` ).join(''); // Scene indicator const inScene = sceneIds.has(actor.id) ? `◉` : ''; return `
${isDead ? '
💀
' : ''}
${actor.name}
${actor.name}${inScene}
${tLabel} ${threat || '–'}
${hpCur} / ${hpMax}
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 = `
Sort:
${cards} ${emptyMsg}
`; return $(html); } // ── Lifecycle ───────────────────────────────────────────────────────────────── async _render(force, options) { await super._render(force, options); if (!this._hooks.length) { const refresh = () => { if (this.rendered) this.render(false); }; this._hooks.push( Hooks.on('updateActor', refresh), Hooks.on('createActor', refresh), Hooks.on('deleteActor', refresh) ); } } async close(options) { const evts = ['updateActor', 'createActor', 'deleteActor']; this._hooks.forEach((id, i) => Hooks.off(evts[i], id)); this._hooks = []; SLANPCThreatBoard._inst = null; return super.close(options); } // ── Listeners ───────────────────────────────────────────────────────────────── activateListeners(html) { super.activateListeners(html); // ── Scene filter buttons ───────────────────────────────────────────────── html.find('[data-filter-scene]').on('click', (ev) => { this._filterScene = ev.currentTarget.dataset.filterScene === 'true'; this.render(false); }); // ── Dead toggle ────────────────────────────────────────────────────────── html.find('.ntb-toggle-dead').on('click', () => { this._showDead = !this._showDead; this.render(false); }); // ── Threat filter buttons ──────────────────────────────────────────────── html.find('.ntb-threat-filter').on('click', (ev) => { this._filterThreat = ev.currentTarget.dataset.threat || 'all'; this.render(false); }); // ── Sort buttons ───────────────────────────────────────────────────────── html.find('.ntb-sort-btn').on('click', (ev) => { this._sortBy = ev.currentTarget.dataset.sort || 'threat'; this.render(false); }); // ── HP decrement ───────────────────────────────────────────────────────── html.find('.ntb-hp-dec').on('click', async (ev) => { ev.stopPropagation(); const actorId = ev.currentTarget.dataset.actorId; const actor = game.actors?.get(actorId); if (!actor) return; const cur = actor.system?.health?.value ?? 0; const newVal = cur - 1; try { await actor.update({ 'system.health.value': newVal }); } catch (err) { console.error('SLANPCThreatBoard | HP decrement error', err); ui.notifications?.error(`Failed to update HP for ${actor.name}.`); } }); // ── HP increment ───────────────────────────────────────────────────────── html.find('.ntb-hp-inc').on('click', async (ev) => { ev.stopPropagation(); const actorId = ev.currentTarget.dataset.actorId; const actor = game.actors?.get(actorId); if (!actor) return; const cur = actor.system?.health?.value ?? 0; const hpMax = actor.system?.health?.max ?? 0; const newVal = hpMax > 0 ? Math.min(hpMax, cur + 1) : cur + 1; try { await actor.update({ 'system.health.value': newVal }); } catch (err) { console.error('SLANPCThreatBoard | HP increment error', err); ui.notifications?.error(`Failed to update HP for ${actor.name}.`); } }); // ── Open actor sheet on card click ─────────────────────────────────────── html.find('.ntb-card').on('click', (ev) => { // Don't open sheet if a HP button was clicked if (ev.target.closest('.ntb-hp-btn')) return; const actorId = ev.currentTarget.dataset.actorId; const actor = game.actors?.get(actorId); if (actor) actor.sheet.render(true); }); } } SLANPCThreatBoard._inst = null; // ── 📋 BPN TRACKER ──────────────────────────────────────────────────────────── class SLABPNTracker extends Application { constructor(...args) { super(...args); this._hooks = []; this._editMode = false; } static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: 'sla-bpn-tracker', title: '📋 BPN Tracker', width: 520, height: 'auto', resizable: true, classes: ['slazero2', 'bpn-tracker-window'] }); } static open() { if (!SLABPNTracker._inst) SLABPNTracker._inst = new SLABPNTracker(); SLABPNTracker._inst.render(true); return SLABPNTracker._inst; } _pcActors() { return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter); } _groupByBPN(actors) { const groups = new Map(); for (const actor of actors) { const code = actor.system?.bpn?.code?.trim() || ''; const key = code === '' ? '__unassigned__' : code; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(actor); } return groups; } _bpnTypeColors() { return { 'Blue': { bg: '#1a3a6e', border: '#3399ff', text: '#88ccff' }, 'White': { bg: '#3a3a3a', border: '#cccccc', text: '#ffffff' }, 'Green': { bg: '#1a3a1a', border: '#44cc66', text: '#88ff88' }, 'Red': { bg: '#3a1a1a', border: '#cc2222', text: '#ff6666' }, 'Black': { bg: '#0d0d0d', border: '#555555', text: '#aaaaaa' }, 'Silver': { bg: '#2a2a3a', border: '#aaaacc', text: '#ccccff' } }; } _buildTypeBadge(bpnType) { const colors = this._bpnTypeColors(); const c = colors[bpnType] ?? { bg: '#222', border: '#666', text: '#ccc' }; return `${bpnType || 'Unknown'}`; } _buildStatusBadge(status) { const cls = { 'Active': 'bpn-status-active', 'Pending': 'bpn-status-pending', 'Complete': 'bpn-status-complete', 'Failed': 'bpn-status-failed' }[status] ?? 'bpn-status-pending'; return `${status || 'Pending'}`; } _buildMissionCard(code, actors) { const rep = actors[0]; const bpn = rep.system?.bpn ?? {}; const bpnType = bpn.type || 'Unknown'; const status = bpn.status || 'Pending'; const reward = bpn.reward ?? 0; const description = bpn.description || ''; const objectives = bpn.objectives || ''; const colors = this._bpnTypeColors(); const c = colors[bpnType] ?? { bg: '#1a1a1a', border: '#555', text: '#aaa' }; const actorIds = actors.map(a => a.id).join(','); const pcBadges = actors.map(a => `${a.name}` ).join(''); const statusOptions = ['Pending', 'Active', 'Complete', 'Failed'].map(s => `` ).join(''); const typeOptions = ['Blue', 'White', 'Green', 'Red', 'Black', 'Silver', 'Unknown'].map(t => `` ).join(''); return `
${code} ${this._buildTypeBadge(bpnType)} ${this._buildStatusBadge(status)}
💳 Reward: ${reward}¢
`; } _buildAssignPanel(actors) { const pcCheckboxes = actors.map(a => `` ).join(''); const typeOptions = ['Blue', 'White', 'Green', 'Red', 'Black', 'Silver'].map(t => `` ).join(''); const statusOptions = ['Pending', 'Active', 'Complete', 'Failed'].map(s => `` ).join(''); return `
Assign BPN
${pcCheckboxes}
`; } 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 = `
Unassigned PCs
${badges}
`; } const assignPanel = this._buildAssignPanel(actors); const html = `
${cardsHtml || '
No active BPNs. Assign one below.
'}
${unassignedHtml} ${assignPanel}
`; return $(html); } async _render(force, options) { await super._render(force, options); if (!this._hooks.length) { const refresh = () => { if (this.rendered) this.render(false); }; this._hooks.push(Hooks.on('updateActor', refresh)); this._hooks.push(Hooks.on('createActor', refresh)); this._hooks.push(Hooks.on('deleteActor', refresh)); } } async close(options) { this._hooks.forEach(id => Hooks.off('updateActor', id)); this._hooks = []; SLABPNTracker._inst = null; return super.close(options); } activateListeners(html) { super.activateListeners(html); // Status change — update all PCs on this BPN html.on('change', '.bpn-status-select', async (ev) => { const card = ev.currentTarget.closest('.bpn-mission-card'); const actorIds = card.dataset.actorIds.split(',').filter(Boolean); const newStatus = ev.currentTarget.value; for (const id of actorIds) { const actor = game.actors.get(id); if (!actor) continue; try { await actor.update({ 'system.bpn.status': newStatus }); } catch (err) { console.error(`BPN Tracker | Failed to update status for ${actor.name}:`, err); } } // Update the badge in-place without full re-render const badge = card.querySelector('.bpn-status-badge'); if (badge) { badge.className = 'bpn-status-badge ' + ({ 'Active': 'bpn-status-active', 'Pending': 'bpn-status-pending', 'Complete': 'bpn-status-complete', 'Failed': 'bpn-status-failed' }[newStatus] ?? 'bpn-status-pending'); badge.textContent = newStatus; } ui.notifications.info(`BPN status updated to "${newStatus}".`); }); // Type change — update all PCs on this BPN html.on('change', '.bpn-type-select', async (ev) => { const card = ev.currentTarget.closest('.bpn-mission-card'); const actorIds = card.dataset.actorIds.split(',').filter(Boolean); const newType = ev.currentTarget.value; const colors = this._bpnTypeColors(); const c = colors[newType] ?? { bg: '#222', border: '#666', text: '#ccc' }; for (const id of actorIds) { const actor = game.actors.get(id); if (!actor) continue; try { await actor.update({ 'system.bpn.type': newType }); } catch (err) { console.error(`BPN Tracker | Failed to update type for ${actor.name}:`, err); } } // Update badge and border color in-place const badge = card.querySelector('.bpn-type-badge'); if (badge) { badge.style.background = c.bg; badge.style.borderColor = c.border; badge.style.color = c.text; badge.textContent = newType; } card.style.borderLeftColor = c.border; ui.notifications.info(`BPN type updated to "${newType}".`); }); // Pay Out — add reward to each PC's credits html.on('click', '[data-action="payout"]', async (ev) => { const card = ev.currentTarget.closest('.bpn-mission-card'); const actorIds = card.dataset.actorIds.split(',').filter(Boolean); let paid = 0; for (const id of actorIds) { const actor = game.actors.get(id); if (!actor) continue; const reward = actor.system?.bpn?.reward ?? 0; const cur = actor.system?.details?.credits ?? 0; try { await actor.update({ 'system.details.credits': cur + reward }); paid++; } catch (err) { console.error(`BPN Tracker | Failed to pay out reward to ${actor.name}:`, err); } } ui.notifications.info(`Reward paid out to ${paid} PC(s).`); }); // Save description + objectives back to all PCs on this BPN html.on('click', '[data-action="save-bpn"]', async (ev) => { const card = ev.currentTarget.closest('.bpn-mission-card'); const actorIds = card.dataset.actorIds.split(',').filter(Boolean); const description = card.querySelector('.bpn-description').value; const objectives = card.querySelector('.bpn-objectives').value; for (const id of actorIds) { const actor = game.actors.get(id); if (!actor) continue; try { await actor.update({ 'system.bpn.description': description, 'system.bpn.objectives': objectives }); } catch (err) { console.error(`BPN Tracker | Failed to save BPN fields for ${actor.name}:`, err); } } ui.notifications.info('BPN description and objectives saved.'); }); // Clear BPN — remove code and status from all PCs on this BPN html.on('click', '[data-action="clear-bpn"]', async (ev) => { const card = ev.currentTarget.closest('.bpn-mission-card'); const actorIds = card.dataset.actorIds.split(',').filter(Boolean); for (const id of actorIds) { const actor = game.actors.get(id); if (!actor) continue; try { await actor.update({ 'system.bpn.code': '', 'system.bpn.status': '' }); } catch (err) { console.error(`BPN Tracker | Failed to clear BPN for ${actor.name}:`, err); } } ui.notifications.info('BPN cleared from assigned PCs.'); if (this.rendered) this.render(false); }); // Assign BPN — write BPN data to selected PCs html.on('click', '[data-action="assign-bpn"]', async (ev) => { const panel = ev.currentTarget.closest('.bpn-assign-panel'); const selectedIds = [...panel.querySelectorAll('.bpn-assign-pc-check:checked')].map(el => el.value); if (selectedIds.length === 0) { ui.notifications.warn('No PCs selected for BPN assignment.'); return; } const code = panel.querySelector('.bpn-assign-code').value.trim(); if (!code) { ui.notifications.warn('Please enter a BPN code.'); return; } const bpnType = panel.querySelector('.bpn-assign-type').value; const status = panel.querySelector('.bpn-assign-status').value; const reward = Number(panel.querySelector('.bpn-assign-reward').value) || 0; const description = panel.querySelector('.bpn-assign-desc').value; const objectives = panel.querySelector('.bpn-assign-obj').value; for (const id of selectedIds) { const actor = game.actors.get(id); if (!actor) continue; try { await actor.update({ 'system.bpn.code': code, 'system.bpn.type': bpnType, 'system.bpn.status': status, 'system.bpn.reward': reward, 'system.bpn.description': description, 'system.bpn.objectives': objectives }); } catch (err) { console.error(`BPN Tracker | Failed to assign BPN to ${actor.name}:`, err); } } ui.notifications.info(`BPN "${code}" assigned to ${selectedIds.length} PC(s).`); // Clear the form panel.querySelector('.bpn-assign-code').value = ''; panel.querySelector('.bpn-assign-reward').value = '0'; panel.querySelector('.bpn-assign-desc').value = ''; panel.querySelector('.bpn-assign-obj').value = ''; panel.querySelectorAll('.bpn-assign-pc-check').forEach(el => el.checked = false); if (this.rendered) this.render(false); }); } } SLABPNTracker._inst = null; // ── ⚡ CONDITION APPLICATOR ──────────────────────────────────────────────────────────── class SLAConditionApplicator extends Application { constructor(...args) { super(...args); this._hooks = []; this._selectedActorId = null; this._allPCsMode = false; } static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: 'sla-condition-applicator', title: '⚡ Condition Applicator', width: 600, height: 520, resizable: true, classes: ['slazero2', 'condition-applicator-window'] }); } static open() { if (!SLAConditionApplicator._inst) SLAConditionApplicator._inst = new SLAConditionApplicator(); SLAConditionApplicator._inst.render(true); return SLAConditionApplicator._inst; } _pcActors() { return (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter); } async _renderInner() { const pcs = this._pcActors(); // Default selected actor to first PC if none set or stale if (!this._selectedActorId || !pcs.find(a => a.id === this._selectedActorId)) { this._selectedActorId = pcs.length ? pcs[0].id : null; } const selActor = pcs.find(a => a.id === this._selectedActorId) ?? null; // --- Build PC roster rows --- let pcRowsHtml = ''; for (const actor of pcs) { const activeConditions = SLA_CONDITIONS.filter(c => actor.statuses?.has(c.id)); const dotBadges = activeConditions.map(c => `` ).join(''); const isSelected = actor.id === this._selectedActorId && !this._allPCsMode; pcRowsHtml += `
${actor.name} ${dotBadges}
`; } if (!pcRowsHtml) { pcRowsHtml = '
No PC actors found.
'; } // --- Build condition grid tiles --- let condTilesHtml = ''; for (const cond of SLA_CONDITIONS) { let isActive = false; if (this._allPCsMode) { // Active if ALL PCs have it (useful visual: show partial state too) const pcCount = pcs.length; const hasCount = pcs.filter(a => a.statuses?.has(cond.id)).length; if (hasCount === pcCount && pcCount > 0) isActive = true; } else if (selActor) { isActive = selActor.statuses?.has(cond.id) ?? false; } // Build dice modifier badge text let modBadge = ''; if (cond.diceModifiers && Object.keys(cond.diceModifiers).length) { const parts = Object.entries(cond.diceModifiers).map(([key, val]) => { const sign = val >= 0 ? '+' : '−'; const abs = Math.abs(val); return `${sign}${abs} ${key}`; }); modBadge = `${parts.join(' ')}`; } const descAttr = cond.description ? ` title="${cond.description.replace(/"/g, '"')}"` : ''; condTilesHtml += `
${modBadge} ${cond.label}
`; } if (!condTilesHtml) { condTilesHtml = '
No conditions defined.
'; } // --- Active conditions status bar --- let statusBarHtml = ''; if (this._allPCsMode) { statusBarHtml = 'All PCs mode active'; } else if (selActor) { const activeConds = SLA_CONDITIONS.filter(c => selActor.statuses?.has(c.id)); if (activeConds.length) { statusBarHtml = activeConds.map(c => ` ${c.label}` ).join(''); } else { statusBarHtml = 'No active conditions'; } } else { statusBarHtml = 'No PC selected'; } const selectedLabel = this._allPCsMode ? 'All PCs' : (selActor ? selActor.name : '—'); const html = `
Target: ${selectedLabel}
PC Roster
${pcRowsHtml}
${condTilesHtml}
Active:
${statusBarHtml}
`; return $(html); } async _render(force, options) { await super._render(force, options); if (!this._hooks.length) { const refresh = () => { if (this.rendered) this.render(false); }; this._hooks.push( Hooks.on('updateActor', refresh), Hooks.on('createActiveEffect', refresh), Hooks.on('deleteActiveEffect', refresh), Hooks.on('updateActiveEffect', refresh) ); } } async close(options) { const evts = ['updateActor', 'createActiveEffect', 'deleteActiveEffect', 'updateActiveEffect']; this._hooks.forEach((id, i) => Hooks.off(evts[i], id)); this._hooks = []; SLAConditionApplicator._inst = null; return super.close(options); } activateListeners(html) { super.activateListeners(html); // PC row click — select actor html.find('.cap-pc-row').on('click', (ev) => { const actorId = ev.currentTarget.dataset.actorId; if (actorId) { this._selectedActorId = actorId; this._allPCsMode = false; this.render(false); } }); // Condition tile click — toggle condition html.find('.cap-cond-tile').on('click', async (ev) => { const condId = ev.currentTarget.dataset.condId; const isActive = ev.currentTarget.dataset.active === 'true'; const pcs = this._pcActors(); if (this._allPCsMode) { for (const actor of pcs) { try { await actor.toggleStatusEffect(condId, { active: !isActive }); } catch (err) { console.error(`SLAConditionApplicator | Failed to toggle ${condId} on ${actor.name}:`, err); ui.notifications.warn(`Could not toggle condition on ${actor.name}.`); } } const verb = isActive ? 'Removed' : 'Applied'; const condLabel = SLA_CONDITIONS.find(c => c.id === condId)?.label ?? condId; ui.notifications.info(`${verb} "${condLabel}" on all PCs.`); } else { const selActor = pcs.find(a => a.id === this._selectedActorId); if (!selActor) { ui.notifications.warn('No PC selected.'); return; } try { await selActor.toggleStatusEffect(condId, { active: !isActive }); const verb = isActive ? 'Removed' : 'Applied'; const condLabel = SLA_CONDITIONS.find(c => c.id === condId)?.label ?? condId; ui.notifications.info(`${verb} "${condLabel}" on ${selActor.name}.`); } catch (err) { console.error(`SLAConditionApplicator | Failed to toggle ${condId} on ${selActor.name}:`, err); ui.notifications.warn(`Could not toggle condition on ${selActor.name}.`); } } }); // Toggle All PCs mode html.find('[data-action="toggle-all-pcs"]').on('click', () => { this._allPCsMode = !this._allPCsMode; this.render(false); }); // Clear All conditions html.find('[data-action="clear-all"]').on('click', async () => { const pcs = this._pcActors(); const targets = this._allPCsMode ? pcs : pcs.filter(a => a.id === this._selectedActorId); if (!targets.length) { ui.notifications.warn('No PC selected.'); return; } for (const actor of targets) { for (const cond of SLA_CONDITIONS) { if (actor.statuses?.has(cond.id)) { try { await actor.toggleStatusEffect(cond.id, { active: false }); } catch (err) { console.error(`SLAConditionApplicator | Failed to clear ${cond.id} on ${actor.name}:`, err); } } } } const targetLabel = this._allPCsMode ? 'all PCs' : (targets[0]?.name ?? 'selected PC'); ui.notifications.info(`Cleared all conditions from ${targetLabel}.`); }); } } SLAConditionApplicator._inst = null; // ── ACTOR DIRECTORY BUTTONS ────────────────────────────────────────────────── Hooks.on('renderActorDirectory', (app, html) => { const root = html instanceof jQuery ? html[0] : html; const actions = root.querySelector('.header-actions') || root.querySelector('.directory-header'); if (!actions) return; // Avoid duplicates on re-render if (actions.querySelector('.sla-gen-pc-btn')) return; const pcBtn = document.createElement('button'); pcBtn.className = 'sla-gen-pc-btn'; pcBtn.innerHTML = ' Generate PC'; pcBtn.style.cssText = 'font-size:11px;padding:2px 7px;margin-left:4px;background:rgba(255,102,0,0.2);border:1px solid #ff6600;color:#ff6600;border-radius:3px;cursor:pointer;'; pcBtn.addEventListener('click', () => new SLAPCCreator().render(true)); const npcBtn = document.createElement('button'); npcBtn.className = 'sla-gen-npc-btn'; npcBtn.innerHTML = ' Generate NPC'; npcBtn.style.cssText = 'font-size:11px;padding:2px 7px;margin-left:4px;background:rgba(0,200,200,0.1);border:1px solid #00aacc;color:#00aacc;border-radius:3px;cursor:pointer;'; npcBtn.addEventListener('click', () => new SLANPCCreator().render(true)); // GM-only Finance Ledger button if (game.user?.isGM) { const finBtn = document.createElement('button'); finBtn.className = 'sla-gm-finance-btn'; finBtn.innerHTML = ' Finance Ledger'; finBtn.style.cssText = 'font-size:11px;padding:2px 7px;margin-left:4px;background:rgba(255,200,0,0.15);border:1px solid #ffcc00;color:#ffcc00;border-radius:3px;cursor:pointer;'; finBtn.addEventListener('click', () => { if (!game.slaGMFinanceTool) game.slaGMFinanceTool = new SLAGMFinanceTool(); game.slaGMFinanceTool.render(true); }); actions.append(finBtn); const grpBtn = document.createElement('button'); grpBtn.className = 'sla-group-npc-btn'; grpBtn.innerHTML = ' Group NPCs'; grpBtn.style.cssText = 'font-size:11px;padding:2px 7px;margin-left:4px;background:rgba(100,200,80,0.15);border:1px solid #66cc44;color:#66cc44;border-radius:3px;cursor:pointer;'; grpBtn.addEventListener('click', () => { if (!game.slaGroupNPCTool) game.slaGroupNPCTool = new SLAGroupNPCTool(); game.slaGroupNPCTool.render(true); }); actions.append(grpBtn); // ── SLA GM Tool buttons ───────────────────────────────────────────────── const gmToolDefs = [ { cls: 'sla-dir-status-btn', icon: 'fas fa-clipboard-list', label: 'PC Status', color: '#e3e300', fn: () => SLAGMStatusWindow.open() }, { cls: 'sla-dir-credit-btn', icon: 'fas fa-credit-card', label: 'Credits', color: '#e3e300', fn: () => SLACreditDistributionTool.open() }, { cls: 'sla-dir-ledger-btn', icon: 'fas fa-book-open', label: 'Ledger', color: '#aa88ff', fn: () => SLAShiftLedger.open() }, { cls: 'sla-dir-threat-btn', icon: 'fas fa-skull', label: 'Threats', color: '#ff4444', fn: () => SLANPCThreatBoard.open() }, { cls: 'sla-dir-bpn-btn', icon: 'fas fa-clipboard-check', label: 'BPN', color: '#ffcc00', fn: () => SLABPNTracker.open() }, { cls: 'sla-dir-condapp-btn', icon: 'fas fa-bolt', label: 'Conditions', color: '#ff6b35', fn: () => SLAConditionApplicator.open() }, ]; for (const def of gmToolDefs) { if (actions.querySelector(`.${def.cls}`)) continue; const btn = document.createElement('button'); btn.className = def.cls; btn.innerHTML = ` ${def.label}`; btn.style.cssText = `font-size:11px;padding:2px 6px;margin-left:3px;background:rgba(0,0,0,0.3);border:1px solid ${def.color};color:${def.color};border-radius:3px;cursor:pointer;`; btn.addEventListener('click', def.fn); actions.append(btn); } } actions.append(pcBtn, npcBtn); }); // ══════════════════════════════════════════════════════════════════════════════ // SLA INDUSTRIES — RACIAL ABILITIES // Auto-embedded specialty items with `category: "racial"` and `racialBonuses` // array driving automatic dice-pool modifiers via _getRacialDiceBonus(). // ══════════════════════════════════════════════════════════════════════════════ const SLA_RACIAL_ABILITY_DATA = { ebon: [ { name: "Ebb Sensitive", type: "specialty", img: "systems/slazero2/assets/icons/ebb/ebb-senses.svg", system: { category: "racial", description: "Born of the Ebb. Sense other Ebons and active Ebb formulae within ~10 metres.", effects: "Sense Ebons and active formulae within ~10 m.", racialBonuses: [], prerequisites: "Ebon", isActive: true, healthMod: 0, resolveMod: 0 } } ], brainwaster: [ { name: "Ebb Fury", type: "specialty", img: "systems/slazero2/assets/icons/ebb/ebb-blast.svg", system: { category: "racial", description: "The Ebb tears through a Brain Waster raw. Spend 1 Flux: +2 dice to one combat roll. Pushing an Ebb roll risks the formula's catastrophe.", effects: "Spend 1 Flux: +2 dice to a combat roll. Pushed Ebb rolls risk catastrophe.", racialBonuses: [], prerequisites: "Brain Waster", isActive: true, healthMod: 0, resolveMod: 0 } } ], stormer: [ { name: "Natural Weapons (Claws & Teeth)", type: "specialty", img: "systems/slazero2/assets/racial/natural-weapons.svg", system: { category: "racial", description: "Vatgrown bio-weapons. Always counts as armed. +1 die on all melee attack rolls.", effects: "+1 die on melee rolls. Always treated as armed in close combat.", racialBonuses: [{ skill: "melee", bonus: 1 }], prerequisites: "Stormer 313-S Malice", isActive: true, healthMod: 0, resolveMod: 0 } }, { name: "Stormer Regeneration", type: "specialty", img: "systems/slazero2/assets/racial/regen.svg", system: { category: "racial", description: "Enhanced biology heals wound tissue rapidly. Recover 1 HP per hour of rest.", effects: "Recover 1 HP per hour of rest. Does not function at 0 HP.", racialBonuses: [], prerequisites: "Stormer 313-S Malice", isActive: true, healthMod: 0, resolveMod: 0, regenHpPerHour: 1 } } ], shaktar: [ { name: "Battle Claws", type: "specialty", img: "systems/slazero2/assets/racial/battle-claws.svg", system: { category: "racial", description: "Retractable duranium-hard claws. Always armed. +1 die on melee rolls.", effects: "+1 die on all melee attack rolls. Always treated as armed.", racialBonuses: [{ skill: "melee", bonus: 1 }], prerequisites: "Shaktar", isActive: true, healthMod: 0, resolveMod: 0 } }, { name: "Warrior Caste Training", type: "specialty", img: "systems/slazero2/assets/racial/warrior-caste.svg", system: { category: "racial", description: "Generations of ritual melee combat. +1 additional die on melee rolls when wielding any melee weapon.", effects: "+1 die on melee rolls when using a melee weapon (stacks with Battle Claws for +2 total).", racialBonuses: [{ skill: "melee", bonus: 1 }], prerequisites: "Shaktar", isActive: true, healthMod: 0, resolveMod: 0 } } ], wraithraider: [ { name: "Shaper Senses", type: "specialty", img: "systems/slazero2/assets/racial/shaper-senses.svg", system: { category: "racial", description: "Heightened multi-spectrum perception. +2 dice on Observation rolls. +1 die on Stealth rolls.", effects: "+2 dice on Observation rolls. +1 die on Stealth rolls.", racialBonuses: [ { skill: "observation", bonus: 2 }, { skill: "stealth", bonus: 1 } ], prerequisites: "Wraith Raider", isActive: true, healthMod: 0, resolveMod: 0 } }, { name: "Wraith Teeth & Claw", type: "specialty", img: "systems/slazero2/assets/racial/natural-weapons.svg", system: { category: "racial", description: "Bone-hard teeth and razor claws grown through decades of cold-world adaptation. Natural melee weapons — Damage 2, AP 1, ROF 2. Always counts as armed.", effects: "Natural weapon: DMG 2, AP 1, ROF 2. Always armed.", racialBonuses: [], prerequisites: "Wraith Raider", isActive: true, healthMod: 0, resolveMod: 0, isNaturalWeapon: true, naturalWeaponDamage: 2, naturalWeaponAP: 1, naturalWeaponROF: 2 } } ], frother: [ { name: "Blade Proficiency", type: "specialty", img: "systems/slazero2/assets/racial/blade-proficiency.svg", system: { category: "racial", description: "Frothers are culturally and chemically attuned to bladed combat. +2 dice on melee rolls with bladed or sword-type weapons.", effects: "+2 dice on melee rolls when using bladed weapons (swords, knives, power blades).", racialBonuses: [{ skill: "melee", bonus: 2, weaponTag: "blade" }, { skill: "melee", bonus: 2, weaponTag: "knife" }], prerequisites: "Frother", isActive: true, healthMod: 0, resolveMod: 0 } }, { name: "Drug Dependency", type: "specialty", img: "systems/slazero2/assets/racial/drug-dependency.svg", system: { category: "racial", description: "A Frother's biochemistry depends on combat stimulants. Without an active drug: −1 die to all rolls (withdrawal).", effects: "−1 to all rolls when no drug is active (withdrawal). Drug bonuses apply normally when active.", racialBonuses: [], prerequisites: "Frother", isActive: true, healthMod: 0, resolveMod: 0, drugDependent: true } } ], human: [ { name: "Social Versatility", type: "specialty", img: "systems/slazero2/assets/racial/social-versatility.svg", system: { category: "racial", description: "Humans navigate SLA's corporate social structures with natural ease. +1 die on Persuasion and Insight rolls.", effects: "+1 die on Persuasion rolls. +1 die on Insight rolls.", racialBonuses: [ { skill: "persuasion", bonus: 1 }, { skill: "insight", bonus: 1 } ], prerequisites: "Human", isActive: true, healthMod: 0, resolveMod: 0 } } ], vevaphon: [ { name: "Brute Form", type: "specialty", img: "systems/slazero2/assets/racial/warrior-caste.svg", system: { category: "racial", description: "Dense armoured combat chassis. +2 STR, +2 BODY equivalent (+2 Health max, ignores knockdown). Cannot be disguised. Costs 1 Instability to activate.", effects: "+2 effective Strength dice, +2 Health max, Knockdown immune. Costs 1 Instability.", racialBonuses: [{ skill: "force", bonus: 1 }, { skill: "melee", bonus: 1 }], prerequisites: "Vevaphon", isActive: false, healthMod: 2, resolveMod: 0 } }, { name: "Stalker Form", type: "specialty", img: "systems/slazero2/assets/racial/shaper-senses.svg", system: { category: "racial", description: "Lean predatory chassis for silence and ambush. +2 Stealth dice, +1 Agility effective. Cannot wear armour while active. Costs 1 Instability to activate.", effects: "+2 Stealth, +1 Agility dice. Cannot wear armour. Costs 1 Instability.", racialBonuses: [{ skill: "stealth", bonus: 2 }, { skill: "mobility", bonus: 1 }], prerequisites: "Vevaphon", isActive: false, healthMod: 0, resolveMod: 0 } }, { name: "Raptor Form", type: "specialty", img: "systems/slazero2/assets/racial/battle-claws.svg", system: { category: "racial", description: "Strike-optimised chassis with natural weapons. +2 Melee dice, +1 Marksmanship (close range only). Critical hits deal +1 damage. Costs 1 Instability to activate.", effects: "+2 Melee, +1 Marksmanship (close range). Crits deal +1 damage. Costs 1 Instability.", racialBonuses: [{ skill: "melee", bonus: 2 }, { skill: "marksmanship", bonus: 1 }], prerequisites: "Vevaphon", isActive: false, healthMod: 0, resolveMod: 0 } }, { name: "Morphic Strike", type: "specialty", img: "systems/slazero2/assets/racial/natural-weapons.svg", system: { category: "racial", description: "Natural biogenetic weapon formed from the Vevaphon's own mass. Damage 2, AP 1, ROF 2. Always available regardless of morph form. Counts as melee weapon for skill rolls.", effects: "Natural weapon: DMG 2, AP 1, ROF 2. Always available.", racialBonuses: [], prerequisites: "Vevaphon", isActive: true, healthMod: 0, resolveMod: 0, isNaturalWeapon: true, naturalWeaponDamage: 2, naturalWeaponAP: 1, naturalWeaponROF: 2 } } ] }; // ─── Auto-embed racial abilities ───────────────────────────────────────────── async function _ensureRacialAbilities(actor) { if (!actor || actor.type !== "character") return; const race = String(actor.system?.race || "") .toLowerCase() .replace(/[\s-]/g, "") .replace("313-s", "") .trim(); const abilities = SLA_RACIAL_ABILITY_DATA[race]; if (!abilities || !abilities.length) return; const toCreate = []; const toUpdate = []; for (const ability of abilities) { const existing = actor.items.find( i => i.type === "specialty" && i.name === ability.name ); if (!existing) { toCreate.push(ability); } else { const patch = {}; if (ability.img && existing.img !== ability.img) patch.img = ability.img; // Patch natural weapon fields if missing or stale if (ability.system?.isNaturalWeapon && !existing.system?.isNaturalWeapon) { patch["system.isNaturalWeapon"] = true; patch["system.naturalWeaponDamage"] = ability.system.naturalWeaponDamage ?? 2; patch["system.naturalWeaponAP"] = ability.system.naturalWeaponAP ?? 1; patch["system.naturalWeaponROF"] = ability.system.naturalWeaponROF ?? 2; } if (Object.keys(patch).length) toUpdate.push({ _id: existing.id, ...patch }); } } if (toUpdate.length) { try { await actor.updateEmbeddedDocuments("Item", toUpdate); } catch(_) {} } if (!toCreate.length) return; try { await actor.createEmbeddedDocuments("Item", toCreate); ui.notifications.info( `${actor.name}: racial abilities embedded (${toCreate.map(a => a.name).join(", ")})` ); } catch (err) { console.warn("SLA Zero Two | Failed to embed racial abilities:", err); } } // ─── Hooks ─────────────────────────────────────────────────────────────────── // Auto-embed when a new actor is created Hooks.on("createActor", async (actor) => { if (!game.user?.isGM) return; await _ensureRacialAbilities(actor); }); // On boot, ensure all existing character actors have their racial abilities Hooks.once("ready", function() { if (!game.user?.isGM) return; (async () => { let count = 0; for (const actor of game.actors ?? []) { if (actor.type !== "character") continue; const race = String(actor.system?.race || "").toLowerCase(); if (!race || race === "undefined") continue; const before = actor.items.filter(i => i.system?.category === "racial").length; await _ensureRacialAbilities(actor); const after = actor.items.filter(i => i.system?.category === "racial").length; if (after > before) count++; } if (count > 0) console.log(`SLA Zero Two | Embedded racial abilities on ${count} actor(s).`); })(); }); // Stormer Regeneration: heal 1 HP every real-time hour (GM only) // Uses in-game time advancement via updateWorldTime hook Hooks.on("updateWorldTime", async (worldTime, delta) => { if (!game.user?.isGM) return; // Only trigger on meaningful time advances (≥ 3600 seconds = 1 hour) if (Math.abs(delta) < 3600) return; const hoursElapsed = Math.floor(Math.abs(delta) / 3600); if (hoursElapsed < 1) return; for (const actor of game.actors ?? []) { if (actor.type !== "character") continue; if (!actor.items.some(i => i.name === "Stormer Regeneration" && i.type === "specialty")) continue; const hp = Number(actor.system?.derivedStats?.health?.value ?? 0); const hpMax = Number(actor.system?.derivedStats?.health?.max ?? 0); if (hp <= 0 || hp >= hpMax) continue; const heal = Math.min(hoursElapsed, hpMax - hp); const newHp = hp + heal; await actor.update({ "system.derivedStats.health.value": newHp }); ChatMessage.create({ speaker: { alias: actor.name }, content: `
⚡ Stormer Regeneration
${actor.name} regenerates +${heal} HP (${hp} → ${newHp}/${hpMax}).
` }); } }); // ══════════════════════════════════════════════════════════════════════════════ // SLA INDUSTRIES — AMMO SYSTEM // Caliber-based ammunition with Standard / AP / HE rounds. // Bullet Tax: credits are deducted when rounds are fired. // ══════════════════════════════════════════════════════════════════════════════ const SLA_AMMO_CATALOG = { // ── Pistol calibres ────────────────────────────────────────────────────── "10mm": { label: "10mm Pistol", fitWeaponTypes: ["pistol", "pistol smg"], magazineDefault: 15, rounds: { standard: { name: "10mm Standard", costPerRound: 5, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Standard ball round. Reliable, cheap." }, ap: { name: "10mm Armor Piercing", costPerRound: 15, apBonus: 2, damageMod: 0, blastRadius: 0, description: "+2 AP. Penetrates light corporate armor." }, he: { name: "10mm High Explosive", costPerRound: 35, apBonus: 0, damageMod: 1, blastRadius: 0, description: "+1 damage on impact. Fragmentation effect." } } }, ".44": { label: ".44 Revolver", fitWeaponTypes: ["revolver"], magazineDefault: 6, rounds: { standard: { name: ".44 Standard", costPerRound: 8, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Standard heavy revolver round." }, ap: { name: ".44 Armor Piercing", costPerRound: 24, apBonus: 2, damageMod: 0, blastRadius: 0, description: "+2 AP. Hardened penetrator." }, he: { name: ".44 High Explosive", costPerRound: 45, apBonus: 0, damageMod: 1, blastRadius: 0, description: "+1 damage. Cavity round." } } }, ".454": { label: ".454 Heavy Pistol", fitWeaponTypes: ["pistol"], magazineDefault: 12, rounds: { standard: { name: ".454 Standard", costPerRound: 10, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Large-bore semi-auto pistol round." }, ap: { name: ".454 Armor Piercing", costPerRound: 28, apBonus: 2, damageMod: 0, blastRadius: 0, description: "+2 AP. Corporate armor buster." }, he: { name: ".454 High Explosive", costPerRound: 50, apBonus: 0, damageMod: 1, blastRadius: 0, description: "+1 damage. Devastating at close range." } } }, ".50": { label: ".50 Heavy Pistol", fitWeaponTypes: ["pistol"], magazineDefault: 8, rounds: { standard: { name: ".50 Standard", costPerRound: 12, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Anti-armor pistol cartridge." }, ap: { name: ".50 Armor Piercing", costPerRound: 35, apBonus: 3, damageMod: 0, blastRadius: 0, description: "+3 AP. Punches through vehicle glass." }, he: { name: ".50 High Explosive", costPerRound: 60, apBonus: 0, damageMod: 2, blastRadius: 0, description: "+2 damage. Explosive tip." } } }, // ── SMG calibres ───────────────────────────────────────────────────────── "9mm": { label: "9mm SMG", fitWeaponTypes: ["smg", "machine pistol"], magazineDefault: 30, rounds: { standard: { name: "9mm Standard", costPerRound: 4, apBonus: 0, damageMod: 0, blastRadius: 0, description: "High-velocity SMG round." }, ap: { name: "9mm Armor Piercing", costPerRound: 12, apBonus: 1, damageMod: 0, blastRadius: 0, description: "+1 AP. Drill-tip penetrator." }, he: { name: "9mm High Explosive", costPerRound: 28, apBonus: 0, damageMod: 1, blastRadius: 0, description: "+1 damage. Micro-charge detonation." } } }, "3mm": { label: "3mm Machine Pistol", fitWeaponTypes: ["machine pistol"], magazineDefault: 20, rounds: { standard: { name: "3mm Standard", costPerRound: 3, apBonus: 0, damageMod: 0, blastRadius: 0, description: "High-rate tiny caliber round. Cheap, disposable." }, ap: { name: "3mm Armor Piercing", costPerRound: 9, apBonus: 1, damageMod: 0, blastRadius: 0, description: "+1 AP. Needle penetrator tip." }, he: { name: "3mm High Explosive", costPerRound: 20, apBonus: 0, damageMod: 1, blastRadius: 0, description: "+1 damage. Micro-explosive." } } }, // ── Assault/Carbine rifle calibres ──────────────────────────────────────── "5.56mm": { label: "5.56mm NATO", fitWeaponTypes: ["rifle", "assault rifle", "carbine"], magazineDefault: 30, rounds: { standard: { name: "5.56mm Standard", costPerRound: 8, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Standard assault rifle round." }, ap: { name: "5.56mm Armor Piercing", costPerRound: 22, apBonus: 3, damageMod: 0, blastRadius: 0, description: "+3 AP. Military-grade tungsten tip." }, he: { name: "5.56mm High Explosive", costPerRound: 45, apBonus: 0, damageMod: 2, blastRadius: 1, description: "+2 damage, 1m blast. Detonates on impact." } } }, "7mm": { label: "7mm SLA Carbine", fitWeaponTypes: ["carbine", "lmg"], magazineDefault: 24, rounds: { standard: { name: "7mm Standard", costPerRound: 10, apBonus: 0, damageMod: 0, blastRadius: 0, description: "SLA-standard intermediate carbine round." }, ap: { name: "7mm Armor Piercing", costPerRound: 28, apBonus: 3, damageMod: 0, blastRadius: 0, description: "+3 AP. Composite penetrator." }, he: { name: "7mm High Explosive", costPerRound: 55, apBonus: 0, damageMod: 2, blastRadius: 1, description: "+2 damage, 1m blast." } } }, // ── Battle rifle / Sniper ───────────────────────────────────────────────── "7.62mm": { label: "7.62mm Battle Rifle", fitWeaponTypes: ["battle rifle", "lmg", "sniper"], magazineDefault: 20, rounds: { standard: { name: "7.62mm Standard", costPerRound: 12, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Heavy battle rifle cartridge." }, ap: { name: "7.62mm Armor Piercing", costPerRound: 35, apBonus: 4, damageMod: 0, blastRadius: 0, description: "+4 AP. Penetrates medium body armor." }, he: { name: "7.62mm High Explosive", costPerRound: 70, apBonus: 0, damageMod: 3, blastRadius: 2, description: "+3 damage, 2m blast. Anti-vehicle." } } }, "8mm": { label: "8mm Sniper", fitWeaponTypes: ["sniper rifle", "sniper"], magazineDefault: 10, rounds: { standard: { name: "8mm Standard", costPerRound: 14, apBonus: 0, damageMod: 0, blastRadius: 0, description: "High-precision sniper cartridge." }, ap: { name: "8mm Armor Piercing", costPerRound: 40, apBonus: 4, damageMod: 0, blastRadius: 0, description: "+4 AP. Long-range penetration round." }, he: { name: "8mm High Explosive", costPerRound: 75, apBonus: 0, damageMod: 3, blastRadius: 0, description: "+3 damage. Detonates inside target." } } }, // ── Shotgun ─────────────────────────────────────────────────────────────── "12g": { label: "12-Gauge Shotgun", fitWeaponTypes: ["shotgun"], magazineDefault: 8, rounds: { standard: { name: "12g Buckshot", costPerRound: 18, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Multi-pellet scatter. Devastating close range." }, ap: { name: "12g Sabot Slug", costPerRound: 45, apBonus: 2, damageMod: 0, blastRadius: 0, description: "+2 AP. Single hardened slug." }, he: { name: "12g Frag Shell", costPerRound: 80, apBonus: 0, damageMod: 2, blastRadius: 1, description: "+2 damage, 1m blast. Explosive round." } } }, // ── Heavy weapon calibres ───────────────────────────────────────────────── "12.7mm": { label: "12.7mm Heavy Autocannon", fitWeaponTypes: ["hw", "heavy weapon", "autocannon"], magazineDefault: 20, rounds: { standard: { name: "12.7mm Standard", costPerRound: 25, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Heavy caliber autocannon round." }, ap: { name: "12.7mm Armor Piercing", costPerRound: 65, apBonus: 5, damageMod: 0, blastRadius: 0, description: "+5 AP. Penetrates vehicle hull." }, he: { name: "12.7mm High Explosive", costPerRound: 120, apBonus: 0, damageMod: 4, blastRadius: 3, description: "+4 damage, 3m blast. Anti-materiel." } } }, // ── Specialist calibres ─────────────────────────────────────────────────── "40mm": { label: "40mm Grenade", fitWeaponTypes: ["grenade", "launcher"], magazineDefault: 6, rounds: { standard: { name: "40mm Fragmentation", costPerRound: 80, apBonus: 0, damageMod: 0, blastRadius: 3, description: "Standard fragmentation grenade round. 3m blast." }, ap: { name: "40mm HEAT", costPerRound: 180, apBonus: 5, damageMod: 2, blastRadius: 1, description: "+5 AP, +2 damage. High-explosive anti-tank." }, he: { name: "40mm Incendiary", costPerRound: 140, apBonus: 0, damageMod: 3, blastRadius: 4, description: "+3 damage, 4m blast. Burns on contact." } } }, "Rail": { label: "Rail Cannon Slug", fitWeaponTypes: ["rail gun"], magazineDefault: 5, rounds: { standard: { name: "Rail Slug Standard", costPerRound: 50, apBonus: 0, damageMod: 0, blastRadius: 0, description: "Electromagnetically accelerated tungsten slug." }, ap: { name: "Rail Slug AP", costPerRound: 120, apBonus: 6, damageMod: 0, blastRadius: 0, description: "+6 AP. Penetrates any personal armor." }, he: { name: "Rail Slug Explosive", costPerRound: 200, apBonus: 0, damageMod: 5, blastRadius: 2, description: "+5 damage, 2m blast. Catastrophic terminal effect." } } } }; // Weapon type → default caliber mapping (matches weaponType field) // Used as fallback only — explicit caliber on the item always takes priority. const SLA_WEAPON_DEFAULT_CALIBER = { "Revolver": ".44", "Pistol": "10mm", "Machine Pistol": "3mm", "SMG": "9mm", "Carbine": "7mm", "Rifle": "5.56mm", "Assault Rifle": "5.56mm", "Battle Rifle": "7.62mm", "Sniper": "7.62mm", "Sniper Rifle": "8mm", "LMG": "7mm", "HW": "12.7mm", "Heavy Weapon": "12.7mm", "Autocannon": "12.7mm", "Shotgun": "12g", "Launcher": "40mm", "Grenade": "40mm", "Rail Gun": "Rail" }; /** * Get the cost per round for a weapon's current round type. * Reads `caliber` and `roundType` from weaponData. * Falls back to default caliber for weaponType if caliber not set. */ function _getAmmoCostForWeapon(weaponData) { const caliber = weaponData.caliber || SLA_WEAPON_DEFAULT_CALIBER[weaponData.weaponType] || null; if (!caliber) return 0; const catalog = SLA_AMMO_CATALOG[caliber]; if (!catalog) return 0; const roundType = (weaponData.roundType || weaponData.ammoType || "standard").toLowerCase(); const rd = catalog.rounds[roundType] || catalog.rounds.standard; return rd ? rd.costPerRound : 0; } /** * Get AP and damage modifiers from the current round type. */ function _getAmmoModifiers(weaponData) { const caliber = weaponData.caliber || SLA_WEAPON_DEFAULT_CALIBER[weaponData.weaponType] || null; if (!caliber) return { apBonus: 0, damageMod: 0, blastRadius: 0 }; const catalog = SLA_AMMO_CATALOG[caliber]; if (!catalog) return { apBonus: 0, damageMod: 0, blastRadius: 0 }; const roundType = (weaponData.roundType || weaponData.ammoType || "standard").toLowerCase(); const rd = catalog.rounds[roundType] || catalog.rounds.standard; return rd ? { apBonus: rd.apBonus, damageMod: rd.damageMod, blastRadius: rd.blastRadius } : { apBonus: 0, damageMod: 0, blastRadius: 0 }; } /** * Deduct bullet tax from actor credits and update session ammo log. * Bullet tax = 2× the base round cost (ammo purchase price + field levy). * Called immediately when a weapon fires. * Two separate update() calls so a finances schema issue never blocks the credit deduction. */ async function _applyBulletTax(actor, weaponData, roundsFired) { if (!actor || roundsFired <= 0) return 0; const costPerRound = _getAmmoCostForWeapon(weaponData); if (costPerRound <= 0) return 0; // Bullet tax is DOUBLED: base cost (ammo) + tax of equal value = 2× const totalCost = costPerRound * roundsFired * 2; // Fall back to 500 (template default) not 0 — actors created before the // credits field existed have undefined here, not zero. const storedCredits = actor.system?.details?.credits; const currentCredits = (storedCredits !== undefined && storedCredits !== null) ? Number(storedCredits) : 500; const newCredits = Math.max(0, currentCredits - totalCost); // ── Credit deduction — critical, standalone update ────────────────────── try { await actor.update({ "system.details.credits": newCredits }); console.log(`SLA Zero Two | Bullet Tax: ${actor.name} credits ${currentCredits}¢ → ${newCredits}¢ (−${totalCost}¢)`); } catch (err) { console.error("SLA Zero Two | Bullet Tax credit deduction failed:", err); return 0; } // ── Finances tracking — non-critical, separate update ─────────────────── try { const sessionSpent = Number(actor.system?.finances?.ammoSpentSession ?? 0) + totalCost; const bulletTaxRun = Number(actor.system?.finances?.expenses?.bulletTax ?? 0) + totalCost; await actor.update({ "system.finances.ammoSpentSession": sessionSpent, "system.finances.expenses.bulletTax": bulletTaxRun }); } catch (_) { // Finances tracking is optional — ignore failures silently } return totalCost; } /** * Build a complete ammo stock for a weapon. * Returns array of ammo item data objects (all 3 round types). */ function _buildAmmoStockForWeapon(weaponName, caliber) { const catalog = SLA_AMMO_CATALOG[caliber]; if (!catalog) return []; const mag = catalog.magazineDefault; return Object.entries(catalog.rounds).map(([type, rd]) => ({ name: rd.name, type: "ammo", system: { description: rd.description, caliber, roundType: type, quantity: type === "standard" ? mag * 3 : mag, // 3 mags standard, 1 each for AP/HE costPerRound: rd.costPerRound, apBonus: rd.apBonus, damageMod: rd.damageMod, blastRadius: rd.blastRadius, weight: 0 } })); } // ── Hook: Auto-set caliber on weapons when created or updated ───────────────── Hooks.on("createItem", async (item, _options, userId) => { if (game.user?.id !== userId) return; if (item.type !== "weapon") return; const wt = item.system?.weaponType || ""; if (!item.system?.caliber && SLA_WEAPON_DEFAULT_CALIBER[wt]) { const caliber = SLA_WEAPON_DEFAULT_CALIBER[wt]; const catalog = SLA_AMMO_CATALOG[caliber]; if (!catalog) return; await item.update({ "system.caliber": caliber, "system.roundType": "standard", "system.roundCostStd": catalog.rounds.standard.costPerRound, "system.roundCostAP": catalog.rounds.ap.costPerRound, "system.roundCostHE": catalog.rounds.he.costPerRound }); } }); // ── Chat helper: show bullet tax in weapon result messages ──────────────────── function _formatBulletTaxLine(totalCost, roundsFired, costPerRound, roundType, caliber) { if (!totalCost) return ''; const label = caliber ? `${caliber} ${roundType}` : roundType; return `
💸 Bullet Tax: ${roundsFired}× ${label} @ ${costPerRound}c = −${totalCost}c
`; } // ══════════════════════════════════════════════════════════════════════════════ // ZERO ENGINE — CHARACTER SHEET MARK 2 // ══════════════════════════════════════════════════════════════════════════════ class ZeroEngineActorSheetMk2 extends ZeroEngineActorSheet { /** Override the base class's hardcoded template path */ get template() { return "systems/slazero2/templates/actor/character-sheet-mk2.hbs"; } static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { classes: ["slazero2", "sheet", "actor"], template: "systems/slazero2/templates/actor/character-sheet-mk2.hbs", width: 760, height: 720, tabs: [], resizable: true }); } async getData(options = {}) { const context = await super.getData(options); // ── Safe finances defaults ───────────────────────────────────────────── if (!context.system.finances) { context.system.finances = { income: { salary: 0, bpnReward: 0, other: 0 }, expenses: { accommodation: 0, drugs: 0, subscriptions: 0, other: 0, bulletTax: 0 }, debt: 0, ammoSpentSession: 0 }; } const inc = context.system.finances.income || {}; const exp = context.system.finances.expenses || {}; context.weeklyIncome = (inc.salary||0) + (inc.bpnReward||0) + (inc.other||0); context.weeklyExpenses = (exp.accommodation||0) + (exp.drugs||0) + (exp.subscriptions||0) + (exp.other||0) + (exp.bulletTax||0); context.weeklyNet = context.weeklyIncome - context.weeklyExpenses; context.weeklyNetSign = context.weeklyNet >= 0 ? '+' : ''; context.weeklyNetColor = context.weeklyNet >= 0 ? '#44cc66' : '#cc1111'; // ── Attribute groups — each attribute with its 3 skills underneath ─── const ATTR_SKILL_MAP = { strength: { label: 'STR', colorClass: 'str-color', skills: ['force','melee','stamina'] }, agility: { label: 'AGI', colorClass: 'agi-color', skills: ['marksmanship','mobility','stealth'] }, wits: { label: 'WIT', colorClass: 'wit-color', skills: ['crafting','observation','survival'] }, empathy: { label: 'EMP', colorClass: 'emp-color', skills: ['healing','insight','persuasion'] } }; context.attributeGroups = Object.entries(ATTR_SKILL_MAP).map(([attrKey, cfg]) => { const attrVal = context.system.attributes?.[attrKey]?.value || 2; return { key: attrKey, label: cfg.label, colorClass: cfg.colorClass, value: attrVal, skills: cfg.skills.map(sk => { const skill = context.system.skills?.[sk] || { value: 0, label: sk }; return { key: sk, label: skill.label || sk, value: skill.value || 0, attribute: attrKey, dots: Array.from({length: 5}, (_, i) => ({ filled: i < (skill.value || 0) })) }; }) }; }); // ── Flat skills list (for ebb skill separately) ─────────────────────── const skillDotCount = 5; context.skillsWithDots = Object.entries(context.system.skills || {}).map(([key, skill]) => ({ key, label: skill.label || key, value: skill.value || 0, attribute: skill.attribute || '', dots: Array.from({length: skillDotCount}, (_, i) => ({ filled: i < (skill.value || 0) })) })); context.attributeList = Object.entries(context.system.attributes || {}).map(([key, attr]) => ({ key, value: attr.value || 2, label: key.toUpperCase().slice(0, 3) })); // ── Weapon shot options for each weapon ──────────────────────────────── context.weaponItems = (context.items || []).filter(i => i.type === 'weapon').map(w => { const fireModes = Array.isArray(w.system?.fireModes) ? w.system.fireModes : ['single']; const shots = _getShotOptions(fireModes); const caliber = w.system?.caliber || SLA_WEAPON_DEFAULT_CALIBER?.[w.system?.weaponType] || ''; const roundType = w.system?.roundType || 'standard'; const costStd = w.system?.roundCostStd || 0; const costAP = w.system?.roundCostAP || 0; const costHE = w.system?.roundCostHE || 0; const currentCost = roundType === 'ap' ? costAP : roundType === 'he' ? costHE : costStd; return { ...w, caliber, roundType, currentCost, multiShot: shots.length > 1, shotOptions: shots }; }); return context; } activateListeners(html) { super.activateListeners(html); const root = html instanceof jQuery ? html[0] : html; // ── Tab switching (new template uses .mk2-nav-tab / .mk2-panel) ────────── // Tab switching — template uses .mk2-tab, panels use data-panel matching data-tab root.querySelectorAll('.mk2-tab').forEach(tab => { tab.addEventListener('click', () => { root.querySelectorAll('.mk2-tab').forEach(t => t.classList.remove('active')); root.querySelectorAll('.mk2-panel').forEach(p => p.classList.remove('active')); tab.classList.add('active'); const panel = root.querySelector(`.mk2-panel[data-panel="${tab.dataset.tab}"]`); if (panel) panel.classList.add('active'); }); }); // ── Round type selector ──────────────────────────────────────────────── root.querySelectorAll('.round-select').forEach(btn => { btn.addEventListener('click', async () => { const itemId = btn.dataset.itemId; const round = btn.dataset.round; const item = this.actor.items.get(itemId); if (!item) return; await item.update({ "system.roundType": round }); }); }); // ── Mk2 critical buttons ─────────────────────────────────────────────── root.querySelector('.physical-critical-btn')?.addEventListener('click', ev => { ev.preventDefault(); this._rollPhysicalCritical(ev); }); root.querySelector('.mental-critical-btn')?.addEventListener('click', ev => { ev.preventDefault(); this._rollMentalCritical(ev); }); // ── Ammo quantity inline edit ────────────────────────────────────────── root.querySelectorAll('.ammo-qty-input').forEach(input => { input.addEventListener('change', async () => { const itemId = input.dataset.itemId; const item = this.actor.items.get(itemId); if (!item) return; await item.update({ "system.quantity": parseInt(input.value) || 0 }); }); }); // ── Net income live update ───────────────────────────────────────────── const netEl = root.querySelector('#mk2-net-income'); const updateNet = () => { if (!netEl) return; const salary = parseInt(root.querySelector('[name="system.finances.income.salary"]')?.value) || 0; const bpn = parseInt(root.querySelector('[name="system.finances.income.bpnReward"]')?.value) || 0; const other = parseInt(root.querySelector('[name="system.finances.income.other"]')?.value) || 0; const acc = parseInt(root.querySelector('[name="system.finances.expenses.accommodation"]')?.value) || 0; const drugs = parseInt(root.querySelector('[name="system.finances.expenses.drugs"]')?.value) || 0; const subs = parseInt(root.querySelector('[name="system.finances.expenses.subscriptions"]')?.value) || 0; const othExp = parseInt(root.querySelector('[name="system.finances.expenses.other"]')?.value) || 0; const net = (salary + bpn + other) - (acc + drugs + subs + othExp); netEl.textContent = `${net >= 0 ? '+' : ''}${net}c`; netEl.style.color = net >= 0 ? '#44cc66' : '#cc1111'; }; root.querySelectorAll('[name^="system.finances"]').forEach(el => { el.addEventListener('input', updateNet); }); updateNet(); } } // (Mk2 sheet registration moved to the main init hook above) // ══════════════════════════════════════════════════════════════════════════════ // SLA INDUSTRIES — COMBAT CONDITIONS + MULTI-SHOT SYSTEM // ══════════════════════════════════════════════════════════════════════════════ // ─── CONDITION DEFINITIONS ─────────────────────────────────────────────────── const SLA_CONDITIONS = [ { id: "sla-bleeding", label: "Bleeding", icon: "icons/svg/blood.svg", hpPerTurn: 1, diceModifiers: {}, description: "Lose 1 HP at the start of each turn. Cleared by any healing." }, { id: "sla-stunned", label: "Stunned", icon: "icons/svg/daze.svg", hpPerTurn: 0, diceModifiers: { all: -3 }, description: "-3 dice to all rolls. Clears at end of turn." }, { id: "sla-pinned", label: "Pinned", icon: "icons/svg/target.svg", hpPerTurn: 0, diceModifiers: { melee: -2, marksmanship: -2, mobility: -2 }, description: "-2 dice on attacks and mobility. Requires cover action to clear." }, { id: "sla-suppressed", label: "Suppressed", icon: "icons/svg/cowled.svg", hpPerTurn: 0, diceModifiers: { all: -2 }, description: "-2 dice to all actions. Cleared by passing a STAMINA roll." }, { id: "sla-on-fire", label: "On Fire", icon: "icons/svg/fire.svg", hpPerTurn: 2, escalating: true, diceModifiers: { all: -1 }, description: "Lose 2+ HP/turn (escalates). -1 die to all rolls. Requires action to extinguish." }, { id: "sla-blinded", label: "Blinded", icon: "icons/svg/blind.svg", hpPerTurn: 0, diceModifiers: { marksmanship: -3, melee: -2, observation: -3 }, description: "-3 dice on ranged attacks and observation. -2 on melee." }, { id: "sla-deafened", label: "Deafened", icon: "icons/svg/deaf.svg", hpPerTurn: 0, diceModifiers: { observation: -1 }, description: "-1 die to observation. Cannot hear audio cues." }, { id: "sla-broken", label: "Broken", icon: "icons/svg/skull.svg", hpPerTurn: 0, diceModifiers: { all: -2 }, description: "-2 dice to all rolls. Must roll STAMINA to act. Cleared by healing to above 0 HP." }, { id: "sla-winded", label: "Winded", icon: "icons/svg/daze.svg", hpPerTurn: 0, diceModifiers: { all: -1 }, description: "Winded — -1 die to all rolls. Clears at end of round." }, { id: "sla-broken-arm", label: "Broken Arm", icon: "icons/svg/blood.svg", hpPerTurn: 0, diceModifiers: { melee: -2, marksmanship: -1, force: -1 }, description: "Broken arm — cannot use that arm; -2 Melee, -1 Marksmanship, -1 Force." }, { id: "sla-broken-leg", label: "Broken Leg", icon: "icons/svg/blood.svg", hpPerTurn: 0, diceModifiers: { mobility: -2, stealth: -1 }, description: "Broken leg — cannot run; -2 Mobility, -1 Stealth." }, { id: "sla-concussed", label: "Concussed", icon: "icons/svg/daze.svg", hpPerTurn: 0, diceModifiers: { all: -1, observation: -1, wits: -1 }, description: "Concussed — -1 die to all rolls, extra -1 to Observation/Wits-based rolls." }, { id: "sla-gut-wound", label: "Gut Wound", icon: "icons/svg/blood.svg", hpPerTurn: 0, diceModifiers: { all: -2 }, description: "Gut wound — severe pain, -2 dice to all rolls." }, { id: "sla-panicking", label: "Panicking", icon: "icons/svg/terror.svg", hpPerTurn: 0, diceModifiers: { all: -2 }, description: "Panicking — cannot act normally. -2 dice to all rolls." }, { id: "sla-shaken", label: "Shaken", icon: "icons/svg/terror.svg", hpPerTurn: 0, diceModifiers: { all: -1 }, description: "Shaken — rattled by horror or trauma. -1 die to all rolls." } ]; // Conditions are registered in the main init hook (search for "SLA_CONDITIONS" above this line) // ─── CONDITION MODIFIER HELPER ──────────────────────────────────────────────── /** * Play a short "ta-da" fanfare using the Web Audio API — no external files needed. */ function _playShowingOffFanfare() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); // Ascending major arpeggio: C5 E5 G5 C6 const notes = [523.25, 659.25, 783.99, 1046.5]; notes.forEach((freq, i) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; osc.frequency.value = freq; const t = ctx.currentTime + i * 0.13; gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(0.35, t + 0.04); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28); osc.start(t); osc.stop(t + 0.30); }); } catch(_) { /* WebAudio unavailable — silent fallback */ } } /** * Post a whispered GM report of every PC's current conditions, HP, Resolve, and Stress. * Called hourly from the ready hook and on demand from the STATUS button on any sheet. */ async function _broadcastPCConditions() { if (!game.user?.isGM) return; const pcs = (game.actors ?? []).filter(a => a.type === 'character' && a.system?.details?.isPlayerCharacter === true ); if (pcs.length === 0) { ui.notifications.info('SLA Zero Two | No PCs found for condition report.'); return; } const condLabels = Object.fromEntries( SLA_CONDITIONS.map(c => [c.id, { label: c.label, desc: c.description }]) ); let rows = ''; for (const pc of pcs) { const hp = Number(pc.system.derivedStats?.health?.value ?? 0); const hpMax = Number(pc.system.derivedStats?.health?.max ?? 0); const res = Number(pc.system.derivedStats?.resolve?.value ?? 0); const resMax = Number(pc.system.derivedStats?.resolve?.max ?? 0); const stress = Number(pc.system.derivedStats?.stress ?? 0); const soTally = Number(pc.system.details?.showingOff ?? 0); const hpColour = hp <= 0 ? '#ff2222' : hp <= hpMax * 0.3 ? '#ff8800' : '#44cc66'; const resColour = res <= 0 ? '#ff2222' : res <= resMax * 0.3 ? '#ff8800' : '#44ccff'; const stressColour = stress >= 8 ? '#ff2222' : stress >= 5 ? '#ff8800' : '#aaaaaa'; const soColour = soTally >= 20 ? '#ff2222' : soTally >= 10 ? '#ff8800' : soTally > 0 ? '#ffcc00' : '#555'; const activeConds = [...(pc.statuses ?? new Set())] .filter(id => condLabels[id]) .map(id => `${condLabels[id].label} (${condLabels[id].desc})`) .join(', '); rows += `
${pc.name}
❤ HP ${hp}/${hpMax}   🧠 Resolve ${res}/${resMax}   ⚡ Stress ${stress}   ✨ Showing Off ${soTally}
${activeConds || 'No active conditions'}
`; } const now = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); await ChatMessage.create({ speaker: { alias: '⚠ SLA Zero Two — PC Status Report' }, content: `
Status Report — ${now} — ${pcs.length} PC${pcs.length !== 1 ? 's' : ''}
${rows}
`, whisper: [game.user.id] }); } /** * Apply a status condition to an actor reliably. * Tries actor.toggleStatusEffect first (Foundry v14 API). If that fails or * the condition still isn't in actor.statuses afterward, falls back to creating * an ActiveEffect directly so conditions always land even if toggleStatusEffect * is unavailable or throws. * @param {Actor} actor * @param {string} condId e.g. "sla-panicking" * @param {string} [label] display name fallback * @param {string} [icon] icon path fallback */ async function _applyStatusCondition(actor, condId, label, icon) { // Skip if already active if (actor.statuses?.has(condId)) return; // Try the standard API first let applied = false; try { await actor.toggleStatusEffect(condId, { active: true }); applied = actor.statuses?.has(condId) ?? false; } catch(e) { console.warn(`SLA Zero Two | toggleStatusEffect failed for "${condId}":`, e); } if (!applied) { // Fallback: look up from SLA_CONDITIONS or use provided label/icon const condDef = SLA_CONDITIONS.find(c => c.id === condId); const effectName = condDef?.label ?? label ?? condId; const effectIcon = condDef?.icon ?? icon ?? 'icons/svg/aura.svg'; try { await actor.createEmbeddedDocuments('ActiveEffect', [{ name: effectName, img: effectIcon, icon: effectIcon, statuses: [condId], disabled: false }]); console.log(`SLA Zero Two | Applied "${condId}" via fallback ActiveEffect on ${actor.name}`); } catch(e2) { console.error(`SLA Zero Two | Could not apply condition "${condId}" on ${actor.name}:`, e2); } } } /** * Returns dice-pool penalty from any active SLA conditions on this actor. * Checks actor.statuses (Foundry v14 Set) and system.derivedStats.broken. */ function _getConditionModifiers(actor, { skill = "", attribute = "" } = {}) { const statuses = actor.statuses ?? new Set(); let bonus = 0; const breakdown = []; const sk = String(skill || "").toLowerCase(); const at = String(attribute || "").toLowerCase(); // ── Built-in SLA conditions ───────────────────────────────────────────────── // Bug fix (v1.7.6): "all" and skill/attribute-specific modifiers now STACK // (e.g. Concussed = -1 all + -1 observation = -2 to Observation rolls). // Attribute keys (e.g. "wits") are now respected too. for (const cond of SLA_CONDITIONS) { if (!statuses.has(cond.id)) continue; const mods = cond.diceModifiers || {}; if (mods.all) { bonus += mods.all; breakdown.push(`${cond.label} ${mods.all}`); } if (sk && mods[sk] !== undefined && mods[sk] !== null) { bonus += mods[sk]; breakdown.push(`${cond.label} (${sk}) ${mods[sk]}`); } if (at && mods[at] !== undefined && mods[at] !== null) { bonus += mods[at]; breakdown.push(`${cond.label} (${at}) ${mods[at]}`); } } // ── Active Effects: read custom dice bonus changes ────────────────────────── // Users can attach an ActiveEffect with a change like: // key: "flags.slazero2.diceBonus.all" value: 2 (any roll) // key: "flags.slazero2.diceBonus.skills.melee" value: -1 (melee only) // key: "flags.slazero2.diceBonus.attributes.wits" value: 1 (wits only) // Mode is ignored — value is always added. This lets the GM grant "+2 dice" // buffs from arbitrary effects without editing code. for (const effect of actor.effects ?? []) { if (effect.disabled) continue; const changes = effect.changes ?? []; for (const ch of changes) { const key = String(ch.key || "").toLowerCase(); const val = Number(ch.value); if (!Number.isFinite(val) || val === 0) continue; if (!key.startsWith("flags.slazero2.dicebonus.")) continue; const rest = key.substring("flags.slazero2.dicebonus.".length); let applies = false; if (rest === "all") applies = true; else if (rest === `skills.${sk}` && sk) applies = true; else if (rest === `attributes.${at}` && at) applies = true; if (applies) { bonus += val; breakdown.push(`${effect.name || "Effect"} ${val > 0 ? "+" : ""}${val}`); } } } // Legacy broken flag — only apply while CURRENT HP is actually 0 // (The flag used to persist forever after healing, causing a permanent -2 penalty) if (actor.system?.derivedStats?.broken && !statuses.has("sla-broken")) { const currentHp = Number( typeof actor.system.derivedStats.health === "object" ? actor.system.derivedStats.health?.value : actor.system.derivedStats.health ) || 0; if (currentHp <= 0) { bonus -= 2; breakdown.push("Broken −2"); } // If HP > 0 the broken flag is stale — ignore it (cleared on next recalc) } return { bonus, breakdown }; } // ─── AUTO-DAMAGE FROM CONDITIONS ────────────────────────────────────────────── // v1.7.6: Per-round damage from Bleeding / On Fire no longer depends on the // combatant being on their turn. Conditions tick every ROUND_TICK_MS for any // actor in the world that has them, whether combat is active or not. The // previous combatTurnChange hook only fired inside combat, so a bleeding PC // who walked out of the encounter would magically stop bleeding. // // Behaviour: // • Ticks while the game is UNPAUSED (pause the game to halt bleed/fire) // • Runs on the active GM client only (single source of truth, no dupes) // • Default cadence: one YZE round = 10 real seconds // • Both PCs and NPCs are damaged so combat encounters still tick down const ROUND_TICK_MS = 10 * 1000; let _zeConditionTickHandle = null; async function _tickConditionsForActor(actor) { if (!actor || (actor.type !== "character" && actor.type !== "npc")) return; const statuses = actor.statuses ?? new Set(); const hasBleed = statuses.has("sla-bleeding"); const hasFire = statuses.has("sla-on-fire"); if (!hasBleed && !hasFire) return; const updates = {}; const messages = []; let hp = Number(actor.system?.derivedStats?.health?.value ?? 0); if (hasBleed) { const newHp = Math.max(0, hp - 1); if (newHp !== hp) { messages.push(`🩸 ${actor.name} bleeds — ${hp} → ${newHp} HP`); hp = newHp; updates["system.derivedStats.health.value"] = newHp; if (newHp <= 0) messages.push(`⚠ ${actor.name} is at 0 HP!`); } } if (hasFire) { const fireLvl = Number(actor.getFlag("slazero2", "fireLevel") ?? 1); const dmg = fireLvl + 1; const newHp = Math.max(0, hp - dmg); if (newHp !== hp) { messages.push(`🔥 ${actor.name} burns (level ${fireLvl}) — ${hp} → ${newHp} HP`); hp = newHp; updates["system.derivedStats.health.value"] = newHp; } try { await actor.setFlag("slazero2", "fireLevel", Math.min(fireLvl + 1, 5)); } catch(_) { /* token actor without persistent flags */ } } if (Object.keys(updates).length > 0) { try { await actor.update(updates); } catch(e) { console.warn("SLA Zero Two | tick condition update failed:", e); } } if (messages.length > 0) { ChatMessage.create({ speaker: { alias: "SLA Conditions" }, content: `
${messages.join('
')}
` }); } } async function _tickAllConditions() { // Only the active GM should run this — prevents N-GM duplicate damage if (!game.user?.isGM) return; const activeGM = game.users?.activeGM ?? game.users?.find(u => u.isGM && u.active); if (activeGM && activeGM.id !== game.user.id) return; if (game.paused) return; for (const actor of game.actors ?? []) { await _tickConditionsForActor(actor); } } Hooks.once("ready", () => { if (_zeConditionTickHandle) return; _zeConditionTickHandle = setInterval(() => { _tickAllConditions().catch(e => console.warn("SLA Zero Two | condition tick error:", e)); }, ROUND_TICK_MS); console.log(`SLA Zero Two | Condition tick started — ${ROUND_TICK_MS / 1000}s/round, runs while game unpaused`); }); // Clear fire level when fire is extinguished Hooks.on("updateActor", async (actor, changes) => { if (!game.user?.isGM) return; // If fire condition removed, reset fire level const statuses = actor.statuses ?? new Set(); if (!statuses.has("sla-on-fire")) { const lvl = actor.getFlag("slazero2", "fireLevel"); if (lvl) await actor.unsetFlag("slazero2", "fireLevel"); } }); // ─── CONDITION APPLICATION UI ───────────────────────────────────────────────── /** * Add condition buttons to the token HUD. * This supplements Foundry's built-in status icon system. */ Hooks.on("renderTokenHUD", (hud, html, data) => { // Foundry's built-in status icons will handle the SLA conditions // since we registered them in CONFIG.statusEffects. // No additional code needed here — the token HUD will show them. }); // ══════════════════════════════════════════════════════════════════════════════ // MULTI-SHOT SYSTEM // Allows firing multiple rounds for bonus dice at cost of ammo + bullet tax. // Shot count capped by weapon fire mode capability. // ══════════════════════════════════════════════════════════════════════════════ const SHOT_OPTIONS = [ { shots: 1, label: "1 Shot (single)", diceBonus: 0, requiresMode: ["single","semi","burst","auto"] }, { shots: 2, label: "2 Shots (+1 die)", diceBonus: 1, requiresMode: ["semi","burst","auto"] }, { shots: 3, label: "3 Shots (+2 dice)", diceBonus: 2, requiresMode: ["burst","auto"] }, { shots: 6, label: "6 Shots (+3 dice)", diceBonus: 3, requiresMode: ["auto"] }, ]; /** * Get which shot options are available for a weapon based on its fire modes. */ function _getShotOptions(fireModes) { const modes = (Array.isArray(fireModes) ? fireModes : [fireModes]).map(m => String(m).toLowerCase()); const hasAuto = modes.some(m => m === "auto" || m === "full_auto" || m === "full"); const hasBurst = modes.some(m => m === "burst"); const hasSemi = modes.some(m => m === "semi" || m === "semi_auto"); const hasSingle = modes.some(m => m === "single"); const caps = []; if (hasSingle || !hasSemi && !hasBurst && !hasAuto) caps.push("single"); if (hasSemi || hasBurst || hasAuto) caps.push("semi"); if (hasBurst || hasAuto) caps.push("burst"); if (hasAuto) caps.push("auto"); return SHOT_OPTIONS.filter(opt => opt.requiresMode.some(req => caps.includes(req)) ); } // ══════════════════════════════════════════════════════════════════════════════ // SLA INDUSTRIES — GAME PAUSED ANIMATION // Animated SVG overlay: dual rotating rings, SLA logo, scan-line, flicker // ══════════════════════════════════════════════════════════════════════════════ let _zePauseRefreshHandle = null; function _zeInjectPauseAnimation() { document.body?.classList.add("ze-pause-active"); if (document.querySelector('.ze-pause-root')) return; const html = `
SLA OPERATIVE STATUS STAND BY SLA INDUSTRIES · MORT CITY GRID
`; document.body?.insertAdjacentHTML('beforeend', html); } function _zeRemovePauseAnimation() { document.querySelector('.ze-pause-root')?.remove(); document.body?.classList.remove('ze-pause-active'); } function _zeQueuePauseRefresh(delay = 0) { window.clearTimeout(_zePauseRefreshHandle); _zePauseRefreshHandle = window.setTimeout(() => { if (!game?.paused) return; _zeInjectPauseAnimation(); }, delay); } Hooks.once('ready', () => { if (game.paused) { _zeQueuePauseRefresh(0); _zeQueuePauseRefresh(60); _zeQueuePauseRefresh(180); } }); Hooks.on('pauseGame', (paused) => { if (paused) { requestAnimationFrame(() => requestAnimationFrame(() => _zeInjectPauseAnimation())); _zeQueuePauseRefresh(60); _zeQueuePauseRefresh(200); } else { _zeRemovePauseAnimation(); } }); // ══════════════════════════════════════════════════════════════════════════════ // SLA ZERO TWO — FIRST-RUN WORLD SETUP (v2.0.1) // A brand-new world should greet the GM like SLA Industries, not a blank // Foundry install: a branded landing scene and a Start Here briefing journal. // ══════════════════════════════════════════════════════════════════════════════ Hooks.once("ready", async () => { if (!game.user?.isGM) return; const FLAG_KEY = "firstRunSetupDone"; if (game.settings.storage.get("world")) { /* settings ready */ } try { game.settings.register("slazero2", FLAG_KEY, { scope: "world", config: false, type: Boolean, default: false }); } catch (_) { /* already registered */ } if (game.settings.get("slazero2", FLAG_KEY)) return; // ── Start Here journal ──────────────────────────────────────────────────── if (!game.journal.getName("SLA ZERO TWO — START HERE")) { await JournalEntry.create({ name: "SLA ZERO TWO — START HERE", pages: [{ name: "Operative Briefing — Read First", type: "text", title: { show: false }, text: { format: 1, content: `

WELCOME TO THE WORLD OF PROGRESS

You are a Gamemaster of SLA Zero Two. This page is your first shift briefing.

1. Recommended module

Enable Dice So Nice (Game Settings → Manage Modules) for the custom SLA Zero Two 3D dice — hazard-yellow Operative dice and black-chrome Stress dice with the blood-red bane face.

2. Build the squad

In the Actors tab, press Generate PC. The wizard walks each player through race, training package, attributes, skills, and a name. Set each finished PC's Player Character flag (Biography tab) so the GM status board tracks them.

3. Build the opposition

Group NPCs stamps whole gangs with stats, art, weapons and folders — set the 1–10 threat slider to the fight you want. Generate NPC makes single archetypes.

4. Hand out work

The BPN button tracks the active Blueprint News file. Credits flow through Credits / Finance Ledger; every round fired auto-bills the Bullet Tax (rounds × cost × 2 — ammunition is billed, not counted).

5. Run the fight

Initiative is on each sheet (AGI+MOB or WIT+OBS). Attacks live on the Equipment tab — fire modes, ammo types, range, scope. Damage prompts the defender's armour roll. Conditions tick automatically. When Health or Resolve breaks, the d66 critical roll is automatic.

The cameras are always on. Encourage Showing Off. Make them pay for it.

` } }] }); console.log("SLA Zero Two | Start Here journal created"); } // ── Branded landing scene ───────────────────────────────────────────────── if (game.scenes.size === 0 || !game.scenes.find(s => s.name === "Mort — Downtown Grid")) { const scene = await Scene.create({ name: "Mort — Downtown Grid", background: { src: "systems/slazero2/assets/SLA_Assets/Maps/slaguru6666_The_words_SLA_INDUSTRIES_in_neon_80s_grafitti_on__bbbf7678-0361-4632-8a3f-4134e9a764dc_1.png" }, width: 1920, height: 1080, padding: 0, grid: { type: 0 }, tokenVision: false, fog: { exploration: false }, backgroundColor: "#0a0a0a", navigation: true }); if (game.scenes.size === 1) await scene.activate(); console.log("SLA Zero Two | Landing scene created"); } await game.settings.set("slazero2", FLAG_KEY, true); }); // ══════════════════════════════════════════════════════════════════════════════ // SLA ZERO TWO — CORE BPN GENERATOR (v2.1.0) // Blueprint News files generated in-system: colour logic, code, contact, // objectives, complication, reward. Creates a briefing journal + chat post. // (The standalone sla-blueprint-news-zero module targets the original // zero-engine system; SLA Zero Two carries its own generator in core.) // ══════════════════════════════════════════════════════════════════════════════ class SLABPNGenerator { static COLOURS = { blue: { label: "Blue", tint: "#3399ff", brief: "civic order / security", objectives: [ "Escort a Shiver patrol through {sector} and return with all bodies breathing", "Guard the {place} against gang reprisal for one full shift", "Recover a stolen SLA property crate before it crosses {sector}", "Suppress a street disturbance in {sector} without collateral on camera" ], reward: [180, 320], complication: [ "The gang has a sponsor with cameras of its own", "One of the Shivers is on the take", "The property crate is leaking something", "A rival squad has the same contract" ] }, green: { label: "Green", tint: "#44cc66", brief: "investigation / intelligence", objectives: [ "Trace the source of counterfeit KickStart flooding {sector}", "Locate a missing DAC researcher last seen near {place}", "Document serial-killer activity in {sector} and identify the pattern", "Verify or refute Carrien sightings below {place}" ], reward: [250, 450], complication: [ "The trail leads somewhere SLA does not want filmed", "The witness will only talk to an Ebon", "The evidence keeps being bought before you arrive", "Someone is running the same investigation — better funded" ] }, red: { label: "Red", tint: "#cc2222", brief: "hostile suppression", objectives: [ "Clear the {place} of Carrien infestation — flame recommended", "Terminate the gang leadership of {sector} — collateral discouraged, not forbidden", "Put down a rogue Stormer unit gone feral near {place}", "Break the siege of the {place} and hold until relieved" ], reward: [400, 700], complication: [ "They are dug in deeper than the brief admits", "Half of them used to be SLA", "The extraction window is a lie", "Something bigger is eating the gang from below" ] }, silver: { label: "Silver", tint: "#aaaacc", brief: "corporate / escort", objectives: [ "Escort a Dept. of Propaganda film crew through {sector} — keep the talent alive", "Bodyguard a corporate negotiator at the {place} summit", "Recover a defecting executive from {sector} before their employer does", "Secure the {place} for a sponsorship gala" ], reward: [350, 600], complication: [ "The principal wants to be filmed in danger", "The other side bid on your contract too", "The defector is carrying more than data", "The gala guest list includes someone who wants you dead" ] }, gold: { label: "Gold", tint: "#e3c300", brief: "high value / high oversight", objectives: [ "Retrieve a prototype Sleight asset from a crash site in {sector}", "Extract a Dark Finder informant alive from {place}", "Recover LAD-flagged remains before a rival house does", "Deliver a sealed case to {place}. Do not open the case." ], reward: [700, 1200], complication: [ "Head Office is watching this one live", "The asset has opinions about being retrieved", "The case is ticking", "Success means someone important owes you — and hates it" ] }, black: { label: "Black", tint: "#555555", brief: "deniable", objectives: [ "Erase all evidence of an operation that never happened in {sector}", "Retire a decommissioned operative quietly — no cameras, no LAD ping", "Plant evidence at the {place} and be gone before the Shivers arrive", "Escort a thing that does not appear on any manifest out of {sector}" ], reward: [600, 1000], complication: [ "There is no debrief for this one", "Your own footage is the loose end", "The target knows you are coming and has prepared a confession", "Payment routes through an account that does not exist" ] } }; static SECTORS = ["Downtown", "Suburbia", "Lower Downtown", "Cannibal Sector 1 fringe", "Sector 41", "the Fen Street corridor", "Grid Square 19", "the Pipe District"]; static PLACES = ["Karma clinic", "Third Eye News relay", "abandoned DAC lab", "Shiver station 9", "monorail interchange", "Meat Market", "sponsorship arena", "sub-level archive"]; static _pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } static _fill(text) { return text.replace("{sector}", this._pick(this.SECTORS)).replace("{place}", this._pick(this.PLACES)); } static _code(colourKey) { const digits = String(Math.floor(Math.random() * 900) + 100); const letter = "ABCDEFGHJKLMNPRSTVWX"[Math.floor(Math.random() * 20)]; return `${colourKey.charAt(0).toUpperCase()}${digits}${letter}`; } static async generate(colourKey) { const c = this.COLOURS[colourKey]; if (!c) return; const code = this._code(colourKey); const objective = this._fill(this._pick(c.objectives)); const complication = this._pick(c.complication); const reward = Math.round((c.reward[0] + Math.random() * (c.reward[1] - c.reward[0])) / 10) * 10; const contact = _slaRandomName("human"); const briefHtml = `

BPN ${code} — ${c.label.toUpperCase()}

${c.brief}


ISSUING CONTACT: ${contact}, SLA Industries

OBJECTIVE: ${objective}.

REWARD: ${reward}c per operative on completion and debrief.

NOTES: Standard rules of engagement. Ammunition is billed. The cameras are always on.

`; const gmHtml = `

GM DOSSIER — what the brief doesn't say

COMPLICATION: ${complication}.

SUGGESTED OPPOSITION: use the Group NPC Generator at threat ${colourKey === "red" || colourKey === "gold" ? "4-6" : colourKey === "black" ? "5-7" : "2-4"}.

`; const journal = await JournalEntry.create({ name: `BPN ${code} — ${c.label}`, pages: [ { name: "Operative Brief", type: "text", text: { format: 1, content: briefHtml } }, { name: "GM Dossier", type: "text", text: { format: 1, content: gmHtml }, ownership: { default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE } } ] }); await ChatMessage.create({ speaker: { alias: "SLA Industries — Dispatch" }, content: `
📠 BPN ${code} — ${c.label.toUpperCase()}
${objective}.
Reward ${reward}c/operative · contact ${contact} · brief filed in Journal
` }); journal.sheet.render(true); return journal; } static open() { const buttons = Object.entries(this.COLOURS).map(([key, c]) => ``).join(""); const dlg = new Dialog({ title: "Generate BPN", content: `
${buttons}
`, buttons: { random: { icon: '', label: "Random Colour", callback: () => this.generate(this._pick(Object.keys(this.COLOURS))) }, cancel: { icon: '', label: "Cancel" } }, default: "random", render: (html) => { const root = html instanceof HTMLElement ? html : html[0]; root.querySelectorAll(".bpn-gen-colour").forEach(btn => { btn.addEventListener("click", () => { dlg.close(); this.generate(btn.dataset.colour); }); }); } }, { classes: ["slazero2", "dialog"], width: 400 }); dlg.render(true); } } // ══════════════════════════════════════════════════════════════════════════════ // SLA ZERO TWO — ROLLTABLE PROVISIONING (v2.1.0) // The dice engine draws "Ranged/Melee Weapon Critical Fail (2-12)" rolltables // by name. Ship them (plus Panic and the d66 crit references) into any world // where they're missing. Idempotent by table name. // ══════════════════════════════════════════════════════════════════════════════ Hooks.once("ready", async () => { if (!game.user?.isGM) return; try { const resp = await fetch("systems/slazero2/packs/tables-data.json"); if (!resp.ok) return; const data = await resp.json(); let created = 0; for (const key of Object.keys(data)) { const t = data[key]; if (!t?.name || game.tables.getName(t.name)) continue; const isD66 = (t.results || []).some(r => typeof r.key === "string"); const results = (t.results || []).map(r => { if (typeof r.key === "string") { // d66 shape: key "t.o" → numeric to*1 range; compose display text const [tens, ones] = r.key.split(".").map(Number); const val = tens * 10 + ones; const tail = [r.effects, r.healing ? `Healing: ${r.healing}` : "", r.recovery ? `Recovery: ${r.recovery}` : "", r.timeLimit ? `⏱ ${r.timeLimit}` : ""].filter(Boolean).join(" — "); return { type: CONST.TABLE_RESULT_TYPES?.TEXT ?? 0, text: `${r.name}${r.lethal ? " ☠" : ""}: ${tail}`, range: [val, val], weight: 1 }; } return { type: CONST.TABLE_RESULT_TYPES?.TEXT ?? 0, text: r.text, range: r.range, weight: 1 }; }); await RollTable.create({ name: t.name, description: t.description || "", formula: isD66 ? "1d6 * 10 + 1d6" : (t.formula || "1d6").replace("@stress", "0"), replacement: true, displayRoll: true, results }); created++; } if (created > 0) { console.log(`SLA Zero Two | Created ${created} system rolltable(s)`); ui.notifications.info(`SLA Zero Two: ${created} rules rolltable(s) added to this world.`); } } catch (err) { console.warn("SLA Zero Two | Rolltable provisioning failed:", err); } });