Files
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

304 lines
17 KiB
JavaScript

// Operator loadout (DESIGN §16) — shared checks (shared/protocol.js checkLoadout / loadoutOptions / resolveLoadout),
// the match side (seats[].loadout → PlayerState → PlayerBattleInput units skillIndex / moduleId → BattleSpec, m.private
// `loadout`, Match.setLoadout only during INFO_CHECK, bots on defaults) and the solo timers (user playtest #11: official
// 独立模拟 has no countdown outside combat).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { validateC2S, checkLoadout, loadoutOptions, resolveLoadout, isLoadoutEntries, MODULE_NONE, LOADOUT_LIMITS } from '../../shared/protocol.js';
import { buildBattleSpec } from '../../server/sim/spec.js';
import { DATA, makeMatch } from './harness.js';
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const visible = Object.values(DATA.chess).filter((c) => !c.isGolden && c.visible && !c.isHidden && !c.isDiy);
// fixtures from the real data (DESIGN §16 skills[] / modules[])
const INSIDE = 'chess_char_1_01_a'; // 隐现: S1 / S2 (default S2), elite module MAR-X only
const SWIRE = 'chess_char_3_04_a'; // 琳琅诗怀雅: three modules
const multiMod = visible.find((c) => (chess(c.goldenId)?.modules || []).length >= 2);
const hidden = Object.values(DATA.chess).find((c) => !c.isGolden && (c.isHidden || c.visible === false));
function seatsWith(loadout, { bots = 0, botLoadout = null } = {}) {
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout }];
for (let i = 0; i < bots; i++) seats.push({ seat: seats.length, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true, loadout: botLoadout });
return seats;
}
/** Put a chess piece straight onto a board tile (no pool / legality: the battle input is what is under test). */
function place(m, ps, chessId, row, col) {
const p = ps.newPiece('chess', chessId);
ps.board.set(`${row},${col}`, p);
return p;
}
// ---- shared checks -------------------------------------------------------------------------------------------------
test('data carries the §16 choices every visible chess needs (skills at both statuses, elite modules + none)', () => {
assert.equal(visible.length, 112);
for (const c of visible) {
const g = chess(c.goldenId);
const o = loadoutOptions(c, g);
assert.ok(o.skills.length >= 2, `${c.name}: selectable skills`);
assert.ok(o.skills.includes(o.defaultSkill), `${c.name}: default skill is selectable`);
assert.equal(o.defaultSkill, c.skill.index, `${c.name}: default = defaultSkillIndex`);
assert.ok(o.modules.includes(MODULE_NONE) && o.modules.includes(o.defaultModule), `${c.name}: modules`);
}
const o = loadoutOptions(chess(INSIDE), chess(chess(INSIDE).goldenId));
assert.deepEqual(o.skills, [0, 1]);
assert.equal(o.defaultSkill, 1);
assert.deepEqual(o.modules, ['uniequip_002_inside', MODULE_NONE]);
assert.equal(o.defaultModule, 'uniequip_002_inside');
assert.ok(multiMod, 'a chess with several modules');
});
test('room.loadout structure: map of ≤160 base ids → { skill?, module? }, nothing else', () => {
const ok = (entries) => validateC2S({ t: 'room.loadout', entries }) === null;
assert.ok(ok({}));
assert.ok(ok({ [INSIDE]: { skill: 0 } }));
assert.ok(ok({ [INSIDE]: { module: 'none' } }));
assert.ok(ok({ [INSIDE]: { skill: 1, module: 'uniequip_002_inside' } }));
assert.ok(!ok([]), 'array');
assert.ok(!ok(null), 'null');
assert.ok(!ok({ [INSIDE]: {} }), 'empty entry');
assert.ok(!ok({ [INSIDE]: { skill: 0, extra: 1 } }), 'unknown field');
assert.ok(!ok({ [INSIDE]: { skill: LOADOUT_LIMITS.skillIndex + 1 } }), 'skill index range');
assert.ok(!ok({ [INSIDE]: { skill: 0.5 } }), 'non-integer skill');
assert.ok(!ok({ [INSIDE]: { module: '' } }), 'empty module id');
assert.ok(!ok({ [INSIDE]: { module: 'a b' } }), 'bad module id');
assert.ok(!ok({ 'bad id!': { skill: 0 } }), 'bad chess id');
const many = {};
for (let i = 0; i <= LOADOUT_LIMITS.entries; i++) many[`c${i}`] = { skill: 0 };
assert.ok(!ok(many), 'too many entries');
assert.equal(validateC2S({ t: 'room.loadout' }), 'bad field entries');
assert.ok(!isLoadoutEntries(Object.create({ [INSIDE]: { skill: 0 } })), 'plain objects only');
});
test('checkLoadout: strict semantic check against the data; defaults dropped; normalised { skill, module }', () => {
const get = chess;
assert.deepEqual(checkLoadout({}, get), { ok: true, loadout: {} });
// a non-default skill; the module is completed with the default
assert.deepEqual(checkLoadout({ [INSIDE]: { skill: 0 } }, get), { ok: true, loadout: { [INSIDE]: { skill: 0, module: 'uniequip_002_inside' } } });
// the default skill + default module ⇒ nothing stored
assert.deepEqual(checkLoadout({ [INSIDE]: { skill: 1 } }, get), { ok: true, loadout: {} });
assert.deepEqual(checkLoadout({ [INSIDE]: { module: 'none' } }, get), { ok: true, loadout: { [INSIDE]: { skill: 1, module: 'none' } } });
const sg = chess(chess(SWIRE).goldenId);
const alt = sg.modules.find((x) => !x.isDefault).uniEquipId;
assert.deepEqual(checkLoadout({ [SWIRE]: { module: alt } }, get).loadout[SWIRE].module, alt);
// illegal: S3 of a chess with two skills, a module of another character, golden / unknown / hidden ids
const bad = (entries, code = ERR.BAD_TARGET) => {
const r = checkLoadout(entries, get);
assert.equal(r.error, code, JSON.stringify(entries));
assert.equal(typeof r.detail, 'string');
};
bad({ [INSIDE]: { skill: 2 } });
bad({ [INSIDE]: { module: alt } });
bad({ [INSIDE]: { module: 'uniequip_999_nobody' } });
bad({ [chess(INSIDE).goldenId]: { skill: 0 } });
bad({ chess_char_9_99_a: { skill: 0 } });
if (hidden) bad({ [hidden.chessId]: { skill: 0 } });
// one bad entry rejects the whole loadout
bad({ [SWIRE]: { module: alt }, [INSIDE]: { skill: 5 } });
bad([], ERR.BAD_MSG);
// a chess without an elite record has no module choice
const solo = { ...chess(INSIDE), goldenId: null };
const noGolden = (id) => (id === INSIDE ? solo : chess(id));
assert.equal(checkLoadout({ [INSIDE]: { module: 'none' } }, noGolden).error, ERR.BAD_TARGET);
assert.deepEqual(checkLoadout({ [INSIDE]: { skill: 0 } }, noGolden), { ok: true, loadout: { [INSIDE]: { skill: 0, module: null } } });
});
test('resolveLoadout: normal chess → moduleId null; elite → the chosen module / none; others → defaults', () => {
const lo = checkLoadout({ [INSIDE]: { skill: 0, module: 'none' } }, chess).loadout;
const n = chess(INSIDE);
const g = chess(n.goldenId);
assert.deepEqual(resolveLoadout(lo, n, chess), { skillIndex: 0, moduleId: null });
assert.deepEqual(resolveLoadout(lo, g, chess), { skillIndex: 0, moduleId: 'none' });
assert.deepEqual(resolveLoadout(null, g, chess), { skillIndex: 1, moduleId: 'uniequip_002_inside' });
const other = chess(SWIRE);
assert.deepEqual(resolveLoadout(lo, other, chess), { skillIndex: other.skill.index, moduleId: null });
// a stale entry (not selectable any more) falls back to the defaults, never throws
assert.deepEqual(resolveLoadout({ [INSIDE]: { skill: 7, module: 'gone' } }, g, chess), { skillIndex: 1, moduleId: 'uniequip_002_inside' });
});
// ---- match side ------------------------------------------------------------------------------------------------------------
test('seats[].loadout → PlayerState (re-checked, frozen), m.private.loadout, battle input skillIndex / moduleId; bots on defaults', () => {
const sg = chess(chess(SWIRE).goldenId);
const alt = sg.modules.find((x) => !x.isDefault).uniEquipId;
const loadout = checkLoadout({ [INSIDE]: { skill: 0, module: 'none' }, [SWIRE]: { skill: 0, module: alt } }, chess).loadout;
const h = makeMatch({ mode: 'coop', seats: seatsWith(loadout, { bots: 1, botLoadout: loadout }), seed: 3 }).start();
const m = h.m;
const ps = h.ps('p_0');
const bot = h.ps('ai_0');
assert.deepEqual(ps.loadout, loadout);
assert.ok(Object.isFrozen(ps.loadout) && Object.isFrozen(ps.loadout[INSIDE]));
assert.deepEqual(bot.loadout, {}, 'bots fight with the defaults');
h.flushAll();
assert.deepEqual(h.lastTo('p_0', 'm.private').loadout, loadout, 'm.private exposes the effective loadout');
h.toPrep(1);
for (const p of [...ps.board.values()]) ps.returnCopies(p);
ps.board.clear();
place(m, ps, INSIDE, 9, 3); // normal: S1, no module
place(m, ps, chess(INSIDE).goldenId, 10, 3); // elite: S1, module none
place(m, ps, sg.chessId, 11, 3); // elite: S1, the alternative module
const plain = visible.find((c) => c.chessId !== INSIDE && c.chessId !== SWIRE);
place(m, ps, plain.goldenId, 12, 3); // not in the loadout: defaults
const units = ps.battleInput().units;
const by = (id) => units.find((u) => u.chessId === id);
assert.equal(by(INSIDE).skillIndex, 0);
assert.equal(by(INSIDE).moduleId, null, 'normal chess have no module');
assert.deepEqual([by(chess(INSIDE).goldenId).skillIndex, by(chess(INSIDE).goldenId).moduleId], [0, 'none']);
assert.deepEqual([by(sg.chessId).skillIndex, by(sg.chessId).moduleId], [0, alt]);
const pg = chess(plain.goldenId);
assert.deepEqual([by(plain.goldenId).skillIndex, by(plain.goldenId).moduleId], [pg.skill.index, (pg.modules.find((x) => x.isDefault) || {}).uniEquipId || 'none']);
// the BattleSpec keeps them (JSON round trip; the normal chess's null module is dropped = default)
const spec = buildBattleSpec(m._normalOpts(ps));
const su = spec.players[0].units;
assert.equal(su.find((u) => u.chessId === INSIDE).skillIndex, 0);
assert.ok(!('moduleId' in su.find((u) => u.chessId === INSIDE)));
assert.equal(su.find((u) => u.chessId === sg.chessId).moduleId, alt);
// the bot's own board uses the defaults even for the same chess
bot.board.clear();
place(m, bot, chess(INSIDE).goldenId, 10, 3);
const bu = bot.battleInput().units[0];
assert.deepEqual([bu.skillIndex, bu.moduleId], [1, 'uniequip_002_inside']);
m.dispose();
});
test('client-side combat: the b.start spec of the owner carries the loadout of its board', () => {
const loadout = checkLoadout({ [INSIDE]: { skill: 0, module: 'none' } }, chess).loadout;
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: seatsWith(loadout), seed: 9, clientCombat: true, clients: false }).start();
const m = h.m;
h.toPrep(1);
const ps = h.ps('p_0');
for (const p of [...ps.board.values()]) ps.returnCopies(p);
ps.board.clear();
place(m, ps, chess(INSIDE).goldenId, 10, 3);
ps.resolveTemp();
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
h.run(() => m.phase === PHASE.COMBAT);
const start = h.lastTo('p_0', 'b.start');
assert.ok(start && start.spec, 'b.start sent');
const u = start.spec.players[0].units.find((x) => x.chessId === chess(INSIDE).goldenId);
assert.deepEqual([u.skillIndex, u.moduleId], [0, 'none']);
m.dispose();
});
test('Match.setLoadout: accepted during INFO_CHECK only; re-checked; bots / strangers refused', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 4 }).start();
const m = h.m;
const ps = h.ps('p_0');
assert.deepEqual(ps.loadout, {});
const lo = checkLoadout({ [INSIDE]: { skill: 0 } }, chess).loadout;
assert.deepEqual(m.setLoadout('p_0', lo), { ok: true });
assert.deepEqual(ps.loadout, lo);
h.flushAll();
assert.deepEqual(h.lastTo('p_0', 'm.private').loadout, lo);
assert.equal(m.setLoadout('p_0', { [INSIDE]: { skill: 9 } }).error, ERR.BAD_TARGET, 'illegal for the data');
assert.deepEqual(ps.loadout, lo, 'unchanged after a refusal');
assert.equal(m.setLoadout('ai_0', lo).error, ERR.NOT_IN_ROOM);
assert.equal(m.setLoadout('nobody', lo).error, ERR.NOT_IN_ROOM);
for (const id of ['p_0', 'p_1']) m.handle(id, { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
const r = m.setLoadout('p_0', {});
assert.equal(r.error, ERR.WRONG_PHASE, 'locked after INFO_CHECK');
assert.deepEqual(ps.loadout, lo);
m.dispose();
});
test('an illegal seat loadout (stale data) falls back to the defaults with a warning', () => {
const h = makeMatch({ mode: 'solo', seats: seatsWith({ [INSIDE]: { skill: 6, module: null } }), seed: 2 });
assert.deepEqual(h.ps('p_0').loadout, {});
assert.ok(h.logs.warn.some((w) => /loadout/.test(w)));
h.m.dispose();
});
// ---- solo timers (#11) ---------------------------------------------------------------------------------------------------
test('solo: no deadline outside combat (INFO_CHECK, strategy draft, 机变, prep and the fixed transitions); combat timed', () => {
// scripted battles (no leaks: the seat survives to the 机变 rounds), paced in virtual time (combat deadlines are real)
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 12, fake: true, instant: false }).start();
const m = h.m;
const seen = new Map(); // phase → [deadline…]
const note = (pub) => { if (!seen.has(pub.phase)) seen.set(pub.phase, []); seen.get(pub.phase).push(pub.deadline); };
h.onBroadcast.push((msg) => { if (msg.t === 'm.public') note(msg); });
for (const msg of h.bc) if (msg.t === 'm.public') note(msg);
// INFO_CHECK waits for the player (no 25 s guard in solo)
assert.equal(m.phase, PHASE.INFO_CHECK);
assert.equal(m.deadline, 0);
h.sched.advance(10 * 60_000);
assert.equal(m.phase, PHASE.INFO_CHECK, 'solo briefing is untimed');
m.handle('p_0', { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
assert.equal(m.deadline, 0);
h.sched.advance(5 * 60_000);
assert.equal(m.phase, PHASE.BAND_DRAFT, 'solo strategy draft is untimed');
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === 2), 'reached PREP R2');
h.sched.advance(60 * 60_000);
assert.equal(m.phase, PHASE.PREP, 'solo prep is untimed');
assert.equal(m.round, 2);
// on past the first 机变 round
const spRounds = m.gd.spRounds();
const lastR = (spRounds.find((r) => r > 1) || 3) + 1;
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === lastR), `reached PREP R${lastR}`);
h.flushAll();
for (const ph of [PHASE.INFO_CHECK, PHASE.BAND_DRAFT, PHASE.BATTLE_CHECK, PHASE.ROUND_START, PHASE.PREP, PHASE.SETTLE]) {
assert.ok(seen.has(ph), `saw ${ph}`);
assert.ok(seen.get(ph).every((d) => d === 0), `${ph}: no deadline in solo (${seen.get(ph)})`);
}
if (spRounds.some((r) => r < lastR)) {
assert.ok(seen.has(PHASE.SP_DRAFT), 'saw 机变');
assert.ok(seen.get(PHASE.SP_DRAFT).every((d) => d === 0), '机变 untimed in solo');
}
assert.ok(seen.get(PHASE.COMBAT).some((d) => d > 0), 'combat keeps its time limit');
m.dispose();
});
test('co-op keeps its guards: INFO_CHECK 25 s, prep timer, transition deadlines', () => {
// two humans: a single human is untimed like solo (Match.soloUntimed, user playtest #4 item 3)
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 12 }).start();
const m = h.m;
const pub = h.lastBc('m.public');
assert.equal(pub.phase, PHASE.INFO_CHECK);
assert.equal(pub.deadline - pub.serverNow, 25000);
h.toPrep(1);
assert.ok(m.deadline > 0, 'co-op prep is timed');
m.dispose();
});
// ---- review fixes -----------------------------------------------------------------------------------------------------------
test('a skill summon is a hand card only with that skill (user playtest #6): 赫默 on S2 gets her 医疗探机, on S1 none', async () => {
const { give } = await import('./harness.js');
const SILENCE = 'chess_char_2_02_a';
const DRONE = 'token_10000_silent_healrb';
assert.equal(chess(SILENCE).skills.find((s) => s.overrideTokenKey === DRONE)?.isDefault, true, 'fixture: the drone is the default S2 summon');
// the drone is placed by hand (PRTS 卫戍协议/帮助 "可手动部署的附属召唤物…加入手牌区"); in battle it takes its tile when S2
// fires (test/content/playtest6_summons.test.js) — only S2 (index 1, the default) makes it
const tokensIn = (ps, id = DRONE) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === id);
assert.equal(DATA.tokens[DRONE].placeable, true, 'tokens.json: a manually deployable summon');
for (const skill of [null, 0, 1]) {
const want = skill === 0 ? 0 : 1;
const lo = skill == null ? null : checkLoadout({ [SILENCE]: { skill } }, chess).loadout;
const h = makeMatch({ mode: 'solo', seats: seatsWith(lo), seed: 5 }).start();
h.toPrep(1);
const ps = h.ps('p_0');
for (const p of [...ps.board.values()]) ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
give(h.m, ps, SILENCE, 'board', [9, 3]);
assert.equal(tokensIn(ps).length, want, `normal 赫默, skill ${skill ?? 'default'}`);
ps.board.clear();
ps.hand.fill(null);
give(h.m, ps, chess(SILENCE).goldenId, 'board', [9, 4]);
assert.equal(tokensIn(ps).length, want, `elite 赫默, skill ${skill ?? 'default'}`);
// a talent summon still is a hand card: 伺夜 → 狼群
ps.board.clear();
ps.hand.fill(null);
give(h.m, ps, 'chess_char_3_19_a', 'board', [9, 5]);
assert.equal(tokensIn(ps, 'token_10028_vigil_wolf').length, 1, '伺夜 sends its 狼群 to the hand');
h.m.dispose();
}
});