Connect Richese Nexus to Karama acquisitions
This commit is contained in:
1 parent
cf268243f3
commit
3bf7d26e80
18 files changed
+1513
-110
No files matched your search
@@ -0,0 +1,184 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user