From fbdf11c789e2d70baa6d4dcac0bd6e8607c06c38 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:40:41 +1100 Subject: [PATCH] Resolve Basic Ambassador child arrivals during Terror --- docs/CURRENT_STATUS.md | 16 +- docs/DEFERRED_SHIPMENT_ARRIVAL.md | 45 ++++- docs/IMPLEMENTATION_STATUS.md | 13 ++ docs/RULE_DECISIONS.md | 9 + game/engine.ts | 63 +++++-- game/reference.ts | 2 +- tests/fixture-guild-overlap-child.ts | 45 +++++ tests/fixture-overlap-fremen-native.ts | 93 +++++++++++ tests/fixture-semuta-overlap-child.ts | 59 +++++++ tests/guild-overlap-child-runtime.test.ts | 61 +++++++ tests/overlap-child-mode-gate.test.ts | 49 ++++++ tests/overlap-fremen-guild-controls.test.tsx | 66 ++++++++ tests/overlap-fremen-native-runtime.test.ts | 163 +++++++++++++++++++ tests/semuta-overlap-child-runtime.test.ts | 47 ++++++ tests/sneak-overlap-child-ambassador.test.ts | 102 ++++++++++++ 15 files changed, 817 insertions(+), 16 deletions(-) create mode 100644 tests/fixture-guild-overlap-child.ts create mode 100644 tests/fixture-overlap-fremen-native.ts create mode 100644 tests/fixture-semuta-overlap-child.ts create mode 100644 tests/guild-overlap-child-runtime.test.ts create mode 100644 tests/overlap-child-mode-gate.test.ts create mode 100644 tests/overlap-fremen-guild-controls.test.tsx create mode 100644 tests/overlap-fremen-native-runtime.test.ts create mode 100644 tests/semuta-overlap-child-runtime.test.ts create mode 100644 tests/sneak-overlap-child-ambassador.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index caaf6d20..98b6dfe3 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,12 +20,24 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Fremen and Guild Ambassador child arrivals — 27 October. Prototyped / Partial.** +In supported bare Basic games, an Ambassador that shares an original entry with Terror may now relocate or +ship its beneficiary's forces. The child arrival resolves first; a Terror token +it consumes cannot trigger twice, while a hidden token retains the original +opportunity. A child Sneak Attack waits to offer its advisor response until the +original queue finishes. Richese may claim or pass a child Sabotage discard. +Parent/child priority is provisional; wider module and deployed acceptance +remain open. +[Scope](DEFERRED_SHIPMENT_ARRIVAL.md#ambassador-child-arrivals-during-overlap--27-october-2026). +The frozen batch passed 108/108 tests in 14 files, type/lint and the production +build. Browser and deployed acceptance remain open. + **Basic native Ambassador/Terror arrivals — 27 October. Prototyped / Partial.** Bare Basic games with selected native faction decks can carry one eligible entry through both Ecaz Ambassador and Moritani Terror opportunities in the existing provisional storm order. Their original payment, entry and continuation happen -once. Nested Fremen/Guild Ambassador arrivals and optional-module boundaries -remain separate. Player reference text also corrects outdated claims that +once. The newer child-arrival follow-up above extends Fremen/Guild effects; +optional-module boundaries remain separate. Player reference text also corrects outdated claims that supported faction previews cannot start. [Scope](DEFERRED_SHIPMENT_ARRIVAL.md#basic-native-faction-overlap--27-october-2026). The frozen batch passed 76/76 tests in nine files, type/lint and the production diff --git a/docs/DEFERRED_SHIPMENT_ARRIVAL.md b/docs/DEFERRED_SHIPMENT_ARRIVAL.md index bf0e6dd6..7d8cf716 100644 --- a/docs/DEFERRED_SHIPMENT_ARRIVAL.md +++ b/docs/DEFERRED_SHIPMENT_ARRIVAL.md @@ -4,6 +4,44 @@ exposed an already accepted shipment whose Guild allowance could not finish. This continuation repair preserves the existing unfinished arrival-order gates. +## Ambassador child arrivals during overlap — 27 October 2026 + +**Prototyped / Partial.** When an Ecaz Ambassador and a Moritani Terror token both +respond to the same eligible Basic arrival, the existing overlap preserves the +two owners' opportunities in provisional storm order. The extension +allows the Fremen Ambassador's relocation and the Guild Ambassador's free +shipment to create their own physical child arrival while the original overlap +waits. The chosen group, destination and ordinary force restrictions remain +authoritative; the original entry and any payment are not replayed. + +The child resolves its own eligible Terror opportunity before the suspended +Ambassador returns to the original overlap. If the same Terror token is +declined on the child, it remains available to an as-yet-unresolved original +entry. If the child reveals and consumes that token, the original entry cannot +offer it again. A distinct child token and the original token retain separate +opportunities; if the original Terror was already resolved first, the child +only encounters tokens still present. This live-token and child-first sequence +is an explicit online composition, not a publisher ruling on simultaneous +priority. + +Sneak Attack remains a real child effect: its physical forces and any resulting +Bene Gesserit accompaniment are preserved, with the follow-up waiting until the +original overlap's outstanding reaction finishes. The Sneak group can then +trigger an Ambassador that remains available, including after an original +entrant declined it. A child Sabotage discard offers the existing Semuta +response before the same Ambassador continuation. +Existing advisor eligibility, +unrelated busy controls, other arrival effects and optional-module boundaries +remain separate. Complete faction rules, broader interaction, recovery, browser +and deployed acceptance remain open. +Frozen changed-path proof passed 108/108 tests in 14 files, type/lint and the +production build. [Fremen](../tests/overlap-fremen-native-runtime.test.ts), +[Guild](../tests/guild-overlap-child-runtime.test.ts), [Sneak](../tests/sneak-overlap-child-ambassador.test.ts) +and [Semuta](../tests/semuta-overlap-child-runtime.test.ts) use real setup and +actions with explicitly controlled original token selections. Rendered +[controls](../tests/overlap-fremen-guild-controls.test.tsx) do not certify +browser or deployed behavior. + ## Basic native faction overlap — 27 October 2026 **Prototyped / Partial.** In bare Basic games with selected native faction @@ -12,9 +50,10 @@ Ambassador and Terror opportunities through the existing provisional storm order. The original move or shipment is committed and paid once; its two owners resolve their separately earned opportunities in sequence. The existing queue and controls apply; this is not a publisher ruling on simultaneous -effects. Actual in-flight controls remain authoritative, and nested Fremen or -Guild Ambassador arrivals and optional-module combinations keep their separate -boundaries. Complete factions, modes and deployed acceptance remain open. +effects. Actual in-flight controls remain authoritative. The child-arrival +follow-up above extends the earlier Fremen and Guild boundary; optional-module +combinations retain their separate boundaries. Complete factions, modes and +deployed acceptance remain open. The new [runtime fixture](../tests/fixture-basic-native-overlap.ts) uses public Basic setup and real shipments with explicitly controlled, native-token placements. [Regression cases](../tests/basic-native-overlap-runtime.test.ts) diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 8e9957ef..c59496f9 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,18 @@ # Dune implementation status +## 27 October 2026 — Ambassador child arrivals during overlap + +Fremen relocation and Guild shipment can resolve their native child arrival +while the original Ambassador/Terror pair remains in its provisional order. +The child uses the actual token, and the original later opportunity follows +the token's live state. A child Sneak Attack queues advisor reactions rather +than replacing the original Terror decision. Wider combinations, complete +modes and deployed acceptance remain open. +Frozen changed-path proof passed 108/108 tests in 14 files, type/lint and the +production build. Real setup and movement use explicitly controlled original +Ambassador/Terror placements; component checks render the projected controls. +[Contract](DEFERRED_SHIPMENT_ARRIVAL.md#ambassador-child-arrivals-during-overlap--27-october-2026). + ## 27 October 2026 — Basic native Ambassador/Terror arrivals The existing ordered same-entry queue admits bare Basic native faction rosters diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index df143272..a23edde6 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,14 @@ # Rule decision index +## Ambassador child arrivals during overlap — 27 October 2026 + +Provisionally finish a Fremen or Guild Ambassador-created child arrival before +continuing the original two-owner entry. A hidden Terror token remains eligible +for the original entrant; a token already consumed by the child is not replayed. +Queued child Sneak advisor reactions do not replace the original controls. +This is an implementation ordering, not a publisher priority ruling. +[Contract](DEFERRED_SHIPMENT_ARRIVAL.md#ambassador-child-arrivals-during-overlap--27-october-2026). + ## Basic native Ambassador/Terror overlap — 27 October 2026 The existing provisional storm-order queue also handles eligible same-entry diff --git a/game/engine.ts b/game/engine.ts index 86e3f966..4d93ae88 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -2156,6 +2156,8 @@ export type Game = { /** A committed revival Sneak group waits for the original overlap before BG reactions. */ revivalSneakAdvisor?: true; caladanSneakAdvisor?: true; + /** An ordinary Sneak group waits for an existing Ambassador/Terror parent. */ + nativeSneakAdvisor?: true; }[] | null; /** One committed entry with two independently optional expansion reactions. */ pendingArrivalOverlap?: { @@ -8849,6 +8851,10 @@ function semutaOfferSupported( const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' || continuation.kind === 'karamaBgConversionCancelDiscard'; const ambassadorDiscard = continuation.kind === 'ambassador'; + const childTerrorDiscard = continuation.kind === 'terrorDiscard' && + !!g.pendingArrivalOverlap && bareBasicSemutaProfile(g) && + g.pendingArrivalOverlap.ambassadorEvent === continuation.entry.ambassadorEvent && + ambassadorTerrorEntryMatches(g, continuation.entry); if (ambassadorDiscard && (!bareBasicSemutaProfile(g) || g.phase !== 5 || continuation.entry.stage !== 'cards' || continuation.entry.resume !== 'none' || @@ -8987,10 +8993,10 @@ function semutaOfferSupported( g.moritaniRetention.stage !== 'choose' || continuation.kind === 'battleResolved')) || g.pendingIxSubstitution)) return false; if (continuation.kind === 'terrorDiscard' && - (continuation.entry.resume !== 'none' || + (!childTerrorDiscard && (continuation.entry.resume !== 'none' || (continuation.entry.cause !== 'shipment' && continuation.entry.cause !== 'movement') || (g.pendingArrivalOverlap && (!bareBasicSemutaProfile(g) || - !overlapOwnsTerrorDiscard(g, continuation.entry, continuation.owner))) || + !overlapOwnsTerrorDiscard(g, continuation.entry, continuation.owner)))) || g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove || g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false; @@ -9178,7 +9184,7 @@ function semutaOfferSupported( !g.pendingArrivalOverlap && !g.pendingRevival && !g.ornithopter && !g.ecologicalStorm)) && (richeseGiftDiscard || ambassadorBenefitDiscard || (!g.pendingExchange && (replacementCostDiscard || !g.pendingIxAlly) && !g.pendingNullentropy && - (!g.pendingAmbassador || (advisorDiscard || terminalDiscard) && + (!g.pendingAmbassador || childTerrorDiscard || (advisorDiscard || terminalDiscard) && semutaAdvisorAmbassadorParent(g,continuation.use.response)) && (terrorAllianceDiscard || !g.pendingTerrorEntry))) && (richeseGiftDiscard || noFieldDiscard || continuation.kind === 'karamaShipmentDiscard' || @@ -22462,8 +22468,9 @@ function ambassadorEffectBlock( beneficiary: Player, entrant: Player, ): string | null { - if (g.pendingArrivalOverlap && (effect === 'fremen' || effect === 'guild')) - return 'Finish the overlapping Terror before another Ambassador-created arrival; decline this effect.'; + if (g.pendingArrivalOverlap && (effect === 'fremen' || effect === 'guild') && + !bareBasicNativeArrivalOverlap(g)) + return 'This overlapping Ambassador-created arrival is only available in the bare Basic faction preview.'; if (!implementedAmbassadorEffects.includes(effect)) return 'This Ambassador effect is still being implemented.'; if (effect === 'ecaz') { @@ -22489,13 +22496,15 @@ function ambassadorEffectBlock( return 'The entrant has no held Traitor Card to inspect.'; return null; } -function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParent): boolean { +function bareBasicNativeArrivalOverlap(g: Game): boolean { // Native Basic faction decks share the same ordered arrival queue. A deck // selection alone is not an optional phase module or a new arrival effect. - if (!g.advanced && selectedPhaseModuleRoster(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani') && + return !g.advanced && selectedPhaseModuleRoster(g) && !!byFaction(g, 'ecaz') && !!byFaction(g, 'moritani') && !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards && - !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.ecazTreachery) - return true; + !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.ecazTreachery; +} +function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParent): boolean { + if (bareBasicNativeArrivalOverlap(g)) return true; if (homeworldPhaseModulesProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true; if (revivalArrival && g.homeworlds && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true; if (bareBasicSemutaProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true; @@ -22555,7 +22564,7 @@ function arrivalOverlapIntegrity(g: Game) { record.terrorTokens.every(id => typeof id === 'string' && id.length > 0) && new Set(record.terrorTokens).size === record.terrorTokens.length && (active === 'ambassador' - ? !!ambassador && !terror && + ? !!ambassador && (!terror || ambassadorTerrorEntryMatches(g, terror)) && ambassador.event === record.ambassadorEvent && ambassador.owner === record.ecaz && ambassador.entrant === record.entrant && @@ -22760,6 +22769,16 @@ function finishAmbassador(g: Game, deferResume = false) { 'The Ambassador has lost its original overlapping arrival.'); if (overlap.stage === 'first' && overlap.first === 'ambassador') { overlap.stage = 'second'; + // The Ambassador's child arrival may already have revealed or returned + // the same physical Terror token. Do not recreate its original offer. + const remaining = g.moritaniTerror?.tokens.some(token => + overlap.terrorTokens.includes(token.id) && token.status === 'placed' && + token.location === overlap.territory); + if (!remaining) { + log(g, 'The original Terror opportunity ends because its triggered token is no longer in the stronghold.'); + finishArrivalOverlap(g, overlap); + return; + } requireRule(openTerrorEntry(g, getPlayer(g, overlap.entrant), overlap.territory, overlap.sector, overlap.amount, overlap.elite, overlap.cause, 'none', undefined, overlap.revivalArrival, overlap.caladanArrival), @@ -25468,6 +25487,15 @@ 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)) { + // 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, + sector:entry.sector,amount,elite:0,cause:'shipment',resume:'none',turn:g.turn, + phase:g.phase,nativeSneakAdvisor:true}); + return; + } // The Terror effect is the arrival itself, so the adopted order resolves // the committed entry first and then offers the Bene Gesserit reactions // the same off-planet reserve entry already uses for ordinary shipments. @@ -25481,6 +25509,10 @@ function decideTerror(g: Game, p: Player, action: Action) { : undefined; if (!intrusion(g, p, entry.territory, followup ? { followup } : undefined) && followup) g.decision = { kind: 'advisor', player: bg!.id, ...followup }; + // The committed Sneak group is a separate arrival. If advisor controls + // opened, the existing entry queue waits for them before its reactions. + if (!g.advanced) + openTerritoryEntry(g, p, entry.territory, entry.sector, amount, 0, 'shipment'); } } else if (entry.stage === 'robbery') { requireRule( @@ -39983,6 +40015,17 @@ function settleAutomaticContinuations(g: Game) { if (!intrusion(g,entrant!,deferred.territory,{...source,...(followup ? {followup} : {})}) && followup) g.decision = {kind:'advisor',player:bg!.id,...followup}; } + if (deferred.nativeSneakAdvisor) { + requireRule(deferred.turn === g.turn && deferred.phase === g.phase && + deferred.cause === 'shipment' && deferred.resume === 'none' && + entrant!.faction === 'moritani' && deferred.amount > 0 && deferred.elite === 0, + 'The queued Sneak reactions have lost their committed native entry.'); + const bg = byFaction(g, 'beneGesserit'); + const followup = bg && bg.id !== entrant!.id && spiritualAdvisorMaximum(g, bg.id) > 0 + ? {shipment:entrant!.id,destination:location(deferred.territory,deferred.sector)} : undefined; + if (!intrusion(g,entrant!,deferred.territory,followup ? {followup} : undefined) && followup) + g.decision = {kind:'advisor',player:bg!.id,...followup}; + } openTerritoryEntry(g, entrant!, deferred.territory, deferred.sector, deferred.amount, deferred.elite, deferred.cause, deferred.resume, deferred.revivalArrival, deferred.caladanArrival); if (deferred.revivalArrival) resumeHomeworldRevivalReturn(g); diff --git a/game/reference.ts b/game/reference.ts index d532be73..2864bf88 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1697,7 +1697,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When an eligible entrant reaches the reusable Ecaz token, Ecaz may acquire the available shared Duke for itself or its current reciprocal ally. The token returns to supply and its companions are unchanged. Direct acquisition persists unused until battle use or Moritani acquisition; that shared tenure is provisional. It is distinct from the optional Basic or automatic Advanced new-alliance turn loan. A fresh token entry is required, with no standing transfer action.', 'Unallied Ecaz may propose an alliance to an unallied entrant. Acceptance activates alliance abilities immediately. Basic then offers an optional Duke loan; Advanced automatically adds an available Duke under supplied PDF p30, without another acknowledgement. The first version releases a surviving loan after one battle, provisionally; an unused living loan is set aside at turn end. Unavailable Duke never prevents consent.', '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 bare 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 when other selected native factions are seated. Finish the first response before the second; the original arrival and payment occur once. Nested Fremen or Guild Ambassador arrivals and other unsupported module interactions remain guarded.', + 'In supported bare 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 when other selected native factions are seated. 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.', '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.', diff --git a/tests/fixture-guild-overlap-child.ts b/tests/fixture-guild-overlap-child.ts new file mode 100644 index 00000000..8035f46f --- /dev/null +++ b/tests/fixture-guild-overlap-child.ts @@ -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.' }; +} diff --git a/tests/fixture-overlap-fremen-native.ts b/tests/fixture-overlap-fremen-native.ts new file mode 100644 index 00000000..077e5e7e --- /dev/null +++ b/tests/fixture-overlap-fremen-native.ts @@ -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); +} diff --git a/tests/fixture-semuta-overlap-child.ts b/tests/fixture-semuta-overlap-child.ts new file mode 100644 index 00000000..99587cee --- /dev/null +++ b/tests/fixture-semuta-overlap-child.ts @@ -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; + +/** 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; +} diff --git a/tests/guild-overlap-child-runtime.test.ts b/tests/guild-overlap-child-runtime.test.ts new file mode 100644 index 00000000..203230e3 --- /dev/null +++ b/tests/guild-overlap-child-runtime.test.ts @@ -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); + }); + } diff --git a/tests/overlap-child-mode-gate.test.ts b/tests/overlap-child-mode-gate.test.ts new file mode 100644 index 00000000..2e596ee7 --- /dev/null +++ b/tests/overlap-child-mode-gate.test.ts @@ -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); + }); +} diff --git a/tests/overlap-fremen-guild-controls.test.tsx b/tests/overlap-fremen-guild-controls.test.tsx new file mode 100644 index 00000000..6823b1d0 --- /dev/null +++ b/tests/overlap-fremen-guild-controls.test.tsx @@ -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(/]*)>([\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/); +}); diff --git a/tests/overlap-fremen-native-runtime.test.ts b/tests/overlap-fremen-native-runtime.test.ts new file mode 100644 index 00000000..06566957 --- /dev/null +++ b/tests/overlap-fremen-native-runtime.test.ts @@ -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); + } +}); diff --git a/tests/semuta-overlap-child-runtime.test.ts b/tests/semuta-overlap-child-runtime.test.ts new file mode 100644 index 00000000..6127a668 --- /dev/null +++ b/tests/semuta-overlap-child-runtime.test.ts @@ -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); + }); +} diff --git a/tests/sneak-overlap-child-ambassador.test.ts b/tests/sneak-overlap-child-ambassador.test.ts new file mode 100644 index 00000000..ff211d88 --- /dev/null +++ b/tests/sneak-overlap-child-ambassador.test.ts @@ -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); +});