From d07d97dc4c2c1dd764826b144372fe232b90cf3e Mon Sep 17 00:00:00 2001 From: siruimei07 Date: Thu, 8 Oct 2026 18:42:00 +0800 Subject: [PATCH] feedback7 G-UI: selling a summoner counts the bench slots its summon stacks free (PR #309 by @siruimei07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied as-is. The sale pre-check now treats each of the operator's own hand or temp summon stacks as one empty slot, independent of stack.count, before the equipment returns. Board summons and another player's tokens still occupy their slots. A sale that does not fit still returns HAND_FULL before anything is detached. Tests: node --test test/match/sell-capacity.test.js test/match/economy.test.js test/match/playtest6_summons.test.js — 28 pass, 0 fail. eslint on the touched files: 0 errors (one pre-existing unused-arg warning in economy.js). --- server/match/player/economy.js | 4 +- test/match/sell-capacity.test.js | 153 +++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 test/match/sell-capacity.test.js 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); + }); +}