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:
commit
7d6fefe8f5
33 files changed
+1751
-77
No files matched your search
@@ -300,6 +300,33 @@ test('S3 临死模式 (a 主动关闭 only — no strategy of this mode closes i
|
||||
}
|
||||
});
|
||||
|
||||
// 社区反馈 (PR #383 by @Convey123): 「绿条被打空以后受到伤害就会涨红条,这里看不到红条」 — the official HP bar shows the 我执 pool as a red bar
|
||||
// growing over the drained green one. The sim has kept the pool since 0.2.0 (unit.mem.hsEgo); the snapshot now lists its share of the
|
||||
// cap (b.snap `neg`, snapshot.js negView — display only) and render/units.js draws it (test/render/hud-readouts.test.js).
|
||||
test('T1 业火 我执: the pool reaches the client as b.snap `neg` — its share of the cap (2 × max HP) from the first lethal hit, growing with the damage, gone with 我执', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 0, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 9 }] });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
h.step();
|
||||
const max = u.s.maxHp;
|
||||
const negOf = () => (h.b.snapshot().neg || []).find((x) => x[0] === u.id);
|
||||
assert.equal(h.b.snapshot().neg, undefined, 'not in 我执: no list');
|
||||
h.b.dealDamage(e, u, { amount: u.hp + 300, type: 'true' });
|
||||
h.step();
|
||||
assert.ok(u.mem.hsEgo, '已进入 我执');
|
||||
const cap = 2 * max; // max_minus_hp_ratio
|
||||
approx(negOf()[1], 300 / cap, 'the share of the cap', 0.02);
|
||||
h.b.dealDamage(e, u, { amount: 400, type: 'true' });
|
||||
h.step();
|
||||
approx(negOf()[1], 700 / cap, 'it grew with the damage', 0.02);
|
||||
assert.ok(negOf()[1] < 1, 'still below a full pool');
|
||||
assert.ok(u.hp / max < 0.01, 'her green bar sits on the 1-HP floor: the red bar is the whole story');
|
||||
assert.equal(h.b.snapshot().units.find((t) => t[0] === u.id).length, 9, 'the tuple is not widened');
|
||||
assert.ok(h.runUntil(() => !u.mem.hsEgo, 60), 'the quiet-time regeneration clears the pool');
|
||||
h.step();
|
||||
assert.equal(h.b.snapshot().neg, undefined, 'no pool, no red bar');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T1 业火 我执: a lethal hit leaves her on the field (HP floor) with the excess as negative HP; later damage goes there (受击回复 SP still), 禁疗 meanwhile; 200 % of max HP knocks her out; 4 s without damage ⇒ 生命回复速度 5 %/s clears it and she leaves 我执', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
// test/render/hud-readouts.test.js — the HP-bar readouts, client side (R22-HUD; PR #303 and #378 by @IceCodeNew, PR #383
|
||||
// by @Convey123, folded into one change): render/interp.js carries b.snap `ammo` [[id, left, magazine]], `wolves`
|
||||
// [[id, left, max]] and `neg` [[id, fill]] beside the unit tuples and samples them from the older snapshot; render/units.js
|
||||
// draws the ammo skill's segmented yellow bar in place of the SP bar, 伺夜's 狼影 as a row of diamonds and the 我执 pool
|
||||
// as the red bar over the drained HP bar; an ammo skill has no standing aura (headless fake PIXI, test/render/fakepixi.js).
|
||||
// The server side is test/sim/hud-readouts.test.js and test/content/op_hsgma2.test.js.
|
||||
// Run: node --test test/render/hud-readouts.test.js
|
||||
|
||||
import { test, describe, 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';
|
||||
import { SnapshotBuffer, normalizeSnapshot } from '../../public/js/render/interp.js';
|
||||
import { renderInfo } from '../../public/js/render/app/info.js';
|
||||
import { UF } from '../../shared/constants.js';
|
||||
import { makeBattle, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { unitInfo } from '../../server/sim/snapshot.js';
|
||||
|
||||
let fake, FX, UnitView, COLORS, AMMO_BAR, WOLF_PIPS;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
FX = await import('../../public/js/render/fx.js');
|
||||
({ UnitView, AMMO_BAR, WOLF_PIPS } = await import('../../public/js/render/units.js'));
|
||||
({ COLORS } = await import('../../public/js/render/style.js'));
|
||||
});
|
||||
after(() => { FX?.setSimProjectileSpeeds(null); fake.restore(); });
|
||||
|
||||
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
|
||||
const tuple = (id, hp = 1000, extra = {}) => [id, 5, 10, hp, 1000, extra.sp ?? 0, extra.spMax ?? 0, extra.flags ?? 0, 0];
|
||||
const view = (info = {}, extra = {}) => new UnitView(fakeViewCtx(fake.P, { cam, ...extra }),
|
||||
{ id: 1, side: 'ally', kind: 'op', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info });
|
||||
const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, ...o });
|
||||
const frames = (v, n = 2) => { for (let i = 0; i < n; i++) v.update(1 / 60, cam(), i / 60); };
|
||||
const barWidth = (v) => v.hpBg.width - 2;
|
||||
|
||||
describe('interp: ammo / wolves / neg', () => {
|
||||
test('sampled from the older snapshot (they step at snapshot boundaries) and cleared by a snapshot without them', () => {
|
||||
const buf = new SnapshotBuffer({ delay: 0 });
|
||||
buf.push({ t: 1, units: [tuple(1), tuple(2)], ammo: [[1, 13, 14]], wolves: [[2, 2, 3]], neg: [[2, 0.25]] }, 0);
|
||||
buf.push({ t: 2, units: [tuple(1), tuple(2)], ammo: [[1, 12, 14]], wolves: [[2, 1, 3]], neg: [[2, 0.5]] }, 0.5);
|
||||
buf.push({ t: 3, units: [tuple(1), tuple(2)] }, 1);
|
||||
// between the first two snapshots nothing slides: a round, a 狼影 or a share of the pool is not halfway
|
||||
const out = buf.sample(1.99);
|
||||
assert.deepEqual([out.get(1).ammo, out.get(1).wolves, out.get(1).neg], [[13, 14], null, 0]);
|
||||
assert.deepEqual([out.get(2).ammo, out.get(2).wolves, out.get(2).neg], [null, [2, 3], 0.25]);
|
||||
buf.sample(2, out);
|
||||
assert.deepEqual([out.get(1).ammo, out.get(2).wolves, out.get(2).neg], [[12, 14], [1, 3], 0.5]);
|
||||
// a snapshot without the lists takes the readouts off the reused sample objects
|
||||
buf.sample(3, out);
|
||||
assert.deepEqual([out.get(1).ammo, out.get(2).wolves, out.get(2).neg], [null, null, 0]);
|
||||
});
|
||||
|
||||
test('malformed rows are dropped: fractions, negatives, a count above its maximum, a maximum under 1, unknown ids, junk', () => {
|
||||
const s = normalizeSnapshot({
|
||||
t: 1, units: [tuple(1), tuple(2), tuple(3)],
|
||||
ammo: [[1, 13, 14], [2, -1, 14], [2, 2, 1], [2, 1.5, 4], [9, 1, 2], [3, 15, 14], null, 'x', [3, 0, 0]],
|
||||
wolves: [[2, 3, 3], [1, 2.5, 3], [1, 4, 3], [3, 1, 0], [9, 1, 3]],
|
||||
neg: [[1, 0.4], [2, 0], [3, -1], [3, 'x'], [9, 0.5], [2, 5], null],
|
||||
});
|
||||
assert.deepEqual([...s.ammo], [[1, [13, 14]]]);
|
||||
assert.deepEqual([...s.wolves], [[2, [3, 3]]]);
|
||||
assert.deepEqual([...s.neg], [[1, 0.4], [2, 1]], 'a share above 1 is a full pool; 0 / negative / non-numbers are no pool');
|
||||
const none = normalizeSnapshot({ t: 1, units: [tuple(1)] });
|
||||
assert.deepEqual([none.ammo, none.wolves, none.neg], [null, null, null]);
|
||||
assert.equal(none.units.get(1).length, 9, 'the tuples are never widened by the readouts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnitView: the ammo skill\'s segmented bar', () => {
|
||||
test('replaces the SP bar while the magazine runs: a yellow cell per round, the fill is rounds left / magazine, a thin cut at every boundary', () => {
|
||||
const v = view();
|
||||
v.sync(sample({ flags: UF.SKILL, sp: 22.3, spMax: 24, ammo: [13, 14] }));
|
||||
frames(v);
|
||||
const bw = barWidth(v);
|
||||
assert.equal(v.spBg.visible && v.spFill.visible, true);
|
||||
assert.equal(v.spFill.tint, COLORS.ammo);
|
||||
assert.ok(Math.abs(v.spFill.width - bw * 13 / 14) < 1e-9, 'rounds left over the magazine, exactly (not the 0.1-rounded SP)');
|
||||
const cuts = v.ammoCuts.filter((c) => c.visible);
|
||||
assert.equal(cuts.length, 13, 'one cut per boundary of 14 cells');
|
||||
const step = bw / 14, x0 = v.spFill.position.x;
|
||||
cuts.forEach((c, i) => {
|
||||
assert.ok(Math.abs(c.position.x - (x0 + (i + 1) * step - 0.5)) < 1e-9, `cut ${i + 1} on the boundary`);
|
||||
assert.equal(c.width, 1);
|
||||
assert.equal(c.height, v.spFill.height);
|
||||
assert.equal(c.position.y, v.spFill.position.y);
|
||||
assert.equal(c.tint, COLORS.hpBack);
|
||||
});
|
||||
assert.ok(v.spFill.height > v.hpFill.height * 0.6, 'taller than the plain SP bar (the HP bar\'s thickness × AMMO_BAR.height)');
|
||||
assert.ok(Math.abs(v.spFill.height - Math.max(2, v.hpFill.height * AMMO_BAR.height)) < 1e-9);
|
||||
// a round is spent: the fill loses 1/14 and the cuts stay
|
||||
v.sync(sample({ flags: UF.SKILL, sp: 20.6, spMax: 24, ammo: [12, 14] }));
|
||||
frames(v);
|
||||
assert.ok(Math.abs(v.spFill.width - bw * 12 / 14) < 1e-9);
|
||||
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 13);
|
||||
// the last round, then the magazine ends: the plain SP bar is back (cyan, no cuts)
|
||||
v.sync(sample({ flags: UF.SKILL, ammo: [0, 14], sp: 0, spMax: 24 }));
|
||||
frames(v);
|
||||
assert.equal(v.spFill.width, 0);
|
||||
v.sync(sample({ flags: 0, sp: 6, spMax: 24, ammo: null }));
|
||||
frames(v);
|
||||
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'no cuts without a magazine');
|
||||
assert.notEqual(v.spFill.tint, COLORS.ammo);
|
||||
assert.ok(Math.abs(v.spFill.width - bw * 6 / 24) < 1e-9, 'the SP fraction of the tuple');
|
||||
v.destroy();
|
||||
});
|
||||
|
||||
test('a magazine needs no SP cost to show; a dense one keeps its exact fill and draws no cuts under AMMO_BAR.minCell px; an enemy never has the bar', () => {
|
||||
const v = view();
|
||||
v.sync(sample({ flags: UF.SKILL, ammo: [20, 40] })); // spMax 0: an auto skill with no SP cost
|
||||
frames(v);
|
||||
const bw = barWidth(v);
|
||||
assert.equal(v.spFill.visible, true, 'the bar shows without an SP cost');
|
||||
assert.ok(Math.abs(v.spFill.width - bw / 2) < 1e-9);
|
||||
const cell = bw / 40;
|
||||
assert.equal(v.ammoCuts.filter((c) => c.visible).length, cell >= AMMO_BAR.minCell ? 39 : 0, `${cell.toFixed(2)} px cells`);
|
||||
v.sync(sample({ flags: UF.SKILL, ammo: [20, 400] }));
|
||||
frames(v);
|
||||
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'under 2 px a cell: no cuts');
|
||||
assert.ok(Math.abs(v.spFill.width - bw * 20 / 400) < 1e-9, 'the fill is still exact');
|
||||
v.destroy();
|
||||
const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' });
|
||||
e.sync(sample({ hp: 500, flags: UF.SKILL, ammo: [3, 4] }));
|
||||
frames(e);
|
||||
assert.equal(e.spFill.visible, false, 'enemies have no SP bar, and so no magazine');
|
||||
assert.equal(e.ammoCuts.filter((c) => c.visible).length, 0);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
test('the prep view draws no bars, so no cells either', () => {
|
||||
const v = view({}, {});
|
||||
v.prep = true;
|
||||
v.sync(sample({ flags: UF.SKILL, ammo: [5, 14] }));
|
||||
frames(v);
|
||||
assert.equal(v.spFill.visible, false);
|
||||
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0);
|
||||
v.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnitView: 伺夜\'s 狼影 pips', () => {
|
||||
const pack = (extra = {}) => view({ kind: 'token', defId: 'token_10028_vigil_wolf', ...extra });
|
||||
|
||||
test('one diamond per 狼影 the talent allows under the HP bar, the first ones lit for the shadows left', () => {
|
||||
const v = pack();
|
||||
v.sync(sample({ wolves: [2, 3] }));
|
||||
frames(v);
|
||||
const r = v._wolfPips;
|
||||
assert.equal(r.root.visible, true);
|
||||
assert.equal(r.lit.length, 3);
|
||||
assert.deepEqual(r.lit.map((d) => d.alpha), [1, 1, WOLF_PIPS.dim]);
|
||||
assert.ok(r.lit.every((d) => d.visible && d.tint === COLORS.wolf && Math.abs(d.rotation - Math.PI / 4) < 1e-9), 'pale diamonds');
|
||||
assert.ok(r.back.every((d) => d.visible && d.tint === COLORS.hpBack), 'on a dark backing');
|
||||
assert.ok(r.lit[0].position.y > v.hpBg.position.y, 'below the HP bar');
|
||||
assert.ok(r.lit[0].position.x > v.hpFill.position.x && r.lit[2].position.x < v.hpFill.position.x + barWidth(v), 'inside the bar\'s span');
|
||||
assert.ok(r.lit[1].position.x > r.lit[0].position.x && r.lit[2].position.x > r.lit[1].position.x, 'left to right');
|
||||
assert.equal(v.spFill.visible, false, 'not an SP bar or a skill indicator');
|
||||
// a 狼影 is spent: the last lit one dims; one is gained: it lights again
|
||||
v.sync(sample({ wolves: [1, 3] }));
|
||||
frames(v);
|
||||
assert.deepEqual(r.lit.map((d) => d.alpha), [1, WOLF_PIPS.dim, WOLF_PIPS.dim]);
|
||||
v.sync(sample({ wolves: [3, 3] }));
|
||||
frames(v);
|
||||
assert.deepEqual(r.lit.map((d) => d.alpha), [1, 1, 1]);
|
||||
// a smaller maximum hides the surplus diamonds
|
||||
v.sync(sample({ wolves: [1, 2] }));
|
||||
frames(v);
|
||||
assert.equal(r.lit.filter((d) => d.visible).length, 2);
|
||||
v.destroy();
|
||||
});
|
||||
|
||||
test('hidden without a count (an older snapshot cannot leave stale pips), in the prep view, and for enemies', () => {
|
||||
const v = pack();
|
||||
v.sync(sample({ wolves: [2, 3] }));
|
||||
frames(v);
|
||||
assert.equal(v._wolfPips.root.visible, true);
|
||||
v.sync(sample({}));
|
||||
frames(v);
|
||||
assert.equal(v._wolfPips.root.visible, false, 'no row in the snapshot: no pips');
|
||||
v.sync(sample({ wolves: [2, 3] }));
|
||||
v.prep = true;
|
||||
frames(v);
|
||||
assert.equal(v._wolfPips.root.visible, false, 'the prep board draws no bars');
|
||||
v.destroy();
|
||||
const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' });
|
||||
e.sync(sample({ hp: 500, wolves: [2, 3] }));
|
||||
frames(e);
|
||||
assert.equal(e._wolfPips, null, 'only allies carry the row');
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
test('the element gauge row moves down below the pips (and below the SP bar when there is one)', () => {
|
||||
const plain = view();
|
||||
plain.sync(sample({ el: 'burn', elFill: 0.4, elUntil: 0, elDur: 0 }));
|
||||
frames(plain);
|
||||
const v = pack();
|
||||
v.sync(sample({ wolves: [2, 3], el: 'burn', elFill: 0.4, elUntil: 0, elDur: 0 }));
|
||||
frames(v);
|
||||
const pipsBottom = v._wolfPips.back[0].position.y + (v._wolfPips.back[0].width * Math.SQRT2) / 2;
|
||||
assert.ok(v._elBar.bg.position.y - v._elBar.bg.height / 2 >= pipsBottom - 1e-6, 'the gauge row starts under the pips');
|
||||
assert.ok(v._elBar.bg.position.y > plain._elBar.bg.position.y + 4, 'and so sits lower than without pips');
|
||||
plain.destroy();
|
||||
v.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnitView: the 我执 pool as the red bar', () => {
|
||||
const ego = (extra = {}) => view({ defId: 'char_1044_hsgma2', tier: 6, ...extra });
|
||||
|
||||
test('drawn over the drained HP bar from its left edge: width = the bar × the pool\'s share, the pool\'s red, gone with the pool', () => {
|
||||
const v = ego();
|
||||
v.sync(sample({ hp: 1 }));
|
||||
frames(v);
|
||||
assert.equal(v.negFill.visible, false, 'no pool, no red bar');
|
||||
assert.equal(v.negFill.tint, COLORS.hpNeg);
|
||||
v.sync(sample({ hp: 1, neg: 0.5 }));
|
||||
frames(v);
|
||||
assert.equal(v.negFill.visible, true);
|
||||
assert.ok(Math.abs(v.negFill.width - barWidth(v) * 0.5) < 1e-9, 'half the pool: half the bar');
|
||||
assert.equal(v.negFill.position.x, v.hpFill.position.x, 'from the bar\'s left edge');
|
||||
assert.equal(v.negFill.position.y, v.hpFill.position.y);
|
||||
assert.equal(v.negFill.height, v.hpFill.height);
|
||||
assert.ok(v.hpFill.width < 1, 'the green bar is drained meanwhile');
|
||||
v.sync(sample({ hp: 1, neg: 1 }));
|
||||
frames(v);
|
||||
assert.ok(Math.abs(v.negFill.width - barWidth(v)) < 1e-9, 'a full pool fills the bar');
|
||||
v.sync(sample({ hp: 600, neg: 0 }));
|
||||
frames(v);
|
||||
assert.equal(v.negFill.visible, false, 'she left 我执');
|
||||
v.destroy();
|
||||
});
|
||||
|
||||
test('not in the prep view, and gone once she falls', () => {
|
||||
const p = ego();
|
||||
p.prep = true;
|
||||
p.sync(sample({ hp: 1, neg: 0.6 }));
|
||||
frames(p);
|
||||
assert.equal(p.negFill.visible, false);
|
||||
p.destroy();
|
||||
const v = ego();
|
||||
v.sync(sample({ hp: 1, neg: 1 }));
|
||||
frames(v);
|
||||
assert.equal(v.negFill.visible, true);
|
||||
v.die();
|
||||
for (let i = 0; i < 300; i++) v.update(1 / 60, cam(), i / 60);
|
||||
assert.equal(v.alive, false);
|
||||
assert.equal(v.negFill.visible, false, 'no bars for a knocked-out operator');
|
||||
v.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('an ammo skill has no standing aura', () => {
|
||||
const unit = (id, info) => ({ id, x: 3, y: 10, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, maxHp: 3000, info, onHit() {} });
|
||||
const makeFx = (views) => {
|
||||
const ctx = fakeViewCtx(fake.P);
|
||||
const map = new Map(views.map((v) => [v.id, v]));
|
||||
const c = presetCamera('normal', { width: 1600, height: 900 });
|
||||
return new FX.FxSystem({
|
||||
P: fake.P, layers: ctx.layers, cam: () => c, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
|
||||
timeScale: () => 2, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null,
|
||||
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
|
||||
});
|
||||
};
|
||||
|
||||
test('UnitInfo.ammoSkill: the activation burst plays, the ground glow and hex do not (a plain skill keeps both)', () => {
|
||||
const magazine = unit(1, { defId: 'char_x', ammoSkill: true }), plain = unit(2, { defId: 'char_y' });
|
||||
const fx = makeFx([magazine, plain]);
|
||||
fx.skill(magazine, true);
|
||||
assert.ok(fx.parts.length > 0 && fx.rings.length > 0, 'the cast itself is still shown');
|
||||
assert.equal(fx.auras.size, 0, 'no glow and hex under a magazine');
|
||||
fx.skill(magazine, false);
|
||||
fx.skill(plain, true);
|
||||
assert.equal(fx.auras.size, 1, 'a plain skill keeps its aura');
|
||||
fx.destroy();
|
||||
});
|
||||
|
||||
test('the flag travels: unitInfo (ammo skills of allies only) → renderInfo', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }, timeLimit: 30, seed: 7,
|
||||
units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 11, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
});
|
||||
h.run(0.5);
|
||||
assert.equal(renderInfo(unitInfo(h.unit('chess_char_1_01_a'))).ammoSkill, true);
|
||||
assert.equal(renderInfo(unitInfo(h.unit('chess_char_1_02_a'))).ammoSkill, undefined);
|
||||
assert.equal(renderInfo(unitInfo(h.enemy('enemy_dummy'))).ammoSkill, undefined);
|
||||
assert.equal(renderInfo({ id: 1, ammoSkill: 'yes' }).ammoSkill, undefined, 'only a real true counts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the chain: sim → snapshot → SnapshotBuffer → UnitView', () => {
|
||||
test('隐现 S2: 14 cells, 13 after the first round, one more gone per attack; no aura; the plain SP bar after the last round', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } },
|
||||
units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
timeLimit: 200, seed: 7,
|
||||
});
|
||||
const u = h.unit('chess_char_1_01_a');
|
||||
assert.ok(h.runUntil(() => u.skill.active, 80));
|
||||
const v = new UnitView(fakeViewCtx(fake.P, { cam }), renderInfo(unitInfo(u)));
|
||||
const buffer = new SnapshotBuffer({ delay: 0 });
|
||||
const show = () => {
|
||||
const snap = h.b.snapshot();
|
||||
buffer.push(snap, snap.t);
|
||||
v.sync(buffer.sample(snap.t).get(u.id), snap.t);
|
||||
v.update(1 / 60, cam(), h.b.time);
|
||||
};
|
||||
show();
|
||||
const bw = barWidth(v);
|
||||
assert.equal(u.skill.ammoLeft, 13, 'the activation tick already fired the first round');
|
||||
assert.ok(Math.abs(v.spFill.width - bw * 13 / 14) < 1e-9);
|
||||
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 13);
|
||||
const left = u.skill.ammoLeft;
|
||||
assert.ok(h.runUntil(() => u.skill.ammoLeft < left && u.skill.active, 10));
|
||||
show();
|
||||
assert.ok(Math.abs(v.spFill.width - bw * 12 / 14) < 1e-9, 'one attack removes exactly one round');
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 60));
|
||||
show();
|
||||
show();
|
||||
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'the magazine is spent: the plain SP bar is back');
|
||||
assert.notEqual(v.spFill.tint, COLORS.ammo);
|
||||
v.destroy();
|
||||
});
|
||||
|
||||
test('伺夜\'s placed pack: the pips follow 2 → 3 → 2 and are gone when the pack falls', () => {
|
||||
const h = makeBattle({
|
||||
units: [{ chessId: 'chess_char_3_19_a', row: 12, col: 3, uid: 1 }, { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 }],
|
||||
timeLimit: 120, defs: { enemies: { enemy_d: enemyRec({ key: 'enemy_d', hp: 1e9, speed: 0, atk: 0 }) } }, enemies: [{ key: 'enemy_d', pos: [1, 1] }],
|
||||
});
|
||||
h.run(0.2);
|
||||
const w = h.b.allyUnits.find((x) => x.defId === 'token_10028_vigil_wolf');
|
||||
const v = new UnitView(fakeViewCtx(fake.P, { cam }), renderInfo(unitInfo(w)));
|
||||
const buffer = new SnapshotBuffer({ delay: 0 });
|
||||
const lit = () => {
|
||||
const snap = h.b.snapshot();
|
||||
buffer.push(snap, snap.t);
|
||||
v.sync(buffer.sample(snap.t).get(w.id), snap.t);
|
||||
v.update(1 / 60, cam(), h.b.time);
|
||||
return v._wolfPips ? v._wolfPips.lit.filter((d) => d.visible && d.alpha === 1).length : 0;
|
||||
};
|
||||
assert.equal(lit(), 2);
|
||||
h.run(25);
|
||||
assert.equal(lit(), 3);
|
||||
h.b.dealDamage(null, w, { amount: 1e9, type: 'true' });
|
||||
h.step(); // (a snapshot of the same game time as the last one would be dropped as a duplicate)
|
||||
assert.equal(lit(), 2);
|
||||
v.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
// Real renderer startup and cancellation, without downloaded art.
|
||||
// RENDER_E2E=1 node --test test/render/initialization.browser.test.js
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const skip = process.env.RENDER_E2E === '1' && existsSync(CHROME) ? false : 'set RENDER_E2E=1 (needs Chrome)';
|
||||
const HTML = `<!doctype html><html><head><link rel="stylesheet" href="/css/theme.css"><link rel="stylesheet" href="/css/screens/game.css"></head>
|
||||
<body style="margin:0"><div id="host" style="position:relative;width:1000px;height:650px"></div><div id="hooks"></div></body></html>`;
|
||||
|
||||
// These wrappers keep the real browser/Pixi behavior and record resources owned by each view.
|
||||
function trackBrowserResources() {
|
||||
const state = window.__initialization = { apps: [], observers: [], listeners: [], calls: 0, settled: 0 };
|
||||
const timers = new Map();
|
||||
const set = window.setTimeout.bind(window), clear = window.clearTimeout.bind(window);
|
||||
window.setTimeout = (fn, ms, ...args) => {
|
||||
const id = set(() => { timers.delete(id); fn(...args); }, ms);
|
||||
if (ms === 12000) timers.set(id, () => fn(...args));
|
||||
return id;
|
||||
};
|
||||
window.clearTimeout = (id) => { timers.delete(id); clear(id); };
|
||||
state.expire = () => {
|
||||
for (const [id, fn] of [...timers]) { clear(id); timers.delete(id); fn(); }
|
||||
};
|
||||
const NativeObserver = window.ResizeObserver;
|
||||
window.ResizeObserver = class extends NativeObserver {
|
||||
constructor(fn) { super(fn); this.targets = new Set(); state.observers.push(this); }
|
||||
observe(target, ...args) { this.targets.add(target); return super.observe(target, ...args); }
|
||||
unobserve(target) { this.targets.delete(target); return super.unobserve(target); }
|
||||
disconnect() { this.targets.clear(); return super.disconnect(); }
|
||||
};
|
||||
const add = EventTarget.prototype.addEventListener, remove = EventTarget.prototype.removeEventListener;
|
||||
const tracked = (target, type) => target instanceof HTMLCanvasElement ||
|
||||
(target === document && type === 'visibilitychange') || (target === window && type === 'resize');
|
||||
const capture = (opts) => typeof opts === 'boolean' ? opts : !!opts?.capture;
|
||||
EventTarget.prototype.addEventListener = function (type, fn, opts) {
|
||||
if (tracked(this, type) && !state.listeners.some((r) => r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts))) {
|
||||
state.listeners.push({ target: this, type, fn, capture: capture(opts) });
|
||||
}
|
||||
return add.call(this, type, fn, opts);
|
||||
};
|
||||
EventTarget.prototype.removeEventListener = function (type, fn, opts) {
|
||||
state.listeners = state.listeners.filter((r) => !(r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts)));
|
||||
return remove.call(this, type, fn, opts);
|
||||
};
|
||||
state.snapshot = () => ({
|
||||
apps: state.apps.map(({ app, ticker, canvas }) => ({ destroyed: app.renderer === null, ticking: ticker.started, attached: canvas.isConnected })),
|
||||
observers: state.observers.filter((r) => r.targets.size).length,
|
||||
listeners: state.listeners.map((r) => r.type),
|
||||
canvases: document.querySelectorAll('#host canvas').length,
|
||||
fallbacks: document.querySelectorAll('#host .ff').length,
|
||||
});
|
||||
}
|
||||
|
||||
function trackPixiApplications() {
|
||||
const Application = PIXI.Application;
|
||||
PIXI.Application = class extends Application {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
window.__initialization.apps.push({ app: this, ticker: this.ticker, canvas: this.view });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const FACTORY_WRAPPER = `export * from '/js/render/app.js?implementation';
|
||||
import { createFieldView as create } from '/js/render/app.js?implementation';
|
||||
export async function createFieldView(...args) {
|
||||
window.__initialization.calls++;
|
||||
if (window.__initialization.ignoreSignal) args[1] = { ...args[1], signal: undefined };
|
||||
try { return await create(...args); }
|
||||
finally { window.__initialization.settled++; }
|
||||
}`;
|
||||
|
||||
describe('field initialization in headless Chrome', { skip }, () => {
|
||||
let srv, browser, pixi;
|
||||
before(async () => {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
|
||||
pixi = readFileSync(path.join(ROOT, 'public/vendor/pixi.min.js'), 'utf8') + `\n;(${trackPixiApplications.toString()})();`;
|
||||
});
|
||||
after(async () => { await browser?.close(); await srv?.close(); });
|
||||
|
||||
async function open(t, { holdPixi = false } = {}) {
|
||||
const page = await browser.newPage();
|
||||
t.after(() => page.close());
|
||||
const errors = [];
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
await page.setViewport({ width: 1100, height: 750 });
|
||||
await page.evaluateOnNewDocument(trackBrowserResources);
|
||||
await page.setRequestInterception(true);
|
||||
let releasePixi;
|
||||
const requested = new Promise((resolve) => {
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname === '/initialization-test') return request.respond({ status: 200, contentType: 'text/html', body: HTML });
|
||||
if (url.pathname === '/js/render/app.js' && !url.search) return request.respond({ status: 200, contentType: 'text/javascript', body: FACTORY_WRAPPER });
|
||||
if (url.pathname === '/vendor/pixi.min.js') {
|
||||
releasePixi = () => request.respond({ status: 200, contentType: 'text/javascript', body: pixi });
|
||||
resolve();
|
||||
if (!holdPixi) return releasePixi();
|
||||
return;
|
||||
}
|
||||
return request.continue();
|
||||
});
|
||||
});
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/initialization-test?board=2d`);
|
||||
// Empty manifests prevent optional local art requests while preserving the real asset store.
|
||||
await page.evaluate(async () => {
|
||||
const { assets } = await import('/js/assets.js');
|
||||
assets.seed({ version: 1, chars: {}, enemies: {}, tokens: {}, items: {} });
|
||||
assets.seedLocal({ version: 1, files: {} });
|
||||
const { data } = await import('/js/data.js');
|
||||
await data.load('stages');
|
||||
window.__initialization.stage = data.list('stages')[0];
|
||||
window.__initialization.host = document.getElementById('host');
|
||||
});
|
||||
return { page, errors, requested, release: () => releasePixi() };
|
||||
}
|
||||
|
||||
async function start(page) {
|
||||
await page.evaluate(async () => {
|
||||
const { mountFieldView } = await import('/js/ui/fieldHost.js');
|
||||
const s = window.__initialization;
|
||||
s.mount = mountFieldView(s.host).then((view) => {
|
||||
s.view = view;
|
||||
view.setStage(s.stage);
|
||||
view.setPrep({ board: [], hand: [], temp: [] }, { editable: true });
|
||||
s.clicks = [];
|
||||
view.on('tileClick', (click) => s.clicks.push([click.row, click.col]));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function pixiFinished(page) {
|
||||
await page.evaluate(async () => {
|
||||
const { ensurePixi } = await import('/js/render/app/pixi.js');
|
||||
await ensurePixi();
|
||||
await new Promise(requestAnimationFrame);
|
||||
});
|
||||
}
|
||||
|
||||
async function clickFallback(page) {
|
||||
await page.waitForSelector('#host .ff-tile--floor');
|
||||
const tile = await page.$eval('#host .ff-tile--floor', (element) => {
|
||||
const r = element.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2, row: +element.dataset.row, col: +element.dataset.col };
|
||||
});
|
||||
await page.mouse.click(tile.x, tile.y);
|
||||
return [tile.row, tile.col];
|
||||
}
|
||||
|
||||
test('successful startup owns one running app and destroy releases its browser resources', async (t) => {
|
||||
const { page, errors } = await open(t);
|
||||
await start(page);
|
||||
await page.waitForFunction(() => window.__initialization.view?.kind === 'engine');
|
||||
const mounted = await page.evaluate(() => window.__initialization.snapshot());
|
||||
assert.equal(mounted.canvases, 1);
|
||||
assert.deepEqual(mounted.apps, [{ destroyed: false, ticking: true, attached: true }]);
|
||||
assert.equal(mounted.observers, 1);
|
||||
assert.ok(mounted.listeners.includes('pointerdown'));
|
||||
assert.ok(mounted.listeners.includes('visibilitychange'));
|
||||
const destroyed = await page.evaluate(() => {
|
||||
const s = window.__initialization;
|
||||
s.view.destroy(); s.view.destroy();
|
||||
return s.snapshot();
|
||||
});
|
||||
assert.deepEqual(destroyed, { apps: [{ destroyed: true, ticking: false, attached: false }], observers: 0, listeners: [], canvases: 0, fallbacks: 0 });
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
for (const ignoreSignal of [false, true]) test(`a timed-out Pixi load cannot cover the clickable fallback (${ignoreSignal ? 'legacy factory ignores cancellation' : 'cancellable factory'})`, async (t) => {
|
||||
const { page, errors, requested, release } = await open(t, { holdPixi: true });
|
||||
await page.evaluate((value) => { window.__initialization.ignoreSignal = value; }, ignoreSignal);
|
||||
await start(page);
|
||||
await requested;
|
||||
await page.evaluate(() => window.__initialization.expire());
|
||||
await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback');
|
||||
const target = await clickFallback(page);
|
||||
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
|
||||
await release();
|
||||
await pixiFinished(page);
|
||||
await page.waitForFunction(() => window.__initialization.settled === 1);
|
||||
await clickFallback(page);
|
||||
const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), clicks: window.__initialization.clicks }));
|
||||
assert.deepEqual(late.clicks, [target, target], 'real pointer input still reaches the fallback after Pixi finishes');
|
||||
assert.equal(late.canvases, 0, 'the abandoned engine must not append an orphan canvas');
|
||||
assert.equal(late.fallbacks, 1);
|
||||
assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached));
|
||||
assert.deepEqual(late.listeners, []);
|
||||
assert.equal(late.observers, 1, 'only the fallback observes the host');
|
||||
if (!ignoreSignal) {
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
await page.screenshot({ path: path.join(OUT, 'render-initialization-fallback.png') });
|
||||
}
|
||||
const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); });
|
||||
assert.equal(destroyed.observers, 0);
|
||||
assert.equal(destroyed.canvases, 0);
|
||||
assert.equal(destroyed.fallbacks, 0);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('timeout during board-art startup immediately releases the allocated app and permits a fresh engine', async (t) => {
|
||||
const { page, errors } = await open(t);
|
||||
await page.evaluate(async () => {
|
||||
const { assets } = await import('/js/assets.js');
|
||||
const s = window.__initialization, local = assets.local.bind(assets);
|
||||
assets.local = () => {
|
||||
if (!s.apps.length) return local();
|
||||
assets.local = local;
|
||||
s.artBlocked = true;
|
||||
return new Promise((resolve) => { s.releaseArt = async () => resolve(await local()); });
|
||||
};
|
||||
});
|
||||
await start(page);
|
||||
await page.waitForFunction(() => window.__initialization.artBlocked);
|
||||
const allocated = await page.evaluate(() => window.__initialization.snapshot());
|
||||
assert.deepEqual(allocated.apps, [{ destroyed: false, ticking: true, attached: true }]);
|
||||
await page.evaluate(() => window.__initialization.expire());
|
||||
await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback');
|
||||
const cancelled = await page.evaluate(() => window.__initialization.snapshot());
|
||||
assert.deepEqual(cancelled.apps, [{ destroyed: true, ticking: false, attached: false }], 'cancellation frees the real renderer before the delayed art resolves');
|
||||
assert.equal(cancelled.canvases, 0);
|
||||
assert.equal(cancelled.observers, 1);
|
||||
assert.deepEqual(cancelled.listeners, []);
|
||||
const target = await clickFallback(page);
|
||||
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
|
||||
await page.evaluate(async () => {
|
||||
const s = window.__initialization;
|
||||
await s.releaseArt();
|
||||
s.view.destroy();
|
||||
s.view = null;
|
||||
});
|
||||
await page.waitForFunction(() => window.__initialization.settled === 1);
|
||||
await start(page);
|
||||
await page.waitForFunction(() => window.__initialization.view?.kind === 'engine');
|
||||
const replacement = await page.evaluate(async () => {
|
||||
await new Promise(requestAnimationFrame);
|
||||
const s = window.__initialization, app = s.view.raw.debug.app;
|
||||
app.renderer.render(app.stage);
|
||||
return s.snapshot();
|
||||
});
|
||||
assert.deepEqual(replacement.apps, [
|
||||
{ destroyed: true, ticking: false, attached: false },
|
||||
{ destroyed: false, ticking: true, attached: true },
|
||||
], 'a replacement engine can render with the surviving shared caches');
|
||||
assert.equal(replacement.canvases, 1);
|
||||
const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); });
|
||||
assert.ok(destroyed.apps.every((app) => app.destroyed && !app.ticking && !app.attached));
|
||||
assert.equal(destroyed.observers, 0);
|
||||
assert.deepEqual(destroyed.listeners, []);
|
||||
assert.equal(destroyed.canvases, 0);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('a setup exception releases partial resources without removing another host child', async (t) => {
|
||||
const { page, errors } = await open(t);
|
||||
await page.evaluate(async () => {
|
||||
const { assets } = await import('/js/assets.js');
|
||||
const s = window.__initialization, subscribe = assets.onChange.bind(assets);
|
||||
s.sibling = document.createElement('span');
|
||||
s.sibling.textContent = 'Existing host content';
|
||||
s.host.appendChild(s.sibling);
|
||||
// Fail after input and resize resources exist, while the real view is still being built.
|
||||
assets.onChange = (fn) => {
|
||||
if (s.observers.some((observer) => observer.targets.has(s.host))) {
|
||||
assets.onChange = subscribe;
|
||||
throw new Error('asset subscription unavailable');
|
||||
}
|
||||
return subscribe(fn);
|
||||
};
|
||||
});
|
||||
await start(page);
|
||||
await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback');
|
||||
const failed = await page.evaluate(() => ({ ...window.__initialization.snapshot(), sibling: window.__initialization.sibling.isConnected }));
|
||||
assert.deepEqual(failed.apps, [{ destroyed: true, ticking: false, attached: false }]);
|
||||
assert.equal(failed.sibling, true, 'failed initialization only cleans up its own resources');
|
||||
assert.equal(failed.canvases, 0);
|
||||
assert.equal(failed.observers, 1);
|
||||
assert.deepEqual(failed.listeners, []);
|
||||
const target = await clickFallback(page);
|
||||
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
|
||||
const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); });
|
||||
assert.equal(destroyed.observers, 0);
|
||||
assert.equal(destroyed.fallbacks, 0);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('unmount during startup cannot erase a newer hook mount on the same host', async (t) => {
|
||||
const { page, errors, requested, release } = await open(t, { holdPixi: true });
|
||||
await page.evaluate(async () => {
|
||||
const { h, render } = await import('/vendor/preact.module.js');
|
||||
const { useFieldView } = await import('/js/ui/fieldHost.js');
|
||||
const s = window.__initialization;
|
||||
function Field() { s.hook = useFieldView({ current: s.host }); return null; }
|
||||
s.renderHook = (key) => render(key ? h(Field, { key }) : null, document.getElementById('hooks'));
|
||||
s.renderHook('first');
|
||||
});
|
||||
await requested;
|
||||
await page.evaluate(() => {
|
||||
const s = window.__initialization;
|
||||
s.renderHook(null);
|
||||
window.__SP_RENDER__ = 'fallback';
|
||||
s.renderHook('second');
|
||||
});
|
||||
await page.waitForFunction(() => window.__initialization.hook?.kind === 'fallback');
|
||||
await page.evaluate(() => {
|
||||
const s = window.__initialization;
|
||||
s.current = s.hook.view;
|
||||
s.current.setStage(s.stage);
|
||||
s.current.setPrep({ board: [], hand: [], temp: [] }, { editable: true });
|
||||
s.clicks = [];
|
||||
s.current.on('tileClick', (click) => s.clicks.push([click.row, click.col]));
|
||||
s.expire();
|
||||
});
|
||||
await release();
|
||||
await pixiFinished(page);
|
||||
await page.waitForFunction(() => window.__initialization.settled === 1);
|
||||
const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), current: window.__SP_VIEW__ === window.__initialization.current }));
|
||||
assert.equal(late.fallbacks, 1, 'the old mount must not clear the newer view from the shared host');
|
||||
assert.equal(late.canvases, 0);
|
||||
assert.equal(late.current, true);
|
||||
assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached));
|
||||
const target = await clickFallback(page);
|
||||
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
|
||||
const destroyed = await page.evaluate(() => {
|
||||
window.__initialization.renderHook(null);
|
||||
return { ...window.__initialization.snapshot(), current: window.__SP_VIEW__ };
|
||||
});
|
||||
assert.equal(destroyed.current, null);
|
||||
assert.equal(destroyed.observers, 0);
|
||||
assert.equal(destroyed.canvases, 0);
|
||||
assert.equal(destroyed.fallbacks, 0);
|
||||
assert.deepEqual(destroyed.listeners, []);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
});
|
||||
@@ -279,6 +279,77 @@ describe('enemy preview pen figures (lod idle)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The impostor refreshes of a frame are spread evenly over the interval (app.js impostorSlot: each frame the impostor
|
||||
// units take slots 0, 1, 2 … in update order): a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. With a random phase per
|
||||
// unit, the busiest frame did 20–66% more refreshes than that (16–48 units, intervals 2–6) — the frame-time peaks of a
|
||||
// crowded battle on a slow device.
|
||||
describe('impostor refresh slots', () => {
|
||||
/** n pen figures (lod idle: interval 3) in one context; `slotOf(frame, i)` gives view i its slot, or null: no slots. */
|
||||
async function crowd(n, slotOf) {
|
||||
let frame = 0;
|
||||
let i = 0;
|
||||
const atlas = {
|
||||
alloc: (w, h) => ({ w, h, tex: new fake.P.Texture(), clip: false }),
|
||||
free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() {},
|
||||
};
|
||||
const ctx = fakeViewCtx(fake.P, {
|
||||
assets: store({ spine: true }), cam, frameNo: () => frame, impostors: atlas,
|
||||
renderer: { resolution: 1, render() {} },
|
||||
...(slotOf ? { impostorSlot: () => slotOf(frame, i) } : {}),
|
||||
});
|
||||
const views = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
views.push(new UnitView(ctx, { id: `e:${k}`, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', tier: 1, x: 9, y: 15, maxHp: 1, facing: -1 }, { prep: true, lod: 'idle' }));
|
||||
}
|
||||
await tick(); await tick();
|
||||
const refreshed = views.map(() => []);
|
||||
views.forEach((v, k) => {
|
||||
assert.ok(v.spineReady, 'spine ready');
|
||||
const upd = v.actor.update.bind(v.actor);
|
||||
v.actor.update = (dt) => { refreshed[k].push(frame); return upd(dt); };
|
||||
});
|
||||
const step = () => { views.forEach((v, k) => { i = k; v.update(1 / 60, cam(), frame / 60); }); frame++; };
|
||||
return { step, refreshed, frames: () => frame };
|
||||
}
|
||||
const perFrame = (refreshed, from, to) => {
|
||||
const c = new Map();
|
||||
for (const list of refreshed) for (const f of list) if (f >= from && f < to) c.set(f, (c.get(f) || 0) + 1);
|
||||
return Array.from({ length: to - from }, (_, j) => c.get(from + j) || 0);
|
||||
};
|
||||
|
||||
test('slots in update order: every frame refreshes ⌊n/3⌋ or ⌈n/3⌉ units, each unit every 3rd frame', async () => {
|
||||
for (const n of [7, 16, 24]) {
|
||||
const { step, refreshed } = await crowd(n, (frame, k) => k);
|
||||
for (let f = 0; f < 31; f++) step();
|
||||
// frame 0: every new impostor is drawn once (dirty); from frame 1 on, the interval rules
|
||||
const counts = perFrame(refreshed, 1, 31);
|
||||
assert.ok(counts.every((c) => c === Math.floor(n / 3) || c === Math.ceil(n / 3)), `${n} units: ${counts.join(' ')}`);
|
||||
for (const list of refreshed) {
|
||||
const after = list.filter((f) => f >= 1);
|
||||
assert.equal(after.length, 10, `each unit refreshed 10 times in 30 frames (${after.join(',')})`);
|
||||
assert.ok(after.every((f, j) => j === 0 || f - after[j - 1] === 3), 'exactly every 3rd frame');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a slot that moves with the frame never starves a unit: refreshed at least every 2 intervals', async () => {
|
||||
// (frame + slot) % 3 is never 0: the turn alone would never come
|
||||
const { step, refreshed } = await crowd(3, (frame) => 4 - (frame % 3));
|
||||
for (let f = 0; f < 31; f++) step();
|
||||
for (const list of refreshed) {
|
||||
const after = list.filter((f) => f >= 1);
|
||||
assert.ok(after.length >= 5, `refreshed ${after.length} times`);
|
||||
assert.ok([0, ...after].every((f, j, a) => j === 0 || f - a[j - 1] <= 6), `gaps ≤ 6 frames (${after.join(',')})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('without slots (a context that has none) the random phase still refreshes every 3rd frame', async () => {
|
||||
const { step, refreshed } = await crowd(5, null);
|
||||
for (let f = 0; f < 31; f++) step();
|
||||
for (const list of refreshed) assert.equal(list.filter((f) => f >= 1).length, 10);
|
||||
});
|
||||
});
|
||||
|
||||
// Player report 2026-10-05: 「无人机等飞行单位贴图位置明显偏低」, and the follow-up "绝对不止 0.35" with an official
|
||||
// screenshot of 帝国炮火先兆者 over a tile (PR #211 by @xcdoge; the owner's decision of 2026-10-06). The lift is the
|
||||
// official client's own single constant — Vector3(0, 0.35, 0) written by Torappu.Battle.CharacterAnimator's constructor
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// test/sim/hud-readouts.test.js — the optional b.snap lists behind the HP-bar readouts (R22-HUD; PR #303 and #378 by
|
||||
// @IceCodeNew, PR #383 by @Convey123): `ammo` (a running ammo skill's rounds — 隐现's S2 holds 14 in the data), `wolves`
|
||||
// (伺夜's 狼群 count) and, in test/content/op_hsgma2.test.js, `neg` (斩业星熊's 我执 pool). All three are display only: the
|
||||
// nine-field unit tuples are unchanged and no sim state reads them. The client side is test/render/hud-readouts.test.js.
|
||||
// Run: node --test test/sim/hud-readouts.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { unitInfo, unitTuple } from '../../server/sim/snapshot.js';
|
||||
|
||||
const DUMMY = { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } };
|
||||
const INSIDE = 'chess_char_1_01_a'; // 隐现: the chess skill index 1 is skchr_inside_2 (AMMO, attack@trigger_time 14)
|
||||
|
||||
test('隐现 S2: b.snap `ammo` carries the whole rounds left and the 14-round magazine of the data; the tuple keeps its SP fraction', () => {
|
||||
const h = makeBattle({
|
||||
defs: DUMMY, timeLimit: 200, seed: 7,
|
||||
units: [{ chessId: INSIDE, row: 10, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
});
|
||||
const u = h.unit(INSIDE);
|
||||
const row = () => (h.b.snapshot().ammo || []).find((e) => e[0] === u.id);
|
||||
assert.equal(h.b.snapshot().ammo, undefined, 'no skill running: no list at all');
|
||||
assert.equal(unitInfo(u).ammoSkill, true, 'UnitInfo says the skill is an ammo magazine from the first appearance');
|
||||
assert.ok(h.runUntil(() => u.skill.active, 80));
|
||||
assert.equal(u.skill.kind, 'ammo');
|
||||
assert.equal(u.skill.ammoMax, 14, 'skill_table skchr_inside_2 attack@trigger_time at the chess skill level');
|
||||
// the activation tick already fired the first round
|
||||
assert.deepEqual(row(), [u.id, 13, 14]);
|
||||
const [, , , , , sp, spMax] = unitTuple(u, h.b.time);
|
||||
assert.ok(Math.abs(sp / spMax - 13 / 14) < 0.01, 'the 9-field tuple still runs the SP bar on the same fraction');
|
||||
assert.equal(h.b.snapshot().units.find((t) => t[0] === u.id).length, 9, 'the tuple is not widened');
|
||||
// every attack spends one round
|
||||
const before = u.skill.ammoLeft;
|
||||
assert.ok(h.runUntil(() => u.skill.ammoLeft < before, 10));
|
||||
assert.deepEqual(row(), [u.id, u.skill.ammoLeft, 14]);
|
||||
// a refill above the magazine raises it (拉特兰's extras, 逃犯引渡手续, refills: the activation's real total)
|
||||
u.skill.addAmmo(20);
|
||||
assert.deepEqual(row(), [u.id, u.skill.ammoLeft, u.skill.ammoMax]);
|
||||
assert.ok(u.skill.ammoMax > 14 && row()[1] === row()[2], 'full again at the new mark');
|
||||
// the skill ends with its last round: the list goes, the plain SP bar is back
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 60));
|
||||
assert.equal(h.b.snapshot().ammo, undefined);
|
||||
checkInvariants(h.b);
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
|
||||
test('ammo rows: whole numbers (a fractional count rounds up), allies only, living deployed units only; an operator without an ammo skill is no ammoSkill', () => {
|
||||
const h = makeBattle({
|
||||
defs: DUMMY, timeLimit: 200, seed: 7,
|
||||
units: [{ chessId: INSIDE, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 11, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
});
|
||||
const u = h.unit(INSIDE);
|
||||
h.run(0.5);
|
||||
assert.equal(unitInfo(h.unit('chess_char_1_02_a')).ammoSkill, undefined, 'a duration skill is not a magazine');
|
||||
assert.equal(unitInfo(h.enemy('enemy_dummy')).ammoSkill, undefined);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 80));
|
||||
u.skill.ammoLeft = 6.2; // (a kit that spends a share of a round)
|
||||
const e = h.b.snapshot().ammo.find((r) => r[0] === u.id);
|
||||
assert.deepEqual(e, [u.id, 7, 14]);
|
||||
assert.ok(Number.isInteger(e[1]) && Number.isInteger(e[2]));
|
||||
u.deployed = false; // off the field: no row (the unit is not in the snapshot either)
|
||||
assert.equal(h.b.snapshot().ammo, undefined);
|
||||
u.deployed = true;
|
||||
});
|
||||
|
||||
for (const placed of [false, true]) {
|
||||
test(`伺夜's ${placed ? 'placed' : 'automatic'} pack: b.snap \`wolves\` follows the 狼影 count (2 of 3, +1 per 25 s, one lost per lethal hit) and goes with the pack`, () => {
|
||||
const units = [{ chessId: 'chess_char_3_19_a', row: 12, col: 3, uid: 1 }];
|
||||
if (placed) units.push({ kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 });
|
||||
const h = makeBattle({
|
||||
units, timeLimit: 120,
|
||||
defs: { enemies: { enemy_d: enemyRec({ key: 'enemy_d', hp: 1e9, speed: 0, atk: 0 }) } },
|
||||
enemies: [{ key: 'enemy_d', pos: [1, 1] }],
|
||||
});
|
||||
h.run(0.2);
|
||||
const w = h.b.allyUnits.find((u) => u.defId === 'token_10028_vigil_wolf');
|
||||
const count = () => (h.b.snapshot().wolves || []).find((e) => e[0] === w.id);
|
||||
assert.deepEqual(count(), [w.id, 2, 3], '初始两只, at most 3 (the talent text)');
|
||||
assert.equal(h.b.snapshot().wolves.length, 1, 'only the pack has the row: 伺夜 herself does not');
|
||||
h.run(25);
|
||||
assert.deepEqual(count(), [w.id, 3, 3], '+1 every 25 s');
|
||||
for (const left of [2, 1]) {
|
||||
h.b.dealDamage(null, w, { amount: 1e9, type: 'true' });
|
||||
assert.deepEqual(count(), [w.id, left, 3], 'a lethal hit spends one 狼影 and refills the HP');
|
||||
}
|
||||
h.b.dealDamage(null, w, { amount: 1e9, type: 'true' });
|
||||
assert.equal(w.alive, false, 'the last 狼影: 战术点形态 (the pack is out of the fight)');
|
||||
assert.equal(count(), undefined, 'a pack in that form shows no pips');
|
||||
h.run(26);
|
||||
assert.deepEqual(count(), [w.id, 1, 3], 'back after the 狼影 interval with one 狼影');
|
||||
h.b.retreat(h.unit('chess_char_3_19_a'), { reason: 'expired', permanent: true });
|
||||
assert.equal(h.b.snapshot().wolves, undefined, 'the pack leaves with its owner');
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user