From 5c1c944a4fae0a48dc54dd56007dd8c345e8b289 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:20:06 +1000 Subject: [PATCH] Prototype Moritani Nexus Betrayal token return --- components/game-table.tsx | 2 + components/nexus-moritani-betrayal.tsx | 33 +++++++++ docs/NEXUS_MORITANI_RULES.md | 21 ++++++ game/bots.ts | 3 + game/engine.ts | 18 +++++ game/nexus-moritani-betrayal-options.ts | 14 ++++ game/nexus-moritani-betrayal.ts | 21 ++++++ game/reference.ts | 13 ++-- tests/fixture-nexus-moritani.ts | 3 +- tests/nexus-moritani-betrayal.test.ts | 99 +++++++++++++++++++++++++ 10 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 components/nexus-moritani-betrayal.tsx create mode 100644 game/nexus-moritani-betrayal-options.ts create mode 100644 game/nexus-moritani-betrayal.ts create mode 100644 tests/nexus-moritani-betrayal.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 18f0f31f..390a8620 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -29,6 +29,7 @@ import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries'; import { DiscoveryEntryDecision } from './discovery-entry'; import { DiscoveryStormDecision } from './discovery-storm'; import { NexusChoamTrade } from './nexus-choam-trade'; +import { NexusMoritaniBetrayal } from './nexus-moritani-betrayal'; import { NexusTleilaxu } from './nexus-tleilaxu'; import { NexusSuboids } from './nexus-suboids'; import { NexusAdvisors } from './nexus-advisors'; @@ -1581,6 +1582,7 @@ export function GameTable({ + diff --git a/components/nexus-moritani-betrayal.tsx b/components/nexus-moritani-betrayal.tsx new file mode 100644 index 00000000..ebc7b3e2 --- /dev/null +++ b/components/nexus-moritani-betrayal.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { territory } from '@/game/board'; +import { moritaniBetrayalAction } from '@/game/nexus-moritani-betrayal-options'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; +export function NexusMoritaniBetrayal({ game, act, busy }: Props) { + const [selected, select] = useState(''); + const offer = game.nexusMoritaniBetrayal; + if (!offer) return null; + const token = offer.tokens.find(choice => choice.id === selected) ?? offer.tokens[0]; + const action = token ? moritaniBetrayalAction(game, token.id) : null; + return ( +
+

Moritani Nexus · Betrayal

+

Return one placed Terror token to Moritani’s hidden supply without revealing its face. Spend this Nexus card.

