Files
ark-stronghold-protocol/test/render/downring.test.js
T
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

237 lines
12 KiB
JavaScript
Raw 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/downring.test.js — user playtest #4 items 8 and 9, client side: render/interp.js carries b.snap `elem`
// (the element gauge a unit shows) and `down` (knocked-out operators waiting to redeploy); render/units.js keeps a
// knocked-out operator on its tile in its held Die pose under a redeploy ring (countdown → "DP" / "!" → redeploy)
// and draws the official element gauge — since user playtest #6 a row under the bars: the element icon and a white bar
// of the remaining 元素值, refilling in the element's colour over a 爆发冷却 (headless fake PIXI, test/render/fakepixi.js). User playtest #5 item 2: an operator entering
// 联防 knocked out ('die' reason FORCED_EXIT) goes straight to the held pose, no burst.
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, TUPLE } from '../../public/js/render/interp.js';
import { FORCED_EXIT as SIM_FORCED_EXIT } from '../../server/sim/constants.js';
let fake, UnitView, DOWN_STATE, DOWN_LOOK, T;
before(async () => {
fake = installFakePixi();
({ UnitView, DOWN_STATE, DOWN_LOOK } = await import('../../public/js/render/units.js'));
T = await import('../../public/js/render/textures.js');
});
after(() => fake.restore());
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const tuple = (id, x = 5, y = 10, hp = 1000) => [id, x, y, hp, 1000, 0, 0, 0, 0];
describe('interp: b.snap `elem` and `down`', () => {
test('`elem` entries are appended to their unit tuple and sampled as el / elFill / elUntil / elDur', () => {
const s = normalizeSnapshot({ t: 3, units: [tuple(1), tuple(2), tuple(3)], elem: [[1, 'burn', 0.4, 0, 0], [2, 'neural', 1, 12.5, 10], [9, 'burn', 0.5, 0, 0], [3, 'lava', 0.5, 0, 0], 'x'] });
assert.deepEqual(s.units.get(1).slice(TUPLE.EL), ['burn', 0.4, 0, 0]);
assert.deepEqual(s.units.get(2).slice(TUPLE.EL), ['neural', 1, 12.5, 10]);
assert.equal(s.units.get(3).length, 9, 'unknown element dropped');
assert.equal(s.down, null);
const buf = new SnapshotBuffer({ delay: 0 });
buf.push({ t: 1, units: [tuple(1)], elem: [[1, 'apoptosis', 0.2, 0, 0]] }, 0);
buf.push({ t: 1.1, units: [tuple(1)] }, 0.05);
const out = buf.sample(1.02);
assert.equal(out.get(1).el, 'apoptosis');
assert.equal(out.get(1).elFill, 0.2);
buf.sample(1.1, out);
assert.equal(out.get(1).el, null, 'cleared once the gauge is gone');
assert.equal(out.get(1).elFill, 0);
});
test('`down` is kept per snapshot; downAt(time) reads the snapshot shown at that time', () => {
const buf = new SnapshotBuffer({ delay: 0 });
buf.push({ t: 1, units: [] }, 0);
buf.push({ t: 1.1, units: [], down: [[7, 20.5, 18, 0], ['bad'], [8, 'x', -3, 1.7]] }, 0.05);
buf.push({ t: 1.2, units: [] }, 0.1);
assert.equal(buf.downAt(1.05), null);
assert.deepEqual(buf.downAt(1.15), [[7, 20.5, 18, 0], ['bad', 0, 0, 0], [8, 0, 0, 1]]);
assert.equal(buf.downAt(1.25), null);
});
});
function view(info = {}) {
const ctx = fakeViewCtx(fake.P, { cam });
return new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info });
}
const frames = (v, n, dt = 1 / 60, gameT = null) => { for (let i = 0; i < n; i++) { if (gameT) v.gameT = gameT(i); v.update(dt, cam(), i * dt); } };
describe('knocked-down operator (UnitView.setDown)', () => {
test('stays on its tile greyed under a redeploy ring counting down, then "DP", then redeploys', () => {
const v = view();
frames(v, 3);
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 10);
assert.equal(v.alive, false, 'knocked down');
assert.ok(v.down);
frames(v, 400, 1 / 60, () => 10); // 6.7 s: a dying view would long be gone
assert.equal(v.remove, false, 'never removed while down');
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.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.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.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, '!');
const kids = v.hud.children.length;
frames(v, 30);
assert.equal(v.hud.children.length, kids, 'no per-frame allocation');
v.onDeploy(); // the redeploy (b.ev 'deploy')
assert.equal(v.alive, true);
assert.equal(v.down, null);
frames(v, 2);
assert.equal(r.root.visible, false, 'ring gone');
});
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);
assert.equal(v.alive, false);
assert.ok(v.dieT >= 30);
frames(v, 2, 1 / 60, () => 12);
assert.ok(v._downRing.root.visible);
assert.equal(v._downRing.text.text, '18');
});
test('an operator entering the battle knocked out (联防, reason FORCED_EXIT): the held pose at once, no death burst, then its ring', async () => {
const { FORCED_EXIT, showsDeathFx } = await import('../../public/js/render/app.js');
assert.equal(FORCED_EXIT, SIM_FORCED_EXIT, 'the renderer mirrors the sim reason');
const info = { side: 'ally', kind: 'chess' };
assert.equal(showsDeathFx(info, false, FORCED_EXIT), false, 'no death particles');
assert.equal(showsDeathFx(info, false, 'killed'), true, 'a knock-out keeps them');
const v = view();
frames(v, 2);
v.die(true); // b.ev ['die', id, 'forcedExit'] (render/app.js handleEvent)
assert.equal(v.alive, false);
assert.ok(v.dieT >= 30, 'no fall: the end of the Die clip');
v.setDown([1, 70, 70, DOWN_STATE.COUNTING], 0.03); // b.snap down, same frame
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');
const w = view({ id: 2 });
frames(w, 2);
w.die();
assert.ok(w.dieT < 1, 'an ordinary death still plays the clip from its start');
});
test('leaving the `down` list without a redeploy fades the view out; a normal death still fades', () => {
const v = view();
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 10);
frames(v, 10);
v.setDown(null, 11);
frames(v, 60);
assert.equal(v.remove, true);
const w = view({ id: 2 });
frames(w, 2);
w.die();
frames(w, 200);
assert.equal(w.remove, true, 'a summon / enemy death is unchanged');
});
});
describe('element gauge row (official: element icon + white bar of the remaining 元素值; user playtest #6)', () => {
const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ...o });
/** The white bar's drawn share of its track (the track is 2 px wider: its dark outline). */
const share = (r) => (r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0);
test('operators: the element disc with its glyph and a white bar of the remaining 元素值 (1 − fill)', () => {
const v = view();
v.sync(sample(), 5);
frames(v, 2);
assert.equal(v._elBar, null, 'nothing built without a gauge');
v.sync(sample({ el: 'burn', elFill: 0.25 }), 5);
frames(v, 1);
const r = v._elBar;
assert.ok(r.root.visible);
assert.equal(r.disc.texture, T.hudRings().disc.burn);
assert.ok(Math.abs(share(r) - 0.75) < 1e-9, `remaining EP = 1 − fill (${share(r)})`);
assert.equal(r.fill.tint, 0xffffff, 'a white bar');
v.sync(sample({ el: 'neural', elFill: 0.6 }), 5);
frames(v, 1);
assert.equal(r.disc.texture, T.hudRings().disc.neural);
assert.ok(Math.abs(share(r) - 0.4) < 1e-9);
assert.equal(v.elementLeft(), 0.4);
const kids = r.root.children.length;
frames(v, 30);
assert.equal(r.root.children.length, kids, 'no per-frame allocation');
v.sync(sample(), 6);
frames(v, 1);
assert.equal(r.root.visible, false, 'hidden once the gauges are empty');
});
test('enemies: a smaller plain disc; during a 爆发冷却 the bar refills over the cooldown', () => {
const e = view({ id: 3, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 10);
frames(e, 1);
const r = e._elBar;
assert.equal(r.disc.texture, T.hudRings().discEnemy.apoptosis);
assert.equal(share(r), 0, 'empty at the burst');
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 17.5);
frames(e, 1);
assert.ok(Math.abs(share(r) - 0.5) < 1e-9, 'half refilled halfway through');
const op = view({ id: 4 });
op.sync(sample({ el: 'apoptosis', elFill: 0.5 }), 1);
frames(op, 1);
assert.ok(r.disc.width < op._elBar.disc.width, 'the enemy icon is smaller');
});
test('the 爆发冷却 refill is not the white 元素值 bar: element colour, pulsing disc; white again after the cooldown', () => {
const op = view({ id: 6 });
op.sync(sample({ el: 'neural', elFill: 0.97 }), 30);
frames(op, 1);
const r = op._elBar;
assert.equal(r.fill.tint, 0xffffff, 'the 元素值 left: white');
assert.equal(r.disc.alpha, 1);
// the burst: 神经 stun + 1000 true damage while the bar refills over the 10 s cooldown
op.sync(sample({ el: 'neural', elFill: 1, elUntil: 40.5, elDur: 10 }), 34);
const alphas = new Set();
for (let i = 0; i < 40; i++) { op.update(1 / 60, cam(), 100 + i / 60); alphas.add(Math.round(r.disc.alpha * 100)); }
assert.ok(op.elementCooling());
assert.ok(Math.abs(share(r) - 0.35) < 1e-9, `refilled by the cooldown run (${share(r)})`);
assert.equal(r.fill.tint, T.ELEMENT_RING.neural.tint, 'the refill is drawn in the element\'s colour');
assert.notEqual(r.fill.tint, 0xffffff);
assert.ok(alphas.size > 5 && Math.min(...alphas) >= 44 && Math.max(...alphas) <= 100, `the disc pulses (${[...alphas]})`);
for (const el of ['erosion', 'burn', 'apoptosis']) {
op.sync(sample({ el, elFill: 1, elUntil: 44, elDur: 10 }), 35);
frames(op, 1);
assert.equal(r.fill.tint, T.ELEMENT_RING[el].tint, el);
}
// the cooldown ends: every gauge resets; a new hit shows white again
op.sync(sample({ el: 'burn', elFill: 0.1 }), 45);
frames(op, 1);
assert.equal(op.elementCooling(), false);
assert.equal(r.fill.tint, 0xffffff);
assert.equal(r.disc.alpha, 1);
});
test('a burst lock status (爆发冷却) is not repeated in the status row while the element gauge shows it', () => {
const e = view({ id: 5, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
e.onStatus('burnBurst', true);
e.onStatus('fragile', true);
assert.deepEqual([...e._iconKeys()], ['burn', 'fragile'], 'no gauge in the feed: the status shows the burst');
e.sync(sample({ el: 'burn', elFill: 1, elUntil: 20, elDur: 10 }), 12);
assert.deepEqual([...e._iconKeys()], ['fragile'], 'the element gauge carries the 爆发冷却');
});
test('the atlas: one ring frame per step (the redeploy ring), every element disc in both styles', () => {
const R = T.hudRings();
assert.equal(R.arcs.length, T.RING_STEPS + 1);
for (const el of ['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']) { assert.ok(R.disc[el]); assert.ok(R.discEnemy[el]); }
assert.equal(T.ringArc(0.001), R.arcs[1], 'a sliver never shows as empty');
assert.equal(T.ringArc(2), R.arcs[T.RING_STEPS]);
});
});