126 lines
6.4 KiB
TypeScript
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);
|
|
});
|