v2.0.1 — evaluation cycle 1 fixes

- CRITICAL: dsnSafeShow() guard on all 7 Dice So Nice awaits — a stalled DSN
  animation froze the whole roll pipeline (attack charged Bullet Tax but never
  posted its card). Rolls now always complete within 3.5s of a DSN stall.
- RULES: racial Health/Resolve modifiers were silently dropped at runtime
  (RACE_DERIVED_MODS all zeros + three inconsistent race key sets). Canonical
  race keys via canonicalRaceKey(); Stormer +2 HP, Ebon/Wraith +1 RES,
  Shaktar/Frother +1 HP, Frother/Vevaphon -1 RES now actually apply.
- Sheet race dropdown matched 'wraith' but actors store 'wraithraider' —
  showed '-- SELECT --' for every Wraith Raider. Fixed across all sheets.
- Natural weapons (Wraith Teeth & Claw, Morphic Strike…) now appear in the
  Equipment tab's Melee Weapons list with a STRIKE button.
- PC wizard: unspent attribute/skill points now require a second click with
  an explicit '<N> points unspent — Next anyway?' prompt.
- First-run world setup: branded 'Mort — Downtown Grid' landing scene (user's
  SLA neon art) + 'START HERE' GM briefing journal on first GM login.
- Removed Blade Runner splash/banner art (wrong game, third-party trademark).
- CSS: push button no longer wraps mid-word; dice flow layout; GM toolbar
  chips unified into the v2 chrome; natural-weapon row accent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:47:35 +01:00
co-authored by Claude Fable 5
parent 6055a83545
commit 9d0146caa8
9 changed files with 214 additions and 23 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

