import test from 'node:test'; import assert from 'node:assert/strict'; import { quoteAlliedSeparation, type AlliedSeparationQuoteContext, } from '../game/allied-separation'; import { applyAction, createGame, newPlayer } from '../game/engine'; type TerritoryFacts = AlliedSeparationQuoteContext['territories'][number]; function group( territory: string, facts: Partial> = {}, ): TerritoryFacts { return { territory, polar: false, ownPresent: true, allyPresent: true, ownAdvisors: false, allyAdvisors: false, ecazCoexist: false, ...facts, }; } function context( overrides: Partial = {}, ): AlliedSeparationQuoteContext { return { advanced: true, turn: 4, player: 'ending', ally: 'ally', playerAllySinceTurn: 2, allySinceTurn: 2, remaining: ['ending', 'ally', 'other'], territories: [group('arrakeen')], ...overrides, }; } void test('Advanced separates on the first, intermediate and last allied ending regardless of alliance age', () => { for (const remaining of [ ['ending', 'ally', 'other'], ['ending', 'other'], ['ending'], ]) { for (const timestamps of [ {}, { playerAllySinceTurn: 2, allySinceTurn: 2 }, { playerAllySinceTurn: 4, allySinceTurn: 4 }, { playerAllySinceTurn: 4, allySinceTurn: 2 }, ]) { assert.deepEqual( quoteAlliedSeparation(context({ playerAllySinceTurn: undefined, allySinceTurn: undefined, remaining, ...timestamps, })), ['arrakeen'], ); } } }); void test('Basic waits for the ally to finish, then exempts only an alliance recorded new for both players', () => { const basic = context({ advanced: false }); assert.deepEqual(quoteAlliedSeparation(basic), []); const afterAlly = { ...basic, remaining: ['ending', 'other'] }; assert.deepEqual(quoteAlliedSeparation(afterAlly), ['arrakeen']); assert.deepEqual(quoteAlliedSeparation({ ...afterAlly, playerAllySinceTurn: 4, allySinceTurn: 4, }), []); for (const timestamps of [ { playerAllySinceTurn: 4, allySinceTurn: 2 }, { playerAllySinceTurn: 2, allySinceTurn: 4 }, { playerAllySinceTurn: undefined, allySinceTurn: undefined }, { playerAllySinceTurn: 4, allySinceTurn: undefined }, { playerAllySinceTurn: undefined, allySinceTurn: 4 }, ]) { assert.deepEqual( quoteAlliedSeparation({ ...afterAlly, ...timestamps }), ['arrakeen'], ); } }); void test('a Basic same-turn alliance loses its exemption next turn while Advanced separates on both turns', () => { const newAlliance = context({ advanced: false, playerAllySinceTurn: 4, allySinceTurn: 4, remaining: ['ending'], }); assert.deepEqual(quoteAlliedSeparation(newAlliance), []); assert.deepEqual(quoteAlliedSeparation({ ...newAlliance, turn: 5 }), ['arrakeen']); assert.deepEqual(quoteAlliedSeparation({ ...newAlliance, advanced: true }), ['arrakeen']); assert.deepEqual(quoteAlliedSeparation({ ...newAlliance, advanced: true, turn: 5 }), ['arrakeen']); }); void test('only shared non-exempt territories are quoted, preserving each territory independently', () => { const territories = [ group('arrakeen'), group('carthag', { ownPresent: false }), group('broken_land', { allyPresent: false }), group('polar_sink', { polar: true }), group('sietch_tabr', { ecazCoexist: true }), group('hagga_basin'), ]; for (const advanced of [false, true]) { assert.deepEqual(quoteAlliedSeparation(context({ advanced, remaining: ['ending'], territories, })), ['arrakeen', 'hagga_basin']); } }); void test('ending without an ally never loses forces even when territory facts show shared presence', () => { for (const advanced of [false, true]) { assert.deepEqual(quoteAlliedSeparation(context({ advanced, ally: null, remaining: ['ending'], territories: [group('arrakeen'), group('hagga_basin')], })), []); } }); void test('Advanced advisors on either side coexist, but conversion to fighters restores separation', () => { for (const stance of ['ownAdvisors', 'allyAdvisors'] as const) { const advisors = context({ territories: [group('arrakeen', { [stance]: true }), group('hagga_basin')], }); assert.deepEqual(quoteAlliedSeparation(advisors), ['hagga_basin']); assert.deepEqual(quoteAlliedSeparation({ ...advisors, territories: [group('arrakeen'), group('hagga_basin')], }), ['arrakeen', 'hagga_basin']); } assert.deepEqual(quoteAlliedSeparation(context({ territories: [group('arrakeen', { ownAdvisors: true, allyAdvisors: true })], })), []); }); void test('advisor facts do not introduce a new exemption into Basic legacy separation', () => { for (const stances of [ { ownAdvisors: true }, { allyAdvisors: true }, { ownAdvisors: true, allyAdvisors: true }, ]) { assert.deepEqual(quoteAlliedSeparation(context({ advanced: false, remaining: ['ending'], territories: [group('arrakeen', stances)], })), ['arrakeen']); } }); void test('loss follows current Ecaz coexistence and presence rather than a previous exemption or quote', () => { for (const advanced of [false, true]) { const occupation = context({ advanced, remaining: ['ending'], territories: [group('arrakeen', { ecazCoexist: true })], }); assert.deepEqual(quoteAlliedSeparation(occupation), []); const suppressed = { ...occupation, territories: [group('arrakeen')] }; assert.deepEqual(quoteAlliedSeparation(suppressed), ['arrakeen']); assert.deepEqual(quoteAlliedSeparation({ ...suppressed, territories: [group('arrakeen', { allyPresent: false })], }), []); assert.deepEqual(quoteAlliedSeparation(occupation), []); } }); void test('quoting frozen canonical facts does not change them or retain a mutable result', () => { const input = Object.freeze(context({ remaining: Object.freeze(['ending', 'ally']), territories: Object.freeze([ Object.freeze(group('arrakeen')), Object.freeze(group('carthag', { ownAdvisors: true })), Object.freeze(group('polar_sink', { polar: true })), ]), })); const before = structuredClone(input); const result = quoteAlliedSeparation(input); assert.deepEqual(result, ['arrakeen']); result.splice(0, 1, 'carthag'); assert.deepEqual(quoteAlliedSeparation(input), ['arrakeen']); assert.deepEqual(input, before); }); void test('ordinary allied reserve arrival retains distinct Basic visitor and Advanced whole-group ending losses', () => { for (const advanced of [false, true]) { let g = createGame('ALLIEDARRIVAL', newPlayer('e', 'Emperor', 'emperor'), advanced); g.players.push(newPlayer('a', 'Atreides', 'atreides')); g.status = 'playing'; g.phase = 5; g.turn = 2; g.storm = 18; g.active = 'e'; g.order = ['e', 'a']; g.movementRemaining = ['e', 'a']; for (const p of g.players) { p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 10; p.hand = []; p.traitors = []; p.shipped = false; p.moved = 0; } g.players[0].ally = 'a'; g.players[1].ally = 'e'; g.players[0].forces = { 'arrakeen:10': 5 }; g.players[0].reserves = 15; g.players[1].forces = { 'arrakeen:10': 3 }; g.players[1].reserves = 17; g = applyAction(g, 'e', { type: 'ship', amount: 2, territory: 'arrakeen', sector: 10 }); assert.equal(g.players[0].spice, 8); g = applyAction(g, 'e', { type: 'endMovement' }); assert.equal(g.players[0].tanks, advanced ? 7 : 2); assert.equal(g.players[0].forces['arrakeen:10'] ?? 0, advanced ? 0 : 5); assert.equal(g.players[1].forces['arrakeen:10'], 3); assert.equal(g.players[1].tanks, 0); } });