- 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.
155 lines
7.0 KiB
JavaScript
155 lines
7.0 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { ERR } from '../../shared/constants.js';
|
|
import { makeMatch, legalTileFor, checkInvariants } from './harness.js';
|
|
|
|
const SILENCE = 'chess_char_2_02_a';
|
|
const CATHY = 'chess_char_4_11_a';
|
|
const EQUIPMENT = 'chess_item_1_02_e_b'; // Elite equipment does not merge.
|
|
|
|
function setup(t) {
|
|
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 1, fake: true }).start();
|
|
t.after(() => h.m.dispose());
|
|
h.toPrep(1);
|
|
return { h, m: h.m, ps: h.ps('p_0') };
|
|
}
|
|
|
|
function deploy(m, ps, piece, chessId = piece.id) {
|
|
const tile = legalTileFor(m, ps, chessId);
|
|
assert.ok(tile);
|
|
assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
|
|
}
|
|
|
|
function equip(m, ps, owner, count) {
|
|
return Array.from({ length: count }, () => {
|
|
const item = ps.acquireItem(EQUIPMENT, { silent: true });
|
|
assert.ok(item);
|
|
assert.deepEqual(m.handle(ps.playerId, { t: 'g.equip', itemUid: item.uid, targetUid: owner.uid }), { ok: true });
|
|
return item;
|
|
});
|
|
}
|
|
|
|
function fillStorage(ps) {
|
|
// 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) {
|
|
const idx = ps.hand.indexOf(piece);
|
|
const filler = ps.temp.find((p) => p?.kind === 'item');
|
|
assert.ok(idx >= 0 && filler);
|
|
assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: filler.uid, to: { area: 'hand', idx } }), { ok: true });
|
|
assert.equal(ps.find(piece.uid).area, 'temp');
|
|
}
|
|
|
|
function snapshot(ps) {
|
|
return structuredClone({
|
|
hand: ps.hand, temp: ps.temp, board: ps.board, funds: ps.funds, pendingFunds: ps.pendingFunds,
|
|
stats: ps.stats, round: ps.round, tempDue: ps._tempDue,
|
|
});
|
|
}
|
|
|
|
const inventory = (ps) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean);
|
|
const tokensOf = (ps, owner) => inventory(ps).filter((p) => p.kind === 'token' && p.ownerUid === owner.uid);
|
|
|
|
for (const area of ['hand', 'temp']) {
|
|
for (const itemCount of [1, 2]) {
|
|
test(`sale uses the freed ${area} summon slot to return ${itemCount} equipment item(s)`, (t) => {
|
|
const { h, m, ps } = setup(t);
|
|
const owner = ps.acquireChess(SILENCE, { silent: true });
|
|
deploy(m, ps, owner);
|
|
const items = equip(m, ps, owner, itemCount);
|
|
const other = ps.acquireChess(SILENCE, { silent: true });
|
|
deploy(m, ps, other);
|
|
const [stack] = tokensOf(ps, owner);
|
|
fillStorage(ps);
|
|
if (area === 'temp') swapIntoTemp(m, ps, stack);
|
|
assert.equal(ps.find(stack.uid).area, area);
|
|
// Two items need one existing free slot as well as the slot released by the stack.
|
|
if (itemCount === 2) {
|
|
const filler = ps.temp.find((p) => p?.kind === 'item');
|
|
assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true });
|
|
}
|
|
const untouched = structuredClone(inventory(ps).filter((p) => p !== owner && p !== stack));
|
|
const funds = ps.funds;
|
|
const poolBefore = m.pool.left(SILENCE);
|
|
const copies = owner.poolCopies;
|
|
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
|
assert.equal(ps.find(owner.uid), null);
|
|
assert.deepEqual(tokensOf(ps, owner), []);
|
|
assert.equal(ps.funds, funds + 1);
|
|
assert.equal(m.pool.left(SILENCE), poolBefore + copies);
|
|
assert.deepEqual(owner.items, []);
|
|
for (const item of items) {
|
|
assert.equal(ps.find(item.uid).piece, item, 'the original equipment object and UID return');
|
|
assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area));
|
|
}
|
|
assert.equal(ps.find(items[0].uid).area, area, 'hand is preferred, with overflow into temp');
|
|
for (const piece of untouched) assert.deepEqual(ps.find(piece.uid).piece, piece);
|
|
assert.equal(inventory(ps).length, untouched.length + items.length);
|
|
assert.deepEqual(h.logs.warn, []);
|
|
checkInvariants(m);
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const { name, ownerId, area, itemCount, stackCount } of [
|
|
{ name: 'one hand stack cannot hold two equipment items', ownerId: SILENCE, area: 'hand', itemCount: 2, stackCount: 1 },
|
|
{ name: 'one temp stack cannot hold two equipment items', ownerId: SILENCE, area: 'temp', itemCount: 2, stackCount: 1 },
|
|
{ name: 'a two-summon stack frees only one slot', ownerId: CATHY, area: 'hand', itemCount: 2, stackCount: 2 },
|
|
{ name: 'board summons and foreign stacks free no usable slot', ownerId: SILENCE, area: 'board', itemCount: 1, stackCount: 1 },
|
|
]) {
|
|
test(`sale rejects atomically when ${name}`, (t) => {
|
|
const { m, ps } = setup(t);
|
|
const owner = ps.acquireChess(ownerId, { silent: true });
|
|
deploy(m, ps, owner);
|
|
const items = equip(m, ps, owner, itemCount);
|
|
const other = ps.acquireChess(SILENCE, { silent: true });
|
|
deploy(m, ps, other);
|
|
const [stack] = tokensOf(ps, owner);
|
|
assert.equal(stack.count, stackCount);
|
|
if (area === 'board') deploy(m, ps, stack, owner.id);
|
|
fillStorage(ps);
|
|
if (area === 'temp') swapIntoTemp(m, ps, stack);
|
|
assert.equal(ps.find(stack.uid).area, area);
|
|
const before = snapshot(ps);
|
|
const poolBefore = m.pool.left(ownerId);
|
|
assert.equal(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }).error, ERR.HAND_FULL);
|
|
assert.deepEqual(snapshot(ps), before, 'pieces, equipment, funds, and bookkeeping remain unchanged');
|
|
assert.equal(m.pool.left(ownerId), poolBefore);
|
|
for (const item of items) assert.equal(ps.find(item.uid).area, 'equipped');
|
|
checkInvariants(m);
|
|
// Free exactly one additional slot and retry the same public intent.
|
|
const filler = ps.temp.find((p) => p?.kind === 'item');
|
|
assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true });
|
|
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
|
assert.equal(ps.find(owner.uid), null);
|
|
assert.deepEqual(tokensOf(ps, owner), []);
|
|
assert.equal(ps.funds, before.funds + 1);
|
|
for (const item of items) {
|
|
assert.equal(ps.find(item.uid).piece, item);
|
|
assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area));
|
|
}
|
|
assert.equal(tokensOf(ps, other).length, 1);
|
|
checkInvariants(m);
|
|
});
|
|
}
|
|
|
|
for (const area of ['hand', 'temp']) {
|
|
test(`sale still counts the operator's own ${area} slot`, (t) => {
|
|
const { m, ps } = setup(t);
|
|
const owner = ps.acquireChess(SILENCE, { silent: true });
|
|
const [item] = equip(m, ps, owner, 1);
|
|
fillStorage(ps);
|
|
if (area === 'temp') swapIntoTemp(m, ps, owner);
|
|
assert.equal(ps.find(owner.uid).area, area);
|
|
const funds = ps.funds;
|
|
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
|
assert.equal(ps.find(owner.uid), null);
|
|
assert.equal(ps.find(item.uid).piece, item);
|
|
assert.equal(ps.find(item.uid).area, area);
|
|
assert.equal(ps.funds, funds + 1);
|
|
checkInvariants(m);
|
|
});
|
|
}
|