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

+1 -1
View File
@@ -2304,7 +2304,7 @@ export function GameTable({
: g.decision.kind === 'moritaniAssassinate'
? 'Moritani · Assassinate Leaders'
: g.decision.kind === 'moritaniRetention'
? 'Moritani · allied card retention'
? g.decision.source === 'nexus' ? 'Moritani Nexus · after-loss choice' : 'Moritani · allied card retention'
: g.decision.kind === 'moritaniTerror'
? 'Moritani · Terror entry reaction'
: g.decision.kind === 'moritaniSetup'
+41 -20
View File
@@ -2,7 +2,6 @@
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { territory } from '@/game/board';
import { battleCardLabel } from '@/game/battle-cards';
import { Button } from './ui/button';
import { CardInspector, CardRules } from './card-inspector';
@@ -21,6 +20,11 @@ export function MoritaniRetention({
const decision = game.decision;
if (decision?.kind !== 'moritaniRetention' || decision.player !== game.me)
return null;
const nexus = decision.source === 'nexus';
const canKeep = !nexus || !!game.nexusMoritaniRetention?.canKeep;
const choose = (keep: string | null): Action => nexus
? { type: 'decision', event: decision.event, keep }
: { type: 'decision', keep };
const me = game.players.find((player) => player.id === game.me);
if (!me) return null;
const cards = (me.hand ?? []).filter((card) =>
@@ -28,23 +32,42 @@ export function MoritaniRetention({
);
const selectedCard = cards.find((card) => card.id === selected);
const owner = game.players.find((player) => player.id === decision.owner);
const location = game.combatLocations.find(place => place.id === decision.territory)?.name
?? decision.territory;
const disabled = busy || !!game.response || !!game.truthtrance;
return (
<section
className="min-w-0 space-y-4"
aria-label="Moritani allied card retention"
aria-label={nexus ? 'Postbattle Moritani Nexus choice' : 'Moritani allied card retention'}
>
<p>
You lost the battle in {territory(decision.territory).name}. Your
Moritani ally{owner ? `, ${owner.name},` : ''} lets you choose one
eligible card played in that battle to keep. Using this ability is
optional.
</p>
<p className="fine">
Declare one card, then the table may respond with Karama before you
retain it. If the power is canceled, all these cards are discarded.
Declining also discards them.
</p>
{nexus ? (
<>
<p>
You lost the battle in {location}. Every
loser with an eligible played card gets this choice. If you hold the
Moritani Nexus card while unallied, spend it to retain one played
card a winner could keep; otherwise continue.
</p>
<p className="fine">
Used cards with mandatory discard cannot be retained. Spending the
Nexus card is public; your other hand cards remain private.
</p>
</>
) : (
<>
<p>
You lost the battle in {location}. Your
Moritani ally{owner ? `, ${owner.name},` : ''} lets you choose one
eligible card played in that battle to keep. Using this ability is
optional.
</p>
<p className="fine">
Declare one card, then the table may respond with Karama before you
retain it. If the power is canceled, all these cards are discarded.
Declining also discards them.
</p>
</>
)}
<fieldset
className="m-0 min-w-0 space-y-3 border-0 p-0"
disabled={disabled}
@@ -85,20 +108,18 @@ export function MoritaniRetention({
)}
<Button
className="min-h-11 w-full whitespace-normal"
disabled={disabled || !selectedCard}
onClick={() =>
selectedCard && act({ type: 'decision', keep: selectedCard.id })
}
disabled={disabled || !selectedCard || !canKeep}
onClick={() => selectedCard && canKeep && act(choose(selectedCard.id))}
>
Declare selected card
{nexus ? 'Spend Moritani Nexus · retain selected card' : 'Declare selected card'}
</Button>
<Button
variant="outline"
className="min-h-11 w-full whitespace-normal"
disabled={disabled}
onClick={() => act({ type: 'decision', keep: null })}
onClick={() => act(choose(null))}
>
Decline retention
{nexus ? 'Continue without retention' : 'Decline retention'}
</Button>
</section>
);
+1 -1
View File
@@ -258,7 +258,7 @@ These links define bounded working behavior, not complete module certification.
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement and [Advanced Loyalty](ECAZ_LOYALTY.md), separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md) and [bounded retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation). Remaining bands and combinations are explicit in those contracts. |
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Orgiz transfers, Jacurutu income, Testing Station and Shrine. Orgiz's per-deposit interpretation, contested/shared benefits and mixed physical allocation remain provisional or pending. The Orgiz path in current source is not yet deployed. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Secret Ally trade and bounded after-victory inspection](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; the CHOAM universal winner window is a privacy-preserving product inference, not publisher text, and Emperor purchase remains pending. A source audit or helper alone is not a completed effect. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Secret Ally trade and bounded after-victory inspection](NEXUS_CHOAM_SECRET_ALLY.md), [Moritani Secret Ally losing-card retention](NEXUS_MORITANI_RULES.md#secret-ally-losing-card-retention-prototype) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected. Uniform CHOAM winner and Moritani loser windows are privacy-preserving product inferences, not publisher text; Emperor purchase remains pending. A source audit or helper alone is not a completed effect. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) connects the bounded battle return in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. |
| Multiplayer | [Public and private discussion](TABLE_DISCUSSION.md), saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), [named-player AI permission](SEAT_AI_PERMISSION.md) and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. |
+6
View File
@@ -1,5 +1,11 @@
# Dune implementation status
## 27 September 2026 — Bounded Moritani Nexus losing-card retention
The [Secret Ally prototype](NEXUS_MORITANI_RULES.md#secret-ally-losing-card-retention-prototype) now opens the same after-loss choice for every Nexus loser when Moritani is absent and at least one publicly played card can be retained. Only an unallied holder may spend the physical Moritani Nexus card to keep one eligible played card; nonholders continue without disclosing custody. Mandatory discards and the other played cards follow ordinary saved battle cleanup. Native allied retention and its Karama response remain separate. Real Basic/Advanced battles, all four legal AI profiles, CHOAM winner inspection ordering, JSON restoration, rejected-action immutability and concurrent in-memory SQLite commits passed seven focused cases. An independent review found a Homeworld location-label crash; the corrected panel renders the server's public combat location name. Local browser views displayed the Arrakis decision, retained private hand and corrected Homeworld decision through read-only intercepted room responses; no room was mutated.
The complete offline suite passed 5,565 tests across 698 files, the app build and scoped lint passed, and a full project TypeScript check excluding only the unrelated in-progress Nexus/Fremen draft passed. `npm run check` still stops at that draft's missing `Player.fremenNexusMovementBlockedTurn`; this checkpoint is not yet deployed. The uniform loser prompt is a privacy-preserving product inference, not publisher timing text, and complete Nexus/module acceptance remains gated.
## 27 September 2026 — Bounded CHOAM Nexus after-victory inspection
The [Secret Ally prototype](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) now preserves the losing combatant's physically used-card IDs and offers every Nexus winner the same post-cleanup decision when CHOAM is absent, without revealing who holds its card. An eligible unallied holder may spend the physical CHOAM Nexus card to sample exactly one unused opposing hand card; only that seat receives the card snapshot, while the public log omits its face. Basic/Advanced real battles, all four AI profiles, signed receipt recovery, paid Nullentropy interruption, subsequent battle/turn expiry, rejected-action immutability and concurrent in-memory SQLite commits passed focused cases. A local browser with a read-only intercepted room view displayed the live table decision and private inspector; no saved room was changed. This is a product privacy interpretation, not a publisher ruling or complete Nexus certification.
+15 -12
View File
@@ -86,16 +86,19 @@ No user question was sent during this audit. No tournament rule, fan effect, pub
## Moritani Secret Ally retention timing
14 September follow-up. The recorded physical card permits retaining one
otherwise retainable played Treachery Card after losing a battle with a winner;
mandatory discards stay excluded. The existing Moritani alliance retention
quote, card selection and saved discard continuation can supply the physical
operation, but do not supply a private opportunity to use a hidden Nexus card.
27 September follow-up. The printed card permits retaining one otherwise
retainable played Treachery Card after losing a battle with a winner; mandatory
discards stay excluded. The connected prototype opens the same after-loss choice
for every loser in a Nexus game without Moritani **when a publicly revealed
played card is eligible**. With no eligible card the opportunity is impossible
from public information, so no prompt appears regardless of concealed custody.
Automatically continuing a loser without the card while pausing a holder
reveals custody through the table's progress. Calling both paths the same
cleanup boundary does not hide that difference. Committing before the result
moves the printed after-loss choice earlier. A universal loser confirmation
requires the same pending product exception as other hidden optional reactions.
Keep this effect unavailable until that shared response policy is resolved;
no new outcome ruling or duplicate user question is introduced by this audit.
An unallied holder can then spend the physical Moritani Nexus card to retain
one such played card; nonholders continue without retaining one. The loser’s
other played cards use the existing saved battle-discard continuation. This
uniform prompt is a privacy-preserving product interpretation, not printed
timing text. A holder-only pause would expose card custody; an earlier
precommitment would remove the printed after-loss choice. The sources do not
establish a generic Karama response to this borrowed Secret Ally effect, so
none is invented. The native Moritani alliance retention response remains a
separate power.
+15
View File
@@ -73,3 +73,18 @@ cover actual placement custody, spent Nexus, rejected/stale actions, JSON view
continuation and hidden-face projection parity. Other Betrayal effects with
secret reaction timing, the unresolved Grumman removal, complete Nexus effects
and public module starts remain gated.
## Secret Ally losing-card retention prototype
The separate absent-Moritani panel now uses the native battle card's
retainability predicate but not Moritani's allied Karama response. A battle
winner and at least one publicly eligible played losing card trigger a uniform
loser choice in a Nexus game; no hidden-card-dependent pause is opened. The
unallied holder alone may spend the physical Moritani Nexus card to keep one
otherwise retainable card. Mandatory-discard cards leave normally, and the
other played cards use the saved public battle-discard continuation. The
receipt binds the resolved battle, event, loser and eligible physical IDs
through winner cleanup and JSON restore. Bots use the loser's private quote;
the table displays the same opportunity to nonholders. The source and
privacy timing interpretation is recorded in [the common audit](NEXUS_CARD_RULES.md#moritani-secret-ally-retention-timing).
This is a bounded prototype, not a complete Moritani/Nexus certification.
+1 -1
View File
@@ -83,7 +83,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Jacurutu physical dial | The user question asks whether the losing player should choose the physical normal/elite forces represented by its dial. When legal Advanced allocations produce different rewards, income remains explicitly unpaid; numeric wheel subtraction is not a ruling. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. The connected prototype chooses a uniform use-or-continue window for every winner when Nexus is enabled and CHOAM absent; nonholders pass, while a holder may decide after the result. This privacy-preserving prompt is a product inference, not publisher text. A holder-only pause would reveal custody and a precommitment would remove the printed choice. Ordinary Karama cancellation remains unestablished by retrieved sources; no response is invented. | [After-victory inspection boundary](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) |
| Moritani Secret Ally battle timing | The optional retention choice follows a battle loss while the Nexus remains secret until use. Automatically continuing nonholders but pausing holders exposes custody; earlier precommitment removes the after-loss choice. A universal loser confirmation needs the same pending UX exception as other hidden reactive cards. The existing alliance retention/cleanup code does not resolve this timing problem; no duplicate user question has been sent. | [Nexus retention boundary](NEXUS_CARD_RULES.md#moritani-secret-ally-retention-timing) |
| Moritani Secret Ally battle timing | The printed after-loss retention is optional and the Nexus card remains secret. The connected prototype offers the same choice to every loser when Moritani is absent and at least one publicly revealed played card is retainable; a loser with no eligible public card has no useful choice, independent of hidden custody. Only an unallied holder may spend the physical card. This prompt is a privacy-preserving product inference, not publisher timing text. Native Moritani alliance retention and its Karama response remain separate; no generic Secret Ally counter is invented. | [Nexus retention boundary](NEXUS_CARD_RULES.md#moritani-secret-ally-retention-timing) |
| Emperor Secret Ally purchase timing | The purchase choice follows winning the bid. A holder-only payment pause exposes concealed card eligibility, while per-bid preauthorization moves the choice earlier. The user has been asked whether to permit a uniform winner payment confirmation, including sole spice payments, or retain the purchase gate. No answer is assumed; the independent three-force revival grant can proceed. | [Purchase source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price) |
| Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
| Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above |
+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.',
+5
View File
@@ -67,6 +67,11 @@ function battle(holdCard: boolean, ids: [string, string, string] = ['p', 'q', 'r
game = applyAction(game, game.decision.player, { type: 'decision', discard: [] });
} else if (game.decision?.kind === 'battleLosses') {
game = applyAction(game, game.decision.player, { type: 'decision', choice: 0 });
} else if (game.decision?.kind === 'moritaniRetention' &&
game.decision.source === 'nexus') {
game = applyAction(game, game.decision.player, {
type: 'decision', event: game.decision.event, keep: null,
});
} else if (game.decision?.kind === 'captureOffer') {
game = applyAction(game, game.decision.player, { type: 'decision', accept: false });
} else break;
+241
View File
@@ -0,0 +1,241 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { MoritaniRetention } from '../components/moritani-retention';
import { nexusTraitorFixture, nexusTraitorInventory } from './fixture-nexus-traitors';
import { unitStore } from './fixture-nexus-room-store';
import type { RoomsClock } from '../db/rooms';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
function battle(holdCard: boolean, advanced = false, playedCard = true,
ids: [string, string, string] = ['p', 'q', 'r'], choamWinner = false) {
const [winnerId, loserId, observerId] = ids;
let game = nexusTraitorFixture({ ownerFaction: 'harkonnen', phase: 6, seatIds: ids, advanced });
const cards = game.nexusCards!.cards!;
if (holdCard) {
const index = cards.deck.indexOf('moritani');
assert.ok(index >= 0);
const previous = cards.hands[loserId];
if (previous) cards.deck[index] = previous;
else cards.deck.splice(index, 1);
cards.hands[loserId] = 'moritani';
}
if (choamWinner) {
const index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[winnerId]!;
cards.hands[winnerId] = 'choam';
}
Object.assign(game, { active: winnerId, order: ids, phase: 6, storm: 18 });
for (const player of game.players) {
player.forces = player.id === observerId ? {} : { 'arrakeen:10': 10 };
player.reserves = player.id === observerId ? 20 : 10;
player.hand = [];
player.spice = 20;
}
const loser = seat(game, loserId);
const used = game.deck.splice(game.deck.findIndex(card => card.kind === 'projectile'), 1)[0];
const unused = game.deck.splice(game.deck.findIndex(card => card.kind === 'worthless'), 1)[0];
loser.hand.push(used, unused);
nexusTraitorInventory(game);
game = applyAction(game, winnerId, { type: 'chooseBattle', territory: 'arrakeen', target: loserId });
for (let i = 0; i < 30; i++) {
if (game.response) {
const responder = game.players.find(player => !game.response!.passed.includes(player.id))!;
game = applyAction(game, responder.id, { type: 'passResponse' });
} else if (game.battle?.preLeader && !game.battle.preLeader.closed) {
const responder = [game.battle.attacker, game.battle.defender]
.find(id => !game.battle!.preLeader!.ready.includes(id))!;
game = applyAction(game, responder, { type: 'battlePreparationReady', event: game.battle.event });
} else if (game.battle?.preparation) {
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
} else if (game.decision?.kind === 'fullPlanOffer') {
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
} else break;
}
assert.ok(game.battle);
const best = seat(game, winnerId).leaders.reduce((a, b) => a.strength > b.strength ? a : b);
const weakest = seat(game, loserId).leaders.reduce((a, b) => a.strength < b.strength ? a : b);
game = applyAction(game, winnerId, { type: 'battlePlan', leader: best.id, dial: 6, support: advanced ? 6 : 0 });
game = applyAction(game, loserId, { type: 'battlePlan', leader: weakest.id, dial: 0, support: 0,
...(playedCard ? { weapon: used.id } : {}) });
for (const id of [winnerId, loserId]) game = applyAction(game, id, { type: 'traitorCall', call: false });
for (let i = 0; i < 40; i++) {
if (game.response) {
const responder = game.players.find(player => !game.response!.passed.includes(player.id))!;
game = applyAction(game, responder.id, { type: 'passResponse' });
} else if (game.decision?.kind === 'battleCards') {
game = applyAction(game, game.decision.player, { type: 'decision', discard: [] });
} else if (game.decision?.kind === 'battleLosses') {
game = applyAction(game, game.decision.player, { type: 'decision', choice: 0 });
} else if (game.decision?.kind === 'captureOffer') {
game = applyAction(game, game.decision.player, { type: 'decision', accept: false });
} else break;
}
assert.equal(game.lastBattleContext?.winner, winnerId);
if (playedCard) {
assert.equal(game.decision?.kind, 'moritaniRetention');
assert.equal(game.moritaniRetention?.source, 'nexus');
} else {
assert.equal(game.moritaniRetention ?? null, null);
assert.equal(game.decision?.kind, 'nexusChoamInspection');
}
return { game, used, unused };
}
void test('every losing seat receives the same source-bound window, even without the hidden card', () => {
for (const holdCard of [false, true]) {
const { game, used, unused } = battle(holdCard);
const event = game.lastBattleContext!.event;
assert.equal(viewGame(game, 'q').nexusMoritaniRetention?.canKeep, holdCard);
assert.equal(viewGame(game, 'p').nexusMoritaniRetention, null);
assert.equal(viewGame(game, 'r').nexusMoritaniRetention, null);
const before = reload(game);
assert.throws(() => applyAction(game, 'p', { type: 'decision', event, keep: used.id }));
assert.throws(() => applyAction(game, 'q', { type: 'decision', event: 'stale', keep: null }));
assert.throws(() => applyAction(game, 'q', { type: 'decision', event, keep: unused.id }));
if (!holdCard) assert.throws(() => applyAction(game, 'q', { type: 'decision', event, keep: used.id }));
assert.deepEqual(game, before);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(game, 'q');
view.players.find(player => player.id === 'q')!.bot = difficulty;
assert.deepEqual(botActions(view)[0], { type: 'decision', event, keep: holdCard ? used.id : null });
}
const passed = applyAction(game, 'q', { type: 'decision', event, keep: null });
assert.equal(passed.discard.filter(card => card.id === used.id).length, 1);
assert.ok(seat(passed, 'q').hand.some(card => card.id === unused.id));
assert.equal(passed.nexusCards!.cards!.hands.q, holdCard ? 'moritani' : before.nexusCards!.cards!.hands.q);
assert.deepEqual(normalizeAutomaticGame(reload(passed)), passed);
nexusTraitorInventory(passed);
}
});
void test('a losing holder spends Moritani Nexus to retain one otherwise retainable physical played card', () => {
for (const advanced of [false, true]) {
const { game, used, unused } = battle(true, advanced);
const event = game.lastBattleContext!.event;
const retained = applyAction(game, 'q', { type: 'decision', event, keep: used.id });
assert.equal(retained.nexusCards!.cards!.hands.q, null);
assert.ok(retained.nexusCards!.cards!.discard.includes('moritani'));
assert.ok(seat(retained, 'q').hand.some(card => card.id === used.id));
assert.ok(seat(retained, 'q').hand.some(card => card.id === unused.id));
assert.equal(retained.discard.some(card => card.id === used.id), false);
assert.equal(retained.lastBattleContext?.nexusMoritaniRetention?.kept, used.id);
assert.equal(retained.log.filter(entry => entry.text.includes('Moritani Nexus Secret Ally')).length, 1);
assert.deepEqual(normalizeAutomaticGame(reload(retained)), retained);
assert.throws(() => applyAction(retained, 'q', { type: 'decision', event, keep: used.id }));
nexusTraitorInventory(retained);
}
});
void test('a CHOAM winner cannot inspect the losing card retained with Moritani Nexus', () => {
const { game, used, unused } = battle(true, false, true, ['p', 'q', 'r'], true);
const event = game.lastBattleContext!.event;
const retained = applyAction(game, 'q', { type: 'decision', event, keep: used.id });
assert.equal(retained.decision?.kind, 'nexusChoamInspection');
assert.equal(viewGame(retained, 'p').nexusChoamInspection?.canInspect, true);
const inspected = applyAction(retained, 'p', { type: 'decision', event, inspect: true });
assert.equal(inspected.nexusChoamInsight?.card.id, unused.id);
assert.ok(seat(inspected, 'q').hand.some(card => card.id === used.id));
nexusTraitorInventory(inspected);
});
void test('no publicly eligible played card skips the loser prompt without exposing hidden custody', () => {
const { game, used } = battle(true, false, false);
assert.equal(game.lastBattleContext?.nexusMoritaniRetention, undefined);
assert.equal(viewGame(game, 'q').nexusMoritaniRetention, null);
const event = game.lastBattleContext!.event;
assert.throws(() => applyAction(game, 'q', { type: 'decision', event, keep: used.id }));
const done = applyAction(game, 'p', { type: 'decision', event, inspect: false });
assert.equal(done.nexusCards!.cards!.hands.q, 'moritani');
assert.ok(seat(done, 'q').hand.some(card => card.id === used.id));
});
void test('loser controls expose no concealed Nexus eligibility to a rival', () => {
const { game, used, unused } = battle(true);
const owner = viewGame(game, 'q');
const markup = renderToStaticMarkup(createElement(MoritaniRetention, {
game: owner, busy: false, act() {},
}));
assert.match(markup, /Spend Moritani Nexus/);
assert.match(markup, /Continue without retention/);
assert.ok(markup.includes(used.name));
assert.equal(markup.includes(unused.name), false);
assert.equal(renderToStaticMarkup(createElement(MoritaniRetention, {
game: viewGame(game, 'p'), busy: false, act() {},
})), '');
const without = renderToStaticMarkup(createElement(MoritaniRetention, {
game: viewGame(battle(false).game, 'q'), busy: false, act() {},
}));
assert.equal(without.includes('Spend Moritani Nexus · retain selected card'), true);
assert.match(without, /disabled=""/);
});
void test('the losing choice renders a public Homeworld battle name without an Arrakis lookup', () => {
const { game } = battle(true);
const view = viewGame(game, 'q');
assert.equal(view.decision?.kind, 'moritaniRetention');
if (view.decision?.kind !== 'moritaniRetention') return;
view.decision.territory = 'homeworld:atreides';
view.combatLocations.push({ id: 'homeworld:atreides', name: 'Caladan', kind: 'homeworld' });
const markup = renderToStaticMarkup(createElement(MoritaniRetention, {
game: view, busy: false, act() {},
}));
assert.match(markup, /You lost the battle in Caladan/);
assert.match(markup, /Spend Moritani Nexus/);
});
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('competing saved loser choices commit one physical spend and restore each private seat', async () => {
const store = unitStore();
try {
const made = await store.rooms.createRoom('Moritani Nexus retention SQL', 'harkonnen', false, []);
const code = made.view.code;
const tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const ids = auths.map(auth => auth.playerId) as [string, string, string];
const { game, used } = battle(true, false, true, ids);
game.code = code;
game.version = (await store.rooms.readRoom(code)).version;
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, code);
const seats = store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all();
const action = { type: 'decision', event: game.lastBattleContext!.event, keep: used.id };
let arrivals = 0, release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
const timer = setTimeout(release, 2000);
let results: PromiseSettledResult<unknown>[];
try {
results = await Promise.allSettled([
store.restart().act(code, auths[1], game.version, action, clock),
store.restart().act(code, auths[1], game.version, action, clock),
]);
} finally { clearTimeout(timer); delete store.hooks.beforeWrite; }
assert.equal(arrivals, 2);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
const done = await store.restart().readRoom(code);
assert.equal(done.version, game.version + 1);
assert.equal(done.nexusCards!.cards!.hands[ids[1]], null);
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'moritani').length, 1);
assert.ok(seat(done, ids[1]).hand.some(card => card.id === used.id));
assert.equal(done.log.filter(entry => entry.text.includes('Moritani Nexus Secret Ally')).length, 1);
const settled = JSON.stringify(done);
await assert.rejects(store.restart().act(code, auths[1], done.version, action, clock));
assert.equal(JSON.stringify(await store.restart().readRoom(code)), settled);
for (const [index, token] of tokens.entries()) {
const auth = await store.restart().authenticate(code, token);
assert.deepEqual(await store.restart().readSeatView(code, auth), viewGame(done, ids[index]));
assert.equal((await store.restart().readSeatView(code, auth)).nexusMoritaniRetention, null);
}
assert.deepEqual(store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
nexusTraitorInventory(done);
} finally { store.sqlite.close(); }
});