Files
Dune/tests/semuta-prevented-gift-reservation.test.ts
T

149 lines
9.4 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
createSemutaRicheseGiftKaramaFixture, actSemutaRicheseGiftKarama as act,
claimSemutaRicheseGiftKarama as claim, passSemutaRicheseGiftKarama as pass,
finishSemutaRicheseGiftKarama as finish, advanceSemutaRicheseGiftKarama as advance,
semutaRicheseGiftKaramaClean as clean, semutaRicheseGiftKaramaInventory as inventory,
semutaRicheseGiftKaramaPlayer as player,
} from './fixture-semuta-richese-gift-karama';
const resources = (game: Game) => ({
players: game.players.map(player => ({ id: player.id, spice: player.spice,
reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites,
moved: player.moved, shipped: player.shipped, revived: player.revived, ally: player.ally })),
order: game.order, active: game.active, ready: game.ready,
turn: game.turn, phase: game.phase, movementRemaining: game.movementRemaining,
});
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Fork the inherited fixture's actual beforeGift snapshot. Its original paid
* Semuta1/Distrans1 acquisitions and native alliance are retained; only the
* final Distrans gift action is replaced by a real Semuta gift declaration.
* No played hand, resource, clock, parent or pending receipt is installed. */
function source(options: { advanced?: boolean; parent?: 'quiet' | 'emperorIncome' } = {}) {
const inherited = createSemutaRicheseGiftKaramaFixture(options);
const prefixLength = inherited.actions.length - inherited.giftDeclaration.steps.length;
assert.equal(inherited.giftDeclaration.steps.length, 1);
assert.deepEqual(inherited.actions.slice(prefixLength), inherited.giftDeclaration.steps);
assert.deepEqual(inherited.giftDeclaration.before, inherited.beforeGift);
const actions = structuredClone(inherited.actions.slice(0, prefixLength));
const beforeGift = structuredClone(inherited.beforeGift);
assert.ok(player(beforeGift, inherited.donor).hand.some(card => card.id === inherited.semuta));
assert.ok(player(beforeGift, inherited.donor).hand.some(card => card.id === inherited.gift));
assert.equal(player(beforeGift, inherited.donor).ally, inherited.recipient);
assert.equal(player(beforeGift, inherited.recipient).ally, inherited.donor);
assert.ok(viewGame(beforeGift, inherited.donor).richeseGift?.cards.some(card => card.id === inherited.semuta));
const giftAction: Action = { type: 'richeseGift', card: inherited.semuta };
const game = act(beforeGift, inherited.donor, giftAction, actions);
assert.deepEqual(actions.at(-1), { actor: inherited.donor, action: giftAction });
assert.equal(actions.length, prefixLength + 1);
assert.equal(game.response?.kind, 'richeseGift');
assert.equal(game.pendingRicheseGift?.intent.cardId, inherited.semuta);
assert.deepEqual(game.pendingRicheseGift!.resume, inherited.originalParent);
assert.deepEqual(inventory(game), inherited.originalInventory);
return { ...inherited, game, beforeGift, actions, sourceActionCount: actions.length,
giftAction, selectedGift: inherited.semuta, unusedDistrans: inherited.gift };
}
type Source = ReturnType<typeof source>;
function open(f: Source) {
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaRicheseGiftDiscard');
assert.equal(pending.reaction?.stage, 'offer');
assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, by: entry.discardedBy, public: entry.publicFace })),
[{ id: f.cost, by: f.spender, public: true }]);
assert.equal(game.pendingRicheseGift?.intent.cardId, f.semuta);
assert.deepEqual(game.pendingRicheseGift!.resume, f.originalParent);
assert.ok(player(game, f.donor).hand.some(card => card.id === f.semuta));
assert.ok(player(game, f.donor).hand.some(card => card.id === f.unusedDistrans));
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true,
'Only this already prevented gift reservation must release the actual Semuta role');
assert.equal(game.richeseGiftBlocked?.cards.includes(f.semuta) ?? false, false);
assert.equal(game.discard.filter(card => card.id === f.cost).length, 1);
assert.deepEqual(resources(game), resources(f.game));
assert.deepEqual(inventory(game), f.originalInventory);
return game;
}
function settled(state: Game, f: Source, claimed: boolean) {
const game = finish(state);
assert.ok(!game.pendingTreacheryDiscard); assert.ok(!game.pendingRicheseGift);
assert.deepEqual(resources(game), resources(f.game));
assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(player(game, f.recipient).hand.some(card => card.id === f.semuta), false);
assert.equal(player(game, f.donor).hand.filter(card => card.id === f.semuta).length, Number(!claimed));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed));
assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false);
assert.equal(player(game, f.donor).hand.filter(card => card.id === f.cost).length, Number(claimed));
assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed));
assert.equal(player(game, f.donor).hand.filter(card => card.id === f.unusedDistrans).length, 1);
assert.equal(player(game, f.recipient).hand.some(card => card.id === f.unusedDistrans), false);
assert.deepEqual(game.richeseGiftBlocked, { turn: f.game.turn, phase: f.game.phase, cards: [f.semuta] });
assert.deepEqual(game.response, f.originalParent.response);
assert.deepEqual(game.decision, f.originalParent.decision);
assert.deepEqual(game.phaseOpening, f.originalParent.phaseOpening);
assert.deepEqual(game.pendingKarama, f.originalParent.pendingKarama);
assert.ok(player(game, f.observer).hand.some(card => card.id === f.parentCounter));
if (claimed) {
// Claiming spent the selected Semuta, so it cannot be offered again.
reject(game, f.donor, f.giftAction);
} else {
// The prevented card is still held and can be re-offered in this phase.
// Fork the state so the original parent-continuation checks remain intact.
const before = structuredClone(game);
const offered = applyAction(game, f.donor, f.giftAction);
assert.deepEqual(game, before);
assert.equal(offered.response?.kind, 'richeseGift');
assert.equal(offered.pendingRicheseGift?.intent.cardId, f.semuta);
assert.ok(player(offered, f.donor).hand.some(card => card.id === f.semuta));
assert.ok(player(offered, f.observer).hand.some(card => card.id === f.parentCounter));
assert.deepEqual(inventory(offered), f.originalInventory);
}
return game;
}
for (const advanced of [false, true]) for (const parent of ['quiet', 'emperorIncome'] as const) for (const claimed of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} prevented Semuta gift ${claimed ? 'spends that reserved Semuta to claim' : 'passes'} its cost with ${parent} parent`, () => {
const f = source({ advanced, parent });
assert.equal(f.semutaPurchase.price, 1); assert.equal(f.giftPurchase.price, 1);
assert.ok(f.semutaPurchase.steps.some(step => step.actor === f.donor && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(f.giftPurchase.steps.some(step => step.actor === f.donor && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(f.holder, f.donor);
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
const game = settled(claimed ? claim(pending, f.holder) : pass(pending), f, claimed);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaPass', event });
if (parent === 'emperorIncome') {
assert.equal(game.response?.kind, 'emperorIncome');
const before = player(game, f.recipient).spice;
const remaining = advance(game, clean);
assert.equal(player(remaining, f.recipient).spice, before + 1,
'The one original cache-purchase income resumes once after gift prevention and Semuta settlement');
assert.ok(!remaining.pendingRicheseGift); assert.ok(!remaining.pendingTreacheryDiscard);
assert.deepEqual(remaining.richeseGiftBlocked, game.richeseGiftBlocked);
assert.equal(player(remaining, f.recipient).hand.some(card => card.id === f.semuta), false);
assert.ok(player(remaining, f.donor).hand.some(card => card.id === f.unusedDistrans));
assert.deepEqual(inventory(remaining), f.originalInventory);
}
});
}
void test('the prevented selected-Semuta reservation permits only its actual cost reaction and rejects stale replay', () => {
const f = source({ advanced: true, parent: 'emperorIncome' });
const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event;
reject(pending, f.spender, f.action);
reject(pending, f.donor, f.giftAction);
reject(pending, f.recipient, { type: 'passResponse' });
reject(pending, f.holder, { type: 'semutaCommit', event: `${event}:stale` });
const game = settled(claim(pending, f.holder), f, true);
reject(game, f.holder, { type: 'semutaCommit', event });
assert.deepEqual(game.richeseGiftBlocked!.cards, [f.semuta]);
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assert.equal(player(game, f.donor).hand.filter(card => card.id === f.cost).length, 1);
});