Add opt-in printed Kull interception and saved counter continuation

This commit is contained in:
Quintine committed 2026-09-30 21:08:33 +10:00
1 parent 3285172eec
commit aeae152370
32 files changed
+2487 -146

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import type { Action, GameView } from './engine';
/** Legal participation uses only this seat's projected offer and public allies. */
export function botChoamKullActions(g: GameView): Action[] {
const offer = g.kullReaction;
if (g.status !== 'playing' || !offer?.event || offer.player !== g.me ||
!offer.canDecline) return [];
const own = g.players.find((player) => player.id === g.me);
if (!(own?.bot ?? own?.autopilot)) return [];
const card = !offer.blocked && offer.target !== own.ally
? offer.cards[0]
: undefined;
return card
? [{ type: 'kullDecision', event: offer.event, card: card.id }]
: [{ type: 'kullDecision', event: offer.event, decline: true }];
}
/** Kull's counter is an ordinary response: never reuse the reserved attempt or
* propose voluntary actions while the public prevention is being settled. */
export function botChoamKullCounterActions(g: GameView): Action[] {
if (g.status !== 'playing' || !g.kullCounterEvent || !g.response ||
g.responseControls?.hasPassed) return [];
const own = g.players.find((player) => player.id === g.me);
if (!(own?.bot ?? own?.autopilot)) return [];
const card = own.hand?.find((candidate) =>
g.responseControls?.cancelCards.includes(candidate.id),
);
if (!card) return [];
const protect = g.response.kind === 'choamWorthless' &&
g.response.intent === 'Kull Wahad' &&
(g.response.recipient === own.id || g.response.recipient === own.ally);
return protect
? [{ type: 'card', mode: 'cancel', card: card.id }]
: [{ type: 'passResponse' }];
}
+7 -1
View File
@@ -102,6 +102,7 @@ import { richeseCardDefinition } from './richese-cards';
import { presenceAt } from './force-presence';
import { botArrivalBlock, botWormRideArrivalBlock } from './bot-arrival';
import { botSemutaActions } from './bot-semuta';
import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kull';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
@@ -1275,6 +1276,7 @@ function policyActions(g: GameView): Action[] {
}
if (
!g.battle &&
!g.karamaBlocked &&
g.choamCashIn?.karamas.length &&
(me.spice ?? 0) < (level === 0 ? 2 : 5)
) {
@@ -1321,6 +1323,7 @@ function policyActions(g: GameView): Action[] {
const sapho = saphoAction(g);
if (sapho) return [sapho];
const specialCard =
!g.karamaBlocked &&
!g.choamMarket &&
(!g.revival.pending ||
(me.faction === 'tleilaxu' && g.decision?.kind === 'revivalStop')) &&
@@ -2638,7 +2641,7 @@ function policyActions(g: GameView): Action[] {
}
if (d.kind === 'auctionPayment') {
const karama = me.hand?.find((c) =>
canUseAsKaramaRole(g, me, c),
!g.karamaBlocked && canUseAsKaramaRole(g, me, c),
);
const use =
!!karama &&
@@ -3823,6 +3826,9 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] {
if (g.kullReaction) return botChoamKullActions(g);
if (g.kullCounterEvent)
return botChoamKullCounterActions(g);
if (g.semutaReaction) return botSemutaActions(g);
const fremenRevival = g.nexusFremenRevival;
if (fremenRevival && !fremenRevival.blocked && fremenRevival.eliteOptions.length) {
+167
View File
@@ -0,0 +1,167 @@
import type { Card } from './cards';
export type KullPhaseRestriction = Readonly<{
player: string;
turn: number;
phase: number;
}>;
export type KullContext = Readonly<{
turn: number;
phase: number;
players: readonly Readonly<{ id: string; faction: string }>[];
}>;
/** Only intrinsic attempt data; the engine owns the typed intent and continuation. */
export type KullAttemptStamp = Readonly<{
event: string;
player: string;
owner: string;
card: string;
turn: number;
phase: number;
form: 'printed' | 'substitution';
stage: 'offer' | 'counter';
}>;
function identifier(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].includes(value);
}
function stamp(turn: unknown, phase: unknown): boolean {
return Number.isSafeInteger(turn) && (turn as number) > 0 &&
Number.isSafeInteger(phase) && (phase as number) >= 0 && (phase as number) <= 8;
}
function plainRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value));
}
function validRestriction(value: unknown): value is KullPhaseRestriction {
return plainRecord(value) &&
Object.keys(value).sort().join(',') === 'phase,player,turn' &&
identifier(value.player) && stamp(value.turn, value.phase);
}
function validContext(context: KullContext): boolean {
return !!context && stamp(context.turn, context.phase) &&
Array.isArray(context.players) && context.players.length > 0 &&
context.players.every(player => player && identifier(player.id) && identifier(player.faction)) &&
new Set(context.players.map(player => player.id)).size === context.players.length;
}
/** Expiry is a turn AND phase comparison, not a ban on physical ownership. */
export function activeKullRestrictions(
restrictions: readonly KullPhaseRestriction[] | undefined,
turn: number,
phase: number,
): KullPhaseRestriction[] {
if (!stamp(turn, phase)) return [];
return (restrictions ?? [])
.filter(restriction => validRestriction(restriction) &&
restriction.turn === turn && restriction.phase === phase)
.map(({ player, turn: savedTurn, phase: savedPhase }) =>
({ player, turn: savedTurn, phase: savedPhase }));
}
export function kullBlocksKarama(
restrictions: readonly KullPhaseRestriction[] | undefined,
turn: number,
phase: number,
player: string,
): boolean {
return identifier(player) && stamp(turn, phase) &&
!!restrictions?.some(restriction => validRestriction(restriction) &&
restriction.player === player && restriction.turn === turn && restriction.phase === phase);
}
/** Saved history may expire, but malformed, duplicate or future bans are not history. */
export function validateKullPhaseRestrictions(
context: KullContext,
restrictions: unknown,
): asserts restrictions is readonly KullPhaseRestriction[] {
if (!validContext(context) || !Array.isArray(restrictions))
throw new Error('Kull Wahad has lost its phase restriction history.');
const seen = new Set<string>();
for (const restriction of restrictions) {
if (!validRestriction(restriction) ||
!context.players.some(player => player.id === restriction.player) ||
restriction.turn > context.turn ||
(restriction.turn === context.turn && restriction.phase > context.phase))
throw new Error('Kull Wahad has lost its phase restriction history.');
const key = JSON.stringify([restriction.player, restriction.turn, restriction.phase]);
if (seen.has(key)) throw new Error('Kull Wahad has duplicate phase restrictions.');
seen.add(key);
}
}
/** Extra frame fields belong to the engine's intent/signature validator, not this stamp. */
export function validateKullAttemptStamp(
context: KullContext,
attempt: KullAttemptStamp,
physicalCards: readonly Card[],
): void {
if (!validContext(context) || !plainRecord(attempt) ||
!identifier(attempt.event) || !identifier(attempt.player) ||
!identifier(attempt.owner) || !identifier(attempt.card) ||
attempt.player === attempt.owner ||
!context.players.some(player => player.id === attempt.player && player.faction === 'choam') ||
!context.players.some(player => player.id === attempt.owner) ||
!stamp(attempt.turn, attempt.phase) ||
attempt.turn !== context.turn || attempt.phase !== context.phase ||
(attempt.stage !== 'offer' && attempt.stage !== 'counter') ||
(attempt.form !== 'printed' && attempt.form !== 'substitution') ||
!Array.isArray(physicalCards) ||
!uniquePhysicalCard(physicalCards, attempt.card))
throw new Error('Kull Wahad has lost its original attempt, phase or physical card.');
}
/** The reserved original cannot pay for a second activation, even after JSON reload. */
export function distinctKullCounter(cardId: string, reservedCardId: string): boolean {
return identifier(cardId) && identifier(reservedCardId) && cardId !== reservedCardId;
}
/** Missing or duplicated custody is not a usable physical identity. */
function uniquePhysicalCard(cards: readonly Card[], id: string): Card | undefined {
let found: Card | undefined;
for (const candidate of cards) {
if (candidate?.id !== id) continue;
if (found) return undefined;
found = candidate;
}
return found;
}
/** Both the hand and the supplied conserved physical inventory must contain one identity. */
function uniquelyHeld(card: Card, held: readonly Card[], physicalCards: readonly Card[]): boolean {
if (!identifier(card.id) || !uniquePhysicalCard(held, card.id)) return false;
const canonical = uniquePhysicalCard(physicalCards, card.id);
return !!canonical && card.name === canonical.name &&
card.kind === canonical.kind && card.effect === canonical.effect;
}
/** Native printed Kull only. Nexus fuel is a separate, engine-authorized cost path. */
export function kullNativeCostCards(
held: readonly Card[],
physicalCards: readonly Card[],
reservedIds: readonly string[] = [],
): Card[] {
return held.filter(card => card?.id === 'ix-kull-wahad' &&
card.name === 'Kull Wahad' && card.kind === 'worthless' && card.effect === undefined &&
!reservedIds.includes(card.id) && uniquelyHeld(card, held, physicalCards));
}
/** The caller supplies effective eligibility (BG, Shrine, phase bans and live reservations).
* This helper never reads a Game or infers activatability from a printed card alone. */
export function kullCounterCards(
held: readonly Card[],
physicalCards: readonly Card[],
reservedCardId: string,
isEffectivelyEligible: (card: Card) => boolean,
reservedIds: readonly string[] = [],
): Card[] {
return held.filter(card => card && distinctKullCounter(card.id, reservedCardId) &&
!reservedIds.includes(card.id) && uniquelyHeld(card, held, physicalCards) &&
isEffectivelyEligible(card));
}
+10 -1
View File
@@ -42,6 +42,7 @@ const effects = {
gamont: { phase: 8, name: 'Trip to Gamont' },
baliset: { phase: 5, name: 'Baliset' },
jubba: { phase: 0, name: 'Jubba Cloak' },
kull: { phase: null, name: 'Kull Wahad' },
} as const;
const id = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0;
@@ -84,7 +85,8 @@ export function quoteChoamWorthlessCancellation(
'The denied Worthless effect needs its original CHOAM declaration.',
);
requireDeclaration(
g.phase === effects[p.effect].phase &&
whole(g.phase) && g.phase <= 8 &&
(p.effect === 'kull' || g.phase === effects[p.effect].phase) &&
response.intent === effects[p.effect].name,
'The Worthless effect does not match its phase and declared printed name.',
);
@@ -148,6 +150,13 @@ export function quoteChoamWorthlessCancellation(
p.elite === 0)),
'Denied Gamont needs its original opposing sector and selected force type.',
);
} else if (p.effect === 'kull') {
requireDeclaration(
target && target.id !== p.owner &&
p.location === undefined && response.location === undefined &&
p.elite === undefined && !flags.some(flag => flag === true),
'Denied Kull needs the original Karama actor and no unrelated continuation.',
);
} else {
const loc = key(response.location);
requireDeclaration(
+289 -10
View File
@@ -464,6 +464,7 @@ import {
import { reserveExtortion, collectExtortion, answerExtortion, validateExtortionState, type ExtortionState } from './moritani-extortion';
import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions';
import { cashInCards } from './choam-karama';
import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull';
import { saleOptions, quoteSale, type ChoamMarket } from './choam-market';
import { highKaitainDiscardsAvailable, quoteKaitainDiscards, homeworldWorthlessSaleBlock } from './homeworld-card-economy';
import { biddingEndError, biddingEndQuiet, biddingEndPubliclyEmpty, type BiddingEnd } from './bidding-end';
@@ -1078,6 +1079,24 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
/** Explicit development profile; never enabled by a public start or player action. */
kullPreview?: boolean;
kullSequence?: number;
kullRestrictions?: KullPhaseRestriction[];
pendingKull?: {
event: string;
player: string;
owner: string;
card: string;
turn: number;
phase: number;
form: 'printed' | 'substitution';
stage: 'offer' | 'counter';
intent: { kind: 'ordinary'; use: KaramaUse } | { kind: 'special'; use: SpecialKaramaIntent };
resume: NonNullable<Game['pendingNullentropy']>['resume'];
worthless: Game['pendingChoamWorthless'];
signature: string;
} | null;
stormMovementSource?: StormMovementSource;
ecologicalStorm?: DiscoveryStorm;
discoveryEnabled?: boolean;
@@ -1576,7 +1595,7 @@ export type Game = {
pendingChoamWorthless?: {
owner: string;
card: string;
effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba';
effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba' | 'kull';
storm?: boolean;
movement?: boolean;
location?: string;
@@ -8142,6 +8161,17 @@ export function initializeSemutaGameForAudit(state: Game): Game {
g.semutaPreview = true;
return g;
}
/** Opt-in printed Kull gameplay with the physical Ix deck, not a public release gate. */
export function initializeKullGameForAudit(state: Game): Game {
requireRule(!state.kullPreview && !state.pendingKull && !state.kullRestrictions &&
state.expansions.length === 2 && state.expansions.includes('choam') &&
state.expansions.includes('ix') && state.players.some(p => p.faction === 'choam') &&
state.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base'),
'Kull preview needs a fresh CHOAM and classic-faction lobby with CHOAM and Ix decks.');
const g = initializeFactionExpansionsGameForAudit(state);
g.kullPreview = true;
return g;
}
/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */
export function initializeDiscoveryGameForAudit(state: Game): Game {
requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker,
@@ -10327,6 +10357,11 @@ function nextAuction(g: Game) {
}
/** Physical custody only: promise feasibility remains conservatively checked at commit. */
function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null {
if (g.kullPreview && kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id))
return 'Kull Wahad prevents your Karama activations for this phase.';
if (g.pendingKull?.stage === 'counter' &&
(g.pendingKull.card === card.id || g.pendingChoamWorthless?.card === card.id))
return 'This physical card is reserved for the interrupted Kull transaction.';
if (g.pendingIxRicheseTechnology?.card.id === card.id)
return 'This card is reserved for the pending Richese lot and Ixian choice.';
if (g.battle?.lateDefense?.[p.id] === card.id)
@@ -10364,6 +10399,17 @@ function responseCancelCards(
response: ResponseWindow,
): string[] {
if (p.id === response.owner) return [];
if (g.pendingKull?.stage === 'counter')
return kullCounterCards(p.hand, physicalTreacheryCards(g), g.pendingKull.card,
card => {
if (!canUseAsKaramaRole(g, p, card) || karamaSpendingBlock(g, p, card)) return false;
try {
return assertKaramaPromiseFeasibility(g, p, { kind: 'cancel', response }, card);
} catch (error) {
if (error instanceof RuleError) return false;
throw error;
}
}).map(card => card.id);
// Do not simulate cancellation here: it can execute RNG and continuations.
// A physically spendable card whose Truthtrance feasibility is not proven
// remains a possible choice; the authoritative action still checks promises.
@@ -10384,6 +10430,7 @@ function savedKaramaContexts(g: Game) {
g.pendingRichesePurchaseIncome?.resume,
g.summonedWorm?.resume,
continuation && 'resume' in continuation ? continuation.resume : null,
g.pendingKull?.resume,
].flatMap((context) =>
context?.pendingKarama
? [
@@ -10436,6 +10483,8 @@ function karamaSourceGame(
g: Game,
pending: NonNullable<Game['pendingKarama']>,
) {
if (g.pendingKull && controlsContainKarama(g, g.pendingKull.resume, pending))
return kullSource(g, g.pendingKull);
const worm = g.summonedWorm;
return worm && controlsContainKarama(g, worm.resume, pending)
? { ...g, ...worm.resume, summonedWorm: null }
@@ -11106,11 +11155,189 @@ function assertKaramaPromiseFeasibility(
);
return true;
}
function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) {
type KullFrame = NonNullable<Game['pendingKull']>;
function kullCostPromisesAllow(g: Game, p: Player, card: Card): boolean {
const battlePromises = g.battle && !g.battle.revealed && !g.battle.plans[p.id]
? (g.battle.truthPromises ?? []).filter(promise => promise.player === p.id && !promise.released)
: [];
const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn);
if (!battlePromises.length && !shipmentPromises.length) return true;
const projected = structuredClone(g.pendingKull ? kullSource(g, g.pendingKull) : g);
const owner = getPlayer(projected, p.id);
owner.hand = owner.hand.filter(held => held.id !== card.id);
if (g.pendingKull)
projected.kullRestrictions = [
...activeKullRestrictions(projected.kullRestrictions, projected.turn, projected.phase),
{ player: g.pendingKull.owner, turn: projected.turn, phase: projected.phase },
];
projected.response = null;
projected.decision = null;
projected.pendingKarama = null;
return (!battlePromises.length ||
!!findReachableBattlePlan(projected, owner, { promises: battlePromises })) &&
(!shipmentPromises.length || !!findShipmentCompletion(projected, owner, shipmentPromises));
}
function kullCostCards(g: Game, p: Player): Card[] {
return kullNativeCostCards(cashInCards(g, p), physicalTreacheryCards(g))
.filter(card => !choamWorthlessBlocked(g, card.id) && !giftReserved(g, p.id, card.id) &&
!transferCardBlock(g, p, card) && kullCostPromisesAllow(g, p, card));
}
function kullSource(g: Game, frame: KullFrame): Game {
return { ...g, ...frame.resume, pendingKull: null, pendingChoamWorthless: frame.worthless };
}
function kullSignature(g: Game, frame: KullFrame): string {
const source = kullSource(g, frame);
return JSON.stringify({
event: frame.event, player: frame.player, owner: frame.owner, card: frame.card,
turn: frame.turn, phase: frame.phase, form: frame.form, intent: frame.intent,
controls: nullentropyParentSignature(source, frame.resume, frame.event),
status: source.status, advanced: source.advanced, active: source.active,
order: source.order, ready: source.ready, hajr: source.hajr, choamMarket: source.choamMarket,
auction: source.auction, battle: source.battle, movementRemaining: source.movementRemaining,
karamaShipping: source.karamaShipping, spiceWindow: source.spiceWindow,
spiceResolution: source.spiceResolution, spiceSequence: source.spiceSequence,
stormResolution: source.stormResolution, worthless: frame.worthless,
});
}
function kullIntegrity(g: Game): void {
try {
if (g.kullRestrictions !== undefined) validateKullPhaseRestrictions(g, g.kullRestrictions);
const frame = g.pendingKull;
if (!frame) {
const conversion = g.pendingKarama?.use;
requireRule(g.pendingChoamWorthless?.effect !== 'kull' &&
!(g.response?.kind === 'choamWorthless' && g.response.intent === 'Kull Wahad') &&
!(conversion?.kind === 'cancel' &&
conversion.response.kind === 'choamWorthless' &&
conversion.response.intent === 'Kull Wahad'),
'The saved Kull declaration has lost its interrupted Karama frame.');
return;
}
requireRule(g.kullPreview === true, 'This table has not opted into the Kull preview.');
validateKullAttemptStamp(g, frame, physicalTreacheryCards(g));
requireRule(Number.isSafeInteger(g.kullSequence) && g.kullSequence! > 0 &&
frame.event === JSON.stringify(['kull', g.turn, g.phase, g.kullSequence, frame.player, frame.owner]),
'The Kull attempt has lost its server-created opportunity identity.');
requireRule(frame.signature === kullSignature(g, frame),
'The saved Kull attempt no longer matches its original controls and opportunity.');
const source = kullSource(g, frame), owner = getPlayer(source, frame.owner);
const card = owner.hand.find(card => card.id === frame.card);
requireRule(card, 'The original attempted Karama must remain in its owner’s hand.');
requireRule(frame.form === (card.kind === 'worthless' ? 'substitution' : 'printed'),
'The Kull attempt no longer matches its original physical card form.');
requireRule(!karamaSpendingBlock(source, owner, card), 'The original Karama activation is no longer legal.');
if (frame.intent?.kind === 'ordinary') {
validateKaramaUse(source, owner, frame.intent.use, card);
requireRule(assertKaramaPromiseFeasibility(source, owner, frame.intent.use, card),
'The interrupted Karama has no proven Truthtrance continuation.');
} else {
requireRule(frame.intent?.kind === 'special', 'The Kull attempt has no validated intent.');
const prepared = prepareSpecialKaramaIntent(source, frame.owner, specialKaramaAction(frame.intent.use));
requireRule(JSON.stringify(prepared) === JSON.stringify(frame.intent.use),
'The saved special Karama selection has changed.');
}
if (frame.stage === 'offer')
requireRule(JSON.stringify(g.response) === JSON.stringify(frame.resume.response) &&
JSON.stringify(g.decision) === JSON.stringify(frame.resume.decision) &&
JSON.stringify(g.pendingKarama ?? null) === JSON.stringify(frame.resume.pendingKarama ?? null) &&
JSON.stringify(g.pendingChoamWorthless ?? null) === JSON.stringify(frame.worthless ?? null),
'The Kull offer no longer owns its original controls.');
else {
const declaration = g.pendingChoamWorthless;
const response = g.pendingKarama?.use.kind === 'cancel'
? g.pendingKarama.use.response : g.response;
requireRule(declaration?.effect === 'kull' && declaration.owner === frame.player &&
declaration.target === frame.owner && response?.kind === 'choamWorthless' &&
response.owner === frame.player && response.intent === 'Kull Wahad' &&
response.recipient === frame.owner &&
kullCostCards(g, getPlayer(g, frame.player)).some(card => card.id === declaration.card),
'The Kull counter no longer matches its held cost and original attempted player.');
currentChoamWorthlessCancellationQuote(g, response);
}
} catch (error) {
if (error instanceof RuleError) throw error;
throw new RuleError(error instanceof Error ? error.message : 'Invalid saved Kull transaction.');
}
}
function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['intent'], promiseProven = true): boolean {
if (!g.kullPreview || g.pendingKull) return false;
const choam = byFaction(g, 'choam');
if (!choam || choam.id === p.id) return false;
requireRule(promiseProven,
'Kull preview cannot interrupt this unproven Truthtrance cancellation continuation.');
// This composition is user-deferred. The guard is private and independent
// of CHOAM's hidden hand; never accept a transaction needing a made-up remedy.
requireRule(!g.auction || g.auction.bidder !== p.id ||
g.auction.bid <= p.spice + (g.auction.allyPayment ?? 0),
'Kull preview defers Karama activation while your winning overbid cannot be paid in spice.');
const resume: KullFrame['resume'] = {
response: g.response, decision: g.decision, pendingKarama: g.pendingKarama ?? null,
phaseOpening: g.phaseOpening,
};
if (intent.kind === 'ordinary' && intent.use.kind === 'auctionPayment')
resume.decision = { kind: 'auctionPayment', player: p.id };
const frame: KullFrame = {
event: JSON.stringify(['kull', g.turn, g.phase, (g.kullSequence ?? 0) + 1, choam.id, p.id]),
player: choam.id, owner: p.id, card: card.id,
turn: g.turn, phase: g.phase, form: card.kind === 'worthless' ? 'substitution' : 'printed',
stage: 'offer', intent: structuredClone(intent), resume: structuredClone(resume),
worthless: structuredClone(g.pendingChoamWorthless ?? null), signature: '',
};
// Payment dispatch removed this control before entering its native branch.
g.decision = resume.decision;
frame.signature = kullSignature(g, frame);
g.kullSequence = (g.kullSequence ?? 0) + 1;
g.pendingKull = frame;
log(g, `${p.name} attempted a Karama activation. CHOAM may react with Kull Wahad.`);
return true;
}
function resumeKullAttempt(g: Game, prevented: boolean): void {
const frame = g.pendingKull!;
Object.assign(g, frame.resume);
g.pendingChoamWorthless = frame.worthless;
// Retire ownership before executing. Neither resumed execution nor a saved
// retry can reopen this exact opportunity or consume its physical card twice.
g.pendingKull = null;
if (prevented) return;
const p = getPlayer(g, frame.owner);
if (frame.intent.kind === 'special') executeSpecialKaramaIntent(g, frame.intent.use);
else {
if (frame.intent.use.kind === 'auctionPayment') g.decision = null;
spendKarama(g, p, p.hand.find(card => card.id === frame.card)!, frame.intent.use, true);
}
}
function decideKull(g: Game, p: Player, action: Action): void {
const frame = g.pendingKull!;
requireRule(action.type === 'kullDecision' && action.event === frame.event &&
p.id === frame.player && Object.keys(action).every(key => ['type','event','decline','card'].includes(key)),
'Choose the current Kull opportunity as CHOAM.');
if (action.decline === true) {
requireRule(action.card === undefined, 'Decline or choose one Kull card, not both.');
resumeKullAttempt(g, false);
return;
}
requireRule(action.decline === undefined && typeof action.card === 'string' &&
kullCostCards(g, p).some(card => card.id === action.card),
'Choose your exact eligible held Kull Wahad card.');
frame.stage = 'counter';
g.decision = null;
g.pendingKarama = null;
g.phaseOpening = null;
g.pendingChoamWorthless = {
owner: p.id, card: action.card, effect: 'kull', target: frame.owner, revival: false,
};
g.response = { kind: 'choamWorthless', owner: p.id, recipient: frame.owner,
intent: 'Kull Wahad', passed: [] };
log(g, `${p.name} declared Kull Wahad. Only a different Karama can prevent it.`);
}
function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered = false) {
const blocked = karamaSpendingBlock(g, p, card);
requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.');
validateKaramaUse(g, p, use, card);
assertKaramaPromiseFeasibility(g, p, use, card);
const promiseProven = assertKaramaPromiseFeasibility(g, p, use, card);
requireRule(!g.pendingKull || promiseProven,
'Kull preview cannot accept this unproven Truthtrance counter continuation.');
if (!kullOffered && beginKullAttempt(g, p, card, { kind: 'ordinary', use }, promiseProven)) return;
const used = discard(g, p, card.id);
if (g.semutaPreview && card.effect === 'karama' &&
use.kind === 'cancel' &&
@@ -17828,6 +18055,7 @@ function reconcileShipmentPromises(
g: Game,
voluntary?: { actor: string; action: Action },
) {
if (g.pendingKull) return;
const kept: ShipmentPromise[] = [];
for (const promise of g.shipmentPromises ?? []) {
if (promise.turn !== g.turn || promise.released || promise.fulfilled)
@@ -17890,6 +18118,7 @@ function reconcileBattlePromises(
g: Game,
voluntary?: { actor: string; action: Action },
) {
if (g.pendingKull) return;
const b = g.battle;
if (
!b ||
@@ -19864,6 +20093,7 @@ function auditorIntegrity(g: Game) {
const contexts = [
g,
g.pendingNullentropy?.resume,
g.pendingKull?.resume,
g.pendingTreacheryDiscard?.continuation.kind === 'nullentropyDiscard' ||
g.pendingTreacheryDiscard?.continuation.kind === 'truthtranceDiscard'
? g.pendingTreacheryDiscard.continuation.resume
@@ -20560,6 +20790,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
karamaConversionIntegrity(g);
currentFactionPayment(g);
const response = g.response!;
if (g.pendingKull?.stage === 'counter' && response.kind === 'choamWorthless') kullIntegrity(g);
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
if (response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, response);
@@ -20656,6 +20887,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
else if (declaration.resume.kind === 'movement') resumeChoamMovement(g);
else if (declaration.resume.kind === 'mentat')
g.decision = declaration.resume.decision;
if (g.pendingKull?.stage === 'counter' && declaration.effect === 'kull')
resumeKullAttempt(g, false);
return;
}
if (auctionCancellation) {
@@ -20907,6 +21140,13 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
) {
const used = discard(g, choam, pending.card);
finishNexusChoam(g,pending,'complete');
if (pending.effect === 'kull') {
g.kullRestrictions = [...activeKullRestrictions(g.kullRestrictions, g.turn, g.phase),
{ player: g.pendingKull!.owner, turn: g.turn, phase: g.phase }];
log(g, `${getPlayer(g, g.pendingKull!.owner).name} cannot activate Karama for this phase. The attempted card stays held.`);
resumeKullAttempt(g, true);
return;
}
if (pending.effect === 'kulon') {
if (g.semutaPreview && used.name === 'Kulon' &&
!pending.storm && !pending.revival && !pending.movement &&
@@ -23306,11 +23546,11 @@ 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}));
blocked:card.name === 'Kull Wahad' ? 'Kull Wahad is reactive: use the Kull opportunity when another player attempts Karama in the explicit preview.' : 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.';
if (effect === 'kull') blocked = 'Kull Wahad via CHOAM Cunning is unavailable in this printed-card preview.';
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.';
@@ -23610,6 +23850,8 @@ export function prepareSpecialKaramaIntent(
(c) => c.id === action.card && canUseAsKaramaRole(g, p, c),
);
requireRule(card, 'Choose a Karama card in your hand.');
const activationBlock = karamaSpendingBlock(g, p, card);
requireRule(!activationBlock, activationBlock ?? 'This Karama is committed elsewhere.');
const base = { owner: p.id, card: card.id, turn: g.turn, phase: g.phase };
if (p.faction === 'richese') {
const blocked = karamaSpendingBlock(g, p, card);
@@ -24141,7 +24383,10 @@ export function executeSpecialKaramaIntent(
}
}
function specialKarama(g: Game, p: Player, action: Action) {
executeSpecialKaramaIntent(g, prepareSpecialKaramaIntent(g, p.id, action));
const intent = prepareSpecialKaramaIntent(g, p.id, action);
const card = p.hand.find(card => card.id === intent.card)!;
if (!beginKullAttempt(g, p, card, { kind: 'special', use: intent }))
executeSpecialKaramaIntent(g, intent);
}
/** Private, read-only choices; validation performs no draws or continuations. */
@@ -24234,6 +24479,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
validateEcazLoyalty(state);
@@ -24316,6 +24562,10 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
if (g.pendingKull) {
settleAutomaticContinuations(g);
if (g.pendingKull) { kullIntegrity(g); return g; }
}
finishLeaderSkillCustody(g, state);
observeOccupation(g);
homeworldRule(() => homeworldGameIntegrity(g));
@@ -24441,6 +24691,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return g;
}
function finishActionContinuations(g: Game) {
if (g.pendingKull) return;
finishLeaderSkillCustody(g);
finishMandatorySkillVisibility(g);
if (g.pendingNullentropy) return;
@@ -24502,6 +24753,7 @@ function finishActionContinuations(g: Game) {
finishMoritaniPlacement(g);
}
function settleAutomaticContinuations(g: Game) {
if (g.pendingKull?.stage === 'offer') return;
if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return;
for (let iteration = 0; iteration < 128; iteration++) {
@@ -24541,6 +24793,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
validateEcazLoyalty(state);
@@ -24573,6 +24826,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
const g = structuredClone(state);
if (g.pendingKull?.stage === 'offer') return g;
ornithopterIntegrity(g);
lateDefenseIntegrity(g);
battleSpecialWeaponIntegrity(g);
@@ -24683,6 +24937,15 @@ function applyActionInner(
);
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
if (g.pendingKull) {
if (t === 'advanceBots') return g;
if (g.pendingKull.stage === 'offer') {
decideKull(g, p, action);
return g;
}
requireRule(t === 'passResponse' || (t === 'card' && action.mode === 'cancel'),
'Finish the Kull counter response before another action.');
} else requireRule(t !== 'kullDecision', 'This Kull opportunity is no longer pending.');
if (t === 'advanceBots') return g;
requireRule(!g.bureaucratPayments?.pending || t === 'decision','Resolve the pending Bureaucrat payment before another action.');
requireRule(!(g.battle?.mentatQuestion?.stage === 'name' || g.battle?.mentatQuestion?.stage === 'reveal') || t === 'decision',
@@ -27002,11 +27265,16 @@ function applyActionInner(
const quotedBid = quoteNormalAuctionBid({
currentBid: a.bid,
amount: action.amount,
maximum: karamaCard(g, p)
maximum: (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card)) : !!karamaCard(g, p))
? Number.MAX_SAFE_INTEGER
: p.spice + (aidFor(g, p)?.amount ?? 0),
});
if (!quotedBid.ok) throw new RuleError(quotedBid.reason);
requireRule(!g.kullPreview ||
(p.id === byFaction(g, 'choam')?.id &&
!kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id)) ||
quotedBid.amount <= p.spice + (aidFor(g, p)?.amount ?? 0),
'Kull preview defers an unfunded Karama-dependent overbid before acceptance.');
a.bid = quotedBid.amount;
a.allyPayment =
a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0)
@@ -28427,6 +28695,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
validateEcazLoyalty(state);
@@ -28927,6 +29196,16 @@ export function viewGame(state: Game, id: string) {
? g.stormDials
: null,
me: id,
kullPreview: !!g.kullPreview,
kullCounterEvent: g.pendingKull?.stage === 'counter' ? g.pendingKull.event : null,
karamaBlocked: g.kullPreview && kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, id)
? 'Kull Wahad prevents your Karama activations for this phase.' : null,
kullReaction: g.pendingKull?.stage === 'offer'
? { event: g.pendingKull.event, player: g.pendingKull.player, target: g.pendingKull.owner,
intent: g.pendingKull.intent.kind === 'ordinary' ? g.pendingKull.intent.use.kind : 'special',
cards: id === g.pendingKull.player ? kullCostCards(g, me) : [],
canDecline: id === g.pendingKull.player, blocked: null as string | null }
: null,
choamWorthless:
me.faction === 'choam'
? {
@@ -29055,7 +29334,7 @@ export function viewGame(state: Game, id: string) {
g.moritaniRetention.keep,
) ?? null)
: null,
decision:
decision: g.pendingKull?.stage === 'offer' ? null :
g.decision?.kind === 'capturedLeader' &&
!g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract<Decision,{kind:'capturedLeader'}>).leader) &&
![g.decision.player, g.decision.controller ?? g.decision.owner].includes(
@@ -29067,7 +29346,7 @@ export function viewGame(state: Game, id: string) {
: g.decision?.kind === 'guildShipment' && g.decision.noFieldSkillProof !== undefined
? { ...g.decision, noFieldSkillProof: undefined }
: (g.decision ?? null),
response: g.response
response: g.pendingKull?.stage === 'offer' ? null : g.response
? {
...g.response,
...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}),
@@ -29091,7 +29370,7 @@ export function viewGame(state: Game, id: string) {
}
: null,
paymentIncome: currentFactionPayment(g),
responseControls: g.response
responseControls: g.pendingKull?.stage === 'offer' ? undefined : g.response
? {
cancelCards: responseCancelCards(g, me, g.response),
hasPassed: g.response.passed.includes(id),
+2 -1
View File
@@ -14,10 +14,11 @@ export const FACTION_RULES: Record<
'Use Kulon on your movement turn to add one territory of range. Use La La La during Revival to prevent a player’s free force returns for the phase; a reactive decision lets you stop a pending free revival before it resolves. Both cards have Karama responses before discard.',
'Trip to Gamont returns one other player’s force from a chosen sector to its reserves during Mentat. Elite types are preserved. A Karama response precedes the return, and CHOAM gets a final opportunity after the closing market before victory is checked.',
'Baliset prevents a player from moving into a territory occupied by CHOAM for the turn, while permitting shipment. Jubba Cloak protects CHOAM forces in one territory from a moving storm. Both powers have Karama responses before the card is discarded.',
'Kull Wahad and combined faction interactions remain unfinished. The available development powers do not enable complete CHOAM games.',
'In the opt-in Kull preview, react to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. See the Kull guide for deferred overbid limits. Full CHOAM starts remain closed.',
],
advanced: [
'Once per game, spend a real Karama and discard selected other cards for three spice each. The cash-in can occur during pending phase or power decisions, while committed battle cards remain protected. See the CHOAM special Karama topic for funding and timing details.',
'The Kull preview also intercepts supported special Karama intents before once-use or other costs, and BG Worthless substitutions before conversion. Successful Kull leaves an attempted special unspent and the original BG card held; ordinary later cancellation of BG conversion still discards its card. Approved preview timing is product policy, not publisher clarification, and does not enable Nexus Cunning Kull or combined modules.',
'Reserve spice to pay for some or all of your ally’s battle support. Each sealed plan records its own and allied shares. A Karama response may prevent this alliance benefit for one battle; unused funding returns at the end of Battle.',
'Receive half of each other player’s actual force-support payment, rounded down, unless any traitor is revealed. Your own payments, including support paid for your ally, go to the bank. A separate Karama response can cancel this battle’s income. See CHOAM combat for examples and outstanding interaction audits.',
'The Auditor is an additional strength-two leader. After using it in battle, CHOAM may inspect two randomly selected unused opposing Treachery Cards if it survives, or one if it dies, limited by the unused cards available. The opponent may pay CHOAM one spice per card that would be inspected to prevent the whole inspection; Karama can also cancel the power. Use the Auditor guide for the exact private choices and limits. Setup and inspection are supported in development games; complete combined play remains unfinished.',
+40 -8
View File
@@ -72,7 +72,7 @@ export const HELP = {
summonedWorm:
'Fremen’s special Karama summons a worm in sand once per game during Spice Blow and Nexus. Resolve destruction and protection immediately, then resume the spice blow. The worm causes a Nexus at the end of that blow, followed by Fremen rides. Calling the worm does not draw or discard a spice card.',
choamWorthless:
'Kulon adds one territory of movement range on CHOAM’s turn. La La La prevents a player’s free force revival for the phase. Baliset blocks a player’s movement into a CHOAM-occupied territory while permitting shipment. Trip to Gamont returns another player’s force to reserves during Mentat, before victory. Jubba Cloak protects CHOAM’s forces in one territory during a storm movement. A Karama response precedes discarding these cards; canceled cards remain in hand.',
'Kulon adds one territory of movement range on CHOAM’s turn. La La La prevents free force revival. Baliset blocks movement into a CHOAM-occupied territory, not shipment. Trip to Gamont returns another player’s force during Mentat. Jubba Cloak protects one territory from a moving storm. In the opt-in Kull preview, Kull Wahad intercepts a Karama attempt; only successful settlement activates the phase ban. A distinct counter may prevent it. Canceled Worthless costs remain held.',
choamCombat:
'CHOAM may reserve spice for an ally’s advanced battle support. The ally chooses the payment split when sealing. A traitor victory costs the winner no support; any revealed traitor prevents CHOAM force-payment income.',
fullPlan:
@@ -2421,8 +2421,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'Advanced Bene Gesserit may spend Worthless cards as Karama. Each conversion opens a separate cancellation response; the Worthless card is discarded even if the power is canceled. Normal Karama cards resolve without that conversion response.',
'A pending Worthless purchase keeps space for its original auction card. Gifts and hand exchanges must leave enough room for every committed incoming card. Refreshing restores the same auction, unused shipment opportunity or original canceled power.',
'Atreides full-plan inspection, Emperor revival, Fremen’s summoned worm, Harkonnen hand exchange the Guild shipment stop and Tleilaxu revival prevention are implemented as once-per-game uses. Other cancellations and special Karamas are still being implemented.',
'In the opt-in Kull development preview, another player’s legal ordinary Karama or supported special activation pauses before its costs for CHOAM’s neutral reaction. A declared Kull is pending, not yet a phase ban. Only a different eligible physical Karama can counter it; the original stays reserved. Successful Kull retains the unplayed original, including an advanced Bene Gesserit Worthless card intercepted before conversion, and prevents further Karama activations for that phase. See Kull Wahad for the explicit preview policy and deferred winning-overbid warning.',
],
related: ['bidding', 'battle', 'advanced-combat', 'special-karama'],
related: ['bidding', 'battle', 'advanced-combat', 'special-karama', 'choam-kull'],
},
{
id: 'truthtrance-forces',
@@ -2741,7 +2742,7 @@ export const RULE_TOPICS: RuleTopic[] = [
`advanced-${f.id}`,
'faction-sheets',
...(f.id === 'richese' ? ['richese-cards', 'richese-no-field', 'richese-gift', 'richese-acquisition'] : []),
...(f.id === 'choam' ? ['choam-auditor', 'choam-worthless', 'choam-combat'] : []),
...(f.id === 'choam' ? ['choam-auditor', 'choam-worthless', 'choam-kull', 'choam-combat'] : []),
...(f.id === 'ecaz'
? ['ecaz-loyalty', 'ecaz-ambassadors', 'duke-vidal', 'implementation-checklist']
: []),
@@ -3307,7 +3308,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
summary:
'Use Kulon for range, La La La to deny free revival, Baliset to prevent enemy movement, Jubba Cloak for storm protection or Trip to Gamont to return a force.',
'Six printed effects cover range, revival, movement, storm protection, force return and Kull Wahad’s opt-in Karama reaction.',
steps: [
'These CHOAM abilities work in basic and advanced games. Choose a printed Worthless card from the phase controls. Its name and intended effect are announced, then a Karama response occurs before discard. A canceled card remains in hand and cannot be retried for its effect during that phase.',
'On your own Shipment and Movement turn, play Kulon while a movement remains. It adds one territory to your movement range, including ornithopters. It does not add another movement action, bypass the storm or relax alliance/stronghold entry rules. The current implementation applies the bonus to both movements if Hajr is used; this combination remains under audit.',
@@ -3315,12 +3316,13 @@ export const RULE_TOPICS: RuleTopic[] = [
'Whenever another faction requests a normal revival with a free portion, CHOAM receives a response decision independently of its private hand. Allow the request, or declare La La La for that requester. If the effect succeeds, the whole pending request stops without payment, force movement or quota use; a faction permitted to purchase normal revivals can submit a paid request. If the effect is canceled, the original quote resumes through any Tleilaxu and other applicable responses.',
'La La La does not prevent otherwise legal paid revivals or separate Ghola card effects. With Tleilaxu in the game, Fremen may submit a paid request within their current limit after free revival is prevented. Without Tleilaxu, Fremen’s base purchase restriction leaves no normal force return available. Emperor-funded extras remain separate. The prohibition resets when the next Revival phase begins. Card custody is checked at settlement: if CHOAM cashed in the declared card during the response, no Worthless effect occurs and the interrupted revival resumes.',
'During Shipment and Movement, use Baliset to select another player and a territory CHOAM occupies. The current implementation prevents that player from entering that territory by movement for the phase while CHOAM remains there; shipment is allowed. A declared move into a CHOAM territory pauses for CHOAM regardless of its private hand. Allow it or declare Baliset. A successful effect leaves the moving forces in place and the movement unspent; a canceled or unavailable card resumes the original move after checking its legality again.',
'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont now have engine, private controls and AI support. See the linked Trip to Gamont topic for force selection and victory timing. See Jubba Cloak for protection during storm movement. Kull Wahad still needs its special effect. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, ordering of simultaneous prevention powers, and later module exceptions remain under audit. Full faction starts remain disabled.',
'Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont have engine, private controls and AI support. Printed Kull Wahad has a separate opt-in development preview; see Kull Wahad for distinct counters, pre-conversion Bene Gesserit custody and the deferred winning-overbid boundary. This is not six-effect Nexus Cunning: its Kull choice remains unavailable. Exact cancellation scope, Hajr/ornithopter combinations, Baliset duration and destination scope, simultaneous prevention and later module exceptions remain under audit. Normal expansion starts remain disabled.',
],
related: [
'faction-choam',
'choam-gamont',
'choam-jubba',
'choam-kull',
'choam-market',
'choam-revival',
'movement',
@@ -3328,6 +3330,35 @@ export const RULE_TOPICS: RuleTopic[] = [
'card-karama',
],
},
{
id: 'choam-kull',
title: 'Kull Wahad: Karama interception preview',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'React before a Karama is played; a distinct counter can stop Kull before its phase restriction begins.',
steps: [
'This opt-in development preview supports CHOAM with classic factions in Basic or Advanced, using the physical CHOAM and Ix Treachery decks without optional modules. Basic supports printed ordinary Karama; Advanced also supports Bene Gesserit substitutions and the legal special powers of this roster. Existing games are not retrofitted. Normal expansion starts remain closed, and the visible preview warning is not rules-completion approval.',
'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.',
'Decline resumes the original attempt once. Declaring Kull opens the ordinary CHOAM-effect response. Kull is still pending: its target may counter with a different eligible physical Karama before any phase ban applies. The interrupted card stays reserved and cannot serve as its own counter. Other eligible responders use their existing response controls.',
'If everyone allows Kull, CHOAM discards the exact Kull card. The original attempted card remains in its owner’s hand; its effect and additional costs do not occur, and an attempted special once-per-game use remains unspent. The activating player cannot activate printed or substituted Karama again during this turn’s current phase. An allied shipment recipient is not automatically the restricted player.',
'The successful restriction ends when the phase or turn changes, not during a temporary response, market or worm substep. It does not prohibit holding, acquiring, transferring or otherwise legally discarding a Karama without activating it.',
'A successful distinct counter spends or converts its own card under ordinary rules. Kull then stays held and its exact physical card cannot use its CHOAM effect again that phase. No Karama ban is established on the original player, and the reserved original attempt resumes once with its normal costs. Prevention is not a refund of a counter card.',
'Advanced Bene Gesserit is intercepted before Worthless conversion or discard: successful Kull keeps that original unplayed Worthless card held. If CHOAM declines or Kull is countered, the original proceeds to its separate conversion response. Ordinary cancellation of that conversion still leaves its Worthless card discarded. A different BG Worthless counter has its own conversion response; it is never the reserved original.',
'Winning overbids whose Karama payment would become unplayable remain deferred. To avoid accepting a bid that the preview cannot settle, a non-CHOAM unfunded Karama-dependent bid is refused privately before commitment, independently of CHOAM’s hidden Kull ownership. CHOAM self-activations are outside interception; normal non-preview bidding is unchanged. An already unfunded winning activation remains guarded. Holding Karama alone is not a reaction trigger. This unfinished-composition fence does not restart bidding, award the lot free, supply new funding or change numeric UI bid limits.',
'A live Truthtrance battle or shipment promise also constrains original attempts, Kull costs and counters. A cost that cannot be proved compatible is unavailable before disposal; CHOAM may decline instead. Proven supported cancellations remain available. Pending Kull cannot release a suspended promise, and its counter choices exclude unproven costs so legal AI can progress. Unsupported continuations remain an incomplete preview composition, not a publisher ban or permission to release the promise.',
'The different-card counter and before-conversion custody are explicitly selected development policy, not a located publisher clarification. Nexus Cunning Kull, other expansion rosters, optional modules, unpayable-auction recovery and full CHOAM acceptance remain outside this preview. Nexus cards do not receive blanket immunity to Karama.',
],
example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held and BG cannot activate any Worthless-as-Karama again this phase. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.',
related: ['choam-worthless', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Opt-in printed Kull interception binds typed ordinary or prepared special intents, distinct physical counters and a stamped activation restriction. Deferred overbid recovery and combined-module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused engine, controls, all-profile legal bots and authenticated SQLite cover saved offer/counter/decline, exact custody, promises, orphan rejection and profile safeguards. Actual phone-width Basic/Advanced seats exercised Kull use, distinct counter and refreshed decline through the table. Complete combined games, proxy-free checkpoint HTTP and deployed acceptance remain separate.', evidence: ['tests/choam-kull-engine.test.ts', 'tests/choam-kull-recovery.test.ts', 'tests/choam-kull-controls.test.tsx', 'tests/bot-choam-kull.test.ts', 'tests/choam-kull.test.ts', 'tests/prototype-room.test.ts'] },
],
},
{
id: 'choam-combat',
title: 'CHOAM combat funding and force income',
@@ -3364,7 +3395,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In the advanced game, use the special Karama controls above your private hand. Choose the activating Karama and one or more other cards. Each selected card earns three spice, including Worthless cards or a second Karama.',
'The activating Karama is spent separately and earns no spice in this implementation. All selected cards must be distinct and still in your hand. The entire operation succeeds or changes nothing.',
'Cards fixed in your sealed battle plan or committed to prescience cannot be cashed in. You must also retain enough spice, committed allied funding or another Karama to honor a current winning auction bid.',
'Cash-in takes effect immediately and preserves a pending phase opening, revival, response, market sale or allied exchange. If it consumes a card already offered for a market sale or trade, that transaction later pays nothing or moves neither card.',
'Cash-in takes effect immediately and preserves a pending phase opening, revival, response, market sale or allied exchange. If it consumes a card already offered for a market sale or trade, that transaction later pays nothing or moves neither card. In the Kull preview, a declared original or counter cost remains reserved while that transaction is pending.',
'The battle preparation guide can find a cash-in that funds a promised plan while retaining required cards. Complete the listed preparation before reviewing and sealing the plan. This can combine with Ghola preparation when both remain possible.',
'The power is available in all nine playing phases. Finish an active Truthtrance question first. Its precise timing against other simultaneous effects, activation-card payment interpretation and remaining expansion interactions are still under audit; full CHOAM starts remain disabled.',
],
@@ -3372,6 +3403,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'faction-choam',
'choam-market',
'card-karama',
'choam-kull',
'card-truthtrance',
'battle',
'bidding',
@@ -4048,7 +4080,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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. 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.',
'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. Five of the six printed effects are supported: Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont. Cunning Kull remains unavailable; the separate printed Kull development preview excludes optional modules and does not authorize that sixth Nexus choice.',
'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.',
'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.',
@@ -4064,7 +4096,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain 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/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment, Emperor purchase and Fremen Cunning controls are connected. 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', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/game-table.tsx'] },
+4 -1
View File
@@ -12,12 +12,15 @@ type TurnView = Pick<
| 'phaseOpening'
| 'automaticContinuationPending'
> &
Partial<Pick<GameView, 'biddingEnd'>>;
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction'>>;
/** The single current decision owner for the table banner and seat highlight.
* Shared response windows have no single acting seat. Only public view fields
* participate; this does not decide which gameplay actions are legal. */
export function tableActionOwner(g: TurnView): string | null {
if (g.kullReaction && g.status === 'playing')
return g.players.some(p => p.id === g.kullReaction!.player)
? g.kullReaction.player : null;
if (
g.status !== 'playing' ||
g.automaticContinuationPending ||