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

+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,