feedback3: merge W3D — 折桠's skill idle (community report #23), the ammo bar's real total (#35), unit sounds at the official chance and volume (#30), shots from the hands to the chest (GitHub #61), stuck model loads restarted (GitHub #68), the boss leader shown in prep with its hit tiles (#12)

This commit is contained in:
sganggs committed 2026-10-04 18:10:01 +08:00
commit a57c2a85e2
26 files changed
+1105 -89

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
// test/render/feedback3-leader-prep.test.js — community report #12 (owner's decision 2026-10-04): in the Final Assault /
// Hidden Core prep the round's leader stands on the boss field (it used to wait in the off-screen pen: research 08's +13
// preview row put it in the pen's upper zone), and while an operator's orange range preview shows, the leader's hit
// tiles — the sim's hit rectangle — are lit in red [ASSUMED colour]. The match layer's preview carries the leader's spawn
// tile (server/match/waves.js previewOf `start`); render/prepfield.js leaderStand + render/pick.js hitTiles (= the sim's
// body.js bodyKeys) give the stand; render/app.js draws it (wiring: test/render/fieldview.browser.test.js). Display only.
import { test } 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 { GameData } from '../../server/match/gamedata.js';
import { setupMatchWaves, buildBossWave, previewOf } from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
import { bodyKeys, normHitArea } from '../../server/sim/body.js';
import { COLS } from '../../server/sim/constants.js';
import { DATA } from '../match/harness.js';
import { leaderStand } from '../../public/js/render/prepfield.js';
import { hitTiles } from '../../public/js/render/pick.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
test('the preview of a boss round carries the leader\'s spawn tile; other entries never do', () => {
const p = previewOf([
{ enemyKey: 'L', count: 1, time: 0, tag: 'boss', preview: { gate: 'upper', boss: true, start: [3, 10] } },
{ enemyKey: 'a', count: 2, time: 1, preview: { gate: 'lower', start: [3, 2] } },
]);
assert.deepEqual(p[0].start, [3, 10]);
assert.equal('start' in p[1], false, 'escorts stay in the pen');
const gd = new GameData(DATA, 'mode_multi_abyss');
const setup = setupMatchWaves(gd, createRng(1));
for (const bossId of Object.keys(gd.mode.bossWeights)) {
for (const solo of [false, true]) {
const w = buildBossWave(gd, createRng(1), setup.factions, 14, { bossId, solo });
const boss = previewOf(w.spawns).filter((e) => e.boss);
assert.ok(boss.length >= 1, `${w.templateId}: a leader`);
const route = DATA.waves[w.templateId].routes[w.spawns.find((s) => s.tag === 'boss').routeIndex];
for (const e of boss) assert.deepEqual(e.start, route.start.slice(0, 2), `${w.templateId}: the leader's route start`);
}
}
});
test('leaderStand: every leader of the data stands on its spawn tile with the sim\'s hit tiles (bodyKeys)', () => {
const seen = new Set();
for (const [id, tpl] of Object.entries(DATA.waves)) {
if (!tpl || (tpl.kind !== 'boss' && tpl.kind !== 'hidden')) continue;
for (const s of tpl.spawns.filter((x) => x.tag === 'boss')) {
const start = tpl.routes[s.routeIndex].start.slice(0, 2);
const st = leaderStand([{ enemyKey: 'escort', count: 3 }, { enemyKey: s.key, count: 1, boss: true, start }], (k) => DATA.enemies[k]?.hitArea ?? null, hitTiles);
assert.ok(st, id);
assert.deepEqual([st.row, st.col], start);
const sim = bodyKeys({ x: start[1], y: start[0], hitArea: normHitArea(DATA.enemies[s.key].hitArea) }).map((k) => [Math.floor(k / COLS), k % COLS]);
assert.deepEqual(st.tiles, sim, `${id} ${s.key}: the sim's hit tiles`);
seen.add(`${s.key} ${st.tiles.length}`);
}
}
// 胄 / 管 / 昆图斯 / 阿利斯泰尔 / 萨米的意志: 5 × 3 (4.95 × 2.95, up 1); 铳 / 卢西恩 are point units: their tile
assert.ok(seen.has('enemy_9013_acstmk 15') && seen.has('enemy_9032_aclionk 15') && seen.has('enemy_9017_achunt 1'), [...seen].join(', '));
const chou = leaderStand([{ enemyKey: 'enemy_9013_acstmk', boss: true, start: [3, 10] }], (k) => DATA.enemies[k].hitArea, hitTiles);
assert.deepEqual([...new Set(chou.tiles.map(([r]) => r))], [3, 4, 5]);
assert.deepEqual([...new Set(chou.tiles.map(([, c]) => c))], [8, 9, 10, 11, 12]);
assert.equal(leaderStand([{ enemyKey: 'x', boss: true }], () => null, hitTiles), null, 'no spawn tile: it stays in the pen');
assert.equal(leaderStand([{ enemyKey: 'x', boss: false, start: [3, 10] }], () => null, hitTiles), null);
assert.equal(leaderStand(null), null);
});
test('render/app.js: the leader leaves the pen for the boss field, red hit tiles beside a range preview (source checks)', async () => {
const { LEADER_HIT_STYLE, leaderShown } = await import('../../public/js/render/app.js');
assert.equal(LEADER_HIT_STYLE.group, 'leaderHit');
assert.ok((LEADER_HIT_STYLE.color >> 16) > 0xd0 && ((LEADER_HIT_STYLE.color >> 8) & 0xff) < 0x60, 'red');
assert.equal(leaderShown('bossPrep'), true);
assert.equal(leaderShown('prep'), false);
assert.equal(leaderShown('pen', 'bossPrep'), true, 'a flight from the boss-field prep');
const app = read('public/js/render/app.js');
assert.match(app, /const pen = layoutPen\(stand \? list\.filter\(\(e\) => e !== stand\.entry\) : list, \{ stage: stageRec \}\);/);
assert.match(app, /\[\.\.\.hlReq\.keys\(\)\]\.some\(\(g\) => RANGE_GROUPS\.has\(g\)\)/);
});
test('the leader\'s view (render/app.js setLeader: a boss, not a prep view) shows its full HP bar, like the official prep image', async () => {
const { installFakePixi, fakeViewCtx } = await import('./fakepixi.js');
const { presetCamera } = await import('../../public/js/render/projection.js');
const fake = installFakePixi();
try {
const { UnitView } = await import('../../public/js/render/units.js');
const cam = presetCamera('bossPrep', { width: 1600, height: 900 });
const v = new UnitView(fakeViewCtx(fake.P, { cam: () => cam }), { id: 'leader:x', kind: 'enemy', side: 'enemy', defId: 'enemy_9013_acstmk', spine: 'enemy_9013_acstmk', tier: 3, x: 10, y: 3, facing: -1, maxHp: 1, boss: true });
v.update(1 / 60, cam, 0);
assert.equal(v.hpFill.visible, true, 'HP bar shown');
assert.ok(Math.abs(v.hpFill.width - v.hpBg.width + 2) < 1e-6, 'full');
assert.equal(v.spFill.visible, false);
v.destroy();
} finally { fake.restore(); }
});
+91
View File
@@ -0,0 +1,91 @@
// test/render/feedback3-shot-height.test.js — GitHub #61 (0.1.3): shots left an operator at its hips and aimed at the
// target's hips, and every attack shoved the whole model 0.12 tiles toward its target. render/fx.js took 0.45 × the
// head height as a WORLD height, which the 30° camera pitch draws at ≈ 18 % of the (upright, screen-space) model; the
// attack jolt of render/units.js was meant for the avatar diamond only. Now launch / aim points are heights on screen
// (fx.js SHOT_HEIGHT, solved through projection.js Camera.liftFor) and a shown Spine model keeps its place.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, FX, UnitView;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
({ UnitView } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const cam = presetCamera('normal', { width: 1600, height: 900 });
const close = (a, b, eps, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
test('Camera.liftFor: the world height that draws `px` above a point — exact on every preset camera', () => {
const fields = { normal: [[2, 9, 0], [10, 11, 0.4], [9, 12, 0]], prep: [[2, 7, 0.16], [8, 12, 0]], boss: [[3, 0, 0], [10, 3, 0], [18, 5, 0.4]], unite: [[2, 9, 0], [18, 12, 0]] };
for (const [kind, pts] of Object.entries(fields)) {
const c = presetCamera(kind, { width: 1280, height: 720 });
for (const [x, y, z] of pts) {
for (const px of [0, 20, 64, 140]) {
const h = c.liftFor(x, y, z, px);
close(c.project(x, y, z).y - c.project(x, y, z + h).y, px, 1e-6, `${kind} (${x},${y},${z}) +${px}px`);
}
}
}
const s = cam.scaleAt(5, 10, 0);
assert.ok(cam.liftFor(5, 10, 0, s) > 1.8, 'one tile of screen height is ≈ 2 world tiles under the 30° pitch');
});
/** A unit view as the FX system reads it. */
const unit = (id, x, y, o = {}) => ({ id, x, y, z: 0, hover: 0, _headTiles: 1.18, alive: true, destroyed: false, isEnemy: false, info: { defId: 'char_x' }, onHit() {}, ...o });
function makeFx() {
const ctx = fakeViewCtx(fake.P);
return new FX.FxSystem({
P: fake.P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
timeScale: () => 2, loadLevel: () => 0, subProfOf: () => null, view: () => null, screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
}
/** Share of a unit's drawn model height (above its feet, on screen) of world point (x, y, z). */
const share = (v, x, y, z) => { const f = cam.project(x, y, v.z); return (f.y - cam.project(x, y, z).y) / (v._headTiles * f.s); };
test('an arrow leaves the shooter at its hands and flies at the target\'s chest (SHOT_HEIGHT), not at the hips', () => {
assert.deepEqual({ ...FX.SHOT_HEIGHT }, { launch: 0.45, aim: 0.5 });
const fx = makeFx();
const src = unit(1, 3, 10), tgt = unit(2, 7, 11, { isEnemy: true, _headTiles: 1.6 });
fx.attack(src, tgt, 'arrow');
const pr = fx.projs[fx.projs.length - 1];
close(share(src, pr.x0, pr.y0, pr.z0), FX.SHOT_HEIGHT.launch, 0.03, 'launch share of the model height');
close(share(tgt, pr.tx, pr.ty, pr.tz), FX.SHOT_HEIGHT.aim, 1e-6, 'aim share of the target\'s model');
// the old rule: 0.45 × head height as a world height → ≈ 18–23 % of the model
assert.ok(share(src, src.x, src.y, 0.45 * src._headTiles) < 0.25, 'the hip height it used to start from');
// a raised / flying unit: measured from where it is drawn
const fly = unit(3, 6, 9, { z: 0.4, hover: 0.32 });
assert.ok(Math.abs(FX.bodyZ(cam, fly, 0.5) - 0.72 - cam.liftFor(6, 9, 0.72, 0.5 * 1.18 * cam.scaleAt(6, 9, 0.72))) < 1e-9);
});
test('the attack jolt only moves the avatar diamond: a shown Spine model keeps its place on an attack', async () => {
const entry = { skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'], anims: { idle: 'Idle', attack: { begin: null, loop: 'Attack', end: null } }, animations: { Idle: 1, Attack: 1 } };
let ready = false;
const assets = { picture: () => null, image: async () => null, spineEntry: () => entry,
spine: { acquire: () => (ready ? Promise.resolve({ animations: [{ name: 'Idle' }, { name: 'Attack' }] }) : new Promise(() => {})), release() {} } };
const make = () => new UnitView(fakeViewCtx(fake.P, { assets, cam: () => cam }), { id: 1, side: 'ally', kind: 'op', defId: 'c', spine: 'c', tier: 3, x: 4, y: 10, maxHp: 1000, dir: 'RIGHT' });
const target = { x: 8, y: 12 };
const feet = cam.project(4, 10, 0);
// no model (still loading): the diamond jolts toward the target
const d = make();
d.update(1 / 60, cam, 0);
d.onAttack(target, 1, 'arrow');
d.update(0.1, cam, 0.1); d.update(0.01, cam, 0.11); // (the jolt peaks a tenth of a second in)
assert.ok(Math.hypot(d.root.position.x - feet.x, d.root.position.y - feet.y) > 1, 'the avatar diamond lunges');
// a Spine model shown: no jolt
ready = true;
const v = make();
await new Promise((r) => setImmediate(r)); await new Promise((r) => setImmediate(r));
assert.ok(v.actor && v.spineReady);
v.update(1 / 60, cam, 0);
v.onAttack(target, 1, 'arrow');
for (let i = 0; i < 4; i++) {
v.update(0.05, cam, 0.05 * i);
close(v.root.position.x, feet.x, 1e-6, 'x'); close(v.root.position.y, feet.y, 1e-6, 'y');
}
assert.equal(v.actor.current, 'Attack', 'the model shows the attack with its own clip');
});
+91
View File
@@ -0,0 +1,91 @@
// test/render/feedback3-skill-idle.test.js — community report #23 (0.1.3): "折娅开了技能就会不停做跳起来打人的动作,哪怕没有接敌".
// 折桠's S2 model (char_4207_branch) has Skill_2_Begin, Skill_2_Loop (the jump attack, OnAttack at 0.8 s), Skill_2_End
// and Skill_2_Idle; the actor queued the loop after the begin clip, so it jumped with nobody to hit (the sim made no
// attack). Now a skill with an idle clip of its own (anims skill.idle ≠ skill.loop) stands in that idle between attacks,
// plays the loop only on attacks, and goes back to the idle after a spell of attacks (its End clip only when the skill
// ends). Skills whose loop IS their idle (蕾缪安 S3) and skills without an idle (宴) are unchanged. Real manifest entries,
// headless fake PIXI (test/render/fakepixi.js).
import { test, before, after } 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 { installFakePixi } from './fakepixi.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
let fake, SpineActor;
before(async () => {
fake = installFakePixi();
({ SpineActor } = await import('../../public/js/render/spine.js'));
});
after(() => fake.restore());
/** An actor of a real operator's Front model with its equipped skill, recording every clip set / queued. */
function actor(charId, skillIndex) {
const entry = assets.chars[charId].spine.front;
const a = new SpineActor({ animations: Object.keys(entry.animations).map((name) => ({ name })) }, entry);
a.setSkillIndex(skillIndex);
a.log = [];
const st = a.spine.state;
const set = st.setAnimation, add = st.addAnimation;
st.setAnimation = (i, name, loop) => { a.log.push(['set', name, !!loop]); return set(i, name, loop); };
st.addAnimation = (i, name, loop) => { a.log.push(['queue', name, !!loop]); return add(i, name, loop); };
return a;
}
const run = (a, s, dt = 1 / 60) => { for (let t = 0; t < s - 1e-9; t += dt) a.update(dt); };
const names = (a) => a.log.map(([, n]) => n);
test('折桠 S2: begin, then Skill_2_Idle — the jump attack only on attacks, back to the idle after them, End only when the skill ends', () => {
const a = actor('char_4207_branch', 1);
assert.deepEqual([a.roles.skill.loop, a.roles.skill.idle], ['Skill_2_Loop', 'Skill_2_Idle'], 'the manifest roles');
a.setSkill(true);
assert.deepEqual(a.log, [['set', 'Skill_2_Begin', false], ['queue', 'Skill_2_Idle', true]], 'no Skill_2_Loop queued after the begin clip');
run(a, 3);
assert.equal(a.mode, 'base');
assert.ok(!names(a).includes('Skill_2_Loop'), 'no attack: no jump attack');
// she is hit, an enemy stands on her tile: attacks every 1.5 s
a.log.length = 0;
a.attack(1.5);
assert.equal(a.current, 'Skill_2_Loop', 'the attack plays the skill loop');
run(a, 1.5); a.attack(1.5);
run(a, 1.5 * 1.4 + 0.1);
assert.equal(a.mode, 'base');
assert.equal(a.current, 'Skill_2_Idle', 'the enemy is gone: back to the skill idle');
assert.ok(!names(a).includes('Skill_2_End'), 'the skill End clip is not played between attacks');
a.setSkill(false);
assert.equal(a.current, 'Skill_2_End', 'the skill ends on its End clip');
run(a, 0.2);
assert.equal(a.current, 'Idle');
});
test('the other skills with an own idle clip follow the same rule (史尔特尔 S3 begin → Skill_3_Idle); 耀骑士临光 S3 (no begin) already idled', () => {
const s = actor('char_350_surtr', 2);
s.setSkill(true);
assert.deepEqual(s.log, [['set', 'Skill_3_Begin', false], ['queue', 'Skill_3_Idle', true]]);
s.attack(1.25);
assert.equal(s.current, 'Skill_3_Loop');
const n = actor('char_1014_nearl2', 2);
n.setSkill(true);
assert.deepEqual(n.log, [['set', 'Skill_3_Idle', true]], 'unchanged: the skill idle at once');
n.attack(1.2);
assert.equal(n.current, 'Skill_3');
run(n, 1.2 * 1.4 + 0.1);
assert.equal(n.current, 'Skill_3_Idle');
});
test('unchanged: a loop that is the skill idle (蕾缪安 S3) and a skill without an idle clip (宴: its loop stays the stance)', () => {
const l = actor('char_4193_lemuen', 2);
l.setSkill(true);
assert.deepEqual(l.log, [['set', 'Skill_3_Begin', false], ['queue', 'Skill_3_Idle', true]]);
const u = actor('char_337_utage', 1);
assert.equal(u.roles.skill.idle, null);
u.setSkill(true);
assert.deepEqual(u.log, [['set', 'Skill_Start', false], ['queue', 'Skill_Loop', true]], 'begin → loop, as before');
u.log.length = 0;
u.attack(1.2);
run(u, 1.2 * 1.4 + 0.1);
assert.deepEqual(names(u).slice(-2), ['Skill_End', 'Idle'], 'its spell of attacks still ends as before');
});
+148
View File
@@ -0,0 +1,148 @@
// test/render/feedback3-stuck-load.test.js — GitHub #68 (0.1.3): placeholder models stayed after switching back to a
// tab that had been hidden. A Spine load begun while the tab was hidden can stay pending (a request the background tab
// left hanging); UnitView.retryAssets — called when the tab is shown — only reloaded views with no load in flight, and
// every retry joined PIXI's same pending request. Now such a load gets SPINE_STUCK_MS after the tab is visible, then it
// is started again in place (RefLru.restart: refs and the waiting promise kept, the first success wins, the abandoned
// attempt's slot freed), and a fresh load forgets PIXI's pending requests of that skeleton (forgetPendingSpine).
// Headless fake PIXI (test/render/fakepixi.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 { createAssets, RefLru, forgetPendingSpine } from '../../public/js/assets.js';
const tick = () => new Promise((r) => setImmediate(r));
const never = () => new Promise(() => {});
const SP = (id) => ({ skel: `/assets/spine/${id}.skel`, atlas: `/assets/spine/${id}.atlas`, textures: [`/assets/spine/${id}.png`], anims: { idle: 'Idle' }, animations: { Idle: 1 } });
const M = { chars: { char_002_amiya: { avatar: '/a/amiya.png', spine: { front: SP('amiya_f') } } }, ui: {} };
describe('RefLru.restart', () => {
test('a load in flight starts again in place: the holders\' promise settles with the new attempt, refs kept, fresh', async () => {
const calls = [];
let resolveOld;
const lru = new RefLru({ timeout: 0, load: (k, arg, o) => { calls.push(o?.fresh ? 'fresh' : 'plain'); return calls.length === 1 ? new Promise((r) => { resolveOld = r; }) : Promise.resolve(`${k}#2`); }, unload: () => { throw new Error('nothing to unload'); } });
const p = lru.acquire('k');
assert.equal(lru.restart('k'), true);
assert.equal(await p, 'k#2', 'the waiting holder gets the restarted load');
assert.deepEqual(calls, ['plain', 'fresh']);
assert.equal(lru.map.get('k').refs, 1);
resolveOld('k#1'); // the abandoned request settles late: ignored, nothing unloaded
await tick();
assert.equal(lru.peek('k'), 'k#2');
assert.equal(lru.restart('k'), false, 'ready: nothing to restart');
assert.equal(lru.restart('nope'), false);
});
test('the abandoned attempt frees its concurrency slot; only the newest attempt\'s failure fails the entry', async () => {
const started = [];
const lru = new RefLru({ timeout: 0, concurrency: 1, load: (k) => { started.push(k); return k === 'a' && started.filter((x) => x === 'a').length === 1 ? never() : Promise.resolve(k); } });
const pa = lru.acquire('a');
const pb = lru.acquire('b');
await tick();
assert.deepEqual(started, ['a'], 'b waits behind the hung load of a');
lru.restart('a');
assert.equal(await pb, 'b', 'the hung attempt\'s slot is freed at once');
assert.equal(await pa, 'a');
// failures: an abandoned attempt failing changes nothing, the newest one failing fails the entry
let n = 0, rejectOld;
const f = new RefLru({ timeout: 0, load: () => (++n === 1 ? new Promise((_, rej) => { rejectOld = rej; }) : Promise.reject(new Error('second'))) });
const pf = f.acquire('x');
f.restart('x');
await assert.rejects(pf, /second/);
rejectOld(new Error('first'));
await tick();
assert.equal(f.map.get('x').error.message, 'second');
});
});
test('forgetPendingSpine: PIXI\'s pending requests of an unfinished skeleton go, finished ones and kept pages stay', () => {
const prev = globalThis.PIXI;
const e = SP('x');
const page = e.textures[0];
const setup = (done) => {
const promiseCache = { [e.skel]: {}, [e.atlas]: {}, [page]: {}, other: {} };
globalThis.PIXI = { Assets: { loader: { promiseCache }, cache: new Map(done.map((u) => [u, {}])) } };
return promiseCache;
};
try {
let c = setup([]);
assert.equal(forgetPendingSpine(e), 3);
assert.deepEqual(Object.keys(c), ['other']);
c = setup([e.atlas]);
assert.equal(forgetPendingSpine(e), 1, 'the atlas (and so its pages) finished: only the .skel');
assert.ok(c[e.atlas] && c[page]);
c = setup([e.skel]);
assert.equal(forgetPendingSpine(e), 0, 'loaded: nothing pending');
c = setup([]);
forgetPendingSpine(e, new Set([page]));
assert.ok(c[page], 'a page another cached skeleton uses stays');
c = setup([]);
c[`http://127.0.0.1:8080${e.atlas}`] = {};
assert.equal(forgetPendingSpine(e), 4, 'an absolute key of the same path too');
globalThis.PIXI = {};
assert.equal(forgetPendingSpine(e), 0, 'no loader cache: nothing');
} finally { globalThis.PIXI = prev; }
});
describe('views restart a load begun in a hidden tab (render/units.js)', () => {
let fake, UnitView, SPINE_STUCK_MS;
before(async () => {
fake = installFakePixi();
({ UnitView, SPINE_STUCK_MS } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const cam = () => presetCamera('prep', { width: 1280, height: 720 });
const unit = (assets) => new UnitView(fakeViewCtx(fake.P, { assets, cam }), { id: 1, side: 'ally', kind: 'chess', defId: 'char_002_amiya', spine: 'char_002_amiya', avatar: 'char_002_amiya', tier: 3, x: 5, y: 12, maxHp: 1000 }, { prep: true });
const hidden = (on) => { globalThis.document.hidden = on; globalThis.document.visibilityState = on ? 'hidden' : 'visible'; };
test('pending when the tab is shown: SPINE_STUCK_MS later it is loaded again (fresh) and the model shows', async () => {
assert.equal(SPINE_STUCK_MS, 5000);
const calls = [];
const a = createAssets({ manifest: M, spineTimeout: 0, loadImage: async () => null, unloadSpine: () => {},
loadSpine: (e, o) => { calls.push(o?.fresh ? 'fresh' : 'plain'); return calls.length === 1 ? never() : Promise.resolve({ animations: [{ name: 'Idle' }] }); } });
hidden(true);
const v = unit(a);
await tick();
assert.equal(v._spineBusy, true);
v.retryAssets(); // the manifest arriving while still hidden arms nothing
assert.equal(v._stuckAt, 0);
hidden(false);
v.retryAssets(); // render/app.js visibilitychange
assert.ok(v._stuckAt > 0, 'armed: SPINE_STUCK_MS to finish');
v.update(1 / 60, cam(), 0);
await tick();
assert.deepEqual(calls, ['plain'], 'not before its time: a load about to succeed is not doubled');
v._stuckAt = 1; // due
v.update(1 / 60, cam(), 0);
await tick(); await tick();
assert.deepEqual(calls, ['plain', 'fresh'], 'started again, not joined to the pending request');
assert.ok(v.actor && v.spineReady, 'the model is shown');
assert.equal(a.spine.stats().refs, 1, 'one acquire, one reference');
v.destroy();
assert.equal(a.spine.stats().refs, 0);
});
test('a load that finishes within the grace is never doubled; one begun while visible is left to its timeout', async () => {
let resolve;
const calls = [];
const a = createAssets({ manifest: M, spineTimeout: 0, loadImage: async () => null, unloadSpine: () => {},
loadSpine: () => { calls.push(1); return new Promise((r) => { resolve = r; }); } });
hidden(true);
const v = unit(a);
hidden(false);
v.retryAssets();
assert.ok(v._stuckAt > 0);
resolve({ animations: [{ name: 'Idle' }] });
await tick(); await tick();
assert.ok(v.actor);
assert.equal(v._stuckAt, 0, 'settled: disarmed');
v.update(1 / 60, cam(), 0);
assert.equal(calls.length, 1);
v.destroy();
const w = unit(createAssets({ manifest: M, spineTimeout: 0, loadImage: async () => null, loadSpine: () => never() }));
w.retryAssets();
assert.equal(w._stuckAt, 0, 'begun while visible: the 20 s timeout and the bounded retries handle it');
w.destroy();
});
});
+50 -2
View File
@@ -1,9 +1,10 @@
// test/render/fieldview.browser.test.js — field-view features in headless Chrome through the dev demo
// (public/dev/render-demo.html): the enemy preview pen (research 09 §2.2 / 08 §4.2) on the 3D and the 2D board and
// the 0.25 s pan to it and back, the Final Assault prep on the player's half of the boss field (research 09 §1.2:
// drop targets / directions stay in board space, the right half mirrored), damage numbers that never touch, the
// drop targets / directions stay in board space, the right half mirrored) with the round's leader on its spawn tile and
// its red hit tiles beside a range preview (community report #12), damage numbers that never touch, the
// automatic rebuild of a 3D board whose WebGL context was lost, and frame rates on a 4× throttled CPU.
// Screenshots → test/e2e/out/pen-*.png, fa-prep-*.png, dmgnum-*.png; perf → test/e2e/out/fieldview-perf.json.
// Screenshots → test/e2e/out/pen-*.png, fa-prep-*.png, fa-leader-*.png, dmgnum-*.png; perf → test/e2e/out/fieldview-perf.json.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/fieldview.browser.test.js
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
@@ -226,6 +227,53 @@ describe('field view features in headless Chrome', { skip }, () => {
});
}
for (const side of ['L', 'R']) {
test(`Final Assault prep (${side}): the leader stands on its spawn tile, out of the pen; a range preview lights its hit tiles in red (report #12)`, async () => {
const { page, problems } = await open(`scene=prep&fa=${side}&stage=act2autochess_m02`, 1920, 1080);
await wait(1500);
const r = await page.evaluate(async () => {
const v = window.__demo.view, d = v.debug;
// m.private as the server sends it in a boss round's prep: nextEnemies with the leader's spawn tile (waves.js previewOf)
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
st.nextEnemies = [
{ enemyKey: 'enemy_9013_acstmk', count: 1, gate: 'upper', t: 0, fly: false, elite: true, boss: true, source: 'wave', tag: 'boss', start: [3, 10] },
{ enemyKey: 'enemy_1007_slime', count: 3, gate: 'lower', t: 2, fly: false, elite: false, boss: false, source: 'wave', tag: null },
];
v.setPrep(st, { editable: true });
await new Promise((res) => setTimeout(res, 2000));
const L = d.leader;
const hl = () => (d.tiles.highlights.get('leaderHit')?.tiles || []).map((t) => t.join(','));
const out = { has: !!L, x: L?.view.x, y: L?.view.y, shown: !!L?.view.root.visible, facing: L?.view.visFacing, spine: !!L?.view.actor,
pen: [...d.penViews.values()].map((p) => p.info.defId), before: hl() };
v.highlightTiles([[10, 5], [10, 6], [10, 7]], { group: 'facing', color: 0xff9c33, fill: 0.36, line: 1 });
out.during = hl();
out.range = (d.tiles.highlights.get('facing')?.tiles || []).length;
return out;
});
await wait(300);
await page.screenshot({ path: path.join(OUT, `fa-leader-${side}.png`) });
const r2 = await page.evaluate(() => {
const v = window.__demo.view, d = v.debug;
v.highlightTiles([], { group: 'facing' });
const after = (d.tiles.highlights.get('leaderHit')?.tiles || []).length;
v.setCamera('prep', { instant: true });
return { after, hiddenOnBoard: !d.leader.view.root.visible };
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual([r.has, r.x, r.y, r.shown], [true, 10, 3, true], 'on its spawn tile (3, 10), shown by the boss-field prep camera');
assert.equal(r.facing, side === 'R' ? 1 : -1, 'facing the player\'s half');
assert.ok(r.spine, 'its Spine model');
assert.ok(!r.pen.includes('enemy_9013_acstmk') && r.pen.length === 3, `not in the pen: ${r.pen}`);
assert.deepEqual(r.before, [], 'no red tiles without a range preview');
assert.equal(r.range, 3);
assert.equal(r.during.length, 15, `the 5 × 3 hit tiles: ${r.during}`);
assert.ok(['3,8', '3,12', '5,8', '5,12', '4,10'].every((t) => r.during.includes(t)));
assert.equal(r2.after, 0, 'gone with the range preview');
assert.equal(r2.hiddenOnBoard, true, 'the own-board camera does not show it');
});
}
test('damage numbers: a boss and a knot of minions under rapid mixed hits never show touching numbers', async () => {
const { page, problems } = await open('scene=numbers', 1920, 1080);
await wait(2500);
+61
View File
@@ -0,0 +1,61 @@
// test/sim/feedback3-ammo-bar.test.js — community report #35 (0.1.3): "拉特兰盟约叠层之后蕾缪安3技能锁定敌人消耗的子弹不会实时让技力条一格
// 一格减少,而是最后突然消耗". 拉特兰's skillStart multiplies the bullets (×(1.05 + 0.015 × layers), floored) and 逃犯引渡手续 adds
// one, but the snapshot drew ammoLeft / skill.ammo (the base 5) and the client clamps at 1: the bar stayed full until
// fewer than 5 bullets were left. Now the draining bar is ammoLeft / ammoMax — the most bullets this activation held
// (set at activation, raised by skillStart additions and addAmmo) — so it starts full and every bullet shortens it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { unitTuple } from '../../server/sim/snapshot.js';
const DEFS = { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } };
const bar = (u, t) => { const [, , , , , sp, spMax] = unitTuple(u, t); return sp / spMax; };
test('蕾缪安 S3 with 30 拉特兰 layers: the bar starts full and loses one bullet\'s share per lock (base 5 + extras)', () => {
const seen = [];
const h = makeBattle({
defs: DEFS, timeLimit: 200, seed: 7,
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
bonds: { lateranoShip: { count: 3, active: true, tier: 1, layers: 30 } },
setup(b) {
b.on('ammoUsed', ({ unit, left, skill }) => seen.push({ left, max: skill.ammoMax, bar: bar(unit, b.time) }), { priority: -999 });
},
});
const u = h.unit('chess_char_6_01_a');
assert.equal(u.skill.ammo, 5, 'S3: 5 bullets (attack@trigger_time)');
assert.ok(h.runUntil(() => u.skill.active, 80));
assert.ok(h.runUntil(() => !u.skill.active, 30));
assert.ok(seen.length >= 7, `${seen.length} bullets: 5 × 1.5 floored, + 逃犯引渡手续`);
const full = seen[0].left + 1;
for (const s of seen) assert.equal(s.max, full, 'the full mark is the activation\'s real total');
seen.forEach((s, i) => assert.ok(Math.abs(s.bar - (full - 1 - i) / full) < 0.01, `bullet ${i + 1}: bar ${s.bar.toFixed(3)} = ${full - 1 - i}/${full}`));
assert.ok(seen[0].bar < 1 - 0.1, 'the first lock already shortens the bar (it used to stay full until < 5 were left)');
assert.equal(u.skill.ammoMax, 0, 'reset when the skill ends');
checkInvariants(h.b);
});
test('the full mark follows bullets added mid-skill only above it; without extras the bar is unchanged (n / base)', () => {
const h = makeBattle({
defs: DEFS, timeLimit: 200, seed: 7,
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
});
const u = h.unit('chess_char_6_01_a');
h.step(1);
u.skill.activate('test', { free: true });
const sk = u.skill;
assert.equal(sk.ammoMax, sk.ammoLeft);
const base = sk.ammoMax;
assert.ok(Math.abs(bar(u, h.b.time) - 1) < 1e-9, 'full at the start');
sk.ammoLeft -= 2; // two locks
assert.ok(Math.abs(bar(u, h.b.time) - (base - 2) / base) < 0.01);
sk.addAmmo(1); // a refill under the mark: the mark stays
assert.equal(sk.ammoMax, base);
sk.addAmmo(3); // above it: the mark rises, the bar is full again
assert.equal(sk.ammoMax, base + 2);
assert.ok(Math.abs(bar(u, h.b.time) - 1) < 1e-9);
sk.end('test');
assert.equal(sk.ammoMax, 0);
});
+86
View File
@@ -0,0 +1,86 @@
// test/ui/feedback3-audio-mix.test.js — community report #30 (0.1.3): "狗、飞机敌人音效声音过大". The official attack bank of
// 猎狗pro / 深池侦察犬 is 80 % silence (an empty asset weighs 80, e_atk_gopro_* 20), but the asset tool kept only the file
// paths, so the client barked on every attack at a fixed 0.55; 妖怪's attack bank is official at volume 0.7. The bank's
// play chance and volume now travel as sfx.units[id].mix (tools/assets/audio.mjs bankMix → plan.mjs) and audio.js plays a
// unit's own sound with chance `p` at its base gain × min(1, vol) — never louder than before.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { AudioManager, unitGain, unitSoundPlays } from '../../public/js/audio.js';
import { indexAudio, pickUnitSfx, bankMix } from '../../tools/assets/audio.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const manifest = JSON.parse(readFileSync(path.join(ROOT, 'data', 'assets.json'), 'utf8'));
const UNITS = manifest.audio.sfx.units;
const OFFICIAL = path.join(ROOT, '.cache', 'gamedata', 'excel', 'audio_data.json');
/** An audio manager with a context, recording what it would play: [{ url, volume, unitKey }]. */
function manager(rolls) {
const a = new AudioManager({ win: null, getManifest: () => manifest, random: () => rolls.shift() ?? 0 });
a.ctx = {};
a.played = [];
a._play = (url, o) => { a.played.push({ url, volume: o.volume, unitKey: o.unitKey }); };
return a;
}
test('the bank mix: an empty asset is a chance of silence, the played file\'s volume (bankMix, indexAudio().mixOf)', () => {
const gopro = [
{ asset: '', weight: 80, minVolume: 1, maxVolume: 1 },
{ asset: 'Audio/Sound_Beta_2/Enemy/e_atk/e_atk_gopro_1', weight: 10, minVolume: 1, maxVolume: 1 },
{ asset: 'Audio/Sound_Beta_2/Enemy/e_atk/e_atk_gopro_2', weight: 10, minVolume: 1, maxVolume: 1 },
];
assert.deepEqual(bankMix(gopro, 'enemy/e_atk/e_atk_gopro_1.mp3'), { p: 0.2 });
assert.deepEqual(bankMix([{ asset: 'Audio/Sound_Beta_2/Enemy/e_atk/e_atk_vehicle_n', weight: 1, minVolume: 0.7, maxVolume: 0.7 }], 'enemy/e_atk/e_atk_vehicle_n.mp3'), { vol: 0.7 });
assert.equal(bankMix([{ asset: 'Audio/Sound_Beta_2/P/x', weight: 1, minVolume: 1, maxVolume: 1 }], 'p/x.mp3'), null, 'the default: nothing stored');
const idx = indexAudio({ soundFXBanks: [
{ name: 'battle.ON_ABILITY_ON.enemy_1000_gopro_2.combat', sounds: gopro },
{ name: 'battle.ON_ABILITY_HIT.enemy_1000_gopro_2.combat', sounds: [{ asset: 'Audio/Sound_Beta_2/Enemy/e_imp/e_imp_general_w', weight: 1, minVolume: 1, maxVolume: 1 }] },
] });
const sfx = pickUnitSfx(idx.unitBanks.get('enemy_1000_gopro_2'));
assert.deepEqual(sfx.attack, ['enemy/e_atk/e_atk_gopro_1.mp3', 'enemy/e_atk/e_atk_gopro_2.mp3']);
assert.deepEqual(idx.mixOf(sfx.attack), { p: 0.2 }, 'the picked list carries its bank\'s mix');
assert.equal(idx.mixOf(sfx.hit), null);
assert.equal(idx.mixOf(['enemy/e_atk/e_atk_gopro_1.mp3']), null, 'only the bank\'s own list');
});
test('data/assets.json carries the official mix: dogs 20 %, 妖怪 / 威龙 0.7 (and matches audio_data.json when cached)', () => {
for (const id of ['enemy_1000_gopro_2', 'enemy_1165_duhond', 'enemy_1165_duhond_2']) assert.deepEqual(UNITS[id].mix, { attack: { p: 0.2 } }, id);
for (const id of ['enemy_1005_yokai_2', 'enemy_1005_yokai_3']) assert.deepEqual(UNITS[id].mix, { attack: { vol: 0.7 } }, id);
assert.equal(UNITS.char_002_amiya?.mix, undefined, 'default banks store nothing');
if (!existsSync(OFFICIAL)) return;
const banks = new Map(JSON.parse(readFileSync(OFFICIAL, 'utf8')).soundFXBanks.map((b) => [b.name, b]));
const dog = banks.get('battle.ON_ABILITY_ON.enemy_1165_duhond.combat').sounds;
assert.equal(dog.filter((s) => !s.asset).reduce((n, s) => n + s.weight, 0) / dog.reduce((n, s) => n + s.weight, 0), 0.8, 'official: 80 % silence');
assert.equal(banks.get('battle.ON_ABILITY_ON.enemy_1005_yokai_2.attack').sounds[0].maxVolume, 0.7);
});
test('a dog barks only on its chance and never falls back to the generic enemy sound; at most the old gain', () => {
assert.equal(unitSoundPlays({ p: 0.2 }, 0.19), true);
assert.equal(unitSoundPlays({ p: 0.2 }, 0.2), false);
assert.equal(unitSoundPlays(null, 0.99), true);
const a = manager([0.5, 0.9, 0.1, 0.3, 0.05]);
a.setFieldUnits([{ id: 1, side: 'enemy', kind: 'enemy', spine: 'enemy_1000_gopro_2' }, { id: 2, side: 'ally', kind: 'op', spine: 'char_002_amiya' }]);
for (let i = 0; i < 5; i++) { a.limiter = new a.limiter.constructor(); a.handleBattleEvents([['atk', 1, 2, 'none']]); }
const barks = a.played.filter((p) => p.url === UNITS.enemy_1000_gopro_2.attack);
assert.equal(barks.length, 2, 'rolls 0.1 and 0.05 of 0.5 / 0.9 / 0.1 / 0.3 / 0.05');
for (const b of barks) assert.equal(b.volume, 0.55, 'official volume 1: the same gain as before');
assert.equal(a.played.length, 2, 'the silent rolls play nothing — not the generic enemyHit either');
});
test('妖怪 plays at 0.55 × 0.7; a bank louder than 1 stays at the old gain; die / born use the mix too', () => {
assert.equal(unitGain(0.55, { vol: 0.7 }), 0.55 * 0.7);
assert.equal(unitGain(0.55, { vol: 2 }), 0.55, 'never louder than before');
assert.equal(unitGain(0.8, null), 0.8);
const a = manager([]);
assert.equal(a.unit('enemy_1005_yokai_2', 'attack', 7), true);
assert.ok(Math.abs(a.played[0].volume - 0.385) < 1e-9);
assert.equal(a.unit('char_1028_texas2', 'hit', 8), true);
assert.equal(a.played[1].volume, 0.55, 'official 2: unchanged');
a.setFieldUnits([{ id: 3, side: 'ally', kind: 'op', spine: 'char_2026_yu', defId: 'chess_char_x' }]);
a.handleBattleEvents([['deploy', 3]]);
const born = a.played.find((p) => p.url === UNITS.char_2026_yu.born);
assert.ok(born && Math.abs(born.volume - 0.8 * 0.75) < 1e-9, '余 ON_UNIT_BORN at 0.75');
});