Implement five CHOAM Nexus Cunning effects with private costs and recovery
This commit is contained in:
1 parent
a6d9235099
commit
290259bac2
24 files changed
+1935
-131
No files matched your search
+22
-40
@@ -1,3 +1,4 @@
|
||||
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
|
||||
import { nexusCardBotActions } from './nexus-card-options';
|
||||
import { nexusTraitorBotActions } from './nexus-traitor-options';
|
||||
import { nexusTleilaxuBotActions } from './nexus-tleilaxu-options';
|
||||
@@ -940,8 +941,8 @@ function plans(g: GameView): Action[] {
|
||||
// This policy receives a personalized view, never the authoritative decks or rival hands.
|
||||
function gamontAction(g: GameView): Action | undefined {
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const card = g.choamWorthless?.cards.find((c) => c.name === 'Trip to Gamont');
|
||||
if (!card || g.phase !== 8) return;
|
||||
const play = choamPowerBotPlay(g, 'gamont', (card) => technologyCardValue(g, card));
|
||||
if (!play || g.phase !== 8) return;
|
||||
const options = g
|
||||
.choamWorthless!.gamont.filter((o) => o.target !== me.ally)
|
||||
.map((o) => {
|
||||
@@ -966,14 +967,9 @@ function gamontAction(g: GameView): Action | undefined {
|
||||
.sort((a, b) => b.score - a.score);
|
||||
const choice = options[0];
|
||||
if (choice)
|
||||
return {
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: card.id,
|
||||
target: choice.target,
|
||||
from: choice.key,
|
||||
elite: choice.elite,
|
||||
};
|
||||
return choamPowerAction(g, play, {
|
||||
target: choice.target, from: choice.key, elite: choice.elite,
|
||||
}) ?? undefined;
|
||||
}
|
||||
/** Supported, server-derived timing only; no rival hand or sealed bid is inspected. */
|
||||
function saphoAction(g: GameView): Action | null {
|
||||
@@ -1155,10 +1151,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const preserve =
|
||||
g.choamWorthless?.pending ??
|
||||
(g.decision?.kind === 'choamMovement'
|
||||
? g.choamWorthless?.cards.find((c) => c.name === 'Baliset')?.id
|
||||
? choamPowerBotPlay(g, 'baliset', (card) => technologyCardValue(g, card))?.card.id
|
||||
: g.decision?.kind === 'choamStorm'
|
||||
? g.choamWorthless?.cards.find((c) => c.name === 'Jubba Cloak')?.id
|
||||
: gamontAction(g)?.card);
|
||||
? choamPowerBotPlay(g, 'jubba', (card) => technologyCardValue(g, card))?.card.id
|
||||
: (gamontAction(g)?.card ?? g.choamWorthless?.cards.find((card) => card.name === 'Trip to Gamont')?.id));
|
||||
const selected = g.choamCashIn.cards.filter(
|
||||
(c) => c.kind === 'worthless' && c.id !== card.id && c.id !== preserve,
|
||||
);
|
||||
@@ -1356,7 +1352,7 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
if (g.biddingEnd && !g.decision)
|
||||
return biddingEndActions(
|
||||
g, level, (card) => technologyCardValue(g, card), gamontAction(g)?.card,
|
||||
g, level, (card) => technologyCardValue(g, card), (gamontAction(g)?.card ?? g.choamWorthless?.cards.find((card) => card.name === 'Trip to Gamont')?.id),
|
||||
);
|
||||
if (g.decision) {
|
||||
if (g.decision.player !== me.id) return [];
|
||||
@@ -1755,38 +1751,24 @@ function policyActions(g: GameView): Action[] {
|
||||
: [{ type: 'decision', decline: true }];
|
||||
}
|
||||
if (d.kind === 'choamStorm') {
|
||||
const card = g.choamWorthless?.cards.find(
|
||||
(c) => c.name === 'Jubba Cloak',
|
||||
);
|
||||
const play = choamPowerBotPlay(g, 'jubba', (card) => technologyCardValue(g, card));
|
||||
const target = [...d.territories].sort(
|
||||
(a, b) => b.amount - a.amount || a.territory.localeCompare(b.territory),
|
||||
)[0];
|
||||
return [
|
||||
card && target
|
||||
? {
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: card.id,
|
||||
territory: target.territory,
|
||||
}
|
||||
: { type: 'decision', decline: true },
|
||||
];
|
||||
const action = play && target ? choamPowerAction(g, play, { territory: target.territory }) : null;
|
||||
return [action ?? { type: 'decision', decline: true }];
|
||||
}
|
||||
if (d.kind === 'choamMovement') {
|
||||
const card = g.choamWorthless?.cards.find((c) => c.name === 'Baliset');
|
||||
return [
|
||||
card && d.mover !== me.ally
|
||||
? { type: 'card', mode: 'choam', card: card.id }
|
||||
: { type: 'decision', decline: true },
|
||||
];
|
||||
const play = choamPowerBotPlay(g, 'baliset', (card) => technologyCardValue(g, card));
|
||||
const action = play && d.mover !== me.ally ? choamPowerAction(g, play) : null;
|
||||
return [action ?? { type: 'decision', decline: true }];
|
||||
}
|
||||
if (d.kind === 'choamMentat')
|
||||
return [gamontAction(g) ?? { type: 'decision', done: true }];
|
||||
if (d.kind === 'choamFreeRevival') {
|
||||
const card = g.choamWorthless?.cards.find((c) => c.name === 'La La La');
|
||||
if (card && d.recipient !== me.ally && level > 0)
|
||||
return [{ type: 'card', mode: 'choam', card: card.id }];
|
||||
return [{ type: 'decision', decline: true }];
|
||||
const play = choamPowerBotPlay(g, 'laLaLa', (card) => technologyCardValue(g, card));
|
||||
const action = play && d.recipient !== me.ally && level > 0 ? choamPowerAction(g, play) : null;
|
||||
return [action ?? { type: 'decision', decline: true }];
|
||||
}
|
||||
if (d.kind === 'choamBattleFunding') {
|
||||
const ally = g.players.find((p) => p.id === me.ally)!;
|
||||
@@ -1805,7 +1787,7 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
if (d.kind === 'choamMarket')
|
||||
return [choamMarketPolicy(
|
||||
g, (card) => technologyCardValue(g, card), gamontAction(g)?.card,
|
||||
g, (card) => technologyCardValue(g, card), (gamontAction(g)?.card ?? g.choamWorthless?.cards.find((card) => card.name === 'Trip to Gamont')?.id),
|
||||
)];
|
||||
if (d.kind === 'choamTradeReply') {
|
||||
const returned = [...(me.hand ?? [])].sort(
|
||||
@@ -2821,14 +2803,14 @@ function policyActions(g: GameView): Action[] {
|
||||
return actions;
|
||||
}
|
||||
if (g.phase === 5) {
|
||||
const kulon = g.choamWorthless?.cards.find((c) => c.name === 'Kulon');
|
||||
const kulon = choamPowerBotPlay(g, 'kulon', (card) => technologyCardValue(g, card));
|
||||
if (
|
||||
kulon &&
|
||||
g.active === me.id &&
|
||||
Object.keys(me.forces).length &&
|
||||
(me.moved ?? 0) < (me.movesAllowed ?? 1)
|
||||
)
|
||||
return [{ type: 'card', mode: 'choam', card: kulon.id }];
|
||||
return [choamPowerAction(g, kulon)!];
|
||||
if (g.active !== me.id) return [];
|
||||
const cardMoves = ornithopterMoves(g);
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { CHOAM_NEXUS_EFFECTS, type NexusChoamEffect } from './nexus-choam';
|
||||
import type { Card } from './cards';
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
export type ChoamPowerEffect = NexusChoamEffect;
|
||||
export type ChoamPowerPlay = {
|
||||
source: 'printed' | 'nexus';
|
||||
effect: ChoamPowerEffect;
|
||||
card: Card;
|
||||
event?: string;
|
||||
blocked: string | null;
|
||||
};
|
||||
export const CHOAM_POWER_NAMES = CHOAM_NEXUS_EFFECTS;
|
||||
export const choamPowerKey = (play: ChoamPowerPlay) =>
|
||||
`${play.source}/${play.effect}/${play.card.id}/${play.event ?? ''}`;
|
||||
|
||||
/** Private server choices keep the physical cost separate from the effect. */
|
||||
export function choamPowerPlays(
|
||||
game: GameView,
|
||||
effect: ChoamPowerEffect,
|
||||
): ChoamPowerPlay[] {
|
||||
if (game.players.find((player) => player.id === game.me)?.faction !== 'choam')
|
||||
return [];
|
||||
const state = game.choamWorthless as
|
||||
| (NonNullable<GameView['choamWorthless']> & { plays?: ChoamPowerPlay[] })
|
||||
| null;
|
||||
if (!state) return [];
|
||||
// Older personalized fixtures retain the printed-only descriptor.
|
||||
const plays =
|
||||
state.plays ??
|
||||
state.cards
|
||||
.filter((card) => card.name === CHOAM_POWER_NAMES[effect])
|
||||
.map((card) => ({
|
||||
source: 'printed' as const,
|
||||
effect,
|
||||
card,
|
||||
blocked: null,
|
||||
}));
|
||||
return plays.filter((play) => play.effect === effect);
|
||||
}
|
||||
|
||||
export function choamPowerAction(
|
||||
game: GameView,
|
||||
requested: ChoamPowerPlay,
|
||||
selection: Record<string, unknown> = {},
|
||||
): Action | null {
|
||||
if (
|
||||
Object.keys(selection).some(
|
||||
(key) =>
|
||||
!['target', 'territory', 'from', 'elite', 'amount'].includes(key),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const owner = game.players.find((player) => player.id === game.me);
|
||||
if (
|
||||
owner?.faction !== 'choam' ||
|
||||
game.status !== 'playing' ||
|
||||
game.response ||
|
||||
game.truthtrance ||
|
||||
game.phaseOpening ||
|
||||
game.automaticContinuationPending ||
|
||||
game.nexusTraitors?.pending ||
|
||||
game.nexusCards?.waiting.length
|
||||
)
|
||||
return null;
|
||||
const play = choamPowerPlays(game, requested.effect).find(
|
||||
(candidate) => choamPowerKey(candidate) === choamPowerKey(requested),
|
||||
);
|
||||
if (!play || play.blocked || play.effect === 'kull') return null;
|
||||
const phases = { kulon: 5, laLaLa: 4, gamont: 8, baliset: 5, jubba: 0 };
|
||||
if (game.phase !== phases[play.effect]) return null;
|
||||
const decisions = {
|
||||
kulon: null,
|
||||
laLaLa: 'choamFreeRevival',
|
||||
gamont: 'choamMentat',
|
||||
baliset: 'choamMovement',
|
||||
jubba: 'choamStorm',
|
||||
};
|
||||
if (
|
||||
game.decision &&
|
||||
(game.decision.kind !== decisions[play.effect] ||
|
||||
game.decision.player !== game.me)
|
||||
)
|
||||
return null;
|
||||
if (play.effect === 'jubba' && game.decision?.kind !== 'choamStorm')
|
||||
return null;
|
||||
if (
|
||||
play.effect === 'kulon' &&
|
||||
(game.active !== game.me || (owner.moved ?? 0) >= (owner.movesAllowed ?? 1))
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
play.source === 'nexus' &&
|
||||
(!play.event || owner.ally || game.nexusCards?.card !== 'choam')
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
play.source === 'printed' &&
|
||||
(play.card.kind !== 'worthless' ||
|
||||
play.card.name !== CHOAM_POWER_NAMES[play.effect])
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
...selection,
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: play.card.id,
|
||||
...(play.source === 'nexus'
|
||||
? { nexus: play.event, effect: play.effect }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Preserve printed powers first, then choose the cheapest legal Nexus fuel. */
|
||||
export function choamPowerBotPlay(
|
||||
game: GameView,
|
||||
effect: ChoamPowerEffect,
|
||||
value: (card: Card) => number,
|
||||
): ChoamPowerPlay | undefined {
|
||||
return choamPowerPlays(game, effect)
|
||||
.filter((play) => choamPowerAction(game, play))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(a.source === 'nexus') - Number(b.source === 'nexus') ||
|
||||
value(a.card) - value(b.card) ||
|
||||
a.card.id.localeCompare(b.card.id),
|
||||
)[0];
|
||||
}
|
||||
+133
-12
@@ -4,6 +4,7 @@ import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFace
|
||||
import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type NexusSuboidReceipt } from './nexus-suboids';
|
||||
import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type NexusAdvisorReceipt } from './nexus-advisors';
|
||||
import { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceipt } from './nexus-sardaukar';
|
||||
import { CHOAM_NEXUS_EFFECTS, createNexusChoam, validateNexusChoam, type NexusChoamEffect, type NexusChoamReceipt } from './nexus-choam';
|
||||
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
|
||||
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
|
||||
import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
|
||||
@@ -1167,6 +1168,7 @@ export type Game = {
|
||||
noFieldEvent?: string;
|
||||
target?: string;
|
||||
revival: boolean;
|
||||
nexusEvent?: string;
|
||||
} | null;
|
||||
pendingChoamBattleIncome?: { owner: string; amount: number } | null;
|
||||
pendingAuditor?: {
|
||||
@@ -1273,6 +1275,8 @@ export type Game = {
|
||||
nexusSardaukarHistory?: {receipt: NexusSardaukarReceipt; stage: 'pending' | 'active' | 'canceled'; parent: string; signature: string;
|
||||
casualties?: {forces: CombatForces; dial: number; support: number; options: Casualties[]; outcome: 'pending' | 'complete'}}[];
|
||||
nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'};
|
||||
nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[];
|
||||
nexusChoamLast?: {event: string; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'};
|
||||
nexusTraitorPending?: string | null;
|
||||
nexusTraitorParent?: { event: string; signature: string } | null;
|
||||
/** Null/absent disables the module; custody is installed at force placement. */
|
||||
@@ -12078,6 +12082,7 @@ function marketGholaIntegrity(g: Game) {
|
||||
nexusSuboidIntegrity(g);
|
||||
nexusAdvisorIntegrity(g);
|
||||
nexusSardaukarIntegrity(g);
|
||||
nexusChoamIntegrity(g);
|
||||
traitorDeclarationIntegrity(g);
|
||||
nexusInspectionIntegrity(g);
|
||||
homeworldHistoryIntegrity(g);
|
||||
@@ -14546,9 +14551,10 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
}
|
||||
if (choamCancellation) {
|
||||
const { declaration, storm, revival } = choamCancellation;
|
||||
finishNexusChoam(g,g.pendingChoamWorthless!,'canceled');
|
||||
g.pendingChoamWorthless = declaration.pendingChoamWorthless;
|
||||
g.choamWorthlessBlocked = declaration.blocked;
|
||||
log(g, 'CHOAM’s Worthless card effect was prevented for this phase.');
|
||||
log(g, 'CHOAM’s Worthless card effect was prevented for this phase. The declared Treachery card is retained. Any Nexus card spent on the declaration remains spent.');
|
||||
if (storm) commitChoamStormOffer(g, storm);
|
||||
else if (revival) offerRevivalStop(g, revival);
|
||||
else if (declaration.resume.kind === 'movement') resumeChoamMovement(g);
|
||||
@@ -14801,6 +14807,7 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
choamStormOptions(g).some((t) => t.territory === pending.location))
|
||||
) {
|
||||
discard(g, choam, pending.card);
|
||||
finishNexusChoam(g,pending,'complete');
|
||||
if (pending.effect === 'kulon') {
|
||||
g.choamMovement = {
|
||||
turn: g.turn,
|
||||
@@ -14872,11 +14879,13 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
);
|
||||
}
|
||||
}
|
||||
} else
|
||||
} else {
|
||||
finishNexusChoam(g,pending,'fizzled');
|
||||
log(
|
||||
g,
|
||||
'The declared card or force is no longer available; no Worthless effect occurs.',
|
||||
);
|
||||
}
|
||||
if (pending.storm && g.stormResolution) offerChoamStorm(g);
|
||||
if (pending.revival && g.pendingRevival) offerRevivalStop(g);
|
||||
if (pending.movement && g.pendingChoamMove) resumeChoamMovement(g);
|
||||
@@ -16106,6 +16115,104 @@ function choamWorthlessBlocked(g: Game, card: string) {
|
||||
g.choamWorthlessBlocked.cards.includes(card)
|
||||
);
|
||||
}
|
||||
function nexusChoamSignature(record: NonNullable<Game['nexusChoamHistory']>[number]) {
|
||||
return JSON.stringify([record.receipt.signature, record.stage, record.frame, record.parent]);
|
||||
}
|
||||
function nexusChoamParent(g: Game, pending: NonNullable<Game['pendingChoamWorthless']>) {
|
||||
return JSON.stringify({
|
||||
movement: pending.movement ? g.pendingChoamMove : undefined,
|
||||
revival: pending.revival ? g.pendingRevival : undefined,
|
||||
storm: pending.storm ? g.stormResolution : undefined,
|
||||
mentat: pending.mentat ? g.choamMentatPending : undefined,
|
||||
});
|
||||
}
|
||||
function nexusChoamIntegrity(g: Game) {
|
||||
const pending = g.pendingChoamWorthless;
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null];
|
||||
const responses = contexts.flatMap(context => {
|
||||
const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
|
||||
return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null];
|
||||
}).filter(response => response?.kind === 'choamWorthless');
|
||||
const history = g.nexusChoamHistory;
|
||||
if (history === undefined) {
|
||||
requireRule(!g.nexusChoamLast && !pending?.nexusEvent, 'CHOAM Cunning has lost its original saved history.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
|
||||
'CHOAM Cunning history requires its original Nexus module.');
|
||||
const events = new Set<string>();
|
||||
for (const [index, record] of history.entries()) {
|
||||
requireRule(record && typeof record === 'object' &&
|
||||
Object.keys(record).sort().join(',') === 'frame,parent,receipt,signature,stage' &&
|
||||
['pending','canceled','complete','fizzled'].includes(record.stage) && typeof record.frame === 'string' && typeof record.parent === 'string' &&
|
||||
record.signature === nexusChoamSignature(record) && !events.has(record.receipt?.event) &&
|
||||
(record.stage !== 'pending' || index === history.length - 1),
|
||||
'CHOAM Cunning has a changed or duplicated outcome record.');
|
||||
nexusRule(() => validateNexusChoam(g,record.receipt));
|
||||
events.add(record.receipt.event);
|
||||
}
|
||||
const last = history.at(-1)!;
|
||||
requireRule(JSON.stringify(g.nexusChoamLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}),
|
||||
'CHOAM Cunning has lost its latest completed or pending outcome.');
|
||||
requireRule((last.stage === 'pending') === !!pending?.nexusEvent,
|
||||
'CHOAM Cunning has lost or reopened its declared effect.');
|
||||
if (last.stage === 'pending') {
|
||||
requireRule(pending && pending.nexusEvent === last.receipt.event &&
|
||||
pending.owner === last.receipt.owner && pending.card === last.receipt.card && pending.effect === last.receipt.effect &&
|
||||
g.turn === last.receipt.turn && g.phase === last.receipt.phase &&
|
||||
JSON.stringify(pending) === last.frame && nexusChoamParent(g,pending) === last.parent && responses.length > 0 && g.nexusCards.cards.discard.includes('choam'),
|
||||
'CHOAM Cunning no longer matches its original card, selected effect or response.');
|
||||
for (const response of responses) {
|
||||
try { quoteChoamWorthlessCancellation(g,response!); }
|
||||
catch(error) {
|
||||
if (error instanceof ChoamWorthlessCancellationError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function recordNexusChoam(g: Game, p: Player, card: Card, effect: NexusChoamEffect) {
|
||||
const receipt = nexusRule(() => createNexusChoam(g,p.id,g.phase,card.id,effect));
|
||||
const record = {receipt,stage:'pending' as const,frame:JSON.stringify(g.pendingChoamWorthless),parent:nexusChoamParent(g,g.pendingChoamWorthless!),signature:''};
|
||||
record.signature = nexusChoamSignature(record);
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
|
||||
(g.nexusChoamHistory ??= []).push(record);
|
||||
g.nexusChoamLast = {event:receipt.event,stage:record.stage};
|
||||
}
|
||||
function finishNexusChoam(g: Game, pending: NonNullable<Game['pendingChoamWorthless']>, stage: 'canceled' | 'complete' | 'fizzled') {
|
||||
if (!pending.nexusEvent) return;
|
||||
const record = g.nexusChoamHistory?.find(record => record.receipt.event === pending.nexusEvent);
|
||||
requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(pending) && record.parent === nexusChoamParent(g,pending),
|
||||
'CHOAM Cunning has lost its original declared effect.');
|
||||
record.stage = stage; record.signature = nexusChoamSignature(record);
|
||||
g.nexusChoamLast = {event:record.receipt.event,stage};
|
||||
}
|
||||
function choamPowerPlays(g: Game, p: Player) {
|
||||
const printed = p.hand.filter(card => card.kind === 'worthless' &&
|
||||
Object.values(CHOAM_NEXUS_EFFECTS).includes(card.name as typeof CHOAM_NEXUS_EFFECTS[NexusChoamEffect]) && !choamWorthlessBlocked(g,card.id))
|
||||
.map(card => ({source:'printed' as const,card,effect:Object.entries(CHOAM_NEXUS_EFFECTS).find(([,name]) => name === card.name)![0] as NexusChoamEffect,
|
||||
blocked:card.name === 'Kull Wahad' ? 'Kull Wahad’s reaction and discard sequence is still being implemented.' : null}));
|
||||
if (p.faction !== 'choam' || p.ally || g.nexusCards?.cards?.hands[p.id] !== 'choam') return printed;
|
||||
return [...printed, ...p.hand.flatMap(card => (Object.keys(CHOAM_NEXUS_EFFECTS) as NexusChoamEffect[]).map(effect => {
|
||||
let blocked: string | null = null;
|
||||
if (effect === 'kull') blocked = 'Kull Wahad’s reaction and discard sequence is still being implemented.';
|
||||
else if (g.status !== 'playing' || g.truthtrance || g.response || g.phaseOpening || g.pendingKarama || g.pendingNullentropy || g.pendingTreacheryDiscard || g.choamMarket)
|
||||
blocked = 'Finish the current interaction before declaring CHOAM Cunning.';
|
||||
else if (choamWorthlessBlocked(g,card.id)) blocked = 'This card’s special-effect use is blocked for this phase.';
|
||||
else if (giftReserved(g,p.id,card.id)) blocked = 'This physical card is reserved for a pending gift.';
|
||||
else if (({kulon:5,laLaLa:4,gamont:8,baliset:5,jubba:0} as const)[effect] !== g.phase)
|
||||
blocked = 'Use this special effect in its normal phase.';
|
||||
else if (g.decision && !(g.decision.player === p.id && ['choamStorm','choamFreeRevival','choamMovement','choamMentat'].includes(g.decision.kind)))
|
||||
blocked = 'Finish the current decision before declaring this effect.';
|
||||
else if (effect === 'kulon' && (g.active !== p.id || p.moved >= movesAllowed(g,p))) blocked = 'Use Kulon before an available move on your own turn.';
|
||||
else if (effect === 'kulon' && g.ornithopter?.mode === 'range3' && g.ornithopter.player === p.id) blocked = 'Kulon combined with fixed Ornithopter range awaits a ruling.';
|
||||
else if (effect === 'jubba' && !(g.decision?.kind === 'choamStorm' && g.decision.player === p.id)) blocked = 'Use Jubba Cloak in your moving-storm response.';
|
||||
return {source:'nexus' as const,card,effect,event:JSON.stringify(['nexusChoam',g.turn,g.phase,p.id,card.id,effect]),blocked};
|
||||
}))];
|
||||
}
|
||||
function gamontAvailable(
|
||||
g: Game,
|
||||
target: string,
|
||||
@@ -16154,19 +16261,28 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
|
||||
(!g.pendingRevival || reactive),
|
||||
'CHOAM must finish the current decision before playing this Worthless effect.',
|
||||
);
|
||||
const card = p.hand.find(
|
||||
(c) => c.id === action.card && c.kind === 'worthless',
|
||||
);
|
||||
const nexus = action.nexus !== undefined;
|
||||
const card = p.hand.find((c) => c.id === action.card && (nexus || c.kind === 'worthless'));
|
||||
requireRule(
|
||||
card && !choamWorthlessBlocked(g, card.id),
|
||||
'Choose an owned Worthless card whose effect is not blocked this phase.',
|
||||
);
|
||||
let effectName = card.name;
|
||||
if (nexus) {
|
||||
requireRule(Object.keys(action).every(key => ['type','mode','card','nexus','effect','target','territory','amount','from','elite'].includes(key)),
|
||||
'Choose one physical card, special effect and its current target.');
|
||||
const play = choamPowerPlays(g,p).find(play => play.source === 'nexus' && play.card.id === card.id &&
|
||||
play.effect === action.effect && 'event' in play && play.event === action.nexus);
|
||||
requireRule(play && !play.blocked, play?.blocked ?? 'Choose your current CHOAM Nexus Cunning effect.');
|
||||
effectName = CHOAM_NEXUS_EFFECTS[play.effect];
|
||||
}
|
||||
requireRule(!giftReserved(g,p.id,card.id), 'This physical card is reserved for a pending gift.');
|
||||
let effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba';
|
||||
let target: string | undefined;
|
||||
let key: string | undefined;
|
||||
let elite: number | undefined;
|
||||
let noFieldEvent: string | undefined;
|
||||
if (card.name === 'Kulon') {
|
||||
if (effectName === 'Kulon') {
|
||||
requireRule(
|
||||
g.ornithopter?.mode !== 'range3' || g.ornithopter.player !== p.id,
|
||||
'Kulon combined with fixed Ornithopter range awaits a ruling.',
|
||||
@@ -16176,7 +16292,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
|
||||
'Use Kulon on your movement turn before an available move.',
|
||||
);
|
||||
effect = 'kulon';
|
||||
} else if (card.name === 'Jubba Cloak') {
|
||||
} else if (effectName === 'Jubba Cloak') {
|
||||
requireRule(
|
||||
g.phase === 0 && storming && g.stormResolution,
|
||||
'Use Jubba Cloak when CHOAM responds to the moving storm.',
|
||||
@@ -16192,7 +16308,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
|
||||
);
|
||||
target = p.id;
|
||||
effect = 'jubba';
|
||||
} else if (card.name === 'Baliset') {
|
||||
} else if (effectName === 'Baliset') {
|
||||
requireRule(
|
||||
g.phase === 5,
|
||||
'Baliset is played during Shipment and Movement.',
|
||||
@@ -16220,7 +16336,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
|
||||
'That player and territory are already restricted.',
|
||||
);
|
||||
effect = 'baliset';
|
||||
} else if (card.name === 'Trip to Gamont') {
|
||||
} else if (effectName === 'Trip to Gamont') {
|
||||
requireRule(
|
||||
g.phase === 8 && (action.amount === undefined || action.amount === 1),
|
||||
'Trip to Gamont returns exactly one force during Mentat Pause.',
|
||||
@@ -16261,7 +16377,7 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
|
||||
effect = 'gamont';
|
||||
} else {
|
||||
requireRule(
|
||||
card.name === 'La La La' && g.phase === 4,
|
||||
effectName === 'La La La' && g.phase === 4,
|
||||
'This Worthless effect is not available in this phase.',
|
||||
);
|
||||
effect = 'laLaLa';
|
||||
@@ -16287,7 +16403,9 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
|
||||
elite,
|
||||
mentat,
|
||||
...(noFieldEvent ? { noFieldEvent } : {}),
|
||||
...(nexus ? {nexusEvent:action.nexus as string} : {}),
|
||||
};
|
||||
if (nexus) recordNexusChoam(g,p,card,effect);
|
||||
if (reactive || mentat || moving || storming) g.decision = null;
|
||||
g.ready = [];
|
||||
g.response = {
|
||||
@@ -16312,10 +16430,12 @@ function playChoamWorthless(g: Game, p: Player, action: Action) {
|
||||
...(elite !== undefined ? { elite } : {}),
|
||||
}
|
||||
: {}),
|
||||
intent: card.name,
|
||||
intent: effectName,
|
||||
passed: [],
|
||||
};
|
||||
log(g, `${p.name} declared ${card.name} for its special effect.`);
|
||||
log(g, nexus
|
||||
? `${p.name} spent CHOAM Nexus Cunning and declared ${effectName}. Its chosen Treachery card stays private and is discarded only if the effect occurs; Karama may prevent this native advantage.`
|
||||
: `${p.name} declared ${effectName} for its special effect.`);
|
||||
}
|
||||
/** Internal normalized command; never accept this object directly from a route. */
|
||||
export type SpecialKaramaIntent = {
|
||||
@@ -20944,6 +21064,7 @@ export function viewGame(state: Game, id: string) {
|
||||
].includes(c.name) &&
|
||||
!choamWorthlessBlocked(g, c.id),
|
||||
),
|
||||
plays: choamPowerPlays(g,me),
|
||||
}
|
||||
: null,
|
||||
balisetRestrictions:
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { FactionId } from './catalog';
|
||||
|
||||
export const CHOAM_NEXUS_EFFECTS = Object.freeze({
|
||||
kulon: 'Kulon',
|
||||
laLaLa: 'La La La',
|
||||
gamont: 'Trip to Gamont',
|
||||
baliset: 'Baliset',
|
||||
jubba: 'Jubba Cloak',
|
||||
kull: 'Kull Wahad',
|
||||
} as const);
|
||||
export type NexusChoamEffect = keyof typeof CHOAM_NEXUS_EFFECTS;
|
||||
export type NexusChoamContext = {
|
||||
turn: number;
|
||||
players: readonly { id: string; faction: FactionId }[];
|
||||
};
|
||||
export type NexusChoamReceipt = {
|
||||
version: 1;
|
||||
event: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
owner: string;
|
||||
card: string;
|
||||
effect: NexusChoamEffect;
|
||||
roster: { id: string; faction: FactionId }[];
|
||||
signature: string;
|
||||
};
|
||||
function requireChoam(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
function identifier(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.trim().length > 0 &&
|
||||
!['__proto__', 'constructor', 'prototype'].includes(value)
|
||||
);
|
||||
}
|
||||
function roster(context: NexusChoamContext) {
|
||||
requireChoam(
|
||||
Number.isSafeInteger(context.turn) &&
|
||||
context.turn > 0 &&
|
||||
Array.isArray(context.players) &&
|
||||
context.players.length > 0 &&
|
||||
context.players.every(
|
||||
(p) => p && identifier(p.id) && identifier(p.faction),
|
||||
) &&
|
||||
new Set(context.players.map((p) => p.id)).size ===
|
||||
context.players.length &&
|
||||
new Set(context.players.map((p) => p.faction)).size ===
|
||||
context.players.length,
|
||||
'CHOAM Nexus needs its original faction roster and turn.',
|
||||
);
|
||||
return context.players
|
||||
.map(({ id, faction }) => ({ id, faction }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
function phaseMatches(effect: NexusChoamEffect, phase: number): boolean {
|
||||
if (!Number.isSafeInteger(phase) || phase < 0 || phase > 8) return false;
|
||||
switch (effect) {
|
||||
case 'kulon':
|
||||
case 'baliset':
|
||||
return phase === 5;
|
||||
case 'laLaLa':
|
||||
return phase === 4;
|
||||
case 'gamont':
|
||||
return phase === 8;
|
||||
case 'jubba':
|
||||
return phase === 0;
|
||||
// This describes printed timing only; the engine must separately authorize
|
||||
// a real Karama attempt and retains Kull's unresolved implementation gate.
|
||||
case 'kull':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function signature(receipt: NexusChoamReceipt): string {
|
||||
return JSON.stringify([
|
||||
receipt.version,
|
||||
receipt.event,
|
||||
receipt.turn,
|
||||
receipt.phase,
|
||||
receipt.owner,
|
||||
receipt.card,
|
||||
receipt.effect,
|
||||
receipt.roster.map((p) => [p.id, p.faction]),
|
||||
]);
|
||||
}
|
||||
/** Historical evidence is independent of later card custody or alliances.
|
||||
* Current eligibility, canonical card inventory and progress belong to the engine. */
|
||||
export function validateNexusChoam(
|
||||
context: NexusChoamContext,
|
||||
receipt: NexusChoamReceipt,
|
||||
): void {
|
||||
const seats = roster(context);
|
||||
requireChoam(
|
||||
receipt &&
|
||||
typeof receipt === 'object' &&
|
||||
!Array.isArray(receipt) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(receipt)) &&
|
||||
Object.keys(receipt).sort().join(',') ===
|
||||
'card,effect,event,owner,phase,roster,signature,turn,version' &&
|
||||
receipt.version === 1 &&
|
||||
Number.isSafeInteger(receipt.turn) &&
|
||||
receipt.turn > 0 &&
|
||||
receipt.turn <= context.turn &&
|
||||
identifier(receipt.card) &&
|
||||
Object.hasOwn(CHOAM_NEXUS_EFFECTS, receipt.effect) &&
|
||||
phaseMatches(receipt.effect, receipt.phase) &&
|
||||
seats.some((p) => p.id === receipt.owner && p.faction === 'choam') &&
|
||||
receipt.event ===
|
||||
JSON.stringify([
|
||||
'nexusChoam',
|
||||
receipt.turn,
|
||||
receipt.phase,
|
||||
receipt.owner,
|
||||
receipt.card,
|
||||
receipt.effect,
|
||||
]) &&
|
||||
JSON.stringify(receipt.roster) === JSON.stringify(seats) &&
|
||||
receipt.signature === signature(receipt),
|
||||
'CHOAM Nexus has lost its original card, chosen effect, timing or owner.',
|
||||
);
|
||||
}
|
||||
export function createNexusChoam(
|
||||
context: NexusChoamContext,
|
||||
owner: string,
|
||||
phase: number,
|
||||
card: string,
|
||||
effect: NexusChoamEffect,
|
||||
): NexusChoamReceipt {
|
||||
const receipt: NexusChoamReceipt = {
|
||||
version: 1,
|
||||
event: JSON.stringify([
|
||||
'nexusChoam',
|
||||
context.turn,
|
||||
phase,
|
||||
owner,
|
||||
card,
|
||||
effect,
|
||||
]),
|
||||
turn: context.turn,
|
||||
phase,
|
||||
owner,
|
||||
card,
|
||||
effect,
|
||||
roster: roster(context),
|
||||
signature: '',
|
||||
};
|
||||
receipt.signature = signature(receipt);
|
||||
validateNexusChoam(context, receipt);
|
||||
return receipt;
|
||||
}
|
||||
+8
-6
@@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '),
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning are integrated. Other effects and complete module play remain in development.',
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit, Emperor and five CHOAM Cunning effects are integrated. Other effects and complete module play remain in development.',
|
||||
steps: [
|
||||
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
|
||||
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
|
||||
@@ -3364,15 +3364,17 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.',
|
||||
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
|
||||
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups and other Emperor Nexus modes remain unavailable.',
|
||||
'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.',
|
||||
'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.',
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion and temporary Emperor Sardaukar. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection and temporary Sardaukar with native Karama responses. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection with native Karama responses. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
Reference in new issue
Block a user