0.2.4-dev: round-29 community fixes and the official shared card pool

- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
This commit is contained in:
sganggs committed 2026-10-11 01:58:52 +08:00
1 parent 1db8e51023
commit 6bfc08270c
129 files changed
+3477 -1011

No files matched your search

+4 -2
View File
@@ -254,8 +254,10 @@ test('withdrawing a deployed summon into a full hand: HAND_FULL like any other c
assert.ok(drone && drone.count === 1, 'one 狼群');
// (9,6): inside 伺夜's range ("只能部署在召唤者攻击范围内", player report #9 after 0.1.0)
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 6 } }), { ok: true });
// golden items never merge with each other: a plain full hand
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_02_e_b', 'hand', i);
// One distinct golden per empty slot: goldens do not merge, and each base stays within its shared cap.
const fillers = Object.values(DATA.items).filter((r) => r.isGolden && r.itemType === 'EQUIP' && (m.gd.itemPoolCopies(r.id) ?? 0) >= 2).map((r) => r.id);
let n = 0;
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, fillers[n++], 'hand', i);
assert.ok(ps.hand.every(Boolean) && ps.tempEmpty);
assert.equal(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'a new card for a full hand');
assert.equal(ps.board.get('9,6'), drone, 'the 狼群 stays on the board');
+15
View File
@@ -68,6 +68,21 @@ test('调和: +1 to core bonds with ≥ 1 real member on the board while active'
assert.equal(s.yanShip.count, 2);
});
test('actual normal/elite 缪尔赛思 activates every available core bond at two real members; duplicates add only one', () => {
for (const id of gd.bondIds.filter(id => gd.bond(id).isCore && !gd.modeInactiveBonds.has(id))) {
const two = members(id, 2, c => !c.bonds.includes('maniShip'));
assert.equal(two.length, 2, id);
for (const mu of ['chess_char_6_11_a', 'chess_char_6_11_b']) {
const s = computeBonds(gd, state({ board: [piece(mu), ...two.map(id => piece(id))] }));
assert.equal(s[id].count, 3, `${id}: two operators plus ${mu}`); assert.ok(s[id].active);
}
const dup = computeBonds(gd, state({ board: [piece('chess_char_6_11_a'), piece('chess_char_6_11_b'), ...two.map(id => piece(id))] }));
assert.equal(dup[id].count, 3, `${id}: duplicate harmony is not +2`);
const hand = computeBonds(gd, state({ board: two.map(id => piece(id)), hand: [piece('chess_char_6_11_a')] }));
assert.equal(hand[id].count, 2, `${id}: harmony needs deployment`);
}
});
test('独行 (downward): active only while exactly 1 distinct 独行 operator is on the board', () => {
const solo = members('soloShip', 2);
assert.equal(tierFor(DATA.bonds.soloShip, 1), 1);
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
test('a solo bot used for rehearsal has no AI teammate and gets no extra grace', () => {
const h = makeMatch({ mode: 'solo', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true }], fake: true, instant: false }).start();
const m = h.m; m.setDeadline(0); m.order[0].lp = 40; m.round = m.gd.bossRound;
m.startFinalAssault(false);
assert.equal(m.bossOvertimeStartReal, m.gd.bossLevelTime(m.round)); m.dispose();
});
for (const hidden of [false, true]) for (const bots of [0, 1]) {
test(`boss overtime follows the visible clock unless an AI teammate is present (hidden=${hidden}, bots=${bots})`, () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots, fake: true, instant: false }).start();
const m = h.m;
m.setDeadline(0);
for (const p of m.order) p.lp = 40;
m.round = hidden ? m.gd.hiddenRound : m.gd.bossRound;
if (hidden) m.teamLp = 100;
m.startFinalAssault(hidden);
const level = m.gd.bossLevelTime(m.round);
const after = bots ? m.gd.bossOvertimeAfterReal : level;
assert.equal(m.overtimeAt, m.sched.now() + after * m.gd.combatTimeScale / m.gameSpeed * 1000);
const lp = m.teamLp;
m._applyOvertime((after + 0.9) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp);
m._applyOvertime((after + 1) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 1);
m._applyOvertime((after + 1) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 1, 'no repeated deduction');
m._applyOvertime((after + 3) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 3);
m.dispose();
});
}
+17
View File
@@ -22,6 +22,23 @@ import { DATA, makeMatch, checkInvariants } from './harness.js';
const gd = new GameData(DATA, 'mode_multi_hard');
test('client result names DIY and stand-in operators from the authoritative lineup, never the client or empty slot (#469)', () => {
const ordinary = Object.values(DATA.chess).find(c => c.visible && !c.isGolden && gd.standIn(c.chessId));
const cases = [
{ chessId: 'chess_char_6_diy1_a', diy: { charId: 'char_4231_clemnt', skillIndex: 2, uniEquipId: null }, expected: '克莱门莎' },
{ chessId: ordinary.chessId, standIn: true, expected: gd.standIn(ordinary.chessId).name },
];
for (const { expected, ...unit } of cases) {
const spec = buildBattleSpec({ battleId: 'names', fieldId: 'n:p', kind: 'normal', seed: 1, round: 3, timeLimit: 60,
players: [{ playerId: 'p', units: [{ uid: 7, kind: 'chess', row: 10, col: 3, items: [], ...unit }], bonds: {} }],
spawns: [{ enemyKey: plainKey, count: 1, time: 1 }], flags: { layerGainsEnabled: true } });
const raw = { reason: 'cleared', time: 20, killed: 1, total: 1, errors: 0,
perPlayer: { p: perPlayer({ killed: 1, total: 1, unitStats: [{ uid: 7, defId: 'forged', name: 'forged', kind: 'op', dmg: 1e6 }] }) } };
const v = validateClientResult(spec, raw, { gd }); assert.ok(v.ok, v.reason);
assert.equal(v.result.perPlayer.p.unitStats[0].name, expected);
}
});
test('eliminated humans auto-observe the first field at COMBAT start (display replica) and may switch freely', () => {
const h = makeMatch({ mode: 'coop', humans: 3, bots: 1, seed: 9301, fake: true, clientCombat: true, instant: false, pace: 'paced',
script: () => ({ duration: 6 }) }).start();
+5 -5
View File
@@ -286,7 +286,7 @@ test('Final Assault: two client-run pair fields share the server pool (b.progres
m.dispose();
});
test('Final Assault: leaks and leader LP effects from b.progress drain the team LP; overtime after 150 real s at 1 LP/real s; team LP 0 → b.end → defeat', () => {
test('Final Assault: leaks and leader LP effects from b.progress drain the team LP; all-human overtime after 120 real s at 1 LP/real s; team LP 0 → b.end → defeat', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9109, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 0, leakEvents: [{ at: 2, lpr: 3 }], lpLossEvents: [{ at: 3, amount: 2 }] } : { duration: 2 }) }).start();
const m = h.m;
@@ -296,14 +296,14 @@ test('Final Assault: leaks and leader LP effects from b.progress drain the team
h.sched.advance(4000);
assert.equal(m.teamLp, lp0 - 5, 'leak lpr 3 + leader effect 2');
const lp1 = m.teamLp;
// overtime (bossTurnHpReduceTime): 150 REAL s like the level's 120 s countdown, then 1 LP per real second
// All-human teams start overtime at the visible level countdown, without the AI team's extra 30 seconds.
const t0 = m._bossStartAt;
const pub = () => h.bc.filter((x) => x.t === 'm.public').pop();
m.flush(true);
assert.equal(pub().deadline, t0 + 120000, 'the HUD counts the boss level\'s 120 s down');
assert.equal(pub().overtimeAt, t0 + 150000, 'and knows when the drain starts');
h.sched.advance(t0 + 150000 - m.sched.now() + 500);
assert.equal(m.teamLp, lp1, 'no drain before 150 real s (the countdown ran out at 120 s, the battle went on)');
assert.equal(pub().overtimeAt, t0 + 120000, 'and knows when the drain starts');
h.sched.advance(t0 + 120000 - m.sched.now() + 500);
assert.equal(m.teamLp, lp1, 'the first deduction is at the next whole real second');
h.sched.advance(5000);
assert.ok(m.teamLp <= lp1 - 4 && m.teamLp >= lp1 - 6, `overtime drain ≈ 5 LP over 5 real s (${lp1} → ${m.teamLp})`);
const end = h.runToEnd();
+5 -3
View File
@@ -191,9 +191,11 @@ test('#9 hand and temp full: a re-orientation that would push 狼群 out is refu
const toasts = [];
const toast = m.toast.bind(m);
m.toast = (p, kind, text) => { toasts.push(renderMessage(text)); return toast(p, kind, text); }; // string or msg()
const golden = Object.keys(DATA.items).find((id) => DATA.items[id].isGolden && DATA.items[id].itemType === 'EQUIP');
while (ps.hand.includes(null)) giveItem(m, ps, golden);
while (ps.temp.includes(null)) giveItem(m, ps, golden, 'temp');
// One distinct golden per empty slot: goldens do not merge, and each base stays within its shared cap.
const fillers = Object.values(DATA.items).filter((r) => r.isGolden && r.itemType === 'EQUIP' && (m.gd.itemPoolCopies(r.id) ?? 0) >= 2).map((r) => r.id);
let n = 0;
while (ps.hand.includes(null)) giveItem(m, ps, fillers[n++]);
while (ps.temp.includes(null)) giveItem(m, ps, fillers[n++], 'temp');
// facing LEFT would leave (11,5) outside her range and the wolf has nowhere to go: refused, nothing changes
assert.equal(move(m, vigil.uid, board(10, 4), 'LEFT').error, ERR.HAND_FULL);
assert.equal(ps.board.get('10,4').dir, 'RIGHT');
+3 -2
View File
@@ -77,6 +77,7 @@ for (const n of [1, 2, 3, 4]) {
assert.equal(pool.maxHp, bossPoolHp(m.gd, m.bossId, n));
for (const f of fields) {
assert.deepEqual(f.opts.rect, GEO.BOSS_RECT);
assert.equal(f.opts.modeId, 'mode_multi_funny', '#475: an unpaired field retains multiplayer mode despite its solo wave template');
assert.equal(f.opts.timeLimit, Infinity);
assert.equal(f.opts.flags.layerGainsEnabled, false);
assert.deepEqual(f.opts.flags.enemyScale, m.gd.enemyScale(14), 'the round\'s enemy effects for the leader\'s summons (PR #272)');
@@ -109,7 +110,7 @@ for (const n of [1, 2, 3, 4]) {
});
}
test('overtime: −1 team LP per REAL second after 150 real s (300 game s at 2×); team LP 0 ends every field → defeat (13 rounds passed)', () => {
test('all-human overtime: −1 team LP per REAL second after 120 real s (240 game s at 2×); team LP 0 ends every field → defeat (13 rounds passed)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 50, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
@@ -120,7 +121,7 @@ test('overtime: −1 team LP per REAL second after 150 real s (300 game s at 2×
const end = h.runToEnd();
assert.equal(end.victory, false);
assert.equal(end.roundsPassed, 13);
assert.ok(Math.abs(f.time - 340) < 2.5, `ended ≈ 300 + 2 × 20 game s (${f.time})`);
assert.ok(Math.abs(f.time - 280) < 2.5, `ended ≈ 240 + 2 × 20 game s (${f.time})`);
assert.equal(m.teamLp, 0);
m.dispose();
});
+2 -1
View File
@@ -222,7 +222,8 @@ function legacyInvariants(m) {
}
}
for (const [base, e] of pool.entries) {
assert.ok(e.left >= 0 && e.left <= e.cap, `pool ${base} left ${e.left} cap ${e.cap}`);
assert.ok(Number.isInteger(e.left) && e.left <= e.cap, `pool ${base} balance ${e.left} cap ${e.cap}`);
assert.equal(pool.left(base), Math.max(0, e.left), 'overdrawn ownership never becomes a negative shop supply');
assert.equal(e.left + (held.get(base) || 0), e.cap, `pool accounting ${base}: left ${e.left} + held ${held.get(base) || 0} != cap ${e.cap}`);
}
for (const [base, n] of held) if (!pool.has(base)) assert.equal(n, 0, `non-pool chess ${base} holds copies`);
+133
View File
@@ -0,0 +1,133 @@
// Jessica's shield placement uses the real Match actions and the client's drag-preview mirror. The only fixture
// grant is the selected operator; its shield is generated by normal deployment. No battle/UI is simulated here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
import { placementContext, canPlace, boardTargets } from '../../public/js/ui/gameLogic/placement.js';
import { planLayout } from '../../server/match/bot.js';
const SLOT = 'chess_char_5_diy1_a';
const JESCA = 'char_1034_jesca2';
const SHIELD = 'token_10032_jesca2_jckshd';
const board = (row, col) => ({ area: 'board', row, col });
const move = (h, uid, to, dir) => h.m.handle('p_0', { t: 'g.move', uid, to, ...(dir ? { dir } : {}) });
const stackOf = (ps, owner) => [...ps.hand, ...ps.temp].find((p) => p?.kind === 'token' && p.ownerUid === owner.uid);
const ctx = (h, ps) => placementContext({ priv: ps.privateView(), stage: h.m.stage, editable: true,
getChess: (id) => ps.gd.chess(id), getToken: (id) => ps.gd.token(id), backups: ps.gd.raw.backups });
function prep(t, { row = 10, col = 4, skillIndex = 0, uniEquipId = null } = {}) {
const h = makeMatch({ mode: 'solo', seed: 11, seats: [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false,
connected: true, diy: { [SLOT]: { charId: JESCA, skillIndex, uniEquipId } } }] }).start();
t.after(() => h.m.dispose());
h.toPrep(1);
assert.equal(h.m.stageId, 'act2autochess_m04');
const ps = h.ps('p_0');
const owner = ps.acquireChess(SLOT, { source: 'grant' });
assert.ok(owner);
assert.deepEqual(move(h, owner.uid, board(row, col)), { ok: true });
assert.equal(stackOf(ps, owner)?.id, SHIELD);
return { h, ps, owner };
}
test('Jessica: hand shield refuses diagonal and distant tiles, with unchanged state and matching drag highlights', (t) => {
const { h, ps, owner } = prep(t, { row: 9, col: 5 });
const shield = stackOf(ps, owner);
for (const [row, col] of [[9, 7], [10, 4], [11, 5], [12, 3]]) {
const before = structuredClone(ps.privateView());
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).code, 'BAD_TILE', `client ${row},${col}`);
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE', `server ${row},${col}`);
assert.deepEqual(ps.privateView(), before, 'rejected placement changes no inventory or board state');
}
assert.deepEqual(boardTargets(ctx(h, ps), shield.uid).legal.sort(), [[9, 4], [9, 6], [10, 5]].sort());
h.invariants();
});
test('Jessica: the server rejects distant and diagonal placements even when the client sends them directly', (t) => {
const { h, ps, owner } = prep(t, { row: 9, col: 5 });
const shield = stackOf(ps, owner);
for (const [row, col] of [[9, 7], [10, 4]]) {
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE');
assert.equal(stackOf(ps, owner), shield);
assert.ok(!ps.board.has(`${row},${col}`));
}
h.invariants();
});
test('Jessica: all four orthogonal tiles are legal regardless of her facing, skill or selected module', (t) => {
for (const [skillIndex, uniEquipId] of [[0, null], [1, 'uniequip_002_jesca2'], [2, 'uniequip_003_jesca2']]) {
const { h, ps, owner } = prep(t, { skillIndex, uniEquipId });
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
assert.deepEqual(move(h, owner.uid, board(10, 4), dir), { ok: true });
for (const [row, col] of [[9, 4], [11, 4], [10, 3], [10, 5]]) {
const shield = stackOf(ps, owner);
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).ok, true);
assert.deepEqual(move(h, shield.uid, board(row, col)), { ok: true });
assert.deepEqual(move(h, ps.board.get(`${row},${col}`).uid, { area: 'hand', idx: ps.hand.findIndex((p) => !p) }), { ok: true });
}
}
h.invariants();
}
});
test('Jessica: a deployed shield cannot move or be swapped to a distant tile; adjacent moves and owner swaps work', (t) => {
const { h, ps, owner } = prep(t);
assert.deepEqual(move(h, stackOf(ps, owner).uid, board(10, 5)), { ok: true });
const shield = ps.board.get('10,5');
for (const [row, col] of [[11, 5], [9, 7]]) {
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).code, 'BAD_TILE');
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE');
assert.equal(ps.board.get('10,5'), shield);
}
const other = ps.acquireChess('chess_char_1_02_a', { source: 'grant' });
assert.deepEqual(move(h, other.uid, board(9, 7)), { ok: true });
for (const [uid, target] of [[shield.uid, board(9, 7)], [other.uid, board(10, 5)]]) {
assert.equal(canPlace(ctx(h, ps), uid, target).code, 'BAD_TILE');
assert.equal(move(h, uid, target).error, 'BAD_TILE');
}
assert.equal(canPlace(ctx(h, ps), shield.uid, board(11, 4)).ok, true);
assert.deepEqual(move(h, shield.uid, board(11, 4)), { ok: true });
assert.equal(canPlace(ctx(h, ps), shield.uid, board(10, 4)).ok, true);
assert.deepEqual(move(h, shield.uid, board(10, 4)), { ok: true });
assert.equal(ps.board.get('11,4'), owner);
assert.equal(ps.board.get('10,4'), shield);
h.invariants();
});
test('Jessica: owner reorientation keeps the shield; moving her returns it and withdrawal removes it', (t) => {
const { h, ps, owner } = prep(t);
assert.deepEqual(move(h, stackOf(ps, owner).uid, board(10, 5)), { ok: true });
const shield = ps.board.get('10,5');
assert.equal(canPlace(ctx(h, ps), shield.uid, board(10, 5)).action, 'orient');
assert.deepEqual(move(h, shield.uid, board(10, 5), 'LEFT'), { ok: true });
assert.deepEqual(move(h, owner.uid, board(10, 4), 'UP'), { ok: true });
assert.equal(ps.board.get('10,5'), shield);
assert.deepEqual(move(h, owner.uid, board(9, 4)), { ok: true });
assert.ok(!ps.board.has('10,5'));
const returned = stackOf(ps, owner);
assert.equal(returned.count, 1);
assert.equal(canPlace(ctx(h, ps), returned.uid, board(10, 5)).code, 'BAD_TILE');
assert.equal(move(h, returned.uid, board(10, 5)).error, 'BAD_TILE');
assert.deepEqual(move(h, returned.uid, board(9, 5)), { ok: true });
assert.deepEqual(move(h, owner.uid, { area: 'hand', idx: ps.hand.findIndex((p) => !p) }), { ok: true });
assert.ok(![...ps.board.values(), ...ps.hand, ...ps.temp].some((p) => p?.ownerUid === owner.uid));
h.invariants();
});
test('Jessica: bots plan her shield on an orthogonal tile', (t) => {
const { h, ps, owner } = prep(t);
const shield = stackOf(ps, owner);
const plan = planLayout(h.m, ps, [shield], undefined, { occupied: new Set(ps.board.keys()) });
assert.ok(['9,4', '11,4', '10,3', '10,5'].includes(plan.get(shield.uid)));
h.invariants();
});
test('other summons keep their own placement rules, including a medical drone far from its owner', (t) => {
const { h, ps } = prep(t);
const silence = ps.acquireChess('chess_char_2_02_a', { source: 'grant' });
assert.deepEqual(move(h, silence.uid, board(9, 3)), { ok: true });
const drone = stackOf(ps, silence);
assert.ok(drone);
assert.equal(canPlace(ctx(h, ps), drone.uid, board(12, 8)).ok, true);
assert.deepEqual(move(h, drone.uid, board(12, 8)), { ok: true });
h.invariants();
});
+54
View File
@@ -0,0 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../../server/index.js';
import { TestClient } from '../helpers/wsClient.js';
import { PHASE, EMOTES } from '../../shared/constants.js';
const ok = async (c, msg) => {
const r = await c.request(msg);
assert.equal(r.t, 'ok', JSON.stringify(r));
};
// Exercise the real lobby/session routing after the match has eliminated a leaker. Only the battle outcome is
// supplied directly: these tests target room ownership, not a particular operator composition or wave duration.
for (const leaverSeat of [0, 1]) for (const phase of [PHASE.PREP, PHASE.COMBAT, PHASE.UNITE]) {
test(`an eliminated ${leaverSeat === 0 ? 'host' : 'guest'} leaving ${phase} cannot evict the survivor`, async (t) => {
const errors = [], clients = [];
const srv = await startServer({ port: 0, host: '127.0.0.1', seedFn: () => 123,
log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } });
t.after(async () => { await Promise.all(clients.map(c => c.terminate())); await srv.close(); assert.deepEqual(errors, []); });
for (const name of ['Host', 'Guest']) {
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
clients.push(c); const w = await c.hello(name); c.id = w.playerId; c.token = w.token;
}
const [host, guest] = clients;
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
const state = await host.waitFor('room.state');
await ok(guest, { t: 'room.join', code: state.code });
await ok(guest, { t: 'room.ready', ready: true });
await ok(host, { t: 'room.start' });
await host.waitFor('m.public', p => p.phase === PHASE.INFO_CHECK);
const room = srv.lobby.rooms.get(state.code), m = room.match;
// Stop phase progression without replacing the actual match/lobby/session implementation.
m.setDeadline(0); m.phase = phase; m.round = 2;
for (const ps of m.order) ps.lp = 20;
const leaver = clients[leaverSeat], survivor = clients[1 - leaverSeat];
const lost = m.players.get(leaver.id); lost.lp = 0; lost.eliminate(2);
await ok(leaver, { t: 'g.leave' });
const leftAgain = await leaver.request({ t: 'room.leave' });
assert.equal(leftAgain.code, 'NOT_IN_ROOM', 'the UI tolerates this only on the departing client');
const retained = await survivor.waitFor('room.state', r => r.code === state.code && !r.seats.some(s => s?.playerId === leaver.id && s.connected));
assert.ok(retained.seats.some(s => s?.playerId === survivor.id && s.connected));
assert.equal(room.seatOf(leaver.id).left, true);
assert.equal(retained.hostId, survivor.id);
assert.equal(srv.lobby.registry.byId(survivor.id).roomCode, state.code);
assert.equal(room.match, m); assert.equal(m.ended, false);
await ok(survivor, { t: 'g.emote', id: EMOTES[0] });
assert.equal(survivor.log.some(x => x.t === 'room.closed'), false);
await survivor.terminate();
const restored = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`); clients.push(restored);
const w = await restored.hello('Survivor', survivor.token); assert.equal(w.playerId, survivor.id);
assert.equal((await restored.waitFor('room.state')).code, state.code);
await ok(restored, { t: 'g.autoplay', on: false });
});
}
+6 -3
View File
@@ -108,11 +108,14 @@ test('bench vs board, half pairs, two 变形同构体, the equip order, distinct
// half pairs grant nothing
wearer(h, 'p_0', target, [HAMMER]);
assert.equal(count(ps, bond), 0, 'the bond item alone');
// A normal plus its golden occupy 3 of the tier-VI cap. The lone copy is a separate match so the pair can sit at that cap.
const alone = prep({ fake: true });
wearer(alone, 'p_0', target, [ISO]);
assert.equal(count(alone.ps('p_0'), bond), 0, '变形同构体 alone');
const h2 = prep({ fake: true });
wearer(h2, 'p_0', target, [ISO]);
const other = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier <= 2 && !c.bonds.includes(bond) && c.chessId !== target).chessId;
wearer(h2, 'p_0', other, [ISO, ISO_B]);
assert.equal(count(h2.ps('p_0'), bond), 0, '变形同构体 alone / two of them');
assert.equal(count(h2.ps('p_0'), bond), 0, 'two of them');
// order: 变形同构体 first
const h3 = prep({ fake: true });
const w = wearer(h3, 'p_0', target, [ISO, HAMMER], { board: false });
@@ -136,7 +139,7 @@ test('bench vs board, half pairs, two 变形同构体, the equip order, distinct
const h5 = prep({ fake: true });
wearer(h5, 'p_0', members(bond)[0], [ISO, HAMMER]);
assert.equal(count(h5.ps('p_0'), bond), 1);
for (const x of [h, h2, h3, h4, h5]) x.invariants();
for (const x of [h, alone, h2, h3, h4, h5]) x.invariants();
});
test('a teammate\'s views: m.public counts the wearer; the scouting UnitInfo and the battle\'s UnitInfo carry the items', () => {
+2 -2
View File
@@ -8,14 +8,14 @@ import { DATA, makeMatch, give, checkInvariants, chessOfTier } from './harness.j
const gdOf = (modeId = 'mode_multi_normal') => new GameData(DATA, modeId);
test('pool caps follow config (12/14/18/16/8/5, 缪尔赛思 4) and only visible, unbanned chess enter', () => {
test('co-op pool doubles the measured single-player capacities; only visible, unbanned chess enter', () => {
const gd = gdOf();
const pool = new SharedPool(gd, { banned: [] });
const caps = { 1: 12, 2: 14, 3: 18, 4: 16, 5: 8, 6: 5 };
assert.equal(pool.entries.size, gd.visibleChess.length);
assert.equal(pool.entries.size, 112);
for (const [id, e] of pool.entries) {
const expect = id === 'chess_char_6_11_a' ? 4 : caps[e.tier];
const expect = 2 * (DATA.config.economy.poolCopiesOverrides[id] ?? caps[e.tier]);
assert.equal(e.cap, expect, id);
assert.equal(e.left, e.cap);
assert.ok(DATA.chess[id].visible && !DATA.chess[id].isGolden);
+1 -1
View File
@@ -7,7 +7,7 @@ const OP = 'chess_char_1_06_a'; // 刺玫, the operator of the report
const SCROLL = 'chess_item_6_02_m';
const HAMMER = 'chess_item_1_01_e_a';
const SHIELD = 'chess_item_1_02_e_a';
const FILLER = 'chess_item_6_09_e_a'; // non-mergeable
const FILLER = 'chess_item_2_03_e_b'; // non-mergeable effect-only item: fill storage without exhausting shop stock
const OK = { ok: true };
function prep(t, { copies = 2, golden = false, items = [HAMMER] } = {}) {
+2 -1
View File
@@ -30,7 +30,8 @@ function equip(m, ps, owner, count) {
}
function fillStorage(ps) {
while ([...ps.hand, ...ps.temp].some((p) => p === null)) assert.ok(ps.acquireItem(EQUIPMENT, { silent: true }));
// Fill storage independently of the equipment-stock limit; the sold operator's real equipment remains unchanged.
while ([...ps.hand, ...ps.temp].some((p) => p === null)) assert.ok(ps.acquireItem('chess_item_2_03_e_b', { silent: true }));
}
function swapIntoTemp(m, ps, piece) {
+135
View File
@@ -0,0 +1,135 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GameData } from '../../server/match/gamedata.js';
import { DATA, makeMatch, give, checkInvariants } from './harness.js';
import { PHASE } from '../../shared/constants.js';
import { cardPickable } from '../../public/js/ui/choiceOverlay.js';
const PACK = 'chess_item_5_07_e_a';
const byName = name => Object.values(DATA.chess).find(c => !c.isGolden && c.name === name).chessId;
test('a duplicate draft card becomes unavailable when the preceding player exhausts equipment stock', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
a.acquireItem(PACK);
m.phase = PHASE.SP_DRAFT;
m.sp = { cards: [PACK, PACK, 'chess_item_1_01_e_a'].map((id, idx) => ({ idx, id, kind: 'item' })),
order: ['p_0', 'p_1'], idx: 0, picks: {}, taken: {}, untimed: true };
assert.ok(m.pickCard(a, 0).ok); assert.equal(m.spTurn(), 'p_1');
assert.equal(m.pickCard(b, 1).error, 'SOLD_OUT'); assert.equal(m.sp.picks.p_1, undefined);
const card = m.publicView().sp.cards[1]; assert.equal(card.soldOut, true);
assert.equal(cardPickable({ turnPid: 'p_1', pickOf: new Map() }, card, { myId: 'p_1', solo: false }), false);
assert.ok(m.pickCard(b, 2).ok); m.dispose();
});
test('measured operator capacities and exceptions: co-op doubles shared stock, never private DIY stock', () => {
const solo = new GameData(DATA, 'mode_single_normal'), coop = new GameData(DATA, 'mode_multi_normal');
for (const [name, n] of [['普罗旺斯', 10], ['跃跃', 10], ['风丸', 8], ['蒂比', 12], ['哈洛德', 14], ['缪尔赛思', 5]]) {
assert.equal(solo.poolCopies(byName(name)), n, name); assert.equal(coop.poolCopies(byName(name)), 2 * n, name);
}
assert.equal(coop.poolCopies('chess_char_6_diy1_a'), solo.poolCopies('chess_char_6_diy1_a'));
assert.equal(solo.itemPoolCopies(PACK), 2); assert.equal(coop.itemPoolCopies(PACK), 2);
});
test('shared equipment occupancy includes hand/equipped/elite, never displayed offers; destroying frees stock', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
assert.equal(m.itemPool.left(PACK), 2);
a.funds = 10; const slot = a.shop.slots.findIndex(s => s?.kind === 'item');
assert.ok(slot >= 0); a.shop.slots[slot] = { kind: 'item', id: PACK, basePrice: 1, sold: false, frozen: true };
a.pushItemOffer([PACK]); assert.equal(m.itemPool.left(PACK), 2, 'display reserves nothing');
const x = a.acquireItem(PACK), y = b.acquireItem(PACK); assert.ok(x && y);
assert.equal(m.itemPool.left(PACK), 0);
const money = a.funds;
assert.equal(a.buy(slot).error, 'SOLD_OUT'); assert.equal(a.funds, money); assert.equal(a.shop.slots[slot].sold, false);
assert.equal(a.pickReward(0).error, 'SOLD_OUT'); assert.equal(a.offers.length, 1);
const id = [...m.pool.entries.keys()].find(id => m.gd.tierOf(id) === 1), owner = give(m, a, id);
assert.ok(a.equip(x.uid, owner.uid).ok); assert.equal(m.itemPool.held(PACK), 2);
assert.ok(a.sell(owner.uid).ok); assert.equal(m.itemPool.held(PACK), 2, 'selling returns equipment to hand, not to stock');
assert.ok(a.destroy(x.uid).ok); assert.equal(m.itemPool.left(PACK), 1);
assert.ok(a.buy(slot).ok, 'a third purchase is legal after one of the two owned copies was destroyed');
assert.equal(m.itemPool.left(PACK), 0);
b.eliminate(1); assert.equal(m.itemPool.left(PACK), 1);
const gold = a.acquireItem(PACK); assert.ok(gold && m.gd.isGolden(gold.id));
assert.equal(m.itemPool.held(PACK), 2, 'one upgraded item occupies two normal copies');
assert.ok(a.destroy(gold.uid).ok); assert.equal(m.itemPool.left(PACK), 2);
checkInvariants(m); m.dispose();
});
test('整备 does not upgrade a purchase that would exceed the shared item cap', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
const KEY = 'effect:builtin_next_buy_golden_item';
assert.ok(b.acquireItem(PACK));
assert.equal(m.itemPool.left(PACK), 1);
a.effects.push({ id: 'zb', key: KEY, name: '整备', counter: 1, battle: false });
a.funds = 50;
const slot = a.shop.slots.findIndex((s) => s?.kind === 'item');
assert.ok(slot >= 0);
a.shop.slots[slot] = { kind: 'item', id: PACK, basePrice: 1, sold: false, frozen: true };
assert.ok(a.buy(slot).ok);
const piece = [...a.hand, ...a.temp].find((p) => p && m.gd.baseIdOf(p.id) === PACK);
assert.ok(piece);
assert.equal(m.gd.isGolden(piece.id), false, 'the last copy stays normal');
assert.equal(a.effects.find((e) => e.key === KEY).counter, 1, 'the charge is kept');
assert.equal(m.itemPool.held(PACK), m.itemPool.cap(PACK));
checkInvariants(m);
const owned = [...b.hand, ...b.temp].find((p) => p && m.gd.baseIdOf(p.id) === PACK);
assert.ok(b.destroy(owned.uid).ok);
assert.equal(m.itemPool.left(PACK), 1, 'the freed copy is available again');
checkInvariants(m);
m.dispose();
});
test('整备 upgrades when the extra golden copy fits, including an uncapped item', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0');
const KEY = 'effect:builtin_next_buy_golden_item';
const uncapped = 'chess_item_2_03_e_a';
assert.equal(m.itemPool.cap(uncapped), null);
assert.equal(m.itemPool.left(PACK), 2);
a.effects.push({ id: 'zb', key: KEY, name: '整备', counter: 1, battle: false });
a.funds = 50;
const slot = a.shop.slots.findIndex((s) => s?.kind === 'item');
assert.ok(slot >= 0);
a.shop.slots[slot] = { kind: 'item', id: PACK, basePrice: 1, sold: false, frozen: true };
assert.ok(a.buy(slot).ok);
const piece = [...a.hand, ...a.temp].find((p) => p && m.gd.baseIdOf(p.id) === PACK);
assert.ok(piece && m.gd.isGolden(piece.id));
assert.equal(m.itemPool.held(PACK), 2);
assert.equal(a.effects.some((e) => e.key === KEY), false, 'the charge is spent');
a.effects.push({ id: 'zb2', key: KEY, name: '整备', counter: 1, battle: false });
const slot2 = slot === 0 ? 1 : 0;
assert.ok(a.shop.slots.length > slot2);
a.shop.slots[slot2] = { kind: 'item', id: uncapped, basePrice: 1, sold: false, frozen: true };
assert.ok(a.buy(slot2).ok);
const free = [...a.hand, ...a.temp].find((p) => p && m.gd.baseIdOf(p.id) === uncapped);
assert.ok(free && m.gd.isGolden(free.id), 'an uncapped item may still become golden');
checkInvariants(m);
m.dispose();
});
test('consumed equipment releases its stock; effect-only items have no shared shop cap', () => {
const h = makeMatch({ mode: 'solo', seed: 11, fake: true }).start().toPrep();
const m = h.m, p = h.ps('p_0'), id = [...m.pool.entries.keys()][0], owner = give(m, p, id);
const item = 'chess_item_1_04_e_a', before = m.itemPool.left(item);
const piece = p.acquireItem(item); assert.ok(piece); assert.equal(m.itemPool.left(item), before - 1);
assert.ok(p.equip(piece.uid, owner.uid).ok); assert.equal(p.find(piece.uid), null); assert.equal(m.itemPool.left(item), before);
assert.equal(m.itemPool.cap('chess_item_2_03_e_a'), null);
checkInvariants(m); m.dispose();
});
test('Mimic can exceed the pool; excess copies keep stock empty until enough ownership leaves', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start().toPrep();
const m = h.m, p = h.ps('p_0');
const id = [...m.pool.entries.keys()].find(id => m.gd.tierOf(id) === 6);
assert.equal(m.pool.cap(id), 5);
for (let i = 0; i < 5; i++) p.acquireChess(id);
const target = p.allChess().find(c => c.id === id);
assert.ok(target); assert.equal(m.pool.left(id), 0);
const mimic = p.acquireItem('chess_item_5_05_e_a'); assert.ok(mimic);
assert.ok(p.equip(mimic.uid, target.uid).ok);
assert.equal(p.allChess().filter(c => m.gd.baseIdOf(c.id) === id).length, 2);
assert.equal(m.pool.entries.get(id).left, -1);
assert.equal(m.pool.left(id), 0); checkInvariants(m);
const elite = p.allChess().find(c => m.gd.baseIdOf(c.id) === id);
assert.ok(p.sell(elite.uid).ok); assert.equal(m.pool.left(id), 2); checkInvariants(m); m.dispose();
});
+47
View File
@@ -0,0 +1,47 @@
// 外勤医疗: the official preparation field already shows 预备干员-医疗; Touch replaces it at battle start.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
const MEDIC = 'char_605_cmedic';
const STAGE = 'act2autochess_m01';
test('外勤医疗 shows the reserve medic on every prep board and in prep scouting, without adding a movable piece', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 6301, fake: true }).start().toPrep();
h.setStage(STAGE);
const [holder, teammate] = [h.ps('p_0'), h.ps('p_1')];
assert.deepEqual(holder.privateView().prepMapChars, [], 'no medic without the strategy');
holder.bandId = 'band_amedic';
for (const ps of [holder, teammate]) {
const priv = ps.privateView();
assert.equal(priv.prepMapChars.length, 1);
const medic = priv.prepMapChars[0];
assert.deepEqual([medic.defId, medic.kind, medic.x, medic.y, medic.dir, medic.ownerId],
[MEDIC, 'token', 2, 10, 'RIGHT', ps.playerId]);
assert.ok(medic.uid < 0 && !priv.board.some((p) => p.uid === medic.uid), 'display only: no board piece to move or sell');
assert.equal(h.m.prepFieldMeta(ps).units.find((u) => u.uid === medic.uid)?.defId, MEDIC);
}
holder.bandId = null;
assert.deepEqual(teammate.privateView().prepMapChars, [], 'the preview goes away when nobody holds the strategy');
h.m.dispose();
});
test('外勤医疗 boss preparation shows both reserve medics on their matching halves, mirrored for the right player', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 6302, fake: true }).start().toPrep();
h.setStage(STAGE);
const m = h.m;
m.bossWaves = [{ players: ['p_0', 'p_1'], wave: { spawns: [] } }];
h.ps('p_0').bandId = 'band_amedic';
const [left, right] = [h.ps('p_0'), h.ps('p_1')];
const own = left.privateView().prepMapChars[0];
const mate = left.privateView().bossMate.units.find((u) => u.defId === MEDIC);
assert.deepEqual([own.y, own.x, own.dir], [10, 2, 'RIGHT'], 'own prep uses board coordinates');
assert.deepEqual([mate.y, mate.x, mate.dir, mate.ownerId], [3, 18, 'LEFT', 'p_1']);
const scout = m.prepFieldMeta(right);
assert.equal(scout.kind, 'boss');
const medics = scout.units.filter((u) => u.defId === MEDIC).sort((a, b) => a.x - b.x);
assert.deepEqual(medics.map((u) => [u.y, u.x, u.dir, u.ownerId]),
[[3, 2, 'RIGHT', 'p_0'], [3, 18, 'LEFT', 'p_1']]);
assert.notEqual(medics[0].uid, medics[1].uid, 'both model IDs remain distinct on the shared field');
m.dispose();
});