- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player; placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call) - #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿 - #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text - #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool, and the team LP reaching 0 first is a defeat (PRTS) - #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones - #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10) - #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range - #9 official per-enemy model scales (威龙) - #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding - #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider - #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes - #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20 - #18 炎佑 is 孤立: no heals or ally buffs - #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators - DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites green; dev recordings regenerated
135 lines
7.4 KiB
JavaScript
135 lines
7.4 KiB
JavaScript
// test/render/playtest6_elembar.test.js — user playtest #6 report 11, client side: "干员血条右边的元素损伤条积累速度不对,
|
||
// 损伤条远远没扣完但是实际已经爆条受到伤害了". The whole display path in Node (headless fake PIXI, test/render/fakepixi.js):
|
||
// a real battle (古米 against real 海嗣) → Battle.snapshot() every tick like the client runner → render/interp.js
|
||
// SnapshotBuffer with the local-feed clock → sample() → render/units.js UnitView. Checks:
|
||
// · the white bar drawn is the sim's remaining 元素值 at the render clock (within 1 %, never less than what is left,
|
||
// never empty before the burst), the icon is the element with the least 元素值 left, and the burst shows as a
|
||
// refill (PRTS 元素 "以白条显示剩余的元素值" / "元素条显示缓慢恢复至上限") drawn in the element's colour, never the
|
||
// white of 元素值 left — a white bar growing while the burst's stun / damage hits read as "not used up yet, but it
|
||
// burst";
|
||
// · the render clock trails the sim by ≤ 3 ticks;
|
||
// · the gauge row is the unit's own: under its HP / SP bars and inside their span — the v2.3 ring right of the bars
|
||
// sat on the next operator's tier chip and bars (drawn under them) and read as that operator's gauge.
|
||
|
||
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';
|
||
import { SnapshotBuffer } from '../../public/js/render/interp.js';
|
||
import { makeBattle } from '../helpers/battleHarness.js';
|
||
import { ELEMENT_ORDER, TICK } from '../../server/sim/constants.js';
|
||
|
||
let fake, UnitView, EL_BAR, ELEMENT_RING;
|
||
before(async () => {
|
||
fake = installFakePixi();
|
||
({ UnitView, EL_BAR } = await import('../../public/js/render/units.js'));
|
||
({ ELEMENT_RING } = await import('../../public/js/render/textures.js'));
|
||
});
|
||
after(() => fake.restore());
|
||
|
||
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
|
||
const OP = 'chess_char_1_10_a'; // 古米
|
||
|
||
/** Remaining 元素值 share of the element with the least left (the sim's truth), null without a gauge. */
|
||
function simLeft(u) {
|
||
let bv = 0, el = null;
|
||
for (const k of ELEMENT_ORDER) if (u.elem[k] > bv) { bv = u.elem[k]; el = k; }
|
||
return el ? { el, left: 1 - bv / u.gaugeMax } : null;
|
||
}
|
||
|
||
/**
|
||
* Run a real battle and render the operator every tick through the client path; returns the per-frame rows
|
||
* { t, renderT, sim (at the snapshot shown), el, share, lock } and the view.
|
||
*/
|
||
function renderRun(keys, { mods = {}, seconds = 70 } = {}) {
|
||
const h = makeBattle({ units: [{ chessId: OP, row: 10, col: 5 }], seed: 3, autoFinish: false, timeLimit: 600 });
|
||
h.step();
|
||
const pos = [[10, 5.6], [10, 4.4]];
|
||
keys.forEach((k, i) => h.spawn(k, { pos: pos[i], routeIndex: 0, mods: { speedMul: 0, hpMul: 1000, ...mods } }));
|
||
const u = h.allies()[0];
|
||
const info = h.b.fieldMeta().units.find((x) => x.id === u.id);
|
||
const v = new UnitView(fakeViewCtx(fake.P, { cam }), info);
|
||
// the client runner's local feed (render/app.js setLocalFeed): ~2 frames of delay, the battle's speed
|
||
const buf = new SnapshotBuffer({ delay: 0.034, rate: 2, maxRate: 8 });
|
||
const truth = new Map(); // snapshot time → the sim's gauge then
|
||
const out = new Map();
|
||
const rows = [];
|
||
for (let i = 0; i < seconds / TICK; i++) {
|
||
h.step();
|
||
if (u.alive) u.hp = u.s.maxHp; // keep the operator standing: only its gauge matters
|
||
const snap = h.b.snapshot();
|
||
truth.set(snap.t, { g: simLeft(u), lock: !!u.s.flags.burstLock });
|
||
const now = (i + 1) / 60; // 60 ticks per real second at 2× (DESIGN §4)
|
||
buf.push({ ...snap, gt: snap.t }, now);
|
||
const rt = buf.update(now);
|
||
buf.sample(rt, out);
|
||
const s = out.get(u.id);
|
||
if (!s) continue;
|
||
v.sync(s, rt);
|
||
v.update(1 / 60, cam(), now);
|
||
const shownT = buf.snaps[Math.max(0, buf._indexAt(rt))].t;
|
||
const r = v._elBar;
|
||
rows.push({
|
||
t: snap.t, renderT: rt, sim: truth.get(shownT), el: r && r.root.visible ? v.el : null,
|
||
share: r && r.root.visible ? (r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0) : null,
|
||
tint: r ? r.fill.tint : null, v,
|
||
});
|
||
}
|
||
return { rows, v };
|
||
}
|
||
|
||
function checkRows(rows) {
|
||
let bursts = 0, prevLock = false, lastLeftBefore = null;
|
||
for (const row of rows) {
|
||
assert.ok(row.t - row.renderT <= 3 * TICK + 1e-9, `the render clock trails by ≤ 3 ticks (${row.t} vs ${row.renderT})`);
|
||
const { g, lock } = row.sim;
|
||
if (lock) {
|
||
assert.ok(row.el, 'the 爆发冷却 shows');
|
||
if (!prevLock) { bursts++; assert.ok(row.share < 0.02, `the refill starts empty (${row.share})`); }
|
||
assert.equal(row.tint, ELEMENT_RING[row.el].tint, `t=${row.t}: the refill is drawn in the element's colour, not white`);
|
||
} else if (!g) assert.equal(row.el, null, 'no gauge, no row');
|
||
else {
|
||
assert.equal(row.el, g.el, `t=${row.t}: the icon is the element with the least 元素值 left`);
|
||
assert.equal(row.tint, 0xffffff, `t=${row.t}: 元素值 left is white`);
|
||
assert.ok(row.share >= g.left - 1e-9, `t=${row.t}: bar ${row.share} never below the real remainder ${g.left}`);
|
||
assert.ok(row.share - g.left < 0.01 + 1e-9, `t=${row.t}: bar ${row.share} within 1 % of ${g.left}`);
|
||
assert.ok(row.share > 0, `t=${row.t}: never empty before the burst`);
|
||
lastLeftBefore = row.share;
|
||
}
|
||
prevLock = lock;
|
||
}
|
||
return { bursts, lastLeftBefore };
|
||
}
|
||
|
||
test('底海滑动者 on 古米: the white bar drawn is the sim\'s remaining 元素值 every frame; the burst comes off a sliver', () => {
|
||
const { rows } = renderRun(['enemy_1148_dssbr'], { seconds: 62 });
|
||
const { bursts } = checkRows(rows);
|
||
assert.equal(bursts, 1, 'the 神经 burst at ≈ 49 game s');
|
||
const before = rows[rows.findIndex((r) => r.sim.lock) - 1];
|
||
assert.ok(before.share <= 0.05, `the last bar before the burst: ${before.share}`);
|
||
// the refill runs over the 10 s 爆发冷却
|
||
const cool = rows.filter((r) => r.sim.lock);
|
||
const mid = cool[Math.floor(cool.length / 2)];
|
||
assert.ok(Math.abs(mid.share - 0.5) < 0.05, `halfway through the cooldown the bar is half refilled (${mid.share})`);
|
||
});
|
||
|
||
test('掠海漂移体 + 骨海漂流体 on 古米 (绝境 R11 ATK ×1.77): chunky 侵蚀 hits — the bar follows every chunk exactly', () => {
|
||
const { rows } = renderRun(['enemy_2025_syufo', 'enemy_2021_syfish'], { mods: { atkMul: 1.771561 }, seconds: 40 });
|
||
const { bursts } = checkRows(rows);
|
||
assert.ok(bursts >= 1);
|
||
});
|
||
|
||
test('the gauge row belongs to its unit: under its own HP / SP bars and inside their span', () => {
|
||
const { rows, v } = renderRun(['enemy_1148_dssbr'], { seconds: 12 });
|
||
assert.ok(rows.at(-1).el, 'a gauge shows');
|
||
const r = v._elBar;
|
||
const x0 = v.hpBg.position.x + 1, bw = v.hpBg.width - 2;
|
||
const spBottom = v.spFill.visible ? v.spFill.position.y + v.spFill.height / 2 : v.hpFill.position.y + v.hpFill.height / 2;
|
||
const discR = r.disc.width * 0.5 * (43 / 64); // the drawn disc (textures.js HUD_DISC of the cell)
|
||
assert.ok(r.disc.position.x - discR >= x0 - 0.5, 'the icon starts at the bars\' left edge');
|
||
assert.ok(r.bg.position.x + r.bg.width <= x0 + bw + 1.5, 'the bar ends at the bars\' right end (never beyond, over a neighbour)');
|
||
assert.ok(r.fill.position.x + r.fill.width <= x0 + bw + 0.5);
|
||
assert.ok(r.disc.position.y - discR >= spBottom + EL_BAR.gap - 0.01, 'the row lies under the SP bar');
|
||
assert.ok(r.bg.position.y - r.bg.height / 2 > spBottom, 'the white bar too');
|
||
});
|