163 lines
5.5 KiB
TypeScript
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);
|
|
});
|