Connect Moritani Nexus losing-card retention

This commit is contained in:
Quintine committed 2026-09-28 11:13:39 +10:00
1 parent cb98ead411
commit 886a8f4478
16 files changed
+551 -51

No files matched your search

+16 -7
View File
@@ -92,6 +92,8 @@ export type AftermathNext =
player: string;
territory: string;
cards: string[];
source?: 'nexus';
event?: string;
}
| { kind: 'choamBattleIncome'; owner: string; amount: number }
| { kind: 'techToken'; player: string; loser: string; choices: TechId[] }
@@ -264,27 +266,32 @@ function calculate(input: BattleAftermathInput): BattleAftermathQuote {
);
}
function retention(value: unknown): MoritaniRetention {
const nexus = record(value) && value.source === 'nexus';
requireAftermath(
record(value) &&
value.stage === 'choose' &&
whole(value.turn) &&
value.turn === input.turn &&
ids(value.played) &&
value.played.length > 0 &&
(nexus || value.played.length > 0) &&
ids(value.eligible) &&
value.eligible.length > 0 &&
(nexus || value.eligible.length > 0) &&
value.eligible.every((card) =>
(value.played as string[]).includes(card),
),
'Resolve the alliance retention response first; its saved cleanup must be current.',
'Resolve the battle card retention response first; its saved cleanup must be current.',
);
const owner = player(value.owner),
loser = player(value.player);
requireAftermath(
owner.faction === 'moritani' &&
owner.id !== loser.id &&
Array.isArray(loser.hand),
'This alliance cleanup needs its Moritani owner and defeated ally.',
Array.isArray(loser.hand) &&
(nexus
? owner.id === loser.id &&
context.kind === 'modern' &&
value.event === context.receipt.event &&
!input.players.some((p) => p.faction === 'moritani')
: owner.faction === 'moritani' && owner.id !== loser.id),
'This card cleanup needs its original loser and source.',
);
const to = territory(value.territory);
winner(other(loser.id));
@@ -297,6 +304,7 @@ function calculate(input: BattleAftermathInput): BattleAftermathQuote {
played: [...value.played],
eligible: [...value.eligible],
stage: 'choose',
...(nexus ? { source: 'nexus' as const, event: value.event as string } : {}),
};
}
function income(value: unknown) {
@@ -511,6 +519,7 @@ function calculate(input: BattleAftermathInput): BattleAftermathQuote {
player: p.player,
territory: p.territory,
cards: [...p.eligible],
...(p.source === 'nexus' ? { source: 'nexus', event: p.event } : {}),
});
}
if (present(pending.income))
+19 -1
View File
@@ -120,6 +120,8 @@ export type BattleResolutionInput = {
physicalCards: readonly { id: string }[];
pendingAuditorPresent: boolean;
pendingRetentionPresent: boolean;
/** Open the same loser window regardless of private Moritani Nexus custody. */
nexusMoritani?: boolean;
};
export type BattleSupportPayment = {
player: string;
@@ -755,7 +757,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
const mandatorySkillDiscard = (side: ResolutionCombatant, selected: string) =>
mandatoryPlanetologistDiscard(side, selected) || side.diplomatDefense?.card === selected || isHarassWithdraw(card(side, selected));
const moritani = input.participants.find((p) => p.faction === 'moritani');
if (winner && moritani) {
if (winner && (moritani || input.nexusMoritani)) {
const loser = winner === a ? d : a,
loserPlayed = loser === a ? played.attacker : played.defender;
const retainablePlayed = loserPlayed.filter(
@@ -770,6 +772,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
),
);
if (
moritani &&
loser.id !== moritani.id &&
loser.ally === moritani.id &&
moritani.ally === loser.id &&
@@ -788,6 +791,21 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
eligible,
stage: 'choose',
};
} else if (!moritani && input.nexusMoritani && eligible.length) {
requireQuote(
!input.pendingRetentionPresent,
'Finish the previous battle card cleanup first.',
);
retention = {
owner: loser.id,
player: loser.id,
territory: input.territory,
turn: input.turn,
played: [...retainablePlayed],
eligible,
source: 'nexus',
stage: 'choose',
};
}
}
const discarded = [a, d].flatMap((s) =>
+4 -1
View File
@@ -1676,7 +1676,10 @@ function policyActions(g: GameView): Action[] {
const candidates = (me.hand ?? [])
.filter((card) => d.cards.includes(card.id))
.sort((a, b) => technologyCardValue(g, b) - technologyCardValue(g, a));
return [{ type: 'decision', keep: candidates[0]?.id ?? null }];
return [d.source === 'nexus'
? { type: 'decision', event: d.event,
keep: g.nexusMoritaniRetention?.canKeep ? candidates[0]?.id ?? null : null }
: { type: 'decision', keep: candidates[0]?.id ?? null }];
}
if (d.kind === 'moritaniTerror') {
const entry = g.terrorEntry;
+144 -7
View File
@@ -34,6 +34,7 @@ import { quoteJacurutuBattleIncome } from './discovery-battle';
import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type GreatMaker } from './great-maker';
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
import { quoteNexusChoamInspection, sampleNexusChoamInspection, nexusChoamInspectionSignature, type NexusChoamInspectionReceipt } from './nexus-choam-inspection';
import { quoteNexusMoritaniRetention, nexusMoritaniRetentionSignature, type NexusMoritaniRetentionReceipt } from './nexus-moritani-retention';
import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal';
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';
@@ -886,6 +887,8 @@ export type Decision =
player: string;
territory: string;
cards: string[];
source?: 'nexus';
event?: string;
}
| { kind: 'battleCards'; player: string; territory: string; cards: string[] };
export type ResponseWindow = {
@@ -1681,6 +1684,7 @@ export type Game = {
nexusSardaukarCasualties?: string;
moritaniAssassinate?: {event:string;signature:string;continuation?:string};
nexusChoamInspection?: NexusChoamInspectionReceipt;
nexusMoritaniRetention?: NexusMoritaniRetentionReceipt;
} | null;
auction: Auction | null;
battle: Battle | null;
@@ -1839,6 +1843,71 @@ function nexusChoamInspectionIntegrity(g: Game) {
g.nexusChoamInsight.card.id === receipt.inspected,
'The private CHOAM Nexus inspection lost its sampled card.');
}
function nexusMoritaniDecision(
decision: Game['decision'] | undefined,
): Extract<Decision, { kind: 'moritaniRetention' }> | null {
return decision?.kind === 'moritaniRetention' && decision.source === 'nexus'
? decision : null;
}
function nexusMoritaniRetentionIntegrity(g: Game) {
const context = g.lastBattleContext;
const receipt = context?.nexusMoritaniRetention;
const pending = g.moritaniRetention?.source === 'nexus' ? g.moritaniRetention : null;
const continuation = g.pendingTreacheryDiscard?.continuation;
const active = nexusMoritaniDecision(g.decision);
const exchange = nexusMoritaniDecision(g.pendingExchange?.decision);
const nullentropy = nexusMoritaniDecision(g.pendingNullentropy?.resume.decision);
const gift = nexusMoritaniDecision(g.pendingRicheseGift?.resume.decision);
const purchase = nexusMoritaniDecision(g.pendingRichesePurchaseIncome?.resume.decision);
const worm = nexusMoritaniDecision(g.summonedWorm?.resume.decision);
const discard = nexusMoritaniDecision(
continuation && 'resume' in continuation ? continuation.resume.decision : null,
);
const count = Number(!!active) + Number(!!exchange) + Number(!!nullentropy) +
Number(!!gift) + Number(!!purchase) + Number(!!worm) + Number(!!discard);
const decision = active ?? exchange ?? nullentropy ?? gift ?? purchase ?? worm ?? discard;
if (!receipt) {
requireRule(!pending && count === 0, 'The Moritani Nexus cleanup lost its battle.');
return;
}
requireRule(
!!g.nexusCards?.cards && !!context?.winner && context.result !== 'legacy' &&
!byFaction(g, 'moritani') && context.event === receipt.event &&
context.combatants.includes(receipt.player) &&
context.winner !== receipt.player &&
Array.isArray(receipt.played) && Array.isArray(receipt.eligible) &&
new Set(receipt.played).size === receipt.played.length &&
new Set(receipt.eligible).size === receipt.eligible.length &&
receipt.played.every(id => typeof id === 'string' && !!id) &&
receipt.eligible.every(id => receipt.played.includes(id)) &&
['pending', 'offer', 'complete'].includes(receipt.stage) &&
receipt.signature === nexusMoritaniRetentionSignature(receipt),
'The Moritani Nexus retention lost its physical losing battle.',
);
if (receipt.stage === 'complete') {
requireRule(!pending && count === 0 &&
(receipt.kept === null || receipt.eligible.includes(receipt.kept!)),
'The Moritani Nexus retention reopened after cleanup.');
return;
}
requireRule(
g.phase === 6 && !g.battle && !!pending &&
pending.stage === 'choose' && pending.event === receipt.event &&
pending.owner === receipt.player && pending.player === receipt.player &&
pending.turn === context.turn && pending.territory === context.territory &&
JSON.stringify(pending.played) === JSON.stringify(receipt.played) &&
JSON.stringify(pending.eligible) === JSON.stringify(receipt.eligible) &&
pending.played.every(id => getPlayer(g, pending.player).hand.some(card => card.id === id)),
'The Moritani Nexus retention lost its reserved battle cards.',
);
if (receipt.stage === 'offer')
requireRule(count === 1 && decision?.player === receipt.player &&
decision.event === receipt.event &&
JSON.stringify(decision.cards) === JSON.stringify(receipt.eligible),
'The Moritani Nexus offer lost its original loser.');
else requireRule(count === 0, 'Finish earlier battle cleanup before Moritani Nexus retention.');
}
function retireNexusChoamInsight(g: Game) {
g.nexusChoamInsight = null;
if (g.lastBattleContext?.nexusChoamInspection?.stage === 'complete')
@@ -5025,10 +5094,16 @@ function treacheryDiscardIntegrity(g: Game) {
r.turn === g.turn &&
r.territory === c.territory &&
r.player === c.player &&
owner?.faction === 'moritani' &&
owner.ally === c.player &&
loser?.ally === owner.id &&
['choose', 'response'].includes(r.stage) &&
(r.source === 'nexus'
? r.owner === c.player && r.event === context.event &&
!byFaction(g, 'moritani') &&
context.nexusMoritaniRetention?.stage === 'complete' &&
context.nexusMoritaniRetention.kept === (c.kept[0] ?? null) &&
!!g.nexusCards?.cards &&
(!c.kept.length || g.nexusCards.cards.discard.includes('moritani'))
: owner?.faction === 'moritani' &&
owner.ally === c.player && loser?.ally === owner.id &&
['choose', 'response'].includes(r.stage)) &&
JSON.stringify(r.played) === JSON.stringify(c.played) &&
Array.isArray(r.eligible) &&
new Set(r.eligible).size === r.eligible.length &&
@@ -16391,6 +16466,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
physicalCards: physicalTreacheryCards(g),
pendingAuditorPresent: !!g.pendingAuditor,
pendingRetentionPresent: !!g.moritaniRetention,
nexusMoritani: !!g.nexusCards?.cards && !byFaction(g, 'moritani'),
});
for (const withdrawal of quote.harassWithdraw ?? [])
validateReserveReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned);
@@ -16835,6 +16911,21 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
}
: null;
if (inspection) inspection.signature = nexusChoamInspectionSignature(inspection);
const nexusRetention: NexusMoritaniRetentionReceipt | null =
quote.retention?.source === 'nexus'
? {
event: battleEvent,
player: quote.retention.player,
played: [...quote.retention.played],
eligible: [...quote.retention.eligible],
stage: 'pending',
signature: '',
}
: null;
if (nexusRetention) {
nexusRetention.signature = nexusMoritaniRetentionSignature(nexusRetention);
g.moritaniRetention!.event = battleEvent;
}
g.lastBattleContext = {
event: battleEvent,
turn: g.turn,
@@ -16845,6 +16936,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
...(playedCardRoles ? { cardRoles: playedCardRoles } : {}),
...(sardaukar?.casualties ? {nexusSardaukarCasualties:sardaukar.receipt.event} : {}),
...(inspection ? { nexusChoamInspection: inspection } : {}),
...(nexusRetention ? { nexusMoritaniRetention: nexusRetention } : {}),
};
recordMoritaniAssassinateOpportunity(g, b, winner, losingPlayer);
if (quote.smuggler) {
@@ -17579,6 +17671,14 @@ function finishBattle(g: Game) {
};
return;
}
if (next.kind === 'moritaniRetention' && next.source === 'nexus') {
const receipt = g.lastBattleContext?.nexusMoritaniRetention;
requireRule(!!receipt && receipt.event === next.event &&
['pending', 'offer'].includes(receipt.stage),
'The Moritani Nexus loser window lost its battle.');
receipt.stage = 'offer';
receipt.signature = nexusMoritaniRetentionSignature(receipt);
}
if (next.kind === 'choamBattleIncome') {
g.response = { ...next, passed: [] };
} else if (next.kind === 'choamAudit') {
@@ -17805,7 +17905,7 @@ function finishMoritaniRetention(g: Game, keep: string | null) {
validateMoritaniCancellation(g, pending.owner);
requireRule(
pending && pending.turn === g.turn && g.phase === 6,
'This alliance card cleanup is no longer current.',
'This battle card cleanup is no longer current.',
);
const player = getPlayer(g, pending.player);
requireRule(
@@ -17816,6 +17916,26 @@ function finishMoritaniRetention(g: Game, keep: string | null) {
pending.played.every((id) => player.hand.some((c) => c.id === id)),
'Cards committed to alliance cleanup must remain available.',
);
if (pending.source === 'nexus') {
const receipt = g.lastBattleContext?.nexusMoritaniRetention;
requireRule(receipt?.stage === 'offer' && receipt.event === pending.event &&
receipt.player === player.id && pending.stage === 'choose',
'Finish the original losing battle before using Moritani Nexus.');
if (keep !== null) {
const offer = quoteNexusMoritaniRetention(g, player.id, {
kind: 'moritaniRetention', owner: pending.owner, player: player.id,
territory: pending.territory, cards: pending.eligible,
source: 'nexus', event: pending.event,
});
requireRule(offer?.canKeep,
'Moritani Secret Ally needs its unallied holder and a retainable played card.');
g.nexusCards!.cards = nexusRule(() =>
discardNexusCard(g.nexusCards!.cards!, player.id, g.players));
}
receipt.kept = keep;
receipt.stage = 'complete';
receipt.signature = nexusMoritaniRetentionSignature(receipt);
}
const discarded = pending.played
.filter((id) => id !== keep)
.map((id) => ({
@@ -17826,8 +17946,8 @@ function finishMoritaniRetention(g: Game, keep: string | null) {
log(
g,
keep
? `${player.name} retained ${cardOf(player, keep)!.name} through the Moritani alliance.`
: `${player.name} retained no battle card through the Moritani alliance.`,
? `${player.name} retained ${cardOf(player, keep)!.name} through ${pending.source === 'nexus' ? 'Moritani Nexus Secret Ally' : 'the Moritani alliance'}.`
: `${player.name} retained no battle card through ${pending.source === 'nexus' ? 'the postbattle Nexus choice' : 'the Moritani alliance'}.`,
);
g.moritaniRetention = null;
const context = discarded.length
@@ -21513,6 +21633,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
nexusChoamInspectionIntegrity(state);
nexusMoritaniRetentionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
@@ -21673,6 +21794,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
greatMakerIntegrity(g);
nexusChoamTradeIntegrity(g);
nexusChoamInspectionIntegrity(g);
nexusMoritaniRetentionIntegrity(g);
nexusChoamBetrayalIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
@@ -21787,6 +21909,7 @@ export function normalizeAutomaticGame(state: Game): Game {
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
nexusChoamInspectionIntegrity(state);
nexusMoritaniRetentionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
@@ -21819,6 +21942,7 @@ export function normalizeAutomaticGame(state: Game): Game {
greatMakerIntegrity(g);
nexusChoamTradeIntegrity(g);
nexusChoamInspectionIntegrity(g);
nexusMoritaniRetentionIntegrity(g);
nexusChoamBetrayalIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
@@ -22348,6 +22472,17 @@ function applyActionInner(
p.hand.some((c) => c.id === action.keep)),
'Keep one eligible played card, or explicitly keep none.',
);
requireRule(decision.source === pending.source,
'The battle-card retention source changed.');
if (pending.source === 'nexus') {
const offer = quoteNexusMoritaniRetention(g, id, decision);
requireRule(offer?.event === action.event &&
Object.keys(action).every(key => ['type', 'event', 'keep'].includes(key)) &&
(action.keep === null || offer?.canKeep === true),
'Choose whether to spend Moritani Nexus after this loss.');
finishMoritaniRetention(g, action.keep as string | null);
return g;
}
if (action.keep === null) finishMoritaniRetention(g, null);
else {
pending.keep = action.keep as string;
@@ -25577,6 +25712,7 @@ export function viewGame(state: Game, id: string) {
greatMakerIntegrity(state);
nexusChoamTradeIntegrity(state);
nexusChoamInspectionIntegrity(state);
nexusMoritaniRetentionIntegrity(state);
nexusChoamBetrayalIntegrity(state);
marketGholaIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
@@ -26130,6 +26266,7 @@ export function viewGame(state: Game, id: string) {
},
moritaniAssassinate: projectedMoritaniAssassinate(g,id),
moritaniRetention: g.moritaniRetention ?? null,
nexusMoritaniRetention: quoteNexusMoritaniRetention(g, id),
moritaniRetentionCard: g.moritaniRetention?.keep
? (cardOf(
getPlayer(g, g.moritaniRetention.player),
+3
View File
@@ -23,6 +23,9 @@ export type MoritaniRetention = {
eligible: string[];
stage: 'choose' | 'response';
keep?: string;
/** A uniform, hidden-card-independent losing window when Moritani is absent. */
source?: 'nexus';
event?: string;
};
export function retentionReservesCard(
+38
View File
@@ -0,0 +1,38 @@
import type { Game } from './engine';
import { nexusCardMode } from './nexus-cards';
/** The public offer is identical for every loser; only the loser sees eligibility. */
export type NexusMoritaniRetentionReceipt = {
event: string;
player: string;
played: string[];
eligible: string[];
stage: 'pending' | 'offer' | 'complete';
kept?: string | null;
signature: string;
};
export function nexusMoritaniRetentionSignature(
receipt: NexusMoritaniRetentionReceipt,
): string {
return JSON.stringify([
receipt.event, receipt.player, receipt.played, receipt.eligible,
receipt.stage, receipt.kept ?? null,
]);
}
export function quoteNexusMoritaniRetention(
game: Game, viewer: string, decision: Game['decision'] = game.decision,
) {
const pending = game.moritaniRetention;
if (pending?.source !== 'nexus' || pending.stage !== 'choose' ||
decision?.kind !== 'moritaniRetention' || decision.player !== viewer ||
pending.player !== viewer || pending.event !== decision.event)
return null;
const owner = game.players.find(player => player.id === viewer)!;
const canKeep = !owner.ally &&
game.nexusCards?.cards?.hands[viewer] === 'moritani' &&
nexusCardMode('moritani', owner.faction, game.players.map(player => player.faction)) === 'secretAlly' &&
pending.eligible.length > 0;
return { event: pending.event, canKeep };
}
+1
View File
@@ -4040,6 +4040,7 @@ 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.',
'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.',
'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.',
'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 and Secret Ally inquiry remain 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.',