feedback7: merge fb7-hud — HP-bar readouts (#303 / #378 / #383: ammo cells, 伺夜's wolf-shadow pips, 斩业星熊's 我执 bar), #302 renderer abort, #292 held skill cue, #334 atlas slots, #354 梓兰's per-card frost

- Grok review: MERGE (no defect). Golden unchanged (union OK); broad node --test 5327 (5326 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
sganggs committed 2026-10-08 20:29:27 +08:00
commit 7d6fefe8f5
33 files changed
+1751 -77

No files matched your search

+52
View File
@@ -17,6 +17,8 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createBattleRunner, keepsState, compactHeld, HELD_MAX, EV_SLICE } from '../../public/js/battle/runner.js';
import { SnapshotBuffer } from '../../public/js/render/interp.js';
import { keepEarly, audioEarly } from '../../public/js/screens/game/early.js';
import { AudioManager } from '../../public/js/audio.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
@@ -243,6 +245,56 @@ test('screens/game.js buffers the form fx with the state-bearing events it repla
assert.match(src, /lastFieldRef\.current = field\.fieldId;\s+reentryRef\.current = null;/);
});
// GitHub PR #292 (by @LimitlessHPPK) wanted the whole pre-entry buffer handed to the sound as well, so that a deploy-tick cast
// ahead of its unit's spawn would be heard. The review kept the hold of such a cast (audio.js `pendingSkill`, which the live
// path needs: `onEv` already forwards every list) and refused the replay: the buffer is every spawn, die, deploy, status and
// skill since the field's m.field (up to 1500 of them), so the sound would play the field's old deaths, deploy lines and casts
// all at once on entering.
test('entering a field late: the view replays every buffered state event, the sound only the spawn tuples that teach it who is there', () => {
const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(src, /view\.pushEvents\(early\);/, 'the view gets the buffered list');
assert.match(src, /audio\.handleBattleEvents\(audioEarly\(early\)\);/, 'the sound gets audioEarly of it');
assert.ok(!/audio\.handleBattleEvents\(early\)/.test(src), 'never the whole list');
const stream = [
['atk', 1, 2, 'none'], // stale cosmetics: dropped by keepEarly
['dmg', 2, 40, 'phys'],
['skill', 1, 1], // a deploy-tick cast, ahead of its unit's spawn
['spawn', { id: 1, side: 'ally', kind: 'op', spine: 'char_a', skillIndex: 2 }],
['spawn', { id: 2, side: 'enemy', kind: 'enemy', spine: 'enemy_x' }],
['deploy', 1],
['skill', 1, 0],
['fx', 'form', 2, 3, { id: 2, form: 'translator_youling' }],
['die', 2, 'killed'],
];
const early = stream.filter(keepEarly);
assert.deepEqual(early.map((e) => e[0]), ['skill', 'spawn', 'spawn', 'deploy', 'skill', 'fx', 'die'], 'the pre-entry buffer keeps every state-bearing kind');
assert.deepEqual(audioEarly(early).map((e) => e[0]), ['spawn', 'spawn'], 'the sound is told who is on the field, nothing else');
assert.deepEqual(audioEarly(null), [], 'no buffer, nothing');
// through a real AudioManager: the replay makes no sound at all (no old death, deploy line or cast) but tracks the units,
// so the first live cast after entering sounds
const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3', born: '/s/a_born.mp3' }, enemy_x: { die: '/s/x_die.mp3' } } }, voice: {} } };
const a = new AudioManager({ win: null, getManifest: () => vm });
a.ctx = {};
const played = [];
a._play = (url) => { played.push(url); };
a.voice = () => { played.push('voice'); return true; };
a.setFieldUnits([]);
a.handleBattleEvents(audioEarly(early));
assert.deepEqual(played, [], 'the replay is silent');
assert.equal(a.units.size, 2, 'and has taught the sound both units');
a.handleBattleEvents([['skill', 1, 1]]);
assert.ok(played.includes('/s/a_skill.mp3'), 'the next live cast of a known unit sounds');
// (the contrast: the whole list would have played the buffered death, the deploy and the cast on entering)
const b = new AudioManager({ win: null, getManifest: () => vm });
b.ctx = {};
const heard = [];
b._play = (url) => { heard.push(url); };
b.voice = () => true;
b.setFieldUnits([]);
b.handleBattleEvents(early);
assert.ok(heard.length >= 2, `the whole list would sound ${heard.length} old cues at once`);
});
/**
* The view's side too (render/app.js): the runner's frames go into the render engine's SnapshotBuffer, whose clock
* follows the snapshots, and processEvents takes the due events with the 1.5 game s stale-cosmetic window — a form fx