import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine'; import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; import { quoteBattleBoard, quoteCoalitionAlliedSeparation } from '../game/board-resolution-quote'; import { isAlliedShipmentVisit } from '../game/allied-separation'; import { strongholdProgress } from '../game/victory-progress'; import { quoteVictory } from '../game/victory-quote'; /** Controlled staged public board and coalition. The engine cannot yet start * this preview or negotiate Advanced groups; no native game is claimed. */ function staged(): Game { const game = createGame('ADVANCEDGROUPBOARD', newPlayer('a', 'Atreides', 'atreides'), true); game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Bene Gesserit', 'beneGesserit'), newPlayer('d', 'Harkonnen', 'harkonnen')); Object.assign(game, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'a', order: ['a', 'b', 'c', 'd'], movementRemaining: ['a', 'b', 'c', 'd'] }); for (const player of game.players) { player.forces = {}; player.ally = null; player.shipped = false; player.moved = 0; } game.players[0].ally = 'b'; game.players[1].ally = 'a'; game.players[0].allySinceTurn = 2; game.players[1].allySinceTurn = 2; const state = emptyCoalitionState('three-short'); const group = coalitionOf(game.players, state, 'a'); assert.ok(group); const offer = beginCoalitionAdmission(game.players, state, { event: 'invite', nexus: 'nexus', turn: 2, groupId: group.id, entrant: 'c', proposer: 'a', expectedRevision: 0 }); const approval = answerCoalitionAdmission(game.players, offer.state, { event: 'invite', nexus: 'nexus', turn: 2, actor: 'b', accept: true, expectedRevision: offer.state.revision }); const joined = answerCoalitionAdmission(game.players, approval.state, { event: 'invite', nexus: 'nexus', turn: 2, actor: 'c', accept: true, expectedRevision: approval.state.revision }); for (const player of game.players) if (Object.hasOwn(joined.allyUpdates, player.id)) player.ally = joined.allyUpdates[player.id]; game.coalitionState = joined.state; return game; } const p = (game: Game, id: string) => game.players.find(player => player.id === id)!; void test('Advanced group separation unions all allies immediately while exempting advisors and Polar Sink', () => { const game = staged(); p(game, 'a').forces = { 'arrakeen:10': 1, 'carthag:11': 1, 'sietch_tabr:14': 1, 'polar_sink:0': 1 }; p(game, 'b').forces = { 'arrakeen:10': 1, 'carthag:11': 1, 'polar_sink:0': 1 }; p(game, 'c').forces = { 'arrakeen:10': 1, 'sietch_tabr:14': 1 }; p(game, 'c').advisors = { sietch_tabr: {} }; const before = structuredClone(game); const losses = quoteCoalitionAlliedSeparation({ ...game, remaining: ['a', 'b', 'c', 'd'] }, 'a'); assert.deepEqual(new Set(losses), new Set(['arrakeen', 'carthag'])); assert.deepEqual(quoteCoalitionAlliedSeparation({ ...game, remaining: ['a', 'b', 'c', 'd'] }, 'c'), ['arrakeen']); assert.deepEqual(game, before, 'A quote does not remove any counters.'); assert.equal(isAlliedShipmentVisit(game, p(game, 'a'), 'arrakeen'), true); }); void test('Advanced endMovement actually removes only the ending group member’s shared fighters', () => { const game = staged(); const owner = p(game, 'a'), emperor = p(game, 'b'), bg = p(game, 'c'); owner.forces = { 'arrakeen:10': 2, 'carthag:11': 3, 'sietch_tabr:14': 1, 'polar_sink:0': 1 }; owner.reserves = 13; emperor.forces = { 'arrakeen:10': 1, 'polar_sink:0': 1 }; emperor.reserves = 18; bg.forces = { 'carthag:11': 1, 'sietch_tabr:14': 1 }; bg.reserves = 18; bg.advisors = { sietch_tabr: {} }; const otherForces = structuredClone(game.players.filter(seat => seat.id !== 'a').map(seat => ({ id: seat.id, forces: seat.forces, tanks: seat.tanks, }))); const warning = viewGame(game, 'a').advancedAllySeparation; assert.ok(warning); assert.deepEqual(new Set(warning.territories), new Set(['Arrakeen', 'Carthag'])); const after = applyAction(game, 'a', { type: 'endMovement' }); assert.equal(p(after, 'a').tanks, 5); assert.equal(p(after, 'a').reserves, 13); assert.equal(p(after, 'a').forces['arrakeen:10'] ?? 0, 0); assert.equal(p(after, 'a').forces['carthag:11'] ?? 0, 0); assert.equal(p(after, 'a').forces['sietch_tabr:14'], 1); assert.equal(p(after, 'a').forces['polar_sink:0'], 1); assert.deepEqual(after.players.filter(seat => seat.id !== 'a').map(seat => ({ id: seat.id, forces: seat.forces, tanks: seat.tanks, })), otherForces); assert.equal(after.active, 'b'); assert.deepEqual(after.movementRemaining, ['b', 'c', 'd']); }); void test('Advanced group board generates enemy battles without ever pairing allies', () => { const game = staged(); p(game, 'a').forces = { 'imperial_basin:9': 1 }; p(game, 'b').forces = { 'imperial_basin:9': 1 }; p(game, 'd').forces = { 'imperial_basin:9': 1 }; const battles = quoteBattleBoard(game).battles.filter(battle => battle.territory === 'imperial_basin'); assert.equal(battles.length, 2); assert.deepEqual(new Set(battles.map(battle => [battle.attacker, battle.defender].sort().join('/'))), new Set(['a/d', 'b/d'])); }); void test('Advanced three-short group wins on three distinct controlled strongholds', () => { const game = staged(); game.phase = 8; p(game, 'a').forces = { 'arrakeen:10': 1 }; p(game, 'b').forces = { 'carthag:11': 1 }; p(game, 'c').forces = { 'sietch_tabr:14': 1 }; const row = strongholdProgress(game).progress.find(item => item.player === 'a')!; assert.deepEqual(row.members, ['a', 'b', 'c']); assert.equal(row.target, 3); assert.equal(row.qualifies, true); assert.deepEqual(quoteVictory(game).winner, ['a', 'b', 'c']); }); void test('Advanced groups with Ecaz or another expansion faction stay outside these board quotes', () => { for (const faction of ['ecaz', 'ixians'] as const) { const game = staged(); p(game, 'd').faction = faction; assert.throws(() => quoteBattleBoard(game)); assert.throws(() => quoteCoalitionAlliedSeparation({ ...game, remaining: game.order }, 'a')); assert.throws(() => strongholdProgress(game)); assert.throws(() => isAlliedShipmentVisit(game, p(game, 'a'), 'arrakeen')); } });