150 lines
8.0 KiB
TypeScript
150 lines
8.0 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';
|
|
|
|
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});
|
|
});
|