Seen: the Art 教鞭 (杜宾's strategy) drew three 战术特训 cards and added a random one — a documented simplification (docs/META.md §2.5: a personal choice would need its own phase, protocol message, timer and AI takeover). Official: act2autochess choice_event hunter_band_1 is a PERSONAL_CHOOSE; PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项". The owner decided on 2026-10-08 to follow it. Taken from PR #353 (code and tests by @Sukvii), ported to the split 0.2.2 modules. Now: ctx.offerBountyChoice -> Match.offerBountyChoice keeps up to three different mode-eligible cards (15 of 20 in 标准 / 险境, 20 in 绝境 / 终极; one meta-rng shuffle) in PlayerState.personalChoice, m.private only (same cards after a reconnect); g.choice { idx, choiceId } confirms it (Match.pickPersonalChoice: owner, id, round, index; a request without a choiceId is still the public draft, a stale id never falls through to it). No new phase or timer: Ready is refused while it is open, the prep's deadline picks at random before the temp pieces and Ready, a bot or a 托管 seat picks with the bounty scorer (botPickCard, the bots' seeded rng; at the prep's start, after the cast, before Ready; the scheduler's fallback picks at random if a step fails). The client reuses ChoiceOverlay (personal: no order, no turns, the prep's countdown) over a locked prep, the Ready reasons, the mock harness; six UI strings in en / ja / ko / zh-TW. An eliminated player's offer goes with it; invariants.js flags an offer outside its owner's own open prep. Not taken from the PR: its §25.24 in the released docs/history/0.2.0.md (the 0.2.2 section is the integrator's), its edits of the Ready toggle beyond the personal-choice case, its tail placement of the i18n keys. Golden: coop2-NORMAL-8-serverrun (ai_0 杜宾 casts 教鞭 from R2 on and now scores the three cards: teamLp 39 -> 34, virtualMs 121400 -> 118400, rng shop 511 -> 483 / bots 18063 -> 18070 / meta 112 -> 103) and coop2-ABYSS-10-boosted (ai_1 杜宾: virtualMs 121650 -> 124650, shop 590 -> 589 / bots 22075 -> 22096 / meta 141 -> 154); both first differ in round 2, every other scenario and family is unchanged; node tools/golden.mjs --twice is identical. Tests: items.test.js (three distinct cards, the tapped one applied once, nothing before the confirm, a refused cast keeps the Art / count / rng / uid, an eliminated player's offer), economy.test.js (Ready refused, the deadline picks before endPrep, solo prep untimed), connection.test.js (two players' offers, reconnect, foreign / duplicate / old ids, an offline player's pick at the deadline), bot.test.js (托管 takeover incl. the failing-step fallback), bot-smarter.test.js (the scorer's pick, deterministic per seed, no rng draw when nothing is pending), followups.test.js (R14 / R15), draft.test.js, unitStats.test.js (protocol), hardening.test.js (invariant), gameLogic / playtest3 / playtest4 (view model, Ready reason, ChoiceView, actions), mock.e2e.test.js and playtest2.real.e2e.test.js (browser, SP_E2E=1).
131 lines
7.7 KiB
JavaScript
131 lines
7.7 KiB
JavaScript
// 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/intents.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'));
|
||
assert.equal(validateC2S({ t: 'g.choice', idx: 0 }), null);
|
||
assert.equal(validateC2S({ t: 'g.choice', idx: 2, choiceId: 'seed-2.choice.12' }), null);
|
||
for (const choiceId of [null, '', 'a b', 12, 'a'.repeat(65)]) {
|
||
assert.notEqual(validateC2S({ t: 'g.choice', idx: 0, choiceId }), null);
|
||
}
|
||
});
|
||
|
||
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));
|
||
});
|