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); });