Implement Bene Gesserit Nexus advisor conversion with durable responses

This commit is contained in:
Quintine committed 2026-09-10 11:13:08 +10:00
1 parent 40912498b1
commit b05125de9a
23 files changed
+2298 -16

No files matched your search

+7 -2
View File
@@ -2,6 +2,7 @@ import { nexusCardBotActions } from './nexus-card-options';
import { nexusTraitorBotActions } from './nexus-traitor-options';
import { nexusTleilaxuBotActions } from './nexus-tleilaxu-options';
import { nexusSuboidBotActions } from './nexus-suboid-options';
import { nexusAdvisorBotActions } from './nexus-advisor-options';
import { tupileIntelligenceActions } from './tupile-intelligence-options';
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
@@ -1325,9 +1326,11 @@ function policyActions(g: GameView): Action[] {
g.auditor?.opponent === me.id &&
(g.auditor.count ?? 0) > 0;
const movementThreat =
g.response.kind === 'fremenMovement' &&
(g.response.kind === 'fremenMovement' &&
!!g.response.location &&
fighterCount(me, splitLocation(g.response.location).territory) > 0;
fighterCount(me, splitLocation(g.response.location).territory) > 0) ||
(g.response.kind === 'nexusAdvisorFlip' &&
!!g.nexusAdvisors?.pending?.territories.some((territory) => fighterCount(me, territory) > 0));
const card = me.hand?.find((c) =>
g.responseControls?.cancelCards.includes(c.id),
);
@@ -3323,6 +3326,8 @@ export function botActions(g: GameView): Action[] {
: nexusTraitorBotActions(g);
if (g.automaticContinuationPending) return [];
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
const advisorConversion = nexusAdvisorBotActions(g);
if (advisorConversion.length) return advisorConversion;
const suboids = nexusSuboidBotActions(g);
if (suboids.length) return suboids;
const faceDancers = nexusTleilaxuBotActions(g);
+125
View File
@@ -2,6 +2,7 @@ import { createNexusInspection, allowNexusInspection, answerNexusInspection, reo
import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTraitorExchange, validateNexusTraitorHistory, validateNexusTraitorSnapshot, type NexusTraitorExchange, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
import { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFaceDancerReceipt } from './nexus-face-dancers';
import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type NexusSuboidReceipt } from './nexus-suboids';
import { quoteNexusAdvisors, createNexusAdvisors, validateNexusAdvisors, type NexusAdvisorReceipt } from './nexus-advisors';
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
@@ -791,6 +792,7 @@ export type ResponseWindow = {
| 'eliteStrength'
| 'fremenSupport'
| 'advisorFlip'
| 'nexusAdvisorFlip'
| 'bgCharity'
| 'choamCharity'
| 'choamInflation'
@@ -1263,6 +1265,8 @@ export type Game = {
nexusFaceDancerHistory?: NexusFaceDancerReceipt[];
nexusSuboidHistory?: NexusSuboidReceipt[];
nexusSuboidLast?: { event: string; owner: string; turn: number };
nexusAdvisorHistory?: { receipt: NexusAdvisorReceipt; stage: 'pending' | 'completed' | 'canceled'; frame: string; signature: string }[];
nexusAdvisorLast?: { event: string; stage: 'pending' | 'completed' | 'canceled' };
nexusTraitorPending?: string | null;
nexusTraitorParent?: { event: string; signature: string } | null;
/** Null/absent disables the module; custody is installed at force placement. */
@@ -1746,6 +1750,118 @@ function commitNexusSuboids(g: Game, p: Player) {
{ faction: p.faction, name: 'Nexus Suboid strength' });
}
function nexusAdvisorFrame(g: Game, owner: string) {
const p = getPlayer(g, owner);
return JSON.stringify([g.status, g.turn, g.phase, g.active, g.movementRemaining,
p.shipped, p.moved, g.hajr, g.guildTimingGranted, g.guildTimingLocked]);
}
function nexusAdvisorRecordSignature(record: NonNullable<Game['nexusAdvisorHistory']>[number]) {
return JSON.stringify([record.receipt.signature, record.stage, record.frame]);
}
function pendingNexusAdvisors(g: Game) {
return g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null;
}
function validateNexusAdvisorResponse(g: Game, response: ResponseWindow) {
const record = pendingNexusAdvisors(g);
requireRule(record && response.kind === 'nexusAdvisorFlip' &&
response.owner === record.receipt.owner && response.intent === record.receipt.event &&
Object.keys(response).every(key => ['kind', 'owner', 'intent', 'passed'].includes(key)) &&
Array.isArray(response.passed) && new Set(response.passed).size === response.passed.length &&
response.passed.every(id => g.players.some(p => p.id === id)),
'The Nexus advisor conversion has lost its original response.');
requireRule(g.status === 'playing' && g.phase === 5 && g.active === record.receipt.owner &&
record.receipt.turn === g.turn && record.frame === nexusAdvisorFrame(g, record.receipt.owner),
'The Nexus advisor conversion has lost its original Shipment and Movement action.');
nexusRule(() => validateNexusAdvisors(g, record.receipt, true));
}
function nexusAdvisorIntegrity(g: Game) {
const continuation = g.pendingTreacheryDiscard?.continuation;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
const responses = contexts.flatMap(context => {
const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
return [context?.response, pending?.use.kind === 'cancel' ? pending.use.response : null];
}).filter(response => response?.kind === 'nexusAdvisorFlip');
const history = g.nexusAdvisorHistory;
if (history === undefined) {
requireRule(!g.nexusAdvisorLast && !responses.length, 'The Nexus advisor conversion has lost its saved history.');
return;
}
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
'Nexus advisor history requires its original module and played card.');
const turns = new Set<number>();
for (const [index, record] of history.entries()) {
requireRule(record && record.receipt && typeof record.receipt === 'object' &&
Object.keys(record).sort().join(',') === 'frame,receipt,signature,stage' &&
['pending', 'completed', 'canceled'].includes(record.stage) &&
typeof record.frame === 'string' && record.signature === nexusAdvisorRecordSignature(record) &&
record.receipt.event === JSON.stringify(['nexusAdvisors', record.receipt.turn, record.receipt.owner, index]) &&
!turns.has(record.receipt.turn) &&
(record.stage !== 'pending' || index === history.length - 1),
'The Nexus advisor conversion history is malformed.');
nexusRule(() => validateNexusAdvisors(g, record.receipt, record.stage === 'pending'));
turns.add(record.receipt.turn);
}
const last = history.at(-1)!;
requireRule(JSON.stringify(g.nexusAdvisorLast) === JSON.stringify({event: last.receipt.event, stage: last.stage}),
'The Nexus advisor conversion has lost its latest outcome.');
requireRule((last.stage === 'pending') === (responses.length > 0),
'The Nexus advisor conversion has lost or reopened its cancellation response.');
if (last.receipt.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('beneGesserit'),
'The advisor conversion has lost its spent Nexus card.');
for (const response of responses) validateNexusAdvisorResponse(g, response!);
}
function projectedNexusAdvisors(g: Game, id: string) {
if (!g.nexusCards?.cards) return null;
const record = pendingNexusAdvisors(g);
const pending = record ? {owner: record.receipt.owner, event: record.receipt.event,
territories: record.receipt.selections.map(selection => selection.territory)} : null;
const p = getPlayer(g, id);
if (p.faction !== 'beneGesserit' || g.nexusCards.cards.hands[id] !== 'beneGesserit') return {offer: null, pending};
let blocked: string | null = null;
if (!g.advanced) blocked = 'Advisors are available only in the Advanced game.';
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== id)
blocked = 'Use Cunning during your own Shipment and Movement action.';
else if (p.ally) blocked = 'Allied players cannot use a Nexus card.';
else if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama ||
g.pendingTreacheryDiscard || g.pendingNullentropy || pendingNexusTraitors(g) || record)
blocked = 'Finish the current interaction before converting advisors.';
else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.';
const territories = nexusRule(() => quoteNexusAdvisors(g, id)).territories;
if (!blocked && !territories.some(group => !group.blocked)) blocked = 'No advisor group is currently available to convert.';
return {offer: {event: JSON.stringify(['nexusAdvisors', g.turn, id, g.nexusAdvisorHistory?.length ?? 0]), blocked, territories}, pending};
}
function playNexusAdvisors(g: Game, p: Player, action: Action) {
const offer = projectedNexusAdvisors(g, p.id)?.offer;
requireRule(Object.keys(action).every(key => ['type', 'event', 'territories'].includes(key)) &&
offer && action.event === offer.event && !offer.blocked,
offer?.blocked ?? 'Choose your current Nexus advisor conversion.');
requireRule(Array.isArray(action.territories) && action.territories.every(t => typeof t === 'string'),
'Select whole advisor territories.');
const receipt = nexusRule(() => createNexusAdvisors(g, p.id, offer.event, action.territories as string[]));
const record = {receipt, stage: 'pending' as const, frame: nexusAdvisorFrame(g, p.id), signature: ''};
record.signature = nexusAdvisorRecordSignature(record);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
(g.nexusAdvisorHistory ??= []).push(record);
g.nexusAdvisorLast = {event: receipt.event, stage: record.stage};
g.response = {kind: 'nexusAdvisorFlip', owner: p.id, intent: receipt.event, passed: []};
log(g, `${p.name} spent Bene Gesserit Nexus Cunning to convert all advisors in ${receipt.selections.map(s => territory(s.territory).name).join(', ')}. Karama may prevent this entire conversion.`,
{faction: p.faction, name: 'Nexus advisor conversion declared'});
}
function finishNexusAdvisors(g: Game, canceled: boolean) {
const record = pendingNexusAdvisors(g)!;
const p = getPlayer(g, record.receipt.owner);
if (!canceled) for (const selection of record.receipt.selections) delete p.advisors![selection.territory];
record.stage = canceled ? 'canceled' : 'completed';
record.signature = nexusAdvisorRecordSignature(record);
g.nexusAdvisorLast = {event: record.receipt.event, stage: record.stage};
log(g, canceled
? 'Karama prevented the entire Nexus advisor conversion. Every selected group remains advisors; the Nexus card stays spent.'
: `${p.name} converted every advisor in ${record.receipt.selections.map(s => territory(s.territory).name).join(', ')} to fighters. The forces remain in place and no shipment, movement or spice was spent.`,
{faction: p.faction, name: canceled ? 'Nexus advisor conversion prevented' : 'Nexus advisors become fighters'});
}
function battleInspectionContext(g: Game): BattleInspectionContext {
const b = g.battle!;
return { event: b.event!, attacker: b.attacker, defender: b.defender,
@@ -6371,6 +6487,7 @@ function validateKaramaUse(
currentIxAuctionDrawQuote(g, use.response, true, spendingCard);
moritaniAllianceCancellationQuote(g, use.response);
if (use.response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, use.response);
if (use.response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, use.response);
if (isCombatResponseKind(use.response.kind))
currentCombatResponseQuote(g, {
kind: 'response',
@@ -11760,6 +11877,7 @@ function marketGholaIntegrity(g: Game) {
nexusTraitorIntegrity(g);
nexusFaceDancerIntegrity(g);
nexusSuboidIntegrity(g);
nexusAdvisorIntegrity(g);
traitorDeclarationIntegrity(g);
nexusInspectionIntegrity(g);
homeworldHistoryIntegrity(g);
@@ -14146,6 +14264,7 @@ function finishResponse(g: Game, canceled: boolean) {
currentFactionPayment(g);
const response = g.response!;
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
const ecazCollectionQuote =
response.kind === 'ecazCollection'
? currentEcazCollectionQuote(g, response, canceled)
@@ -14174,6 +14293,10 @@ function finishResponse(g: Game, canceled: boolean) {
? moritaniAllianceCancellationQuote(g, response)
: null;
g.response = null;
if (response.kind === 'nexusAdvisorFlip') {
finishNexusAdvisors(g, canceled);
return;
}
if (ecazCollectionQuote) {
if (canceled)
log(
@@ -16970,6 +17093,7 @@ function applyActionInner(
if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; }
if (t === 'nexusFaceDancers') { playNexusFaceDancers(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 === 'nexusTraitorReturn') {
requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.');
finishNexusTraitorReturn(g, p, action); return g;
@@ -20439,6 +20563,7 @@ export function viewGame(state: Game, id: string) {
nexusTraitors: projectedNexusTraitors(g, id),
nexusTleilaxu: projectedNexusTleilaxu(g, id),
nexusSuboids: projectedNexusSuboids(g, id),
nexusAdvisors: projectedNexusAdvisors(g, id),
nexusAtreides: nexusAtreidesOffer(g, id),
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
+4
View File
@@ -80,6 +80,10 @@ const cancellationSources = {
inspection: g.battle?.nexusInspection ?? null,
preparation: g.battle?.preparation ?? null,
}),
nexusAdvisorFlip: (g) => ({
...movementSource(g),
conversion: g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null,
}),
advisor: (g, response) =>
response.advisorResume === 'ambassador'
? {
+74
View File
@@ -0,0 +1,74 @@
import type { Action, GameView } from './engine';
import { fighterCount } from './advisors';
export function nexusAdvisorCanAct(game: GameView): boolean {
const owner = game.players.find((player) => player.id === game.me);
return !!(
game.status === 'playing' &&
game.advanced &&
game.phase === 5 &&
game.active === game.me &&
owner?.faction === 'beneGesserit' &&
!owner.ally &&
game.nexusCards?.card === 'beneGesserit' &&
!game.automaticContinuationPending &&
!game.truthtrance &&
!game.response &&
!game.decision &&
!game.phaseOpening &&
!game.nexusCards.waiting.length &&
!game.nexusTraitors?.pending &&
!game.nexusAdvisors?.pending &&
game.nexusAdvisors?.offer &&
!game.nexusAdvisors.offer.blocked
);
}
/** Whole-territory legality and quantities come from the private server quote. */
export function nexusAdvisorAction(
game: GameView,
event: string,
territories: readonly string[],
): Action | null {
if (!nexusAdvisorCanAct(game)) return null;
const offer = game.nexusAdvisors!.offer!;
if (
!event ||
offer.event !== event ||
!territories.length ||
new Set(territories).size !== territories.length ||
!territories.every((territory) =>
offer.territories.some(
(choice) =>
choice.territory === territory &&
!choice.blocked &&
Number.isSafeInteger(choice.count) &&
choice.count > 0,
),
)
)
return null;
return { type: 'nexusAdvisors', event, territories: [...territories] };
}
/** Prefer publicly favorable battles, using no hidden cards or No-Field values.
* This is the shared legal policy, not a calibrated battle-strength estimate. */
export function nexusAdvisorBotActions(game: GameView): Action[] {
if (!nexusAdvisorCanAct(game)) return [];
const owner = game.players.find((player) => player.id === game.me)!;
const offer = game.nexusAdvisors!.offer!;
const territories = offer.territories
.filter((choice) => {
if (choice.blocked) return false;
const enemies = game.players
.filter((player) => player.id !== owner.id && player.id !== owner.ally)
.reduce(
(count, player) => count + fighterCount(player, choice.territory),
0,
);
return enemies > 0 && enemies < choice.count;
})
.map((choice) => choice.territory);
const action = nexusAdvisorAction(game, offer.event, territories);
return action ? [action] : [];
}
+363
View File
@@ -0,0 +1,363 @@
import {
gameDistance,
gameTerritories,
location,
MOBILE_LOCATION,
splitLocation,
territory,
validLocation,
} from './board';
import {
settledBoard,
type BoardContext,
type BoardSeat,
} from './board-resolution-quote';
import { presenceAt, presenceByLocation } from './force-presence';
import { fighterCount } from './advisors';
import { territoryEntryBlock } from './occupancy';
import { ecazOccupancyIdentity } from './ecaz-occupy';
export type NexusAdvisorContext = BoardContext & { turn: number };
export type NexusAdvisorSelection = {
territory: string;
count: number;
locations: { location: string; count: number }[];
noField: string | null;
stance: { lockedTurn?: number };
};
export type NexusAdvisorReceipt = {
version: 1;
event: string;
owner: string;
turn: number;
selections: NexusAdvisorSelection[];
signature: string;
};
function requireAdvisors(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new Error(message);
}
function record(value: unknown): value is Record<string, unknown> {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value))
);
}
function exact(value: unknown, keys: string[]): boolean {
return (
record(value) &&
Object.keys(value).sort().join(',') === [...keys].sort().join(',')
);
}
const whole = (value: unknown): value is number =>
Number.isSafeInteger(value) && Number(value) >= 0;
const text = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
function validStance(
stance: unknown,
turn: number,
): stance is { lockedTurn?: number } {
return (
record(stance) &&
Object.keys(stance).every((key) => key === 'lockedTurn') &&
(stance.lockedTurn === undefined ||
(whole(stance.lockedTurn) && stance.lockedTurn <= turn))
);
}
function board(context: NexusAdvisorContext, owner: string) {
requireAdvisors(
whole(context.turn) &&
context.turn > 0 &&
typeof context.advanced === 'boolean' &&
Array.isArray(context.players),
'Nexus advisors need a valid public board and turn.',
);
ecazOccupancyIdentity(context.players, owner, {
kind: 'territory',
id: 'polar_sink',
});
const player = context.players.find((p) => p.id === owner)!;
requireAdvisors(
player.faction === 'beneGesserit',
'Only native Bene Gesserit may convert Nexus advisors.',
);
const sites = gameTerritories(context);
for (const p of context.players)
if (p.advisors !== undefined) {
requireAdvisors(
record(p.advisors),
'Advisor stances need a valid territory map.',
);
for (const [id, stance] of Object.entries(p.advisors)) {
// Homeworld invasions are outside the advisor territory subsystem.
if (id.startsWith('homeworld:')) continue;
requireAdvisors(
sites.some((site) => site.id === id) &&
validStance(stance, context.turn),
'Invalid original advisor territory stance.',
);
}
}
const settled = settledBoard(context);
return { player, sites, settled };
}
function selection(player: BoardSeat, to: string): NexusAdvisorSelection {
const locations = Object.entries(player.forces)
.filter(([key, count]) => count > 0 && splitLocation(key).territory === to)
.sort(([one], [two]) => one.localeCompare(two))
.map(([key, count]) => ({ location: key, count }));
const marker = player.noField?.deployed?.location;
const noField =
marker?.territory === to ? location(marker.territory, marker.sector) : null;
const count =
locations.reduce((sum, group) => sum + group.count, 0) + (noField ? 1 : 0);
return {
territory: to,
count,
locations,
noField,
stance:
player.advisors![to].lockedTurn === undefined
? {}
: { lockedTurn: player.advisors![to].lockedTurn },
};
}
function stormBlocked(
context: NexusAdvisorContext,
players: readonly BoardSeat[],
owner: BoardSeat,
to: string,
): boolean {
if (context.storm === 0 || territory(to).type === 'polar') return false;
const inStorm = (key: string) =>
splitLocation(
key === MOBILE_LOCATION
? (context.mobileStronghold?.location ?? key)
: key,
).sector === context.storm;
const keys = (p: BoardSeat) =>
Object.entries(presenceByLocation(p))
.filter(
([key, count]) => count > 0 && splitLocation(key).territory === to,
)
.map(([key]) => key);
const own = keys(owner);
if (own.some(inStorm)) return true;
// Only storm-caused separation is an unresolved interaction. No general
// requirement to be in a battle-capable territory is imposed by this card.
for (const other of players) {
if (
other.id === owner.id ||
other.id === owner.ally ||
!fighterCount(other, to)
)
continue;
const theirs = keys(other);
if (
own.some((x) =>
theirs.some((y) => x === y || gameDistance(context, x, y) === 0),
) &&
!own.some((x) =>
theirs.some(
(y) =>
!inStorm(x) &&
!inStorm(y) &&
(x === y || gameDistance(context, x, y, inStorm) === 0),
),
)
)
return true;
}
return false;
}
export function quoteNexusAdvisors(
context: NexusAdvisorContext,
owner: string,
): {
territories: { territory: string; count: number; blocked: string | null }[];
} {
const { player, sites, settled } = board(context, owner);
if (!context.advanced) return { territories: [] };
const territories = sites
.filter(
(site) =>
player.advisors?.[site.id] &&
presenceAt(player, site.id) > 0 &&
!settled.released.some(
(release) =>
release.player === owner && release.territory === site.id,
),
)
.map((site) => {
const original = selection(player, site.id);
let blocked = territoryEntryBlock(settled.players, owner, site.id);
if (!blocked && player.advisors![site.id].lockedTurn === context.turn)
blocked =
'Whether Nexus Cunning overrides the same-turn advisor lock is unresolved.';
if (!blocked && stormBlocked(context, settled.players, player, site.id))
blocked =
'Nexus advisor conversion involving storm-blocked forces awaits a ruling.';
return { territory: site.id, count: original.count, blocked };
});
return { territories };
}
function signature(receipt: Omit<NexusAdvisorReceipt, 'signature'>): string {
return JSON.stringify([
'nexusAdvisors',
receipt.version,
receipt.event,
receipt.owner,
receipt.turn,
receipt.selections.map((s) => [
s.territory,
s.count,
s.locations.map((group) => [group.location, group.count]),
s.noField,
Object.hasOwn(s.stance, 'lockedTurn') ? [s.stance.lockedTurn] : null,
]),
]);
}
function validateReceipt(receipt: NexusAdvisorReceipt): void {
requireAdvisors(
exact(receipt, [
'version',
'event',
'owner',
'turn',
'selections',
'signature',
]) &&
receipt.version === 1 &&
text(receipt.event) &&
text(receipt.owner) &&
whole(receipt.turn) &&
receipt.turn > 0 &&
Array.isArray(receipt.selections) &&
receipt.selections.length > 0 &&
new Set(receipt.selections.map((s) => s.territory)).size ===
receipt.selections.length,
'Invalid saved Nexus advisor selection.',
);
for (const chosen of receipt.selections) {
requireAdvisors(
exact(chosen, ['territory', 'count', 'locations', 'noField', 'stance']) &&
text(chosen.territory) &&
validLocation(
chosen.territory,
territory(chosen.territory).sectors[0],
) &&
whole(chosen.count) &&
chosen.count > 0 &&
Array.isArray(chosen.locations) &&
validStance(chosen.stance, receipt.turn) &&
(!Object.hasOwn(chosen.stance, 'lockedTurn') ||
chosen.stance.lockedTurn !== undefined),
'Invalid original Nexus advisor group.',
);
const validKey = (key: unknown) => {
if (!text(key)) return false;
const loc = splitLocation(key);
return (
loc.territory === chosen.territory &&
key === location(loc.territory, loc.sector) &&
validLocation(loc.territory, loc.sector)
);
};
requireAdvisors(
chosen.locations.every(
(group) =>
exact(group, ['location', 'count']) &&
validKey(group.location) &&
whole(group.count) &&
group.count > 0,
) &&
new Set(chosen.locations.map((group) => group.location)).size ===
chosen.locations.length &&
(chosen.noField === null || validKey(chosen.noField)) &&
chosen.count ===
chosen.locations.reduce((sum, group) => sum + group.count, 0) +
(chosen.noField ? 1 : 0),
'The Nexus advisor receipt needs exact original public force counts.',
);
}
requireAdvisors(
receipt.signature === signature(receipt),
'The Nexus advisor selection has lost its original stance or force counts.',
);
}
export function createNexusAdvisors(
context: NexusAdvisorContext,
owner: string,
event: string,
territories: string[],
): NexusAdvisorReceipt {
const quote = quoteNexusAdvisors(context, owner);
requireAdvisors(
text(event) &&
Array.isArray(territories) &&
territories.length > 0 &&
territories.every(text) &&
new Set(territories).size === territories.length,
'Choose a nonempty distinct set of advisor territories.',
);
for (const to of territories) {
const offer = quote.territories.find((offer) => offer.territory === to);
requireAdvisors(
offer && !offer.blocked,
offer?.blocked ?? 'Choose an original advisor territory.',
);
}
const player = context.players.find((p) => p.id === owner)!;
const receipt: NexusAdvisorReceipt = {
version: 1,
event,
owner,
turn: context.turn,
selections: territories.map((to) => selection(player, to)),
signature: '',
};
receipt.signature = signature(receipt);
validateNexusAdvisors(context, receipt, true);
return receipt;
}
export function validateNexusAdvisors(
context: NexusAdvisorContext,
receipt: NexusAdvisorReceipt,
pending: boolean,
): void {
validateReceipt(receipt);
requireAdvisors(
typeof pending === 'boolean' &&
whole(context.turn) &&
context.turn >= receipt.turn &&
context.players.some(
(p) => p.id === receipt.owner && p.faction === 'beneGesserit',
),
'The Nexus advisor receipt needs its original owner and turn.',
);
if (!pending) return;
requireAdvisors(
context.turn === receipt.turn,
'Complete the Nexus advisor conversion in its original turn.',
);
const quote = quoteNexusAdvisors(context, receipt.owner),
player = context.players.find((p) => p.id === receipt.owner)!;
for (const chosen of receipt.selections) {
const current = quote.territories.find(
(offer) => offer.territory === chosen.territory,
);
requireAdvisors(
current && !current.blocked,
current?.blocked ?? 'The original advisor group is no longer available.',
);
const actual = selection(player, chosen.territory);
requireAdvisors(
JSON.stringify(actual) === JSON.stringify(chosen),
'The pending Nexus conversion must retain its exact original forces and stance.',
);
}
}
+9 -6
View File
@@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [
searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '),
category: 'Advanced & expansions',
coverage: 'Partial',
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and Tleilaxu/Ixian Cunning are integrated. Other effects and complete module play remain in development.',
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian and Bene Gesserit Cunning are integrated. Other effects and complete module play remain in development.',
steps: [
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
@@ -3359,15 +3359,18 @@ export const RULE_TOPICS: RuleTopic[] = [
'Tleilaxu Cunning sets aside all your revealed Face Dancers, draws their replacements secretly, and only then shuffles the set-aside cards into the Traitor Deck. Unrevealed dancers stay in your hand. This single operation does not use or renew your ordinary Mentat replacement; the native all-three-revealed reset keeps its separate shuffle-before-draw order.',
'Ixian Cunning is used in your battle before submitting your Battle Plan. Your Suboids then count as strength one without spice support in every battle for the rest of the turn, in Basic and Advanced. Cyborg strength and support are unchanged. Earlier inspected or promised plan elements remain binding; you cannot activate the card if the new strength would make them impossible.',
'Tleilaxu Secret Ally revival remains unavailable pending its ordinary-allowance, leader-eligibility and optional-leader rulings. The printed fixed force price does not resolve those questions.',
'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.',
'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh and turn-wide Suboid strength. Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspection and exchange controls, atomic Tleilaxu refresh, and Ixian activation with Cyborg-only paid support. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries.', 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'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, controls, bot and recovery regressions; final native Cunning acceptance and complete module games remain separate requirements.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength and whole-territory advisor conversion. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection with one Karama response. 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'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; final advisor acceptance and complete module games remain separate requirements.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts'] },
],
},
...[
+1
View File
@@ -74,6 +74,7 @@ const RESPONSES = {
voice: true,
prescience: true,
nexusPrescience: true,
nexusAdvisorFlip: true,
advisor: true,
emperorIncome: true,
richesePurchaseIncome: true,