Handle Sabotage gifts at the recipient hand limit

This commit is contained in:
Quintine committed 2026-10-27 21:59:52 +11:00
1 parent e9154a90f1
commit f1bef950e0
13 files changed
+440 -30

No files matched your search

+36 -8
View File
@@ -394,18 +394,20 @@ void test('worm entry pauses the remaining ride queue until Terror resolves, the
rejected(done, 'm', { type: 'decision', decline: true });
});
void test('Sabotage refuses exceptional recipient overflow but leaves a safe optional pass', () => {
void test('Sabotage permits a full recipient to discard the gift, or the giver may decline', () => {
const initial = fixture('sabotage');
hold(initial, 'm', 1);
const offered = decide(enter(initial), { reveal: true });
// A persisted exceptional hand state must not turn the optional gift into an overflow bypass.
// Controlled exceptional hand fill; the same original entry remains pending.
hold(offered, 'e', 4);
rejected(
offered,
'm',
{ type: 'decision', card: player(offered, 'm').hand[0].id },
/overflow/,
);
const card = player(offered, 'm').hand[0].id;
const gifted = decide(reload(offered), { card });
assert.equal(gifted.pendingTerrorEntry?.stage, 'giftDiscard');
assert.equal(gifted.decision?.player, 'e');
const resolved = applyAction(gifted, 'e', { type: 'decision', card });
assert.equal(player(resolved, 'e').hand.length, 4);
assert.equal(resolved.pendingTerrorEntry, null);
assert.equal(resolved.discard.filter(row => row.id === card).length, 1);
const passed = decide(offered, { decline: true });
assert.equal(player(passed, 'e').hand.length, 4);
assert.equal(player(passed, 'm').hand.length, 1);
@@ -461,6 +463,32 @@ void test('resolved Truthtrance can satisfy Robbery overflow without leaving a s
rejected(g, 'm', { type: 'decision', card: player(g, 'm').hand[0].id });
});
void test('resolved Truthtrance can satisfy Sabotage gift overflow without leaving a stale choice', () => {
const initial = fixture('sabotage');
hold(initial, 'm', 1);
const offered = decide(enter(initial), { reveal: true });
const index = offered.deck.findIndex(card => card.effect === 'truthtrance');
assert.ok(index >= 0);
const truth = offered.deck.splice(index, 1)[0];
player(offered, 'e').hand.push(truth);
hold(offered, 'e', 3);
let game = decide(offered, { card: player(offered, 'm').hand[0].id });
assert.equal(game.pendingTerrorEntry?.stage, 'giftDiscard');
game = applyAction(game, 'e', { type: 'card', card: truth.id });
while (game.truthtrance?.stage === 'priority') {
const id = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id))!.id;
game = applyAction(game, id, { type: 'truthPass' });
}
game = applyAction(game, 'e', { type: 'truthAsk', question: {
kind: 'fact', target: 'a', fact: { kind: 'hand', name: 'Shield' },
} });
game = applyAction(reload(game), 'a', { type: 'truthAnswer', answer: 'no' });
assert.equal(player(game, 'e').hand.length, 4);
assert.equal(game.pendingTerrorEntry, null);
assert.equal(game.decision, null);
rejected(game, 'e', { type: 'decision', card: player(game, 'e').hand[0].id });
});
void test('changing a hidden face leaves the entrant projection unchanged before revelation', () => {
const initial = fixture();
const alternative = structuredClone(initial);
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { baseDeck } from '../game/cards';
import { MoritaniEntry, SabotageGiftDiscardButton } from '../components/moritani-terror';
/** UI-only projection; the runtime suite owns the real physical arrival. */
function projected(viewer: 'entrant' | 'moritani'): GameView {
const game = createGame('GIFTCONTROLS', newPlayer('moritani', 'Moritani', 'moritani'), false, ['ecaz']);
joinGame(game, newPlayer('entrant', 'Entrant', 'emperor'));
const view = viewGame(game, viewer);
view.status = 'playing';
view.phase = 5;
view.decision = { kind: 'moritaniTerror', player: 'entrant', entrant: 'entrant', territory: 'arrakeen' };
view.terrorEntry = { entrant: 'entrant', territory: 'arrakeen', sector: 9,
cause: 'movement', stage: 'giftDiscard' } as unknown as GameView['terrorEntry'];
const own = view.players.find(player => player.id === viewer)!;
own.hand = baseDeck().slice(0, 2);
return view;
}
void test('only the recipient sees their own cards and the provisional overflow choice', () => {
const entrant = projected('entrant');
const hand = entrant.players.find(player => player.id === 'entrant')!.hand!;
const html = renderToStaticMarkup(createElement(MoritaniEntry, { game: entrant, busy: false, act() {} }));
assert.match(html, /Sabotage gift · discard a card/);
assert.match(html, /including the card you just received/);
assert.match(html, /provisional interpretation/);
for (const card of hand) {
assert.ok(html.includes(card.name));
assert.ok(html.includes(card.id));
}
const other = projected('moritani');
assert.equal(renderToStaticMarkup(createElement(MoritaniEntry, { game: other, busy: false, act() {} })), '');
});
void test('the recipient button submits only a chosen physical card and respects busy state', () => {
const actions: Action[] = [];
const submit = (action: Action) => { actions.push(action); };
SabotageGiftDiscardButton({ card: 'received-card', act: submit, disabled: false }).props.onClick();
assert.deepEqual(actions, [{ type: 'decision', card: 'received-card' }]);
SabotageGiftDiscardButton({ card: 'received-card', act: submit, disabled: true }).props.onClick();
SabotageGiftDiscardButton({ card: null, act: submit, disabled: false }).props.onClick();
assert.equal(actions.length, 1);
const html = renderToStaticMarkup(createElement(MoritaniEntry, { game: projected('entrant'), busy: true, act() {} }));
assert.match(html, /disabled=""[^>]*>Discard selected card/);
});
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
createSemutaArrivalOverlapFixture as fixture, actSemutaArrivalOverlap as act,
inventory, player, passCurrentSemuta as passSemuta, claimOverlapSemuta as claimSemuta,
openOverlapTerrorDiscard,
} from './fixture-semuta-arrival-overlap';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
/** Real public Basic setup, original token placement, paid shipment and
* Sabotage discard. Only the exceptional intervening hand fill is staged:
* original unopened physical cards move from the deck to the entrant's hand. */
function source() {
const f = fixture({ effect: 'choam', first: 'terror', arrival: 'shipment', terror: 'sabotage' });
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
game = openOverlapTerrorDiscard(game, f);
const originalDiscard = game.pendingTreacheryDiscard!.batch.event;
game = passSemuta(game);
assert.equal(game.pendingTerrorEntry?.stage, 'gift');
assert.equal(game.decision?.kind, 'moritaniTerror');
assert.equal(game.decision?.player, f.moritani);
assert.ok(player(game, f.moritani).hand.some(card => card.id === f.gift));
const beforeFill = structuredClone(game);
while (player(game, f.entrant).hand.length < handLimit(player(game, f.entrant))) {
const index = game.deck.findIndex(card => ['projectile', 'poison', 'shield', 'snooper', 'worthless'].includes(card.kind));
assert.ok(index >= 0);
player(game, f.entrant).hand.push(game.deck.splice(index, 1)[0]);
}
assert.equal(player(game, f.entrant).hand.length, handLimit(player(game, f.entrant)));
assert.deepEqual(inventory(game), f.originalInventory);
return { f, game, beforeFill, originalDiscard };
}
function finishArrival(game: Game, f: ReturnType<typeof fixture>) {
if (game.pendingAmbassador?.stage === 'offer')
game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador.event, decline: true });
assert.equal(game.pendingTerrorEntry, null);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(game.active, f.entrant);
assert.equal(player(game, f.entrant).forces[f.location], 1);
assert.equal(player(game, f.entrant).spice, f.balances[f.entrant] - 1);
assert.equal(player(game, f.entrant).shipped, true);
assert.deepEqual(inventory(game), f.originalInventory);
reject(game, f.entrant, f.arrivalStep.action);
return game;
}
for (const discard of ['gift', 'existing'] as const) for (const claim of [false, true]) {
void test(`Sabotage full-hand entrant discards ${discard}; Semuta ${claim ? 'claims' : 'passes'} once`, () => {
const { f, game: sourceGame, originalDiscard } = source();
const priorHand = player(sourceGame, f.entrant).hand.map(card => card.id);
const discardId = discard === 'gift' ? f.gift : priorHand[0];
let game = act(sourceGame, f.moritani, { type: 'decision', card: f.gift });
assert.equal(game.pendingTerrorEntry?.stage, 'giftDiscard');
assert.equal(game.decision?.kind, 'moritaniTerror');
assert.equal(game.decision?.player, f.entrant);
assert.equal(player(game, f.entrant).hand.length, handLimit(player(game, f.entrant)) + 1);
assert.ok(player(game, f.entrant).hand.some(card => card.id === f.gift));
assert.ok(!player(game, f.moritani).hand.some(card => card.id === f.gift));
for (const seat of game.players.filter(row => row.id !== f.entrant))
assert.equal(viewGame(game, seat.id).players.find(row => row.id === f.entrant)?.hand, undefined);
reject(game, f.moritani, { type: 'decision', card: discardId });
reject(game, f.entrant, { type: 'decision', card: 'stale-or-foreign-card' });
reject(game, f.entrant, { type: 'decision', decline: true });
const bot = viewGame(game, f.entrant);
bot.players.find(row => row.id === f.entrant)!.bot = 'Easy';
const botAction = botActions(bot).find(action => action.type === 'decision' &&
typeof action.card === 'string' && player(game, f.entrant).hand.some(card => card.id === action.card));
assert.ok(botAction, 'A minimal bot must select its own actual excess card');
const botResult = act(structuredClone(game), f.entrant, botAction);
assert.equal(player(botResult, f.entrant).hand.length, handLimit(player(botResult, f.entrant)));
game = act(game, f.entrant, { type: 'decision', card: discardId });
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.batch.cause, 'terror:sabotageGiftOverflow');
assert.notEqual(pending.batch.event, originalDiscard);
assert.equal(pending.batch.entries.length, 1);
assert.equal(pending.batch.entries[0].card.id, discardId);
assert.equal(pending.batch.entries[0].discardedBy, f.entrant);
assert.equal(pending.batch.entries[0].publicFace, false);
assert.equal(player(game, f.entrant).hand.length, handLimit(player(game, f.entrant)));
assert.deepEqual(inventory(game), f.originalInventory);
game = claim ? claimSemuta(game, f.claimant, discardId) : passSemuta(game);
assert.equal(player(game, f.claimant).hand.some(card => card.id === discardId), claim);
assert.equal(player(game, f.entrant).hand.some(card => card.id === f.gift), discard !== 'gift');
finishArrival(game, f);
});
}
+55
View File
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { quoteSabotageGift, quoteSabotageGiftDiscard, SabotageGiftError } from '../game/sabotage-gift';
const card = (id: string) => Object.freeze({ id, face: `face of ${id}` });
void test('a Sabotage gift transfers the same physical card without mutating either hand', () => {
const given = card('gift'), held = card('keep'), existing = card('existing');
const ownerHand = Object.freeze([given, held]), recipientHand = Object.freeze([existing]);
const result = quoteSabotageGift({ ownerHand, recipientHand, cardId: given.id, handLimit: 2 });
assert.equal(result.card, given);
assert.deepEqual(result.ownerHand, [held]);
assert.deepEqual(result.recipientHand, [existing, given]);
assert.equal(result.overflow, 0);
assert.deepEqual(ownerHand, [given, held]);
assert.deepEqual(recipientHand, [existing]);
});
void test('a full recipient chooses any actual card, including the gift, to resolve one overflow', () => {
const cards = [card('a'), card('b'), card('c'), card('d')];
const given = card('gift');
const gift = quoteSabotageGift({ ownerHand: [given], recipientHand: cards, cardId: given.id, handLimit: 4 });
assert.equal(gift.overflow, 1);
for (const choice of [cards[1], given]) {
const before = [...gift.recipientHand];
const discard = quoteSabotageGiftDiscard({ recipientHand: gift.recipientHand, cardId: choice.id, handLimit: 4 });
assert.equal(discard.card, choice);
assert.equal(discard.overflow, 0);
assert.equal(discard.recipientHand.length, 4);
assert.ok(!discard.recipientHand.some(held => held.id === choice.id));
assert.deepEqual(gift.recipientHand, before);
}
});
void test('gift quotes reject stale, duplicate and already-invalid physical hands', () => {
const gift = card('gift'), other = card('other');
for (const input of [
{ ownerHand: [gift], recipientHand: [other], cardId: 'missing', handLimit: 4 },
{ ownerHand: [gift, gift], recipientHand: [other], cardId: gift.id, handLimit: 4 },
{ ownerHand: [gift], recipientHand: [gift], cardId: gift.id, handLimit: 4 },
{ ownerHand: [gift], recipientHand: [other], cardId: gift.id, handLimit: 0 },
{ ownerHand: [gift], recipientHand: [other], cardId: gift.id, handLimit: -1 },
{ ownerHand: [gift], recipientHand: [other], cardId: ' gift ', handLimit: 4 },
]) assert.throws(() => quoteSabotageGift(input), SabotageGiftError);
});
void test('overflow resolution rejects a stale limit, unavailable card or duplicate custody', () => {
const a = card('a'), b = card('b');
for (const input of [
{ recipientHand: [a, b], cardId: a.id, handLimit: 2 },
{ recipientHand: [a, b], cardId: 'missing', handLimit: 1 },
{ recipientHand: [a, a], cardId: a.id, handLimit: 1 },
{ recipientHand: [a, b], cardId: a.id, handLimit: 0 },
]) assert.throws(() => quoteSabotageGiftDiscard(input), SabotageGiftError);
});