diff --git a/public/js/ui/gameLogic/placement.js b/public/js/ui/gameLogic/placement.js index e166fefc..cd0ad0fa 100644 --- a/public/js/ui/gameLogic/placement.js +++ b/public/js/ui/gameLogic/placement.js @@ -301,7 +301,8 @@ function unitAllowed(ctx, piece, row, col, owner = null) { * must be legal on the source tile); from hand/temp the deploy cap applies unless a chess occupant swaps out. * board ← token: legal tile; from the hand the tile must be empty and its summoner deployed; board ↔ board swaps. * hand ← chess from the board: empty slot (withdraw), chess occupant (swap, legal on the source tile), otherwise - * it goes to any free slot (HAND_FULL when none). hand ← token from the board: always (back onto its stack). + * it uses a free slot or one freed by its own summon stack (HAND_FULL when none). hand ← token from the board: + * always (back onto its stack). * hand ← hand/temp piece: move / swap. hand ← EQUIP item onto a chess: equip; onto a non-chess: swap. * board ← EQUIP item: equip the chess on that tile. board ← MAGIC (Arts): used on that tile. * @param {ReturnType} ctx @@ -334,8 +335,11 @@ export function canPlace(ctx, uid, target) { if (!tileAllows(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', t('交换后的单位无法部署在原位置')); return { ok: true, action: 'swap' }; } - // the withdrawn operator goes to another free slot - const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => !ctx.handAt.has(i)); + // Withdrawal removes the operator's own summon stacks, freeing their hand slots too. + const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => { + const p = ctx.handAt.get(i)?.piece; + return !p || (p.kind === 'token' && p.ownerUid === piece.uid); + }); return free ? { ok: true, action: 'move' } : no('HAND_FULL', t('整备区已满')); } return { ok: true, action: 'swap' }; diff --git a/test/ui/gameLogic.test.js b/test/ui/gameLogic.test.js index 7a383f7f..3f0089cc 100644 --- a/test/ui/gameLogic.test.js +++ b/test/ui/gameLogic.test.js @@ -442,6 +442,58 @@ describe('placement mirror (canPlace)', () => { assert.equal(canPlace(ctx, h0.uid, { area: 'hand', idx: 10 }).ok, false); assert.equal(canPlace(ctx, h0.uid, { area: 'hand', idx: -1 }).ok, false); }); + test('summoner withdrawal: a full hand accepts a drop onto its own summon stack', () => { + const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 }; + const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 }; + const ctx = ctxFor(privWith({ board: [b], hand: [...Array.from({ length: 9 }, () => item(EQUIP)), stack] })); + const to = { area: 'hand', idx: 9 }; + assert.deepEqual(canPlace(ctx, b.uid, to), { ok: true, action: 'move' }); + assert.deepEqual(dropIntent(ctx, b.uid, to), { t: 'g.move', fields: { uid: b.uid, to } }); + assert.equal(dropFailureReason(ctx, b.uid, to), null); + }); + test('summoner withdrawal: its own stack frees space when dropped onto another item or token', () => { + const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 }; + const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 }; + const other = { ...stack, uid: ++uid, ownerUid: ++uid }; + const ctx = ctxFor(privWith({ board: [b], hand: [...Array.from({ length: 8 }, () => item(EQUIP)), other, stack] })); + for (const idx of [0, 8]) { + const to = { area: 'hand', idx }; + assert.deepEqual(canPlace(ctx, b.uid, to), { ok: true, action: 'move' }); + assert.deepEqual(dropIntent(ctx, b.uid, to), { t: 'g.move', fields: { uid: b.uid, to } }); + assert.equal(dropFailureReason(ctx, b.uid, to), null); + } + }); + test('summoner withdrawal: a full hand without its own hand stack still refuses the move', () => { + const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 }; + const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 }; + const other = { ...stack, uid: ++uid, ownerUid: ++uid }; + const hand = Array.from({ length: 10 }, () => item(EQUIP)); + const cases = [ + privWith({ board: [b], hand }), + privWith({ board: [{ ...b, id: MELEE }], hand }), + privWith({ board: [b], hand: [...hand.slice(0, 9), other] }), + privWith({ board: [b], hand, temp: [stack] }), + privWith({ board: [b, { ...stack, row: 9, col: 3 }], hand }), + privWith({ board: [b], hand: [...hand.slice(0, 9), { ...item(EQUIP), ownerUid: b.uid }] }), + ]; + for (const priv of cases) { + const ctx = ctxFor(priv); + for (const idx of [0, 9]) { + const to = { area: 'hand', idx }; + assert.equal(canPlace(ctx, b.uid, to).code, 'HAND_FULL'); + assert.equal(dropIntent(ctx, b.uid, to), null); + assert.equal(dropFailureReason(ctx, b.uid, to), '整备区已满'); + } + } + }); + test('summoner withdrawal: freed space does not bypass an illegal chess swap', () => { + const b = { ...piece('chess_char_2_02_a'), row: 10, col: 4 }; + const stack = { uid: ++uid, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: b.uid, count: 1 }; + const ctx = ctxFor(privWith({ board: [b], hand: [piece(MELEE), ...Array.from({ length: 8 }, () => item(EQUIP)), stack] })); + const to = { area: 'hand', idx: 0 }; + assert.equal(canPlace(ctx, b.uid, to).code, 'BAD_TILE'); + assert.equal(dropIntent(ctx, b.uid, to), null); + }); test('items: equip on chess (board or hand), arts on tiles, not on tokens', () => { const b = { ...piece(MELEE), row: 9, col: 3 }; const hChess = piece(RANGED);