0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)

Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
This commit is contained in:
sganggs committed 2026-10-09 22:55:24 +08:00
1 parent 314564c139
commit f3658a0dd4
104 files changed
+3674 -741

No files matched your search

+7 -1
View File
@@ -50,7 +50,7 @@ const M = {
audio: {
bgm: { prep: { loop: '/bgm/prep.mp3' }, lobby: { intro: '/bgm/i.mp3', loop: '/bgm/l.mp3' } },
bossBgm: { boss_1: { loop: '/bgm/b1.mp3' } },
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3' } } },
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', hit: '/sfx/c_hit.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3', attacks: { 2: '/sfx/c_atk_s3.mp3' }, hits: { 2: '/sfx/c_hit_s3.mp3' } } } },
},
};
@@ -134,6 +134,12 @@ describe('URL helpers (synthetic manifest)', () => {
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 2), '/sfx/c_s3.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 0), '/sfx/c_s.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen_2', 'attack'), '/sfx/c_atk.mp3');
// the running skill's own attack sound (sfx.units[id].attacks, tools/assets/audio.mjs pickModeAttacks)
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack', 2), '/sfx/c_atk_s3.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack', 0), '/sfx/c_atk.mp3', 'no attacks[0] ⇒ the normal bank');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'hit', 2), '/sfx/c_hit_s3.mp3', 'the running skill\'s own impact');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'hit', 1), '/sfx/c_hit.mp3', 'no hits[1] ⇒ the normal impact');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'die', 2), null, 'only an attack / an impact has a per-skill file');
assert.equal(unitSfxUrl(M, 'nobody', 'attack'), null);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, UnitView;
before(async () => { fake = installFakePixi(); ({ UnitView } = await import('../../public/js/render/units.js')); });
after(() => fake.restore());
function rig(rate = 1) {
let resolve;
const entry = { skel: '/op.skel', atlas: '/op.atlas', textures: ['/op.png'], anims: { idle: 'Idle', deploy: 'Start', die: 'Die', attack: { loop: 'Attack' } }, animations: { Idle: 1, Start: 1, Die: 1, Attack: 1 } };
const assets = { spineEntry: () => entry, hasBack: () => false, spine: { acquire: () => new Promise((r) => { resolve = r; }), release() {} } };
const cam = presetCamera('prep', { width: 1280, height: 720 });
const ctx = fakeViewCtx(fake.P, { assets, cam: () => cam }); ctx.animRate = () => rate;
const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_op', x: 5, y: 12, maxHp: 1000, dir: 'RIGHT' }, { prep: true });
return { v, advance: (dt) => v.update(dt, cam, dt), ready: async () => { await new Promise((r) => setImmediate(r)); resolve({ animations: [{ name: 'Idle' }, { name: 'Start' }, { name: 'Die' }, { name: 'Attack' }] }); await new Promise((r) => setImmediate(r)); } };
}
test('late model continues the current deployment at elapsed game time', async () => {
const h = rig(2); h.v.onDeploy(); h.advance(0.1); await h.ready();
assert.equal(h.v.actor.mode, 'deploy'); assert.ok(Math.abs(h.v.actor.deployElapsed() - 0.2) < 1e-6); h.v.destroy();
});
test('model arriving after Start duration does not replay the old entrance', async () => {
const h = rig(); h.v.onDeploy(); h.advance(2); await h.ready();
assert.equal(h.v.actor.mode, 'base'); assert.equal(h.v._pendingDeployElapsed, null); h.v.destroy();
});
test('an attack or death supersedes the pending entrance', async () => {
for (const action of ['attack', 'die']) {
const h = rig(); h.v.onDeploy();
if (action === 'attack') h.v.onAttack(null, 1, 'none'); else h.v.die();
await h.ready(); assert.notEqual(h.v.actor.mode, 'deploy'); assert.equal(h.v._pendingDeployElapsed, null); h.v.destroy();
}
});
test('a destroyed view never consumes a late model as a fresh entrance', async () => {
const h = rig(); h.v.onDeploy(); h.v.destroy(); await h.ready(); assert.equal(h.v.actor, null);
});