135 lines
7.0 KiB
TypeScript
135 lines
7.0 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, type Action, type Game } from '../game/engine';
|
|
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
|
import { advanceTruthAlliance, createTruthAllianceFixture, truthAllianceClean } from './fixture-truth-alliance';
|
|
|
|
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
|
const seat = (game: Game, id: string) => { const result = game.players.find(player => player.id === id); assert.ok(result); return result; };
|
|
|
|
/** Original public H/A/E setup and real second-turn Nexus; only the optional
|
|
* empty group ledger is staged. Test wallets and the cancellation card swap
|
|
* below are explicitly controlled, not claimed native earnings. */
|
|
function auction(advanced: boolean, group: boolean): Game {
|
|
const source = createTruthAllianceFixture({ advanced, consent: 'none' });
|
|
let game = structuredClone(source.game);
|
|
if (group) game.coalitionState = emptyCoalitionState('three-short');
|
|
game = act(game, 'harkonnen', { type: 'alliance', target: 'emperor' });
|
|
game = act(game, 'emperor', { type: 'alliance', target: 'harkonnen' });
|
|
if (group) {
|
|
const pair = coalitionOf(game.players, game.coalitionState, 'harkonnen'); assert.ok(pair);
|
|
game = act(game, 'harkonnen', { type: 'coalitionPropose', groupId: pair.id,
|
|
entrant: 'atreides', expectedRevision: game.coalitionState!.revision });
|
|
for (const id of ['emperor', 'atreides']) {
|
|
const offer = game.coalitionState!.proposal!;
|
|
game = act(game, id, { type: 'coalitionAnswer', event: offer.event, nexus: offer.nexus,
|
|
turn: offer.turn, accept: true, expectedRevision: game.coalitionState!.revision });
|
|
}
|
|
}
|
|
game = advanceTruthAlliance(game, state => state.phase === 3 && !!state.auction && truthAllianceClean(state));
|
|
for (let count = 0; game.auction && game.auction.active !== 'harkonnen' && count < game.players.length; count++)
|
|
game = act(game, game.auction.active, { type: 'passBid' });
|
|
assert.equal(game.auction?.active, 'harkonnen');
|
|
// Set only the wallets needed to observe the original physical bid and payer
|
|
// legs; no auction, card, seat, alliance, or pending receipt is installed.
|
|
seat(game, 'harkonnen').spice = 2;
|
|
seat(game, 'emperor').spice = 10;
|
|
seat(game, 'atreides').spice = 10;
|
|
return game;
|
|
}
|
|
|
|
function passResponses(game: Game): Game {
|
|
for (let count = 0; game.response && count < 20; count++) {
|
|
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
|
game = act(game, actor.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(game.response, null);
|
|
return game;
|
|
}
|
|
|
|
/** Reach native paid sale, leaving any resulting Emperor-income response for
|
|
* the caller. Do not invent the special purchase or repeat an invoice. */
|
|
function bid(game: Game, action: Action): Game {
|
|
game = act(game, 'harkonnen', action);
|
|
for (let count = 0; !game.response && !game.decision && game.auction?.bidder === 'harkonnen' && count < 12; count++)
|
|
game = act(game, game.auction.active, { type: 'passBid' });
|
|
if (game.pendingEmperorAuctionGift)
|
|
game = act(game, game.pendingEmperorAuctionGift.owner,
|
|
{ type: 'emperorAuctionGiftContinue', event: game.pendingEmperorAuctionGift.event });
|
|
if (!game.response && game.decision?.kind === 'auctionPayment')
|
|
game = act(game, 'harkonnen', { type: 'decision', karama: false });
|
|
return game;
|
|
}
|
|
|
|
for (const amount of [2, 6]) {
|
|
void test(`Advanced ordinary pair routes the Emperor's ${amount}-spice contribution to the bank`, () => {
|
|
let game = auction(true, false);
|
|
const hand = seat(game, 'harkonnen').hand.length;
|
|
seat(game, 'harkonnen').spice = amount === 2 ? 4 : 0;
|
|
game = act(game, 'emperor', { type: 'pledgeAid', amount });
|
|
game = bid(game, { type: 'bid', amount: 6, allyPayment: amount });
|
|
game = passResponses(game);
|
|
assert.ok(seat(game, 'harkonnen').hand.length > hand);
|
|
assert.equal(seat(game, 'harkonnen').spice, 0);
|
|
assert.equal(seat(game, 'emperor').spice, 10 - amount + (6 - amount));
|
|
});
|
|
}
|
|
|
|
for (const amount of [2, 6]) {
|
|
void test(`Advanced three-member bid routes the Emperor's ${amount}-spice share to the bank`, () => {
|
|
let game = auction(true, true);
|
|
seat(game, 'harkonnen').spice = amount === 6 ? 0 : 1;
|
|
game = act(game, 'emperor', { type: 'coalitionPledge', recipient: 'harkonnen', amount });
|
|
if (amount === 2) game = act(game, 'atreides', { type: 'coalitionPledge', recipient: 'harkonnen', amount: 3 });
|
|
game = bid(game, { type: 'bid', amount: 6, allyPayment: 0, coalitionContributions: amount === 6
|
|
? [{ donor: 'emperor', amount: 6 }]
|
|
: [{ donor: 'emperor', amount: 2 }, { donor: 'atreides', amount: 3 }] });
|
|
game = passResponses(game);
|
|
assert.equal(seat(game, 'harkonnen').spice, 0);
|
|
assert.equal(seat(game, 'emperor').spice, 10 - amount + (6 - amount));
|
|
assert.equal(seat(game, 'atreides').spice, amount === 2 ? 7 : 10);
|
|
});
|
|
}
|
|
|
|
void test('Advanced direct Emperor gift belongs to the buyer and is not treated as an Emperor contribution', () => {
|
|
let game = auction(true, false);
|
|
game = act(game, 'emperor', { type: 'emperorGift', amount: 2 });
|
|
game = passResponses(game);
|
|
assert.equal(seat(game, 'emperor').spice, 8);
|
|
assert.equal(seat(game, 'harkonnen').spice, 4);
|
|
game = bid(game, { type: 'bid', amount: 4, allyPayment: 0 });
|
|
game = passResponses(game);
|
|
assert.equal(seat(game, 'harkonnen').spice, 0);
|
|
assert.equal(seat(game, 'emperor').spice, 12);
|
|
});
|
|
|
|
void test('Basic keeps the earlier full-Emperor-income handling for the same ordinary pair bid', () => {
|
|
let game = auction(false, false);
|
|
seat(game, 'harkonnen').spice = 4;
|
|
game = act(game, 'emperor', { type: 'pledgeAid', amount: 2 });
|
|
game = bid(game, { type: 'bid', amount: 6, allyPayment: 2 });
|
|
game = passResponses(game);
|
|
assert.equal(seat(game, 'harkonnen').spice, 0);
|
|
assert.equal(seat(game, 'emperor').spice, 14);
|
|
});
|
|
|
|
void test('canceling the remaining Advanced Emperor income keeps both payer debits and the purchased card', () => {
|
|
let game = auction(true, false);
|
|
seat(game, 'harkonnen').spice = 4;
|
|
// Controlled conservation supplement: swap one original dealt Harkonnen
|
|
// card for an original unauctioned Karama, with no card duplicated.
|
|
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
|
const [karama] = game.deck.splice(index, 1), [swapped] = seat(game, 'harkonnen').hand.splice(0, 1);
|
|
seat(game, 'harkonnen').hand.push(karama); game.deck.push(swapped);
|
|
const hand = seat(game, 'harkonnen').hand.length;
|
|
game = act(game, 'emperor', { type: 'pledgeAid', amount: 2 });
|
|
game = bid(game, { type: 'bid', amount: 6, allyPayment: 2 });
|
|
assert.equal(game.response?.kind, 'emperorIncome');
|
|
game = act(game, 'harkonnen', { type: 'card', mode: 'cancel', card: karama.id });
|
|
game = passResponses(game);
|
|
assert.equal(seat(game, 'harkonnen').spice, 0);
|
|
assert.equal(seat(game, 'emperor').spice, 8);
|
|
assert.ok(seat(game, 'harkonnen').hand.length >= hand);
|
|
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
|
|
});
|