diff --git a/assets/blade-runner-banner-small.webp b/assets/blade-runner-banner-small.webp deleted file mode 100644 index 4743698..0000000 Binary files a/assets/blade-runner-banner-small.webp and /dev/null differ diff --git a/assets/blade-runner-cover.webp b/assets/blade-runner-cover.webp deleted file mode 100644 index 1b998de..0000000 Binary files a/assets/blade-runner-cover.webp and /dev/null differ diff --git a/assets/splash.webp b/assets/splash.webp deleted file mode 100644 index e846607..0000000 Binary files a/assets/splash.webp and /dev/null differ diff --git a/slazero2.mjs b/slazero2.mjs index bb9be73..2b17162 100644 --- a/slazero2.mjs +++ b/slazero2.mjs @@ -8,6 +8,23 @@ const ZERO_ENGINE_CORE_COMBAT = { 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; @@ -391,6 +408,9 @@ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { 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; @@ -421,7 +441,7 @@ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { // 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; + 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"); @@ -1135,7 +1155,7 @@ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { const roll = await (new Roll(`${pool}d6`)).evaluate(); if (game.dice3d) { - await game.dice3d.showForRoll(roll, game.user, true); + await dsnSafeShow(roll); } const successes = (roll.dice?.[0]?.results || []).filter(r => r.result === 6).length; const failed = successes <= 0; @@ -1535,7 +1555,7 @@ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { const roll = new Roll(`${pool}d6`); await roll.evaluate(); - if (game.dice3d) await game.dice3d.showForRoll(roll, game.user, true); + if (game.dice3d) await dsnSafeShow(roll); const results = roll.dice[0]?.results || []; const successes = results.filter(r => r.result === 6).length; @@ -2556,7 +2576,7 @@ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { if (effectiveDice > 0) { armorRoll = await (new Roll(`${effectiveDice}d6`)).evaluate(); if (game.dice3d) { - await game.dice3d.showForRoll(armorRoll, game.user, true); + await dsnSafeShow(armorRoll); } armorSuccesses = (armorRoll.dice?.[0]?.results || []).filter(r => r.result === 6).length; } @@ -2656,7 +2676,7 @@ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { // Show Dice So Nice animation if available if (game.dice3d) { this._applyDiceSoNiceColors(roll, normalDice, stressDice); - await game.dice3d.showForRoll(roll, game.user, true); + await dsnSafeShow(roll); } const ebbInfo = rollOptions.ebbInfo || null; @@ -2923,7 +2943,7 @@ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { // Show Dice So Nice animation if available if (game.dice3d) { this._applyDiceSoNiceColors(reroll, normalRerollCount, stressRerollCount + addedStressDice); - await game.dice3d.showForRoll(reroll, game.user, true); + await dsnSafeShow(reroll); } // Combine locked dice with new results @@ -3158,7 +3178,7 @@ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { await roll.evaluate(); if (game.dice3d) { - await game.dice3d.showForRoll(roll, game.user, true); + await dsnSafeShow(roll); } const rollSuccesses = roll.dice[0].results.filter(r => r.result === 6).length; @@ -3358,7 +3378,7 @@ class ZeroEngineActorSheet extends foundry.appv1.sheets.ActorSheet { const roll = await (new Roll("2d6")).evaluate(); if (game.dice3d) { - await game.dice3d.showForRoll(roll, game.user, true); + await dsnSafeShow(roll); } await table.draw({ roll, displayChat: true }); } @@ -4513,16 +4533,27 @@ async function runAutoCritical(actor, kind) { }, 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: 0 }, - brainwaster: { health: 0, resolve: 0 }, - stormer: { health: 0, resolve: 0 }, - shaktar: { health: 0, resolve: 0 }, - wraith: { health: 0, resolve: 0 }, - frother: { health: 0, resolve: 0 } + 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 }, @@ -4569,7 +4600,7 @@ function getSpecialtyDerivedMod(item, stat) { function calculateHealthResolve(actor) { const attrs = actor?.system?.attributes || {}; - const raceKey = String(actor?.system?.race || "").trim().toLowerCase(); + 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); @@ -5293,7 +5324,17 @@ class SLAPCCreator extends Application { this._showStep(html, 3); this._renderAttrTable(html); this._getEl(html, '#gen-back-3').addEventListener('click', () => { this._step = 2; this._bindStep2(html); }); - this._getEl(html, '#gen-next-3').addEventListener('click', () => { this._step = 4; this._bindStep4(html); }); + 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) { @@ -5341,7 +5382,18 @@ class SLAPCCreator extends Application { this._showStep(html, 4); this._renderSkillTable(html); this._getEl(html, '#gen-back-4').addEventListener('click', () => { this._step = 3; this._bindStep3(html); }); - this._getEl(html, '#gen-next-4').addEventListener('click', () => { this._step = 5; this._bindStep5(html); }); + 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) { @@ -10363,3 +10415,77 @@ Hooks.on('pauseGame', (paused) => { _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: ` +
You are a Gamemaster of SLA Zero Two. This page is your first shift briefing.
+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.
+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.
+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.
+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).
+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.
+