Prototype CHOAM Nexus after-victory inspection

This commit is contained in:
Quintine committed 2026-09-28 10:34:09 +10:00
1 parent 209fb18a27
commit cb98ead411
15 files changed
+614 -37

No files matched your search

+4 -1
View File
@@ -28,7 +28,7 @@ import { GreatMakerDecision } from './great-maker';
import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries';
import { DiscoveryEntryDecision } from './discovery-entry';
import { DiscoveryStormDecision } from './discovery-storm';
import { NexusChoamTrade } from './nexus-choam-trade';
import { NexusChoamTrade, NexusChoamInspectionDecision, NexusChoamInspectionInsight } from './nexus-choam-trade';
import { NexusChoamBetrayal } from './nexus-choam-betrayal';
import { NexusMoritaniBetrayal } from './nexus-moritani-betrayal';
import { buildRevision, buildRevisionLabel } from '@/lib/build-revision';
@@ -1040,6 +1040,7 @@ export function GameTable({
<AmbassadorSupply game={g} />
<AmbassadorInsights key={`${g.code}-${g.me}`} game={g} />
<AuditorInsight game={g} />
<NexusChoamInspectionInsight game={g} />
<div className="play-grid">
<aside className="players-panel">
<div className="eyebrow">THE GREAT HOUSES</div>
@@ -2667,6 +2668,8 @@ export function GameTable({
decline: true,
})}
</>
) : g.decision.kind === 'nexusChoamInspection' ? (
<NexusChoamInspectionDecision game={g} act={act} busy={busy} />
) : g.decision.kind === 'choamAudit' ||
g.decision.kind === 'choamAuditPayment' ? (
<AuditorDecision game={g} act={act} busy={busy} />
+51 -2
View File
@@ -3,7 +3,7 @@
import { useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { nexusChoamTradeAction } from '@/game/nexus-choam-trade-options';
import { CardInspector } from './card-inspector';
import { CardInspector, CardRules } from './card-inspector';
import { Button } from './ui/button';
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
@@ -30,7 +30,56 @@ export function NexusChoamTrade({ game, act, busy }: Props) {
onClick={() => { if (!busy && action) act(action); }}>
Trade {card?.name ?? 'a Worthless card'} for 2 spice
</Button>
<p className="fine">Optional: keep both cards by continuing the phase. This Nexus card’s battle inspection alternative is still in development.</p>
<p className="fine">Optional: keep both cards by continuing the phase. The same card can instead be spent after a battle victory to inspect one unused opposing card.</p>
</section>
);
}
export function NexusChoamInspectionDecision({ game, act, busy }: Props) {
const offer = game.nexusChoamInspection;
if (!offer) return null;
const opponent = game.players.find(player => player.id === offer.opponent);
return (
<section aria-label="Postbattle CHOAM Nexus choice" className="min-w-0 space-y-3">
<h3 className="font-serif text-xl">After victory · CHOAM Secret Ally</h3>
<p className="text-base leading-7">
Every winner receives this private choice. If you hold the CHOAM Nexus
card while unallied, you may spend it to inspect one random unused card
held by {opponent?.name ?? 'your opponent'}. Otherwise continue.
</p>
<div className="flex flex-wrap gap-2">
{offer.canInspect && (
<Button disabled={busy} onClick={() =>
act({ type: 'decision', event: offer.event, inspect: true })}>
Spend CHOAM Nexus · inspect one card
</Button>
)}
<Button variant="outline" disabled={busy} onClick={() =>
act({ type: 'decision', event: offer.event, inspect: false })}>
Continue without inspection
</Button>
</div>
</section>
);
}
export function NexusChoamInspectionInsight({ game }: { game: GameView }) {
const insight = game.nexusChoamInsight;
if (!insight) return null;
const opponent = game.players.find(player => player.id === insight.target);
return (
<section aria-label="Your private CHOAM Nexus inspection" className="notice min-w-0 space-y-3">
<h3 className="font-serif text-xl">Your CHOAM Nexus inspection · {opponent?.name}</h3>
<p className="text-base leading-7">
Only you see this card. It was in the opposing hand when the battle
ended; it may move afterward. This snapshot remains until the next
battle or turn.
</p>
<article className="min-w-0 space-y-3 rounded-lg border border-[#485542] p-4">
<h4 className="font-serif text-xl">{insight.card.name}</h4>
<CardRules card={insight.card} />
<CardInspector card={insight.card} />
</article>
</section>
);
}
+7 -1
View File
@@ -26,6 +26,12 @@ checkpoint is in [Discovery prototype](DISCOVERY_PROTOTYPE.md); this code is
not yet in the live `c9a3625` image. Contested/shared transfers and full
Discovery release acceptance remain gated.
Checkpoint `209fb18` was pushed and its matching container workflow
`36359970371` completed successfully. The dedicated deployment agent could not
enter TrueNAS Apps because the host desktop was OS-locked; it made no NAS change,
backup or post-rollout claim. Production remains at the last verified `c9a3625`
image until the Apps session is unlocked and this exact image is deployed.
The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path)
prototype connects an initially empty worm's public offer, one spent private
card, a Karama response, a typed remote ride after the Nexus, human controls
@@ -252,7 +258,7 @@ These links define bounded working behavior, not complete module certification.
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement and [Advanced Loyalty](ECAZ_LOYALTY.md), separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md) and [bounded retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation). Remaining bands and combinations are explicit in those contracts. |
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Orgiz transfers, Jacurutu income, Testing Station and Shrine. Orgiz's per-deposit interpretation, contested/shared benefits and mixed physical allocation remain provisional or pending. The Orgiz path in current source is not yet deployed. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Secret Ally trade and bounded after-victory inspection](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; the CHOAM universal winner window is a privacy-preserving product inference, not publisher text, and Emperor purchase remains pending. A source audit or helper alone is not a completed effect. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) connects the bounded battle return in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. |
| Multiplayer | [Public and private discussion](TABLE_DISCUSSION.md), saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), [named-player AI permission](SEAT_AI_PERMISSION.md) and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. |
+6
View File
@@ -1,5 +1,11 @@
# Dune implementation status
## 27 September 2026 — Bounded CHOAM Nexus after-victory inspection
The [Secret Ally prototype](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) now preserves the losing combatant's physically used-card IDs and offers every Nexus winner the same post-cleanup decision when CHOAM is absent, without revealing who holds its card. An eligible unallied holder may spend the physical CHOAM Nexus card to sample exactly one unused opposing hand card; only that seat receives the card snapshot, while the public log omits its face. Basic/Advanced real battles, all four AI profiles, signed receipt recovery, paid Nullentropy interruption, subsequent battle/turn expiry, rejected-action immutability and concurrent in-memory SQLite commits passed focused cases. A local browser with a read-only intercepted room view displayed the live table decision and private inspector; no saved room was changed. This is a product privacy interpretation, not a publisher ruling or complete Nexus certification.
The complete offline suite passed 5,558 tests across 697 files, and the app build and scoped lint passed. `npm run check` still stops at the unrelated in-progress Nexus/Fremen `Player.fremenNexusMovementBlockedTurn` type error; the CHOAM type error found on the first pass was corrected, and a second full TypeScript run reported only that unrelated diagnostic. The live image has not yet received this checkpoint.
## 27 September 2026 — Bounded Orgiz Processing Station Collection
The [Discovery prototype](DISCOVERY_PROTOTYPE.md#connected-behavior) now composes a sole Orgiz occupant's one-spice transfer over ordinary Collection: one uniquely collected board deposit is one observable blow, even when older blows stacked there. Separate deposits pay separately; contested Orgiz occupancy and unresolved Ecaz shared lots withhold their uncertain transfers without blocking ordinary collection. Advisor releases follow the ordinary Collection quote. This is a documented inference from the *Ecaz & Moritani* rulebook, not a publisher ruling. Basic/Advanced engine, JSON restoration, simultaneous in-memory SQLite CAS, conserved spice, rejected-action custody and player-visible log are covered by 20 focused Discovery cases. An actual staged engine continuation reached Collection and credited the Orgiz owner; an isolated local browser table rendered its token guidance and chronicle log without writing a room. The broader Discovery, combined-expansion and publication gates remain closed; this source is not yet the live `c9a3625` image.
+33 -29
View File
@@ -1,7 +1,7 @@
# CHOAM Nexus Secret Ally prototype
13 September 2026. The Spice Collection trade is **Prototyped** in the development
Nexus module. Its battle inspection alternative is still missing. This checkpoint
27 September 2026. The Spice Collection trade and a bounded after-victory
inspection are **Prototyped** in the development Nexus module. This checkpoint
does not enable normal Nexus starts, certify the full card family, or open a
publication gate.
@@ -51,32 +51,34 @@ the opponent, including a Cheap Hero, cards played in weapon or defense slots
and a late Portable Snooper. A used card remains excluded if another effect
later retains it. The effect neither transfers nor discards the inspected card.
The battle engine must preserve the opposing used-card IDs with the resolved
battle event before mandatory loser disposal. The existing Auditor candidate
filter is reusable because it removes exact physical used IDs from the current
opposing hand. The Nexus effect still needs its own one-card sampler and receipt;
it must not borrow Auditor survival, payment or cancellation behavior. A
successful use discards the held CHOAM Nexus card once, records the sampled
snapshot once and projects the face only to its holder. The public chronicle may
name the use, winner and opponent, but not the inspected face.
The battle engine records the opposing used-card IDs with the resolved battle
event before mandatory loser disposal. The existing Auditor exclusion contract
is reused for physical IDs, but the Secret Ally has its own one-card sampler
and receipt. It does not borrow Auditor survival, payment or cancellation.
Winning normally or by a single traitor qualifies; no-winner explosion and
mutual-traitor outcomes do not. The physical CHOAM Nexus card is spent once.
Only the holder sees the sampled card snapshot. The public chronicle names the
use and opponent without naming the face.
The safe continuation point is the existing final `finishBattle` board boundary:
after mandatory and optional battle-card cleanup, Moritani retention, battle
income, technology, capture, Auditor, Face Dance and Caladan reinforcement, and
before another battle or phase begins. No existing choice occurs at that point
for every possible winner. Opening a pause only when the winner secretly holds
the CHOAM Nexus card would reveal that hidden identity. Precommitting a
conditional use before the result would remove the printed after-victory choice.
A universal use-or-pass window would preserve secrecy and the full choice, but
would also ask winners without the card to pass. The user has been asked which
product exception to adopt; no timing policy is implemented while that answer
is pending.
After compulsory and optional card cleanup, battle income, technology, capture,
Auditor, Face Dance and Caladan reinforcement, the final `finishBattle` board
boundary opens the same use-or-continue window for **every winner** when Nexus
is enabled and CHOAM is absent. This is a privacy-preserving product
interpretation, not a printed requirement: nonholders and ineligible allied
holders continue without use. Opening only for a secret holder would expose
card custody; precommitting before the result would remove the printed
after-victory choice. All four AI levels use the same private offer and can
legally continue. Legacy battle cleanup saves without a physical used-card
receipt do not infer one and continue through their existing path.
The saved winner choice survives a paid Nullentropy Box search or another
interruption without exposing the held Nexus card. Only the active decision
projects its private offer; suspended frames retain their signed battle receipt.
The private sampled-card snapshot expires at the next battle or Storm.
The retrieved Nexus sources neither establish an ordinary Karama response to
this Secret Ally play nor grant blanket Nexus immunity. The Collection trade is
currently direct and this inspection must not inherit the native Auditor's
Karama/payment window by analogy. Cancellation remains an explicit later audit
boundary rather than an invented prototype rule.
this Secret Ally play nor grant blanket Nexus immunity. The current inspection
is direct, as with the Collection trade. Cancellation remains an explicit
later source-audit boundary rather than an invented prototype rule.
## Focused evidence and remaining scope
@@ -86,8 +88,10 @@ stale action rejection, Truthtrance locking, damaged saved receipts and JSON
continuation. Production SQLite tests exercise competing trades, seat restoration,
replay rejection and an already-paid discard resume.
The after-victory inspection still needs an original battle receipt, exclusion of
the opponent's used cards, a private one-card sample and composition with winner
cleanup. Complete Nexus games, broader Homeworld interactions, strategic choice
between the two Secret Ally uses and visual acceptance of that full family remain
Run `npm test -- nexus-choam-inspection` for real Basic/Advanced battles,
private controls, physical used-card exclusion, JSON continuation, paid-search
interruption, snapshot expiry and competing in-memory SQLite cases. The
Collection trade retains its separate focused and production SQLite evidence
above. Complete Nexus games, broader Homeworld interactions, strategic choice
between the two Secret Ally uses and visual acceptance of the full family remain
unfinished. Prototype evidence is deliberately narrower than complete compliance.
+1 -1
View File
@@ -82,7 +82,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Orgiz collection | The [publisher rulebook, page 13](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) says an occupant steals one spice of each collected blow, but its Q&A and searched designer/community sources do not define stacked board piles. Choose one transfer per positive collected board deposit as the observable first version, from its unique rival collector; this is an inference, not a publisher ruling. Contested Orgiz occupancy and Ecaz shared lots withhold only the uncertain transfer while ordinary collection continues. | [Discovery interpretation](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Jacurutu physical dial | The user question asks whether the losing player should choose the physical normal/elite forces represented by its dial. When legal Advanced allocations produce different rewards, income remains explicitly unpaid; numeric wheel subtraction is not a ruling. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. No existing postbattle choice covers every winner: a pause only for a holder leaks ownership, precommitment removes the after-win choice, and a universal window makes nonholders pass. The product-exception question has been sent; no timing policy is implemented pending the answer. Ordinary Karama cancellation is also not established by the retrieved sources. | [After-victory inspection audit](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) |
| CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. The connected prototype chooses a uniform use-or-continue window for every winner when Nexus is enabled and CHOAM absent; nonholders pass, while a holder may decide after the result. This privacy-preserving prompt is a product inference, not publisher text. A holder-only pause would reveal custody and a precommitment would remove the printed choice. Ordinary Karama cancellation remains unestablished by retrieved sources; no response is invented. | [After-victory inspection boundary](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) |
| Moritani Secret Ally battle timing | The optional retention choice follows a battle loss while the Nexus remains secret until use. Automatically continuing nonholders but pausing holders exposes custody; earlier precommitment removes the after-loss choice. A universal loser confirmation needs the same pending UX exception as other hidden reactive cards. The existing alliance retention/cleanup code does not resolve this timing problem; no duplicate user question has been sent. | [Nexus retention boundary](NEXUS_CARD_RULES.md#moritani-secret-ally-retention-timing) |
| Emperor Secret Ally purchase timing | The purchase choice follows winning the bid. A holder-only payment pause exposes concealed card eligibility, while per-bid preauthorization moves the choice earlier. The user has been asked whether to permit a uniform winner payment confirmation, including sole spice payments, or retain the purchase gate. No answer is assumed; the independent three-force revival grant can proceed. | [Purchase source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price) |
| Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
+1
View File
@@ -32,6 +32,7 @@ const decisions = {
grummanCollection: true,
caladanReinforcement: true,
choamAudit: true,
nexusChoamInspection: true,
choamAuditPayment: true,
strongholdCopy: true,
richeseBlackMarket: true,
+3
View File
@@ -1501,6 +1501,9 @@ function policyActions(g: GameView): Action[] {
return [{ type: 'decision', event: d.event,
returns: offer.selection ?? defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support) }];
}
if (d.kind === 'nexusChoamInspection')
return [{ type: 'decision', event: d.event,
inspect: !!g.nexusChoamInspection?.canInspect }];
if (d.kind === 'choamAudit')
return [{ type: 'decision', event: d.event, audit: true }];
if (d.kind === 'choamAuditPayment') {
+147 -2
View File
@@ -33,6 +33,7 @@ import { createDiscoveryState, validateDiscoveryState, placeDiscovery, rememberD
import { quoteJacurutuBattleIncome } from './discovery-battle';
import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type GreatMaker } from './great-maker';
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
import { quoteNexusChoamInspection, sampleNexusChoamInspection, nexusChoamInspectionSignature, type NexusChoamInspectionReceipt } from './nexus-choam-inspection';
import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal';
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
@@ -729,6 +730,7 @@ export type Decision =
| { kind: 'grummanCollection'; player: string; event: string }
| { kind: 'caladanReinforcement'; player: string; event: string }
| { kind: 'choamAudit'; player: string; event: string }
| { kind: 'nexusChoamInspection'; player: string; event: string }
| { kind: 'choamAuditPayment'; player: string; event: string }
| {
kind: 'strongholdCopy';
@@ -1387,6 +1389,13 @@ export type Game = {
turn: number;
cards: Card[];
} | null;
nexusChoamInsight?: {
event: string;
viewer: string;
target: string;
turn: number;
card: Card;
} | null;
choamMarket?: ChoamMarket | null;
biddingEnd?: BiddingEnd | null;
ecazPoisonIncome?: { player: string; turn: number; phase: number; amount: number; count: number }[];
@@ -1671,6 +1680,7 @@ export type Game = {
caladanReinforcement?: HomeworldVictoryObligation;
nexusSardaukarCasualties?: string;
moritaniAssassinate?: {event:string;signature:string;continuation?:string};
nexusChoamInspection?: NexusChoamInspectionReceipt;
} | null;
auction: Auction | null;
battle: Battle | null;
@@ -1770,6 +1780,71 @@ function nexusChoamTradeIntegrity(g: Game) {
pending.spiceAfter === last.spiceBefore + 2 && getPlayer(g, last.owner).spice === pending.spiceAfter,
'The CHOAM Nexus trade no longer matches its committed payment.');
}
function choamInspectionDecision(
decision: Game['decision'] | undefined,
): Extract<Decision, { kind: 'nexusChoamInspection' }> | null {
return decision?.kind === 'nexusChoamInspection' ? decision : null;
}
function nexusChoamInspectionIntegrity(g: Game) {
const context = g.lastBattleContext;
const receipt = context?.nexusChoamInspection;
const continuation = g.pendingTreacheryDiscard?.continuation;
const active = choamInspectionDecision(g.decision);
const exchange = choamInspectionDecision(g.pendingExchange?.decision);
const nullentropy = choamInspectionDecision(g.pendingNullentropy?.resume.decision);
const gift = choamInspectionDecision(g.pendingRicheseGift?.resume.decision);
const purchase = choamInspectionDecision(g.pendingRichesePurchaseIncome?.resume.decision);
const discard = choamInspectionDecision(
continuation && 'resume' in continuation ? continuation.resume.decision : null,
);
const decisionCount = Number(!!active) + Number(!!exchange) +
Number(!!nullentropy) + Number(!!gift) + Number(!!purchase) + Number(!!discard);
const inspectionDecision = active ?? exchange ?? nullentropy ?? gift ?? purchase ?? discard;
if (!receipt) {
requireRule(decisionCount === 0, 'The CHOAM Nexus inspection has lost its battle.');
return;
}
requireRule(
!!g.nexusCards?.cards && !!context?.winner &&
context.result !== 'legacy' &&
receipt.event === context.event &&
context.combatants.length === 2 &&
context.combatants.includes(receipt.opponent) &&
receipt.opponent !== context.winner &&
Array.isArray(receipt.usedCards) &&
receipt.usedCards.every(id => typeof id === 'string' && id.length > 0) &&
new Set(receipt.usedCards).size === receipt.usedCards.length &&
['pending', 'offer', 'complete'].includes(receipt.stage) &&
receipt.signature === nexusChoamInspectionSignature(receipt),
'The CHOAM Nexus inspection lost its original battle or used cards.',
);
if (receipt.stage === 'offer')
requireRule(g.phase === 6 && !g.battle && decisionCount === 1 &&
inspectionDecision?.event === receipt.event &&
inspectionDecision.player === context.winner,
'The CHOAM Nexus inspection offer has lost its winner.');
else
requireRule(decisionCount === 0, 'The CHOAM Nexus inspection reopened after its battle.');
if (receipt.stage === 'pending')
requireRule(g.phase === 6 && !g.battle,
'Finish the original battle cleanup before CHOAM inspection.');
if (receipt.inspected !== undefined)
requireRule(receipt.stage === 'complete' &&
!receipt.usedCards.includes(receipt.inspected) &&
g.nexusChoamInsight?.event === receipt.event &&
g.nexusChoamInsight.viewer === context.winner &&
g.nexusChoamInsight.target === receipt.opponent &&
g.nexusChoamInsight.turn === context.turn &&
g.nexusChoamInsight.card.id === receipt.inspected,
'The private CHOAM Nexus inspection lost its sampled card.');
}
function retireNexusChoamInsight(g: Game) {
g.nexusChoamInsight = null;
if (g.lastBattleContext?.nexusChoamInspection?.stage === 'complete')
delete g.lastBattleContext.nexusChoamInspection;
}
function nexusChoamBetrayalIntegrity(g: Game) {
const history = g.nexusChoamBetrayals;
const continuation = g.pendingTreacheryDiscard?.continuation;
@@ -13613,7 +13688,10 @@ function openPhase(g: Game, initialize = true) {
else if (initialize) beginPhase(g);
}
function beginPhase(g: Game) {
if (g.phase === 0) g.auditorInsight = null;
if (g.phase === 0) {
g.auditorInsight = null;
retireNexusChoamInsight(g);
}
if (g.phase === 2 && g.choamCharity?.turn !== g.turn) {
const choam = byFaction(g, 'choam');
if (choam && charityMultiplier(g) === 0) {
@@ -16745,8 +16823,20 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
if (g.dukeVidal && [ap.leader, dp.leader].includes(DUKE_VIDAL_ID))
g.dukeVidal = consumeDuke(g.dukeVidal);
g.lastBattle = [a.id, d.id];
const battleEvent = b.event ?? g.pendingAuditor?.event ?? crypto.randomUUID();
const inspection: NexusChoamInspectionReceipt | null =
winner && g.nexusCards?.cards && !byFaction(g, 'choam')
? {
event: battleEvent,
opponent: winner.id === a.id ? d.id : a.id,
usedCards: [...(winner.id === a.id ? quote.played.defender : quote.played.attacker)],
stage: 'pending',
signature: '',
}
: null;
if (inspection) inspection.signature = nexusChoamInspectionSignature(inspection);
g.lastBattleContext = {
event: b.event ?? g.pendingAuditor?.event ?? crypto.randomUUID(),
event: battleEvent,
turn: g.turn,
territory: b.territory,
combatants: [...g.lastBattle],
@@ -16754,6 +16844,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
result: quote.result,
...(playedCardRoles ? { cardRoles: playedCardRoles } : {}),
...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}),
...(inspection ? { nexusChoamInspection: inspection } : {}),
};
recordMoritaniAssassinateOpportunity(g, b, winner, losingPlayer);
if (quote.smuggler) {
@@ -17477,6 +17568,17 @@ function finishBattle(g: Game) {
}
if ((next.kind === 'faceDance' || next.kind === 'board') &&
openHomeworldVictoryReturn(g, next.kind === 'faceDance')) return;
const choamInspection = g.lastBattleContext?.nexusChoamInspection;
if (next.kind === 'board' && choamInspection?.stage === 'pending') {
choamInspection.stage = 'offer';
choamInspection.signature = nexusChoamInspectionSignature(choamInspection);
g.decision = {
kind: 'nexusChoamInspection',
player: g.lastBattleContext!.winner!,
event: choamInspection.event,
};
return;
}
if (next.kind === 'choamBattleIncome') {
g.response = { ...next, passed: [] };
} else if (next.kind === 'choamAudit') {
@@ -21410,6 +21512,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
nexusChoamInspectionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
@@ -21569,6 +21672,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
discoveryIntegrity(g);
greatMakerIntegrity(g);
nexusChoamTradeIntegrity(g);
nexusChoamInspectionIntegrity(g);
nexusChoamBetrayalIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
@@ -21682,6 +21786,7 @@ export function normalizeAutomaticGame(state: Game): Game {
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
nexusChoamInspectionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
@@ -21713,6 +21818,7 @@ export function normalizeAutomaticGame(state: Game): Game {
discoveryIntegrity(g);
greatMakerIntegrity(g);
nexusChoamTradeIntegrity(g);
nexusChoamInspectionIntegrity(g);
nexusChoamBetrayalIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
@@ -22409,6 +22515,38 @@ function applyActionInner(
resumeChoamMovement(g, true);
return g;
}
if (decision.kind === 'nexusChoamInspection') {
const offer = quoteNexusChoamInspection(g, id, decision);
requireRule(
offer?.event === decision.event &&
action.event === decision.event &&
typeof action.inspect === 'boolean' &&
Object.keys(action).every(key => ['type', 'event', 'inspect'].includes(key)),
'Choose whether to use CHOAM Secret Ally for this resolved victory.',
);
const receipt = g.lastBattleContext!.nexusChoamInspection!;
if (action.inspect) {
requireRule(offer.canInspect,
'The CHOAM Secret Ally needs its unallied holder and an unused opposing card.');
const opponent = getPlayer(g, receipt.opponent);
const card = nexusRule(() =>
sampleNexusChoamInspection(opponent.hand, receipt.usedCards, random));
g.nexusCards!.cards = nexusRule(() =>
discardNexusCard(g.nexusCards!.cards!, id, g.players));
receipt.inspected = card.id;
g.nexusChoamInsight = {
event: receipt.event, viewer: id, target: opponent.id,
turn: g.turn, card,
};
log(g, `${p.name} spent CHOAM Nexus Secret Ally to privately inspect one unused card held by ${opponent.name} after winning the battle. The inspected face remains private.`,
{ faction: p.faction, name: 'CHOAM Nexus inspection' });
}
receipt.stage = 'complete';
receipt.signature = nexusChoamInspectionSignature(receipt);
g.decision = null;
finishBattle(g);
return g;
}
if (
decision.kind === 'choamAudit' ||
decision.kind === 'choamAuditPayment'
@@ -25025,6 +25163,7 @@ function applyActionInner(
);
requireRule(choice, 'Choose one of your unresolved battles.');
g.auditorInsight = null;
retireNexusChoamInsight(g);
const noFieldPlayers = [choice.attacker, choice.defender].filter(
(playerId) =>
getPlayer(g, playerId).noField?.deployed?.location.territory ===
@@ -25437,6 +25576,7 @@ export function viewGame(state: Game, id: string) {
discoveryIntegrity(state);
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
nexusChoamInspectionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
@@ -25729,6 +25869,10 @@ export function viewGame(state: Game, id: string) {
g.auditorInsight?.viewer === id
? structuredClone(g.auditorInsight)
: null,
nexusChoamInsight:
g.nexusChoamInsight?.viewer === id
? structuredClone(g.nexusChoamInsight)
: null,
mobileStronghold: g.mobileStronghold ?? null,
mobileRoute: g.pendingMobileMove
? {
@@ -25747,6 +25891,7 @@ export function viewGame(state: Game, id: string) {
greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null,
nexusCards: projectedNexusCards(g, id),
nexusChoamTrade: currentNexusChoamTrade(g, id),
nexusChoamInspection: quoteNexusChoamInspection(g, id),
nexusChoamBetrayal: quoteNexusChoamBetrayal(g, id,
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id),
+67
View File
@@ -0,0 +1,67 @@
import type { Card } from './cards';
import type { Game } from './engine';
import { nexusCardMode } from './nexus-cards';
export type NexusChoamInspectionReceipt = {
event: string;
opponent: string;
usedCards: string[];
stage: 'pending' | 'offer' | 'complete';
inspected?: string;
signature: string;
};
export function nexusChoamInspectionSignature(
receipt: NexusChoamInspectionReceipt,
): string {
return JSON.stringify([
receipt.event, receipt.opponent, receipt.usedCards, receipt.stage,
receipt.inspected ?? null,
]);
}
/** Every winner gets the same public window. Only its owner's projection may
* reveal whether a usable Secret Ally card and an unused opposing card exist. */
export function quoteNexusChoamInspection(
game: Game, viewer: string, decision: Game['decision'] = game.decision,
) {
const context = game.lastBattleContext;
const receipt = context?.nexusChoamInspection;
if (!context?.winner || !receipt || receipt.stage !== 'offer' ||
decision?.kind !== 'nexusChoamInspection' || viewer !== context.winner)
return null;
const owner = game.players.find(player => player.id === viewer)!;
const opponent = game.players.find(player => player.id === receipt.opponent)!;
const canInspect = !owner.ally &&
game.nexusCards?.cards?.hands[viewer] === 'choam' &&
nexusCardMode('choam', owner.faction, game.players.map(player => player.faction)) === 'secretAlly' &&
opponent.hand.some(card => !receipt.usedCards.includes(card.id));
return { event: receipt.event, opponent: receipt.opponent, canInspect };
}
/** Uniform one-card sample; no hidden hand or randomness is read by a view. */
export function sampleNexusChoamInspection(
hand: readonly Card[],
usedCards: readonly string[],
random: () => number,
): Card {
if (!Array.isArray(hand) || !Array.isArray(usedCards) ||
usedCards.some(id => typeof id !== 'string' || !id) ||
new Set(usedCards).size !== usedCards.length)
throw new Error('CHOAM inspection needs the original physical used-card IDs.');
const used = new Set(usedCards);
let count = 0;
for (const card of hand) {
if (!card || typeof card.id !== 'string' || !card.id)
throw new Error('CHOAM inspection needs physical opposing hand cards.');
if (!used.has(card.id)) count++;
}
if (!count) throw new Error('The opposing hand has no unused card to inspect.');
const draw = random();
if (!Number.isFinite(draw) || draw < 0 || draw >= 1)
throw new Error('CHOAM inspection needs a finite random draw in [0, 1).');
let index = Math.floor(draw * count);
for (const card of hand)
if (!used.has(card.id) && index-- === 0) return structuredClone(card);
throw new Error('The opposing hand changed during CHOAM inspection.');
}
+1 -1
View File
@@ -4034,7 +4034,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Emperor Betrayal has a battle alternative in Advanced play: an unallied opponent holding the Emperor Nexus card can spend it during the ordinary pre-plan Sardaukar response to suppress actual Sardaukar strength for this battle. Every seat receives the same response opportunity even with no card, keeping hidden custody private. The starred pieces remain physical Sardaukar; no card grants a new force or cancels their separate support benefit.',
'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This first path requires base factions and Nexus alone, before any normal force returns, without combined revival effects. Partial returns, prior returns, higher free rates and separate sandworm protection remain unavailable.',
'Native Fremen Cunning has a bounded Nexus-only path: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker and combined optional modules await integration.',
'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. Its after-victory inspection remains unfinished.',
'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. After a battle victory, every winner in an eligible Nexus game receives the same use-or-continue window; an unallied CHOAM-card holder may instead spend that card to inspect one random unused card in the opposing combatant’s current hand. Played battle cards remain excluded even if retained. The inspected face is private, while use is public. The universal window protects concealed card custody; this is an interface interpretation, not a publisher-prescribed prompt. An ordinary Karama response to this Secret Ally inspection remains unestablished.',
'With CHOAM seated, an unallied holder may spend CHOAM Betrayal at a clean play boundary to discard one uniformly random held CHOAM Treachery Card without spice compensation. The target name is public, but its hand count is offered only during Bidding; the holder cannot pick or inspect the card before the ordinary discard reveal.',
'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.',
'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.',
+1
View File
@@ -15,6 +15,7 @@ const DECISIONS = {
diplomatRetreat: true,
harassWithdraw: true,
choamAudit: true,
nexusChoamInspection: true,
choamAuditPayment: true,
strongholdCopy: true,
richeseBlackMarket: true,
+284
View File
@@ -0,0 +1,284 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { richeseCards } from '../game/richese-cards';
import { sampleNexusChoamInspection } from '../game/nexus-choam-inspection';
import { NexusChoamInspectionDecision, NexusChoamInspectionInsight } from '../components/nexus-choam-trade';
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
import { unitStore } from './fixture-nexus-room-store';
import type { RoomsClock } from '../db/rooms';
import { nexusReady } from './fixture-nexus-cards';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
function battle(holdCard: boolean, ids: [string, string, string] = ['p', 'q', 'r'], advanced = false, secondBattle = true) {
const [ownerId, opponentId, observerId] = ids;
let game = nexusTraitorFixture({ ownerFaction: 'harkonnen', phase: 6, seatIds: ids, advanced });
const cards = game.nexusCards!.cards!;
if (holdCard) {
const index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[ownerId]!;
cards.hands[ownerId] = 'choam';
}
Object.assign(game, { active: ownerId, order: ids, phase: 6, storm: 18 });
for (const p of game.players) {
p.forces = p.id === observerId ? {} : secondBattle
? { 'arrakeen:10': 10, 'hagga_basin:12': 1 }
: { 'arrakeen:10': 10 };
p.reserves = p.id === observerId ? 20 : secondBattle ? 9 : 10;
p.hand = [];
p.spice = 20;
}
const opponent = seat(game, opponentId);
const used = game.deck.splice(game.deck.findIndex(card => card.kind === 'projectile'), 1)[0];
const unused = game.deck.splice(game.deck.findIndex(card => card.kind === 'worthless'), 1)[0];
opponent.hand.push(used, unused);
nexusTraitorInventory(game);
game = applyAction(game, ownerId, { type: 'chooseBattle', territory: 'arrakeen', target: opponentId });
for (let i = 0; i < 30; i++) {
if (game.response) {
const responder = game.players.find(player => !game.response!.passed.includes(player.id))!;
game = applyAction(game, responder.id, { type: 'passResponse' });
} else if (game.battle?.preLeader && !game.battle.preLeader.closed) {
const responder = [game.battle.attacker, game.battle.defender].find(id => !game.battle!.preLeader!.ready.includes(id))!;
game = applyAction(game, responder, { type: 'battlePreparationReady', event: game.battle.event });
} else if (game.battle?.preparation) {
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
} else if (game.decision?.kind === 'fullPlanOffer') {
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
} else break;
}
assert.ok(game.battle);
const best = seat(game, ownerId).leaders.reduce((a, b) => a.strength > b.strength ? a : b);
const weakest = seat(game, opponentId).leaders.reduce((a, b) => a.strength < b.strength ? a : b);
game = applyAction(game, ownerId, { type: 'battlePlan', leader: best.id, dial: 6, support: advanced ? 6 : 0 });
game = applyAction(game, opponentId, { type: 'battlePlan', leader: weakest.id, dial: 0, support: 0, weapon: used.id });
for (const id of [ownerId, opponentId]) game = applyAction(game, id, { type: 'traitorCall', call: false });
for (let i = 0; i < 40; i++) {
if (game.response) {
const responder = game.players.find(player => !game.response!.passed.includes(player.id))!;
game = applyAction(game, responder.id, { type: 'passResponse' });
} else if (game.decision?.kind === 'battleCards') {
game = applyAction(game, game.decision.player, { type: 'decision', discard: [] });
} else if (game.decision?.kind === 'battleLosses') {
game = applyAction(game, game.decision.player, { type: 'decision', choice: 0 });
} else if (game.decision?.kind === 'captureOffer') {
game = applyAction(game, game.decision.player, { type: 'decision', accept: false });
} else break;
}
assert.equal(game.lastBattleContext?.winner, ownerId);
return { game, used, unused };
}
void test('all eligible winners receive the same postbattle window, even without the hidden Nexus card', () => {
for (const holdCard of [false, true]) {
const { game } = battle(holdCard);
assert.equal(game.decision?.kind, 'nexusChoamInspection');
const event = game.lastBattleContext!.event;
const winner = viewGame(game, 'p');
assert.equal(winner.nexusChoamInspection?.canInspect, holdCard);
assert.equal(viewGame(game, 'q').nexusChoamInspection, null);
assert.equal(viewGame(game, 'r').nexusChoamInspection, null);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const botView = viewGame(game, 'p');
botView.players.find(p => p.id === 'p')!.bot = difficulty;
assert.deepEqual(botActions(botView)[0], {
type: 'decision', event, inspect: holdCard,
});
}
const before = reload(game);
assert.throws(() => applyAction(game, 'q', { type: 'decision', event, inspect: true }));
assert.deepEqual(game, before);
const passed = applyAction(game, 'p', { type: 'decision', event, inspect: false });
assert.notEqual(passed.decision?.kind, 'nexusChoamInspection');
assert.equal(passed.nexusCards!.cards!.hands.p, holdCard ? 'choam' : before.nexusCards!.cards!.hands.p);
assert.deepEqual(normalizeAutomaticGame(reload(passed)), passed);
}
});
void test('a paid Nullentropy search suspends and restores the winner window without leaking the hidden card', () => {
const { game } = battle(true);
assert.equal(game.decision?.kind, 'nexusChoamInspection');
game.richeseCache ??= richeseCards();
const index = game.richeseCache.findIndex(card => card.name === 'Nullentropy Box');
assert.ok(index >= 0);
const [box] = game.richeseCache.splice(index, 1);
seat(game, 'p').hand.push(box);
game.discard.push(...game.deck.splice(0, 3));
const pending = applyAction(game, 'p', { type: 'card', card: box.id });
assert.equal(pending.decision?.kind, 'nullentropy');
assert.equal(pending.pendingNullentropy?.resume.decision?.kind, 'nexusChoamInspection');
assert.equal(viewGame(pending, 'p').nexusChoamInspection, null);
const search = viewGame(pending, 'p').nullentropy?.search;
assert.ok(search && search.cards.length >= 2);
const restored = applyAction(pending, 'p', {
type: 'decision', event: search.event, card: search.cards[0].id,
});
assert.equal(restored.decision?.kind, 'nexusChoamInspection');
assert.equal(viewGame(restored, 'p').nexusChoamInspection?.canInspect, true);
assert.deepEqual(normalizeAutomaticGame(reload(restored)), reload(restored));
});
void test('Advanced victory resolves its own cleanup before the private CHOAM choice', () => {
const { game, unused } = battle(true, ['p', 'q', 'r'], true);
assert.equal(game.decision?.kind, 'nexusChoamInspection');
assert.equal(viewGame(game, 'p').nexusChoamInspection?.canInspect, true);
const done = applyAction(game, 'p', {
type: 'decision', event: game.lastBattleContext!.event, inspect: true,
});
assert.equal(viewGame(done, 'p').nexusChoamInsight?.card.id, unused.id);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
});
void test('CHOAM Secret Ally inspects one unused physical opposing card once, privately, after victory', () => {
const { game, used, unused } = battle(true);
assert.ok(game.decision?.kind === 'nexusChoamInspection');
const event = game.decision.event;
const before = reload(game);
assert.throws(() => applyAction(game, 'p', { type: 'decision', event: 'stale', inspect: true }));
assert.throws(() => applyAction(game, 'p', { type: 'decision', event, inspect: true, card: unused.id }));
assert.deepEqual(game, before);
const done = applyAction(game, 'p', { type: 'decision', event, inspect: true });
assert.equal(done.nexusCards!.cards!.hands.p, null);
assert.ok(done.nexusCards!.cards!.discard.includes('choam'));
assert.equal(done.nexusChoamInsight?.card.id, unused.id);
assert.notEqual(done.nexusChoamInsight?.card.id, used.id);
assert.equal(viewGame(done, 'p').nexusChoamInsight?.card.id, unused.id);
assert.equal(viewGame(done, 'q').nexusChoamInsight, null);
assert.equal(viewGame(done, 'r').nexusChoamInsight, null);
assert.equal(done.log.some(entry => entry.text.includes(unused.name)), false);
const restored = reload(done);
assert.deepEqual(normalizeAutomaticGame(restored), done);
assert.throws(() => applyAction(done, 'p', { type: 'decision', event, inspect: true }));
nexusTraitorInventory(done);
});
void test('an inspected card expires when the next physical battle begins without blocking play', () => {
const { game } = battle(true);
assert.ok(game.decision?.kind === 'nexusChoamInspection');
const resolved = applyAction(game, 'p', {
type: 'decision', event: game.decision.event, inspect: true,
});
assert.equal(resolved.phase, 6);
const next = applyAction(resolved, 'p', {
type: 'chooseBattle', territory: 'hagga_basin', target: 'q',
});
assert.equal(next.battle?.territory, 'hagga_basin');
assert.equal(viewGame(next, 'p').nexusChoamInsight, null);
assert.deepEqual(normalizeAutomaticGame(reload(next)), next);
});
void test('private inspection snapshot retires on the next natural Storm boundary', () => {
const { game } = battle(true, ['p', 'q', 'r'], false, false);
assert.ok(game.decision?.kind === 'nexusChoamInspection');
const done = applyAction(game, 'p', {
type: 'decision', event: game.decision.event, inspect: true,
});
assert.equal(done.phase, 7);
assert.ok(done.nexusChoamInsight);
const collectionDone = nexusReady(done);
assert.equal(collectionDone.phase, 8);
const next = nexusReady(collectionDone);
assert.ok(next.turn > done.turn);
assert.equal(next.nexusChoamInsight, null);
assert.equal(next.lastBattleContext?.nexusChoamInspection, undefined);
assert.deepEqual(normalizeAutomaticGame(reload(next)), next);
});
void test('winner controls and snapshot inspector reveal no rival card or concealed Nexus eligibility', () => {
const { game, unused } = battle(true);
const owner = viewGame(game, 'p'), opponent = viewGame(game, 'q');
const controls = renderToStaticMarkup(createElement(NexusChoamInspectionDecision, {
game: owner, busy: false, act() {},
}));
assert.match(controls, /Spend CHOAM Nexus/);
assert.match(controls, /Continue without inspection/);
assert.equal(controls.includes(unused.name), false);
assert.equal(renderToStaticMarkup(createElement(NexusChoamInspectionDecision, {
game: opponent, busy: false, act() {},
})), '');
assert.match(renderToStaticMarkup(createElement(NexusChoamInspectionDecision, {
game: owner, busy: true, act() {},
})), /disabled=""/);
assert.equal(renderToStaticMarkup(createElement(NexusChoamInspectionDecision, {
game: viewGame(battle(false).game, 'p'), busy: false, act() {},
})).includes('Spend CHOAM Nexus · inspect one card'), false);
const event = owner.nexusChoamInspection!.event;
const done = applyAction(game, 'p', { type: 'decision', event, inspect: true });
const privateMarkup = renderToStaticMarkup(createElement(NexusChoamInspectionInsight, {
game: viewGame(done, 'p'),
}));
assert.match(privateMarkup, /Your CHOAM Nexus inspection/);
assert.ok(privateMarkup.includes(unused.name));
assert.equal(renderToStaticMarkup(createElement(NexusChoamInspectionInsight, {
game: viewGame(done, 'q'),
})), '');
});
void test('one-card CHOAM sampler excludes used IDs and selects uniformly among remaining physical cards', () => {
const { game, used, unused } = battle(true);
const other = game.deck.find(card => card.id !== used.id && card.id !== unused.id)!;
const hand = [used, unused, other];
assert.equal(sampleNexusChoamInspection(hand, [used.id], () => 0).id, unused.id);
assert.equal(sampleNexusChoamInspection(hand, [used.id], () => 0.999).id, other.id);
assert.equal(hand.length, 3);
assert.throws(() => sampleNexusChoamInspection(hand, [used.id], () => 1));
assert.throws(() => sampleNexusChoamInspection([used], [used.id], () => 0));
});
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('competing saved victory inspections commit one physical Nexus spend and restore private evidence', async () => {
const store = unitStore();
try {
const made = await store.rooms.createRoom('CHOAM inspection SQL', 'harkonnen', false, []);
const code = made.view.code;
const tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const ids = auths.map(auth => auth.playerId) as [string, string, string];
const { game, unused } = battle(true, ids);
game.code = code;
game.version = (await store.rooms.readRoom(code)).version;
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, code);
const seats = store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all();
const action = { type: 'decision', event: game.lastBattleContext!.event, inspect: true };
let arrivals = 0, release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
const timer = setTimeout(release, 2000);
let results: PromiseSettledResult<unknown>[];
try {
results = await Promise.allSettled([
store.restart().act(code, auths[0], game.version, action, clock),
store.restart().act(code, auths[0], game.version, action, clock),
]);
} finally { clearTimeout(timer); delete store.hooks.beforeWrite; }
assert.equal(arrivals, 2);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
const done = await store.restart().readRoom(code);
assert.equal(done.version, game.version + 1);
assert.equal(done.nexusCards!.cards!.hands[ids[0]], null);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'choam').length, 1);
assert.equal(done.nexusChoamInsight?.card.id, unused.id);
assert.equal(done.log.filter(entry => entry.automatic?.name === 'CHOAM Nexus inspection').length, 1);
const settled = JSON.stringify(done);
await assert.rejects(store.restart().act(code, auths[0], done.version, action, clock));
assert.equal(JSON.stringify(await store.restart().readRoom(code)), settled);
for (const [index, token] of tokens.entries()) {
const auth = await store.restart().authenticate(code, token);
assert.deepEqual(await store.restart().readSeatView(code, auth), viewGame(done, ids[index]));
assert.equal((await store.restart().readSeatView(code, auth)).nexusChoamInsight?.card.id,
index === 0 ? unused.id : undefined);
}
assert.deepEqual(store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
nexusTraitorInventory(done);
} finally { store.sqlite.close(); }
});
+4
View File
@@ -177,6 +177,10 @@ void test('every winning casualty choice removes ordinary counters and never inv
void test('the completed grant expires before a second battle in the same turn', () => {
let g = resolveOrdinary(activate(nexusSardaukarFixture()), 0, 0);
assert.equal(g.battle, null);
assert.ok(g.decision?.kind === 'nexusChoamInspection');
g = applyAction(g, g.decision.player, {
type: 'decision', event: g.decision.event, inspect: false,
});
g = applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'hagga_basin',
+4
View File
@@ -71,6 +71,10 @@ void test('Suboid effect survives a second battle in the same turn and expires w
assert.ok(acted, `Battle stalled at ${g.decision?.kind}/${g.response?.kind}`);
}
assert.equal(g.battle, null);
assert.ok(g.decision?.kind === 'nexusChoamInspection');
g = applyAction(g, g.decision.player, {
type: 'decision', event: g.decision.event, inspect: false,
});
g = applyAction(g, 'p', { type: 'chooseBattle', territory: 'hagga_basin', target: 'q' });
assert.notEqual(g.battle!.event, first);
assert.equal(viewGame(g, 'p').battle!.ownForces!.normalFreeSupport, true);