diff --git a/server/match/player/economy.js b/server/match/player/economy.js index bd0686bd..67a7a58f 100644 --- a/server/match/player/economy.js +++ b/server/match/player/economy.js @@ -209,7 +209,9 @@ export class PlayerEconomy { if (loc.piece.kind !== 'chess') return fail(ERR.BAD_TARGET, loc.piece.kind === 'item' ? 'items cannot be sold' : 'tokens cannot be sold'); const piece = loc.piece; // its equipment returns to the hand (overflow temp): refuse rather than destroy it when there is no room - const room = this.hand.filter((x) => x == null).length + this.temp.filter((x) => x == null).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0); + // Removing the owner's summon stacks frees one slot per stack, regardless of its summon count. + const freed = (x) => x == null || (x.kind === 'token' && x.ownerUid === piece.uid); + const room = this.hand.filter(freed).length + this.temp.filter(freed).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0); if ((piece.items || []).length > room) return fail(ERR.HAND_FULL, 'no room for the equipment'); this._detach(loc); this.removeTokensOf(piece.uid); diff --git a/test/match/sell-capacity.test.js b/test/match/sell-capacity.test.js new file mode 100644 index 00000000..961bdd10 --- /dev/null +++ b/test/match/sell-capacity.test.js @@ -0,0 +1,153 @@ +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) { + while ([...ps.hand, ...ps.temp].some((p) => p === null)) assert.ok(ps.acquireItem(EQUIPMENT, { 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); + }); +}