Files
Dune/tests/allied-separation.test.ts
T

223 lines
7.7 KiB
TypeScript

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<Omit<TerritoryFacts, 'territory'>> = {},
): TerritoryFacts {
return {
territory,
polar: false,
ownPresent: true,
allyPresent: true,
ownAdvisors: false,
allyAdvisors: false,
ecazCoexist: false,
...facts,
};
}
function context(
overrides: Partial<AlliedSeparationQuoteContext> = {},
): 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);
}
});