Enable advanced ambassador child arrivals
Verify and publish checkpoint container / container (push) Successful in 10m16s

This commit is contained in:
Quintine committed 2026-10-29 19:42:27 +11:00
1 parent 22b463a395
commit 8a39f5a172
9 files changed
+288 -17

No files matched your search

@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { reserveShipmentCost } from '../game/shipment-price';
import { guildAmbassadorShipments } from '../game/guild-ambassador';
import { nextBasicSemutaAmbassadorStep } from './fixture-basic-semuta-ambassador';
import { act, player, advancedOverlapChildFixture, type ChildEffect, type First } from './fixture-advanced-overlap-child';
function continueTo(game: Game, until: (state: Game) => boolean): Game {
for (let n = 0; n < 100 && !until(game); n++) {
if (game.decision?.kind === 'advisor') {
game = act(game, game.decision.player, { type:'decision', accept:false });
continue;
}
if (game.decision?.kind === 'guildShipment') {
game = act(game, game.decision.player, { type:'decision', allow:true });
continue;
}
const next = nextBasicSemutaAmbassadorStep(game);
assert.ok(next, `Unanticipated original continuation ${game.decision?.kind ?? game.response?.kind}`);
game = act(game, next.actor, next.action);
}
assert.ok(until(game), 'The native source reached the intended reaction');
return game;
}
function originalEntry(effect: ChildEffect, first: First, withBG = false) {
const f = advancedOverlapChildFixture(effect, first, withBG);
const originalSpice = player(f.game, f.entrant).spice;
const shipper = player(f.game, f.entrant);
const price = reserveShipmentCost({ faction:shipper.faction, halfRate:shipper.faction === 'guild' }, 'stronghold', 1);
let game = act(f.game, f.entrant, f.shipment);
game = continueTo(game, state => !!state.pendingArrivalOverlap);
assert.equal(player(game, f.entrant).spice, originalSpice - price);
assert.equal(game.pendingArrivalOverlap?.first, first);
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.token);
assert.equal(viewGame(game, f.owner).ambassadorEntry?.beneficiaries[0].blocked, null);
const parentEvent = game.pendingArrivalOverlap!.event;
game = act(game, f.owner, { type:'decision', event:entry.event, trigger:true, beneficiary:f.owner });
assert.equal(game.pendingAmbassador?.stage, effect === 'fremen' ? 'move' : 'ship');
return { f, game, parentEvent, entryEvent:entry.event, originalSpice, price };
}
for (const effect of ['fremen','guild'] as const)
for (const first of ['ambassador','terror'] as const)
for (const reveal of [false,true])
void test(`Advanced ${effect} child ${first} first and ${reveal ? 'revealed' : 'declined'} Terror`, () => {
const start = originalEntry(effect, first), f = start.f;
let game = start.game;
const before = player(game, f.owner);
const reserves = before.reserves, spice = before.spice;
if (effect === 'fremen') {
game = act(game, f.owner, { type:'decision', event:start.entryEvent,
forces:{ [f.moveFrom]:1 }, territory:f.destination, sector:f.sector });
} else {
assert.ok(guildAmbassadorShipments(game, f.owner).destinations.some(option =>
option.territory === f.destination && option.sector === f.sector));
game = act(game, f.owner, { type:'decision', event:start.entryEvent,
amount:1, elite:0, territory:f.destination, sector:f.sector });
}
game = continueTo(game, state => state.pendingTerrorEntry?.cause === 'ambassador');
assert.equal(game.pendingTerrorEntry?.entrant, f.owner);
assert.equal(game.pendingTerrorEntry?.token, f.terror);
assert.equal(game.pendingArrivalOverlap?.event, start.parentEvent);
assert.equal(player(game, f.owner).reserves, reserves - (effect === 'guild' ? 1 : 0));
assert.equal(player(game, f.owner).spice, spice);
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(game.moritaniTerror!.tokens.find(token => token.id === f.terror)?.status, 'removed');
} else game = act(game, f.moritani, { type:'decision', decline:true });
if (first === 'ambassador' && !reveal) {
assert.equal(game.pendingTerrorEntry?.entrant, f.entrant);
assert.equal(game.pendingTerrorEntry?.token, f.terror);
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(player(game, f.entrant).spice, start.originalSpice - start.price);
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
assert.ok((player(game, f.owner).forces[`${f.destination}:${f.sector}`] ?? 0) >= 1);
});
void test('Advanced child Sneak and Bene Gesserit accompaniment wait for the original Terror', () => {
const start = originalEntry('fremen', 'ambassador', true), f = start.f;
assert.ok(f.alternate && f.sneak);
const alternate = f.alternate;
let game = act(start.game, f.owner, { type:'decision', event:start.entryEvent,
forces:{ [alternate.sector.key]:1 }, territory:alternate.destination.territory, sector:alternate.destination.sector });
game = continueTo(game, state => state.pendingTerrorEntry?.token === f.sneak);
const reserves = player(game, f.moritani).reserves;
game = act(game, f.moritani, { type:'decision', reveal:true });
assert.equal(game.pendingTerrorEntry?.stage, 'sneakAttack');
game = act(game, f.moritani, { type:'decision', amount:1 });
assert.equal(player(game, f.moritani).reserves, reserves - 1);
assert.equal(game.pendingTerrorEntry?.token, f.terror);
assert.equal(game.pendingTerrorEntry?.entrant, f.entrant);
assert.equal(game.decision?.kind, 'moritaniTerror');
game = act(game, f.moritani, { type:'decision', decline:true });
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.pendingAmbassador, null);
assert.equal(game.pendingTerrorEntry, null);
assert.equal(player(game, f.moritani).forces[`${alternate.destination.territory}:${alternate.destination.sector}`], 1);
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
});
+92
View File
@@ -0,0 +1,92 @@
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 { guildAmbassadorShipments } from '../game/guild-ambassador';
import { advanceBasicSemutaAmbassador as advance, actBasicSemutaAmbassador as act,
basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador';
export { act, player };
export type ChildEffect = 'fremen' | 'guild';
export type First = 'ambassador' | 'terror';
/** Public Advanced native setup and an actual paid faction shipment. The chosen
* original Ambassador cohort member and the single original Terror marker are
* controlled, physical placements at the clean turn boundary. No player force,
* purse, order, pending decision or receipt is staged. */
export function advancedOverlapChildFixture(effect: ChildEffect, first: First, withBG = false) {
let game = createGame('ADVOVERLAPCHILD', newPlayer('ecaz','Ecaz','ecaz'), true, ['ecaz']);
for (const faction of ['atreides','emperor',...(withBG ? ['beneGesserit'] as const : []),
...(withBG ? [] : ['guild'] as const),'moritani'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const send = (actor: string, action: Action) => { game = act(game, actor, action); };
const reach = (until: (state: Game) => boolean) => { game = advance(game, until); };
send(withBG ? 'ecaz' : 'guild', { type:'seatPosition', position:6 });
if (withBG) {
send('atreides', { type:'seatPosition', position:1 });
send('emperor', { type:'seatPosition', position:2 });
send('beneGesserit', { type:'seatPosition', position:3 });
}
if (first === 'terror') send('moritani', { type:'seatPosition', position:4 });
send('ecaz', { type:'seatPosition', position:first === 'ambassador' ? 4 : 5 });
if (!withBG) {
send('atreides', { type:'seatPosition', position:1 });
send('emperor', { type:'seatPosition', position:2 });
}
if (withBG) joinGame(game, newPlayer('guild','guild','guild'));
for (const seat of game.players) send(seat.id, { type:'ready' });
send('ecaz', { type:'start', advancedPreview:true });
reach(state => state.status === 'playing');
assert.ok(game.advanced && !game.homeworlds && !game.nexusCards && !game.leaderSkills &&
!game.techTokens && !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
// Select existing unopened ordinary Spice cards, never an invented draw.
for (const position of [0,1]) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
assert.ok(index >= position);
[game.spiceDeck[position], game.spiceDeck[index]] = [game.spiceDeck[index], game.spiceDeck[position]];
}
const entrant = effect === 'guild' ? 'emperor' : 'guild';
reach(state => state.turn === 1 && state.phase === 5 && state.active === entrant && clean(state));
assert.equal(game.order.indexOf('ecaz') < game.order.indexOf('moritani'), first === 'ambassador');
const candidates = fremenAmbassadorMovement(game, 'ecaz').sources.flatMap(source =>
source.sectors.filter(sector => sector.forces > 0).flatMap(sector =>
source.destinations.filter(destination => destination.territory !== source.territory &&
['arrakeen','carthag','sietch_tabr','habbanya_ridge_sietch','tueks_sietch'].includes(destination.territory))
.map(destination => ({ source, sector, destination }))))
.filter(option => guildAmbassadorShipments(game, 'ecaz').destinations.some(destination =>
destination.territory === option.destination.territory && destination.sector === option.destination.sector));
const candidate = candidates[0];
assert.ok(candidate, 'Ecaz needs a reachable physical stronghold and legal reserve destination');
const destination = candidate.destination.territory, sector = candidate.destination.sector;
const ambassadors = game.ecazAmbassadors!;
const token = ambassadors.tokens.find(item => item.effect === effect); assert.ok(token);
if (token.zone === 'pool') {
const replacement = ambassadors.tokens.find(item => item.zone === 'supply' && item.effect !== 'ecaz');
assert.ok(replacement);
const index = ambassadors.cohort.indexOf(replacement.id); assert.ok(index >= 0);
ambassadors.cohort[index] = token.id; replacement.zone = 'pool'; token.zone = 'supply';
}
assert.equal(token.zone, 'supply');
validateAmbassadors(ambassadors);
game.ecazAmbassadors = placeAmbassador(ambassadors, token.id, { turn:game.turn,
availableSpice:player(game,'ecaz').spice,
destination:{id:destination,stronghold:true,inStorm:false,allowed:true} }).state;
const terror = game.moritaniTerror!.tokens.find(item => item.kind === 'robbery' && item.status === 'available');
assert.ok(terror);
game.moritaniTerror = placeTerror(game.moritaniTerror!, terror.id, destination, game.turn);
const alternate = candidates.find(item => item.destination.territory !== destination);
let sneak: string | undefined;
if (withBG) {
assert.ok(alternate, 'The Sneak fixture requires a second reachable stronghold');
const child = game.moritaniTerror.tokens.find(item => item.kind === 'sneakAttack' && item.status === 'available');
assert.ok(child);
// A second existing marker is staged without adding a force or resource.
child.status = 'placed'; child.location = alternate.destination.territory; sneak = child.id;
}
const shipment: Action = { type:'ship', territory:destination, sector, amount:1, elite:0, allyPayment:0 };
return { game, effect, first, destination, sector, token:token.id, terror:terror.id, shipment,
moveFrom:candidate.sector.key, alternate, sneak,
entrant, owner:'ecaz', moritani:'moritani' };
}
+3 -8
View File
@@ -6,7 +6,7 @@ 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
* It isolates Advanced overlap availability, 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']);
@@ -31,16 +31,11 @@ function advancedTable(effect: 'fremen' | 'guild'): Game {
}
for (const effect of ['fremen','guild'] as const) {
void test(`Advanced ${effect} Ambassador child stays gated during overlap`,()=>{
void test(`Advanced ${effect} Ambassador child is offered during a native 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);
assert.equal(viewGame(game,'ec').ambassadorEntry?.beneficiaries[0].blocked, null);
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});
@@ -7,6 +7,10 @@ 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';
import { advancedOverlapChildFixture } from './fixture-advanced-overlap-child';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { nextBasicSemutaAmbassadorStep } from './fixture-basic-semuta-ambassador';
const aliases = registerHooks({
resolve(specifier, context, next) {
@@ -64,3 +68,28 @@ void test('Guild overlap exposes the enabled real Ambassador trigger and shipmen
assert.match(selectedHtml, /aria-label="Ambassador reserve shipment"/);
assert.match(selectedHtml, /Ship selected forces for free/);
});
for (const effect of ['fremen', 'guild'] as const) void test(`Advanced ${effect} overlap exposes controls and legal bot choices`, () => {
const f = advancedOverlapChildFixture(effect, 'ambassador');
let offered = act(f.game, f.entrant, f.shipment);
for (let i = 0; i < 30 && !offered.pendingAmbassador; i++) {
const next = nextBasicSemutaAmbassadorStep(offered);
assert.ok(next, 'The original shipment has a legal continuation');
offered = act(offered, next.actor, next.action);
}
const view = viewGame(offered, 'ecaz');
assert.equal(view.ambassadorEntry?.beneficiaries[0].blocked, null);
const html = htmlFor(view);
assert.doesNotMatch(triggerButton(html), /\bdisabled(?:=|\s|>)/);
for (const difficulty of DIFFICULTIES) {
const botView = viewGame(offered, 'ecaz');
botView.players.find(player => player.id === 'ecaz')!.bot = difficulty;
const action = botActions(botView)[0];
assert.ok(action, difficulty);
assert.doesNotThrow(() => act(offered, 'ecaz', action), difficulty);
}
const entry = offered.pendingAmbassador; assert.ok(entry);
const selected = act(offered, 'ecaz', { type:'decision', event:entry.event, trigger:true, beneficiary:'ecaz' });
const selectedHtml = htmlFor(viewGame(selected, 'ecaz'));
assert.match(selectedHtml, effect === 'fremen' ? /aria-label="Ambassador force relocation"/ : /aria-label="Ambassador reserve shipment"/);
});