feedback7 G-UI: retreat with a full bench counts the slot of the own summon stack (PR #361 by @Xinglan233)

Applied as-is. The client precheck for withdrawing a board operator onto an occupied hand slot now treats that operator's own hand summon stack as a free slot, which the server already accepted. Temp stacks, board summons, another player's tokens, and an item that only carries ownerUid stay HAND_FULL. stack.count is not extra slots. Dropping on a chess still checks the swap first.

Tests: node --test test/ui/gameLogic.test.js test/ui/facing.test.js test/ui/uiFixes.test.js test/match/feedback1-placement.test.js test/match/feedback5-we2-placement.test.js — 116 pass, 0 fail. Before this commit: npm run -s lint — 0 errors (134 existing warnings); npm run -s typecheck — pass; npm run -s check:imports — pass; node --test test/docs-consistency.test.js test/docs-paths.test.js test/version.test.js test/package.test.js — 47 pass, 0 fail. No new UI strings, so the i18n check was not run. No golden update.
This commit is contained in:
Xinglan233 authored and sganggs committed 2026-10-08 18:45:01 +08:00
1 parent bc22f9cbe6
commit 5f0a06cc70
2 files changed
+59 -3

No files matched your search

+7 -3
View File
@@ -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<typeof placementContext>} 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' };
+52
View File
@@ -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);