Enforce Atomics shipment ban across transport
Verify and publish checkpoint container / container (push) Successful in 10m7s
Verify and publish checkpoint container / container (push) Successful in 10m7s
This commit is contained in:
1 parent
4feb418488
commit
0f8e0b4153
17 files changed
+477
-19
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { botArrivalBlock } from '../game/bot-arrival';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { applyAction, createGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
|
||||
import { guildTransportQuote } from '../game/transport-quote';
|
||||
import { atomicsFixtureGame } from './fixture-moritani-atomics';
|
||||
|
||||
const p = (g: Game, id: string) => g.players.find(player => player.id === id)!;
|
||||
const act = (g: Game, id: string, action: Action) => applyAction(g,id,action);
|
||||
const cross: Action = {type:'guildShip',forces:{'imperial_basin:10':2},
|
||||
territory:'arrakeen',sector:10,allyPayment:0};
|
||||
|
||||
/** The physical Atomics token is genuinely revealed by the original entrant.
|
||||
* Later turn ownership, a conserved board group and marker custody are staged
|
||||
* to reach the particular transportation quotes without replaying a game. */
|
||||
function aftermath(advanced = false) {
|
||||
let game = createGame('GUILDATOMICS',newPlayer('m','Moritani','moritani'),advanced,['ecaz','choam']);
|
||||
game.players.push(newPlayer('e','Emperor','emperor'),newPlayer('g','Guild','guild'),
|
||||
newPlayer('f','Fremen','fremen'),newPlayer('r','Richese','richese'));
|
||||
atomicsFixtureGame(game,{moritani:'m',entrant:'e',ally:'g'},{advanced,allied:false});
|
||||
game.order=['e','m','g','f','r']; game.movementRemaining=[...game.order];
|
||||
game=act(game,'e',{type:'ship',territory:'arrakeen',sector:10,amount:2});
|
||||
if (game.decision?.kind==='guildShipment')
|
||||
game=act(game,game.decision.player,{type:'decision',allow:true});
|
||||
game=allow(game);
|
||||
assert.equal(game.pendingTerrorEntry?.stage,'offer');
|
||||
game=normalizeAutomaticGame(act(game,'m',{type:'decision',reveal:true}));
|
||||
assert.equal(game.moritaniAtomics?.territory,'arrakeen');
|
||||
assert.equal(game.pendingTerrorEntry,null);
|
||||
return game;
|
||||
}
|
||||
function prepare(g: Game, actor: 'g'|'f'|'r') {
|
||||
g.active=actor; g.movementRemaining=[actor,...g.order.filter(id=>id!==actor)];
|
||||
p(g,actor).shipped=false; p(g,actor).moved=0; p(g,actor).spice=20;
|
||||
return g;
|
||||
}
|
||||
function reject(g: Game, actor: string, action: Action) {
|
||||
const before=structuredClone(g);
|
||||
assert.throws(()=>act(g,actor,action),/Atomics Aftermath/);
|
||||
assert.deepEqual(g,before);
|
||||
}
|
||||
function allow(g: Game) {
|
||||
for (let i=0;g.response && i<30;i++) {
|
||||
const actor=g.players.find(player=>!g.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
g=act(g,actor.id,{type:'passResponse'});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
for (const advanced of [false,true]) {
|
||||
void test(`${advanced?'Advanced':'Basic'} Guild cannot cross-ship into Aftermath, but may move in or return to reserves`,()=>{
|
||||
const game=prepare(aftermath(advanced),'g');
|
||||
p(game,'g').forces={'imperial_basin:10':2}; p(game,'g').reserves=18;
|
||||
const view=viewGame(game,'g');
|
||||
assert.ok(guildTransportQuote(view,cross).unavailableReasons.some(reason=>reason.includes('Atomics Aftermath')));
|
||||
assert.match(botArrivalBlock(view,cross)??'',/Atomics Aftermath/);
|
||||
reject(game,'g',cross);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const botView=viewGame(game,'g'); botView.players.find(player=>player.id==='g')!.bot=difficulty;
|
||||
assert.ok(!botActions(botView).some(action=>action.type==='guildShip' && action.territory==='arrakeen'));
|
||||
}
|
||||
const movement: Action={type:'move',forces:{'imperial_basin:10':2},territory:'arrakeen',sector:10};
|
||||
const moved=act(structuredClone(game),'g',movement);
|
||||
assert.equal(p(moved,'g').forces['arrakeen:10'],2);
|
||||
const back: Action={type:'guildShip',forces:{'imperial_basin:10':2},territory:'reserves',sector:0,allyPayment:0};
|
||||
assert.ok(!guildTransportQuote(view,back).unavailableReasons.some(reason=>reason.includes('Atomics')));
|
||||
const returned=allow(act(structuredClone(game),'g',back));
|
||||
assert.equal(p(returned,'g').forces['imperial_basin:10']??0,0);
|
||||
assert.equal(p(returned,'g').reserves,20);
|
||||
});
|
||||
void test(`${advanced?'Advanced':'Basic'} Guild-allied Fremen cannot cross-ship southern reserves into Aftermath`,()=>{
|
||||
const game=prepare(aftermath(advanced),'f'); p(game,'g').ally='f'; p(game,'f').ally='g';
|
||||
const shipment: Action={type:'guildShip',from:'reserves',amount:2,territory:'arrakeen',sector:10,allyPayment:0};
|
||||
const view=viewGame(game,'f');
|
||||
assert.ok(guildTransportQuote(view,shipment).unavailableReasons.some(reason=>reason.includes('Atomics Aftermath')));
|
||||
assert.match(botArrivalBlock(view,shipment)??'',/Atomics Aftermath/);
|
||||
reject(game,'f',shipment);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Guild-allied Richese cannot cross-ship a concealed No-Field into Aftermath',()=>{
|
||||
const game=prepare(aftermath(true),'r'); p(game,'g').ally='r'; p(game,'r').ally='g';
|
||||
const stock=createRicheseNoField(['controlled-zero','controlled-three','controlled-five']);
|
||||
const marker=stock.tokens.find(token=>token.value===0)!;
|
||||
p(game,'r').noField=deployRicheseNoField(stock,{tokenId:marker.id,controller:'r',
|
||||
location:{territory:'imperial_basin',sector:10}});
|
||||
p(game,'r').noFieldEvent='controlled-marker-event';
|
||||
const shipment: Action={type:'guildShip',from:'imperial_basin:10',forces:{},noField:marker.id,
|
||||
event:p(game,'r').noFieldEvent,territory:'arrakeen',sector:10,allyPayment:0};
|
||||
const view=viewGame(game,'r');
|
||||
assert.ok(guildTransportQuote(view,shipment).unavailableReasons.some(reason=>reason.includes('Atomics Aftermath')));
|
||||
assert.match(botArrivalBlock(view,shipment)??'',/Atomics Aftermath/);
|
||||
reject(game,'r',shipment);
|
||||
assert.equal(p(game,'r').noField?.deployed?.tokenId,marker.id);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { applyAction, viewGame, type Action } from '../game/engine';
|
||||
import { junctionTransportActions, junctionTransportChoice } from '../game/junction-transport-options';
|
||||
import { TERROR_STRONGHOLDS } from '../game/moritani-terror';
|
||||
import { createJunctionPurchasedPrice, type PurchasedJunctionRoute } from './fixture-junction-purchased-price';
|
||||
|
||||
/** Original public setup, physical purchased rate and Guild's actual Junction
|
||||
* offer come from the native fixture. To examine the later persisted state,
|
||||
* Atomics' removed token, original victim and hand-limit marker are staged;
|
||||
* any excess held card is moved into the original deck, not duplicated. */
|
||||
function staged(route: PurchasedJunctionRoute) {
|
||||
const f=createJunctionPurchasedPrice({route});
|
||||
const g=structuredClone(f.beforeAction);
|
||||
const available=TERRITORIES.filter(t=>TERROR_STRONGHOLDS.includes(t.id)).flatMap(t=>
|
||||
t.sectors.map(sector=>`${t.id}:${sector}`)).find(destination=>
|
||||
!!junctionTransportChoice(viewGame(g,f.shipper),destination,f.sources).action);
|
||||
assert.ok(available,'An original offered Junction route needs an eligible stronghold.');
|
||||
const target=available.split(':')[0];
|
||||
const owner=g.players.find(player=>player.faction==='moritani'); assert.ok(owner);
|
||||
const token=g.moritaniTerror?.tokens.find(candidate=>candidate.kind==='atomics'); assert.ok(token);
|
||||
token.status='removed'; token.location=null;
|
||||
owner.atomicsHandLimitPenalty=true;
|
||||
if (owner.hand.length>3) g.deck.push(...owner.hand.splice(3));
|
||||
g.moritaniAtomics={territory:target,turn:g.turn,moritaniId:owner.id,
|
||||
allyAtActivation:null,alliancePolicy:'activation-targets'};
|
||||
return {f,g,destination:available,target};
|
||||
}
|
||||
|
||||
for (const route of ['nativeArrival','arrakisReturn'] as const) {
|
||||
void test(`Junction ${route==='nativeArrival'?'Homeworld-to-Arrakis':'Arrakis-to-Arrakis'} cannot enter Atomics Aftermath`,()=>{
|
||||
const {f,g,destination,target}=staged(route);
|
||||
const view=viewGame(g,f.shipper);
|
||||
const quoted=junctionTransportChoice(view,destination,f.sources);
|
||||
assert.match(quoted.blocked??'',/Atomics Aftermath/);
|
||||
assert.equal(quoted.action,null);
|
||||
const offer=view.junctionTransport?.offer; assert.ok(offer);
|
||||
const declaration: Action={type:'junctionShip',event:view.junctionTransport!.event,
|
||||
offer:offer.event,destination,sources:f.sources,allyPayment:0,forfeitPurchasedRate:true};
|
||||
const before=structuredClone(g);
|
||||
assert.throws(()=>applyAction(g,f.shipper,declaration),/Atomics Aftermath/);
|
||||
assert.deepEqual(g,before);
|
||||
for (let level=0;level<4;level++)
|
||||
assert.ok(!junctionTransportActions(view,level).some(action=>
|
||||
action.type==='junctionShip' && typeof action.destination==='string' && action.destination.split(':')[0]===target));
|
||||
// A Homeworld destination remains legal because the marker affects only Arrakis.
|
||||
const world=junctionTransportChoice(view,'homeworld:ecaz',f.sources);
|
||||
assert.ok(!world.blocked?.includes('Atomics'));
|
||||
});
|
||||
}
|
||||
@@ -89,13 +89,13 @@ void test('a changed revival parent cannot resume a saved Recruits discard', ()
|
||||
assert.ok(viewGame(pending, f.owner).semutaReaction);
|
||||
});
|
||||
|
||||
void test('a native CHOAM seat does not silently enable this narrow preview', () => {
|
||||
void test('a native CHOAM seat enables the Recruits reaction without enabling other Semuta previews', () => {
|
||||
const f = fixture(), lobby = structuredClone(f.lobby);
|
||||
const index = lobby.players.findIndex(seat => seat.id === 'emperor');
|
||||
assert.ok(index >= 0);
|
||||
lobby.players[index] = newPlayer('emperor', 'CHOAM', 'choam');
|
||||
lobby.players[index].ready = true;
|
||||
const game = act(lobby, lobby.host, {type: 'start'});
|
||||
assert.equal(game.semutaRecruitsPreview, undefined);
|
||||
assert.equal(game.semutaRecruitsPreview, true);
|
||||
assert.equal(game.semutaPreview, undefined);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorInventory as inventory,
|
||||
basicSemutaAmbassadorPlayer as seat } from './fixture-basic-semuta-ambassador';
|
||||
|
||||
/** Public selected-deck setup, original auctions and actual Revival. Only the
|
||||
* initial unopened ordinary/Spice order is selected to make this journey short. */
|
||||
function nativeFixture(native: 'tleilaxu' | 'ecaz') {
|
||||
let game = createGame('SEMUTANATIVERECRUITS', newPlayer('atreides', 'Atreides', 'atreides'), false,
|
||||
['choam', 'ix', 'ecaz']);
|
||||
for (const faction of ['richese', 'guild', native] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const owner = 'atreides', holder = 'guild';
|
||||
const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); };
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
for (let i = 0; i < 1600; i++) {
|
||||
if (until(game)) return;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextRicheseStrongholdsNativeStep(game);
|
||||
assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
throw Error('The native Semuta/Recruits game did not reach its requested boundary.');
|
||||
};
|
||||
act(owner, {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
for (const player of game.players) act(player.id, {type:'ready'});
|
||||
act(owner, {type:'start'});
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.semutaRecruitsPreview, true);
|
||||
const original = inventory(game);
|
||||
const starting = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind))
|
||||
.slice(0, game.players.length).map(card => card.id);
|
||||
assert.equal(starting.length, game.players.length);
|
||||
for (const [position, id] of [...starting, RECRUITS_CARD_ID].entries()) {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
}
|
||||
for (let i = 0; game.status === 'setup' && i < 100; i++) {
|
||||
const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step); act(step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
for (let position = 0; position < 4; position++) {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position);
|
||||
const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
}
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
|
||||
act('richese', {type:'decision', event:game.richeseBidding!.event, card:SEMUTA_DRUG_ID,
|
||||
method:'onceAround', direction:'counterclockwise'});
|
||||
reach(state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
|
||||
for (let i = 0; !seat(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && i < 8; i++) {
|
||||
const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome);
|
||||
act(lot.active, {type:'richeseBid', event:lot.event, amount:lot.active === holder ? 1 : null, allyPayment:0});
|
||||
reach(clean);
|
||||
}
|
||||
assert.ok(seat(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
|
||||
reach(state => state.phase === 3 && clean(state) && state.auction?.active === owner);
|
||||
assert.equal(game.auction!.cards[game.auction!.index].id, RECRUITS_CARD_ID);
|
||||
act(owner, {type:'bid', amount:1, allyPayment:0});
|
||||
reach(state => clean(state) && seat(state, owner).hand.some(card => card.id === RECRUITS_CARD_ID));
|
||||
reach(state => state.phase === 5 && state.active === owner && clean(state));
|
||||
const sector = game.storm % 18 + 1;
|
||||
const destination = TERRITORIES.find(place => place.type === 'sand' && place.sectors.includes(sector) &&
|
||||
game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`))));
|
||||
assert.ok(destination);
|
||||
const location = `${destination.id}:${sector}`;
|
||||
act(owner, {type:'ship', territory:destination.id, sector, amount:3, elite:0, allyPayment:0});
|
||||
reach(state => state.turn === 2 && state.phase === 4 && clean(state));
|
||||
assert.equal(seat(game, owner).forces[location], undefined);
|
||||
assert.equal(seat(game, owner).tanks, 3);
|
||||
assert.deepEqual(inventory(game), original);
|
||||
return {game, owner, holder, original};
|
||||
}
|
||||
|
||||
for (const native of ['tleilaxu','ecaz'] as const) for (const claim of [false,true]) {
|
||||
void test(`Basic ${native} selected-family Recruits discard offers Semuta: ${claim ? 'claim' : 'pass'}`, () => {
|
||||
const {game, owner, holder, original} = nativeFixture(native);
|
||||
const pending = applyAction(game, owner, {type:'card', card:RECRUITS_CARD_ID});
|
||||
const frame = pending.pendingTreacheryDiscard; assert.ok(frame);
|
||||
assert.equal(frame.continuation.kind, 'ordinaryCardDiscard');
|
||||
assert.equal(frame.reaction?.stage, 'offer');
|
||||
assert.deepEqual(frame.batch.entries.map(entry => entry.card.id), [RECRUITS_CARD_ID]);
|
||||
assert.equal(pending.recruits?.turn, pending.turn);
|
||||
const event = frame.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending, holder); view.players.find(player => player.id === holder)!.bot = difficulty;
|
||||
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'));
|
||||
}
|
||||
let done = pending;
|
||||
if (claim) {
|
||||
done = applyAction(done, holder, {type:'semutaCommit', event});
|
||||
if (done.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
done = applyAction(done, holder, {type:'semutaSelect', event, card:RECRUITS_CARD_ID});
|
||||
} else for (const player of pending.players) done = applyAction(done, player.id, {type:'semutaPass', event});
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
assert.equal(viewGame(done, owner).revival.freeRemaining, 4);
|
||||
assert.equal(viewGame(done, owner).revival.limit, 7);
|
||||
assert.equal(seat(done, holder).hand.some(card => card.id === RECRUITS_CARD_ID), claim);
|
||||
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
assert.deepEqual(inventory(done), original);
|
||||
const tanks = seat(done, owner).tanks, reserves = seat(done, owner).reserves;
|
||||
done = applyAction(done, owner, {type:'revive', amount:3, elite:0});
|
||||
assert.equal(seat(done, owner).tanks, tanks - 3);
|
||||
assert.equal(seat(done, owner).reserves, reserves + 3);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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/);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user