diff --git a/components/battle-preparation.tsx b/components/battle-preparation.tsx index c4377cae..4db23057 100644 --- a/components/battle-preparation.tsx +++ b/components/battle-preparation.tsx @@ -11,7 +11,62 @@ import { import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import type { Action, GameView } from '@/game/engine'; +import type { Action, GameView, PlanField } from '@/game/engine'; +import type { CommittedPlanElement } from '@/game/battle-inspections'; + +/** Read only the recipient's projected commitments, including an explicit null. */ +export function battlePlanCommitments(game: GameView): Partial> { + const battle = game.battle; + if (!battle) return {}; + const commitments = battle.ownCommitments ?? ( + battle.insight && battle.prescience && battle.prescience.player !== game.me && + [battle.attacker, battle.defender].includes(game.me) + ? [{ source: 'native' as const, beneficiary: battle.prescience.player, target: game.me, + field: battle.insight.field, value: battle.insight.value }] + : [] + ); + return Object.fromEntries(commitments.filter((item) => item.target === game.me).map((item) => [item.field, item])); +} + +export function bindBattlePlanCommitments(game: GameView, plan: Action): Action { + if (plan.type !== 'battlePlan') return plan; + return { ...plan, ...Object.fromEntries(Object.values(battlePlanCommitments(game)).map((item) => [item.field, item.value])) }; +} + +export function battlePreparationField(game: GameView): PlanField | null { + const battle = game.battle; + if (battle?.preparation?.kind === 'nexusPrescienceAnswer') { + const inspection = battle.nexusInspection; + return inspection?.stage === 'answer' && inspection.event === battle.event && + inspection.target === battle.preparation.owner && inspection.owner === battle.preparation.beneficiary + ? inspection.field : null; + } + return battle?.preparation?.kind === 'prescienceAnswer' ? battle.prescience?.field ?? null : null; +} + +export function battleInspectionAnswerAction(game: GameView, value: string): Action | null { + const battle = game.battle; + const field = battlePreparationField(game); + if (game.status !== 'playing' || game.phase !== 6 || !battle || battle.revealed || + battle.preparation?.owner !== game.me || !field) return null; + const answer = field === 'dial' ? Number(value) : value || null; + if (field === 'dial' && (value.trim() === '' || !Number.isFinite(answer) || Number(answer) < 0 || !Number.isSafeInteger(Number(answer) * 2))) return null; + return battle.preparation.kind === 'nexusPrescienceAnswer' + ? { type: 'nexusPrescienceAnswer', event: battle.nexusInspection!.event, value: answer } + : { type: 'prescienceAnswer', value: answer }; +} + +export function NexusInspectionHistory({ game }: { game: GameView }) { + const battle = game.battle; + if (!battle?.nexusInspection || ![battle.nexusInspection.owner, battle.nexusInspection.target].includes(game.me) || !battle.nexusInsights?.length) return null; + return
+

Nexus inspection

+ {battle.nexusInsights.map((insight, index) =>

+ {insight.active ? (battle.nexusInspection!.target === game.me ? 'Your committed element' : 'Current answer') : 'Previous answer'}: {insight.field} = {insight.label} + {!insight.active && · No longer binding} +

)} +
; +} export function BattlePreparation({ game, @@ -134,7 +189,9 @@ export function BattlePreparation({ ); - const field = b.prescience!.field; + const field = battlePreparationField(game); + if (!field) return

This inspection is no longer awaiting an answer.

; + const answerAction = battleInspectionAnswerAction(game, value); const stoneReason = field === 'weapon' && isStoneBurner(me.hand?.find((c) => c.id === value)) ? b.stoneBurnerContext?.blocked @@ -173,6 +230,7 @@ export function BattlePreparation({ )} {field === 'dial' ? ( )} - {stoneReason &&

{stoneReason}

} diff --git a/components/game-table.tsx b/components/game-table.tsx index 4e97c969..e123f8e5 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -121,7 +121,7 @@ import { } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { BattlePreparation } from './battle-preparation'; +import { BattlePreparation, battlePlanCommitments, bindBattlePlanCommitments, NexusInspectionHistory } from './battle-preparation'; import { PrivateBattlePlan } from './private-battle-plan'; import { RevealedBattle } from './revealed-battle'; import { EliteCount } from './elite-count'; @@ -374,13 +374,10 @@ export function GameTable({ ); const [discardCards, setDiscardCards] = useState>({}); const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null; - const committed = - g.battle?.insight && g.battle.prescience?.player !== me.id - ? g.battle.insight - : null; + const committed = battlePlanCommitments(g); const selectedStone = me.hand?.find( (c) => - c.id === (committed?.field === 'weapon' ? committed.value : weapon) && + c.id === (committed.weapon ? committed.weapon.value : weapon) && isStoneBurner(c), ); const stoneContext = g.battle?.stoneBurnerContext; @@ -393,8 +390,8 @@ export function GameTable({ .map((pool) => stoneBurnerPlanBlock( ownBattleForces, - committed?.field === 'dial' - ? Number(committed.value) + committed.dial + ? Number(committed.dial?.value) : battleDial, battleSupport, pool, @@ -1780,6 +1777,7 @@ export function GameTable({ faceDancerReplacement: 'Replace an unrevealed Face Dancer', voice: 'The Voice', prescience: 'Prescience', + nexusPrescience: 'Atreides Nexus Cunning', emperorIncome: g.response.source === 'ambassador' ? 'Emperor card-purchase income' @@ -4038,6 +4036,7 @@ export function GameTable({

)} {g.battle.revealed && } + {(!g.battle.preLeader || g.battle.preLeader.closed) && ( ) : g.battle.preparation ? ( {g.battle.insight && (

- {committed + {committed[g.battle.insight.field] ? 'Your committed element' : 'Prescience'} : {g.battle.insight.field} ={' '} @@ -4133,11 +4132,11 @@ export function GameTable({ max={battleDialMaximum} step={battleDialStep} value={ - committed?.field === 'dial' - ? Number(committed.value) + committed.dial + ? Number(committed.dial?.value) : battleDial } - disabled={busy || committed?.field === 'dial'} + disabled={busy || !!committed.dial} onChange={setDial} />

@@ -4216,11 +4215,11 @@ export function GameTable({ (fn as (s: string) => void)(e.target.value) @@ -4345,7 +4342,7 @@ export function GameTable({ g.battle.fullPlan?.target === me.id ? 'Commit plan for inspection' : 'Seal battle plan', - { + bindBattlePlanCommitments(g, { type: 'battlePlan', ...(g.aid.available > 0 && allyPayment !== '' ? { allyPayment: Number(allyPayment) } @@ -4366,10 +4363,7 @@ export function GameTable({ leader, weapon, defense, - ...(committed - ? { [committed.field]: committed.value } - : {}), - }, + }), !!stonePlanReason, )} diff --git a/components/nexus-cards.tsx b/components/nexus-cards.tsx index 99500627..5290ced5 100644 --- a/components/nexus-cards.tsx +++ b/components/nexus-cards.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import Image from 'next/image'; import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'; import { faction, type FactionId } from '@/game/catalog'; -import type { Action, GameView } from '@/game/engine'; +import type { Action, GameView, PlanField } from '@/game/engine'; import { HOMEWORLD_CARDS } from '@/game/homeworld-cards'; import { nexusCardMode, type NexusCardMode } from '@/game/nexus-cards'; import type { NexusCardChoice } from '@/game/nexus-card-phase'; @@ -56,7 +56,7 @@ export function NexusCardFace({ card, mode, enlarged = false }: { {identity.name} Nexus card }>Close - All three Nexus effects and their faction requirements. Card effects are not playable yet. + All three Nexus effects and their faction requirements. Atreides actions appear when available; other card effects are not playable yet.

@@ -84,6 +84,22 @@ export function nexusCardChoiceAction(game: GameView, choice: NexusCardChoice, o return nexusCardAction(game, choice, ownRedraws); } +/** The server's private offer defines current fields and timing; no other + * player's hand or sealed plan participates in client eligibility. */ +export function nexusAtreidesAction(game: GameView, field?: PlanField): Action | null { + const offer = game.nexusAtreides; + if (!offer || offer.blocked || game.status !== 'playing' || game.phase !== 6 || + game.nexusCards?.card !== 'atreides') return null; + const owner = game.players.find((player) => player.id === game.me); + if (!owner || nexusCardMode('atreides', owner.faction, game.players.map((player) => player.faction)) !== offer.mode) return null; + const battle = game.battle; + if (!battle || battle.event !== offer.event || battle.revealed) return null; + if (offer.mode === 'betrayal') + return field === undefined ? { type: 'nexusAtreides', event: offer.event, mode: offer.mode } : null; + if (!field || !offer.fields.includes(field) || ![battle.attacker, battle.defender].includes(game.me) || battle.submitted.includes(game.me)) return null; + return { type: 'nexusAtreides', event: offer.event, mode: offer.mode, field }; +} + export function NexusCards({ game, act, busy }: { game: GameView; act: (action: Action) => void; busy: boolean }) { const [ownRedraws, setOwnRedraws] = useState<0 | 1 | 2>(0); const offer = game.nexusCards; @@ -102,8 +118,31 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:

Nexus cards

{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held

-

Drawing and keeping cards is supported; their effects are not playable yet.

+

Atreides Nexus actions are available at their battle timing; other card effects are not playable yet.

+ {game.nexusAtreides && offer.card === 'atreides' && ( +
+

{NEXUS_PANEL_NAMES[game.nexusAtreides.mode]}

+ {game.nexusAtreides.blocked ?

{game.nexusAtreides.blocked}

: ( + <> +

{game.nexusAtreides.mode === 'betrayal' + ? 'Discard this Nexus card to cancel the pending Atreides inspection.' + : game.nexusAtreides.mode === 'cunning' + ? 'Discard this Nexus card to inspect a second, different element of your opponent’s plan.' + : 'Discard this Nexus card to inspect one element of your opponent’s plan.'}

+
+ {(game.nexusAtreides.mode === 'betrayal' ? [undefined] : game.nexusAtreides.fields).map((field) => { + const action = nexusAtreidesAction(game, field); + return ; + })} +
+ + )} +
+ )}
{offer.card ? :

You do not hold a Nexus card.

}
diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md index b680b2c7..6d55d1dc 100644 --- a/docs/COMPONENT_INVENTORY.md +++ b/docs/COMPONENT_INVENTORY.md @@ -1,6 +1,6 @@ # Component inventory and verification ledger -**Nexus update, 10 September 2026:** all twelve physical Nexus identities and all thirty-six effect panels are verified against the original printed components. The separate inventory preserves one card per faction, independent of the selected roster; held cards remain private. The internal rules gallery and owner panel provide complete original-language effect text, existing bundled faction artwork and enlarged inspection. Common draws, replacement, preselected own-faction redraw, alliance forfeiture and saved phase progress are integrated. Individual effects and complete module games remain unfinished and gated. See [NEXUS_CARD_RULES.md](NEXUS_CARD_RULES.md) and [NEXUS_CARD_RUNTIME.md](NEXUS_CARD_RUNTIME.md) for current verification and limits. +**Nexus update, 10 September 2026:** all twelve physical Nexus identities and all thirty-six effect panels are verified against the original printed components. The separate inventory preserves one card per faction, independent of the selected roster; held cards remain private. The internal rules gallery and owner panel provide complete original-language effect text, existing bundled faction artwork and enlarged inspection. Common draws, replacement, preselected own-faction redraw, alliance forfeiture and saved phase progress are integrated. Atreides Cunning and Secret Ally now have inspected battle controls and private history; Betrayal and the other effect families and complete module games remain unfinished and gated. See [NEXUS_ATREIDES_RUNTIME.md](NEXUS_ATREIDES_RUNTIME.md), [NEXUS_CARD_RULES.md](NEXUS_CARD_RULES.md) and [NEXUS_CARD_RUNTIME.md](NEXUS_CARD_RUNTIME.md) for current verification and limits. **Homeworld update, 9 September 2026:** all 13 cards and 26 physical faces are verified, including native population/battle values and occupied income symbols. The internal gallery supplies both faces, complete original gameplay prose, enlarged inspection and thirteen original bundled illustrations. Separate typed custody and population helpers are tested; actual Homeworld gameplay, occupation and effect integration remain unfinished and gated. See [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md) and [HOMEWORLD_COMPONENT_AUDIT.md](HOMEWORLD_COMPONENT_AUDIT.md). Earlier missing-Homeworld statements below are historical. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 14ea2420..c1e6c32a 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,7 @@ # Dune implementation status +**Atreides Nexus inspection checkpoint, 10 September 2026:** [NEXUS_ATREIDES_RUNTIME.md](NEXUS_ATREIDES_RUNTIME.md) records Cunning's distinct second commitment and absent-faction Secret Ally, physical card disposal, No-Field distinctions, private history, all four editor locks and bot handling. Independent review fixed lost-response bypass, first-answer drift and a sealed plan contradicting its signed observation; legitimate Residual reanswers preserve the first historical disclosure. **Types, lint, all 3,839 offline tests, production build and 40 HTTP/session tests pass.** Forty-four new cases include concurrent SQLite recovery and four independent corruption regressions. Three-seat keyboard play, answer, protected plan submission and desktop/phone refresh passed. All **3,234** preexisting room versions/hashes survived the scheduled backup/restart and final comparison. Betrayal response timing, Residual after two answered Cunning fields, other card families and complete module games remain explicitly unfinished; no release gate opened. Automation stays removed; verified checkpoints are authorized for push without another confirmation. + **Nexus ownership and complete component reference checkpoint, 10 September 2026:** [NEXUS_CARD_RUNTIME.md](NEXUS_CARD_RUNTIME.md) records the twelve-card physical deck, whole-phase Basic/Advanced draw opportunity, private replacement policy, all three alliance-forfeiture paths and complete inspectable 36-panel reference. Review fixed real Storm-entry initialization, edited progress/replay acceptance, one-way alliances and the private redraw prompt's public timing leak. Pre-draw preferences resolve without extra confirmations. **Types, lint, all 3,795 offline tests, production build and 40 HTTP/session tests pass.** Three-seat keyboard draw/redraw/refresh and all twelve normal/enlarged desktop/phone cards passed; independent review inspected 48 renders. All **3,173** preexisting room versions/hashes remain unchanged. All individual Nexus effects, Discoveries and complete expansion games remain unfinished; no release gate opened. Existing server reused, no restart due, no automation recreated. **Tupile private intelligence and occupation evidence checkpoint, 10 September 2026:** [HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md) records current low-population contact checks, one historical spice/category answer per opposing faction, private controls and shared legal bot requests. Genuine setup binds qualification history and lifetime usage; legacy history stays unknown. Ordered battle observations retain brief sole occupation while simultaneous destruction does not invent it. Deterministic source identities make direct actions and restored continuations agree. **3,755 offline tests, types, lint, production build and 40 HTTP/session tests pass.** Three-seat desktop/mobile keyboard shipment, private disclosure, later balance change and refresh passed. The hourly restart and final comparison preserved all 3,112 preexisting room versions/hashes. Occupation entitlement/expiry/income, complete module games and the full expansion goal remain unfinished; no release gate opened. The removed automation stays removed. diff --git a/docs/NEXUS_ATREIDES_RULES.md b/docs/NEXUS_ATREIDES_RULES.md new file mode 100644 index 00000000..384a453c --- /dev/null +++ b/docs/NEXUS_ATREIDES_RULES.md @@ -0,0 +1,111 @@ +# Atreides Nexus: inspection and cancellation + +Primary-source audit, 10 September 2026. This is a rule contract and an explicit record of unresolved interactions, not a runtime or expansion-completion claim. The [common Nexus contract](NEXUS_CARD_RULES.md) supplies physical custody, mode selection and alliance disposal. + +## Authority + +- [GF9 Ecaz & Moritani rulebook, pp.11 and 16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11): the illustrated Atreides face, common mode definitions, special battle-card exclusions and Nexus FAQ. Fresh publisher-indexed text was inspected, alongside the previously verified publisher-authored PDF mirror and [photograph of the printed cards](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani). +- [GF9 base rulebook, pp.17 and 23](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=17): native inspection, its alliance counterpart and Voice/Prescience/Truthtrance ordering. Fresh official-domain indexed page text was retrieved. +- [GF9 November 2020 FAQ, pp.3, 7 and 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7): effective weapon/defense answers, single-use Karama cancellation, cancellation of alliance abilities and simultaneous Truthtrances. Fresh official-domain indexed page text was retrieved. Direct PDF requests returned HTML despite HTTP 200; those downloaded files were not treated as PDFs or evidence. +- [GF9 CHOAM & Richese rulebook, p.6](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=6): the exact No-Field restriction. Fresh official-domain indexed page text was retrieved. + +Tournament compilations surfaced in the search, including a longer battle sequence and broader post-Karama re-answer rules. Those are not adopted here as publisher rules. No newer designer clarification resolving the Nexus-specific cancellation questions below was retrieved. + +## Three printed effects + +| Mode | Native-faction condition | Effect and target | +| --- | --- | --- | +| Betrayal | Another seat is Atreides | Prevent its attempted battle-plan inspection. This is a reaction to the advantage being attempted. | +| Cunning | Holder is Atreides | Inspect a second element of that holder's opponent's Battle Plan. | +| Secret Ally | No Atreides seat | In the holder's battle, inspect one chosen opposing plan element: leader, weapon, defense or dial. | + +Each use consumes the held Nexus card. None of these panels creates a real alliance or grants all Atreides powers. The Cunning wording targets the holder's own opponent; it does not independently grant an extra inspection in an ally's battle. The common Betrayal definition acknowledges that some cards can cancel alliance advantages, but does not enumerate an Atreides alliance example. + +## Supported inspection composition + +**A completed ordinary answer supplies Cunning's first element.** The face calls for a second element and imposes no requirement to select it before hearing the first. Allowing the holder to choose a different element after the ordinary answer, before battle commitments close the opportunity, follows that wording. This is composition, not a printed worked sequence. An answer of no weapon or no defense is still an answered first element; the native rule forbids a free replacement question, while Cunning expressly purchases the additional element. + +**Keep inspection categories distinct from physical slots.** The November FAQ makes Weirding Way and Chemistry follow their role in the actual plan. E3 explicitly excludes Reinforcements and Harass & Withdraw from weapon/defense answers. Reuse the established role-aware answer and feasible-plan validation; do not reveal the entire plan, the player's hand, or an excluded special card merely because it occupies the requested slot. + +**Voice precedes inspection.** Base p.23 orders native Voice before native Prescience, allows Truthtrance during their interaction, and permits changes to unaffected plan elements. E3 p.16 expressly puts BG Secret Ally before Prescience. Applying the same ordering when the inspection comes from Atreides Secret Ally is consistent composition; the FAQ does not separately name that reversed native/card pairing. Do not let a later Voice evade an already disclosed element. + +**An attempted use needs a durable result.** Bind the requesting seat, opposing seat, battle, source mode and element. Cancellation must terminate that attempt without delivering its answer. Answer delivery must preserve its original value and corresponding commitment. Neither a read nor a retry creates another question. A later legal change requiring renewed disclosure is a continuation of the old commitment, not a new choice of element; the precise publisher basis for a blanket re-answer after every Karama was not established by this audit. + +## Cancellation: what is established and what remains open + +The November FAQ stops one use of a faction ability, using one battle's Voice as its example; it also permits cancellation of alliance abilities. Its Atreides entry blocks knowledge of one plan element. It does not cancel every inspection for the phase. This supports consuming the native opportunity when canceled rather than immediately offering the same ordinary request again. E3 Betrayal's instruction to prevent the attempted advantage supports the same outcome for its original attempt. + +There is no published permission in the inspected material to repeat the canceled ordinary request indefinitely in the same battle. Equally, this does not establish that every independent card-granted inspection in that battle is canceled. Keep sources separate instead of setting one blanket no-inspection flag. + +| Interaction | Evidence boundary | +| --- | --- | +| Native inspection canceled, then ordinary inspection redeclared in the same battle | Treat the original native use as spent; no retrieved rule grants a fresh ordinary use. | +| Native inspection answered, then Cunning requests another element | Supported composition of the second-element wording; the second choice is distinct. | +| Native inspection canceled by ordinary Karama, then Cunning played | Material unresolved case: does the enhanced advantage still supply one element after the first attempt failed, or require a first revealed element? No inspected FAQ answers this. A preceding Nexus Betrayal cannot create this sequence because it consumes the same unique card that native Atreides would need for Cunning, with no new draw during Battle. | +| Karama used against a separately declared Cunning inspection | Selected composition: Cunning is explicitly an enhanced native advantage. Karama's one-use cancellation therefore targets the additional live attempt and preserves the already completed first answer. Do not combine both attempts into a new indivisible effect. No new ruling is needed for this sequential case. | +| Karama used against Atreides Secret Ally | Selected composition: the card grants its effect to a non-Atreides seat with Atreides absent, so there is no native Atreides advantage owner for the ordinary cancellation target. Do not create that target. This follows source ownership; it is not a rule that all Nexus effects are immune to Karama. | +| Atreides Betrayal used against Cunning | Unreachable with the one physical Atreides Nexus card: it cannot simultaneously be held by native Atreides for Cunning and another seat for Betrayal. Do not fabricate a second copy to test this interaction. | +| Atreides Betrayal used against an allied native inspection | The cancellation panel names the Atreides battle advantage, and the common definition permits cancellation of some alliance advantages. Treating the ally's original native inspection as the named advantage is consistent composition; it does not turn Cunning into an ally effect. | +| Multiple seats attempt Karama/Betrayal on the same inspection | No inspected primary rule specifies their relative priority. Only simultaneous Truthtrances explicitly use storm order. A general storm-order serializer is implementation composition, not a Nexus-specific publisher instruction. | + +Homeworld Karama immunity on E3 p.10 applies to Homeworld advantages and penalties. It supplies no Nexus immunity. Likewise, the separate protection of special faction Karama powers is not a general protection for Nexus cards. + +## No-Field is a named-faction restriction + +The exact publisher sentence is: “When you are in a Battle with a No-Field token, Atreides may not see your number dialed.” [CHOAM & Richese, p.6](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=6) + +Native Atreides cannot evade this through Cunning. The sentence does not say every opponent, every inspection, or every Prescience effect. For Atreides Secret Ally, the later printed card explicitly offers the dial to a holder whose faction is not Atreides. Allowing that holder to request the dial is a defensible literal composition; no retrieved E3 rule expands the older named-faction prohibition. This distinction is not a claim that a card overrides every restriction. Keep the public No-Field token presence, exact hidden force availability and eventual reveal separate: seeing the number dialed does not reveal the token's denomination or the whole plan. + +## Private responses and automatic progression + +These are software requirements, not printed timing additions: + +- The original requested effect may be public; the unplayed identities and response options in other hands remain private. Cunning and Secret Ally are publicly identified only when the holder actually plays the card. +- Validate a response against the exact still-live attempt before discarding either card or changing an answer. A second concurrent cancellation cannot spend another card against an already terminated attempt. +- Show a pass/decline only when the seat has a meaningful choice. A private answer already fixed by the declared plan needs no acknowledgement. Preserve the user's requirement to advance automatically when there is no action. +- Automatically removing seats without hidden responses while publishing the remaining waiting seats can disclose response availability. Hiding only card names does not prove timing privacy. Conversely, making every powerless seat acknowledge a window would violate the requested UX. The coordinator has asked the user about this privacy/progression tradeoff; the answer is pending. A private precommitted response policy could avoid a new hidden-hand-dependent pause, but must not invent or silently choose a player's optional card play. +- Do not retroactively remove an answer already seen, infer hidden eligibility from opponent plans, or inspect other hands in a client/bot legality helper. Historical private observations and live legal commitments are separate data. + +The unresolved cases were reported to the coordinator before implementation decisions. This audit sent no user question and resolves none of the unrelated pending Homeworld rulings. Development may support verified common cases while retaining explicit guards, but must not label all three Atreides effects complete until their enabled cancellation and response combinations are decided and verified. + +## Residual Poison and two inspected commitments + +The following is a reasoned counterexample from the existing preparation rules, +not a reported executed gameplay regression. In Advanced play, CHOAM has four +ordinary forces in the battle, no spice, one living usable leader, a projectile, +Ghola and a real Karama, and its cash-in power is unused. Suppose native +Prescience fixed the dial at three and Cunning fixed the projectile. Before a +leader death, both answers are reachable: cashing Ghola supplies three spice, +enough to support the dial, while the living leader permits the projectile. + +If Residual Poison kills that last leader, each answer can remain feasible by +itself. Keeping the projectile permits Ghola revival with a lower dial. Keeping +the dial permits cash-in while omitting the weapon. Keeping both cannot spend +the same Ghola both on revival and on funding, nor cash the committed projectile. +There is therefore no generally valid assumption that an earlier answer, a later +answer, or all individually feasible answers identify the uniquely correct +surviving set. The retrieved publisher rules do not select between these sets. + +The coordinator has asked whether the affected player should choose the retained +commitment, the earlier commitment should take priority, or this combination +should await an official ruling. The answer is pending. No such priority is +implemented by this document. Until resolved, the runtime uses an explicit +development boundary: Residual Poison is unavailable after an answered Cunning +inspection. This guard depends on the publicly played mode and completion stage, +not the opponent's hidden cards, spice, or whether a private feasibility search +happens to find a conflict. It is not a printed prohibition. The independent +leader-commitment restriction still applies, and this boundary does not prohibit +the single-element Secret Ally continuation. + +Any later implementation should test complete legal continuations with the +proposed retired bindings removed on private copies. Testing one binding while +another impossible binding remains can falsely release a valid dial. Likewise, +reconciling Truthtrance before retiring an impossible inspection can wrongly +release an otherwise feasible promise. Preserve historical disclosures and the +consumed Nexus receipt separately from active bindings; a renewed answer is to +the original field and does not consume another card. Multiple incomparable +maximal surviving sets must not acquire an unexplained software tie-break. + +The focused [public-guard regression](../tests/nexus-residual-ambiguity.test.ts) +covers the declared implementation boundary and hidden-state independence. It +does not claim to execute or resolve the counterexample above. diff --git a/docs/NEXUS_ATREIDES_RUNTIME.md b/docs/NEXUS_ATREIDES_RUNTIME.md new file mode 100644 index 00000000..38ed4fe6 --- /dev/null +++ b/docs/NEXUS_ATREIDES_RUNTIME.md @@ -0,0 +1,37 @@ +# Atreides Nexus inspection checkpoint + +10 September 2026. This extends the [Nexus lifecycle](NEXUS_CARD_RUNTIME.md) with **Cunning and Secret Ally battle inspections**. It is a partial effect-family checkpoint: Betrayal response timing, one Residual Poison interaction, the other eleven card families, and complete module games remain unfinished. Public Nexus and other incomplete mode starts remain gated. [Source audit and unresolved rulings](NEXUS_ATREIDES_RULES.md) distinguish publisher text from composed interactions. + +## Rules and state + +- Native Prescience remains its original first observation. Cunning is a separate second, distinct field, requested by the actual Atreides combatant after the native answer and before its own plan is sealed. Zero and no-card answers count as completed observations. +- Secret Ally requires Atreides absent from the genuine initialized roster. The actual holder must be a combatant; it receives one requested opposing field. An already sealed target supplies that field automatically without another acknowledgement or disclosure of the rest of its plan. +- Cunning creates a separately bound ordinary Karama response. Cancellation spends only the accepted responder's physical card, cancels the extra attempt, and preserves the first binding answer. Secret Ally creates no fictitious native Atreides cancellation target. +- No-Field's named-faction restriction blocks native Atreides and Cunning dial inspection. A non-Atreides Secret Ally holder can inspect the dial without receiving the hidden token value or full plan. The regression uses actual initialized Richese markers. +- [battle-inspections.ts](../game/battle-inspections.ts) keeps the battle event, mode, source/target, field, original Cunning disclosure and append-only Nexus answer history. Current binding values are derived separately from historical observations. A consistency signature detects changed or omitted record fields; it is not authentication against an attacker able to fabricate an entire saved state. +- Actual effect play discards the one physical Atreides card. The engine validates the current event, holder, mode, timing and exact action keys before mutation. A second declaration or response cannot reuse the consumed card or completed attempt. +- All active inspections constrain authoritative plan validation and legal-completion search. Card transfer, ambassador disposal, CHOAM cash-in and Karama spending reserve both committed cards. The UI fixes every committed field in its editor, preview and submitted action. Bots use those same projected obligations and only authorized opposing observations. + +## Residual Poison and remaining boundaries + +Any answered leader inspection blocks Residual Poison. When a single nonleader inspection becomes impossible after a legal leader death, the same field reopens. Nexus history retains the former private observation and marks it inactive; the replacement answer appends without inventing a new inspection or spending another card. A surviving leader, Cheap Hero or reachable Ghola preparation can keep the original answer feasible. Inspection feasibility is checked before Truthtrance reconciliation so an obsolete weapon requirement cannot by itself release a still-feasible Truthtrance answer. + +Residual Poison after two completed Cunning inspections is guarded using public battle state, independent of opponent hands or spice. Two old choices can each remain feasible separately while becoming impossible together. No inspected official source selects which obligation should survive; the user ruling is pending. This is an explicit temporary limitation, not a house priority or complete interaction claim. + +Betrayal remains unavailable pending the private response timing decision. Existing automatic advancement would otherwise skip an opportunity held only in a secret Nexus hand; selectively waiting for the matching card would leak availability. Its printed reference and pure canceled-attempt record are present, but no enabled play or timing-completeness claim is made. Cunning after a canceled first native inspection also remains guarded pending its source interpretation. + +## Private views and recovery + +The public record identifies an already played effect, its owner, target, selected field and stage. Only owner and target receive answer values/history; outsiders receive neither. The unplayed card and legal offer belong only to its holder. Labels include ordinary/foreign leaders and Duke Vidal. An opponent's unseen hand and unrequested plan fields are absent from consumer inputs. + +Three production-room/SQLite regressions exercise real authentication, SQL and version fencing against disposable databases. Competing field requests commit one disclosure attempt; a lost answer response restores both fixed elements; concurrent Karamas charge one accepted responder. Stale, duplicate and corrupted requests do not write. Reloaded room modules recover identical private seat views. The automatic scheduler leaves ordinary owned decisions alone; authenticated reads and actions still validate their records. + +## Verification + +- **44 new cases:** 10 pure inspection, 13 actual-engine, 8 control/render, 5 bot/CHOAM reservation, 3 production SQLite recovery, 4 independent integrity regressions and 1 public Residual guard. Controls cover all four fixed fields and actual table rendering. Engine fixtures choose final faction identities before component setup; conserved battle/card acquisition seams are explicitly documented in the fixtures. +- Types, lint and **3,839/3,839 offline tests** passed with no skipped or canceled cases. The production build and **40/40 HTTP/session cases** passed. The final offline suite took 95.7 seconds, with no skips or cancellations. Logs: `/tmp/dune-atreides-final-check.log`, `/tmp/dune-atreides-final-build.log` and `/tmp/dune-atreides-final-http.log`. +- **Independent recovery review** reproduced a deleted Cunning response, an altered native first value and a sealed plan contradicting its inspected answer. The final receipt distinguishes pending response from allowed answer, signs the current native binding separately from its original snapshot, checks active/nested response identity and compares both sealed plans to every live inspection. Four direct regressions reject damaged state before every-seat projection, normalization or action; final independent review found no additional concrete defects. Legitimate native reanswers after canceled/pending Cunning remain covered. +- **Three-seat browser:** isolated room `DFRSD7CM` used a genuine setup and conserved battle/card boundary. Atreides played Cunning with the keyboard, Harkonnen answered with Crysknife, and the editor fixed both weapon and native dial zero while leaving leader and defense editable. Harkonnen selected a leader and Shield and sealed; version 7 retains those two obligations and hides the unrequested defense and complete plan from the other seats. All three seats restored after refresh. Desktop and 390-pixel phone views had no page errors or overflow; screenshots of card controls, answer, history and fixed fields were visually inspected. An exact-label browser test exposed option text inside the wrapping answer label; the label now explicitly names only its control. +- During development this isolated room's unpublished receipt format gained signed response/native progress. Its original version-4 state was backed up; the version-5 upgrade preserved the already spent card, selected field, original first answer and unanswered target. It did not replay the effect, reroll or alter any other room. +- **Preservation:** the controlled hourly restart backed up all **3,234** preexisting rooms and restored three private Nexus seats. After browser work and two HTTP runs, all original versions and SHA-256 state hashes remain identical. There are 3,295 rooms: the original baseline, one isolated inspection room and 60 HTTP test rooms. Read-only final restoration also passed for Nexus `43U78HGQ` v4 and Tupile `ZMA84DY8` v7. The server remains running; no saved game was reset and the removed automation remains removed. +- No complete-game or AI strength claim follows from these focused inspection checks. diff --git a/docs/NEXUS_CARD_RUNTIME.md b/docs/NEXUS_CARD_RUNTIME.md index 4bb3ed77..326cc3d2 100644 --- a/docs/NEXUS_CARD_RUNTIME.md +++ b/docs/NEXUS_CARD_RUNTIME.md @@ -1,5 +1,7 @@ # Nexus cards: common lifecycle checkpoint +**Historical foundation checkpoint.** Subsequent Atreides inspection integration is recorded in [Atreides runtime](NEXUS_ATREIDES_RUNTIME.md). The counts and effect limitations below describe the original lifecycle checkpoint. + Integration record, 10 September 2026. **Types, lint, all 3,795 offline tests, the production build and 40 HTTP/session tests pass.** This checkpoint implements the optional Nexus card inventory and common draw/keep/replace lifecycle, plus a complete inspectable reference. It does **not** implement the thirty-six card effects, enable a complete public Nexus module, or certify Ecaz & Moritani or all expansions. Source facts, photographed component faces, and unresolved effect interactions are in [NEXUS_CARD_RULES.md](NEXUS_CARD_RULES.md). ## Implemented scope diff --git a/game/battle-inspections.ts b/game/battle-inspections.ts new file mode 100644 index 00000000..9671b616 --- /dev/null +++ b/game/battle-inspections.ts @@ -0,0 +1,487 @@ +import { FACTIONS, type FactionId } from './catalog'; +import type { PlanField } from './engine'; + +export type InspectionValue = string | number | null; +export type NativeInspection = { + player: string; + field: PlanField; + value?: InspectionValue; +}; +export type NexusInspection = { + version: 1; + event: string; + mode: 'cunning' | 'secretAlly' | 'betrayal'; + owner: string; + target: string; + field: PlanField; + stage: 'response' | 'answer' | 'answered' | 'canceled'; + answers: InspectionValue[]; + /** Original first disclosure remains evidence even after Residual reopens it. */ + first?: { player: string; field: PlanField; value: InspectionValue }; + /** Current native commitment; absent only after its recorded reopening. */ + firstCurrent?: { value: InspectionValue }; + /** Original native attempt can disappear when Betrayal cancels it. */ + nativeProvider?: string; + nativeBeneficiary?: string; + signature: string; +}; +export type BattleInspectionContext = { + event: string; + attacker: string; + defender: string; + players: readonly { id: string; faction: FactionId }[]; + native?: NativeInspection; +}; +export type CommittedPlanElement = { + source: 'native' | 'nexus'; + beneficiary: string; + target: string; + field: PlanField; + value: InspectionValue; +}; +type InspectionBattle = { + event?: string; + attacker: string; + defender: string; + prescience?: NativeInspection; + nexusInspection?: NexusInspection; +}; +const has = (object: object, key: string) => + Object.prototype.hasOwnProperty.call(object, key); +const fields: PlanField[] = ['leader', 'weapon', 'defense', 'dial']; +function requireInspection( + condition: unknown, + message: string, +): asserts condition { + if (!condition) throw new Error(message); +} +function validId(id: unknown): id is string { + return typeof id === 'string' && id.trim().length > 0; +} +function validateValue(field: PlanField, value: unknown): void { + requireInspection( + field === 'dial' + ? typeof value === 'number' && + value >= 0 && + Number.isSafeInteger(value * 2) + : value === null || validId(value), + 'Invalid battle inspection value.', + ); +} +function validateNative(native: NativeInspection): void { + requireInspection( + native && validId(native.player) && fields.includes(native.field), + 'Invalid native inspection.', + ); + if (has(native, 'value')) validateValue(native.field, native.value); +} +function opponent( + context: { attacker: string; defender: string }, + player: string, +): string { + requireInspection( + player === context.attacker || player === context.defender, + 'An inspection must belong to a combatant.', + ); + return player === context.attacker ? context.defender : context.attacker; +} +function validateContext(context: BattleInspectionContext): void { + requireInspection( + validId(context.event) && + validId(context.attacker) && + validId(context.defender) && + context.attacker !== context.defender && + Array.isArray(context.players) && + context.players.length >= 2 && + context.players.length <= 6, + 'Invalid inspection battle.', + ); + const ids = new Set(); + const factions = new Set(); + for (const player of context.players) { + requireInspection( + validId(player.id) && + !ids.has(player.id) && + !factions.has(player.faction) && + FACTIONS.some((faction) => faction.id === player.faction), + 'Invalid inspection seats.', + ); + ids.add(player.id); + factions.add(player.faction); + } + requireInspection( + ids.has(context.attacker) && ids.has(context.defender), + 'Inspection combatants must be seated.', + ); + if (context.native) { + validateNative(context.native); + opponent(context, context.native.player); + } +} +function signature(inspection: Omit): string { + return JSON.stringify([ + 'nexusInspection', + inspection.version, + inspection.event, + inspection.mode, + inspection.owner, + inspection.target, + inspection.field, + inspection.stage, + inspection.answers, + inspection.first + ? [ + inspection.first.player, + inspection.first.field, + inspection.first.value, + ] + : null, + inspection.firstCurrent ? [inspection.firstCurrent.value] : null, + inspection.nativeProvider ?? null, + inspection.nativeBeneficiary ?? null, + ]); +} +function validateRecord(inspection: NexusInspection): void { + requireInspection( + inspection && + typeof inspection === 'object' && + !Array.isArray(inspection) && + inspection.version === 1 && + validId(inspection.event) && + validId(inspection.owner) && + validId(inspection.target) && + ['cunning', 'secretAlly', 'betrayal'].includes(inspection.mode) && + fields.includes(inspection.field) && + ['response', 'answer', 'answered', 'canceled'].includes( + inspection.stage, + ) && + Array.isArray(inspection.answers), + 'Invalid Nexus inspection record.', + ); + const keys = [ + 'version', + 'event', + 'mode', + 'owner', + 'target', + 'field', + 'stage', + 'answers', + 'signature', + ]; + if (inspection.mode === 'cunning') { + keys.push('first'); + requireInspection( + inspection.first && + Object.keys(inspection.first).sort().join(',') === 'field,player,value', + 'Cunning requires the original first disclosure.', + ); + validateNative(inspection.first); + if (has(inspection, 'firstCurrent')) { + keys.push('firstCurrent'); + requireInspection( + inspection.firstCurrent && + Object.keys(inspection.firstCurrent).join(',') === 'value', + 'Invalid current native inspection receipt.', + ); + validateValue(inspection.first.field, inspection.firstCurrent.value); + } + } + if (inspection.mode === 'betrayal') { + keys.push('nativeProvider', 'nativeBeneficiary'); + requireInspection( + validId(inspection.nativeProvider) && + validId(inspection.nativeBeneficiary) && + inspection.stage === 'canceled' && + inspection.answers.length === 0, + 'Betrayal records a canceled native attempt without an answer.', + ); + } else { + requireInspection( + (inspection.stage !== 'response' || + (inspection.mode === 'cunning' && inspection.answers.length === 0)) && + (inspection.stage !== 'canceled' || + (inspection.mode === 'cunning' && inspection.answers.length === 0)) && + (inspection.stage !== 'answered' || inspection.answers.length > 0) && + (inspection.field !== 'leader' || inspection.answers.length <= 1) && + !( + inspection.field === 'leader' && + inspection.stage === 'answer' && + inspection.answers.length + ), + 'Invalid Nexus inspection answer history.', + ); + } + requireInspection( + Object.keys(inspection).sort().join(',') === keys.sort().join(','), + 'Invalid Nexus inspection fields.', + ); + for (const value of inspection.answers) + validateValue(inspection.field, value); + requireInspection( + inspection.signature === signature(inspection), + 'The Nexus inspection has lost its original disclosure history.', + ); +} +export function validateNexusInspection( + context: BattleInspectionContext, + inspection: NexusInspection, +): void { + validateContext(context); + validateRecord(inspection); + requireInspection( + inspection.event === context.event, + 'The Nexus inspection belongs to another battle.', + ); + const owner = context.players.find((p) => p.id === inspection.owner); + const atreides = context.players.find((p) => p.faction === 'atreides'); + requireInspection(owner, 'The Nexus inspection owner must be seated.'); + if (inspection.mode === 'betrayal') { + requireInspection( + atreides && + owner.id !== atreides.id && + inspection.nativeProvider === atreides.id && + inspection.target === opponent(context, inspection.nativeBeneficiary!), + 'Betrayal must bind the native Atreides attempt.', + ); + if (context.native) + requireInspection( + context.native.player === inspection.nativeBeneficiary && + context.native.field === inspection.field && + !has(context.native, 'value'), + 'Betrayal must cancel the original unanswered attempt.', + ); + } else { + requireInspection( + inspection.target === opponent(context, owner.id), + 'An inspection must target its owner’s battle opponent.', + ); + if (inspection.mode === 'secretAlly') + requireInspection( + !atreides, + 'Secret Ally requires Atreides to be absent.', + ); + else + requireInspection( + owner.faction === 'atreides' && + inspection.first!.player === owner.id && + context.native?.player === inspection.first!.player && + context.native.field === inspection.first!.field && + inspection.field !== inspection.first!.field && + has(context.native, 'value') === has(inspection, 'firstCurrent') && + (!has(inspection, 'firstCurrent') || + context.native.value === inspection.firstCurrent!.value), + 'Cunning must preserve a distinct original native disclosure.', + ); + } +} +export function createNexusInspection( + context: BattleInspectionContext, + input: Pick, +): NexusInspection { + validateContext(context); + const inspection: NexusInspection = { + version: 1, + event: context.event, + mode: input.mode, + owner: input.owner, + target: input.target, + field: input.field, + stage: + input.mode === 'betrayal' + ? 'canceled' + : input.mode === 'cunning' + ? 'response' + : 'answer', + answers: [], + signature: '', + }; + if (input.mode === 'cunning') { + requireInspection( + context.native && has(context.native, 'value'), + 'Answer native prescience before using Cunning.', + ); + inspection.firstCurrent = { value: context.native.value! }; + inspection.first = { + player: context.native.player, + field: context.native.field, + value: context.native.value!, + }; + } + if (input.mode === 'betrayal') { + requireInspection( + context.native && + !has(context.native, 'value') && + context.native.field === input.field, + 'Betrayal needs an unanswered native attempt.', + ); + inspection.nativeProvider = context.players.find( + (p) => p.faction === 'atreides', + )?.id; + inspection.nativeBeneficiary = context.native.player; + } + inspection.signature = signature(inspection); + validateNexusInspection(context, inspection); + return inspection; +} +/** Record a native re-answer obligation before the engine removes its value. */ +export function reopenNexusNative( + context: BattleInspectionContext, + inspection: NexusInspection, +): NexusInspection { + validateNexusInspection(context, inspection); + requireInspection( + inspection.mode === 'cunning' && + context.native && + context.native.field !== 'leader' && + has(context.native, 'value') && + inspection.firstCurrent, + 'Only an answered non-leader native inspection can reopen.', + ); + const next = structuredClone(inspection); + delete next.firstCurrent; + next.signature = signature(next); + validateNexusInspection( + { + ...context, + native: { player: context.native.player, field: context.native.field }, + }, + next, + ); + return next; +} +/** Record a legitimate replacement native answer before the engine stores it. */ +export function answerNexusNative( + context: BattleInspectionContext, + inspection: NexusInspection, + value: InspectionValue, +): NexusInspection { + validateNexusInspection(context, inspection); + requireInspection( + inspection.mode === 'cunning' && + context.native && + !has(context.native, 'value') && + !has(inspection, 'firstCurrent'), + 'The original native inspection is not awaiting a replacement answer.', + ); + validateValue(context.native.field, value); + const next = { ...structuredClone(inspection), firstCurrent: { value } }; + next.signature = signature(next); + validateNexusInspection( + { ...context, native: { ...context.native, value } }, + next, + ); + return next; +} +/** The engine calls this only after the original Karama response finishes. */ +export function allowNexusInspection( + context: BattleInspectionContext, + inspection: NexusInspection, +): NexusInspection { + validateNexusInspection(context, inspection); + requireInspection( + inspection.mode === 'cunning' && + inspection.stage === 'response' && + inspection.answers.length === 0, + 'Only the original Cunning response can allow its answer.', + ); + const next = { ...structuredClone(inspection), stage: 'answer' as const }; + next.signature = signature(next); + validateNexusInspection(context, next); + return next; +} +export function answerNexusInspection( + context: BattleInspectionContext, + inspection: NexusInspection, + value: InspectionValue, +): NexusInspection { + validateNexusInspection(context, inspection); + requireInspection( + inspection.stage === 'answer', + 'This Nexus inspection is not awaiting an answer.', + ); + validateValue(inspection.field, value); + const next = { + ...structuredClone(inspection), + stage: 'answered' as const, + answers: [...inspection.answers, value], + }; + next.signature = signature(next); + validateNexusInspection(context, next); + return next; +} +/** Ordinary native-power cancellation can stop only the original unanswered + * extra Cunning attempt. The first disclosure and spent-card record survive. */ +export function cancelNexusInspection( + context: BattleInspectionContext, + inspection: NexusInspection, +): NexusInspection { + validateNexusInspection(context, inspection); + requireInspection( + inspection.mode === 'cunning' && + inspection.stage === 'response' && + inspection.answers.length === 0, + 'Only the original unanswered Cunning inspection can be canceled.', + ); + const next = { ...structuredClone(inspection), stage: 'canceled' as const }; + next.signature = signature(next); + validateNexusInspection(context, next); + return next; +} +export function reopenNexusInspection( + context: BattleInspectionContext, + inspection: NexusInspection, +): NexusInspection { + validateNexusInspection(context, inspection); + requireInspection( + inspection.stage === 'answered' && inspection.field !== 'leader', + 'Only an answered non-leader inspection can reopen.', + ); + const next = { ...structuredClone(inspection), stage: 'answer' as const }; + next.signature = signature(next); + validateNexusInspection(context, next); + return next; +} +/** Current binding only. Historical answers survive on the record but do not + * constrain a reopened field. Callers validate the full public context first. */ +export function committedPlanElements( + battle: InspectionBattle, + target: string, +): CommittedPlanElement[] { + const result: CommittedPlanElement[] = []; + if (battle.prescience) { + const native = battle.prescience; + validateNative(native); + if (opponent(battle, native.player) === target && has(native, 'value')) + result.push({ + source: 'native', + beneficiary: native.player, + target, + field: native.field, + value: native.value!, + }); + } + const inspection = battle.nexusInspection; + if (inspection) { + validateRecord(inspection); + requireInspection( + battle.event === inspection.event, + 'The Nexus inspection belongs to another battle.', + ); + if (inspection.mode !== 'betrayal') + requireInspection( + opponent(battle, inspection.owner) === inspection.target, + 'An inspection must target its owner’s battle opponent.', + ); + if (inspection.stage === 'answered' && inspection.target === target) + result.push({ + source: 'nexus', + beneficiary: inspection.owner, + target, + field: inspection.field, + value: inspection.answers.at(-1)!, + }); + } + return result; +} +export const inspectedPlanElements = committedPlanElements; diff --git a/game/bots.ts b/game/bots.ts index b10a4f64..667696a8 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -626,11 +626,17 @@ function plans(g: GameView): Action[] { ? b.opponentForces.normal * (b.opponentForces.normalFixedHalf ? 0.5 : 1) + b.opponentForces.elite * b.opponentForces.eliteStrength : enemies; - const insight = b.insight; - const revealedCard = - insight && ['weapon', 'defense'].includes(insight.field) - ? treacheryDeck(['ix']).find((c) => c.id === insight.value) - : undefined; + const insights = [ + ...(b.insight && b.prescience?.player === me.id ? [b.insight] : []), + ...(b.nexusInspection?.owner === me.id + ? b.nexusInsights.filter((insight) => insight.active) + : []), + ]; + const known = (field: string) => + insights.find((insight) => insight.field === field); + const knownCards = treacheryDeck(['ix']); + const revealedWeapon = knownCards.find((c) => c.id === known('weapon')?.value); + const revealedDefense = knownCards.find((c) => c.id === known('defense')?.value); const inspected = b.fullPlanInsight?.target === other.id ? b.fullPlanInsight : null; const enemyWeapon = inspected?.cards.find( @@ -639,9 +645,13 @@ function plans(g: GameView): Action[] { const enemyDefense = inspected?.cards.find( (c) => c.id === inspected.plan.defense, ); - const committed = b.prescience?.player !== me.id ? insight : null; - const fixed = (field: string, value: string | null) => - committed?.field !== field || committed.value === value; + const commitments = b.ownCommitments.filter( + (element) => element.target === me.id, + ); + const fixed = (field: string, value: string | number | null) => + commitments.every( + (element) => element.field !== field || element.value === value, + ); const weapons = [ null, ...(me.hand ?? []).filter(isWeaponCard).map((c) => c.id), @@ -670,10 +680,7 @@ function plans(g: GameView): Action[] { !!voice && (availableLeaders.length > 0 || heroes.length > 0) && (me.hand ?? []).some((c) => defaultVoiceMatch(c, voice.kind)); - const knownEnemyLeader = - insight?.field === 'leader' && b.prescience?.player === me.id - ? g.allLeaders.find((l) => l.id === insight.value) - : undefined; + const knownEnemyLeader = g.allLeaders.find((l) => l.id === known('leader')?.value); const expectedLeader = inspected ? (g.allLeaders.find((l) => l.id === inspected.plan.leader)?.strength ?? 0) + (inspected.plan.kwisatz ? 2 : 0) @@ -688,8 +695,8 @@ function plans(g: GameView): Action[] { : 3)); const expectedDial = inspected ? inspected.plan.dial - : insight?.field === 'dial' && b.prescience?.player === me.id - ? Number(insight.value) + : known('dial') + ? Number(known('dial')!.value) : Math.ceil(enemyCapacity * [0.25, 0.4, 0.6, 0.75][level]); const options: { action: Action; score: number }[] = []; for (const leader of leaders) @@ -813,7 +820,11 @@ function plans(g: GameView): Action[] { weapon, defense, }; - if (committed?.field === 'dial') action.dial = committed.value; + const committedDial = commitments.find( + (element) => element.field === 'dial', + ); + if (committedDial) action.dial = committedDial.value; + if (!fixed('dial', Number(action.dial))) continue; const commitment = typedForces ? supporting(Number(action.dial)) : null; @@ -845,18 +856,13 @@ function plans(g: GameView): Action[] { const selfExplosion = battleWeaponsExplode(w, d); const correctDefense = inspected ? weaponKills(enemyWeapon) && !weaponKills(enemyWeapon, d) - : insight?.field === 'weapon' && - !!revealedCard && - weaponKills(revealedCard) && - !weaponKills(revealedCard, d); + : !!revealedWeapon && + weaponKills(revealedWeapon) && + !weaponKills(revealedWeapon, d); const explosionRisk = inspected ? battleWeaponsExplode(w, d, enemyWeapon, enemyDefense) - : (insight?.field === 'defense' && - isShield(revealedCard) && - w?.kind === 'lasgun') || - (insight?.field === 'weapon' && - revealedCard?.kind === 'lasgun' && - isShield(d)); + : (isShield(revealedDefense) && w?.kind === 'lasgun') || + (revealedWeapon?.kind === 'lasgun' && isShield(d)); const undialed = stoneBattle ? Math.min( ...casualtyOptions( @@ -922,7 +928,9 @@ function plans(g: GameView): Action[] { me.hand ?? [], ), ); - if (b.compliantPlan) ranked.push({ type: 'battlePlan', ...b.compliantPlan }); + if (b.compliantPlan && commitments.every( + (element) => b.compliantPlan![element.field] === element.value, + )) ranked.push({ type: 'battlePlan', ...b.compliantPlan }); return ranked; } @@ -1303,6 +1311,8 @@ function policyActions(g: GameView): Action[] { ((g.response.kind === 'voice' && g.battle!.voice?.target === me.id) || (g.response.kind === 'prescience' && g.battle!.prescience?.player !== me.id) || + (g.response.kind === 'nexusPrescience' && + g.battle!.nexusInspection?.target === me.id) || g.response.kind === 'harkonnenTraitor' || g.response.kind === 'eliteStrength' || g.response.kind === 'fremenSupport' || @@ -3098,10 +3108,18 @@ function policyActions(g: GameView): Action[] { }, ]; } - const field = b.prescience!.field; + const nexus = b.preparation.kind === 'nexusPrescienceAnswer' + ? b.nexusInspection + : null; + if ( + b.preparation.kind === 'nexusPrescienceAnswer' && + (!nexus || nexus.target !== me.id || nexus.event !== b.event || nexus.stage !== 'answer') + ) return []; + const field = nexus ? nexus.field : b.prescience!.field; // Candidate completions are legal-checked by the server; no rival secrets enter this choice. return plans(g).map((p) => ({ - type: 'prescienceAnswer', + type: nexus ? 'nexusPrescienceAnswer' : 'prescienceAnswer', + ...(nexus ? { event: nexus.event } : {}), value: p[field], })); } @@ -3298,6 +3316,22 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null { export function botActions(g: GameView): Action[] { if (g.automaticContinuationPending) return []; if (g.nexusCards?.waiting.length) return nexusCardBotActions(g); + const nexus = g.nexusAtreides; + if ( + g.status === 'playing' && g.phase === 6 && nexus && !nexus.blocked && + g.nexusCards?.card === 'atreides' && nexus.event === g.battle?.event && + !g.truthtrance && !g.phaseOpening && !g.decision && + (nexus.mode === 'betrayal' || !g.response) + ) { + if (nexus.mode === 'betrayal') + return [{ type: 'nexusAtreides', event: nexus.event, mode: nexus.mode }]; + const preferences = nexus.mode === 'cunning' || rank(g) >= 2 + ? ['weapon', 'defense', 'leader', 'dial'] as const + : ['dial', 'weapon', 'defense', 'leader'] as const; + const field = preferences.find((field) => nexus.fields.includes(field)); + if (field) + return [{ type: 'nexusAtreides', event: nexus.event, mode: nexus.mode, field }]; + } const intelligence = tupileIntelligenceActions(g); if (intelligence.length) return intelligence; const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => { diff --git a/game/choam-karama.ts b/game/choam-karama.ts index 75bfeb78..acba8999 100644 --- a/game/choam-karama.ts +++ b/game/choam-karama.ts @@ -1,18 +1,24 @@ import type { Game, Player } from './engine'; import { retentionReservesCard } from './moritani-retention'; +import { committedPlanElements } from './battle-inspections'; /** Only cards not already fixed into a battle may be converted into spice. */ export function cashInCards( g: Pick, p: Pick, ) { const plan = g.battle?.plans[p.id]; - const insight = g.battle?.prescience; + const commitments = g.battle ? committedPlanElements(g.battle, p.id) : []; return p.hand.filter( (c) => - !(g.pendingWinnerDiscards?.player === p.id && - [...g.pendingWinnerDiscards.cards, ...g.pendingWinnerDiscards.optional].includes(c.id)) && + !( + g.pendingWinnerDiscards?.player === p.id && + [ + ...g.pendingWinnerDiscards.cards, + ...g.pendingWinnerDiscards.optional, + ].includes(c.id) + ) && !retentionReservesCard(g, p.id, c.id) && (!plan || ![plan.weapon, plan.defense, plan.leader].includes(c.id)) && - (!insight || insight.player === p.id || insight.value !== c.id), + !commitments.some((element) => element.value === c.id), ); } diff --git a/game/engine.ts b/game/engine.ts index 0c710168..786d3f3c 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,4 +1,5 @@ -import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, type NexusState } from './nexus-cards'; +import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections'; +import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards'; import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase'; import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history'; import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence'; @@ -532,6 +533,9 @@ export type PlanField = Exclude< 'support' | 'kwisatz' | 'allyPayment' >; export type Battle = { + nexusInspection?: NexusInspection; + /** Independent presence marker: deleting a spent-card record is not a legacy save. */ + nexusInspectionUsed?: string; homeworldDefensePassed?: string[]; event?: string; strongholdCopy?: StrongholdId; @@ -561,7 +565,7 @@ export type Battle = { revealed: boolean; traitorCalls: Record; preparation?: { - kind: 'voice' | 'prescience' | 'prescienceAnswer'; + kind: 'voice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer'; owner: string; beneficiary: string; }; @@ -757,6 +761,7 @@ export type ResponseWindow = { | 'moritaniRetention' | 'voice' | 'prescience' + | 'nexusPrescience' | 'advisor' | 'emperorIncome' | 'richesePurchaseIncome' @@ -1484,6 +1489,140 @@ function projectedNexusCards(g: Game, id: string) { choices: nexusCardChoices(nexus.phase, nexus.cards, id), waiting: nexus.phase?.stage === 'drawing' ? nexus.phase.eligible.filter(owner => !nexus.phase!.done.includes(owner)) : [] }; } +function battleInspectionContext(g: Game): BattleInspectionContext { + const b = g.battle!; + return { event: b.event!, attacker: b.attacker, defender: b.defender, + players: g.players.map(({id,faction}) => ({id,faction})), native: b.prescience }; +} +function nexusInspectionIntegrity(g: Game) { + const b = g.battle; + if (!b) return; + const record = b.nexusInspection; + requireRule(!!record === !!b.nexusInspectionUsed, 'The Nexus inspection presence record is missing.'); + if (!record) return; + requireRule(g.phase === 6 && b.event && b.nexusInspectionUsed === b.event && + g.nexusCards?.cards?.discard.includes('atreides'), 'The played Nexus inspection has lost its battle or physical card.'); + nexusRule(() => validateNexusInspection(battleInspectionContext(g), record)); + const continuation = g.pendingTreacheryDiscard?.continuation; + const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, + g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, + continuation && 'resume' in continuation ? continuation.resume : null]; + const responses = contexts.flatMap(context => { + const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; + return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null]; + }).filter(response => response?.kind === 'nexusPrescience'); + requireRule((record.stage === 'response') === (responses.length > 0), + 'The extra inspection has lost or reopened its original cancellation response.'); + for (const response of responses) validateNexusInspectionResponse(g, response!); + for (const target of [b.attacker, b.defender]) { + const plan = b.plans[target]; + if (plan) requireRule(committedPlanElements(b, target).every(element => plan[element.field] === element.value), + 'The sealed plan contradicts its inspected commitment.'); + } + if (record.stage === 'answer' || record.stage === 'response') + requireRule(!b.revealed && (b.preparation?.kind === 'nexusPrescienceAnswer' || + (b.preparation?.kind === 'prescienceAnswer' && b.prescience && !Object.hasOwn(b.prescience, 'value'))), + 'The unanswered Nexus inspection is missing its owned answer.'); + if (b.preparation?.kind === 'nexusPrescienceAnswer') + requireRule((record.stage === 'answer' || record.stage === 'response') && b.preparation.owner === record.target && + b.preparation.beneficiary === record.owner, 'The pending Nexus answer belongs to the wrong player.'); +} +function nexusAtreidesOffer(g: Game, id: string) { + if (g.nexusCards?.cards?.hands[id] !== 'atreides') return null; + const p = getPlayer(g, id), b = g.battle; + const mode = nexusCardMode('atreides', p.faction, g.players.map(player => player.faction)); + let blocked: string | null = null; + if (g.status !== 'playing' || g.phase !== 6 || !b?.event || b.revealed) + blocked = 'Play this Nexus Card before the current battle plans are revealed.'; + else if (p.ally) blocked = 'Allied players cannot use a Nexus Card.'; + else if (b.nexusInspection || b.nexusInspectionUsed) blocked = 'This battle already used the Atreides Nexus Card.'; + else if (g.truthtrance || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy) + blocked = 'Finish the current interaction before playing this Nexus Card.'; + else if (mode === 'betrayal') { + blocked = 'Betrayal reactions await the private response timing decision.'; + } else if (![b.attacker, b.defender].includes(id) || b.plans[id]) + blocked = 'Use this inspection in your own battle before sealing your plan.'; + else if (g.response || b.preparation || (b.preLeader && !b.preLeader.closed)) + blocked = 'Finish the preceding battle preparation first.'; + else if (mode === 'cunning' && (!b.prescience || b.prescience.player !== id || !Object.hasOwn(b.prescience, 'value'))) + blocked = 'Cunning needs your first completed native inspection.'; + const target = b && (b.attacker === id ? b.defender : b.attacker); + const fields = mode === 'betrayal' ? [] : (['leader', 'weapon', 'defense', 'dial'] as PlanField[]) + .filter(field => !(mode === 'cunning' && (field === b?.prescience?.field || + (field === 'dial' && target && b?.noFieldPlayers?.includes(target))))); + return { event: b?.event ?? '', mode, fields, blocked }; +} +function finishInspectionAnswers(g: Game) { + const b = g.battle!; + if (b.prescience && !Object.hasOwn(b.prescience, 'value')) { + b.preparation = { kind: 'prescienceAnswer', owner: b.prescience.player === b.attacker ? b.defender : b.attacker, + beneficiary: b.prescience.player }; + } else if (b.nexusInspection?.stage === 'answer') { + b.preparation = { kind: 'nexusPrescienceAnswer', owner: b.nexusInspection.target, beneficiary: b.nexusInspection.owner }; + } else if (b.nexusInspection && b.nexusInspection.mode !== 'betrayal') delete b.preparation; + else finishBattlePreparation(g); +} +function answerCurrentNexusInspection(g: Game, value: unknown) { + const b = g.battle!, record = b.nexusInspection!; + const target = getPlayer(g, record.target); + requireRule(feasiblePrescience(g, target, record.field, value), + 'Choose an element that permits a legal battle plan and respects every prior commitment.'); + b.nexusInspection = nexusRule(() => answerNexusInspection(battleInspectionContext(g), record, value as string | number | null)); + log(g, `${target.name} committed the requested ${record.field}. Its value is private to the two combatants.`); + finishInspectionAnswers(g); +} +function validateNexusInspectionResponse(g: Game, response: ResponseWindow) { + const b = g.battle, record = b?.nexusInspection; + requireRule(response.kind === 'nexusPrescience' && record?.mode === 'cunning' && record.stage === 'response' && + !record.answers.length && response.owner === record.owner && response.intent === b!.event, + 'The extra Prescience response no longer matches its original attempt.'); + return record; +} +function settleNexusInspectionResponse(g: Game, response: ResponseWindow, canceled: boolean) { + const b = g.battle!, record = validateNexusInspectionResponse(g, response); + if (canceled) { + b!.nexusInspection = nexusRule(() => cancelNexusInspection(battleInspectionContext(g), record)); + log(g, 'Karama canceled the additional Atreides inspection. The first disclosed element remains binding.'); + finishInspectionAnswers(g); + } else { + b.nexusInspection = nexusRule(() => allowNexusInspection(battleInspectionContext(g), record)); + if (b.plans[record.target]) answerCurrentNexusInspection(g, b.plans[record.target][record.field]); + } +} +function playNexusAtreides(g: Game, p: Player, action: Action) { + const offer = nexusAtreidesOffer(g, p.id); + requireRule(offer && !offer.blocked && action.event === offer.event && action.mode === offer.mode, + offer?.blocked ?? 'This Atreides Nexus opportunity is stale.'); + requireRule(Object.keys(action).sort().join(',') === (offer.mode === 'betrayal' ? 'event,mode,type' : 'event,field,mode,type') && + (offer.mode === 'betrayal' || offer.fields.includes(action.field as PlanField)), 'Choose a current Nexus inspection field.'); + const b = g.battle!; + const field = offer.mode === 'betrayal' ? b.prescience!.field : action.field as PlanField; + const beneficiary = offer.mode === 'betrayal' ? b.prescience!.player : p.id; + const target = beneficiary === b.attacker ? b.defender : b.attacker; + const record = nexusRule(() => createNexusInspection(battleInspectionContext(g), { mode: offer.mode, owner: p.id, target, field })); + g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + b.nexusInspection = record; + b.nexusInspectionUsed = b.event; + log(g, `${p.name} played the Atreides Nexus Card: ${offer.mode === 'cunning' ? 'Cunning' : offer.mode === 'secretAlly' ? 'Secret Ally' : 'Betrayal'}.`, + { faction: p.faction, name: 'Atreides Nexus' }); + if (offer.mode === 'betrayal') { + g.response = null; + delete b.prescience; + finishBattlePreparation(g); + } else { + b.preparation = { kind: 'nexusPrescienceAnswer', owner: target, beneficiary: p.id }; + if (offer.mode === 'cunning') g.response = { kind: 'nexusPrescience', owner: p.id, intent: b.event, passed: [] }; + else if (b.plans[target]) answerCurrentNexusInspection(g, b.plans[target][field]); + } +} +function projectedNexusInsights(g: Game, id: string) { + const record = g.battle?.nexusInspection; + if (!record || ![record.owner, record.target].includes(id)) return []; + return record.answers.map((value, index) => ({ field: record.field, value, + label: g.players.flatMap(p => [...p.leaders, ...p.hand]).concat(g.dukeVidal ? [g.dukeVidal.leader] : []) + .find(item => item.id === value)?.name ?? String(value ?? 'None'), + active: record.stage === 'answered' && index === record.answers.length - 1 })); +} function homeworldRule(quote: () => T): T { try { return quote(); @@ -5600,8 +5739,7 @@ function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null { const plan = g.battle?.plans[p.id]; if (plan && [plan.weapon, plan.defense, plan.leader].includes(card.id)) return 'A sealed battle card cannot be spent as Karama.'; - const insight = g.battle?.prescience; - if (insight && insight.player !== p.id && insight.value === card.id) + if (g.battle && committedPlanElements(g.battle, p.id).some(element => element.value === card.id)) return 'The card committed to prescience must remain available.'; if (card.kind === 'worthless' && g.pendingKarama) return 'A Worthless conversion is already pending.'; @@ -5973,6 +6111,7 @@ function validateKaramaUse( if (use.response.kind === 'ixAuction') currentIxAuctionDrawQuote(g, use.response, true, spendingCard); moritaniAllianceCancellationQuote(g, use.response); + if (use.response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, use.response); if (isCombatResponseKind(use.response.kind)) currentCombatResponseQuote(g, { kind: 'response', @@ -6107,6 +6246,11 @@ function assertKaramaPromiseFeasibility( delete b.prescience; delete b.preparation; break; + case 'nexusPrescience': + if (!b?.nexusInspection) return false; + b.nexusInspection = cancelNexusInspection(battleInspectionContext(projected), b.nexusInspection); + delete b.preparation; + break; case 'eliteStrength': if (!b) return false; (b.eliteBlocked ??= []).push(use.response.owner); @@ -7219,8 +7363,7 @@ function ambassadorDiscardBlock(g: Game, p: Player, card: Card, effect?: Ambassa const plan = g.battle?.plans[p.id]; if (plan && [plan.weapon, plan.defense, plan.leader].includes(card.id)) return 'This card is committed to a sealed battle plan.'; - const insight = g.battle?.prescience; - if (insight && insight.player !== p.id && insight.value === card.id) + if (g.battle && committedPlanElements(g.battle, p.id).some(element => element.value === card.id)) return 'This card is committed to prescience.'; if (effect === 'choam') return homeworldRule(() => homeworldWorthlessSaleBlock(g, p.id, card)); @@ -10745,9 +10888,11 @@ function validateResidualPoison( ); requireRule( !Object.keys(b.plans).length && - !(b.prescience?.field === 'leader' && 'value' in b.prescience), + ![b.attacker, b.defender].some(owner => committedPlanElements(b, owner).some(element => element.field === 'leader')), 'Residual Poison must be played before either combatant commits a leader.', ); + requireRule(!(b.nexusInspection?.mode === 'cunning' && b.nexusInspection.stage === 'answered'), + 'Residual Poison after two inspected elements awaits the ruling for incompatible surviving commitments.'); requireRule( !g.truthtrance && !g.response && !g.decision && !g.phaseOpening, 'Resolve the current interaction before playing Residual Poison.', @@ -10826,15 +10971,20 @@ function playResidualPoison( `${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`, { faction: p.faction, name: 'Residual Poison' }, ); - // Opposing involuntary death may release an impossible answer; it never rerolls the victim. - reconcileBattlePromises(g); + // First retire an inspection made impossible by the death itself. Testing on + // a private copy without Truthtrance avoids releasing a still-feasible truth + // answer solely because an obsolete inspection was left in the trial. const insight = b.prescience; if (insight && 'value' in insight && insight.field !== 'leader') { + const trial = structuredClone(g); + trial.battle!.truthPromises = []; const answering = getPlayer( - g, + trial, insight.player === b.attacker ? b.defender : b.attacker, ); - if (!feasiblePrescience(g, answering, insight.field, insight.value)) { + if (!feasiblePrescience(trial, answering, insight.field, insight.value)) { + if (b.nexusInspection?.mode === 'cunning') + b.nexusInspection = nexusRule(() => reopenNexusNative(battleInspectionContext(g), b.nexusInspection!)); delete insight.value; b.preparation = { kind: 'prescienceAnswer', @@ -10847,6 +10997,17 @@ function playResidualPoison( ); } } + const nexus = b.nexusInspection; + if (nexus?.stage === 'answered' && nexus.field !== 'leader') { + const trial = structuredClone(g); + trial.battle!.truthPromises = []; + if (!findReachableBattlePlan(trial, getPlayer(trial, nexus.target))) { + b.nexusInspection = nexusRule(() => reopenNexusInspection(battleInspectionContext(g), nexus)); + finishInspectionAnswers(g); + log(g, 'The leader death made the inspected element impossible. Its owner must answer the same element again; the earlier private observation is retained.'); + } + } + reconcileBattlePromises(g); if (b.preLeader && !b.preLeader.closed) b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id); } @@ -11132,11 +11293,10 @@ function validatePlan( 'Your battle plan must comply with the Voice.', ); } - const prescience = b.prescience; - if (prescience && prescience.player !== p.id && 'value' in prescience) + for (const element of committedPlanElements(b, p.id)) requireRule( - plan[prescience.field] === prescience.value, - 'The element revealed to Atreides must remain unchanged.', + plan[element.field] === element.value, + 'Every element revealed by a battle inspection must remain unchanged.', ); requireRule( respectsBattlePromises( @@ -11338,6 +11498,7 @@ function gholaOptions(g: Game, p: Player) { } function marketGholaIntegrity(g: Game) { nexusCardsIntegrity(g); + nexusInspectionIntegrity(g); homeworldHistoryIntegrity(g); grummanCollectionIntegrity(g); homeworldVictoryReturnIntegrity(g); @@ -11462,7 +11623,10 @@ function findLegalBattlePlan( const b = g.battle!; const promises = options.promises ?? b.truthPromises ?? []; const fixed = options.prescience; + const commitments = committedPlanElements(b, p.id); const accepts = (plan: Partial) => + commitments.every(element => + plan[element.field] === undefined || plan[element.field] === element.value) && respectsBattlePromises(promises, p.id, plan, p.hand) && (!fixed || plan[fixed.field] === undefined || @@ -11603,6 +11767,8 @@ function findReachableBattlePlan( const pending = pendingBattleRevivalIncome(initial, player); if (pending) player.spice += pending; // The real table must finish its current window before it can execute a preparation action. + if (initial.battle?.nexusInspection?.stage === 'response') + initial.battle.nexusInspection = allowNexusInspection(battleInspectionContext(initial), initial.battle.nexusInspection); initial.response = null; initial.pendingKarama = null; // Search after Truthtrance resolves, retaining every existing or proposed @@ -13708,6 +13874,7 @@ function finishResponse(g: Game, canceled: boolean) { karamaConversionIntegrity(g); currentFactionPayment(g); const response = g.response!; + if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response); const ecazCollectionQuote = response.kind === 'ecazCollection' ? currentEcazCollectionQuote(g, response, canceled) @@ -14484,6 +14651,8 @@ function finishResponse(g: Game, canceled: boolean) { ); } else log(g, 'CHOAM battle force payments went to the bank.'); finishBattle(g); + } else if (response.kind === 'nexusPrescience') { + settleNexusInspectionResponse(g, response, canceled); } else if (isCombatResponseKind(response.kind)) { commitCombatResponseQuote( g, @@ -16524,6 +16693,7 @@ function applyActionInner( if (t === 'advanceBots') return g; if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; } requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.'); + if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; } requireRule( !( t === 'card' && @@ -19346,7 +19516,7 @@ function applyActionInner( if (t === 'declineBattlePower') { const b = g.battle; requireRule( - b?.preparation?.owner === id && b.preparation.kind !== 'prescienceAnswer', + b?.preparation?.owner === id && !['prescienceAnswer', 'nexusPrescienceAnswer'].includes(b.preparation.kind), 'You do not own this battle power decision.', ); const kind = b.preparation.kind; @@ -19383,6 +19553,20 @@ function applyActionInner( g.response = { kind: 'prescience', owner: id, passed: [] }; return g; } + if (t === 'nexusPrescienceAnswer') { + const b = g.battle, record = b?.nexusInspection; + requireRule(b?.preparation?.kind === 'nexusPrescienceAnswer' && + b.preparation.owner === id && record?.stage === 'answer' && record.target === id && + action.event === record.event && Object.keys(action).sort().join(',') === 'event,type,value', + 'This Nexus answer does not match your current inspection.'); + requireRule(record.mode !== 'cunning' || record.field !== 'dial' || !b.noFieldPlayers?.includes(id), + 'Atreides may not inspect the number dialed in a No-Field battle.'); + const value = record.field === 'dial' + ? typeof action.value === 'number' ? action.value : NaN + : action.value === null || action.value === '' ? null : stringField(action.value); + answerCurrentNexusInspection(g, value); + return g; + } if (t === 'prescienceAnswer') { const b = g.battle; requireRule( @@ -19414,8 +19598,10 @@ function applyActionInner( feasiblePrescience(g, p, field, value), 'Choose an element that permits a legal battle plan and obeys the Voice.', ); + if (b.nexusInspection?.mode === 'cunning') + b.nexusInspection = nexusRule(() => answerNexusNative(battleInspectionContext(g), b.nexusInspection!, value)); b.prescience.value = value; - finishBattlePreparation(g); + finishInspectionAnswers(g); return g; } if (t === 'voice') { @@ -19714,7 +19900,7 @@ export function viewGame(state: Game, id: string) { (b.truthPromises?.some( (promise) => promise.player === id && !promise.released, ) || - (b.prescience && b.prescience.player !== id && 'value' in b.prescience)) + committedPlanElements(b, id).length > 0) ? findReachableBattlePlan(g, me) : null; return { @@ -19965,6 +20151,7 @@ export function viewGame(state: Game, id: string) { techTokens: g.techTokens ?? null, strongholdCards: g.strongholdCards ?? null, nexusCards: projectedNexusCards(g, id), + nexusAtreides: nexusAtreidesOffer(g, id), homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id), caladanReinforcement: projectedHomeworldVictoryReturn(g, id), homeworldRevivalBlocks: homeworldRevivalChoiceBlocks(g, me), @@ -20594,6 +20781,13 @@ export function viewGame(state: Game, id: string) { traitorSubmitted: Object.keys(b.traitorCalls), traitorVoters: traitorVoters(g, b), preparation: b.preparation ?? null, + nexusInspection: b.nexusInspection ? { + event: b.nexusInspection.event, mode: b.nexusInspection.mode, + owner: b.nexusInspection.owner, target: b.nexusInspection.target, + field: b.nexusInspection.field, stage: b.nexusInspection.stage, + } : null, + nexusInsights: projectedNexusInsights(g, id), + ownCommitments: committedPlanElements(b, id), prescience: b.prescience ? { player: b.prescience.player, diff --git a/game/karama-context.ts b/game/karama-context.ts index 58838da5..0aed0a16 100644 --- a/game/karama-context.ts +++ b/game/karama-context.ts @@ -75,6 +75,11 @@ const cancellationSources = { prescience: g.battle?.prescience ?? null, preparation: g.battle?.preparation ?? null, }), + nexusPrescience: (g) => ({ + battle: battleSource(g), + inspection: g.battle?.nexusInspection ?? null, + preparation: g.battle?.preparation ?? null, + }), advisor: (g, response) => response.advisorResume === 'ambassador' ? { diff --git a/game/reference.ts b/game/reference.ts index 797d3f9b..f3544a08 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3340,7 +3340,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. Ownership and private draw choices are integrated; individual effects remain in development.', + summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and Atreides inspection controls are integrated. 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.', @@ -3349,15 +3349,19 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Before drawing, choose whether to keep your own faction card, redraw it once, or redraw whenever it appears. Its effect is available in the reference before making this choice. The server applies that preference immediately; the table never pauses in a way that identifies a card you chose to keep.', 'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.', 'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.', - 'The development table supports custody, closing choices, replacement, immediate redraw, alliance discard and private card inspection. Effect execution and full module games are unfinished, so public module starts remain disabled.', + 'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.', + 'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.', + 'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.', + 'Residual Poison cannot follow a committed leader. If it makes a single nonleader inspection impossible, the same field is answered again and the earlier observation remains private history. Residual Poison after two completed Cunning inspections awaits a ruling on incompatible surviving commitments.', + 'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and 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 ownership and whole-phase draw lifecycle; all individual card effects still require integration.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Private draw, keep, replace and immediate own-faction redraw; readable card inspection. Effect play controls remain unfinished.', evidence: ['components/nexus-cards.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All profiles draw when empty and keep their held card. Effect strategy and module strength calibration are unfinished.', evidence: ['game/nexus-card-options.ts'] }, - { area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve printed faces are recorded. Effect interaction guidance remains in development.', evidence: ['docs/NEXUS_CARD_RULES.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Focused custody and phase regressions; complete module games remain a release requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle plus Atreides Cunning and Secret Ally inspections, distinct commitments and cancellation. Betrayal and remaining card families are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Private card lifecycle, Atreides element selection, owned answers, private history and all four plan locks. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All profiles handle lifecycle, offered Atreides inspections and both binding answers using only authorized views. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and Atreides inspection interactions are recorded with explicit unresolved boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Custody, phase, inspection, controls, bot and concurrent recovery regressions; complete module games remain a release requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts'] }, ], }, ...[ diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts index 982f9ab6..cf73bdf1 100644 --- a/game/richese-cancellation.ts +++ b/game/richese-cancellation.ts @@ -73,6 +73,7 @@ const RESPONSES = { moritaniRetention: true, voice: true, prescience: true, + nexusPrescience: true, advisor: true, emperorIncome: true, richesePurchaseIncome: true, diff --git a/tests/battle-inspections.test.ts b/tests/battle-inspections.test.ts new file mode 100644 index 00000000..da6d0946 --- /dev/null +++ b/tests/battle-inspections.test.ts @@ -0,0 +1,460 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createNexusInspection, + cancelNexusInspection, + answerNexusInspection, + allowNexusInspection, + reopenNexusNative, + answerNexusNative, + reopenNexusInspection, + validateNexusInspection, + committedPlanElements, + inspectedPlanElements, + type BattleInspectionContext, + type NexusInspection, +} from '../game/battle-inspections'; + +const context = (): BattleInspectionContext => ({ + event: 'battle-2', + attacker: 'a', + defender: 'g', + players: [ + { id: 'a', faction: 'atreides' }, + { id: 'g', faction: 'guild' }, + { id: 'h', faction: 'harkonnen' }, + ], + native: { player: 'a', field: 'dial', value: 0 }, +}); +const cunning = (c = context()) => + createNexusInspection(c, { + mode: 'cunning', + owner: 'a', + target: 'g', + field: 'weapon', + }); +const reload = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +void test('Cunning snapshots a real first answer including zero and null and requires a distinct second element', () => { + for (const native of [ + { player: 'a', field: 'dial' as const, value: 0 }, + { player: 'a', field: 'leader' as const, value: null }, + ]) { + const c = { ...context(), native }; + const before = JSON.stringify(c); + const inspection = cunning(c); + assert.deepEqual(inspection.first, native); + assert.equal(inspection.stage, 'response'); + assert.deepEqual(inspection.answers, []); + assert.throws(() => + createNexusInspection(c, { + mode: 'cunning', + owner: 'a', + target: 'g', + field: native.field, + }), + ); + assert.equal(JSON.stringify(c), before); + const unanswered = { + ...c, + native: { player: native.player, field: native.field }, + }; + assert.throws(() => cunning(unanswered)); + assert.throws(() => cunning({ ...c, native: undefined })); + } +}); + +void test('Cunning binds the current native answer and permits changes only through recorded Residual transitions', () => { + const c = context(); + const inspection = answerNexusInspection( + c, + allowNexusInspection(c, cunning(c)), + null, + ); + const pending = { ...c, native: { player: 'a', field: 'dial' as const } }; + const changed = { + ...c, + native: { player: 'a', field: 'dial' as const, value: 2.5 }, + }; + assert.throws(() => validateNexusInspection(pending, inspection)); + assert.throws(() => validateNexusInspection(changed, inspection)); + const reopened = reopenNexusNative(c, inspection); + assert.equal(Object.hasOwn(reopened, 'firstCurrent'), false); + validateNexusInspection(pending, reopened); + assert.throws(() => reopenNexusNative(pending, reopened)); + assert.throws(() => answerNexusNative(c, inspection, 2.5)); + const answered = answerNexusNative(pending, reopened, 2.5); + validateNexusInspection(changed, answered); + assert.deepEqual(answered.firstCurrent, { value: 2.5 }); + assert.equal(answered.first!.value, 0); + assert.equal(inspection.firstCurrent!.value, 0); + assert.throws(() => validateNexusInspection(c, answered)); + assert.throws(() => + validateNexusInspection({ ...c, native: undefined }, answered), + ); + const canceled = cancelNexusInspection(c, cunning(c)); + const canceledReopened = reopenNexusNative(c, canceled); + validateNexusInspection(pending, canceledReopened); + assert.equal( + answerNexusNative(pending, canceledReopened, 1).stage, + 'canceled', + ); +}); + +void test('reopened non-leader inspection retains immutable disclosure history but only its latest answered value binds', () => { + const c = context(); + const first = answerNexusInspection( + c, + allowNexusInspection(c, cunning(c)), + 'treachery-1', + ); + const firstJson = JSON.stringify(first); + const reopened = reopenNexusInspection(c, reload(first)); + const battle = { + event: c.event, + attacker: c.attacker, + defender: c.defender, + prescience: c.native, + nexusInspection: reopened, + }; + assert.deepEqual(committedPlanElements(battle, 'g'), [ + { + source: 'native', + beneficiary: 'a', + target: 'g', + field: 'dial', + value: 0, + }, + ]); + const second = answerNexusInspection(c, reopened, null); + assert.deepEqual(second.answers, ['treachery-1', null]); + assert.deepEqual( + committedPlanElements({ ...battle, nexusInspection: second }, 'g').at(-1), + { + source: 'nexus', + beneficiary: 'a', + target: 'g', + field: 'weapon', + value: null, + }, + ); + assert.deepEqual( + inspectedPlanElements({ ...battle, nexusInspection: second }, 'a'), + [], + ); + assert.deepEqual( + committedPlanElements({ ...battle, nexusInspection: second }, 'h'), + [], + ); + assert.equal(JSON.stringify(first), firstJson); + assert.deepEqual(reopened.answers, ['treachery-1']); + assert.throws(() => reopenNexusInspection(c, reopened)); + assert.throws(() => answerNexusInspection(c, second, 'treachery-2')); +}); + +void test('Secret Ally requires an absent Atreides and an actual opposing combatant; leaders never reopen', () => { + const c: BattleInspectionContext = { + event: 'absent', + attacker: 'h', + defender: 'g', + players: [ + { id: 'h', faction: 'harkonnen' }, + { id: 'g', faction: 'guild' }, + { id: 'e', faction: 'emperor' }, + ], + }; + const input = { + mode: 'secretAlly' as const, + owner: 'h', + target: 'g', + field: 'leader' as const, + }; + const inspection = answerNexusInspection( + c, + createNexusInspection(c, input), + null, + ); + assert.throws(() => reopenNexusInspection(c, inspection)); + assert.throws(() => + createNexusInspection( + { ...c, players: [...c.players, { id: 'a', faction: 'atreides' }] }, + input, + ), + ); + assert.throws(() => createNexusInspection(c, { ...input, owner: 'e' })); + assert.throws(() => createNexusInspection(c, { ...input, target: 'e' })); + assert.throws(() => createNexusInspection(c, { ...input, mode: 'cunning' })); + validateNexusInspection(reload(c), reload(inspection)); +}); + +void test('Betrayal binds a pending native attempt even for an allied beneficiary and a third-party holder', () => { + const c: BattleInspectionContext = { + ...context(), + attacker: 'h', + native: { player: 'h', field: 'defense' }, + }; + const input = { + mode: 'betrayal' as const, + owner: 'g', + target: 'g', + field: 'defense' as const, + }; + const inspection = createNexusInspection(c, input); + assert.equal(inspection.nativeProvider, 'a'); + assert.equal(inspection.nativeBeneficiary, 'h'); + assert.equal(inspection.stage, 'canceled'); + assert.deepEqual(inspection.answers, []); + validateNexusInspection({ ...c, native: undefined }, reload(inspection)); + assert.deepEqual( + committedPlanElements({ ...c, nexusInspection: inspection }, 'g'), + [], + ); + assert.throws(() => answerNexusInspection(c, inspection, null)); + assert.throws(() => reopenNexusInspection(c, inspection)); + assert.throws(() => + createNexusInspection( + { ...c, native: { ...c.native!, value: null } }, + input, + ), + ); + assert.throws(() => + createNexusInspection({ ...c, native: undefined }, input), + ); + assert.throws(() => createNexusInspection(c, { ...input, owner: 'a' })); + const outside = { + ...c, + players: [...c.players, { id: 'e', faction: 'emperor' as const }], + }; + validateNexusInspection( + outside, + createNexusInspection(outside, { ...input, owner: 'e' }), + ); +}); + +void test('bound receipt rejects battle, owner, target, field, history and stage corruption without mutation', () => { + const c = context(); + const original = answerNexusInspection( + c, + allowNexusInspection(c, cunning(c)), + null, + ); + const corruptions: Array<(i: NexusInspection) => void> = [ + (i) => { + i.event = 'another'; + }, + (i) => { + i.owner = 'h'; + }, + (i) => { + i.target = 'a'; + }, + (i) => { + i.field = 'defense'; + }, + (i) => { + i.stage = 'answer'; + }, + (i) => { + i.answers = []; + }, + (i) => { + i.first!.value = 1; + }, + (i) => { + delete i.first; + }, + (i) => { + Object.assign(i, { hand: ['secret'] }); + }, + (i) => { + i.signature = ''; + }, + ]; + for (const corrupt of corruptions) { + const candidate = reload(original); + corrupt(candidate); + const before = JSON.stringify(candidate); + assert.throws(() => validateNexusInspection(c, candidate)); + assert.throws(() => reopenNexusInspection(c, candidate)); + assert.equal(JSON.stringify(candidate), before); + } + assert.throws(() => + validateNexusInspection({ ...c, event: 'another' }, original), + ); + assert.throws(() => + committedPlanElements( + { + attacker: 'a', + defender: 'g', + event: 'another', + nexusInspection: original, + }, + 'g', + ), + ); +}); + +void test('typed answers allow half-force dials and no-card null but reject invalid serialized values', () => { + const c: BattleInspectionContext = { + event: 'dial', + attacker: 'h', + defender: 'g', + players: [ + { id: 'h', faction: 'harkonnen' }, + { id: 'g', faction: 'guild' }, + ], + }; + const waiting = createNexusInspection(c, { + mode: 'secretAlly', + owner: 'h', + target: 'g', + field: 'dial', + }); + assert.deepEqual(answerNexusInspection(c, waiting, 0.5).answers, [0.5]); + for (const value of [ + null, + '2', + -1, + NaN, + Infinity, + 0.25, + Number.MAX_SAFE_INTEGER, + ]) + assert.throws(() => answerNexusInspection(c, waiting, value)); + const weapon = createNexusInspection(c, { + mode: 'secretAlly', + owner: 'h', + target: 'g', + field: 'weapon', + }); + for (const value of [0, '', ' ', undefined]) + assert.throws(() => + answerNexusInspection(c, weapon, value as unknown as null), + ); +}); + +void test('public-only context never reads hands, plans, resources or unrelated private state', () => { + const c = context(); + for (const player of c.players) + for (const key of ['hand', 'spice', 'traitors']) + Object.defineProperty(player, key, { + get() { + throw new Error(`Private ${key} accessed`); + }, + }); + Object.defineProperty(c, 'plans', { + get() { + throw new Error('Plans accessed'); + }, + }); + const before = JSON.stringify(c); + const answered = answerNexusInspection( + c, + allowNexusInspection(c, cunning(c)), + null, + ); + validateNexusInspection(c, reload(answered)); + assert.equal(JSON.stringify(c), before); + assert.deepEqual(Object.keys(answered).sort(), [ + 'answers', + 'event', + 'field', + 'first', + 'firstCurrent', + 'mode', + 'owner', + 'signature', + 'stage', + 'target', + 'version', + ]); +}); + +void test('canceling the original Cunning extra attempt preserves its first disclosure and cannot cancel other sources or old answers', () => { + const c = context(); + const waiting = cunning(c); + const before = JSON.stringify(waiting); + const canceled = cancelNexusInspection(c, waiting); + assert.equal(canceled.stage, 'canceled'); + assert.deepEqual(canceled.answers, []); + assert.deepEqual(canceled.first, waiting.first); + validateNexusInspection(c, reload(canceled)); + assert.deepEqual( + committedPlanElements( + { + event: c.event, + attacker: c.attacker, + defender: c.defender, + prescience: c.native, + nexusInspection: canceled, + }, + 'g', + ), + [ + { + source: 'native', + beneficiary: 'a', + target: 'g', + field: 'dial', + value: 0, + }, + ], + ); + assert.equal(JSON.stringify(waiting), before); + assert.throws(() => cancelNexusInspection(c, canceled)); + assert.throws(() => answerNexusInspection(c, canceled, null)); + assert.throws(() => reopenNexusInspection(c, canceled)); + const answered = answerNexusInspection( + c, + allowNexusInspection(c, waiting), + null, + ); + assert.throws(() => cancelNexusInspection(c, answered)); + assert.throws(() => + cancelNexusInspection(c, reopenNexusInspection(c, answered)), + ); + const absent: BattleInspectionContext = { + ...c, + attacker: 'h', + native: undefined, + players: c.players.filter((p) => p.faction !== 'atreides'), + }; + const secret = createNexusInspection(absent, { + mode: 'secretAlly', + owner: 'h', + target: 'g', + field: 'weapon', + }); + assert.throws(() => cancelNexusInspection(absent, secret)); + const pending = { ...c, native: { player: 'a', field: 'weapon' as const } }; + const betrayal = createNexusInspection(pending, { + mode: 'betrayal', + owner: 'h', + target: 'g', + field: 'weapon', + }); + assert.throws(() => cancelNexusInspection(pending, betrayal)); +}); + +void test('Cunning cannot answer or reopen before its original response is explicitly allowed', () => { + const c = context(); + const response = cunning(c); + const before = JSON.stringify(response); + assert.equal(response.stage, 'response'); + assert.throws(() => answerNexusInspection(c, response, null)); + assert.throws(() => reopenNexusInspection(c, response)); + const allowed = allowNexusInspection(c, response); + assert.equal(allowed.stage, 'answer'); + assert.deepEqual(allowed.first, response.first); + assert.equal(JSON.stringify(response), before); + assert.throws(() => allowNexusInspection(c, allowed)); + assert.throws(() => cancelNexusInspection(c, allowed)); + const rewritten = reload(response); + rewritten.stage = 'answer'; + assert.throws(() => validateNexusInspection(c, rewritten)); + const answered = answerNexusInspection(c, allowed, null); + assert.throws(() => allowNexusInspection(c, answered)); + const reopened = reopenNexusInspection(c, answered); + assert.throws(() => allowNexusInspection(c, reopened)); + assert.throws(() => cancelNexusInspection(c, reopened)); +}); diff --git a/tests/fixture-nexus-inspection.ts b/tests/fixture-nexus-inspection.ts new file mode 100644 index 00000000..2320ede0 --- /dev/null +++ b/tests/fixture-nexus-inspection.ts @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + initializeNexusGameForAudit, + joinGame, + newPlayer, + viewGame, + type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { baseDeck, type Card } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import { deployRicheseNoField } from '../game/richese-no-field'; +import { validateNexusCards } from '../game/nexus-cards'; +import { drawNexusCard } from '../game/nexus-cards'; +import { + nexusFixture, + enterNexusSpice, + finishNexusSpice, + orderNexusSpice, + nexusInventory, + nexusPlayer, + nexusAllow, +} from './fixture-nexus-cards'; + +/** Genuine final roster and component setup, followed by a conserved battle + * boundary. No faction, seat identity or signed history is changed afterward. */ +export function nexusInspectionFixture( + secretAlly = false, + holder = 'a', + options: { richese?: boolean; noField?: boolean } = {}, +): Game { + let g: Game; + if (!secretAlly && !options.richese && !options.noField) g = nexusFixture(); + else { + g = createGame( + 'INSPECT', + newPlayer('f', 'Fremen', 'fremen'), + false, + [], // The explicit audit includes Richese's printed cache and markers; E2 deck release remains gated. + ); + joinGame( + g, + newPlayer( + 'a', + secretAlly ? 'Guild' : 'Atreides', + secretAlly ? 'guild' : 'atreides', + ), + ); + joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen')); + // Public lobby placeholders are replaced before any initialized components/history. + if (options.noField) g.players[2] = newPlayer('h', 'Richese', 'richese'); + else if (options.richese) + g.players[0] = newPlayer('f', 'Richese', 'richese'); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + g.nexusCards = { cards: null, phase: null }; + g = initializeNexusGameForAudit(g); + for (let i = 0; g.status === 'setup' && i < 60; i++) { + let next: Game | undefined; + for (const p of g.players) { + const v = viewGame(g, p.id); + v.players.find((seat) => seat.id === p.id)!.bot = 'Easy'; + const action = botActions(v)[0]; + if (action) { + next = applyAction(g, p.id, action); + break; + } + } + assert.ok(next, `Setup stalled at ${g.setupStage}`); + g = next; + } + assert.equal(g.status, 'playing'); + for (const p of g.players) g.deck.push(...p.hand.splice(0)); + } + g = enterNexusSpice(g); + orderNexusSpice(g, ['land', 'land']); + g = finishNexusSpice(g); + Object.assign(g, { + phase: 6, + active: 'a', + order: ['a', 'h', 'f'], + ready: [], + storm: 18, + response: null, + decision: null, + phaseOpening: null, + }); + for (const p of g.players) { + p.forces = {}; + p.reserves = 20; + p.tanks = 0; + p.spice = 20; + } + for (const id of ['a', 'h']) { + nexusPlayer(g, id).forces = { 'pasty_mesa:5': 5 }; + nexusPlayer(g, id).reserves = 15; + } + const cards = g.nexusCards!.cards!; + cards.deck = [ + 'atreides', + ...cards.deck.filter((card) => card !== 'atreides'), + ]; + g.nexusCards!.cards = drawNexusCard(cards, holder, g.players, () => 0); + if (options.richese || options.noField) inspectionInventory(g); + else nexusInventory(g); + if (options.noField) { + const target = nexusPlayer(g, 'h'); + assert.ok(target.noField); + target.forces = {}; + target.reserves = 20; + target.noField = deployRicheseNoField(target.noField, { + tokenId: target.noField.tokens.find((t) => t.value === 3)!.id, + controller: 'h', + location: { territory: 'pasty_mesa', sector: 5 }, + }); + } + g = applyAction(g, 'a', { + type: 'chooseBattle', + territory: 'pasty_mesa', + target: 'h', + }); + for (const id of ['a', 'h']) + if (g.battle?.preLeader && !g.battle.preLeader.closed) + g = applyAction(g, id, { + type: 'battlePreparationReady', + event: g.battle!.event, + }); + return g; +} +export function inspectionHold(g: Game, id: string, kind: Card['kind']): Card { + const index = g.deck.findIndex((card) => card.kind === kind); + assert.ok(index >= 0, `Missing physical ${kind}`); + const [card] = g.deck.splice(index, 1); + nexusPlayer(g, id).hand.push(card); + return card; +} +export function inspectionNative( + g: Game, + field: 'leader' | 'weapon' | 'defense' | 'dial' = 'dial', + value: string | number | null = 0, +): Game { + assert.equal(g.battle?.preparation?.kind, 'prescience'); + g = nexusAllow(applyAction(g, 'a', { type: 'prescience', field })); + return applyAction(g, 'h', { type: 'prescienceAnswer', value }); +} + +export function inspectionInventory(g: Game): void { + validateNexusCards(g.nexusCards!.cards!, g.players); + assert.deepEqual( + [ + ...g.deck, + ...g.discard, + ...g.players.flatMap((p) => p.hand), + ...(g.richeseCache ?? []), + ] + .map((c) => c.id) + .sort(), + [ + ...baseDeck(), + ...(g.players.some((p) => p.faction === 'richese') ? richeseCards() : []), + ] + .map((c) => c.id) + .sort(), + ); + for (const p of g.players) + assert.equal( + p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), + 20, + ); +} +export function inspectionResidual(g: Game, holder = 'a'): string { + const index = g.richeseCache!.findIndex( + (c) => c.id === 'richese-residual-poison', + ); + assert.ok(index >= 0); + const card = g.richeseCache!.splice(index, 1)[0]; + nexusPlayer(g, holder).hand.push(card); + return card.id; +} diff --git a/tests/fixture-nexus-room-store.ts b/tests/fixture-nexus-room-store.ts new file mode 100644 index 00000000..2b4bb050 --- /dev/null +++ b/tests/fixture-nexus-room-store.ts @@ -0,0 +1,87 @@ +import { webcrypto } from 'node:crypto'; +import { readFileSync, readdirSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { runInNewContext } from 'node:vm'; +import ts from 'typescript'; +import * as engine from '../game/engine'; +import * as bots from '../game/bots'; +import type * as Rooms from '../db/rooms'; + +/** Execute the production room module and SQL, with a hook immediately before its CAS. */ +export function unitStore(runBots = bots.runBots) { + const sqlite = new DatabaseSync(':memory:'); + const hooks: { beforeWrite?: () => Promise } = {}; + const writes: { expected: number; changes: number }[] = []; + for (const file of readdirSync(new URL('../drizzle/', import.meta.url)) + .filter((f) => f.endsWith('.sql')) + .sort()) + sqlite.exec( + readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'), + ); + class Statement { + values: (string | number | null)[] = []; + constructor(readonly sql: string) {} + bind(...values: (string | number | null)[]) { + this.values = values; + return this; + } + async first() { + return sqlite.prepare(this.sql).get(...this.values) ?? null; + } + async run() { + const continuation = this.sql.startsWith('UPDATE rooms SET state'); + if (continuation) await hooks.beforeWrite?.(); + const changes = Number( + sqlite.prepare(this.sql).run(...this.values).changes, + ); + if (continuation) + writes.push({ expected: Number(this.values[3]), changes }); + return { meta: { changes } }; + } + } + const database = { + prepare: (sql: string) => new Statement(sql), + batch: async (statements: Statement[]) => { + sqlite.exec('BEGIN'); + try { + const results = []; + for (const statement of statements) results.push(await statement.run()); + sqlite.exec('COMMIT'); + return results; + } catch (error) { + sqlite.exec('ROLLBACK'); + throw error; + } + }, + }; + function loadRooms() { + const exports = {}; + runInNewContext( + ts.transpileModule( + readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }, + ).outputText, + { + exports, + crypto: webcrypto, + TextEncoder, + structuredClone, + // Keep persisted JSON values in the test realm for strict structural assertions. + JSON, + require: (name: string) => { + if (name === 'cloudflare:workers') return { env: { DB: database } }; + if (name === '@/game/engine') return engine; + if (name === '@/game/bots') return { ...bots, runBots }; + throw new Error('Unexpected module ' + name); + }, + }, + ); + return exports as typeof Rooms; + } + return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes }; +} diff --git a/tests/nexus-card-recovery.test.ts b/tests/nexus-card-recovery.test.ts index e72b5056..54b9a92e 100644 --- a/tests/nexus-card-recovery.test.ts +++ b/tests/nexus-card-recovery.test.ts @@ -1,93 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createHash, webcrypto } from 'node:crypto'; -import { readFileSync, readdirSync } from 'node:fs'; -import { DatabaseSync } from 'node:sqlite'; -import { runInNewContext } from 'node:vm'; -import ts from 'typescript'; +import { createHash } from 'node:crypto'; +import type { DatabaseSync } from 'node:sqlite'; +import { unitStore } from './fixture-nexus-room-store'; import * as engine from '../game/engine'; -import * as bots from '../game/bots'; import type * as Rooms from '../db/rooms'; import { nexusTurnTwo, nexusReady, orderNexusSpice, finishNexusSpice, nexusInventory } from './fixture-nexus-cards'; -/** Execute the production room module and SQL, with a hook immediately before its CAS. */ -function unitStore(runBots = bots.runBots) { - const sqlite = new DatabaseSync(':memory:'); - const hooks: { beforeWrite?: () => Promise } = {}; - const writes: { expected: number; changes: number }[] = []; - for (const file of readdirSync(new URL('../drizzle/', import.meta.url)) - .filter((f) => f.endsWith('.sql')) - .sort()) - sqlite.exec( - readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'), - ); - class Statement { - values: (string | number | null)[] = []; - constructor(readonly sql: string) {} - bind(...values: (string | number | null)[]) { - this.values = values; - return this; - } - async first() { - return sqlite.prepare(this.sql).get(...this.values) ?? null; - } - async run() { - const continuation = this.sql.startsWith('UPDATE rooms SET state'); - if (continuation) await hooks.beforeWrite?.(); - const changes = Number( - sqlite.prepare(this.sql).run(...this.values).changes, - ); - if (continuation) - writes.push({ expected: Number(this.values[3]), changes }); - return { meta: { changes } }; - } - } - const database = { - prepare: (sql: string) => new Statement(sql), - batch: async (statements: Statement[]) => { - sqlite.exec('BEGIN'); - try { - const results = []; - for (const statement of statements) results.push(await statement.run()); - sqlite.exec('COMMIT'); - return results; - } catch (error) { - sqlite.exec('ROLLBACK'); - throw error; - } - }, - }; - function loadRooms() { - const exports = {}; - runInNewContext( - ts.transpileModule( - readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'), - { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2022, - }, - }, - ).outputText, - { - exports, - crypto: webcrypto, - TextEncoder, - structuredClone, - // Keep persisted JSON values in the test realm for strict structural assertions. - JSON, - require: (name: string) => { - if (name === 'cloudflare:workers') return { env: { DB: database } }; - if (name === '@/game/engine') return engine; - if (name === '@/game/bots') return { ...bots, runBots }; - throw new Error('Unexpected module ' + name); - }, - }, - ); - return exports as typeof Rooms; - } - return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes }; -} + const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} }; @@ -118,7 +38,7 @@ async function persisted() { store.writes.length = 0; return {...store,code,credentials,auths,ids,initial,save,otherCode:other.view.code,otherBefore}; } -type Fixture = Awaited>; +type Fixture = Awaited> & { sqlite: DatabaseSync }; function snapshot(f: Fixture) { const row = f.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(f.code)!; return {version:row.version,hash:createHash('sha256').update(String(row.state)).digest('hex')}; diff --git a/tests/nexus-inspection-bots.test.ts b/tests/nexus-inspection-bots.test.ts new file mode 100644 index 00000000..2ec97ba1 --- /dev/null +++ b/tests/nexus-inspection-bots.test.ts @@ -0,0 +1,327 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + newPlayer, + viewGame, + type GameView, +} from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { cashInCards } from '../game/choam-karama'; +import { + createNexusInspection, + allowNexusInspection, + answerNexusInspection, + reopenNexusInspection, +} from '../game/battle-inspections'; + +/** These are consumer tests of authoritative projected fields, not evidence + * of Nexus acquisition or effect execution. The underlying ordinary battle + * supplies real public map/leader/card shapes and conserved physical cards. */ +function fixture(): GameView { + let g = createGame('NEXUSBOTS', newPlayer('e', 'Emperor', 'emperor')); + g.players.push( + newPlayer('q', 'Guild', 'guild'), + newPlayer('h', 'Harkonnen', 'harkonnen'), + ); + Object.assign(g, { + status: 'playing', + phase: 6, + turn: 2, + storm: 18, + active: 'e', + order: ['e', 'q', 'h'], + deck: baseDeck(), + }); + for (const p of g.players) + Object.assign(p, { forces: {}, reserves: 20, spice: 20, hand: [] }); + for (const p of g.players.slice(0, 2)) { + p.forces = { 'arrakeen:10': 5 }; + p.reserves = 15; + } + for (const kind of ['projectile', 'shield', 'poison', 'snooper'] as const) { + const index = g.deck.findIndex((c) => c.kind === kind); + g.players[0].hand.push(g.deck.splice(index, 1)[0]); + } + g = applyAction(g, 'e', { + type: 'chooseBattle', + territory: 'arrakeen', + target: 'q', + }); + const v = viewGame(g, 'e'); + v.battle!.ownCommitments = []; + v.battle!.nexusInsights = []; + v.battle!.nexusInspection = null; + v.nexusAtreides = null; + return v; +} +const own = (v: GameView) => v.players.find((p) => p.id === v.me)!; + +void test('all four profiles preserve simultaneous native and Nexus commitments including zero and null', () => { + for (const difficulty of DIFFICULTIES) + for (const fields of ['cards', 'dial', 'leader'] as const) { + const v = fixture(), + me = own(v), + b = v.battle!; + me.bot = difficulty; + const weapon = me.hand!.find((c) => c.kind === 'projectile')!.id; + b.ownCommitments = + fields === 'cards' + ? [ + { + source: 'native', + beneficiary: 'q', + target: 'e', + field: 'weapon', + value: weapon, + }, + { + source: 'nexus', + beneficiary: 'q', + target: 'e', + field: 'defense', + value: null, + }, + ] + : fields === 'dial' + ? [ + { + source: 'native', + beneficiary: 'q', + target: 'e', + field: 'weapon', + value: weapon, + }, + { + source: 'nexus', + beneficiary: 'q', + target: 'e', + field: 'dial', + value: 0, + }, + ] + : [ + { + source: 'native', + beneficiary: 'q', + target: 'e', + field: 'leader', + value: me.leaders[1].id, + }, + { + source: 'nexus', + beneficiary: 'q', + target: 'e', + field: 'dial', + value: 2, + }, + ]; + const before = structuredClone(v), + actions = botActions(v); + assert.ok(actions.length); + for (const action of actions) { + assert.equal(action.type, 'battlePlan'); + for (const element of b.ownCommitments) + assert.equal(action[element.field], element.value); + } + assert.deepEqual(v, before); + } +}); + +void test('pending Nexus answers bind the battle event and preserve the earlier native weapon', () => { + for (const difficulty of DIFFICULTIES) { + const v = fixture(), + me = own(v), + b = v.battle!; + me.bot = difficulty; + const weapon = me.hand!.find((c) => c.kind === 'projectile')!.id; + b.ownCommitments = [ + { + source: 'native', + beneficiary: 'q', + target: 'e', + field: 'weapon', + value: weapon, + }, + ]; + b.preparation = { + kind: 'nexusPrescienceAnswer', + owner: 'e', + beneficiary: 'q', + }; + b.nexusInspection = { + event: b.event!, + mode: 'cunning', + owner: 'q', + target: 'e', + field: 'defense', + stage: 'answer', + }; + const actions = botActions(v); + assert.ok(actions.length); + for (const action of actions) { + assert.equal(action.type, 'nexusPrescienceAnswer'); + assert.equal(action.event, b.event); + assert.ok( + action.value === null || + me.hand!.some( + (c) => + c.id === action.value && ['shield', 'snooper'].includes(c.kind), + ), + ); + } + b.nexusInspection.stage = 'response'; + assert.deepEqual(botActions(v), []); + b.nexusInspection.stage = 'answer'; + b.nexusInspection.event = 'old-battle'; + assert.deepEqual(botActions(v), []); + } +}); + +void test('all profiles choose only an offered distinct Nexus field and respect public interruption fences', () => { + for (const difficulty of DIFFICULTIES) { + const v = fixture(); + own(v).bot = difficulty; + v.nexusCards = { + card: 'atreides', + deckCount: 11, + discardCount: 0, + held: { e: true, q: false, h: false }, + turn: 2, + choices: [], + waiting: [], + }; + v.nexusAtreides = { + event: v.battle!.event!, + mode: 'cunning', + fields: ['defense', 'leader', 'dial'], + blocked: null, + }; + assert.deepEqual(botActions(v), [ + { + type: 'nexusAtreides', + event: v.battle!.event, + mode: 'cunning', + field: 'defense', + }, + ]); + v.nexusAtreides.mode = 'secretAlly'; + v.nexusAtreides.fields = ['weapon', 'dial']; + assert.equal( + botActions(v)[0].field, + ['Easy', 'Medium'].includes(difficulty) ? 'dial' : 'weapon', + ); + for (const edit of [ + (x: GameView) => { + x.nexusAtreides!.blocked = 'Awaiting timing ruling'; + }, + (x: GameView) => { + x.nexusAtreides!.event = 'old-battle'; + }, + (x: GameView) => { + x.nexusCards!.card = 'ecaz'; + }, + (x: GameView) => { + x.automaticContinuationPending = true; + }, + (x: GameView) => { + x.phaseOpening = { passed: [] }; + }, + (x: GameView) => { + x.response = { kind: 'voice', owner: 'q', passed: [] }; + }, + ]) { + const blocked = structuredClone(v); + edit(blocked); + assert.ok(botActions(blocked).every((a) => a.type !== 'nexusAtreides')); + } + v.nexusCards.waiting = ['q']; + assert.deepEqual(botActions(v), []); + } +}); + +void test('private enemy fields are never consulted and retired Nexus observations are not current predictions', () => { + const v = fixture(); + own(v).bot = 'Hard'; + const base = botActions(v); + v.battle!.nexusInspection = { + event: v.battle!.event!, + mode: 'secretAlly', + owner: 'e', + target: 'q', + field: 'weapon', + stage: 'answer', + }; + v.battle!.nexusInsights = [ + { + field: 'weapon', + value: 'treachery-3', + label: 'Historical only', + active: false, + }, + ]; + for (const p of v.players.filter((p) => p.id !== v.me)) + for (const field of ['hand', 'spice', 'traitors']) + Object.defineProperty(p, field, { + get() { + throw Error(`Read private ${field}`); + }, + configurable: true, + }); + assert.deepEqual(botActions(v), base); +}); + +void test('CHOAM cash-in reserves both answered sources but releases a retired Nexus binding without losing its history', () => { + const state = createGame('NEXUSCASH', newPlayer('a', 'Atreides', 'atreides')); + state.players.push(newPlayer('c', 'CHOAM', 'choam')); + const cards = baseDeck() + .filter((c) => c.kind === 'worthless') + .slice(0, 3), + p = state.players[1]; + p.hand = cards; + const native = { player: 'a', field: 'weapon' as const, value: cards[0].id }; + const context = { + event: 'battle-cash', + attacker: 'a', + defender: 'c', + players: state.players, + native, + }; + const inspection = answerNexusInspection( + context, + allowNexusInspection( + context, + createNexusInspection(context, { + mode: 'cunning', + owner: 'a', + target: 'c', + field: 'defense', + }), + ), + cards[1].id, + ); + state.battle = { + event: context.event, + territory: 'arrakeen', + attacker: 'a', + defender: 'c', + plans: {}, + revealed: false, + traitorCalls: {}, + prescience: native, + nexusInspection: inspection, + }; + assert.deepEqual( + cashInCards(state, p).map((c) => c.id), + [cards[2].id], + ); + state.battle.nexusInspection = reopenNexusInspection(context, inspection); + assert.deepEqual( + cashInCards(state, p).map((c) => c.id), + [cards[1].id, cards[2].id], + ); + assert.deepEqual(state.battle.nexusInspection.answers, [cards[1].id]); +}); diff --git a/tests/nexus-inspection-controls.test.ts b/tests/nexus-inspection-controls.test.ts new file mode 100644 index 00000000..898afddf --- /dev/null +++ b/tests/nexus-inspection-controls.test.ts @@ -0,0 +1,219 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createGame, newPlayer, viewGame, type GameView, type PlanField } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { + BattlePreparation, NexusInspectionHistory, battlePlanCommitments, + bindBattlePlanCommitments, battlePreparationField, battleInspectionAnswerAction, +} from '../components/battle-preparation'; + +const aliases = registerHooks({ resolve(specifier, context, nextResolve) { + return nextResolve(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); +}, load(url, context, nextLoad) { + if (!url.endsWith('.module.css')) return nextLoad(url, context); + // Node has no CSS-module loader. Preserve real selector names for structural + // rendering; this does not replace any component or claim visual CSS QA. + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((match) => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; +} }); +const { NexusCards, nexusAtreidesAction } = await import('../components/nexus-cards'); +const { GameTable } = await import('../components/game-table'); +aliases.deregister(); + +/** Presentation fixture: full ordinary view plus explicit private projections. + * Engine/recovery suites separately prove how those projections are earned. */ +function fixture(me = 'a'): GameView { + const g = createGame('NEXUSINSPECTUI', newPlayer('a', 'Atreides', 'atreides')); + g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('e', 'Emperor', 'emperor')); + Object.assign(g, { status: 'playing', phase: 6, turn: 2, storm: 18, active: 'a', order: ['a', 'h', 'e'] }); + g.deck = baseDeck(); + for (const player of g.players) { + player.hand = []; + player.forces = player.id === 'e' ? {} : { 'arrakeen:10': 4 }; + player.reserves = player.id === 'e' ? 20 : 16; + player.spice = 10; + player.traitors = []; + } + g.battle = { event: 'battle-ui', territory: 'arrakeen', attacker: 'a', defender: 'h', + prepared: true, plans: {}, revealed: false, traitorCalls: {} }; + const view = viewGame(g, me); + view.nexusCards = { card: 'atreides', deckCount: 11, discardCount: 0, + held: { a: true, h: false, e: false }, turn: null, choices: [], waiting: [] }; + view.nexusAtreides = { event: 'battle-ui', mode: 'cunning', fields: ['leader', 'defense', 'dial'], blocked: null }; + view.battle!.ownCommitments = []; + view.battle!.nexusInsights = []; + return view; +} +function nexusHtml(view: GameView, busy = false) { + return renderToStaticMarkup(createElement(NexusCards, { game: view, act() {}, busy })); +} +function tableHtml(view: GameView) { + return renderToStaticMarkup(createElement(GameTable, { game: view, send: async () => {}, onExit() {}, busy: false })); +} +function inspection(view: GameView, field: PlanField = 'defense') { + view.battle!.nexusInspection = { event: 'battle-ui', mode: 'cunning', owner: 'a', target: 'h', field, stage: 'answer' }; + view.battle!.preparation = { kind: 'nexusPrescienceAnswer', owner: 'h', beneficiary: 'a' }; +} + +void test('Cunning offers only projected distinct fields and binds the exact battle event', () => { + const view = fixture(); + assert.deepEqual(nexusAtreidesAction(view, 'defense'), { type: 'nexusAtreides', event: 'battle-ui', mode: 'cunning', field: 'defense' }); + assert.equal(nexusAtreidesAction(view, 'weapon'), null); + const html = nexusHtml(view); + assert.match(html, /Use Cunning: inspect defense/); + assert.doesNotMatch(html, /Use Cunning: inspect weapon/); + assert.match(nexusHtml(view, true), /]*disabled=""[^>]*>Use Cunning: inspect defense/); + for (const change of [ + (v: GameView) => { v.nexusAtreides!.event = 'another-battle'; }, + (v: GameView) => { v.nexusAtreides!.blocked = 'Finish the current response.'; }, + (v: GameView) => { v.battle!.submitted.push('a'); }, + (v: GameView) => { v.battle!.revealed = true; }, + (v: GameView) => { v.phase = 5; }, + (v: GameView) => { v.status = 'finished'; }, + (v: GameView) => { v.me = 'h'; }, + (v: GameView) => { v.nexusCards!.card = 'fremen'; }, + ]) { + const stale = fixture(); change(stale); + assert.equal(nexusAtreidesAction(stale, 'defense'), null); + } +}); + +void test('Secret Ally follows projected dial access even with a public No-Field; Cunning cannot add an excluded dial', () => { + const view = fixture(); + view.players[0].faction = 'fremen'; + view.nexusAtreides!.mode = 'secretAlly'; + view.nexusAtreides!.fields = ['leader', 'weapon', 'defense', 'dial']; + view.battle!.noFieldPlayers = ['h']; + assert.deepEqual(nexusAtreidesAction(view, 'dial'), { type: 'nexusAtreides', event: 'battle-ui', mode: 'secretAlly', field: 'dial' }); + assert.match(nexusHtml(view), /Use Secret Ally: inspect dial/); + view.players[0].faction = 'atreides'; + view.nexusAtreides!.mode = 'cunning'; + view.nexusAtreides!.fields = ['leader', 'defense']; + assert.equal(nexusAtreidesAction(view, 'dial'), null); +}); + +void test('Betrayal uses a card-bound reaction without inventing a field or bypassing a pending server guard', () => { + const view = fixture('e'); + view.nexusAtreides!.mode = 'betrayal'; + view.nexusAtreides!.fields = []; + assert.deepEqual(nexusAtreidesAction(view), { type: 'nexusAtreides', event: 'battle-ui', mode: 'betrayal' }); + assert.equal(nexusAtreidesAction(view, 'dial'), null); + assert.match(nexusHtml(view), />Use BetrayalUse Betrayal { + const view = fixture('h'); + inspection(view); + view.battle!.prescience = { player: 'a', field: 'weapon', answered: true }; + assert.equal(battlePreparationField(view), 'defense'); + assert.deepEqual(battleInspectionAnswerAction(view, ''), { type: 'nexusPrescienceAnswer', event: 'battle-ui', value: null }); + const html = renderToStaticMarkup(createElement(BattlePreparation, { game: view, send: async () => {}, busy: false })); + assert.match(html, /Reveal your defense/); + assert.doesNotMatch(html, /Reveal your weapon/); + view.battle!.nexusInspection!.event = 'old-battle'; + assert.equal(battleInspectionAnswerAction(view, ''), null); + view.battle!.nexusInspection!.event = 'battle-ui'; + view.battle!.nexusInspection!.target = 'e'; + assert.equal(battleInspectionAnswerAction(view, ''), null); + view.battle!.nexusInspection!.target = 'h'; + view.battle!.nexusInspection!.field = 'dial'; + assert.deepEqual(battleInspectionAnswerAction(view, '2.5'), { type: 'nexusPrescienceAnswer', event: 'battle-ui', value: 2.5 }); + for (const value of ['', ' ', 'NaN', 'Infinity', '-1', '0.25']) assert.equal(battleInspectionAnswerAction(view, value), null); + view.battle!.nexusInspection!.stage = 'answered'; + assert.equal(battleInspectionAnswerAction(view, '2'), null); + view.battle!.preparation!.kind = 'prescienceAnswer'; + assert.equal(battlePreparationField(view), 'weapon'); + assert.deepEqual(battleInspectionAnswerAction(view, 'projectile-1'), { type: 'prescienceAnswer', value: 'projectile-1' }); + view.me = 'e'; + assert.equal(battleInspectionAnswerAction(view, 'projectile-1'), null); +}); + +void test('every simultaneous own commitment overrides plan submission, including zero and None, without affecting unrelated fields', () => { + const view = fixture('h'); + view.battle!.ownCommitments = [ + { source: 'native', beneficiary: 'a', target: 'h', field: 'dial', value: 0 }, + { source: 'nexus', beneficiary: 'a', target: 'h', field: 'defense', value: null }, + ]; + const plan = { type: 'battlePlan', dial: 4, defense: 'snooper', leader: 'harkonnen-0', weapon: 'projectile-1', support: 3 }; + const before = JSON.stringify(view); + assert.deepEqual(Object.keys(battlePlanCommitments(view)).sort(), ['defense', 'dial']); + assert.deepEqual(bindBattlePlanCommitments(view, plan), { ...plan, dial: 0, defense: null }); + assert.equal(plan.dial, 4); + assert.equal(JSON.stringify(view), before); + view.me = 'a'; + assert.deepEqual(bindBattlePlanCommitments(view, plan), plan); + view.battle!.ownCommitments = []; + assert.deepEqual(battlePlanCommitments(view), {}); +}); + +void test('the actual battle table locks each native-plus-Nexus field pair to the same values used by submission', () => { + for (const fields of [['dial', 'defense'], ['leader', 'weapon']] as const) { + const view = fixture('h'); + view.nexusAtreides = null; + view.nexusCards = null; + const values = { dial: 0, defense: null, leader: view.players.find((p) => p.id === 'h')!.leaders[0].id, weapon: null }; + view.battle!.ownCommitments = fields.map((field, index) => ({ source: index ? 'nexus' : 'native', beneficiary: 'a', target: 'h', field, value: values[field] })); + const html = tableHtml(view); + for (const field of fields) { + const id = field === 'dial' ? 'forces-dialed' : `battle-${field}`; + const control = html.match(new RegExp(`<[^>]+id="${id}"[^>]*>`))?.[0]; + assert.ok(control, `${field} control is visible`); + assert.match(control, /disabled=""|aria-disabled="true"/); + if (field === 'dial') assert.match(control, /value="0"/); + else { + const select = html.match(new RegExp(`]+id="${id}"[^>]*>[\\s\\S]*?`))?.[0]; + assert.ok(select); + assert.match(select, new RegExp(`]+value="${values[field] ?? ''}"[^>]+selected=""`)); + } + } + for (const field of ['leader', 'weapon', 'defense'] as const) if (!(fields as readonly string[]).includes(field)) { + const control = html.match(new RegExp(`]+id="battle-${field}"[^>]*>`))?.[0]; + assert.ok(control); + assert.doesNotMatch(control, /disabled=""/); + } + const action = bindBattlePlanCommitments(view, { type: 'battlePlan', dial: 4, leader: 'wrong', weapon: 'wrong', defense: 'wrong' }); + for (const field of fields) assert.equal(action[field], values[field]); + } +}); + +void test('private Nexus history distinguishes superseded answers and remains visible after the card was spent', () => { + const view = fixture(); + inspection(view); + view.battle!.nexusInspection!.stage = 'answered'; + view.nexusCards!.card = null; + view.nexusAtreides = null; + view.battle!.nexusInsights = [ + { field: 'defense', value: 'old-defense', label: 'Old defense', active: false }, + { field: 'defense', value: null, label: 'None', active: true }, + ]; + const render = () => renderToStaticMarkup(createElement(NexusInspectionHistory, { game: view })); + assert.match(render(), /Previous answer: defense = Old defense/); + assert.match(render(), /No longer binding/); + assert.match(render(), /Current answer: defense = None/); + view.me = 'h'; + assert.match(render(), /Your committed element: defense = None/); + view.me = 'e'; + assert.equal(render(), ''); +}); + +void test('Nexus controls and commitment binding never read opponent hands, plans or unprojected inspection history', () => { + const view = fixture(); + const expected = nexusHtml(view); + for (const player of view.players) for (const field of ['hand', 'spice', 'traitors']) + Object.defineProperty(player, field, { get() { throw new Error(`Private ${field}`); } }); + for (const field of ['plans', 'cards']) Object.defineProperty(view.battle!, field, { get() { throw new Error(`Hidden ${field}`); } }); + for (const field of ['deck', 'discard', 'hands']) Object.defineProperty(view, field, { get() { throw new Error(`Source ${field}`); } }); + assert.equal(nexusHtml(view), expected); + assert.ok(nexusAtreidesAction(view, 'defense')); + assert.deepEqual(battlePlanCommitments(view), {}); + assert.deepEqual(bindBattlePlanCommitments(view, { type: 'battlePlan', dial: 0 }), { type: 'battlePlan', dial: 0 }); +}); diff --git a/tests/nexus-inspection-engine.test.ts b/tests/nexus-inspection-engine.test.ts new file mode 100644 index 00000000..6de7aaf1 --- /dev/null +++ b/tests/nexus-inspection-engine.test.ts @@ -0,0 +1,448 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + viewGame, + normalizeAutomaticGame, + type Action, + type Game, +} from '../game/engine'; +import { + nexusInspectionFixture, + inspectionHold, + inspectionNative, + inspectionInventory, + inspectionResidual, +} from './fixture-nexus-inspection'; +import { + nexusAllow, + nexusInventory, + nexusPlayer, + nexusReload, + nexusReject, +} from './fixture-nexus-cards'; + +function play(g: Game, field = 'weapon', mode = 'cunning'): Game { + return applyAction(g, 'a', { + type: 'nexusAtreides', + event: g.battle!.event, + mode, + field, + }); +} +function answer(g: Game, value: string | number | null): Game { + return applyAction(g, 'h', { + type: 'nexusPrescienceAnswer', + event: g.battle!.event, + value, + }); +} +function plan(g: Game, id: string, extra: Partial = {}): Action { + return { + type: 'battlePlan', + dial: 0, + leader: nexusPlayer(g, id).leaders[0].id, + ...extra, + }; +} +function karama(g: Game, id: string) { + const index = g.deck.findIndex((card) => card.effect === 'karama'); + assert.ok(index >= 0); + const card = g.deck.splice(index, 1)[0]; + nexusPlayer(g, id).hand.push(card); + return card; +} + +void test('Cunning spends one physical Nexus card and keeps both plan elements binding across JSON recovery', () => { + let g = inspectionNative(nexusInspectionFixture()); + const weapon = inspectionHold(g, 'h', 'projectile'); + const defense = inspectionHold(g, 'h', 'shield'); + const first = { ...g.battle!.prescience! }; + g = nexusAllow(play(g)); + assert.equal(g.battle!.preparation?.kind, 'nexusPrescienceAnswer'); + g = answer(nexusReload(g), weapon.id); + assert.deepEqual(g.battle!.prescience, first); + assert.deepEqual(g.battle!.nexusInspection!.answers, [weapon.id]); + assert.equal(g.nexusCards!.cards!.hands.a, null); + assert.equal( + g.nexusCards!.cards!.discard.filter((card) => card === 'atreides').length, + 1, + ); + assert.equal(viewGame(g, 'h').battle!.ownCommitments.length, 2); + nexusReject( + g, + 'h', + plan(g, 'h', { dial: 1, weapon: weapon.id }), + /unchanged|commitment/, + ); + nexusReject(g, 'h', plan(g, 'h'), /unchanged|commitment/); + g = applyAction( + g, + 'h', + plan(g, 'h', { weapon: weapon.id, defense: defense.id }), + ); + assert.equal(g.battle!.revealed, false); + assert.deepEqual(viewGame(g, 'a').battle!.plans, {}); + const restored = normalizeAutomaticGame(nexusReload(g)); + assert.deepEqual(restored.battle, g.battle); + nexusInventory(restored); +}); + +void test('only the two combatants receive the selected Nexus answer, without exposing the rest of the target hand', () => { + let g = inspectionNative(nexusInspectionFixture()); + const weapon = inspectionHold(g, 'h', 'poison'); + const defense = inspectionHold(g, 'h', 'shield'); + g = answer(nexusAllow(play(g)), weapon.id); + for (const id of ['a', 'h']) + assert.equal(viewGame(g, id).battle!.nexusInsights[0].value, weapon.id); + const outsider = viewGame(g, 'f'); + assert.deepEqual(outsider.battle!.nexusInsights, []); + assert.deepEqual(outsider.battle!.ownCommitments, []); + assert.equal(JSON.stringify(outsider.battle).includes(weapon.id), false); + assert.equal( + JSON.stringify(viewGame(g, 'a').battle).includes(defense.id), + false, + ); + assert.equal( + Object.hasOwn(outsider.battle!.nexusInspection!, 'answers'), + false, + ); + nexusInventory(g); +}); + +void test('Cunning rejects duplicate fields, stale events, foreign ownership and replay without changing custody', () => { + let g = inspectionNative(nexusInspectionFixture()); + const event = g.battle!.event; + for (const [id, action] of [ + ['a', { type: 'nexusAtreides', event, mode: 'cunning', field: 'dial' }], + [ + 'a', + { + type: 'nexusAtreides', + event: 'old-event', + mode: 'cunning', + field: 'weapon', + }, + ], + ['h', { type: 'nexusAtreides', event, mode: 'cunning', field: 'weapon' }], + [ + 'a', + { type: 'nexusAtreides', event, mode: 'secretAlly', field: 'weapon' }, + ], + ['a', { type: 'nexusAtreides', event, mode: 'cunning', field: 'support' }], + ] as Array<[string, Action]>) + nexusReject(g, id, action, /Nexus|inspection/); + g = nexusAllow(play(g)); + nexusReject( + g, + 'a', + { type: 'nexusPrescienceAnswer', event, value: null }, + /answer|inspection/, + ); + nexusReject( + g, + 'h', + { type: 'nexusPrescienceAnswer', event: 'old-event', value: null }, + /answer|inspection/, + ); + g = answer(g, null); + nexusReject( + g, + 'a', + { type: 'nexusAtreides', event, mode: 'cunning', field: 'defense' }, + /Nexus|inspection/, + ); + nexusReject( + g, + 'h', + { type: 'nexusPrescienceAnswer', event, value: null }, + /answer|inspection/, + ); + nexusInventory(g); +}); + +void test('ordinary Karama cancels only the extra Cunning attempt and preserves the first native commitment', () => { + let g = inspectionNative(nexusInspectionFixture()); + const card = karama(g, 'f'); + g = play(g); + assert.equal(g.response?.kind, 'nexusPrescience'); + g = applyAction(g, 'f', { type: 'card', card: card.id, mode: 'cancel' }); + assert.equal(g.battle!.nexusInspection!.stage, 'canceled'); + assert.deepEqual(g.battle!.nexusInspection!.answers, []); + assert.equal(g.battle!.prescience!.value, 0); + assert.equal(g.battle!.preparation, undefined); + assert.equal(viewGame(g, 'h').battle!.ownCommitments.length, 1); + nexusReject(g, 'h', plan(g, 'h', { dial: 1 }), /unchanged|commitment/); + g = applyAction(nexusReload(g), 'h', plan(g, 'h')); + nexusInventory(g); +}); + +void test('Secret Ally uses a genuinely absent Atreides roster and reveals only the selected element of an already sealed plan', () => { + let g = nexusInspectionFixture(true); + assert.equal( + g.players.some((p) => p.faction === 'atreides'), + false, + ); + const weapon = inspectionHold(g, 'h', 'projectile'); + const defense = inspectionHold(g, 'h', 'shield'); + g = applyAction( + g, + 'h', + plan(g, 'h', { dial: 2, weapon: weapon.id, defense: defense.id }), + ); + assert.deepEqual(viewGame(g, 'a').battle!.plans, {}); + g = play(g, 'weapon', 'secretAlly'); + assert.equal(g.battle!.nexusInspection!.stage, 'answered'); + assert.deepEqual(g.battle!.nexusInspection!.answers, [weapon.id]); + assert.equal(g.battle!.prescience, undefined); + const own = viewGame(g, 'a').battle!; + assert.deepEqual(own.plans, {}); + assert.equal(own.nexusInsights[0].value, weapon.id); + assert.equal(JSON.stringify(own).includes(defense.id), false); + assert.deepEqual(viewGame(g, 'f').battle!.nexusInsights, []); + nexusInventory(normalizeAutomaticGame(nexusReload(g))); +}); + +void test('missing, rewound and rebound Nexus inspection records reject read, normalization and action', () => { + let g = inspectionNative(nexusInspectionFixture()); + g = answer(nexusAllow(play(g)), null); + for (const mutate of [ + (copy: Game) => { + delete copy.battle!.nexusInspection; + }, + (copy: Game) => { + delete copy.battle!.nexusInspectionUsed; + }, + (copy: Game) => { + copy.battle!.nexusInspection!.stage = 'answer'; + }, + (copy: Game) => { + copy.battle!.nexusInspection!.target = 'f'; + }, + (copy: Game) => { + copy.battle!.nexusInspection!.first!.value = 1; + }, + ]) { + const copy = nexusReload(g); + mutate(copy); + const before = nexusReload(copy); + assert.throws(() => viewGame(copy, 'a')); + assert.throws(() => normalizeAutomaticGame(copy)); + assert.throws(() => applyAction(copy, 'h', plan(copy, 'h'))); + assert.deepEqual(copy, before); + } +}); + +void test('Betrayal remains explicitly unavailable while its reaction policy is unresolved, preserving the held card and native attempt', () => { + let g = nexusInspectionFixture(false, 'f'); + karama(g, 'h'); + g = applyAction(g, 'a', { type: 'prescience', field: 'weapon' }); + assert.equal(g.response?.kind, 'prescience'); + assert.equal(Object.hasOwn(g.battle!.prescience!, 'value'), false); + const offer = viewGame(g, 'f').nexusAtreides!; + assert.equal(offer.mode, 'betrayal'); + assert.match(offer.blocked!, /reaction|policy|unfinished|not.*available/i); + nexusReject( + g, + 'f', + { type: 'nexusAtreides', event: g.battle!.event, mode: 'betrayal' }, + /reaction|policy|unfinished|not.*available/i, + ); + assert.equal(g.nexusCards!.cards!.hands.f, 'atreides'); + assert.equal(g.battle!.nexusInspection, undefined); + assert.equal(g.response?.kind, 'prescience'); + nexusInventory(normalizeAutomaticGame(nexusReload(g))); +}); + +void test('Secret Ally weapon answer reopens after Residual kills the last usable leader and preserves inactive answer history', () => { + let g = nexusInspectionFixture(true, 'a', { richese: true }); + const residual = inspectionResidual(g); + const weapon = inspectionHold(g, 'h', 'projectile'); + const target = nexusPlayer(g, 'h'); + target.leaders.forEach((leader, i) => { + leader.dead = i !== 0; + leader.deaths = i === 0 ? 0 : 1; + }); + g = answer(play(g, 'weapon', 'secretAlly'), weapon.id); + g = applyAction(g, 'a', { + type: 'card', + card: residual, + target: 'h', + event: g.battle!.event, + }); + assert.equal( + nexusPlayer(g, 'h').leaders.every((l) => l.dead), + true, + ); + assert.equal(g.battle!.nexusInspection!.stage, 'answer'); + assert.deepEqual(g.battle!.nexusInspection!.answers, [weapon.id]); + assert.equal(viewGame(g, 'a').battle!.nexusInsights[0].active, false); + assert.deepEqual(viewGame(g, 'h').battle!.ownCommitments, []); + g = answer(nexusReload(g), null); + assert.deepEqual(g.battle!.nexusInspection!.answers, [weapon.id, null]); + assert.deepEqual( + viewGame(g, 'a').battle!.nexusInsights.map((i) => i.active), + [false, true], + ); + assert.deepEqual(viewGame(g, 'f').battle!.nexusInsights, []); + nexusReject( + g, + 'h', + plan(g, 'h', { leader: null, weapon: weapon.id }), + /leader|unchanged|commitment/i, + ); + g = applyAction(g, 'h', plan(g, 'h', { leader: null })); + inspectionInventory(normalizeAutomaticGame(nexusReload(g))); +}); + +void test('Residual retains a Secret Ally weapon answer when a surviving leader, Cheap Hero or Ghola still permits that plan', () => { + for (const alternative of ['leader', 'hero', 'ghola'] as const) { + let g = nexusInspectionFixture(true, 'a', { richese: true }); + const residual = inspectionResidual(g); + const weapon = inspectionHold(g, 'h', 'projectile'); + nexusPlayer(g, 'h').leaders.forEach((leader, i) => { + leader.dead = i >= (alternative === 'leader' ? 2 : 1); + leader.deaths = leader.dead ? 1 : 0; + }); + if (alternative === 'hero') inspectionHold(g, 'h', 'hero'); + if (alternative === 'ghola') { + const index = g.deck.findIndex((card) => card.effect === 'ghola'); + assert.ok(index >= 0); + nexusPlayer(g, 'h').hand.push(g.deck.splice(index, 1)[0]); + } + g = answer(play(g, 'weapon', 'secretAlly'), weapon.id); + g = applyAction(g, 'a', { + type: 'card', + card: residual, + target: 'h', + event: g.battle!.event, + }); + assert.equal(g.battle!.nexusInspection!.stage, 'answered', alternative); + assert.deepEqual(g.battle!.nexusInspection!.answers, [weapon.id]); + assert.equal(viewGame(g, 'h').battle!.ownCommitments[0].value, weapon.id); + assert.equal(viewGame(g, 'a').battle!.nexusInsights[0].active, true); + inspectionInventory(g); + } +}); + +void test('an answered Secret Ally leader inspection blocks Residual before card loss or random casualty', () => { + let g = nexusInspectionFixture(true, 'a', { richese: true }); + const residual = inspectionResidual(g); + const leader = nexusPlayer(g, 'h').leaders[0].id; + g = answer(play(g, 'leader', 'secretAlly'), leader); + assert.match( + viewGame(g, 'a').residualPoison!.blocked!, + /leader|inspection|Prescience/i, + ); + nexusReject( + g, + 'a', + { type: 'card', card: residual, target: 'h', event: g.battle!.event }, + /leader|inspection|Prescience/i, + ); + inspectionInventory(g); +}); + +void test('the same genuine Richese No-Field marker blocks native Cunning dial inspection but allows Secret Ally dial', () => { + let native = nexusInspectionFixture(false, 'a', { noField: true }); + assert.ok(native.battle!.noFieldPlayers?.includes('h')); + native = inspectionNative(native, 'weapon', null); + assert.equal( + viewGame(native, 'a').nexusAtreides!.fields.includes('dial'), + false, + ); + nexusReject( + native, + 'a', + { + type: 'nexusAtreides', + event: native.battle!.event, + mode: 'cunning', + field: 'dial', + }, + /field|No-Field/i, + ); + let secret = nexusInspectionFixture(true, 'a', { noField: true }); + assert.ok(secret.battle!.noFieldPlayers?.includes('h')); + assert.equal( + viewGame(secret, 'a').nexusAtreides!.fields.includes('dial'), + true, + ); + secret = answer(play(secret, 'dial', 'secretAlly'), 1); + assert.deepEqual(secret.battle!.nexusInspection!.answers, [1]); + inspectionInventory(secret); +}); + +void test('canceled Cunning preserves its original disclosure while Residual legitimately reopens and replaces the native answer', () => { + let g = nexusInspectionFixture(false, 'a', { richese: true }); + const residual = inspectionResidual(g); + const weapon = inspectionHold(g, 'h', 'projectile'); + const cancel = karama(g, 'f'); + nexusPlayer(g, 'h').leaders.forEach((leader, i) => { + leader.dead = i !== 0; + leader.deaths = leader.dead ? 1 : 0; + }); + g = inspectionNative(g, 'weapon', weapon.id); + g = play(g, 'defense'); + g = applyAction(g, 'f', { type: 'card', card: cancel.id, mode: 'cancel' }); + assert.equal(g.battle!.nexusInspection!.stage, 'canceled'); + g = applyAction(g, 'a', { + type: 'card', + card: residual, + target: 'h', + event: g.battle!.event, + }); + assert.equal(g.battle!.preparation?.kind, 'prescienceAnswer'); + assert.equal(Object.hasOwn(g.battle!.prescience!, 'value'), false); + assert.equal( + Object.hasOwn(g.battle!.nexusInspection!, 'firstCurrent'), + false, + ); + assert.equal(g.battle!.nexusInspection!.first!.value, weapon.id); + g = applyAction(nexusReload(g), 'h', { + type: 'prescienceAnswer', + value: null, + }); + assert.equal(g.battle!.nexusInspection!.stage, 'canceled'); + assert.deepEqual(g.battle!.nexusInspection!.firstCurrent, { value: null }); + assert.equal(g.battle!.nexusInspection!.first!.value, weapon.id); + assert.equal(g.battle!.prescience!.value, null); + inspectionInventory(normalizeAutomaticGame(nexusReload(g))); +}); + +void test('a pending Cunning second answer waits behind a legitimate native Residual reanswer without losing either receipt', () => { + let g = nexusInspectionFixture(false, 'a', { richese: true }); + const residual = inspectionResidual(g); + const weapon = inspectionHold(g, 'h', 'projectile'); + nexusPlayer(g, 'h').leaders.forEach((leader, i) => { + leader.dead = i !== 0; + leader.deaths = leader.dead ? 1 : 0; + }); + g = inspectionNative(g, 'weapon', weapon.id); + g = nexusAllow(play(g, 'defense')); + assert.equal(g.battle!.nexusInspection!.stage, 'answer'); + g = applyAction(g, 'a', { + type: 'card', + card: residual, + target: 'h', + event: g.battle!.event, + }); + assert.equal(g.battle!.preparation?.kind, 'prescienceAnswer'); + assert.equal(g.battle!.nexusInspection!.stage, 'answer'); + assert.deepEqual(g.battle!.nexusInspection!.answers, []); + assert.equal( + Object.hasOwn(g.battle!.nexusInspection!, 'firstCurrent'), + false, + ); + g = applyAction(nexusReload(g), 'h', { + type: 'prescienceAnswer', + value: null, + }); + assert.equal(g.battle!.preparation?.kind, 'nexusPrescienceAnswer'); + assert.deepEqual(g.battle!.nexusInspection!.firstCurrent, { value: null }); + g = answer(nexusReload(g), null); + assert.deepEqual(g.battle!.nexusInspection!.answers, [null]); + assert.equal(g.battle!.nexusInspection!.first!.value, weapon.id); + assert.equal(viewGame(g, 'h').battle!.ownCommitments.length, 2); + inspectionInventory(normalizeAutomaticGame(nexusReload(g))); +}); diff --git a/tests/nexus-inspection-integrity.test.ts b/tests/nexus-inspection-integrity.test.ts new file mode 100644 index 00000000..1a618aaa --- /dev/null +++ b/tests/nexus-inspection-integrity.test.ts @@ -0,0 +1,153 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + normalizeAutomaticGame, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { + inspectionNative, + nexusInspectionFixture, +} from './fixture-nexus-inspection'; +import { + nexusAllow, + nexusInventory, + nexusPlayer, + nexusReload, +} from './fixture-nexus-cards'; + +function play(g: Game, mode = 'cunning', field = 'weapon'): Game { + return applyAction(g, 'a', { + type: 'nexusAtreides', + event: g.battle!.event, + mode, + field, + }); +} + +function answerAction(g: Game): Action { + return { + type: 'nexusPrescienceAnswer', + event: g.battle!.event, + value: null, + }; +} + +function plan(g: Game, id: string, dial = 0): Action { + return { + type: 'battlePlan', + dial, + leader: nexusPlayer(g, id).leaders[0].id, + }; +} + +function rejectedEverywhere(g: Game, actor: string, action: Action): void { + const before = JSON.stringify(g); + for (const seat of g.players) { + assert.throws(() => viewGame(g, seat.id), /inspection|inspected|Nexus|Prescience|Cunning/i); + assert.equal(JSON.stringify(g), before); + } + assert.throws( + () => normalizeAutomaticGame(g), + /inspection|inspected|Nexus|Prescience|Cunning/i, + ); + assert.equal(JSON.stringify(g), before); + assert.throws( + () => applyAction(g, actor, action), + /inspection|inspected|Nexus|Prescience|Cunning/i, + ); + assert.equal(JSON.stringify(g), before); +} + +function pendingCunning(): Game { + const g = inspectionNative(nexusInspectionFixture()); + const index = g.deck.findIndex((card) => card.effect === 'karama'); + assert.ok(index >= 0); + nexusPlayer(g, 'f').hand.push(g.deck.splice(index, 1)[0]); + const pending = play(g); + assert.equal(pending.response?.kind, 'nexusPrescience'); + assert.equal(pending.battle!.nexusInspection!.stage, 'response'); + nexusInventory(pending); + return pending; +} + +void test('deleting the actual Cunning cancellation response cannot silently allow its answer', () => { + const pending = pendingCunning(); + const corrupted = nexusReload(pending); + corrupted.response = null; + rejectedEverywhere(corrupted, 'h', answerAction(corrupted)); + + // Explicitly passing the real response remains a valid recoverable path. + let restored = nexusAllow(normalizeAutomaticGame(nexusReload(pending))); + restored = applyAction(restored, 'h', answerAction(restored)); + assert.equal(restored.battle!.nexusInspection!.stage, 'answered'); + assert.deepEqual(restored.battle!.nexusInspection!.answers, [null]); + assert.equal(restored.battle!.prescience!.value, 0); + assert.equal( + restored.nexusCards!.cards!.discard.filter((card) => card === 'atreides') + .length, + 1, + ); + nexusInventory(restored); +}); + +void test('Cunning cancellation response owner and intent remain bound on every seat read and continuation', () => { + const pending = pendingCunning(); + for (const mutate of [ + (g: Game) => { + g.response!.owner = 'f'; + }, + (g: Game) => { + g.response!.intent = 'another-battle'; + }, + ]) { + const corrupted = nexusReload(pending); + mutate(corrupted); + rejectedEverywhere(corrupted, 'h', answerAction(corrupted)); + } +}); + +void test('a completed Cunning inspection cannot change its first native commitment by editing the live value', () => { + let g = nexusAllow(play(inspectionNative(nexusInspectionFixture()))); + g = applyAction(g, 'h', answerAction(g)); + assert.equal(g.battle!.nexusInspection!.first!.value, 0); + assert.equal(g.battle!.prescience!.value, 0); + const corrupted = nexusReload(g); + corrupted.battle!.prescience!.value = 1; + rejectedEverywhere(corrupted, 'h', plan(corrupted, 'h', 1)); + + const restored = applyAction( + normalizeAutomaticGame(nexusReload(g)), + 'h', + plan(g, 'h'), + ); + assert.equal(restored.battle!.plans.h.dial, 0); + assert.equal(viewGame(restored, 'h').battle!.ownCommitments.length, 2); + nexusInventory(restored); +}); + +void test('a sealed target plan cannot diverge from the real Secret Ally answer before the opponent seals', () => { + let g = nexusInspectionFixture(true); + g = applyAction(g, 'h', plan(g, 'h')); + g = play(g, 'secretAlly', 'dial'); + assert.deepEqual(g.battle!.nexusInspection!.answers, [0]); + assert.equal(g.battle!.revealed, false); + assert.deepEqual(viewGame(g, 'a').battle!.plans, {}); + assert.deepEqual(viewGame(g, 'f').battle!.nexusInsights, []); + const corrupted = nexusReload(g); + corrupted.battle!.plans.h.dial = 1; + rejectedEverywhere(corrupted, 'a', plan(corrupted, 'a')); + + const restored = applyAction( + normalizeAutomaticGame(nexusReload(g)), + 'a', + plan(g, 'a'), + ); + assert.equal(restored.battle!.revealed, true); + assert.equal(restored.battle!.plans.h.dial, 0); + for (const id of ['a', 'h']) + assert.equal(viewGame(restored, id).battle!.nexusInsights[0].value, 0); + nexusInventory(restored); +}); diff --git a/tests/nexus-inspection-recovery.test.ts b/tests/nexus-inspection-recovery.test.ts new file mode 100644 index 00000000..e5733bbc --- /dev/null +++ b/tests/nexus-inspection-recovery.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { DatabaseSync } from 'node:sqlite'; +import { unitStore } from './fixture-nexus-room-store'; +import { nexusInspectionFixture, inspectionNative, inspectionHold } from './fixture-nexus-inspection'; +import { nexusInventory, nexusPlayer } from './fixture-nexus-cards'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import type { RoomsClock } from '../db/rooms'; +const clock: RoomsClock = { now: () => 10000, sleep: async () => {} }; +async function fixture(initial = inspectionNative(nexusInspectionFixture())) { + const store = unitStore(); + const made = await store.rooms.createRoom('Inspection SQL', 'fremen', false, []); + const code = made.view.code, tokens = [made.token]; + for (const faction of ['atreides', 'harkonnen'] as const) + tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!); + const ids = ['f', 'a', 'h']; + // The game was initialized with its final identities. Bind disposable test + // sessions to those seats; never rewrite identifiers inside signed histories. + for (const [i, token] of tokens.entries()) { + const auth = await store.rooms.authenticate(code, token); + store.sqlite.prepare('UPDATE seats SET player_id = ? WHERE room_code = ? AND player_id = ?') + .run(ids[i], code, auth.playerId); + } + const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token))); + initial.code = code; initial.version = (await store.rooms.readRoom(code)).version; + store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(initial), initial.version, code); + store.writes.length = 0; + return { ...store, code, tokens, auths, ids, initial }; +} +type Fixture = Awaited> & { sqlite: DatabaseSync }; +const row = (f: Fixture) => f.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(f.code); +const play = (g: Game, field: string): Action => ({ type: 'nexusAtreides', event: g.battle!.event, mode: 'cunning', field }); +async function restored(f: Fixture, g: Game) { + const before = row(f), rooms = f.restart(); + for (const [i, token] of f.tokens.entries()) { + const auth = await rooms.authenticate(f.code, token); + const view = await rooms.readSeatView(f.code, auth); + assert.deepEqual(view, viewGame(g, f.ids[i])); + if (i === 0) assert.deepEqual(view.battle!.nexusInsights, []); + } + await rooms.continueRoomAutomatic(f.code, clock); + assert.deepEqual(row(f), before); + nexusInventory(g); +} +async function race(f: Fixture, inputs: { seat: number; action: Action }[]) { + let arrivals = 0; let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + f.hooks.beforeWrite = async () => { if (++arrivals === inputs.length) release(); await gate; }; + const results = await Promise.allSettled(inputs.map(({seat, action}) => + f.restart().act(f.code, f.auths[seat], f.initial.version, action, clock))); + delete f.hooks.beforeWrite; + assert.equal(results.filter(r => r.status === 'fulfilled').length, 1); + assert.deepEqual(f.writes.map(w => w.changes).sort((a,b) => a-b), [0,1]); + return f.rooms.readRoom(f.code); +} +void test('Nexus inspection SQL competing second-field declarations consume exactly one card and survive module reload', async () => { + const f = await fixture(); + try { + const g = await race(f, [{seat:1,action:play(f.initial,'weapon')},{seat:1,action:play(f.initial,'defense')}]); + assert.equal(g.version, f.initial.version + 1); + assert.equal(g.battle!.nexusInspection!.stage, 'answer'); + assert.deepEqual(g.battle!.prescience, f.initial.battle!.prescience); + assert.equal(g.nexusCards!.cards!.discard.filter(c => c === 'atreides').length, 1); + const before = row(f); + await assert.rejects(f.rooms.act(f.code, f.auths[1], g.version, play(g,'leader'),clock)); + assert.deepEqual(row(f),before); + await restored(f,g); + } finally { f.sqlite.close(); } +}); +void test('Nexus inspection SQL lost answer response restores both commitments and rejects stale or duplicate disclosures', async () => { + let initial = inspectionNative(nexusInspectionFixture()); + const weapon = inspectionHold(initial,'h','projectile'); + initial = applyAction(initial,'a',play(initial,'weapon')); + const f = await fixture(initial); + try { + const action: Action = {type:'nexusPrescienceAnswer',event:initial.battle!.event,value:weapon.id}; + await f.rooms.act(f.code,f.auths[2],initial.version,action,clock); + const g = await f.restart().readRoom(f.code), before = row(f); + assert.deepEqual(g.battle!.nexusInspection!.answers,[weapon.id]); + assert.equal(viewGame(g,'h').battle!.ownCommitments.length,2); + await assert.rejects(f.rooms.act(f.code,f.auths[2],initial.version,action,clock)); + await assert.rejects(f.rooms.act(f.code,f.auths[2],g.version,action,clock)); + assert.deepEqual(row(f),before); + await restored(f,g); + const damaged = structuredClone(g); damaged.battle!.nexusInspection!.answers[0] = null; + f.sqlite.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(JSON.stringify(damaged), f.code); + f.writes.length = 0; + await assert.rejects(f.restart().readSeatView(f.code,f.auths[2])); + await assert.rejects(f.restart().act(f.code,f.auths[2],g.version,action,clock)); + // No automatic continuation is scheduled at this owned decision. The + // scheduler leaves it untouched; authenticated reads/actions reject it. + await f.restart().continueRoomAutomatic(f.code,clock); + assert.equal(f.writes.length,0); + } finally { f.sqlite.close(); } +}); +void test('Nexus inspection SQL concurrent Karamas cancel one extra attempt and charge only its accepted responder', async () => { + let initial = inspectionNative(nexusInspectionFixture()); + const cards = ['f','h'].map(id => { + const index = initial.deck.findIndex(c => c.effect === 'karama'); assert.ok(index >= 0); + const card = initial.deck.splice(index,1)[0]; nexusPlayer(initial,id).hand.push(card); return card; + }); + initial = applyAction(initial,'a',play(initial,'weapon')); + const f = await fixture(initial); + try { + const g = await race(f,cards.map((card,i) => ({seat:i===0?0:2,action:{type:'card',card:card.id,mode:'cancel'}}))); + assert.equal(g.battle!.nexusInspection!.stage,'canceled'); + assert.equal(g.battle!.prescience!.value,0); + assert.equal(cards.filter(card => g.players.some(p => p.hand.some(c => c.id === card.id))).length,1); + assert.deepEqual(g.battle!.nexusInspection!.answers,[]); + await restored(f,g); + } finally { f.sqlite.close(); } +}); diff --git a/tests/nexus-residual-ambiguity.test.ts b/tests/nexus-residual-ambiguity.test.ts new file mode 100644 index 00000000..93f2542b --- /dev/null +++ b/tests/nexus-residual-ambiguity.test.ts @@ -0,0 +1,114 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + normalizeAutomaticGame, + viewGame, + type Game, +} from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import { + nexusInspectionFixture, + inspectionNative, + inspectionInventory, +} from './fixture-nexus-inspection'; +import { nexusAllow, nexusPlayer, nexusReload } from './fixture-nexus-cards'; + +const residual = 'richese-residual-poison'; +function inventory(g: Game): string[] { + return [ + ...g.deck, + ...g.discard, + ...(g.richeseCache ?? []), + ...g.players.flatMap((p) => p.hand), + ] + .map((c) => c.id) + .sort(); +} +function answeredCunning(): Game { + let g = inspectionNative( + nexusInspectionFixture(false, 'a', { richese: true }), + 'dial', + 0, + ); + // Conserved acquisition seam: move the canonical Residual from the cache + // initialized by the genuine Richese setup. No purchase is claimed here. + assert.ok(g.richeseCache); + const index = g.richeseCache.findIndex((c) => c.id === residual); + nexusPlayer(g, 'a').hand.push(g.richeseCache.splice(index, 1)[0]); + assert.equal(viewGame(g, 'a').residualPoison!.blocked, null); + g = nexusAllow( + applyAction(g, 'a', { + type: 'nexusAtreides', + event: g.battle!.event, + mode: 'cunning', + field: 'weapon', + }), + ); + g = applyAction(g, 'h', { + type: 'nexusPrescienceAnswer', + event: g.battle!.event, + value: null, + }); + assert.equal(g.battle!.nexusInspection!.stage, 'answered'); + assert.equal(g.battle!.nexusInspection!.mode, 'cunning'); + inspectionInventory(g); + return g; +} + +void test('answered Cunning publicly blocks Residual independently of hidden funding or Ghola, before RNG or mutation', (t) => { + const original = answeredCunning(); + assert.deepEqual( + inventory(original), + [...baseDeck(), ...richeseCards()].map((c) => c.id).sort(), + ); + const reasons: string[] = []; + for (const hasGhola of [false, true]) { + const g = nexusReload(original), + target = nexusPlayer(g, 'h'); + const index = g.deck.findIndex((c) => + hasGhola ? c.effect === 'ghola' : c.kind === 'projectile', + ); + assert.ok(index >= 0); + target.hand.push(g.deck.splice(index, 1)[0]); + target.spice = hasGhola ? 0 : 20; + const before = nexusReload(g), + beforeCards = inventory(g); + const reason = viewGame(g, 'a').residualPoison!.blocked; + assert.ok(reason); + assert.match(reason, /two inspected elements.*ruling/i); + reasons.push(reason); + for (const p of g.players) assert.doesNotThrow(() => viewGame(g, p.id)); + assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g); + let randomCalls = 0; + const random = t.mock.method(crypto, 'getRandomValues', () => { + randomCalls++; + throw Error('Rejected action must not draw a victim'); + }); + try { + assert.throws( + () => + applyAction(g, 'a', { + type: 'card', + card: residual, + target: 'h', + event: g.battle!.event, + }), + /two inspected elements.*ruling/i, + ); + } finally { + random.mock.restore(); + } + assert.equal(randomCalls, 0); + assert.deepEqual(g, before); + assert.deepEqual(inventory(g), beforeCards); + assert.equal( + nexusPlayer(g, 'a').hand.filter((c) => c.id === residual).length, + 1, + ); + assert.equal(g.battle!.prescience!.value, 0); + assert.deepEqual(g.battle!.nexusInspection!.answers, [null]); + } + assert.equal(reasons[0], reasons[1]); +});