Files
sganggs 821ab827bd 修复赤刃明霄陈三技能对空自动开启并核实社区反馈
S3 的扩展范围含可选中的飞行目标时可以自动开启剑气;普攻保持仅对地。
[ASSUMED] 自动触发区域沿用扩展攻击格和永久射程延长,不扩张到未来剑气的整条路线。

补测部署技能与休整属性预览、后排协防蕾缪安真实锁定耗弹、诗怀雅落地金币、
莎草对炸弹的治疗链,以及予愿三种技能的普通地面溅射和无视可选性例外。
未复现的报告保留证据边界;诗怀雅被动落地投瓶符合 PRTS。

全 285 场 golden 重建并验证,仅 diy-074 变化:新增 4 次陈 S3,伤害 142376→163563,
时序/选敌改变后击杀 21→18、漏怪 96→99,223.83 秒和结束方式不变。
其余 284 场不变。

CI:5879 tests,5876 pass,0 fail,3 skip;setup/smoke/lint/imports/typecheck 均通过。
新增 16 项回归,相关 6 文件 63/63,补充 2 文件 20/20,文档 31/31。
2026-10-10 01:02:23 +08:00

157 lines
9.1 KiB
JavaScript
Raw Permalink 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/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('prep stat previews do not spend a deployment skill duration in the next real battle', () => {
const { h, m, ps } = setup(27);
const id = 'chess_char_4_16_a'; // 缄默德克萨斯: a real timed deployment skill
const piece = give(m, ps, id, 'board', legalTileFor(m, ps, id));
const previews = [];
const create = m.newBattle.bind(m);
m.newBattle = (opts) => { const b = create(opts); previews.push(b); return b; };
for (let i = 0; i < 3; i++) {
m._unitStatsCache = null; // test fresh previews as well as the cached request
assert.deepEqual(m.handle('p_0', { t: 'g.unitStats', seq: i }), { ok: true });
const entry = h.lastTo('p_0', 'm.unitStats').units.find(u => u.uid === piece.uid);
assert.ok(entry); assert.equal('sp' in entry, false); assert.equal('timeLeft' in entry, false);
}
assert.equal(m.phase, PHASE.PREP);
for (const b of previews) {
const u = b.allyUnits.find(u => u.uid === piece.uid);
assert.equal(b.time, 0); assert.equal(b.tickCount, 0);
assert.ok(u.skill.active); assert.equal(u.skill.timeLeft, u.skill.duration);
}
const b = create(m._normalOpts(ps)); const u = b.allyUnits.find(u => u.uid === piece.uid);
assert.equal(b.started, false); assert.equal(u.deployed, false);
b.start(); assert.equal(u.skill.timeLeft, u.skill.duration);
b.step(); assert.ok(Math.abs(u.skill.timeLeft - (u.skill.duration - b.dt)) < 1e-8);
assert.deepEqual(b.errors, []); 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));
});