Files
Dune/tests/coalition-guild-transport.test.ts
T
Quintine 9cc659602c
Verify and publish checkpoint container / container (push) Canceled after 0s
Support group-funded Karama shipment pricing
2026-10-29 00:09:16 +11:00

341 lines
17 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 { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
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 { botEntryAllowed } from '../game/bot-mobility';
import { guildTransportQuote } from '../game/transport-quote';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' :
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
},
});
const {GameTable} = await import('../components/game-table');
aliases.deregister();
const send = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const pledge = (game: Game, donor: string, recipient: string) =>
game.coalitionEscrow!.pledges.find(entry => entry.donor === donor && entry.recipient === recipient)?.amount ?? 0;
/** Three-member play has no lobby entry. Stage a Basic Nexus, use the actual
* pair and unanimous admission actions, then stage conserved forces and
* wallets at Shipment and Movement. A physical Karama moves from the undealt
* deck to the outside faction so both native response windows are exercised. */
function fixture(shipper: 'e' | 'f' | 'g' = 'e', group = true) {
let game = createGame('GROUPGUILDTRANSPORT', newPlayer('g', 'Guild', 'guild'), false);
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'),
newPlayer('h', 'Harkonnen', 'harkonnen'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['g','e','f','h'],
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
game.coalitionState = emptyCoalitionState();
const pairAlly = shipper === 'g' ? 'e' : shipper;
game = send(game, 'g', {type:'alliance', target:pairAlly});
game = send(game, pairAlly, {type:'alliance', target:'g'});
if (group) {
const pair = coalitionOf(game.players, game.coalitionState, 'g'); assert.ok(pair);
const entrant = pairAlly === 'e' ? 'f' : 'e';
game = send(game, 'g', {type:'coalitionPropose', groupId:pair.id, entrant,
expectedRevision:game.coalitionState!.revision});
for (const id of [pairAlly, entrant]) {
const offer = game.coalitionState!.proposal!;
game = send(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:5, nexus:false, active:shipper, ready:[],
movementRemaining:[shipper,...game.order.filter(id => id !== shipper)],
guildTimingGranted:false, guildTimingLocked:false});
for (const player of game.players) {
player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10;
player.hand = []; player.traitors = []; player.traitorChoices = []; player.shipped = false; player.moved = 0;
}
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [karama] = game.deck.splice(index, 1); seat(game, 'h').hand.push(karama);
return {game, karama};
}
function passKind(game: Game, kind: string): Game {
for (let count = 0; game.response?.kind === kind && count < 24; count++) {
const next = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(next, `The ${kind} response needs an eligible player.`);
game = send(game, next.id, {type:'passResponse'});
}
assert.notEqual(game.response?.kind, kind);
return game;
}
function passAll(game: Game): Game {
for (let count = 0; game.response && count < 32; count++) {
const next = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(next);
game = send(game, next.id, {type:'passResponse'});
}
assert.equal(game.response ?? null, null);
return game;
}
function conserved(game: Game) {
for (const player of game.players)
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a,b) => a+b,0),20);
}
function fund(game: Game, target: 'e' | 'f'): Game {
const other = target === 'e' ? 'f' : 'e';
game = send(game, 'g', {type:'coalitionPledge', recipient:target, amount:2});
return send(game, other, {type:'coalitionPledge', recipient:target, amount:2});
}
const shares = (target: 'e' | 'f') => [{donor:'g',amount:1},{donor:target === 'e' ? 'f' : 'e',amount:1}];
void test('group cross-planet shipment retains donor shares across permission and rate responses', () => {
let {game} = fixture();
seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17;
game = fund(game, 'e');
game = send(game, 'e', {type:'guildShip', forces:{'imperial_basin:10':3},
territory:'red_chasm', sector:7, allyPayment:0, coalitionContributions:shares('e')});
assert.equal(game.response?.kind, 'guildTransportPermission');
assert.equal(seat(game,'e').forces['imperial_basin:10'],3);
assert.equal(seat(game,'e').spice,10);
game = passKind(game, 'guildTransportPermission');
assert.equal(game.response?.kind, 'guildRate');
assert.equal(game.pendingGuildTransport?.cost,3);
assert.equal(seat(game,'e').forces['imperial_basin:10'],3);
game = passAll(game);
assert.equal(seat(game,'e').forces['red_chasm:7'],3);
assert.equal(seat(game,'e').forces['imperial_basin:10'] ?? 0,0);
assert.equal(seat(game,'e').spice,9);
assert.equal(seat(game,'g').spice,10, 'Guild receives the two non-Guild shares, not its own.');
assert.equal(seat(game,'f').spice,8);
assert.equal(pledge(game,'g','e'),1); assert.equal(pledge(game,'f','e'),1);
assert.equal(seat(game,'e').shipped,true);
conserved(game);
});
void test('group-funded purchased-rate cross-planet shipment keeps permission and pays the bank', () => {
const initial = fixture();
let {game} = initial;
// Keep Harkonnen's physical Karama available to exercise the permission
// window; the purchaser uses the other undealt physical Karama.
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [karama] = game.deck.splice(index,1); seat(game,'e').hand.push(karama);
seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17;
game = fund(game,'e');
game = send(game,'e',{type:'card',mode:'shipment',card:karama.id});
assert.equal(game.karamaShipping?.player,'e');
game = send(game,'e',{type:'guildShip', forces:{'imperial_basin:10':3},
territory:'red_chasm', sector:7, allyPayment:0, coalitionContributions:shares('e')});
assert.equal(game.response?.kind,'guildTransportPermission');
assert.equal(seat(game,'e').forces['imperial_basin:10'],3);
assert.equal(pledge(game,'g','e'),2); assert.equal(pledge(game,'f','e'),2);
game = passKind(game,'guildTransportPermission');
assert.equal(game.response ?? null,null, 'Purchased rate and bank payment need no Guild-rate or income response.');
assert.equal(seat(game,'e').forces['red_chasm:7'],3);
assert.equal(seat(game,'e').forces['imperial_basin:10'] ?? 0,0);
assert.equal(seat(game,'e').spice,9);
assert.equal(seat(game,'g').spice,8, 'Guild receives none of the shipment payment.');
assert.equal(seat(game,'f').spice,8);
assert.equal(pledge(game,'g','e'),1); assert.equal(pledge(game,'f','e'),1);
assert.equal(game.karamaShipping,null);
assert.equal(game.discard.filter(card => card.id === karama.id).length,1);
conserved(game);
});
void test('Karama reprices the selected group shares while preserving cross-planet shipment and income', () => {
const initial = fixture();
let {game} = initial;
const {karama} = initial;
seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17;
game = fund(game, 'e');
game = send(game, 'e', {type:'guildShip', forces:{'imperial_basin:10':3},
territory:'red_chasm', sector:7, allyPayment:0, coalitionContributions:shares('e')});
game = passKind(game,'guildTransportPermission');
assert.equal(game.response?.kind,'guildRate');
game = send(game,'h',{type:'card', mode:'cancel', card:karama.id});
game = passAll(game);
assert.equal(seat(game,'e').forces['red_chasm:7'],3);
assert.equal(seat(game,'e').spice,6, 'The shipper pays the repriced four-spice remainder.');
assert.equal(seat(game,'g').spice,13, 'Guild receives shipper plus Fremen shares only.');
assert.equal(seat(game,'f').spice,8);
assert.equal(pledge(game,'g','e'),1); assert.equal(pledge(game,'f','e'),1);
assert.equal(game.discard.filter(card => card.id === karama.id).length,1);
conserved(game);
});
void test('Fremen group ally transports southern reserves with the original permission and Guild rate', () => {
let {game} = fixture('f');
game = fund(game, 'f');
game = send(game, 'f', {type:'guildShip', from:'reserves', amount:3, elite:0,
territory:'carthag', sector:11, allyPayment:0, coalitionContributions:shares('f')});
assert.equal(game.response?.kind,'guildTransportPermission');
assert.equal(seat(game,'f').reserves,20);
game = passKind(game,'guildTransportPermission');
assert.equal(game.response?.kind,'guildRate');
assert.equal(game.pendingGuildTransport?.cost,2);
game = passAll(game);
assert.equal(seat(game,'f').reserves,17);
assert.equal(seat(game,'f').forces['carthag:11'],3);
assert.equal(seat(game,'f').spice,10);
assert.equal(seat(game,'g').spice,9, 'Only Emperor’s share goes to Guild.');
assert.equal(pledge(game,'g','f'),1); assert.equal(pledge(game,'e','f'),1);
conserved(game);
});
void test('Karama cancels group Guild permission before any payment or force movement', () => {
const initial = fixture();
let {game} = initial;
const {karama} = initial;
seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17;
game = fund(game,'e');
game = send(game,'e',{type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm',
sector:7, allyPayment:0, coalitionContributions:shares('e')});
assert.equal(game.response?.kind,'guildTransportPermission');
game = send(game,'h',{type:'card',mode:'cancel',card:karama.id});
game = passAll(game);
assert.equal(seat(game,'e').forces['imperial_basin:10'],3);
assert.equal(seat(game,'e').spice,10); assert.equal(seat(game,'e').shipped,false);
assert.equal(pledge(game,'g','e'),2); assert.equal(pledge(game,'f','e'),2);
assert.equal(viewGame(game,'e').guildTransportCanceled,true);
conserved(game);
});
void test('legacy pair Guild transport still accepts its implicit allied funding', () => {
let {game} = fixture('e', false);
seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17;
game = send(game,'g',{type:'pledgeAid', amount:2});
game = send(game,'e',{type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm', sector:7, allyPayment:1});
assert.equal(game.response?.kind,'guildTransportPermission');
game = passKind(game,'guildTransportPermission');
assert.equal(game.response?.kind,'guildRate');
game = passAll(game);
assert.equal(seat(game,'e').forces['red_chasm:7'],3);
assert.equal(seat(game,'e').spice,8); assert.equal(game.aid.g.amount,1);
conserved(game);
});
void test('an unaffordable canceled group rate releases the original transport and leaves both pledges intact', () => {
const initial = fixture();
let {game} = initial;
seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17;
seat(game,'e').spice = 1;
game = fund(game,'e');
game = send(game,'e',{type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm',
sector:7, allyPayment:0, coalitionContributions:shares('e')});
game = passKind(game,'guildTransportPermission');
assert.equal(game.response?.kind,'guildRate');
game = send(game,'h',{type:'card',mode:'cancel',card:initial.karama.id});
game = passAll(game);
assert.equal(game.pendingGuildTransport ?? null,null);
assert.equal(seat(game,'e').forces['imperial_basin:10'],3);
assert.equal(seat(game,'e').forces['red_chasm:7'] ?? 0,0);
assert.equal(seat(game,'e').spice,1); assert.equal(seat(game,'e').shipped,false);
assert.equal(pledge(game,'g','e'),2); assert.equal(pledge(game,'f','e'),2);
assert.equal(viewGame(game,'e').guildRateCanceled,true);
conserved(game);
});
void test('Guild may return its own physical group with separate allied donor shares', () => {
let {game} = fixture('g');
seat(game,'g').forces = {'imperial_basin:10':3}; seat(game,'g').reserves = 17;
game = send(game,'e',{type:'coalitionPledge',recipient:'g',amount:1});
game = send(game,'f',{type:'coalitionPledge',recipient:'g',amount:1});
game = send(game,'g',{type:'guildShip', forces:{'imperial_basin:10':3}, territory:'reserves',
allyPayment:0, coalitionContributions:[{donor:'e',amount:1},{donor:'f',amount:1}]});
assert.equal(game.response?.kind,'guildTransportPermission');
game = passKind(game,'guildTransportPermission');
assert.equal(game.response?.kind,'guildRate');
assert.equal(game.pendingGuildTransport?.cost,2);
game = passAll(game);
assert.equal(seat(game,'g').reserves,20);
assert.equal(seat(game,'g').forces['imperial_basin:10'] ?? 0,0);
assert.equal(seat(game,'g').spice,12);
assert.equal(seat(game,'e').spice,9); assert.equal(seat(game,'f').spice,9);
assert.equal(pledge(game,'e','g'),0); assert.equal(pledge(game,'f','g'),0);
conserved(game);
});
void test('invalid cross-transport allocations reject without changing funds, escrow or forces', () => {
let {game} = fixture();
seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17;
game = fund(game,'e');
const base: Action = {type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm',
sector:7, allyPayment:0};
for (const coalitionContributions of [
[{donor:'g',amount:1},{donor:'g',amount:1}],
[{donor:'h',amount:1}],
[{donor:'e',amount:1}],
[{donor:'g',amount:3}],
[{donor:'g',amount:2},{donor:'f',amount:2}],
]) {
const before = structuredClone(game);
assert.throws(() => send(game,'e',{...base,coalitionContributions}));
assert.deepEqual(game,before);
}
conserved(game);
});
void test('a Medium Fremen bot proposes an executable group southern-reserve transport', () => {
const {game} = fixture('f');
const view = viewGame(game,'f');
view.players.find(player => player.id === 'f')!.bot = 'Medium';
const action = botActions(view).find(candidate => candidate.type === 'guildShip' && candidate.from === 'reserves');
assert.ok(action);
const pending = send(game,'f',action);
assert.equal(pending.response?.kind,'guildTransportPermission');
assert.equal(seat(pending,'f').reserves,20);
});
void test('group transport quote uses each pledged share and the table exposes its transport control', () => {
let {game} = fixture();
seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17;
game = fund(game,'e');
const action: Action = {type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm',
sector:7, allyPayment:0, coalitionContributions:shares('e')};
const view = viewGame(game,'e');
const before = structuredClone(view);
const quote = guildTransportQuote(view,action);
assert.deepEqual(quote.unavailableReasons,[]);
assert.deepEqual(quote.quote,{cost:3,normalCost:3,ownPayment:1,pledgedPayment:2});
assert.deepEqual(view,before);
for (const coalitionContributions of [
[{donor:'g',amount:3}],
[{donor:'g',amount:1},{donor:'g',amount:1}],
[{donor:'h',amount:1}],
[{donor:'g',amount:2},{donor:'f',amount:2}],
]) {
const invalid = guildTransportQuote(view,{...action,coalitionContributions});
assert.ok(invalid.unavailableReasons.length);
assert.equal(invalid.quote,null);
}
const markup = renderToStaticMarkup(createElement(GameTable,{
game:view, busy:false, send:async () => {}, onExit() {},
}));
assert.match(markup,/<button\b[^>]*>(?:(?!<\/button>)[\s\S])*Guild transport(?:(?!<\/button>)[\s\S])*<\/button>/);
});
void test('group cross-transport rejects a destination occupied by another ally in both quote and engine', () => {
let {game} = fixture();
seat(game,'e').forces = {'imperial_basin:10':3}; seat(game,'e').reserves = 17;
seat(game,'f').forces = {'red_chasm:7':1}; seat(game,'f').reserves = 19;
game = fund(game,'e');
const action: Action = {type:'guildShip', forces:{'imperial_basin:10':3}, territory:'red_chasm',
sector:7, allyPayment:0, coalitionContributions:shares('e')};
const view = viewGame(game,'e');
const own = view.players.find(player => player.id === 'e')!;
assert.equal(botEntryAllowed(view,own,'red_chasm',7,'guildShip','imperial_basin'),false);
assert.ok(guildTransportQuote(view,action).unavailableReasons.length);
const before = structuredClone(game);
assert.throws(() => send(game,'e',action));
assert.deepEqual(game,before);
conserved(game);
});