+ {offer.tokens.length > 1 && } + {token &&

Selected: {territory(token.territory).name}

} + {offer.blocked &&

{offer.blocked}

} + +
+ ); +} diff --git a/docs/NEXUS_MORITANI_RULES.md b/docs/NEXUS_MORITANI_RULES.md index 6875d905..3e4066ed 100644 --- a/docs/NEXUS_MORITANI_RULES.md +++ b/docs/NEXUS_MORITANI_RULES.md @@ -52,3 +52,24 @@ Required integration evidence includes genuine Basic/Advanced placement, cancel/ [nexus-moritani.ts](../game/nexus-moritani.ts) now provides the initial Mentat-only historical receipt and immutable supply-placement quote. Its receipt fixes source `mentat` and phase 8; it does not represent a Grumman grant. The quote admits static Arrakis destinations and stacks, validates the six physical faces and custody zones, and leaves ordinary `placeTerror` unchanged. Current source ownership, phase, held Nexus card, authorization of prior non-stronghold custody and allowed/canceled progress remain engine responsibilities. The five focused cases in [nexus-moritani.test.ts](../tests/nexus-moritani.test.ts) cover every static destination, stacks and later ordinary relocation, physical-inventory rejection, historical privacy and altered proof rejection. `npm test -- nexus-moritani` and focused type-aware lint passed. Local links and whitespace were checked. Full checks and integrated runtime verification remain the coordinator's pending work; this pure-module evidence does not lift release gates. + +## Opposing Betrayal token return prototype + +The separate printed Betrayal panel lets an unallied holder return one board +Terror token to Moritani without revealing it. The current [runtime +quote](../game/nexus-moritani-betrayal.ts) offers public placed token IDs and +locations only to that holder. At a clean play boundary, the accepted choice +spends the physical Nexus card and calls the existing hidden-supply return: +the selected token joins Moritani's available supply, all supply IDs rotate, +other placed tokens remain, and Moritani's Mentat placement allowance is not +consumed. Moritani receives the returned face privately. The chronicle names +the location and action, never the hidden face. + +The printed panel specifies no phase; using a clean boundary rather than +interrupting another unresolved response is a development serializer, not a +new printed phase rule. Pending Terror, placement, battle and card continuations +are not interrupted. [Focused engine cases](../tests/nexus-moritani-betrayal.test.ts) +cover actual placement custody, spent Nexus, rejected/stale actions, JSON view +continuation and hidden-face projection parity. Other Betrayal effects with +secret reaction timing, the unresolved Grumman removal, complete Nexus effects +and public module starts remain gated. diff --git a/game/bots.ts b/game/bots.ts index 663d19e3..4b9dce5f 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -13,6 +13,7 @@ import { greatMakerBotActions } from './great-maker-options'; import { nexusChoamTradeBotActions } from './nexus-choam-trade-options'; import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options'; import { nexusMoritaniBotActions } from './nexus-moritani-options'; +import { moritaniBetrayalBotActions } from './nexus-moritani-betrayal-options'; import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup'; import { choamPowerAction, choamPowerBotPlay } from './choam-power-options'; import { nexusCardBotActions } from './nexus-card-options'; @@ -3785,6 +3786,8 @@ export function botActions(g: GameView): Action[] { if (discovery.length) return discovery; const trade = nexusChoamTradeBotActions(g); if (trade.length) return trade; + const moritaniBetrayal = moritaniBetrayalBotActions(g); + if (moritaniBetrayal.length) return moritaniBetrayal; const sardaukar = nexusSardaukarBotActions(g); if (sardaukar.length) return sardaukar; const advisorConversion = nexusAdvisorBotActions(g); diff --git a/game/engine.ts b/game/engine.ts index 7e66ae5f..449a4940 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -46,6 +46,7 @@ import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type Nexu import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type NexusAdvisorReceipt } from './nexus-advisors'; import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceipt } from './nexus-sardaukar'; import { createNexusMoritani, validateNexusMoritani, quoteNexusMoritaniPlacement, type NexusMoritaniReceipt } from './nexus-moritani'; +import { moritaniBetrayalOffer } from './nexus-moritani-betrayal'; 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'; @@ -1808,6 +1809,20 @@ function playNexusChoamTrade(g: Game, p: Player, action: Action) { log(g, `${p.name} spent CHOAM Nexus Secret Ally and discarded ${card.name} during Spice Collection to receive 2 spice from the bank. Both cards are spent.`, {faction: p.faction, name: 'CHOAM Nexus trade'}); } +function playMoritaniBetrayal(g: Game, p: Player, action: Action) { + const offer = moritaniBetrayalOffer(g, p.id, + g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)); + requireRule(offer && !offer.blocked && action.event === offer.event && + Object.keys(action).sort().join(',') === 'event,token,type' && + offer.tokens.some(token => token.id === action.token), + offer?.blocked ?? 'Choose a currently placed Terror token.'); + const target = offer.tokens.find(token => token.id === action.token)!; + g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + g.moritaniTerror = nexusRule(() => returnTerror(g.moritaniTerror!, target.id, random)); + if (g.nexusMoritaniLocations) delete g.nexusMoritaniLocations[target.id]; + log(g, `${p.name} spent Moritani Nexus Betrayal to return a hidden Terror token from ${territory(target.territory).name} to Moritani's supply without revealing its face.`, + { faction: p.faction, name: 'Moritani Nexus Betrayal' }); +} function markNexusOccurred(g: Game) { if (!g.nexusCards) return; const phase = g.nexusCards.phase; @@ -21145,6 +21160,7 @@ function applyActionInner( if (t === 'discovery') { playDiscovery(g, p, action); return g; } if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; } if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; } + if (t === 'nexusMoritaniBetrayal') { playMoritaniBetrayal(g, p, action); return g; } if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; } requireRule( !( @@ -24992,6 +25008,8 @@ export function viewGame(state: Game, id: string) { nexusChoamTrade: currentNexusChoamTrade(g, id), nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id), nexusMoritani: nexusMoritaniOffer(g,id), + nexusMoritaniBetrayal: moritaniBetrayalOffer(g,id, + g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)), nexusRichese: nexusRicheseOffer(g,id), nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id), nexusGuildCunning: projectedNexusGuildCunning(g,id), diff --git a/game/nexus-moritani-betrayal-options.ts b/game/nexus-moritani-betrayal-options.ts new file mode 100644 index 00000000..8d11d51d --- /dev/null +++ b/game/nexus-moritani-betrayal-options.ts @@ -0,0 +1,14 @@ +import type { Action, GameView } from './engine'; + +export function moritaniBetrayalAction(game: GameView, token: string): Action | null { + const offer = game.nexusMoritaniBetrayal; + if (!offer || offer.blocked || game.nexusCards?.card !== 'moritani' || + game.automaticContinuationPending || !offer.tokens.some(choice => choice.id === token)) return null; + return { type: 'nexusMoritaniBetrayal', event: offer.event, token }; +} + +export function moritaniBetrayalBotActions(game: GameView): Action[] { + const token = game.nexusMoritaniBetrayal?.tokens[0]; + const action = token ? moritaniBetrayalAction(game, token.id) : null; + return action ? [action] : []; +} diff --git a/game/nexus-moritani-betrayal.ts b/game/nexus-moritani-betrayal.ts new file mode 100644 index 00000000..8e9a1339 --- /dev/null +++ b/game/nexus-moritani-betrayal.ts @@ -0,0 +1,21 @@ +import type { Game } from './engine'; + +/** Only public placement IDs enter the offer; Terror faces remain Moritani-private. */ +export function moritaniBetrayalOffer(g: Game, owner: string, automaticPending: boolean) { + const player = g.players.find(seat => seat.id === owner); + if (!player || g.nexusCards?.cards?.hands[owner] !== 'moritani' || + !g.players.some(seat => seat.faction === 'moritani') || player.faction === 'moritani') return null; + const tokens = g.moritaniTerror?.tokens.filter(token => token.status === 'placed' && token.location !== null) + .map(token => ({ id: token.id, territory: token.location! })) ?? []; + let blocked: string | null = null; + if (player.ally) blocked = 'An allied player cannot hold or play a Nexus card.'; + else if (g.status !== 'playing') blocked = 'Wait until play begins.'; + else if (automaticPending) blocked = 'Finish the automatic phase continuation before returning a Terror token.'; + else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || + g.pendingTreacheryDiscard || g.pendingTerrorEntry || g.pendingMoritaniPlacement || + g.pendingExchange || g.pendingNullentropy || g.pendingRicheseGift || g.pendingAmbassador || + g.pendingCapture || g.battle || g.nexusTraitorPending || g.nexusCards?.phase?.stage === 'drawing') + blocked = 'Finish the current interaction before returning a Terror token.'; + else if (!tokens.length) blocked = 'Moritani has no placed Terror token to return.'; + return { event: JSON.stringify(['nexusMoritaniBetrayal', g.turn, g.phase, owner, g.nexusCards!.cards!.discard.length]), blocked, tokens }; +} diff --git a/game/reference.ts b/game/reference.ts index bb38990c..815e2aaa 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3984,7 +3984,7 @@ export const RULE_TOPICS: RuleTopic[] = [ searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '), category: 'Advanced & expansions', coverage: 'Partial', - summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit, Emperor, five CHOAM Cunning effects Moritani Cunning supply placement and Richese Secret Ally reserve shipping and Guild Cunning’s second shipment and absent-Guild Secret Ally shipping are integrated. Other effects and complete module play remain in development.', + summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and selected Atreides, Harkonnen, Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM, Moritani, Richese and Guild effects are connected. Other effects and complete module play remain in development.', steps: [ 'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.', 'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.', @@ -4012,19 +4012,20 @@ export const RULE_TOPICS: RuleTopic[] = [ 'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.', 'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.', 'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.', + 'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.', 'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.', 'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.', 'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.', 'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules; this does not implement Atomics, Extortion or complete Moritani games.', - 'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.', + 'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.', ], related: ['ecaz-modules', 'spice-blow', 'alliance-funding'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects Moritani supply placement beyond ordinary strongholds and Richese Secret Ally physical reserve shipping priced as one. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection with native Karama responses, plus an explicit Richese shipment source and physical-force price breakdown. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar, five source-aware CHOAM Cunning effects, Moritani Cunning placement and Betrayal token return, and Richese Secret Ally reserve shipping priced as one. Borrowed Voice, other Betrayal reactions, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects with native Karama responses, Moritani Betrayal token selection, and Richese shipment pricing. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All profiles handle integrated effects through private offers, including Moritani Betrayal public-token selection. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions include Moritani Betrayal; Emperor Cunning browser checks pass while complete module games remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] }, ], }, { diff --git a/tests/fixture-nexus-moritani.ts b/tests/fixture-nexus-moritani.ts index a7d4b0f6..ad682679 100644 --- a/tests/fixture-nexus-moritani.ts +++ b/tests/fixture-nexus-moritani.ts @@ -40,6 +40,7 @@ export function nexusMoritaniFixture( native?: number; karama?: boolean; stack?: boolean; + cardOwner?: string; } = {}, ): NexusMoritaniFixture { const [owner, target, observer] = options.seatIds ?? ['p', 'q', 'r']; @@ -150,7 +151,7 @@ export function nexusMoritaniFixture( 'moritani', ...cards.deck.filter((card) => card !== 'moritani'), ]; - g.nexusCards!.cards = drawNexusCard(cards, owner, g.players, () => 0); + g.nexusCards!.cards = drawNexusCard(cards, options.cardOwner ?? owner, g.players, () => 0); const karama = options.karama === false ? undefined diff --git a/tests/nexus-moritani-betrayal.test.ts b/tests/nexus-moritani-betrayal.test.ts new file mode 100644 index 00000000..78910f1e --- /dev/null +++ b/tests/nexus-moritani-betrayal.test.ts @@ -0,0 +1,99 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { moritaniBetrayalAction, moritaniBetrayalBotActions } from '../game/nexus-moritani-betrayal-options'; +import { NexusMoritaniBetrayal } from '../components/nexus-moritani-betrayal'; +import { grummanCollectionSignature } from '../game/grumman-collection-return'; +import { nexusMoritaniFixture, nexusMoritaniInventory, nexusMoritaniReload } from './fixture-nexus-moritani'; + +function position() { + const f = nexusMoritaniFixture({ stack: true, karama: false, cardOwner: 'q' }); + const g = applyAction(f.g, f.owner, { type: 'decision', decline: true }); + return { ...f, g }; +} +function reject(g: Game, owner: string, action: { type: string; [key: string]: unknown }) { + const before = nexusMoritaniReload(g); + assert.throws(() => applyAction(g, owner, action)); + assert.deepEqual(g, before); +} + +void test('Moritani Betrayal returns one actual placed token without disclosing its face or replaying Mentat placement', () => { + const { g: initial, owner, target, observer } = position(); + const offer = viewGame(initial, target).nexusMoritaniBetrayal!; + assert.equal(offer.blocked, null); + assert.equal(offer.tokens.length, 1); + const token = offer.tokens[0]; + const before = nexusMoritaniReload(initial); + const action = moritaniBetrayalAction(viewGame(initial, target), token.id)!; + assert.equal(moritaniBetrayalBotActions(viewGame(initial, target))[0].token, token.id); + const g = applyAction(initial, target, action); + nexusMoritaniInventory(g); + assert.equal(g.nexusCards!.cards!.hands[target], null); + assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'moritani').length, 1); + assert.equal(g.moritaniTerror!.tokens.some(t => t.id === token.id), false); + assert.equal(g.moritaniTerror!.tokens.filter(t => t.status === 'available').length, + before.moritaniTerror!.tokens.filter(t => t.status === 'available').length + 1); + assert.equal(g.moritaniTerror!.placementTurn, before.moritaniTerror!.placementTurn); + assert.deepEqual(g.players.map(p => [p.forces, p.reserves, p.spice]), before.players.map(p => [p.forces, p.reserves, p.spice])); + assert.deepEqual(g.nexusMoritaniLocations, before.nexusMoritaniLocations); + assert.equal(viewGame(g, observer).nexusMoritaniBetrayal, null); + assert.equal(viewGame(g, owner).nexusMoritaniBetrayal, null); + assert.ok(g.log.at(-1)?.text.includes('without revealing')); + reject(g, target, action); + assert.deepEqual(viewGame(nexusMoritaniReload(g), target), viewGame(g, target)); +}); + +void test('Moritani Betrayal rejects foreign, stale, pending and nonexistent token choices before spending either physical component', () => { + const { g, owner, target, observer } = position(); + const offer = viewGame(g, target).nexusMoritaniBetrayal!; + const action = moritaniBetrayalAction(viewGame(g, target), offer.tokens[0].id)!; + reject(g, observer, action); + reject(g, owner, action); + reject(g, target, { ...action, event: 'old' }); + reject(g, target, { ...action, token: 'unknown' }); + const pending = nexusMoritaniReload(g); + pending.decision = { kind: 'moritaniPlacement', player: owner }; + reject(pending, target, action); + assert.equal(g.nexusCards!.cards!.hands[target], 'moritani'); +}); + +void test('Betrayal offer and result depend on public token positions, not Moritani hidden faces', () => { + const { g, target, observer } = position(); + const shuffled = nexusMoritaniReload(g); + const placed = shuffled.moritaniTerror!.tokens.find(t => t.status === 'placed')!; + const available = shuffled.moritaniTerror!.tokens.find(t => t.status === 'available')!; + [placed.kind, available.kind] = [available.kind, placed.kind]; + assert.deepEqual(viewGame(g, target), viewGame(shuffled, target)); + assert.deepEqual(viewGame(g, observer), viewGame(shuffled, observer)); +}); + +void test('Betrayal controls name the chosen public token and disable during a request', () => { + const { g, target, observer } = position(); + const view = viewGame(g, target); + const html = renderToStaticMarkup(createElement(NexusMoritaniBetrayal, { game: view, act() {}, busy: false })); + assert.match(html, /Moritani Nexus · Betrayal/); + assert.match(html, /Arrakeen/); + assert.match(html, /Return hidden Terror token/); + assert.doesNotMatch(html, /Sabotage/); + assert.match(renderToStaticMarkup(createElement(NexusMoritaniBetrayal, { game: view, act() {}, busy: true })), /disabled=""/); + assert.equal(renderToStaticMarkup(createElement(NexusMoritaniBetrayal, { game: viewGame(g, observer), act() {}, busy: false })), ''); +}); + +void test('queued high-Grumman Collection resolves before a rival spends Moritani Betrayal', () => { + const f = nexusMoritaniFixture({ stack: true, karama: false, cardOwner: 'q', homeworlds: true, native: 8 }); + const g = nexusMoritaniReload(f.g); + // Reconstruct the saved automatic boundary just before opening the Grumman + // choice; opening that choice only changes this frame and its decision. + g.phase = 7; + g.decision = null; + g.grummanCollection!.stage = 'waiting'; + delete g.grummanCollection!.outcome; + g.grummanCollection!.signature = grummanCollectionSignature(g.grummanCollection!); + const view = viewGame(g, f.target); + assert.equal(view.automaticContinuationPending, true); + assert.match(view.nexusMoritaniBetrayal!.blocked!, /automatic phase continuation/); + reject(g, f.target, { type: 'nexusMoritaniBetrayal', + event: view.nexusMoritaniBetrayal!.event, token: view.nexusMoritaniBetrayal!.tokens[0].id }); +});