Files
Dune/tests/southern-allocation-runtime.test.ts
T

232 lines
13 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { homeworldRevivalDeploymentActions } from '../game/homeworld-revival-deployment-options';
import {
actSouthernAllocation as act, advanceSouthernAllocation as advance,
createSouthernAllocationFixture as fixture, openSouthernAllocation as open,
settleSouthernAllocation as settle, southernAllocationAction as choice,
southernAllocationClean as clean, southernAllocationInventory as inventory,
southernPlayer as p, southernReload as reload, type SouthernAllocationStep,
} from './fixture-southern-allocation';
function reject(game: Game, actor: string, action: Action) {
const before = reload(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected allocation leaves the native return and all physical custody unchanged');
}
function offer(game: Game) {
const current = viewGame(game, 'fremen').homeworldRevivalDeployment; assert.ok(current); return current;
}
function allocation(game: Game) {
const current = offer(game).allocation; assert.ok(current); return current;
}
function finish(game: Game, actions: SouthernAllocationStep[] = []) {
game = settle(game, actions);
assert.equal(game.homeworldRevivalReturn?.stage, 'complete');
assert.equal(game.decision ?? null, null);
inventory(game);
return game;
}
function unchangedUses(before: Game, after: Game) {
for (const key of ['spice', 'shipped', 'moved', 'revived', 'freeForcesRevived'] as const)
assert.equal(p(after)[key], p(before)[key], key);
assert.deepEqual(after.homeworldRevivalReturn?.group, before.homeworldRevivalReturn?.group);
}
void test('genuine Southern source earns three starred Tanks through original setup paid invasion and battle', () => {
const f = fixture();
assert.equal(f.sourceCounts.setup, f.setupSteps.length);
assert.equal(f.sourceCounts.throughBattle, f.setupSteps.length + f.battleSteps.length);
assert.equal(f.sourceCounts.source, f.actions.length);
assert.deepEqual(f.actions.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [0, 0, 1, 1]);
assert.equal(f.shipmentCost, 1);
assert.ok(f.actions.some(step => step.actor === 'emperor' && step.action.type === 'ship' && step.action.amount === 1));
assert.equal(p(f.afterBattle).tanks, 3); assert.equal(p(f.afterBattle).elites?.tanks, 3);
assert.equal(p(f.afterBattle).forces['sietch_tabr:14'], 1);
assert.equal(p(f.game).reserves, 10); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
assert.equal(f.game.advanced, false); assert.equal(f.game.storm, 3);
assert.deepEqual(inventory(f.game), f.original);
assert.equal(f.original.cards.length, f.setup.deck.length);
assert.ok(f.original.traitors.length > 0 && f.original.spice.length > 0);
assert.equal(f.game.homeworldRevivalReturn ?? null, null, 'The source has not installed or played its upcoming revival');
});
void test('one genuine free Basic return opens its original three-star allocation without spending shipment or movement', () => {
const f = fixture(), game = open(f);
assert.equal(p(game).spice, p(f.game).spice);
assert.equal(p(game).tanks, 0); assert.equal(p(game).reserves, 13);
assert.equal(p(game).elites?.reserves, 3); assert.equal(p(game).elites?.revived, 3);
assert.equal(p(game).revived, 3); assert.equal(p(game).freeForcesRevived, 3);
assert.deepEqual(game.homeworldRevivalReturn?.group, { amount: 3, elite: 3, free: 3 });
assert.equal(offer(game).normal, 0); assert.equal(offer(game).elite, 3);
assert.deepEqual(allocation(game), { total: 3, remaining: 3, placed: 0, kept: 0 });
assert.equal(p(game).shipped, p(f.game).shipped); assert.equal(p(game).moved, p(f.game).moved);
assert.ok(offer(game).destinations.filter(destination => !destination.blocked).length >= 2);
assert.deepEqual(inventory(game), f.original);
});
void test('three returned Southern stars split one plus two between distinct native occupied territories', () => {
const f = fixture(); let game = open(f);
const returned = reload(game), first = choice(game, 'false_wall_south:5', 1);
game = settle(act(reload(game), 'fremen', first));
assert.equal(p(game).forces['false_wall_south:5'], 4);
assert.equal(p(game).elites?.forces['false_wall_south:5'], 1);
assert.equal(p(game).reserves, 12); assert.equal(p(game).elites?.reserves, 2);
assert.deepEqual(allocation(game), { total: 3, remaining: 2, placed: 1, kept: 0 });
assert.notEqual(offer(game).event, first.event);
assert.equal(offer(game).elite, 2);
game = finish(act(reload(game), 'fremen', choice(game, 'false_wall_west:18', 2)));
assert.equal(p(game).forces['false_wall_west:18'], 5);
assert.equal(p(game).elites?.forces['false_wall_west:18'], 2);
assert.equal(p(game).reserves, 10); assert.equal(p(game).elites?.reserves, 0);
unchangedUses(returned, game);
assert.deepEqual(inventory(game), f.original);
});
void test('one star may be placed and every remaining returned star kept at Southern Hemisphere', () => {
const f = fixture(); let game = open(f);
const returned = reload(game);
game = settle(act(game, 'fremen', choice(game, 'sietch_tabr:14', 1)));
const last = offer(game).event;
game = finish(act(reload(game), 'fremen', { type: 'decision', event: last, decline: true }));
assert.equal(p(game).forces['sietch_tabr:14'], 2);
assert.equal(p(game).elites?.forces['sietch_tabr:14'], 1);
assert.equal(p(game).reserves, 12); assert.equal(p(game).elites?.reserves, 2);
assert.equal(p(game).tanks, 0);
unchangedUses(returned, game);
reject(game, 'fremen', { type: 'decision', event: last, decline: true });
});
void test('legacy whole-group packet still places all three stars at one native destination', () => {
const f = fixture(); let game = open(f);
const returned = reload(game), packet = choice(game, 'sietch_tabr:14', 3);
game = finish(act(reload(game), 'fremen', packet));
assert.equal(p(game).forces['sietch_tabr:14'], 4);
assert.equal(p(game).elites?.forces['sietch_tabr:14'], 3);
assert.equal(p(game).reserves, 10);
unchangedUses(returned, game);
reject(game, 'fremen', packet);
});
void test('declining the first child keeps the exact complete returned group without undoing revival', () => {
const f = fixture(); let game = open(f);
const returned = reload(game), event = offer(game).event;
game = finish(act(reload(game), 'fremen', { type: 'decision', event, decline: true }));
assert.equal(p(game).reserves, 13); assert.equal(p(game).elites?.reserves, 3);
assert.equal(p(game).tanks, 0); assert.equal(p(game).elites?.tanks, 0);
assert.deepEqual(p(game).forces, p(f.game).forces);
assert.deepEqual(p(game).elites?.forces, p(f.game).elites?.forces);
unchangedUses(returned, game);
assert.deepEqual(inventory(game), f.original);
});
void test('fresh child event fences stale packets overdraw wrong owner and an unearned destination', () => {
let game = open(fixture());
const old = choice(game, 'false_wall_south:5', 1);
game = settle(act(game, 'fremen', old));
const current = choice(game, 'false_wall_west:18', 1);
reject(game, 'fremen', old);
reject(game, 'emperor', current);
for (const amount of [0, -1, 3, 1.5]) reject(game, 'fremen', { ...current, amount });
reject(game, 'fremen', { ...current, destination: 'arrakeen:10' });
reject(game, 'fremen', { ...current, destination: 'homeworld:fremen' });
reject(game, 'fremen', { type: 'decision', event: current.event, decline: true, amount: 1 });
assert.deepEqual(allocation(game), { total: 3, remaining: 2, placed: 1, kept: 0 });
});
void test('pending allocations keep the original Revival control owned until all stars are disposed', () => {
let game = open(fixture());
reject(game, 'fremen', { type: 'ready' });
reject(game, 'fremen', { type: 'revive', amount: 1, elite: 0 });
reject(game, 'emperor', { type: 'revive', amount: 1, elite: 0 });
game = settle(act(game, 'fremen', choice(game, 'false_wall_south:5', 1)));
reject(game, 'fremen', { type: 'ready' });
game = finish(act(game, 'fremen', { type: 'decision', event: offer(game).event, decline: true }));
game = advance(game, state => state.phase === 5 && clean(state));
assert.equal(p(game).revived, 3); assert.equal(p(game).elites?.revived, 3);
});
void test('two actual starred casualties produce only their exact two-star native allocation', () => {
const f = fixture({ stars: 2 }); let game = open(f);
assert.equal(p(f.game).elites?.forces['sietch_tabr:14'], 1);
assert.deepEqual(allocation(game), { total: 2, remaining: 2, placed: 0, kept: 0 });
game = settle(act(game, 'fremen', choice(game, 'false_wall_south:5', 1)));
assert.equal(offer(game).elite, 1);
game = finish(act(reload(game), 'fremen', choice(game, 'false_wall_west:18', 1)));
assert.equal(p(game).elites?.forces['sietch_tabr:14'], 1);
assert.equal(p(game).elites?.forces['false_wall_south:5'], 1);
assert.equal(p(game).elites?.forces['false_wall_west:18'], 1);
assert.equal(p(game).revived, 2);
assert.deepEqual(inventory(game), f.original);
});
void test('original one-spice Tleilax free-revival income waits across every child and settles once after final disposition', () => {
const f = fixture({ third: 'tleilaxu' }); let game = open(f);
const provider = p(game, 'tleilaxu').spice;
const returned = reload(game);
assert.equal(game.homeworldRevivalReturn?.resumeResponse?.kind, 'revivalIncome');
const income = game.homeworldRevivalReturn!.resumeResponse!.amount;
assert.equal(income, 1, 'Normal free revival grants one native bank payment per faction and turn, regardless of returned group size');
assert.equal(game.revivalFreeIncome?.fremen, game.turn);
game = settle(act(game, 'fremen', choice(game, 'false_wall_south:5', 1)));
assert.equal(p(game, 'tleilaxu').spice, provider);
assert.equal(game.response ?? null, null);
game = act(reload(game), 'fremen', choice(game, 'false_wall_west:18', 2));
assert.equal(game.homeworldRevivalReturn?.stage, 'complete');
assert.equal(game.response ?? null, null, 'Native normalization settles the restored income when nobody owns a cancellation choice');
assert.equal(p(game, 'tleilaxu').spice, provider + income!);
assert.equal(game.revivalFreeIncome?.fremen, game.turn);
game = finish(game);
assert.equal(p(game, 'tleilaxu').spice, provider + income!);
unchangedUses(returned, game);
assert.deepEqual(inventory(game), f.original);
});
void test('actual native Ecaz arrival finishes before the next fresh Southern child becomes available', () => {
const f = fixture({ third: 'ecaz' }); let game = open(f);
assert.ok(f.actions.some(step => step.actor === 'ecaz' && step.action.type === 'decision' && step.action.territory === 'sietch_tabr'));
const first = choice(game, 'sietch_tabr:14', 1);
game = act(game, 'fremen', first);
assert.equal(game.homeworldRevivalReturn?.stage, 'arrival');
assert.equal(game.pendingAmbassador?.stage, 'offer');
assert.equal(game.pendingAmbassador?.entrant, 'fremen');
assert.equal(game.pendingAmbassador?.phase, 4);
assert.equal(viewGame(game, 'fremen').homeworldRevivalDeployment ?? null, null);
assert.deepEqual(homeworldRevivalDeploymentActions(viewGame(game, 'fremen')), []);
reject(game, 'fremen', { ...first, amount: 2 });
const parent = game.pendingAmbassador!.event;
game = act(reload(game), 'ecaz', { type: 'decision', event: parent, decline: true });
game = settle(game);
assert.equal(game.pendingAmbassador ?? null, null);
assert.notEqual(offer(game).event, first.event);
assert.equal(offer(game).elite, 2);
assert.ok(offer(game).destinations.some(destination => destination.id === 'false_wall_west:18' && !destination.blocked));
game = finish(act(game, 'fremen', { type: 'decision', event: offer(game).event, decline: true }));
assert.equal(p(game).elites?.forces['sietch_tabr:14'], 1);
assert.equal(p(game).elites?.reserves, 2);
assert.deepEqual(inventory(game), f.original);
});
void test('all four minimal profiles consume only exact current native Southern choices', () => {
const f = fixture();
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
let game = open(f), decisions = 0;
while (game.homeworldRevivalReturn?.stage !== 'complete' && decisions < 4) {
const view = viewGame(reload(game), 'fremen');
view.players.find(player => player.id === 'fremen')!.bot = difficulty;
const offered = homeworldRevivalDeploymentActions(view); assert.ok(offered.length);
const action = botActions(view).find(action => action.type === 'decision' && action.event === offer(game).event);
assert.ok(action, `${difficulty} consumes its current owner-view allocation`);
assert.ok(offered.some(legal => JSON.stringify(legal) === JSON.stringify(action)));
game = settle(act(game, 'fremen', action)); decisions++;
}
game = finish(game);
assert.ok(decisions > 0 && decisions <= 3);
assert.equal(p(game).revived, 3); assert.equal(p(game).elites?.revived, 3);
assert.deepEqual(inventory(game), f.original);
}
});