diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 42f6ac84..2e93bb87 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,19 @@ open mode gates. ## Current checkpoint and work +**Aftermath shipment and Recruits Semuta — 29 October. Prototyped / Partial.** +Moritani Atomics Aftermath now blocks Spiritual accompaniment, Guild +Ambassador, cross-planet and Junction shipments, including concealed markers; +ordinary movement, reserve return and the printed Sneak Attack exception stay +available. Basic native expansion rosters can offer the independent Recruits +discard to Semuta without broadening other discard producers. Twelve related +files pass 93 cases, including actual Guild Ambassador/Atomics actions and real +Tleilaxu/Ecaz games with selected card order. Typecheck, lint and the production +build pass. +Great Maker's reserve ride classification, other Semuta producers and complete +module games remain open. [Aftermath scope](MORITANI_TERROR_RULES.md#aftermath-shipment-destinations--29-october-2026); +[Semuta scope](SEMUTA_DISCARD_CONTINUATIONS.md#recruits-with-native-expansion-factions--29-october-2026). + **Black Market knowledge and group Guild transport — 29 October. Prototyped / Partial.** Atreides can answer the existing private auction-knowledge question from an actual Advanced Black Market inspection. A staged group Guild ally can @@ -643,8 +656,9 @@ published revision and checkpoint tags with digest Run 26 was canceled before starting; run 27 for `2fe97dd` was running at the last earlier check. Run 51 for `cdc47ed` subsequently succeeded, including isolated HTTP verification, registry login and image publication. Run 52 was -canceled before starting; run 53 for `3ba9087` was still running at the latest -check. The already-approved runner network adjustment and registry secret +canceled before starting; run 53 for `3ba9087` succeeded, including HTTP +verification and image publication. Run 54 was canceled before starting and +run 55 for `4feb418` was running at the latest check. The already-approved runner network adjustment and registry secret remain in use; neither needed to be recreated. Deployment remains unverified. Existing NAS deployment and its persistent storage remain untouched. diff --git a/docs/MORITANI_TERROR_RULES.md b/docs/MORITANI_TERROR_RULES.md index abe65605..68ca50d4 100644 --- a/docs/MORITANI_TERROR_RULES.md +++ b/docs/MORITANI_TERROR_RULES.md @@ -1,5 +1,15 @@ # Moritani terror rules and components +## Aftermath shipment destinations — 29 October 2026 + +Aftermath's printed shipment ban applies to ordinary and Fremen shipment, +Guild cross-planet transport and Junction, free Guild Ambassador shipment +(including No-Field), and Bene Gesserit Spiritual accompaniment. The shared +destination checks preserve reserve returns and do not block ordinary movement; +Sneak Attack retains its printed exception. The Guild Ambassador journey uses +an explicitly staged position followed by actual shipment, Atomics and advisor +decisions. Other module combinations and complete Moritani games remain open. + ## Sabotage gift to a full hand — 27 October 2026 **Prototyped / Partial.** Moritani may give the chosen physical card to an diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index e9a65236..1111d793 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -1,5 +1,15 @@ # Semuta discard producers and continuation inventory +## Recruits with native expansion factions — 29 October 2026 + +**Prototyped / Partial.** Fresh Basic games with Richese, selected native +faction decks and the independent Ecaz Treachery Cards can offer a completed +Recruits discard to Semuta. The same physical card, claim/pass controls and +completed Revival benefit apply. Real Tleilaxu and Ecaz journeys use all three +selected expansion decks and actual card purchases, with unopened card and +Spice order selected in the fixture. Other Semuta producers in these wider +rosters and optional modules remain separate; this does not certify the card. + ## Residual Poison in Basic independent cards — 28 October 2026 **Prototyped / Partial.** In the existing Richese/classic independent-card diff --git a/game/bot-arrival.ts b/game/bot-arrival.ts index e0d9b47c..d6851c34 100644 --- a/game/bot-arrival.ts +++ b/game/bot-arrival.ts @@ -13,6 +13,7 @@ import { shipmentPaymentBounds, } from './shipment-price'; import { quoteSmugglerShipment } from './smuggler-shipment'; +import { atomicsShipmentBlocked } from './moritani-atomics'; /** * Known arrival support for ordinary owner-view candidates, plus the public @@ -23,6 +24,10 @@ import { quoteSmugglerShipment } from './smuggler-shipment'; * are never treated as rejected candidates. */ export function botArrivalBlock(g: GameView, action: Action): string | null { + if (g.status === 'playing' && g.phase === 5 && g.active === g.me && + (action.type === 'ship' || action.type === 'guildShip') && + typeof action.territory === 'string' && atomicsShipmentBlocked(g.moritaniAtomics, action.territory)) + return 'Atomics Aftermath permanently blocks shipments into this territory.'; if (g.status === 'playing' && g.phase === 5 && g.active === g.me && action.type === 'guildShip' && action.nexus === undefined && g.guildTransportCanceled) return 'Karama prevents native Guild cross/return transport for this shipment opportunity.'; @@ -49,8 +54,6 @@ export function botArrivalBlock(g: GameView, action: Action): string | null { paidBox: g.decision?.kind === 'nullentropy', }; let order: CompletedMovementArrivalInput['order']; - if (action.type === 'ship' && g.moritaniAtomics?.territory === to) - return 'Atomics Aftermath permanently blocks shipments into this territory.'; if (action.type === 'guildShip') { if ( action.forces !== undefined || diff --git a/game/engine.ts b/game/engine.ts index 957b08ae..aac77c39 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -7911,7 +7911,7 @@ function tupileDiscardSignature(g: Game, c: TupileDiscardContinuation) { function treacheryDiscardIntegrity(g: Game) { const discovery = g.discoveryEnabled === true && !!g.discoveries && discoveryModeSupported(g); requireRule(g.semutaRecruitsPreview === undefined || g.semutaRecruitsPreview === true && - basicIndependentSemutaProfile(g) && !g.semutaPreview, + basicIndependentRecruitsSemutaProfile(g) && !g.semutaPreview, 'This Recruits Semuta preview no longer matches its original Basic profile.'); requireRule(g.semutaPreview === undefined || g.semutaPreview === true && g.expansions.includes('choam') && !!byFaction(g, 'richese') && @@ -8130,7 +8130,7 @@ function treacheryDiscardIntegrity(g: Game) { ); requireRule( c.effect === 'recruits' - ? g.semutaRecruitsPreview === true && basicIndependentSemutaProfile(g) && + ? g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g) && g.phase === 4 && g.recruits?.turn === g.turn && g.recruits.player === c.player && g.recruits.card === c.card : c.effect === 'hajr' @@ -9469,7 +9469,7 @@ function semutaOfferSupported( entries: FreshDiscardBatch['entries'], ) { if (continuation.kind === 'ordinaryCardDiscard' && continuation.effect === 'recruits') - return g.semutaRecruitsPreview === true && basicIndependentSemutaProfile(g) && + return g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g) && g.status === 'playing' && g.phase === 4 && g.recruits?.turn === g.turn && g.recruits.player === continuation.player && g.recruits.card === continuation.card && entries.length === 1 && entries[0].publicFace && entries[0].discardedBy === continuation.player && @@ -13183,6 +13183,20 @@ function basicIndependentSemutaProfile(g: Game): boolean { !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.kullPreview && !g.nexusKullPreview && !g.ecazOccupyPreview && !g.moritaniAssassinatePreview; } +/** Recruits' completed Revival discard has no faction-specific continuation. + * Its separate admission does not broaden other independent-card reactions. */ +function basicIndependentRecruitsSemutaProfile(g: Game): boolean { + return g.advanced === false && g.ecazTreachery === true && + g.expansions.includes('choam') && !!byFaction(g, 'richese') && + g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) && + new Set(g.expansions).size === g.expansions.length && + g.players.length >= 2 && g.players.length <= 6 && + g.players.every(p => faction(p.faction).expansion === 'base' || + g.expansions.includes(faction(p.faction).expansion)) && + !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards && + !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && + !g.kullPreview && !g.nexusKullPreview && !g.moritaniAssassinatePreview; +} /** Bare native Basic starts share the original deck/roster validation. Optional * modules retain their own explicit Semuta envelopes; saved games are not upgraded. */ function bareBasicSemutaProfile(g: Game): boolean { @@ -13396,7 +13410,7 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game { requireFreshFactionInventory(g); } const initialized = initializeSetupGameForAudit(g, homeworlds, independentNexusProfile(g), false, discovery, false, false, true, true); - if (basicIndependentSemutaProfile(initialized)) initialized.semutaRecruitsPreview = true; + if (basicIndependentRecruitsSemutaProfile(initialized)) initialized.semutaRecruitsPreview = true; if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized); return initialized; @@ -38024,6 +38038,8 @@ function quoteJunctionDeclaration(g: Game, p: Player, declaration: JunctionTrans const advisors = arrival ? arrivalAsAdvisor(g, p, arrival.territory, quote.originKind === 'arrakis' ? quote.origin : undefined) : false; if (arrival) { + requireRule(!atomicsShipmentBlocked(g.moritaniAtomics, arrival.territory), + 'Atomics Aftermath prevents shipment into this territory.'); requireRule(arrival.territory !== MOBILE_STRONGHOLD || p.faction === 'ixians', 'Only Ixians may ship directly into the mobile stronghold.'); requireRule(advisors || sourceLock !== g.turn || @@ -39088,6 +39104,8 @@ function validateGuildTransportForCommit( 'New advisors cannot become fighters this turn.'); requireRule(!frame.noField || to === 'reserves' || !to.startsWith('homeworld:'), 'A concealed No-Field cannot be placed on a Homeworld.'); + requireRule(to === 'reserves' || !atomicsShipmentBlocked(g.moritaniAtomics, to), + 'Atomics Aftermath prevents shipment into this territory.'); if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors); const halfRate = !!guildSecretEvent || g.karamaShipping?.player === p.id || !(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id); @@ -39112,6 +39130,8 @@ function commitGuildTransport( ) { requireRule(!g.guildBetrayalPreview || !guildSecretEvent, 'Guild Betrayal native transport does not combine with a Guild Secret Ally producer.'); + requireRule(frame.to === 'reserves' || !atomicsShipmentBlocked(g.moritaniAtomics, frame.to), + 'Atomics Aftermath prevents shipment into this territory.'); if (!guildSecretEvent || frame.noField) validateGuildTransportForCommit(g, p, frame, fromReserves, guildSecretEvent); if (!settlement && beginGuildBetrayal(g, {source: 'guildTransport', frame, fromReserves})) return; const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame; @@ -42643,7 +42663,7 @@ function applyActionInner( `${p.name} played Recruits. Current free revival rates are doubled and the ordinary force revival limit is seven for this turn.`, { faction: p.faction, name: 'Recruits' }, ); - if (execution === 'live' && g.semutaRecruitsPreview && basicIndependentSemutaProfile(g)) + if (execution === 'live' && g.semutaRecruitsPreview && basicIndependentRecruitsSemutaProfile(g)) stageOrdinaryCardDiscard(g, p, card); return g; } @@ -45976,6 +45996,8 @@ function applyActionInner( ); const to = stringField(action.territory ?? 'reserves'); const sector = integer(action.sector ?? 0, 0, 18); + requireRule(to === 'reserves' || !atomicsShipmentBlocked(g.moritaniAtomics, to), + 'Atomics Aftermath prevents shipment into this territory.'); requireRule(fromReserves || to === 'reserves' || to !== origin, 'Cross-shipment requires a different destination territory.'); requireRule( to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent, diff --git a/game/guild-ambassador-no-field.ts b/game/guild-ambassador-no-field.ts index a216d88b..da208cc8 100644 --- a/game/guild-ambassador-no-field.ts +++ b/game/guild-ambassador-no-field.ts @@ -12,6 +12,7 @@ import { import { FACTIONS } from './catalog'; import { presenceAt, type ForcePresence } from './force-presence'; import { territoryEntryBlock } from './occupancy'; +import { atomicsShipmentBlocked } from './moritani-atomics'; import { deployRicheseNoField, isBoardNoFieldLocation, @@ -222,6 +223,8 @@ function destination(g: Game, c: MarkerContext, to: string, sector: number) { requireMarker(text(to) && count(sector) && printedLocation(to, sector) && to !== MOBILE_STRONGHOLD, 'Choose a printed planet territory and sector for the No-Field shipment.'); + requireMarker(!atomicsShipmentBlocked(g.moritaniAtomics, to), + 'Atomics Aftermath permanently blocks shipments into this territory.'); requireMarker(sector === 0 || sector !== g.storm, 'The shipment destination is in storm.'); const recipient = c.seats.find((seat) => seat.id === c.recipient.id)!; diff --git a/game/guild-ambassador.ts b/game/guild-ambassador.ts index 192dcd0d..3191c228 100644 --- a/game/guild-ambassador.ts +++ b/game/guild-ambassador.ts @@ -1,6 +1,7 @@ import type { Action, Game, Player } from './engine'; import { arrivalAsAdvisor } from './advisors'; import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival } from './spiritual-advisor'; +import { atomicsShipmentBlocked } from './moritani-atomics'; import { gameTerritories, location, @@ -162,9 +163,7 @@ function validateDestinationOccupancy( /** E3's independent free reserve shipment. No ordinary shipment turn, funding, * faction distance, reserve-region or incoming accompaniment is resolved here. * No-Field substitution is deliberately not admitted by this physical quote. - * E3 Atomics Aftermath also forbids this shipment. The current Game has no - * Aftermath location state: its eventual producer must supply that public - * exclusion here before the unfinished Atomics module can be enabled. */ + * E3 Atomics Aftermath also forbids this independent shipment. */ export function quoteGuildAmbassadorShipment( g: Game, beneficiaryId: string, @@ -197,6 +196,8 @@ export function quoteGuildAmbassadorShipment( usableLocation(g, location(to, sector)), 'Choose a sector of an available board territory.', ); + requireShipment(!atomicsShipmentBlocked(g.moritaniAtomics, to), + 'Atomics Aftermath permanently blocks shipments into this territory.'); requireShipment( sector === 0 || sector !== g.storm, 'The shipment destination is in storm.', diff --git a/game/junction-transport-options.ts b/game/junction-transport-options.ts index 7e3675be..a1f29433 100644 --- a/game/junction-transport-options.ts +++ b/game/junction-transport-options.ts @@ -18,6 +18,7 @@ import { } from './homeworld-shipment-options'; import { liveShipmentPromises, matchesShipment } from './shipment-promises'; import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching'; +import { atomicsShipmentBlocked } from './moritani-atomics'; export function junctionTransportOrigins(view: GameView) { const me = view.players.find((player) => player.id === view.me); @@ -236,7 +237,9 @@ export function junctionTransportChoice( arrival.territory, result.advisors, ); - if (arrival.territory === MOBILE_STRONGHOLD && me.faction !== 'ixians') + if (atomicsShipmentBlocked(view.moritaniAtomics, arrival.territory)) + result.blocked = 'Atomics Aftermath permanently blocks shipments into this territory.'; + else if (arrival.territory === MOBILE_STRONGHOLD && me.faction !== 'ixians') result.blocked = 'Only Ixians may ship directly into the mobile stronghold.'; else if (arrival.sector !== 0 && arrival.sector === view.storm) diff --git a/game/moritani-atomics.ts b/game/moritani-atomics.ts index 2d3d59fa..5e8d8a85 100644 --- a/game/moritani-atomics.ts +++ b/game/moritani-atomics.ts @@ -119,7 +119,7 @@ export function quoteMoritaniAtomics(input: { /** Ordinary and Fremen shipments are both blocked; movement and Sneak Attack are not shipments. */ export function atomicsShipmentBlocked( - aftermath: AtomicsAftermath | null | undefined, + aftermath: Pick | null | undefined, destinationTerritory: string, ): boolean { return aftermath?.territory === destinationTerritory; diff --git a/game/reference.ts b/game/reference.ts index c4b80720..dc95629f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4200,7 +4200,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'On a supported native Moritani table with original selected modules, revealing Atomics sends every faction’s forces in the triggered territory to the Tanks and places a public Atomics Aftermath marker there. Hidden tokens remain face down until revealed.', 'An original Moritani Nexus Cunning placement may put Atomics in a printed Arrakis desert. The later explosion and Aftermath retain that actual placement grant after the token is removed. This does not permit ordinary desert placement, Homeworld, HMS or Discovery targets.', 'A caught concealed No-Field provisionally reveals its reserve-capped native forces before the explosion sends them to Tanks. This composition is labelled provisional; it is not a located publisher clarification.', - 'Aftermath permanently prohibits shipping forces into that territory, including Fremen reinforcements. The restriction is on shipment: it does not prohibit otherwise legal ordinary movement into the territory.', + 'Aftermath permanently prohibits shipping forces into that territory, including Fremen reinforcements, Guild transport and Junction shipments, Guild Ambassador shipments and Bene Gesserit Spiritual accompaniment. The restriction is on shipment: it does not prohibit otherwise legal ordinary movement into the territory.', 'Sneak Attack expressly permits its reserve forces to enter despite Aftermath. Its storm and occupancy restrictions still apply.', 'Beginning on the activation turn, Moritani’s hand limit and its ally’s at activation are each reduced by one. If either hand exceeds its reduced limit on resolution, discard one random card per excess card. The public Aftermath notice identifies the affected players; their individual projected hand limits reflect the penalty.', 'Under the recommended persistent-target interpretation, later Nexus or Enemy of My Enemy alliances do not move the penalty: Moritani and the ally at detonation remain reduced; a later ally keeps its normal limit. An initially unallied detonation gains no ally target afterward. The notice labels this provisional, not a publisher ruling. Original stored targets migrate without replaying casualties or card discards. Genuine Basic/Advanced later-pact, special-alliance, migration and corruption boundaries pass112/112 in6 related files, with types/lint/build and actual390px controls verified. Broader Atomics compositions and complete modes remain open.', @@ -6620,7 +6620,7 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.', 'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.', - 'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. Used ordinary specials, supported battle cleanup, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card; other Semuta opportunities in this combination remain unfinished.', + 'In a fresh Basic game with Richese, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it, including when other selected expansion factions are seated. The Recruits benefit still lasts for the turn and cannot be played again. In the separately supported classic-roster profile, used ordinary specials, supported battle cleanup, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card. Other Semuta opportunities remain unfinished.', 'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.', 'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Fremen may now grant after play under the same provisional current-rate policy, with its original Karama response settled before commitment. Missing/inconsistent ledgers, pending transactions and same-turn replay remain guarded.', 'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.', diff --git a/game/spiritual-advisor.ts b/game/spiritual-advisor.ts index 56733b10..0c1d0c2f 100644 --- a/game/spiritual-advisor.ts +++ b/game/spiritual-advisor.ts @@ -4,6 +4,7 @@ import type { Game } from './engine'; import { validCaladanEntryParent, validateCaladanTerrorPacket, type CaladanEntryParent } from './caladan-arrival'; import { currentHomeworldRevivalArrival } from './homeworld-revival-return'; import { presenceAt, type ForcePresence } from './force-presence'; +import { atomicsShipmentBlocked, type AtomicsAftermath } from './moritani-atomics'; export type SpiritualAdvisorSource = { event: string; @@ -26,6 +27,7 @@ export type SpiritualAdvisorPlayer = ForcePresence & { export type SpiritualAdvisorBoardContext = { advanced: boolean; players: readonly SpiritualAdvisorPlayer[]; + moritaniAtomics?: Pick | null; }; export type SpiritualAdvisorContext = SpiritualAdvisorBoardContext & MobileBoard & { @@ -82,10 +84,12 @@ export function spiritualAdvisorTargetBlock( owner: string, target: SpiritualAdvisorTarget, ): string | null { + const territory = territoryId(target); + if (atomicsShipmentBlocked(ctx.moritaniAtomics, territory)) + return 'Atomics Aftermath prevents shipments into this territory; choose the Polar Sink or decline.'; if (!ctx.advanced) return null; const player = nativeOwner(ctx, owner); if (!player) return 'Spiritual Advisors need the current native Bene Gesserit owner.'; - const territory = territoryId(target); if (territory === 'polar_sink') return null; return hasResidents(player, territory) && !player.advisors?.[territory] ? 'Advanced Spiritual Advisors cannot accompany into a non-Polar territory containing your fighters; choose the Polar Sink or decline. Intrusion that already made them advisors removes this block.' diff --git a/game/transport-quote.ts b/game/transport-quote.ts index f1427d3d..b014ec7f 100644 --- a/game/transport-quote.ts +++ b/game/transport-quote.ts @@ -7,6 +7,7 @@ import { groupGuildShipmentRate, shipmentPaymentBounds } from './shipment-price' import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching'; import { guildNoFieldSelection } from './guild-no-field-selection'; import { isBoardNoFieldLocation } from './richese-no-field'; +import { atomicsShipmentBlocked } from './moritani-atomics'; /** Preview the submitted Guild transport using only this seat's entitled view. * The authoritative action still revalidates the selection when it arrives. @@ -142,6 +143,8 @@ export function guildTransportQuote(g: GameView, action: Action) { ); const to = typeof action.territory === 'string' ? action.territory : 'reserves'; + if (to !== 'reserves' && atomicsShipmentBlocked(g.moritaniAtomics, to)) + unavailableReasons.push('Atomics Aftermath permanently blocks shipments into this territory.'); const sector = action.sector ?? 0; const noFieldWorld = !!noField && !!g.homeworlds?.worlds?.some(world => world.id === to); const validDestination = diff --git a/tests/guild-transport-atomics.test.ts b/tests/guild-transport-atomics.test.ts new file mode 100644 index 00000000..cf95bf2c --- /dev/null +++ b/tests/guild-transport-atomics.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { botArrivalBlock } from '../game/bot-arrival'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { applyAction, createGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; +import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field'; +import { guildTransportQuote } from '../game/transport-quote'; +import { atomicsFixtureGame } from './fixture-moritani-atomics'; + +const p = (g: Game, id: string) => g.players.find(player => player.id === id)!; +const act = (g: Game, id: string, action: Action) => applyAction(g,id,action); +const cross: Action = {type:'guildShip',forces:{'imperial_basin:10':2}, + territory:'arrakeen',sector:10,allyPayment:0}; + +/** The physical Atomics token is genuinely revealed by the original entrant. + * Later turn ownership, a conserved board group and marker custody are staged + * to reach the particular transportation quotes without replaying a game. */ +function aftermath(advanced = false) { + let game = createGame('GUILDATOMICS',newPlayer('m','Moritani','moritani'),advanced,['ecaz','choam']); + game.players.push(newPlayer('e','Emperor','emperor'),newPlayer('g','Guild','guild'), + newPlayer('f','Fremen','fremen'),newPlayer('r','Richese','richese')); + atomicsFixtureGame(game,{moritani:'m',entrant:'e',ally:'g'},{advanced,allied:false}); + game.order=['e','m','g','f','r']; game.movementRemaining=[...game.order]; + game=act(game,'e',{type:'ship',territory:'arrakeen',sector:10,amount:2}); + if (game.decision?.kind==='guildShipment') + game=act(game,game.decision.player,{type:'decision',allow:true}); + game=allow(game); + assert.equal(game.pendingTerrorEntry?.stage,'offer'); + game=normalizeAutomaticGame(act(game,'m',{type:'decision',reveal:true})); + assert.equal(game.moritaniAtomics?.territory,'arrakeen'); + assert.equal(game.pendingTerrorEntry,null); + return game; +} +function prepare(g: Game, actor: 'g'|'f'|'r') { + g.active=actor; g.movementRemaining=[actor,...g.order.filter(id=>id!==actor)]; + p(g,actor).shipped=false; p(g,actor).moved=0; p(g,actor).spice=20; + return g; +} +function reject(g: Game, actor: string, action: Action) { + const before=structuredClone(g); + assert.throws(()=>act(g,actor,action),/Atomics Aftermath/); + assert.deepEqual(g,before); +} +function allow(g: Game) { + for (let i=0;g.response && i<30;i++) { + const actor=g.players.find(player=>!g.response!.passed.includes(player.id)); assert.ok(actor); + g=act(g,actor.id,{type:'passResponse'}); + } + return g; +} + +for (const advanced of [false,true]) { + void test(`${advanced?'Advanced':'Basic'} Guild cannot cross-ship into Aftermath, but may move in or return to reserves`,()=>{ + const game=prepare(aftermath(advanced),'g'); + p(game,'g').forces={'imperial_basin:10':2}; p(game,'g').reserves=18; + const view=viewGame(game,'g'); + assert.ok(guildTransportQuote(view,cross).unavailableReasons.some(reason=>reason.includes('Atomics Aftermath'))); + assert.match(botArrivalBlock(view,cross)??'',/Atomics Aftermath/); + reject(game,'g',cross); + for (const difficulty of DIFFICULTIES) { + const botView=viewGame(game,'g'); botView.players.find(player=>player.id==='g')!.bot=difficulty; + assert.ok(!botActions(botView).some(action=>action.type==='guildShip' && action.territory==='arrakeen')); + } + const movement: Action={type:'move',forces:{'imperial_basin:10':2},territory:'arrakeen',sector:10}; + const moved=act(structuredClone(game),'g',movement); + assert.equal(p(moved,'g').forces['arrakeen:10'],2); + const back: Action={type:'guildShip',forces:{'imperial_basin:10':2},territory:'reserves',sector:0,allyPayment:0}; + assert.ok(!guildTransportQuote(view,back).unavailableReasons.some(reason=>reason.includes('Atomics'))); + const returned=allow(act(structuredClone(game),'g',back)); + assert.equal(p(returned,'g').forces['imperial_basin:10']??0,0); + assert.equal(p(returned,'g').reserves,20); + }); + void test(`${advanced?'Advanced':'Basic'} Guild-allied Fremen cannot cross-ship southern reserves into Aftermath`,()=>{ + const game=prepare(aftermath(advanced),'f'); p(game,'g').ally='f'; p(game,'f').ally='g'; + const shipment: Action={type:'guildShip',from:'reserves',amount:2,territory:'arrakeen',sector:10,allyPayment:0}; + const view=viewGame(game,'f'); + assert.ok(guildTransportQuote(view,shipment).unavailableReasons.some(reason=>reason.includes('Atomics Aftermath'))); + assert.match(botArrivalBlock(view,shipment)??'',/Atomics Aftermath/); + reject(game,'f',shipment); + }); +} + +void test('Guild-allied Richese cannot cross-ship a concealed No-Field into Aftermath',()=>{ + const game=prepare(aftermath(true),'r'); p(game,'g').ally='r'; p(game,'r').ally='g'; + const stock=createRicheseNoField(['controlled-zero','controlled-three','controlled-five']); + const marker=stock.tokens.find(token=>token.value===0)!; + p(game,'r').noField=deployRicheseNoField(stock,{tokenId:marker.id,controller:'r', + location:{territory:'imperial_basin',sector:10}}); + p(game,'r').noFieldEvent='controlled-marker-event'; + const shipment: Action={type:'guildShip',from:'imperial_basin:10',forces:{},noField:marker.id, + event:p(game,'r').noFieldEvent,territory:'arrakeen',sector:10,allyPayment:0}; + const view=viewGame(game,'r'); + assert.ok(guildTransportQuote(view,shipment).unavailableReasons.some(reason=>reason.includes('Atomics Aftermath'))); + assert.match(botArrivalBlock(view,shipment)??'',/Atomics Aftermath/); + reject(game,'r',shipment); + assert.equal(p(game,'r').noField?.deployed?.tokenId,marker.id); +}); diff --git a/tests/junction-atomics-shipment.test.ts b/tests/junction-atomics-shipment.test.ts new file mode 100644 index 00000000..c9bbe8d4 --- /dev/null +++ b/tests/junction-atomics-shipment.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { TERRITORIES } from '../game/board'; +import { applyAction, viewGame, type Action } from '../game/engine'; +import { junctionTransportActions, junctionTransportChoice } from '../game/junction-transport-options'; +import { TERROR_STRONGHOLDS } from '../game/moritani-terror'; +import { createJunctionPurchasedPrice, type PurchasedJunctionRoute } from './fixture-junction-purchased-price'; + +/** Original public setup, physical purchased rate and Guild's actual Junction + * offer come from the native fixture. To examine the later persisted state, + * Atomics' removed token, original victim and hand-limit marker are staged; + * any excess held card is moved into the original deck, not duplicated. */ +function staged(route: PurchasedJunctionRoute) { + const f=createJunctionPurchasedPrice({route}); + const g=structuredClone(f.beforeAction); + const available=TERRITORIES.filter(t=>TERROR_STRONGHOLDS.includes(t.id)).flatMap(t=> + t.sectors.map(sector=>`${t.id}:${sector}`)).find(destination=> + !!junctionTransportChoice(viewGame(g,f.shipper),destination,f.sources).action); + assert.ok(available,'An original offered Junction route needs an eligible stronghold.'); + const target=available.split(':')[0]; + const owner=g.players.find(player=>player.faction==='moritani'); assert.ok(owner); + const token=g.moritaniTerror?.tokens.find(candidate=>candidate.kind==='atomics'); assert.ok(token); + token.status='removed'; token.location=null; + owner.atomicsHandLimitPenalty=true; + if (owner.hand.length>3) g.deck.push(...owner.hand.splice(3)); + g.moritaniAtomics={territory:target,turn:g.turn,moritaniId:owner.id, + allyAtActivation:null,alliancePolicy:'activation-targets'}; + return {f,g,destination:available,target}; +} + +for (const route of ['nativeArrival','arrakisReturn'] as const) { + void test(`Junction ${route==='nativeArrival'?'Homeworld-to-Arrakis':'Arrakis-to-Arrakis'} cannot enter Atomics Aftermath`,()=>{ + const {f,g,destination,target}=staged(route); + const view=viewGame(g,f.shipper); + const quoted=junctionTransportChoice(view,destination,f.sources); + assert.match(quoted.blocked??'',/Atomics Aftermath/); + assert.equal(quoted.action,null); + const offer=view.junctionTransport?.offer; assert.ok(offer); + const declaration: Action={type:'junctionShip',event:view.junctionTransport!.event, + offer:offer.event,destination,sources:f.sources,allyPayment:0,forfeitPurchasedRate:true}; + const before=structuredClone(g); + assert.throws(()=>applyAction(g,f.shipper,declaration),/Atomics Aftermath/); + assert.deepEqual(g,before); + for (let level=0;level<4;level++) + assert.ok(!junctionTransportActions(view,level).some(action=> + action.type==='junctionShip' && typeof action.destination==='string' && action.destination.split(':')[0]===target)); + // A Homeworld destination remains legal because the marker affects only Arrakis. + const world=junctionTransportChoice(view,'homeworld:ecaz',f.sources); + assert.ok(!world.blocked?.includes('Atomics')); + }); +} diff --git a/tests/semuta-recruits-basic.test.ts b/tests/semuta-recruits-basic.test.ts index cb568bfd..96092471 100644 --- a/tests/semuta-recruits-basic.test.ts +++ b/tests/semuta-recruits-basic.test.ts @@ -89,13 +89,13 @@ void test('a changed revival parent cannot resume a saved Recruits discard', () assert.ok(viewGame(pending, f.owner).semutaReaction); }); -void test('a native CHOAM seat does not silently enable this narrow preview', () => { +void test('a native CHOAM seat enables the Recruits reaction without enabling other Semuta previews', () => { const f = fixture(), lobby = structuredClone(f.lobby); const index = lobby.players.findIndex(seat => seat.id === 'emperor'); assert.ok(index >= 0); lobby.players[index] = newPlayer('emperor', 'CHOAM', 'choam'); lobby.players[index].ready = true; const game = act(lobby, lobby.host, {type: 'start'}); - assert.equal(game.semutaRecruitsPreview, undefined); + assert.equal(game.semutaRecruitsPreview, true); assert.equal(game.semutaPreview, undefined); }); diff --git a/tests/semuta-recruits-native-rosters.test.ts b/tests/semuta-recruits-native-rosters.test.ts new file mode 100644 index 00000000..bba8f6ed --- /dev/null +++ b/tests/semuta-recruits-native-rosters.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { TERRITORIES } from '../game/board'; +import { RECRUITS_CARD_ID } from '../game/recruits'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorInventory as inventory, + basicSemutaAmbassadorPlayer as seat } from './fixture-basic-semuta-ambassador'; + +/** Public selected-deck setup, original auctions and actual Revival. Only the + * initial unopened ordinary/Spice order is selected to make this journey short. */ +function nativeFixture(native: 'tleilaxu' | 'ecaz') { + let game = createGame('SEMUTANATIVERECRUITS', newPlayer('atreides', 'Atreides', 'atreides'), false, + ['choam', 'ix', 'ecaz']); + for (const faction of ['richese', 'guild', native] as const) + joinGame(game, newPlayer(faction, faction, faction)); + const owner = 'atreides', holder = 'guild'; + const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); }; + const reach = (until: (state: Game) => boolean) => { + for (let i = 0; i < 1600; i++) { + if (until(game)) return; + assert.notEqual(game.status, 'finished'); + const step = nextRicheseStrongholdsNativeStep(game); + assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`); + act(step.actor, step.action); + } + throw Error('The native Semuta/Recruits game did not reach its requested boundary.'); + }; + act(owner, {type:'moduleOption', module:'ecazTreachery', enabled:true}); + for (const player of game.players) act(player.id, {type:'ready'}); + act(owner, {type:'start'}); + assert.equal(game.status, 'setup'); + assert.equal(game.semutaRecruitsPreview, true); + const original = inventory(game); + const starting = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)) + .slice(0, game.players.length).map(card => card.id); + assert.equal(starting.length, game.players.length); + for (const [position, id] of [...starting, RECRUITS_CARD_ID].entries()) { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + } + for (let i = 0; game.status === 'setup' && i < 100; i++) { + const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step); act(step.actor, step.action); + } + assert.equal(game.status, 'playing'); + for (let position = 0; position < 4; position++) { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(from >= position); + const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); + } + reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache'); + act('richese', {type:'decision', event:game.richeseBidding!.event, card:SEMUTA_DRUG_ID, + method:'onceAround', direction:'counterclockwise'}); + reach(state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome); + for (let i = 0; !seat(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && i < 8; i++) { + const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome); + act(lot.active, {type:'richeseBid', event:lot.event, amount:lot.active === holder ? 1 : null, allyPayment:0}); + reach(clean); + } + assert.ok(seat(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + reach(state => state.phase === 3 && clean(state) && state.auction?.active === owner); + assert.equal(game.auction!.cards[game.auction!.index].id, RECRUITS_CARD_ID); + act(owner, {type:'bid', amount:1, allyPayment:0}); + reach(state => clean(state) && seat(state, owner).hand.some(card => card.id === RECRUITS_CARD_ID)); + reach(state => state.phase === 5 && state.active === owner && clean(state)); + const sector = game.storm % 18 + 1; + const destination = TERRITORIES.find(place => place.type === 'sand' && place.sectors.includes(sector) && + game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`)))); + assert.ok(destination); + const location = `${destination.id}:${sector}`; + act(owner, {type:'ship', territory:destination.id, sector, amount:3, elite:0, allyPayment:0}); + reach(state => state.turn === 2 && state.phase === 4 && clean(state)); + assert.equal(seat(game, owner).forces[location], undefined); + assert.equal(seat(game, owner).tanks, 3); + assert.deepEqual(inventory(game), original); + return {game, owner, holder, original}; +} + +for (const native of ['tleilaxu','ecaz'] as const) for (const claim of [false,true]) { + void test(`Basic ${native} selected-family Recruits discard offers Semuta: ${claim ? 'claim' : 'pass'}`, () => { + const {game, owner, holder, original} = nativeFixture(native); + const pending = applyAction(game, owner, {type:'card', card:RECRUITS_CARD_ID}); + const frame = pending.pendingTreacheryDiscard; assert.ok(frame); + assert.equal(frame.continuation.kind, 'ordinaryCardDiscard'); + assert.equal(frame.reaction?.stage, 'offer'); + assert.deepEqual(frame.batch.entries.map(entry => entry.card.id), [RECRUITS_CARD_ID]); + assert.equal(pending.recruits?.turn, pending.turn); + const event = frame.batch.event; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(pending, holder); view.players.find(player => player.id === holder)!.bot = difficulty; + assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass')); + } + let done = pending; + if (claim) { + done = applyAction(done, holder, {type:'semutaCommit', event}); + if (done.pendingTreacheryDiscard?.reaction?.stage === 'select') + done = applyAction(done, holder, {type:'semutaSelect', event, card:RECRUITS_CARD_ID}); + } else for (const player of pending.players) done = applyAction(done, player.id, {type:'semutaPass', event}); + assert.equal(done.pendingTreacheryDiscard, null); + assert.equal(viewGame(done, owner).revival.freeRemaining, 4); + assert.equal(viewGame(done, owner).revival.limit, 7); + assert.equal(seat(done, holder).hand.some(card => card.id === RECRUITS_CARD_ID), claim); + assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim)); + assert.deepEqual(inventory(done), original); + const tanks = seat(done, owner).tanks, reserves = seat(done, owner).reserves; + done = applyAction(done, owner, {type:'revive', amount:3, elite:0}); + assert.equal(seat(done, owner).tanks, tanks - 3); + assert.equal(seat(done, owner).reserves, reserves + 3); + }); +} diff --git a/tests/spiritual-atomics-shipment.test.ts b/tests/spiritual-atomics-shipment.test.ts new file mode 100644 index 00000000..c310478f --- /dev/null +++ b/tests/spiritual-atomics-shipment.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; +import { createTerrorState, placeTerror } from '../game/moritani-terror'; +import { spiritualAdvisorTargetBlock } from '../game/spiritual-advisor'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { quoteGuildAmbassadorShipment, guildAmbassadorShipments } from '../game/guild-ambassador'; +import { quoteGuildAmbassadorMarker, markerDestinations } from '../game/guild-ambassador-no-field'; +import { createRicheseNoField } from '../game/richese-no-field'; + +const player = (game: Game, id: string) => { + const found = game.players.find(seat => seat.id === id); + assert.ok(found); return found; +}; +const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); + +/** Stage a conserved Advanced position, then use the real entry, Guild + * Ambassador shipment and physical Terror token. No pending receipt is staged. */ +function atomicsDuringGuildAmbassador(): Game { + const game = createGame('SPIRITUALATOMICS', newPlayer('ec', 'Ecaz', 'ecaz'), true, ['ecaz']); + game.players.push(newPlayer('in', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'), + newPlayer('m', 'Moritani', 'moritani'), newPlayer('bg', 'Bene Gesserit', 'beneGesserit')); + Object.assign(game, {status:'playing', phase:5, turn:2, storm:18, active:'in', deck:baseDeck(), + discard:[], order:game.players.map(seat => seat.id), movementRemaining:game.players.map(seat => seat.id), + response:null, decision:null, phaseOpening:null}); + for (const seat of game.players) Object.assign(seat, {hand:[], forces:{}, reserves:20, tanks:0, + spice:20, traitors:[], traitorChoices:[], shipped:false, moved:0}); + player(game,'ec').ally='a'; player(game,'a').ally='ec'; + player(game,'in').forces={'imperial_basin:10':1}; player(game,'in').reserves=19; + player(game,'in').shipped=true; + let ambassadors = createAmbassadors(() => 0.2); + const guild = ambassadors.tokens.find(token => token.effect === 'guild'); assert.ok(guild); + ambassadors.cohort = [guild.id, ...ambassadors.tokens.filter(token => token.effect !== 'ecaz' && token.id !== guild.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; + } + ambassadors = placeAmbassador(ambassadors, guild.id, {turn:1, availableSpice:20, + destination:{id:'arrakeen', stronghold:true, inStorm:false, allowed:true}}).state; + game.ecazAmbassadors = ambassadors; + let terror = createTerrorState(() => 0); + const atomics = terror.tokens.find(token => token.kind === 'atomics'); assert.ok(atomics); + terror = placeTerror(terror, atomics.id, 'sietch_tabr', 1); + game.moritaniTerror = terror; + let next = act(game, 'in', {type:'move', forces:{'imperial_basin:10':1}, territory:'arrakeen', sector:10}); + assert.equal(next.pendingAmbassador?.stage, 'offer'); + next = act(next, 'ec', {type:'decision', event:next.pendingAmbassador!.event, trigger:true, beneficiary:'a'}); + next = act(next, 'a', {type:'decision', event:next.pendingAmbassador!.event, + amount:2, territory:'sietch_tabr', sector:14}); + assert.equal(next.pendingTerrorEntry?.entrant, 'a'); + next = act(next, 'm', {type:'decision', reveal:true}); + assert.equal(next.moritaniAtomics?.territory, 'sietch_tabr'); + assert.equal(player(next,'a').forces['sietch_tabr:14'] ?? 0, 0); + assert.equal(player(next,'a').tanks, 2); + assert.equal(next.decision?.kind, 'advisor'); + assert.equal(next.decision?.player, 'bg'); + return next; +} + +void test('Atomics Aftermath prevents Spiritual shipments in either rules mode without blocking other destinations', () => { + for (const advanced of [false,true]) { + const ctx = {advanced, players:[newPlayer('bg','Bene Gesserit','beneGesserit')], + moritaniAtomics:{territory:'sietch_tabr',turn:2,moritaniId:'m',allyAtActivation:null,alliancePolicy:'activation-targets' as const}}; + assert.match(spiritualAdvisorTargetBlock(ctx,'bg','sietch_tabr')!, /Atomics Aftermath/); + assert.equal(spiritualAdvisorTargetBlock(ctx,'bg','polar_sink'), null); + assert.equal(spiritualAdvisorTargetBlock(ctx,'bg','arrakeen'), null); + } +}); + +void test('Guild Ambassador Atomics blocks its later BG accompaniment, retaining Polar Sink and original shipment', () => { + let game = atomicsDuringGuildAmbassador(); + const bg = player(game,'bg'), priorReserve = bg.reserves, priorTanks = player(game,'a').tanks; + assert.throws(() => quoteGuildAmbassadorShipment(game,'a',{type:'decision',amount:1, + territory:'sietch_tabr',sector:14}), /Atomics Aftermath/); + assert.ok(!guildAmbassadorShipments(game,'a').destinations.some(choice => choice.territory === 'sietch_tabr')); + assert.ok(guildAmbassadorShipments(game,'a').destinations.some(choice => choice.territory !== 'sietch_tabr')); + const choices = viewGame(game,'bg').spiritualAdvisorChoices; assert.ok(choices); + const local = choices.choices.filter(choice => choice.accompany && choice.territory === 'sietch_tabr'); + assert.ok(local.length && local.every(choice => choice.blocked?.includes('Atomics Aftermath'))); + assert.ok(choices.choices.some(choice => !choice.accompany && choice.territory === 'polar_sink' && !choice.blocked)); + const initial = structuredClone(game); + assert.throws(() => act(game,'bg',{type:'decision',accept:true,accompany:true,territory:'sietch_tabr',sector:14}), /Atomics Aftermath/); + assert.deepEqual(game,initial); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game,'bg'); view.players.find(seat => seat.id === 'bg')!.bot = difficulty; + const actions = botActions(view); assert.ok(actions.length); + assert.ok(actions.every(action => !(action.type === 'decision' && action.accept === true && action.accompany === true))); + } + game = act(game,'bg',{type:'decision',accept:true,accompany:false,territory:'polar_sink',sector:0}); + for (let count=0;game.response && count<50;count++) { + const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(next); + game = act(game,next.id,{type:'passResponse'}); + } + assert.equal(player(game,'bg').reserves, priorReserve-1); + assert.equal(player(game,'bg').forces['polar_sink:0'],1); + assert.equal(player(game,'bg').forces['sietch_tabr:14'] ?? 0,0); + assert.equal(player(game,'a').tanks, priorTanks); + assert.equal(game.pendingAmbassador ?? null, null); +}); + +void test('a pre-existing Aftermath also blocks native and allied Guild Ambassador No-Field markers', () => { + for (const beneficiary of ['native','allied'] as const) { + const game = createGame('ATOMICSNOFIELD', newPlayer('p','Recipient',beneficiary === 'native' ? 'richese' : 'ecaz'), false); + game.players.push(newPlayer('q','Other','atreides'), newPlayer('r','Supplier',beneficiary === 'native' ? 'ecaz' : 'richese')); + Object.assign(game,{status:'playing',turn:2,phase:5,storm:18,active:'q',order:['p','q','r'], + moritaniAtomics:{territory:'sietch_tabr',turn:1,moritaniId:'m',allyAtActivation:null,alliancePolicy:'activation-targets'}}); + for (const seat of game.players) Object.assign(seat,{forces:{},reserves:20,tanks:0,spice:5, + elites:undefined,hand:[],shipped:true,moved:2}); + const owner = game.players.find(seat => seat.faction === 'richese')!; + owner.noField = createRicheseNoField(['token0','token3','token5']); owner.noFieldEvent = 'selection-event'; + if (owner.id !== 'p') {owner.ally='p';player(game,'p').ally=owner.id;} + const selection = {tokenId:'token3',tokenEvent:'selection-event',territory:'sietch_tabr',sector:14}; + assert.throws(() => quoteGuildAmbassadorMarker(game,'p',selection), /Atomics Aftermath/); + const destinations=markerDestinations(game,'p'); + assert.ok(!destinations.some(choice => choice.territory === 'sietch_tabr')); + assert.ok(destinations.some(choice => choice.territory !== 'sietch_tabr')); + assert.throws(() => quoteGuildAmbassadorShipment(game,'p',{type:'decision',amount:1, + territory:'sietch_tabr',sector:14}), /Atomics Aftermath/); + } +});