From 8a39f5a17291815b309bc211cdacccaaf9910d96 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:42:27 +1100 Subject: [PATCH] Enable advanced ambassador child arrivals --- docs/CURRENT_STATUS.md | 11 ++ docs/MORITANI_ENTRY_TIMING.md | 29 ++++- docs/RULE_DECISIONS.md | 3 +- game/engine.ts | 9 +- game/reference.ts | 6 +- tests/advanced-overlap-child-runtime.test.ts | 115 +++++++++++++++++++ tests/fixture-advanced-overlap-child.ts | 92 +++++++++++++++ tests/overlap-child-mode-gate.test.ts | 11 +- tests/overlap-fremen-guild-controls.test.tsx | 29 +++++ 9 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 tests/advanced-overlap-child-runtime.test.ts create mode 100644 tests/fixture-advanced-overlap-child.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 843dfd31..40b28b2b 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**Advanced Ambassador child arrivals in the Ecaz–Moritani overlap — 29 +October. Prototyped / Partial.** Bare native Advanced games can use the Fremen +relocation and Guild shipment within the paired entry, retaining the child +Terror reaction and the original entry's remaining opportunity. Sneak Attack +queues its own Bene Gesserit reactions without overwriting that opportunity. +Eight related files pass 52 cases, including actual Advanced setup, both owner +orders, controls and minimal bot actions; token selection and placements are +controlled in the fixtures. Optional-module combinations, other nested cases, +complete modes and deployed verification remain open. +[Scope](MORITANI_ENTRY_TIMING.md#advanced-native-ambassador-child-arrivals--29-october-2026). + **Early legacy Duke proof and occupied Ecaz displacement — 29 October. Prototyped / Partial.** An unstamped game can establish a clear capture history from public first-turn, pre-battle phase and continuation state; an explicit diff --git a/docs/MORITANI_ENTRY_TIMING.md b/docs/MORITANI_ENTRY_TIMING.md index 3fbdc71c..ce7a34e4 100644 --- a/docs/MORITANI_ENTRY_TIMING.md +++ b/docs/MORITANI_ENTRY_TIMING.md @@ -2,6 +2,31 @@ Reviewed 2026-09-06. This supplements `MORITANI_TERROR_RULES.md`; it does not enable combined-faction play or certify an implementation order. +## Advanced native Ambassador child arrivals — 29 October 2026 + +**Prototyped / Partial.** In the bare Advanced native-faction profile, the +Fremen Ambassador relocation and Guild Ambassador shipment can now create a +child arrival while the original paired Ambassador/Terror entry remains in +progress. The existing child-first continuation preserves the original entry, +remaining opportunity and physical movement or payment. A positive Sneak +Attack in this overlap queues its own reactions until the original decisions +finish, including its Bene Gesserit opportunity. The owners' storm order and +child ordering remain the provisional software interpretations below, not an +official priority ruling; the printed Ambassador effects and interruption are +described in GF9 Ecaz & Moritani rules pp. 8 and 15. + +Fremen destinations combining Advanced fighter Intrusion with Terror retain +their existing restriction, and simultaneous Ambassador/Terror on Guild +accompaniment remains separate. Other optional modules, Advanced Sneak outside +this overlap, complete rules and deployed verification remain open. + +Eight related files pass 52 cases. Real Advanced setup covers both owner orders, +Fremen and Guild child effects, child Terror decline or reveal, the paid original +entry, the free Guild grant, controls and legal existing bot choices. A separate +Advanced Sneak/Bene Gesserit case verifies that the original Terror decision +finishes before accompaniment. Fixtures control original token selection and +placement; the separate child Sneak token is staged. + ## Advanced mixed-family native arrivals — 29 October 2026 The existing paired-entry queue now also accepts Advanced games with Ecaz, @@ -10,8 +35,8 @@ separate preview overlays are active. CHOAM can first prevent a movement into a territory containing both tokens; if the move proceeds, the original movement commits once and both eligible owners receive their opportunities in the provisional storm order described below. Preventing the move leaves its -forces and both tokens in place. Fremen and Guild Ambassador effects that -create another arrival remain guarded in this Advanced overlap; further +forces and both tokens in place. The later native child-arrival section above +extends Fremen and Guild effects within its bounded profile; further module combinations and full rules acceptance remain open. ## Basic mixed-family independent cards — 28 October 2026 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 31ae70b4..7ee5b03f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -130,7 +130,8 @@ guarded. [Scope](RICHESE_NO_FIELD_RULES.md#basic-additional-response-windows--29 Advanced native-faction tables can use the existing paired Ecaz Ambassador and Moritani Terror entry queue after CHOAM's movement-prevention opportunity. The owners' order remains a provisional digital interpretation, not a -publisher ruling; nested Fremen and Guild Ambassador arrivals remain guarded. +publisher ruling; the bounded native Fremen and Guild child arrivals now follow +the separate [child-arrival contract](MORITANI_ENTRY_TIMING.md#advanced-native-ambassador-child-arrivals--29-october-2026). [Scope and timing](MORITANI_ENTRY_TIMING.md#advanced-mixed-family-native-arrivals--29-october-2026). ## Public Semuta with Homeworlds — 29 October 2026 diff --git a/game/engine.ts b/game/engine.ts index c5ab4d95..349d3233 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -25068,8 +25068,8 @@ function ambassadorEffectBlock( entrant: Player, ): string | null { if (g.pendingArrivalOverlap && (effect === 'fremen' || effect === 'guild') && - !bareBasicNativeArrivalOverlap(g)) - return 'This overlapping Ambassador-created arrival is available in the supported Basic faction and independent-card previews.'; + !bareBasicNativeArrivalOverlap(g) && !bareAdvancedNativeArrivalOverlap(g)) + return 'This overlapping Ambassador-created arrival is available in supported native faction previews.'; if (!implementedAmbassadorEffects.includes(effect)) return 'This Ambassador effect is still being implemented.'; if (effect === 'ecaz') { @@ -28168,6 +28168,7 @@ function decideTerror(g: Game, p: Player, action: Action) { const revivalArrival: RevivalEntryParent | undefined = amount > 0 && entry.revivalArrival ? {event:entry.revivalArrival.event,terror:{event:entry.entrySignature!,owner:p.id, territory:entry.territory,sector:entry.sector,amount,elite:0}} : undefined; + const advancedNativeOverlap = g.advanced && bareAdvancedNativeArrivalOverlap(g) && !!g.pendingArrivalOverlap; if (caladan) (g.homeworldVictoryReinforcement!.terrorArrivals ??= []).push(caladan.receipt); finishTerrorEntry(g); if (caladan) { @@ -28184,8 +28185,8 @@ function decideTerror(g: Game, p: Player, action: Action) { phase:g.phase,revivalArrival,revivalSneakAdvisor:true}); return; } - if (amount > 0 && !g.advanced && (g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingTerrorEntry || - g.response || g.decision)) { + if (amount > 0 && (advancedNativeOverlap || !g.advanced && + (g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingTerrorEntry || g.response || g.decision))) { // The child may have opened the original Terror decision. Let it finish // before offering advisor reactions for this already-committed group. (g.pendingArrivalReaction ??= []).push({entrant:p.id,territory:entry.territory, diff --git a/game/reference.ts b/game/reference.ts index b5b22584..c8dcdb9d 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1730,7 +1730,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In Advanced games with Harkonnen, a public capture that could have involved Duke makes the direct grant and automatic loan unavailable, regardless of which leader was secretly captured. A public event establishing the actual Duke, such as a revealed ordinary Duke battle, named revival or logged occupied-Ecaz displacement of an ordinary grant, clears this uncertainty; a later possible capture can restore it. An older game without the record can also establish clear history through public first-turn pre-battle state; later or ambiguous history remains guarded until a supported resolution. This provisional restriction protects the hidden captive identity and may prevent an otherwise legal grant.', 'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave it in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Ordinary shipment and native Guild transport settle their own income, BG Intrusion and accompaniment controls before queued entry, without replaying payment or forces. Actual busy controls and unsupported paired or module combinations remain guarded.', 'In supported Basic games with Ecaz and Moritani, a single entry that earns both an Ambassador and Terror opportunity retains both in their existing provisional storm order, including selected native factions and the independent Ecaz cards. A Fremen relocation or Guild shipment from the Ambassador resolves its own child arrival before the original queue continues. If that child reveals the same Terror token, it cannot trigger again for the original entrant; if it is left hidden, the original opportunity remains. Unsupported module interactions remain guarded.', - 'Supported Advanced games with both Ecaz and Moritani and other native faction decks also offer both entry reactions in provisional storm order. CHOAM may prevent an eligible move first; otherwise the move happens once before the token decisions. Fremen and Guild Ambassador effects that create another arrival remain unavailable in this Advanced overlap.', + 'Supported Advanced games with both Ecaz and Moritani and other native faction decks also offer both entry reactions in provisional storm order. CHOAM may prevent an eligible move first; otherwise the move happens once before the token decisions. In the bare native profile, Fremen relocation and Guild shipment may create child arrivals before the original entry resumes. More deeply nested accompaniment and optional modules remain limited.', 'Accompaniment joining retained BG fighters creates a distinct eligible child entry before the original shipper. Declining the child leaves the live token for the original entry; consuming it does not create a token to offer again. Advisors remain exempt. This existing child-first, live-token composition is explicitly provisional, not a publisher priority ruling.', 'Emperor grants five bank spice. Atreides records the entrant’s current Treachery hand privately for the beneficiary; Harkonnen records one randomly selected held Traitor identity, or Face Dancer identity for a Tleilaxu entrant. These snapshots remain in the recipient’s private inspection history, identify when they were taken, and do not become a live view of later changes. Opening, enlarging or closing them needs no game acknowledgement.', 'CHOAM lets the beneficiary choose any available hand cards, including none, for three spice each. Ixians require one available card to discard before drawing its replacement. Card controls respect projected reservations. After committing an Ixian exchange, a voluntary interruption must leave a card available for its required discard; a queued Truthtrance counts as already committed. A Bene Gesserit trigger permanently removes its token, then the beneficiary chooses an available effect outside the original supply for this group; unsupported copies remain visibly blocked.', @@ -1776,8 +1776,10 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - 'Placed-HMS proof passes12 files135/135, types/lint/build, twelve actual placement-control/gameplay/GameTable SSR journeys and eight minimal policies. Actual46/50-action Basic/Advanced sources cover paid placement, entry/Emperor5/decline, held Karama, storm shelter and native relocation; Basic CHOAM/Ix retain original card continuations. Browser/deployed and complete-mode acceptance remain open. Earlier Basic Guild-marker proof passes13 files129/129, types/lint/build, fifteen gameplay/control/GameTable SSR journeys and eight minimal-policy continuations. Genuine53/71/74-action sources plus two explicitly conserved shortage arrangements cover native0/3/5, prior reveal, consent, Guild/Bank fee, prevention/refusal and physical fallback. Earlier repeat13-file114/114, types/lint/build and three controlled gameplay/rendering programmes pass: original Basic paid-entry sources plus assigned native histories reach the next actual ordinary Revival; separate Advanced actual entry returns KH with old2/cohort3 → marker4. Twelve legal policies finish original entry. First-opening, income allow/cancel and ordinary controls pass. Controlled histories are not earned repeat-battle/full-game proof. Earlier original Duke/income evidence stays separate; full modes, wider combinations, assurance and deployed acceptance remain open.', + 'Advanced native Fremen/Guild child arrivals pass eight related files and 52 cases, including actual setup, both owner orders, child Terror, controls and legal bot actions; physical token selection and placement are controlled. Placed-HMS proof passes12 files135/135, types/lint/build, twelve actual placement-control/gameplay/GameTable SSR journeys and eight minimal policies. Actual46/50-action Basic/Advanced sources cover paid placement, entry/Emperor5/decline, held Karama, storm shelter and native relocation; Basic CHOAM/Ix retain original card continuations. Browser/deployed and complete-mode acceptance remain open. Earlier Basic Guild-marker proof passes13 files129/129, types/lint/build, fifteen gameplay/control/GameTable SSR journeys and eight minimal-policy continuations. Genuine53/71/74-action sources plus two explicitly conserved shortage arrangements cover native0/3/5, prior reveal, consent, Guild/Bank fee, prevention/refusal and physical fallback. Earlier repeat13-file114/114, types/lint/build and three controlled gameplay/rendering programmes pass: original Basic paid-entry sources plus assigned native histories reach the next actual ordinary Revival; separate Advanced actual entry returns KH with old2/cohort3 → marker4. Twelve legal policies finish original entry. First-opening, income allow/cancel and ordinary controls pass. Controlled histories are not earned repeat-battle/full-game proof. Earlier original Duke/income evidence stays separate; full modes, wider combinations, assurance and deployed acceptance remain open.', evidence: [ + 'tests/advanced-overlap-child-runtime.test.ts', + 'tests/overlap-fremen-guild-controls.test.tsx', 'tests/fixture-ecaz-hms-ambassador.ts', 'tests/ecaz-hms-ambassador-engine.test.ts', 'tests/ecaz-hms-ambassador-controls.test.tsx', diff --git a/tests/advanced-overlap-child-runtime.test.ts b/tests/advanced-overlap-child-runtime.test.ts new file mode 100644 index 00000000..8f135f6e --- /dev/null +++ b/tests/advanced-overlap-child-runtime.test.ts @@ -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); +}); diff --git a/tests/fixture-advanced-overlap-child.ts b/tests/fixture-advanced-overlap-child.ts new file mode 100644 index 00000000..ea7d8f16 --- /dev/null +++ b/tests/fixture-advanced-overlap-child.ts @@ -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' }; +} diff --git a/tests/overlap-child-mode-gate.test.ts b/tests/overlap-child-mode-gate.test.ts index 2e596ee7..a3f774b5 100644 --- a/tests/overlap-child-mode-gate.test.ts +++ b/tests/overlap-child-mode-gate.test.ts @@ -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}); diff --git a/tests/overlap-fremen-guild-controls.test.tsx b/tests/overlap-fremen-guild-controls.test.tsx index 6823b1d0..a3f1b402 100644 --- a/tests/overlap-fremen-guild-controls.test.tsx +++ b/tests/overlap-fremen-guild-controls.test.tsx @@ -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"/); +});