266 lines
13 KiB
TypeScript
266 lines
13 KiB
TypeScript
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);
|
||
});
|