Implement Richese Nexus Secret Ally shipment and recovery

This commit is contained in:
Quintine committed 2026-09-10 13:03:53 +10:00
1 parent ae66668e2e
commit 6ba1050a92
21 files changed
+2286 -103

No files matched your search

+13
View File
@@ -52,6 +52,7 @@ import { ownedTech } from './tech-tokens';
import type { StrongholdProgress } from './victory-progress';
import { canUseAsKarama } from './karama';
import { reserveShipmentCost } from './shipment-price';
import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
import {
botEntryAllowed,
@@ -2925,6 +2926,18 @@ function policyActions(g: GameView): Action[] {
me.reserves,
level === 0 ? 1 : Math.max([1, 3, 4, 5][level], to.enemy + 2),
);
// Quote the discounted candidate independently of ordinary affordability.
const richeseAmount = Math.min(me.reserves, 5, g.nexusRichese?.maxForces ?? 0);
const richeseQuote = nexusRicheseQuote(g, to.t, richeseAmount);
if (richeseQuote && richeseAmount > 1 && richeseQuote.cost <= shipmentBudget &&
richeseQuote.cost < reserveShipmentCost({ faction: me.faction, halfRate }, territory(to.t).type, richeseAmount)) {
const candidate = nexusRicheseAction(g, g.nexusRichese!.event, {
type: 'ship', territory: to.t, sector: to.s, amount: richeseAmount,
elite: level > 0 ? Math.min(richeseAmount, me.elites?.reserves ?? 0) :
Math.max(0, richeseAmount - (me.reserves - (me.elites?.reserves ?? 0))),
});
if (candidate) actions.push(candidate);
}
for (const amount of new Set([desired, Math.min(desired, 3), 1])) {
if (
reserveShipmentCost(
+176 -81
View File
@@ -1,3 +1,4 @@
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
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';
@@ -845,6 +846,7 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
pools: ReturnType<typeof quoteHomeworldShipment>['sources'] | ReturnType<typeof quoteGuildHomeworldShipment>['boardSources'];
};
type PendingShipment = {
nexusEvent?: string;
homeworldSources?: NativeReserveSelections;
source?: 'ambassador';
ambassadorEvent?: string;
@@ -1282,6 +1284,8 @@ export type Game = {
nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'};
nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[];
nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[];
nexusRicheseHistory?: {receipt:NexusRicheseReceipt;stage:'pending'|'shipped'|'stopped';frame:string;halfRate:boolean;signature:string}[];
nexusRicheseLast?: {event:string;stage:'pending'|'shipped'|'stopped'};
nexusMoritaniLocations?: Record<string,{event:string;territory:string}>;
nexusMoritaniLast?: {event:string;stage:'pending'|'complete'|'canceled'};
nexusChoamLast?: {event: string; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'};
@@ -12097,6 +12101,7 @@ function marketGholaIntegrity(g: Game) {
nexusSardaukarIntegrity(g);
nexusChoamIntegrity(g);
nexusMoritaniIntegrity(g);
nexusRicheseIntegrity(g);
traitorDeclarationIntegrity(g);
nexusInspectionIntegrity(g);
homeworldHistoryIntegrity(g);
@@ -12524,62 +12529,63 @@ function findShipmentCompletion(
)
continue;
for (const sector of territory(destination).sectors) {
const cost = reserveShipmentCost(
{
faction: p.faction,
halfRate:
p.faction === 'guild' ||
byFaction(g, 'guild')?.id === p.ally ||
g.karamaShipping?.player === p.id,
},
territory(destination).type,
amount,
);
try {
const allyPayment = contribution(g, p, cost);
const elite = eliteChoice(
amount,
p.reserves,
p.elites?.reserves ?? 0,
undefined,
);
const sources = g.homeworlds?.custody
? nativeReserveSources(
homeworldContext(g),
g.homeworlds.custody,
p.id,
{ normal: amount - elite, elite },
)
: null;
const nativeSources = sources ? { homeworldSources: sources } : {};
validatePhysicalShipment(g, {
...nativeSources,
turn: g.turn,
player: p.id,
territory: destination,
sector,
amount,
elite,
cost,
allyPayment,
advisors: arrivalAsAdvisor(g, p, destination),
});
return {
actions: [
...actions,
{
type: 'ship',
territory: destination,
sector,
amount,
elite,
allyPayment,
...nativeSources,
},
],
};
} catch (error) {
if (!(error instanceof RuleError)) throw error;
const offer = nexusRicheseOffer(g,p.id);
for (const nexus of [undefined, ...(offer && !offer.blocked && amount <= offer.maxForces ? [offer.event] : [])]) {
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
const cost = nexus
? nexusRule(() => quoteNexusRicheseShipment(price,territory(destination).type,amount)).cost
: reserveShipmentCost(price,territory(destination).type,amount);
try {
const allyPayment = contribution(g, p, cost);
const elite = eliteChoice(
amount,
p.reserves,
p.elites?.reserves ?? 0,
undefined,
);
const sources = g.homeworlds?.custody
? nativeReserveSources(
homeworldContext(g),
g.homeworlds.custody,
p.id,
{ normal: amount - elite, elite },
)
: null;
const nativeSources = sources ? { homeworldSources: sources } : {};
const shipment: PendingShipment = {
...(nexus ? {nexusEvent:nexus} : {}),
...nativeSources,
turn: g.turn,
player: p.id,
territory: destination,
sector,
amount,
elite,
cost,
allyPayment,
advisors: arrivalAsAdvisor(g, p, destination),
};
const trial = nexus ? structuredClone(g) : g;
if (nexus) recordNexusRicheseShipment(trial,shipment);
validatePhysicalShipment(trial,shipment);
return {
actions: [
...actions,
{
type: 'ship',
...(nexus ? {nexus} : {}),
territory: destination,
sector,
amount,
elite,
allyPayment,
...nativeSources,
},
],
};
} catch (error) {
if (!(error instanceof RuleError)) throw error;
}
}
if (p.faction === 'fremen' && byFaction(g, 'guild')?.id === p.ally) {
const action: Action = {
@@ -15767,6 +15773,7 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI
} else commitHomeworldShipment(g, shipment);
}
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
if (shipment.nexusEvent !== undefined) currentNexusRicheseShipment(g,shipment);
requireRule(
!(shipment.noField || shipment.alliedNoField) ||
shipment.homeworldSources === undefined,
@@ -15835,17 +15842,10 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
),
'Fremen reinforcements must arrive within two territories of the Great Flat.',
);
const cost = reserveShipmentCost(
{
faction: p.faction,
halfRate:
p.faction === 'guild' ||
byFaction(g, 'guild')?.id === p.ally ||
g.karamaShipping?.player === p.id,
},
territory(shipment.territory).type,
n,
);
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
const cost = shipment.nexusEvent
? nexusRule(() => quoteNexusRicheseShipment(price,territory(shipment.territory).type,n)).cost
: reserveShipmentCost(price,territory(shipment.territory).type,n);
requireRule(
Number.isSafeInteger(shipment.cost) && shipment.cost === cost,
'The declared shipment price is no longer current.',
@@ -15911,7 +15911,7 @@ function offerShipment(g: Game, shipment: PendingShipment) {
};
log(
g,
`${p.name} declared a shipment of ${n} forces to ${territory(to).name}, sector ${s}.`,
`${p.name} declared a shipment of ${n} forces to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`,
);
} else commitShipment(g, shipment);
}
@@ -15921,6 +15921,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
return;
}
validatePhysicalShipment(g, shipment);
finishNexusRicheseShipment(g,shipment,'shipped');
finishShipmentPromises(
g,
getPlayer(g, shipment.player),
@@ -16033,10 +16034,10 @@ function commitShipment(g: Game, shipment: PendingShipment) {
? `${getPlayer(g, shipment.alliedNoField.owner).name} shipped ${p.name} with No-Field ${quoteAlliedTokenValue(g, shipment.alliedNoField)}, immediately placing ${n} physical forces (${elite} elite) in ${territory(to).name}, sector ${s}. ${shipment.alliedNoField.payer === 'both' ? 'Each ally paid 1 spice' : `${getPlayer(g, shipment.alliedNoField.payer).name} paid ${cost} spice`}.`
: shipment.noField
? `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. It counts as one force; physical reserves remain unchanged until reveal.`
: `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
: `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
shipment.noField || shipment.alliedNoField
? { faction: 'richese', name: 'No-Field shipment' }
: undefined,
: shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : undefined,
);
const bg = byFaction(g, 'beneGesserit');
const followup =
@@ -16131,6 +16132,92 @@ function choamWorthlessBlocked(g: Game, card: string) {
g.choamWorthlessBlocked.cards.includes(card)
);
}
function nexusRicheseOffer(g: Game, owner: string) {
const p = g.players.find(p => p.id === owner);
if (!p || g.nexusCards?.cards?.hands[owner] !== 'richese' || byFaction(g,'richese')) return null;
let blocked: string | null = null;
if (p.ally) blocked = 'Richese Secret Ally requires an unallied holder.';
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || p.shipped)
blocked = 'Use Secret Ally for your unused reserve shipment before moving.';
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama ||
g.pendingNullentropy || g.pendingTreacheryDiscard || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing')
blocked = 'Finish the current interaction before declaring this shipment.';
else if (p.reserves < 1) blocked = 'You need physical forces in reserves.';
return {event:JSON.stringify(['nexusRichese',g.turn,owner]),blocked,maxForces:Math.min(5,p.reserves)};
}
function shipmentHalfRate(g: Game, p: Player) {
return p.faction === 'guild' || byFaction(g,'guild')?.id === p.ally || g.karamaShipping?.player === p.id;
}
function nexusRicheseSignature(record: NonNullable<Game['nexusRicheseHistory']>[number]) {
return JSON.stringify([record.receipt.signature,record.stage,record.frame,record.halfRate]);
}
function currentNexusRicheseShipment(g: Game, shipment: PendingShipment) {
const record = g.nexusRicheseHistory?.find(r => r.receipt.event === shipment.nexusEvent);
requireRule(record?.stage === 'pending' && record.frame === JSON.stringify(shipment) &&
record.receipt.owner === shipment.player && record.receipt.turn === g.turn && !getPlayer(g,shipment.player).ally &&
record.signature === nexusRicheseSignature(record) && !shipment.noField && !shipment.alliedNoField && !shipment.source,
'Richese Secret Ally has lost its original physical shipment declaration.');
nexusRule(() => validateNexusRichese(g,record.receipt));
return record;
}
function recordNexusRicheseShipment(g: Game, shipment: PendingShipment) {
const p = getPlayer(g,shipment.player);
const receipt = nexusRule(() => createNexusRichese(g,p.id));
requireRule(shipment.nexusEvent === receipt.event, 'This Richese Secret Ally selection is stale.');
const record = {receipt,stage:'pending' as const,frame:JSON.stringify(shipment),halfRate:shipmentHalfRate(g,p),signature:''};
record.signature = nexusRicheseSignature(record);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
(g.nexusRicheseHistory ??= []).push(record);
g.nexusRicheseLast = {event:receipt.event,stage:'pending'};
}
function finishNexusRicheseShipment(g: Game, shipment: PendingShipment, stage: 'shipped'|'stopped') {
if (!shipment.nexusEvent) return;
const record = currentNexusRicheseShipment(g,shipment);
record.stage = stage;
record.signature = nexusRicheseSignature(record);
g.nexusRicheseLast = {event:record.receipt.event,stage};
}
function nexusRicheseIntegrity(g: Game) {
const history = g.nexusRicheseHistory, pending = g.pendingShipment;
if (history === undefined) {
requireRule(!g.nexusRicheseLast && !pending?.nexusEvent, 'Richese Secret Ally has lost its saved shipment history.');
return;
}
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
'Richese Secret Ally requires its original Nexus module and history.');
const events = new Set<string>();
for (const [index,record] of history.entries()) {
requireRule(record && typeof record === 'object' &&
Object.keys(record).sort().join(',') === 'frame,halfRate,receipt,signature,stage' &&
['pending','shipped','stopped'].includes(record.stage) && typeof record.frame === 'string' &&
typeof record.halfRate === 'boolean' && record.signature === nexusRicheseSignature(record) &&
!events.has(record.receipt?.event) && (record.stage !== 'pending' || index === history.length - 1),
'Richese Secret Ally has a changed or duplicated shipment record.');
nexusRule(() => validateNexusRichese(g,record.receipt));
const frame = nexusRule(() => JSON.parse(record.frame)) as PendingShipment;
requireRule(frame && frame.nexusEvent === record.receipt.event && frame.player === record.receipt.owner &&
frame.turn === record.receipt.turn && !frame.noField && !frame.alliedNoField && !frame.source &&
Object.keys(frame).every(k => ['nexusEvent','turn','player','territory','sector','amount','elite','cost','allyPayment','advisors','homeworldSources'].includes(k)),
'Richese Secret Ally has changed its original shipment source.');
const quote = nexusRule(() => quoteNexusRicheseShipment({faction:getPlayer(g,frame.player).faction,halfRate:record.halfRate},territory(frame.territory).type,frame.amount));
requireRule(frame.cost === quote.cost && frame.allyPayment === 0 && Number.isSafeInteger(frame.elite) &&
frame.elite >= 0 && frame.elite <= frame.amount && typeof frame.advisors === 'boolean' &&
territory(frame.territory).sectors.includes(frame.sector), 'Richese Secret Ally has changed its original physical allocation or price.');
events.add(record.receipt.event);
}
const last = history.at(-1)!;
requireRule(JSON.stringify(g.nexusRicheseLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}) &&
(last.stage === 'pending') === !!pending?.nexusEvent,
'Richese Secret Ally has lost or reopened its latest shipment.');
if (last.stage === 'pending') {
requireRule(pending && pending.nexusEvent === last.receipt.event && g.nexusCards.cards.discard.includes('richese'),
'Richese Secret Ally has lost its pending shipment or spent Nexus card.');
currentNexusRicheseShipment(g,pending);
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment');
requireRule(decisions.length > 0, 'Richese Secret Ally has lost its Guild interception decision.');
for (const decision of decisions) validateGuildShipmentDecision(g,decision);
}
}
function nexusMoritaniOffer(g: Game, owner: string, decision: Game['decision'] = g.decision) {
const p = g.players.find(p => p.id === owner);
if (!p || p.faction !== 'moritani' || g.nexusCards?.cards?.hands[p.id] !== 'moritani') return null;
@@ -17058,6 +17145,7 @@ export function executeSpecialKaramaIntent(
finishAmbassador(g);
return;
}
if (g.pendingShipment) finishNexusRicheseShipment(g,g.pendingShipment,'stopped');
shipper.shipped = true;
// Provisional canceled-shipment settlement pending a primary-source
// clarification: retain the attempted shipment's spice and rate card.
@@ -19868,6 +19956,7 @@ function applyActionInner(
return g;
}
if (t === 'homeworldShip' || t === 'guildHomeworldShip') {
requireRule(action.nexus === undefined, 'Richese Secret Ally applies only to an ordinary Arrakis reserve shipment.');
const route = t === 'guildHomeworldShip' ? 'arrakis' as const : undefined;
const blocked = route ? guildHomeworldShipmentBlock(g, p) : homeworldShipmentBlock(g, p);
requireRule(!blocked, blocked ?? 'Homeworld shipment is unavailable.');
@@ -19883,6 +19972,15 @@ function applyActionInner(
g.phase === 5 && g.active === id && !p.shipped,
'You may ship once, before your movement.',
);
let nexusEvent: string | undefined;
if (action.nexus !== undefined) {
const offer = nexusRicheseOffer(g,id);
requireRule(offer && !offer.blocked && action.nexus === offer.event,
offer?.blocked ?? 'This Richese Secret Ally shipment is unavailable or stale.');
requireRule(Object.keys(action).every(k => ['type','territory','sector','amount','elite','homeworldSources','allyPayment','nexus'].includes(k)),
'Richese Secret Ally requires an ordinary physical reserve shipment.');
nexusEvent = offer.event;
}
checkShipmentPromises(g, p, {
territory: String(action.territory),
amount: Number(action.amount),
@@ -19929,17 +20027,10 @@ function applyActionInner(
const elite = noField
? 0
: eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, action.elite);
const cost = reserveShipmentCost(
{
faction: p.faction,
halfRate:
p.faction === 'guild' ||
byFaction(g, 'guild')?.id === p.ally ||
g.karamaShipping?.player === id,
},
territory(to).type,
n,
);
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
const cost = nexusEvent
? nexusRule(() => quoteNexusRicheseShipment(price,territory(to).type,n)).cost
: reserveShipmentCost(price,territory(to).type,n);
if (p.faction === 'fremen') {
requireRule(
territory('the_great_flat').sectors.some((fs) =>
@@ -19957,6 +20048,7 @@ function applyActionInner(
);
const allyPayment = contribution(g, p, cost, action.allyPayment);
const shipment: PendingShipment = {
...(nexusEvent ? {nexusEvent} : {}),
turn: g.turn,
player: id,
territory: to,
@@ -19974,6 +20066,7 @@ function applyActionInner(
}),
...(noField ? { noField } : {}),
};
if (nexusEvent) recordNexusRicheseShipment(g,shipment);
if (shipment.noField) {
checkShipmentIncomeRounding(g, p, cost, allyPayment);
g.pendingShipment = shipment;
@@ -19986,6 +20079,7 @@ function applyActionInner(
return g;
}
if (t === 'guildShip') {
requireRule(action.nexus === undefined, 'Choose ordinary reserve shipment to use Richese Secret Ally.');
const homeworldTarget = action.territory ?? 'reserves';
if (g.homeworlds?.custody && p.faction === 'guild' &&
(homeworldTarget === 'reserves' || (typeof homeworldTarget === 'string' && homeworldTarget.startsWith('homeworld:')))) {
@@ -21044,6 +21138,7 @@ export function viewGame(state: Game, id: string) {
strongholdCards: g.strongholdCards ?? null,
nexusCards: projectedNexusCards(g, id),
nexusMoritani: nexusMoritaniOffer(g,id),
nexusRichese: nexusRicheseOffer(g,id),
nexusTraitors: projectedNexusTraitors(g, id),
nexusTleilaxu: projectedNexusTleilaxu(g, id),
nexusSuboids: projectedNexusSuboids(g, id),
+4 -1
View File
@@ -2,6 +2,7 @@ import type { Action, GameView } from './engine';
import { quoteGuildPaymentRounding } from './homeworld-payment-income';
import { reserveShipmentCost, guildShipmentCost } from './shipment-price';
import { territory } from './board';
import { nexusRicheseQuote } from './nexus-richese-options';
/** Own funding and public native population only; no donor balance or hidden card access. */
export function homeworldShipmentPaymentBlock(g: GameView, cost: number, allyPayment: number): string | null {
@@ -21,7 +22,9 @@ export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action):
const me = g.players.find(p => p.id === g.me)!;
const to = String(action.territory);
const amount = action.noField ? 1 : Number(action.amount);
const cost = action.type === 'ship'
const nexusQuote = action.nexus === undefined ? null : nexusRicheseQuote(g, to, amount);
if (action.nexus !== undefined && (!nexusQuote || action.nexus !== g.nexusRichese?.event || action.type !== 'ship' || action.noField !== undefined)) return false;
const cost = nexusQuote ? nexusQuote.cost : action.type === 'ship'
? reserveShipmentCost({ faction: me.faction, halfRate: me.faction === 'guild' ||
g.players.some(p => p.faction === 'guild' && p.id === me.ally) || g.karamaShipping?.player === me.id }, territory(to).type, amount)
: guildShipmentCost(to === 'reserves' ? 'reserves' : territory(to).type, amount);
+85
View File
@@ -0,0 +1,85 @@
import type { Action, GameView } from './engine';
import { gameTerritories, MOBILE_STRONGHOLD } from './board';
import { quoteNexusRicheseShipment } from './nexus-richese';
export function nexusRicheseCanAct(g: GameView): boolean {
const me = g.players.find((p) => p.id === g.me);
if (
!me ||
me.ally ||
g.nexusCards?.card !== 'richese' ||
g.players.some((p) => p.faction === 'richese')
)
return false;
const offer = g.nexusRichese;
return !!(
offer &&
!offer.blocked &&
offer.event === JSON.stringify(['nexusRichese', g.turn, g.me]) &&
g.status === 'playing' &&
g.phase === 5 &&
g.active === g.me &&
!me.shipped &&
!g.response &&
!g.decision &&
!g.truthtrance &&
!g.phaseOpening &&
!g.automaticContinuationPending &&
!g.nexusCards.waiting.length &&
!g.nexusTraitors?.pending
);
}
/** Price only the authorized reserve shipment; physical arrival stays unchanged. */
export function nexusRicheseQuote(
g: GameView,
territory: string,
amount: number,
) {
if (!nexusRicheseCanAct(g)) return null;
const me = g.players.find((p) => p.id === g.me)!;
const destination = gameTerritories(g).find((t) => t.id === territory);
if (
!destination ||
(territory === MOBILE_STRONGHOLD && me.faction !== 'ixians') ||
!Number.isSafeInteger(amount) ||
amount < 1 ||
amount > Math.min(5, me.reserves, g.nexusRichese!.maxForces)
)
return null;
return quoteNexusRicheseShipment(
{
faction: me.faction,
halfRate: me.faction === 'guild' || g.karamaShipping?.player === g.me,
},
destination.type,
amount,
);
}
export function nexusRicheseAction(
g: GameView,
event: string,
shipment: Action,
): Action | null {
if (
!nexusRicheseCanAct(g) ||
event !== g.nexusRichese!.event ||
shipment.type !== 'ship' ||
Object.keys(shipment).some(
(key) =>
![
'type',
'territory',
'sector',
'amount',
'elite',
'homeworldSources',
'allyPayment',
].includes(key),
) ||
!nexusRicheseQuote(g, String(shipment.territory), Number(shipment.amount))
)
return null;
return { ...shipment, nexus: event };
}
+144
View File
@@ -0,0 +1,144 @@
import { FACTIONS, type FactionId } from './catalog';
import { reserveShipmentCost } from './shipment-price';
export type NexusRicheseContext = {
turn: number;
players: readonly { id: string; faction: FactionId }[];
};
export type NexusRicheseReceipt = {
version: 1;
event: string;
owner: string;
turn: number;
phase: 5;
mode: 'secretAlly';
roster: { id: string; faction: FactionId }[];
signature: string;
};
export type NexusRicheseShipmentQuote = {
physicalAmount: number;
pricedAmount: 1;
cost: number;
source: 'nexusRichese';
};
function requireRichese(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
const faction = (value: unknown): value is FactionId =>
FACTIONS.some((candidate) => candidate.id === value);
const identifier = (value: unknown): value is string =>
typeof value === 'string' &&
value.trim().length > 0 &&
!['__proto__', 'prototype', 'constructor'].includes(value);
const plain = (value: unknown): value is Record<string, unknown> =>
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value));
/** Only the price basis changes. The engine validates actual typed reserve
* custody, destination, native Fremen geography, payment and arrival timing. */
export function quoteNexusRicheseShipment(
player: { faction: FactionId; halfRate: boolean },
territoryType: string,
amount: number,
): NexusRicheseShipmentQuote {
requireRichese(
player &&
faction(player.faction) &&
player.faction !== 'richese' &&
typeof player.halfRate === 'boolean' &&
['stronghold', 'sand', 'rock', 'polar'].includes(territoryType) &&
Number.isSafeInteger(amount) &&
amount >= 1 &&
amount <= 5,
'Richese Secret Ally needs one to five physical reserve forces and a valid ordinary shipment price.',
);
return {
physicalAmount: amount,
pricedAmount: 1,
cost: reserveShipmentCost(player, territoryType, 1),
source: 'nexusRichese',
};
}
function roster(context: NexusRicheseContext) {
requireRichese(
context &&
Number.isSafeInteger(context.turn) &&
context.turn > 0 &&
Array.isArray(context.players) &&
context.players.length > 0 &&
context.players.every(
(p) => p && identifier(p.id) && faction(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 &&
!context.players.some((p) => p.faction === 'richese'),
'Richese Secret Ally needs a valid original faction roster with Richese absent.',
);
return context.players
.map(({ id, faction }) => ({ id, faction }))
.sort((a, b) => a.id.localeCompare(b.id));
}
function signature(receipt: NexusRicheseReceipt) {
return JSON.stringify([
'nexusRicheseReceipt',
receipt.version,
receipt.event,
receipt.owner,
receipt.turn,
receipt.phase,
receipt.mode,
receipt.roster.map((p) => [p.id, p.faction]),
]);
}
/** Historical evidence stays valid after later card use, alliances and turns.
* Signatures establish consistency, not cryptographic authentication. */
export function validateNexusRichese(
context: NexusRicheseContext,
receipt: NexusRicheseReceipt,
): void {
const seats = roster(context);
requireRichese(
plain(receipt) &&
Object.keys(receipt).sort().join(',') ===
'event,mode,owner,phase,roster,signature,turn,version' &&
receipt.version === 1 &&
receipt.phase === 5 &&
receipt.mode === 'secretAlly' &&
Number.isSafeInteger(receipt.turn) &&
receipt.turn > 0 &&
receipt.turn <= context.turn &&
identifier(receipt.owner) &&
seats.some((p) => p.id === receipt.owner) &&
receipt.event ===
JSON.stringify(['nexusRichese', receipt.turn, receipt.owner]) &&
Array.isArray(receipt.roster) &&
receipt.roster.every(
(p) => plain(p) && Object.keys(p).sort().join(',') === 'faction,id',
) &&
JSON.stringify(receipt.roster) === JSON.stringify(seats) &&
receipt.signature === signature(receipt),
'Richese Secret Ally has lost its original owner, turn, phase or faction roster.',
);
}
export function createNexusRichese(
context: NexusRicheseContext,
owner: string,
): NexusRicheseReceipt {
const receipt: NexusRicheseReceipt = {
version: 1,
event: JSON.stringify(['nexusRichese', context.turn, owner]),
owner,
turn: context.turn,
phase: 5,
mode: 'secretAlly',
roster: roster(context),
signature: '',
};
receipt.signature = signature(receipt);
validateNexusRichese(context, receipt);
return receipt;
}
+7 -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 native Tleilaxu, Ixian, Bene Gesserit, Emperor, five CHOAM Cunning effects and Moritani Cunning supply placement 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, Emperor, five CHOAM Cunning effects Moritani Cunning supply placement and Richese Secret Ally reserve shipping 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.',
@@ -3367,16 +3367,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'CHOAM Cunning spends its Nexus card to use one held Treachery Card for one chosen Worthless effect at that effect’s normal timing, in Basic and Advanced. Choose the physical card separately from the effect. Kulon, La La La, Baliset, Jubba Cloak and Trip to Gamont are supported; Kull Wahad’s Karama-prevention reaction remains unavailable.',
'One Karama response covers the CHOAM effect. Cancellation leaves the chosen Treachery Card in hand and the Nexus spent. An allowed use discards the actual card once, retaining its printed identity and applicable discard consequences; it does not create a named Worthless card or a turn-long conversion permission.',
'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.',
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules; this does not implement Atomics, Extortion or complete Moritani games.',
'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, whole-territory advisor conversion, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects and Moritani supply placement beyond ordinary strongholds. 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/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection 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', 'components/choam-power-cost.tsx', 'components/moritani-terror.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', 'game/choam-power-options.ts', 'game/nexus-moritani-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', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_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', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-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, temporary Emperor Sardaukar and five source-aware CHOAM Cunning effects Moritani supply placement beyond ordinary strongholds and Richese Secret Ally physical reserve shipping priced as one. 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/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-richese-options.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation and counted advisor-territory selection, temporary Sardaukar and CHOAM physical-card/effect selection with native Karama responses, plus an explicit Richese shipment source and physical-force price breakdown. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx'] },
{ 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', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-richese-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', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md'] },
{ 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', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts'] },
],
},
...[