feedback7 R22-HUD: the HP bar shows the ammo skill's rounds as yellow cells (隐现 S2: 14), 伺夜's 狼影 left as diamonds and 斩业星熊's 我执 pool as a red bar — one snapshot change for PR #303 and #378 by @IceCodeNew and PR #383 by @Convey123; golden: unchanged
The three PRs patched the same snapshot and HUD lines (Grok's review: do not merge three diffs), so this is one design built from their code and tests; IceCodeNew is the commit author (#303, #378), Convey123 co-author (#383). - b.snap gains three optional, display-only lists beside the unchanged 9-field tuples (snapshot.js ammoView / wolfView / negView, called from battle/events.js snapshot(); a living, deployed, visible ally, only when it has one): ammo [[id, rounds left, magazine]] (whole rounds; the magazine is the activation's real total that unitTuple's SP fraction runs on, extras and refills included), wolves [[id, 狼影 left, talent maximum]] (mem.shadows of the new mem.wolfCapacity, set by tokens.js wolfPack and the managed vigil kit), neg [[id, fill]] (unit.negFill(), a function op-hsgma2.js sets: the pool is read when the snapshot is taken, no per-tick sync). UnitInfo.ammoSkill (an ally whose skill is an ammo magazine) is known from the first appearance: fx events run before the frame's sample, so the aura cannot wait for the first ammo row. - render/interp.js keeps the lists per snapshot (malformed rows / unknown ids dropped) and sample() hands them out from the older snapshot like flags (they step with the skill flag); render/units.js draws them: the ammo magazine replaces the SP bar (a cell per round, thin cuts, exact fill, no cuts under 2 px a cell), a row of diamonds under the bars for the wolves (the element gauge row moves below), the red bar over the drained HP bar for the pool. An ammo skill draws no standing aura (rings.js _aura; the activation burst stays). - PR #378's wolf-head avatars are NOT taken: the count is data (the talent text 至多3只, mem.shadows), the HUD art of the official counter is in no game data we have, so the pips are plain diamonds [ASSUMED look]. The PRs' own tests are folded in and extended (test/sim/hud-readouts.test.js, test/render/hud-readouts.test.js, op_hsgma2.test.js). - Docs: network.md, SIM.md §9, client.md, PLAYING.md, types/core.js. - Golden: unchanged (283 scenarios; the canon hashes units / down / elem only, no sim state moved). - Tests: 4 + 14 new + 1 in op_hsgma2 (12), all fail on the parent; 344 related tests pass; lint 0 errors, typecheck, check:imports, docs tests. Co-authored-by: Convey123 <Convey123@users.noreply.github.com>
This commit is contained in:
1 parent
ae22335cfe
commit
8ae891f1f3
19 files changed
+721
-28
No files matched your search
@@ -300,6 +300,33 @@ test('S3 临死模式 (a 主动关闭 only — no strategy of this mode closes i
|
||||
}
|
||||
});
|
||||
|
||||
// 社区反馈 (PR #383 by @Convey123): 「绿条被打空以后受到伤害就会涨红条,这里看不到红条」 — the official HP bar shows the 我执 pool as a red bar
|
||||
// growing over the drained green one. The sim has kept the pool since 0.2.0 (unit.mem.hsEgo); the snapshot now lists its share of the
|
||||
// cap (b.snap `neg`, snapshot.js negView — display only) and render/units.js draws it (test/render/hud-readouts.test.js).
|
||||
test('T1 业火 我执: the pool reaches the client as b.snap `neg` — its share of the cap (2 × max HP) from the first lethal hit, growing with the damage, gone with 我执', () => {
|
||||
const { h, u } = field({ tier: 6, elite: true, skill: 0, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 9 }] });
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 8] });
|
||||
h.step();
|
||||
const max = u.s.maxHp;
|
||||
const negOf = () => (h.b.snapshot().neg || []).find((x) => x[0] === u.id);
|
||||
assert.equal(h.b.snapshot().neg, undefined, 'not in 我执: no list');
|
||||
h.b.dealDamage(e, u, { amount: u.hp + 300, type: 'true' });
|
||||
h.step();
|
||||
assert.ok(u.mem.hsEgo, '已进入 我执');
|
||||
const cap = 2 * max; // max_minus_hp_ratio
|
||||
approx(negOf()[1], 300 / cap, 'the share of the cap', 0.02);
|
||||
h.b.dealDamage(e, u, { amount: 400, type: 'true' });
|
||||
h.step();
|
||||
approx(negOf()[1], 700 / cap, 'it grew with the damage', 0.02);
|
||||
assert.ok(negOf()[1] < 1, 'still below a full pool');
|
||||
assert.ok(u.hp / max < 0.01, 'her green bar sits on the 1-HP floor: the red bar is the whole story');
|
||||
assert.equal(h.b.snapshot().units.find((t) => t[0] === u.id).length, 9, 'the tuple is not widened');
|
||||
assert.ok(h.runUntil(() => !u.mem.hsEgo, 60), 'the quiet-time regeneration clears the pool');
|
||||
h.step();
|
||||
assert.equal(h.b.snapshot().neg, undefined, 'no pool, no red bar');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('T1 业火 我执: a lethal hit leaves her on the field (HP floor) with the excess as negative HP; later damage goes there (受击回复 SP still), 禁疗 meanwhile; 200 % of max HP knocks her out; 4 s without damage ⇒ 生命回复速度 5 %/s clears it and she leaves 我执', () => {
|
||||
for (const [tier, elite] of [[5, false], [6, true]]) {
|
||||
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
// 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 });
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// test/sim/hud-readouts.test.js — the optional b.snap lists behind the HP-bar readouts (R22-HUD; PR #303 and #378 by
|
||||
// @IceCodeNew, PR #383 by @Convey123): `ammo` (a running ammo skill's rounds — 隐现's S2 holds 14 in the data), `wolves`
|
||||
// (伺夜's 狼群 count) and, in test/content/op_hsgma2.test.js, `neg` (斩业星熊's 我执 pool). All three are display only: the
|
||||
// nine-field unit tuples are unchanged and no sim state reads them. The client side is test/render/hud-readouts.test.js.
|
||||
// Run: node --test test/sim/hud-readouts.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { unitInfo, unitTuple } from '../../server/sim/snapshot.js';
|
||||
|
||||
const DUMMY = { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } };
|
||||
const INSIDE = 'chess_char_1_01_a'; // 隐现: the chess skill index 1 is skchr_inside_2 (AMMO, attack@trigger_time 14)
|
||||
|
||||
test('隐现 S2: b.snap `ammo` carries the whole rounds left and the 14-round magazine of the data; the tuple keeps its SP fraction', () => {
|
||||
const h = makeBattle({
|
||||
defs: DUMMY, timeLimit: 200, seed: 7,
|
||||
units: [{ chessId: INSIDE, row: 10, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
});
|
||||
const u = h.unit(INSIDE);
|
||||
const row = () => (h.b.snapshot().ammo || []).find((e) => e[0] === u.id);
|
||||
assert.equal(h.b.snapshot().ammo, undefined, 'no skill running: no list at all');
|
||||
assert.equal(unitInfo(u).ammoSkill, true, 'UnitInfo says the skill is an ammo magazine from the first appearance');
|
||||
assert.ok(h.runUntil(() => u.skill.active, 80));
|
||||
assert.equal(u.skill.kind, 'ammo');
|
||||
assert.equal(u.skill.ammoMax, 14, 'skill_table skchr_inside_2 attack@trigger_time at the chess skill level');
|
||||
// the activation tick already fired the first round
|
||||
assert.deepEqual(row(), [u.id, 13, 14]);
|
||||
const [, , , , , sp, spMax] = unitTuple(u, h.b.time);
|
||||
assert.ok(Math.abs(sp / spMax - 13 / 14) < 0.01, 'the 9-field tuple still runs the SP bar on the same fraction');
|
||||
assert.equal(h.b.snapshot().units.find((t) => t[0] === u.id).length, 9, 'the tuple is not widened');
|
||||
// every attack spends one round
|
||||
const before = u.skill.ammoLeft;
|
||||
assert.ok(h.runUntil(() => u.skill.ammoLeft < before, 10));
|
||||
assert.deepEqual(row(), [u.id, u.skill.ammoLeft, 14]);
|
||||
// a refill above the magazine raises it (拉特兰's extras, 逃犯引渡手续, refills: the activation's real total)
|
||||
u.skill.addAmmo(20);
|
||||
assert.deepEqual(row(), [u.id, u.skill.ammoLeft, u.skill.ammoMax]);
|
||||
assert.ok(u.skill.ammoMax > 14 && row()[1] === row()[2], 'full again at the new mark');
|
||||
// the skill ends with its last round: the list goes, the plain SP bar is back
|
||||
assert.ok(h.runUntil(() => !u.skill.active, 60));
|
||||
assert.equal(h.b.snapshot().ammo, undefined);
|
||||
checkInvariants(h.b);
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
|
||||
test('ammo rows: whole numbers (a fractional count rounds up), allies only, living deployed units only; an operator without an ammo skill is no ammoSkill', () => {
|
||||
const h = makeBattle({
|
||||
defs: DUMMY, timeLimit: 200, seed: 7,
|
||||
units: [{ chessId: INSIDE, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 11, col: 3 }],
|
||||
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||
});
|
||||
const u = h.unit(INSIDE);
|
||||
h.run(0.5);
|
||||
assert.equal(unitInfo(h.unit('chess_char_1_02_a')).ammoSkill, undefined, 'a duration skill is not a magazine');
|
||||
assert.equal(unitInfo(h.enemy('enemy_dummy')).ammoSkill, undefined);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 80));
|
||||
u.skill.ammoLeft = 6.2; // (a kit that spends a share of a round)
|
||||
const e = h.b.snapshot().ammo.find((r) => r[0] === u.id);
|
||||
assert.deepEqual(e, [u.id, 7, 14]);
|
||||
assert.ok(Number.isInteger(e[1]) && Number.isInteger(e[2]));
|
||||
u.deployed = false; // off the field: no row (the unit is not in the snapshot either)
|
||||
assert.equal(h.b.snapshot().ammo, undefined);
|
||||
u.deployed = true;
|
||||
});
|
||||
|
||||
for (const placed of [false, true]) {
|
||||
test(`伺夜's ${placed ? 'placed' : 'automatic'} pack: b.snap \`wolves\` follows the 狼影 count (2 of 3, +1 per 25 s, one lost per lethal hit) and goes with the pack`, () => {
|
||||
const units = [{ chessId: 'chess_char_3_19_a', row: 12, col: 3, uid: 1 }];
|
||||
if (placed) units.push({ kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 });
|
||||
const h = makeBattle({
|
||||
units, 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((u) => u.defId === 'token_10028_vigil_wolf');
|
||||
const count = () => (h.b.snapshot().wolves || []).find((e) => e[0] === w.id);
|
||||
assert.deepEqual(count(), [w.id, 2, 3], '初始两只, at most 3 (the talent text)');
|
||||
assert.equal(h.b.snapshot().wolves.length, 1, 'only the pack has the row: 伺夜 herself does not');
|
||||
h.run(25);
|
||||
assert.deepEqual(count(), [w.id, 3, 3], '+1 every 25 s');
|
||||
for (const left of [2, 1]) {
|
||||
h.b.dealDamage(null, w, { amount: 1e9, type: 'true' });
|
||||
assert.deepEqual(count(), [w.id, left, 3], 'a lethal hit spends one 狼影 and refills the HP');
|
||||
}
|
||||
h.b.dealDamage(null, w, { amount: 1e9, type: 'true' });
|
||||
assert.equal(w.alive, false, 'the last 狼影: 战术点形态 (the pack is out of the fight)');
|
||||
assert.equal(count(), undefined, 'a pack in that form shows no pips');
|
||||
h.run(26);
|
||||
assert.deepEqual(count(), [w.id, 1, 3], 'back after the 狼影 interval with one 狼影');
|
||||
h.b.retreat(h.unit('chess_char_3_19_a'), { reason: 'expired', permanent: true });
|
||||
assert.equal(h.b.snapshot().wolves, undefined, 'the pack leaves with its owner');
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user