Files
Dune/tests/spiritual-atomics-shipment.test.ts
T
Quintine 0f8e0b4153
Verify and publish checkpoint container / container (push) Successful in 10m7s
Enforce Atomics shipment ban across transport
2026-10-29 03:03:18 +11:00

124 lines
8.2 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import { spiritualAdvisorTargetBlock } from '../game/spiritual-advisor';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { quoteGuildAmbassadorShipment, guildAmbassadorShipments } from '../game/guild-ambassador';
import { quoteGuildAmbassadorMarker, markerDestinations } from '../game/guild-ambassador-no-field';
import { createRicheseNoField } from '../game/richese-no-field';
const player = (game: Game, id: string) => {
const found = game.players.find(seat => seat.id === id);
assert.ok(found); return found;
};
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
/** Stage a conserved Advanced position, then use the real entry, Guild
* Ambassador shipment and physical Terror token. No pending receipt is staged. */
function atomicsDuringGuildAmbassador(): Game {
const game = createGame('SPIRITUALATOMICS', newPlayer('ec', 'Ecaz', 'ecaz'), true, ['ecaz']);
game.players.push(newPlayer('in', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'),
newPlayer('m', 'Moritani', 'moritani'), newPlayer('bg', 'Bene Gesserit', 'beneGesserit'));
Object.assign(game, {status:'playing', phase:5, turn:2, storm:18, active:'in', deck:baseDeck(),
discard:[], order:game.players.map(seat => seat.id), movementRemaining:game.players.map(seat => seat.id),
response:null, decision:null, phaseOpening:null});
for (const seat of game.players) Object.assign(seat, {hand:[], forces:{}, reserves:20, tanks:0,
spice:20, traitors:[], traitorChoices:[], shipped:false, moved:0});
player(game,'ec').ally='a'; player(game,'a').ally='ec';
player(game,'in').forces={'imperial_basin:10':1}; player(game,'in').reserves=19;
player(game,'in').shipped=true;
let ambassadors = createAmbassadors(() => 0.2);
const guild = ambassadors.tokens.find(token => token.effect === 'guild'); assert.ok(guild);
ambassadors.cohort = [guild.id, ...ambassadors.tokens.filter(token => token.effect !== 'ecaz' && token.id !== guild.id).slice(0,4).map(token => token.id)];
for (const token of ambassadors.tokens) {
token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) ? 'supply' : 'pool';
token.location = null;
}
ambassadors = placeAmbassador(ambassadors, guild.id, {turn:1, availableSpice:20,
destination:{id:'arrakeen', stronghold:true, inStorm:false, allowed:true}}).state;
game.ecazAmbassadors = ambassadors;
let terror = createTerrorState(() => 0);
const atomics = terror.tokens.find(token => token.kind === 'atomics'); assert.ok(atomics);
terror = placeTerror(terror, atomics.id, 'sietch_tabr', 1);
game.moritaniTerror = terror;
let next = act(game, 'in', {type:'move', forces:{'imperial_basin:10':1}, territory:'arrakeen', sector:10});
assert.equal(next.pendingAmbassador?.stage, 'offer');
next = act(next, 'ec', {type:'decision', event:next.pendingAmbassador!.event, trigger:true, beneficiary:'a'});
next = act(next, 'a', {type:'decision', event:next.pendingAmbassador!.event,
amount:2, territory:'sietch_tabr', sector:14});
assert.equal(next.pendingTerrorEntry?.entrant, 'a');
next = act(next, 'm', {type:'decision', reveal:true});
assert.equal(next.moritaniAtomics?.territory, 'sietch_tabr');
assert.equal(player(next,'a').forces['sietch_tabr:14'] ?? 0, 0);
assert.equal(player(next,'a').tanks, 2);
assert.equal(next.decision?.kind, 'advisor');
assert.equal(next.decision?.player, 'bg');
return next;
}
void test('Atomics Aftermath prevents Spiritual shipments in either rules mode without blocking other destinations', () => {
for (const advanced of [false,true]) {
const ctx = {advanced, players:[newPlayer('bg','Bene Gesserit','beneGesserit')],
moritaniAtomics:{territory:'sietch_tabr',turn:2,moritaniId:'m',allyAtActivation:null,alliancePolicy:'activation-targets' as const}};
assert.match(spiritualAdvisorTargetBlock(ctx,'bg','sietch_tabr')!, /Atomics Aftermath/);
assert.equal(spiritualAdvisorTargetBlock(ctx,'bg','polar_sink'), null);
assert.equal(spiritualAdvisorTargetBlock(ctx,'bg','arrakeen'), null);
}
});
void test('Guild Ambassador Atomics blocks its later BG accompaniment, retaining Polar Sink and original shipment', () => {
let game = atomicsDuringGuildAmbassador();
const bg = player(game,'bg'), priorReserve = bg.reserves, priorTanks = player(game,'a').tanks;
assert.throws(() => quoteGuildAmbassadorShipment(game,'a',{type:'decision',amount:1,
territory:'sietch_tabr',sector:14}), /Atomics Aftermath/);
assert.ok(!guildAmbassadorShipments(game,'a').destinations.some(choice => choice.territory === 'sietch_tabr'));
assert.ok(guildAmbassadorShipments(game,'a').destinations.some(choice => choice.territory !== 'sietch_tabr'));
const choices = viewGame(game,'bg').spiritualAdvisorChoices; assert.ok(choices);
const local = choices.choices.filter(choice => choice.accompany && choice.territory === 'sietch_tabr');
assert.ok(local.length && local.every(choice => choice.blocked?.includes('Atomics Aftermath')));
assert.ok(choices.choices.some(choice => !choice.accompany && choice.territory === 'polar_sink' && !choice.blocked));
const initial = structuredClone(game);
assert.throws(() => act(game,'bg',{type:'decision',accept:true,accompany:true,territory:'sietch_tabr',sector:14}), /Atomics Aftermath/);
assert.deepEqual(game,initial);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game,'bg'); view.players.find(seat => seat.id === 'bg')!.bot = difficulty;
const actions = botActions(view); assert.ok(actions.length);
assert.ok(actions.every(action => !(action.type === 'decision' && action.accept === true && action.accompany === true)));
}
game = act(game,'bg',{type:'decision',accept:true,accompany:false,territory:'polar_sink',sector:0});
for (let count=0;game.response && count<50;count++) {
const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(next);
game = act(game,next.id,{type:'passResponse'});
}
assert.equal(player(game,'bg').reserves, priorReserve-1);
assert.equal(player(game,'bg').forces['polar_sink:0'],1);
assert.equal(player(game,'bg').forces['sietch_tabr:14'] ?? 0,0);
assert.equal(player(game,'a').tanks, priorTanks);
assert.equal(game.pendingAmbassador ?? null, null);
});
void test('a pre-existing Aftermath also blocks native and allied Guild Ambassador No-Field markers', () => {
for (const beneficiary of ['native','allied'] as const) {
const game = createGame('ATOMICSNOFIELD', newPlayer('p','Recipient',beneficiary === 'native' ? 'richese' : 'ecaz'), false);
game.players.push(newPlayer('q','Other','atreides'), newPlayer('r','Supplier',beneficiary === 'native' ? 'ecaz' : 'richese'));
Object.assign(game,{status:'playing',turn:2,phase:5,storm:18,active:'q',order:['p','q','r'],
moritaniAtomics:{territory:'sietch_tabr',turn:1,moritaniId:'m',allyAtActivation:null,alliancePolicy:'activation-targets'}});
for (const seat of game.players) Object.assign(seat,{forces:{},reserves:20,tanks:0,spice:5,
elites:undefined,hand:[],shipped:true,moved:2});
const owner = game.players.find(seat => seat.faction === 'richese')!;
owner.noField = createRicheseNoField(['token0','token3','token5']); owner.noFieldEvent = 'selection-event';
if (owner.id !== 'p') {owner.ally='p';player(game,'p').ally=owner.id;}
const selection = {tokenId:'token3',tokenEvent:'selection-event',territory:'sietch_tabr',sector:14};
assert.throws(() => quoteGuildAmbassadorMarker(game,'p',selection), /Atomics Aftermath/);
const destinations=markerDestinations(game,'p');
assert.ok(!destinations.some(choice => choice.territory === 'sietch_tabr'));
assert.ok(destinations.some(choice => choice.territory !== 'sietch_tabr'));
assert.throws(() => quoteGuildAmbassadorShipment(game,'p',{type:'decision',amount:1,
territory:'sietch_tabr',sector:14}), /Atomics Aftermath/);
}
});