Connect bounded Richese Nexus Cunning No-Field pair
This commit is contained in:
1 parent
0c6d28a706
commit
763749f25e
14 files changed
+758
-31
No files matched your search
@@ -3140,6 +3140,19 @@ function policyActions(g: GameView): Action[] {
|
||||
Math.abs(a.value - preferred) - Math.abs(b.value - preferred) ||
|
||||
b.value - a.value,
|
||||
)[0];
|
||||
const cunning = g.nexusRicheseCunning;
|
||||
if (cunning && !cunning.blocked && tokens.length >= 2) {
|
||||
const revealed = tokens.reduce((best, token) => token.value > best.value ? token : best);
|
||||
const concealed = tokens.find(token => token.id !== revealed.id)!;
|
||||
const target = markerTargets.slice(0, 24).find(to =>
|
||||
reserveShipmentCost({ faction: me.faction, halfRate: false },
|
||||
territory(to.t).type, 1) <= (me.spice ?? 0));
|
||||
if (target) return [{
|
||||
type: 'ship', noField: concealed.id, revealedToken: revealed.id,
|
||||
nexus: cunning.event, event: noField.event,
|
||||
territory: target.t, sector: target.s, allyPayment: 0,
|
||||
}];
|
||||
}
|
||||
if (selected)
|
||||
for (const to of markerTargets.slice(0, 24)) {
|
||||
if (Math.min(selected.value, me.reserves) === 0 && to.enemy > 0)
|
||||
|
||||
+204
-20
@@ -37,6 +37,7 @@ import { quoteNexusChoamInspection, sampleNexusChoamInspection, nexusChoamInspec
|
||||
import { quoteNexusMoritaniRetention, nexusMoritaniRetentionSignature, type NexusMoritaniRetentionReceipt } from './nexus-moritani-retention';
|
||||
import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal';
|
||||
import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySignature, validateEcazInquiry, type EcazInquiryReceipt } from './nexus-ecaz-inquiry';
|
||||
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
|
||||
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
|
||||
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, fremenNexusRevivalPools, fremenNexusRevivalSignature, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
|
||||
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
|
||||
@@ -382,6 +383,8 @@ import { richeseCards, richeseCardDefinition } from './richese-cards';
|
||||
import {
|
||||
createRicheseNoField,
|
||||
deployRicheseNoField,
|
||||
deployRicheseNoFieldPair,
|
||||
validateRicheseNoFieldPair,
|
||||
moveRicheseNoField,
|
||||
revealRicheseNoField,
|
||||
shipAlliedRicheseNoField,
|
||||
@@ -1009,6 +1012,7 @@ type PendingShipment = {
|
||||
guildSecretEvent?: string;
|
||||
guildNexusEvent?: string;
|
||||
nexusEvent?: string;
|
||||
richesePair?: { event: string; revealedTokenId: string };
|
||||
homeworldSources?: NativeReserveSelections;
|
||||
source?: 'ambassador';
|
||||
ambassadorEvent?: string;
|
||||
@@ -1516,6 +1520,8 @@ export type Game = {
|
||||
nexusGuildSecretLast?: {event:string};
|
||||
nexusRicheseHistory?: {receipt:NexusRicheseReceipt;stage:'pending'|'shipped'|'stopped';frame:string;halfRate:boolean;signature:string}[];
|
||||
nexusRicheseLast?: {event:string;stage:'pending'|'shipped'|'stopped'};
|
||||
nexusRicheseCunningHistory?: { event: string; owner: string; turn: number; phase: 5; tokenEvent: string; frame: string; stage: 'pending' | 'shipped' | 'stopped'; signature: string }[];
|
||||
nexusRicheseCunningLast?: { event: string; stage: 'pending' | 'shipped' | 'stopped' };
|
||||
nexusMoritaniLocations?: Record<string,{event:string;territory:string}>;
|
||||
nexusMoritaniLast?: {event:string;stage:'pending'|'complete'|'canceled'};
|
||||
nexusChoamLast?: {event: string; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'};
|
||||
@@ -15255,6 +15261,7 @@ function marketGholaIntegrity(g: Game) {
|
||||
nexusMoritaniIntegrity(g);
|
||||
nexusGuildSecretIntegrity(g);
|
||||
nexusRicheseIntegrity(g);
|
||||
richesePairIntegrity(g);
|
||||
nexusGuildCunningIntegrity(g);
|
||||
traitorDeclarationIntegrity(g);
|
||||
nexusInspectionIntegrity(g);
|
||||
@@ -18675,6 +18682,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
shipment.alliedNoField?.owner === response.owner),
|
||||
'No current No-Field shipment is awaiting this response.',
|
||||
);
|
||||
if (canceled && shipment.richesePair) finishRichesePairShipment(g, shipment, 'stopped');
|
||||
g.pendingShipment = null;
|
||||
if (canceled) {
|
||||
requireRule(quote, 'Missing canceled No-Field declaration.');
|
||||
@@ -18682,7 +18690,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
else getPlayer(g, quote.player).noFieldBlockedTurn = quote.blockedTurn;
|
||||
log(
|
||||
g,
|
||||
'Karama prevented No-Field use for this shipment opportunity. No spice, reserves or token history changed; normal shipment remains available.',
|
||||
`Karama prevented No-Field use for this shipment opportunity. No spice, reserves or token history changed; normal shipment remains available.${shipment.richesePair ? ' The Richese Nexus card remains spent.' : ''}`,
|
||||
);
|
||||
} else offerShipment(g, shipment);
|
||||
} else if (response.kind === 'moritaniRetention') {
|
||||
@@ -19847,16 +19855,20 @@ function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment)
|
||||
'The declared No-Field contributor is no longer your mutual ally.');
|
||||
contribution(g, p, cost, shipment.allyPayment);
|
||||
}
|
||||
/** Bind both private stages, also while a card effect has saved their controls. */
|
||||
function leaderSkillNoFieldIntegrity(g: Game): boolean {
|
||||
/** Find a No-Field response while another card has temporarily suspended it. */
|
||||
function savedNoFieldResponses(g: Game): ResponseWindow[] {
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null];
|
||||
const responses = contexts.flatMap(context => {
|
||||
return contexts.flatMap(context => {
|
||||
const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
|
||||
return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null];
|
||||
}).filter((response): response is ResponseWindow => !!response);
|
||||
}
|
||||
/** Bind both private stages, also while a card effect has saved their controls. */
|
||||
function leaderSkillNoFieldIntegrity(g: Game): boolean {
|
||||
const responses = savedNoFieldResponses(g);
|
||||
const decisions = homeworldSavedDecisions(g);
|
||||
const shipment = g.pendingShipment;
|
||||
if (!(shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
|
||||
@@ -19892,6 +19904,7 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
|
||||
}
|
||||
if (shipment.guildNexusEvent) validateGuildCunningShipment(g,shipment.player,shipment.guildNexusEvent,'reserve',shipment);
|
||||
if (shipment.nexusEvent !== undefined) currentNexusRicheseShipment(g,shipment);
|
||||
if (shipment.richesePair) currentRichesePairShipment(g, shipment);
|
||||
requireRule(
|
||||
!(shipment.noField || shipment.alliedNoField) ||
|
||||
shipment.homeworldSources === undefined,
|
||||
@@ -20050,6 +20063,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
validateShipmentArrival(g, shipment);
|
||||
if (shipment.guildSecretEvent) recordGuildSecretShipment(g,shipment.player,shipment.guildSecretEvent,'reserve',shipment);
|
||||
finishNexusRicheseShipment(g,shipment,'shipped');
|
||||
finishRichesePairShipment(g, shipment, 'shipped');
|
||||
if (shipment.guildNexusEvent) finishGuildCunningShipment(g,shipment.player,'reserve',shipment);
|
||||
finishShipmentPromises(
|
||||
g,
|
||||
@@ -20066,6 +20080,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
advisors,
|
||||
} = shipment;
|
||||
const p = getPlayer(g, shipment.player);
|
||||
let pairRevealed: ReturnType<typeof deployRicheseNoFieldPair>['revealed'] | null = null;
|
||||
if (shipment.noField) {
|
||||
requireRule(
|
||||
p.faction === 'richese' &&
|
||||
@@ -20073,13 +20088,19 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
p.noFieldEvent === shipment.noField.event,
|
||||
'This No-Field shipment is no longer current.',
|
||||
);
|
||||
p.noField = noFieldRule(() =>
|
||||
deployRicheseNoField(p.noField!, {
|
||||
if (shipment.richesePair) {
|
||||
const pair = noFieldRule(() => deployRicheseNoFieldPair(p.noField!, {
|
||||
tokenId: shipment.noField!.tokenId,
|
||||
controller: p.id,
|
||||
location: { territory: to, sector: s },
|
||||
}),
|
||||
);
|
||||
}, shipment.richesePair!.revealedTokenId, p.reserves));
|
||||
p.noField = pair.state;
|
||||
pairRevealed = pair.revealed;
|
||||
} else p.noField = noFieldRule(() => deployRicheseNoField(p.noField!, {
|
||||
tokenId: shipment.noField!.tokenId,
|
||||
controller: p.id,
|
||||
location: { territory: to, sector: s },
|
||||
}));
|
||||
p.noFieldEvent = crypto.randomUUID();
|
||||
}
|
||||
if (shipment.alliedNoField) {
|
||||
@@ -20121,6 +20142,11 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
owner.spice -= quote.ownerPayment;
|
||||
p.spice -= quote.recipientPayment;
|
||||
} else payWithAlly(g, p, cost, allyPayment);
|
||||
if (pairRevealed) {
|
||||
p.reserves -= pairRevealed.forces;
|
||||
if (pairRevealed.forces) place(p, to, s, pairRevealed.forces);
|
||||
observeOccupation(g);
|
||||
}
|
||||
let homeworldOrigins = '';
|
||||
if (!shipment.noField) {
|
||||
const receipts = withdrawNativeReserves(
|
||||
@@ -20166,7 +20192,9 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
shipment.alliedNoField
|
||||
? `${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. ${shipment.smugglerCompanion ? 'One free Smuggler force left reserves and arrived beside the marker; the concealed token remains unchanged until reveal.' : 'It counts as one force; physical reserves remain unchanged until reveal.'}`
|
||||
? pairRevealed
|
||||
? `${p.name} shipped two No-Fields with Richese Nexus Cunning to ${territory(to).name}, sector ${s}, for ${cost} spice. The faceup ${pairRevealed.value} token placed ${pairRevealed.forces} physical forces from reserves${pairRevealed.forces < pairRevealed.value ? ' (only the available reserves)' : ''}; the other token remains concealed and counts as one force.`
|
||||
: `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. ${shipment.smugglerCompanion ? 'One free Smuggler force left reserves and arrived beside the marker; the concealed token remains unchanged until reveal.' : 'It counts as one force; physical reserves remain unchanged until reveal.'}`
|
||||
: `${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' }
|
||||
@@ -20197,7 +20225,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
|
||||
p,
|
||||
to,
|
||||
s,
|
||||
shipment.alliedNoField ? Math.max(1, n) : n + (shipment.smugglerCompanion ? 1 : 0),
|
||||
shipment.alliedNoField ? Math.max(1, n) : n + (shipment.smugglerCompanion ? 1 : 0) + (pairRevealed?.forces ?? 0),
|
||||
elite,
|
||||
'shipment',
|
||||
);
|
||||
@@ -20489,6 +20517,142 @@ function nexusRicheseOffer(g: Game, owner: string) {
|
||||
else if (p.reserves < 1) blocked = 'You need physical forces in reserves.';
|
||||
return {event:JSON.stringify(['nexusRichese',g.turn,owner]),blocked,maxForces:Math.min(5,p.reserves)};
|
||||
}
|
||||
const RICHESE_CUNNING_ROSTER = new Set<FactionId>([
|
||||
'atreides', 'harkonnen', 'emperor', 'fremen', 'beneGesserit', 'guild', 'richese', 'choam',
|
||||
]);
|
||||
function richeseCunningModeSupported(g: Game) {
|
||||
return !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled &&
|
||||
!g.ecazTreachery && !g.sandtrout &&
|
||||
g.players.every(player => RICHESE_CUNNING_ROSTER.has(player.faction));
|
||||
}
|
||||
function nexusRicheseCunningOffer(g: Game, owner: string) {
|
||||
const p = g.players.find(player => player.id === owner);
|
||||
if (!p || p.faction !== 'richese' || g.nexusCards?.cards?.hands[owner] !== 'richese') return null;
|
||||
let blocked: string | null = null;
|
||||
if (!richeseCunningModeSupported(g))
|
||||
blocked = 'This two-token shipment supports classic factions, Richese and CHOAM without other optional modules.';
|
||||
else if (p.ally) blocked = 'Nexus Cunning requires an unallied Richese holder.';
|
||||
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || !shipmentAvailable(g, p))
|
||||
blocked = 'Use Cunning for your unused shipment before movement.';
|
||||
else if (nexusCleanPlayBlocked(g) || g.karamaShipping)
|
||||
blocked = 'Finish the current interaction or Karama shipment before using Cunning.';
|
||||
else blocked = noFieldShipBlock(g, p);
|
||||
return {
|
||||
event: JSON.stringify(['nexusRicheseCunning', g.turn, owner, p.noFieldEvent]),
|
||||
blocked,
|
||||
};
|
||||
}
|
||||
function richesePairSignature(record: NonNullable<Game['nexusRicheseCunningHistory']>[number]) {
|
||||
return JSON.stringify([record.event, record.owner, record.turn, record.phase,
|
||||
record.tokenEvent, record.frame, record.stage]);
|
||||
}
|
||||
function currentRichesePairShipment(g: Game, shipment: PendingShipment) {
|
||||
const pair = shipment.richesePair;
|
||||
const record = g.nexusRicheseCunningHistory?.at(-1);
|
||||
requireRule(pair && record?.stage === 'pending' && record.event === pair.event &&
|
||||
record.frame === JSON.stringify(shipment) && record.owner === shipment.player &&
|
||||
record.turn === g.turn && record.phase === 5 &&
|
||||
record.tokenEvent === shipment.noField?.event &&
|
||||
record.signature === richesePairSignature(record) &&
|
||||
g.nexusCards?.cards?.discard.includes('richese') &&
|
||||
!shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.guildSecretEvent &&
|
||||
!shipment.alliedNoField && !shipment.source && !shipment.smuggler &&
|
||||
!shipment.smugglerCompanion && !shipment.homeworldSources &&
|
||||
!shipment.noFieldSkillProof && shipment.amount === 1 && shipment.elite === 0 &&
|
||||
shipment.advisors === false && shipment.allyPayment === 0 &&
|
||||
richeseCunningModeSupported(g),
|
||||
'Richese Cunning lost its original two-token shipment or physical Nexus card.');
|
||||
const p = getPlayer(g, shipment.player);
|
||||
requireRule(p.faction === 'richese' && !p.ally && p.noField &&
|
||||
p.noFieldEvent === record.tokenEvent && !p.noField.deployed &&
|
||||
g.status === 'playing' && g.phase === 5 && g.active === p.id &&
|
||||
shipmentAvailable(g, p), 'Richese Cunning lost its unused native shipment.');
|
||||
const cost = reserveShipmentCost({ faction: p.faction, halfRate: false },
|
||||
territory(shipment.territory).type, 1);
|
||||
requireRule(shipment.cost === cost && Number.isSafeInteger(shipment.sector) &&
|
||||
territory(shipment.territory).sectors.includes(shipment.sector),
|
||||
'Richese Cunning lost its original one-marker price or destination.');
|
||||
noFieldRule(() => validateRicheseNoFieldPair(p.noField!, {
|
||||
tokenId: shipment.noField!.tokenId,
|
||||
controller: p.id,
|
||||
location: { territory: shipment.territory, sector: shipment.sector },
|
||||
}, pair.revealedTokenId, p.reserves));
|
||||
return record;
|
||||
}
|
||||
function recordRichesePairShipment(g: Game, shipment: PendingShipment) {
|
||||
const p = getPlayer(g, shipment.player);
|
||||
const offer = nexusRicheseCunningOffer(g, p.id);
|
||||
requireRule(offer && !offer.blocked && offer.event === shipment.richesePair?.event,
|
||||
offer?.blocked ?? 'Richese Cunning is unavailable or stale.');
|
||||
const record: NonNullable<Game['nexusRicheseCunningHistory']>[number] = {
|
||||
event: offer.event, owner: p.id, turn: g.turn, phase: 5,
|
||||
tokenEvent: shipment.noField!.event, frame: JSON.stringify(shipment),
|
||||
stage: 'pending', signature: '',
|
||||
};
|
||||
record.signature = richesePairSignature(record);
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
(g.nexusRicheseCunningHistory ??= []).push(record);
|
||||
g.nexusRicheseCunningLast = { event: record.event, stage: record.stage };
|
||||
}
|
||||
function finishRichesePairShipment(g: Game, shipment: PendingShipment, stage: 'shipped' | 'stopped') {
|
||||
if (!shipment.richesePair) return;
|
||||
const record = currentRichesePairShipment(g, shipment);
|
||||
record.stage = stage;
|
||||
record.signature = richesePairSignature(record);
|
||||
g.nexusRicheseCunningLast = { event: record.event, stage };
|
||||
}
|
||||
function richesePairIntegrity(g: Game) {
|
||||
const history = g.nexusRicheseCunningHistory;
|
||||
const pending = g.pendingShipment;
|
||||
if (history === undefined) {
|
||||
requireRule(!g.nexusRicheseCunningLast && !pending?.richesePair,
|
||||
'Richese Cunning lost its saved shipment history.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.nexusCards?.cards && Array.isArray(history) && history.length > 0,
|
||||
'Richese Cunning needs its physical Nexus module and history.');
|
||||
const events = new Set<string>();
|
||||
for (const [index, record] of history.entries()) {
|
||||
requireRule(record && typeof record === 'object' &&
|
||||
Object.keys(record).sort().join(',') === 'event,frame,owner,phase,signature,stage,tokenEvent,turn' &&
|
||||
typeof record.owner === 'string' && typeof record.tokenEvent === 'string' &&
|
||||
Number.isSafeInteger(record.turn) && record.turn > 0 && record.turn <= g.turn &&
|
||||
record.phase === 5 && ['pending', 'shipped', 'stopped'].includes(record.stage) &&
|
||||
typeof record.frame === 'string' && record.signature === richesePairSignature(record) &&
|
||||
!events.has(record.event) && (record.stage !== 'pending' || index === history.length - 1) &&
|
||||
record.event === JSON.stringify(['nexusRicheseCunning', record.turn, record.owner, record.tokenEvent]),
|
||||
'Richese Cunning changed its original card, owner or shipment receipt.');
|
||||
const frame = JSON.parse(record.frame) as PendingShipment;
|
||||
requireRule(frame && typeof frame === 'object' && !Array.isArray(frame) &&
|
||||
Object.keys(frame).sort().join(',') === 'advisors,allyPayment,amount,cost,elite,noField,player,richesePair,sector,territory,turn' &&
|
||||
frame.richesePair?.event === record.event && frame.player === record.owner &&
|
||||
frame.noField?.event === record.tokenEvent && frame.turn === record.turn &&
|
||||
typeof frame.noField.tokenId === 'string' && typeof frame.richesePair.revealedTokenId === 'string' &&
|
||||
g.players.some(player => player.id === record.owner && player.faction === 'richese' &&
|
||||
player.noField?.tokens.some(token => token.id === frame.noField?.tokenId) &&
|
||||
player.noField?.tokens.some(token => token.id === frame.richesePair?.revealedTokenId)),
|
||||
'Richese Cunning changed its two physical token identities.');
|
||||
events.add(record.event);
|
||||
}
|
||||
const last = history.at(-1)!;
|
||||
requireRule(JSON.stringify(g.nexusRicheseCunningLast) ===
|
||||
JSON.stringify({ event: last.event, stage: last.stage }) &&
|
||||
(last.stage === 'pending') === !!pending?.richesePair,
|
||||
'Richese Cunning lost or reopened its latest shipment.');
|
||||
if (last.stage === 'pending') {
|
||||
requireRule(pending && pending.richesePair?.event === last.event,
|
||||
'Richese Cunning lost its pending shipment.');
|
||||
currentRichesePairShipment(g, pending);
|
||||
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'richeseNoField');
|
||||
const decisions = homeworldSavedDecisions(g).filter(decision => decision.kind === 'guildShipment');
|
||||
requireRule(responses.length + decisions.length === 1,
|
||||
'Richese Cunning lost its unique Karama response or Guild interception decision.');
|
||||
for (const response of responses)
|
||||
requireRule(response.owner === pending.player && response.noFieldSkillProof === undefined,
|
||||
'Richese Cunning changed its native No-Field response.');
|
||||
for (const decision of decisions) validateGuildShipmentDecision(g, decision);
|
||||
}
|
||||
}
|
||||
function shipmentHalfRate(g: Game, p: Player) {
|
||||
return p.faction === 'guild' || byFaction(g,'guild')?.id === p.ally || g.karamaShipping?.player === p.id;
|
||||
}
|
||||
@@ -21513,6 +21677,7 @@ export function executeSpecialKaramaIntent(
|
||||
}
|
||||
if (g.pendingShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'reserve',g.pendingShipment,'stopped');
|
||||
if (g.pendingShipment) finishNexusRicheseShipment(g,g.pendingShipment,'stopped');
|
||||
if (g.pendingShipment) finishRichesePairShipment(g, g.pendingShipment, 'stopped');
|
||||
shipper.shipped = true;
|
||||
// Provisional canceled-shipment settlement pending a primary-source
|
||||
// clarification: retain the attempted shipment's spice and rate card.
|
||||
@@ -21682,6 +21847,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
nexusMoritaniRetentionIntegrity(state);
|
||||
nexusChoamBetrayalIntegrity(state);
|
||||
nexusEcazInquiryIntegrity(state);
|
||||
richesePairIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -21844,6 +22010,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
nexusMoritaniRetentionIntegrity(g);
|
||||
nexusChoamBetrayalIntegrity(g);
|
||||
nexusEcazInquiryIntegrity(g);
|
||||
richesePairIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
@@ -21960,6 +22127,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
nexusMoritaniRetentionIntegrity(state);
|
||||
nexusChoamBetrayalIntegrity(state);
|
||||
nexusEcazInquiryIntegrity(state);
|
||||
richesePairIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -21994,6 +22162,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
nexusMoritaniRetentionIntegrity(g);
|
||||
nexusChoamBetrayalIntegrity(g);
|
||||
nexusEcazInquiryIntegrity(g);
|
||||
richesePairIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
@@ -24799,7 +24968,16 @@ function applyActionInner(
|
||||
);
|
||||
let nexusEvent: string | undefined;
|
||||
let guildSecretEvent: string | undefined;
|
||||
if (action.nexus !== undefined && action.nexus === nexusGuildSecretAllyOffer(g,id)?.event) {
|
||||
let richesePair: PendingShipment['richesePair'];
|
||||
if (action.revealedToken !== undefined) {
|
||||
const offer = nexusRicheseCunningOffer(g, id);
|
||||
requireRule(offer && !offer.blocked && action.nexus === offer.event,
|
||||
offer?.blocked ?? 'Richese Cunning is unavailable or stale.');
|
||||
requireRule(Object.keys(action).sort().join(',') ===
|
||||
'allyPayment,event,nexus,noField,revealedToken,sector,territory,type',
|
||||
'Choose exactly two physical No-Field tokens and a shipment destination.');
|
||||
richesePair = { event: offer.event, revealedTokenId: stringField(action.revealedToken) };
|
||||
} else 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.');
|
||||
@@ -24813,7 +24991,7 @@ function applyActionInner(
|
||||
}
|
||||
checkShipmentPromises(g, p, {
|
||||
territory: String(action.territory),
|
||||
amount: Number(action.amount),
|
||||
amount: richesePair ? 1 : Number(action.amount),
|
||||
});
|
||||
const to = String(action.territory),
|
||||
s = integer(action.sector, 0, 18, 'Sector');
|
||||
@@ -24836,13 +25014,14 @@ function applyActionInner(
|
||||
'This No-Field selection is stale.',
|
||||
);
|
||||
const tokenId = stringField(action.noField);
|
||||
noFieldRule(() =>
|
||||
deployRicheseNoField(p.noField!, {
|
||||
tokenId,
|
||||
controller: id,
|
||||
location: { territory: to, sector: s },
|
||||
}),
|
||||
);
|
||||
if (richesePair)
|
||||
noFieldRule(() => validateRicheseNoFieldPair(p.noField!, {
|
||||
tokenId, controller: id, location: { territory: to, sector: s },
|
||||
}, richesePair.revealedTokenId, p.reserves));
|
||||
else
|
||||
noFieldRule(() => deployRicheseNoField(p.noField!, {
|
||||
tokenId, controller: id, location: { territory: to, sector: s },
|
||||
}));
|
||||
requireRule(
|
||||
action.amount === undefined || action.amount === 1,
|
||||
'The shipment price is for one No-Field, not a chosen number of physical forces.',
|
||||
@@ -24853,6 +25032,7 @@ function applyActionInner(
|
||||
);
|
||||
noField = { tokenId, event: p.noFieldEvent! };
|
||||
}
|
||||
requireRule(!richesePair || noField, 'Richese Cunning needs a concealed native No-Field.');
|
||||
const n = noField ? 1 : integer(action.amount, 1, p.reserves, 'Forces');
|
||||
const elite = noField
|
||||
? 0
|
||||
@@ -24890,6 +25070,7 @@ function applyActionInner(
|
||||
...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
|
||||
...(nexusEvent ? {nexusEvent} : {}),
|
||||
...(guildSecretEvent ? {guildSecretEvent} : {}),
|
||||
...(richesePair ? { richesePair } : {}),
|
||||
turn: g.turn,
|
||||
player: id,
|
||||
territory: to,
|
||||
@@ -24916,12 +25097,13 @@ function applyActionInner(
|
||||
// unsupported arrival combinations before saving its private intent.
|
||||
validateShipmentArrival(g, shipment);
|
||||
checkShipmentIncomeRounding(g, p, cost, allyPayment);
|
||||
if (richesePair) recordRichesePairShipment(g, shipment);
|
||||
g.pendingShipment = shipment;
|
||||
g.response = { kind: 'richeseNoField', owner: p.id, passed: [],
|
||||
...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}) };
|
||||
log(
|
||||
g,
|
||||
`${p.name} declared a concealed No-Field shipment${smugglerCompanion ? ' with one free Smuggler force' : ''} to ${territory(to).name}, sector ${s}.`,
|
||||
`${p.name} declared a concealed No-Field shipment${richesePair ? ' with Richese Nexus Cunning and one faceup token' : ''}${smugglerCompanion ? ' with one free Smuggler force' : ''} to ${territory(to).name}, sector ${s}.${richesePair ? ' The Nexus card is spent; payment and both token effects await the responses.' : ''}`,
|
||||
);
|
||||
} else offerShipment(g, shipment);
|
||||
return g;
|
||||
@@ -25766,6 +25948,7 @@ export function viewGame(state: Game, id: string) {
|
||||
nexusMoritaniRetentionIntegrity(state);
|
||||
nexusChoamBetrayalIntegrity(state);
|
||||
nexusEcazInquiryIntegrity(state);
|
||||
richesePairIntegrity(state);
|
||||
marketGholaIntegrity(state);
|
||||
homeworldRule(() => homeworldGameIntegrity(state));
|
||||
homeworldBattleLossIntegrity(state);
|
||||
@@ -26097,6 +26280,7 @@ export function viewGame(state: Game, id: string) {
|
||||
nexusEcazBetrayal: ecazBetrayalOffer(g, id,
|
||||
g.grummanCollection?.stage === 'waiting' && grummanCollectionAutomatic(g)),
|
||||
nexusRichese: nexusRicheseOffer(g,id),
|
||||
nexusRicheseCunning: nexusRicheseCunningOffer(g, id),
|
||||
nexusGuildSecretAlly: nexusGuildSecretAllyOffer(g,id),
|
||||
nexusGuildCunning: projectedNexusGuildCunning(g,id),
|
||||
nexusTraitors: projectedNexusTraitors(g, id),
|
||||
|
||||
@@ -22,6 +22,11 @@ export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action):
|
||||
const me = g.players.find(p => p.id === g.me)!;
|
||||
const to = String(action.territory);
|
||||
const amount = action.noField ? 1 : action.forces ? Object.values(action.forces as Record<string, number>).reduce((sum, n) => sum + n, 0) : Number(action.amount);
|
||||
if (action.revealedToken !== undefined) {
|
||||
const offer = g.nexusRicheseCunning;
|
||||
return action.noField !== undefined && !g.homeworlds && !!offer && !offer.blocked &&
|
||||
action.nexus === offer.event;
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -4044,6 +4044,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported; Duke Cunning remains unavailable.',
|
||||
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
|
||||
'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.',
|
||||
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.',
|
||||
'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.',
|
||||
|
||||
@@ -119,10 +119,10 @@ export function createRicheseNoField(ids: readonly string[]): RicheseNoField {
|
||||
}
|
||||
|
||||
/** Engine resolves authorization, shipment legality, payment and Karama first. */
|
||||
export function deployRicheseNoField(
|
||||
function validateDeployment(
|
||||
previous: RicheseNoField,
|
||||
deployment: NoFieldDeployment,
|
||||
): RicheseNoField {
|
||||
): void {
|
||||
validateRicheseNoField(previous);
|
||||
requireNoField(
|
||||
!previous.deployed,
|
||||
@@ -141,12 +141,71 @@ export function deployRicheseNoField(
|
||||
'Choose the No-Field force controller.',
|
||||
);
|
||||
validateLocation(deployment.location);
|
||||
}
|
||||
|
||||
export function deployRicheseNoField(
|
||||
previous: RicheseNoField,
|
||||
deployment: NoFieldDeployment,
|
||||
): RicheseNoField {
|
||||
validateDeployment(previous, deployment);
|
||||
const state = structuredClone(previous);
|
||||
state.lastShipped = deployment.tokenId;
|
||||
state.deployed = structuredClone(deployment);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate both identities and current reserves without allocating shipment
|
||||
* state. The token reference belongs to the caller's validated input state.
|
||||
*/
|
||||
export function validateRicheseNoFieldPair(
|
||||
previous: RicheseNoField,
|
||||
concealed: NoFieldDeployment,
|
||||
revealedTokenId: string,
|
||||
reserves: number,
|
||||
): NoFieldToken {
|
||||
validateDeployment(previous, concealed);
|
||||
requireNoField(
|
||||
Number.isSafeInteger(reserves) && reserves >= 0,
|
||||
'Available reserves must be a nonnegative safe integer.',
|
||||
);
|
||||
const token = previous.tokens.find((candidate) => candidate.id === revealedTokenId);
|
||||
requireNoField(token && token.id !== concealed.tokenId,
|
||||
'Choose a different physical No-Field token to reveal.');
|
||||
requireNoField(token.id !== previous.lastShipped,
|
||||
'The same No-Field cannot be shipped twice in a row.');
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ship two distinct tokens for one shipment. Only the concealed token remains
|
||||
* in marker custody; the other materializes immediately from available reserves.
|
||||
* Engine owns payment, board placement and Karama resolution.
|
||||
*/
|
||||
export function deployRicheseNoFieldPair(
|
||||
previous: RicheseNoField,
|
||||
concealed: NoFieldDeployment,
|
||||
revealedTokenId: string,
|
||||
reserves: number,
|
||||
): { state: RicheseNoField; revealed: NoFieldReveal } {
|
||||
const token = validateRicheseNoFieldPair(previous, concealed, revealedTokenId, reserves);
|
||||
const state = structuredClone(previous);
|
||||
state.lastShipped = concealed.tokenId;
|
||||
state.deployed = structuredClone(concealed);
|
||||
return {
|
||||
state,
|
||||
revealed: {
|
||||
state,
|
||||
tokenId: token.id,
|
||||
controller: concealed.controller,
|
||||
location: structuredClone(concealed.location),
|
||||
value: token.value,
|
||||
forces: Math.min(token.value, reserves),
|
||||
cause: 'voluntary',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Location/custody only; path, movement allowance and exposure belong to engine. */
|
||||
export function moveRicheseNoField(
|
||||
previous: RicheseNoField,
|
||||
|
||||
Reference in new issue
Block a user