Connect CHOAM Nexus Betrayal random discard
This commit is contained in:
1 parent
90c5c7af25
commit
0bf61c8067
12 files changed
+709
No files matched your search
@@ -11,6 +11,7 @@ import { discoveryEntryBotActions } from './discovery-entry-options';
|
||||
import { discoveryStormActions } from './discovery-storm-options';
|
||||
import { greatMakerBotActions } from './great-maker-options';
|
||||
import { nexusChoamTradeBotActions } from './nexus-choam-trade-options';
|
||||
import { nexusChoamBetrayalBotActions } from './nexus-choam-betrayal-options';
|
||||
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options';
|
||||
import { nexusMoritaniBotActions } from './nexus-moritani-options';
|
||||
import { moritaniBetrayalBotActions } from './nexus-moritani-betrayal-options';
|
||||
@@ -3848,6 +3849,8 @@ export function botActions(g: GameView): Action[] {
|
||||
if (discovery.length) return discovery;
|
||||
const trade = nexusChoamTradeBotActions(g);
|
||||
if (trade.length) return trade;
|
||||
const choamBetrayal = nexusChoamBetrayalBotActions(g);
|
||||
if (choamBetrayal.length) return choamBetrayal;
|
||||
const moritaniBetrayal = moritaniBetrayalBotActions(g);
|
||||
if (moritaniBetrayal.length) return moritaniBetrayal;
|
||||
const ecazBetrayal = ecazBetrayalBotActions(g);
|
||||
|
||||
@@ -32,6 +32,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 { 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';
|
||||
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
|
||||
@@ -1028,6 +1029,8 @@ export type Game = {
|
||||
greatMaker?: GreatMaker;
|
||||
nexusChoamTrades?: NexusChoamTrade[];
|
||||
nexusChoamTradeLast?: { event: string; stage: NexusChoamTrade['stage'] };
|
||||
nexusChoamBetrayals?: NexusChoamBetrayal[];
|
||||
nexusChoamBetrayalLast?: { event: string; stage: NexusChoamBetrayal['stage'] };
|
||||
nexusEmperorSecretHistory?: NexusEmperorRevival[];
|
||||
nexusEmperorSecretEvents?: string[];
|
||||
nexusFremenRevivalHistory?: FremenNexusRevival[];
|
||||
@@ -1042,6 +1045,7 @@ export type Game = {
|
||||
| { kind: 'ambassador'; entry: NonNullable<Game['pendingAmbassador']> }
|
||||
| { kind: 'discoveryStash'; event: string; owner: string; card: string }
|
||||
| { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number }
|
||||
| { kind: 'nexusChoamBetrayal'; event: string; owner: string; target: string; card: string; handBefore: number }
|
||||
| { kind: 'kaitainDiscard'; owner: string; event: string; cost: number; spiceAfter: number }
|
||||
| { kind: 'winnerMandatoryDiscard'; event: string; player: string; territory: string; optional: string[]; commitment: NonNullable<Game['pendingWinnerDiscards']> }
|
||||
| {
|
||||
@@ -1756,6 +1760,42 @@ 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 nexusChoamBetrayalIntegrity(g: Game) {
|
||||
const history = g.nexusChoamBetrayals;
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const pending = continuation?.kind === 'nexusChoamBetrayal' ? continuation : null;
|
||||
if (history === undefined) {
|
||||
requireRule(!g.nexusChoamBetrayalLast && !pending,
|
||||
'The CHOAM Nexus Betrayal has lost its spent card history.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
|
||||
'CHOAM Nexus Betrayal requires its physical card and saved history.');
|
||||
const events = new Set<string>();
|
||||
const physical = physicalTreacheryCards(g);
|
||||
for (const [index, record] of history.entries()) {
|
||||
nexusRule(() => validateNexusChoamBetrayal(g, record, physical));
|
||||
requireRule(!events.has(record.event) &&
|
||||
(record.stage === 'complete' || index === history.length - 1),
|
||||
'The CHOAM Nexus Betrayal has a duplicated or unfinished prior discard.');
|
||||
events.add(record.event);
|
||||
}
|
||||
const last = history.at(-1)!;
|
||||
requireRule(JSON.stringify(g.nexusChoamBetrayalLast) ===
|
||||
JSON.stringify({ event: last.event, stage: last.stage }) &&
|
||||
(last.stage === 'discard') === !!pending,
|
||||
'The CHOAM Nexus Betrayal has lost or reopened its committed discard.');
|
||||
if (pending)
|
||||
requireRule(g.nexusCards.cards.discard.includes('choam'),
|
||||
'The pending CHOAM Nexus Betrayal has lost its spent card.');
|
||||
if (pending)
|
||||
requireRule(g.status === 'playing' && last.turn === g.turn &&
|
||||
pending.event === last.event && pending.owner === last.owner &&
|
||||
pending.target === last.target && pending.card === last.card &&
|
||||
pending.handBefore === last.handBefore &&
|
||||
getPlayer(g, last.target).hand.length === last.handBefore - 1,
|
||||
'The CHOAM Nexus Betrayal no longer matches its random discard.');
|
||||
}
|
||||
function currentNexusChoamTrade(g: Game, owner: string) {
|
||||
return nexusRule(() => quoteNexusChoamTrade(g, owner,
|
||||
g.phase === 7 && grummanCollectionAutomatic(g)));
|
||||
@@ -2099,6 +2139,32 @@ function playNexusChoamTrade(g: Game, p: Player, action: Action) {
|
||||
log(g, `${p.name} spent CHOAM Nexus Secret Ally and discarded ${card.name} during Spice Collection to receive 2 spice from the bank. Both cards are spent.`,
|
||||
{faction: p.faction, name: 'CHOAM Nexus trade'});
|
||||
}
|
||||
function playNexusChoamBetrayal(g: Game, p: Player, action: Action) {
|
||||
const offer = quoteNexusChoamBetrayal(g, p.id,
|
||||
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g));
|
||||
requireRule(Object.keys(action).sort().join(',') === 'event,type' &&
|
||||
offer && !offer.blocked && action.event === offer.event,
|
||||
offer?.blocked ?? 'Choose the current CHOAM Nexus Betrayal.');
|
||||
const target = getPlayer(g, offer.target.id);
|
||||
requireRule(target.hand.length > 0, 'CHOAM Betrayal has no legal card to discard.');
|
||||
const card = target.hand[Math.floor(random() * target.hand.length)];
|
||||
const record: NexusChoamBetrayal = {
|
||||
event: offer.event, turn: g.turn, phase: g.phase, owner: p.id,
|
||||
target: target.id, card: card.id, handBefore: target.hand.length,
|
||||
stage: 'discard', signature: '',
|
||||
};
|
||||
record.signature = nexusChoamBetrayalSignature(record);
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
const discarded = discard(g, target, card.id);
|
||||
(g.nexusChoamBetrayals ??= []).push(record);
|
||||
g.nexusChoamBetrayalLast = { event: record.event, stage: record.stage };
|
||||
stageTreacheryDiscard(g, 'nexus:choamBetrayal',
|
||||
[{ card: discarded, discardedBy: target.id, publicFace: false }],
|
||||
{ kind: 'nexusChoamBetrayal', event: record.event, owner: p.id,
|
||||
target: target.id, card: card.id, handBefore: record.handBefore });
|
||||
log(g, `${p.name} spent CHOAM Nexus Betrayal. ${target.name} discarded one randomly selected Treachery card without receiving spice.`,
|
||||
{ faction: p.faction, name: 'CHOAM Nexus Betrayal' });
|
||||
}
|
||||
function playMoritaniBetrayal(g: Game, p: Player, action: Action) {
|
||||
const offer = moritaniBetrayalOffer(g, p.id,
|
||||
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g));
|
||||
@@ -4962,6 +5028,13 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
batch.entries[0].discardedBy === c.owner && batch.entries[0].publicFace &&
|
||||
batch.entries[0].card.id === c.card && batch.entries[0].card.kind === 'worthless',
|
||||
'The CHOAM Nexus trade has changed its discarded Worthless card.');
|
||||
} else if (continuation?.kind === 'nexusChoamBetrayal') {
|
||||
const c = continuation;
|
||||
nexusChoamBetrayalIntegrity(g);
|
||||
requireRule(batch.cause === 'nexus:choamBetrayal' && batch.entries.length === 1 &&
|
||||
batch.entries[0].discardedBy === c.target && !batch.entries[0].publicFace &&
|
||||
batch.entries[0].card.id === c.card && g.nexusChoamBetrayals?.at(-1)?.event === c.event,
|
||||
'The CHOAM Nexus Betrayal has changed its random physical discard.');
|
||||
} else if (continuation?.kind === 'kaitainDiscard') {
|
||||
const c = continuation;
|
||||
requireRule(g.phase === 3 && g.biddingEnd?.event === c.event &&
|
||||
@@ -5021,6 +5094,12 @@ function finishTreacheryDiscard(g: Game) {
|
||||
g.nexusChoamTradeLast = { event: record.event, stage: record.stage };
|
||||
return;
|
||||
}
|
||||
if (next.kind === 'nexusChoamBetrayal') {
|
||||
const record = g.nexusChoamBetrayals!.at(-1)!;
|
||||
record.stage = 'complete'; record.signature = nexusChoamBetrayalSignature(record);
|
||||
g.nexusChoamBetrayalLast = { event: record.event, stage: record.stage };
|
||||
return;
|
||||
}
|
||||
if (next.kind === 'kaitainDiscard') return;
|
||||
if (next.kind === 'ambassador') {
|
||||
const p = getPlayer(g, next.entry.beneficiary!);
|
||||
@@ -21194,6 +21273,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
nexusChoamTradeIntegrity(state);
|
||||
nexusChoamBetrayalIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -21352,6 +21432,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
discoveryIntegrity(g);
|
||||
greatMakerIntegrity(g);
|
||||
nexusChoamTradeIntegrity(g);
|
||||
nexusChoamBetrayalIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
@@ -21464,6 +21545,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
nexusChoamTradeIntegrity(state);
|
||||
nexusChoamBetrayalIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -21494,6 +21576,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
discoveryIntegrity(g);
|
||||
greatMakerIntegrity(g);
|
||||
nexusChoamTradeIntegrity(g);
|
||||
nexusChoamBetrayalIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
@@ -21592,6 +21675,7 @@ function applyActionInner(
|
||||
}
|
||||
if (t === 'discovery') { playDiscovery(g, p, action); return g; }
|
||||
if (t === 'nexusChoamTrade') { playNexusChoamTrade(g, p, action); return g; }
|
||||
if (t === 'nexusChoamBetrayal') { playNexusChoamBetrayal(g, p, action); return g; }
|
||||
if (t === 'nexusEmperorRevive') { playNexusEmperorRevive(g,p,action); return g; }
|
||||
if (t === 'nexusEmperorBetrayal') { playNexusEmperorBetrayal(g,p,action); return g; }
|
||||
if (t === 'nexusBgBetrayal') { playNexusBgBetrayal(g,p,action); return g; }
|
||||
@@ -25201,6 +25285,7 @@ export function viewGame(state: Game, id: string) {
|
||||
discoveryIntegrity(state);
|
||||
greatMakerIntegrity(state);
|
||||
nexusChoamTradeIntegrity(state);
|
||||
nexusChoamBetrayalIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -25510,6 +25595,8 @@ 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),
|
||||
nexusChoamBetrayal: quoteNexusChoamBetrayal(g, id,
|
||||
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
|
||||
nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id),
|
||||
nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id),
|
||||
nexusBgBetrayal: nexusBgBetrayalOffer(g, id),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** Bind the action to the private projected offer, never to a client-picked hand card. */
|
||||
export function nexusChoamBetrayalAction(game: GameView): Action | null {
|
||||
const offer = game.nexusChoamBetrayal;
|
||||
const owner = game.players.find((player) => player.id === game.me);
|
||||
if (
|
||||
game.status !== 'playing' ||
|
||||
game.automaticContinuationPending ||
|
||||
!owner ||
|
||||
owner.ally ||
|
||||
game.nexusCards?.card !== 'choam' ||
|
||||
!offer ||
|
||||
!offer.event ||
|
||||
offer.blocked ||
|
||||
!game.players.some((player) => player.id === offer.target.id && player.faction === 'choam')
|
||||
) return null;
|
||||
return { type: 'nexusChoamBetrayal', event: offer.event };
|
||||
}
|
||||
|
||||
/** Bots use only Bidding's public hand count, avoiding blind invalid attempts. */
|
||||
export function nexusChoamBetrayalBotActions(game: GameView): Action[] {
|
||||
if (game.phase !== 3) return [];
|
||||
const action = nexusChoamBetrayalAction(game);
|
||||
return action ? [action] : [];
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Card } from './cards';
|
||||
import type { Game } from './engine';
|
||||
import { nexusCardMode } from './nexus-cards';
|
||||
|
||||
export type NexusChoamBetrayalOffer = Readonly<{
|
||||
event: string;
|
||||
blocked: string | null;
|
||||
target: Readonly<{ id: string; name: string; handSize: number | null }>;
|
||||
}>;
|
||||
|
||||
/** The sampled card is a private, committed Treachery identity, not offer data. */
|
||||
export type NexusChoamBetrayal = {
|
||||
event: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
owner: string;
|
||||
target: string;
|
||||
card: string;
|
||||
handBefore: number;
|
||||
stage: 'discard' | 'complete';
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export function nexusChoamBetrayalEvent(
|
||||
turn: number, phase: number, owner: string, target: string,
|
||||
): string {
|
||||
return JSON.stringify(['nexusChoamBetrayal', turn, phase, owner, target]);
|
||||
}
|
||||
|
||||
export function nexusChoamBetrayalSignature(record: NexusChoamBetrayal): string {
|
||||
return JSON.stringify([record.event, record.turn, record.phase, record.owner,
|
||||
record.target, record.card, record.handBefore, record.stage]);
|
||||
}
|
||||
|
||||
/** The hand count is public during Bidding only; identities are never offer data. */
|
||||
export function quoteNexusChoamBetrayal(
|
||||
g: Game, owner: string, automaticPending = false,
|
||||
): NexusChoamBetrayalOffer | null {
|
||||
const holder = g.players.find(player => player.id === owner);
|
||||
const choam = g.players.find(player => player.faction === 'choam');
|
||||
if (!holder || !choam || choam.id === owner ||
|
||||
g.nexusCards?.cards?.hands[owner] !== 'choam' ||
|
||||
nexusCardMode('choam', holder.faction, g.players.map(player => player.faction)) !== 'betrayal')
|
||||
return null;
|
||||
const handSize = g.phase === 3 ? choam.hand.length : null;
|
||||
let blocked: string | null = null;
|
||||
if (holder.ally) blocked = 'Use a Nexus card while unallied.';
|
||||
else if (g.status !== 'playing') blocked = 'Use CHOAM Betrayal during play.';
|
||||
else if (automaticPending || g.response || g.decision || g.truthtrance || g.phaseOpening ||
|
||||
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy ||
|
||||
g.pendingExchange || g.pendingAmbassador || g.pendingRicheseGift ||
|
||||
g.pendingRichesePurchaseIncome || g.pendingRevival || g.pendingCapture ||
|
||||
g.pendingShipment || g.pendingHomeworldShipment || g.pendingIxMove ||
|
||||
g.pendingFremenMove || g.pendingMobileMove || g.pendingTerrorEntry ||
|
||||
g.pendingMoritaniPlacement || g.pendingEcazPlacement || g.pendingChoamMove ||
|
||||
g.pendingChoamWorthless || g.pendingChoamBattleIncome ||
|
||||
g.pendingChoamMarketGhola || g.pendingAuditor || g.pendingFaceDance ||
|
||||
g.pendingTech || g.pendingIxTechnology || g.pendingIxAlly ||
|
||||
g.pendingIxSubstitution || g.pendingIxRicheseTechnology ||
|
||||
g.pendingWinnerDiscards || g.pendingSukRescue || g.ornithopter ||
|
||||
g.summonedWorm || g.battle || g.auction || g.richeseAuction ||
|
||||
g.choamMarket || g.nexusTraitorPending ||
|
||||
g.nexusTraitorExchanges?.some(record => record.stage === 'return') ||
|
||||
g.nexusCards?.phase?.stage === 'drawing')
|
||||
blocked = 'Finish the current interaction before using CHOAM Betrayal.';
|
||||
else if (handSize === 0) blocked = 'CHOAM has no Treachery cards to discard.';
|
||||
return {
|
||||
event: nexusChoamBetrayalEvent(g.turn, g.phase, owner, choam.id),
|
||||
blocked,
|
||||
target: { id: choam.id, name: choam.name, handSize },
|
||||
};
|
||||
}
|
||||
|
||||
/** Verify intrinsic history against the original seated Betrayal mode and one
|
||||
* conserved physical card, without requiring its former hand to remain held. */
|
||||
export function validateNexusChoamBetrayal(
|
||||
g: Game, record: NexusChoamBetrayal, physicalCards: readonly Card[],
|
||||
): void {
|
||||
const keys = 'card,event,handBefore,owner,phase,signature,stage,target,turn';
|
||||
const owner = record && g.players.find(player => player.id === record.owner);
|
||||
const target = record && g.players.find(player => player.id === record.target);
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record) ||
|
||||
Object.keys(record).sort().join(',') !== keys ||
|
||||
!owner || !target || owner.id === target.id || target.faction !== 'choam' ||
|
||||
owner.faction === 'choam' ||
|
||||
nexusCardMode('choam', owner.faction, g.players.map(player => player.faction)) !== 'betrayal' ||
|
||||
!Number.isSafeInteger(g.turn) || g.turn < 1 ||
|
||||
!Number.isSafeInteger(g.phase) || g.phase < 0 || g.phase > 8 ||
|
||||
!Number.isSafeInteger(record.turn) || record.turn < 1 || record.turn > g.turn ||
|
||||
!Number.isSafeInteger(record.phase) || record.phase < 0 || record.phase > 8 ||
|
||||
(record.turn === g.turn && record.phase > g.phase) ||
|
||||
!Number.isSafeInteger(record.handBefore) || record.handBefore < 1 ||
|
||||
typeof record.card !== 'string' || !record.card.trim() ||
|
||||
(record.stage !== 'discard' && record.stage !== 'complete') ||
|
||||
record.event !== nexusChoamBetrayalEvent(record.turn, record.phase,
|
||||
record.owner, record.target) ||
|
||||
record.signature !== nexusChoamBetrayalSignature(record) ||
|
||||
!Array.isArray(physicalCards))
|
||||
throw new Error('CHOAM Betrayal has lost its original roster, event or physical Treachery card.');
|
||||
let matches = 0;
|
||||
for (const card of physicalCards)
|
||||
if (card?.id === record.card) matches++;
|
||||
if (matches !== 1)
|
||||
throw new Error('CHOAM Betrayal has lost its original physical Treachery card.');
|
||||
}
|
||||
@@ -3955,6 +3955,28 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, private controls, AI, saved continuation and competing SQLite actions. Complete family acceptance remains open.', evidence: ['tests/nexus-choam-trade.test.ts', 'tests/nexus-choam-trade-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nexus-choam-betrayal',
|
||||
title: 'CHOAM Nexus Betrayal: random discard',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'With CHOAM seated, an unallied rival may spend its CHOAM Nexus card to discard one random physical Treachery Card from CHOAM without paying spice.',
|
||||
steps: [
|
||||
'Hold the CHOAM Nexus card while CHOAM is seated and holds at least one Treachery Card. Finish other choices or automatic continuations before using Betrayal; the printed panel has no phase restriction.',
|
||||
'The server chooses uniformly from CHOAM’s held cards, not from a client choice. Discard both the Nexus card and the selected Treachery Card; CHOAM receives no spice.',
|
||||
'Only the holder sees the Betrayal control. CHOAM’s hand count is shown only during Bidding; no rival card identity is disclosed before the ordinary discard reveal. Outside Bidding an empty-hand attempt rejects without spending the card.',
|
||||
'Basic and Advanced share this bounded effect. Other Nexus effects and complete expansion combinations retain their separate gates.',
|
||||
],
|
||||
related: ['nexus-cards', 'choam-modules'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical random discard, spent Nexus custody, clean action boundary and saved discard receipt are connected. Full CHOAM Nexus family and module acceptance remain open.', evidence: ['game/nexus-choam-betrayal.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private holder control shows CHOAM’s name, Bidding-only public hand count, blocked reasons and spent-card cost without a non-Bidding count leak.', evidence: ['components/nexus-choam-betrayal.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles use the legal server-quoted action during Bidding when CHOAM’s count is public. Other phases avoid blind hidden-hand attempts; the server samples the actual card.', evidence: ['game/nexus-choam-betrayal-options.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed outcome, bounded timing, custody, privacy and remaining gates are explicit.', evidence: ['docs/NEXUS_CARD_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused Basic/Advanced custody, rejected actions, private controls, legal bots and JSON discard continuation are covered; complete module acceptance is not.', evidence: ['tests/nexus-choam-betrayal-engine.test.ts', 'tests/nexus-choam-betrayal-quote.test.ts', 'tests/nexus-choam-betrayal-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nexus-emperor-secret-ally',
|
||||
title: 'Emperor Nexus Secret Ally: extra revivals',
|
||||
@@ -4013,6 +4035,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'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.',
|
||||
'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.',
|
||||
|
||||
Reference in new issue
Block a user