Route Advanced Emperor allied auction funding
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 02:48:11 +11:00
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);
});
}
+87
View File
@@ -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);
});
+2 -1
View File
@@ -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);
+3 -2
View File
@@ -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);
+2 -2
View File
@@ -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});
});
+5 -5
View File
@@ -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"/);
});