Files
Dune/tests/bidding-end-engine.test.ts
T

505 lines
17 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeHomeworldGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck, spiceDeck, type Card } from '../game/cards';
import { createHomeworldCustody } from '../game/homeworld-custody';
import {
homeworldContext,
homeworldGameIntegrity,
} from '../game/homeworld-game';
import { botActions } from '../game/bots';
import { richeseCards } from '../game/richese-cards';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const own = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
function hold(g: Game, id: string, match: (card: Card) => boolean) {
const at = g.deck.findIndex(match);
assert.ok(at >= 0);
const card = g.deck.splice(at, 1)[0];
own(g, id).hand.push(card);
return card;
}
/** Emperor-only cases use the genuine audited setup pipeline. CHOAM's complete
* expansion deck remains gated, so its shared-window cases use an explicit
* faction seam with real base-deck IDs and conserved Homeworld counters. */
function fixture(choam = false, advanced = true, richese = false) {
let g = createGame(
'BIDDINGEND',
newPlayer('e', 'Emperor', 'emperor'),
advanced,
choam || richese ? ['choam'] : [],
);
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
if (richese) joinGame(g, newPlayer('r', 'Richese', 'richese'));
// The Richese audit uses its separate cache plus the implemented base deck.
if (richese) g.expansions = [];
if (choam) {
joinGame(g, newPlayer('c', 'CHOAM', 'choam'));
Object.assign(g, {
status: 'playing',
deck: baseDeck(),
spiceDeck: spiceDeck(),
});
own(g, 'e').elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
} else {
g = applyAction(g, 'e', { type: 'homeworlds', enabled: true });
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
for (let n = 0; g.status === 'setup' && n < 40; n++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(next, 'Genuine setup must expose an owned legal choice.');
g = next;
}
assert.equal(g.status, 'playing');
for (const p of g.players) g.deck.push(...p.hand.splice(0));
}
Object.assign(g, {
phase: 2,
turn: 2,
storm: 18,
active: null,
order: g.players.map((p) => p.id),
ready: [],
phaseOpening: null,
decision: null,
response: null,
choamCharity: { turn: 2, canceled: false },
});
for (const p of g.players) p.spice = 20;
inventory(g);
return g;
}
function inventory(g: Game) {
homeworldGameIntegrity(g);
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
if (p.elites)
assert.equal(
p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
5,
);
}
const ids = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...(g.auction?.cards.slice(g.auction.index) ?? []),
]
.map((card) => card.id)
.sort();
assert.deepEqual(
ids,
baseDeck()
.map((card) => card.id)
.sort(),
);
}
function endBidding(g: Game) {
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
if (g.decision?.kind === 'choamMarket')
g = applyAction(g, 'c', { type: 'decision', done: true });
assert.equal(g.phase, 3);
assert.ok(
g.auction,
'The normal auction opens through real phase transition.',
);
for (let n = 0; g.auction && n < 40; n++)
g = g.response
? allow(g)
: applyAction(g, g.auction.active, { type: 'passBid' });
assert.equal(g.auction, null);
assert.equal(g.phase, 3);
assert.ok(g.biddingEnd);
return g;
}
function act(g: Game, id: string, action: Omit<Action, 'type'>) {
return applyAction(g, id, {
type: 'biddingEnd',
event: g.biddingEnd!.event,
...action,
});
}
function reject(g: Game, id: string, action: Action) {
const before = reload(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function allow(g: Game) {
for (let n = 0; g.response && n < 20; n++) {
const responder = g.players.find((p) => {
const controls = viewGame(g, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length;
});
assert.ok(responder);
g = applyAction(g, responder.id, { type: 'passResponse' });
}
return g;
}
function trade(g: Game, offered: string, returned: string) {
g = act(g, 'c', { mode: 'trade', card: offered });
assert.equal(g.decision?.kind, 'choamTradeReply');
assert.equal(viewGame(g, 'e').biddingEnd?.canAct, false);
g = applyAction(reload(g), 'e', { type: 'decision', card: returned });
g = applyAction(reload(g), 'c', { type: 'decision', accept: true });
return g;
}
void test('actual final normal auction opens Kaitain once and paid multi-card disposal never restarts bidding', () => {
let g = fixture();
const first = hold(g, 'e', (card) => card.kind === 'worthless');
const second = hold(g, 'e', (card) => card.kind === 'shield');
g = endBidding(g);
assert.deepEqual(g.biddingEnd!.owners, ['e']);
const event = g.biddingEnd!.event;
const deck = reload(g).deck;
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
g = act(reload(g), 'e', { mode: 'discard', cards: [second.id, first.id] });
assert.equal(own(g, 'e').spice, 16);
assert.equal(
g.discard.filter((card) => [first.id, second.id].includes(card.id)).length,
2,
);
assert.deepEqual(g.deck, deck);
assert.equal(g.auction, null);
if (g.biddingEnd) {
assert.equal(g.biddingEnd.event, event);
g = act(g, 'e', { mode: 'ready' });
}
assert.equal(g.phase, 4);
assert.equal(g.biddingEnd, null);
inventory(g);
});
void test('Kaitain rejects empty, repeated, foreign, stale and unaffordable selections without mutation', () => {
let g = fixture();
const card = hold(g, 'e', (card) => card.kind === 'worthless');
const foreign = hold(g, 'a', (card) => card.kind === 'shield');
g = endBidding(g);
for (const cards of [[], [card.id, card.id], [foreign.id], ['missing']])
reject(g, 'e', {
type: 'biddingEnd',
event: g.biddingEnd!.event,
mode: 'discard',
cards,
});
reject(g, 'e', {
type: 'biddingEnd',
event: 'stale',
mode: 'discard',
cards: [card.id],
});
reject(g, 'a', {
type: 'biddingEnd',
event: g.biddingEnd!.event,
mode: 'discard',
cards: [foreign.id],
});
own(g, 'e').spice = 1;
reject(g, 'e', {
type: 'biddingEnd',
event: g.biddingEnd!.event,
mode: 'discard',
cards: [card.id],
});
inventory(g);
});
void test('Kaitain payment spends already held spice and does not activate discarded Karama or consume special Karama', () => {
let g = fixture();
const karama = hold(g, 'e', (card) => card.effect === 'karama');
hold(g, 'e', (card) => card.kind === 'shield');
hold(g, 'a', (card) => card.effect === 'karama');
g = endBidding(g);
const oldUsed = own(g, 'e').specialKaramaUsed;
g = act(reload(g), 'e', { mode: 'discard', cards: [karama.id] });
assert.equal(own(g, 'e').spice, 18);
assert.equal(own(g, 'e').specialKaramaUsed, oldUsed);
assert.equal(g.response, null);
assert.equal(g.discard.filter((card) => card.id === karama.id).length, 1);
inventory(g);
});
void test('shared owners can choose trade before or after an Emperor discard without faction priority', () => {
for (const tradeFirst of [false, true]) {
let g = fixture(true);
own(g, 'e').ally = 'c';
own(g, 'c').ally = 'e';
const dispose = hold(g, 'e', (card) => card.kind === 'worthless');
const returned = hold(g, 'e', (card) => card.name === 'Shield');
const incoming = hold(g, 'c', (card) => card.name === 'Snooper');
g = endBidding(g);
assert.deepEqual(g.biddingEnd!.owners, ['e', 'c']);
assert.equal(viewGame(g, 'e').biddingEnd?.canAct, true);
assert.equal(viewGame(g, 'c').biddingEnd?.canAct, true);
if (tradeFirst) g = trade(g, incoming.id, returned.id);
g = act(g, 'e', { mode: 'discard', cards: [dispose.id] });
if (!tradeFirst) g = trade(g, incoming.id, returned.id);
assert.equal(own(g, 'e').spice, 18);
assert.ok(own(g, 'e').hand.some((card) => card.id === incoming.id));
assert.ok(own(g, 'c').hand.some((card) => card.id === returned.id));
assert.equal(g.auction, null);
assert.equal(g.phase, 3);
inventory(g);
}
});
void test('incoming traded cards reopen readiness and an already-ready Emperor can discard them', () => {
let g = fixture(true);
own(g, 'e').ally = 'c';
own(g, 'c').ally = 'e';
const returned = hold(g, 'e', (card) => card.name === 'Shield');
const incoming = hold(g, 'c', (card) => card.kind === 'worthless');
g = endBidding(g);
g = act(g, 'e', { mode: 'ready' });
assert.ok(g.biddingEnd!.ready.includes('e'));
assert.equal(viewGame(g, 'e').biddingEnd?.canAct, true);
g = trade(g, incoming.id, returned.id);
assert.equal(g.biddingEnd!.ready.length, 0);
g = act(reload(g), 'e', { mode: 'discard', cards: [incoming.id] });
assert.equal(own(g, 'e').spice, 18);
assert.equal(g.discard.filter((card) => card.id === incoming.id).length, 1);
assert.equal(g.phase, 3);
if (!g.biddingEnd!.ready.includes('e')) g = act(g, 'e', { mode: 'ready' });
g = act(g, 'c', { mode: 'ready' });
assert.equal(g.phase, 4);
inventory(g);
});
void test('observers see shared timing but never private Emperor card choices or held spice', () => {
let g = fixture(true);
const card = hold(g, 'e', (card) => card.kind === 'worthless');
hold(g, 'c', (card) => card.name === 'Shield');
g = endBidding(g);
const observer = viewGame(g, 'a');
assert.equal(observer.biddingEnd?.canAct, false);
assert.equal(observer.players.find((p) => p.id === 'e')!.hand, undefined);
assert.equal(observer.players.find((p) => p.id === 'e')!.spice, undefined);
assert.ok(!JSON.stringify(observer.biddingEnd).includes(card.id));
assert.ok(
viewGame(g, 'e')
.players.find((p) => p.id === 'e')!
.hand!.some((c) => c.id === card.id),
);
reject(g, 'a', {
type: 'biddingEnd',
event: g.biddingEnd!.event,
mode: 'ready',
});
});
void test('current Kaitain population excludes Salusa and a real Ghola revival can reopen its eligibility', () => {
let g = fixture(true);
const e = own(g, 'e');
e.reserves = 9;
e.tanks = 1;
e.forces = { 'polar_sink:0': 10 };
e.elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
g.homeworlds!.custody!.salusa = { normal: 0, elite: 5 };
const card = hold(g, 'e', (card) => card.kind === 'worthless');
const ghola = hold(g, 'e', (card) => card.effect === 'ghola');
hold(g, 'c', (card) => card.name === 'Shield');
g = endBidding(g);
assert.equal(viewGame(g, 'e').biddingEnd?.kaitain.eligible, false);
reject(g, 'e', {
type: 'biddingEnd',
event: g.biddingEnd!.event,
mode: 'discard',
cards: [card.id],
});
g = act(g, 'c', { mode: 'ready' });
g = applyAction(reload(g), 'e', {
type: 'card',
card: ghola.id,
amount: 1,
elite: 0,
});
assert.equal(own(g, 'e').reserves, 10);
assert.equal(viewGame(g, 'e').biddingEnd?.kaitain.eligible, true);
assert.equal(g.biddingEnd!.ready.length, 0);
g = act(g, 'e', { mode: 'discard', cards: [card.id] });
assert.equal(own(g, 'e').spice, 18);
inventory(g);
});
void test('CHOAM can settle an actual duplicate sale inside the shared window without forcing Emperor readiness', () => {
let g = fixture(true);
hold(g, 'e', (card) => card.kind === 'worthless');
const card = hold(g, 'c', (card) => card.name === 'Snooper');
const witness = hold(g, 'c', (card) => card.name === 'Snooper');
hold(g, 'a', (card) => card.effect === 'karama');
g = endBidding(g);
const event = g.biddingEnd!.event;
g = act(g, 'c', { mode: 'sell', card: card.id, witness: witness.id });
assert.equal(g.response?.kind, 'choamSale');
assert.equal(viewGame(g, 'e').biddingEnd?.canAct, false);
g = allow(reload(g));
assert.equal(g.biddingEnd!.event, event);
assert.equal(own(g, 'c').spice, 23);
assert.equal(g.phase, 3);
assert.equal(g.auction, null);
assert.equal(g.decision, null);
assert.equal(viewGame(g, 'e').biddingEnd?.canAct, true);
inventory(g);
});
void test('Kaitain threshold is five native counters in both modes, with typed Salusa excluded in Advanced', () => {
for (const advanced of [false, true])
for (const native of [4, 5]) {
let g = fixture(true, advanced);
const e = own(g, 'e');
e.reserves = native + (advanced ? 5 : 0);
e.forces = { 'polar_sink:0': 20 - e.reserves };
e.elites = {
reserves: advanced ? 5 : 0,
tanks: 0,
forces: advanced ? {} : { 'polar_sink:0': 5 },
revived: 0,
};
g.homeworlds!.custody!.salusa = advanced ? { normal: 0, elite: 5 } : null;
const card = hold(g, 'e', (card) => card.kind === 'worthless');
hold(g, 'c', (card) => card.name === 'Shield');
g = endBidding(g);
assert.equal(viewGame(g, 'e').biddingEnd?.kaitain.eligible, native === 5);
if (native === 5) {
g = act(g, 'e', { mode: 'discard', cards: [card.id] });
assert.equal(own(g, 'e').spice, 18);
} else
reject(g, 'e', {
type: 'biddingEnd',
event: g.biddingEnd!.event,
mode: 'discard',
cards: [card.id],
});
inventory(g);
}
});
void test('closing both owners returns auction aid once and advances to Revival only after both are ready', () => {
let g = fixture(true);
hold(g, 'e', (card) => card.kind === 'worthless');
hold(g, 'c', (card) => card.name === 'Shield');
g = endBidding(g);
g.aid = { a: { recipient: 'e', amount: 3 } };
own(g, 'a').spice = 17;
const event = g.biddingEnd!.event;
g = act(reload(g), 'c', { mode: 'ready' });
assert.equal(g.phase, 3);
assert.equal(own(g, 'a').spice, 17);
assert.equal(g.biddingEnd!.event, event);
g = act(reload(g), 'e', { mode: 'ready' });
assert.equal(g.phase, 4);
assert.equal(own(g, 'a').spice, 20);
assert.deepEqual(g.aid, {});
assert.equal(g.biddingEnd, null);
const settled = reload(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), settled);
reject(g, 'e', { type: 'biddingEnd', event, mode: 'ready' });
inventory(g);
});
void test('ordinary CHOAM market retains its existing end-of-Bidding decision when Homeworlds are disabled', () => {
let g = fixture(true);
g.homeworlds = null;
hold(g, 'e', (card) => card.kind === 'worthless');
hold(g, 'c', (card) => card.name === 'Shield');
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'choamMarket');
g = applyAction(g, 'c', { type: 'decision', done: true });
for (let n = 0; g.auction && n < 40; n++)
g = g.response
? allow(g)
: applyAction(g, g.auction.active, { type: 'passBid' });
assert.equal(g.phase, 3);
assert.equal(g.biddingEnd ?? null, null);
assert.equal(g.decision?.kind, 'choamMarket');
g = applyAction(g, 'c', { type: 'decision', done: true });
assert.equal(g.phase, 4);
inventory(g);
});
void test('a scheduled post-normal Richese cache auction completes before the shared Kaitain window opens', () => {
let g = fixture(false, false, true);
// Richese and its separate physical cache originate in genuine setup before
// occupation-history initialization. Public expansion release remains gated.
assert.equal(g.richeseCache?.length, 10);
assert.deepEqual(g.richeseRemoved, []);
const disposal = hold(g, 'e', (card) => card.kind === 'worthless');
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'richeseDeclaration');
g = applyAction(g, 'r', {
type: 'decision',
event: g.richeseBidding!.event,
position: 'last',
});
for (let n = 0; g.auction && n < 40; n++)
g = applyAction(g, g.auction.active, { type: 'passBid' });
assert.equal(g.decision?.kind, 'richeseCache');
assert.equal(g.biddingEnd ?? null, null);
const cacheCard = g.richeseCache![0].id;
g = applyAction(reload(g), 'r', {
type: 'decision',
event: g.richeseBidding!.event,
card: cacheCard,
method: 'silent',
});
for (const p of g.players)
g = applyAction(g, p.id, {
type: 'richeseBid',
event: g.richeseAuction!.event,
amount: 0,
});
assert.equal(g.decision?.kind, 'richeseUnbid');
assert.equal(g.biddingEnd ?? null, null);
g = applyAction(reload(g), 'r', {
type: 'decision',
event: g.richeseBidding!.event,
keep: false,
});
assert.equal(g.richeseBidding!.stage, 'complete');
assert.equal(g.richeseAuction, null);
assert.equal(g.phase, 3);
assert.deepEqual(g.biddingEnd!.owners, ['e']);
assert.equal(
g.richeseRemoved!.filter((card) => card.id === cacheCard).length,
1,
);
g = act(reload(g), 'e', { mode: 'discard', cards: [disposal.id] });
assert.equal(own(g, 'e').spice, 18);
assert.equal(g.phase, 4);
inventory(g);
const cacheIds = [...g.richeseCache!, ...g.richeseRemoved!]
.map((card) => card.id)
.sort();
assert.deepEqual(
cacheIds,
richeseCards()
.map((card) => card.id)
.sort(),
);
});