Support native Basic Ambassador and Terror arrivals

This commit is contained in:
Quintine committed 2026-10-27 20:29:47 +11:00
1 parent a07c6d8e50
commit a45d81aa34
9 files changed
+258 -28

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { act, player, createBasicNativeOverlapFixture, type First, type Native } from './fixture-basic-native-overlap';
function decline(game:Game, owner:First):Game {
if (owner==='ambassador') {
const pending=game.pendingAmbassador; assert.ok(pending);
return act(game,'ecaz',{type:'decision',event:pending.event,decline:true});
}
assert.ok(game.pendingTerrorEntry);
return act(game,'moritani',{type:'decision',decline:true});
}
for (const native of ['ixians','tleilaxu'] as const satisfies readonly Native[])
for (const first of ['ambassador','terror'] as const satisfies readonly First[]) {
void test(`Basic ${native} native roster preserves the original shipment through ${first}-first overlapping arrivals`,()=>{
const f=createBasicNativeOverlapFixture(native,first),initial=f.game;
const spice=player(initial,f.entrant).spice,reserves=player(initial,f.entrant).reserves;
const offered=act(initial,f.entrant,f.shipment);
assert.equal(player(offered,f.entrant).spice,spice-1);
assert.equal(player(offered,f.entrant).reserves,reserves-1);
assert.equal(player(offered,f.entrant).forces[`${f.destination}:${f.sector}`],1);
assert.equal(player(offered,f.entrant).shipped,true);
assert.equal(offered.pendingArrivalOverlap?.first,first);
assert.equal(offered.pendingArrivalOverlap?.stage,'first');
assert.equal(offered.pendingArrivalOverlap?.entrant,f.entrant);
assert.equal(viewGame(offered,f.entrant).arrivalOverlap?.first,first);
const other:First=first==='ambassador'?'terror':'ambassador';
const second=decline(offered,first);
assert.equal(second.pendingArrivalOverlap?.first,first);
assert.equal(second.pendingArrivalOverlap?.stage,'second');
assert.equal(player(second,f.entrant).spice,spice-1);
assert.equal(player(second,f.entrant).forces[`${f.destination}:${f.sector}`],1);
const settled=decline(second,other);
assert.equal(settled.pendingArrivalOverlap,null);
assert.equal(settled.pendingAmbassador,null);
assert.equal(settled.pendingTerrorEntry,null);
assert.equal(player(settled,f.entrant).spice,spice-1);
assert.equal(player(settled,f.entrant).reserves,reserves-1);
assert.equal(player(settled,f.entrant).forces[`${f.destination}:${f.sector}`],1);
assert.equal(settled.ecazAmbassadors?.tokens.find(t=>t.id===f.token)?.zone,'placed');
assert.equal(settled.moritaniTerror?.tokens.find(t=>t.id===f.terror)?.status,'placed');
});
}
void test('all minimal bot profiles can legally handle the first mixed-native overlap decision',()=>{
const f=createBasicNativeOverlapFixture('ixians','ambassador');
const offered=act(f.game,f.entrant,f.shipment);
for(const difficulty of DIFFICULTIES){
const view=viewGame(offered,'ecaz');
view.players.find(p=>p.id==='ecaz')!.bot=difficulty;
const action=botActions(view)[0]; assert.ok(action,difficulty);
const after=act(offered,'ecaz',action);
assert.ok(after.pendingArrivalOverlap || after.pendingTerrorEntry || after.pendingAmbassador,
'The bot choice enters the same real arrival continuation');
}
});
for (const first of ['ambassador','terror'] as const satisfies readonly First[]) {
void test(`Basic mixed-native ${first}-first arrival settles Duke acquisition and Robbery once`,()=>{
const f=createBasicNativeOverlapFixture('tleilaxu',first);
const guildBefore=player(f.game,f.entrant).spice;
const moritaniBefore=player(f.game,f.moritani).spice;
let game=act(f.game,f.entrant,f.shipment);
const afterInvoice=guildBefore-1, stolen=Math.ceil(afterInvoice/2);
for(const owner of [first,first==='ambassador'?'terror':'ambassador'] as const){
if(owner==='ambassador'){
const entry=game.pendingAmbassador; assert.ok(entry);
game=act(game,f.owner,{type:'decision',event:entry.event,trigger:true,
beneficiary:f.owner,choice:'duke'});
assert.equal(game.dukeVidal?.controller,f.owner);
assert.equal(game.ecazAmbassadors?.tokens.find(t=>t.id===f.token)?.zone,'supply');
}else{
assert.equal(game.pendingTerrorEntry?.stage,'offer');
game=act(game,f.moritani,{type:'decision',reveal:true});
assert.equal(game.pendingTerrorEntry?.stage,'robbery');
game=act(game,f.moritani,{type:'decision',choice:'spice'});
assert.equal(player(game,f.entrant).spice,afterInvoice-stolen);
assert.equal(player(game,f.moritani).spice,moritaniBefore+stolen);
}
}
assert.equal(game.pendingArrivalOverlap,null);
assert.equal(game.moritaniTerror?.tokens.find(t=>t.id===f.terror)?.status,'removed');
assert.equal(player(game,f.entrant).forces[`${f.destination}:${f.sector}`],1);
assert.equal(player(game,f.entrant).shipped,true);
});
}
void test('a real native Tleilaxu shipment triggers both reactions and resumes with physical forces',()=>{
const f=createBasicNativeOverlapFixture('tleilaxu','terror','native');
const initial=f.game, before=player(initial,f.entrant).spice;
let game=act(initial,f.entrant,f.shipment);
assert.equal(game.pendingArrivalOverlap?.entrant,'tleilaxu');
assert.equal(game.pendingArrivalOverlap?.first,'terror');
assert.equal(player(game,f.entrant).spice,before-1);
assert.equal(player(game,f.entrant).forces[`${f.destination}:${f.sector}`],1);
game=act(game,f.moritani,{type:'decision',reveal:true});
assert.equal(game.pendingTerrorEntry?.stage,'robbery');
game=act(game,f.moritani,{type:'decision',choice:'spice'});
const pending=game.pendingAmbassador; assert.ok(pending);
game=act(game,f.owner,{type:'decision',event:pending.event,trigger:true,
beneficiary:f.owner,choice:'duke'});
assert.equal(game.dukeVidal?.controller,f.owner);
assert.equal(game.pendingArrivalOverlap,null);
assert.equal(player(game,f.entrant).forces[`${f.destination}:${f.sector}`],1);
assert.equal(player(game,f.entrant).shipped,true);
});
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { placeAmbassador } from '../game/ecaz-ambassadors';
import { placeTerror } from '../game/moritani-terror';
import { actBasicSemutaAmbassador, advanceBasicSemutaAmbassador, basicSemutaAmbassadorClean,
basicSemutaAmbassadorPlayer, type BasicSemutaAmbassadorStep } from './fixture-basic-semuta-ambassador';
export const act = actBasicSemutaAmbassador;
export const player = basicSemutaAmbassadorPlayer;
export const advance = advanceBasicSemutaAmbassador;
export type Native = 'ixians' | 'tleilaxu';
export type First = 'ambassador' | 'terror';
/** A real public five-seat selected-deck Basic start. All actual setup, phase
* choices and owned shipment are native. At the original first movement turn,
* two legal marker placements are explicitly controlled on their existing
* physical tokens; no resource, force, pending reaction or receipt is staged. */
export function createBasicNativeOverlapFixture(native: Native, first: First, entrantKind: 'guild' | 'native' = 'guild') {
const entrant = entrantKind === 'native' ? native : 'guild';
let game = createGame('BASICNATIVEOVERLAP',newPlayer('ecaz','Ecaz','ecaz'),false,['ecaz','choam','ix']);
for (const faction of ['choam',native,'guild','moritani'] as const)
joinGame(game,newPlayer(faction,faction,faction));
const actions: BasicSemutaAmbassadorStep[] = [];
const send = (actor: string, action: Action) => {game=act(game,actor,action,actions);};
const reach = (until: (g: Game)=>boolean) => {game=advance(game,until,actions);};
// The real lobby circle choices choose the storm ordering; no saved order is edited.
send('guild',{type:'seatPosition',position:6});
if (first === 'terror') send('moritani',{type:'seatPosition',position:4});
send('ecaz',{type:'seatPosition',position:first === 'ambassador' ? 4 : 5});
send('choam',{type:'seatPosition',position:1});
send(native,{type:'seatPosition',position:2});
for (const seat of game.players) send(seat.id,{type:'ready'});
const lobby=structuredClone(game);
send('ecaz',{type:'start'});
const setup=structuredClone(game);
assert.equal(game.status,'setup');
assert.equal(game.advanced,false);
assert.ok(!game.semutaPreview && !game.kullPreview && !game.nexusCards && !game.leaderSkills && !game.homeworlds);
reach(g=>g.status==='playing');
const afterSetup=structuredClone(game);
assert.ok(game.ecazAmbassadors && game.moritaniTerror);
// Pick a known ordinary unopened Spice draw; this does not stage its result.
const spice=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery);
assert.ok(spice>=0);
game.spiceDeck.unshift(game.spiceDeck.splice(spice,1)[0]);
reach(g=>g.turn===1 && g.phase===5 && g.active===entrant && basicSemutaAmbassadorClean(g));
assert.equal(game.order.indexOf('ecaz')<game.order.indexOf('moritani'),first==='ambassador');
const beforeMarkers=structuredClone(game);
const token=game.ecazAmbassadors!.tokens.find(t=>t.effect==='ecaz' && t.zone==='supply');
const terror=game.moritaniTerror!.tokens.find(t=>t.kind==='robbery' && t.status==='available');
assert.ok(token && terror);
const destination='habbanya_ridge_sietch',sector=17;
game.ecazAmbassadors=placeAmbassador(game.ecazAmbassadors!,token.id,{
turn:game.turn,availableSpice:player(game,'ecaz').spice,
destination:{id:destination,stronghold:true,inStorm:false,allowed:true},
}).state;
game.moritaniTerror=placeTerror(game.moritaniTerror!,terror.id,destination,game.turn);
const source=structuredClone(game);
const shipment: Action={type:'ship',territory:destination,sector,amount:1,elite:0,allyPayment:0};
return {game,source,lobby,setup,afterSetup,beforeMarkers,actions,first,native,
entrant,owner:'ecaz',moritani:'moritani',destination,sector,token:token.id,terror:terror.id,shipment,
staging:'Actual public lobby, setup and turn-one native controls. Two existing supply tokens are placed by their native pure placement helpers at the clean entrant turn; no receipt, pending action, force, purse, seat or order is staged. One unopened ordinary Spice card is selected.'};
}