+142 -16
View File
@@ -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 },
ebon: { health: 0, resolve: 1 },
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 }
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: `
<div style="font-family:'SLA Borg UI',sans-serif;">
<h1 style="color:#9a9a00;">WELCOME TO THE WORLD OF PROGRESS</h1>
<p><em>You are a Gamemaster of SLA Zero Two. This page is your first shift briefing.</em></p>
<h2>1. Recommended module</h2>
<p>Enable <strong>Dice So Nice</strong> (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.</p>
<h2>2. Build the squad</h2>
<p>In the <strong>Actors</strong> tab, press <strong>Generate PC</strong>. The wizard walks each player through race,
training package, attributes, skills, and a name. Set each finished PC's <strong>Player Character</strong> flag
(Biography tab) so the GM status board tracks them.</p>
<h2>3. Build the opposition</h2>
<p><strong>Group NPCs</strong> stamps whole gangs with stats, art, weapons and folders — set the 1–10 threat slider
to the fight you want. <strong>Generate NPC</strong> makes single archetypes.</p>
<h2>4. Hand out work</h2>
<p>The <strong>BPN</strong> button tracks the active Blueprint News file. Credits flow through
<strong>Credits</strong> / <strong>Finance Ledger</strong>; every round fired auto-bills the
<strong>Bullet Tax</strong> (rounds × cost × 2 — ammunition is billed, not counted).</p>
<h2>5. Run the fight</h2>
<p>Initiative is on each sheet (<strong>AGI+MOB</strong> or <strong>WIT+OBS</strong>). 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.</p>
<p style="color:#cc1111;"><strong>The cameras are always on. Encourage Showing Off. Make them pay for it.</strong></p>
</div>` }
}]
});
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);
});
+42
View File
@@ -6788,3 +6788,45 @@ body > .ze-pause-root .ze-sat-inner {
border-radius: 4px;
}
.slazero2 ::-webkit-scrollbar-thumb:hover { background: var(--sz2-yellow-dim); }
/* ── V2.0.1 evaluation fixes ─────────────────────────────────────────────── */
/* Push button: never wrap mid-word */
.yze-push-button {
white-space: nowrap;
padding: 8px 14px;
}
/* Chat dice: even flow, no orphan wrap surprises */
.yze-dice-display {
display: flex;
flex-wrap: wrap;
gap: 5px;
justify-content: flex-start;
}
/* GM toolbar chips in the Actor directory: one language, not a sticker sheet */
#actors .directory-header button,
#actors .header-actions button,
.sla-dir-status-btn, .sla-dir-credit-btn, .sla-dir-ledger-btn, .sla-dir-threat-btn,
.sla-dir-bpn-btn, .sla-dir-cond-btn, .sla-dir-groupnpc-btn, .sla-dir-finance-btn {
background: linear-gradient(180deg, #1d1d1d, #0a0a0a) !important;
border: 1px solid #2e2e2e !important;
border-radius: 3px !important;
color: #d8d8d0 !important;
font-family: "SLA Borg UI", sans-serif !important;
text-transform: uppercase;
letter-spacing: 1px;
transition: border-color .15s, color .15s;
}
#actors .directory-header button:hover,
#actors .header-actions button:hover {
border-color: #e3e300 !important;
color: #e3e300 !important;
box-shadow: 0 0 8px rgba(227, 227, 0, 0.25);
}
/* Natural weapon rows share the weapon-item look with a claw accent */
.slazero2 .natural-weapon-item {
border-left: 3px solid #9a9a00;
}
+1 -1
View File
@@ -59,7 +59,7 @@
<option value="brainwaster" {{#if (eq system.race "brainwaster")}}selected{{/if}}>Brain Waster</option>
<option value="stormer" {{#if (eq system.race "stormer")}}selected{{/if}}>Stormer (313 Malice)</option>
<option value="shaktar" {{#if (eq system.race "shaktar")}}selected{{/if}}>Shaktar</option>
<option value="wraith" {{#if (eq system.race "wraith")}}selected{{/if}}>Wraith Raider</option>
<option value="wraithraider" {{#if (contains system.race "wraith")}}selected{{/if}}>Wraith Raider</option>
<option value="frother" {{#if (eq system.race "frother")}}selected{{/if}}>Frother</option>
</select>
</div>
+1 -1
View File
@@ -19,7 +19,7 @@
<option value="brainwaster" {{#if (contains system.race "brainwaster")}}selected{{/if}}>Brain Waster</option>
<option value="stormer" {{#if (contains system.race "stormer")}}selected{{/if}}>Stormer</option>
<option value="shaktar" {{#if (contains system.race "shaktar")}}selected{{/if}}>Shaktar</option>
<option value="wraith" {{#if (contains system.race "wraith")}}selected{{/if}}>Wraith Raider</option>
<option value="wraithraider" {{#if (contains system.race "wraith")}}selected{{/if}}>Wraith Raider</option>
<option value="frother" {{#if (contains system.race "frother")}}selected{{/if}}>Frother</option>
</select>
</div>
+25 -2
View File
@@ -59,7 +59,7 @@
<option value="brainwaster"{{#if (eq raceStr "brainwaster")}}selected{{/if}}>Brain Waster</option>
<option value="stormer" {{#if (eq raceStr "stormer")}}selected{{/if}}>Stormer (313 Malice)</option>
<option value="shaktar" {{#if (eq raceStr "shaktar")}}selected{{/if}}>Shaktar</option>
<option value="wraith" {{#if (eq raceStr "wraith")}}selected{{/if}}>Wraith Raider</option>
<option value="wraithraider" {{#if (eq raceStr "wraithraider")}}selected{{/if}}>Wraith Raider</option>
<option value="frother" {{#if (eq raceStr "frother")}}selected{{/if}}>Frother</option>
<option value="vevaphon" {{#if (eq raceStr "vevaphon")}}selected{{/if}}>Stormer Vevaphon</option>
</select>
@@ -602,6 +602,29 @@
<div class="weapons-section">
<h2 class="section-header"><i class="fas fa-fist-raised"></i> Melee Weapons</h2>
<div class="weapon-list">
{{#each naturalWeapons}}
<div class="weapon-item natural-weapon-item" data-item-id="{{this._id}}">
<img class="weapon-img" src="{{this.img}}" width="36" height="36"/>
<div class="weapon-info">
<div class="weapon-name-row">
<span class="weapon-name">{{this.name}}</span>
<span class="weapon-type-badge melee">NATURAL</span>
</div>
<div class="weapon-stats-row">
<span class="weapon-stat"><strong>DMG</strong> {{this.system.naturalWeaponDamage}}</span>
<span class="weapon-stat"><strong>AP</strong> {{this.system.naturalWeaponAP}}</span>
<span class="weapon-stat"><strong>ROF</strong> {{this.system.naturalWeaponROF}}</span>
<span class="weapon-stat">always armed</span>
</div>
</div>
<div class="weapon-actions">
<button type="button" class="natural-weapon-roll-btn weapon-roll-btn" data-item-id="{{this._id}}"
title="Strike: STR + Melee | DMG {{this.system.naturalWeaponDamage}}, AP {{this.system.naturalWeaponAP}}, ROF {{this.system.naturalWeaponROF}}">
<i class="fas fa-paw-claws"></i> STRIKE
</button>
</div>
</div>
{{/each}}
{{#each meleeWeapons}}
<div class="weapon-item" data-item-id="{{this._id}}">
<img class="weapon-img" src="{{this.img}}" width="36" height="36"/>
@@ -633,7 +656,7 @@
</div>
</div>
{{/each}}
{{#unless meleeWeapons.length}}
{{#unless (or meleeWeapons.length naturalWeapons.length)}}
<div class="weapon-empty"><em>Drag melee weapons from the compendium.</em></div>
{{/unless}}
</div>
@@ -56,7 +56,7 @@
<option value="brainwaster" {{#if (contains system.race "brainwaster")}}selected{{/if}}>Brain Waster</option>
<option value="stormer" {{#if (contains system.race "stormer")}}selected{{/if}}>Stormer (313 Malice)</option>
<option value="shaktar" {{#if (contains system.race "shaktar")}}selected{{/if}}>Shaktar</option>
<option value="wraith" {{#if (contains system.race "wraith")}}selected{{/if}}>Wraith Raider</option>
<option value="wraithraider" {{#if (contains system.race "wraith")}}selected{{/if}}>Wraith Raider</option>
<option value="frother" {{#if (contains system.race "frother")}}selected{{/if}}>Frother</option>
</select>
</div>