Files
Dune/tests/semuta-box-ordinary-auction.test.ts
T

120 lines
6.7 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
import {
createSemutaAuctionInspectionFixture,
semutaAuctionInspectionInventory as inventory,
semutaAuctionInspectionPlayer as player,
} from './fixture-semuta-auction-inspection';
const BOX = 'richese-nullentropy-box';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function rejectWithoutMutation(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Real public Basic setup, paid cache Semuta acquisition, and a later real
* ordinary auction. This supplemental boundary deliberately stages a mutual
* alliance and relocates the physical Box plus two physical discards; no money,
* auction state, counters, phase or saved continuation is manufactured. */
function fixture() {
const source = createSemutaAuctionInspectionFixture();
let game = structuredClone(source.game);
while (game.response?.kind === 'atreidesAuction') {
const actor = game.players.find(row => !game.response!.passed.includes(row.id));
assert.ok(actor);
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
assert.ok(game.auction && !game.richeseAuction && !game.currentAuctionSale);
const bidder = game.auction.active;
const owner = game.players.find(row => row.id !== bidder && row.id !== source.holder &&
row.spice >= 2 && row.hand.length < handLimit(row));
assert.ok(owner, 'A non-bidding, non-Semuta holder must afford the staged Box.');
const donor = game.players.find(row => row.id !== bidder && row.id !== owner.id && row.spice >= 1);
assert.ok(donor, 'A distinct player must fund the standing auction bid.');
assert.ok(!player(game, bidder).ally && !donor.ally, 'The supplemental alliance must not replace a real pact.');
player(game, bidder).ally = donor.id;
donor.ally = bidder;
game = applyAction(game, donor.id, { type: 'pledgeAid', amount: 1 });
game = applyAction(game, bidder, { type: 'bid', amount: 1, allyPayment: 1 });
assert.equal(game.auction?.bidder, bidder);
assert.equal(game.auction?.bid, 1);
assert.equal(game.auction?.allyPayment, 1);
assert.equal(game.aid[donor.id]?.amount, 1);
const zones = [game.deck, game.discard, game.richeseCache!, game.richeseRemoved!,
...game.players.map(row => row.hand)].filter(Boolean);
const boxZone = zones.find(zone => zone.some(card => card.id === BOX));
assert.ok(boxZone, 'The original physical Box must be in a movable zone.');
const [box] = boxZone.splice(boxZone.findIndex(card => card.id === BOX), 1);
player(game, owner.id).hand.push(box);
const target = game.deck.shift(), other = game.deck.shift();
assert.ok(target && other, 'The original ordinary deck must supply two discard targets.');
game.discard.push(target, other);
assert.deepEqual(inventory(game), source.originalInventory);
return { game, source, owner: owner.id, donor: donor.id, bidder, target: target.id,
lot: structuredClone(game.auction), aid: structuredClone(game.aid),
ownerSpice: player(game, owner.id).spice, originalCards: inventory(game) };
}
for (const claim of [false, true]) {
void test(`Semuta ${claim ? 'claims' : 'passes'} the paid Box during a standing Basic ordinary bid`, () => {
const f = fixture();
const paid = applyAction(f.game, f.owner, { type: 'card', card: BOX });
assert.equal(player(paid, f.owner).spice, f.ownerSpice - 2);
assert.ok(paid.pendingNullentropy);
assert.deepEqual(paid.auction, f.lot);
assert.deepEqual(paid.aid, f.aid);
const searchEvent = paid.pendingNullentropy.event;
const pending = applyAction(paid, f.owner, { type: 'decision', event: searchEvent, card: f.target });
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'nullentropyDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.deepEqual(pending.pendingTreacheryDiscard!.batch.entries.map(entry =>
[entry.discardedBy, entry.card.id, entry.publicFace]), [[f.owner, BOX, true]]);
assert.deepEqual(pending.auction, f.lot);
assert.deepEqual(pending.aid, f.aid);
assert.equal(player(pending, f.owner).spice, f.ownerSpice - 2);
assert.equal(player(pending, f.owner).hand.filter(card => card.id === f.target).length, 1);
assert.equal(viewGame(pending, f.source.holder).players.find(row => row.id === f.owner)?.hand, undefined);
assert.deepEqual(viewGame(pending, f.source.holder).semutaReaction?.candidates, []);
const shuffled = pending.discard.filter(card => card.id !== BOX).map(card => card.id);
rejectWithoutMutation(pending, f.source.holder, { type: 'semutaCommit', event: 'stale-event' });
rejectWithoutMutation(pending, pending.auction!.active, { type: 'passBid' });
const alteredLot = reload(pending);
alteredLot.auction!.bid += 1;
rejectWithoutMutation(alteredLot, f.source.holder, { type: 'semutaCommit', event });
const alteredAid = reload(pending);
alteredAid.aid[f.donor]!.amount += 1;
rejectWithoutMutation(alteredAid, f.source.holder, { type: 'semutaCommit', event });
let resumed = reload(pending);
if (claim) resumed = applyAction(resumed, f.source.holder, { type: 'semutaCommit', event });
else for (const row of resumed.players)
resumed = applyAction(resumed, row.id, { type: 'semutaPass', event });
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.deepEqual(resumed.auction, f.lot);
assert.deepEqual(resumed.aid, f.aid);
assert.equal(player(resumed, f.owner).spice, f.ownerSpice - 2);
assert.equal(player(resumed, f.owner).hand.filter(card => card.id === f.target).length, 1);
assert.equal(player(resumed, f.source.holder).hand.some(card => card.id === BOX), claim);
assert.equal(resumed.discard.some(card => card.id === BOX), !claim);
assert.equal(resumed.discard.some(card => card.id === f.source.semuta), claim);
assert.deepEqual(resumed.discard.filter(card => card.id !== BOX && card.id !== f.source.semuta)
.map(card => card.id), shuffled);
assert.deepEqual(inventory(resumed), f.originalCards);
rejectWithoutMutation(resumed, f.source.holder, { type: 'semutaCommit', event });
rejectWithoutMutation(resumed, f.owner, { type: 'decision', event: searchEvent, card: f.target });
const active = resumed.auction!.active;
const continued = applyAction(resumed, active, { type: 'passBid' });
assert.notDeepEqual(continued.auction, f.lot, 'The same original auction continues after the reaction.');
assert.equal(player(continued, f.owner).spice, f.ownerSpice - 2);
});
}