Connect absent Bene Gesserit Nexus Voice before Prescience

This commit is contained in:
Quintine committed 2026-10-15 00:51:43 +11:00
1 parent 72e37a8978
commit 8ab827b902
15 files changed
+1417 -50

No files matched your search

+8
View File
@@ -3749,6 +3749,14 @@ function policyActions(g: GameView): Action[] {
if (!b) return botBattleChoices(g);
if (b.preparation) {
if (b.preparation.owner !== me.id) return [];
if (b.preparation.kind === 'nexusVoice') {
const offer = g.nexusBorrowedVoice;
if (!offer || offer.blocked) return [];
const command = offer.choices.find(choice => !choice.must && choice.kind === 'shield');
return [{ type: 'nexusBorrowedVoice', event: offer.event,
use: !!(offer.canUse && command),
...(offer.canUse && command ? { kind: command.kind, must: command.must } : {}) }];
}
if (b.preparation.kind === 'voice')
return level === 0 || b.preparation.blocked
? [{ type: 'declineBattlePower' }]
+19 -4
View File
@@ -1,6 +1,7 @@
import { FACTIONS, type FactionId } from './catalog';
import { VOICE_KINDS } from './battle-cards';
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
import { validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from './nexus-borrowed-voice';
export type CombatCheckKind =
| 'kwisatz'
@@ -14,6 +15,7 @@ export type CombatResponseInput = {
status: string;
phase: number;
advanced: boolean;
turn?: number;
territoryIds: readonly string[];
players: readonly {
id: string;
@@ -22,6 +24,8 @@ export type CombatResponseInput = {
specialKaramaUsed?: boolean;
}[];
battle: {
event?: string;
nexusBorrowedVoice?: NexusBorrowedVoiceFrame;
attacker: string;
defender: string;
territory: string;
@@ -296,10 +300,21 @@ function calculate(
}
const owner = input.players.find((p) => p.id === response.owner),
faction = response.kind === 'voice' ? 'beneGesserit' : 'atreides';
requireCombat(
owner?.faction === faction,
'Combat preparation response has the wrong faction owner.',
);
const borrowed = response.kind === 'voice' && b.nexusBorrowedVoice;
if (borrowed) {
requireCombat(typeof input.turn === 'number' && typeof b.event === 'string',
'Borrowed Voice has lost its original battle occurrence.');
validateNexusBorrowedVoice({ turn: input.turn,
battle: { event: b.event, attacker: b.attacker, defender: b.defender } }, borrowed);
requireCombat(borrowed.stage === 'declared' && borrowed.owner === response.owner &&
owner && !owner.ally && combatants.includes(owner.id) &&
!input.players.some(p => p.faction === 'beneGesserit') && record(b.voice) &&
b.voice.target === borrowed.voice?.target && b.voice.kind === borrowed.voice?.kind &&
b.voice.must === borrowed.voice?.must,
'Borrowed Voice does not match the declared combatant source.');
} else requireCombat(owner?.faction === faction,
'Combat preparation response has the wrong faction owner.');
requireCombat(owner, 'Combat preparation response has no owner.');
// Match battlePreparation's existing provider selection, including its
// one-way ally link. CHOAM's queue above independently requires mutual links.
const beneficiary = combatants.includes(owner.id)
+113 -34
View File
@@ -58,6 +58,7 @@ import { createIxianBetrayalSource, validateIxianBetrayalFrame, initialIxianBetr
import { createHarkonnenBetrayalSource, validateHarkonnenBetrayalFrame, initialHarkonnenBetrayalCursor, closeHarkonnenBetrayalSource, validateHarkonnenBetrayalHistory, harkonnenBetrayalResponders, harkonnenBetrayalEligible, createHarkonnenBetrayalReplacement, validateHarkonnenBetrayalReplacements, drawHarkonnenBetrayalReplacement, type HarkonnenBetrayalSource, type HarkonnenBetrayalCursor, type HarkonnenBetrayalReceipt, type HarkonnenBetrayalReplacement, type HarkonnenBetrayalAuthority } from './nexus-harkonnen-betrayal';
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
import { createNexusBorrowedVoice, currentNexusBorrowedVoiceOwner, passNexusBorrowedVoice, declareNexusBorrowedVoice, finishNexusBorrowedVoice, validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from './nexus-borrowed-voice';
import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections';
import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTraitorExchange, validateNexusTraitorHistory, validateNexusTraitorSnapshot, type NexusTraitorExchange, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFaceDancerReceipt } from './nexus-face-dancers';
@@ -768,8 +769,9 @@ export type Battle = {
required: string[];
passed: string[];
};
nexusBorrowedVoice?: NexusBorrowedVoiceFrame;
preparation?: {
kind: 'voice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer';
kind: 'voice' | 'nexusVoice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer';
owner: string;
beneficiary: string;
blocked?: string;
@@ -779,7 +781,7 @@ export type Battle = {
field: PlanField;
value?: string | number | null;
};
voice?: { target: string; kind: Card['kind'] | 'stoneBurner'; must: boolean };
voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon'; must: boolean };
};
export type Auction = {
peekKnown?: boolean;
@@ -2568,6 +2570,94 @@ function playNexusFremenBetrayal(g: Game, p: Player, action: Action) {
: `${p.name} spent Fremen Nexus Betrayal to suppress the Fremen two-territory movement advantage this turn. Independent ornithopters and movement abilities remain available.`,
{ faction: p.faction, name: 'Fremen Nexus Betrayal' });
}
function quoteVoiceCommand(g: Game, target: string, kind: unknown, must: unknown): NonNullable<Battle['voice']> {
const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target));
requireRule(occupation.status === 'allowed', occupation.blocked ?? 'This target cannot receive the Voice.');
requireRule(typeof kind === 'string' && VOICE_KINDS.includes(kind), 'Choose a card type.');
if (kind === 'mirrorWeapon') {
const blocked = mirrorWeaponModeBlock(g);
requireRule(!blocked, blocked ?? 'Mirror Weapon is unavailable in this battle configuration.');
}
requireRule(typeof must === 'boolean', 'Choose require or forbid.');
if ((kind === 'stoneBurner' || kind === 'mirrorWeapon') && must) {
const blocked = stoneCompulsionBlock(g, getPlayer(g, target));
requireRule(!blocked, blocked ?? `${kind === 'mirrorWeapon' ? 'Mirror Weapon' : 'Stone Burner'} cannot be compelled in this battle.`);
}
return { target, kind: kind as NonNullable<Battle['voice']>['kind'], must };
}
function nexusBorrowedVoiceIntegrity(g: Game) {
const b = g.battle, frame = b?.nexusBorrowedVoice;
if (!b || !frame) return;
requireRule(b.event && g.nexusCards?.cards && !byFaction(g, 'beneGesserit'),
'Borrowed Voice has lost its absent-faction Nexus module.');
nexusRule(() => validateNexusBorrowedVoice({
turn: g.turn, battle: { event: b.event!, attacker: b.attacker, defender: b.defender },
}, frame));
if (frame.stage === 'choose') {
const owner = currentNexusBorrowedVoiceOwner(frame);
requireRule(b.preparation?.kind === 'nexusVoice' && b.preparation.owner === owner &&
b.preparation.beneficiary === owner && !b.voice && !b.prescience &&
!b.nexusInspection && !Object.keys(b.plans).length,
'The borrowed Voice queue has lost its original pre-inspection preparation.');
} else if (frame.stage !== 'passed') {
requireRule(frame.owner && !getPlayer(g, frame.owner).ally &&
g.nexusCards.cards.discard.includes('beneGesserit'),
'Borrowed Voice has lost its combatant or spent physical card.');
if (frame.stage === 'canceled')
requireRule(!b.voice, 'Canceled borrowed Voice cannot regain its command.');
else requireRule(b.voice && frame.voice && b.voice.target === frame.voice.target &&
b.voice.kind === frame.voice.kind && b.voice.must === frame.voice.must,
'Borrowed Voice has lost or changed its declared command.');
if (frame.stage === 'declared')
requireRule(g.response?.kind === 'voice' && g.response.owner === frame.owner,
'Borrowed Voice has lost its original cancellation response.');
}
}
function nexusBorrowedVoiceOffer(g: Game, id: string) {
const b = g.battle, frame = b?.nexusBorrowedVoice;
if (!b || !frame || currentNexusBorrowedVoiceOwner(frame) !== id ||
b.preparation?.kind !== 'nexusVoice') return null;
const p = getPlayer(g, id), target = b.attacker === id ? b.defender : b.attacker;
const canUse = !p.ally && g.nexusCards?.cards?.hands[id] === 'beneGesserit';
const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target));
const blocked = g.truthtrance || pendingNexusTraitors(g) || g.response || g.decision ||
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy
? 'Finish the current response before continuing borrowed Voice.' : null;
const choices: { kind: NonNullable<Battle['voice']>['kind']; must: boolean }[] = [];
if (canUse && occupation.status === 'allowed') for (const kind of VOICE_KINDS) {
if (kind === 'mirrorWeapon' && mirrorWeaponModeBlock(g)) continue;
choices.push({ kind: kind as NonNullable<Battle['voice']>['kind'], must: false });
if (!['stoneBurner', 'mirrorWeapon'].includes(kind) || !stoneCompulsionBlock(g, getPlayer(g, target)))
choices.push({ kind: kind as NonNullable<Battle['voice']>['kind'], must: true });
}
return { event: frame.event, owner: id, target, canUse,
blocked: blocked ?? (canUse && occupation.status !== 'allowed' ? occupation.blocked ?? 'This target cannot receive the Voice.' : null),
choices };
}
function playNexusBorrowedVoice(g: Game, p: Player, action: Action) {
const offer = nexusBorrowedVoiceOffer(g, p.id), b = g.battle;
requireRule(offer && b && action.event === offer.event && !offer.blocked,
'This borrowed Voice opportunity is stale, blocked, or belongs to another combatant.');
requireRule(typeof action.use === 'boolean' && Object.keys(action).every(key =>
['type', 'event', 'use', ...(action.use ? ['kind', 'must'] : [])].includes(key)),
'Choose borrowed Voice or continue without a command.');
if (!action.use) {
b.nexusBorrowedVoice = nexusRule(() => passNexusBorrowedVoice(b.nexusBorrowedVoice!, p.id));
const next = currentNexusBorrowedVoiceOwner(b.nexusBorrowedVoice);
if (next) b.preparation = { kind: 'nexusVoice', owner: next, beneficiary: next };
else battlePreparation(g, 'prescience');
return;
}
requireRule(offer.canUse, 'Use the held Bene Gesserit Nexus card while unallied.');
const voice = quoteVoiceCommand(g, offer.target, action.kind, action.must);
b.nexusBorrowedVoice = nexusRule(() => declareNexusBorrowedVoice(b.nexusBorrowedVoice!, p.id, voice));
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
b.voice = voice;
battlePreparation(g, 'prescience');
g.response = { kind: 'voice', owner: p.id, passed: [] };
log(g, `${p.name} spent Bene Gesserit Nexus Secret Ally to use the Voice before Prescience. Provisional policy: Karama may cancel the advantage; the Nexus card remains spent.`,
{ faction: p.faction, name: 'Borrowed Voice' });
}
function bgBetrayalWindow(g: Game) {
const response = g.response, battle = g.battle;
return !!(g.nexusCards?.cards && g.status === 'playing' && g.phase === 6 &&
@@ -12161,7 +12251,7 @@ function beginBattlePowers(g: Game) {
)
g.decision = { kind: 'choamBattleFunding', player: choam.id };
else {
battlePreparation(g, 'voice');
battlePreparation(g, 'voice', true);
combatResponses(g);
}
}
@@ -20377,6 +20467,7 @@ function currentCombatResponseQuote(
status: g.status,
phase: g.phase,
advanced: g.advanced,
turn: g.turn,
...(g.battle?.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
territoryIds: combatLocations(g).map((t) => t.id),
players: g.players.map((p) => ({
@@ -20456,8 +20547,18 @@ function finishBattlePreparation(g: Game) {
currentCombatResponseQuote(g, { kind: 'finishPreparation' }),
);
}
function battlePreparation(g: Game, kind: 'voice' | 'prescience') {
function battlePreparation(g: Game, kind: 'voice' | 'prescience', fresh = false) {
const b = g.battle!;
if (fresh && kind === 'voice' && g.nexusCards?.cards && !byFaction(g, 'beneGesserit') &&
!b.nexusBorrowedVoice && !b.voice && !b.prescience && !b.nexusInspection &&
!Object.keys(b.plans).length) {
requireRule(b.event, 'Borrowed Voice requires the original battle event.');
b.nexusBorrowedVoice = createNexusBorrowedVoice({
turn: g.turn, battle: { event: b.event, attacker: b.attacker, defender: b.defender },
});
b.preparation = { kind: 'nexusVoice', owner: b.attacker, beneficiary: b.attacker };
return;
}
const owner = byFaction(g, kind === 'voice' ? 'beneGesserit' : 'atreides');
const combatants = [b.attacker, b.defender];
const beneficiary =
@@ -20612,6 +20713,7 @@ function marketGholaIntegrity(g: Game) {
nexusEmperorBetrayalIntegrity(g);
nexusFremenBetrayalIntegrity(g);
nexusBgBetrayalIntegrity(g);
nexusBorrowedVoiceIntegrity(g);
nexusFremenRevivalIntegrity(g);
nexusFremenCunningIntegrity(g);
nexusEcazBetrayalIntegrity(g);
@@ -25160,6 +25262,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
canceled,
}),
);
if (response.kind === 'voice' && g.battle?.nexusBorrowedVoice?.stage === 'declared')
g.battle.nexusBorrowedVoice = finishNexusBorrowedVoice(g.battle.nexusBorrowedVoice, canceled);
} else if (response.kind === 'guildTiming') {
if (canceled) {
const quote = movementCancellationQuote(g, response);
@@ -28839,6 +28943,7 @@ function applyActionInner(
if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; }
if (t === 'nexusFaceDancers') { playNexusFaceDancers(g, p, action); return g; }
if (t === 'nexusTleilaxuRevival') { playNexusTleilaxuRevival(g,p,action); return g; }
if (t === 'nexusBorrowedVoice') { playNexusBorrowedVoice(g, p, action); return g; }
if (t === 'nexusSuboids') { playNexusSuboids(g, p, action); return g; }
if (t === 'nexusAdvisors') { playNexusAdvisors(g, p, action); return g; }
if (t === 'nexusSardaukar') { playNexusSardaukar(g,p,action); return g; }
@@ -29678,7 +29783,7 @@ function applyActionInner(
'CHOAM battle funding is unavailable.',
);
pledgeAid(g, p, action.amount);
battlePreparation(g, 'voice');
battlePreparation(g, 'voice', true);
combatResponses(g);
return g;
}
@@ -32298,7 +32403,7 @@ function applyActionInner(
if (t === 'declineBattlePower') {
const b = g.battle;
requireRule(
b?.preparation?.owner === id && !['prescienceAnswer', 'nexusPrescienceAnswer'].includes(b.preparation.kind),
b?.preparation?.owner === id && !['nexusVoice', 'prescienceAnswer', 'nexusPrescienceAnswer'].includes(b.preparation.kind),
'You do not own this battle power decision.',
);
const kind = b.preparation.kind;
@@ -32393,34 +32498,7 @@ function applyActionInner(
'Voice must be chosen by Bene Gesserit before prescience and battle plans.',
);
const target = b.attacker === b.preparation.beneficiary ? b.defender : b.attacker;
const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target));
requireRule(occupation.status === 'allowed', occupation.blocked ?? 'This target cannot receive the Voice.');
requireRule(
VOICE_KINDS.includes(String(action.kind)),
'Choose a card type.',
);
if (action.kind === 'mirrorWeapon') {
const blocked = mirrorWeaponModeBlock(g);
requireRule(!blocked, blocked ?? 'Mirror Weapon is unavailable in this battle configuration.');
}
requireRule(typeof action.must === 'boolean', 'Choose require or forbid.');
if ((action.kind === 'stoneBurner' || action.kind === 'mirrorWeapon') && action.must) {
const target = getPlayer(
g,
b.attacker === b.preparation.beneficiary ? b.defender : b.attacker,
);
const blocked = stoneCompulsionBlock(g, target);
requireRule(
!blocked,
blocked ?? `${action.kind === 'mirrorWeapon' ? 'Mirror Weapon' : 'Stone Burner'} cannot be compelled in this battle.`,
);
}
b.voice = {
target:
b.attacker === b.preparation.beneficiary ? b.defender : b.attacker,
kind: action.kind as NonNullable<Battle['voice']>['kind'],
must: action.must,
};
b.voice = quoteVoiceCommand(g, target, action.kind, action.must);
battlePreparation(g, 'prescience');
g.response = { kind: 'voice', owner: id, passed: [] };
return g;
@@ -33133,6 +33211,7 @@ export function viewGame(state: Game, id: string) {
nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id),
nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id),
nexusBgBetrayal: nexusBgBetrayalOffer(g, id),
nexusBorrowedVoice: nexusBorrowedVoiceOffer(g, id),
nexusFremenRevival: fremenNexusRevivalOffer(g,id),
nexusMoritani: nexusMoritaniOffer(g,id),
nexusMoritaniBetrayal: moritaniBetrayalOffer(g,id,
+1
View File
@@ -70,6 +70,7 @@ const cancellationSources = {
battle: battleSource(g),
voice: g.battle?.voice ?? null,
preparation: g.battle?.preparation ?? null,
borrowedVoice: g.battle?.nexusBorrowedVoice ?? null,
}),
prescience: (g) => ({
battle: battleSource(g),
+169
View File
@@ -0,0 +1,169 @@
import { VOICE_KINDS } from './battle-cards';
import type { Battle } from './engine';
export type NexusBorrowedVoiceFrame = {
event: string;
turn: number;
battle: string;
order: [string, string];
passed: string[];
stage: 'choose' | 'declared' | 'active' | 'canceled' | 'passed';
owner: string | null;
/** A canceled command remains a receipt; only stage 'active' applies it. */
voice: {
target: string;
kind: NonNullable<Battle['voice']>['kind'];
must: boolean;
} | null;
};
export type NexusBorrowedVoiceContext = {
turn: number;
battle: { event: string; attacker: string; defender: string };
};
function requireVoice(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function validId(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function event(turn: number, battle: string): string {
return JSON.stringify(['nexusBorrowedVoice', turn, battle]);
}
function validateContext(context: NexusBorrowedVoiceContext): void {
requireVoice(
context && Number.isSafeInteger(context.turn) && context.turn > 0 &&
context.battle && validId(context.battle.event) &&
validId(context.battle.attacker) && validId(context.battle.defender) &&
context.battle.attacker !== context.battle.defender,
'Borrowed Voice needs its original turn and distinct combatants.',
);
}
function validateFrame(frame: NexusBorrowedVoiceFrame): void {
requireVoice(
frame && typeof frame === 'object' && !Array.isArray(frame) &&
Object.keys(frame).sort().join(',') === 'battle,event,order,owner,passed,stage,turn,voice' &&
Number.isSafeInteger(frame.turn) && frame.turn > 0 &&
validId(frame.battle) && frame.event === event(frame.turn, frame.battle) &&
Array.isArray(frame.order) && frame.order.length === 2 &&
validId(frame.order[0]) && validId(frame.order[1]) && frame.order[0] !== frame.order[1] &&
Array.isArray(frame.passed) && frame.passed.length <= 2 &&
(frame.passed.length === 0 || frame.passed[0] === frame.order[0]) &&
(frame.passed.length < 2 || frame.passed[1] === frame.order[1]) &&
['choose', 'declared', 'active', 'canceled', 'passed'].includes(frame.stage),
'Borrowed Voice lost its original event or ordered opportunity.',
);
if (frame.stage === 'choose' || frame.stage === 'passed') {
requireVoice(
frame.owner === null && frame.voice === null &&
(frame.stage === 'passed' ? frame.passed.length === 2 : frame.passed.length < 2),
'An undecided or all-passed borrowed Voice cannot contain a declaration.',
);
return;
}
requireVoice(
frame.passed.length < 2 && frame.owner === frame.order[frame.passed.length] &&
frame.voice && typeof frame.voice === 'object' && !Array.isArray(frame.voice) &&
Object.keys(frame.voice).sort().join(',') === 'kind,must,target' &&
frame.voice.target === frame.order[1 - frame.passed.length] &&
typeof frame.voice.kind === 'string' && VOICE_KINDS.includes(frame.voice.kind) &&
typeof frame.voice.must === 'boolean',
'Borrowed Voice needs one ordered owner and a canonical opposing command.',
);
}
export function validateNexusBorrowedVoice(
context: NexusBorrowedVoiceContext,
frame: NexusBorrowedVoiceFrame,
): void {
validateContext(context);
validateFrame(frame);
requireVoice(
frame.turn === context.turn && frame.battle === context.battle.event &&
frame.order[0] === context.battle.attacker && frame.order[1] === context.battle.defender,
'Borrowed Voice must remain bound to its original battle and combatants.',
);
}
export function createNexusBorrowedVoice(
context: NexusBorrowedVoiceContext,
): NexusBorrowedVoiceFrame {
validateContext(context);
return {
event: event(context.turn, context.battle.event),
turn: context.turn,
battle: context.battle.event,
order: [context.battle.attacker, context.battle.defender],
passed: [],
stage: 'choose',
owner: null,
voice: null,
};
}
export function currentNexusBorrowedVoiceOwner(frame: NexusBorrowedVoiceFrame): string | null {
validateFrame(frame);
return frame.stage === 'choose' ? frame.order[frame.passed.length] : null;
}
export function passNexusBorrowedVoice(
frame: NexusBorrowedVoiceFrame,
actor: string,
): NexusBorrowedVoiceFrame {
requireVoice(
currentNexusBorrowedVoiceOwner(frame) === actor && frame.stage === 'choose',
'Only the current combatant may continue past borrowed Voice.',
);
const passed = [...frame.passed, actor];
return {
...frame,
order: [...frame.order],
passed,
stage: passed.length === 2 ? 'passed' : 'choose',
};
}
export function declareNexusBorrowedVoice(
frame: NexusBorrowedVoiceFrame,
actor: string,
voice: NonNullable<NexusBorrowedVoiceFrame['voice']>,
): NexusBorrowedVoiceFrame {
requireVoice(
currentNexusBorrowedVoiceOwner(frame) === actor && frame.stage === 'choose',
'Only the current combatant may declare borrowed Voice once.',
);
const next: NexusBorrowedVoiceFrame = {
...frame,
order: [...frame.order],
passed: [...frame.passed],
stage: 'declared',
owner: actor,
voice: voice && { ...voice },
};
validateFrame(next);
return next;
}
/** Settle the original Voice response exactly once; cancellation never reopens the queue. */
export function finishNexusBorrowedVoice(
frame: NexusBorrowedVoiceFrame,
canceled: boolean,
): NexusBorrowedVoiceFrame {
validateFrame(frame);
requireVoice(
frame.stage === 'declared' && typeof canceled === 'boolean',
'Only an unsettled borrowed Voice declaration can finish its response.',
);
return {
...frame,
order: [...frame.order],
passed: [...frame.passed],
stage: canceled ? 'canceled' : 'active',
voice: { ...frame.voice! },
};
}
+6 -6
View File
@@ -4565,7 +4565,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'With Tleilaxu absent, its actual unallied Nexus holder may use a quiet Revival control to return one mandatory own dead leader free and up to five exact physical counters for one bank spice each. The provisional policy uses the ordinary leader slot and expands the shared ordinary ceiling to at least five, counting prior returns and retaining larger native/Recruits allowance. Only the unopened roster trigger is waived: repeated deaths, actual stars/Cyborgs and special custody retain canonical rules, and ordinary cycle history is not fabricated. Zero forces is legal; force-only use is not. Spend the physical card once; native Tleilaxu income and cancellation are not imported.',
'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.',
'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.',
'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.',
'With BG absent, every fresh Nexus battle asks both combatants in attacker order before Prescience, Nexus inspection or sealed plans. Continue preserves the card. The actual unallied BG-card holder may spend it to require or forbid one canonical type against the opponent. Under the explicit provisional recommendation, original Karama can cancel this named Voice advantage; the Nexus stays spent, only Voice is removed and native preparation continues. Existing in-flight preparation is not retrofitted. Native compliance and special/mode restrictions remain; this does not complete other unfinished effects.',
'Bene Gesserit Betrayal can prevent the native Voice while the ordinary pre-plan response is open. In Nexus games every seat responds even without a canceling card, so the window does not reveal the hidden holder. An unallied opponent spends one physical Bene Gesserit Nexus card to remove only that declared Voice; Prescience and plan commitments continue. Truthtrance has priority. This path does not grant borrowed Voice.',
'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 remain unavailable. The separate Secret Ally revival and normal bank-auction purchase have connected controls; seller-paid and forced Emperor payments remain unfinished.',
@@ -4596,11 +4596,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle and existing native/borrowed effects remain connected. Tleilaxu Secret Ally now returns one free mandatory own leader with up to five exact paid counters, sharing ordinary force allowance and the leader slot under an explicit provisional policy. Original cycle/custody, typed caps, bank payment, skill return and Homeworld force source remain connected. Borrowed Voice, exceptional custody, worm-protection responses and broader combinations remain open.', 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-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Existing private lifecycle and effect controls remain. Tleilaxu Secret Ally adds owner-only free-leader and exact paid-force/elite selection, authoritative price, zero-force use and a visible provisional-policy explanation. The spent card removes the offer and original Revival Ready remains usable. Remaining borrowed/reactive effects and complete modes are open.', 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/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four legal profiles retain existing private effect policies and now execute actual Tleilaxu Secret Ally own-leader/five-paid-counter returns through the shared offered selection. Native Cunning priority/private boundaries remain. Complete module strategy and calibration are deferred and 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', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries. Guild Betrayal separately records full funded payment, own pre-funding, donor escrow once, the printed full-Junction override, four native adapters and neutral privacy; ordinary occupation/rounding and general response-priority questions remain unresolved.', 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_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Current Tleilaxu revival batch passes141/141 in9 related files, types/lint and build. Genuine Basic/Advanced weapon death/eight-counter exchange, worm alliance and actual Nexus draw feed leader/paid/zero returns, both shared-allowance orders, stale/invalid rejection and the next real turn. Actual390px Dr. Yueh/five return changes spice11 to6, Tanks8 to3 and reserves10 to15 with original cycle0/deaths1, one card discard and Ready restored; no overflow/errors. Four policies execute the actual return. Earlier mixed-family evidence stays source-bound; complete modes, assurance and deployed acceptance remain open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.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', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Existing physical effects remain connected. Tleilaxu Secret Ally retains its provisional shared revival policy. Absent-BG borrowed Voice now uses a neutral attacker-first pre-inspection queue, actual unallied holder and one physical card declaration. Original Karama can cancel only Voice while preserving spent Nexus, receipt and Prescience. Native command checks are shared; old preparations are not retrofitted. Exceptional custody, worm protection and wider combinations remain open.', 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-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Existing private lifecycle and Tleilaxu free-leader/paid-counter controls remain. Borrowed Voice adds neutral Continue and current-holder canonical require/forbid selection before Prescience, with a visible provisional cancellation policy. Original Karama and native preparation controls continue. Remaining reactive combinations and complete modes are open.', 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/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal profiles retain existing effect policies, actual Tleilaxu combined returns, and now answer both borrowed Voice opportunities through their own projected offer. Cardless Continue does not spend; eligible Use opens the original response. Complete strategy and calibration remain deferred and 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', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve faces retain source-bound remaining custody, inquiry, nested worm, Great Maker, fresh advisor/storm and combination questions. Borrowed Voice explicitly adopts recommended cancellable named-advantage semantics and a neutral pre-Prescience queue; this is not publisher clarification. Existing Richese and Guild payment/privacy contracts retain their own limits. Full mode and response-priority acceptance remain open.', 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_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Borrowed Voice related4-file union50/50, types/lint and build pass. Genuine Basic/Advanced setup, paid Karama, worm/alliance/Nexus draw and reserve shipment produce the original battle; cases exercise order, spend, cancellation, compliance, impossible compliance, all-pass and four legal profiles. Actual390px Continue, projectile command, Karama, native Prescience decline and Seal battle plan preserve one spent Nexus and canceled receipt without overflow/errors after QA configuration repair. Earlier Tleilaxu and mixed-family evidence remains bounded; full modes, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.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', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts'] },
],
},
{