124 lines
8.2 KiB
TypeScript
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/);
|
|
}
|
|
});
|