224 lines
11 KiB
TypeScript
224 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions, runBots } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance';
|
|
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
|
|
|
const act = (g: Game, actor: string, action: Action) => applyAction(g, actor, action);
|
|
const seat = (g: Game, id: string) => { const p = g.players.find(row => row.id === id); assert.ok(p); return p; };
|
|
const copy = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
|
|
/** Genuine Advanced H/A/E setup, second-turn worm and reciprocal alliance.
|
|
* Test wallets and conservation-preserving Karama swaps are staged; bidding,
|
|
* passing, settlement and all responses are native actions. */
|
|
function openAuction(buyerKarama = false, emperorSpice = 10) {
|
|
const source = createTruthAllianceFixture({advanced:true, consent:'none'});
|
|
let g = source.game;
|
|
g = act(g, 'harkonnen', {type:'alliance', target:'emperor'});
|
|
g = act(g, 'emperor', {type:'alliance', target:'harkonnen'});
|
|
g = advanceTruthAlliance(g, s => s.phase === 3 && !!s.auction && truthAllianceClean(s));
|
|
for (let i = 0; g.auction && g.auction.active !== 'harkonnen' && i < g.players.length; i++)
|
|
g = act(g, g.auction.active, {type:'passBid'});
|
|
assert.equal(g.auction?.active, 'harkonnen');
|
|
seat(g, 'harkonnen').spice = 6; seat(g, 'emperor').spice = emperorSpice;
|
|
// The winner's private physical Karama changes only whether the underlying
|
|
// auction needs its own payment choice, never whether this gift opens.
|
|
const buyer = seat(g, 'harkonnen');
|
|
const removed = buyer.hand.filter(card => card.effect === 'karama');
|
|
buyer.hand = buyer.hand.filter(card => card.effect !== 'karama');
|
|
g.deck.push(...removed);
|
|
if (buyerKarama) {
|
|
const index = g.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
|
const [card] = g.deck.splice(index, 1); buyer.hand.push(card);
|
|
}
|
|
return g;
|
|
}
|
|
function finishBid(g: Game, amount = 4, allyPayment = 0) {
|
|
const card = g.auction!.cards[g.auction!.index].id;
|
|
g = act(g, 'harkonnen', {type:'bid', amount, allyPayment});
|
|
for (let i = 0; g.auction && !g.response && !g.decision && i < 12; i++)
|
|
g = act(g, g.auction.active, {type:'passBid'});
|
|
assert.equal(g.decision?.kind, 'auctionPayment');
|
|
return {g, card};
|
|
}
|
|
function opportunity(g: Game) {
|
|
const result = viewGame(g, 'emperor').emperorAuctionGift;
|
|
assert.ok(result); return result;
|
|
}
|
|
function continued(g: Game) {
|
|
const offer = opportunity(g);
|
|
return act(g, 'emperor', {type:'emperorAuctionGiftContinue', event:offer.event});
|
|
}
|
|
function passResponses(g: Game) {
|
|
for (let i = 0; g.response && i < 30; i++) {
|
|
const p = g.players.find(row => !g.response!.passed.includes(row.id)); assert.ok(p);
|
|
g = act(g, p.id, {type:'passResponse'});
|
|
}
|
|
return g;
|
|
}
|
|
function completePayment(g: Game) {
|
|
if (g.decision?.kind === 'auctionPayment')
|
|
g = act(g, g.decision.player, {type:'decision', karama:false});
|
|
return passResponses(g);
|
|
}
|
|
function frozenAuction(g: Game) {
|
|
const a = g.auction!;
|
|
return {decision:structuredClone(g.decision), turn:g.turn, phase:g.phase, active:g.active,
|
|
card:a.cards[a.index].id, index:a.index, bid:a.bid, bidder:a.bidder,
|
|
passed:structuredClone(a.passed), allyPayment:a.allyPayment,
|
|
aid:structuredClone(g.aid), clock:structuredClone(g.auctionClock ?? null)};
|
|
}
|
|
|
|
for (const buyerKarama of [false, true]) for (const emperorSpice of [0, 10]) {
|
|
void test(`uniform prepayment gift opportunity: buyer Karama ${buyerKarama}, Emperor spice ${emperorSpice}`, () => {
|
|
const {g: initial, card} = finishBid(openAuction(buyerKarama, emperorSpice));
|
|
const g = copy(initial), offer = opportunity(g), parent = frozenAuction(g);
|
|
assert.equal(offer.recipient, 'harkonnen');
|
|
assert.equal(offer.maximum, emperorSpice);
|
|
assert.deepEqual(viewGame(g, 'emperor').emperorAuctionGiftPending, {owner:'emperor'});
|
|
for (const other of ['harkonnen', 'atreides']) {
|
|
assert.equal(viewGame(g, other).emperorAuctionGift, null);
|
|
assert.deepEqual(viewGame(g, other).emperorAuctionGiftPending, {owner:'emperor'});
|
|
}
|
|
const before = copy(g);
|
|
assert.throws(() => act(g, 'harkonnen', {type:'decision', karama:false}));
|
|
assert.throws(() => act(g, 'harkonnen', {type:'emperorAuctionGiftContinue', event:offer.event}));
|
|
assert.throws(() => act(g, 'emperor', {type:'emperorAuctionGiftContinue', event:'stale'}));
|
|
assert.deepEqual(g, before);
|
|
let next = continued(g);
|
|
assert.equal(viewGame(next, 'emperor').emperorAuctionGift, null);
|
|
assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null);
|
|
if (buyerKarama) {
|
|
assert.deepEqual(frozenAuction(next), parent);
|
|
assert.equal(next.decision?.kind, 'auctionPayment');
|
|
next = completePayment(next);
|
|
} else next = passResponses(next);
|
|
assert.ok(seat(next, 'harkonnen').hand.some(row => row.id === card), 'original lot delivered once');
|
|
assert.throws(() => act(next, 'emperor', {type:'emperorAuctionGiftContinue', event:offer.event}));
|
|
});
|
|
}
|
|
|
|
void test('a prepayment gift preserves the declared mixed payer split and Advanced payment routing', () => {
|
|
let g = openAuction(true);
|
|
seat(g, 'harkonnen').spice = 4;
|
|
g = act(g, 'emperor', {type:'pledgeAid', amount:2});
|
|
const bid = finishBid(g, 6, 2); g = bid.g;
|
|
const parent = frozenAuction(g), event = opportunity(g).event;
|
|
assert.equal(opportunity(g).maximum, 8);
|
|
g = act(g, 'emperor', {type:'emperorGift', amount:1});
|
|
assert.equal(g.response?.kind, 'emperorGift');
|
|
assert.equal(seat(g, 'emperor').spice, 8);
|
|
g = passResponses(copy(g));
|
|
assert.deepEqual(frozenAuction(g), parent);
|
|
assert.equal(seat(g, 'harkonnen').spice, 5);
|
|
assert.equal(seat(g, 'emperor').spice, 7);
|
|
assert.equal(opportunity(g).event, event);
|
|
g = continued(g);
|
|
assert.equal(g.decision?.kind, 'auctionPayment');
|
|
g = completePayment(g);
|
|
assert.ok(seat(g, 'harkonnen').hand.some(card => card.id === bid.card));
|
|
assert.equal(seat(g, 'harkonnen').spice, 1);
|
|
assert.equal(seat(g, 'emperor').spice, 11,
|
|
'Own-price four returns to Emperor; its separately pledged two went to Bank.');
|
|
});
|
|
|
|
void test('a real Karama cancels the gift without disturbing the auction or its payment', () => {
|
|
let g = openAuction(true);
|
|
const third = seat(g, 'atreides');
|
|
let card = third.hand.find(row => row.effect === 'karama');
|
|
if (!card) {
|
|
const index = g.deck.findIndex(row => row.effect === 'karama'); assert.ok(index >= 0);
|
|
[card] = g.deck.splice(index, 1); third.hand.push(card);
|
|
}
|
|
assert.ok(card);
|
|
const bid = finishBid(g); g = bid.g;
|
|
const parent = frozenAuction(g), emperor = seat(g, 'emperor').spice, buyer = seat(g, 'harkonnen').spice;
|
|
g = act(g, 'emperor', {type:'emperorGift', amount:2});
|
|
assert.equal(g.response?.kind, 'emperorGift');
|
|
g = act(g, 'atreides', {type:'card', card:card.id, mode:'cancel'});
|
|
assert.deepEqual(frozenAuction(g), parent);
|
|
assert.equal(seat(g, 'emperor').spice, emperor);
|
|
assert.equal(seat(g, 'harkonnen').spice, buyer);
|
|
assert.equal(g.discard.filter(row => row.id === card!.id).length, 1);
|
|
g = continued(g);
|
|
g = completePayment(g);
|
|
assert.ok(seat(g, 'harkonnen').hand.some(row => row.id === bid.card));
|
|
});
|
|
|
|
void test('four minimal Emperor policies can continue the neutral prepayment opportunity', () => {
|
|
const {g} = finishBid(openAuction(false, 0));
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(g, 'emperor');
|
|
view.players.find(p => p.id === 'emperor')!.bot = difficulty;
|
|
const actions = botActions(view);
|
|
assert.deepEqual(actions, [{type:'emperorAuctionGiftContinue', event:opportunity(g).event}]);
|
|
const next = act(copy(g), 'emperor', actions[0]);
|
|
assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null);
|
|
}
|
|
});
|
|
|
|
void test('Advanced Bene Gesserit may use a physical Worthless card to cancel the gift', () => {
|
|
let g = createGame('EMPAUCTIONBG', newPlayer('emperor', 'Emperor', 'emperor'), true);
|
|
joinGame(g, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
|
|
joinGame(g, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit'));
|
|
for (const p of g.players) g = act(g, p.id, {type:'ready'});
|
|
g = act(g, 'emperor', {type:'start', advancedPreview:true});
|
|
for (let i = 0; i < 80 && g.status === 'setup'; i++) {
|
|
const step = nextStrongholdFactionsNativeStep(g); assert.ok(step);
|
|
g = act(g, step.actor, step.action);
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
// Stage only the current alliance, test wallets and unopened ordinary lot;
|
|
// the Worthless card is moved from the actual deck, never manufactured.
|
|
seat(g, 'emperor').ally = 'harkonnen'; seat(g, 'harkonnen').ally = 'emperor';
|
|
seat(g, 'emperor').spice = 10; seat(g, 'harkonnen').spice = 6;
|
|
const bg = seat(g, 'beneGesserit');
|
|
let worthless = bg.hand.find(card => card.kind === 'worthless');
|
|
if (!worthless) {
|
|
const index = g.deck.findIndex(card => card.kind === 'worthless'); assert.ok(index >= 0);
|
|
[worthless] = g.deck.splice(index, 1);
|
|
if (bg.hand.length >= 4) g.deck.push(bg.hand.pop()!);
|
|
bg.hand.push(worthless);
|
|
}
|
|
assert.ok(worthless);
|
|
// A genuine opposing Karama keeps the conversion response visible long
|
|
// enough to exercise its continuation instead of automatically passing.
|
|
const emperorSeat = seat(g, 'emperor');
|
|
if (!emperorSeat.hand.some(card => card.effect === 'karama')) {
|
|
const index = g.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
|
const [counter] = g.deck.splice(index, 1);
|
|
if (emperorSeat.hand.length >= 4) g.deck.push(emperorSeat.hand.pop()!);
|
|
emperorSeat.hand.push(counter);
|
|
}
|
|
const lot = g.deck.shift(); assert.ok(lot);
|
|
Object.assign(g, {phase:3, active:'harkonnen', order:['harkonnen','emperor','beneGesserit'],
|
|
response:null, decision:null, phaseOpening:null, ready:[],
|
|
auction:{cards:[lot], index:0, bid:0, bidder:null, active:'harkonnen', passed:[], opener:0, peekKnown:false}});
|
|
const bid = finishBid(g); g = bid.g;
|
|
const original = frozenAuction(g), event = opportunity(g).event;
|
|
const emperor = seat(g, 'emperor').spice, buyer = seat(g, 'harkonnen').spice;
|
|
g = act(g, 'emperor', {type:'emperorGift', amount:2});
|
|
assert.equal(g.response?.kind, 'emperorGift');
|
|
g = act(g, 'beneGesserit', {type:'card', card:worthless.id, mode:'cancel'});
|
|
assert.equal(g.response?.kind, 'worthlessKarama');
|
|
g = passResponses(g);
|
|
assert.deepEqual(frozenAuction(g), original);
|
|
assert.equal(opportunity(g).event, event);
|
|
assert.equal(seat(g, 'emperor').spice, emperor);
|
|
assert.equal(seat(g, 'harkonnen').spice, buyer);
|
|
assert.equal(g.discard.filter(card => card.id === worthless!.id).length, 1);
|
|
g = continued(g);
|
|
g = completePayment(g);
|
|
assert.ok(seat(g, 'harkonnen').hand.some(card => card.id === bid.card));
|
|
});
|
|
|
|
void test('the normal bot scheduler advances a bot Emperor before the buyer payment', () => {
|
|
const {g, card} = finishBid(openAuction(false, 0));
|
|
seat(g, 'emperor').bot = 'Medium';
|
|
const next = runBots(g, 8);
|
|
assert.equal(viewGame(next, 'emperor').emperorAuctionGiftPending, null);
|
|
assert.ok(seat(next, 'harkonnen').hand.some(row => row.id === card));
|
|
});
|