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

+83
View File
@@ -577,6 +577,89 @@ describe('AudioManager', () => {
} finally { globalThis.fetch = origFetch; }
}
});
// 「技能音效有时候不触发」 (PR #292 by @LimitlessHPPK): the sim emits the cast of a deployment that fires inside its own first
// tick (`initSp` already at `spCost`, 宴's deploy-timed skill, 银灰 S3 真银斩 …) before the unit's `['spawn', unitInfo]` — the
// same batch, or the next — so `handleBattleEvents` used to skip the cue for good: that first cast was silent and every
// later one played. The cue is held and answered once, when the unit is tracked (`_track`); it never outlives its field.
test('a cast that arrives before its unit is known is held and plays once when the unit is tracked', () => {
const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3' } } },
voice: { char_a: { skill3: '/v/a_s3.mp3' } } } };
const a = new AudioManager({ win: null, getManifest: () => vm });
a.ctx = {}; // unlocked: every path below is the real one
const played = [], voiced = [];
a._play = (url) => { played.push(url); }; // the cue itself, and the 作战中 line that goes with it
a.voice = (charId, slot, o) => { voiced.push([charId, slot, o.unitKey]); return true; };
/** UnitInfo of the operator `char_a` (spine = the sfx.units key; skillIndex picks its 作战中N slot). */
const info = (id, skillIndex) => ({ id, side: 'ally', spine: 'char_a', kind: 'op', skillIndex });
// (1) the sim's order inside one batch: the cast, then the unit's spawn — the cast waits, then sounds once
a.handleBattleEvents([['skill', 8, 1]]);
assert.deepEqual(played, [], 'nothing to play yet: the unit is unknown');
a.handleBattleEvents([['spawn', info(8, 2)]]);
assert.deepEqual(played, ['/s/a_skill.mp3'], 'the held cast plays as soon as the unit is tracked');
assert.deepEqual(voiced, [['char_a', 'skill3', 8]], 'with the 作战中N line its skillIndex picks');
a.handleBattleEvents([['skill', 7, 1], ['atk', 7, 8, 'none'], ['spawn', info(7, 0)]]);
assert.equal(played.length, 2, 'the same batch: the cast waits for the spawn of its own batch');
assert.deepEqual(voiced.at(-1), ['char_a', 'skill1', 7]);
// (2) once: a unit that is told about again (a repeated spawn) never replays it; a later cast plays directly
a.handleBattleEvents([['spawn', info(8, 2)], ['spawn', info(7, 0)]]);
assert.equal(played.length, 2, 'no second cue for the held cast');
a.handleBattleEvents([['skill', 8, 1]]);
assert.equal(played.length, 3, 'a later cast of a known unit plays through');
assert.deepEqual(voiced.at(-1), ['char_a', 'skill3', 8], 'and sounds the same as the replayed one');
// (3) skill off (`['skill', id, 0]`) is no cast: nothing is held for it
a.handleBattleEvents([['skill', 9, 0]]);
a.handleBattleEvents([['spawn', info(9, 0)]]);
assert.equal(played.length, 3, 'the off event holds nothing');
assert.equal(a.pendingSkill.size, 0);
});
test('a held cast does not outlive its field: a unit of the next field that shares its id does not sound it', () => {
const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3' } } }, voice: {} } };
const a = new AudioManager({ win: null, getManifest: () => vm });
a.ctx = {};
const played = [];
a._play = (url) => { played.push(url); };
a.voice = () => true;
const info = (id) => ({ id, side: 'ally', spine: 'char_a', kind: 'op' });
a.handleBattleEvents([['skill', 9, 1]]); // a cast whose unit never appeared on this field
assert.equal(a.pendingSkill.size, 1);
a.setFieldUnits([info(9)]); // another battle: unit ids are per battle, 9 is a different unit
assert.equal(a.pendingSkill.size, 0, 'the field change drops the hold');
a.handleBattleEvents([['spawn', info(9)]]);
assert.deepEqual(played, [], 'the old field\'s cast never sounds in the new one');
// the hold is bounded: casts of units that never appear cannot pile up
for (let id = 100; id < 400; id++) a.handleBattleEvents([['skill', id, 1]]);
assert.ok(a.pendingSkill.size <= 64, `${a.pendingSkill.size} holds`);
assert.ok(a.pendingSkill.has(399) && !a.pendingSkill.has(100), 'the oldest holds go first');
});
test('a real battle: the deploy-tick cast of a deployment-activated skill is heard, once', () => {
// The reported shape in 0.2.0's own sim: 宴's kit activates its skill on deployment (activateOnDeploy, PR #109), and
// `sim/battle/deploy.js` runs `skill.reset()` (→ `activate` → `['skill', id, 1]`) BEFORE it emits
// `['spawn', unitInfo]` — three events earlier in 宴's case (a status and an fx come between). Fed to the audio as
// the socket delivers it, that cast used to be dropped for good.
const chessId = 'chess_char_1_18_a'; // 宴 (char_337_utage): a deploy-timed skill with a skill sound
const h = makeBattle({ seed: 7, autoFinish: false, timeLimit: 5, units: [{ chessId, row: 9, col: 5 }] });
h.step(3);
const ev = h.events;
const iCast = ev.findIndex((e) => e[0] === 'skill' && e[2]);
const iSpawn = ev.findIndex((e) => e[0] === 'spawn');
assert.ok(iCast >= 0 && iSpawn > iCast, `the sim emits the deploy-tick cast before the unit info (${iCast} < ${iSpawn})`);
// the cast's sound, resolved as `unit()` does (the equipped skill's own ON_SKILL_START file, else its `skill`)
const info = ev[iSpawn][1];
const rec = manifest.audio.sfx.units[info.spine];
const url = (Number.isInteger(info.skillIndex) && rec?.skills ? rec.skills[info.skillIndex] : null) ?? rec?.skill;
assert.ok(typeof url === 'string', `前提:${info.spine} 有技能音效`);
const played = [];
const a = new AudioManager({ win: null, getManifest: () => manifest });
a.ctx = {};
a._play = (u) => { played.push(u); };
a.voice = () => true;
a.handleBattleEvents(ev);
assert.equal(played.filter((u) => u === url).length, 1, 'the cast of the deployment is heard, exactly once');
});
});
// user playtest #4 item 6: 纯烬艾雅法拉's skill sound rang outside her skill — her manifest `hit` is her S3 impact
+109
View File
@@ -0,0 +1,109 @@
// test/ui/feedback7-orchid-freeze.test.js — GitHub #354 (DuolaCCCCat): 「梓兰的主动刷新不会触发主动冻结」. 梓兰's 猎头顾问 (band_orchid) makes
// every active refresh a special one: two operators of the same name, and ONE of them frozen — kept to the next prep. The
// server did freeze it (slot.frozen, sent in m.private shop.slots[i].frozen) while the shop toggle stayed off; the shop bar
// drew the frost from the toggle alone, so the player never saw which card was frozen. Each card now reads its own slot.
// The rule itself (a second manual refresh re-rolls the frozen card too, like the toggle) is unchanged.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeMatch, DATA } from '../match/harness.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
// the browser data store reads the real data files from disk
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { ChessCard, ItemCard, slotFrozen } = await import('../../public/js/ui/shopBar.js');
const { data } = await import('../../public/js/data.js');
await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds', 'garrisons');
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const iced = (card) => hasClass(card, 'is-frozen') && [...walk(card)].some((n) => hasClass(n, 'scard__ice'));
const bare = (card) => !hasClass(card, 'is-frozen') && ![...walk(card)].some((n) => hasClass(n, 'scard__ice'));
/** A solo match in its first prep with 梓兰's strategy, level 4 shop, funds for a few refreshes. */
function orchid(seed = 12) {
const h = makeMatch({ mode: 'solo', seed, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
ps.bandId = 'band_orchid';
ps.shop.level = 4;
ps.funds = 50;
return { h, ps };
}
describe('#354: the card the 猎头顾问 freezes shows its frost', () => {
test('the wire: the active refresh freezes one copied card and leaves the shop toggle off; the cards read their own slot', () => {
const { h, ps } = orchid();
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
const priv = ps.privateView();
assert.equal(priv.shop.frozen, false, 'the 冻结 toggle is off');
const chess = priv.shop.slots.filter((s) => s && s.kind === 'chess');
const frozen = chess.filter((s) => s.frozen);
assert.equal(frozen.length, 1, 'exactly one card is frozen');
assert.equal(chess.filter((s) => s.id === frozen[0].id).length, 2, 'one of the two operators of the same name');
// the bar's frame and the button follow the toggle (off), each card follows its own slot
for (const s of priv.shop.slots.filter(Boolean)) {
const props = { slot: s, idx: 0, priv, frozen: slotFrozen(s, priv.shop.frozen), onBuy() {}, onDetail() {} };
const card = s.kind === 'item' ? ItemCard(props) : ChessCard(props);
assert.equal(s.frozen ? iced(card) : bare(card), true, `${s.id}: ${s.frozen ? 'frost' : 'no frost'}`);
}
h.m.dispose();
});
test('the toggle still freezes every unsold card, and the helper reads the slot and the toggle', () => {
const { h, ps } = orchid(13);
h.m.handle('p_0', { t: 'g.freeze' });
const priv = ps.privateView();
assert.equal(priv.shop.frozen, true);
assert.ok(priv.shop.slots.filter(Boolean).every((s) => s.frozen), 'every slot copies the toggle');
assert.equal(slotFrozen(priv.shop.slots[0], priv.shop.frozen), true);
assert.equal(slotFrozen({ frozen: false }, true), true, 'a frame without per-slot flags: the toggle');
assert.equal(slotFrozen({ frozen: true }, false), true, 'a slot of its own: its flag');
assert.equal(slotFrozen({ frozen: false }, false), false);
assert.equal(slotFrozen(null), false);
h.m.dispose();
});
test('the rule stays as it was: the next manual refresh re-rolls the frozen card too and freezes one of the new pair; one refresh then the next prep keeps that card', () => {
const { h, ps } = orchid();
h.m.handle('p_0', { t: 'g.refresh' });
const first = ps.shop.slots.find((s) => s && s.frozen);
h.m.handle('p_0', { t: 'g.refresh' });
const frozenNow = ps.shop.slots.filter((s) => s && s.frozen);
assert.equal(frozenNow.length, 1, 'again exactly one frozen card');
assert.equal(ps.shop.frozen, false);
assert.ok(first && frozenNow[0], 'both refreshes froze a card');
const kept = frozenNow[0].id;
h.toPrep(2);
assert.ok(ps.shop.slots.some((s) => s && s.id === kept), 'the frozen card is still in the shop in the next prep');
assert.equal(ps.shop.frozen, false);
h.m.dispose();
});
test('ShopBar passes slotFrozen(slot, toggle) to the operator and item cards; the frame and the 冻结 button stay on the toggle', () => {
const src = readFileSync(path.join(ROOT, 'public/js/ui/shopBar.js'), 'utf8');
assert.match(src, /<\$\{ChessCard\} key=\$\{`c\$\{i\}:\$\{s\.id\}`\} slot=\$\{s\} idx=\$\{i\} priv=\$\{priv\} frozen=\$\{slotFrozen\(s, frozen\)\}/);
assert.match(src, /<\$\{ItemCard\} key=\$\{`i\$\{i\}:\$\{s\.id\}`\} slot=\$\{s\} idx=\$\{i\} frozen=\$\{slotFrozen\(s, frozen\)\}/);
assert.match(src, /const frozen = !!shop\.frozen;/);
assert.match(src, /cx\('shopbar', frozen && 'is-frozen'/, 'the bar\'s frame is the toggle');
assert.match(src, /cx\('toolbtn', 'toolbtn--ice', frozen && 'is-on'\)/, 'the button is the toggle');
assert.ok(DATA.bands.band_orchid, 'the band exists in the data');
});
});
+158
View File
@@ -0,0 +1,158 @@
// Field-host ownership under cancellation and timeout. Browser tests cover the actual renderer; this fixture runs
// the production orchestration with controlled imports and timers, without a DOM or GPU dependency.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { getEventListeners } from 'node:events';
import vm from 'node:vm';
const sourcePath = new URL('../../public/js/ui/fieldHost.js', import.meta.url);
const source = readFileSync(sourcePath, 'utf8')
.replace(/^import .*;$/gm, '').replace(/^export /gm, '')
.replace("import('../assets.js')", 'importAssets()')
.replace("import('../render/app.js')", 'importApp()');
const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize'];
const flush = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); };
function fixture() {
const timers = new Map(), requests = [], fallbacks = [], warnings = [];
let timerId = 0;
const unrelated = { name: 'unrelated' };
const host = {
children: [unrelated],
get firstChild() { return this.children[0] || null; },
appendChild(node) { this.children.push(node); },
removeChild(node) { const i = this.children.indexOf(node); if (i >= 0) this.children.splice(i, 1); },
};
function view(name, valid = true) {
const node = { name };
host.appendChild(node);
return {
...(valid ? Object.fromEntries(METHODS.map((k) => [k, () => {}])) : {}),
node, destroyCalls: 0,
destroy() { this.destroyCalls++; host.removeChild(node); },
};
}
const context = vm.createContext({
AbortController, DOMException, URLSearchParams, Promise,
console: { warn: (...args) => warnings.push(args) },
audio: {}, data: { get() { return null; } }, settingsStore: { get() { return {}; } },
setTimeout(fn) { timers.set(++timerId, fn); return timerId; },
clearTimeout(id) { timers.delete(id); },
importAssets: async () => null,
importApp: async () => ({
createFieldView(_host, opts) {
// Deliberately ignore cancellation: the host must still dispose a factory's late return value.
return new Promise((resolve, reject) => requests.push({ resolve, reject, signal: opts.signal }));
},
}),
createFallbackView() {
const fallback = view('fallback');
fallbacks.push({ view: fallback, rendererAborted: requests.at(-1)?.signal?.aborted });
return fallback;
},
});
vm.runInContext(source + '\nglobalThis.mount = mountFieldView;', context, { filename: sourcePath.pathname });
return {
host, unrelated, timers, requests, fallbacks, warnings, view,
mount: (signal) => context.mount(host, { signal }),
timeout() { const fn = [...timers.values()].at(-1); assert.ok(fn, 'initialization has a deadline'); fn(); },
};
}
function settled(promise) {
const result = { state: 'pending', value: null };
promise.then((value) => { result.state = 'fulfilled'; result.value = value; }, (error) => { result.state = 'rejected'; result.value = error; });
return result;
}
test('successful initialization releases deadlines and cancellation listeners', async () => {
const f = fixture(), controller = new AbortController();
const pending = f.mount(controller.signal);
await flush();
const raw = f.view('engine');
f.requests[0].resolve(raw);
const mounted = await pending;
assert.equal(mounted.kind, 'engine');
assert.equal(f.timers.size, 0);
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
assert.equal(getEventListeners(f.requests[0].signal, 'abort').length, 0);
mounted.destroy();
assert.equal(raw.destroyCalls, 1);
});
test('cancelling a pending mount rejects without fallback and disposes a late result', async () => {
const f = fixture(), controller = new AbortController();
const result = settled(f.mount(controller.signal));
await flush();
controller.abort();
await flush();
assert.equal(result.state, 'rejected');
assert.equal(result.value.name, 'AbortError');
assert.equal(f.fallbacks.length, 0);
assert.equal(f.warnings.length, 0);
assert.equal(f.timers.size, 0);
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
const late = f.view('late');
f.requests[0].resolve(late);
await flush();
assert.equal(late.destroyCalls, 1);
assert.deepEqual(f.host.children, [f.unrelated]);
});
test('cancellation between factory resolution and mount continuation disposes the resolved view', async () => {
const f = fixture(), controller = new AbortController();
const result = settled(f.mount(controller.signal));
await flush();
const raw = f.view('settling');
f.requests[0].resolve(raw);
await Promise.resolve();
controller.abort();
await flush();
assert.equal(result.state, 'rejected');
assert.equal(result.value.name, 'AbortError');
assert.equal(raw.destroyCalls, 1);
assert.equal(f.fallbacks.length, 0);
assert.equal(f.timers.size, 0);
assert.deepEqual(f.host.children, [f.unrelated]);
});
test('an invalid renderer API is disposed and aborted before fallback mounts', async () => {
const f = fixture();
const pending = f.mount();
await flush();
const invalid = f.view('invalid', false);
f.requests[0].resolve(invalid);
const fallback = await pending;
assert.equal(fallback.kind, 'fallback');
assert.equal(invalid.destroyCalls, 1);
assert.equal(f.fallbacks[0].rendererAborted, true);
assert.equal(f.timers.size, 0);
assert.deepEqual(f.host.children, [f.unrelated, f.fallbacks[0].view.node]);
fallback.destroy();
});
test('a timed-out factory returning late cannot remove a replacement view or unrelated children', async () => {
const f = fixture();
const first = f.mount();
await flush();
f.timeout();
const fallback = await first;
assert.equal(fallback.kind, 'fallback');
fallback.destroy();
const replacementPending = f.mount();
await flush();
const replacement = f.view('replacement');
f.requests[1].resolve(replacement);
const mounted = await replacementPending;
const late = f.view('late');
f.requests[0].resolve(late);
await flush();
assert.equal(late.destroyCalls, 1);
assert.equal(replacement.destroyCalls, 0);
assert.equal(f.fallbacks[0].rendererAborted, true);
assert.equal(f.timers.size, 0);
assert.deepEqual(f.host.children, [f.unrelated, replacement.node]);
mounted.destroy();
});
+1 -1
View File
@@ -126,7 +126,7 @@ describe('8: the detail card never covers the selected unit\'s underframe', () =
const { readFileSync } = await import('node:fs');
const app = readFileSync(new URL('../../public/js/render/app.js', import.meta.url), 'utf8');
assert.match(app, /\n {2}canvas\.addEventListener\('click', onTapTarget\);\n/, 'registered with the other canvas listeners');
assert.match(app, /\n {6}canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy');
assert.match(app, /\n +canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy');
assert.match(app, /const onTapTarget = \(\) => \{\};/, 'a no-op: the press itself stays with the pointer events');
});