diff --git a/components/game-table.tsx b/components/game-table.tsx index d7c3a59e..a5542b5e 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -42,6 +42,7 @@ import { NexusAdvisors } from './nexus-advisors'; import { NexusSardaukar } from './nexus-sardaukar'; import { TerrorBoardMarkers } from './terror-board-markers'; import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options'; +import { atomicsAllianceChangeBlocked } from '@/game/moritani-atomics'; import { GuildHomeworldShipment } from './guild-homeworld-shipment'; import { JunctionTransport } from './junction-transport'; import { BiddingEnd } from './bidding-end'; @@ -830,6 +831,8 @@ export function GameTable({ : null, ) !== p.answer, ); + const atomicsShipmentBlocked = + a.type === 'ship' && a.territory === g.moritaniAtomics?.territory; return (

- Robbery, Sabotage, Sneak Attack and ordinary native-leader - Assassination support eligible arrivals. Enemy of My Enemy has - partial alliance controls. Atomics and Extortion are not yet - available; complete Moritani games remain unavailable. + Robbery, Sabotage, Sneak Attack, ordinary native-leader + Assassination and Extortion support eligible arrivals. Atomics + is available only on supported tables. Enemy of My Enemy has + partial alliance controls; complete Moritani games remain + unavailable.

)} diff --git a/components/terror-board-markers.tsx b/components/terror-board-markers.tsx index 509e626f..707d702d 100644 --- a/components/terror-board-markers.tsx +++ b/components/terror-board-markers.tsx @@ -1,26 +1,44 @@ import { territory } from '@/game/board'; +import type { GameView } from '@/game/engine'; -/** The map shows public stack counts without reading any hidden token face. */ -export function TerrorBoardMarkers({ tokens }: { +/** Public map markers never inspect the face of a placed Terror token. */ +export function TerrorBoardMarkers({ tokens, atomics }: { tokens: readonly { status: string; location: string | null }[]; + atomics?: GameView['moritaniAtomics']; }) { const groups = new Map(); for (const token of tokens) if (token.status === 'placed' && token.location) groups.set(token.location, (groups.get(token.location) ?? 0) + 1); - return [...groups].map(([id, count]) => { - const place = territory(id); - const label = `${count} hidden Terror token${count === 1 ? '' : 's'} in ${place.name}`; - return ( - - {label} - {count > 1 && } - - - {count === 1 ? 'T' : `T${count}`} - - - ); - }); + const aftermath = atomics && territory(atomics.territory); + const handLimitLabel = atomics?.allyAtActivation + ? 'Moritani and the ally at activation have hand limits reduced by one' + : 'Moritani has a hand limit reduced by one'; + return ( + <> + {[...groups].map(([id, count]) => { + const place = territory(id); + const label = `${count} hidden Terror token${count === 1 ? '' : 's'} in ${place.name}`; + return ( + + {label} + {count > 1 && } + + + {count === 1 ? 'T' : `T${count}`} + + + ); + })} + {aftermath && ( + + {`Atomics Aftermath in ${aftermath.name}: no shipments, including Fremen reinforcements; ordinary movement and Sneak Attack remain legal. ${handLimitLabel}.`} + + A + + )} + + ); } diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ff32151a..ef24664e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # Current development status -Updated 28 September 2026. **The full twelve-faction goal is unfinished. Basic +Updated 29 September 2026. **The full twelve-faction goal is unfinished. Basic is playable and the six classic factions have a user-authorized Advanced preview. Expansion starts and full rules/publication acceptance remain gated.** @@ -17,9 +17,10 @@ A summoned worm during the overlap retains its Nexus before the queued ride; Robbery overflow and Sabotage discards preserve physical custody. The table shows public owner order without disclosing the hidden Terror face; existing owned controls and minimal legal AI remain usable. Competing BG Intrusion, -Fremen/Guild Ambassador child arrivals, Atomics and optional-module combinations -remain gated, as do normal public expansion starts. This source is not in the -last verified live image. +Fremen/Guild Ambassador child arrivals and optional-module combinations remain +gated, as do normal public expansion starts. The bounded Atomics path below +does not open these overlap combinations. This source is not in the last +verified live image. A bounded [Guild native rate Karama path](GUILD_RATE_KARAMA_RULES.md#native-transport-extension--29-september-2026) now interrupts classic Guild/reciprocal-ally ordinary reserve shipments and @@ -45,8 +46,11 @@ was selected by the user, not sourced as a publisher priority ruling. Public notices hide the current payer's affordability from other seats. Owner controls, legal AI, JSON/SQLite CAS, nested Box/gift interruptions and a genuine late Mentat Ambassador entry have focused coverage; each still settles before the -turn ends. Atomics/Aftermath and complete expansion starts stay gated. This -local change is not in the last verified live image below. +turn ends. A separate bounded classic-plus-Moritani Atomics path now records +public Aftermath with permanent shipment prohibition and a one-card hand-limit +reduction for Moritani and its ally at activation; later alliance changes are +gated pending a ruling. Complete expansion starts stay gated. These local +changes are not in the last verified live image below. The live NAS Apps `dune` image is the immutable `ghcr.io/quintine/dune:sha-c9a3625ff2a9ddafdbc08e64de809eca6029db85` @@ -98,7 +102,18 @@ remote ride order across JSON/isolated SQLite recovery and duplicate CAS. Natural and summoned offer/ride browser surfaces were exercised. Review found and fixed an inherited-key force-custody bypass. A second Cunning offer inside an interrupted control, Great Maker, combined modules, Secret Ally -protection and complete Nexus games remain gated. +protection and complete Nexus games remain gated. The bounded Atomics reveal +is a separate classic-plus-Moritani path, not a playable Nexus Terror effect; +its later-alliance hand-limit policy is unresolved. + +The bounded [Fremen Nexus Betrayal movement alternative](NEXUS_FREMEN_RULES.md#bounded-betrayal-movement-range) +lets an unallied holder spend the physical card before Fremen move in +Shipment and Movement. The native two-territory advantage stays suppressed +through that turn, while independent ornithopters and ordinary moves remain +legal. Owner controls, a prioritized legal bot play, Basic/Advanced JSON +continuation and an authenticated concurrent SQLite CAS test are connected. +The card's separate worm-riding suppression alternative and combined modules +remain unfinished; no public Nexus gate changes. The bounded [Ecaz Nexus Betrayal](NEXUS_ECAZ_RULES.md#bounded-betrayal-runtime) path lets a card holder select one public territory shared by Ecaz and its diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 02107ce7..ab87c674 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,34 @@ # Dune implementation status +## 29 September 2026 — bounded Moritani Atomics Aftermath + +In classic-plus-Moritani tables without Guild, Ecaz or optional modules, a +placed Atomics Terror token may be revealed against an eligible original +entry. The existing arrival is never replayed. All physical forces in that +territory enter the Tanks, the revealed token leaves play, and one public +Aftermath marker permanently bars shipments there, including Fremen free +reinforcements. Ordinary movement and the printed Sneak Attack exception +remain available. From detonation Moritani and its ally at activation lose +one Treachery hand slot; excess cards are discarded randomly through the +saved fresh-discard continuation, not by identity chosen by another seat. +The unresolved later-alliance penalty is conservatively gated before any +Moritani alliance offer or break, while unrelated alliances remain possible. + +Seventeen focused pure, Basic/Advanced gameplay, owner/rival component and +authenticated SQLite CAS/restart cases pass. They cover elite counters, +unallied and Harkonnen hand limits, physical card custody, privacy, Fremen +shipment rejection, movement and Sneak Attack, and corrupted saved state. +A direct non-test Basic reveal preserved two random discards, two lost +armies, reduced projected hand limits and public Aftermath through JSON. +Read-only browser inspection showed the owner-only reveal, no rival offer, +public Aftermath notice and map marker; no room was written. Independent +review found an impossible post-Aftermath Nexus bot alliance; the shared +pre-offer guard and corresponding player controls now leave bots a legal +Ready path. Typecheck, lint, the app build, **5,747/5,747 offline tests** +and **54/54 local HTTP integration cases** passed. This is not a complete +Moritani faction or release gate. See +[source-bound Atomics scope](MORITANI_TERROR_RULES.md#bounded-atomicsaftermath-checkpoint--29-september-2026). + ## 29 September 2026 — bounded Fremen Nexus Betrayal movement alternative An unallied holder may spend the one physical Fremen Nexus card before Fremen diff --git a/docs/MORITANI_TERROR_RULES.md b/docs/MORITANI_TERROR_RULES.md index 865238e6..721c9e2c 100644 --- a/docs/MORITANI_TERROR_RULES.md +++ b/docs/MORITANI_TERROR_RULES.md @@ -83,6 +83,8 @@ These are software integration recommendations, not newly asserted publisher tim | Sabotage recipient hand overflow | The normal case frees a slot before the gift. If there was no discardable card or another exceptional capacity change, this paragraph supplies no additional forced-discard procedure. | | One placement; unrevealed token retained on decline; no normal movement ban from Aftermath | Ordinary composition of the singular option, optional trigger and shipment-specific prohibition. These do not need invented restrictions or a hypothetical FAQ before implementation. | +The requested source search also found no publisher/designer ruling for a later alliance change. The [printed expansion rulebook, p. 5](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf#page=5) says “From this turn forward, your hand limit is reduced by 1 (as well as your ally’s), discarding a random card if a hand exceeds the limit”; it does not identify which ally after a change. Do not infer a current-ally or original-ally policy from search summaries. + Verification commands: `pdftotext -layout /tmp/dune-rules/ecaz-mirror.pdf /tmp/dune-rules/ecaz-audit.txt`; `pdftoppm -f 3 -singlefile -scale-to 2400 -png /tmp/dune-rules/ecaz-mirror.pdf /tmp/dune-rules/ecaz-p3-audit`; equivalent p. 6 render; `node_modules/.bin/oxfmt --check docs/MORITANI_TERROR_RULES.md`. Source inspection and document formatting only; no runtime behavior tests or live-room actions were performed. ## Additional visual verification: Ecaz native leader discs @@ -128,3 +130,9 @@ Acceptance/refusal, cancellation, hidden return custody, former-alliance cleanup The earlier Extortion absence and unresolved-order checkpoints above are historical. The user chose normal Terror placement first (including any Karama response), then Extortion bank collection and storm-order payment choices. Revealing Extortion now reserves five bank spice without making it spendable; the placement opportunity closes before the five is credited. A first non-Moritani player's three-spice payment permanently removes the token. If every player declines, the existing return operation rotates all hidden supply IDs before Extortion can be placed on a later turn. The original triggering entry does not replay on reload, and CHOAM/victory continuations wait until the payment window settles. This placement-relative timing is a user interpretation, not a located publisher ruling; see [the exact source sequence and selected boundary](MORITANI_EXTORTION_RULES.md). Atomics/Aftermath and complete Moritani expansion starts remain gated. Independent review repaired current-payer affordability disclosure and continuation custody for Nullentropy Box and Richese gifts. A real Advanced Homeworld Fremen Ghola return during Mentat triggered a Guild Ambassador shipment into Extortion **after** Moritani's placement; the bank award and payment window settled before the next turn. The late path does not justify opening full Homeworld/expansion combination gates. + +## Bounded Atomics/Aftermath checkpoint — 29 September 2026 + +The printed Atomics contract above is connected for a bounded classic-plus-Moritani prototype without Guild, Ecaz or optional modules and with Moritani's alliance stable after activation. On an eligible entry the owner may leave Atomics hidden or reveal it; revealing kills every faction's forces in the territory and records public Aftermath there. The public map and Terror notice identify the territory and lasting shipment ban without identifying a still-placed hidden token's face. The owner sees the consequences before choosing to reveal. Ordinary movement and Sneak Attack are not prohibited by Aftermath, but shipments into that territory, including Fremen reinforcements, are. + +From activation turn forward, Moritani and the ally **at activation** each have a hand limit reduced by one. Each discards one random card per excess card on resolution, and their projected hand limits reflect the lasting penalty. The Aftermath record includes the territory, turn, Moritani seat and original ally seat; it is not a second secret Terror face. The publisher paragraph ([E3-M, p. 5](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf#page=5)) does not resolve what happens on later alliance change, so that transition remains gated rather than assigning the penalty to a new ally by inference. This checkpoint does not enable complete Moritani games or public expansion starts; other reactions and module combinations retain their own gates. Earlier absence statements above describe their dated checkpoints, not current behavior. diff --git a/game/bot-arrival.ts b/game/bot-arrival.ts index 95b241b3..c44a7163 100644 --- a/game/bot-arrival.ts +++ b/game/bot-arrival.ts @@ -46,6 +46,8 @@ export function botArrivalBlock(g: GameView, action: Action): string | null { }; let order: CompletedMovementArrivalInput['order']; if (action.type === 'ship') { + if (g.moritaniAtomics?.territory === to) + return 'Atomics Aftermath permanently blocks shipments into this territory.'; const amount = action.noField === undefined ? action.amount : 1; if ( typeof amount !== 'number' || diff --git a/game/bots.ts b/game/bots.ts index 266697cc..29e1a380 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -21,6 +21,7 @@ import { fremenBetrayalBotActions } from './nexus-fremen-betrayal-options'; import { ecazInquiryBotActions } from './nexus-ecaz-inquiry-options'; import { nexusEcazDukeBotActions } from './nexus-ecaz-duke-options'; import { ecazBetrayalOffer } from './nexus-ecaz-betrayal'; +import { atomicsAllianceChangeBlocked } from './moritani-atomics'; import { fremenBetrayalOffer } from './nexus-fremen-betrayal'; import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup'; import { choamPowerAction, choamPowerBotPlay } from './choam-power-options'; @@ -2844,13 +2845,15 @@ function policyActions(g: GameView): Action[] { const offer = g.players.find( (player) => player.id !== me.id && g.allianceOffers[player.id] === me.id && + !atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, player.id) && !g.homeworldAllianceBlocks?.[player.id], ); if (offer) return [{ type: 'alliance', target: offer.id }]; if (level > 0 && !g.ready.includes(me.id) && !g.allianceOffers[me.id]) { const target = g.players.find( (player) => player.id !== me.id && - !player.ally && !g.homeworldAllianceBlocks?.[player.id], + !player.ally && !g.homeworldAllianceBlocks?.[player.id] && + !atomicsAllianceChangeBlocked(g.moritaniAtomics, me.id, player.id), ); if (target) return [{ type: 'alliance', target: target.id }]; } diff --git a/game/engine.ts b/game/engine.ts index 1d6be265..12ed3242 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -58,6 +58,7 @@ import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement import { moritaniBetrayalOffer } from './nexus-moritani-betrayal'; import { ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot, type EcazBetrayalSnapshot } from './nexus-ecaz-betrayal'; import { fremenBetrayalOffer, fremenBetrayalSignature, validateFremenBetrayal, type FremenNexusBetrayal } from './nexus-fremen-betrayal'; +import { atomicsAllianceChangeBlocked, atomicsAllianceStatus, atomicsShipmentBlocked, quoteMoritaniAtomics, type AtomicsAftermath, type AtomicsQuote } from './moritani-atomics'; import { nexusMoritaniRecordSignature, terrorLocationAllowed, terrorEntryLocationAllowed } from './terror-location'; import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam'; import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations'; @@ -591,6 +592,8 @@ export type Player = { ixMovementBlocked?: { turn: number; move: number }; fremenMovementBlocked?: { turn: number; move: number }; fremenNexusMovementBlockedTurn?: number; + /** Permanent printed one-card reduction after Atomics; bound to its Aftermath receipt. */ + atomicsHandLimitPenalty?: boolean; elites?: { reserves: number; tanks: number; @@ -1155,6 +1158,12 @@ export type Game = { entry: NonNullable; discardedHandSize: number; } + | { + kind: 'terrorAtomics'; + entry: NonNullable; + aftermath: AtomicsAftermath; + handBefore: { player: string; count: number }[]; + } | { kind: 'battleResolved'; event: string; @@ -1414,6 +1423,7 @@ export type Game = { ambassadorEvent?: string; } | null; moritaniTerror?: TerrorState; + moritaniAtomics?: AtomicsAftermath | null; moritaniExtortion?: ExtortionState; grummanCollection?: GrummanCollection; pendingMoritaniPlacement?: { @@ -4217,8 +4227,81 @@ function richeseGiftView(g: Game, viewer: Player) { : null, }; } -export const handLimit = (p: Pick) => - p.faction === 'harkonnen' ? 8 : p.faction === 'choam' ? 5 : 4; +export const handLimit = ( + p: Pick & Partial>, +) => + (p.faction === 'harkonnen' ? 8 : p.faction === 'choam' ? 5 : 4) - + Number(p.atomicsHandLimitPenalty === true); + +function atomicsRule(run: () => T): T { + try { return run(); } + catch (error) { + if (error instanceof Error) throw new RuleError(error.message); + throw error; + } +} +/** Only the clean printed-territory entry is connected; other arrival parents remain gated. */ +function atomicsModeSupported(g: Game): boolean { + return !!byFaction(g, 'moritani') && !byFaction(g, 'ecaz') && + !byFaction(g, 'guild') && + g.players.every(p => CLASSIC_FACTIONS[p.faction] === true || p.faction === 'moritani') && + g.expansions.every(expansion => expansion === 'ecaz') && + !g.homeworlds && !g.nexusCards && !g.leaderSkills && + !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && + !g.ecazTreachery && !g.pendingArrivalOverlap; +} +function currentAtomicsQuote( + g: Game, + entry: NonNullable, +): AtomicsQuote { + requireRule(atomicsModeSupported(g) && !g.moritaniAtomics && !g.moritaniExtortion, + 'Atomics with these combined modules or another unresolved Terror effect is not yet supported.'); + const token = g.moritaniTerror?.tokens.find(row => row.id === entry.token); + requireRule(token, 'The Atomics token has lost its original stronghold.'); + return atomicsRule(() => quoteMoritaniAtomics({ + token, territory: entry.territory, moritaniId: byFaction(g, 'moritani')!.id, + turn: g.turn, aftermath: g.moritaniAtomics ?? null, + players: g.players.map(player => ({ + ...player, handSize: player.hand.length, baseHandLimit: handLimit(player), + })), + })); +} +function resolveAtomics( + g: Game, + entry: NonNullable, + quote: AtomicsQuote, +) { + g.moritaniAtomics = quote.aftermath; + for (const casualty of quote.casualties) { + const player = getPlayer(g, casualty.playerId); + kill(g, player, casualty.location, casualty.normal + casualty.elite, false, casualty.elite); + } + for (const player of g.players) + if (!at(player, quote.aftermath.territory) && player.advisors) + delete player.advisors[quote.aftermath.territory]; + observeOccupation(g); + const handBefore: { player: string; count: number }[] = []; + const entries: FreshDiscardBatch['entries'][number][] = []; + for (const reduction of quote.handReductions) { + const player = getPlayer(g, reduction.playerId); + handBefore.push({ player: player.id, count: player.hand.length }); + player.atomicsHandLimitPenalty = true; + requireRule(handLimit(player) === reduction.limit, + 'Atomics has lost its original hand-limit reduction.'); + for (let i = 0; i < reduction.randomDiscards; i++) { + const card = player.hand[Math.floor(random() * player.hand.length)]; + entries.push({ card: discard(g, player, card.id), + discardedBy: player.id, publicFace: false }); + } + } + log(g, `Atomics destroyed every force in ${territory(quote.aftermath.territory).name}. Aftermath permanently blocks shipments there, including Fremen reinforcements. ${quote.aftermath.allyAtActivation ? 'Moritani and its ally each lost' : 'Moritani lost'} one Treachery hand slot; ${entries.length} excess ${entries.length === 1 ? 'card was' : 'cards were'} discarded at random.`); + if (entries.length) { + g.pendingTerrorEntry = null; + g.decision = null; + stageTreacheryDiscard(g, 'terror:atomics', entries, + { kind: 'terrorAtomics', entry, aftermath: quote.aftermath, handBefore }); + } else finishTerrorEntry(g); +} export function newPlayer(id: string, name: string, f: FactionId): Player { requireRule( name.trim().length > 0 && name.trim().length <= 32, @@ -5207,6 +5290,37 @@ function treacheryDiscardIntegrity(g: Game) { (sabotage || discardedHandSize > handLimit(owner)), 'The saved Terror discard does not match its consumed entry and hand.', ); + } else if (continuation?.kind === 'terrorAtomics') { + const { entry, aftermath, handBefore } = continuation; + atomicsIntegrity(g); + const owner = byFaction(g, 'moritani'); + const token = g.moritaniTerror?.tokens.find(row => row.id === entry?.token); + requireRule(!g.pendingTerrorEntry && !g.pendingExchange && !g.summonedWorm && + entry && entry.turn === g.turn && entry.phase === g.phase && + entry.stage === 'offer' && entry.resume === (entry.cause === 'wormRide' ? 'wormRide' : 'none') && + (entry.cause !== 'wormRide' || (g.phase === 1 && + g.players.some(player => player.id === entry.entrant && player.faction === 'fremen'))) && + ['shipment', 'movement', 'advisor', 'wormRide'].includes(entry.cause) && + entry.entrant !== owner?.id && entry.territory === aftermath.territory && + validGameLocation(g, entry.territory, entry.sector) && + token?.kind === 'atomics' && token.status === 'removed' && + token.location === null && + batch.cause === 'terror:atomics' && batch.entries.length > 0 && + batch.entries.every(row => !row.publicFace && + (row.discardedBy === owner?.id || row.discardedBy === aftermath.allyAtActivation)) && + Array.isArray(handBefore) && + handBefore.length === (aftermath.allyAtActivation ? 2 : 1) && + handBefore.every((row, index) => { + const id = index === 0 ? owner?.id : aftermath.allyAtActivation; + const player = g.players.find(seat => seat.id === id); + const discarded = batch.entries.reduce((count, card) => + count + Number(card.discardedBy === row.player), 0); + return player && row.player === id && + Number.isSafeInteger(row.count) && row.count >= 0 && + row.count === player.hand.length + discarded && + discarded === Math.max(0, row.count - handLimit(player)); + }), + 'The saved Atomics random discards lost their revealed entry or hand custody.'); } else if (continuation?.kind === 'ixAllyCard') { const sale = g.currentAuctionSale; const buyer = g.players.find((p) => p.id === continuation.player); @@ -5581,6 +5695,9 @@ function finishTreacheryDiscard(g: Game) { } else if (next.kind === 'terrorDiscard') { g.pendingTerrorEntry = next.entry; continueTerrorDiscard(g, next.source); + } else if (next.kind === 'terrorAtomics') { + g.pendingTerrorEntry = next.entry; + finishTerrorEntry(g); } else { finishBattle(g); } @@ -5988,7 +6105,7 @@ function requireFreshSetup(g: Game, allowIxElites = false) { p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === undefined && p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined && p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined && - p.faceDancers === undefined && + p.atomicsHandLimitPenalty === undefined && p.faceDancers === undefined && p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined; }), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.'); requireRule(Object.keys(g.playerPositions ?? {}).every(id => g.players.some(p => p.id === id)), @@ -12943,7 +13060,8 @@ function decideAmbassador(g: Game, p: Player, action: Action) { function terrorEntryIntegrity(g: Game) { const continuation = g.pendingTreacheryDiscard?.continuation; const entries = [g.pendingTerrorEntry, - continuation?.kind === 'terrorDiscard' ? continuation.entry : null]; + (continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics') + ? continuation.entry : null]; for (const entry of entries) { if (!entry) continue; homeworldRule(() => validateTerrorEntrySignature(entry)); @@ -12953,7 +13071,8 @@ function terrorEntryIntegrity(g: Game) { g.players.filter((p) => p.faction === 'moritani').length === 1, 'The saved Terror entry has lost its original turn or seated participants.'); if (entry.candidates) { - const committedDiscard = continuation?.kind === 'terrorDiscard' && entry === continuation.entry; + const committedDiscard = (continuation?.kind === 'terrorDiscard' || + continuation?.kind === 'terrorAtomics') && entry === continuation.entry; const reserved = entry.candidates.filter((id) => id !== entry.token || (!committedDiscard && ['select', 'offer', 'allianceResponse', 'allianceReply'].includes(entry.stage))); requireRule(reserved.every((id) => g.moritaniTerror?.tokens.filter((token) => @@ -13097,6 +13216,13 @@ function terrorRevealBlocked( ): string | null { const homeworldBlock = homeworldTerrorEntryBlock(g, entry.amount); if (homeworldBlock) return homeworldBlock; + if (kind === 'atomics') { + try { currentAtomicsQuote(g, entry); return null; } + catch (error) { + if (error instanceof RuleError) return error.message; + throw error; + } + } if (kind === 'robbery' || kind === 'sabotage' || kind === 'sneakAttack' || kind === 'extortion') return null; if (kind !== 'assassination') @@ -13169,6 +13295,8 @@ function terrorAllianceBlocked( ): string | null { if (entry.allianceBlocked) return 'Karama prevented this alliance opportunity; you may still reveal the token or leave it hidden.'; + if (g.moritaniAtomics) + return 'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.'; const entrant = getPlayer(g, entry.entrant); const owner = byFaction(g, 'moritani')!; const homeworldBlock = homeworldAllianceReason(g, owner.id, entrant.id); @@ -13185,6 +13313,8 @@ function terrorAllianceBlocked( function formTerrorAlliance(g: Game, owner: Player, entrant: Player) { const blocked = homeworldAllianceReason(g, owner.id, entrant.id); requireRule(!blocked, blocked ?? 'This alliance is unavailable.'); + requireRule(!g.moritaniAtomics, + 'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.'); const changed = new Set([owner.id, entrant.id]); for (const member of [owner, entrant]) { if (member.ally) getPlayer(g, member.ally).ally = null; @@ -13300,7 +13430,8 @@ function decideTerror(g: Game, p: Player, action: Action) { action.reveal === true, 'Reveal the Terror token or leave it hidden.', ); - const blocked = terrorRevealBlocked(g, entry, token.kind); + const atomics = token.kind === 'atomics' ? currentAtomicsQuote(g, entry) : null; + const blocked = atomics ? null : terrorRevealBlocked(g, entry, token.kind); requireRule(!blocked, blocked ?? 'This Terror effect is unavailable.'); g.moritaniTerror = revealTerror(g.moritaniTerror!, token.id); if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[token.id]; @@ -13334,6 +13465,8 @@ function decideTerror(g: Game, p: Player, action: Action) { }, extortionContext(g))).state; log(g, 'Five bank spice is reserved for Moritani until Mentat Pause; it is not yet spendable.'); finishTerrorEntry(g); + } else if (atomics) { + resolveAtomics(g, entry, atomics); } else { const victimCard = shuffle(entrant.hand)[0]; if (victimCard) { @@ -15738,6 +15871,26 @@ function gholaOptions(g: Game, p: Player) { reason, }; } +function atomicsIntegrity(g: Game) { + const aftermath = g.moritaniAtomics; + if (!aftermath) { + requireRule(g.players.every(player => player.atomicsHandLimitPenalty === undefined), + 'Atomics hand-limit penalties require the physical Aftermath.'); + return; + } + const owner = byFaction(g, 'moritani'); + const token = g.moritaniTerror?.tokens.find(row => row.kind === 'atomics'); + requireRule(atomicsModeSupported(g) && owner?.id === aftermath.moritaniId && + Number.isSafeInteger(aftermath.turn) && aftermath.turn >= 1 && + aftermath.turn <= g.turn && TERROR_STRONGHOLDS.includes(aftermath.territory) && + token?.status === 'removed' && token.location === null && + aftermath.alliancePolicy === 'unresolved' && + atomicsAllianceStatus(aftermath, owner.ally) === 'activation-alliance' && + g.players.every(player => + player.atomicsHandLimitPenalty === + (player.id === owner.id || player.id === aftermath.allyAtActivation ? true : undefined)), + 'Atomics Aftermath has lost its revealed token, stable alliance or hand-limit custody.'); +} function marketGholaIntegrity(g: Game) { battleOrderIntegrity(g); saphoAggressorIntegrity(g); @@ -15766,6 +15919,7 @@ function marketGholaIntegrity(g: Game) { grummanCollectionIntegrity(g); homeworldVictoryReturnIntegrity(g); homeworldRevivalReturnIntegrity(g); + atomicsIntegrity(g); terrorEntryIntegrity(g); homeworldRule(() => validateGiediCollection(g, g.turn, g.giediCollection)); currentFactionPayment(g); @@ -20465,6 +20619,8 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean { return true; } function validatePhysicalShipment(g: Game, shipment: PendingShipment) { + requireRule(!atomicsShipmentBlocked(g.moritaniAtomics, shipment.territory), + 'Atomics Aftermath permanently blocks shipments into this territory.'); if (shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined || (g.leaderSkills && shipment.noField)) validateLeaderSkillNoFieldShipment(g, shipment); if (shipment.smuggler) requireRule( @@ -21674,7 +21830,9 @@ function nexusMoritaniIntegrity(g: Game) { 'The Terror token has no valid original placement for this territory.'); } const continuation = g.pendingTreacheryDiscard?.continuation; - for (const entry of [g.pendingTerrorEntry,continuation?.kind === 'terrorDiscard' ? continuation.entry : null]) + for (const entry of [g.pendingTerrorEntry, + continuation?.kind === 'terrorDiscard' || continuation?.kind === 'terrorAtomics' + ? continuation.entry : null]) if (entry) requireRule(terrorEntryLocationAllowed(g,entry), 'The Terror arrival has lost its original Cunning placement permission.'); } @@ -25262,6 +25420,9 @@ function applyActionInner( 'Alliances change only during a Nexus.', ); const target = action.target ? stringField(action.target) : null; + requireRule(!atomicsAllianceChangeBlocked(g.moritaniAtomics, id, target) || + (target === null && !p.ally), + 'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.'); const formerAlly = p.ally ? getPlayer(g, p.ally).name : null; const formerOffer = g.allianceOffers[id]; const formerOfferName = g.players.find((player) => player.id === formerOffer)?.name; @@ -25285,6 +25446,11 @@ function applyActionInner( }, ), ); + if (g.moritaniAtomics) { + const moritani = byFaction(g, 'moritani')!; + requireRule(quote.allies[moritani.id] === moritani.ally, + 'Changing Moritani’s ally after Atomics awaits a hand-limit ruling.'); + } for (const player of g.players) player.ally = quote.allies[player.id]; g.allianceOffers = quote.offers; if (target === null) { @@ -26963,6 +27129,11 @@ export function viewGame(state: Game, id: string) { moritaniTerror: g.moritaniTerror ? projectTerror(g.moritaniTerror, me.faction === 'moritani') : null, + moritaniAtomics: g.moritaniAtomics + ? { territory: g.moritaniAtomics.territory, turn: g.moritaniAtomics.turn, + moritaniId: g.moritaniAtomics.moritaniId, + allyAtActivation: g.moritaniAtomics.allyAtActivation } + : null, arrivalOverlapMode: arrivalOverlapModeSupported(g), arrivalOverlap: g.pendingArrivalOverlap ? { diff --git a/game/moritani-atomics.ts b/game/moritani-atomics.ts new file mode 100644 index 00000000..cb0d9f22 --- /dev/null +++ b/game/moritani-atomics.ts @@ -0,0 +1,155 @@ +import { splitLocation, validLocation } from './board'; +import type { Player } from './engine'; +import type { TerrorToken } from './moritani-terror'; +import { TERROR_STRONGHOLDS } from './moritani-terror'; + +/** The sole Aftermath piece and the ally affected at activation belong in saved Game state. */ +export type AtomicsAftermath = Readonly<{ + territory: string; + turn: number; + moritaniId: string; + allyAtActivation: string | null; + /** A later alliance change has no source-backed hand-limit interpretation yet. */ + alliancePolicy: 'unresolved'; +}>; + +export type AtomicsPlayer = Pick< + Player, + 'id' | 'faction' | 'forces' | 'elites' | 'advisors' | 'ally' | 'noField' | 'tanks' +> & { handSize: number; baseHandLimit: number }; + +export type AtomicsCasualty = Readonly<{ + playerId: string; + location: string; + normal: number; + elite: number; + advisor: number; +}>; +export type AtomicsHandReduction = Readonly<{ + playerId: string; + limit: number; + randomDiscards: number; +}>; +export type AtomicsQuote = Readonly<{ + casualties: readonly AtomicsCasualty[]; + aftermath: AtomicsAftermath; + handReductions: readonly AtomicsHandReduction[]; +}>; + +const whole = (value: number) => Number.isSafeInteger(value) && value >= 0; +function requireAtomics(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +/** Plan, but never apply, the single revealed token's initial effect. No card identities enter this quote. */ +export function quoteMoritaniAtomics(input: { + token: TerrorToken; + territory: string; + players: readonly AtomicsPlayer[]; + moritaniId: string; + turn: number; + aftermath: AtomicsAftermath | null; +}): AtomicsQuote { + const { token, players, moritaniId, turn, territory } = input; + requireAtomics(token.kind === 'atomics' && token.status === 'placed' && + token.location === territory && TERROR_STRONGHOLDS.includes(territory), + 'Atomics requires its still-placed token in the entered ordinary stronghold.'); + requireAtomics(input.aftermath === null, 'Atomics Aftermath is already on the board.'); + requireAtomics(Number.isSafeInteger(turn) && turn >= 1, 'Atomics needs a valid turn.'); + requireAtomics(players.length > 0 && new Set(players.map(p => p.id)).size === players.length && + players.every(p => !!p.id), 'Atomics needs distinct physical players.'); + const moritani = players.find(p => p.id === moritaniId); + requireAtomics(moritani?.faction === 'moritani', 'Atomics requires Moritani ownership.'); + const ally = moritani.ally === null ? null : players.find(p => p.id === moritani.ally); + requireAtomics(moritani.ally === null || (!!ally && ally.id !== moritani.id && + ally.ally === moritani.id), 'Atomics requires a current mutual ally.'); + + const casualties: AtomicsCasualty[] = []; + for (const p of players) { + requireAtomics(whole(p.tanks) && (!p.elites || + (whole(p.elites.tanks) && p.elites.tanks <= p.tanks)), + 'Atomics needs valid Tanks custody.'); + let total = 0, eliteTotal = 0; + // A marker with zero counters is not a casualty. A concealed No-Field with + // physical counters needs its own reveal/custody ruling before this effect. + requireAtomics(p.noField?.deployed?.location.territory !== token.location, + 'Atomics with a concealed No-Field awaits its reveal and casualty rules.'); + for (const key of new Set([...Object.keys(p.forces), + ...Object.keys(p.elites?.forces ?? {})])) { + const { territory, sector } = splitLocation(key); + if (territory !== token.location) continue; + const amount = p.forces[key] ?? 0; + const elite = p.elites?.forces[key] ?? 0; + requireAtomics(key === `${territory}:${sector}` && validLocation(territory, sector) && + whole(amount) && whole(elite) && elite <= amount, + 'Atomics needs valid typed forces in the stronghold.'); + total += amount; + eliteTotal += elite; + if (amount) casualties.push({ playerId: p.id, location: key, + normal: amount - elite, elite, + advisor: p.faction === 'beneGesserit' && p.advisors?.[territory] + ? amount : 0 }); + } + requireAtomics(whole(p.tanks + total) && (!p.elites || + whole(p.elites.tanks + eliteTotal)), 'Atomics casualties overflow Tanks.'); + } + + const handReductions = [moritani, ...(ally ? [ally] : [])].map(p => { + requireAtomics(whole(p.handSize) && Number.isSafeInteger(p.baseHandLimit) && + p.baseHandLimit >= 1, 'Atomics requires authoritative hand counts and limits.'); + const limit = p.baseHandLimit - 1; + return { playerId: p.id, limit, randomDiscards: Math.max(0, p.handSize - limit) }; + }); + return { + casualties, + aftermath: { territory: token.location, turn, moritaniId, + allyAtActivation: ally?.id ?? null, alliancePolicy: 'unresolved' }, + handReductions, + }; +} + +/** Ordinary and Fremen shipments are both blocked; movement and Sneak Attack are not shipments. */ +export function atomicsShipmentBlocked( + aftermath: AtomicsAftermath | null | undefined, + destinationTerritory: string, +): boolean { + return aftermath?.territory === destinationTerritory; +} + +/** A public pre-offer guard avoids promises whose acceptance would change Moritani's unresolved penalty recipient. */ +export function atomicsAllianceChangeBlocked( + aftermath: Pick | null | undefined, + actor: string, + target: string | null, +): boolean { + return !!aftermath && (actor === aftermath.moritaniId || + actor === aftermath.allyAtActivation || + target === aftermath.moritaniId || + target === aftermath.allyAtActivation); +} + +/** Must be checked before interpreting lasting hand limits after any ally change. */ +export function atomicsAllianceStatus( + aftermath: AtomicsAftermath, + currentAllyId: string | null, +): 'activation-alliance' | 'clarification-required' { + return aftermath.alliancePolicy === 'unresolved' && + currentAllyId === aftermath.allyAtActivation + ? 'activation-alliance' : 'clarification-required'; +} + +/** A queried player retains the initial reduction only while the alliance remains unchanged. */ +export function atomicsEffectiveHandLimit( + aftermath: AtomicsAftermath | null | undefined, + playerId: string, + currentAllyId: string | null, + baseLimit: number, +): number { + requireAtomics(Number.isSafeInteger(baseLimit) && baseLimit >= 1, + 'Atomics requires an authoritative base hand limit.'); + if (!aftermath) return baseLimit; + requireAtomics(atomicsAllianceStatus(aftermath, currentAllyId) === 'activation-alliance', + 'A later Moritani alliance change needs an authoritative Atomics hand-limit ruling.'); + return playerId === aftermath.moritaniId || + playerId === aftermath.allyAtActivation ? baseLimit - 1 : baseLimit; +} diff --git a/game/reference.ts b/game/reference.ts index 48c92499..1d91efa4 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2802,9 +2802,9 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Advanced & expansions', coverage: 'Partial', summary: - 'Six hidden tokens threaten stronghold entrants. Moritani starts remain unavailable.', + 'Six hidden tokens threaten stronghold entrants. Bounded Atomics revelation is available in classic-plus-Moritani tables; complete Moritani starts remain unavailable.', steps: [ - 'Placement and isolated entry decisions have controls for Robbery, Sabotage, Sneak Attack and ordinary native-leader Assassination. You may instead leave a token hidden. Arrivals with competing reactions remain unavailable while their timing is clarified; Moritani starts remain disabled.', + 'Placement and isolated entry decisions have controls for Robbery, Sabotage, Sneak Attack, Extortion, ordinary native-leader Assassination and bounded Atomics. You may instead leave a token hidden. Arrivals with unsupported competing reactions remain gated; Moritani starts remain disabled.', 'Begin with all six Terror tokens in your hidden supply. Once during Mentat Pause, you may either place one from supply or move one already placed to another eligible stronghold. There is no spice cost.', 'The destination must have no Terror token. A stronghold under storm is eligible, but the Hidden Mobile Stronghold and Homeworlds are excluded.', 'When another faction ships or moves into the marked stronghold, you may reveal its token and apply the effect to that entrant. Your ally does not trigger this opportunity. Bene Gesserit advisors do.', @@ -2831,14 +2831,14 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'Random assassination, territorial destruction, deferred income, theft, sabotage and reserve entry.', steps: [ - 'Robbery, Sabotage, Sneak Attack, ordinary native-leader Assassination and Extortion have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Atomics and complete Moritani games remain unfinished.', + 'Robbery, Sabotage, Sneak Attack, ordinary native-leader Assassination, Extortion and bounded Atomics have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Complete Moritani games remain unfinished.', 'Assassination: randomly select one of the entrant’s leaders and send it to the Tanks. Moritani collects spice for that leader’s value; Zoal pays three. Revealing the token resolves the random selection without letting Moritani choose a victim. This token needs neither a battle loss nor a matching Traitor Card. Current controls support ordinary native leaders; unresolved captured, foreign or special-leader pools block revelation and show Moritani the reason privately.', - 'Atomics: send every faction’s forces in the territory to the Tanks and place Atomics Aftermath there. Its lasting shipment prohibition and hand-limit penalty are explained in the Aftermath topic.', + 'Atomics: send every faction’s forces in the territory to the Tanks and place public Atomics Aftermath there. Supported revelation is limited to classic-plus-Moritani without Guild, Ecaz or optional modules and with a stable Moritani alliance. Its lasting shipment prohibition and hand-limit penalty are explained in the Aftermath topic.', 'Extortion: set aside five spice from the bank for collection during Mentat Pause. After collection, recover this token unless one player pays you three spice in the storm-order opportunity. The Extortion topic explains the two separate payments.', 'Robbery: choose between taking half the entrant’s spice, rounded up, and drawing the top Treachery Card. If the draw exceeds your hand limit, choose a card to discard afterward; a full hand does not remove the draw option.', 'Sabotage: randomly draw and discard a Treachery Card from the entrant if possible. Then you may give that player a card of your choice from your own hand. Giving a card is optional.', 'Sneak Attack: send up to five of your reserve forces into the triggered territory at no cost, respecting storm and occupancy restrictions. This particular entry is allowed even when Atomics Aftermath is present.', - 'Before revealing Sneak Attack, inspect its private available maximum and any reason positive entry is blocked. You may keep it hidden. Revealing spends the token even if you then choose Send no forces. For a supported entry, select zero through the displayed maximum; forces enter the arrival’s territory and sector without consuming your ordinary shipment or movement turn. Zero remains available when positive entry is blocked. Aftermath’s printed exception does not mean the unfinished Atomics lifecycle is enabled.', + 'Before revealing Sneak Attack, inspect its private available maximum and any reason positive entry is blocked. You may keep it hidden. Revealing spends the token even if you then choose Send no forces. For a supported entry, select zero through the displayed maximum; forces enter the arrival’s territory and sector without consuming your ordinary shipment or movement turn. Zero remains available when positive entry is blocked. Aftermath’s printed exception allows this reserve entry even after Atomics resolves.', 'The random Assassination token is separate from the advanced Assassinate Leaders advantage. Neither ability supplies the other’s selection or timing rules.', ], related: [ @@ -2852,16 +2852,16 @@ export const RULE_TOPICS: RuleTopic[] = [ id: 'moritani-aftermath', title: 'Moritani Atomics and Aftermath', category: 'Advanced & expansions', - coverage: 'Planned', + coverage: 'Partial', summary: - 'Destroy the territory’s forces, prohibit future shipment there, and reduce hand limits.', + 'Bounded Atomics reveals destroy the territory’s forces, prohibit future shipment there, and reduce hand limits.', steps: [ - 'When the Atomics Terror token resolves, all forces in that territory go to the Tanks. Put the separate Atomics Aftermath marker in the territory.', + 'On a supported classic-plus-Moritani table without Guild, Ecaz or optional 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.', '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.', 'Sneak Attack expressly permits its reserve forces to enter despite Aftermath. Its storm and occupancy restrictions still apply.', - 'Beginning this turn, Moritani’s hand limit and its ally’s hand limit are each reduced by one. If a hand exceeds its reduced limit, discard a random card.', - 'The lasting penalty’s treatment after a later alliance change is unresolved. This topic does not decide whether the penalty remains with an old ally or follows a new one.', - 'The Atomics Terror token and Family Atomics Treachery Card are separate components with different effects. Aftermath is not the destroyed Shield Wall.', + '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.', + 'The lasting penalty’s treatment after a later alliance change is unresolved. Supported play gates later Moritani alliance changes rather than guessing whether the penalty remains with an old ally or follows a new one.', + 'The Atomics Terror token and Family Atomics Treachery Card are separate components with different effects. Aftermath is not the destroyed Shield Wall. Complete Moritani games and public expansion starts remain unavailable.', ], related: ['moritani-terror-effects', 'movement', 'card-atomics', 'bidding'], }, diff --git a/tests/ecaz-loyalty-engine.test.ts b/tests/ecaz-loyalty-engine.test.ts index 9e16cb8a..40f16063 100644 --- a/tests/ecaz-loyalty-engine.test.ts +++ b/tests/ecaz-loyalty-engine.test.ts @@ -126,10 +126,9 @@ void test('all four bot profiles can legally perform Ecaz setup', () => { let g = readyTable(['beneGesserit', 'ecaz', 'atreides', 'harkonnen']); g.players.forEach((p) => (p.bot = difficulty)); g = initializeFactionExpansionsGameForAudit(g); - for (let i = 0; i < 20 && g.status === 'setup'; i++) { - g = runBots(g); - if (g.setupStage === undefined) break; - } + // Stop at the setup boundary; a bulk bot run may already finish a short game. + for (let i = 0; i < 200 && g.status === 'setup'; i++) + g = runBots(g, 1); assert.equal(g.status, 'playing', `${difficulty} did not complete setup`); assert.ok(g.ecazLoyalty?.card); } diff --git a/tests/fixture-moritani-atomics.ts b/tests/fixture-moritani-atomics.ts new file mode 100644 index 00000000..096cdbfc --- /dev/null +++ b/tests/fixture-moritani-atomics.ts @@ -0,0 +1,45 @@ +import type { Game } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { createTerrorState, placeTerror } from '../game/moritani-terror'; + +/** A clean classic Moritani arrival; the room variant supplies authenticated IDs. */ +export function atomicsFixtureGame( + game: Game, + ids: { moritani: string; entrant: string; ally: string }, + options: { advanced?: boolean; allied?: boolean; target?: string } = {}, +) { + const { moritani, entrant, ally } = ids; + const owner = game.players.find(p => p.id === moritani)!; + const incoming = game.players.find(p => p.id === entrant)!; + const partner = game.players.find(p => p.id === ally)!; + const target = options.target ?? 'arrakeen'; + Object.assign(game, { + status: 'playing', phase: 5, turn: 2, storm: 18, active: entrant, + order: [entrant, moritani, ally], deck: baseDeck(), + movementRemaining: [entrant, moritani, ally], + }); + for (const p of game.players) + Object.assign(p, { hand: [], spice: 20, forces: {}, reserves: 20, tanks: 0, + shipped: false, moved: 0 }); + if (options.allied !== false) { + owner.ally = ally; + partner.ally = moritani; + } + owner.forces = { 'carthag:11': 1 }; + owner.reserves = 19; + partner.forces = { 'arrakeen:10': 2 }; + partner.reserves = 18; + if (options.advanced) { + incoming.forces = { 'arrakeen:10': 1 }; + incoming.reserves = 19; + incoming.elites = { + reserves: 2, tanks: 0, forces: { 'arrakeen:10': 1 }, revived: 0, + }; + } + owner.hand = game.deck.splice(0, 4); + if (options.allied !== false) partner.hand = game.deck.splice(0, 4); + game.moritaniTerror = createTerrorState(() => 0.2); + const token = game.moritaniTerror.tokens.find(row => row.kind === 'atomics')!; + game.moritaniTerror = placeTerror(game.moritaniTerror, token.id, target, 1); + return { game, token: token.id }; +} diff --git a/tests/moritani-atomics-controls.test.ts b/tests/moritani-atomics-controls.test.ts new file mode 100644 index 00000000..7cac8afb --- /dev/null +++ b/tests/moritani-atomics-controls.test.ts @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MoritaniEntry, MoritaniTerrorSupply } from '../components/moritani-terror'; +import { TerrorBoardMarkers } from '../components/terror-board-markers'; +import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine'; +import { createTerrorState, placeTerror, revealTerror } from '../game/moritani-terror'; + +function fixture() { + const game = createGame('ATOMICS-CONTROLS', newPlayer('m', 'Moritani', 'moritani')); + joinGame(game, newPlayer('a', 'Atreides', 'atreides')); + joinGame(game, newPlayer('f', 'Fremen', 'fremen')); + game.players.find((player) => player.id === 'm')!.ally = 'a'; + game.players.find((player) => player.id === 'a')!.ally = 'm'; + game.moritaniTerror = createTerrorState(() => 0); + const token = game.moritaniTerror.tokens.find((item) => item.kind === 'atomics')!; + game.moritaniTerror = placeTerror(game.moritaniTerror, token.id, 'arrakeen', 1); + return { game, token }; +} + +const supply = (view: GameView) => + renderToStaticMarkup(createElement(MoritaniTerrorSupply, { game: view })); +const markers = (view: GameView) => + renderToStaticMarkup(createElement('svg', null, createElement(TerrorBoardMarkers, { + tokens: view.moritaniTerror?.tokens ?? [], atomics: view.moritaniAtomics, + }))); + +void test('a placed hidden face stays private; only owner sees the Atomics reveal consequences', () => { + const { game } = fixture(); + const owner = viewGame(game, 'm'); + owner.decision = { kind: 'moritaniTerror', player: 'm', entrant: 'f', territory: 'arrakeen' }; + owner.terrorEntry = { + entrant: 'f', territory: 'arrakeen', sector: 10, cause: 'shipment', stage: 'offer', + kind: 'atomics', canReveal: true, canOfferAlliance: false, + }; + const offer = renderToStaticMarkup(createElement(MoritaniEntry, { game: owner, busy: false, act() {} })); + assert.match(offer, /Reveal Atomics/); + assert.match(offer, /every faction’s forces/); + assert.match(offer, /including Fremen reinforcements/); + assert.match(offer, /discards one random card per excess/); + const rival = viewGame(game, 'f'); + assert.match(supply(rival), /Inspect Hidden Terror/); + assert.doesNotMatch(supply(rival), /Inspect Atomics|Atomics Aftermath/); + assert.match(markers(rival), /hidden Terror token in Arrakeen/); + assert.doesNotMatch(markers(rival), /Atomics Aftermath/); + assert.equal(renderToStaticMarkup(createElement(MoritaniEntry, { game: rival, busy: false, act() {} })), ''); +}); + +void test('after revelation all seats see persistent Aftermath and its exact shipment and hand-limit consequences', () => { + const { game, token } = fixture(); + game.moritaniTerror = revealTerror(game.moritaniTerror!, token.id); + game.moritaniAtomics = { territory: 'arrakeen', turn: 2, moritaniId: 'm', + allyAtActivation: 'a', alliancePolicy: 'unresolved' }; + game.turn = 2; + game.players.find((player) => player.id === 'm')!.atomicsHandLimitPenalty = true; + game.players.find((player) => player.id === 'a')!.atomicsHandLimitPenalty = true; + for (const id of ['m', 'a', 'f']) { + const view = viewGame(game, id); + const notice = supply(view); + assert.match(notice, /Atomics Aftermath · Arrakeen/); + assert.match(notice, /From turn 2, no faction may ship forces/); + assert.match(notice, /including Fremen reinforcements/); + assert.match(notice, /ordinary movement and Sneak Attack remain possible/); + assert.match(notice, /Moritani and Atreides/); + assert.match(notice, /lasting hand-limit reduction of one/); + const board = markers(view); + assert.match(board, /Atomics Aftermath in Arrakeen: permanent shipment ban/); + assert.doesNotMatch(board, /hidden Terror token in Arrakeen/); + } +}); diff --git a/tests/moritani-atomics-engine.test.ts b/tests/moritani-atomics-engine.test.ts new file mode 100644 index 00000000..78c7e3d6 --- /dev/null +++ b/tests/moritani-atomics-engine.test.ts @@ -0,0 +1,198 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, createGame, newPlayer, normalizeAutomaticGame, viewGame, + type Action, type Game, +} from '../game/engine'; +import { placeTerror } from '../game/moritani-terror'; +import { botArrivalBlock } from '../game/bot-arrival'; +import { botActions } from '../game/bots'; +import { atomicsFixtureGame } from './fixture-moritani-atomics'; + +const seat = (g: Game, id: string) => g.players.find(p => p.id === id)!; +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +const ship: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 }; +const move: Action = { type: 'move', from: 'imperial_basin:10', territory: 'arrakeen', sector: 10, amount: 2 }; +function rejected(g: Game, id: string, action: Action, pattern?: RegExp) { + const before = structuredClone(g); + if (pattern) assert.throws(() => applyAction(g, id, action), pattern); + else assert.throws(() => applyAction(g, id, action)); + assert.deepEqual(g, before); +} +function fixture(advanced = false, ally = true, target = 'arrakeen') { + const g = createGame('ATOMIC01', newPlayer('m', 'Moritani', 'moritani'), advanced); + g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides')); + const { token } = atomicsFixtureGame(g, + { moritani: 'm', entrant: 'e', ally: 'a' }, + { advanced, allied: ally, target }); + return { g, token }; +} + +for (const advanced of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} Atomics destroys exact groups, discards excess at random and persists Aftermath`, () => { + const { g, token } = fixture(advanced); + const before = structuredClone(g); + const mCards = seat(g, 'm').hand.map(card => card.id); + const aCards = seat(g, 'a').hand.map(card => card.id); + let state = applyAction(g, 'e', ship); + assert.equal(state.pendingTerrorEntry?.stage, 'offer'); + const owner = viewGame(state, 'm'); + assert.equal(owner.terrorEntry?.kind, 'atomics'); + assert.equal(owner.terrorEntry?.canReveal, true); + assert.equal('kind' in (viewGame(state, 'e').terrorEntry ?? {}), false); + assert.equal(viewGame(state, 'e').moritaniAtomics, null); + state = applyAction(reload(state), 'm', { type: 'decision', reveal: true }); + state = normalizeAutomaticGame(reload(state)); + assert.equal(state.moritaniAtomics?.territory, 'arrakeen'); + assert.equal(state.moritaniAtomics?.allyAtActivation, 'a'); + assert.equal(state.moritaniTerror!.tokens.find(row => row.id === token)!.status, 'removed'); + assert.equal(state.pendingTerrorEntry ?? null, null); + assert.equal(state.pendingTreacheryDiscard ?? null, null); + assert.equal(seat(state, 'e').forces['arrakeen:10'] ?? 0, 0); + assert.equal(seat(state, 'm').forces['arrakeen:10'] ?? 0, 0); + assert.equal(seat(state, 'a').forces['arrakeen:10'] ?? 0, 0); + assert.equal(seat(state, 'm').forces['carthag:11'], 1); + assert.equal(seat(state, 'e').tanks, advanced ? 3 : 2); + assert.equal(seat(state, 'e').elites?.tanks ?? 0, advanced ? 1 : 0); + assert.equal(seat(state, 'm').tanks, 0); + assert.equal(seat(state, 'a').tanks, 2); + for (const id of ['m', 'a']) { + assert.equal(seat(state, id).hand.length, 3); + assert.equal(viewGame(state, id).players.find(p => p.id === id)!.handLimit, 3); + assert.equal(seat(state, id).atomicsHandLimitPenalty, true); + } + assert.equal(state.discard.filter(card => mCards.includes(card.id)).length, 1); + assert.equal(state.discard.filter(card => aCards.includes(card.id)).length, 1); + assert.equal(before.moritaniAtomics ?? null, null); + assert.equal(seat(before, 'e').reserves, advanced ? 19 : 20); + for (const id of ['m', 'e', 'a']) + assert.equal(viewGame(state, id).moritaniAtomics?.territory, 'arrakeen'); + rejected(state, 'm', { type: 'decision', reveal: true }); + }); +} + +void test('Aftermath blocks every ordinary shipment, including Fremen reinforcement, but not movement', () => { + const { g } = fixture(false, false); + let state = applyAction(g, 'e', ship); + state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true })); + assert.equal(viewGame(state, 'e').moritaniAtomics?.territory, 'arrakeen'); + state.active = 'a'; + seat(state, 'a').spice = 20; + rejected(state, 'a', ship, /Atomics Aftermath/); + assert.match(String(botArrivalBlock(viewGame(state, 'a'), ship)), /Atomics Aftermath/); + seat(state, 'a').forces = { 'imperial_basin:10': 2 }; + seat(state, 'a').reserves = 18; + seat(state, 'a').shipped = true; + const moved = applyAction(reload(state), 'a', move); + assert.equal(seat(moved, 'a').forces['arrakeen:10'], 2); + assert.equal(moved.moritaniAtomics?.territory, 'arrakeen'); +}); + +void test('unallied Atomics destroys Moritani forces but reduces only its own hand', () => { + const { g } = fixture(false, false); + seat(g, 'a').forces = {}; + seat(g, 'a').reserves = 20; + seat(g, 'm').forces = { 'arrakeen:10': 1, 'carthag:11': 1 }; + seat(g, 'm').reserves = 18; + let state = applyAction(g, 'e', ship); + state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true })); + assert.equal(state.moritaniAtomics?.allyAtActivation, null); + assert.equal(seat(state, 'm').tanks, 1); + assert.equal(seat(state, 'm').forces['carthag:11'], 1); + assert.equal(seat(state, 'm').hand.length, 3); + assert.equal(seat(state, 'a').atomicsHandLimitPenalty, undefined); + assert.equal(viewGame(state, 'a').players.find(p => p.id === 'a')!.handLimit, 4); +}); + +void test('Harkonnen ally loses one of its eight hand slots and one random physical card', () => { + const g = createGame('ATOMHARK', newPlayer('m', 'Moritani', 'moritani')); + g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Harkonnen', 'harkonnen')); + atomicsFixtureGame(g, { moritani: 'm', entrant: 'e', ally: 'a' }); + seat(g, 'a').hand.push(...g.deck.splice(0, 4)); + const before = seat(g, 'a').hand.map(card => card.id); + let state = applyAction(g, 'e', ship); + state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true })); + assert.equal(seat(state, 'a').hand.length, 7); + assert.equal(viewGame(state, 'a').players.find(p => p.id === 'a')!.handLimit, 7); + assert.equal(state.discard.filter(card => before.includes(card.id)).length, 1); +}); + +void test('Fremen free reinforcement into Aftermath is rejected without spending its shipment', () => { + const { g } = fixture(false, false, 'sietch_tabr'); + g.players.push(newPlayer('f', 'Fremen', 'fremen')); + g.order.push('f'); + g.movementRemaining!.push('f'); + const arrival: Action = { type: 'ship', territory: 'sietch_tabr', sector: 14, amount: 1 }; + let state = applyAction(g, 'e', arrival); + state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true })); + state.active = 'f'; + seat(state, 'f').spice = 20; + rejected(state, 'f', arrival, /Atomics Aftermath/); + assert.match(String(botArrivalBlock(viewGame(state, 'f'), arrival)), /Atomics Aftermath/); + assert.equal(seat(state, 'f').shipped, false); +}); + +void test('Sneak Attack remains a free reserve entry into an Atomics Aftermath stronghold', () => { + const { g } = fixture(false, false); + let state = applyAction(g, 'e', ship); + state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true })); + const token = state.moritaniTerror!.tokens.find(row => row.kind === 'sneakAttack')!; + state.moritaniTerror = placeTerror(state.moritaniTerror!, token.id, 'arrakeen', state.turn); + seat(state, 'e').forces = { 'imperial_basin:10': 1 }; + seat(state, 'e').reserves--; + seat(state, 'e').moved = 0; + state.active = 'e'; + const entered = applyAction(state, 'e', { + type: 'move', from: 'imperial_basin:10', amount: 1, + territory: 'arrakeen', sector: 10, + }); + assert.equal(entered.pendingTerrorEntry?.stage, 'offer'); + const revealed = applyAction(entered, 'm', { type: 'decision', reveal: true }); + assert.equal(revealed.pendingTerrorEntry?.stage, 'sneakAttack'); + const done = applyAction(revealed, 'm', { type: 'decision', amount: 1 }); + assert.equal(seat(done, 'm').forces['arrakeen:10'], 1); + assert.equal(done.moritaniAtomics?.territory, 'arrakeen'); +}); + +void test('later Moritani alliance change and forged hand penalties reject atomically', () => { + const { g } = fixture(false, true); + let state = applyAction(g, 'e', ship); + state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true })); + state.phase = 1; + state.nexus = true; + state.spiceWindow = null; + state.spiceResolution = null; + rejected(state, 'm', { type: 'alliance', target: null }, /Atomics/); + for (const mutate of [ + (game: Game) => { delete seat(game, 'a').atomicsHandLimitPenalty; }, + (game: Game) => { game.moritaniAtomics = { ...game.moritaniAtomics!, allyAtActivation: 'e' }; }, + (game: Game) => { game.moritaniTerror!.tokens.find(row => row.kind === 'atomics')!.status = 'placed'; }, + ]) { + const damaged = reload(state); + mutate(damaged); + rejected(damaged, 'e', { type: 'ready' }); + } +}); + +void test('a later Nexus filters impossible Moritani offers before bots can stall', () => { + const { g } = fixture(false, false); + let state = applyAction(g, 'e', ship); + state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true })); + state.phase = 1; + state.nexus = true; + state.spiceWindow = null; + state.spiceResolution = null; + state.ready = []; + state.allianceOffers = { m: 'e' }; + for (const p of state.players) state.deck.push(...p.hand.splice(0)); + seat(state, 'm').bot = 'Medium'; + seat(state, 'e').bot = 'Medium'; + rejected(state, 'e', { type: 'alliance', target: 'm' }, /Atomics/); + rejected(state, 'm', { type: 'alliance', target: 'e' }, /Atomics/); + assert.deepEqual(botActions(viewGame(state, 'm'))[0], { type: 'ready' }); + assert.equal(botActions(viewGame(state, 'e')).some(action => + action.type === 'alliance' && action.target === 'm'), false); + const withdrawn = applyAction(state, 'm', { type: 'alliance' }); + assert.equal(withdrawn.allianceOffers.m, undefined); + assert.equal(withdrawn.players.find(p => p.id === 'm')!.ally, null); +}); diff --git a/tests/moritani-atomics-recovery.test.ts b/tests/moritani-atomics-recovery.test.ts new file mode 100644 index 00000000..859cfbed --- /dev/null +++ b/tests/moritani-atomics-recovery.test.ts @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { RoomsClock } from '../db/rooms'; +import { viewGame, type Action } from '../game/engine'; +import { atomicsFixtureGame } from './fixture-moritani-atomics'; +import { unitStore } from './fixture-nexus-room-store'; + +const clock: RoomsClock = { now: () => 45000, sleep: async () => {} }; +const shipment: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 }; + +void test('Atomics persists one reveal, random overflow, custody and public Aftermath across concurrent SQL workers', async t => { + const store = unitStore(); + t.after(() => store.sqlite.close()); + const made = await store.rooms.createRoom('Atomics SQL', 'moritani', false, []); + const code = made.view.code; + const entrant = await store.rooms.joinRoom(code, 'Emperor', 'emperor'); + const ally = await store.rooms.joinRoom(code, 'Atreides', 'atreides'); + const tokens = [made.token, entrant.token!, ally.token!]; + const auth = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); + const [m, e, a] = auth.map(seat => seat.playerId); + const g = await store.rooms.readRoom(code); + const { token } = atomicsFixtureGame(g, { moritani: m, entrant: e, ally: a }); + const originalM = g.players.find(p => p.id === m)!.hand.map(card => card.id); + const originalA = g.players.find(p => p.id === a)!.hand.map(card => card.id); + g.code = code; + g.version = (await store.rooms.readRoom(code)).version; + store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(g), g.version, code); + store.writes.length = 0; + const rooms = store.restart(); + const initialM = await rooms.readSeatView(code, auth[0]); + const initialE = await rooms.readSeatView(code, auth[1]); + assert.equal(initialM.moritaniAtomics, null); + assert.equal(initialE.moritaniAtomics, null); + assert.equal('kind' in initialE.moritaniTerror!.tokens.find(row => row.id === token)!, false); + + await store.restart().act(code, auth[1], g.version, shipment, clock); + const offered = await store.restart().readRoom(code); + assert.equal(offered.pendingTerrorEntry?.stage, 'offer'); + assert.equal(viewGame(offered, m).terrorEntry?.kind, 'atomics'); + assert.equal('kind' in (viewGame(offered, e).terrorEntry ?? {}), false); + const reveal: Action = { type: 'decision', reveal: true }; + const outcomes = await Promise.allSettled([ + store.restart().act(code, auth[0], offered.version, reveal, clock), + store.restart().act(code, auth[0], offered.version, reveal, clock), + ]); + assert.equal(outcomes.filter(result => result.status === 'fulfilled').length, 1); + assert.equal(outcomes.filter(result => result.status === 'rejected').length, 1); + const saved = await store.restart().readRoom(code); + assert.equal(saved.moritaniAtomics?.territory, 'arrakeen'); + assert.equal(saved.moritaniAtomics?.allyAtActivation, a); + assert.equal(saved.moritaniTerror!.tokens.find(row => row.id === token)!.status, 'removed'); + assert.equal(saved.pendingTerrorEntry, null); + assert.equal(saved.pendingTreacheryDiscard ?? null, null); + assert.equal(saved.players.find(p => p.id === e)!.tanks, 2); + assert.equal(saved.players.find(p => p.id === a)!.tanks, 2); + assert.equal(saved.players.find(p => p.id === m)!.hand.length, 3); + assert.equal(saved.players.find(p => p.id === a)!.hand.length, 3); + assert.equal(saved.discard.filter(card => originalM.includes(card.id)).length, 1); + assert.equal(saved.discard.filter(card => originalA.includes(card.id)).length, 1); + for (let i = 0; i < tokens.length; i++) { + const authFresh = await store.restart().authenticate(code, tokens[i]); + const view = await store.restart().readSeatView(code, authFresh); + assert.deepEqual(view, viewGame(saved, authFresh.playerId)); + assert.equal(view.moritaniAtomics?.territory, 'arrakeen'); + assert.equal(view.players.find(p => p.id === m)!.handLimit, 3); + assert.equal(view.players.find(p => p.id === a)!.handLimit, 3); + } + const beforeReplay = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code); + await assert.rejects(store.restart().act(code, auth[0], saved.version, reveal, clock)); + assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), beforeReplay); + let later = saved; + for (const index of [1, 0]) { + await store.restart().act(code, auth[index], later.version, { type: 'endMovement' }, clock); + later = await store.restart().readRoom(code); + } + assert.equal(later.active, a); + const row = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code); + await assert.rejects(store.restart().act(code, auth[2], later.version, shipment, clock), /Atomics Aftermath/); + await store.restart().continueRoomAutomatic(code, clock); + assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), row); +}); diff --git a/tests/moritani-atomics.test.ts b/tests/moritani-atomics.test.ts new file mode 100644 index 00000000..c0336f54 --- /dev/null +++ b/tests/moritani-atomics.test.ts @@ -0,0 +1,125 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + atomicsAllianceStatus, + atomicsEffectiveHandLimit, + atomicsShipmentBlocked, + quoteMoritaniAtomics, + type AtomicsPlayer, + type AtomicsAftermath, +} from '../game/moritani-atomics'; +import type { TerrorToken } from '../game/moritani-terror'; + +const token: TerrorToken = { + id: 'terror-opaque', kind: 'atomics', status: 'placed', location: 'arrakeen', +}; +const moritani: AtomicsPlayer = { + id: 'm', faction: 'moritani', ally: null, forces: {}, tanks: 0, + handSize: 4, baseHandLimit: 4, +}; +const quote = (players: readonly AtomicsPlayer[], aftermath: AtomicsAftermath | null = null) => + quoteMoritaniAtomics({ token, territory: 'arrakeen', players, + moritaniId: 'm', turn: 3, aftermath }); + +void test('no ally at activation: all owners lose typed forces in the territory, not elsewhere', () => { + const players: AtomicsPlayer[] = [ + { ...moritani, forces: { 'arrakeen:10': 2, 'carthag:11': 3 } }, + { id: 'ix', faction: 'ixians', ally: null, forces: { + 'arrakeen:10': 4, 'carthag:11': 2, + }, elites: { reserves: 0, tanks: 0, revived: 0, + forces: { 'arrakeen:10': 2 } }, tanks: 1, + handSize: 5, baseHandLimit: 4 }, + { id: 'bg', faction: 'beneGesserit', ally: null, + forces: { 'arrakeen:10': 1, 'carthag:11': 2 }, + advisors: { arrakeen: {} }, tanks: 0, handSize: 4, baseHandLimit: 4 }, + { id: 'zero', faction: 'fremen', ally: null, + forces: { 'arrakeen:10': 0 }, tanks: 0, + handSize: 0, baseHandLimit: 4 }, + ]; + const before = structuredClone(players); + const plan = quote(players); + assert.deepEqual(plan.casualties, [ + { playerId: 'm', location: 'arrakeen:10', normal: 2, elite: 0, advisor: 0 }, + { playerId: 'ix', location: 'arrakeen:10', normal: 2, elite: 2, advisor: 0 }, + { playerId: 'bg', location: 'arrakeen:10', normal: 1, elite: 0, advisor: 1 }, + ]); + assert.deepEqual(plan.handReductions, + [{ playerId: 'm', limit: 3, randomDiscards: 1 }]); + assert.deepEqual(plan.aftermath, { territory: 'arrakeen', turn: 3, + moritaniId: 'm', allyAtActivation: null, alliancePolicy: 'unresolved' }); + assert.deepEqual(players, before); + assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'ix', null, 4), 4); + assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'm', null, 4), 3); + assert.equal(atomicsAllianceStatus(plan.aftermath, null), 'activation-alliance'); + assert.equal(atomicsAllianceStatus(plan.aftermath, 'newAlly'), 'clarification-required'); + assert.throws(() => atomicsEffectiveHandLimit(plan.aftermath, 'm', 'newAlly', 4), + /alliance change/); +}); + +void test('activation ally is reduced with Moritani; counts exceed-one overflow without selecting cards', () => { + const plan = quote([ + { ...moritani, ally: 'choam', handSize: 6, baseHandLimit: 4 }, + { id: 'choam', faction: 'choam', ally: 'm', tanks: 0, + forces: { 'arrakeen:10': 1 }, handSize: 6, baseHandLimit: 5 }, + { id: 'other', faction: 'harkonnen', ally: null, tanks: 0, + forces: {}, handSize: 9, baseHandLimit: 8 }, + ]); + assert.deepEqual(plan.handReductions, [ + { playerId: 'm', limit: 3, randomDiscards: 3 }, + { playerId: 'choam', limit: 4, randomDiscards: 2 }, + ]); + assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'choam', 'choam', 5), 4); + assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'other', 'choam', 8), 8); + for (const next of [null, 'other']) { + assert.equal(atomicsAllianceStatus(plan.aftermath, next), 'clarification-required'); + assert.throws(() => atomicsEffectiveHandLimit(plan.aftermath, 'other', next, 8), + /alliance change/); + } +}); + +void test('Aftermath blocks shipment by territory regardless of faction or sector, not other territories', () => { + const aftermath = quote([moritani]).aftermath; + assert.equal(atomicsShipmentBlocked(aftermath, 'arrakeen'), true); + assert.equal(atomicsShipmentBlocked(aftermath, 'carthag'), false); + assert.equal(atomicsShipmentBlocked(null, 'arrakeen'), false); + assert.equal(atomicsShipmentBlocked(aftermath, 'arrakeen:10'), false); +}); + +void test('placement, existing Aftermath, alliance inconsistency and malformed physical custody fail closed', () => { + const aftermath = quote([moritani]).aftermath; + assert.throws(() => quote([moritani], aftermath), /already on the board/); + for (const wrong of [ + { ...token, status: 'removed', location: null } as TerrorToken, + { ...token, kind: 'robbery' } as TerrorToken, + { ...token, location: 'hidden_mobile_stronghold' } as TerrorToken, + { ...token, location: 'carthag' } as TerrorToken, + ]) assert.throws(() => quoteMoritaniAtomics({ token: wrong, + territory: 'arrakeen', moritaniId: 'm', turn: 3, + aftermath: null, players: [moritani] }), + /still-placed token/); + assert.throws(() => quoteMoritaniAtomics({ token, territory: 'carthag', + moritaniId: 'm', turn: 3, aftermath: null, players: [moritani] }), + /entered ordinary stronghold/); + assert.throws(() => quote([{ ...moritani, ally: 'missing' }]), /mutual ally/); + assert.throws(() => quote([{ ...moritani, forces: { 'arrakeen:11': 1 } }]), + /valid typed forces/); + assert.throws(() => quote([{ ...moritani, forces: { 'arrakeen:10': 1 }, + elites: { reserves: 0, tanks: 0, revived: 0, + forces: { 'arrakeen:10': 2 } } }]), /valid typed forces/); + assert.throws(() => quote([{ ...moritani, noField: { + tokens: [{ id: 'zero', value: 0 }], lastShipped: null, + deployed: { tokenId: 'zero', controller: 'm', + location: { territory: 'arrakeen', sector: 10 } }, + } }]), /concealed No-Field/); +}); + +void test('a token at a storm-exposed stronghold still quotes all occupied counters', () => { + const stormExposed = quoteMoritaniAtomics({ + token: { ...token, location: 'tueks_sietch' }, territory: 'tueks_sietch', players: [ + { ...moritani, forces: { 'tueks_sietch:5': 1, 'arrakeen:10': 1 } }, + ], moritaniId: 'm', turn: 4, aftermath: null, + }); + assert.deepEqual(stormExposed.casualties, + [{ playerId: 'm', location: 'tueks_sietch:5', normal: 1, elite: 0, advisor: 0 }]); + assert.equal(stormExposed.aftermath.territory, 'tueks_sietch'); +});