Merge branch 'master' into fix/spine-start-anim

This commit is contained in:
Cromemadnd Lancity authored and GitHub committed 2026-10-11 20:27:12 +08:00
commit e2b0aab534
577 files changed
+43650 -10558

No files matched your search

+14 -2
View File
@@ -50,7 +50,7 @@ const M = {
audio: {
bgm: { prep: { loop: '/bgm/prep.mp3' }, lobby: { intro: '/bgm/i.mp3', loop: '/bgm/l.mp3' } },
bossBgm: { boss_1: { loop: '/bgm/b1.mp3' } },
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3' } } },
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', hit: '/sfx/c_hit.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3', attacks: { 2: '/sfx/c_atk_s3.mp3' }, hits: { 2: '/sfx/c_hit_s3.mp3' } } } },
},
};
@@ -134,6 +134,12 @@ describe('URL helpers (synthetic manifest)', () => {
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 2), '/sfx/c_s3.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 0), '/sfx/c_s.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen_2', 'attack'), '/sfx/c_atk.mp3');
// the running skill's own attack sound (sfx.units[id].attacks, tools/assets/audio.mjs pickModeAttacks)
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack', 2), '/sfx/c_atk_s3.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack', 0), '/sfx/c_atk.mp3', 'no attacks[0] ⇒ the normal bank');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'hit', 2), '/sfx/c_hit_s3.mp3', 'the running skill\'s own impact');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'hit', 1), '/sfx/c_hit.mp3', 'no hits[1] ⇒ the normal impact');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'die', 2), null, 'only an attack / an impact has a per-skill file');
assert.equal(unitSfxUrl(M, 'nobody', 'attack'), null);
});
});
@@ -497,7 +503,8 @@ describe('createAssets store', () => {
test('image cache + preload progress (failures resolve to null)', async () => {
const loaded = [];
const a = createAssets({ manifest: M, loadImage: async (u) => { loaded.push(u); if (u.includes('bad')) throw new Error('x'); return { src: u }; } });
let fail = true;
const a = createAssets({ manifest: M, loadImage: async (u) => { loaded.push(u); if (u.includes('bad') && fail) throw new Error('x'); return { src: u }; } });
const progress = [];
const r = await a.preload(['/a.png', '/bad.png', '/a.png', null, ''], (d, t) => progress.push([d, t]));
assert.deepEqual(r, { ok: 1, failed: 1, total: 2 });
@@ -507,6 +514,11 @@ describe('createAssets store', () => {
assert.equal(loaded.filter((u) => u === '/a.png').length, 1, 'cached');
assert.equal(a.imageNow('/a.png').src, '/a.png');
assert.equal(await a.image(null), null);
fail = false;
assert.equal(await a.image('/bad.png'), null, 'ordinary consumers retain the remembered failure');
assert.equal((await a.image('/bad.png', { retry: true })).src, '/bad.png');
assert.equal(loaded.filter((u) => u === '/bad.png').length, 2, 'explicit board retry performs a new load');
assert.equal((await a.image('/bad.png')).src, '/bad.png');
});
test('spine acquire/release through the store with an injected loader', async () => {
+428
View File
@@ -0,0 +1,428 @@
// 敌人普攻后摇:可选快照元数据的生产、校验与位置插值;不改动战斗规则。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SnapshotBuffer, normalizeSnapshot } from '../../public/js/render/interp.js';
import { ANIM, UF } from '../../shared/constants.js';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { snapFrame } from '../../server/match/fields.js';
import { TICK } from '../../server/sim/constants.js';
import { rupture } from '../../server/sim/content/kits/ops/op-weedy.js';
const tuple = (x, anim = ANIM.ATTACK) => [1, x, 9, 100, 100, 0, 0, 0, anim];
const frame = (t, x, until, anim) => ({ t, units: [tuple(x, anim)], ...(until === undefined ? {} : { stand: [[1, until]] }) });
const near = (got, want) => assert.ok(Math.abs(got - want) < 1e-8, `${got} ≠ ${want}`);
test('stand:仅保留快照中存在的单位与有限、未来的结束时间', () => {
const valid = normalizeSnapshot({ t: 'b.snap', gt: 1, units: [tuple(0)], stand: [
null, 'x', [1], [2, 2], [1, '2'], [1, NaN], [1, Infinity], [1, 1], [1, 0.9], [1, 1.08],
] });
assert.deepEqual([...valid.stand], [[1, 1.08]]);
for (const stand of [undefined, null, {}, [], [[1, 1]], [[2, 2]]]) {
assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], stand }).stand, null);
}
});
test('standCut:仅保留已有单位的有限、非负且不晚于快照的时间', () => {
const valid = normalizeSnapshot({ t: 'b.snap', gt: 1, units: [tuple(0)], standCut: [
null, 'x', [1], [2, 0.9], [1, '0.9'], [1, NaN], [1, Infinity], [1, -1], [1, 1.01], [1, 0.9],
] });
assert.deepEqual([...valid.standCut], [[1, 0.9]]);
for (const standCut of [undefined, null, {}, [], [[1, -1]], [[2, 0]], [[1, 1.01]]]) {
assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], standCut }).standCut, null);
}
assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], standCut: [[1, 0], [1, 1]] }).standCut.get(1), 1);
});
test('后摇结束前保持位置,结束后插值剩余区间;HP/SP 仍按整段时间插值', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
const next = frame(1.1, 0.02, undefined, ANIM.MOVE);
next.units[0][3] = 50;
next.units[0][5] = 10;
b.push(next, 0.05);
for (const t of [1, 1.04, 1.079]) {
const s = b.sample(t).get(1);
assert.equal(s.x, 0);
assert.equal(s.vx, 0);
assert.equal(s.anim, ANIM.ATTACK);
}
const middle = b.sample(1.05).get(1);
near(middle.hp, 75);
near(middle.sp, 5);
near(b.sample(1.09).get(1).x, 0.01);
near(b.sample(1.09).get(1).vx, 1);
near(b.sample(1.1).get(1).x, 0.02);
});
test('旧快照、过期元数据及攻击→移动动画不推断停步', () => {
for (const until of [undefined, 0.9, 1, NaN, Infinity]) {
const b = new SnapshotBuffer();
b.push(frame(1, 0, until), 0);
b.push(frame(1.1, 0.1, undefined, ANIM.MOVE), 0.05);
near(b.sample(1.05).get(1).x, 0.05);
near(b.sample(1.05).get(1).vx, 1);
}
});
test('后摇结束时间恰为下一帧时,位置在该帧之前保持不动', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.1), 0);
b.push(frame(1.1, 0.02), 0.05);
assert.equal(b.sample(1.09).get(1).x, 0);
near(b.sample(1.1).get(1).x, 0.02);
});
test('后摇尚未到期但新帧已移动时,不阻止权威位置更新', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 2), 0);
b.push(frame(1.1, 0.1, undefined, ANIM.MOVE), 0.05);
near(b.sample(1.05).get(1).x, 0.05);
});
test('后摇结束时间落在两帧之间,但该区间有打断时,插值和外推均使用完整区间', () => {
for (const cutAt of [1, 1.04, 1.1]) {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
b.push({ ...frame(1.1, 0.1, undefined, ANIM.MOVE), standCut: [[1, cutAt]] }, 0.05);
near(b.sample(1.05).get(1).x, 0.05);
near(b.sample(1.099).get(1).vx, 1);
near(b.sample(1.101).get(1).vx, 1);
near(b.sample(1.15).get(1).x, 0.15);
}
});
test('旧的打断记录不影响后续未被打断的后摇', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
b.push({ ...frame(1.1, 0.02), standCut: [[1, 0.9]] }, 0.05);
assert.equal(b.sample(1.04).get(1).x, 0);
near(b.sample(1.09).get(1).vx, 1);
near(b.sample(1.15).get(1).vx, 1);
});
test('后摇恢复段进入外推时速度连续,零长度移动区间不产生无穷速度', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.05);
near(b.sample(1.099).get(1).vx, 1);
near(b.sample(1.101).get(1).vx, 1);
near(b.sample(1.15).get(1).x, 0.07);
const edge = new SnapshotBuffer();
edge.push(frame(1, 0, 1.1), 0);
edge.push(frame(1.1, 0.02), 0.05);
near(edge.sample(1.15).get(1).x, 0.02);
assert.equal(edge.sample(1.15).get(1).vx, 0);
});
test('停步期间及网络停顿越过结束时间后,都不使用攻击前的速度外推', () => {
const b = new SnapshotBuffer({ maxExtrapolate: 0.2 });
b.push(frame(0.9, -0.1), 0);
b.push(frame(1, 0, 1.08), 0.05);
for (const t of [1.04, 1.09, 5]) {
const s = b.sample(t).get(1);
assert.equal(s.x, 0);
assert.equal(s.vx, 0);
}
b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.1);
assert.ok(b.sample(1.15).get(1).x > 0.02, '新位置确认后恢复原有外推');
});
test('瞬移与重新部署的跳转优先于后摇插值', () => {
for (const [x, anim] of [[8, ANIM.MOVE], [0.1, ANIM.DEPLOY]]) {
const b = new SnapshotBuffer({ teleport: 2 });
b.push(frame(1, 0, 1.04), 0);
b.push(frame(1.1, x, undefined, anim), 0.05);
assert.equal(b.sample(1.09).get(1).x, 0);
near(b.sample(1.1).get(1).x, x);
assert.equal(b.sample(1.09).get(1).vx, 0);
near(b.sample(1.15).get(1).x, x);
assert.equal(b.sample(1.15).get(1).vx, 0);
}
});
test('死亡或眩晕的最新帧不沿用旧速度外推;恢复移动后仍可外推', () => {
for (const anim of [ANIM.DIE, ANIM.STUN]) {
const b = new SnapshotBuffer();
b.push(frame(0.9, -0.1, undefined, ANIM.MOVE), 0);
const stopped = frame(1, 0, undefined, anim);
if (anim === ANIM.DIE) stopped.units[0][3] = 0;
b.push(stopped, 0.05);
for (const t of [1.01, 1.24, 5]) {
assert.equal(b.sample(t).get(1).x, 0);
assert.equal(b.sample(t).get(1).vx, 0);
}
b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.1);
near(b.sample(1.15).get(1).x, 0.03);
}
});
test('最新帧中已消失的单位不外推,并从复用的采样结果中删除', () => {
const b = new SnapshotBuffer(), out = new Map();
b.push(frame(0.9, -0.1, undefined, ANIM.MOVE), 0);
b.push(frame(1, 0, 1.08), 0.05);
b.push({ t: 1.1, units: [] }, 0.1);
assert.equal(b.sample(1.05, out).get(1).x, 0);
assert.equal(b.sample(1.05, out).get(1).vx, 0);
assert.equal(b.sample(1.2, out).has(1), false);
});
function arena(key, wallRow = 10) {
return makeBattle({
defs: { chess: { t_wall: chessRec({ id: 't_wall', stats: { maxHp: 1e9, atk: 0, def: 1e6 }, skill: null }) } },
units: [{ chessId: 't_wall', row: wallRow, col: 6 }],
enemies: [{ key, time: 0, route: 0 }],
content: 'none', autoFinish: false, timeLimit: 60,
});
}
test('真实隐形弩手:Battle → b.snap → SnapshotBuffer 在后摇结束前不滑动', () => {
const h = arena('enemy_1019_jshoot');
let prev, pair;
for (let i = 0; i < 1200 && !pair; i++) {
h.step();
if (i % 3 !== 2) continue;
const next = h.b.snapshot();
const e = h.enemy('enemy_1019_jshoot');
const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id);
const until = prev?.stand?.find((v) => v[0] === e.id)?.[1];
if (a && z && until > prev.t && until < next.t && (a[1] !== z[1] || a[2] !== z[2])) pair = { a, z, until, prev, next };
prev = next;
}
assert.ok(pair, '真实攻击后摇跨越两帧且随后恢复移动');
const { a, z, until, prev: A, next: B } = pair;
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
const held = b.sample((A.t + until) / 2).get(a[0]);
assert.equal(held.x, a[1]);
assert.equal(held.y, a[2]);
assert.equal(held.vx, 0);
const moved = b.sample((until + B.t) / 2).get(a[0]);
near(moved.x, (a[1] + z[1]) / 2);
near(moved.y, (a[2] + z[2]) / 2);
near(b.sample(B.t - 1e-6).get(a[0]).vx, b.sample(B.t + 1e-6).get(a[0]).vx);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:失衡跨帧打断时不按旧后摇时间冻结和集中追赶', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => {
const e = h.enemy('enemy_1019_jshoot');
return e && e.atkStandUntil - h.b.time > TICK && e.atkStandUntil - h.b.time < 3 * TICK;
}, 10));
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
const until = A.stand.find((s) => s[0] === e.id)[1];
h.step();
const cutAt = Math.round(h.b.time * 1000) / 1000;
assert.ok(h.b.displace(e, { x: -1, y: 0 }, 0.6) > 0);
h.step(2);
const B = h.b.snapshot();
assert.ok(until > A.t && until < B.t);
assert.equal(B.standCut.find((s) => s[0] === e.id)[1], cutAt);
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id);
near(b.sample((A.t + B.t) / 2).get(e.id).x, (a[1] + z[1]) / 2);
near(b.sample(B.t + 1e-6).get(e.id).vx, (z[1] - a[1]) / (B.t - A.t));
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:后摇中击退并被移动创伤击杀,死亡帧保留打断且尸体不外推', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => {
const e = h.enemy('enemy_1019_jshoot');
return e && e.atkStandUntil - h.b.time > TICK && e.atkStandUntil - h.b.time < 3 * TICK;
}, 10));
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
const until = A.stand.find((s) => s[0] === e.id)[1];
h.step();
const cutAt = Math.round(h.b.time * 1000) / 1000;
// 温蒂的真实移动创伤实现:击退后下一次伤害结算在同一个快照间隔内击杀目标。
rupture(h.b, h.unit('t_wall'), e, { duration: 5, perTile: 1e6, interval: TICK });
near(h.b.push(e, e.s.massLevel + 1, { dir: { x: -1, y: 0 }, fixed: true }), 2.14);
h.step(2);
const B = h.b.snapshot();
assert.equal(e.alive, false);
assert.ok(until > A.t && until < B.t);
assert.equal(B.standCut?.find((s) => s[0] === e.id)?.[1], cutAt);
assert.equal(B.stand?.some((s) => s[0] === e.id) ?? false, false);
const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id);
assert.equal(z[8], ANIM.DIE);
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
near(b.sample((A.t + B.t) / 2).get(e.id).x, (a[1] + z[1]) / 2);
for (const t of [B.t, B.t + 0.01, B.t + 0.24, B.t + 1]) {
assert.equal(b.sample(t).get(e.id).x, z[1]);
assert.equal(b.sample(t).get(e.id).vx, 0);
}
b.update(0.05);
for (let i = 1; i <= 90; i++) b.update(0.05 + i / 120);
assert.ok(b.renderT > B.t);
assert.equal(b.sample().get(e.id).x, z[1]);
h.run(0.9);
const C = h.b.snapshot();
assert.equal(C.units.some((u) => u[0] === e.id), false);
assert.equal(C.standCut?.some((s) => s[0] === e.id) ?? false, false);
b.push(snapFrame('test', C), 0.5);
assert.equal(b.sample(C.t + 0.1).has(e.id), false);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:AI 更新前死亡也记录后摇终止,隐藏和死亡窗口外不发送记录', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => h.enemy('enemy_1019_jshoot')?.atkStandUntil > h.b.time + 0.1, 10));
const e = h.enemy('enemy_1019_jshoot');
const at = Math.round(h.b.time * 1000) / 1000;
h.b.kill(e, h.unit('t_wall'));
assert.equal(Math.round(e.atkStandCutAt * 1000) / 1000, at);
assert.deepEqual(h.b.snapshot().standCut, [[e.id, at]]);
e.hidden = true;
assert.equal(h.b.snapshot().standCut, undefined);
e.hidden = false;
h.run(0.9);
assert.equal(h.b.snapshot().standCut, undefined);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
for (const status of ['stun', 'freeze', 'sleep', 'levitate']) {
test(`真实隐形弩手:移动后${status}的最新帧不继续滑动,解除后恢复移动`, () => {
const h = arena('enemy_1019_jshoot');
h.step(3);
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
h.step();
assert.equal(h.b.applyStatus(e, status, { duration: 1, force: true, source: h.unit('t_wall') }), true);
h.step(2);
const B = h.b.snapshot(), z = B.units.find((u) => u[0] === e.id);
assert.equal(z[8], ANIM.STUN);
assert.notEqual(A.units.find((u) => u[0] === e.id)[1], z[1]);
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
assert.equal(b.sample(B.t + 0.1).get(e.id).x, z[1]);
assert.equal(b.sample(B.t + 0.1).get(e.id).vx, 0);
h.run(1.1);
const C = h.b.snapshot();
assert.equal(e.s.flags.stun, false);
assert.ok(C.units.find((u) => u[0] === e.id)[1] < z[1]);
b.push(snapFrame('test', C), 0.6);
assert.ok(b.sample(C.t + 0.1).get(e.id).vx < 0);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
}
test('真实隐形弩手:刚被阻挡的最新帧不继续滑动,阻挡者离场后恢复移动', () => {
const h = arena('enemy_1019_jshoot', 9);
let prev, pair;
for (let i = 0; i < 1200 && !pair; i++) {
h.step();
if (i % 3 !== 2) continue;
const next = h.b.snapshot(), e = h.enemy('enemy_1019_jshoot');
const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id);
if (a && z && (z[7] & UF.BLOCKED) && a[1] !== z[1] && !next.stand?.some((s) => s[0] === e.id)) pair = { A: prev, B: next, e, z };
prev = next;
}
assert.ok(pair, '移动与首次阻挡发生于同一个快照间隔,且没有后摇元数据');
const { A, B, e, z } = pair;
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
assert.equal(b.sample(B.t + 0.24).get(e.id).x, z[1]);
assert.equal(b.sample(B.t + 0.24).get(e.id).vx, 0);
h.b.retreat(h.unit('t_wall'), { permanent: true });
h.step(3);
const C = h.b.snapshot();
assert.equal(e.blockedBy, null);
assert.ok(C.units.find((u) => u[0] === e.id)[1] < z[1]);
b.push(snapFrame('test', C), 0.1);
assert.ok(b.sample(C.t + 0.1).get(e.id).vx < 0);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:恐惧打断在延迟帧之后仍恢复线性插值', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => {
const e = h.enemy('enemy_1019_jshoot');
return e && e.atkStandUntil - h.b.time > 4 * TICK && e.atkStandUntil - h.b.time < 5 * TICK;
}, 10));
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
const until = A.stand.find((s) => s[0] === e.id)[1];
h.step();
assert.equal(h.b.applyStatus(e, 'fear', { duration: 1, source: h.unit('t_wall') }), true);
// 本地 runner 在卡帧时会推进多个 tick 后再发出一帧。
h.step(5);
const B = h.b.snapshot();
assert.ok(e.s.flags.fear && until > A.t && until < B.t);
assert.ok(B.standCut.some((s) => s[0] === e.id && s[1] >= A.t));
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), (B.t - A.t) / 2);
const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id);
const s = b.sample((A.t + until) / 2).get(e.id);
const alpha = (until - A.t) / (2 * (B.t - A.t));
near(s.x, a[1] + (z[1] - a[1]) * alpha);
assert.ok(s.x > a[1]);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实枯朽之种:命中帧即动画末尾,没有后摇时仍连续移动', () => {
const h = arena('enemy_1269_nhfly');
let prev, checked = false;
for (let i = 0; i < 1200 && !checked; i++) {
h.step();
if (i % 3 !== 2) continue;
const next = h.b.snapshot();
const e = h.enemy('enemy_1269_nhfly');
assert.ok(!next.stand?.some((s) => s[0] === e.id), '零后摇不发送 stand');
const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id);
if (a && z && a[8] === ANIM.ATTACK && z[8] === ANIM.MOVE && (a[1] !== z[1] || a[2] !== z[2])) {
const b = new SnapshotBuffer();
b.push(prev, 0);
b.push(next, 0.05);
const s = b.sample((prev.t + next.t) / 2).get(e.id);
near(s.x, (a[1] + z[1]) / 2);
near(s.y, (a[2] + z[2]) / 2);
checked = true;
}
prev = next;
}
assert.ok(checked, '真实攻击→移动帧对');
assert.equal(h.b.errors.length, 0);
});
test('快照不泄漏无效后摇:盟友、隐藏、未部署、死亡、恐惧、眩晕、到期及失衡打断', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => h.enemy('enemy_1019_jshoot')?.atkStandUntil > h.b.time + 0.1, 10));
const e = h.enemy('enemy_1019_jshoot');
const has = () => h.b.snapshot().stand?.some((s) => s[0] === e.id) ?? false;
assert.ok(has());
const ally = h.unit('t_wall');
ally.atkStandUntil = e.atkStandUntil;
assert.ok(!h.b.snapshot().stand.some((s) => s[0] === ally.id));
for (const key of ['hidden', 'alive', 'deployed']) {
const old = e[key];
e[key] = key === 'hidden';
assert.equal(has(), false, key);
e[key] = old;
}
for (const key of ['fear', 'stun']) {
e.s.flags[key] = true;
assert.equal(has(), false, key);
e.s.flags[key] = false;
}
for (const until of [h.b.time, -Infinity, NaN, Infinity]) {
e.atkStandUntil = until;
assert.equal(has(), false);
}
e.atkStandUntil = h.b.time + 1;
assert.ok(h.b.displace(e, { x: -1, y: 0 }, 0.2) > 0, '实际发生位移');
assert.equal(has(), false, '失衡已清除后摇');
});
+61
View File
@@ -0,0 +1,61 @@
// test/render/battle-perf.browser.test.js — the battle perf page (public/dev/battle-perf.html) in headless Chrome: every
// committed spec starts, the battle runner steps the sim and the field view draws it, with a clean console; the sample
// hook tools/perfbench.mjs reads returns its figures.
//
// Opt-in (starts Chrome, needs the downloaded assets): RENDER_E2E=1 node --test test/render/battle-perf.browser.test.js
// Chrome path: $CHROME_PATH or the platform default of tools/perfbench.mjs.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { gpuArgs } from '../../tools/perfbench.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const CHROME = process.env.CHROME_PATH || {
win32: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
linux: '/usr/bin/google-chrome',
}[process.platform];
const enabled = process.env.RENDER_E2E === '1' && !!CHROME && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and the downloaded assets)';
const index = JSON.parse(readFileSync(path.join(ROOT, 'public/dev/perf/index.json'), 'utf8'));
describe('battle perf page in headless Chrome', { skip }, () => {
let srv, browser;
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: gpuArgs() });
});
after(async () => {
await browser?.close();
await srv?.close();
});
for (const s of index) {
test(`${s.name}: the battle runs and draws, the sample hook reports`, async () => {
const page = await browser.newPage();
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
await page.setViewport({ width: 844, height: 390, deviceScaleFactor: 2 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/battle-perf.html?spec=${s.name}`);
await page.waitForFunction('window.__perf && (window.__perf.ready || window.__perf.error)', { timeout: 60000 });
assert.equal(await page.evaluate(() => window.__perf.error), null);
await new Promise((r) => setTimeout(r, 3000));
const r = await page.evaluate(() => window.__perf.sample(2000));
const short = await page.evaluate(() => window.__perf.sample(0)); // still a full frame time, not 0 / 0
await page.close();
assert.deepEqual(problems, []);
assert.ok(r.fps > 10, `fps ${r.fps}`);
assert.ok(r.ticks > 60, `sim ticks ${r.ticks}`);
assert.ok(r.units > 0, `units ${r.units}`);
assert.ok(Number.isFinite(r.p95) && r.p95 > 0);
assert.ok(Number.isFinite(short.fps) && short.fps > 0 && short.p95 > 0, `sample(0): ${JSON.stringify(short)}`);
});
}
});
+25 -1
View File
@@ -15,7 +15,7 @@ import {
buildBoard, classifyStage, heightOf, AREAS, areaFor, unionAreas, objToBoard, boxProjectUV, tube, Geom, uvAt, ROWS, COLS,
} from '../../public/js/render/board3d/layout.js';
import { BoardScene, gatePulse, DIR_TURNS, boxData, LIGHTING } from '../../public/js/render/board3d/scene.js';
import { loadBoardPack, resetBoardPack, PACK_IMAGES } from '../../public/js/render/board3d/load.js';
import { loadBoardPack, loadThree, resetBoardPack, PACK_IMAGES } from '../../public/js/render/board3d/load.js';
import { parseStage } from '../../public/js/render/tiles.js';
import { TILE_H } from '../../public/js/render/style.js';
import { presetCamera, syncThreeCamera } from '../../public/js/render/projection.js';
@@ -303,6 +303,30 @@ describe('BoardScene (three.js scene graph, stub renderer)', () => {
});
describe('asset pack loader (fake store)', () => {
test('resetBoardPack lets a later three.js import try again after three failures', async () => {
resetBoardPack();
const missing = new URL('./no-such-three.mjs', import.meta.url).href;
for (let i = 0; i < 3; i++) assert.equal(await loadThree(missing), null);
assert.equal(await loadThree(missing), null, 'three failures stop further imports');
resetBoardPack();
const mod = await loadThree(new URL('./fake-three.mjs', import.meta.url).href);
assert.equal(typeof mod?.WebGLRenderer, 'function');
resetBoardPack();
});
test('a transient diffuse-image failure is retried by the next mount instead of cached as permanent 2D', async () => {
resetBoardPack();
let calls = 0;
const store = { local: async () => ({ groups: {} }), localUrl: (g, n) => n === 'TX_autochessi_D' ? '/test/atlas.png' : null,
image: async () => ++calls === 1 ? null : { width: 8, height: 8 } };
const original = globalThis.fetch;
globalThis.fetch = async () => ({ ok: false });
try {
assert.equal(await loadBoardPack(store), null);
const pack = await loadBoardPack(store);
assert.ok(pack?.images.D); assert.equal(calls, 2);
assert.equal(await loadBoardPack(store), pack, 'successful pack remains shared');
} finally { globalThis.fetch = original; resetBoardPack(); }
});
test('loads images through the store and meshes through fetch; null without the diffuse atlas', async () => {
resetBoardPack();
const manifest = { groups: {} };
+79
View File
@@ -73,6 +73,85 @@ describe('render engine in headless Chrome', { skip }, () => {
});
}
test('Swire coins from a real snapshot remain separate from the elemental gauge', async () => {
const { makeBattle } = await import('../helpers/battleHarness.js');
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 10, col: 4, skillIndex: 1 }] });
h.step(); const u = h.unit(1); u.mem.coins = 1;
h.b.dealDamage(null, u, { type: 'element', element: 'burn', amount: 300 });
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
try {
await page.evaluate(({ meta, snap }) => {
window.__demo.pause(); const v = window.__demo.view;
v.enterBattle(meta); v.pushSnapshot(snap);
}, { meta: h.b.fieldMeta(), snap: h.b.snapshot() });
await page.waitForFunction(id => window.__demo.view.debug.views.get(id)?.coinHud?.root.visible, {}, u.id);
const bounds = await page.evaluate(id => {
const v = window.__demo.view.debug.views.get(id), coins = v.coinHud.root.getBounds(), elem = v._elBar.root.getBounds();
return { label: v.coinHud.text.text, coinBottom: coins.bottom, elementTop: elem.top };
}, u.id);
assert.equal(bounds.label, `1/${u.skill.bb.sp}`);
assert.ok(bounds.coinBottom <= bounds.elementTop, JSON.stringify(bounds));
await page.screenshot({ path: path.join(OUT, 'swire-coins-element.png') });
assert.deepEqual(problems, []);
} finally { await page.close(); }
});
test('外勤医疗 reserve medic is visible but not draggable in normal and right boss preparation', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
const first = await page.evaluate(() => {
const v = window.__demo.view;
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
st.prepMapChars = [{ id: -1, uid: -1, kind: 'token', side: 'ally', ownerId: 'p_0',
defId: 'char_605_cmedic', name: '预备干员-医疗', spine: 'char_605_cmedic', avatar: 'char_605_cmedic',
area: 'board', x: 2, y: 10, dir: 'RIGHT', facing: 1, maxHp: 1202 }];
window.__touchPrepChar = st.prepMapChars[0];
v.setPrep(st, { editable: true });
const medic = v.debug.views.get('m:-1');
const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect();
const out = { exists: !!medic, id: medic?.info.defId, x: medic?.x, y: medic?.y,
draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top) };
return out;
});
await wait(500);
await page.screenshot({ path: path.join(OUT, 'touch-prep-normal.png') });
const boss = await page.evaluate(() => {
const v = window.__demo.view;
v.setCamera('bossPrep', { side: 'R', instant: true });
const mirrored = v.debug.views.get('m:-1');
return [mirrored?.x, mirrored?.y, mirrored?.dir];
});
await wait(500);
await page.screenshot({ path: path.join(OUT, 'touch-prep-boss-right.png') });
const removed = await page.evaluate(() => {
const v = window.__demo.view;
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
st.prepMapChars = [];
v.setPrep(st, { editable: true });
return !v.debug.views.has('m:-1');
});
const fallback = await page.evaluate(async () => {
const { createFallbackView } = await import('/js/ui/fallbackField.js');
const host = document.createElement('div');
host.style.cssText = 'position:absolute;width:800px;height:600px';
document.body.appendChild(host);
const ff = createFallbackView(host, { data: { get: () => null, lookup: () => null } });
ff.setPrep({ board: [], hand: [], temp: [], prepMapChars: [window.__touchPrepChar] }, { editable: true });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const el = host.querySelector('.ff-piece--map-char');
const out = { exists: !!el, name: el?.title, pointerEvents: el ? getComputedStyle(el).pointerEvents : null,
movableId: el?.dataset.uid ?? null };
ff.destroy(); host.remove();
return out;
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10,
draggable: false });
assert.deepEqual(boss, [18, 3, 'LEFT']);
assert.equal(removed, true);
assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'none', movableId: null });
});
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
const { page, problems } = await open('scene=fx&panel=0', 1280, 720);
// cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s), watching the FX system: shots
+43
View File
@@ -0,0 +1,43 @@
// test/render/champagne-hpbar.test.js — 琳琅诗怀雅's 香槟炸弹 (token_10031_swire2_gdtrap) never shows an HP bar: PRTS 香槟炸弹
// 备注 "即使自身生命值未满,模型下方也不会显示生命值槽" (the owner's report of 2026-10-08, 「香槟会掉血,会被治疗」). Allies
// otherwise always draw one (render/units.js _updateHud). Only the bar changes; its HP and healing stay as they are.
// Run: node --test test/render/champagne-hpbar.test.js
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, UnitView;
before(async () => {
fake = installFakePixi();
({ UnitView } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const view = (defId, id) => new UnitView(fakeViewCtx(fake.P, { cam }), { id, side: 'ally', kind: 'summon', defId, tier: 1, x: 11, y: 10, maxHp: 1000, hp: 1000 }, {});
const frames = (v) => { for (let i = 0; i < 2; i++) v.update(1 / 60, cam(), i / 60); };
const bar = (v) => [v.hpBg.visible, v.hpFill.visible, v.hpGhost.visible];
test('香槟炸弹 shows no HP bar at full HP, below it, or healed back up', () => {
const bomb = view('token_10031_swire2_gdtrap', 1);
frames(bomb);
assert.deepEqual(bar(bomb), [false, false, false], 'full HP');
bomb.sync({ x: 11, y: 10, hp: 860, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 1);
frames(bomb);
assert.equal(bomb.hp, 860, 'the view keeps its HP');
assert.deepEqual(bar(bomb), [false, false, false], 'hurt (活性源石: 70 true damage a second)');
bomb.sync({ x: 11, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 2);
frames(bomb);
assert.deepEqual(bar(bomb), [false, false, false], 'healed');
});
test('another summon still draws its HP bar, full or hurt', () => {
const other = view('token_10000_silent_healrb', 2);
frames(other);
assert.deepEqual(bar(other), [true, true, true]);
other.sync({ x: 11, y: 10, hp: 400, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 1);
frames(other);
assert.deepEqual(bar(other), [true, true, true]);
});
+1 -1
View File
@@ -42,7 +42,7 @@ function installFeed() {
state() {
const out = {};
for (const [id, x] of v.debug.views) {
out[id] = { alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible, label: x._downRing?.text?.text ?? null,
out[id] = { alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible, label: x._downRing?.label ?? null,
el: x._elBar?.root?.visible ? x.el : null, spine: !!x.spineReady, dieClip: x.actor?.current ?? null, alpha: +(x.alpha ?? 0).toFixed(2) };
}
return out;
+43 -6
View File
@@ -70,21 +70,25 @@ describe('knocked-down operator (UnitView.setDown)', () => {
assert.ok(v.alpha > 0.85, `drawn (alpha ${v.alpha})`);
const r = v._downRing;
assert.ok(r && r.root.visible, 'ring shown');
assert.equal(r.text.text, '20', 'seconds left (game s)');
assert.equal(r.label, '20', 'seconds left (game s)');
assert.equal(r.text.texture, T.downLabel('20', '#ffffff', 1), 'drawn with the shared label texture');
assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.COUNTING]);
assert.equal(r.arc.texture, T.ringArc(0), 'nothing elapsed yet');
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 25);
frames(v, 1, 1 / 60, () => 25);
assert.equal(r.text.text, '5');
assert.equal(r.label, '5');
assert.equal(r.text.texture, T.downLabel('5', '#ffffff', 1));
assert.equal(r.arc.texture, T.ringArc(0.75), 'three quarters elapsed');
v.setDown([1, 30, 20, DOWN_STATE.WAIT_DP], 31);
frames(v, 1, 1 / 60, () => 31);
assert.equal(r.text.text, 'DP');
assert.equal(r.label, 'DP');
assert.equal(r.text.texture, T.downLabel('DP', '#ffc600', 1), 'in the ring colour');
assert.equal(r.arc.tint, DOWN_LOOK.ring[DOWN_STATE.WAIT_DP]);
assert.equal(r.arc.texture, T.ringArc(1), 'full ring while it waits');
v.setDown([1, 30, 20, DOWN_STATE.WAIT_TILE], 32);
frames(v, 1);
assert.equal(r.text.text, '!');
assert.equal(r.label, '!');
assert.equal(r.text.texture, T.downLabel('!', '#ff4b3e', 1));
const kids = v.hud.children.length;
frames(v, 30);
assert.equal(v.hud.children.length, kids, 'no per-frame allocation');
@@ -95,6 +99,39 @@ describe('knocked-down operator (UnitView.setDown)', () => {
assert.equal(r.root.visible, false, 'ring gone');
});
test('rings showing the same label share one rasterised texture, drawn once (textures.js downLabel)', () => {
const a = view({ id: 11 }), b = view({ id: 12 });
frames(a, 2);
frames(b, 2);
a.setDown([11, 30, 20, DOWN_STATE.COUNTING], 17.5);
b.setDown([12, 31, 20, DOWN_STATE.COUNTING], 18.5); // both 12.5 s left → "13"
frames(a, 1, 1 / 60, () => 17.5);
frames(b, 1, 1 / 60, () => 18.5);
const ra = a._downRing, rb = b._downRing;
assert.equal(ra.label, '13');
assert.equal(rb.label, '13');
assert.equal(ra.text.texture, rb.text.texture, 'one texture for both rings');
assert.ok(!(ra.text instanceof fake.P.Text), 'the ring holds a plain Sprite, not a Text of its own');
const t13 = ra.text.texture;
frames(a, 1, 1 / 60, () => 18.5);
assert.equal(ra.label, '12');
assert.notEqual(ra.text.texture, t13, 'a new number, a new texture');
a.setDown([11, 30, 20, DOWN_STATE.COUNTING], 17.5);
frames(a, 1, 1 / 60, () => 17.5);
assert.equal(ra.text.texture, t13, 'a number seen before reuses its texture');
assert.equal(T.downLabel('13', '#ffffff', 2) === t13, false, 'another resolution, another texture');
});
test('the shared labels are redrawn in place once the web fonts land (refreshDownLabels)', () => {
const tex = T.downLabel('47', '#ffffff', 1);
const proto = fake.P.Text.prototype, up = proto.updateText;
const redrawn = [];
proto.updateText = function (respectDirty) { redrawn.push([this.text, respectDirty]); return up.call(this, respectDirty); };
try { T.refreshDownLabels(); } finally { proto.updateText = up; }
assert.ok(redrawn.some(([t, d]) => t === '47' && d === false), 'the cached label re-rasterised (forced)');
assert.equal(T.downLabel('47', '#ffffff', 1), tex, 'same texture object, so every ring showing it updates');
});
test('a view made for a unit already down starts on the held end of the Die clip', () => {
const v = view();
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 12, true);
@@ -102,7 +139,7 @@ describe('knocked-down operator (UnitView.setDown)', () => {
assert.ok(v.dieT >= 30);
frames(v, 2, 1 / 60, () => 12);
assert.ok(v._downRing.root.visible);
assert.equal(v._downRing.text.text, '18');
assert.equal(v._downRing.label, '18');
});
test('an operator entering the battle knocked out (联防, reason FORCED_EXIT): the held pose at once, no death burst, then its ring', async () => {
@@ -120,7 +157,7 @@ describe('knocked-down operator (UnitView.setDown)', () => {
frames(v, 240, 1 / 60, () => 2);
assert.equal(v.remove, false, 'stays on its tile');
assert.ok(v._downRing.root.visible);
assert.equal(v._downRing.text.text, '68');
assert.equal(v._downRing.label, '68');
const w = view({ id: 2 });
frames(w, 2);
w.die();
+54 -2
View File
@@ -1,9 +1,10 @@
// test/render/drag.test.js — drop target resolution and the pointer state machine (no DOM, fake timers).
// test/render/drag.test.js — drop target resolution, the pointer state machine (no DOM, fake timers) and the tile a
// dragged unit is drawn standing on (dragStandTile; the harness's 100 px grid stands in for the camera's tile pick).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { GEO } from '../../shared/constants.js';
import { tileSlot, pieceSlot, pieceTile, resolveDrop, isLegal, sameSlot, createDragController } from '../../public/js/render/drag.js';
import { tileSlot, pieceSlot, pieceTile, resolveDrop, isLegal, sameSlot, createDragController, dragStandTile } from '../../public/js/render/drag.js';
describe('slots & targets', () => {
test('tileSlot classifies hand, temp, board and everything else', () => {
@@ -293,3 +294,54 @@ describe('drag controller', () => {
} finally { console.error = orig; }
});
});
// The official deploy drag (the owner's recording of 2026-10-09; the report in PR #403): the dragged unit is drawn
// standing on the tile under the pointer while that is a legal target, and held under the pointer elsewhere. The tile
// is the drag target itself, so the pick, tileHover, the highlight and the drop stay the pointer's tile.
describe('the tile a dragged unit stands on (dragStandTile)', () => {
test('a legal board tile or bench slot is stood on; an illegal tile, a temp slot, no target hold it under the pointer', () => {
assert.deepEqual(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: true }), { row: 10, col: 3, bench: false });
assert.deepEqual(dragStandTile({ target: { area: 'hand', idx: 6 }, legal: true }), { row: GEO.HAND_ROW, col: 6, bench: true });
assert.equal(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: false }), null, 'an illegal tile');
assert.equal(dragStandTile({ target: { area: 'hand', idx: 6 }, legal: false }), null, 'the own bench slot / an illegal slot');
assert.equal(dragStandTile({ target: { area: 'temp', idx: 1 }, legal: true }), null, 'a temp slot is never a target');
assert.equal(dragStandTile({ target: { area: 'outside', clientX: 1, clientY: 2 }, legal: true }), null);
assert.equal(dragStandTile({ target: null, legal: false }), null, 'off the grid, over the shop bar');
assert.equal(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: 1 }), null, 'legal must be true');
assert.equal(dragStandTile({ target: { area: 'board', row: 9.5, col: 3 }, legal: true }), null);
assert.equal(dragStandTile({ target: { area: 'hand' }, legal: true }), null);
assert.equal(dragStandTile(null), null);
assert.equal(dragStandTile(undefined), null);
assert.deepEqual(dragStandTile({ target: { area: 'hand', idx: 2 }, legal: true }, { HAND_ROW: 3 }), { row: 3, col: 2, bench: true }, 'geo is a parameter');
});
test('over the drag path: one tile while the pointer moves inside it, the next legal tile at once, none where no drop lands (mouse and touch)', () => {
for (const pointerType of ['mouse', 'touch']) {
const h = harness({ domCover: true, canPlace: (p, r, c) => !(r === 11 && c === 4) });
h.c.setEditable(true);
const stand = () => dragStandTile(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1]);
const at = (x, y) => { h.ev('pointerMove', x, y, { pointerType }); return stand(); };
h.ev('pointerDown', 50, 550, { pointerType });
assert.equal(at(50, 530), null, `${pointerType}: on its own bench slot (no target) it is held under the pointer`);
assert.ok(h.c.dragging);
// the pointer anywhere on (9,4) (x 400–499, y 300–399 in the harness grid): the unit stands on (9,4) the whole time
const inside = [[410, 390], [450, 350], [495, 305], [402, 301]].map(([x, y]) => at(x, y));
assert.deepEqual(inside, Array(4).fill({ row: 9, col: 4, bench: false }), `${pointerType}: moving inside the tile leaves it there`);
assert.deepEqual(at(450, 299), { row: 10, col: 4, bench: false }, `${pointerType}: the next tile takes it at once`);
assert.equal(at(450, 150), null, `${pointerType}: an illegal tile (11,4)`);
assert.equal(at(550, 450), null, `${pointerType}: a temp slot`);
assert.deepEqual(at(250, 550), { row: GEO.HAND_ROW, col: 2, bench: true }, `${pointerType}: another bench slot`);
assert.equal(at(250, 950), null, `${pointerType}: over the shop bar (DOM over the canvas)`);
assert.equal(at(-20, 300), null, `${pointerType}: off the grid`);
assert.deepEqual(at(350, 250), { row: 10, col: 3, bench: false });
h.ev('pointerUp', 350, 250, { pointerType });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 3 }, `${pointerType}: dropped on the tile it stood on`);
// a board piece lifts on its own tile (a legal drop: the direction wheel re-orients it in place)
h.events.length = 0;
h.ev('pointerDown', 450, 250, { pointerType });
assert.deepEqual(at(470, 230), { row: 10, col: 4, bench: false }, `${pointerType}: a board piece stands on its own tile`);
h.ev('pointerUp', 470, 230, { pointerType });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 4 });
}
});
});
+135
View File
@@ -0,0 +1,135 @@
// test/render/eyemask.test.js — closed eyes without the stencil masks (GitHub #177; PR #384 by @Convey123, re-implemented):
// the eyelids of these models are Spine clipping attachments, the masks are off on a crowded field or below high
// quality (render/app.js pickClipping), and SpineActor._eyeMaskFallback (render/spine.js) hides a covered slot whose
// centre leaves the clip polygon.
//
// Real runtime objects throughout: the official skeletons read by @pixi-spine/runtime-3.8, drawn by its real Spine
// (pixi-spine's slot containers and meshes on @pixi/core display objects — a 16 px canvas stand-in is all it needs
// headless) inside the real SpineActor. The eyeballs are meshes, drawn through `slot.currentMesh` — PR #384 hid
// `slot.currentSprite` (null on a mesh slot) and its test stubbed one onto every slot, so nothing was hidden in the
// game. The skeletons are not in the repository (public/assets: `npm run assets`): without them those tests skip.
// Run: node --test test/render/eyemask.test.js
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const require = createRequire(path.join(ROOT, 'package.json'));
const ASSETS = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
const dirOf = (id) => path.join(ROOT, 'public', 'assets', 'spine', 'op', id, 'front');
const have = (id) => existsSync(path.join(dirOf(id), `${id}.skel`)) && existsSync(path.join(dirOf(id), `${id}.atlas`));
const MODELS = ['char_4058_pepe', 'char_4082_qiubai', 'char_4010_etlchi', 'char_1033_swire2'];
let SpineActor, pointInPolygon, rt, base, core;
before(async () => {
// headless pixi: its shader-precision probe and Texture.WHITE ask for a canvas; no WebGL context is needed to build
// and update a Spine's display tree
class HeadlessCanvas { constructor() { this.width = 16; this.height = 16; } getContext(kind) { return kind === '2d' ? { fillStyle: '', fillRect() {}, clearRect() {}, drawImage() {} } : null; } }
globalThis.HTMLCanvasElement ??= HeadlessCanvas;
globalThis.document ??= { createElement: () => new HeadlessCanvas() };
rt = require('@pixi-spine/runtime-3.8');
base = require('@pixi-spine/base');
core = require('@pixi/core');
globalThis.PIXI = { spine: { Spine: rt.Spine } }; // SpineActor builds `new PIXI.spine.Spine(data)`
({ SpineActor, pointInPolygon } = await import('../../public/js/render/spine.js'));
});
const cache = new Map();
function skeletonData(id) {
if (!cache.has(id)) {
const atlas = new base.TextureAtlas(readFileSync(path.join(dirOf(id), `${id}.atlas`), 'utf8'), (line, cb) => cb(new core.BaseTexture(null, { width: 1024, height: 1024 })));
cache.set(id, new rt.SkeletonBinary(new rt.AtlasAttachmentLoader(atlas)).readSkeletonData(new Uint8Array(readFileSync(path.join(dirOf(id), `${id}.skel`)))));
}
return cache.get(id);
}
/** A real SpineActor of `id` with its manifest entry, clipping masks on or off. */
function actor(id, clip) {
const a = new SpineActor(skeletonData(id), ASSETS.chars[id].spine.front);
a.setClipping(clip);
return a;
}
/** Slots whose container the fallback hid: still attached (pixi-spine shows those), yet not visible. */
const hidden = (a) => a.spine.skeleton.slots.filter((s) => s.getAttachment() && a.spine.slotContainers[s.data.index].visible === false).map((s) => s.data.name).sort();
/** Hidden slots `t` s into clip `clip` ('Die' through SpineActor.die — a knock-down — or 'Idle'), masks off. A fresh
* actor per time: pixi-spine's state.update takes a delta. */
function hiddenAt(id, clip, t) {
const a = actor(id, false);
if (clip === 'Die') a.die(); else a.spine.state.setAnimation(0, clip, true);
a.update(t);
return hidden(a);
}
test('pointInPolygon: inside / outside of a convex and a concave polygon', () => {
const square = [0, 0, 10, 0, 10, 10, 0, 10];
assert.equal(pointInPolygon(5, 5, square, 4), true);
for (const [x, y] of [[-1, 5], [11, 5], [5, -3]]) assert.equal(pointInPolygon(x, y, square, 4), false);
const l = [0, 0, 10, 0, 10, 4, 4, 4, 4, 10, 0, 10]; // an L: the notch is outside
assert.equal(pointInPolygon(6, 6, l, 6), false);
assert.equal(pointInPolygon(2, 6, l, 6), true);
assert.equal(pointInPolygon(8, 2, l, 6), true);
});
describe('eyelids on the official skeletons, drawn by the real pixi-spine runtime', { skip: !MODELS.every(have) && 'the operator skeletons are not in public/assets (npm run assets)' }, () => {
test('the eyeballs are meshes drawn through slot.currentMesh, inside the eyelid clip\'s container', () => {
const a = actor('char_4058_pepe', true);
a.update(0.1);
const slots = a.spine.skeleton.slots.filter((s) => /^F_[LR]_Eye_[BC]$/.test(s.data.name));
assert.equal(slots.length, 4);
const clip = a.spine.skeleton.slots.find((s) => s.clippingContainer);
for (const s of slots) {
assert.equal(s.getAttachment().constructor.name, 'MeshAttachment', s.data.name);
assert.ok(s.currentMesh && !s.currentSprite, `${s.data.name}: a mesh, no sprite`);
assert.equal(a.spine.slotContainers[s.data.index].parent, clip.clippingContainer, `${s.data.name} is clipped`);
}
});
test('佩佩 knocked down (Die): the four eyeball / eye-white slots hide once the eyes close (0.13 → 1.00 s), none before', () => {
for (const t of [0, 0.1]) assert.deepEqual(hiddenAt('char_4058_pepe', 'Die', t), [], `open at ${t}`);
for (const t of [0.3, 0.52, 1]) assert.deepEqual(hiddenAt('char_4058_pepe', 'Die', t), ['F_L_Eye_B', 'F_L_Eye_C', 'F_R_Eye_B', 'F_R_Eye_C'], `closed at ${t}`);
});
test('仇白 knocked down: both eyeballs hide once the eyes close', () => {
for (const t of [0, 0.1]) assert.deepEqual(hiddenAt('char_4082_qiubai', 'Die', t), [], `open at ${t}`);
for (const t of [0.2, 0.52, 1]) assert.deepEqual(hiddenAt('char_4082_qiubai', 'Die', t), ['F_L_Eyeball', 'F_R_Eyeball'], `closed at ${t}`);
});
test('隐德来希 / 琳琅诗怀雅 blink in Idle (2.13–2.37 s / 2.90–3.00 s): hidden then, never with the eyes open', () => {
for (const t of [0.5, 1.9, 3]) assert.deepEqual(hiddenAt('char_4010_etlchi', 'Idle', t), [], `隐德来希 open at ${t}`);
for (const t of [2.13, 2.25, 2.37]) assert.ok(hiddenAt('char_4010_etlchi', 'Idle', t).length >= 4, `隐德来希 blinking at ${t}`);
for (const t of [0.5, 2.6, 3.2]) assert.deepEqual(hiddenAt('char_1033_swire2', 'Idle', t), [], `琳琅诗怀雅 open at ${t}`);
for (const t of [2.9, 2.97, 3]) assert.deepEqual(hiddenAt('char_1033_swire2', 'Idle', t), ['F_H_L_Eyeball', 'F_H_L_Eyew', 'F_H_R_Eyeball', 'F_H_R_Eyew'], `琳琅诗怀雅 at ${t}`);
});
test('a whole clip sampled at 30 Hz: hiding is rare outside a knock-down and never takes more than the eyes', () => {
// [model, clip, seconds, share of frames with something hidden: at least, at most]
for (const [id, clip, dur, lo, hi] of [
['char_4058_pepe', 'Idle', 4, 0, 0], ['char_4058_pepe', 'Die', 1.5, 0.5, 1],
['char_4082_qiubai', 'Idle', 4, 0, 0.35], ['char_4082_qiubai', 'Die', 1, 0.5, 1],
['char_4010_etlchi', 'Idle', 4, 0.02, 0.2], ['char_1033_swire2', 'Idle', 4, 0.01, 0.2],
]) {
let hit = 0, frames = 0, most = 0;
for (let t = 0; t <= dur; t += 1 / 30) { const n = hiddenAt(id, clip, t).length; frames++; if (n) hit++; most = Math.max(most, n); }
assert.ok(hit / frames >= lo && hit / frames <= hi, `${id} ${clip}: ${Math.round(hit / frames * 100)} % of frames (want ${lo * 100}–${hi * 100} %)`);
assert.ok(most <= 8, `${id} ${clip}: at most ${most} slots in one frame`);
}
});
test('with the masks on nothing is hidden (the stencil cuts), and turning them back on shows what the fallback hid', () => {
const on = actor('char_4058_pepe', true);
on.die();
on.update(0.52);
assert.deepEqual(hidden(on), [], 'masks on: the fallback stays out');
const off = actor('char_4058_pepe', false);
off.die();
off.update(0.52);
assert.equal(hidden(off).length, 4, 'masks off: the closed eyes are hidden');
off.setClipping(true);
assert.deepEqual(hidden(off), [], 'shown again for the stencil');
const clip = off.spine.skeleton.slots.find((s) => s.clippingContainer);
assert.equal(clip.clippingContainer.mask, clip.currentGraphics, 'the mask is back on');
});
});
+1
View File
@@ -0,0 +1 @@
export function WebGLRenderer() {}
+4 -1
View File
@@ -83,7 +83,10 @@ export function installFakePixi() {
drawRect() { return this; } drawCircle() { return this; } drawEllipse() { return this; } drawPolygon() { return this; } drawRoundedRect() { return this; } closePath() { return this; }
quadraticCurveTo() { return this; } bezierCurveTo() { return this; } arc() { return this; }
}
class Text extends Sprite { constructor(text = '', style = {}) { super(); this.text = text; this.style = style; } }
class Text extends Sprite {
constructor(text = '', style = {}) { super(); this.text = text; this.style = style; this.resolution = 1; this.rasters = 0; this.texture = new Texture(new BaseTexture({ width: 1, height: 1 })); }
updateText() { this.rasters++; }
}
class BitmapText extends Text {}
class Matrix { constructor() { this.a = 1; this.b = 0; this.c = 0; this.d = 1; this.tx = 0; this.ty = 0; } set() { return this; } translate() { return this; } scale() { return this; } identity() { return this; } }
class RenderTexture extends Texture { static create({ width = 1, height = 1 } = {}) { return new RenderTexture(new BaseTexture({ width, height })); } resize() {} }
@@ -71,6 +71,27 @@ test('an enemy attacking quicker than its clip plays it faster, one clip per att
assert.equal(a.current, 'Move', 'the rest of the clip at 2×: 0.23 s');
});
// #246 (by @TsangAsuna, accepted by the owner on 2026-10-07): seen late, an attack whose rhythm leaves room for the whole
// clip plays it complete from the wind-up — 重犯's iron ball lifts before the slam instead of the slam appearing alone
test('an attack seen late plays the whole clip from its wind-up when the rhythm leaves room for it (重犯), else from the strike frame', () => {
const LIFBOS = 'enemy_1121_lifbos'; // 重犯: Attack_grey 2.333 s, strike at 1.0 s; attacks every 3 s
const a = actor(LIFBOS, true);
a.attack(3);
assert.equal(a.current, 'Attack_grey');
assert.equal(track(a).trackTime, 0, 'from the wind-up (the ball lifts), not the 1.0 s strike frame');
assert.equal(track(a).timeScale, 1, 'at its own speed');
run(a, 2.25);
assert.equal(a.current, 'Attack_grey', 'the whole 2.333 s clip plays (it used to end 1.333 s after the strike frame)');
run(a, 0.15);
assert.equal(a.current, 'Move_grey', 'then the resting clip');
const quick = actor(LIFBOS, true);
quick.attack(2); // quicker than the clip: no room for the wind-up
assert.ok(Math.abs(track(quick).trackTime - 1) < 1e-9, 'from the strike frame');
const op = actor(LIFBOS, false);
op.attack(3); // an operator's looping attack is unchanged
assert.ok(Math.abs(track(op).trackTime - 1) < 1e-9);
});
test('an operator keeps its attack loop stretched over the attack rhythm (unchanged)', () => {
const a = actor(JSHOOT, false);
a.attack(2.7);
+6 -1
View File
@@ -1,7 +1,8 @@
// 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) with the round's leader on its spawn tile and
// drop targets / directions stay in board space, the right half mirrored; a dragged unit stands on its target's
// boss-field tile) 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, fa-leader-*.png, dmgnum-*.png; perf → test/e2e/out/fieldview-perf.json.
@@ -194,6 +195,9 @@ describe('field view features in headless Chrome', { skip }, () => {
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let i = 1; i <= 12; i++) await page.mouse.move(cx + (target.x - cx) * i / 12, cy + (target.y - cy) * i / 12);
// over the legal target the dragged unit stands on it (the official deploy drag): on that board tile's boss-field
// tile, as the drop will place it — row − 7, the column mirrored on the right half
const stood = await page.evaluate((uid) => { const v = window.__demo.view.debug.views.get('p:' + uid); return { x: v.x, y: v.y, lift: v.lift }; }, info.hand.uid);
await page.mouse.up();
await wait(400);
const drops = await page.evaluate(() => window.__drops);
@@ -222,6 +226,7 @@ describe('field view features in headless Chrome', { skip }, () => {
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual(drops[0], { area: 'board', row: target.row, col: target.col }, `drop target in board space: ${JSON.stringify(drops)}`);
assert.deepEqual(stood, { x: side === 'R' ? 20 - target.col : target.col, y: target.row - 7, lift: 0.3 }, 'while dragged over it: standing on the target\'s boss-field tile');
assert.deepEqual(dirs, { a: side === 'R' ? 'LEFT' : 'RIGHT', u: 'UP' });
assert.deepEqual(past, { field: true, wallBelow: null, below: null, wallAbove: true, pen: null, pad: true });
});
+191
View File
@@ -19,6 +19,9 @@ import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { UF, ANIM } from '../../shared/constants.js';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import { enemyStealthed } from '../../server/sim/targeting.js';
import { flagsOf } from '../../server/sim/snapshot.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
@@ -133,6 +136,194 @@ async function enemy(id, info = {}) {
return v;
}
describe('骨刺 effective stealth drives A/B clips', () => {
const BONE = 'enemy_9008_acbunn';
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-6, `${a} ≈ ${b}`);
function arena() {
const h = makeBattle({
content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false,
defs: { chess: { wall: chessRec({ id: 'wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }) } },
kits: { wall: () => ({ trait: { noAttack: true } }) }, units: [{ chessId: 'wall', row: 9, col: 6 }],
});
h.step();
const e = h.spawn(BONE, { pos: [9, 6], routeIndex: 0, mods: { speedMul: 0 } });
return { h, e, wall: h.unit('wall') };
}
function pose(v, h, e, hidden) {
const flags = flagsOf(e);
assert.equal(enemyStealthed(e), hidden);
assert.equal(!!(flags & UF.STEALTH), hidden);
v.sync({ ...sample(flags), x: e.x, y: e.y }, h.b.time);
assert.equal(v.form, hidden ? null : 'revealed');
assert.equal(clip(v), hidden ? 'Idle_A' : 'Idle_B');
v.fadeIn = 1;
v.update(1 / 60, cam(), h.b.time);
assert.equal(v.root.alpha, hidden ? 0.45 : 1);
}
test('骨刺: actual blocking, blocker stun, reveal and expiry give A → B → A → B → A', async () => {
const { h, e, wall } = arena();
const v = await enemy(BONE);
try {
pose(v, h, e, true);
h.step(2); assert.equal(e.blockedBy, wall); pose(v, h, e, false);
h.b.applyStatus(wall, 'stun', { duration: 30, force: true });
h.step(2); assert.equal(e.blockedBy, null); pose(v, h, e, true);
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
pose(v, h, e, false);
h.run(1.1); pose(v, h, e, true);
} finally { v.destroy(); }
});
test('骨刺: overlapping block and reveal keep B until both end', async () => {
const { h, e, wall } = arena();
const v = await enemy(BONE);
try {
h.step(2); assert.equal(e.blockedBy, wall);
h.b.addBuff(e, { key: 'test:reveal', duration: 0.1, flags: { reveal: true } });
pose(v, h, e, false);
h.run(0.2); assert.equal(e.blockedBy, wall); pose(v, h, e, false);
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
h.b.applyStatus(wall, 'stun', { duration: 30, force: true });
h.step(2); assert.equal(e.blockedBy, null); pose(v, h, e, false);
h.run(1.1); pose(v, h, e, true);
} finally { v.destroy(); }
});
test('骨刺: switching an ongoing attack maps A 0.8 ↔ B 1.0 without another attack or a repeated-snapshot restart', async () => {
const v = await enemy(BONE);
try {
v.sync(sample(UF.STEALTH), 1);
v.atkInterval = 4;
v.onAttack(null, 1.1);
v.actor.clock = 2;
v.actor.spine.state.tracks[0].trackTime = 0.8;
const last = v.lastAtk, interval = v.atkInterval;
v.imp = { dirty: false };
v.sync(sample(0), 2);
assert.equal(clip(v), 'Attack_B');
near(v.actor.spine.state.tracks[0].trackTime, 1);
near(v.actor.attackUntil, 2.5);
assert.equal(v.imp.dirty, true);
const track = v.actor.spine.state.tracks[0];
v.sync(sample(0), 2.1);
assert.equal(v.actor.spine.state.tracks[0], track);
v.sync(sample(UF.STEALTH), 2.2);
assert.equal(clip(v), 'Attack_A');
near(v.actor.spine.state.tracks[0].trackTime, 0.8);
near(v.actor.attackUntil, 2.7);
assert.equal(v.lastAtk, last);
assert.equal(v.atkInterval, interval);
assert.equal(v.actor.wound, false);
v.imp = null;
} finally { v.imp = null; v.destroy(); }
});
for (const fromB of [false, true]) {
test(`骨刺: ${fromB ? 'B → A truncated' : 'A → B'} wind-up retains its deadline and tail speed`, async () => {
const v = await enemy(BONE);
try {
v.sync(sample(fromB ? 0 : UF.STEALTH), 1);
v.actor.clock = 2;
const lead = fromB ? 0.633 : 0.333;
assert.ok(v.actor.windUp(4, lead));
v.actor.spine.state.tracks[0].trackTime = fromB ? 0.1 : 0.2;
const until = v.actor.windUntil, last = v.lastAtk, interval = v.atkInterval;
v.sync(sample(fromB ? UF.STEALTH : 0), 2);
const track = v.actor.spine.state.tracks[0];
assert.equal(clip(v), fromB ? 'Attack_A' : 'Attack_B');
near(track.trackTime, fromB ? 0 : 0.4);
near(track.timeScale, fromB ? 0.533 / lead : 1);
assert.equal(v.actor.windUntil, until);
assert.equal(v.actor.windTs, 1);
assert.equal(v.actor.wound, true);
near(v.actor.attackUntil, 2 + lead + 1.5 - (fromB ? 0.533 : 0.733));
assert.equal(v.lastAtk, last);
assert.equal(v.atkInterval, interval);
v.sync(sample(fromB ? UF.STEALTH : 0), 2.1);
assert.equal(v.actor.spine.state.tracks[0], track);
v.actor.update(lead + 0.001);
assert.equal(track.timeScale, 1, 'the original tail speed resumes');
} finally { v.destroy(); }
});
}
for (const control of [UF.FROZEN, UF.STUNNED, UF.SLEEP]) {
test(`骨刺: control ${control} keeps the skeleton frozen while B/A attachments are applied immediately`, async () => {
const v = await enemy(BONE);
try {
v.sync(sample(UF.STEALTH | control), 1);
let zeroUpdates = 0;
const update = v.actor.spine.update.bind(v.actor.spine);
v.actor.spine.update = (dt) => { if (dt === 0) zeroUpdates++; update(dt); };
for (const hidden of [false, true]) {
v.sync(sample(control | (hidden ? UF.STEALTH : 0)), 2);
assert.equal(clip(v), hidden ? 'Idle_A' : 'Idle_B');
assert.equal(v.actor.mode, 'stun');
assert.equal(v.actor.frozen, true);
assert.equal(v.actor.spine.state.tracks[0].mixDuration, 0);
}
assert.equal(zeroUpdates, 2);
v.sync(sample(UF.STEALTH, ANIM.MOVE), 3);
assert.equal(v.actor.frozen, false);
assert.equal(clip(v), 'Move_A');
} finally { v.destroy(); }
});
}
test('骨刺: a first snapshot with flags = 0 selects B even though previous flags were also 0', async () => {
const v = await enemy(BONE);
try {
assert.equal(v.flags, 0);
v.sync(sample(0), 1);
assert.equal(v.form, 'revealed');
assert.equal(clip(v), 'Idle_B');
} finally { v.destroy(); }
});
for (const finalFlags of [0, UF.STEALTH, UF.FROZEN]) {
test(`骨刺: delayed model loading applies the latest snapshot (${finalFlags}), including frozen B`, async () => {
const a = store(BONE);
let resolve;
a.spine.acquire = () => new Promise((r) => { resolve = r; });
const v = new UnitView(fakeViewCtx(fake.P, { assets: a, cam }), { id: 9, side: 'enemy', kind: 'enemy', defId: BONE, spine: BONE, maxHp: 1000 });
const proto = fake.P.spine.Spine.prototype, update = proto.update;
let zeroUpdates = 0;
proto.update = function (dt) { if (dt === 0) zeroUpdates++; return update.call(this, dt); };
try {
v.sync(sample(0), 1);
assert.equal(v.actor, null);
assert.equal(v.form, 'revealed');
v.sync(sample(finalFlags), 2);
resolve({ animations: Object.keys(a.spineEntry().animations).map((name) => ({ name })) });
await tick(); await tick();
assert.ok(v.actor);
assert.equal(clip(v), finalFlags & UF.STEALTH ? 'Idle_A' : 'Idle_B');
assert.ok(zeroUpdates > 0, 'the loaded pose is applied at zero time');
assert.equal(v.actor.frozen, !!(finalFlags & UF.FROZEN));
} finally { proto.update = update; v.destroy(); }
});
}
for (const hidden of [true, false]) {
test(`骨刺: ${hidden ? 'A' : 'B'} idle, move and death; a DIE snapshot selects its form before dying`, async () => {
const flags = hidden ? UF.STEALTH : 0, suffix = hidden ? 'A' : 'B';
const v = await enemy(BONE), w = await enemy(BONE);
try {
v.sync(sample(flags, ANIM.MOVE), 1); assert.equal(clip(v), `Move_${suffix}`);
v.sync(sample(flags, ANIM.IDLE), 2); assert.equal(clip(v), `Idle_${suffix}`);
v.die(); assert.equal(clip(v), `Die_${suffix}`);
w.sync(sample(hidden ? 0 : UF.STEALTH), 1);
w.sync(sample(flags, ANIM.DIE), 2); assert.equal(clip(w), `Die_${suffix}`);
w.sync(sample(hidden ? 0 : UF.STEALTH, ANIM.DIE), 3);
frames(w, 10);
assert.equal(clip(w), `Die_${suffix}`, 'death keeps its chosen form');
assert.equal(w.form, hidden ? null : 'revealed');
} finally { v.destroy(); w.destroy(); }
});
}
});
describe('转译基底·α forms (user report after 0.1.0, #5)', () => {
const TR = 'enemy_10081_mpplai';
test('every clip of the three forms exists in the skeleton; the change clips last the official 2 s; the manifest keeps the original form', () => {
+132
View File
@@ -0,0 +1,132 @@
// test/render/fxchen3wave.test.js — 赤刃明霄陈 S3 赤霄·天喟 的「龙剑气」(用户报告: 三技能的龙剑气看不到).
//
// The wave is a kit-managed probe in the sim (server/sim/content/kits/ops/op-chen3.js): it leaves her tile forward at
// 1.5 tiles/s, turns 90° at the field's edge / high ground / gates, and lives as long as the skill. It is not in the
// snapshot's projectile list, so the client can only draw it from `fx` events — and before this change the sim sent
// none for the wave itself (one `dash` puff at the cast, one `slash` spark per enemy hit): the 龙剑气 was invisible.
// The sim now emits `fx('chen3Wave', { x, y, fromX, fromY, dr, dc, id, skill })` every 0.12 s — its position, the
// previous point (the trail) and its direction (the crescent's heading) — and render/fx.js draws the 'qi' archetype.
// Against the headless fake PIXI (test/render/fakepixi.js), like fxflame.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';
let fake, FX;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
});
after(() => fake.restore());
const DT = 1 / 120;
const cam = presetCamera('normal', { width: 1600, height: 900 });
const unit = (id, x, y, o = {}) => ({ id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {}, ...o });
function makeFx(views, ts = 1) {
const P = fake.P;
const ctx = fakeViewCtx(P);
const map = new Map(views.map((v) => [v.id, v]));
const fx = new FX.FxSystem({
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
timeScale: () => ts, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null,
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
return { fx, map };
}
const run = (fx, seconds) => { for (let t = 0; t < seconds - 1e-9; t += DT) fx.update(DT); };
/** One sim event of the wave: its position, the previous point and the direction (`dc` cols, `dr` rows). */
const wave = (fx, id, x, y, fromX, fromY, dc = 1, dr = 0) => fx.simFx('chen3Wave', x, y, { x, y, fromX, fromY, dr, dc, id, skill: 'chen3:wave' });
/** Screen box of the drawn trail/head: min/max of every live particle position. */
const drawn = (fx) => {
const list = fx.parts.filter((p) => p.sp && p.sp.visible !== false);
if (!list.length) return null;
const xs = list.map((p) => p.sp.position.x), ys = list.map((p) => p.sp.position.y);
return { n: list.length, x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
};
describe('赤刃明霄陈 S3 龙剑气: 波自己会被画出来 (issue: 看不到)', () => {
test('its own archetype — a travelling blade, not the generic sparkle or a dust puff', () => {
assert.equal(FX.FX_KINDS.chen3Wave.a, 'qi');
assert.equal(FX.fxSpec('chen3Wave').a, 'qi');
assert.equal(FX.fxSpec('chen3Wave').pt, true, 'drawn at the wave (never anchored to the caster)');
});
test('one event draws a trail and a crescent head at the wave, not at her', () => {
const her = unit(1, 3, 10), waveAt = { x: 8, y: 10 };
const { fx } = makeFx([her]);
assert.equal(drawn(fx), null, 'nothing before the event');
wave(fx, her.id, waveAt.x, waveAt.y, 7.8, 10);
run(fx, 1 / 60);
const box = drawn(fx);
assert.ok(box && box.n >= 2, `the trail and the head are drawn (${box && box.n} pieces)`);
const at = cam.project(waveAt.x, waveAt.y, 0.35, { x: 0, y: 0, s: 0, depth: 0 });
const hers = cam.project(her.x, her.y, 0.5, { x: 0, y: 0, s: 0, depth: 0 });
const near = (v, t) => Math.abs(v - t) <= 90;
assert.ok(near(box.x1, at.x) || near(box.x0, at.x), `drawn around the wave (${Math.round(box.x0)}…${Math.round(box.x1)} vs ${Math.round(at.x)})`);
assert.ok(Math.abs(box.x1 - hers.x) > 60, 'not at her tile');
});
// streak() reuses the effect system's projection scratch: a head placed from it sat on the trail's start, one event
// (~0.2 tile) behind the wave
test('the crescent head sits on the wave itself (not on the trail\'s start) and points the way it travels', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
const heads = () => fx.parts.filter((p) => p.sp.visible !== false && p.sp.texture === fx.tex.slash);
wave(fx, her.id, 8, 10, 7.6, 10, 1, 0); // moving right (+col)
const right = heads();
assert.equal(right.length, 1, 'one crescent head');
const at = cam.project(8, 10, 0.35, { x: 0, y: 0, s: 0, depth: 0 });
assert.ok(Math.abs(right[0].x - at.x) < 0.5 && Math.abs(right[0].y - at.y) < 0.5,
`head at the wave (${right[0].x.toFixed(1)}, ${right[0].y.toFixed(1)}) vs (${at.x.toFixed(1)}, ${at.y.toFixed(1)})`);
assert.ok(Math.abs(right[0].sp.rotation - Math.PI / 2) < 0.35, `bulge to the right (rot ${right[0].sp.rotation.toFixed(2)})`);
run(fx, 1);
wave(fx, her.id, 8, 10, 8, 9.6, 0, 1); // moving up the screen (+row: the far side)
const up = heads();
assert.equal(up.length, 1);
assert.ok(Math.abs(up[0].sp.rotation) < 0.35, `bulge up (rot ${up[0].sp.rotation.toFixed(2)})`);
});
test('the events of a sweep join into one trail that goes the way it travels', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
let prev = 3;
const fronts = [];
for (let i = 0; i < 8; i++) { // 0.12 s apart at 1.5 tiles/s ⇒ 0.18 tile a step
const x = 3 + 0.18 * (i + 1);
wave(fx, her.id, x, 10, prev, 10);
prev = x;
run(fx, 0.12);
fronts.push(drawn(fx).x1);
}
const box = drawn(fx);
assert.ok(box && box.n >= 4, `the recent steps are still alive (${box && box.n} pieces)`);
// the drawn front walks forward with the wave (each event is drawn ahead of the last, the older ones still fading)
for (let i = 1; i < fronts.length; i++) {
assert.ok(fronts[i] > fronts[i - 1] - 1, `step ${i}: the front moved on (${Math.round(fronts[i - 1])} → ${Math.round(fronts[i])})`);
}
assert.ok(fronts[fronts.length - 1] - fronts[0] > 60, `the sweep crossed the field (${Math.round(fronts[0])} → ${Math.round(fronts[fronts.length - 1])} px)`);
});
test('a wave boxed in (no movement) and a junk extra never throw', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
wave(fx, her.id, 8, 10, 8, 10, 0, 0); // standing still: no direction
wave(fx, her.id, 8, 10, undefined, undefined);
fx.simFx('chen3Wave', 8, 10, { id: her.id }); // bare event
fx.simFx('chen3Wave', NaN, NaN, { id: her.id, dr: 1, dc: 0 });
run(fx, 0.2);
assert.ok(fx.counts.particles > 0, 'still drawn');
});
test('the whole thing is gone once the events stop', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
wave(fx, her.id, 8, 10, 7.8, 10);
run(fx, 0.05);
assert.ok(drawn(fx).n > 0);
run(fx, 1); // every trail/head particle expires (life 0.34 s max)
assert.equal(drawn(fx), null, 'nothing is left hanging on the field');
});
});
+60
View File
@@ -0,0 +1,60 @@
// test/render/fxchill.test.js — the 'chill' fx of coldWind (render/fx/simfx.js + zones.js snowfall), PR #386 by
// @leiming2333: 谢拉格's 盟约寒风 blows a blizzard UP the field (the official direction, the owner's confirmation of
// 2026-10-08) with a stronger cold flash; an operator's own coldWind (灵知 S3, 麦哲伦 S1's pulse every 3 s — emitted with
// the unit's `id`) keeps the light downward flurry and the softer flash. Against the headless fake PIXI
// (test/render/fakepixi.js), like fxchen3wave.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';
let fake, FX;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
});
after(() => fake.restore());
const W = 1600, H = 900;
const cam = presetCamera('normal', { width: W, height: H });
function makeFx(quality = 'high') {
const P = fake.P;
const ctx = fakeViewCtx(P);
const unit = { id: 7, x: 5, y: 10, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {} };
return new FX.FxSystem({
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality, damageNumbers: true },
timeScale: () => 1, loadLevel: () => 0, subProfOf: () => null, view: (id) => (id === unit.id ? unit : null),
screenSize: () => ({ width: W, height: H }), fieldTop: () => 120,
});
}
const live = (fx) => fx.parts.filter((p) => p.sp && p.sp.visible !== false);
describe('coldWind: 谢拉格\'s 盟约寒风 blows up the field, an operator\'s own cold keeps the light flurry', () => {
test('the bond\'s gust (the device emits no anchor unit): 48 flakes and 10 streaks rising from the bottom, the stronger flash', () => {
const fx = makeFx();
// server/sim/content/devices.js startColdWind: the field centre, the player, the cold stacks, the cold duration
fx.simFx('coldWind', 8, 9, { x: 8, y: 9, playerId: 'p1', n: 3, duration: 5 });
const ps = live(fx);
assert.equal(ps.length, 58, '48 flakes + 10 streaks at quality high');
assert.ok(ps.every((p) => p.vy < 0), 'every particle rises (screen y grows downward)');
assert.ok(ps.every((p) => p.vx > 0), 'on a crosswind');
assert.ok(ps.every((p) => p.y >= H * 0.55 - 1e-9), 'lifted off the lower part of the screen (some start below it)');
assert.equal(ps.filter((p) => p.sp.texture === fx.tex.streak).length, 10, 'gust streaks');
assert.deepEqual([fx.tintA, fx.tintDur], [0.45, 1]);
const low = makeFx('low');
low.simFx('coldWind', 8, 9, { x: 8, y: 9, playerId: 'p1', n: 3, duration: 5 });
assert.equal(live(low).length, 26, 'quality low: 22 flakes + 4 streaks');
});
test('an operator\'s own coldWind (灵知 S3, 麦哲伦\'s pulse: `id` = the unit): the light flurry drifting down, the softer flash', () => {
const fx = makeFx();
fx.simFx('coldWind', 5, 10, { x: 5, y: 10, id: 7, n: 2 });
const ps = live(fx);
assert.equal(ps.length, 26);
assert.ok(ps.every((p) => p.vy > 0), 'falling as before');
assert.equal(ps.filter((p) => p.sp.texture === fx.tex.streak).length, 0, 'no gust streaks');
assert.deepEqual([fx.tintA, fx.tintDur], [0.35, 0.8]);
});
});
+95
View File
@@ -0,0 +1,95 @@
// PR #434: real pixi-spine's final pose/mixing and shared ring textures, beyond the fake-PIXI unit tests.
// RENDER_E2E=1 node --test test/render/held-pose.browser.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const skip = process.env.RENDER_E2E !== '1' || !existsSync(CHROME);
test('real Spine held death matches forced updates; revival and late font rasterisation remain live', { skip, timeout: 90000 }, async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
let browser;
try {
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => { if (m.type() === 'error') console.error(m.text()); });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&t=18&paused=1&panel=0`);
await page.waitForFunction(() => window.__demo?.ready && [...window.__demo.view.debug.views.values()].some(v => v.actor?.dieClip()), { timeout: 30000 });
const result = await page.evaluate(async () => {
const { SpineActor } = await import('/js/render/spine.js');
const T = await import('/js/render/textures.js');
const { app, views } = window.__demo.view.debug;
app.stop();
const source = [...views.values()].find(v => v.actor?.dieClip()).actor;
const a = new SpineActor(source.spine.spineData, source.entry);
const b = new SpineActor(source.spine.spineData, source.entry);
const renderer = app.renderer;
const hash = obj => {
const pixels = renderer.extract.pixels(obj);
let value = 2166136261, ink = 0;
for (let i = 0; i < pixels.length; i++) { value = Math.imul(value ^ pixels[i], 16777619); if (i % 4 === 3 && pixels[i]) ink++; }
return { value: value >>> 0, bytes: pixels.length, ink };
};
a.update(0.1);b.update(0.1);a.die();b.die();
const firstHeld = a.poseHeld();
// Exercise a real mix that outlasts the outgoing death clip, not merely the unposed first frame.
for (const actor of [a, b]) {
const track = actor.spine.state.tracks[0];
track.mixDuration = track.animationEnd - track.animationStart + 0.25;
}
let skipped = 0, equal = true, ink = 0, endMixFrames = 0, mixedHeld = false;
for (let i = 0; i < 600; i++) {
const track = a.spine.state.tracks[0];
if (track.mixingFrom && track.trackTime >= track.animationEnd - track.animationStart) {
endMixFrames++;mixedHeld ||= a.poseHeld();
}
if (a.poseHeld()) { a.clock += 1 / 60;skipped++; } else a.update(1 / 60);
b.update(1 / 60);
if (i % 15 === 0) {
const x = hash(a.spine), y = hash(b.spine);
equal &&= x.value === y.value && x.bytes === y.bytes;
ink = Math.max(ink, x.ink);
}
}
const finalA = hash(a.spine), finalB = hash(b.spine);
const finalVisibleEqual = a.poseHeld() && finalA.ink > 0 && finalA.value === finalB.value && finalA.bytes === finalB.bytes;
const clocksEqual = Math.abs(a.clock - b.clock) < 1e-9;
a.revive();a.deploy();a.update(1 / 60);
const revived = !a.poseHeld() && !a.dead;
a.spine.destroy({ children: true });b.spine.destroy({ children: true });
// A label created before Bender loads must keep its texture identity when real glyphs arrive.
await document.fonts.ready;
document.querySelector('link[href="/fonts/fonts.css"]').remove();
await Promise.resolve();
const benderAbsent = ![...document.fonts].some(f => f.family.replace(/["']/g, '') === 'Bender');
const label = T.downLabel('470', '#ffffff', 1);
const sprite = new PIXI.Sprite(label), before = hash(sprite);
const font = new FontFace('Bender', 'url(/fonts/bender-regular.woff2)', { weight: '400' });
await font.load();document.fonts.add(font);T.refreshDownLabels();
const after = hash(sprite);
const sameTexture = T.downLabel('470', '#ffffff', 1) === label;
const separateResolution = T.downLabel('470', '#ffffff', 2) !== label;
sprite.destroy();
return { firstHeld, skipped, equal, ink, finalVisibleEqual, clocksEqual, revived, sameTexture, separateResolution, endMixFrames, mixedHeld,
benderAbsent, fontChanged: before.value !== after.value || before.bytes !== after.bytes, fontInk: after.ink };
});
assert.equal(result.firstHeld, false, 'initial pose / mix must run');
assert.ok(result.skipped > 100, JSON.stringify(result));
assert.ok(result.endMixFrames > 0, 'real runtime mix remains after the death clip ends');
assert.equal(result.mixedHeld, false, 'mixed final poses still advance');
assert.equal(result.equal, true, 'held final frame is pixel-identical to continual updates');
assert.ok(result.ink > 0, 'the comparison renders actual art');
assert.equal(result.finalVisibleEqual, true, 'the held final pose itself is visible and identical');
assert.equal(result.clocksEqual, true);
assert.equal(result.revived, true);
assert.equal(result.sameTexture, true);
assert.equal(result.separateResolution, true);
assert.equal(result.benderAbsent, true, 'CSS font faces are absent before fallback rasterisation');
assert.equal(result.fontChanged, true, 'late Bender glyphs replace the fallback');
assert.ok(result.fontInk > 0);
assert.deepEqual(errors, []);
} catch (error) { console.error(error);throw error; } finally { await browser?.close();await srv.close(); }
});
+209
View File
@@ -0,0 +1,209 @@
// test/render/held-pose.test.js — a model whose pose cannot change is neither re-posed nor redrawn (render/spine.js
// SpineActor.poseHeld, render/units.js UnitView.update / _updateImpostor): a knocked-out operator lying in the held end
// of its Die clip, a frozen model. On a slow device in a late round a dozen operators lie so, and each was re-posed and
// redrawn into the impostor atlas every couple of frames for the same pixels. The actor's clock still runs; anything
// that can move the pose again (a new clip, a revive, a wind-up, a form's closing clip, clipping masks switched, a zoom
// past the impostor's rescale threshold, a tint the slots do not show yet) brings the updates back. 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';
let fake, UnitView, DOWN_STATE, SpineActor;
before(async () => {
fake = installFakePixi();
({ UnitView, DOWN_STATE } = await import('../../public/js/render/units.js'));
({ SpineActor } = await import('../../public/js/render/spine.js'));
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const ENTRY = { skel: '/s/op.skel', atlas: '/s/op.atlas', textures: ['/s/op.png'], anims: { idle: 'Idle', die: 'Die' }, animations: { Idle: 1, Die: 0.8 } };
const DATA = { animations: [{ name: 'Idle' }, { name: 'Die' }] };
/** Play the current track 0 clip to its end (the fake skeleton does not advance clip time itself). */
const finish = (a) => { const e = a.spine.state.tracks[0]; e.trackTime = e.animationEnd - e.animationStart; };
describe('SpineActor.poseHeld', () => {
test('a dead model on the finished end of its Die clip is held; a clip still playing, looping or mixing is not', () => {
const a = new SpineActor(DATA, ENTRY);
assert.equal(a.poseHeld(), false, 'idle loop');
a.die();
assert.equal(a.current, 'Die');
assert.equal(a.poseHeld(), false, 'falling');
finish(a);
assert.equal(a.poseHeld(), false, 'not before an update has posed the end of the clip');
a.update(0);
assert.equal(a.poseHeld(), true, 'lying on the last frame');
const e = a.spine.state.tracks[0];
e.mixingFrom = {};
assert.equal(a.poseHeld(), false, 'still mixing out of the previous clip');
e.mixingFrom = null;
a.spine.state.tracks[1] = {};
assert.equal(a.poseHeld(), false, 'another track plays');
a.spine.state.tracks.length = 1;
e.loop = true;
assert.equal(a.poseHeld(), false, 'a looping clip moves on');
e.loop = false;
assert.equal(a.poseHeld(), true);
a.revive();
assert.equal(a.poseHeld(), false, 'standing again');
});
test('a skeleton without a Die clip holds its idle at timeScale 0 at once; a frozen model is held', () => {
const a = new SpineActor({ animations: [{ name: 'Idle' }] }, { ...ENTRY, anims: { idle: 'Idle' }, animations: { Idle: 1 } });
a.die();
assert.equal(a.spine.state.tracks[0].timeScale, 0);
assert.equal(a.poseHeld(), false, 'not before an update has posed it');
a.update(0);
assert.equal(a.poseHeld(), true);
const b = new SpineActor(DATA, ENTRY);
b.frozen = true;
b.update(0);
assert.equal(b.poseHeld(), true, 'frozen: update never moves the skeleton');
});
test('a tint the slots do not show yet waits for one update (pixi-spine applies tint in update)', () => {
const a = new SpineActor(DATA, ENTRY);
a.die();
finish(a);
a.update(0);
assert.equal(a.poseHeld(), true);
a.spine.tint = 0xb4b4b4; // the knocked-out grey, set after the frame's update
assert.equal(a.poseHeld(), false, 'the new tint is not on the slots yet');
a.update(0);
assert.equal(a.poseHeld(), true, 'shown: held again');
});
test('never while something pending acts on the clock: a form\'s closing clip, a wind-up', () => {
const a = new SpineActor(DATA, ENTRY);
a.die();
finish(a);
a.update(0);
a.endClip = 'Die'; a.endAt = 5;
assert.equal(a.poseHeld(), false, 'closing clip pending');
a.endClip = null;
a.windUntil = 3;
assert.equal(a.poseHeld(), false, 'wind-up pending');
a.windUntil = null;
a.frozen = true;
a.endClip = 'X';
assert.equal(a.poseHeld(), false, 'frozen, but a closing clip is due');
});
});
describe('UnitView: a held pose is neither re-posed nor redrawn', () => {
/** An operator with a Spine model in a context with an impostor atlas (interval `iv`) and counters. */
async function operator(iv) {
let frame = 0;
let draws = 0;
let clip = true;
const atlas = {
alloc: (w, h, o) => ({ w, h, tex: new fake.P.Texture(), clip: !!o?.clip }),
free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() { draws++; },
};
const ctx = fakeViewCtx(fake.P, {
cam, frameNo: () => frame, impostors: atlas, impostorInterval: () => iv, clipAllowed: () => clip,
renderer: { resolution: 1, render() {} },
assets: { picture: () => null, image: async () => null, spineEntry: () => ENTRY, spine: { acquire: async () => DATA, release() {} } },
});
const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT' });
await tick(); await tick();
assert.ok(v.spineReady, 'spine ready');
let updates = 0;
const upd = v.actor.update.bind(v.actor);
v.actor.update = (dt) => { updates++; return upd(dt); };
const step = (n = 1) => { for (let i = 0; i < n; i++) { v.update(1 / 60, cam(), frame / 60); frame++; } };
const counts = () => ({ updates, draws });
return { v, step, counts, setClip: (on) => { clip = on; } };
}
const knockOut = (v) => { v.setDown([1, 60, 60, DOWN_STATE.COUNTING], 1); };
test('impostor mode: lying in the held Die pose, no re-pose and no atlas redraw; the clock still runs', async () => {
const { v, step, counts } = await operator(2);
step(4);
assert.ok(v.imp, 'impostor mode');
knockOut(v);
step(4);
const falling = counts();
assert.ok(falling.updates >= 2 && falling.draws >= 2, 'the fall is animated');
finish(v.actor);
step(1); // the frame the clip ends on may still draw (dirty / turn)
const c0 = counts(), clock0 = v.actor.clock;
step(60);
assert.deepEqual(counts(), c0, 'one second down: nothing re-posed, nothing redrawn');
assert.ok(Math.abs(v.actor.clock - clock0 - 1) < 1e-6, `the actor clock ran on (${v.actor.clock - clock0})`);
assert.ok(v._downRing.root.visible, 'under its redeploy ring');
});
test('a zoom past the rescale threshold, clipping masks switched or a redeploy bring the redraws back', async () => {
const { v, step, counts, setClip } = await operator(2);
step(4);
knockOut(v);
step(2);
finish(v.actor);
step(2);
let c = counts();
v.modelK *= 1.3; // the impostor's scale is off by > 12 %
step(1);
assert.equal(counts().draws, c.draws + 1, 'redrawn once at the new scale');
step(10);
assert.equal(counts().draws, c.draws + 1, 'then held again');
v.actor.clipped = true; // a skeleton with clipping masks, the masks switched off
c = counts();
setClip(false);
step(1);
assert.equal(counts().draws, c.draws + 1, 'masks off: redrawn');
assert.equal(counts().updates, c.updates + 1, '… and re-posed (the eyelid fallback runs in the update)');
step(10);
assert.equal(counts().draws, c.draws + 1, 'then held again');
c = counts();
v.onDeploy(); // b.ev 'deploy'
v.setDown(null, 70);
step(6);
assert.ok(counts().updates >= c.updates + 3, 'standing up: animated again');
});
test('direct mode (no impostor): no re-pose while held, the clock still runs', async () => {
const { v, step, counts } = await operator(0);
step(2);
assert.ok(!v.imp, 'drawn directly');
knockOut(v);
step(3);
finish(v.actor);
step(1);
const c0 = counts(), clock0 = v.actor.clock;
step(30);
assert.equal(counts().updates, c0.updates, 'no update while held');
assert.ok(Math.abs(v.actor.clock - clock0 - 0.5) < 1e-6, 'clock advanced by the frames\' time');
});
test('direct mode: a hit flash on a lying model is updated until its tint is steady on the slots', async () => {
const { v, step, counts } = await operator(0);
step(2);
knockOut(v);
step(3);
finish(v.actor);
step(2);
const c0 = counts();
step(10);
assert.equal(counts().updates, c0.updates, 'held');
v.flash = 1; // a hit: the tint mixes toward red and fades back over ~10 frames
step(20);
const c1 = counts();
assert.ok(c1.updates > c0.updates, `updated while the tint changes (${c1.updates - c0.updates})`);
step(10);
assert.equal(counts().updates, c1.updates, 'held again once the tint is steady');
});
test('a living model in the same context keeps its usual refreshes', async () => {
const { step, counts } = await operator(2);
step(2);
const c0 = counts();
step(20);
const c1 = counts();
assert.ok(c1.updates - c0.updates >= 9 && c1.draws - c0.draws >= 9, `every 2nd frame (${c1.updates - c0.updates} updates, ${c1.draws - c0.draws} draws in 20)`);
});
});
+378
View File
@@ -0,0 +1,378 @@
// 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 });
test('Swire purse snapshots include zero and the selected skill cap; death removes the readout', () => {
for (const skillIndex of [0, 1, 2]) {
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 10, col: 4, skillIndex }] });
h.b.start(); const u = h.unit(1); const cap = u.skill.bb.sp;
assert.deepEqual(h.b.snapshot().coins, [[u.id, u.mem.coins, cap]]);
u.mem.coins = 0; assert.deepEqual(h.b.snapshot().coins, [[u.id, 0, cap]]);
h.b.retreat(u, { reason: 'test' }); assert.equal(h.b.snapshot().coins, undefined);
}
});
test('coin balance is snapshot-owned: preserves zero, rejects malformed entries and clears when absent', () => {
const first = normalizeSnapshot({ t: 0, units: [tuple(1)], coins: [[1, 0, 10], [2, 3, 10], [1, -1, 10], [1, 3.5, 10]] });
assert.deepEqual([...first.coins], [[1, [0, 10]]]);
const b = new SnapshotBuffer();
b.push({ t: 0, units: [tuple(1)], coins: [[1, 3, 10]] });
b.push({ t: 1, units: [tuple(1)] });
assert.deepEqual(b.sample(0).get(1).coins, [3, 10]);
assert.equal(b.sample(1).get(1).coins, null);
});
test('the coin HUD shows balance/cap and does not leave a stale label when its snapshot entry disappears', () => {
const v = view();
try {
v.sync(sample({ coins: [0, 10] }), 0); v.update(0.02, cam(), 0);
assert.equal(v.coinHud?.text.text, '0/10'); assert.equal(v.coinHud.root.visible, true);
v.sync(sample(), 1); v.update(0.02, cam(), 1); assert.equal(v.coinHud.root.visible, false);
} finally { v.destroy(); }
});
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();
});
});
+343
View File
@@ -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, []);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, UnitView;
before(async () => { fake = installFakePixi(); ({ UnitView } = await import('../../public/js/render/units.js')); });
after(() => fake.restore());
function rig(rate = 1) {
let resolve;
const entry = { skel: '/op.skel', atlas: '/op.atlas', textures: ['/op.png'], anims: { idle: 'Idle', deploy: 'Start', die: 'Die', attack: { loop: 'Attack' } }, animations: { Idle: 1, Start: 1, Die: 1, Attack: 1 } };
const assets = { spineEntry: () => entry, hasBack: () => false, spine: { acquire: () => new Promise((r) => { resolve = r; }), release() {} } };
const cam = presetCamera('prep', { width: 1280, height: 720 });
const ctx = fakeViewCtx(fake.P, { assets, cam: () => cam }); ctx.animRate = () => rate;
const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_op', x: 5, y: 12, maxHp: 1000, dir: 'RIGHT' }, { prep: true });
return { v, advance: (dt) => v.update(dt, cam, dt), ready: async () => { await new Promise((r) => setImmediate(r)); resolve({ animations: [{ name: 'Idle' }, { name: 'Start' }, { name: 'Die' }, { name: 'Attack' }] }); await new Promise((r) => setImmediate(r)); } };
}
test('late model continues the current deployment at elapsed game time', async () => {
const h = rig(2); h.v.onDeploy(); h.advance(0.1); await h.ready();
assert.equal(h.v.actor.mode, 'deploy'); assert.ok(Math.abs(h.v.actor.deployElapsed() - 0.2) < 1e-6); h.v.destroy();
});
test('model arriving after Start duration does not replay the old entrance', async () => {
const h = rig(); h.v.onDeploy(); h.advance(2); await h.ready();
assert.equal(h.v.actor.mode, 'base'); assert.equal(h.v._pendingDeployElapsed, null); h.v.destroy();
});
test('an attack or death supersedes the pending entrance', async () => {
for (const action of ['attack', 'die']) {
const h = rig(); h.v.onDeploy();
if (action === 'attack') h.v.onAttack(null, 1, 'none'); else h.v.die();
await h.ready(); assert.notEqual(h.v.actor.mode, 'deploy'); assert.equal(h.v._pendingDeployElapsed, null); h.v.destroy();
}
});
test('a destroyed view never consumes a late model as a fresh entrance', async () => {
const h = rig(); h.v.onDeploy(); h.v.destroy(); await h.ready(); assert.equal(h.v.actor, null);
});
+61 -20
View File
@@ -9,11 +9,13 @@
// #4.1 picking by tile ("地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行"): a press anywhere on a tile — board
// (adjacent rows and columns, high ground), bench, temporary bench — selects the unit on it and an empty tile
// nothing (a press on a unit's head, drawn over the tile behind it, is a press on that tile), in prep and in battle
// (allies by their tile, enemies by their ground position or drawn body); a dragged unit is held under the pointer — its drawn
// feet DRAG_HOLD_TILES below it, the model around it — for a mouse and a finger (phone, 844×390); the drop, its
// tileHover and the direction wheel are the tile under the pointer; equipment dropped on a tile equips the unit
// on it (not the one in front whose head is drawn there); on a phone a release on a bench slot lands there and one
// over the shop bar goes back.
// (allies by their tile, enemies by their ground position or drawn body); a dragged unit stands on the tile under
// the pointer while that is a legal target — wherever the pointer is on the tile, as in the official deploy drag
// (the owner's recording of 2026-10-09; PR #403's report) — and is held under the pointer elsewhere (over the
// shop bar: its drawn feet DRAG_HOLD_TILES below it, the model around it), for a mouse and a finger (phone,
// 844×390); the drop, its tileHover and the direction wheel are the tile under the pointer; equipment dropped on
// a tile equips the unit on it (not the one in front whose head is drawn there); on a phone a release on a bench
// slot lands there and one over the shop bar goes back.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/models.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/models-*.png.
@@ -315,13 +317,23 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
assert.deepEqual(problems, []);
});
/** The dragged view against the pointer (client px): its drawn feet, px per tile, and the Spine model's drawn bounds. */
/** The dragged view against the pointer (client px): its drawn feet, px per tile, and the Spine model's drawn bounds;
* its world point (wx = col, wy = row, wz) and the height of the board tile under it (hz). */
const ghostAt = (page, uid) => page.evaluate((u) => {
const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), v = R.debug.views.get('p:' + u);
const b = v.actor && v.spineReady && !v.imp ? v.actor.spine.getBounds() : null;
return { lift: v.lift, x: cv.left + v.screen.x, y: cv.top + v.screen.y, s: v.screen.s, model: b && { x0: cv.left + b.x, y0: cv.top + b.y, x1: cv.left + b.x + b.width, y1: cv.top + b.y + b.height } };
return { lift: v.lift, x: cv.left + v.screen.x, y: cv.top + v.screen.y, s: v.screen.s, model: b && { x0: cv.left + b.x, y0: cv.top + b.y, x1: cv.left + b.x + b.width, y1: cv.top + b.y + b.height },
wx: v.x, wy: v.y, wz: v.z, hz: R.debug.tiles.heightAt(Math.round(v.y), Math.round(v.x)) };
}, uid);
/** The model is under the pointer: its feet DRAG_HOLD_TILES (0.45) below it, the pointer inside its drawn body. */
/** Over a legal target the model stands on the pointer's tile (the official deploy drag): its world point is that
* tile — the tile's top on the board, the bench pad on the bench — and it is lifted. */
const assertStands = (g, [row, col], label) => {
assert.ok(g.lift > 0, `${label}: lifted`);
assert.ok(Math.abs(g.wx - col) < 1e-6 && Math.abs(g.wy - row) < 1e-6, `${label}: stands on (${row},${col}) (at ${g.wx.toFixed(3)},${g.wy.toFixed(3)})`);
if (row !== 7) assert.ok(Math.abs(g.wz - g.hz) < 1e-6, `${label}: on the tile's top (z ${g.wz} vs ${g.hz})`);
else assert.ok(g.wz > 0, `${label}: on the bench pad (z ${g.wz})`);
};
/** Elsewhere the model is under the pointer: its feet DRAG_HOLD_TILES (0.45) below it, the pointer inside its drawn body. */
const assertHeld = (g, ptr, label) => {
assert.ok(g.lift > 0, `${label}: lifted`);
assert.ok(Math.abs(g.x - ptr.x) < 1.5 && Math.abs(g.y - ptr.y - 0.45 * g.s) < 1.5, `${label}: feet 0.45 tile below the pointer (feet ${g.x.toFixed(1)},${g.y.toFixed(1)}, pointer ${ptr.x.toFixed(1)},${ptr.y.toFixed(1)}, s ${g.s.toFixed(1)})`);
@@ -331,7 +343,7 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
}
};
test('#4.1 drag (mouse): the unit is held under the pointer; target, tileHover and the direction wheel are the pointer\'s tile', async () => {
test('#4.1 drag (mouse): the unit stands on the pointer\'s tile over a legal target, held under the pointer elsewhere; target, tileHover and the direction wheel are the pointer\'s tile', async () => {
const { page, problems } = await open('phase=PREP');
const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
await page.evaluate(() => { window.__hover = []; globalThis.__SP_VIEW__.raw.on('tileHover', (t) => window.__hover.push(t && [t.row, t.col])); });
@@ -340,17 +352,46 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
await page.mouse.move(from.x, from.y); await page.mouse.down();
for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
await sleep(150);
assertHeld(await ghostAt(page, hand.uid), to, 'mouse');
const first = await ghostAt(page, hand.uid);
assertStands(first, [9, 4], 'mouse');
assert.deepEqual(await page.evaluate(() => window.__hover.at(-1)), [9, 4], 'tileHover = the tile under the pointer');
await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') });
// the whole way: the model stays under the pointer (sampled at every step of a second drag leg)
// the pointer moving about inside the tile leaves the model where it is (the official recording: the finger moves,
// the model stays on its tile)
for (const [u, v] of [[-0.6, -0.3], [0.6, -0.3], [-0.5, 0.7], [0, 0]]) { // (the nearest strip is hidden by row 8: SPOTS)
const pt = await tileSpot(page, 9, 4, u, v);
await page.mouse.move(pt.x, pt.y);
await sleep(40);
const g = await ghostAt(page, hand.uid);
assertStands(g, [9, 4], `mouse at (${u}, ${v}) of the tile`);
assert.ok(Math.abs(g.x - first.x) < 0.5 && Math.abs(g.y - first.y) < 0.5, `mouse at (${u}, ${v}): the model did not move on screen`);
}
// the whole way: on a legal tile the model stands on the pointer's tile, elsewhere it is under the pointer (sampled
// at every step of a second drag leg; the legal tiles are the view's own 'legal' highlight)
const legal = new Set(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.tiles.highlights.get('legal').tiles.map(([r, c]) => `${r},${c}`)));
const leg = await tileSpot(page, 10, 5);
const seen = { stands: 0, held: 0 };
for (let i = 1; i <= 6; i++) {
const pt = { x: to.x + ((leg.x - to.x) * i) / 6, y: to.y + ((leg.y - to.y) * i) / 6 };
await page.mouse.move(pt.x, pt.y);
await sleep(40);
assertHeld(await ghostAt(page, hand.uid), pt, `mouse step ${i}`);
const tile = await page.evaluate(() => window.__hover.at(-1));
if (legal.has(`${tile}`)) { seen.stands++; assertStands(await ghostAt(page, hand.uid), tile, `mouse step ${i}`); }
else { seen.held++; assertHeld(await ghostAt(page, hand.uid), pt, `mouse step ${i}`); }
}
assert.ok(seen.stands > 0, `the leg crosses legal tiles (${JSON.stringify(seen)})`);
// over the shop bar (DOM over the canvas: no target) the model is held under the pointer, so the player still sees
// what they carry
const bar = await page.evaluate(() => { const el = [...document.querySelectorAll('.scard')][2], r = el && el.getBoundingClientRect(); return r && { x: r.left + r.width / 2, y: r.top + r.height * 0.3 }; });
assert.ok(bar, 'premise: a shop card');
assert.ok(await page.evaluate((x, y) => !!document.elementFromPoint(x, y)?.closest('.shopbar'), bar.x, bar.y), 'premise: the point is on the shop bar');
await page.mouse.move(bar.x, bar.y, { steps: 6 });
await sleep(60);
assert.equal(await page.evaluate(() => window.__hover.at(-1)), null, 'no tile under the shop bar');
assertHeld(await ghostAt(page, hand.uid), bar, 'mouse over the shop bar');
await page.mouse.move(to.x, to.y, { steps: 6 });
await sleep(60);
assertStands(await ghostAt(page, hand.uid), [9, 4], 'mouse back on (9,4)');
await page.mouse.move(to.x, to.y);
await sleep(60);
await page.mouse.up();
@@ -402,10 +443,10 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
assert.deepEqual(problems, []);
});
// a phone (touch, landscape 844×390): the finger holds the model like the mouse (no lift above the finger); on the
// bench (the lowest canvas row, the shop bar right under it) a release on the slot lands there, one on the shop bar
// goes back
test('#4.1 touch (phone): held under the finger, dropped on the tile under it; a release over the shop bar goes back', async () => {
// a phone (touch, landscape 844×390): the finger drags the model like the mouse — standing on the finger's tile over a
// legal target, under the finger elsewhere (no lift above the finger); on the bench (the lowest canvas row, the shop
// bar right under it) a release on the slot lands there, one on the shop bar goes back
test('#4.1 touch (phone): stands on the finger\'s tile (under the finger over the shop bar), dropped on the tile under it; a release over the shop bar goes back', async () => {
const page = await browser.newPage();
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
@@ -436,10 +477,10 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
await page.keyboard.press('Escape');
await sleep(200);
}
// a bench operator onto the board: held under the finger, the wheel on the finger's tile
// a bench operator onto the board: standing on the finger's tile, the wheel on that tile
const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
const to = await tileSpot(page, 9, 4, -0.3, 0.3);
await touchDrag(await tileSpot(page, 7, hand.idx), to, { hold: async () => assertHeld(await ghostAt(page, hand.uid), to, 'touch') });
await touchDrag(await tileSpot(page, 7, hand.idx), to, { hold: async () => assertStands(await ghostAt(page, hand.uid), [9, 4], 'touch') });
await page.waitForSelector('.fwheel__dia', { timeout: 3000 });
const w = await page.evaluate(() => { const r = document.querySelector('.fwheel__dia').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const c = await tileSpot(page, 9, 4);
@@ -457,9 +498,9 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
return null;
}, slot);
assert.ok(bar, 'premise: the shop bar covers the canvas right below the bench slot');
await touchDrag(await tileSpot(page, unit.row, unit.col), bar);
await touchDrag(await tileSpot(page, unit.row, unit.col), bar, { hold: async () => assertHeld(await ghostAt(page, unit.uid), bar, 'touch over the shop bar') });
assert.ok(await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.some((b) => b.uid === u), unit.uid), 'released over the shop bar: back on the board');
await touchDrag(await tileSpot(page, unit.row, unit.col), slot);
await touchDrag(await tileSpot(page, unit.row, unit.col), slot, { hold: async () => assertStands(await ghostAt(page, unit.uid), [7, 5], 'touch on the free bench slot') });
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), unit.uid, 'released on the bench slot: it is there');
await page.screenshot({ path: path.join(OUT, 'models-touch-phone.png') });
await page.close();
+149
View File
@@ -0,0 +1,149 @@
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME);
const art = existsSync(new URL('../../public/assets/local/map/autochess/TX_autochessi_D.png', import.meta.url));
describe('renderer recovery after transient resource failures', { skip: !enabled }, () => {
let srv, browser, serverRun = false;
before(async () => {
const { startServer } = await import('../../server/index.js');
const { Match } = await import('../../server/match/Match.js');
class RecoveryMatch extends Match { constructor(opts) { super({ ...opts, clientCombat: !serverRun }); } }
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, MatchClass: RecoveryMatch, seedFn: () => 123 });
const P = (await import('puppeteer-core')).default;
browser = await P.launch({ executablePath: CHROME, headless: true,
args: ['--no-first-run', '--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] });
mkdirSync(new URL('../e2e/out/', import.meta.url), { recursive: true });
});
after(async () => { await browser?.close(); await srv?.close(); });
for (const streamed of [false, true]) test(`a live ${streamed ? 'server' : 'local'} battle survives renderer replacement with current enemy metadata`, { timeout: 45000 }, async () => {
serverRun = streamed;
const context = await browser.createBrowserContext(), page = await context.newPage(); let failing = true;
try {
await page.setRequestInterception(true);
page.on('request', req => {
if (failing && new URL(req.url()).pathname === '/vendor/pixi.min.js') void req.abort('failed'); else void req.continue();
});
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery'); sessionStorage.setItem('sp.entered', '1'); });
await page.goto(`http://127.0.0.1:${srv.port}/?board=2d`);
await page.waitForFunction(() => window.__SP__?.store.get().connection.status === 'online');
const send = (type, args = {}) => page.evaluate(async (t, a) => (await import('/js/net.js')).net.request(t, a), type, args);
await send('room.create', { mode: 'solo', difficulty: 'FUNNY' }); await send('room.start');
await page.waitForFunction(() => window.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
await send('g.infoReady', { setupRevision: 0 });
await page.waitForFunction(() => window.__SP__.store.get().match.public?.phase === 'BAND_DRAFT');
await send('g.band', { bandId: 'band_bldsk' });
await page.waitForFunction(() => window.__SP__.store.get().match.public?.phase === 'PREP');
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'fallback');
await send('g.ready', { ready: true });
await page.waitForSelector('.ff-unit.is-enemy:not(.ff-pen__enemy)');
const code = await page.evaluate(() => window.__SP__.store.get().room.code);
const match = srv.lobby.rooms.get(code).match;
const serverBattle = streamed ? match.fields[0].battle : null;
const serverTime = serverBattle?.time;
const enemies = streamed ? match.fields[0].battle.enemies.filter(e => e.alive).map(e => [e.id, e.defId])
: await page.evaluate(async () => {
const runner = (await import('/js/battle/runner.js')).battleRunner;
const entry = [...runner._entries.values()].find(e => e.battle && !e.done);
window.__originalBattle = entry.battle; window.__originalTime = entry.battle.time;
return entry.battle.enemies.filter(e => e.alive).map(e => [e.id, e.defId]);
});
assert.ok(enemies.length > 0, 'enemies spawned after the original field metadata');
failing = false; await page.evaluate(() => window.dispatchEvent(new Event('online')));
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'engine', { timeout: 15000 });
await page.waitForFunction(([id, defId]) => {
const v = window.__SP_VIEW__.raw;
return v.stats().mode === 'battle' && v.debug.views.get(id)?.info.defId === defId;
}, { timeout: 7000 }, enemies[0]);
assert.equal(srv.lobby.rooms.get(code).match, match, 'renderer recovery never restarts the match');
if (streamed) {
assert.equal(match.fields[0].battle, serverBattle); assert.ok(serverBattle.time >= serverTime);
} else {
assert.equal(await page.evaluate(async () => {
const runner = (await import('/js/battle/runner.js')).battleRunner;
return [...runner._entries.values()].some(e => e.battle === window.__originalBattle && e.battle.time >= window.__originalTime);
}), true, 'the same local simulation continues from its current clock');
}
await send('g.leave');
} finally { await context.close(); }
});
for (const forced of [false, true]) test(`field hook retries a failed Pixi download; forced fallback=${forced}`, async () => {
const page = await browser.newPage(); let pixiRequests = 0, failing = true;
try {
await page.setRequestInterception(true);
page.on('request', req => {
const pixi = new URL(req.url()).pathname === '/vendor/pixi.min.js';
if (pixi) pixiRequests++;
if (pixi && failing) void req.abort('failed'); else void req.continue();
});
await page.goto(`http://127.0.0.1:${srv.port}/?${forced ? 'render=fallback&' : ''}board=2d`);
await page.evaluate(async () => {
const { render, h } = await import('/vendor/preact.module.js');
const { useRef, useEffect } = await import('/vendor/hooks.module.js');
const { useFieldView } = await import('/js/ui/fieldHost.js');
const { data } = await import('/js/data.js');
await data.loadAll('stages', 'chess', 'assets', 'local');
function Harness() {
const host = useRef(null), state = useFieldView(host);
useEffect(() => {
if (!state.view) return;
state.view.setStage(data.lookup('stages', 'act2autochess_m01'));
state.view.setPrep({ playerId: 'p', board: [], hand: [] });
}, [state.view]);
return h('div', { ref: host, id: 'recovery-host', style: 'width:100vw;height:100vh' });
}
render(h(Harness), document.getElementById('app'));
window.__unmount = () => render(null, document.getElementById('app'));
});
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'fallback');
const before = pixiRequests; failing = false;
await page.evaluate(() => {
window.__firstView = window.__SP_VIEW__;
for (let i = 0; i < 5; i++) window.dispatchEvent(new Event('online'));
});
if (forced) {
assert.equal(await page.evaluate(() => window.__SP_VIEW__ === window.__firstView), true);
assert.equal(pixiRequests, before, 'explicit fallback never requests an engine retry');
} else {
await page.waitForFunction(() => window.__SP_VIEW__?.kind === 'engine', { timeout: 20000 });
assert.equal(pixiRequests, before + 1, 'one successful retry after the failed prefetch and initialization');
const result = await page.evaluate(() => {
const view = window.__SP_VIEW__;
for (let i = 0; i < 5; i++) document.dispatchEvent(new Event('visibilitychange'));
return { same: view === window.__SP_VIEW__, canvases: document.querySelectorAll('#recovery-host canvas').length,
field: view.raw.stats().mode };
});
assert.deepEqual(result, { same: true, canvases: 1, field: 'prep' });
}
await page.evaluate(() => window.__unmount());
assert.equal(await page.evaluate(() => window.__SP_VIEW__), null, 'unmount releases the recovery view');
} finally { await page.close(); }
});
for (const resource of ['atlas', 'three']) test(`${resource} download can recover the 3D board on visibility`, { skip: !art }, async () => {
const page = await browser.newPage(); let failing = true, requests = 0;
try {
await page.setRequestInterception(true);
page.on('request', req => {
const p = new URL(req.url()).pathname;
const selected = resource === 'atlas' ? /\/TX_autochessi_D\.(?:png|webp)$/.test(p) : p === '/vendor/three.module.js';
if (selected) requests++;
if (selected && failing) void req.abort('failed'); else void req.continue();
});
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=prep&stage=act2autochess_m01&board=3d&panel=0`);
await page.waitForFunction(() => window.__demo?.ready, { timeout: 30000 });
assert.equal(await page.evaluate(() => window.__demo.stats().board3d.on), false);
const before = requests; failing = false;
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
await page.waitForFunction(() => window.__demo.stats().board3d.on, { timeout: 15000 });
assert.ok(requests > before, 'the failed resource is actually requested again');
assert.equal(await page.evaluate(() => window.__demo.view.debug.app.view.parentNode.querySelectorAll('canvas').length), 2);
await page.screenshot({ path: new URL(`../e2e/out/recovery-${resource}.png`, import.meta.url).pathname });
} finally { await page.close(); }
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
// test/render/token-models.browser.test.js — the 39 summon models only the local client has (tools/local-extract/
// test/render/token-models.browser.test.js — the 40 original summon models extracted from the local client (tools/local-extract/
// extract.py TOKEN_SPINES → data/assets.json tokens[id].spineLocal, docs/ASSETS.md "Token models from the local client")
// drawn on the prep board of the dev render demo (public/dev/render-demo.html) in headless Chrome: every summon placed on
// a board tile draws its official model (the local entry, not the avatar diamond), on its own idle clip (the mapped ones
+1 -1
View File
@@ -68,7 +68,7 @@ async function installSim(spec) {
state() {
const out = {};
for (const [id, x] of v.debug.views) {
out[id] = { alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible, label: x._downRing?.text?.text ?? null,
out[id] = { alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible, label: x._downRing?.label ?? null,
dieClip: x.actor?.current ?? null, dieT: x.dieT ?? null, alpha: +(x.alpha ?? 0).toFixed(2), x: x.x, y: x.y, spine: !!x.spineReady };
}
return out;
+427
View File
@@ -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
@@ -435,3 +506,359 @@ describe('a fast enemy walks on its Run cycle (PR #275)', () => {
assert.equal(hound.actor.roles.skill.loop, 'Skill_02', 'the cast slot survives that too');
});
});
// 推拉 (player report): the sim displaces instantly (battle/displacement.js walks 0.1-tile steps inside one call) and the
// snapshot only carries the destination, so the view eases there (app.js `displace` fx → UnitView.slideTo) instead of
// appearing at the end of the path.
describe('a push / pull slide (推拉: the official impulse under friction)', () => {
const view = async (id = 1) => {
const ctx = fakeViewCtx(fake.P, { assets: store({ spine: true }), cam });
const v = new UnitView(ctx, { id, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', x: 5, y: 12, maxHp: 100 }, {});
await tick(); await tick();
return v;
};
// 推进 n 帧(update 会把单帧 dt 截到 0.1 s,所以必须多帧走完)
const step = (v, dur, n = 60) => { for (let i = 0; i < n; i++) v.update(dur / n, cam(), 0); };
test('slideTo runs a real deceleration: the velocity falls every frame and stops on the destination', async () => {
const v = await view();
v.slideTo(7.7, 12);
const sl = v.slide;
assert.ok(sl, 'a slide is in flight');
assert.ok(sl.v > 0 && sl.a > 0, 'it starts with a speed and a deceleration');
assert.ok(Math.abs(sl.v * sl.v / (2 * sl.a) - 2.7) < 1e-6, 'v²/2a = the distance (constant deceleration)');
let prev = sl.v;
let monotone = true;
for (let i = 0; i < 40; i++) {
v.update(sl.dur / 80, cam(), 0);
if (v.slide && v.slide.v > prev + 1e-9) monotone = false;
if (!v.slide) break;
prev = v.slide.v;
}
assert.ok(monotone, 'the velocity only ever decreases');
step(v, sl.dur);
assert.equal(v.x, 7.7, 'it arrives exactly on the authoritative destination');
assert.equal(v.y, 12, 'the cross axis holds');
assert.equal(v.slide, null);
});
test('starting a slide from the destination is a no-op (why app.js pre-scans the frame)', async () => {
// The trap this guards: app.js's snapshot loop runs before processEvents, and the snapshot already carries the
// displacement's destination, so a fx handler that runs after it finds the view there and has nothing to animate —
// the reported pause with no frames. app.js therefore starts the slide from the pre-snapshot position.
const v = await view(8);
v.sync({ x: 7.7, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0 });
assert.equal(v.x, 7.7);
v.slideTo(7.7, 12);
assert.equal(v.slide, null, 'no slide when the view is already there');
});
test('a second request for the same destination never restarts the slide in flight', async () => {
const v = await view(9);
v.slideTo(7.7, 12);
const first = v.slide;
for (let i = 0; i < 10; i++) v.update(first.dur / 40, cam(), 0);
const mid = v.x, done = v.slide.done;
v.slideTo(7.7, 12); // the fx handler, later in the same frame
assert.equal(v.slide, first, 'the same slide object, not a restart');
assert.equal(v.x, mid, 'the position is untouched');
assert.equal(v.slide.done, done);
step(v, first.dur);
assert.equal(v.x, 7.7, 'and it still lands');
});
test('an unmarked raw displacement still faces its travel direction', async () => {
const v = await view(10);
v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0, vx: -1.2 });
assert.equal(v.visFacing, -1, 'walking left before the pull');
v.slideTo(7.7, 12); // unmarked raw move to the right
assert.equal(v.visFacing, 1, 'faces the movement at once');
for (let i = 0; i < 20; i++) v.update(v.slide.dur / 40, cam(), 0);
assert.equal(v.visFacing, 1, 'still facing the movement in flight');
v.slideTo(5.2, 12); // another unmarked raw move to the left
assert.equal(v.visFacing, -1, 'and the other way for a raw move');
});
test('a flagged push or pull keeps the enemy facing left while sliding right, then resumes snapshot facing', async () => {
const v = await view(14);
v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -1 });
assert.equal(v.visFacing, -1, 'facing left before the push');
v.slideTo(6.7, 12, { at: 3, dur: 0.8, keepFacing: true });
assert.equal(v.visFacing, -1, 'unchanged when the push begins');
v.sync({ x: 6.7, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: 20 }, 2.95);
v.update(1 / 60, cam(), 0);
assert.equal(v.visFacing, -1, 'a positive interpolated velocity cannot flip it before the destination snapshot');
v.sync({ x: 6.7, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -1 }, 3);
const dur = v.slide.dur;
step(v, dur / 2);
assert.ok(v.x > 5 && v.x < 6.7, `still slides right (${v.x})`);
assert.equal(v.visFacing, -1, 'unchanged in flight');
step(v, dur / 2);
assert.equal(v.slide, null, 'slide ends normally');
assert.equal(v.x, 6.7);
v.sync({ x: 6.6, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: 1 });
assert.equal(v.visFacing, 1, 'normal snapshot facing resumes after the slide');
});
test('a buffered earlier attack sets the facing retained by a prestarted slide in either field direction', async () => {
for (const sign of [-1, 1]) {
const v = await view(30 + sign);
v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -sign });
v.slideTo(5 - sign, 12, { at: 3, dur: 0.8, keepFacing: true });
v.onAttack({ x: 5 + sign, y: 12 }, 2.9, 'melee');
assert.equal(v.visFacing, sign, 'the earlier attack supplies the real pre-push facing');
v.sync({ x: 5 - sign, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -sign * 20 }, 3);
step(v, v.slide.dur / 2);
assert.equal(v.visFacing, sign, 'the slide and sampled velocity retain that facing');
}
});
test('a snapshot in flight does not teleport it (the slide owns the position until it lands)', async () => {
const v = await view(7);
v.slideTo(7.7, 12);
const dur = v.slide.dur;
for (let i = 0; i < 12; i++) v.update(dur / 40, cam(), 0);
const mid = v.x;
assert.ok(mid > 5.2 && mid < 7.6, `mid-flight (${mid})`);
// the sim's snapshots already carry the destination (it displaced the enemy inside one call), so a plain sync
// would snap the view there — the reported "no frames in between"
v.sync({ x: 7.7, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0 });
assert.equal(v.x, mid, 'the snapshot does not move a sliding view');
assert.equal(v.y, 12);
step(v, dur);
assert.equal(v.x, 7.7, 'and it still lands on the destination');
// once landed, snapshots drive it again
v.update(dur, cam(), 0);
v.sync({ x: 6.4, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 0 });
assert.equal(v.x, 6.4, 'a landed view follows the snapshot again');
});
test('a slippery floor (a smaller friction factor) slides longer and lands just the same', async () => {
const dry = await view(2), ice = await view(3);
dry.slideTo(7.7, 12);
ice.slideTo(7.7, 12, { friction: 0.5 });
assert.ok(ice.slide.dur > dry.slide.dur * 1.5, `ice ${ice.slide.dur} > dry ${dry.slide.dur}`);
step(ice, ice.slide.dur * 1.05, 90);
assert.equal(ice.x, 7.7, 'the destination is authoritative whatever the floor');
});
test('the slide takes the sim\'s 失衡 time: the fx `dur` (game s) at the playback rate — 0.8 s at 2× is 0.4 real s', async () => {
const at2 = async (id) => {
const ctx = fakeViewCtx(fake.P, { assets: store({ spine: true }), cam, animRate: () => 2 });
const v = new UnitView(ctx, { id, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', x: 5, y: 12, maxHp: 100 }, {});
await tick(); await tick();
return v;
};
const push = await at2(11), pull = await at2(12), old = await at2(13);
push.slideTo(6.7, 12, { dur: 0.8 }); // a 受力等级 0 push (1.7 tiles, PRTS 位移时间 0.8 s)
assert.ok(Math.abs(push.slide.dur - 0.4) < 1e-9, `${push.slide.dur}`);
pull.slideTo(3, 12, { dur: 1 }); // a pull's force window
assert.ok(Math.abs(pull.slide.dur - 0.5) < 1e-9, `${pull.slide.dur}`);
old.slideTo(6.7, 12); // an fx without `dur` (an older recording): the fallback
assert.ok(Math.abs(old.slide.dur - Math.min(0.45, Math.max(0.12, 0.14 * Math.sqrt(1.7)))) < 1e-9, `${old.slide.dur}`);
step(push, push.slide.dur);
assert.equal(push.x, 6.7, 'lands on the destination');
});
test('the duration follows √distance, not distance', async () => {
const near = await view(4), far = await view(5);
near.slideTo(6.2, 12); far.slideTo(10.5, 12); // 1.2 vs 5.5 tiles
assert.ok(far.slide.dur > near.slide.dur, `${far.slide.dur} > ${near.slide.dur}`);
assert.ok(far.slide.dur / near.slide.dur < 2.6, 'sub-linear (∝ √d)');
});
test('a trivial move snaps, an unknown target is ignored; a unit that dies on the way lies where it was pushed', async () => {
const v = await view(6);
v.slideTo(5.01, 12);
assert.equal(v.slide, null); assert.equal(v.x, 5.01, 'a sub-0.05 tile move is not a displacement');
v.slideTo(NaN, 12);
assert.equal(v.slide, null); assert.equal(v.x, 5.01);
v.slideTo(7.7, 12);
step(v, v.slide.dur / 3, 20);
assert.ok(v.x > 5 && v.x < 7.7, `moving (${v.x})`);
// the sim's displacement already put the body at the destination (a Weedy wall kill dies there): the corpse
// finishes the slide and stays — PR #380 sent it back to where it stood, and the next snapshot then jumped it
v.die();
v.update(1, cam(), 0);
v.update(0.1, cam(), 0);
assert.equal(v.x, 7.7, 'the corpse lies where the push put it');
assert.equal(v.slide, null);
const dead = await view(11);
dead.die();
dead.slideTo(7.7, 12);
assert.equal(dead.slide, null, 'a dead view does not start a slide');
assert.equal(dead.x, 7.7, 'it is placed on the destination');
});
test('held until the snapshot with the destination is shown (`at`), then eased into the sampled position — the walk it resumes included — with no jump on landing', async () => {
const v = await view(12);
v.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -0.8 }, 2.9);
v.slideTo(7.7, 12, { at: 3 }); // app.js: the interval ending on the destination's snapshot is shown
for (const [x, t] of [[5.9, 2.93], [6.8, 2.96]]) { // the interpolator lerps there in the meantime
v.sync({ x, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: 27 }, t);
v.update(1 / 60, cam(), 0);
assert.equal(v.x, 5, `held where it stood at ${t}`);
}
// from the destination's snapshot on the sample anchors the slide, and the enemy walks back down its route
let live = 7.7, prev = v.x, maxStep = 0, frames = 0;
for (let t = 3; v.slide && frames < 120; t += 1 / 30, frames++) {
v.sync({ x: live, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -0.8 }, t);
v.update(1 / 60, cam(), 0);
maxStep = Math.max(maxStep, Math.abs(v.x - prev)); prev = v.x;
live -= 0.8 / 30;
}
assert.ok(frames >= 8, `a slide of ${frames} frames, not a jump`);
assert.equal(v.slide, null);
assert.ok(maxStep < 0.4, `no frame moves more than ${maxStep.toFixed(3)} tiles`);
const landed = v.x;
v.sync({ x: live, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: -0.8 }, 3 + frames / 30);
assert.ok(Math.abs(v.x - landed) < 0.05, `landed on the walking enemy (${landed.toFixed(3)} → ${v.x.toFixed(3)})`);
});
test('a slide whose destination snapshot never shows (the unit left the snapshots) goes to the fx\'s destination', async () => {
const v = await view(13);
v.slideTo(7.7, 12, { at: 3 });
for (let i = 0; i < 24; i++) v.update(1 / 60, cam(), 0);
assert.equal(v.x, 5, 'held while the snapshot may still come');
for (let i = 0; i < 30; i++) v.update(1 / 60, cam(), 0);
assert.equal(v.slide, null);
assert.equal(v.x, 7.7, 'then slid to where the push put it');
v.sync({ x: 6.1, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1 }, 3.4);
assert.equal(v.x, 6.1, 'snapshots drive it again');
});
});
// the app.js frame order (render/app.js: processEvents takes the events ≤ renderT — a 'die' kills the view —, then
// syncBattle's look-ahead starts a slide for a `displace` fx still queued up to the next snapshot, then each sampled unit
// goes through syncView — a dying view takes only the position —, then update) run over a real battle: a pushed enemy is
// drawn sliding, not snapped, and one killed by the push lies where the sim put it
describe('a push through SnapshotBuffer and UnitView in the app.js frame order (PR #380)', () => {
/**
* A real battle: an enemy (`kind` 'route': on its route; 'summon': spawned mid-battle; 'flyer': a flying one) pushed
* 1.7 tiles at 3 s, killed `killDelay` ticks later when `kill`. Returns the drawn x of every frame from renderT 2.8 to
* 4.6, the sim's position right after the push and where the body lies (the sim's position at the kill).
*/
async function pushed({ kill = false, killDelay = 0, kind = 'route' } = {}) {
const { makeBattle, enemyRec } = await import('../helpers/battleHarness.js');
const { SnapshotBuffer } = await import('../../public/js/render/interp.js');
const { snapFrame } = await import('../../server/match/fields.js');
const { syncView } = await import('../../public/js/render/units.js');
const rec = enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 0.8, ...(kind === 'flyer' ? { motion: 'FLY' } : {}) });
const h = makeBattle({ defs: { enemies: { enemy_walker: rec } }, enemies: kind === 'summon' ? [] : [{ key: 'enemy_walker', time: 0, route: 0 }],
content: 'none', autoFinish: false, timeLimit: 60 });
const frames = [];
let e = null, info = null, moved = 0, dest = null, since = -1, body = null;
for (let i = 0; i < 30 * 7; i++) {
h.b.step(); // the battle's own step: h.step() would drain the events into h.events
if (kind === 'summon' && !e && h.b.time >= 1) e = h.b.spawnEnemy('enemy_walker', { pos: [10, 8] }); // (h.spawn drains too)
e = e || h.enemy('enemy_walker');
if (!moved && e && h.b.time >= 3) { moved = h.b.displace(e, { x: 1, y: 0 }, 1.7); dest = e.x; since = 0; }
else if (since >= 0) since++;
if (kill && since === killDelay) { h.b.kill(e, null); body = e.x; }
if (i % 3 === 2) {
const ev = h.b.drainEvents();
info = info || ev.find((x) => x[0] === 'spawn' && e && x[1].id === e.id)?.[1];
const s = h.b.snapshot();
frames.push({ at: s.t / 2, snap: snapFrame('f', s), ev, gt: s.t });
}
}
assert.ok(moved > 1, `the sim pushed it ${moved} tiles`);
assert.equal(e.alive, !kill);
const v = new UnitView(fakeViewCtx(fake.P, { cam }), info, {});
const buf = new SnapshotBuffer(), sample = new Map(), xs = [];
const start = (ev, at) => { if (ev[0] === 'fx' && ev[1] === 'displace' && ev[4]?.id === e.id) v.slideTo(Number(ev[2]), Number(ev[3]), at == null ? {} : { at }); };
for (let f = 0, fi = 0; f < 60 * 4; f++) {
const now = f / 60;
for (; fi < frames.length && frames[fi].at <= now; fi++) { buf.push(frames[fi].snap, frames[fi].at); buf.pushEvents(frames[fi].ev, frames[fi].at, frames[fi].gt); }
const renderT = buf.update(now);
if (!Number.isFinite(renderT)) continue;
for (const ev of buf.takeEvents(renderT, [])) { start(ev); if (ev[0] === 'die' && ev[1] === e.id && v.alive) v.die(); }
const at = buf.nextSnapT(renderT);
if (at > renderT) buf.forEachUpcoming(renderT, at, (ev) => start(ev, at));
buf.sample(renderT, sample);
if (sample.get(e.id)) syncView(v, sample.get(e.id), renderT);
v.update(1 / 60, cam(), now);
if (renderT > 2.8 && renderT < 4.6) xs.push(v.x);
}
return { xs, dest, body };
}
/** The largest frame-to-frame step, the frames moving, the slide's first frame and any step back after it. */
const steps = (xs) => {
let big = 0, moving = 0, back = false;
const from = xs.findIndex((x, i) => i > 0 && x > xs[i - 1] + 0.01); // the slide's first frame (it walked left before)
for (let i = 1; i < xs.length; i++) {
const d = xs[i] - xs[i - 1];
big = Math.max(big, Math.abs(d)); if (Math.abs(d) > 0.02) moving++;
if (from > 0 && i > from && d < -1e-9) back = true;
}
return { big, moving, from, back };
};
test('a 1.7-tile push is drawn over a dozen frames, every frame a step under 0.35 tiles', async () => {
const { xs } = await pushed();
const { big, moving } = steps(xs);
assert.ok(moving >= 8, `${moving} frames show it moving (the bare interpolator: 3)`);
assert.ok(big < 0.35, `largest step ${big.toFixed(3)} tiles (the bare interpolator: 0.58)`);
});
// a unit killed on the very step of its push: its 'die' comes due with the displace fx, so its view is dying before the
// slide can anchor — and syncBattle gives a dying view only its position (syncView). The slide used to stay on hold,
// be dropped after 0.5 s, past the 0.8 s DIE window, and leave the body on its pre-push tile (Grok's review of fb7-render)
for (const [name, o] of [
['killed on the step of the push', { kind: 'route', killDelay: 0 }],
['a summoned enemy killed on the step of the push', { kind: 'summon', killDelay: 0 }],
['a flyer killed on the step of the push', { kind: 'flyer', killDelay: 0 }],
['killed 6 ticks after the push', { kind: 'route', killDelay: 6 }],
]) {
test(`${name}: the body slides on to where the sim put it and stays there, no frame stepping 0.35 tiles`, async () => {
const { xs, dest, body } = await pushed({ kill: true, ...o });
const { big, moving, from, back } = steps(xs);
if (!o.killDelay) assert.equal(body, dest, 'killed where the push put it');
assert.ok(Math.abs(xs[xs.length - 1] - body) < 0.011, `lies at ${xs[xs.length - 1].toFixed(3)}, the sim's body at ${body.toFixed(3)}`);
assert.ok(from > 0 && moving >= 8, `${moving} frames show it sliding`);
assert.ok(big < 0.35, `largest step ${big.toFixed(3)} tiles`);
// (killed later, it walked back down its route a little first: the body eases onto that)
if (!o.killDelay) assert.ok(!back, 'never back towards where it stood');
});
}
});
// PR #380, death: a flyer holds its lift through the Die clip (the official client drops its fly offset in
// CharacterAnimator.OnFinish) and a long Die clip plays whole (DIE_CLIP_MAX, data/assets.json's longest 7.97 s)
describe('death timing (PR #380): a dying flyer drops inside the fade, a long Die clip plays whole', () => {
test('a dying flyer keeps FLY_HOVER through its Die clip and sinks only in the fade tail; a walker stays down', async () => {
const { FLY_HOVER, UF } = { ...(await import('../../public/js/render/units.js')), ...(await import('../../shared/constants.js')) };
const ctx = fakeViewCtx(fake.P, { cam });
const fly = new UnitView(ctx, { id: 21, side: 'enemy', kind: 'enemy', defId: 'enemy_fly', motion: 'FLY', x: 5, y: 12, maxHp: 100 }, {});
fly.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: UF.FLYING, anim: 1 }, 1);
for (let i = 0; i < 120; i++) fly.update(1 / 60, cam(), 0);
assert.ok(Math.abs(fly.hover - FLY_HOVER) < 1e-3, 'aloft while alive');
fly.die(); // no clip known: 0.35 s of clip, then the 0.55 s fade
for (let i = 0; i < 18; i++) fly.update(1 / 60, cam(), 0);
assert.ok(Math.abs(fly.hover - FLY_HOVER) < 1e-3, `still aloft in the Die clip (${fly.hover.toFixed(3)})`);
for (let i = 0; i < 30; i++) fly.update(1 / 60, cam(), 0);
assert.ok(fly.hover < FLY_HOVER * 0.25, `sinking in the fade (${fly.hover.toFixed(3)})`);
const walker = new UnitView(ctx, { id: 22, side: 'enemy', kind: 'enemy', defId: 'enemy_walk', x: 5, y: 12, maxHp: 100 }, {});
walker.die();
walker.update(1 / 60, cam(), 0);
assert.equal(walker.hover, 0);
});
test('every enemy Die clip of data/assets.json is within DIE_CLIP_MAX, and a 7.97 s one is scheduled whole', async () => {
const { DIE_CLIP_MAX, dieClipDur } = await import('../../public/js/render/units.js');
const durs = Object.values(ASSETS.enemies).map((e) => dieClipDur(e && e.spine)).filter((d) => d > 0);
assert.ok(durs.length > 200, `${durs.length} enemy Die clips`);
const longest = Math.max(...durs);
assert.ok(longest <= DIE_CLIP_MAX, `longest Die clip ${longest} s ≤ ${DIE_CLIP_MAX}`);
assert.ok(durs.filter((d) => d > 1.6).length > 0, 'the old 1.6 s cap cut some clips short');
const entry = { skel: '/s/q.skel', atlas: '/s/q.atlas', textures: ['/s/q.png'], anims: { idle: 'Idle', die: 'Die' }, animations: { Idle: 1, Die: longest } };
const assets = { ...store({ spine: true }), spineEntry: () => entry };
const v = new UnitView(fakeViewCtx(fake.P, { assets, cam }), { id: 23, side: 'enemy', kind: 'enemy', defId: 'enemy_1521_dslily', x: 5, y: 12, maxHp: 100 }, {});
await tick(); await tick();
assert.ok(v.actor, 'the model is up');
v.die();
assert.ok(Math.abs(v.dying - (longest + 0.55)) < 1e-6, `the whole clip, then the fade (${v.dying.toFixed(3)} s)`);
for (let i = 0; i < 300; i++) v.update(1 / 60, cam(), 0);
assert.ok(!v.remove && v.alpha > 0.5, 'still on screen 5 s in');
});
});