Files
Dune/tests/coalition-bid-funding.test.ts
T
Quintine 420943b890
Verify and publish checkpoint container / container (push) Canceled after 0s
Allow group actions after settled Truthtrance
2026-10-29 00:12:09 +11:00

266 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
/** Staged quiet Basic Nexus, empty group ledger and pre-Charity wallets; pair
* formation, unanimous group agreement, Charity completion and the auction
* are real engine actions. One original Karama is physically transferred from
* the deck to the bidder, so the actual payment choice remains available.
* This is a controlled setup, not a completed game or a public group option. */
function fixture(): Game {
let game = createGame('GROUPBID', newPlayer('a', 'Fremen bidder', 'fremen'), false);
game.players.push(newPlayer('b', 'Guild donor', 'guild'),
newPlayer('c', 'Harkonnen donor', 'harkonnen'),
newPlayer('d', 'Emperor outsider', 'emperor'));
Object.assign(game, { status: 'playing', phase: 1, turn: 2, nexus: true,
order: ['a', 'b', 'c', 'd'], ready: [], storm: 18, deck: baseDeck(),
discard: [], response: null, decision: null, phaseOpening: null });
for (const player of game.players) {
player.spice = 10; player.hand = []; player.forces = {}; player.reserves = 20;
player.tanks = 0; player.traitorChoices = []; player.traitors = [];
}
game.players[0].spice = 2;
const cardIndex = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(cardIndex >= 0);
game.players[0].hand.push(game.deck.splice(cardIndex, 1)[0]);
game.coalitionState = emptyCoalitionState();
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 id of ['b', 'c']) {
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});
}
assert.ok(game.coalitionEscrow);
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');
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 groupBid: Action = {type: 'bid', amount: 6, allyPayment: 0,
coalitionContributions: [{donor:'b', amount:3}, {donor:'c', amount:2}]};
const pledged = (game: Game, donor: string, recipient = 'a') =>
game.coalitionEscrow!.pledges.find(item => item.donor === donor && item.recipient === recipient)?.amount ?? 0;
const spice = (game: Game) => game.players.reduce((sum, player) => sum + player.spice, 0) +
(game.coalitionEscrow?.pledges.reduce((sum, entry) => sum + entry.amount, 0) ?? 0);
function rejected(game: Game, id: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, id, action));
assert.deepEqual(game, before);
}
function passResponse(game: Game): Game {
if (!game.response) return game;
const kind = game.response?.kind;
for (let i = 0; game.response?.kind === kind && i < 16; i++) {
const next = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(next);
game = act(game, next.id, {type: 'passResponse'});
}
return game;
}
function finishBid(game: Game): Game {
for (let i = 0; !game.response && !game.decision && i < 12; i++) {
assert.ok(game.auction?.active);
game = act(game, game.auction.active, {type: 'passBid'});
}
return game;
}
void test('each donor share of a leading group bid remains locked until outbid', () => {
let game = act(pledge(fixture()), 'a', groupBid);
assert.equal(game.auction!.bidder, 'a');
assert.deepEqual(game.auction!.coalitionContributions, groupBid.coalitionContributions);
rejected(game, 'b', {type:'coalitionPledge', recipient:'a', amount:2});
rejected(game, 'c', {type:'coalitionPledge', recipient:'a', amount:1});
assert.equal(pledged(game, 'b'), 4);
assert.equal(pledged(game, 'c'), 3);
game = act(game, 'b', {type:'passBid'});
game = act(game, 'c', {type:'passBid'});
assert.equal(game.auction!.active, 'd');
game = act(game, 'd', {type:'bid', amount:7, allyPayment:0});
assert.equal(game.auction!.bidder, 'd');
assert.equal(game.auction!.coalitionContributions, undefined);
game = act(game, 'b', {type:'coalitionPledge', recipient:'a', amount:1});
game = act(game, 'c', {type:'coalitionPledge', recipient:'a', amount:0});
assert.equal(pledged(game, 'b'), 1);
assert.equal(pledged(game, 'c'), 0);
assert.equal(game.players[1].spice, 9);
assert.equal(game.players[2].spice, 10);
assert.equal(spice(game), 32);
});
void test('fulfilled and released bid promises leave group bid funding usable', () => {
for (const history of ['settled', 'prior-turn'] as const) {
const initial = fixture();
initial.phaseBidPromises = [{event:`bid-${history}`, player:'d', asker:'a',
turn:history === 'prior-turn' ? initial.turn - 1 : initial.turn,
minimum:1, answer:true, ...(history === 'settled' ? {fulfilled:true} : {released:true})}];
const game = act(pledge(initial), 'a', groupBid);
assert.equal(game.auction?.bidder, 'a');
assert.deepEqual(game.auction?.coalitionContributions, groupBid.coalitionContributions);
assert.deepEqual(game.phaseBidPromises, initial.phaseBidPromises);
}
});
void test('only the bidder sees the combined group payment, without raw donor allocations', () => {
const game = act(pledge(fixture()), 'a', groupBid);
const own = viewGame(game, 'a').auction; assert.ok(own);
assert.equal(own.coalitionPayment, 5);
assert.equal(Object.hasOwn(own, 'coalitionContributions'), false);
for (const id of ['b', 'c', 'd']) {
const other = viewGame(game, id).auction; assert.ok(other);
assert.equal(Object.hasOwn(other, 'coalitionPayment'), false);
assert.equal(Object.hasOwn(other, 'coalitionContributions'), false);
}
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty} chooses a legal decision for a funded group bid`, () => {
let game = act(pledge(fixture()), 'a', groupBid);
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
const before = structuredClone(game), view = viewGame(game, 'a');
view.players.find(player => player.id === 'a')!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
assert.equal(action.type, 'decision');
assert.equal(typeof action.karama, 'boolean');
assert.ok(act(game, 'a', action));
assert.deepEqual(game, before);
});
void test('winning group bid settles its named donors once and pays the outside Emperor', () => {
let game = pledge(fixture());
const original = spice(game), beforeHand = game.players[0].hand.length;
game = act(game, 'a', groupBid);
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
game = act(game, 'a', {type:'decision', karama:false});
if (game.response) assert.equal(game.response.kind, 'emperorIncome');
game = passResponse(game);
if (game.auction) assert.equal(game.auction.coalitionContributions, undefined,
'A later lot cannot retain the previous bid’s donor allocation.');
assert.equal(game.players[0].spice, 1);
assert.equal(game.players[1].spice, 6);
assert.equal(game.players[2].spice, 7);
assert.equal(pledged(game, 'b'), 1);
assert.equal(pledged(game, 'c'), 1);
assert.equal(game.players[3].spice, 16);
assert.equal(game.players[0].hand.length, beforeHand + 1);
assert.equal(spice(game), original);
});
void test('a later Truthtrance bid commitment does not invalidate an already funded group bid', () => {
const source = fixture();
// Controlled, custody-preserving transfer: the real Truthtrance moves from
// the undealt deck to Emperor's empty hand; no card or pledge is invented.
const index = source.deck.findIndex(card => card.effect === 'truthtrance');
assert.ok(index >= 0);
const [truth] = source.deck.splice(index, 1);
source.players[3].hand.push(truth);
let game = act(pledge(source), 'a', groupBid);
game = act(game, 'd', {type:'card', card:truth.id});
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const next = game.players.find(player => !game.truthtrance!.passed.includes(player.id));
assert.ok(next);
game = act(game, next.id, {type:'truthPass'});
}
assert.equal(game.truthtrance?.stage, 'ask');
assert.ok(game.auction);
game = act(game, 'd', {type:'truthAsk', question:{kind:'bid', target:'b',
turn:game.turn, lot:game.auction.index + 1, minimum:8}});
game = act(game, 'b', {type:'truthAnswer', answer:'no'});
assert.ok(game.bidPromises?.some(promise => promise.player === 'b' && !promise.answer));
assert.equal(game.auction!.bidder, 'a');
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
game = act(game, 'a', {type:'decision', karama:false});
if (game.response) game = passResponse(game);
assert.equal(game.players[0].spice, 1);
assert.equal(pledged(game, 'b'), 1);
assert.equal(pledged(game, 'c'), 1);
assert.equal(game.players[3].spice, 16);
});
void test('an Emperor donor receives the entire winning payment under the normal income rule', () => {
let game = fixture();
// Staged faction swap tests the printed Emperor contribution case while
// preserving wallets, cards and the original group membership.
game.players[1].faction = 'emperor';
game.players[3].faction = 'guild';
game = act(pledge(game), 'a', groupBid);
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
game = act(game, 'a', {type:'decision', karama:false});
assert.equal(game.response?.kind, 'emperorIncome');
game = passResponse(game);
assert.equal(game.players[1].spice, 12);
assert.equal(pledged(game, 'b'), 1);
assert.equal(game.players[3].spice, 10);
assert.equal(spice(game), 32);
});
void test('invalid or insufficient group bid splits reject atomically', () => {
const game = pledge(fixture());
for (const shares of [
[{donor:'d', amount:3}],
[{donor:'b', amount:5}, {donor:'c', amount:1}],
[{donor:'b', amount:3}, {donor:'b', amount:1}],
[{donor:'b', amount:3}],
[{donor:'b', amount:4}, {donor:'c', amount:3}],
]) rejected(game, 'a', {...groupBid, coalitionContributions: shares});
});
void test('paying with the real Karama releases the group shares without charging donors or Emperor', () => {
let game = act(pledge(fixture()), 'a', groupBid);
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
const card = game.players[0].hand.find(item => item.effect === 'karama'); assert.ok(card);
game = act(game, 'a', {type:'decision', karama:true, card:card.id});
if (game.response) game = passResponse(game);
assert.equal(game.players[0].spice, 2);
assert.equal(game.players[3].spice, 10);
assert.equal(pledged(game, 'b'), 4);
assert.equal(pledged(game, 'c'), 3);
assert.equal(game.discard.filter(item => item.id === card.id).length, 1);
game = act(game, 'b', {type:'coalitionPledge', recipient:'a', amount:0});
game = act(game, 'c', {type:'coalitionPledge', recipient:'a', amount:0});
assert.equal(spice(game), 32);
assert.equal(game.players[1].spice, 10);
assert.equal(game.players[2].spice, 10);
});
void test('unused group bid pledges return to each donor when Bidding closes', () => {
let game = act(pledge(fixture()), 'a', groupBid);
game = finishBid(game);
assert.equal(game.decision?.kind, 'auctionPayment');
game = act(game, 'a', {type:'decision', karama:false});
if (game.response) game = passResponse(game);
for (let i = 0; game.phase === 3 && i < 25; i++) {
if (game.response) { game = passResponse(game); continue; }
assert.ok(!game.decision, 'No unrelated decision should interrupt this staged basic auction.');
assert.ok(game.auction?.active);
game = act(game, game.auction.active, {type:'passBid'});
}
assert.notEqual(game.phase, 3);
assert.equal(pledged(game, 'b'), 0);
assert.equal(pledged(game, 'c'), 0);
assert.equal(game.players[1].spice, 7);
assert.equal(game.players[2].spice, 8);
assert.equal(game.players[3].spice, 16);
assert.equal(spice(game), 32);
});