Support staged group bid funding
Verify and publish checkpoint container / container (push) Successful in 10m27s

This commit is contained in:
Quintine committed 2026-10-28 23:22:50 +11:00
1 parent 34aa64cdef
commit 1eeeb32566
12 files changed
+426 -37

No files matched your search

+252
View File
@@ -0,0 +1,252 @@
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('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);
});
+15
View File
@@ -29,6 +29,12 @@ void test('each outgoing ally has a separate labelled pledge and incoming amount
assert.match(html, /Fremen: 2 spice/);
assert.match(html, /Use from Emperor for your next shipment/);
assert.match(html, /Use from Fremen for your next shipment/);
const bidding = renderToStaticMarkup(createElement(CoalitionFunding, {
view, act() {}, onContributionsChange() {}, purpose: 'bid',
}));
assert.match(bidding, /Use from Emperor for your next bid/);
assert.match(bidding, /Use from Fremen for your next bid/);
assert.doesNotMatch(bidding, /for your next shipment/);
});
void test('pledge action uses the named recipient and validates whole, safe limits', () => {
@@ -41,6 +47,15 @@ void test('pledge action uses the named recipient and validates whole, safe limi
assert.equal(coalitionPledgeAction(target, invalid, null), null);
assert.equal(coalitionPledgeAction(target, '2', 'Finish the pending payment.'), null);
assert.equal(coalitionPledgeAction(target, '2', null, true), null);
const committed = {...target, minimum: 2};
assert.equal(coalitionPledgeAction(committed, '1', null), null);
assert.deepEqual(coalitionPledgeAction(committed, '2', null),
{type: 'coalitionPledge', recipient: 'b', amount: 2});
const html = renderToStaticMarkup(createElement(CoalitionFunding, {
view:{...view, targets:[committed]}, act() {}, purpose:'bid',
}));
assert.match(html, /The current high bid commits 2 spice of this pledge/);
assert.match(html, /min="2"/);
});
void test('shipment selections retain donor identity and clamp to live pledges', () => {
+23
View File
@@ -69,6 +69,29 @@ void test('staged group shipment funding appears on the live table for the donor
assert.match(markup(game, 'e'), /Pledge to Atreides offerer · 1 spice currently set aside/);
});
void test('staged group bid funding displays donor selection in the ordinary auction', () => {
let game = nexusOfferGame();
game.coalitionState = emptyCoalitionState();
game = applyAction(game, 'a', {type:'alliance', target:'e'});
game = applyAction(game, 'e', {type:'alliance', target:'a'});
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
game = applyAction(game, 'a', {type:'coalitionPropose', groupId:pair.id,
entrant:'h', expectedRevision:game.coalitionState!.revision});
for (const id of ['e', 'h']) {
const offer = game.coalitionState!.proposal!;
game = applyAction(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
const card = game.deck.shift(); assert.ok(card);
// Stage an ordinary lot with its original physical card in auction custody.
Object.assign(game, {phase:3, nexus:false, active:'a', ready:[], auction:{
cards:[card], index:0, bid:0, bidder:null, active:'a', passed:[], opener:0,
}});
game = applyAction(game, 'e', {type:'coalitionPledge', recipient:'a', amount:1});
assert.match(markup(game, 'a'), /Use from Emperor recipient for your next bid/);
assert.match(markup(game, 'a'), /Raise bid/);
});
void test('live outgoing offer is named and withdrawable only by its sender, separately from an incoming invitation', () => {
const g = applyAction(nexusOfferGame(), 'a', { type: 'alliance', target: 'e' });
const html = markup(g);