Files
Dune/tests/normal-auction-retirement.test.ts

185 lines
8.7 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { quoteAuctionContinuation, type AuctionContinuationInput } from '../game/auction-continuation-quote';
import {
quoteNormalAuctionRetirement, NormalAuctionRetirementError,
type NormalAuctionRetirementInput,
} from '../game/normal-auction-retirement';
function fixture(): NormalAuctionRetirementInput {
const cards = baseDeck().slice(0, 3);
const held = baseDeck()[3];
return {
turn: 2, advanced: false, order: ['r', 'a', 'i', 'g'],
players: [
{ id: 'r', faction: 'richese', spice: 5, hand: [], handLimit: 4 },
{ id: 'a', faction: 'atreides', spice: 5, hand: [], handLimit: 4 },
{ id: 'i', faction: 'ixians', spice: 5, hand: [held], handLimit: 4 },
{ id: 'g', faction: 'guild', spice: 5, hand: [], handLimit: 4 },
],
physicalCards: [...cards, held],
auction: { cards, index: 0, bid: 0, bidder: null, active: 'r', passed: [], opener: 0 },
};
}
function round(input: NormalAuctionRetirementInput) {
input.richeseBidding = {
owner: 'r', event: 'round-2', turn: 2, stage: 'normal', position: 'last',
normalCount: 3, blackMarketSold: false, cacheCanceled: false,
};
}
function paid(input: NormalAuctionRetirementInput): AuctionContinuationInput {
return {
...input, status: 'playing', phase: 3,
auction: { ...input.auction!, bid: 4, bidder: 'r' },
};
}
function reject(input: NormalAuctionRetirementInput) {
const before = JSON.stringify(input);
assert.throws(() => quoteNormalAuctionRetirement(input), NormalAuctionRetirementError);
assert.equal(JSON.stringify(input), before);
}
void test('unbought retirement advances the original pool without a bidder, sale or payment', () => {
const input = fixture(), before = structuredClone(input);
const next = quoteNormalAuctionRetirement(input);
assert.equal(next.kind, 'normalLot');
if (next.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.equal(next.auction.index, 1);
assert.equal(next.auction.bid, 0);
assert.equal(next.auction.bidder, null);
assert.equal(next.auction.allyPayment, 0);
assert.equal(next.active, 'a');
assert.deepEqual(next.offer, { kind: 'atreidesAuction', owner: 'a', passed: [] });
assert.equal(Object.hasOwn(next, 'sale'), false);
assert.deepEqual(input, before);
next.auction.cards[1].name = 'Changed output';
assert.deepEqual(input, before);
});
void test('retirement rotates the physical opener rather than another seat currently leading the bid', () => {
const input = fixture();
input.auction = { ...input.auction!, bid: 5, bidder: 'g', active: 'i', passed: ['r', 'a'] };
const next = quoteNormalAuctionRetirement(input);
assert.equal(next.kind, 'normalLot');
if (next.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.equal(next.active, 'a');
assert.equal(next.auction.opener, 1);
assert.equal(next.auction.bidder, null);
});
void test('next-lot retirement skips full hands, removes Sapho and clears the prior inspection', () => {
const input = fixture();
input.players = input.players.map(player => player.id === 'a'
? { ...player, hand: [{ id: 'a-full' }], handLimit: 1 } : player);
input.auction = { ...input.auction!, saphoOrder: ['g', 'i', 'a', 'r'], peekKnown: true,
occupiedEvent: 'original-occupied-event' };
const before = structuredClone(input), next = quoteNormalAuctionRetirement(input);
assert.equal(next.kind, 'normalLot');
if (next.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.equal(next.active, 'i');
assert.equal(next.auction.opener, 2);
assert.deepEqual(next.auction.passed, []);
assert.equal(next.auction.peekKnown, false);
assert.equal(Object.hasOwn(next.auction, 'saphoOrder'), false);
assert.equal(next.auction.occupiedEvent, 'original-occupied-event');
assert.deepEqual(input, before);
});
void test('exhaustion keeps the 7 October last-cache stock and cancellation rule', () => {
for (const [stock, canceled, after] of [[1, false, 'richeseCache'], [0, false, 'phase'], [1, true, 'phase']] as const) {
const input = fixture();
round(input);
input.auction!.index = 2;
input.richeseCacheCount = stock;
input.richeseBidding!.cacheCanceled = canceled;
assert.deepEqual(quoteNormalAuctionRetirement(input), {
kind: 'normalEnd', returned: [], after, completeRound: after === 'phase',
});
}
const ordinary = fixture();
ordinary.auction!.index = 2;
assert.deepEqual(quoteNormalAuctionRetirement(ordinary), {
kind: 'normalEnd', returned: [], after: 'phase', completeRound: false,
});
});
void test('full hands return only the untouched remainder as independent physical snapshots', () => {
const input = fixture();
input.players = input.players.map(player => ({ ...player, hand: [{ id: `${player.id}-full` }], handLimit: 1 }));
const before = structuredClone(input), next = quoteNormalAuctionRetirement(input);
assert.equal(next.kind, 'normalEnd');
if (next.kind !== 'normalEnd') throw new Error('No seat can buy the remaining cards.');
assert.deepEqual(next.returned, input.auction!.cards.slice(1));
next.returned[0].name = 'Changed output';
assert.deepEqual(input, before);
});
void test('next inspection offers retain Advanced Ix priority and ordinary Atreides fallback', () => {
const input = fixture();
input.advanced = true;
const ix = quoteNormalAuctionRetirement(input);
assert.equal(ix.kind, 'normalLot');
if (ix.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.deepEqual(ix.offer, { kind: 'ixTechnology', player: 'i' });
input.ixTechnologyTurn = input.turn;
const atreides = quoteNormalAuctionRetirement(input);
assert.equal(atreides.kind, 'normalLot');
if (atreides.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.deepEqual(atreides.offer, { kind: 'atreidesAuction', owner: 'a', passed: [] });
input.players = input.players.map(player => player.id === 'a' ? { ...player, faction: 'beneGesserit' } : player);
const neither = quoteNormalAuctionRetirement(input);
assert.equal(neither.kind, 'normalLot');
if (neither.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.equal(neither.offer, null);
});
void test('the existing paid continuation delegates unchanged and preserves declared-cost hand projection', () => {
const input = paid(fixture());
assert.deepEqual(quoteAuctionContinuation(input, { kind: 'next' }).next, quoteNormalAuctionRetirement(input));
const cost = { id: 'r-last-slot' };
input.players = input.players.map(player => ({ ...player, hand: [player.id === 'r' ? cost : { id: `${player.id}-full` }], handLimit: 1 }));
const before = structuredClone(input);
const next = quoteAuctionContinuation(input, { kind: 'next' }, { player: 'r', card: cost.id }).next;
assert.equal(next.kind, 'normalLot');
if (next.kind !== 'normalLot') throw new Error('Spending the real card opens the original slot.');
assert.equal(next.active, 'r');
assert.deepEqual(input, before);
const ixInput = paid(fixture());
ixInput.advanced = true;
const ixCard = ixInput.players.find(player => player.id === 'i')!.hand[0].id;
const afterIxCost = quoteAuctionContinuation(ixInput, { kind: 'next' }, { player: 'i', card: ixCard }).next;
assert.equal(afterIxCost.kind, 'normalLot');
if (afterIxCost.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
assert.deepEqual(afterIxCost.offer, { kind: 'atreidesAuction', owner: 'a', passed: [] });
});
void test('retirement rejects invalid source indices, order, hand limits and remaining physical custody', () => {
for (const index of [-1, 3, 0.5, NaN, Infinity]) {
const input = fixture();
input.auction!.index = index;
reject(input);
}
const badOpener = fixture();
badOpener.auction!.opener = 4;
reject(badOpener);
const nullBidderPrice = fixture();
nullBidderPrice.auction!.bid = 1;
reject(nullBidderPrice);
const duplicateOrder = fixture();
duplicateOrder.order = ['r', 'r'];
reject(duplicateOrder);
const noHandLimit = fixture();
noHandLimit.players = noHandLimit.players.map(player => player.id === 'r' ? { ...player, handLimit: 0 } : player);
reject(noHandLimit);
const noPhysical = fixture();
noPhysical.physicalCards = noPhysical.physicalCards.filter(card => card.id !== noPhysical.auction!.cards[1].id);
reject(noPhysical);
const duplicatePhysical = fixture();
duplicatePhysical.physicalCards = [...duplicatePhysical.physicalCards, duplicatePhysical.auction!.cards[1]];
reject(duplicatePhysical);
const duplicateCurrent = fixture();
duplicateCurrent.auction!.cards[1] = duplicateCurrent.auction!.cards[0];
reject(duplicateCurrent);
});