Support staged group bid funding
Verify and publish checkpoint container / container (push) Successful in 10m27s
Verify and publish checkpoint container / container (push) Successful in 10m27s
This commit is contained in:
1 parent
34aa64cdef
commit
1eeeb32566
12 files changed
+426
-37
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user