// 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)); });