Implement Emperor Nexus Cunning with physical casualty recovery
This commit is contained in:
1 parent
b05125de9a
commit
ba0b5647e1
27 files changed
+2487
-64
No files matched your search
@@ -6,7 +6,12 @@ import {
|
||||
} from './cards';
|
||||
import type { FactionId } from './catalog';
|
||||
import { battleWeaponsExplode, isStoneBurner } from './battle-cards';
|
||||
import { casualtyOptions, type Casualties, type CombatForces } from './combat';
|
||||
import {
|
||||
casualtyOptions,
|
||||
validCombatForces,
|
||||
type Casualties,
|
||||
type CombatForces,
|
||||
} from './combat';
|
||||
import { strongholdBattleEffects } from './stronghold-battle';
|
||||
import {
|
||||
strongholdBattleIncome,
|
||||
@@ -160,17 +165,7 @@ function validateCombatant(
|
||||
p &&
|
||||
f &&
|
||||
whole(side.spice) &&
|
||||
whole(f.normal) &&
|
||||
whole(f.elite) &&
|
||||
f.normal + f.elite <= 20 &&
|
||||
(f.eliteStrength === 1 || f.eliteStrength === 2) &&
|
||||
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') &&
|
||||
validCombatForces(f) &&
|
||||
Number.isFinite(p.dial) &&
|
||||
p.dial >= 0 &&
|
||||
Number.isSafeInteger(p.dial * 2) &&
|
||||
|
||||
+8
-6
@@ -3,6 +3,7 @@ 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 { nexusSardaukarBotActions } from './nexus-sardaukar-options';
|
||||
import { tupileIntelligenceActions } from './tupile-intelligence-options';
|
||||
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
|
||||
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
|
||||
@@ -60,7 +61,7 @@ import { richeseCardDefinition } from './richese-cards';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
|
||||
import { fighterCount, isAdvisor } from './advisors';
|
||||
import { casualtyOptions, type CombatForces } from './combat';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
||||
import { stoneBurnerPlanBlock, stoneBurnerComparison } from './stone-burner';
|
||||
import {
|
||||
applyAction,
|
||||
@@ -566,8 +567,7 @@ function plans(g: GameView): Action[] {
|
||||
const own = forces.normal + forces.elite;
|
||||
const typedForces = g.advanced || me.faction === 'ixians';
|
||||
const ownStrength = typedForces
|
||||
? forces.normal * (forces.normalFixedHalf ? 0.5 : 1) +
|
||||
forces.elite * forces.eliteStrength
|
||||
? maxCombatDial(forces)
|
||||
: own;
|
||||
const supportCache = new Map<
|
||||
number,
|
||||
@@ -580,7 +580,7 @@ function plans(g: GameView): Action[] {
|
||||
length:
|
||||
(g.advanced
|
||||
? Math.min(
|
||||
own,
|
||||
maxCombatSupport(forces),
|
||||
(me.spice ?? 0) +
|
||||
g.aid.available +
|
||||
(b.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0),
|
||||
@@ -627,8 +627,7 @@ function plans(g: GameView): Action[] {
|
||||
const ownNativeBonus = b.native === me.id ? b.nativeBattleStrength : 0;
|
||||
const enemyNativeBonus = b.native === other.id ? b.nativeBattleStrength : 0;
|
||||
const enemyCapacity = b.opponentForces
|
||||
? b.opponentForces.normal * (b.opponentForces.normalFixedHalf ? 0.5 : 1) +
|
||||
b.opponentForces.elite * b.opponentForces.eliteStrength
|
||||
? maxCombatDial(b.opponentForces)
|
||||
: enemies;
|
||||
const insights = [
|
||||
...(b.insight && b.prescience?.player === me.id ? [b.insight] : []),
|
||||
@@ -1320,6 +1319,7 @@ function policyActions(g: GameView): Action[] {
|
||||
g.response.kind === 'harkonnenTraitor' ||
|
||||
g.response.kind === 'eliteStrength' ||
|
||||
g.response.kind === 'fremenSupport' ||
|
||||
g.response.kind === 'nexusSardaukar' ||
|
||||
g.response.kind === 'choamBattleAid');
|
||||
const auditThreat =
|
||||
g.response.kind === 'choamAudit' &&
|
||||
@@ -3326,6 +3326,8 @@ export function botActions(g: GameView): Action[] {
|
||||
: nexusTraitorBotActions(g);
|
||||
if (g.automaticContinuationPending) return [];
|
||||
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
|
||||
const sardaukar = nexusSardaukarBotActions(g);
|
||||
if (sardaukar.length) return sardaukar;
|
||||
const advisorConversion = nexusAdvisorBotActions(g);
|
||||
if (advisorConversion.length) return advisorConversion;
|
||||
const suboids = nexusSuboidBotActions(g);
|
||||
|
||||
+56
-3
@@ -5,6 +5,9 @@ export type CombatForces = {
|
||||
/** Normal counters alone retain full strength without support, overriding
|
||||
* fixed half strength. Special counters keep their separate support rules. */
|
||||
normalFreeSupport?: boolean;
|
||||
/** A battle-only effective role assigned to this many physical normal
|
||||
* counters. It never changes their board or casualty custody. */
|
||||
temporaryElite?: number;
|
||||
elite: number;
|
||||
eliteStrength: 1 | 2;
|
||||
freeSupport: boolean;
|
||||
@@ -30,6 +33,11 @@ export function validCombatForces(forces: CombatForces): boolean {
|
||||
Number.isSafeInteger(forces.elite) &&
|
||||
forces.elite >= 0 &&
|
||||
forces.normal + forces.elite <= 20 &&
|
||||
(forces.temporaryElite === undefined ||
|
||||
(Number.isSafeInteger(forces.temporaryElite) &&
|
||||
forces.temporaryElite >= 0 &&
|
||||
forces.temporaryElite <= forces.normal &&
|
||||
forces.elite === 0)) &&
|
||||
(forces.eliteStrength === 1 || forces.eliteStrength === 2) &&
|
||||
typeof forces.freeSupport === 'boolean' &&
|
||||
(forces.eliteFreeSupport === undefined ||
|
||||
@@ -41,6 +49,30 @@ export function validCombatForces(forces: CombatForces): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Maximum supported strength, without making any assertion about funding. */
|
||||
export function maxCombatDial(forces: CombatForces): number {
|
||||
if (!validCombatForces(forces)) return 0;
|
||||
const temporary = forces.temporaryElite ?? 0;
|
||||
return (
|
||||
(forces.normal - temporary) *
|
||||
(forces.normalFixedHalf && !forces.normalFreeSupport ? 0.5 : 1) +
|
||||
(forces.elite + temporary) * forces.eliteStrength
|
||||
);
|
||||
}
|
||||
|
||||
/** Maximum usable spice support; physical normal counters with temporary
|
||||
* elite roles follow elite support rules without becoming starred counters. */
|
||||
export function maxCombatSupport(forces: CombatForces): number {
|
||||
if (!validCombatForces(forces) || forces.freeSupport) return 0;
|
||||
const temporary = forces.temporaryElite ?? 0;
|
||||
return (
|
||||
(forces.normalFixedHalf || forces.normalFreeSupport
|
||||
? 0
|
||||
: forces.normal - temporary) +
|
||||
(forces.eliteFreeSupport ? 0 : forces.elite + temporary)
|
||||
);
|
||||
}
|
||||
|
||||
/** Every loss combination consistent with the revealed dial and spice payment. */
|
||||
export function casualtyOptions(
|
||||
forces: CombatForces,
|
||||
@@ -54,12 +86,33 @@ export function casualtyOptions(
|
||||
!Number.isInteger(dial * 2) ||
|
||||
!Number.isSafeInteger(support) ||
|
||||
support < 0 ||
|
||||
support >
|
||||
(forces.normalFixedHalf || forces.normalFreeSupport ? 0 : forces.normal) +
|
||||
(forces.eliteFreeSupport ? 0 : forces.elite) ||
|
||||
support > maxCombatSupport(forces) ||
|
||||
(forces.freeSupport && support !== 0)
|
||||
)
|
||||
return [];
|
||||
if (forces.temporaryElite) {
|
||||
const effective = casualtyOptions(
|
||||
{
|
||||
...forces,
|
||||
normal: forces.normal - forces.temporaryElite,
|
||||
elite: forces.temporaryElite,
|
||||
temporaryElite: undefined,
|
||||
},
|
||||
dial,
|
||||
support,
|
||||
);
|
||||
const physical = new Map<number, Casualties>();
|
||||
for (const loss of effective) {
|
||||
const normal = loss.normal + loss.elite;
|
||||
physical.set(normal, {
|
||||
normal,
|
||||
elite: 0,
|
||||
paidNormal: loss.paidNormal + loss.paidElite,
|
||||
paidElite: 0,
|
||||
});
|
||||
}
|
||||
return [...physical.values()].sort((a, b) => a.normal - b.normal);
|
||||
}
|
||||
const options: Casualties[] = [];
|
||||
for (let elite = 0; elite <= forces.elite; elite++) {
|
||||
for (let normal = 0; normal <= forces.normal; normal++) {
|
||||
|
||||
+238
-13
@@ -3,6 +3,7 @@ import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTra
|
||||
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 { createNexusSardaukar, validateNexusSardaukar, type NexusSardaukarReceipt } from './nexus-sardaukar';
|
||||
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';
|
||||
@@ -451,7 +452,7 @@ import {
|
||||
settleAdvisors,
|
||||
arrivalAsAdvisor,
|
||||
} from './advisors';
|
||||
import { casualtyOptions, type Casualties, type CombatForces } from './combat';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, validCombatForces, type Casualties, type CombatForces } from './combat';
|
||||
import { FACTIONS, faction, type FactionId } from './catalog';
|
||||
import {
|
||||
treacheryDeck,
|
||||
@@ -541,6 +542,7 @@ export type Battle = {
|
||||
nexusInspection?: NexusInspection;
|
||||
/** Independent presence marker: deleting a spent-card record is not a legacy save. */
|
||||
nexusInspectionUsed?: string;
|
||||
nexusSardaukarUsed?: string;
|
||||
traitorDeclarationVersion?: 1;
|
||||
traitorDeclarations?: Record<string, TraitorDeclaration>;
|
||||
homeworldDefensePassed?: string[];
|
||||
@@ -793,6 +795,7 @@ export type ResponseWindow = {
|
||||
| 'fremenSupport'
|
||||
| 'advisorFlip'
|
||||
| 'nexusAdvisorFlip'
|
||||
| 'nexusSardaukar'
|
||||
| 'bgCharity'
|
||||
| 'choamCharity'
|
||||
| 'choamInflation'
|
||||
@@ -1267,6 +1270,9 @@ export type Game = {
|
||||
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' };
|
||||
nexusSardaukarHistory?: {receipt: NexusSardaukarReceipt; stage: 'pending' | 'active' | 'canceled'; parent: string; signature: string;
|
||||
casualties?: {forces: CombatForces; dial: number; support: number; options: Casualties[]; outcome: 'pending' | 'complete'}}[];
|
||||
nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'};
|
||||
nexusTraitorPending?: string | null;
|
||||
nexusTraitorParent?: { event: string; signature: string } | null;
|
||||
/** Null/absent disables the module; custody is installed at force placement. */
|
||||
@@ -1385,6 +1391,7 @@ export type Game = {
|
||||
cardRolesSignature?: string;
|
||||
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
|
||||
caladanReinforcement?: HomeworldVictoryObligation;
|
||||
nexusSardaukarCasualties?: string;
|
||||
} | null;
|
||||
auction: Auction | null;
|
||||
battle: Battle | null;
|
||||
@@ -1862,6 +1869,182 @@ function finishNexusAdvisors(g: Game, canceled: boolean) {
|
||||
{faction: p.faction, name: canceled ? 'Nexus advisor conversion prevented' : 'Nexus advisors become fighters'});
|
||||
}
|
||||
|
||||
function currentNexusSardaukar(g: Game) {
|
||||
return g.battle?.event ? g.nexusSardaukarHistory?.find(record => record.receipt.battle === g.battle!.event) ?? null : null;
|
||||
}
|
||||
function nexusSardaukarEffective(g: Game, id: string, to: string) {
|
||||
const record = currentNexusSardaukar(g);
|
||||
return record && record.receipt.owner === id && record.receipt.territory === to && record.stage === 'active' ? record.receipt.count : 0;
|
||||
}
|
||||
function nexusSardaukarParent(g: Game) {
|
||||
const b = g.battle!;
|
||||
return JSON.stringify([b.attacker, b.defender, b.territory, b.preparation ?? null,
|
||||
b.powerChecks ?? null, b.preLeader ?? null, b.plans]);
|
||||
}
|
||||
function nexusSardaukarSignature(record: NonNullable<Game['nexusSardaukarHistory']>[number]) {
|
||||
return JSON.stringify([record.receipt.signature, record.stage, record.parent, record.casualties ?? null]);
|
||||
}
|
||||
function validateNexusSardaukarResponse(g: Game, response: ResponseWindow) {
|
||||
const record = currentNexusSardaukar(g), b = g.battle;
|
||||
requireRule(record?.stage === 'pending' && b && !b.revealed &&
|
||||
record.receipt.turn === g.turn && record.receipt.territory === b.territory &&
|
||||
response.kind === 'nexusSardaukar' && 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)) && record.parent === nexusSardaukarParent(g),
|
||||
'The Nexus Sardaukar response has lost its original battle and preparation.');
|
||||
const p = getPlayer(g, record.receipt.owner), other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
|
||||
const forces = combatForces(g, p, b.territory, other);
|
||||
requireRule(forces.normal === record.receipt.normal && forces.elite === 0,
|
||||
'The pending Nexus Sardaukar declaration has lost its original eligible forces.');
|
||||
}
|
||||
function nexusSardaukarIntegrity(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 === 'nexusSardaukar');
|
||||
const history = g.nexusSardaukarHistory;
|
||||
if (history === undefined) {
|
||||
requireRule(!g.nexusSardaukarLast && !g.battle?.nexusSardaukarUsed && !g.lastBattleContext?.nexusSardaukarCasualties && !responses.length,
|
||||
'The Nexus Sardaukar use has lost its saved history.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.advanced && g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
|
||||
'Nexus Sardaukar history requires its original Advanced module.');
|
||||
const battles = new Set<string>(), turns = new Set<number>();
|
||||
for (const [index, record] of history.entries()) {
|
||||
requireRule(record && record.receipt && typeof record.receipt === 'object' &&
|
||||
Object.keys(record).sort().join(',') === (record.casualties ? 'casualties,parent,receipt,signature,stage' : 'parent,receipt,signature,stage') &&
|
||||
['pending', 'active', 'canceled'].includes(record.stage) && typeof record.parent === 'string' &&
|
||||
record.signature === nexusSardaukarSignature(record) && !battles.has(record.receipt.battle) &&
|
||||
!turns.has(record.receipt.turn) && (record.stage !== 'pending' || index === history.length - 1),
|
||||
'The Nexus Sardaukar battle history is malformed.');
|
||||
nexusRule(() => validateNexusSardaukar(g, record.receipt));
|
||||
const losses = record.casualties;
|
||||
if (losses) {
|
||||
requireRule(record.stage === 'active' && ['pending','complete'].includes(losses.outcome) &&
|
||||
Object.keys(losses).sort().join(',') === 'dial,forces,options,outcome,support' &&
|
||||
validCombatForces(losses.forces) && losses.forces.temporaryElite === 5 &&
|
||||
Array.isArray(losses.options) && losses.options.length > 0 &&
|
||||
JSON.stringify(losses.options) === JSON.stringify(casualtyOptions(losses.forces,losses.dial,losses.support)),
|
||||
'The Nexus Sardaukar casualties have lost their committed force allocation.');
|
||||
if (losses.outcome === 'pending') {
|
||||
const context = g.lastBattleContext;
|
||||
requireRule(!g.battle && context?.event === record.receipt.battle && context.result === 'normal' &&
|
||||
context.winner === record.receipt.owner && context.nexusSardaukarCasualties === record.receipt.event &&
|
||||
context.territory === record.receipt.territory && context.turn === record.receipt.turn,
|
||||
'The pending Sardaukar casualties have lost their completed battle.');
|
||||
const pool = combatArmy(g,record.receipt.owner,record.receipt.territory);
|
||||
requireRule(pool.normal === losses.forces.normal && pool.elite === losses.forces.elite,
|
||||
'The pending Sardaukar casualties have lost their exact physical army.');
|
||||
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'battleLosses' && d.player === record.receipt.owner);
|
||||
const committed = continuation?.kind === 'battleResolved' && continuation.event === record.receipt.battle ? continuation.casualties : null;
|
||||
requireRule(decisions.length > 0 || committed, 'The Nexus Sardaukar battle has lost its casualty choice.');
|
||||
for (const decision of decisions) requireRule(decision.kind === 'battleLosses' &&
|
||||
decision.territory === record.receipt.territory && JSON.stringify(decision.options) === JSON.stringify(losses.options),
|
||||
'The casualty choices differ from the committed Nexus Sardaukar plan.');
|
||||
if (committed) requireRule(JSON.stringify(committed) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options}),
|
||||
'The suspended Sardaukar casualty allocation was changed.');
|
||||
if (g.homeworldBattleLoss) requireRule(JSON.stringify(g.homeworldBattleLoss.commitment) ===
|
||||
JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support}),
|
||||
'The Homeworld Sardaukar casualties have lost their effective combat roles.');
|
||||
}
|
||||
}
|
||||
battles.add(record.receipt.battle); turns.add(record.receipt.turn);
|
||||
}
|
||||
const last = history.at(-1)!;
|
||||
requireRule(JSON.stringify(g.nexusSardaukarLast) === JSON.stringify({event: last.receipt.event, stage: last.stage}),
|
||||
'The Nexus Sardaukar use has lost its latest saved outcome.');
|
||||
if (last.receipt.turn === g.turn) requireRule(g.nexusCards.cards.discard.includes('emperor'),
|
||||
'The Nexus Sardaukar use has lost its spent card.');
|
||||
const record = currentNexusSardaukar(g), b = g.battle;
|
||||
requireRule((record?.stage === 'pending') === (responses.length > 0) &&
|
||||
(last.stage !== 'pending' || record === last), 'The Nexus Sardaukar use has lost or reopened its response.');
|
||||
if (b) requireRule(b.nexusSardaukarUsed === record?.receipt.event,
|
||||
'The battle has lost its original Nexus Sardaukar marker.');
|
||||
if (record) {
|
||||
requireRule(g.phase === 6 && record.receipt.turn === g.turn && b!.territory === record.receipt.territory &&
|
||||
[b!.attacker, b!.defender].includes(record.receipt.owner), 'The temporary Sardaukar belong to another battle.');
|
||||
if (record.stage !== 'canceled') {
|
||||
const p = getPlayer(g, record.receipt.owner), other = getPlayer(g, b!.attacker === p.id ? b!.defender : b!.attacker);
|
||||
const forces = combatForces(g, p, b!.territory, other);
|
||||
requireRule(forces.normal >= 5 && forces.elite === 0, 'The Nexus battle no longer has five eligible ordinary forces.');
|
||||
}
|
||||
}
|
||||
for (const response of responses) validateNexusSardaukarResponse(g, response!);
|
||||
const lastLoss = history.find(record => record.receipt.battle === g.lastBattleContext?.event)?.casualties;
|
||||
const lossEvent = history.find(record => record.receipt.battle === g.lastBattleContext?.event)?.receipt.event;
|
||||
requireRule(g.lastBattleContext?.nexusSardaukarCasualties === (lastLoss ? lossEvent : undefined),
|
||||
'The completed battle has lost its Nexus Sardaukar casualty receipt.');
|
||||
}
|
||||
function nexusSardaukarEligibility(g: Game, p: Player): string | null {
|
||||
const b = g.battle;
|
||||
if (!g.advanced) return 'Sardaukar are an Advanced advantage.';
|
||||
if (g.status !== 'playing' || g.phase !== 6 || !b?.event || ![b.attacker,b.defender].includes(p.id))
|
||||
return 'Use Cunning in your own battle before submitting your Battle Plan.';
|
||||
if (p.faction !== 'emperor' || p.ally || g.nexusCards?.cards?.hands[p.id] !== 'emperor')
|
||||
return 'Use your own Emperor Nexus card while unallied.';
|
||||
if (b.revealed || b.plans[p.id] || b.nexusSardaukarUsed) return 'This battle has already committed your plan or used Emperor Cunning.';
|
||||
const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
|
||||
const forces = combatForces(g, p, b.territory, other);
|
||||
if (forces.elite) return 'Cunning requires a battle containing none of your actual Sardaukar.';
|
||||
if (forces.normal < 5) return 'Using Emperor Cunning with fewer than five eligible forces awaits a ruling.';
|
||||
return null;
|
||||
}
|
||||
function projectedNexusSardaukar(g: Game, id: string) {
|
||||
if (!g.nexusCards?.cards) return null;
|
||||
const record = currentNexusSardaukar(g);
|
||||
const active = record?.stage === 'active';
|
||||
const pending = record?.stage === 'pending' ? {event: record.receipt.event, owner: record.receipt.owner} : null;
|
||||
const p = getPlayer(g,id);
|
||||
if (p.faction !== 'emperor' || g.nexusCards.cards.hands[id] !== 'emperor') return {offer:null,active,pending};
|
||||
let blocked = nexusSardaukarEligibility(g,p);
|
||||
if (!blocked && (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama ||
|
||||
g.pendingTreacheryDiscard || g.pendingNullentropy || pendingNexusTraitors(g) || g.nexusCards.phase?.stage === 'drawing'))
|
||||
blocked = 'Finish the current interaction before declaring Nexus Sardaukar.';
|
||||
const event = g.battle?.event ? JSON.stringify(['nexusSardaukar',g.turn,g.battle.event,id]) : '';
|
||||
if (!blocked) {
|
||||
const trial = structuredClone(g);
|
||||
commitNexusSardaukar(trial,getPlayer(trial,id),'active');
|
||||
if (!findReachableBattlePlan(trial,getPlayer(trial,id)))
|
||||
blocked = 'Sardaukar strength would prevent you from honoring an existing Battle Plan commitment.';
|
||||
}
|
||||
return {offer:{event,blocked},active,pending};
|
||||
}
|
||||
function commitNexusSardaukar(g: Game, p: Player, stage: 'pending' | 'active') {
|
||||
const blocked = nexusSardaukarEligibility(g,p);
|
||||
requireRule(!blocked, blocked ?? 'The Nexus Sardaukar opportunity is unavailable.');
|
||||
const b = g.battle!, other = getPlayer(g,b.attacker === p.id ? b.defender : b.attacker);
|
||||
const receipt = nexusRule(() => createNexusSardaukar(g,p.id,b.event!,b.territory,combatForces(g,p,b.territory,other).normal));
|
||||
const record = {receipt,stage,parent:nexusSardaukarParent(g),signature:''};
|
||||
record.signature = nexusSardaukarSignature(record);
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
|
||||
(g.nexusSardaukarHistory ??= []).push(record);
|
||||
g.nexusSardaukarLast = {event:receipt.event,stage}; b.nexusSardaukarUsed = receipt.event;
|
||||
if (stage === 'pending') g.response = {kind:'nexusSardaukar',owner:p.id,intent:receipt.event,passed:[]};
|
||||
}
|
||||
function playNexusSardaukar(g: Game,p: Player,action: Action) {
|
||||
const offer = projectedNexusSardaukar(g,p.id)?.offer;
|
||||
requireRule(Object.keys(action).every(key => ['type','event'].includes(key)) && offer && !offer.blocked && action.event === offer.event,
|
||||
offer?.blocked ?? 'Choose the current Emperor Cunning opportunity.');
|
||||
commitNexusSardaukar(g,p,'pending');
|
||||
log(g,`${p.name} spent Emperor Nexus Cunning to count five ordinary forces as Sardaukar for this battle. Karama may prevent this use. The physical counters remain ordinary forces.`,
|
||||
{faction:p.faction,name:'Nexus Sardaukar declared'});
|
||||
}
|
||||
function finishNexusSardaukar(g: Game,canceled: boolean) {
|
||||
const record = currentNexusSardaukar(g)!;
|
||||
record.stage = canceled ? 'canceled' : 'active'; record.signature = nexusSardaukarSignature(record);
|
||||
g.nexusSardaukarLast = {event:record.receipt.event,stage:record.stage};
|
||||
if (canceled) reconcileChangedBattleInspections(g, 'the canceled Sardaukar enhancement');
|
||||
log(g,canceled ? 'Karama prevented Emperor Cunning. These forces remain ordinary in both strength and physical custody; the Nexus card stays spent.'
|
||||
: 'Five ordinary Emperor forces count as Sardaukar in this battle only. They use Sardaukar strength and support, including the Fremen exception, but every casualty remains an ordinary physical counter.',
|
||||
{faction:'emperor',name:canceled ? 'Nexus Sardaukar prevented' : 'Nexus Sardaukar active'});
|
||||
}
|
||||
|
||||
function battleInspectionContext(g: Game): BattleInspectionContext {
|
||||
const b = g.battle!;
|
||||
return { event: b.event!, attacker: b.attacker, defender: b.defender,
|
||||
@@ -4014,6 +4197,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
requireRule(
|
||||
winner &&
|
||||
forces &&
|
||||
validCombatForces(forces) &&
|
||||
Number.isSafeInteger(forces.normal) &&
|
||||
forces.normal >= 0 &&
|
||||
Number.isSafeInteger(forces.elite) &&
|
||||
@@ -4467,6 +4651,7 @@ function combatForces(
|
||||
const elite = army.elite;
|
||||
return {
|
||||
normal: physical + prospective - elite,
|
||||
...(nexusSardaukarEffective(g,p.id,t) ? {temporaryElite:nexusSardaukarEffective(g,p.id,t)} : {}),
|
||||
...(homeworldSardaukarFreeSupport(g, p.id)
|
||||
? { eliteFreeSupport: true }
|
||||
: {}),
|
||||
@@ -4708,7 +4893,7 @@ export function initializeHomeworldGameForAudit(state: Game): Game {
|
||||
export function initializeNexusGameForAudit(state: Game): Game {
|
||||
requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null,
|
||||
'Enable Nexus cards in a fresh audit lobby first.');
|
||||
return initializeSetupGameForAudit(state, false, true);
|
||||
return initializeSetupGameForAudit(state, !!state.homeworlds, true);
|
||||
}
|
||||
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false): Game {
|
||||
nexusCardsIntegrity(state);
|
||||
@@ -6488,6 +6673,7 @@ function validateKaramaUse(
|
||||
moritaniAllianceCancellationQuote(g, use.response);
|
||||
if (use.response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, use.response);
|
||||
if (use.response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, use.response);
|
||||
if (use.response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, use.response);
|
||||
if (isCombatResponseKind(use.response.kind))
|
||||
currentCombatResponseQuote(g, {
|
||||
kind: 'response',
|
||||
@@ -6627,6 +6813,10 @@ function assertKaramaPromiseFeasibility(
|
||||
b.nexusInspection = cancelNexusInspection(battleInspectionContext(projected), b.nexusInspection);
|
||||
delete b.preparation;
|
||||
break;
|
||||
case 'nexusSardaukar':
|
||||
if (!b || !currentNexusSardaukar(projected)) return false;
|
||||
finishNexusSardaukar(projected,true);
|
||||
break;
|
||||
case 'eliteStrength':
|
||||
if (!b) return false;
|
||||
(b.eliteBlocked ??= []).push(use.response.owner);
|
||||
@@ -11347,13 +11537,24 @@ function playResidualPoison(
|
||||
`${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`,
|
||||
{ faction: p.faction, name: 'Residual Poison' },
|
||||
);
|
||||
// First retire an inspection made impossible by the death itself. Testing on
|
||||
reconcileChangedBattleInspections(g, 'the leader death');
|
||||
if (b.preLeader && !b.preLeader.closed)
|
||||
b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id);
|
||||
}
|
||||
function reconcileChangedBattleInspections(g: Game, cause: string) {
|
||||
const b = g.battle!;
|
||||
// Retire an inspection made impossible by the opposing effect. Testing on
|
||||
// a private copy without Truthtrance avoids releasing a still-feasible truth
|
||||
// answer solely because an obsolete inspection was left in the trial.
|
||||
const insight = b.prescience;
|
||||
if (insight && 'value' in insight && insight.field !== 'leader') {
|
||||
const trial = structuredClone(g);
|
||||
trial.battle!.truthPromises = [];
|
||||
// A different impossible disclosure must not falsely retire this one.
|
||||
// The real extra answer is tested afterward against any retained native
|
||||
// commitment; the trial keeps its signed first-answer relationship.
|
||||
if (trial.battle!.nexusInspection?.mode === 'cunning' && trial.battle!.nexusInspection.stage === 'answered' && trial.battle!.nexusInspection.field !== 'leader')
|
||||
trial.battle!.nexusInspection = nexusRule(() => reopenNexusInspection(battleInspectionContext(trial),trial.battle!.nexusInspection!));
|
||||
const answering = getPlayer(
|
||||
trial,
|
||||
insight.player === b.attacker ? b.defender : b.attacker,
|
||||
@@ -11369,7 +11570,7 @@ function playResidualPoison(
|
||||
};
|
||||
log(
|
||||
g,
|
||||
'The prior inspected element no longer permits a legal battle plan after the leader death. Its owner must answer that same element again; the earlier private value is not revealed.',
|
||||
`The prior inspected element no longer permits a legal battle plan after ${cause}. Its owner must answer that same element again; the earlier private value is not revealed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11380,12 +11581,10 @@ function playResidualPoison(
|
||||
if (!findReachableBattlePlan(trial, getPlayer(trial, nexus.target))) {
|
||||
b.nexusInspection = nexusRule(() => reopenNexusInspection(battleInspectionContext(g), nexus));
|
||||
finishInspectionAnswers(g);
|
||||
log(g, 'The leader death made the inspected element impossible. Its owner must answer the same element again; the earlier private observation is retained.');
|
||||
log(g, `After ${cause}, the inspected element became impossible. Its owner must answer the same element again; the earlier private observation is retained.`);
|
||||
}
|
||||
}
|
||||
reconcileBattlePromises(g);
|
||||
if (b.preLeader && !b.preLeader.closed)
|
||||
b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id);
|
||||
}
|
||||
function residualPoisonView(g: Game, p: Player) {
|
||||
const card = p.hand.find(
|
||||
@@ -11525,7 +11724,7 @@ function stoneCompulsionBlock(g: Game, target: Player): string | null {
|
||||
const opponents = stonePublicPools(g, other, target);
|
||||
for (const own of stonePublicPools(g, target, other)) {
|
||||
const supported = Array.from(
|
||||
{ length: (own.normal + own.elite * own.eliteStrength) * 2 + 1 },
|
||||
{ length: maxCombatDial(own) * 2 + 1 },
|
||||
(_, n) => n / 2,
|
||||
).some(
|
||||
(dial) =>
|
||||
@@ -11878,6 +12077,7 @@ function marketGholaIntegrity(g: Game) {
|
||||
nexusFaceDancerIntegrity(g);
|
||||
nexusSuboidIntegrity(g);
|
||||
nexusAdvisorIntegrity(g);
|
||||
nexusSardaukarIntegrity(g);
|
||||
traitorDeclarationIntegrity(g);
|
||||
nexusInspectionIntegrity(g);
|
||||
homeworldHistoryIntegrity(g);
|
||||
@@ -12019,17 +12219,14 @@ function findLegalBattlePlan(
|
||||
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
|
||||
const forces = combatForces(g, p, b.territory, opponent);
|
||||
const typed = g.advanced || p.faction === 'ixians';
|
||||
const maxDial = typed
|
||||
? forces.normal * (forces.normalFixedHalf ? 0.5 : 1) +
|
||||
forces.elite * forces.eliteStrength
|
||||
: forces.normal + forces.elite;
|
||||
const maxDial = typed ? maxCombatDial(forces) : forces.normal + forces.elite;
|
||||
const numerical: { dial: number; support: number }[] = [];
|
||||
for (let dial = 0; dial <= maxDial; dial += typed ? 0.5 : 1)
|
||||
for (
|
||||
let support = 0;
|
||||
support <=
|
||||
(g.advanced && !forces.freeSupport
|
||||
? Math.min(battleSupportBudget(g, p), forces.normal + forces.elite)
|
||||
? Math.min(battleSupportBudget(g, p), maxCombatSupport(forces))
|
||||
: 0);
|
||||
support++
|
||||
) {
|
||||
@@ -12150,6 +12347,7 @@ function findReachableBattlePlan(
|
||||
// The real table must finish its current window before it can execute a preparation action.
|
||||
if (initial.battle?.nexusInspection?.stage === 'response')
|
||||
initial.battle.nexusInspection = allowNexusInspection(battleInspectionContext(initial), initial.battle.nexusInspection);
|
||||
if (currentNexusSardaukar(initial)?.stage === 'pending') finishNexusSardaukar(initial,false);
|
||||
initial.response = null;
|
||||
initial.pendingKarama = null;
|
||||
// Search after Truthtrance resolves, retaining every existing or proposed
|
||||
@@ -12174,6 +12372,11 @@ function findReachableBattlePlan(
|
||||
!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]
|
||||
: []),
|
||||
...(actor.faction === 'emperor' && !nexusSardaukarEligibility(node.state,actor) &&
|
||||
node.state.nexusCards?.phase?.stage !== 'drawing' && !node.state.pendingNullentropy &&
|
||||
!node.state.pendingTreacheryDiscard && !pendingNexusTraitors(node.state)
|
||||
? [{type:'nexusSardaukar',event:JSON.stringify(['nexusSardaukar',node.state.turn,node.state.battle!.event,actor.id])} as Action]
|
||||
: []),
|
||||
...gholaPreparationActions(node.state, actor),
|
||||
...cashInPreparationActions(node.state, actor),
|
||||
]) {
|
||||
@@ -12181,6 +12384,7 @@ function findReachableBattlePlan(
|
||||
target = getPlayer(trial, p.id);
|
||||
try {
|
||||
if (action.type === 'nexusSuboids') commitNexusSuboids(trial, target);
|
||||
else if (action.type === 'nexusSardaukar') commitNexusSardaukar(trial,target,'active');
|
||||
else if (action.mode === 'special') specialKarama(trial, target, action);
|
||||
else {
|
||||
applyGholaEffect(trial, target, action);
|
||||
@@ -12945,6 +13149,11 @@ function resolveBattle(g: Game) {
|
||||
};
|
||||
const winner = quote.winner ? getPlayer(g, quote.winner) : undefined;
|
||||
const casualtyCommitment = quote.casualties ?? undefined;
|
||||
const sardaukar = currentNexusSardaukar(g);
|
||||
if (sardaukar?.stage === 'active' && casualtyCommitment && winner?.id === sardaukar.receipt.owner) {
|
||||
sardaukar.casualties = {...structuredClone(casualtyCommitment),outcome:'pending'};
|
||||
sardaukar.signature = nexusSardaukarSignature(sardaukar);
|
||||
}
|
||||
for (const payment of quote.payments) {
|
||||
const player = getPlayer(g, payment.player);
|
||||
player.spice -= payment.ownPayment;
|
||||
@@ -13112,6 +13321,7 @@ function resolveBattle(g: Game) {
|
||||
winner: winner?.id ?? null,
|
||||
result: quote.result,
|
||||
...(playedCardRoles ? { cardRoles: playedCardRoles } : {}),
|
||||
...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}),
|
||||
};
|
||||
if (g.homeworlds?.custody && winner?.faction === 'atreides' &&
|
||||
(quote.result === 'normal' || quote.result === 'traitor')) {
|
||||
@@ -13209,7 +13419,15 @@ function settleWinnerCasualties(
|
||||
automatic = false,
|
||||
) {
|
||||
if (to.startsWith('homeworld:')) homeworldBattleLossIntegrity(g);
|
||||
const sardaukar = g.nexusSardaukarHistory?.find(record => record.receipt.battle === g.lastBattleContext?.event && record.receipt.owner === p.id);
|
||||
if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' &&
|
||||
sardaukar.casualties.options.some(option => JSON.stringify(option) === JSON.stringify(choice)),
|
||||
'Choose an original Nexus Sardaukar casualty allocation.');
|
||||
const losses = takeBattleLosses(g, p, to, choice);
|
||||
if (sardaukar?.casualties) {
|
||||
sardaukar.casualties.outcome = 'complete';
|
||||
sardaukar.signature = nexusSardaukarSignature(sardaukar);
|
||||
}
|
||||
observeOccupation(g);
|
||||
if (to.startsWith('homeworld:')) g.homeworldBattleLoss = null;
|
||||
log(
|
||||
@@ -14265,6 +14483,7 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
const response = g.response!;
|
||||
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
|
||||
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
|
||||
if (response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, response);
|
||||
const ecazCollectionQuote =
|
||||
response.kind === 'ecazCollection'
|
||||
? currentEcazCollectionQuote(g, response, canceled)
|
||||
@@ -14297,6 +14516,10 @@ function finishResponse(g: Game, canceled: boolean) {
|
||||
finishNexusAdvisors(g, canceled);
|
||||
return;
|
||||
}
|
||||
if (response.kind === 'nexusSardaukar') {
|
||||
finishNexusSardaukar(g,canceled);
|
||||
return;
|
||||
}
|
||||
if (ecazCollectionQuote) {
|
||||
if (canceled)
|
||||
log(
|
||||
@@ -17094,6 +17317,7 @@ function applyActionInner(
|
||||
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 === 'nexusSardaukar') { playNexusSardaukar(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;
|
||||
@@ -20564,6 +20788,7 @@ export function viewGame(state: Game, id: string) {
|
||||
nexusTleilaxu: projectedNexusTleilaxu(g, id),
|
||||
nexusSuboids: projectedNexusSuboids(g, id),
|
||||
nexusAdvisors: projectedNexusAdvisors(g, id),
|
||||
nexusSardaukar: projectedNexusSardaukar(g,id),
|
||||
nexusAtreides: nexusAtreidesOffer(g, id),
|
||||
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
|
||||
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
|
||||
|
||||
@@ -84,6 +84,10 @@ const cancellationSources = {
|
||||
...movementSource(g),
|
||||
conversion: g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null,
|
||||
}),
|
||||
nexusSardaukar: (g) => ({
|
||||
battle: battleSource(g),
|
||||
declaration: g.nexusSardaukarHistory?.find(record => record.stage === 'pending') ?? null,
|
||||
}),
|
||||
advisor: (g, response) =>
|
||||
response.advisorResume === 'ambassador'
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** The owner-only server offer verifies the five-counter army and the
|
||||
* feasibility of existing commitments. Preserve its exact battle event. */
|
||||
export function nexusSardaukarAction(
|
||||
game: GameView,
|
||||
event: string,
|
||||
): Action | null {
|
||||
const state = game.nexusSardaukar;
|
||||
const owner = game.players.find((player) => player.id === game.me);
|
||||
const battle = game.battle;
|
||||
if (
|
||||
game.status !== 'playing' ||
|
||||
!game.advanced ||
|
||||
game.phase !== 6 ||
|
||||
!owner ||
|
||||
owner.faction !== 'emperor' ||
|
||||
owner.ally ||
|
||||
game.nexusCards?.card !== 'emperor' ||
|
||||
!battle ||
|
||||
![battle.attacker, battle.defender].includes(owner.id) ||
|
||||
battle.submitted.includes(owner.id) ||
|
||||
game.automaticContinuationPending ||
|
||||
game.truthtrance ||
|
||||
game.response ||
|
||||
game.decision ||
|
||||
game.phaseOpening ||
|
||||
game.nexusCards.waiting.length ||
|
||||
game.nexusTraitors?.pending ||
|
||||
state?.pending ||
|
||||
state?.active ||
|
||||
!state?.offer ||
|
||||
!event ||
|
||||
state.offer.event !== event ||
|
||||
state.offer.blocked
|
||||
)
|
||||
return null;
|
||||
return { type: 'nexusSardaukar', event };
|
||||
}
|
||||
export function nexusSardaukarBotActions(game: GameView): Action[] {
|
||||
const offer = game.nexusSardaukar?.offer;
|
||||
const action = offer ? nexusSardaukarAction(game, offer.event) : null;
|
||||
return action ? [action] : [];
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { FactionId } from './catalog';
|
||||
|
||||
export type NexusSardaukarContext = {
|
||||
turn: number;
|
||||
players: readonly { id: string; faction: FactionId }[];
|
||||
};
|
||||
export type NexusSardaukarReceipt = {
|
||||
version: 1;
|
||||
event: string;
|
||||
owner: string;
|
||||
turn: number;
|
||||
battle: string;
|
||||
territory: string;
|
||||
normal: number;
|
||||
elite: 0;
|
||||
count: 5;
|
||||
roster: { id: string; faction: FactionId }[];
|
||||
signature: string;
|
||||
};
|
||||
function requireSardaukar(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
function identifier(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.trim().length > 0 &&
|
||||
!['__proto__', 'constructor', 'prototype'].includes(value)
|
||||
);
|
||||
}
|
||||
function roster(context: NexusSardaukarContext) {
|
||||
requireSardaukar(
|
||||
Number.isSafeInteger(context.turn) &&
|
||||
context.turn > 0 &&
|
||||
Array.isArray(context.players) &&
|
||||
context.players.length > 0 &&
|
||||
context.players.every(
|
||||
(p) => p && identifier(p.id) && identifier(p.faction),
|
||||
) &&
|
||||
new Set(context.players.map((p) => p.id)).size ===
|
||||
context.players.length &&
|
||||
new Set(context.players.map((p) => p.faction)).size ===
|
||||
context.players.length,
|
||||
'The Sardaukar Nexus needs its original faction roster and turn.',
|
||||
);
|
||||
return context.players
|
||||
.map(({ id, faction }) => ({ id, faction }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
function signature(receipt: NexusSardaukarReceipt): string {
|
||||
return JSON.stringify([
|
||||
receipt.version,
|
||||
receipt.event,
|
||||
receipt.owner,
|
||||
receipt.turn,
|
||||
receipt.battle,
|
||||
receipt.territory,
|
||||
receipt.normal,
|
||||
receipt.elite,
|
||||
receipt.count,
|
||||
receipt.roster.map((p) => [p.id, p.faction]),
|
||||
]);
|
||||
}
|
||||
/** Historical evidence does not depend on forces surviving this battle. */
|
||||
export function validateNexusSardaukar(
|
||||
context: NexusSardaukarContext,
|
||||
receipt: NexusSardaukarReceipt,
|
||||
): void {
|
||||
const seats = roster(context);
|
||||
requireSardaukar(
|
||||
receipt &&
|
||||
typeof receipt === 'object' &&
|
||||
!Array.isArray(receipt) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(receipt)) &&
|
||||
Object.keys(receipt).sort().join(',') ===
|
||||
'battle,count,elite,event,normal,owner,roster,signature,territory,turn,version' &&
|
||||
receipt.version === 1 &&
|
||||
identifier(receipt.battle) &&
|
||||
identifier(receipt.territory) &&
|
||||
Number.isSafeInteger(receipt.turn) &&
|
||||
receipt.turn > 0 &&
|
||||
receipt.turn <= context.turn &&
|
||||
Number.isSafeInteger(receipt.normal) &&
|
||||
receipt.normal >= 5 &&
|
||||
receipt.normal <= 20 &&
|
||||
receipt.elite === 0 &&
|
||||
receipt.count === 5 &&
|
||||
seats.some((p) => p.id === receipt.owner && p.faction === 'emperor') &&
|
||||
receipt.event ===
|
||||
JSON.stringify([
|
||||
'nexusSardaukar',
|
||||
receipt.turn,
|
||||
receipt.battle,
|
||||
receipt.owner,
|
||||
]) &&
|
||||
JSON.stringify(receipt.roster) === JSON.stringify(seats) &&
|
||||
receipt.signature === signature(receipt),
|
||||
'The Sardaukar Nexus has lost its original battle, five-counter group or owner.',
|
||||
);
|
||||
}
|
||||
export function createNexusSardaukar(
|
||||
context: NexusSardaukarContext,
|
||||
owner: string,
|
||||
battle: string,
|
||||
territory: string,
|
||||
normal: number,
|
||||
): NexusSardaukarReceipt {
|
||||
const receipt: NexusSardaukarReceipt = {
|
||||
version: 1,
|
||||
event: JSON.stringify(['nexusSardaukar', context.turn, battle, owner]),
|
||||
owner,
|
||||
turn: context.turn,
|
||||
battle,
|
||||
territory,
|
||||
normal,
|
||||
elite: 0,
|
||||
count: 5,
|
||||
roster: roster(context),
|
||||
signature: '',
|
||||
};
|
||||
receipt.signature = signature(receipt);
|
||||
validateNexusSardaukar(context, receipt);
|
||||
return receipt;
|
||||
}
|
||||
+8
-6
@@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '),
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian and Bene Gesserit Cunning are integrated. Other effects and complete module play remain in development.',
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections, Harkonnen exchanges and native Tleilaxu, Ixian, Bene Gesserit and Emperor 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.',
|
||||
@@ -3362,15 +3362,17 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
|
||||
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups and other Emperor Nexus modes remain unavailable.',
|
||||
'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.',
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength 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'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, revealed Face Dancer refresh, turn-wide Suboid strength, whole-territory advisor conversion and temporary Emperor Sardaukar. Borrowed Voice, Betrayal, Tleilaxu revival and remaining effects are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection and temporary Sardaukar with native Karama responses. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles handle the integrated effects through private offers, authorized choices and projected forces. Advisor choices use public opposing fighter counts. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and the integrated effect families retain explicit unresolved boundaries, including fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, support solver, territory-selection controls, bot and recovery regressions; Emperor Cunning browser checks pass while complete module games remain a separate requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
@@ -75,6 +75,7 @@ const RESPONSES = {
|
||||
prescience: true,
|
||||
nexusPrescience: true,
|
||||
nexusAdvisorFlip: true,
|
||||
nexusSardaukar: true,
|
||||
advisor: true,
|
||||
emperorIncome: true,
|
||||
richesePurchaseIncome: true,
|
||||
|
||||
+7
-10
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
casualtyOptions,
|
||||
validCombatForces,
|
||||
maxCombatDial,
|
||||
maxCombatSupport,
|
||||
type CombatForces,
|
||||
} from './combat';
|
||||
|
||||
@@ -28,7 +30,7 @@ function totals(forces: CombatForces, dial: number, support: number): number[] {
|
||||
!Number.isInteger(dial * 2) ||
|
||||
!Number.isSafeInteger(support) ||
|
||||
support < 0 ||
|
||||
support > forces.normal + forces.elite
|
||||
support > maxCombatSupport(forces)
|
||||
)
|
||||
return [];
|
||||
return sorted(
|
||||
@@ -88,6 +90,7 @@ function allOpposingTotals(
|
||||
forces.normal,
|
||||
forces.elite,
|
||||
forces.eliteStrength,
|
||||
forces.temporaryElite ?? 0,
|
||||
!!forces.normalFixedHalf,
|
||||
!!forces.normalFreeSupport,
|
||||
forces.freeSupport,
|
||||
@@ -96,14 +99,8 @@ function allOpposingTotals(
|
||||
const cached = opposingChoices.get(key);
|
||||
if (cached) return cached;
|
||||
const result: number[][] = [];
|
||||
const maximumHalfDial =
|
||||
forces.normal *
|
||||
(forces.normalFixedHalf && !forces.normalFreeSupport ? 1 : 2) +
|
||||
forces.elite * forces.eliteStrength * 2;
|
||||
const maximumSupport = forces.freeSupport
|
||||
? 0
|
||||
: (forces.normalFixedHalf || forces.normalFreeSupport ? 0 : forces.normal) +
|
||||
(forces.eliteFreeSupport ? 0 : forces.elite);
|
||||
const maximumHalfDial = maxCombatDial(forces) * 2;
|
||||
const maximumSupport = maxCombatSupport(forces);
|
||||
// Use the same legality and typed-support arithmetic as sealed plans. Cache
|
||||
// public pool results so repeated preflights do not repeat this enumeration.
|
||||
for (let halfDial = 0; halfDial <= maximumHalfDial; halfDial++)
|
||||
@@ -158,7 +155,7 @@ export function stoneBurnerCompulsionBlock(
|
||||
return 'Choose valid Stone Burner combatant roles.';
|
||||
if (!validCombatForces(own) || !validCombatForces(opponent))
|
||||
return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.';
|
||||
const maximumStrength = own.normal + own.elite * own.eliteStrength;
|
||||
const maximumStrength = maxCombatDial(own);
|
||||
for (let halfDial = 0; halfDial <= maximumStrength * 2; halfDial++) {
|
||||
if (!totals(own, halfDial / 2, 0).length) continue;
|
||||
if (
|
||||
|
||||
Reference in new issue
Block a user