Files
Dune/tests/auction-unsold-custody.test.ts
T
2026-09-08 07:37:52 +10:00

163 lines
5.5 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, newPlayer, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
// Exposed Charity-phase fixture, then real readiness, bidding, payment and
// income allowance produce the pending Harkonnen bonus below. Cards are
// physically extracted; only the explicitly marked corruption is fabricated.
function live(harkCount = 1, keepBgCounter = true) {
let g = createGame(
'SALEQUOTE',
newPlayer('h', 'Harkonnen', 'harkonnen'),
true,
);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('b', 'BG', 'beneGesserit'),
newPlayer('g', 'Guild', 'guild'),
);
Object.assign(g, {
status: 'playing',
turn: 2,
phase: 2,
ready: [],
order: ['h', 'e', 'b', 'g'],
deck: baseDeck(),
discard: [],
phaseOpening: null,
response: null,
decision: null,
});
for (const p of g.players) {
p.hand = [];
p.spice = 20;
p.forces = {};
p.reserves = 20;
p.traitors = [];
}
const hold = (id: string, match: (c: Game['deck'][number]) => boolean) => {
const i = g.deck.findIndex(match);
assert.ok(i >= 0);
const card = g.deck.splice(i, 1)[0];
g.players.find((p) => p.id === id)!.hand.push(card);
return card;
};
const own = hold('h', (c) => c.effect === 'karama'),
printed = hold('g', (c) => c.effect === 'karama'),
bg = hold('b', (c) => c.kind === 'worthless');
if (!keepBgCounter) {
g.players[2].hand = [];
g.deck.push(bg);
}
while (g.players[0].hand.length < harkCount) hold('h', () => true);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.phase, 3);
assert.ok(g.auction);
g = applyAction(g, 'h', { type: 'bid', amount: 4 });
for (let i = 0; !g.decision && !g.response && i < 20; i++)
g = applyAction(g, g.auction!.active, { type: 'passBid' });
assert.equal(g.decision?.kind, 'auctionPayment');
return { g, own, printed, bg };
}
function paid(harkCount = 1, free = false, keepBgCounter = true) {
const f = live(harkCount, keepBgCounter);
return {
...f,
g: applyAction(f.g, 'h', {
type: 'decision',
karama: free,
...(free ? { card: f.own.id } : {}),
}),
};
}
function passOne(state: Game) {
let g = state;
const kind = g.response!.kind,
use = JSON.stringify(g.pendingKarama?.use);
for (
let i = 0;
g.response?.kind === kind &&
JSON.stringify(g.pendingKarama?.use) === use &&
i < 20;
i++
) {
const p = g.players.find((p) => !g.response!.passed.includes(p.id));
assert.ok(p);
g = applyAction(g, p.id, { type: 'passResponse' });
}
return g;
}
function bonus() {
const f = paid();
f.g = passOne(f.g);
assert.equal(f.g.response?.kind, 'harkonnenBonus');
assert.ok(f.g.auction!.cards.length > f.g.auction!.index + 1);
return f;
}
function duplicateUnsold(g: Game, zone: 'deck' | 'hand') {
const card = structuredClone(g.auction!.cards[g.auction!.index + 1]);
if (zone === 'deck') g.deck.push(card);
else g.players.find((p) => p.id === 'e')!.hand.push(card);
return card.id;
}
for (const zone of ['deck', 'hand'] as const) {
void test(`duplicate unsold card in ${zone} rejects printed and BG cancellation before accepting cost`, () => {
const f = bonus();
duplicateUnsold(f.g, zone);
const before = structuredClone(f.g);
for (const form of ['printed', 'bg'] as const) {
assert.throws(() =>
applyAction(f.g, form === 'printed' ? 'g' : 'b', {
type: 'card',
card: f[form].id,
mode: 'cancel',
}),
);
assert.deepEqual(f.g, before);
assert.equal(f.g.discard.filter((c) => c.id === f[form].id).length, 0);
}
});
void test(`saved paid BG allowance rejects newly duplicated unsold ${zone} custody without another discard`, () => {
const f = bonus();
const paid = applyAction(f.g, 'b', {
type: 'card',
card: f.bg.id,
mode: 'cancel',
});
assert.equal(paid.response?.kind, 'worthlessKarama');
const bad = JSON.parse(JSON.stringify(paid)) as Game;
duplicateUnsold(bad, zone);
const before = structuredClone(bad);
assert.throws(() => passOne(bad));
assert.deepEqual(bad, before);
assert.equal(bad.discard.filter((c) => c.id === f.bg.id).length, 1);
assert.equal(bad.auction!.index, f.g.auction!.index);
assert.equal(bad.response?.kind, 'worthlessKarama');
});
}
void test('valid unsold custody permits paid BG continuation while the acquired current card remains a historical auction alias', () => {
const f = bonus(),
acquired = f.g.auction!.cards[f.g.auction!.index].id;
assert.equal(f.g.players[0].hand.filter((c) => c.id === acquired).length, 1);
const expectedNext = f.g.auction!.cards[f.g.auction!.index + 1].id;
const paid = applyAction(f.g, 'b', {
type: 'card',
card: f.bg.id,
mode: 'cancel',
});
const done = passOne(JSON.parse(JSON.stringify(paid)) as Game);
assert.equal(done.auction!.index, f.g.auction!.index + 1);
assert.equal(done.auction!.cards[done.auction!.index].id, expectedNext);
assert.equal(
done.deck.some((c) => c.id === expectedNext),
false,
);
assert.equal(done.players[0].hand.filter((c) => c.id === acquired).length, 1);
assert.equal(done.auction!.cards[0].id, acquired);
assert.equal(done.players[0].spice, f.g.players[0].spice);
assert.equal(done.players[1].spice, f.g.players[1].spice);
assert.equal(done.discard.filter((c) => c.id === f.bg.id).length, 1);
});