Resolve Basic Ambassador child arrivals during Terror

This commit is contained in:
Quintine committed 2026-10-27 20:40:41 +11:00
1 parent a45d81aa34
commit fbdf11c789
15 files changed
+817 -16

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { placeAmbassador } from '../game/ecaz-ambassadors';
import { guildAmbassadorShipments } from '../game/guild-ambassador';
import { placeTerror } from '../game/moritani-terror';
import { createBasicNativeOverlapFixture, player, type First } from './fixture-basic-native-overlap';
/** Real public Basic setup and native Tleilaxu's real turn from the shared
* fixture. Controlled supplements select Guild as one original Ambassador in
* the five-token cohort and legally place it at the original destination.
* For distinct destinations an additional original Terror token is placed by
* a controlled custody-preserving edit (not a claim of natural Mentat history).
* No purse, forces, phase, player order, pending decision or receipt is staged. */
export function createGuildOverlapChildFixture(first: First, same: boolean) {
const f = createBasicNativeOverlapFixture('tleilaxu', first, 'native');
const game = structuredClone(f.beforeMarkers);
const state = game.ecazAmbassadors!;
const guild = state.tokens.find(token => token.effect === 'guild'); assert.ok(guild);
if (guild.zone === 'pool') {
const replaced = state.tokens.find(token => token.zone === 'supply' && token.effect !== 'ecaz');
assert.ok(replaced);
const index = state.cohort.indexOf(replaced.id); assert.ok(index >= 0);
state.cohort[index] = guild.id; replaced.zone = 'pool'; guild.zone = 'supply';
}
assert.equal(guild.zone, 'supply');
game.ecazAmbassadors = placeAmbassador(state, guild.id, {
turn:game.turn, availableSpice:player(game,'ecaz').spice,
destination:{id:f.destination,stronghold:true,inStorm:false,allowed:true},
}).state;
const choices = guildAmbassadorShipments(game, 'ecaz').destinations;
const separate = choices.find(choice => choice.territory !== f.destination &&
['arrakeen','carthag','sietch_tabr','tueks_sietch'].includes(choice.territory));
const target = same ? choices.find(choice => choice.territory === f.destination && choice.sector === f.sector) : separate;
assert.ok(target, 'The free shipment must have an available selected stronghold destination');
const terror = game.moritaniTerror!;
const original = terror.tokens.find(token => token.kind === (same ? 'robbery' : 'sabotage')); assert.ok(original);
game.moritaniTerror = placeTerror(terror, original.id, f.destination, game.turn);
let child = original;
if (!same) {
const second = game.moritaniTerror.tokens.find(token => token.kind === 'robbery'); assert.ok(second);
second.status = 'placed'; second.location = target.territory; child = second;
}
return { ...f, game, ambassador:guild.id, originalTerror:original.id,
childTerror:child.id, childDestination:target.territory, childSector:target.sector,
staging:'Public native setup and actual Tleilaxu shipment. Original Guild token/cohort and Terror placements are controlled; the distinct child Terror is a physical token placed without staging a Mentat turn.' };
}
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { placeAmbassador, validateAmbassadors } from '../game/ecaz-ambassadors';
import { placeTerror } from '../game/moritani-terror';
import { fremenAmbassadorMovement } from '../game/fremen-ambassador';
import { act, player, createBasicNativeOverlapFixture, type First } from './fixture-basic-native-overlap';
import { advanceBasicSemutaAmbassador, basicSemutaAmbassadorClean } from './fixture-basic-semuta-ambassador';
export { act, player };
/** Public Basic native setup and actual Guild shipment. The Fremen cohort and
* two original physical tokens are selected and placed in controlled setup;
* the second token is explicitly staged because two placements cannot occur
* during one real turn. All later moves, payments and reactions are native. */
function createWithBg(first: First) {
let game = createGame('OVERLAPFREMENBG',newPlayer('ecaz','Ecaz','ecaz'),false,['ecaz','choam','ix']);
// Arrange five occupied circles while the sixth is still free, then join
// Guild in the remaining circle. Public seat selection never swaps seats.
for (const faction of ['choam','tleilaxu','beneGesserit','moritani'] as const)
joinGame(game,newPlayer(faction,faction,faction));
for (const [actor,position] of [['ecaz',6],['choam',1],['tleilaxu',2],['beneGesserit',3]] as const)
game = act(game,actor,{type:'seatPosition',position});
if (first === 'terror') game = act(game,'moritani',{type:'seatPosition',position:4});
game = act(game,'ecaz',{type:'seatPosition',position:first === 'ambassador' ? 4 : 5});
joinGame(game,newPlayer('guild','guild','guild'));
for (const p of game.players) game = act(game,p.id,{type:'ready'});
game = act(game,'ecaz',{type:'start'});
game = advanceBasicSemutaAmbassador(game, g => g.status === 'playing');
const draw = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(draw >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(draw,1)[0]);
game = advanceBasicSemutaAmbassador(game, g => g.turn === 1 && g.phase === 5 &&
g.active === 'guild' && basicSemutaAmbassadorClean(g));
assert.equal(game.order.indexOf('ecaz') < game.order.indexOf('moritani'),first === 'ambassador');
return { beforeMarkers:structuredClone(game), entrant:'guild', destination:'habbanya_ridge_sietch', sector:17,
shipment:{type:'ship',territory:'habbanya_ridge_sietch',sector:17,amount:1,elite:0,allyPayment:0} as Action };
}
export function createFremenOverlapFixture(first: First, otherTerror = false, withBG = false) {
const f = withBG ? createWithBg(first) : createBasicNativeOverlapFixture('tleilaxu', first);
const game = structuredClone(f.beforeMarkers);
const ambassadors = game.ecazAmbassadors!;
const fremen = ambassadors.tokens.find(token => token.effect === 'fremen'); assert.ok(fremen);
if (fremen.zone === 'pool') {
const index = ambassadors.cohort.findIndex(id => ambassadors.tokens.some(token => token.id === id && token.zone === 'supply'));
assert.ok(index >= 0);
const old = ambassadors.tokens.find(token => token.id === ambassadors.cohort[index])!;
old.zone = 'pool'; fremen.zone = 'supply'; ambassadors.cohort[index] = fremen.id;
}
assert.equal(fremen.zone, 'supply');
validateAmbassadors(ambassadors);
game.ecazAmbassadors = placeAmbassador(ambassadors, fremen.id, {
turn: game.turn, availableSpice: player(game, 'ecaz').spice,
destination: { id: f.destination, stronghold: true, inStorm: false, allowed: true },
}).state;
const terror = game.moritaniTerror!.tokens.find(token => token.kind === 'robbery' && token.status === 'available');
assert.ok(terror);
game.moritaniTerror = placeTerror(game.moritaniTerror!, terror.id, f.destination, game.turn);
const options = fremenAmbassadorMovement(game, 'ecaz').sources.flatMap(source =>
source.destinations.filter(destination => source.territory !== destination.territory &&
source.sectors.some(sector => sector.forces > 0)).map(destination => ({ source, destination })));
const alternate = options.find(option => option.destination.territory !== f.destination &&
['arrakeen','carthag','sietch_tabr','habbanya_ridge_sietch','tuek_sietch'].includes(option.destination.territory));
assert.ok(alternate, 'A real Ecaz group can enter a second ordinary stronghold');
let childTerror: string | undefined;
if (otherTerror) {
const token = game.moritaniTerror.tokens.find(row => row.kind === (withBG ? 'sneakAttack' : 'extortion') && row.status === 'available');
assert.ok(token);
// Controlled additional original token placement. It preserves the native
// inventory and current placement-turn history; no force or spice is added.
token.status = 'placed'; token.location = alternate.destination.territory;
childTerror = token.id;
}
return { ...f, game, fremen: fremen.id, terror: terror.id, alternate, childTerror,
staging: 'Public native setup and shipment; selected Fremen cohort and original Terror placements are controlled. An optional second physical Terror placement is staged without changing force or spice.' };
}
export function triggerFremen(game: Game): Game {
const entry = game.pendingAmbassador; assert.ok(entry && entry.stage === 'offer');
const next = act(game, 'ecaz', { type: 'decision', event: entry.event, trigger: true, beneficiary: 'ecaz' });
assert.equal(next.pendingAmbassador?.effect, 'fremen');
assert.equal(next.pendingAmbassador?.stage, 'move');
return next;
}
export function relocate(game: Game, territory: string, sector: number): Game {
const sources = fremenAmbassadorMovement(game, 'ecaz').sources;
const source = sources.find(row => row.territory !== territory && row.sectors.some(s => s.forces > 0) &&
row.destinations.some(d => d.territory === territory && d.sector === sector));
assert.ok(source, `A native Ecaz group can reach ${territory}:${sector}`);
const position = source.sectors.find(row => row.forces > 0); assert.ok(position);
const action: Action = { type: 'decision', event: game.pendingAmbassador!.event,
forces: { [position.key]: 1 }, territory, sector };
return act(game, 'ecaz', action);
}
+59
View File
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import type { Action, Game } from '../game/engine';
import { validateAmbassadors } from '../game/ecaz-ambassadors';
import { fremenAmbassadorMovement } from '../game/fremen-ambassador';
import { actSemutaArrivalOverlap as act, createSemutaArrivalOverlapFixture,
inventory, player } from './fixture-semuta-arrival-overlap';
export { act, inventory, player };
/** Native Basic public setup, original paid Semuta auction and a real turn-one
* Terror placement come from the shared fixture. At the clean turn-two source,
* swap the placed original CHOAM Ambassador with the original Fremen Ambassador
* already in the same cohort. The physical tokens and paid location remain;
* this supplement is controlled, not a played Fremen placement. The inherited
* paid Semuta is also transferred to Richese as the shared fixture documents. */
export function createSemutaOverlapChildFixture() {
const f = createSemutaArrivalOverlapFixture({ effect: 'choam', first: 'ambassador', terror: 'sabotage' });
const game = structuredClone(f.game), ambassadors = game.ecazAmbassadors!;
const existing = ambassadors.tokens.find(token => token.id === f.token);
const fremen = ambassadors.tokens.find(token => token.effect === 'fremen');
assert.ok(existing && fremen);
assert.equal(existing.zone, 'placed');
assert.equal(existing.location, f.territory);
assert.equal(fremen.zone, 'supply');
assert.ok(ambassadors.cohort.includes(fremen.id));
existing.zone = 'supply'; existing.location = null;
fremen.zone = 'placed'; fremen.location = f.territory;
validateAmbassadors(ambassadors);
assert.ok(player(game, f.owner).hand.length > 0, 'The physical child entrant can suffer Sabotage');
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.semuta));
assert.deepEqual(inventory(game), f.originalInventory);
return { ...f, game, fremenToken: fremen.id,
staging: [...f.staging, 'At the clean turn-two source, swap the placed original CHOAM Ambassador with the original Fremen cohort token. Both retain native custody; no force, wallet, turn or response is installed.'] };
}
export type SemutaOverlapChildFixture = ReturnType<typeof createSemutaOverlapChildFixture>;
/** The real paid Guild arrival opens both original opportunities; the Fremen
* Ambassador then moves one existing Ecaz force into that same stronghold. */
export function openSemutaOverlapChild(f: SemutaOverlapChildFixture): Game {
let game = act(f.game, f.arrivalStep.actor, f.arrivalStep.action);
assert.equal(game.pendingArrivalOverlap?.first, 'ambassador');
const entry = game.pendingAmbassador; assert.ok(entry && entry.stage === 'offer');
assert.equal(entry.token, f.fremenToken);
game = act(game, f.owner, { type: 'decision', event: entry.event, trigger: true, beneficiary: f.owner });
assert.equal(game.pendingAmbassador?.effect, 'fremen');
assert.equal(game.pendingAmbassador?.stage, 'move');
const sources = fremenAmbassadorMovement(game, f.owner).sources;
const source = sources.find(row => row.territory !== f.territory && row.sectors.some(sector => sector.forces > 0) &&
row.destinations.some(destination => destination.territory === f.territory && destination.sector === f.sector));
assert.ok(source);
const sector = source.sectors.find(row => row.forces > 0); assert.ok(sector);
const action: Action = { type: 'decision', event: entry.event, forces: { [sector.key]: 1 },
territory: f.territory, sector: f.sector };
game = act(game, f.owner, action);
assert.equal(game.pendingTerrorEntry?.cause, 'ambassador');
assert.equal(game.pendingTerrorEntry?.entrant, f.owner);
assert.equal(game.pendingTerrorEntry?.token, f.terrorToken);
return game;
}
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { act, player, type First } from './fixture-basic-native-overlap';
import { createGuildOverlapChildFixture } from './fixture-guild-overlap-child';
for (const first of ['ambassador','terror'] as const satisfies readonly First[])
for (const same of [true,false])
for (const reveal of [false,true]) {
void test(`Basic Guild child ${same?'same':'different'} stronghold, ${first} first, child ${reveal?'reveals':'declines'}`,()=>{
const f=createGuildOverlapChildFixture(first,same), source=f.game;
const originalSpice=player(source,f.entrant).spice;
const beneficiary=player(source,f.owner);
const reserve=beneficiary.reserves,spice=beneficiary.spice;
let game=act(source,f.entrant,f.shipment);
assert.equal(game.pendingArrivalOverlap?.first,first);
const overlapEvent=game.pendingArrivalOverlap!.event;
assert.equal(player(game,f.entrant).spice,originalSpice-1);
assert.equal(player(game,f.entrant).forces[`${f.destination}:${f.sector}`],1);
if(first==='terror') {
assert.equal(game.pendingTerrorEntry?.entrant,f.entrant);
game=act(game,f.moritani,{type:'decision',decline:true});
}
const entry=game.pendingAmbassador; assert.ok(entry);
assert.equal(entry.token,f.ambassador);
const projection=viewGame(game,f.owner).ambassadorEntry; assert.ok(projection);
game=act(game,f.owner,{type:'decision',event:entry.event,trigger:true,beneficiary:f.owner});
assert.equal(game.pendingAmbassador?.stage,'ship');
assert.equal(game.decision?.kind,'ecazAmbassador');
game=act(game,f.owner,{type:'decision',event:entry.event,amount:1,elite:0,
territory:f.childDestination,sector:f.childSector});
assert.equal(player(game,f.owner).reserves,reserve-1);
assert.equal(player(game,f.owner).forces[`${f.childDestination}:${f.childSector}`],1);
assert.equal(player(game,f.owner).spice,spice,'The independent Guild shipment is free');
assert.equal(game.pendingTerrorEntry?.entrant,f.owner);
assert.equal(game.pendingTerrorEntry?.token,f.childTerror);
assert.equal(game.pendingTerrorEntry?.resume,'ambassador');
assert.equal(game.pendingArrivalOverlap?.event,overlapEvent);
assert.equal(game.pendingArrivalOverlap?.ambassadorEvent,entry.event);
if(reveal) {
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.owner).spice,Math.floor(spice/2));
assert.equal(game.moritaniTerror?.tokens.find(token=>token.id===f.childTerror)?.status,'removed');
} else game=act(game,f.moritani,{type:'decision',decline:true});
if(first==='ambassador' && (!same || !reveal)) {
assert.equal(game.pendingTerrorEntry?.entrant,f.entrant,'The original live token keeps its own prior opportunity');
assert.equal(game.pendingTerrorEntry?.token,f.originalTerror);
game=act(game,f.moritani,{type:'decision',decline:true});
}
assert.equal(game.pendingArrivalOverlap,null);
assert.equal(game.pendingAmbassador,null);
assert.equal(game.pendingTerrorEntry,null);
assert.equal(game.ecazAmbassadors?.tokens.find(token=>token.id===f.ambassador)?.zone,'used');
assert.equal(player(game,f.entrant).forces[`${f.destination}:${f.sector}`],1);
assert.equal(player(game,f.entrant).spice,originalSpice-1);
assert.equal(player(game,f.owner).forces[`${f.childDestination}:${f.childSector}`],1);
assert.equal(player(game,f.owner).reserves,reserve-1);
});
}
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
/** Controlled Advanced Ecaz/Moritani table with original physical markers.
* It isolates the pre-existing Advanced overlap gate, not a played setup or
* an assurance of every optional-module combination. */
function advancedTable(effect: 'fremen' | 'guild'): Game {
const game = createGame('OVERLAPADVANCEDGATE', newPlayer('ec','Ecaz','ecaz'), true, ['ecaz']);
game.players.push(newPlayer('m','Moritani','moritani'),newPlayer('in','Entrant','harkonnen'));
Object.assign(game, {status:'playing',phase:5,turn:3,storm:18,active:'in',
order:['ec','m','in'],movementRemaining:['ec','m','in'],deck:baseDeck()});
for (const player of game.players)
Object.assign(player,{hand:[],spice:20,forces:{},reserves:20,shipped:false,moved:0});
const ambassadors = createAmbassadors(() => 0.2);
const selected = ambassadors.tokens.find(token=>token.effect===effect); assert.ok(selected);
ambassadors.cohort = [selected.id,...ambassadors.tokens.filter(token=>token.effect!=='ecaz' &&
token.id!==selected.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;
}
game.ecazAmbassadors = placeAmbassador(ambassadors,selected.id,{turn:2,availableSpice:20,
destination:{id:'arrakeen',stronghold:true,inStorm:false,allowed:true}}).state;
const terror = createTerrorState(()=>0.2), token = terror.tokens.find(row=>row.kind==='robbery'); assert.ok(token);
game.moritaniTerror = placeTerror(terror,token.id,'arrakeen',2);
return game;
}
for (const effect of ['fremen','guild'] as const) {
void test(`Advanced ${effect} Ambassador child stays gated during overlap`,()=>{
const game = applyAction(advancedTable(effect),'in',{type:'ship',territory:'arrakeen',sector:10,amount:2});
const entry = game.pendingAmbassador; assert.ok(entry);
assert.equal(game.pendingArrivalOverlap?.first,'ambassador');
const blocked = viewGame(game,'ec').ambassadorEntry?.beneficiaries[0].blocked;
assert.match(blocked ?? '',/bare Basic faction preview/);
const before = structuredClone(game);
assert.throws(()=>applyAction(game,'ec',{type:'decision',event:entry.event,trigger:true,beneficiary:'ec'}),
/bare Basic faction preview/);
assert.deepEqual(game,before);
let settled = applyAction(game,'ec',{type:'decision',event:entry.event,decline:true});
assert.equal(settled.pendingTerrorEntry?.entrant,'in');
settled = applyAction(settled,'m',{type:'decision',decline:true});
assert.equal(settled.pendingArrivalOverlap,null);
});
}
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type GameView } from '../game/engine';
import { act, createFremenOverlapFixture, triggerFremen } from './fixture-overlap-fremen-native';
import { createGuildOverlapChildFixture } from './fixture-guild-overlap-child';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1],match[1]]));
return {format:'module',source:`export default ${JSON.stringify(classes)}`,shortCircuit:true};
},
});
const { EcazEntry } = await import('../components/ecaz-entry');
aliases.deregister();
const htmlFor = (game: GameView, busy = false) => renderToStaticMarkup(createElement(EcazEntry,
{ game, busy, act() { assert.fail('Rendering cannot submit an action'); } }));
function triggerButton(html: string) {
const buttons = [...html.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)];
const match = buttons.find(([, , text]) => text.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').includes('Trigger for yourself'));
assert.ok(match, 'The current Ecaz player sees its projected trigger');
return match[1];
}
void test('Fremen overlap exposes the enabled real Ambassador trigger and relocation controls', () => {
const f = createFremenOverlapFixture('ambassador');
const offered = act(f.game, f.entrant, f.shipment);
const view = viewGame(offered, 'ecaz');
assert.equal(view.ambassadorEntry?.beneficiaries[0].blocked, null);
const html = htmlFor(view);
assert.match(html, /Fremen Ambassador/);
assert.doesNotMatch(triggerButton(html), /\bdisabled(?:=|\s|>)/);
assert.match(triggerButton(htmlFor(view, true)), /\bdisabled/);
const selected = triggerFremen(offered);
const movement = viewGame(selected, 'ecaz');
assert.ok(movement.ambassadorEntry?.movement?.sources.length);
assert.match(htmlFor(movement), /aria-label="Ambassador force relocation"/);
assert.match(htmlFor(movement), /Relocate selected forces/);
});
void test('Guild overlap exposes the enabled real Ambassador trigger and shipment controls', () => {
const f = createGuildOverlapChildFixture('ambassador', true);
const offered = act(f.game, f.entrant, f.shipment);
const view = viewGame(offered, 'ecaz');
assert.equal(view.ambassadorEntry?.beneficiaries[0].blocked, null);
const html = htmlFor(view);
assert.match(html, /Guild Ambassador/);
assert.doesNotMatch(triggerButton(html), /\bdisabled(?:=|\s|>)/);
assert.match(triggerButton(htmlFor(view, true)), /\bdisabled/);
const entry = offered.pendingAmbassador; assert.ok(entry);
const selected = act(offered, 'ecaz', { type:'decision', event:entry.event, trigger:true, beneficiary:'ecaz' });
const shipment = viewGame(selected, 'ecaz');
assert.ok(shipment.ambassadorEntry?.shipment?.destinations.length);
const selectedHtml = htmlFor(shipment);
assert.match(selectedHtml, /aria-label="Ambassador reserve shipment"/);
assert.match(selectedHtml, /Ship selected forces for free/);
});
+163
View File
@@ -0,0 +1,163 @@
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, createFremenOverlapFixture, triggerFremen, relocate } from './fixture-overlap-fremen-native';
import type { First } from './fixture-basic-native-overlap';
function entered(first: First, otherTerror = false, withBG = false) {
const f = createFremenOverlapFixture(first, otherTerror, withBG);
const spice = player(f.game, f.entrant).spice;
let game = act(f.game, f.entrant, f.shipment);
assert.equal(player(game, f.entrant).spice, spice - 1);
// A real Bene Gesserit seat may accompany the original shipment before
// the queued Ambassador/Terror entry is opened.
if (game.decision?.kind === 'advisor')
game = act(game, 'beneGesserit', { type: 'decision', accept: false });
assert.equal(game.pendingArrivalOverlap?.first, first);
if (first === 'terror') {
assert.equal(game.pendingTerrorEntry?.entrant, f.entrant);
game = act(game, 'moritani', { type: 'decision', decline: true });
}
assert.equal(game.pendingAmbassador?.token, f.fremen);
assert.equal(viewGame(game, 'ecaz').ambassadorEntry?.beneficiaries[0].blocked, null);
game = triggerFremen(game);
return { f, game, spice };
}
function decline(game: Game) { return act(game, 'moritani', { type: 'decision', decline: true }); }
function rob(game: Game) {
game = act(game, 'moritani', { type: 'decision', reveal: true });
assert.equal(game.pendingTerrorEntry?.stage, 'robbery');
return act(game, 'moritani', { type: 'decision', choice: 'spice' });
}
void test('Ambassador-first child decline preserves the original Terror opportunity', () => {
const { f, game: open, spice } = entered('ambassador');
let game = relocate(open, f.destination, f.sector);
assert.equal(game.pendingTerrorEntry?.cause, 'ambassador');
assert.equal(game.pendingTerrorEntry?.entrant, 'ecaz');
assert.equal(game.pendingTerrorEntry?.token, f.terror);
game = decline(game);
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
assert.equal(game.pendingTerrorEntry?.cause, 'shipment');
assert.equal(game.pendingTerrorEntry?.entrant, f.entrant);
assert.equal(game.pendingTerrorEntry?.token, f.terror);
game = decline(game);
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(game.pendingAmbassador, null);
assert.equal(game.pendingTerrorEntry, null);
assert.equal(player(game, f.entrant).spice, spice - 1);
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
assert.equal(player(game, 'ecaz').forces[`${f.destination}:${f.sector}`], 1);
assert.equal(game.moritaniTerror!.tokens.find(t => t.id === f.terror)?.status, 'placed');
});
void test('Ambassador-first child reveal consumes the shared token without replaying the original entry', () => {
const { f, game: open, spice } = entered('ambassador');
let game = relocate(open, f.destination, f.sector);
assert.equal(game.pendingTerrorEntry?.entrant, 'ecaz');
game = rob(game);
assert.equal(game.moritaniTerror!.tokens.find(t => t.id === f.terror)?.status, 'removed');
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(game.pendingAmbassador, null);
assert.equal(game.pendingTerrorEntry, null);
assert.equal(player(game, f.entrant).spice, spice - 1);
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
assert.equal(player(game, 'ecaz').forces[`${f.destination}:${f.sector}`], 1);
});
void test('Terror-first original decline keeps the token available for a Fremen child entry', () => {
const { f, game: open } = entered('terror');
let game = relocate(open, f.destination, f.sector);
assert.equal(game.pendingTerrorEntry?.cause, 'ambassador');
assert.equal(game.pendingTerrorEntry?.entrant, 'ecaz');
assert.equal(game.pendingTerrorEntry?.token, f.terror);
game = rob(game);
assert.equal(game.moritaniTerror!.tokens.find(t => t.id === f.terror)?.status, 'removed');
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(game.pendingAmbassador, null);
assert.equal(game.pendingTerrorEntry, null);
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
assert.equal(player(game, 'ecaz').forces[`${f.destination}:${f.sector}`], 1);
});
void test('a separate child Terror resolves before the still-eligible original Terror', () => {
const { f, game: open } = entered('ambassador', true);
const at = f.alternate.destination;
let game = relocate(open, at.territory, at.sector);
assert.equal(game.pendingTerrorEntry?.entrant, 'ecaz');
assert.equal(game.pendingTerrorEntry?.token, f.childTerror);
game = act(game, 'moritani', { type: 'decision', reveal: true });
assert.equal(game.moritaniTerror!.tokens.find(t => t.id === f.childTerror)?.status, 'extortion');
assert.equal(game.pendingTerrorEntry?.entrant, f.entrant);
assert.equal(game.pendingTerrorEntry?.token, f.terror);
game = decline(game);
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(player(game, 'ecaz').forces[`${at.territory}:${at.sector}`], 1);
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
});
void test('Terror-first original reveal leaves no token for the subsequent same-stronghold child', () => {
const f = createFremenOverlapFixture('terror');
let game = act(f.game, f.entrant, f.shipment);
assert.equal(game.pendingTerrorEntry?.entrant, f.entrant);
game = rob(game);
assert.equal(game.moritaniTerror!.tokens.find(t => t.id === f.terror)?.status, 'removed');
assert.equal(game.pendingAmbassador?.token, f.fremen);
game = triggerFremen(game);
game = relocate(game, f.destination, f.sector);
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(game.pendingAmbassador, null);
assert.equal(game.pendingTerrorEntry, null);
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
assert.equal(player(game, 'ecaz').forces[`${f.destination}:${f.sector}`], 1);
});
void test('a child Sneak Attack queues its Bene Gesserit accompaniment after the original Terror', () => {
const { f, game: open } = entered('ambassador', true, true);
const at = f.alternate.destination;
const moritaniReserves = player(open, 'moritani').reserves;
let game = relocate(open, at.territory, at.sector);
assert.equal(game.pendingTerrorEntry?.token, f.childTerror);
game = act(game, 'moritani', { type: 'decision', reveal: true });
assert.equal(game.pendingTerrorEntry?.stage, 'sneakAttack');
game = act(game, 'moritani', { type: 'decision', amount: 1 });
assert.equal(player(game, 'moritani').reserves, moritaniReserves - 1);
assert.equal(player(game, 'moritani').forces[`${at.territory}:${at.sector}`], 1);
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
assert.equal(game.pendingTerrorEntry?.token, f.terror);
assert.equal(game.pendingTerrorEntry?.entrant, f.entrant);
assert.equal(game.decision?.kind, 'moritaniTerror', 'The original Terror controls cannot be overwritten');
game = decline(game);
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(game.decision?.kind, 'advisor');
assert.equal(game.decision?.player, 'beneGesserit');
game = act(game, 'beneGesserit', { type: 'decision', accept: false });
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(game.pendingTerrorEntry, null);
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
assert.equal(player(game, 'moritani').forces[`${at.territory}:${at.sector}`], 1);
});
void test('all minimal bot profiles choose a legal Fremen Ambassador action in the overlap', () => {
const f = createFremenOverlapFixture('ambassador');
const offered = act(f.game, f.entrant, f.shipment);
assert.equal(offered.pendingAmbassador?.stage, 'offer');
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);
let game = act(offered, 'ecaz', action);
if (game.pendingAmbassador?.stage === 'move') {
game = relocate(game, f.destination, f.sector);
assert.equal(game.pendingTerrorEntry?.cause, 'ambassador');
game = decline(game);
}
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
assert.equal(game.pendingTerrorEntry?.entrant, f.entrant);
game = decline(game);
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
}
});
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { claimOverlapSemuta, passCurrentSemuta } from './fixture-semuta-arrival-overlap';
import { act, inventory, player, createSemutaOverlapChildFixture, openSemutaOverlapChild } from './fixture-semuta-overlap-child';
for (const claim of [false, true]) {
void test(`Richese may ${claim ? 'claim' : 'pass'} a child Sabotage discard before the original overlap continues`, () => {
const f = createSemutaOverlapChildFixture();
let game = openSemutaOverlapChild(f);
const before = structuredClone(game), hand = player(game, f.owner).hand.map(card => card.id);
assert.equal(game.pendingArrivalOverlap?.stage, 'first');
assert.equal(game.pendingTerrorEntry?.entrant, f.owner);
game = act(game, f.moritani, { type: 'decision', reveal: true });
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.reaction?.stage, 'offer');
assert.equal(pending.continuation.kind, 'terrorDiscard');
assert.equal(pending.batch.entries.length, 1);
const entry = pending.batch.entries[0], card = entry.card.id, event = pending.batch.event;
assert.equal(entry.discardedBy, f.owner);
assert.equal(entry.publicFace, false);
assert.ok(hand.includes(card), 'Sabotage discards an original held physical card');
assert.equal(player(game, f.owner).hand.length, hand.length - 1);
assert.ok(game.pendingArrivalOverlap, 'The original overlap remains suspended');
assert.deepEqual(viewGame(game, f.claimant).semutaReaction?.candidates, []);
assert.equal(player(game, f.entrant).forces[f.location], 1);
assert.equal(player(game, f.entrant).spice, player(before, f.entrant).spice);
assert.deepEqual(inventory(game), f.originalInventory);
game = claim ? claimOverlapSemuta(game, f.claimant, card) : passCurrentSemuta(game);
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
assert.equal(player(game, f.claimant).hand.some(held => held.id === card), claim);
assert.equal(player(game, f.claimant).hand.some(held => held.id === f.semuta), !claim);
assert.equal(game.discard.some(discarded => discarded.id === card), !claim);
assert.equal(game.discard.some(discarded => discarded.id === f.semuta), claim);
assert.equal(game.moritaniTerror?.tokens.find(token => token.id === f.terrorToken)?.status, 'removed');
if (game.pendingTerrorEntry?.stage === 'gift')
game = act(game, f.moritani, { type: 'decision', decline: true });
assert.equal(game.pendingArrivalOverlap, null, 'A consumed shared Terror is not offered again');
assert.equal(game.pendingAmbassador, null);
assert.equal(game.pendingTerrorEntry, null);
assert.equal(player(game, f.entrant).forces[f.location], 1);
assert.equal(player(game, f.owner).forces[f.location], 1);
assert.equal(player(game, f.entrant).spice, player(before, f.entrant).spice);
assert.deepEqual(inventory(game), f.originalInventory);
});
}
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { act, player, createFremenOverlapFixture } from './fixture-overlap-fremen-native';
void test('Terror-first Sneak waits for its own BG choice and then earns a retained Ambassador', () => {
const f = createFremenOverlapFixture('terror', false, true);
const source = structuredClone(f.game);
// Controlled physical-token selection in the original public fixture: return
// its staged Robbery token to supply and put the existing Sneak token in the
// same stronghold. No force, money, pending event or turn is installed.
const robbery = source.moritaniTerror!.tokens.find(token => token.id === f.terror);
const sneak = source.moritaniTerror!.tokens.find(token => token.kind === 'sneakAttack');
assert.ok(robbery && sneak && sneak.status === 'available');
robbery.status = 'available'; robbery.location = null;
sneak.status = 'placed'; sneak.location = f.destination;
const originalSpice = player(source, f.entrant).spice;
const reserves = player(source, 'moritani').reserves;
let game = act(source, f.entrant, f.shipment);
// The first accompaniment belongs to the real original shipment.
if (game.decision?.kind === 'advisor')
game = act(game, 'beneGesserit', { type: 'decision', accept: false });
assert.equal(game.pendingArrivalOverlap?.first, 'terror');
assert.equal(game.pendingTerrorEntry?.token, sneak.id);
game = act(game, 'moritani', { type: 'decision', reveal: true });
assert.equal(game.pendingTerrorEntry?.stage, 'sneakAttack');
game = act(game, 'moritani', { type: 'decision', amount: 1 });
assert.equal(player(game, 'moritani').reserves, reserves - 1);
assert.equal(player(game, 'moritani').forces[`${f.destination}:${f.sector}`], 1);
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
const original = game.pendingAmbassador; assert.ok(original);
assert.equal(original.entrant, f.entrant);
game = act(game, 'ecaz', { type: 'decision', event: original.event, decline: true });
assert.equal(game.pendingArrivalOverlap, null);
// Sneak is its own off-planet arrival: its BG opportunity precedes any
// additional Ambassador created by that same committed group.
assert.equal(game.decision?.kind, 'advisor');
assert.equal(game.decision?.player, 'beneGesserit');
game = act(game, 'beneGesserit', { type: 'decision', accept: false });
const later = game.pendingAmbassador; assert.ok(later, 'The same retained Ambassador also responds to the Sneak group');
assert.equal(later.token, f.fremen);
assert.equal(later.entrant, 'moritani');
assert.notEqual(later.event, original.event);
game = act(game, 'ecaz', { type: 'decision', event: later.event, decline: true });
assert.equal(game.pendingAmbassador, null);
assert.equal(game.pendingTerrorEntry, null);
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(game.pendingArrivalReaction ?? null, null);
assert.equal(game.ecazAmbassadors?.tokens.find(token => token.id === f.fremen)?.zone, 'placed');
assert.equal(player(game, f.entrant).spice, originalSpice - 1);
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
assert.equal(player(game, 'moritani').forces[`${f.destination}:${f.sector}`], 1);
});
void test('a direct Sneak arrival receives BG timing and triggers an eligible Ambassador', () => {
const f = createFremenOverlapFixture('terror', false, true);
const source = structuredClone(f.game);
const inventory = source.ecazAmbassadors!;
const old = inventory.tokens.find(token => token.id === f.fremen);
const guild = inventory.tokens.find(token => token.effect === 'guild');
assert.ok(old && guild && old.zone === 'placed');
// Controlled custody-preserving token selection: use the original Guild
// Ambassador in the same cohort and location. The original Guild entrant
// cannot trigger its own faction's token, while the later Moritani entrant can.
old.zone = 'supply'; old.location = null;
if (guild.zone === 'pool') {
const index = inventory.cohort.indexOf(old.id); assert.ok(index >= 0);
inventory.cohort[index] = guild.id;
old.zone = 'pool'; guild.zone = 'supply';
}
assert.equal(guild.zone, 'supply');
guild.zone = 'placed'; guild.location = f.destination;
const robbery = source.moritaniTerror!.tokens.find(token => token.id === f.terror);
const sneak = source.moritaniTerror!.tokens.find(token => token.kind === 'sneakAttack');
assert.ok(robbery && sneak && sneak.status === 'available');
robbery.status = 'available'; robbery.location = null;
sneak.status = 'placed'; sneak.location = f.destination;
const originalSpice = player(source, f.entrant).spice;
const reserves = player(source, 'moritani').reserves;
let game = act(source, f.entrant, f.shipment);
if (game.decision?.kind === 'advisor')
game = act(game, 'beneGesserit', { type: 'decision', accept: false });
assert.ok(!game.pendingArrivalOverlap, 'The Guild entrant does not trigger the Guild Ambassador');
assert.equal(game.pendingTerrorEntry?.token, sneak.id);
game = act(game, 'moritani', { type: 'decision', reveal: true });
assert.equal(game.pendingTerrorEntry?.stage, 'sneakAttack');
game = act(game, 'moritani', { type: 'decision', amount: 1 });
assert.equal(player(game, 'moritani').reserves, reserves - 1);
assert.equal(game.decision?.kind, 'advisor');
assert.equal(game.decision?.player, 'beneGesserit');
game = act(game, 'beneGesserit', { type: 'decision', accept: false });
const later = game.pendingAmbassador; assert.ok(later);
assert.equal(later.token, guild.id);
assert.equal(later.entrant, 'moritani');
game = act(game, 'ecaz', { type: 'decision', event: later.event, decline: true });
assert.equal(game.pendingAmbassador, null);
assert.equal(game.pendingTerrorEntry, null);
assert.equal(game.pendingArrivalOverlap ?? null, null);
assert.equal(game.pendingArrivalReaction ?? null, null);
assert.equal(player(game, f.entrant).spice, originalSpice - 1);
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
assert.equal(player(game, 'moritani').forces[`${f.destination}:${f.sector}`], 1);
});