Support staged group Guild transport
Verify and publish checkpoint container / container (push) Successful in 12m5s
Verify and publish checkpoint container / container (push) Successful in 12m5s
This commit is contained in:
1 parent
478b38f5de
commit
2fe97ddd1f
10 files changed
+424
-23
No files matched your search
@@ -0,0 +1,311 @@
|
||||
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('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);
|
||||
});
|
||||
Reference in new issue
Block a user