Handle Sabotage gifts at the recipient hand limit
This commit is contained in:
1 parent
e9154a90f1
commit
f1bef950e0
13 files changed
+440
-30
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user