Route Advanced Emperor allied auction funding
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
3ba9087405
commit
599af2475d
19 files changed
+883
-35
No files matched your search
@@ -0,0 +1,130 @@
|
||||
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.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);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import {
|
||||
allowE3Responses, chooseBureaucratE3Payment, createBureaucratE3AuctionFixture,
|
||||
payBureaucratE3Auction,
|
||||
} from './fixture-bureaucrat-e3-funding';
|
||||
import { createSpiceBankerIncomeFixture } from './fixture-spice-banker-income';
|
||||
import { freshBiddingGame, biddingPlayer } from './fixture-homeworld-occupied-bidding';
|
||||
import { qualifyDefensePosition } from './fixture-homeworld-occupied-defenses';
|
||||
import { advanceOriginal } from './fixture-homeworld-occupied-producers';
|
||||
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
};
|
||||
|
||||
void test('Advanced Emperor ally bank share does not inflate a Bureaucrat diversion from the buyer-funded income', () => {
|
||||
// Real E3 setup, original Bureaucrat assignment and turn-two alliance;
|
||||
// the fixture uses the original wallets and physical card.
|
||||
const f = createBureaucratE3AuctionFixture({ advanced: true, donor: 'emperor', payerShare: 5, donorShare: 3 });
|
||||
const paid = payBureaucratE3Auction(f);
|
||||
assert.deepEqual(paid.currentAuctionSale?.paymentRouting, [
|
||||
{ payer: f.payer, amount: 5, recipient: 'emperor' },
|
||||
{ payer: f.donor, amount: 3, recipient: 'bank' },
|
||||
]);
|
||||
const pending = allowE3Responses(paid);
|
||||
assert.equal(pending.decision?.kind, 'bureaucratPayment');
|
||||
assert.equal(pending.decision?.player, f.trainer);
|
||||
const done = allowE3Responses(chooseBureaucratE3Payment(pending, true));
|
||||
assert.equal(seat(done, f.payer).spice, seat(f.beforePledge, f.payer).spice - 5);
|
||||
assert.equal(seat(done, f.donor).spice, seat(f.beforePledge, f.donor).spice - 3 + 3);
|
||||
assert.equal(done.bureaucratPayments?.used.length, 1);
|
||||
assert.ok(seat(done, f.payer).hand.some(card => card.id === f.card));
|
||||
});
|
||||
|
||||
void test('Advanced Emperor ally bank share creates a Spice Banker grant from the actual bank leg', () => {
|
||||
// Real Skills setup and native bidding; the existing fixture stages the
|
||||
// reciprocal alliance and its original aid before the physical purchase.
|
||||
const f = createSpiceBankerIncomeFixture({ advanced: true, kind: 'auction',
|
||||
emperor: true, amount: 6, allySplit: 4 });
|
||||
const emperor = f.game.players.find(player => player.faction === 'emperor')!;
|
||||
let game = applyAction(f.beforePayment, f.actor, f.paymentAction);
|
||||
game = allowE3Responses(game);
|
||||
const sources = game.spiceBankerIncome!.sources.slice(f.beforePayment.spiceBankerIncome!.sources.length);
|
||||
assert.equal(sources.length, 1);
|
||||
assert.deepEqual(sources[0].source.bankLegs, [{ payer: emperor.id, amount: 4 }]);
|
||||
assert.equal(sources[0].grant?.owner, f.owner);
|
||||
assert.equal(seat(game, emperor.id).spice, seat(f.beforePayment, emperor.id).spice + 2);
|
||||
assert.equal(seat(game, f.payer).spice, seat(f.beforePayment, f.payer).spice - 2);
|
||||
});
|
||||
|
||||
void test('occupied Kaitain allocates only the buyer-funded Emperor income from an Advanced split bid', () => {
|
||||
let game = freshBiddingGame('emperor', 'harkonnen');
|
||||
// Existing physical occupation fixture stages Kaitain's qualifying position;
|
||||
// this test also stages the pair and its wallets, then uses native actions.
|
||||
qualifyDefensePosition(game, 'homeworld:emperor');
|
||||
biddingPlayer(game, 'ally').ally = 'native';
|
||||
biddingPlayer(game, 'native').ally = 'ally';
|
||||
game = advanceOriginal(game, state => state.phase === 3 && !!state.auction && !state.response && !state.decision);
|
||||
for (let n = 0; game.auction?.active !== 'ally' && n < game.players.length; n++)
|
||||
game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||
assert.equal(game.auction?.active, 'ally');
|
||||
biddingPlayer(game, 'ally').spice = 4;
|
||||
biddingPlayer(game, 'native').spice = 10;
|
||||
const occupierBefore = biddingPlayer(game, 'occupier').spice;
|
||||
game = applyAction(game, 'native', { type: 'pledgeAid', amount: 2 });
|
||||
game = applyAction(game, 'ally', { type: 'bid', amount: 6, allyPayment: 2 });
|
||||
for (let n = 0; !game.currentAuctionSale && n < game.players.length + 2; n++) {
|
||||
if (game.decision?.kind === 'auctionPayment')
|
||||
game = applyAction(game, 'ally', { type: 'decision', karama: false });
|
||||
else game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||
}
|
||||
game = advanceOriginal(game, state => state.decision?.kind === 'homeworldOccupiedPercentage');
|
||||
const offer = viewGame(game, 'occupier').homeworldOccupiedPercentage!;
|
||||
assert.equal(offer.amount, 2);
|
||||
const receipt = game.homeworldOccupiedPercentageLedger!.receipts.at(-1)!;
|
||||
assert.equal(receipt.source.amount, 4);
|
||||
game = applyAction(game, 'occupier', { type: 'decision', event: offer.event, ownAmount: 2 });
|
||||
assert.equal(seat(game, 'native').spice, 10);
|
||||
assert.equal(seat(game, 'ally').spice, 0);
|
||||
assert.equal(seat(game, 'occupier').spice, occupierBefore + 2);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { type Action, type Game } from '../game/engine';
|
||||
import { createTruthtranceRichesePromisesFixture, advanceTruthtranceRichesePromises as advance,
|
||||
actTruthtranceRichesePromises as act, truthtranceRichesePromisesInventory as inventory } from './fixture-truthtrance-richese-promises';
|
||||
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
||||
};
|
||||
|
||||
function choose(game: Game, fields: Record<string, unknown>): Game {
|
||||
assert.ok(game.decision && game.richeseBidding);
|
||||
return act(game, game.decision.player, { type: 'decision', event: game.richeseBidding.event, ...fields } as Action);
|
||||
}
|
||||
|
||||
function responses(game: Game): Game {
|
||||
for (let count = 0; game.response && count < 20; count++) {
|
||||
const player = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(player);
|
||||
game = act(game, player.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Native Advanced public Richese/Atreides/Emperor setup and original card
|
||||
* chronology. The pair is explicitly staged at Charity; wallets are staged only
|
||||
* once the native silent lot is open. No lot, payment or held card is created. */
|
||||
function lot(source: 'cache' | 'blackMarket', buyer: 'atreides' | 'richese') {
|
||||
const f = createTruthtranceRichesePromisesFixture({ advanced: true });
|
||||
let game = structuredClone(f.game);
|
||||
seat(game, buyer).ally = 'emperor'; seat(game, 'emperor').ally = buyer;
|
||||
seat(game, buyer).allySinceTurn = game.turn; seat(game, 'emperor').allySinceTurn = game.turn;
|
||||
game = advance(game, state => state.decision?.kind === 'richeseBlackMarket');
|
||||
if (source === 'blackMarket') {
|
||||
const card = seat(game, 'richese').hand[0]; assert.ok(card);
|
||||
game = choose(game, { card: card.id, method: 'silent' });
|
||||
} else {
|
||||
game = choose(game, { decline: true });
|
||||
game = advance(game, state => state.decision?.kind === 'richeseDeclaration');
|
||||
game = choose(game, { position: 'first' });
|
||||
game = responses(game);
|
||||
game = advance(game, state => state.decision?.kind === 'richeseCache');
|
||||
const card = game.richeseCache?.[0]; assert.ok(card);
|
||||
game = choose(game, { card: card.id, method: 'silent' });
|
||||
}
|
||||
game = responses(game);
|
||||
assert.equal(game.richeseAuction?.source, source);
|
||||
assert.equal(game.richeseAuction?.method, 'silent');
|
||||
seat(game, buyer).spice = 10;
|
||||
seat(game, 'emperor').spice = 10;
|
||||
if (buyer !== 'richese') seat(game, 'richese').spice = 10;
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return { game, buyer, card: game.richeseAuction!.cardId, originalCards: f.originalCards };
|
||||
}
|
||||
|
||||
function purchase(f: ReturnType<typeof lot>): Game {
|
||||
let game = act(f.game, 'emperor', { type: 'pledgeAid', amount: 2 });
|
||||
const event = game.richeseAuction!.event, order = [...game.richeseAuction!.order];
|
||||
for (const id of order)
|
||||
game = act(game, id, { type: 'richeseBid', event, amount: id === f.buyer ? 6 : 0,
|
||||
allyPayment: id === f.buyer ? 2 : 0 });
|
||||
return responses(game);
|
||||
}
|
||||
|
||||
for (const source of ['cache', 'blackMarket'] as const) {
|
||||
void test(`Advanced ${source} sends an Emperor ally's contribution to bank, not Richese`, () => {
|
||||
const f = lot(source, 'atreides');
|
||||
const game = purchase(f);
|
||||
assert.equal(seat(game, 'atreides').spice, 6);
|
||||
assert.equal(seat(game, 'emperor').spice, 8);
|
||||
assert.equal(seat(game, 'richese').spice, 14, 'Seller receives only the buyer-funded four spice.');
|
||||
assert.equal(seat(game, 'atreides').hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test(`Advanced ${source} paid Richese self-purchase sends only its own share to Emperor`, () => {
|
||||
const f = lot(source, 'richese');
|
||||
const game = purchase(f);
|
||||
assert.equal(seat(game, 'richese').spice, 6);
|
||||
assert.equal(seat(game, 'emperor').spice, 12, 'Emperor gets four from Richese; its own two goes to bank.');
|
||||
assert.equal(seat(game, 'richese').hand.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { AuctionPaymentRoutingError, routeAuctionPayment, type AuctionPaymentInput } from '../game/auction-payment-routing';
|
||||
|
||||
const leg = (payer: string, amount: number, recipient: 'bank' | 'emperor' | 'seller') =>
|
||||
({payer, amount, recipient});
|
||||
function invoice(patch: Partial<AuctionPaymentInput> = {}): AuctionPaymentInput {
|
||||
return {advanced:true, free:false, price:9, buyer:'buyer', emperor:'emperor', seller:null,
|
||||
contributions:[{payer:'buyer',amount:3}, {payer:'emperor',amount:4}, {payer:'ally',amount:2}], ...patch};
|
||||
}
|
||||
|
||||
void test('Advanced ordinary auction routes Emperor aid to Bank and other actual payer legs to Emperor', () => {
|
||||
const source = invoice(), before = structuredClone(source);
|
||||
assert.deepEqual(routeAuctionPayment(source), [
|
||||
leg('buyer',3,'emperor'), leg('emperor',4,'bank'), leg('ally',2,'emperor'),
|
||||
]);
|
||||
assert.deepEqual(source, before);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({advanced:false})), [
|
||||
leg('buyer',3,'emperor'), leg('emperor',4,'emperor'), leg('ally',2,'emperor'),
|
||||
], 'Basic retains the preexisting aggregate income routing.');
|
||||
});
|
||||
|
||||
void test('a direct gift is buyer-owned funding and does not become Emperor aid', () => {
|
||||
assert.deepEqual(routeAuctionPayment(invoice({price:7, contributions:[
|
||||
{payer:'buyer',amount:5}, {payer:'ally',amount:2},
|
||||
]})), [leg('buyer',5,'emperor'), leg('ally',2,'emperor')]);
|
||||
});
|
||||
|
||||
void test('an Emperor buyer or an absent Emperor sends a normal invoice to Bank', () => {
|
||||
assert.deepEqual(routeAuctionPayment(invoice({buyer:'emperor',price:6,contributions:[
|
||||
{payer:'emperor',amount:4}, {payer:'ally',amount:2},
|
||||
]})), [leg('emperor',4,'bank'), leg('ally',2,'bank')]);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({emperor:null,price:5,contributions:[
|
||||
{payer:'buyer',amount:3}, {payer:'ally',amount:2},
|
||||
]})), [leg('buyer',3,'bank'), leg('ally',2,'bank')]);
|
||||
});
|
||||
|
||||
void test('Richese seller, Emperor and other donors retain distinct Advanced special-auction destinations', () => {
|
||||
const contributions = [{payer:'buyer',amount:3}, {payer:'emperor',amount:2},
|
||||
{payer:'richese',amount:2}, {payer:'ally',amount:1}];
|
||||
assert.deepEqual(routeAuctionPayment(invoice({seller:'richese',price:8,contributions})), [
|
||||
leg('buyer',3,'seller'), leg('emperor',2,'bank'), leg('richese',2,'emperor'), leg('ally',1,'seller'),
|
||||
]);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({advanced:false,seller:'richese',price:8,contributions})), [
|
||||
leg('buyer',3,'seller'), leg('emperor',2,'seller'), leg('richese',2,'seller'), leg('ally',1,'seller'),
|
||||
]);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({seller:'richese',emperor:null,price:5,contributions:[
|
||||
{payer:'buyer',amount:3}, {payer:'richese',amount:2},
|
||||
]})), [leg('buyer',3,'seller'), leg('richese',2,'bank')]);
|
||||
});
|
||||
|
||||
void test('Emperor buying a Richese card receives Richese aid, while buyer payment goes to the seller', () => {
|
||||
assert.deepEqual(routeAuctionPayment(invoice({buyer:'emperor',seller:'richese',price:5,contributions:[
|
||||
{payer:'emperor',amount:3}, {payer:'richese',amount:2},
|
||||
]})), [leg('emperor',3,'seller'), leg('richese',2,'emperor')]);
|
||||
});
|
||||
|
||||
void test('Richese paid self-retention routes its own price normally and Emperor aid to Bank', () => {
|
||||
assert.deepEqual(routeAuctionPayment(invoice({buyer:'richese',seller:'richese',price:7,contributions:[
|
||||
{payer:'richese',amount:3}, {payer:'emperor',amount:4},
|
||||
]})), [leg('richese',3,'emperor'), leg('emperor',4,'bank')]);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({buyer:'richese',seller:'richese',emperor:null,price:3,
|
||||
contributions:[{payer:'richese',amount:3}]})), [leg('richese',3,'bank')]);
|
||||
});
|
||||
|
||||
void test('a Bank-only override reroutes every leg and a free purchase carries no paid legs', () => {
|
||||
assert.deepEqual(routeAuctionPayment(invoice({seller:'richese',bankOnly:true})), [
|
||||
leg('buyer',3,'bank'), leg('emperor',4,'bank'), leg('ally',2,'bank'),
|
||||
]);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({free:true,contributions:[]})), []);
|
||||
assert.deepEqual(routeAuctionPayment(invoice({free:false,price:0,contributions:[]})), []);
|
||||
assert.throws(() => routeAuctionPayment(invoice({free:true})), AuctionPaymentRoutingError);
|
||||
});
|
||||
|
||||
void test('the physical invoice rejects duplicates, phantom legs, unfunded costs and unsafe sums', () => {
|
||||
for (const patch of [
|
||||
{contributions:[{payer:'buyer',amount:0}, {payer:'ally',amount:9}]},
|
||||
{contributions:[{payer:'buyer',amount:4}, {payer:'buyer',amount:5}]},
|
||||
{contributions:[{payer:'buyer',amount:8}]},
|
||||
{contributions:[{payer:'buyer',amount:10}]},
|
||||
{contributions:[{payer:'',amount:9}]},
|
||||
{price:-1}, {price:1.5}, {price:Number.MAX_SAFE_INTEGER + 1},
|
||||
{seller:'emperor'}, {bankOnly:'yes'},
|
||||
{price:Number.MAX_SAFE_INTEGER, contributions:[{payer:'buyer',amount:Number.MAX_SAFE_INTEGER},
|
||||
{payer:'ally',amount:1}]},
|
||||
]) assert.throws(() => routeAuctionPayment(invoice(patch as Partial<AuctionPaymentInput>)), AuctionPaymentRoutingError);
|
||||
});
|
||||
@@ -41,8 +41,9 @@ function assertAuctionCosts(f: BureaucratE1AuctionFixture, game: Game): void {
|
||||
function assertAuctionOutcome(f: BureaucratE1AuctionFixture, done: Game, diverted: number): void {
|
||||
assertAuctionCosts(f, done);
|
||||
const donorLeg = f.donor === f.payee ? f.donorShare : 0;
|
||||
const income = f.amount - (f.beforeAction.advanced ? donorLeg : 0) - diverted;
|
||||
assert.equal(seat(done, f.payee).spice,
|
||||
seat(f.beforePledge, f.payee).spice - donorLeg + f.amount - diverted);
|
||||
seat(f.beforePledge, f.payee).spice - donorLeg + income);
|
||||
assert.deepEqual(hand(done, f.payer),
|
||||
[...hand(f.beforeAction, f.payer), f.card, f.beforeAction.deck[0].id]);
|
||||
assert.equal(done.currentAuctionSale, null);
|
||||
|
||||
@@ -35,10 +35,11 @@ function assertAuctionCosts(f: BureaucratE3AuctionFixture, game: Game): void {
|
||||
function assertAuctionOutcome(f: BureaucratE3AuctionFixture, done: Game, diverted: number): void {
|
||||
assertAuctionCosts(f, done);
|
||||
const donorLeg = f.donor === f.payee ? f.donorShare : 0;
|
||||
const income = f.amount - (f.beforeAction.advanced ? donorLeg : 0) - diverted;
|
||||
assert.equal(seat(done, f.payee).spice,
|
||||
seat(f.beforePledge, f.payee).spice - donorLeg + f.amount - diverted);
|
||||
seat(f.beforePledge, f.payee).spice - donorLeg + income);
|
||||
assert.equal(seat(done, f.donor).spice,
|
||||
seat(f.beforePledge, f.donor).spice - f.donorShare + (f.donor === f.payee ? f.amount - diverted : 0));
|
||||
seat(f.beforePledge, f.donor).spice - f.donorShare + (f.donor === f.payee ? income : 0));
|
||||
assert.deepEqual(hand(done, f.payer), [...hand(f.beforeAction, f.payer), f.card]);
|
||||
assert.equal(done.currentAuctionSale, null);
|
||||
assert.equal(done.bureaucratPayments?.used.length ?? 0, diverted ? 1 : 0);
|
||||
|
||||
@@ -74,10 +74,10 @@ void test('Advanced group bid settles both donors once and pays the Emperor', ()
|
||||
assert.ok(seat(game, payer).hand.length > hand);
|
||||
assert.equal(seat(game, payer).spice, 1);
|
||||
assert.equal(seat(game, donor1).spice, 6);
|
||||
assert.equal(seat(game, donor2).spice, 13, 'Emperor receives the six-spice winning price.');
|
||||
assert.equal(seat(game, donor2).spice, 11, 'The Emperor donor pays two to the bank and receives the other four.');
|
||||
assert.equal(remaining(game, donor1, payer), 1);
|
||||
assert.equal(remaining(game, donor2, payer), 1);
|
||||
assert.equal(allSpice(game), beforeSpice);
|
||||
assert.equal(allSpice(game), beforeSpice - 2, 'The Emperor donor’s own contribution goes to the bank.');
|
||||
});
|
||||
|
||||
void test('Advanced group shipment spends the actual donor pledges and bank invoice', () => {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
|
||||
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||
const escrow = (game: Game, donor: string) => game.coalitionEscrow!.pledges
|
||||
.find(row => row.donor === donor && row.recipient === 'a')?.amount ?? 0;
|
||||
const spice = (game: Game) => game.players.reduce((sum, player) => sum + player.spice, 0) +
|
||||
(game.coalitionEscrow?.pledges.reduce((sum, pledge) => sum + pledge.amount, 0) ?? 0);
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function pass(game: Game): Game {
|
||||
for (let i = 0; game.response && i < 20; i++) {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Group mode is unexposed: stage only its fresh Nexus ledger, the later
|
||||
* phase, wallets and card custody. Group formation, physical Truthtrance
|
||||
* declarations and answers, escrow and the native payment actions are real. */
|
||||
function source(advanced: boolean, phase: 'bid' | 'ship'): Game {
|
||||
let game = createGame('GROUPNEGATIVEBRIBE', newPlayer('a', 'Atreides', 'atreides'), advanced);
|
||||
game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
|
||||
newPlayer('d', 'Guild', 'guild'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','b','c','d'],
|
||||
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
|
||||
game.coalitionState = emptyCoalitionState('unlimited');
|
||||
game = act(game, 'a', {type:'alliance', target:'b'});
|
||||
game = act(game, 'b', {type:'alliance', target:'a'});
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
|
||||
game = act(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const actor of ['b','c']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = act(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
|
||||
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
for (const player of game.players) {
|
||||
player.spice = 10; player.hand = []; player.forces = {}; player.reserves = 20;
|
||||
player.tanks = 0; player.traitors = []; player.traitorChoices = [];
|
||||
player.shipped = false; player.moved = 0;
|
||||
}
|
||||
seat(game, 'a').spice = 2;
|
||||
const truth = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(truth >= 0);
|
||||
seat(game, 'd').hand.push(game.deck.splice(truth, 1)[0]);
|
||||
// A real printed Karama keeps the bidder's native invoice choice open.
|
||||
const karama = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karama >= 0);
|
||||
seat(game, 'a').hand.push(game.deck.splice(karama, 1)[0]);
|
||||
if (phase === 'bid') {
|
||||
Object.assign(game, {phase:2, nexus:false, active:null, ready:[]});
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
assert.equal(game.phase, 3);
|
||||
assert.equal(game.auction?.active, 'a');
|
||||
} else Object.assign(game, {phase:5, nexus:false, active:'a', ready:[],
|
||||
movementRemaining:['a','b','c','d'], guildTimingGranted:false, guildTimingLocked:false});
|
||||
return game;
|
||||
}
|
||||
|
||||
function answer(game: Game, kind: 'bribe' | 'bribeExpression', reply: 'yes' | 'no'): Game {
|
||||
const card = seat(game, 'd').hand.find(row => row.effect === 'truthtrance'); assert.ok(card);
|
||||
game = act(game, 'd', {type:'card', card:card.id});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(player);
|
||||
game = act(game, player.id, {type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
const question = kind === 'bribe'
|
||||
? {kind, target:'b', turn:game.turn, recipient:'d', minimum:1}
|
||||
: {kind, target:'b', turn:game.turn, claim:{kind:'or', terms:[
|
||||
{kind:'payment', recipient:'d', minimum:1}, {kind:'payment', recipient:'d', minimum:2},
|
||||
]}};
|
||||
game = act(game, 'd', {type:'truthAsk', question});
|
||||
game = act(game, 'b', {type:'truthAnswer', answer:reply});
|
||||
assert.equal(game.truthtrance, null);
|
||||
return game;
|
||||
}
|
||||
function pledge(game: Game): Game {
|
||||
game = act(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4});
|
||||
return act(game, 'c', {type:'coalitionPledge', recipient:'a', amount:3});
|
||||
}
|
||||
const shares = [{donor:'b', amount:3}, {donor:'c', amount:2}];
|
||||
function settleBid(game: Game): Game {
|
||||
for (let count = 0; !game.response && !game.decision && count < 15; count++) {
|
||||
assert.ok(game.auction?.active);
|
||||
game = act(game, game.auction.active, {type:'passBid'});
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'auctionPayment');
|
||||
game = act(game, 'a', {type:'decision', karama:false});
|
||||
return pass(game);
|
||||
}
|
||||
|
||||
for (const advanced of [false,true]) for (const kind of ['bribe','bribeExpression'] as const)
|
||||
for (const phase of ['bid','ship'] as const)
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${phase} preserves donor's No ${kind} through group funding`, () => {
|
||||
let game = answer(source(advanced, phase), kind, 'no');
|
||||
const original = spice(game);
|
||||
const ledger = kind === 'bribe' ? game.bribePromises! : game.bribeExpressionPromises!;
|
||||
assert.ok(ledger.some(row => row.player === 'b' && !row.answer && !row.fulfilled && !row.released));
|
||||
assert.ok(viewGame(game, 'a').coalitionFunding);
|
||||
game = pledge(game);
|
||||
assert.equal(seat(game, 'b').spice, 6);
|
||||
assert.equal(seat(game, 'c').spice, 7);
|
||||
if (phase === 'bid') {
|
||||
game = act(game, 'a', {type:'bid', amount:6, allyPayment:0, coalitionContributions:shares});
|
||||
assert.deepEqual(game.auction?.coalitionContributions, shares);
|
||||
game = settleBid(game);
|
||||
} else {
|
||||
game = act(game, 'a', {type:'ship', territory:'imperial_basin', sector:9, amount:3,
|
||||
elite:0, allyPayment:0, coalitionContributions:shares});
|
||||
game = pass(game);
|
||||
if (game.decision?.kind === 'guildShipment') {
|
||||
game = act(game, game.decision.player, {type:'decision', allow:true});
|
||||
game = pass(game);
|
||||
}
|
||||
assert.equal(seat(game, 'a').forces['imperial_basin:9'], 3);
|
||||
}
|
||||
assert.equal(escrow(game, 'b'), 1);
|
||||
assert.equal(escrow(game, 'c'), 1);
|
||||
assert.equal(spice(game), original - (advanced && phase === 'bid' ? 3 : 0),
|
||||
'The group shares and native recipient are paid exactly once, including the Advanced Emperor bank leg.');
|
||||
const after = kind === 'bribe' ? game.bribePromises! : game.bribeExpressionPromises!;
|
||||
assert.ok(after.some(row => row.player === 'b' && !row.answer && !row.fulfilled && !row.released));
|
||||
reject(game, 'b', {type:'bribe', target:'d', amount:1});
|
||||
});
|
||||
|
||||
for (const advanced of [false,true]) for (const kind of ['bribe','bribeExpression'] as const)
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} positive ${kind} remains fenced from group funding`, () => {
|
||||
const game = answer(source(advanced, 'ship'), kind, 'yes');
|
||||
assert.equal(viewGame(game, 'a').coalitionFunding, null);
|
||||
reject(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4});
|
||||
});
|
||||
|
||||
void test('an unreconciled previous-turn No bribe answer retains the existing funding fence', () => {
|
||||
const game = source(false, 'ship');
|
||||
game.bribePromises = [{event:'staged-old-answer', player:'b', asker:'d', turn:game.turn - 1,
|
||||
recipient:'d', minimum:1, answer:false, progress:0}];
|
||||
assert.equal(viewGame(game, 'a').coalitionFunding, null);
|
||||
reject(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4});
|
||||
});
|
||||
@@ -129,14 +129,14 @@ void test('an unreconciled prior-turn commitment blocks group shipment funding',
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('a live No bribe-expression commitment blocks group shipment funding', () => {
|
||||
void test('a live No bribe-expression commitment permits group shipment funding', () => {
|
||||
const game = fixture();
|
||||
game.bribeExpressionPromises = [{event:'live-expression-no', player:'b', asker:'a',
|
||||
turn:game.turn, claim:{kind:'payment', recipient:'d', minimum:1}, answer:false, progress:[]}];
|
||||
assert.equal(viewGame(game, 'a').coalitionFunding, null);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4}));
|
||||
assert.deepEqual(game, before);
|
||||
assert.ok(viewGame(game, 'a').coalitionFunding);
|
||||
const pledged = send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4});
|
||||
assert.equal(remaining(pledged, 'b'), 4);
|
||||
assert.deepEqual(pledged.bribeExpressionPromises, game.bribeExpressionPromises);
|
||||
});
|
||||
|
||||
void test('a current unsupported Truthtrance commitment still blocks group shipment funding', () => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type GameView } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TurnActionLeaf } from '../game/turn-action-promises';
|
||||
|
||||
const payment = (recipient: string): MixedActionExpression<TurnActionLeaf> =>
|
||||
({kind:'action', action:{kind:'payment', recipient, minimum:1}});
|
||||
|
||||
/** Controlled quiet Nexus and preview ledger, with native pair and triple
|
||||
* formation. The editor consumes the resulting public projection. */
|
||||
function projected(triple: boolean): GameView {
|
||||
let game = createGame('TURNPAYGROUP', newPlayer('a', 'Atreides', 'atreides'), false);
|
||||
game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
|
||||
newPlayer('d', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','b','c','d'], ready:[]});
|
||||
game.coalitionState = emptyCoalitionState('three-short');
|
||||
game = applyAction(game, 'a', {type:'alliance', target:'b'});
|
||||
game = applyAction(game, 'b', {type:'alliance', target:'a'});
|
||||
if (triple) {
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
|
||||
game = applyAction(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const actor of ['b','c']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = applyAction(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
|
||||
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
}
|
||||
return viewGame(game, 'd');
|
||||
}
|
||||
|
||||
function html(game: GameView, recipient: string) {
|
||||
return renderToStaticMarkup(createElement(TurnActionFields, {
|
||||
game, target:'a', value:payment(recipient), disabled:false, onChange() {},
|
||||
}));
|
||||
}
|
||||
|
||||
void test('whole-turn payment editor excludes every group ally and accepts an outsider', () => {
|
||||
const game = projected(true);
|
||||
assert.equal(game.players.find(player => player.id === 'a')!.ally, null,
|
||||
'Triple membership must come from the projected coalition ledger.');
|
||||
for (const id of ['a','b','c']) {
|
||||
assert.match(turnActionInputError(payment(id), game, 'a') ?? '', /non-allied recipient/);
|
||||
assert.doesNotMatch(html(game, 'd'), new RegExp(`<option value="${id}"`));
|
||||
}
|
||||
assert.equal(turnActionInputError(payment('d'), game, 'a'), null);
|
||||
assert.match(html(game, 'd'), /<option value="d" selected="">Harkonnen<\/option>/);
|
||||
});
|
||||
|
||||
void test('whole-turn payment editor preserves reciprocal pairs and unallied choices', () => {
|
||||
const game = projected(false);
|
||||
assert.match(turnActionInputError(payment('b'), game, 'a') ?? '', /non-allied recipient/);
|
||||
for (const id of ['c','d']) assert.equal(turnActionInputError(payment(id), game, 'a'), null);
|
||||
const legacy = structuredClone(game);
|
||||
delete legacy.coalitionState;
|
||||
assert.match(turnActionInputError(payment('b'), legacy, 'a') ?? '', /non-allied recipient/);
|
||||
for (const id of ['c','d']) assert.equal(turnActionInputError(payment(id), legacy, 'a'), null);
|
||||
assert.match(html(legacy, 'c'), /<option value="c" selected="">Fremen<\/option>/);
|
||||
assert.doesNotMatch(html(legacy, 'c'), /<option value="b"/);
|
||||
});
|
||||
Reference in new issue
Block a user