Files
ark-stronghold-protocol/test/match/unitStats.test.js
T
sganggs 620f88c0d8 Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit
- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
2026-10-02 04:19:28 +08:00

125 lines
7.3 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 },
});
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));
});