760 lines
24 KiB
TypeScript
760 lines
24 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { webcrypto } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { runInNewContext } from 'node:vm';
|
|
import ts from 'typescript';
|
|
import { baseDeck } from '../game/cards';
|
|
import {
|
|
createRicheseAuction,
|
|
submitRicheseBid,
|
|
} from '../game/richese-auction';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import {
|
|
quoteAuctionContinuation,
|
|
auctionEmperorIncomeAmount,
|
|
AuctionContinuationError,
|
|
type AuctionContinuationInput,
|
|
type AuctionContinuationOperation,
|
|
} from '../game/auction-continuation-quote';
|
|
|
|
function fixture(): AuctionContinuationInput {
|
|
const cards = baseDeck().slice(0, 4);
|
|
return {
|
|
status: 'playing',
|
|
phase: 3,
|
|
turn: 2,
|
|
advanced: true,
|
|
physicalCards: cards,
|
|
order: ['h', 'e', 'b', 'g'],
|
|
players: [
|
|
{
|
|
id: 'h',
|
|
faction: 'harkonnen',
|
|
spice: 16,
|
|
hand: [cards[0]],
|
|
handLimit: 8,
|
|
},
|
|
{ id: 'e', faction: 'emperor', spice: 20, hand: [], handLimit: 4 },
|
|
{ id: 'b', faction: 'beneGesserit', spice: 20, hand: [], handLimit: 4 },
|
|
{ id: 'g', faction: 'guild', spice: 20, hand: [], handLimit: 4 },
|
|
],
|
|
auction: {
|
|
cards,
|
|
index: 0,
|
|
bid: 4,
|
|
bidder: 'h',
|
|
active: 'h',
|
|
passed: ['e', 'b', 'g'],
|
|
opener: 0,
|
|
},
|
|
sale: {
|
|
winner: 'h',
|
|
amount: 4,
|
|
free: false,
|
|
origin: 'normal',
|
|
seller: null,
|
|
},
|
|
};
|
|
}
|
|
const cancel = (
|
|
kind: 'emperorIncome' | 'harkonnenBonus' | 'ixAllyCard',
|
|
owner: string,
|
|
recipient?: string,
|
|
): AuctionContinuationOperation => ({
|
|
kind: 'cancel',
|
|
response: { kind, owner, passed: [], ...(recipient ? { recipient } : {}) },
|
|
});
|
|
void test('the paid normal sale stops at Emperor then Harkonnen responses without replaying buyer payment', () => {
|
|
const g = fixture(),
|
|
before = structuredClone(g);
|
|
assert.deepEqual(
|
|
quoteAuctionContinuation(g, { kind: 'sale', free: false }).next,
|
|
{
|
|
kind: 'response',
|
|
response: { kind: 'emperorIncome', owner: 'e', passed: [] },
|
|
},
|
|
);
|
|
assert.deepEqual(quoteAuctionContinuation(g, { kind: 'bonus' }).next, {
|
|
kind: 'response',
|
|
response: { kind: 'harkonnenBonus', owner: 'h', passed: [] },
|
|
});
|
|
assert.deepEqual(
|
|
quoteAuctionContinuation(g, cancel('emperorIncome', 'e')).steps,
|
|
[],
|
|
);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
void test('a free purchase bypasses Emperor income but still offers Harkonnen bonus, including the explicit legacy sale fallback', () => {
|
|
const g = fixture();
|
|
g.sale!.free = true;
|
|
assert.equal(
|
|
(
|
|
quoteAuctionContinuation(g, { kind: 'sale', free: true }).next as {
|
|
response: { kind: string };
|
|
}
|
|
).response.kind,
|
|
'harkonnenBonus',
|
|
);
|
|
assert.throws(
|
|
() => quoteAuctionContinuation(g, cancel('emperorIncome', 'e')),
|
|
/not payable/,
|
|
);
|
|
delete g.sale;
|
|
const q = quoteAuctionContinuation(g, { kind: 'sale', free: true });
|
|
assert.equal(q.sale.legacy, true);
|
|
assert.equal(q.sale.free, true);
|
|
assert.equal(q.next.kind, 'response');
|
|
assert.equal(
|
|
quoteAuctionContinuation(g, cancel('harkonnenBonus', 'h')).next.kind,
|
|
'normalLot',
|
|
);
|
|
});
|
|
void test('the next normal lot resets exactly once and rotates the opener past full hands', () => {
|
|
const g = fixture();
|
|
g.players[1].hand = baseDeck().slice(10, 14);
|
|
const before = structuredClone(g),
|
|
q = quoteAuctionContinuation(g, { kind: 'next' });
|
|
assert.ok(q.next.kind === 'normalLot');
|
|
assert.equal(q.next.active, 'b');
|
|
assert.equal(q.next.auction.opener, 2);
|
|
assert.equal(q.next.auction.index, 1);
|
|
assert.equal(q.next.auction.bid, 0);
|
|
assert.equal(q.next.auction.allyPayment, 0);
|
|
assert.equal(q.next.auction.bidder, null);
|
|
assert.deepEqual(q.next.auction.passed, []);
|
|
assert.equal(q.next.auction.peekKnown, false);
|
|
q.next.auction.cards.reverse();
|
|
assert.deepEqual(g, before);
|
|
});
|
|
void test('a final lot or all-full table returns only the unauctioned suffix at an explicit phase boundary', () => {
|
|
const g = fixture();
|
|
for (const p of g.players)
|
|
p.hand = Array.from({ length: p.handLimit }, (_, i) => ({
|
|
id: `${p.id}-${i}`,
|
|
}));
|
|
const q = quoteAuctionContinuation(g, { kind: 'next' });
|
|
assert.ok(q.next.kind === 'normalEnd');
|
|
assert.deepEqual(q.next.returned, g.auction!.cards.slice(1));
|
|
assert.equal(q.next.after, 'phase');
|
|
g.auction!.index = 3;
|
|
g.sale!.amount = g.auction!.bid = 4;
|
|
const last = quoteAuctionContinuation(g, { kind: 'next' });
|
|
assert.ok(last.next.kind === 'normalEnd');
|
|
assert.deepEqual(last.next.returned, []);
|
|
});
|
|
void test('spending a last-slot card is projected before bonus and next-lot eligibility, without altering source hands', () => {
|
|
const g = fixture();
|
|
for (const p of g.players)
|
|
p.hand = Array.from({ length: p.handLimit }, (_, i) => ({
|
|
id: `${p.id}-${i}`,
|
|
}));
|
|
const before = structuredClone(g);
|
|
assert.equal(
|
|
quoteAuctionContinuation(g, cancel('emperorIncome', 'e')).next.kind,
|
|
'normalEnd',
|
|
);
|
|
const bonus = quoteAuctionContinuation(g, cancel('emperorIncome', 'e'), {
|
|
player: 'h',
|
|
card: 'h-0',
|
|
});
|
|
assert.deepEqual(bonus.next, {
|
|
kind: 'response',
|
|
response: { kind: 'harkonnenBonus', owner: 'h', passed: [] },
|
|
});
|
|
const lot = quoteAuctionContinuation(g, cancel('harkonnenBonus', 'h'), {
|
|
player: 'g',
|
|
card: 'g-0',
|
|
});
|
|
assert.ok(lot.next.kind === 'normalLot');
|
|
assert.equal(lot.next.active, 'g');
|
|
assert.deepEqual(g, before);
|
|
assert.throws(
|
|
() =>
|
|
quoteAuctionContinuation(
|
|
g,
|
|
{ kind: 'next' },
|
|
{ player: 'g', card: 'missing' },
|
|
),
|
|
/custody/,
|
|
);
|
|
});
|
|
void test('post-cost Ix hand eligibility chooses Technology or the next Atreides peek without inspecting card identities', () => {
|
|
const g = fixture();
|
|
g.players = [
|
|
...g.players,
|
|
{
|
|
id: 'i',
|
|
faction: 'ixians',
|
|
spice: 20,
|
|
hand: [{ id: 'last-ix-card' }],
|
|
handLimit: 4,
|
|
},
|
|
{ id: 'a', faction: 'atreides', spice: 20, hand: [], handLimit: 4 },
|
|
];
|
|
g.order = [...g.order, 'i', 'a'];
|
|
const before = quoteAuctionContinuation(g, { kind: 'next' });
|
|
assert.ok(before.next.kind === 'normalLot');
|
|
assert.deepEqual(before.next.offer, { kind: 'ixTechnology', player: 'i' });
|
|
const after = quoteAuctionContinuation(g, cancel('harkonnenBonus', 'h'), {
|
|
player: 'i',
|
|
card: 'last-ix-card',
|
|
});
|
|
assert.ok(after.next.kind === 'normalLot');
|
|
assert.deepEqual(after.next.offer, {
|
|
kind: 'atreidesAuction',
|
|
owner: 'a',
|
|
passed: [],
|
|
});
|
|
g.ixTechnologyTurn = 2;
|
|
const used = quoteAuctionContinuation(g, { kind: 'next' });
|
|
assert.ok(used.next.kind === 'normalLot');
|
|
assert.equal(used.next.offer?.kind, 'atreidesAuction');
|
|
});
|
|
void test('historical sold prefix aliases are valid, while duplicate unsold physical cards reject', () => {
|
|
const g = fixture();
|
|
g.auction!.index = 1;
|
|
g.auction!.cards[0] = g.auction!.cards[1];
|
|
assert.equal(
|
|
quoteAuctionContinuation(g, { kind: 'next' }).next.kind,
|
|
'normalLot',
|
|
);
|
|
g.auction!.cards[2] = g.auction!.cards[1];
|
|
assert.throws(
|
|
() => quoteAuctionContinuation(g, { kind: 'next' }),
|
|
/physical/,
|
|
);
|
|
});
|
|
void test('bad source index, opener, bidder, receipt and declared family reject atomically without requiring funds or future draw supply', () => {
|
|
const mutations: ((g: AuctionContinuationInput) => void)[] = [
|
|
(g) => {
|
|
g.phase = 5;
|
|
},
|
|
(g) => {
|
|
g.order = [];
|
|
},
|
|
(g) => {
|
|
g.order = ['h', 'h'];
|
|
},
|
|
(g) => {
|
|
g.auction!.index = 99;
|
|
},
|
|
(g) => {
|
|
g.auction!.opener = -1;
|
|
},
|
|
(g) => {
|
|
g.auction!.active = 'unknown';
|
|
},
|
|
(g) => {
|
|
g.auction!.bid = -1;
|
|
},
|
|
(g) => {
|
|
g.sale!.amount = 3;
|
|
},
|
|
(g) => {
|
|
g.sale!.winner = 'e';
|
|
},
|
|
(g) => {
|
|
g.sale!.seller = 'e';
|
|
},
|
|
(g) => {
|
|
g.auction!.passed = ['e', 'e'];
|
|
},
|
|
];
|
|
for (const mutate of mutations) {
|
|
const g = fixture();
|
|
mutate(g);
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() => quoteAuctionContinuation(g, { kind: 'next' }),
|
|
AuctionContinuationError,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
const g = fixture();
|
|
g.players[0].spice = 0;
|
|
assert.equal(
|
|
quoteAuctionContinuation(g, cancel('emperorIncome', 'e')).next.kind,
|
|
'response',
|
|
);
|
|
assert.throws(
|
|
() => quoteAuctionContinuation(g, cancel('emperorIncome', 'g')),
|
|
/not payable/,
|
|
);
|
|
assert.throws(
|
|
() => quoteAuctionContinuation(g, cancel('harkonnenBonus', 'e')),
|
|
/wrong winner/,
|
|
);
|
|
});
|
|
function richese(
|
|
source: 'cache' | 'blackMarket' = 'blackMarket',
|
|
winner = 'h',
|
|
) {
|
|
const g = fixture();
|
|
g.players = [
|
|
...g.players,
|
|
{ id: 'r', faction: 'richese', spice: 20, hand: [], handLimit: 4 },
|
|
];
|
|
g.order = [...g.order, 'r'];
|
|
let lot = createRicheseAuction({
|
|
event: 'lot-2',
|
|
cardId: 'richese-distrans',
|
|
source,
|
|
owner: 'r',
|
|
method: 'silent',
|
|
eligible: g.order,
|
|
order: g.order,
|
|
tieOrder: g.order,
|
|
});
|
|
for (const actor of g.order)
|
|
lot = submitRicheseBid(
|
|
lot,
|
|
{ event: lot.event, actor, amount: actor === winner ? 4 : 0 },
|
|
20,
|
|
);
|
|
g.auction = null;
|
|
g.richeseAuction = lot;
|
|
g.richeseBidding = {
|
|
owner: 'r',
|
|
event: 'declaration-2',
|
|
turn: 2,
|
|
stage: 'lot',
|
|
position: source === 'cache' ? 'first' : null,
|
|
normalCount: source === 'cache' ? 3 : null,
|
|
blackMarketSold: false,
|
|
cacheCanceled: false,
|
|
};
|
|
g.richeseCacheCount = 8;
|
|
g.sale = { winner, amount: 4, free: false, origin: source, seller: 'r' };
|
|
return g;
|
|
}
|
|
void test('sold Richese lots credit their seller exactly once before bonus, retaining separate declaration/pool/phase boundaries', () => {
|
|
for (const source of ['blackMarket', 'cache'] as const) {
|
|
const g = richese(source),
|
|
q = quoteAuctionContinuation(g, { kind: 'sale', free: false });
|
|
assert.deepEqual(q.steps, [
|
|
{ kind: 'sellerCredit', player: 'r', amount: 4, balance: 24 },
|
|
]);
|
|
assert.equal(q.next.kind, 'response');
|
|
assert.deepEqual(quoteAuctionContinuation(g, { kind: 'bonus' }).steps, []);
|
|
const end = quoteAuctionContinuation(g, { kind: 'next' });
|
|
assert.ok(end.next.kind === 'richeseEnd');
|
|
assert.equal(
|
|
end.next.after,
|
|
source === 'cache' ? 'normalPool' : 'declaration',
|
|
);
|
|
}
|
|
const g = richese('cache', 'r');
|
|
assert.deepEqual(
|
|
quoteAuctionContinuation(g, { kind: 'sale', free: false }).next,
|
|
{
|
|
kind: 'response',
|
|
response: { kind: 'emperorIncome', owner: 'e', passed: [] },
|
|
},
|
|
);
|
|
g.richeseBidding!.position = 'last';
|
|
assert.equal(
|
|
(quoteAuctionContinuation(g, { kind: 'next' }).next as { after: string })
|
|
.after,
|
|
'phase',
|
|
);
|
|
});
|
|
|
|
void test('paid Richese donor receipts route only their bound share, including Emperor buyer and full donor funding', () => {
|
|
for (const contribution of [1, 4]) {
|
|
const g = richese('cache', 'e');
|
|
g.sale!.richeseContribution = contribution;
|
|
// Payment is already committed: no live alliance/funds reconstruction.
|
|
g.players.find(p => p.id === 'r')!.spice = 0;
|
|
const q = quoteAuctionContinuation(g, { kind: 'sale', free: false });
|
|
assert.deepEqual(q.steps, [
|
|
{ kind: 'sellerCredit', player: 'r', amount: 4 - contribution, balance: 4 - contribution },
|
|
]);
|
|
assert.equal(auctionEmperorIncomeAmount(q.sale), contribution);
|
|
assert.ok(q.next.kind === 'response' && q.next.response.kind === 'emperorIncome');
|
|
const canceled = quoteAuctionContinuation(g, cancel('emperorIncome', 'e'));
|
|
assert.deepEqual(canceled.steps, []);
|
|
assert.ok(canceled.next.kind === 'richeseEnd' && canceled.next.after === 'normalPool');
|
|
g.sale!.recipient = 'bank';
|
|
assert.equal(auctionEmperorIncomeAmount(g.sale!), 0);
|
|
assert.deepEqual(quoteAuctionContinuation(g, { kind: 'sale', free: false }).steps, []);
|
|
assert.throws(() => quoteAuctionContinuation(g, cancel('emperorIncome', 'e')), /not payable/);
|
|
}
|
|
});
|
|
|
|
void test('Richese donor scalars reject impossible source, rule band and amount without changing the receipt', () => {
|
|
const invalid: ((g: AuctionContinuationInput) => void)[] = [
|
|
g => { g.sale!.richeseContribution = 0; },
|
|
g => { g.sale!.richeseContribution = -1; },
|
|
g => { g.sale!.richeseContribution = 1.5; },
|
|
g => { g.sale!.richeseContribution = 5; },
|
|
g => { g.advanced = false; },
|
|
];
|
|
for (const mutate of invalid) {
|
|
const g = richese('cache');
|
|
g.sale!.richeseContribution = 2;
|
|
mutate(g);
|
|
const before = structuredClone(g);
|
|
assert.throws(() => quoteAuctionContinuation(g, { kind: 'sale', free: false }), /contribution/);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
for (const g of [fixture(), richese('cache', 'r')]) {
|
|
g.sale!.richeseContribution = 2;
|
|
const before = structuredClone(g);
|
|
assert.throws(() => quoteAuctionContinuation(g, { kind: 'next' }), /contribution/);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|
|
|
|
void test('canceling a partial Richese-paid Emperor invoice advances to native Harkonnen bonus without replaying seller income', () => {
|
|
const g = richese('blackMarket');
|
|
g.sale!.richeseContribution = 2;
|
|
const paid = quoteAuctionContinuation(g, { kind: 'sale', free: false });
|
|
assert.deepEqual(paid.steps, [
|
|
{ kind: 'sellerCredit', player: 'r', amount: 2, balance: 22 },
|
|
]);
|
|
assert.ok(paid.next.kind === 'response' && paid.next.response.kind === 'emperorIncome');
|
|
const before = structuredClone(g);
|
|
const canceled = quoteAuctionContinuation(g, cancel('emperorIncome', 'e'));
|
|
assert.deepEqual(canceled.steps, []);
|
|
assert.ok(canceled.next.kind === 'response' && canceled.next.response.kind === 'harkonnenBonus');
|
|
assert.deepEqual(g, before);
|
|
});
|
|
void test('Black Market cache exhaustion is checked only when reaching that declaration boundary; seller balance overflow rejects before credit', () => {
|
|
const g = richese();
|
|
g.richeseCacheCount = 0;
|
|
assert.equal(
|
|
quoteAuctionContinuation(g, { kind: 'sale', free: false }).next.kind,
|
|
'response',
|
|
);
|
|
assert.throws(
|
|
() => quoteAuctionContinuation(g, { kind: 'next' }),
|
|
/exhausted/,
|
|
);
|
|
g.richeseCacheCount = 8;
|
|
g.players.find((p) => p.id === 'r')!.spice = Number.MAX_SAFE_INTEGER;
|
|
assert.throws(
|
|
() => quoteAuctionContinuation(g, { kind: 'sale', free: false }),
|
|
/overflow/,
|
|
);
|
|
});
|
|
void test('Ix denial binds acquired-card reference and free paid receipt without demanding current card custody or repeating its purchase', () => {
|
|
const g = fixture();
|
|
g.players = [
|
|
...g.players,
|
|
{ id: 'i', faction: 'ixians', spice: 0, hand: [], handLimit: 4 },
|
|
];
|
|
g.order = [...g.order, 'i'];
|
|
g.pendingIxAlly = { player: 'h', card: g.auction!.cards[0].id, free: false };
|
|
g.players[0].hand = [];
|
|
g.players[0].spice = 0;
|
|
const q = quoteAuctionContinuation(g, cancel('ixAllyCard', 'i', 'h'));
|
|
assert.deepEqual(q.steps, [{ kind: 'clearIxAlly' }]);
|
|
assert.equal(q.next.kind, 'response');
|
|
g.pendingIxAlly.free = true;
|
|
assert.throws(
|
|
() => quoteAuctionContinuation(g, cancel('ixAllyCard', 'i', 'h')),
|
|
/completed purchase/,
|
|
);
|
|
g.pendingIxAlly.free = false;
|
|
delete g.sale;
|
|
assert.throws(
|
|
() => quoteAuctionContinuation(g, cancel('ixAllyCard', 'i', 'h')),
|
|
/completed purchase/,
|
|
);
|
|
});
|
|
|
|
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 normalized(g: Game): AuctionContinuationInput {
|
|
return {
|
|
status: g.status,
|
|
phase: g.phase,
|
|
turn: g.turn,
|
|
advanced: g.advanced,
|
|
physicalCards: [
|
|
...g.players.flatMap((p) => p.hand),
|
|
...g.deck,
|
|
...g.discard,
|
|
...(g.auction?.cards.slice(g.auction.index + 1) ?? []),
|
|
],
|
|
order: g.order,
|
|
players: g.players.map((p) => ({
|
|
id: p.id,
|
|
faction: p.faction,
|
|
ally: p.ally,
|
|
spice: p.spice,
|
|
hand: p.hand,
|
|
handLimit: p.faction === 'harkonnen' ? 8 : 4,
|
|
})),
|
|
auction: g.auction,
|
|
sale: g.currentAuctionSale,
|
|
pendingIxAlly: g.pendingIxAlly,
|
|
richeseAuction: g.richeseAuction,
|
|
richeseBidding: g.richeseBidding,
|
|
richeseCacheCount: g.richeseCache?.length,
|
|
ixTechnologyTurn: g.ixTechnologyTurn,
|
|
};
|
|
}
|
|
const observed = {} as {
|
|
currentAuctionContinuationQuote: (
|
|
g: Game,
|
|
o: AuctionContinuationOperation,
|
|
s?: { player: string; card: string },
|
|
) => unknown;
|
|
};
|
|
const engineURL = new URL('../game/engine.ts', import.meta.url);
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(engineURL, 'utf8') +
|
|
'\nexport {currentAuctionContinuationQuote};',
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports: observed,
|
|
require: createRequire(engineURL),
|
|
crypto: webcrypto,
|
|
structuredClone,
|
|
TextEncoder,
|
|
JSON,
|
|
},
|
|
);
|
|
for (const form of ['printed', 'bg'] as const)
|
|
void test(`a real ${form} income cancellation preserves the paid card and opens then denies the bonus before the next lot`, () => {
|
|
const f = paid(),
|
|
before = structuredClone(f.g),
|
|
actor = form === 'printed' ? 'g' : 'b',
|
|
card = f[form];
|
|
assert.equal(f.g.response?.kind, 'emperorIncome');
|
|
assert.equal(f.g.players[0].spice, 16);
|
|
let g = applyAction(f.g, actor, {
|
|
type: 'card',
|
|
card: card.id,
|
|
mode: 'cancel',
|
|
});
|
|
if (form === 'bg') g = passOne(JSON.parse(JSON.stringify(g)) as Game);
|
|
assert.deepEqual(f.g, before);
|
|
assert.equal(g.response?.kind, 'harkonnenBonus');
|
|
assert.equal(g.players[1].spice, 20);
|
|
const expected = quoteAuctionContinuation(
|
|
normalized(g),
|
|
cancel('harkonnenBonus', 'h'),
|
|
form === 'printed'
|
|
? { player: 'b', card: f.bg.id }
|
|
: { player: 'g', card: f.printed.id },
|
|
);
|
|
if (form === 'printed') {
|
|
g = applyAction(g, 'b', { type: 'card', card: f.bg.id, mode: 'cancel' });
|
|
g = passOne(g);
|
|
} else
|
|
g = applyAction(g, 'g', {
|
|
type: 'card',
|
|
card: f.printed.id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.equal(g.auction!.index, 1);
|
|
assert.equal(g.currentAuctionSale, null);
|
|
assert.equal(g.players[0].spice, 16);
|
|
assert.equal(g.players[0].hand.length, 2);
|
|
assert.ok(expected.next.kind === 'normalLot');
|
|
assert.equal(g.auction!.active, expected.next.active);
|
|
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
|
|
assert.equal(viewGame(g, 'e').players[0].hand, undefined);
|
|
});
|
|
void test('a real full Harkonnen hand may spend its own last-slot Karama to cancel Emperor income and create bonus eligibility', () => {
|
|
const f = paid(7);
|
|
assert.equal(f.g.players[0].hand.length, 8);
|
|
assert.equal(f.g.response?.kind, 'emperorIncome');
|
|
const preview = quoteAuctionContinuation(
|
|
normalized(f.g),
|
|
cancel('emperorIncome', 'e'),
|
|
{ player: 'h', card: f.own.id },
|
|
);
|
|
assert.equal(preview.next.kind, 'response');
|
|
const g = applyAction(f.g, 'h', {
|
|
type: 'card',
|
|
card: f.own.id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.equal(g.response?.kind, 'harkonnenBonus');
|
|
assert.equal(g.players[0].hand.length, 7);
|
|
assert.equal(g.players[1].spice, 20);
|
|
});
|
|
void test('actual allowed income and bonus credit/draw once and use the shared next-lot reset', () => {
|
|
const f = paid();
|
|
let g = passOne(f.g);
|
|
assert.equal(g.players[1].spice, 24);
|
|
assert.equal(g.response?.kind, 'harkonnenBonus');
|
|
const before = g.players[0].hand.length;
|
|
g = passOne(g);
|
|
assert.equal(g.players[0].hand.length, before + 1);
|
|
assert.equal(g.players[0].spice, 16);
|
|
assert.equal(g.players[1].spice, 24);
|
|
assert.equal(g.auction!.index, 1);
|
|
assert.equal(g.auction!.bid, 0);
|
|
const free = paid(1, true);
|
|
assert.equal(free.g.players[0].spice, 20);
|
|
assert.equal(free.g.response?.kind, 'harkonnenBonus');
|
|
assert.equal(free.g.players[1].spice, 20);
|
|
});
|
|
void test('genuine paid declarations reject malformed next opener and current receipt before printed or BG cost without RNG', (t) => {
|
|
const f = paid();
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('No random draw');
|
|
}),
|
|
uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('No event');
|
|
});
|
|
for (const form of ['printed', 'bg'] as const)
|
|
for (const mutate of [
|
|
(g: Game) => {
|
|
g.auction!.opener = -1;
|
|
},
|
|
(g: Game) => {
|
|
g.currentAuctionSale!.winner = 'e';
|
|
},
|
|
(g: Game) => {
|
|
g.currentAuctionSale!.amount = 99;
|
|
},
|
|
]) {
|
|
const bad = structuredClone(f.g);
|
|
mutate(bad);
|
|
const before = structuredClone(bad),
|
|
actor = form === 'printed' ? 'g' : 'b',
|
|
card = f[form];
|
|
assert.throws(() =>
|
|
observed.currentAuctionContinuationQuote(
|
|
bad,
|
|
cancel('emperorIncome', 'e'),
|
|
{ player: actor, card: card.id },
|
|
),
|
|
);
|
|
assert.throws(() =>
|
|
applyAction(bad, actor, {
|
|
type: 'card',
|
|
card: card.id,
|
|
mode: 'cancel',
|
|
}),
|
|
);
|
|
assert.deepEqual(bad, before);
|
|
}
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
void test('spending the last opposing Karama permits the actual automatic Harkonnen bonus draw and advances exactly one lot', () => {
|
|
const f = paid(1, false, false);
|
|
assert.equal(f.g.response?.kind, 'emperorIncome');
|
|
assert.equal(f.g.players[2].hand.length, 0);
|
|
const ownedBefore = f.g.players[0].hand.length,
|
|
deckBefore = f.g.deck.length;
|
|
const g = applyAction(f.g, 'g', {
|
|
type: 'card',
|
|
card: f.printed.id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.auction!.index, 1);
|
|
assert.equal(g.players[0].hand.length, ownedBefore + 1);
|
|
assert.equal(g.deck.length, deckBefore - 1);
|
|
assert.equal(g.players[0].spice, 16);
|
|
assert.equal(g.players[1].spice, 20);
|
|
assert.equal(g.discard.filter((c) => c.id === f.printed.id).length, 1);
|
|
assert.equal(
|
|
g.log.filter((e) => e.text.includes('drew one bonus treachery card'))
|
|
.length,
|
|
1,
|
|
);
|
|
});
|