Implement Guild Nexus Cunning second shipment and recovery

This commit is contained in:
Quintine committed 2026-09-10 13:35:24 +10:00
1 parent 6ba1050a92
commit 90bf699627
27 files changed
+2657 -43

No files matched your search

+20 -5
View File
@@ -1,3 +1,4 @@
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options';
import { nexusMoritaniBotActions } from './nexus-moritani-options';
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
import { nexusCardBotActions } from './nexus-card-options';
@@ -2816,7 +2817,11 @@ function policyActions(g: GameView): Action[] {
)
return [choamPowerAction(g, kulon)!];
if (g.active !== me.id) return [];
const cardMoves = ornithopterMoves(g);
const shipmentAvailable = nexusGuildShipmentAvailable(g);
const movementAvailable = nexusGuildMovementAvailable(g);
const hajr = nexusGuildHajrAction(g);
if (hajr) return [hajr];
const cardMoves = nexusGuildCunningActive(g) ? [] : ornithopterMoves(g);
if (
cardMoves.length &&
(g.ornithopter?.active || me.shipped || me.reserves === 0)
@@ -2913,7 +2918,7 @@ function policyActions(g: GameView): Action[] {
});
}
}
if (!me.shipped && me.reserves > 0)
if (shipmentAvailable && me.reserves > 0)
for (const to of destinations(g, g.advanced && me.faction === 'fremen')
.filter(
(to) =>
@@ -2961,7 +2966,7 @@ function policyActions(g: GameView): Action[] {
const guildTransport =
me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally);
if (!me.shipped && guildTransport)
if (shipmentAvailable && guildTransport)
for (const to of targets
.filter((to) => botEntryAllowed(g, me, to.t, to.s, 'guildShip'))
.slice(0, 16)) {
@@ -3027,7 +3032,7 @@ function policyActions(g: GameView): Action[] {
return { from, amount, elite };
})
.filter((source) => source.amount > 0);
if ((me.moved ?? 0) < (me.movesAllowed ?? 1))
if (movementAvailable)
for (const to of targets
.filter(
(to) =>
@@ -3051,6 +3056,15 @@ function policyActions(g: GameView): Action[] {
if (reveal && !actions.length) actions.push(reveal);
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
actions.unshift(...emperorHomeworldMoveActions(g));
const activeCunning = nexusGuildCunningActive(g);
if (activeCunning?.stage === 'secondShipment' && activeCunning.hajrAvailable &&
me.hand?.some(card => card.effect === 'hajr') &&
!actions.some(action => ['ship', 'guildShip', 'homeworldShip', 'guildHomeworldShip'].includes(action.type))) {
const skip = nexusGuildSkipShipmentAction(g, activeCunning.event);
if (skip) actions.push(skip);
}
const cunning = g.nexusGuildCunning?.offer && nexusGuildCunningAction(g, g.nexusGuildCunning.offer.event);
if (cunning && (me.reserves > 0 || Object.values(me.forces).some(n => n > 0))) actions.push(cunning);
actions.push({ type: 'endMovement' });
return g.ornithopter?.active
? actions.filter((action) => action.type !== 'move')
@@ -3353,6 +3367,7 @@ export function botActions(g: GameView): Action[] {
const intelligence = tupileIntelligenceActions(g);
if (intelligence.length) return intelligence;
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
if (action.type === 'move' && nexusGuildCunningActive(g) && !nexusGuildMovementAvailable(g)) return [];
const sourced = withNativeShipmentSources(g, action);
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) ? [sourced] : [];
});
@@ -3362,7 +3377,7 @@ export function botActions(g: GameView): Action[] {
if (
g.phase !== 5 ||
g.active !== me.id ||
me.shipped ||
!nexusGuildShipmentAvailable(g) ||
g.truthtrance ||
g.response ||
g.decision ||
+2 -1
View File
@@ -24,6 +24,7 @@ export type OrdinaryCardState = {
phase: number;
active: string | null;
me: string;
nexusGuildCunning?: { active: { hajrAvailable: boolean } | null } | null;
truthtrance?: { stage: string } | null;
phaseOpening?: { passed: readonly string[] } | null;
response?: object | null;
@@ -101,7 +102,7 @@ export function ordinaryCardAvailability(
if (
state.phase !== 5 ||
state.active !== state.me ||
own.movesAllowed !== 1
(state.nexusGuildCunning?.active ? !state.nexusGuildCunning.active.hajrAvailable : own.movesAllowed !== 1)
)
return blocked('Use Hajr during your movement.');
break;
+201 -11
View File
@@ -1,3 +1,5 @@
import { shipmentAvailable } from './shipment-opportunity';
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
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';
@@ -801,6 +803,7 @@ export type ResponseWindow = {
| 'advisorFlip'
| 'nexusAdvisorFlip'
| 'nexusSardaukar'
| 'nexusGuildCunning'
| 'bgCharity'
| 'choamCharity'
| 'choamInflation'
@@ -835,6 +838,7 @@ type KaramaUse =
| { kind: 'purchase' }
| { kind: 'auctionPayment' };
type PendingHomeworldShipment = HomeworldShipmentIntent & {
guildNexusEvent?: string;
/** Absent in older world-to-world declarations. */
route?: 'arrakis';
event: string;
@@ -846,6 +850,7 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
pools: ReturnType<typeof quoteHomeworldShipment>['sources'] | ReturnType<typeof quoteGuildHomeworldShipment>['boardSources'];
};
type PendingShipment = {
guildNexusEvent?: string;
nexusEvent?: string;
homeworldSources?: NativeReserveSelections;
source?: 'ambassador';
@@ -1284,6 +1289,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}[];
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'};
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}>;
@@ -6428,6 +6435,7 @@ function karamaOpportunitySignature(
active: g.active,
recipient: use.recipient,
shipped: getPlayer(g, use.recipient).shipped,
...(currentGuildCunning(g,use.recipient) ? {guildCunning:currentGuildCunning(g,use.recipient)} : {}),
rate: g.karamaShipping ?? null,
movementRemaining: g.movementRemaining ?? null,
}
@@ -6758,6 +6766,7 @@ function validateKaramaUse(
use.response,
spendingCard ? { player: p.id, card: spendingCard.id } : undefined,
);
if (use.response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,use.response);
currentChoamWorthlessCancellationQuote(g, use.response);
currentPlacementCancellationQuote(g, use.response);
if (use.response.kind === 'ecazCollection')
@@ -6770,7 +6779,7 @@ function validateKaramaUse(
g.status === 'playing' &&
g.phase === 5 &&
g.active === recipient.id &&
!recipient.shipped &&
shipmentAvailable(g,recipient) &&
!g.karamaShipping,
'Choose the active player before their unused Karama shipment.',
);
@@ -7315,6 +7324,8 @@ function forceGroup(p: Player, action: Action) {
};
}
function movesAllowed(g: Game, p: Player) {
const cunning = currentGuildCunning(g,p.id);
if (cunning) return p.moved + (cunning.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(cunning.receipt,g.hajr.includes(p.id),cunning.movesAfter)) : 0);
const flight = g.ornithopter;
return flight?.player === p.id && flight.turn === g.turn
? flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1)
@@ -7387,6 +7398,7 @@ function ornithopterBlock(
return 'Resolve the current interaction before declaring a movement.';
if (g.ornithopter)
return 'Finish the already played Ornithopter movement first.';
if (currentGuildCunning(g,p.id)) return 'Guild Cunning combined with the physical Ornithopter card awaits its timing ruling.';
if (p.moved || g.hajr.includes(p.id))
return 'Ornithopter combined with prior moves or Hajr awaits its timing ruling.';
if (
@@ -7827,6 +7839,8 @@ function completeMove(g: Game, move: MovementOrder) {
finishShipmentPromises(g, p, null);
p.shipped = true;
p.moved++;
const cunning = currentGuildCunning(g,id);
if (cunning) { requireRule(cunning.stage === 'extraMove', 'Guild Cunning permits no movement before the second shipment.'); cunning.movesAfter++; saveGuildCunning(g,cunning); }
const completed: CompletedMovement = {
player: id,
origin,
@@ -7862,6 +7876,8 @@ function finishMovedGroup(g: Game, move: CompletedMovement) {
/** A played movement card is already retired before queue advancement. */
function finishMovementTurn(g: Game, id: string) {
const p = getPlayer(g, id);
const cunning = currentGuildCunning(g,id);
if (cunning) { requireRule(cunning.stage !== 'pending' && cunning.shipment?.stage !== 'pending', 'Finish the Guild Cunning response or shipment first.'); cunning.stage = 'completed'; saveGuildCunning(g,cunning); }
g.karamaShipping = null;
g.movementRemaining ??= g.order.slice(g.order.indexOf(id));
const shared = p.ally
@@ -12102,6 +12118,7 @@ function marketGholaIntegrity(g: Game) {
nexusChoamIntegrity(g);
nexusMoritaniIntegrity(g);
nexusRicheseIntegrity(g);
nexusGuildCunningIntegrity(g);
traitorDeclarationIntegrity(g);
nexusInspectionIntegrity(g);
homeworldHistoryIntegrity(g);
@@ -12468,7 +12485,7 @@ function shipmentPromiseIntegrity(g: Game) {
g.status === 'playing' &&
g.phase === 5 &&
g.active === promise.player &&
!getPlayer(g, promise.player).shipped,
shipmentAvailable(g,getPlayer(g,promise.player)),
'The saved shipment promise does not belong to this unused Basic shipment opportunity.',
);
}
@@ -12487,7 +12504,7 @@ function findShipmentCompletion(
state.status !== 'playing' ||
state.phase !== 5 ||
state.active !== owner.id ||
owner.shipped
!shipmentAvailable(state,owner)
)
return null;
if (!promises.some((p) => p.answer))
@@ -14509,6 +14526,7 @@ function finishResponse(g: Game, canceled: boolean) {
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
if (response.kind === 'nexusSardaukar') validateNexusSardaukarResponse(g, response);
if (response.kind === 'nexusGuildCunning') validateGuildCunningResponse(g,response);
if (response.kind === 'moritaniPlacement') {
// Both outcomes need the same physical inventory and declared source.
// This detached denial quote is only validation here; allowance still
@@ -14548,6 +14566,7 @@ function finishResponse(g: Game, canceled: boolean) {
? moritaniAllianceCancellationQuote(g, response)
: null;
g.response = null;
if (response.kind === 'nexusGuildCunning') { settleGuildCunning(g,response,canceled); return; }
if (response.kind === 'nexusAdvisorFlip') {
finishNexusAdvisors(g, canceled);
return;
@@ -15566,11 +15585,12 @@ function homeworldShipmentEvent(g: Game, p: Player, route?: 'arrakis'): string {
g.storm, p.forces, p.elites?.forces ?? {}, g.mobileStronghold?.location ?? null]);
return JSON.stringify([g.turn, g.phase, g.active, p.id, p.shipped,
homeworldContext(g), g.homeworlds?.custody,
g.players.map((seat) => [seat.id, seat.ally])]);
g.players.map((seat) => [seat.id, seat.ally]),
...(currentGuildCunning(g,p.id) ? [currentGuildCunning(g,p.id)!.receipt.event] : [])]);
}
function homeworldShipmentBlock(g: Game, p: Player, checkPromises = true): string | null {
if (!g.homeworlds?.custody) return 'Homeworld shipment requires the Homeworld module.';
if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || p.shipped)
if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || !shipmentAvailable(g,p))
return 'Use your unused shipment during your own Shipment and Movement turn.';
if (g.truthtrance || g.response || g.decision || g.phaseOpening ||
g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingShipment ||
@@ -15697,8 +15717,9 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
sourceNames: quote.sources.map((s) => combatLocationName(g, s.homeworld))};
}
function validateHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
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 && !p.shipped &&
requireRule(g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g,p) &&
shipment.turn === g.turn && (shipment.route === undefined || shipment.route === 'arrakis') &&
shipment.event === homeworldShipmentEvent(g, p, shipment.route),
'This Homeworld declaration no longer matches its unused shipment and physical custody.');
@@ -15732,6 +15753,7 @@ function homeworldShipmentIntegrity(g: Game) {
}
function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
const quote = validateHomeworldShipment(g, shipment);
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'homeworld',shipment);
const p = getPlayer(g, shipment.player);
payWithAlly(g, p, quote.cost, shipment.allyPayment);
g.homeworlds!.custody = quote.state;
@@ -15761,9 +15783,10 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
}
function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis'}, allyPayment?: unknown) {
const quote = homeworldShipmentQuote(g, intent);
const shipment: PendingHomeworldShipment = {...intent, sources: structuredClone(intent.sources),
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,
cost: quote.cost, allyPayment: contribution(g, p, quote.cost, allyPayment), pools: quote.sources};
bindGuildCunningShipment(g,p,'homeworld',shipment);
const guild = byFaction(g, 'guild');
if (g.advanced && guild && !guild.specialKaramaUsed) {
g.pendingHomeworldShipment = shipment;
@@ -15773,6 +15796,7 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI
} else commitHomeworldShipment(g, shipment);
}
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'reserve',shipment);
if (shipment.nexusEvent !== undefined) currentNexusRicheseShipment(g,shipment);
requireRule(
!(shipment.noField || shipment.alliedNoField) ||
@@ -15790,7 +15814,7 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
g.status === 'playing' &&
g.phase === 5 &&
g.active === p.id &&
!p.shipped &&
shipmentAvailable(g,p) &&
(shipment.turn === undefined || shipment.turn === g.turn),
'This shipment no longer belongs to the current unused shipment opportunity.',
);
@@ -15922,6 +15946,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
}
validatePhysicalShipment(g, shipment);
finishNexusRicheseShipment(g,shipment,'shipped');
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'reserve',shipment);
finishShipmentPromises(
g,
getPlayer(g, shipment.player),
@@ -16132,6 +16157,149 @@ function choamWorthlessBlocked(g: Game, card: string) {
g.choamWorthlessBlocked.cards.includes(card)
);
}
function currentGuildCunning(g: Game, owner?: string) {
const last = g.nexusGuildCunningHistory?.at(-1);
return last && last.receipt.turn === g.turn && (!owner || last.receipt.owner === owner) &&
['pending','secondShipment','extraMove'].includes(last.stage) ? last : null;
}
function guildCunningParent(g: Game) {
return JSON.stringify([g.active,g.order,g.movementRemaining,g.guildTimingGranted,g.guildTimingLocked,g.saphoMovementLast ?? null]);
}
function guildCunningSignature(record: NonNullable<Game['nexusGuildCunningHistory']>[number]) {
return JSON.stringify([record.receipt.signature,record.stage,record.parent,record.movesAfter,record.shipment ?? null]);
}
function saveGuildCunning(g: Game, record: NonNullable<Game['nexusGuildCunningHistory']>[number]) {
record.signature = guildCunningSignature(record);
g.nexusGuildCunningLast = {event:record.receipt.event,stage:record.stage};
}
function projectedNexusGuildCunning(g: Game, owner: string) {
const p = g.players.find(p => p.id === owner);
if (!p || p.faction !== 'guild') return null;
const record = currentGuildCunning(g,owner);
if (record) return {offer:null,active:{event:record.receipt.event,stage:record.stage as 'pending'|'secondShipment'|'extraMove',
movesLeft:record.stage === 'extraMove' ? nexusRule(() => nexusGuildCunningMoves(record.receipt,g.hajr.includes(owner),record.movesAfter)) : 0,
hajrAvailable:record.stage !== 'pending' && !g.hajr.includes(owner)}};
if (g.nexusCards?.cards?.hands[owner] !== 'guild') return null;
let blocked: string | null = null;
if (p.ally) blocked = 'Guild Cunning requires an unallied native Guild player.';
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner)
blocked = 'Use Cunning when finishing your own Shipment and Movement turn.';
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard ||
g.pendingNullentropy || g.pendingShipment || g.pendingHomeworldShipment || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing')
blocked = 'Finish the current interaction before declaring a second shipment.';
else if (g.ornithopter || p.moved > (g.hajr.includes(owner) ? 2 : 1))
blocked = 'Cunning combined with the physical Ornithopter movement card awaits its timing ruling.';
else if (g.nexusGuildCunningHistory?.some(r => r.receipt.turn === g.turn && r.receipt.owner === owner))
blocked = 'This turn’s Guild Cunning opportunity is already used.';
return {offer:{event:JSON.stringify(['nexusGuildCunning',g.turn,owner]),blocked},active:null};
}
function declareGuildCunning(g: Game, p: Player, event: unknown) {
const offer = projectedNexusGuildCunning(g,p.id)?.offer;
requireRule(offer && !offer.blocked && event === offer.event,offer?.blocked ?? 'This Guild Cunning opportunity is stale.');
const receipt = nexusRule(() => createNexusGuildCunning(g,p.id,{shipped:p.shipped,moved:p.moved,hajrUsed:g.hajr.includes(p.id)}));
const record = {receipt,stage:'pending' as const,parent:guildCunningParent(g),movesAfter:0,signature:''};
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
(g.nexusGuildCunningHistory ??= []).push(record); saveGuildCunning(g,record);
g.karamaShipping = null;
g.response = {kind:'nexusGuildCunning',owner:p.id,intent:receipt.event,passed:[]};
log(g,`${p.name} finished the ordinary shipment and movement opportunity and played Guild Cunning for an immediate second paid shipment. The Nexus is spent; Hajr is required for any further movement.`);
}
function validateGuildCunningResponse(g: Game, response: ResponseWindow) {
const record = currentGuildCunning(g,response.owner);
requireRule(record?.stage === 'pending' && response.kind === 'nexusGuildCunning' && response.intent === record.receipt.event &&
record.parent === guildCunningParent(g), 'Guild Cunning has lost its original combined-turn response.');
return record;
}
function settleGuildCunning(g: Game, response: ResponseWindow, canceled: boolean) {
const record = validateGuildCunningResponse(g,response);
record.stage = canceled ? 'canceled' : 'secondShipment'; saveGuildCunning(g,record);
log(g,canceled ? 'Karama prevented Guild Cunning. The first shipment and movement remain completed and the Nexus remains spent.' :
'Guild Cunning permits one immediate paid shipment using normal Guild routes. Ordinary movement is finished; an unused Hajr move remains possible afterward.',
canceled ? undefined : {faction:'guild',name:'Cunning: second shipment'});
if (canceled) finishMovementTurn(g,record.receipt.owner);
}
function bindGuildCunningShipment(g: Game, p: Player, kind: 'reserve'|'guild'|'homeworld', frame: unknown) {
const record = currentGuildCunning(g,p.id);
if (!record) return;
requireRule(record.stage === 'secondShipment' && !record.shipment, 'Guild Cunning already has its second shipment.');
record.shipment = {kind,frame:JSON.stringify(frame),stage:'pending'}; saveGuildCunning(g,record);
}
function validateGuildCunningShipment(g: Game, owner: string, event: string, kind: 'reserve'|'guild'|'homeworld', frame: unknown) {
const record = currentGuildCunning(g,owner);
requireRule(record?.stage === 'secondShipment' && record.receipt.event === event && record.shipment?.stage === 'pending' &&
record.shipment.kind === kind && record.shipment.frame === JSON.stringify(frame), 'Guild Cunning has lost its original second shipment.');
return record;
}
function finishGuildCunningShipment(g: Game, owner: string, kind: 'reserve'|'guild'|'homeworld', frame: unknown, outcome:'complete'|'stopped' = 'complete') {
const record = currentGuildCunning(g,owner);
if (!record) return;
validateGuildCunningShipment(g,owner,record.receipt.event,kind,frame);
record.shipment!.stage = outcome; record.stage = 'extraMove'; saveGuildCunning(g,record);
}
function nexusGuildCunningIntegrity(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(c => {
if (!c) return [];
const karama = 'pendingKarama' in c ? c.pendingKarama as Game['pendingKarama'] : null;
return [c.response,karama?.use?.kind === 'cancel' ? karama.use.response : null];
}).filter(r => r?.kind === 'nexusGuildCunning');
const history = g.nexusGuildCunningHistory;
if (history === undefined) {
requireRule(!g.nexusGuildCunningLast && !responses.length && !g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent,
'Guild Cunning has lost its saved second-shipment history.'); return;
}
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,'Guild Cunning 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).every(k => ['receipt','stage','parent','movesAfter','shipment','signature'].includes(k)) &&
['pending','secondShipment','extraMove','completed','canceled'].includes(record.stage) && typeof record.parent === 'string' &&
record.signature === guildCunningSignature(record) && !events.has(record.receipt?.event) &&
Number.isSafeInteger(record.movesAfter) && record.movesAfter >= 0 && record.movesAfter <= 1 &&
(['completed','canceled'].includes(record.stage) || index === history.length - 1),'Guild Cunning has a changed or duplicated allowance record.');
nexusRule(() => validateNexusGuildCunning(g,record.receipt));
if (record.shipment?.kind === 'declined') requireRule(Object.keys(record.shipment).sort().join(',') === 'kind,stage' && record.shipment.stage === 'declined' && ['extraMove','completed'].includes(record.stage), 'Guild Cunning has changed its declined second shipment.');
else if (record.shipment) requireRule(Object.keys(record.shipment).sort().join(',') === 'frame,kind,stage' &&
['reserve','guild','homeworld'].includes(record.shipment.kind) && typeof record.shipment.frame === 'string' &&
['pending','complete','stopped'].includes(record.shipment.stage) &&
(record.shipment.stage === 'pending' ? record.stage === 'secondShipment' : ['extraMove','completed'].includes(record.stage)),
'Guild Cunning has changed its second shipment outcome.');
requireRule(record.stage !== 'extraMove' || (record.shipment && record.shipment.stage !== 'pending'), 'Guild Cunning has lost its completed second shipment.');
requireRule(record.stage !== 'pending' || (!record.shipment && !record.movesAfter), 'Guild Cunning changed before its response completed.');
requireRule(!record.movesAfter || record.stage === 'extraMove' || record.stage === 'completed','Guild Cunning has moved before its second shipment.');
events.add(record.receipt.event);
}
const last = history.at(-1)!;
requireRule(JSON.stringify(g.nexusGuildCunningLast) === JSON.stringify({event:last.receipt.event,stage:last.stage}), 'Guild Cunning has lost its latest allowance marker.');
const active = currentGuildCunning(g);
requireRule(['completed','canceled'].includes(last.stage) || active, 'Guild Cunning has left its original turn unfinished.');
requireRule((active?.stage === 'pending') === !!responses.length, 'Guild Cunning has lost or reopened its native response.');
if (!active) {
requireRule(!g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent,'Guild Cunning has reopened a completed shipment.'); return;
}
const owner = getPlayer(g,active.receipt.owner);
requireRule(g.status === 'playing' && g.phase === 5 && g.active === owner.id &&
active.parent === guildCunningParent(g) && owner.moved === active.receipt.before.moved + active.movesAfter &&
owner.shipped === (active.stage === 'extraMove' && (active.shipment?.kind !== 'declined' || active.movesAfter > 0) ? true : active.receipt.before.shipped) && g.nexusCards.cards.discard.includes('guild'),
'Guild Cunning changed its original active player, queue, shipment or movement allowance.');
nexusRule(() => nexusGuildCunningMoves(active.receipt,g.hajr.includes(owner.id),active.movesAfter));
for (const response of responses) validateGuildCunningResponse(g,response!);
if (active.shipment?.stage === 'pending') {
if (active.shipment.kind === 'reserve') {
requireRule(g.pendingShipment?.guildNexusEvent === active.receipt.event, 'Guild Cunning lost its pending reserve shipment.');
validateGuildCunningShipment(g,owner.id,active.receipt.event,'reserve',g.pendingShipment);
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment');
requireRule(decisions.length > 0,'Guild Cunning lost its shipment interception decision.');
for (const d of decisions) validateGuildShipmentDecision(g,d);
} else if (active.shipment.kind === 'homeworld') {
requireRule(g.pendingHomeworldShipment?.guildNexusEvent === active.receipt.event,'Guild Cunning lost its pending Homeworld shipment.');
validateGuildCunningShipment(g,owner.id,active.receipt.event,'homeworld',g.pendingHomeworldShipment);
homeworldShipmentIntegrity(g);
} else throw new RuleError('Guild Cunning retained an unfinished immediate cross-shipment.');
} else requireRule(!g.pendingShipment?.guildNexusEvent && !g.pendingHomeworldShipment?.guildNexusEvent,
'Guild Cunning has a shipment without its original pending record.');
}
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;
@@ -17125,6 +17293,7 @@ export function executeSpecialKaramaIntent(
const shipper = getPlayer(g, intent.target);
discard(g, p, card.id);
p.specialKaramaUsed = true;
if (g.pendingHomeworldShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'homeworld',g.pendingHomeworldShipment,'stopped');
shipper.shipped = true;
g.pendingHomeworldShipment = null;
g.decision = null;
@@ -17145,6 +17314,7 @@ export function executeSpecialKaramaIntent(
finishAmbassador(g);
return;
}
if (g.pendingShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'reserve',g.pendingShipment,'stopped');
if (g.pendingShipment) finishNexusRicheseShipment(g,g.pendingShipment,'stopped');
shipper.shipped = true;
// Provisional canceled-shipment settlement pending a primary-source
@@ -19969,7 +20139,7 @@ function applyActionInner(
}
if (t === 'ship') {
requireRule(
g.phase === 5 && g.active === id && !p.shipped,
g.phase === 5 && g.active === id && shipmentAvailable(g,p),
'You may ship once, before your movement.',
);
let nexusEvent: string | undefined;
@@ -20048,6 +20218,7 @@ function applyActionInner(
);
const allyPayment = contribution(g, p, cost, action.allyPayment);
const shipment: PendingShipment = {
...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
...(nexusEvent ? {nexusEvent} : {}),
turn: g.turn,
player: id,
@@ -20067,6 +20238,7 @@ function applyActionInner(
...(noField ? { noField } : {}),
};
if (nexusEvent) recordNexusRicheseShipment(g,shipment);
bindGuildCunningShipment(g,p,'reserve',shipment);
if (shipment.noField) {
checkShipmentIncomeRounding(g, p, cost, allyPayment);
g.pendingShipment = shipment;
@@ -20109,7 +20281,7 @@ function applyActionInner(
requireRule(
g.phase === 5 &&
g.active === id &&
!p.shipped &&
shipmentAvailable(g,p) &&
(p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally),
'Guild shipment is not available.',
);
@@ -20144,6 +20316,7 @@ function applyActionInner(
);
const to = stringField(action.territory ?? 'reserves');
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',
'Only the Guild may ship forces back to reserves.',
@@ -20172,6 +20345,8 @@ 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};
bindGuildCunningShipment(g,p,'guild',cunningFrame);
payWithAlly(g, p, cost, allyPayment);
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
@@ -20207,6 +20382,7 @@ function applyActionInner(
p,
fromReserves ? { territory: to, amount: n } : null,
);
finishGuildCunningShipment(g,id,'guild',cunningFrame);
p.shipped = true;
g.karamaShipping = null;
log(g, `${p.name} used Guild transport for ${n} forces.`);
@@ -20441,12 +20617,24 @@ function applyActionInner(
} else offerChoamMovement(g, move);
return g;
}
if (t === 'nexusGuildSkipShipment') {
const record = currentGuildCunning(g,id);
requireRule(g.status === 'playing' && g.phase === 5 && g.active === id &&
record?.stage === 'secondShipment' && !record.shipment && action.event === record.receipt.event &&
Object.keys(action).every(k => ['type','event'].includes(k)), 'Only the current unused Guild Cunning shipment may be declined.');
finishShipmentPromises(g,p,null);
record.shipment = {kind:'declined',stage:'declined'};
record.stage = 'extraMove'; saveGuildCunning(g,record);
log(g,`${p.name} declined the second Guild shipment. No forces or spice moved; only an unused Hajr movement remains available.`);
return g;
}
if (t === 'endMovement') {
finishShipmentPromises(g, p, null);
requireRule(
g.phase === 5 && g.active === id,
'Wait for your movement turn.',
);
if (action.nexus !== undefined) { declareGuildCunning(g,p,action.nexus); return g; }
if (g.ornithopter?.player === id) finishOrnithopter(g, 'end');
else finishMovementTurn(g, id);
return g;
@@ -20717,7 +20905,7 @@ function applyActionInner(
) {
const recipient = getPlayer(g, stringField(action.target ?? id));
requireRule(
g.phase === 5 && g.active === recipient.id && !recipient.shipped,
g.phase === 5 && g.active === recipient.id && shipmentAvailable(g,recipient),
'Choose the active player before their shipment.',
);
requireRule(
@@ -20761,6 +20949,7 @@ function applyActionInner(
phase: g.phase,
active: g.active,
me: id,
nexusGuildCunning: projectedNexusGuildCunning(g,id),
truthtrance: g.truthtrance,
phaseOpening: g.phaseOpening,
response: g.response,
@@ -21139,6 +21328,7 @@ export function viewGame(state: Game, id: string) {
nexusCards: projectedNexusCards(g, id),
nexusMoritani: nexusMoritaniOffer(g,id),
nexusRichese: nexusRicheseOffer(g,id),
nexusGuildCunning: projectedNexusGuildCunning(g,id),
nexusTraitors: projectedNexusTraitors(g, id),
nexusTleilaxu: projectedNexusTleilaxu(g, id),
nexusSuboids: projectedNexusSuboids(g, id),
+3 -2
View File
@@ -1,3 +1,4 @@
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import type { Action, GameView } from './engine';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { gameTerritories, MOBILE_LOCATION, splitLocation } from './board';
@@ -43,7 +44,7 @@ export function guildHomeworldShipmentChoice(
view.status !== 'playing' ||
view.phase !== 5 ||
view.active !== me.id ||
me.shipped
!nexusGuildShipmentAvailable(view)
) {
result.blocked = 'Use Guild transport during your unused shipment turn.';
return result;
@@ -148,7 +149,7 @@ export function guildHomeworldShipmentActions(
me.faction !== 'guild' ||
!view.guildHomeworldShipment ||
view.guildHomeworldShipment.blocked ||
me.shipped ||
!nexusGuildShipmentAvailable(view) ||
view.phase !== 5 ||
view.active !== me.id
)
+3 -2
View File
@@ -1,3 +1,4 @@
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import type { Action, GameView } from './engine';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import {
@@ -50,7 +51,7 @@ export function homeworldShipmentChoice(
view.status !== 'playing' ||
view.phase !== 5 ||
view.active !== view.me ||
me.shipped
!nexusGuildShipmentAvailable(view)
) {
result.blocked = 'Use Homeworld shipment during your unused shipment turn.';
return result;
@@ -131,7 +132,7 @@ export function homeworldShipmentActions(
view.homeworldShipment.blocked ||
view.phase !== 5 ||
view.active !== me.id ||
me.shipped
!nexusGuildShipmentAvailable(view)
)
return [];
const difficulty = Math.max(0, Math.min(3, level));
+1
View File
@@ -84,6 +84,7 @@ const cancellationSources = {
...movementSource(g),
conversion: g.nexusAdvisorHistory?.find(record => record.stage === 'pending') ?? null,
}),
nexusGuildCunning: (g) => ({cunning:g.nexusGuildCunningHistory?.at(-1),active:g.active,remaining:g.movementRemaining}),
nexusSardaukar: (g) => ({
battle: battleSource(g),
declaration: g.nexusSardaukarHistory?.find(record => record.stage === 'pending') ?? null,
+98
View File
@@ -0,0 +1,98 @@
import type { Action, GameView } from './engine';
import { ordinaryCardAvailability } from './card-availability';
function owner(g: GameView) {
return g.players.find((p) => p.id === g.me);
}
function quiet(g: GameView) {
return (
g.status === 'playing' &&
g.phase === 5 &&
g.active === g.me &&
!g.response &&
!g.decision &&
!g.truthtrance &&
!g.phaseOpening &&
!g.automaticContinuationPending &&
!g.nexusCards?.waiting.length &&
!g.nexusTraitors?.pending
);
}
export function nexusGuildCunningActive(g: GameView) {
if (owner(g)?.faction !== 'guild' || g.phase !== 5 || g.active !== g.me)
return null;
const active = g.nexusGuildCunning?.active;
return active?.event === JSON.stringify(['nexusGuildCunning', g.turn, g.me])
? active
: null;
}
/** The original shipped flag remains physical turn history. */
export function nexusGuildShipmentAvailable(g: GameView): boolean {
const me = owner(g);
if (!me || !quiet(g)) return false;
const active = nexusGuildCunningActive(g);
if (me.faction === 'guild' && g.nexusGuildCunning?.active && !active)
return false;
return active ? active.stage === 'secondShipment' : !me.shipped;
}
export function nexusGuildMovementAvailable(g: GameView): boolean {
const me = owner(g);
if (!me || !quiet(g)) return false;
const active = nexusGuildCunningActive(g);
if (me.faction === 'guild' && g.nexusGuildCunning?.active && !active)
return false;
return active
? active.stage === 'extraMove' && active.movesLeft > 0
: (me.moved ?? 0) < (me.movesAllowed ?? 1);
}
export function nexusGuildCunningAction(
g: GameView,
event: string,
): Action | null {
const me = owner(g);
if (
!quiet(g) ||
me?.faction !== 'guild' ||
me.ally ||
g.nexusCards?.card !== 'guild'
)
return null;
const offer = g.nexusGuildCunning?.offer;
if (
!offer ||
offer.blocked ||
g.nexusGuildCunning?.active ||
event !== offer.event ||
event !== JSON.stringify(['nexusGuildCunning', g.turn, g.me])
)
return null;
return { type: 'endMovement', nexus: event };
}
/** Only the unused Hajr exception can prepare movement after the second shipment. */
export function nexusGuildHajrAction(g: GameView): Action | null {
const active = nexusGuildCunningActive(g);
if (
!quiet(g) ||
active?.stage !== 'extraMove' ||
active.movesLeft > 0 ||
!active.hajrAvailable
)
return null;
const card = owner(g)?.hand?.find(
(c) => c.effect === 'hajr' && ordinaryCardAvailability(g, c.id)?.available,
);
return card ? { type: 'card', card: card.id } : null;
}
/** Passing this extra shipment preserves the separate unused Hajr choice. */
export function nexusGuildSkipShipmentAction(
g: GameView,
event: string,
): Action | null {
const active = nexusGuildCunningActive(g);
if (!quiet(g) || active?.stage !== 'secondShipment' || active.event !== event)
return null;
return { type: 'nexusGuildSkipShipment', event };
}
+173
View File
@@ -0,0 +1,173 @@
import { FACTIONS, type FactionId } from './catalog';
export type NexusGuildCunningContext = {
turn: number;
players: readonly { id: string; faction: FactionId; ally?: string | null }[];
};
export type NexusGuildCunningBefore = {
shipped: boolean;
moved: number;
hajrUsed: boolean;
};
export type NexusGuildCunningReceipt = {
version: 1;
event: string;
owner: string;
turn: number;
phase: 5;
mode: 'cunning';
roster: { id: string; faction: FactionId }[];
before: NexusGuildCunningBefore;
signature: string;
};
function requireGuild(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' &&
value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].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));
function roster(context: NexusGuildCunningContext) {
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) && FACTIONS.some((f) => f.id === 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,
'Guild Cunning requires its original faction roster and turn.',
);
return context.players
.map(({ id, faction }) => ({ id, faction }))
.sort((a, b) => a.id.localeCompare(b.id));
}
function validateBefore(before: NexusGuildCunningBefore) {
requireGuild(
plain(before) &&
Object.keys(before).sort().join(',') === 'hajrUsed,moved,shipped' &&
typeof before.shipped === 'boolean' &&
typeof before.hajrUsed === 'boolean' &&
Number.isSafeInteger(before.moved) &&
before.moved >= 0 &&
before.moved <= (before.hajrUsed ? 2 : 1),
'Guild Cunning needs the original ordinary shipment and Hajr movement allowance.',
);
}
function signature(receipt: NexusGuildCunningReceipt) {
return JSON.stringify([
'nexusGuildCunningReceipt',
receipt.version,
receipt.event,
receipt.owner,
receipt.turn,
receipt.phase,
receipt.mode,
receipt.roster.map((p) => [p.id, p.faction]),
[receipt.before.shipped, receipt.before.moved, receipt.before.hajrUsed],
]);
}
/** Validates historical facts, not current cards, alliances, resources or movement.
* The engine binds this receipt to its actual completed first combined turn. */
export function validateNexusGuildCunning(
context: NexusGuildCunningContext,
receipt: NexusGuildCunningReceipt,
): void {
const seats = roster(context);
requireGuild(
plain(receipt) &&
Object.keys(receipt).sort().join(',') ===
'before,event,mode,owner,phase,roster,signature,turn,version' &&
receipt.version === 1 &&
receipt.phase === 5 &&
receipt.mode === 'cunning' &&
Number.isSafeInteger(receipt.turn) &&
receipt.turn > 0 &&
receipt.turn <= context.turn &&
identifier(receipt.owner) &&
seats.some((p) => p.id === receipt.owner && p.faction === 'guild') &&
receipt.event ===
JSON.stringify(['nexusGuildCunning', 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),
'Guild Cunning has changed its original owner, turn or faction roster.',
);
validateBefore(receipt.before);
requireGuild(
receipt.signature === signature(receipt),
'Guild Cunning has changed its original combined turn.',
);
}
export function createNexusGuildCunning(
context: NexusGuildCunningContext & { phase: number; active: string | null },
owner: string,
before: NexusGuildCunningBefore,
): NexusGuildCunningReceipt {
const seats = roster(context);
requireGuild(
context.phase === 5 &&
context.active === owner &&
context.players.some(
(p) => p.id === owner && p.faction === 'guild' && !p.ally,
),
'Guild Cunning requires the unallied native Guild at the end of its own combined turn.',
);
validateBefore(before);
const receipt: NexusGuildCunningReceipt = {
version: 1,
event: JSON.stringify(['nexusGuildCunning', context.turn, owner]),
owner,
turn: context.turn,
phase: 5,
mode: 'cunning',
roster: seats,
before: { ...before },
signature: '',
};
receipt.signature = signature(receipt);
validateNexusGuildCunning(context, receipt);
return receipt;
}
/** Ending ordinary movement forgoes at least its first slot. Hajr supplies only
* the still-unused extra slot, whether played before or after the shipment. */
export function nexusGuildCunningMoves(
receipt: NexusGuildCunningReceipt,
currentHajrUsed: boolean,
movesAfterSecond = 0,
): 0 | 1 {
validateNexusGuildCunning(
{ turn: receipt.turn, players: receipt.roster },
receipt,
);
requireGuild(
typeof currentHajrUsed === 'boolean' &&
(!receipt.before.hajrUsed || currentHajrUsed) &&
Number.isSafeInteger(movesAfterSecond) &&
movesAfterSecond >= 0 &&
movesAfterSecond <= 1,
'Guild Cunning has changed its remaining Hajr movement.',
);
const available = currentHajrUsed
? Math.max(0, 2 - Math.max(1, receipt.before.moved))
: 0;
requireGuild(
movesAfterSecond <= available,
'Guild Cunning cannot supply another ordinary movement.',
);
return (available - movesAfterSecond) as 0 | 1;
}
+6 -5
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 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 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.',
@@ -3368,16 +3368,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'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.',
'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/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/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'] },
{ 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'] },
{ 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', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
],
},
...[
+1
View File
@@ -76,6 +76,7 @@ const RESPONSES = {
nexusPrescience: true,
nexusAdvisorFlip: true,
nexusSardaukar: true,
nexusGuildCunning: true,
advisor: true,
emperorIncome: true,
richesePurchaseIncome: true,
+13
View File
@@ -0,0 +1,13 @@
import type { Game, Player } from './engine';
/** Rule callers validate the saved record before using this allowance. The
* original shipped flag remains history when Cunning grants another shipment. */
export function shipmentAvailable(
g: Pick<Game, 'turn' | 'nexusGuildCunningHistory'>,
p: Pick<Player, 'id' | 'shipped'>,
): boolean {
const last = g.nexusGuildCunningHistory?.at(-1);
const active = last && last.receipt.turn === g.turn && last.receipt.owner === p.id &&
['pending', 'secondShipment', 'extraMove'].includes(last.stage);
return active ? last.stage === 'secondShipment' : !p.shipped;
}
+4 -1
View File
@@ -1,3 +1,4 @@
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import type { Action, GameView } from './engine';
import { splitLocation, validLocation } from './board';
import { botEntryAllowed, guildTransportCost } from './bot-mobility';
@@ -122,10 +123,12 @@ export function guildTransportQuote(g: GameView, action: Action) {
unavailableReasons.push(
'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.');
if (
g.phase !== 5 ||
g.active !== p.id ||
p.shipped ||
!nexusGuildShipmentAvailable(g) ||
!(
p.faction === 'guild' ||
g.players.some((seat) => seat.id === p.ally && seat.faction === 'guild')
+2 -1
View File
@@ -1,3 +1,4 @@
import { shipmentAvailable } from './shipment-opportunity';
import type { ShipmentClaim } from './shipment-promises';
import { TERRITORIES } from './board';
import {
@@ -145,7 +146,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
g.status === 'playing' &&
g.phase === 5 &&
g.active === v.target &&
!g.players.find((p) => p.id === v.target)!.shipped &&
shipmentAvailable(g,g.players.find((p) => p.id === v.target)!) &&
!g.response &&
!g.decision &&
!g.phaseOpening &&