Files
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #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
2026-10-01 19:08:23 +08:00

135 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
});