Files
Dune/tests/coalition-transit-engine.test.ts
T
Quintine 34aa64cdef
Verify and publish checkpoint container / container (push) Canceled after 0s
Support staged group shipment funding
2026-10-28 23:14:44 +11:00

126 lines
6.4 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { createRicheseNoField } from '../game/richese-no-field';
import type { CoalitionState } from '../game/coalition-alliance';
import { isAlliedShipmentVisit } from '../game/allied-separation';
/** Controlled Basic phase and force placement; the three-member group is
* staged because Nexus cannot create one yet. No full-game claim is made. */
function fixture(marker = false): Game {
const game = createGame('GROUPTRANSIT', newPlayer('a', 'Visitor', marker ? 'richese' : 'atreides'),
false, marker ? ['choam'] : []);
game.players.push(newPlayer('b', 'First ally', 'emperor'), newPlayer('c', 'Second ally', 'fremen'),
newPlayer('d', 'Outsider', 'harkonnen'));
game.status = 'playing'; game.phase = 5; game.turn = 2; game.active = 'a'; game.storm = 18;
game.order = ['a', 'b', 'c', 'd']; game.movementRemaining = ['a', 'b', 'c', 'd'];
game.deck = baseDeck();
for (const player of game.players) {
player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10;
player.hand = []; player.traitorChoices = []; player.traitors = [];
player.ally = null; delete player.allySinceTurn;
player.shipped = false; player.moved = 0;
}
game.players[0].forces['imperial_basin:11'] = 2; game.players[0].reserves = 18;
game.players[1].forces['imperial_basin:11'] = 2; game.players[1].reserves = 18;
game.players[2].forces['polar_sink:0'] = 2; game.players[2].reserves = 18;
const coalitionState: CoalitionState = { version: 1, revision: 1, proposal: null, usedEvents: [],
groups: [{ id: 'abc', members: ['a', 'b', 'c'], formedTurn: 2, edges: [
{ members: ['a', 'b'], formedTurn: 2 }, { members: ['a', 'c'], formedTurn: 2 },
{ members: ['b', 'c'], formedTurn: 2 },
] }] };
game.coalitionState = coalitionState;
if (marker) {
game.players[0].noField = createRicheseNoField(['zero', 'three', 'five']);
game.players[0].noFieldEvent = 'marker-event';
}
return game;
}
const send = (game: Game, id: string, action: Action) => applyAction(game, id, action);
function finishResponse(game: Game) {
for (let count = 0; game.response && count < 10; 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.pendingShipment ?? null, null);
return game;
}
function ship(game: Game): Game {
return finishResponse(send(game, 'a', {type: 'ship', amount: 3, elite: 0,
territory: 'imperial_basin', sector: 9, allyPayment: 0}));
}
function warn(game: Game) { return viewGame(game, 'a').basicAlliedShipment; }
function forceTotal(game: Game, id: string, prefix: string) {
return Object.entries(game.players.find(player => player.id === id)!.forces)
.reduce((total, [key, value]) => total + (key.startsWith(`${prefix}:`) ? value : 0), 0);
}
void test('ordinary movement cannot enter either group ally territory', () => {
const game = fixture();
// Put the second ally in a non-polar site without changing its physical pool.
delete game.players[2].forces['polar_sink:0'];
game.players[2].forces['red_chasm:7'] = 2;
const before = structuredClone(game);
assert.throws(() => send(game, 'a', {type: 'move', forces: {'imperial_basin:11': 1},
territory: 'red_chasm', sector: 7}), /occupied by your ally/);
assert.deepEqual(game, before);
});
void test('the player view gives the shared transit quote the public group membership', () => {
const game = fixture();
const view = viewGame(game, 'a');
const own = view.players.find(player => player.id === 'a')!;
assert.equal(isAlliedShipmentVisit(view, own, 'imperial_basin'), true);
assert.equal(isAlliedShipmentVisit(view, own, 'polar_sink'), false);
assert.equal(Object.hasOwn(view, 'coalitionEscrow'), false);
});
void test('a three-member group visitor can ship then depart without losing its pre-arrival forces', () => {
const original = fixture(); let game = ship(original);
assert.equal(game.players[0].spice, 4);
assert.deepEqual(game.basicAlliedShipment?.allies, ['b']);
assert.equal(warn(game)?.normal, 3);
game = send(game, 'a', {type: 'move', forces: {'imperial_basin:9': 3},
territory: 'polar_sink', sector: 0});
assert.equal(warn(game), null);
game = send(game, 'a', {type: 'endMovement'});
assert.equal(game.players[0].tanks, 0);
assert.equal(forceTotal(game, 'a', 'imperial_basin'), 2);
assert.equal(forceTotal(game, 'a', 'polar_sink'), 3);
assert.equal(forceTotal(game, 'b', 'imperial_basin'), 2);
});
void test('group visitor warning and loss follow a different current ally with partial departure', () => {
let game = ship(fixture());
// Staged witness switch: relocate existing counters only, preserving each
// physical pool; gameplay does not move another ally during this turn.
delete game.players[1].forces['imperial_basin:11']; game.players[1].forces['polar_sink:0'] = 2;
delete game.players[2].forces['polar_sink:0']; game.players[2].forces['imperial_basin:11'] = 2;
assert.equal(warn(game)?.normal, 3);
game = send(game, 'a', {type: 'move', forces: {'imperial_basin:9': 2},
territory: 'polar_sink', sector: 0});
assert.equal(warn(game)?.normal, 1);
game = send(game, 'a', {type: 'endMovement'});
assert.equal(game.players[0].tanks, 1);
assert.equal(forceTotal(game, 'a', 'imperial_basin'), 2);
assert.equal(forceTotal(game, 'c', 'imperial_basin'), 2);
assert.equal(forceTotal(game, 'b', 'polar_sink'), 2);
});
void test('group visiting zero No-Field remains visible as a marker obligation and reveals without loss', () => {
let game = fixture(true);
game = finishResponse(send(game, 'a', {type: 'ship', noField: 'zero', event: 'marker-event',
territory: 'imperial_basin', sector: 9, allyPayment: 0}));
assert.deepEqual(warn(game), {territory: 'Imperial Basin', normal: 0, elite: 0, noField: true});
assert.equal(viewGame(game, 'b').basicAlliedShipment, null);
game = send(game, 'a', {type: 'endMovement'});
assert.equal(game.players[0].noField!.deployed, null);
assert.equal(game.players[0].noField!.lastShipped, 'zero');
assert.equal(game.players[0].tanks, 0);
assert.equal(forceTotal(game, 'a', 'imperial_basin'), 2);
assert.equal(forceTotal(game, 'b', 'imperial_basin'), 2);
});