Files
ark-stronghold-protocol/test/match/unitStats.test.js
T
sganggs 2723a3daea feedback3 WJ: grey the 折射 line and drop its icon while silenced
The RES bonus was already off. The enemy card now greys a SILENCE-format line from the live silenced flag, and the status row leaves the 折射 icon out while silence is up.
2026-10-04 20:38:26 +08:00

126 lines
7.4 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.
// Live operator stats (user playtest #4 item 7 — the detail card showed the fixed record numbers): g.unitStats answers
// m.unitStats { seq, round, units } with the stats every unit of the player's board starts its next battle with — the
// real battle input (equipment, bonds / layers, 特质, band and 机变 effects, the onBattleStart meta) built into a Battle
// that is started and read, never stepped (server/match/Match.js unitStats); the shape is shared/protocol.js
// unitStatsEntry (also the browser runner's live battle stats, test/match/runner.test.js). The preview changes nothing
// of the match.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { validateC2S, unitStatsEntry, S2C } from '../../shared/protocol.js';
import { makeMatch, give, giveItem, chessOfTier, legalTileFor } from './harness.js';
/** 阿戈尔重刃: ATK +40 % (its own multiplier), attack speed −10 (data/items.json). */
const BLADE = 'chess_item_3_07_e_a';
/** 双模机械臂: ATK +30 % (a second, different item: both apply — two equal ones would merge into the hand). */
const ARM = 'chess_item_5_03_e_a';
/** Solo PREP R1 with one chess on an empty board carrying 阿戈尔重刃 (equipped through g.equip). */
function setup(seed = 21, opts = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, ...opts }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
const id = chessOfTier(1, (c) => c.position === 'MELEE' && !m.gd.placeableTokens(c.chessId).length).find((x) => m.pool.has(x));
const carrier = give(m, ps, id, 'board', legalTileFor(m, ps, id));
const item = giveItem(m, ps, BLADE);
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }), { ok: true });
return { h, m, ps, carrier };
}
test('protocol: g.unitStats { seq? } and g.bandFocus { bandId? } validate; m.unitStats is a known push', () => {
assert.equal(validateC2S({ t: 'g.unitStats' }), null);
assert.equal(validateC2S({ t: 'g.unitStats', seq: 12 }), null);
assert.notEqual(validateC2S({ t: 'g.unitStats', seq: -1 }), null);
assert.notEqual(validateC2S({ t: 'g.unitStats', seq: 1.5 }), null);
assert.equal(validateC2S({ t: 'g.bandFocus' }), null, 'no band = clear the highlight');
assert.equal(validateC2S({ t: 'g.bandFocus', bandId: null }), null);
assert.equal(validateC2S({ t: 'g.bandFocus', bandId: 'band_bldsk' }), null);
assert.notEqual(validateC2S({ t: 'g.bandFocus', bandId: 'a b' }), null);
assert.ok(S2C.includes('m.unitStats'));
});
test('unitStatsEntry: effective stats next to the base, rounded for display; the interval from bat / aspd when absent', () => {
const u = { id: 3, uid: 44, defId: 'chess_x', hp: 812.6, alive: true, base: { maxHp: 1000, atk: 300, def: 100, res: 10, aspd: 100, bat: 1.2, blockCnt: 2, moveSpeed: 0 } };
const e = unitStatsEntry(u, { maxHp: 1250.4, atk: 420.49, def: 90, res: 12.34, interval: 1.0833333, blockCnt: 3, moveSpeed: 0 });
assert.deepEqual(e, {
id: 3, uid: 44, defId: 'chess_x', hp: 813, alive: true,
maxHp: 1250, atk: 420, def: 90, res: 12.3, interval: 1.08, blockCnt: 3, moveSpeed: 0,
base: { maxHp: 1000, atk: 300, def: 100, res: 10, interval: 1.2, blockCnt: 2, moveSpeed: 0 },
silenced: false,
});
const plain = unitStatsEntry(u);
assert.equal(plain.atk, 300, 'no aggregated stats: the base');
assert.equal(plain.interval, 1.2);
assert.equal(unitStatsEntry(null).maxHp, 0, 'never throws');
assert.equal(unitStatsEntry({ base: { bat: 0 } }).interval, null, 'no attack');
});
test('g.unitStats: the board\'s start-of-battle stats (equipment in: ATK ×1.4, a slower attack), the same numbers the battle starts with; seq echoed', () => {
const { h, m, ps, carrier } = setup();
assert.deepEqual(m.handle('p_0', { t: 'g.unitStats', seq: 7 }), { ok: true });
const msg = h.lastTo('p_0', 'm.unitStats');
assert.ok(msg, 'answered with a push');
assert.equal(msg.seq, 7);
assert.equal(msg.round, 1);
const boardUids = [...ps.board.values()].map((p) => p.uid).sort((a, b) => a - b);
assert.deepEqual(msg.units.map((u) => u.uid).sort((a, b) => a - b), boardUids, 'every board unit, by uid');
const u = msg.units.find((x) => x.uid === carrier.uid);
assert.ok(u.atk > u.base.atk, `ATK up (${u.base.atk} → ${u.atk})`);
assert.ok(Math.abs(u.atk - Math.round(u.base.atk * 1.4)) <= 1, `阿戈尔重刃 multiplies ATK by 1.4 (${u.atk})`);
assert.ok(u.interval > u.base.interval, `attack speed −10: a longer interval (${u.base.interval} → ${u.interval})`);
assert.equal(u.hp, u.maxHp, 'full HP at the start');
assert.equal(u.alive, true);
// exactly what the real battle of this board starts with (the same input, onBattleStart meta included)
const b = m.newBattle(m._normalOpts(ps));
b.start();
const bu = b.allyUnits.find((x) => x.uid === carrier.uid);
const { id: _a, ...want } = unitStatsEntry(bu, bu.s);
const { id: _b, ...have } = u;
assert.deepEqual(have, want);
// no seq: null; a second request with the same state answers the cached numbers
assert.deepEqual(m.handle('p_0', { t: 'g.unitStats' }), { ok: true });
assert.equal(h.lastTo('p_0', 'm.unitStats').seq, null);
assert.deepEqual(h.lastTo('p_0', 'm.unitStats').units, msg.units);
m.dispose();
});
test('g.unitStats follows the prep state: another item changes the numbers; prep phases only', () => {
const { h, m, ps, carrier } = setup(22);
m.handle('p_0', { t: 'g.unitStats', seq: 1 });
const before = h.lastTo('p_0', 'm.unitStats').units.find((x) => x.uid === carrier.uid);
const item = giveItem(m, ps, ARM);
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }), { ok: true });
assert.deepEqual(carrier.items.map((x) => x.id).sort(), [ARM, BLADE].sort());
m.handle('p_0', { t: 'g.unitStats', seq: 2 });
const after = h.lastTo('p_0', 'm.unitStats').units.find((x) => x.uid === carrier.uid);
// equipment percentages are 直接乘算 — they add up (PRTS 盟约记录 / 游戏数据基础; DESIGN §20.10): +40 % and +30 % = +70 %
assert.ok(Math.abs(after.atk - Math.round(after.base.atk * (1 + 0.4 + 0.3))) <= 1, `the items' percentages add up: ATK ${before.atk} → ${after.atk}`);
// outside the prep phases: WRONG_PHASE, nothing pushed
const n = h.allTo('p_0', 'm.unitStats').length;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
assert.equal(m.handle('p_0', { t: 'g.unitStats', seq: 3 }).error, ERR.WRONG_PHASE);
assert.equal(h.allTo('p_0', 'm.unitStats').length, n);
m.dispose();
});
test('the preview changes nothing of the match: two identical matches, one asking for the stats all along, stay identical', () => {
const run = (ask) => {
const { h, m, ps } = setup(23);
const snaps = [];
for (let r = 1; r <= 3; r++) {
if (ask) for (let k = 0; k < 3; k++) assert.deepEqual(m.handle('p_0', { t: 'g.unitStats', seq: k }), { ok: true });
snaps.push(JSON.stringify(ps.privateView()));
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === r + 1), `PREP R${r + 1}`);
}
snaps.push(JSON.stringify(ps.privateView()), JSON.stringify({ ...m.publicView(), serverNow: 0, deadline: 0 }));
m.dispose();
return snaps;
};
assert.deepEqual(run(true), run(false));
});