Implement Guild Nexus Secret Ally shipping routes and recovery

This commit is contained in:
Quintine committed 2026-09-10 13:56:36 +10:00
1 parent 90bf699627
commit 06d0a66fc5
25 files changed
+2095 -52

No files matched your search

+31 -1
View File
@@ -53,6 +53,7 @@ import { ownedTech } from './tech-tokens';
import type { StrongholdProgress } from './victory-progress';
import { canUseAsKarama } from './karama';
import { reserveShipmentCost } from './shipment-price';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
import {
@@ -2835,6 +2836,7 @@ function policyActions(g: GameView): Action[] {
g.players.some((p) => p.faction === 'guild' && p.id === me.ally) ||
g.karamaShipping?.player === me.id;
const shipmentBudget = (me.spice ?? 0) + g.aid.available;
const guildSecret = nexusGuildSecretAllyCanAct(g);
const noField = g.richeseNoField;
const ownNoField = noField?.owner === me.id ? noField.private : null;
const marker = ownNoField?.deployed;
@@ -2918,6 +2920,23 @@ function policyActions(g: GameView): Action[] {
});
}
}
// Generate paid Guild-source candidates before applying ordinary prices or Fremen range.
if (shipmentAvailable && guildSecret && me.reserves > 0)
for (const to of targets.filter(to => botEntryAllowed(g, me, to.t, to.s, 'guildShip')).slice(0, 24)) {
if (marker?.location.territory === to.t) continue;
const desired = Math.min(me.reserves, Math.max([3, 4, 5, 6][level], to.enemy + 2));
for (const amount of new Set([desired, Math.min(desired, shipmentBudget * (territory(to.t).type === 'stronghold' ? 2 : 1))])) {
const quote = nexusGuildSecretAllyQuote(g, to.t, amount);
if (!quote || quote.cost > shipmentBudget ||
(me.faction === 'fremen' && fremenReserveEntry(to.t)) ||
(me.faction !== 'fremen' && quote.cost >= reserveShipmentCost({faction: me.faction, halfRate}, territory(to.t).type, amount))) continue;
const candidate = nexusGuildSecretAllyAction(g, g.nexusGuildSecretAlly!.event, {
type: 'ship', territory: to.t, sector: to.s, amount,
elite: level > 0 ? Math.min(amount, me.elites?.reserves ?? 0) : Math.max(0, amount - (me.reserves - (me.elites?.reserves ?? 0))),
});
if (candidate) actions.push(candidate);
}
}
if (shipmentAvailable && me.reserves > 0)
for (const to of destinations(g, g.advanced && me.faction === 'fremen')
.filter(
@@ -2964,6 +2983,7 @@ function policyActions(g: GameView): Action[] {
}
}
const guildTransport =
guildSecret ||
me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally);
if (shipmentAvailable && guildTransport)
@@ -2972,7 +2992,7 @@ function policyActions(g: GameView): Action[] {
.slice(0, 16)) {
if (marker?.location.territory === to.t) continue;
if (
me.faction === 'fremen' &&
!guildSecret && me.faction === 'fremen' &&
me.reserves > 0 &&
guildTransportCost(
to.t,
@@ -3007,6 +3027,7 @@ function policyActions(g: GameView): Action[] {
)
actions.push({
type: 'guildShip',
...(guildSecret ? {nexus: g.nexusGuildSecretAlly!.event} : {}),
from,
territory: to.t,
sector: to.s,
@@ -3014,6 +3035,15 @@ function policyActions(g: GameView): Action[] {
});
}
}
if (shipmentAvailable && guildSecret && !g.homeworlds?.worlds?.length)
for (const [from, amount] of Object.entries(me.forces)) {
const source = splitLocation(from);
if (source.sector === g.storm || !g.players.some(p => p.id !== me.id && countAt(p, source.territory) > countAt(me, source.territory))) continue;
const quote = nexusGuildSecretAllyQuote(g, 'reserves', amount);
if (!quote || quote.cost > shipmentBudget) continue;
const candidate = nexusGuildSecretAllyAction(g, g.nexusGuildSecretAlly!.event, {type:'guildShip', from, amount, territory:'reserves'});
if (candidate) actions.push(candidate);
}
const movingSources = Object.entries(me.forces)
.map(([from, n]) => {
const origin = splitLocation(from).territory;
+123 -25
View File
@@ -1,3 +1,4 @@
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
import { shipmentAvailable } from './shipment-opportunity';
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
@@ -838,6 +839,7 @@ type KaramaUse =
| { kind: 'purchase' }
| { kind: 'auctionPayment' };
type PendingHomeworldShipment = HomeworldShipmentIntent & {
guildSecretEvent?: string;
guildNexusEvent?: string;
/** Absent in older world-to-world declarations. */
route?: 'arrakis';
@@ -850,6 +852,7 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
pools: ReturnType<typeof quoteHomeworldShipment>['sources'] | ReturnType<typeof quoteGuildHomeworldShipment>['boardSources'];
};
type PendingShipment = {
guildSecretEvent?: string;
guildNexusEvent?: string;
nexusEvent?: string;
homeworldSources?: NativeReserveSelections;
@@ -1291,6 +1294,8 @@ export type Game = {
nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[];
nexusGuildCunningHistory?: {receipt:NexusGuildCunningReceipt;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled';parent:string;movesAfter:number;shipment?:{kind:'reserve'|'guild'|'homeworld';frame:string;stage:'pending'|'complete'|'stopped'}|{kind:'declined';stage:'declined'};signature:string}[];
nexusGuildCunningLast?: {event:string;stage:'pending'|'secondShipment'|'extraMove'|'completed'|'canceled'};
nexusGuildSecretHistory?: {receipt:NexusGuildSecretAllyReceipt;kind:'reserve'|'cross'|'homeworld';frame:string;signature:string}[];
nexusGuildSecretLast?: {event: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}>;
@@ -12117,6 +12122,7 @@ function marketGholaIntegrity(g: Game) {
nexusSardaukarIntegrity(g);
nexusChoamIntegrity(g);
nexusMoritaniIntegrity(g);
nexusGuildSecretIntegrity(g);
nexusRicheseIntegrity(g);
nexusGuildCunningIntegrity(g);
traitorDeclarationIntegrity(g);
@@ -12546,10 +12552,11 @@ function findShipmentCompletion(
)
continue;
for (const sector of territory(destination).sectors) {
const offer = nexusRicheseOffer(g,p.id);
for (const nexus of [undefined, ...(offer && !offer.blocked && amount <= offer.maxForces ? [offer.event] : [])]) {
const offer = nexusRicheseOffer(g,p.id), guildOffer = nexusGuildSecretAllyOffer(g,p.id);
for (const nexus of [undefined, ...(offer && !offer.blocked && amount <= offer.maxForces ? [offer.event] : []), ...(guildOffer && !guildOffer.blocked ? [guildOffer.event] : [])]) {
const guildSecretEvent = nexus && nexus === guildOffer?.event ? nexus : undefined;
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
const cost = nexus
const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(destination).type,amount)).cost : nexus
? nexusRule(() => quoteNexusRicheseShipment(price,territory(destination).type,amount)).cost
: reserveShipmentCost(price,territory(destination).type,amount);
try {
@@ -12570,7 +12577,7 @@ function findShipmentCompletion(
: null;
const nativeSources = sources ? { homeworldSources: sources } : {};
const shipment: PendingShipment = {
...(nexus ? {nexusEvent:nexus} : {}),
...(guildSecretEvent ? {guildSecretEvent} : nexus ? {nexusEvent:nexus} : {}),
...nativeSources,
turn: g.turn,
player: p.id,
@@ -12583,7 +12590,7 @@ function findShipmentCompletion(
advisors: arrivalAsAdvisor(g, p, destination),
};
const trial = nexus ? structuredClone(g) : g;
if (nexus) recordNexusRicheseShipment(trial,shipment);
if (nexus && !guildSecretEvent) recordNexusRicheseShipment(trial,shipment);
validatePhysicalShipment(trial,shipment);
return {
actions: [
@@ -15698,7 +15705,7 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
openTerritoryEntry(g, p, arrival.territory, arrival.sector, quote.amount, quote.elite, 'shipment');
}
}
function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis'}) {
function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) {
requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.');
const context = {
...homeworldContext(g),
@@ -15713,10 +15720,15 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
return {...quote, sources: quote.boardSources, sourceNames: [territory(quote.origin).name]};
}
const quote = homeworldRule(() => quoteHomeworldShipment(context, g.homeworlds!.custody!, order));
if (intent.guildSecretEvent) {
validateGuildSecretDeclaration(g,intent.player,intent.guildSecretEvent);
quote.cost = nexusRule(() => quoteNexusGuildSecretShipment('homeworld',quote.amount)).cost;
}
return {...quote, boardForces: undefined, boardEliteForces: undefined,
sourceNames: quote.sources.map((s) => combatLocationName(g, s.homeworld))};
}
function validateHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
if (shipment.guildSecretEvent) requireRule(!shipment.route && !shipment.guildNexusEvent, 'Guild Secret Ally discounts independently legal world-to-world routes only.');
if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'homeworld',shipment);
const p = getPlayer(g, shipment.player);
requireRule(g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g,p) &&
@@ -15753,6 +15765,7 @@ function homeworldShipmentIntegrity(g: Game) {
}
function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
const quote = validateHomeworldShipment(g, shipment);
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'homeworld',shipment);
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'homeworld',shipment);
const p = getPlayer(g, shipment.player);
payWithAlly(g, p, quote.cost, shipment.allyPayment);
@@ -15778,10 +15791,10 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
if (guild && income > 0)
g.response = guildPaymentResponse(g, guild.id, [income]);
finishShipmentPromises(g, p, null);
log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.`,
{faction: p.faction, name: 'Homeworld shipment'});
log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
{faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'});
}
function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis'}, allyPayment?: unknown) {
function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}, allyPayment?: unknown) {
const quote = homeworldShipmentQuote(g, intent);
const shipment: PendingHomeworldShipment = {...intent, ...(currentGuildCunning(g,p.id) ? {guildNexusEvent:currentGuildCunning(g,p.id)!.receipt.event} : {}), sources: structuredClone(intent.sources),
event: homeworldShipmentEvent(g, p, intent.route), turn: g.turn, amount: quote.amount, elite: quote.elite,
@@ -15796,6 +15809,11 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI
} else commitHomeworldShipment(g, shipment);
}
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
if (shipment.guildSecretEvent) {
validateGuildSecretDeclaration(g,shipment.player,shipment.guildSecretEvent);
requireRule(!shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.noField && !shipment.alliedNoField && !shipment.source,
'Guild Secret Ally needs its own ordinary physical reserve shipment.');
}
if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'reserve',shipment);
if (shipment.nexusEvent !== undefined) currentNexusRicheseShipment(g,shipment);
requireRule(
@@ -15850,10 +15868,10 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
p,
shipment.territory,
shipment.sector,
g.advanced && p.faction === 'fremen',
!shipment.guildSecretEvent && g.advanced && p.faction === 'fremen',
shipment.advisors,
);
if (p.faction === 'fremen')
if (p.faction === 'fremen' && !shipment.guildSecretEvent)
requireRule(
territory('the_great_flat').sectors.some((from) =>
territory(shipment.territory).sectors.some(
@@ -15867,7 +15885,7 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
'Fremen reinforcements must arrive within two territories of the Great Flat.',
);
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
const cost = shipment.nexusEvent
const cost = shipment.guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(shipment.territory).type,n)).cost : shipment.nexusEvent
? nexusRule(() => quoteNexusRicheseShipment(price,territory(shipment.territory).type,n)).cost
: reserveShipmentCost(price,territory(shipment.territory).type,n);
requireRule(
@@ -15945,6 +15963,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
return;
}
validatePhysicalShipment(g, shipment);
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'reserve',shipment);
finishNexusRicheseShipment(g,shipment,'shipped');
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'reserve',shipment);
finishShipmentPromises(
@@ -16059,10 +16078,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}.${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
: `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.guildSecretEvent ? ` Guild Secret Ally is spent; Guild shipping prices cost ${cost} spice paid to the bank.` : ''}${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' }
: shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : undefined,
: shipment.guildSecretEvent ? {faction:'guild',name:'Secret Ally shipment'} : shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : undefined,
);
const bg = byFaction(g, 'beneGesserit');
const followup =
@@ -16300,6 +16319,70 @@ function nexusGuildCunningIntegrity(g: Game) {
} else requireRule(!g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent,
'Guild Cunning has a shipment without its original pending record.');
}
function nexusGuildSecretAllyOffer(g: Game, owner: string) {
const p = g.players.find(p => p.id === owner);
if (!p || g.nexusCards?.cards?.hands[owner] !== 'guild' || byFaction(g,'guild')) return null;
let blocked: string | null = null;
if (p.ally) blocked = 'Guild Secret Ally requires an unallied holder.';
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || !shipmentAvailable(g,p))
blocked = 'Use Guild Secret Ally for your unused 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 (g.nexusGuildSecretHistory?.some(r => r.receipt.owner === owner && r.receipt.turn === g.turn))
blocked = 'Guild Secret Ally has already been used this turn.';
return {event:JSON.stringify(['nexusGuildSecretAlly',g.turn,owner]),blocked};
}
function validateGuildSecretDeclaration(g: Game, owner: string, event: unknown) {
const offer = nexusGuildSecretAllyOffer(g,owner);
requireRule(offer && !offer.blocked && event === offer.event,
offer?.blocked ?? 'This Guild Secret Ally shipment is unavailable or stale.');
return offer.event;
}
function guildSecretSignature(record: NonNullable<Game['nexusGuildSecretHistory']>[number]) {
return JSON.stringify([record.receipt.signature,record.kind,record.frame]);
}
function recordGuildSecretShipment(g: Game, owner: string, event: string, kind: 'reserve'|'cross'|'homeworld', frame: object) {
validateGuildSecretDeclaration(g,owner,event);
const receipt = nexusRule(() => createNexusGuildSecretAlly(g,owner));
const record = {receipt,kind,frame:JSON.stringify(frame),signature:''};
record.signature = guildSecretSignature(record);
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,owner,g.players));
(g.nexusGuildSecretHistory ??= []).push(record);
g.nexusGuildSecretLast = {event};
}
function nexusGuildSecretIntegrity(g: Game) {
const history = g.nexusGuildSecretHistory;
requireRule(!g.pendingShipment?.guildSecretEvent && !g.pendingHomeworldShipment?.guildSecretEvent,
'Absent-Guild Secret Ally cannot retain a native Guild interception.');
if (history === undefined) {
requireRule(!g.nexusGuildSecretLast,'Guild Secret Ally has lost its original shipment history.'); return;
}
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
'Guild Secret Ally requires its original Nexus module and shipment history.');
const events = new Set<string>();
for (const record of history) {
requireRule(record && Object.keys(record).sort().join(',') === 'frame,kind,receipt,signature' &&
['reserve','cross','homeworld'].includes(record.kind) && typeof record.frame === 'string' &&
record.receipt && record.signature === guildSecretSignature(record) && !events.has(record.receipt.event),
'Guild Secret Ally has a changed or duplicated shipment record.');
nexusRule(() => validateNexusGuildSecretAlly(g,record.receipt));
const frame = nexusRule(() => JSON.parse(record.frame)) as Record<string,unknown>;
requireRule(frame && typeof frame === 'object' && frame.guildSecretEvent === record.receipt.event &&
frame.player === record.receipt.owner && frame.turn === record.receipt.turn &&
!frame.nexusEvent && !frame.guildNexusEvent && !frame.noField && !frame.alliedNoField && !frame.source,
'Guild Secret Ally has changed its original shipment source.');
const destination = record.kind === 'homeworld' ? 'homeworld' :
record.kind === 'cross' && frame.to === 'reserves' ? 'reserves' : territory(String(record.kind === 'cross' ? frame.to : frame.territory)).type;
const quote = nexusRule(() => quoteNexusGuildSecretShipment(destination,frame.amount as number));
requireRule(frame.cost === quote.cost && frame.allyPayment === 0 && Number.isSafeInteger(frame.elite) &&
Number(frame.elite) >= 0 && Number(frame.elite) <= quote.physicalAmount,
'Guild Secret Ally has changed its original payment or typed allocation.');
events.add(record.receipt.event);
}
requireRule(JSON.stringify(g.nexusGuildSecretLast) === JSON.stringify({event:history.at(-1)!.receipt.event}),
'Guild Secret Ally has lost its latest shipment outcome.');
}
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;
@@ -20126,15 +20209,16 @@ 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 guildSecretEvent = action.nexus === undefined ? undefined : validateGuildSecretDeclaration(g,id,action.nexus);
requireRule(!guildSecretEvent || t === 'homeworldShip', 'Guild Secret Ally does not grant arbitrary Arrakis-to-Homeworld routes.');
const route = t === 'guildHomeworldShip' ? 'arrakis' as const : undefined;
const blocked = route ? guildHomeworldShipmentBlock(g, p) : homeworldShipmentBlock(g, p);
requireRule(!blocked, blocked ?? 'Homeworld shipment is unavailable.');
requireRule(Object.keys(action).every((key) => ['type', 'event', 'destination', 'sources', 'allyPayment'].includes(key)),
requireRule(Object.keys(action).every((key) => ['type', 'event', 'destination', 'sources', 'allyPayment', ...(guildSecretEvent ? ['nexus'] : [])].includes(key)),
'Choose explicit typed source groups and a Homeworld destination without concealed tokens.');
requireRule(action.event === homeworldShipmentEvent(g, p, route), 'This Homeworld source selection is stale.');
declareHomeworldShipment(g, p, {player: id, destination: stringField(action.destination),
sources: action.sources as HomeworldShipmentIntent['sources'], ...(route ? {route} : {})}, action.allyPayment);
sources: action.sources as HomeworldShipmentIntent['sources'], ...(route ? {route} : {}), ...(guildSecretEvent ? {guildSecretEvent} : {})}, action.allyPayment);
return g;
}
if (t === 'ship') {
@@ -20143,7 +20227,12 @@ function applyActionInner(
'You may ship once, before your movement.',
);
let nexusEvent: string | undefined;
if (action.nexus !== undefined) {
let guildSecretEvent: string | undefined;
if (action.nexus !== undefined && action.nexus === nexusGuildSecretAllyOffer(g,id)?.event) {
guildSecretEvent = validateGuildSecretDeclaration(g,id,action.nexus);
requireRule(Object.keys(action).every(k => ['type','territory','sector','amount','elite','homeworldSources','allyPayment','nexus'].includes(k)),
'Guild Secret Ally needs an ordinary physical reserve shipment.');
} else 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.');
@@ -20162,7 +20251,7 @@ function applyActionInner(
to !== MOBILE_STRONGHOLD || p.faction === 'ixians',
'Only Ixians may ship directly into the mobile stronghold.',
);
allowedEntry(g, p, to, s, g.advanced && p.faction === 'fremen', advisors);
allowedEntry(g, p, to, s, !guildSecretEvent && g.advanced && p.faction === 'fremen', advisors);
let noField: PendingShipment['noField'];
if (action.noField !== undefined) {
requireRule(
@@ -20198,10 +20287,10 @@ function applyActionInner(
? 0
: eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, action.elite);
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
const cost = nexusEvent
const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(to).type,n)).cost : nexusEvent
? nexusRule(() => quoteNexusRicheseShipment(price,territory(to).type,n)).cost
: reserveShipmentCost(price,territory(to).type,n);
if (p.faction === 'fremen') {
if (p.faction === 'fremen' && !guildSecretEvent) {
requireRule(
territory('the_great_flat').sectors.some((fs) =>
territory(to).sectors.some(
@@ -20220,6 +20309,7 @@ function applyActionInner(
const shipment: PendingShipment = {
...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
...(nexusEvent ? {nexusEvent} : {}),
...(guildSecretEvent ? {guildSecretEvent} : {}),
turn: g.turn,
player: id,
territory: to,
@@ -20251,7 +20341,9 @@ function applyActionInner(
return g;
}
if (t === 'guildShip') {
requireRule(action.nexus === undefined, 'Choose ordinary reserve shipment to use Richese Secret Ally.');
const guildSecretEvent = action.nexus === undefined ? undefined : validateGuildSecretDeclaration(g,id,action.nexus);
if (guildSecretEvent) requireRule(Object.keys(action).every(k => ['type','from','forces','eliteForces','amount','elite','territory','sector','allyPayment','nexus'].includes(k)),
'Guild Secret Ally cross-shipment needs explicit physical forces.');
const homeworldTarget = action.territory ?? 'reserves';
if (g.homeworlds?.custody && p.faction === 'guild' &&
(homeworldTarget === 'reserves' || (typeof homeworldTarget === 'string' && homeworldTarget.startsWith('homeworld:')))) {
@@ -20282,10 +20374,11 @@ function applyActionInner(
g.phase === 5 &&
g.active === id &&
shipmentAvailable(g,p) &&
(p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally),
(p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally || !!guildSecretEvent),
'Guild shipment is not available.',
);
const fromReserves = action.from === 'reserves';
requireRule(!guildSecretEvent || !fromReserves, 'Use the reserve shipment selector for paid Guild Secret Ally reinforcements.');
requireRule(
!fromReserves || p.faction === 'fremen',
'Only allied Fremen may cross-ship from their southern reserves.',
@@ -20318,9 +20411,11 @@ function applyActionInner(
const sector = integer(action.sector ?? 0, 0, 18);
requireRule(fromReserves || to === 'reserves' || to !== origin, 'Cross-shipment requires a different destination territory.');
requireRule(
to !== 'reserves' || p.faction === 'guild',
to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent,
'Only the Guild may ship forces back to reserves.',
);
requireRule(!guildSecretEvent || to !== 'reserves' || !g.homeworlds?.custody,
'Guild Secret Ally return to Homeworld reserves awaits its route ruling.');
const advisors = to !== 'reserves' && arrivalAsAdvisor(g, p, to);
const sourceLock = p.advisors?.[origin]?.lockedTurn;
requireRule(
@@ -20346,6 +20441,7 @@ function applyActionInner(
const allyPayment = contribution(g, p, cost, action.allyPayment);
checkShipmentIncomeRounding(g, p, cost, allyPayment);
const cunningFrame = {turn:g.turn,player:id,origin,group,eliteGroup,to,sector,amount:n,elite,cost,allyPayment,advisors};
if (guildSecretEvent) recordGuildSecretShipment(g,id,guildSecretEvent,'cross',{...cunningFrame,guildSecretEvent});
bindGuildCunningShipment(g,p,'guild',cunningFrame);
payWithAlly(g, p, cost, allyPayment);
const guild = byFaction(g, 'guild');
@@ -20385,7 +20481,8 @@ function applyActionInner(
finishGuildCunningShipment(g,id,'guild',cunningFrame);
p.shipped = true;
g.karamaShipping = null;
log(g, `${p.name} used Guild transport for ${n} forces.`);
log(g, `${p.name} used Guild transport for ${n} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`,
guildSecretEvent ? {faction:'guild',name:'Secret Ally transport'} : undefined);
if (to !== 'reserves') {
intrusion(g, p, to);
if (origin !== to)
@@ -21328,6 +21425,7 @@ export function viewGame(state: Game, id: string) {
nexusCards: projectedNexusCards(g, id),
nexusMoritani: nexusMoritaniOffer(g,id),
nexusRichese: nexusRicheseOffer(g,id),
nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id),
nexusGuildCunning: projectedNexusGuildCunning(g,id),
nexusTraitors: projectedNexusTraitors(g, id),
nexusTleilaxu: projectedNexusTleilaxu(g, id),
+6 -4
View File
@@ -1,3 +1,4 @@
import { nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
import type { Action, GameView } from './engine';
import { quoteGuildPaymentRounding } from './homeworld-payment-income';
import { reserveShipmentCost, guildShipmentCost } from './shipment-price';
@@ -18,12 +19,13 @@ export function homeworldShipmentPaymentBlock(g: GameView, cost: number, allyPay
export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action): boolean {
if (!['ship', 'guildShip'].includes(action.type)) return true;
if (!g.homeworlds?.worlds?.some(world => world.card === 'junction' && world.side === 'low')) return true;
const me = g.players.find(p => p.id === g.me)!;
const to = String(action.territory);
const amount = action.noField ? 1 : Number(action.amount);
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 amount = action.noField ? 1 : action.forces ? Object.values(action.forces as Record<string, number>).reduce((sum, n) => sum + n, 0) : Number(action.amount);
const guildSource = action.nexus !== undefined && action.nexus === g.nexusGuildSecretAlly?.event;
const nexusQuote = action.nexus === undefined ? null : guildSource ? nexusGuildSecretAllyQuote(g, to, amount) : nexusRicheseQuote(g, to, amount);
if (action.nexus !== undefined && (!nexusQuote || (!guildSource && (action.nexus !== g.nexusRichese?.event || action.type !== 'ship')) || action.noField !== undefined)) return false;
if (!g.homeworlds?.worlds?.some(world => world.card === 'junction' && world.side === 'low')) return true;
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)
+33 -5
View File
@@ -1,3 +1,7 @@
import {
nexusGuildSecretAllyCanAct,
nexusGuildSecretAllyQuote,
} from './nexus-guild-secret-ally-options';
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import type { Action, GameView } from './engine';
import { HOMEWORLD_CARDS } from './homeworld-cards';
@@ -23,6 +27,7 @@ export function homeworldShipmentChoice(
destination: string,
sources: HomeworldShipmentSources,
allyPayment = 0,
nexus?: string,
) {
const result: {
blocked: string | null;
@@ -87,14 +92,26 @@ export function homeworldShipmentChoice(
destination,
sources,
});
result.cost = quote.cost;
const nexusQuote =
nexus === undefined
? null
: nexusGuildSecretAllyQuote(view, destination, quote.amount);
if (
nexus !== undefined &&
(!nexusQuote || nexus !== view.nexusGuildSecretAlly?.event)
) {
result.blocked = 'Choose a current Guild Secret Ally shipment offer.';
return result;
}
const cost = nexusQuote?.cost ?? quote.cost;
result.cost = cost;
result.amount = quote.amount;
result.elite = quote.elite;
result.ownPayment = quote.cost - allyPayment;
result.ownPayment = cost - allyPayment;
if (
!Number.isSafeInteger(allyPayment) ||
allyPayment < 0 ||
allyPayment > quote.cost ||
allyPayment > cost ||
allyPayment > (me.ally ? view.aid.available : 0)
)
result.blocked =
@@ -109,6 +126,7 @@ export function homeworldShipmentChoice(
destination,
sources,
allyPayment,
...(nexus === undefined ? {} : { nexus }),
};
} catch (error) {
if (!(error instanceof HomeworldCustodyError)) throw error;
@@ -135,6 +153,7 @@ export function homeworldShipmentActions(
!nexusGuildShipmentAvailable(view)
)
return [];
const useNexus = nexusGuildSecretAllyCanAct(view);
const difficulty = Math.max(0, Math.min(3, level));
const budget = Math.max(
0,
@@ -212,7 +231,7 @@ export function homeworldShipmentActions(
const amount = Math.min(
pools.reduce((sum, pool) => sum + pool.capacity, 0),
[3, 5, 7, 9][difficulty],
budget * (me.faction === 'guild' ? 2 : 1),
budget * (me.faction === 'guild' || useNexus ? 2 : 1),
Math.max([1, 3, 4, 5][difficulty], required),
);
if (amount <= 0) continue;
@@ -245,12 +264,21 @@ export function homeworldShipmentActions(
target.strength + (difficulty > 0 ? 1 : 0)
)
continue;
const initial = homeworldShipmentChoice(view, target.world.id, selection);
const nexus =
useNexus && amount > 1 ? view.nexusGuildSecretAlly!.event : undefined;
const initial = homeworldShipmentChoice(
view,
target.world.id,
selection,
0,
nexus,
);
const quote = homeworldShipmentChoice(
view,
target.world.id,
selection,
Math.max(0, initial.cost - (me.spice ?? 0)),
nexus,
);
if (
quote.action &&
+104
View File
@@ -0,0 +1,104 @@
import type { Action, GameView } from './engine';
import { gameTerritories, MOBILE_STRONGHOLD } from './board';
import { quoteNexusGuildSecretShipment } from './nexus-guild-secret-ally';
/** Consume only the current actor's private offer and public faction roster. */
export function nexusGuildSecretAllyCanAct(g: GameView): boolean {
const me = g.players.find((p) => p.id === g.me);
if (
!me ||
me.ally ||
g.nexusCards?.card !== 'guild' ||
g.players.some((p) => p.faction === 'guild')
)
return false;
const offer = g.nexusGuildSecretAlly;
return !!(
offer &&
!offer.blocked &&
offer.event === JSON.stringify(['nexusGuildSecretAlly', 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 is independent of native faction; route permission is still separate. */
export function nexusGuildSecretAllyQuote(
g: GameView,
destination: string,
amount: number,
) {
if (
!nexusGuildSecretAllyCanAct(g) ||
!Number.isSafeInteger(amount) ||
amount < 1 ||
amount > 20
)
return null;
const me = g.players.find((p) => p.id === g.me)!;
if (destination === 'reserves') {
if (g.homeworlds?.worlds?.length) return null;
return quoteNexusGuildSecretShipment('reserves', amount);
}
const world = g.homeworlds?.worlds?.find((w) => w.id === destination);
if (world) {
if (world.native === me.id || world.native === me.ally) return null;
return quoteNexusGuildSecretShipment('homeworld', amount);
}
const to = gameTerritories(g).find((t) => t.id === destination);
if (!to || (destination === MOBILE_STRONGHOLD && me.faction !== 'ixians'))
return null;
return quoteNexusGuildSecretShipment(to.type, amount);
}
/** Add a selected source, never infer card use just from possession. */
export function nexusGuildSecretAllyAction(
g: GameView,
event: string,
action: Action,
): Action | null {
if (
!nexusGuildSecretAllyCanAct(g) ||
event !== g.nexusGuildSecretAlly!.event ||
!['ship', 'guildShip', 'homeworldShip'].includes(action.type) ||
action.nexus !== undefined ||
action.noField !== undefined ||
(action.type === 'guildShip' && action.from === 'reserves')
)
return null;
const amount =
action.type === 'homeworldShip'
? Object.values(
(action.sources ?? {}) as Record<
string,
{ normal: number; elite: number }
>,
).reduce((n, p) => n + p.normal + p.elite, 0)
: action.forces
? Object.values(action.forces as Record<string, number>).reduce(
(n, p) => n + p,
0,
)
: Number(action.amount);
const destination = String(
action.type === 'homeworldShip' ? action.destination : action.territory,
);
if (!nexusGuildSecretAllyQuote(g, destination, amount)) return null;
const me = g.players.find((p) => p.id === g.me)!;
if (
action.type === 'ship' &&
(amount > me.reserves ||
!gameTerritories(g).some((t) => t.id === destination))
)
return null;
return { ...action, nexus: event };
}
+120
View File
@@ -0,0 +1,120 @@
import { FACTIONS, type FactionId } from './catalog';
import { guildShipmentCost } from './shipment-price';
export type NexusGuildSecretAllyContext = {
turn: number;
players: readonly { id: string; faction: FactionId }[];
};
export type NexusGuildSecretAllyReceipt = {
version: 1;
event: string;
owner: string;
turn: number;
phase: 5;
mode: 'secretAlly';
roster: { id: string; faction: FactionId }[];
signature: string;
};
function requireGuild(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));
/** The caller validates route permission and actual physical custody. */
export function quoteNexusGuildSecretShipment(destinationType: string, amount: number) {
requireGuild(['stronghold','sand','rock','polar','reserves','homeworld'].includes(destinationType) &&
Number.isSafeInteger(amount) && amount > 0 && amount <= 20,
'Guild Secret Ally requires a valid destination and one to twenty physical forces.');
return {physicalAmount:amount,cost:guildShipmentCost(destinationType === 'homeworld' ? 'reserves' : destinationType,amount),source:'nexusGuildSecretAlly' as const};
}
function roster(context: NexusGuildSecretAllyContext) {
requireGuild(
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 === 'guild'),
'Guild Secret Ally needs a valid original faction roster with the native Guild absent.',
);
return context.players
.map(({ id, faction }) => ({ id, faction }))
.sort((a, b) => a.id.localeCompare(b.id));
}
function signature(receipt: NexusGuildSecretAllyReceipt) {
return JSON.stringify([
'nexusGuildSecretAllyReceipt',
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 validateNexusGuildSecretAlly(
context: NexusGuildSecretAllyContext,
receipt: NexusGuildSecretAllyReceipt,
): void {
const seats = roster(context);
requireGuild(
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(['nexusGuildSecretAlly', 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),
'Guild Secret Ally has lost its original owner, turn, phase or faction roster.',
);
}
export function createNexusGuildSecretAlly(
context: NexusGuildSecretAllyContext,
owner: string,
): NexusGuildSecretAllyReceipt {
const receipt: NexusGuildSecretAllyReceipt = {
version: 1,
event: JSON.stringify(['nexusGuildSecretAlly', context.turn, owner]),
owner,
turn: context.turn,
phase: 5,
mode: 'secretAlly',
roster: roster(context),
signature: '',
};
receipt.signature = signature(receipt);
validateNexusGuildSecretAlly(context, receipt);
return receipt;
}
+3 -2
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 Moritani Cunning supply placement and Richese Secret Ally reserve shipping and Guild Cunning’s second shipment 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 and Guild Cunning’s second shipment and absent-Guild Secret Ally 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.',
@@ -3369,12 +3369,13 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
'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 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/nexus-guild-cunning-options.ts', 'game/engine.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/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-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', 'game/nexus-guild-cunning-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', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] },
+21 -2
View File
@@ -1,4 +1,5 @@
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import { nexusGuildSecretAllyCanAct } from './nexus-guild-secret-ally-options';
import type { Action, GameView } from './engine';
import { splitLocation, validLocation } from './board';
import { botEntryAllowed, guildTransportCost } from './bot-mobility';
@@ -10,6 +11,13 @@ import { shipmentPaymentBounds } from './shipment-price';
export function guildTransportQuote(g: GameView, action: Action) {
const p = g.players.find((seat) => seat.id === g.me)!;
const unavailableReasons: string[] = [];
const nexus = action.nexus !== undefined;
const nexusAllowed =
nexus &&
nexusGuildSecretAllyCanAct(g) &&
action.nexus === g.nexusGuildSecretAlly?.event;
if (nexus && !nexusAllowed)
unavailableReasons.push('Choose a current Guild Secret Ally offer.');
const fromReserves = action.from === 'reserves';
let total = 0;
let origin: string | undefined;
@@ -51,6 +59,10 @@ export function guildTransportQuote(g: GameView, action: Action) {
'Guild transport of a concealed No-Field is unavailable. Reveal the marker first or use its movement action.',
);
if (fromReserves) {
if (nexus)
unavailableReasons.push(
'Use the paid reserve shipment selector for Guild Secret Ally.',
);
if (p.faction !== 'fremen')
unavailableReasons.push(
'Only allied Fremen may cross-ship southern reserves.',
@@ -112,8 +124,12 @@ export function guildTransportQuote(g: GameView, action: Action) {
sector >= 0 &&
sector <= 18 &&
(to === 'reserves' || validLocation(to, sector));
if (to === 'reserves' && p.faction !== 'guild')
if (to === 'reserves' && p.faction !== 'guild' && !nexusAllowed)
unavailableReasons.push('Only the Guild may return forces to reserves.');
if (to === 'reserves' && nexus && g.homeworlds?.worlds?.length)
unavailableReasons.push(
'Guild Secret Ally return to native Homeworld reserves awaits a ruling.',
);
if (!validDestination)
unavailableReasons.push('Choose a sector belonging to the destination.');
else if (
@@ -124,12 +140,15 @@ export function guildTransportQuote(g: GameView, action: Action) {
'This destination is unavailable because of the storm, allied forces, stronghold capacity, or your advisor restrictions.',
);
if (!fromReserves && to !== 'reserves' && to === origin)
unavailableReasons.push('Guild cross-shipment must enter another territory.');
unavailableReasons.push(
'Guild cross-shipment must enter another territory.',
);
if (
g.phase !== 5 ||
g.active !== p.id ||
!nexusGuildShipmentAvailable(g) ||
!(
nexusAllowed ||
p.faction === 'guild' ||
g.players.some((seat) => seat.id === p.ally && seat.faction === 'guild')
)