Implement Tleilaxu and Ixian Nexus Cunning with durable private recovery

This commit is contained in:
Quintine committed 2026-09-10 10:49:18 +10:00
1 parent d5830d260c
commit 40912498b1
32 files changed
+2761 -24

No files matched your search

+2
View File
@@ -167,6 +167,8 @@ function validateCombatant(
typeof f.freeSupport === 'boolean' &&
(f.eliteFreeSupport === undefined ||
typeof f.eliteFreeSupport === 'boolean') &&
(f.normalFreeSupport === undefined ||
typeof f.normalFreeSupport === 'boolean') &&
(f.normalFixedHalf === undefined ||
typeof f.normalFixedHalf === 'boolean') &&
Number.isFinite(p.dial) &&
+6
View File
@@ -1,5 +1,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 { tupileIntelligenceActions } from './tupile-intelligence-options';
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
@@ -3321,6 +3323,10 @@ export function botActions(g: GameView): Action[] {
: nexusTraitorBotActions(g);
if (g.automaticContinuationPending) return [];
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
const suboids = nexusSuboidBotActions(g);
if (suboids.length) return suboids;
const faceDancers = nexusTleilaxuBotActions(g);
if (faceDancers.length) return faceDancers;
const traitorExchange = nexusTraitorBotActions(g);
if (traitorExchange.length) return traitorExchange;
const nexus = g.nexusAtreides;
+21 -5
View File
@@ -2,6 +2,9 @@
export type CombatForces = {
normal: number;
normalFixedHalf?: boolean;
/** Normal counters alone retain full strength without support, overriding
* fixed half strength. Special counters keep their separate support rules. */
normalFreeSupport?: boolean;
elite: number;
eliteStrength: 1 | 2;
freeSupport: boolean;
@@ -31,6 +34,8 @@ export function validCombatForces(forces: CombatForces): boolean {
typeof forces.freeSupport === 'boolean' &&
(forces.eliteFreeSupport === undefined ||
typeof forces.eliteFreeSupport === 'boolean') &&
(forces.normalFreeSupport === undefined ||
typeof forces.normalFreeSupport === 'boolean') &&
(forces.normalFixedHalf === undefined ||
typeof forces.normalFixedHalf === 'boolean')
);
@@ -49,7 +54,9 @@ export function casualtyOptions(
!Number.isInteger(dial * 2) ||
!Number.isSafeInteger(support) ||
support < 0 ||
support > forces.normal + (forces.eliteFreeSupport ? 0 : forces.elite) ||
support >
(forces.normalFixedHalf || forces.normalFreeSupport ? 0 : forces.normal) +
(forces.eliteFreeSupport ? 0 : forces.elite) ||
(forces.freeSupport && support !== 0)
)
return [];
@@ -58,7 +65,8 @@ export function casualtyOptions(
for (let normal = 0; normal <= forces.normal; normal++) {
if (forces.freeSupport) {
if (
normal * (forces.normalFixedHalf ? 0.5 : 1) +
normal *
(forces.normalFixedHalf && !forces.normalFreeSupport ? 0.5 : 1) +
elite * forces.eliteStrength ===
dial
)
@@ -66,14 +74,22 @@ export function casualtyOptions(
continue;
}
for (
let paidElite = Math.max(0, support - normal);
let paidElite = Math.max(
0,
support -
(forces.normalFixedHalf || forces.normalFreeSupport ? 0 : normal),
);
paidElite <= (forces.eliteFreeSupport ? 0 : Math.min(elite, support));
paidElite++
) {
const paidNormal = support - paidElite;
if (forces.normalFixedHalf && paidNormal !== 0) continue;
if (
(forces.normalFixedHalf || forces.normalFreeSupport) &&
paidNormal !== 0
)
continue;
const doubledStrength =
normal +
normal * (forces.normalFreeSupport ? 2 : 1) +
paidNormal +
(forces.eliteFreeSupport ? elite * 2 : elite + paidElite) *
forces.eliteStrength;
+124 -4
View File
@@ -1,5 +1,7 @@
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';
import { createNexusSuboids, validateNexusSuboids, nexusSuboidsActive, type NexusSuboidReceipt } from './nexus-suboids';
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';
@@ -1258,6 +1260,9 @@ export type Game = {
/** Optional independent module; complete effect coverage is still release-gated. */
nexusCards?: { cards: NexusState | null; phase: NexusCardPhase | null } | null;
nexusTraitorExchanges?: NexusTraitorExchange[];
nexusFaceDancerHistory?: NexusFaceDancerReceipt[];
nexusSuboidHistory?: NexusSuboidReceipt[];
nexusSuboidLast?: { event: string; owner: string; turn: number };
nexusTraitorPending?: string | null;
nexusTraitorParent?: { event: string; signature: string } | null;
/** Null/absent disables the module; custody is installed at force placement. */
@@ -1641,6 +1646,106 @@ function projectedNexusTraitors(g: Game, id: string) {
} : null };
}
function nexusFaceDancerIntegrity(g: Game) {
if (g.nexusFaceDancerHistory === undefined) return;
requireRule(g.nexusCards?.cards && Array.isArray(g.nexusFaceDancerHistory),
'Face Dancer Nexus history requires its original module.');
const universe = nexusTraitorUniverse(g), events = new Set<string>();
nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), universe));
for (const receipt of g.nexusFaceDancerHistory) {
requireRule(!events.has(receipt.event) && receipt.turn <= g.turn &&
g.players.some(p => p.id === receipt.owner && p.faction === 'tleilaxu'),
'The Face Dancer replacement has lost its original owner or turn.');
events.add(receipt.event);
nexusRule(() => validateNexusFaceDancerHistory(universe, receipt));
}
}
function projectedNexusTleilaxu(g: Game, id: string) {
if (!g.nexusCards?.cards) return null;
if (g.nexusCards.cards.hands[id] !== 'tleilaxu') return { cunning: null };
const p = getPlayer(g, id);
if (p.faction !== 'tleilaxu') return { cunning: null };
const count = p.faceDancers?.filter(card => card.revealed).length ?? 0;
let blocked: string | null = null;
if (g.status !== 'playing') blocked = 'Use Cunning during an active game.';
else if (p.ally) blocked = 'Allied players cannot use a Nexus card.';
else if (g.truthtrance) blocked = 'Finish Truthtrance before replacing Face Dancers.';
else if (g.pendingTreacheryDiscard) blocked = 'Finish the automatic card disposal first.';
else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.';
else if (pendingNexusTraitors(g)) blocked = 'Finish the current private Traitor Deck return first.';
else if (!count) blocked = 'No revealed Face Dancers are available to replace.';
else if ((g.traitorReserve?.length ?? 0) < count) blocked = 'The Traitor Deck cannot supply the full replacement draw.';
return { cunning: { event: JSON.stringify(['nexusFaceDancers', g.turn, g.phase, g.nexusFaceDancerHistory?.length ?? 0, id]), count, blocked } };
}
function playNexusFaceDancers(g: Game, p: Player, action: Action) {
requireRule(Object.keys(action).every(key => ['type', 'event'].includes(key)),
'Cunning replaces every revealed Face Dancer; do not select a subset.');
const offer = projectedNexusTleilaxu(g, p.id)?.cunning;
requireRule(offer && action.event === offer.event && !offer.blocked,
offer?.blocked ?? 'Choose the current owned Face Dancer replacement.');
const result = nexusRule(() => replaceNexusFaceDancers(nexusTraitorSnapshot(g), nexusTraitorUniverse(g),
{ event: offer.event, owner: p.id, turn: g.turn, phase: g.phase }, random));
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
applyNexusTraitorSnapshot(g, result.state);
(g.nexusFaceDancerHistory ??= []).push(result.receipt);
log(g, `${p.name} set aside ${offer.count} revealed Face Dancer ${offer.count === 1 ? 'Card' : 'Cards'}, secretly drew replacements, then shuffled the set-aside cards into the Traitor Deck. Unrevealed Face Dancers and the normal Mentat replacement allowance are unchanged.`,
{ faction: p.faction, name: 'Nexus Face Dancer replacement' });
}
function nexusSuboidIntegrity(g: Game) {
if (g.nexusSuboidHistory === undefined) {
requireRule(!g.nexusSuboidLast, 'The Suboid Nexus effect has lost its saved history.');
return;
}
requireRule(g.nexusCards?.cards, 'Suboid Nexus history requires its original module.');
nexusRule(() => validateNexusSuboids(g, g.nexusSuboidHistory!));
const last = g.nexusSuboidHistory.at(-1);
requireRule(last && JSON.stringify(g.nexusSuboidLast) === JSON.stringify({ event: last.event, owner: last.owner, turn: last.turn }),
'The Suboid Nexus effect has lost its latest committed play.');
if (g.nexusSuboidHistory.some(receipt => receipt.turn === g.turn))
requireRule(g.nexusCards.cards.discard.includes('ixians'), 'The active Suboid effect has lost its spent Nexus card.');
}
function projectedNexusSuboids(g: Game, id: string) {
if (!g.nexusCards?.cards) return null;
const ixians = byFaction(g, 'ixians');
const active = !!ixians && nexusSuboidsActive(g, g.nexusSuboidHistory, ixians.id);
if (g.nexusCards.cards.hands[id] !== 'ixians' || ixians?.id !== id) return { offer: null, active };
const b = g.battle;
let blocked: string | null = null;
if (g.status !== 'playing' || g.phase !== 6 || !b || ![b.attacker, b.defender].includes(id))
blocked = 'Use Cunning in your battle before submitting your Battle Plan.';
else if (ixians.ally) blocked = 'Allied players cannot use a Nexus card.';
else if (b.revealed || b.plans[id]) blocked = 'Your Battle Plan is already submitted.';
else if (active) blocked = 'Your Suboids already have full strength this turn.';
else if (g.truthtrance || g.pendingTreacheryDiscard || pendingNexusTraitors(g)) blocked = 'Finish the current private question or card return first.';
else if (g.pendingNullentropy) blocked = 'Finish the paid Nullentropy search first.';
else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.';
const event = b?.event ? JSON.stringify(['nexusSuboids', g.turn, b.event, id]) : '';
if (!blocked) {
const trial = structuredClone(g);
(trial.nexusSuboidHistory ??= []).push(nexusRule(() => createNexusSuboids(trial, id, b!.event!)));
trial.nexusSuboidLast = { event, owner: id, turn: g.turn };
trial.nexusCards!.cards = nexusRule(() => discardNexusCard(trial.nexusCards!.cards!, id, trial.players));
if (!findReachableBattlePlan(trial, getPlayer(trial, id)))
blocked = 'Full-strength Suboids would prevent you from honoring an existing Battle Plan commitment.';
}
return { offer: { event, blocked }, active };
}
function playNexusSuboids(g: Game, p: Player, action: Action) {
const offer = projectedNexusSuboids(g, p.id)?.offer;
requireRule(Object.keys(action).every(key => ['type', 'event'].includes(key)) && offer &&
action.event === offer.event && !offer.blocked, offer?.blocked ?? 'Choose your current Ixian Cunning opportunity.');
commitNexusSuboids(g, p);
}
function commitNexusSuboids(g: Game, p: Player) {
const receipt = nexusRule(() => createNexusSuboids(g, p.id, g.battle!.event!));
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
(g.nexusSuboidHistory ??= []).push(receipt);
g.nexusSuboidLast = { event: receipt.event, owner: receipt.owner, turn: receipt.turn };
log(g, `${p.name} used Ixian Cunning. Every Suboid has full strength without spice support in all battles for the rest of this turn. Cyborg strength, support and physical force counts are unchanged.`,
{ faction: p.faction, name: 'Nexus Suboid strength' });
}
function battleInspectionContext(g: Game): BattleInspectionContext {
const b = g.battle!;
return { event: b.event!, attacker: b.attacker, defender: b.defender,
@@ -4249,7 +4354,8 @@ function combatForces(
...(homeworldSardaukarFreeSupport(g, p.id)
? { eliteFreeSupport: true }
: {}),
normalFixedHalf: p.faction === 'ixians',
normalFixedHalf: p.faction === 'ixians' && !nexusSuboidsActive(g, g.nexusSuboidHistory, p.id),
...(nexusSuboidsActive(g, g.nexusSuboidHistory, p.id) ? { normalFreeSupport: true } : {}),
elite,
eliteStrength:
(!g.advanced && p.faction !== 'ixians') ||
@@ -4548,7 +4654,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
!p.prediction &&
!p.advisorSetup &&
(!p.elites ||
(homeworlds &&
((homeworlds || nexus) &&
p.faction === 'ixians' &&
p.elites.reserves === 7 &&
p.elites.tanks === 0 &&
@@ -11652,6 +11758,8 @@ function gholaOptions(g: Game, p: Player) {
function marketGholaIntegrity(g: Game) {
nexusCardsIntegrity(g);
nexusTraitorIntegrity(g);
nexusFaceDancerIntegrity(g);
nexusSuboidIntegrity(g);
traitorDeclarationIntegrity(g);
nexusInspectionIntegrity(g);
homeworldHistoryIntegrity(g);
@@ -11941,13 +12049,21 @@ function findReachableBattlePlan(
return { plan, actions: node.actions, waitingForIncome: pending > 0 };
}
for (const action of [
...(node.state.status === 'playing' && node.state.phase === 6 && actor.faction === 'ixians' &&
!actor.ally && node.state.nexusCards?.cards?.hands[actor.id] === 'ixians' &&
node.state.nexusCards.phase?.stage !== 'drawing' && !node.state.pendingNullentropy &&
!node.state.pendingTreacheryDiscard && !pendingNexusTraitors(node.state) &&
!nexusSuboidsActive(node.state, node.state.nexusSuboidHistory, actor.id)
? [{ type: 'nexusSuboids', event: JSON.stringify(['nexusSuboids', node.state.turn, node.state.battle!.event, actor.id]) } as Action]
: []),
...gholaPreparationActions(node.state, actor),
...cashInPreparationActions(node.state, actor),
]) {
const trial = structuredClone(node.state),
target = getPlayer(trial, p.id);
try {
if (action.mode === 'special') specialKarama(trial, target, action);
if (action.type === 'nexusSuboids') commitNexusSuboids(trial, target);
else if (action.mode === 'special') specialKarama(trial, target, action);
else {
applyGholaEffect(trial, target, action);
discard(trial, target, action.card as string);
@@ -16794,7 +16910,7 @@ function applyActionInner(
stoneBurnerIntegrity(g);
strongholdIntegrity(g);
auditorIntegrity(g);
if (g.pendingNullentropy && !['nexusTraitorDraw', 'nexusTraitorReturn'].includes(action?.type)) {
if (g.pendingNullentropy && !['nexusTraitorDraw', 'nexusTraitorReturn', 'nexusFaceDancers'].includes(action?.type)) {
if (action?.type === 'advanceBots') return g;
requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing the paid search.');
requireRule(
@@ -16852,6 +16968,8 @@ function applyActionInner(
if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; }
requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.');
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 === 'nexusTraitorReturn') {
requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.');
finishNexusTraitorReturn(g, p, action); return g;
@@ -20319,6 +20437,8 @@ export function viewGame(state: Game, id: string) {
strongholdCards: g.strongholdCards ?? null,
nexusCards: projectedNexusCards(g, id),
nexusTraitors: projectedNexusTraitors(g, id),
nexusTleilaxu: projectedNexusTleilaxu(g, id),
nexusSuboids: projectedNexusSuboids(g, id),
nexusAtreides: nexusAtreidesOffer(g, id),
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
+217
View File
@@ -0,0 +1,217 @@
import {
validateNexusTraitorSnapshot,
type NexusTraitorSnapshot,
} from './nexus-traitor-exchange';
export type NexusFaceDancerReceipt = {
version: 1;
event: string;
owner: string;
turn: number;
phase: number;
stage: 'complete';
source: NexusTraitorSnapshot;
replaced: string[];
drawn: string[];
settledReserve: string[];
signature: string;
};
export type NexusFaceDancerResult = {
state: NexusTraitorSnapshot;
receipt: NexusFaceDancerReceipt;
};
function requireDancers(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new Error(message);
}
function identifier(value: unknown): value is string {
return (
typeof value === 'string' &&
value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].includes(value)
);
}
function exact(value: unknown, keys: readonly string[]): boolean {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
Object.keys(value).sort().join(',') === [...keys].sort().join(',')
);
}
function identities(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every(identifier) &&
new Set(value).size === value.length
);
}
function same(one: readonly string[], two: readonly string[]): boolean {
return one.length === two.length && one.every((value, i) => value === two[i]);
}
function validateInput(
input: Pick<NexusFaceDancerReceipt, 'event' | 'owner' | 'turn' | 'phase'>,
): void {
requireDancers(
exact(input, ['event', 'owner', 'turn', 'phase']) &&
identifier(input.event) &&
identifier(input.owner) &&
Number.isSafeInteger(input.turn) &&
input.turn > 0 &&
Number.isSafeInteger(input.phase) &&
input.phase >= 0 &&
input.phase <= 8,
'Invalid Tleilaxu Nexus replacement event.',
);
}
function replacementGroup(
state: NexusTraitorSnapshot,
owner: string,
): string[] {
const player = state.players.find((p) => p.id === owner);
requireDancers(
player?.faction === 'tleilaxu' && player.faceDancers,
'Tleilaxu Cunning requires its native Face Dancer owner.',
);
const revealed = player.faceDancers
.filter((card) => card.revealed)
.map((card) => card.leader);
requireDancers(
revealed.length > 0,
'Tleilaxu Cunning requires a revealed Face Dancer.',
);
requireDancers(
state.reserve.length >= revealed.length,
'Draw every replacement before returning the revealed Face Dancers.',
);
return revealed;
}
function signature(receipt: Omit<NexusFaceDancerReceipt, 'signature'>): string {
return JSON.stringify([
'nexusFaceDancers',
receipt.version,
receipt.event,
receipt.owner,
receipt.turn,
receipt.phase,
receipt.stage,
receipt.source.reserve,
receipt.source.players.map((p) => [
p.id,
p.faction,
p.traitors,
p.faceDancers?.map((card) => [card.leader, card.revealed]) ?? null,
]),
receipt.replaced,
receipt.drawn,
receipt.settledReserve,
]);
}
function resultingState(receipt: NexusFaceDancerReceipt): NexusTraitorSnapshot {
const state = structuredClone(receipt.source);
const owner = state.players.find((p) => p.id === receipt.owner)!;
owner.faceDancers = [
...owner.faceDancers!.filter((card) => !card.revealed),
...receipt.drawn.map((leader) => ({ leader, revealed: false })),
];
state.reserve = [...receipt.settledReserve];
return state;
}
/** Intrinsic original-event evidence, independent of later live custody. */
export function validateNexusFaceDancerHistory(
universe: readonly string[],
receipt: NexusFaceDancerReceipt,
): void {
requireDancers(
exact(receipt, [
'version',
'event',
'owner',
'turn',
'phase',
'stage',
'source',
'replaced',
'drawn',
'settledReserve',
'signature',
]) &&
receipt.version === 1 &&
receipt.stage === 'complete' &&
identities(receipt.replaced) &&
identities(receipt.drawn) &&
identities(receipt.settledReserve),
'Invalid saved Tleilaxu Nexus replacement.',
);
validateInput({
event: receipt.event,
owner: receipt.owner,
turn: receipt.turn,
phase: receipt.phase,
});
validateNexusTraitorSnapshot(receipt.source, universe);
const group = replacementGroup(receipt.source, receipt.owner);
requireDancers(
same(receipt.replaced, group) &&
same(receipt.drawn, receipt.source.reserve.slice(0, group.length)),
'The Nexus replacement must preserve the entire original revealed batch and its prior draw.',
);
requireDancers(
same(
[...receipt.settledReserve].sort(),
[...receipt.source.reserve.slice(group.length), ...group].sort(),
),
'The revealed Face Dancers return only after their replacements leave the deck.',
);
requireDancers(
receipt.signature === signature(receipt),
'The Tleilaxu Nexus replacement has lost its original history.',
);
validateNexusTraitorSnapshot(resultingState(receipt), universe);
}
/** Replace the full revealed batch atomically. No optional subset and no draw
* from the just-set-aside cards. Timing and Nexus-card use belong to the engine. */
export function replaceNexusFaceDancers(
state: NexusTraitorSnapshot,
universe: readonly string[],
input: Pick<NexusFaceDancerReceipt, 'event' | 'owner' | 'turn' | 'phase'>,
rng: () => number,
): NexusFaceDancerResult {
validateInput(input);
validateNexusTraitorSnapshot(state, universe);
const replaced = replacementGroup(state, input.owner);
requireDancers(
typeof rng === 'function',
'A Nexus replacement needs valid shuffle randomness.',
);
const drawn = state.reserve.slice(0, replaced.length);
const reserve = [...state.reserve.slice(replaced.length), ...replaced];
for (let i = reserve.length - 1; i > 0; i--) {
const value = rng();
requireDancers(
typeof value === 'number' &&
Number.isFinite(value) &&
value >= 0 &&
value < 1,
'Invalid Tleilaxu Nexus shuffle randomness.',
);
const j = Math.floor(value * (i + 1));
[reserve[i], reserve[j]] = [reserve[j], reserve[i]];
}
const receipt: NexusFaceDancerReceipt = {
version: 1,
...input,
stage: 'complete',
source: structuredClone(state),
replaced,
drawn,
settledReserve: reserve,
signature: '',
};
receipt.signature = signature(receipt);
validateNexusFaceDancerHistory(universe, receipt);
return { state: resultingState(receipt), receipt };
}
+39
View File
@@ -0,0 +1,39 @@
import type { Action, GameView } from './engine';
/** Use only the private current battle offer; the server checks existing
* commitments before offering changed Suboid strength/support. */
export function nexusSuboidsAction(
game: GameView,
event: string,
): Action | null {
const offer = game.nexusSuboids?.offer;
const owner = game.players.find((player) => player.id === game.me);
const battle = game.battle;
if (
game.status !== 'playing' ||
game.phase !== 6 ||
!owner ||
owner.faction !== 'ixians' ||
owner.ally ||
game.nexusCards?.card !== 'ixians' ||
game.truthtrance ||
game.nexusCards.waiting.length ||
game.nexusTraitors?.pending ||
!battle ||
![battle.attacker, battle.defender].includes(owner.id) ||
battle.submitted.includes(owner.id) ||
game.nexusSuboids?.active ||
!offer ||
!event ||
offer.event !== event ||
offer.blocked
)
return null;
return { type: 'nexusSuboids', event };
}
export function nexusSuboidBotActions(game: GameView): Action[] {
const offer = game.nexusSuboids?.offer;
const action = offer ? nexusSuboidsAction(game, offer.event) : null;
return action ? [action] : [];
}
+48
View File
@@ -0,0 +1,48 @@
import type { FactionId } from './catalog';
export type NexusSuboidContext = {
turn: number;
players: readonly { id: string; faction: FactionId }[];
};
export type NexusSuboidReceipt = {
version: 1; event: string; owner: string; turn: number; battle: string;
roster: { id: string; faction: FactionId }[]; signature: string;
};
function requireSuboids(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
function signature(receipt: NexusSuboidReceipt) {
return JSON.stringify([receipt.version, receipt.event, receipt.owner, receipt.turn, receipt.battle,
receipt.roster.map(p => [p.id, p.faction])]);
}
function roster(context: NexusSuboidContext) {
return context.players.map(({id, faction}) => ({id, faction})).sort((a, b) => a.id.localeCompare(b.id));
}
export function validateNexusSuboids(context: NexusSuboidContext, history: readonly NexusSuboidReceipt[]) {
requireSuboids(Number.isSafeInteger(context.turn) && context.turn > 0 && Array.isArray(history),
'Invalid Suboid Nexus turn or history.');
const turns = new Set<number>();
for (const receipt of history) {
requireSuboids(receipt && typeof receipt === 'object' && !Array.isArray(receipt) &&
Object.keys(receipt).sort().join(',') === 'battle,event,owner,roster,signature,turn,version' &&
receipt.version === 1 && typeof receipt.battle === 'string' && receipt.battle.length > 0 &&
Number.isSafeInteger(receipt.turn) && receipt.turn > 0 && receipt.turn <= context.turn &&
!turns.has(receipt.turn) &&
context.players.some(p => p.id === receipt.owner && p.faction === 'ixians') &&
receipt.event === JSON.stringify(['nexusSuboids', receipt.turn, receipt.battle, receipt.owner]) &&
JSON.stringify(receipt.roster) === JSON.stringify(roster(context)) &&
receipt.signature === signature(receipt),
'The Suboid Nexus effect has lost its original battle, owner or turn.');
turns.add(receipt.turn);
}
}
export function createNexusSuboids(context: NexusSuboidContext, owner: string, battle: string): NexusSuboidReceipt {
const receipt: NexusSuboidReceipt = { version: 1, owner, turn: context.turn, battle,
event: JSON.stringify(['nexusSuboids', context.turn, battle, owner]), roster: roster(context), signature: '' };
receipt.signature = signature(receipt);
validateNexusSuboids(context, [receipt]);
return receipt;
}
/** A completed play applies to every battle in its own turn only. */
export function nexusSuboidsActive(context: NexusSuboidContext, history: readonly NexusSuboidReceipt[] | undefined, owner: string) {
return history?.some(receipt => receipt.owner === owner && receipt.turn === context.turn) ?? false;
}
+37
View File
@@ -0,0 +1,37 @@
import type { Action, GameView } from './engine';
/** The server's private offer binds the complete revealed replacement group.
* Do not reconstruct eligibility from private Face Dancer hands. */
export function nexusFaceDancersAction(
game: GameView,
event: string,
): Action | null {
const offer = game.nexusTleilaxu?.cunning;
const owner = game.players.find((player) => player.id === game.me);
if (
game.status !== 'playing' ||
!owner ||
owner.faction !== 'tleilaxu' ||
owner.ally ||
game.nexusCards?.card !== 'tleilaxu' ||
game.truthtrance ||
game.nexusCards.waiting.length ||
game.nexusTraitors?.pending ||
!offer ||
!event ||
offer.event !== event ||
offer.blocked ||
!Number.isSafeInteger(offer.count) ||
offer.count < 1
)
return null;
return { type: 'nexusFaceDancers', event };
}
/** All profiles share the legal replacement policy. There is no post-draw
* choice and no access to another player's hand or the reserve order. */
export function nexusTleilaxuBotActions(game: GameView): Action[] {
const offer = game.nexusTleilaxu?.cunning;
const action = offer ? nexusFaceDancersAction(game, offer.event) : null;
return action ? [action] : [];
}
+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 and Harkonnen traitor exchanges 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 Tleilaxu/Ixian 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.',
@@ -3356,15 +3356,18 @@ export const RULE_TOPICS: RuleTopic[] = [
'Harkonnen Cunning draws one Traitor Card before you choose one held card to return. It can pause another action, including a response or decision. Finish the private return before that action continues; active Truthtrance takes priority. Returning an identity already declared as a traitor does not cancel that declaration.',
'With Harkonnen absent, Secret Ally draws two Traitor Cards during Mentat Pause, then you choose exactly two distinct held identities to return. Newly drawn cards may be returned. Tleilaxu receives Face Dancers and can return newly drawn or existing unrevealed dancers; previously revealed dancers stay protected.',
'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.',
'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.',
'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 draw-first exchanges and preserved traitor declarations. Betrayal and remaining card families are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, Atreides answers/history/plan locks, and exact Harkonnen returns with Traitor or Face Dancer inspection. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All profiles handle lifecycle, Atreides commitments and Harkonnen exchanges using authorized choices and public leaders. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces, Atreides inspections and Harkonnen exchanges are recorded with explicit unresolved boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_HARKONNEN_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Custody, phase, inspection, exchange controls, bot and concurrent recovery regressions; final Harkonnen acceptance and complete module games remain separate requirements.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-controls.test.ts', 'tests/nexus-traitor-bots.test.ts', 'tests/nexus-traitor-recovery.test.ts'] },
{ 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'] },
],
},
...[
+4 -2
View File
@@ -89,6 +89,7 @@ function allOpposingTotals(
forces.elite,
forces.eliteStrength,
!!forces.normalFixedHalf,
!!forces.normalFreeSupport,
forces.freeSupport,
!!forces.eliteFreeSupport,
].join(':');
@@ -96,11 +97,12 @@ function allOpposingTotals(
if (cached) return cached;
const result: number[][] = [];
const maximumHalfDial =
forces.normal * (forces.normalFixedHalf ? 1 : 2) +
forces.normal *
(forces.normalFixedHalf && !forces.normalFreeSupport ? 1 : 2) +
forces.elite * forces.eliteStrength * 2;
const maximumSupport = forces.freeSupport
? 0
: (forces.normalFixedHalf ? 0 : forces.normal) +
: (forces.normalFixedHalf || forces.normalFreeSupport ? 0 : forces.normal) +
(forces.eliteFreeSupport ? 0 : forces.elite);
// Use the same legality and typed-support arithmetic as sealed plans. Cache
// public pool results so repeated preflights do not repeat this enumeration.