Enable advanced ambassador child arrivals
Verify and publish checkpoint container / container (push) Successful in 10m16s
Verify and publish checkpoint container / container (push) Successful in 10m16s
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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"/);
|
||||
});
|
||||
Reference in new issue
Block a user