Connect Emperor Nexus compulsory funding to native auction settlement

This commit is contained in:
Quintine committed 2026-10-15 06:38:14 +11:00
1 parent 25996ec8a7
commit 022040972c
18 files changed
+1291 -16

No files matched your search

+3
View File
@@ -236,6 +236,7 @@ import { MoritaniRetention } from './moritani-retention';
import { MoritaniAssassinate, MoritaniAssassinateHistory } from './moritani-assassinate';
import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion';
import { AdvancedAllySeparation } from './advanced-ally-separation';
import { NexusEmperorPayment } from './nexus-emperor-payment';
export function GameTable({
game: g,
send,
@@ -3504,6 +3505,8 @@ export function GameTable({
</div>
))}
</>
) : g.decision.kind === 'nexusEmperorPayment' ? (
<NexusEmperorPayment game={g} send={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
) : g.decision.kind === 'auctionPayment' ? (
<>
<p className="muted">
+47
View File
@@ -0,0 +1,47 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
export function NexusEmperorPayment({ game, send, busy }: {
game: GameView;
send: (action: Action) => void;
busy: boolean;
}) {
const decision = game.decision;
if (decision?.kind !== 'nexusEmperorPayment' || decision.player !== game.me) return null;
const own = game.players.find(player => player.id === game.me);
const offer = game.nexusEmperorPayment;
const emperor = offer && game.players.find(player => player.id === offer.emperor);
const winner = offer && game.players.find(player => player.id === offer.winner);
const disabled = busy || game.status !== 'playing' || game.phase !== 3 ||
game.automaticContinuationPending || !!game.phaseOpening || !!game.response ||
!!game.truthtrance || !!game.nexusTraitors?.pending ||
!!game.roomControl?.paused || !!game.roomControl?.closed || !!own?.autopilot;
const canUse = !!offer && offer.event === decision.event && offer.canUse && !offer.blocked;
return (
<section aria-label="Auction payment acknowledgement" className="notice min-w-0 space-y-3 break-words">
<h3>Auction payment acknowledgement</h3>
<p>Continue to allow the original winning bid to proceed. Every publicly possible unallied Nexus holder
acknowledges this payment, regardless of their private card.</p>
{offer && <>
<h4>Emperor Nexus · Betrayal</h4>
<p>Force {emperor?.name ?? offer.emperor} to cover the full {offer.price}-spice price of
{' '}{winner?.name ?? offer.winner}’s original winning Treachery Card bid.</p>
<p className="fine">Provisional interpretation, not a publisher ruling:
{' '}{offer.policy} Your Nexus Card is spent. The buyer keeps their own spice.
Original Emperor income may still be canceled without refunding the funded purchase.</p>
{offer.blocked && <output className="fine block">{offer.blocked}</output>}
</>}
<Button className="game-action min-h-11 whitespace-normal" disabled={disabled}
onClick={() => { if (!disabled) send({ type: 'nexusEmperorPayment', event: decision.event, use: false }); }}>
Continue
</Button>
{canUse && <Button className="game-action min-h-11 whitespace-normal" disabled={disabled}
onClick={() => { if (!disabled) send({ type: 'nexusEmperorPayment', event: decision.event, use: true }); }}>
Use Emperor Nexus · force full-price payment
</Button>}
</section>
);
}
+16
View File
@@ -29,6 +29,22 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Emperor Nexus compulsory original purchase — 14 October.** The conservative
provisional full-price policy now connects normal-auction Betrayal before
winner payment, with neutral public held-Nexus storm-order acknowledgements.
One actual card forces native Emperor's full original allied obligation,
reusing prepaid aid and adding only missing own spice. Original acquisition,
income allow/cancel and next-lot inspection continue; all-pass keeps ordinary
payment. Truthtrance's original discard restores the same source event.
Affected10-file union155/155, types/lint/build pass. Four API profiles and
actual390px neutral/Use/allow/cancel preserve buyer6, prepaid1/missing1,
one purchased card and one Nexus; original Emperor income10 or canceled8
restores next lot1 with no refund, overflow or browser errors.
Special seller lots, full combinations, assurance and deployed acceptance remain.
[Source-bound policy and proof](NEXUS_EMPEROR_RULES.md#recommended-original-full-price-funding--14-october-2026).
Owned QA scaffolding is removed; API/phone proofs remain outside Git.
Final build retains existing route-classification/chunk-size warnings.
**Fremen Secret Ally ordinary free quota — 14 October.** Recommended
provisional fixed-three permission now replaces the early-only exact-three
transfer. Activation spends one Nexus and moves nothing; original revival
+32
View File
@@ -1,5 +1,37 @@
# Dune implementation status
## 14 October 2026 — Emperor Nexus original compulsory purchase
Connected original positive normal-auction allied obligation through a neutral
public possible-holder storm-order queue and owner-private actual Emperor
Nexus Use. Explicit provisional full-price funding reuses matching prepaid
aid and debits only missing native own-wallet credit before original purchase.
No new bid, oversized gift, bank-created subsidy or future-income funding.
Fully prepaid/underfunded Use blocks before disposal; all-pass preserves the
ordinary funded winner decision. Source receipt binds original lot/price,
mutual alliance, declared split, escrow/wallet and frozen public membership.
Native Emperor income remains cancelable separately, never refunding acquisition.
Genuine four-seat Basic/Advanced original setup, undealt starting cards,
first-turn flow, real later worm/pact/closing draw and Guild price2 auction
produce the source. Initial strict receipt whitelist omitted winner; repaired.
A new pending fence prematurely stopped native Truthtrance discard settlement;
it now permits the original discard/Semuta suffix before resuming the same event.
Regression passes; unrelated rules are unchanged. Wording, action-copy and
fixture-provenance pins are removed rather than updated. Minimal Easy policy
performs actual neutral Pass then Use; strategic AI stays deferred.
Affected10-file union155/155, types/lint/build pass. Four genuine API profiles
with allow/cancel/prepaid credit restore original next-lot Atreides inspection.
Actual390px neutral Continue→holder Use→original income allow/cancel preserves
buyer6, prepaid1/missing1, one actual card and one Nexus. Emperor10→9→8→10
for allow, or remains8 after cancellation; original lot1 resumes once without
refund. No horizontal overflow/browser errors; no saved/deployed room targeted.
Special seller lots and final combinations/rules/assurance remain open.
[Canonical contract](NEXUS_EMPEROR_RULES.md#recommended-original-full-price-funding--14-october-2026).
Owned QA host/tab/scaffolding is removed; API/phone proof receipts remain outside
Git. Final build retains existing route-classification/chunk-size warnings.
## 14 October 2026 — Fremen Nexus fixed ordinary free quota
Recommended provisional composition replaces the early-only direct transfer
+14
View File
@@ -12,6 +12,20 @@ movement, battle geometry and actual casualties stay unchanged. One original
Karama response cancels the entire physical batch and keeps the Nexus spent.
[Source, original native proofs and controls](NEXUS_BENE_GESSERIT_RULES.md#recommended-cunning-card-exceptions--14-october-2026).
## Recommended Emperor compulsory funding — 14 October 2026
Normal-auction Betrayal now forces native Emperor to cover the original
positive allied price, reusing matching prepaid aid and adding only missing
own-wallet credit. Neutral storm-order held-Nexus acknowledgements precede
original winner payment regardless of hidden face; only the actual current
holder may Use. All-pass preserves ordinary payment. One original card,
one physical Nexus and native income allow/cancel retain original continuation.
Truthtrance's original discard restores the same event. Full-price rather
than buyer-shortfall funding is visibly provisional, not a publisher ruling.
Underfunded/fully prepaid use, excess gifts and special seller lots are not
silently substituted.
[Source and changed-path proof](NEXUS_EMPEROR_RULES.md#recommended-original-full-price-funding--14-october-2026).
## Recommended Fremen ordinary free quota — 14 October 2026
The absent-Fremen Secret Ally now enables a fixed ordinary free quota of
+50 -6
View File
@@ -121,16 +121,60 @@ receipt, suppression and seat-private offer survive JSON and room restart;
replay cannot spend it twice. Karama remains an independent alternative in
the same window.
This does not implement the forced-purchase Betrayal alternative. The bounded
Secret Ally bank-auction purchase is connected above; Emperor Cunning still
has its own earlier declaration and response. One unique Emperor Nexus card
cannot be both Cunning and Betrayal at once. The wider Nexus module remains
gated.
The normal-auction forced-purchase alternative is now connected below.
Secret Ally bank-auction purchase and Cunning retain their separate producers.
One unique Emperor Nexus cannot pay for two alternatives. The wider module
remains gated.
### Recommended original full-price funding — 14 October 2026
The original printed photograph was reread: force Emperor to pay for their
ally's Treachery Card during Bidding, **at least as much spice as necessary**.
The content-first conservative recommendation covers exactly the original full
positive normal-auction price, not merely the buyer's shortfall. Reuse matching
already-prepaid aid first; debit only missing credit from Emperor's own spice.
No arbitrary excess gift, bank-created subsidy, prospective-income borrowing,
new bid or refund is inferred. Fully prepaid or underfunded opportunities keep
the card and permit Pass. This is a visible provisional interpretation, not
publisher clarification or a separately answered numerical user ruling.
Before original winner payment/recovery, publicly possible unallied held-Nexus
rivals acknowledge in original storm order regardless of secret face. Current
neutral Continue retains all wallets/cards/bids; only the current actual Emperor
holder receives the private Use offer. Truthtrance and private Traitor returns
retain priority. Its original discard restores the same acknowledgement/event;
native Semuta continuation remains before that restoration if selected.
`game/nexus-emperor-payment.ts` binds the original physical lot/index, winner,
price, mutual alliance, declared funding, prepaid escrow, native wallet and
frozen public roster. Event-bound Use spends one physical Nexus, completes the
original full-price ally credit, and calls original `settleAuction`. The buyer
keeps their own spice and gets one original card. Native Emperor income still
opens its original allow/Karama window; cancellation does not refund the funded
purchase. All-pass restores original winner payment without changing the bid.
Completed history does not demand later wallets or holdings equal old snapshots.
Genuine four-seat Basic/Advanced setup/deal, original first turn, real turn-two
worm/Emperor–Guild alliance, physical closing draws and paid-price-two winning
bid produce the source. Fifteen pure boundaries and native runtime consumers
are included in the affected10-file union155/155. The original receipt whitelist
omission and premature discard fence were repaired; regressions now accept
native funding and restore the original Truthtrance suffix. Wording,
action-copy and fixture-provenance pins were deleted, not re-pinned.
Types/lint/build pass; four actual API profiles restore next-lot Atreides
inspection with allow/cancel and prior credit. Actual390px neutral Continue,
holder Use and native income allow/cancel preserve buyer6, reuse prepaid1,
add only missing1, give one card and spend one Nexus. Emperor10→9→8→10
when allowed, or stays8 when income is canceled. No overflow/browser errors.
Original paid purchase advances to lot1 once, without refund. Full combinations,
special seller lots, assurance and deployed acceptance remain separate.
Owned QA host/tab/scaffolding is removed; API and phone proofs remain outside Git.
The final build retains existing route-classification/chunk-size warnings.
The following material cases remain unresolved; no new question was sent:
- **Short groups:** may Cunning operate with fewer than five eligible ordinary counters, or Secret Ally revive fewer than three when Tanks/typed limits cannot supply three? Neither panel says up to. Do not disguise the question as a silently truncated count.
- **Forced payment:** who chooses Emperor's compulsory contribution, what is the minimum when its ally already holds the full price, and what occurs if Emperor cannot cover the requested amount? The panel's necessary-amount wording does not settle the complete digital transaction.
- **Wider forced payment:** the normal-auction prototype chooses the full original obligation and refuses missing own funding or an already fully prepaid price. Arbitrary larger transfers and special seller-lot composition remain unimplemented; the printed text is not claimed to answer them.
- **Special purchases and revival suppression/income:** resolve the concrete boundaries above before connecting broad existing payment/free-revival adapters.
The existing private reaction-policy question still covers other hidden Betrayal timing. This battle path uses a uniform native response rather than an identity-dependent private pause. A native holder and a Betrayal holder cannot possess the same unique Emperor card simultaneously, so do not manufacture Betrayal-versus-Cunning tests with duplicate physical copies.
+6 -4
View File
@@ -59,10 +59,12 @@ owned and otherwise use the ordinary decision. The previously asked
confirmation-versus-gate question remains pending; this prototype does not
settle it for release.
Seller-paid Richese/CHOAM transactions, allied split, expansions, other
purchase sources and the forced Emperor-payment Betrayal alternative remain
gated. The [source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price)
records those payment boundaries.
Seller-paid Richese/CHOAM transactions, allied split, expansions and other
Secret Ally purchase sources remain gated. The separate normal-auction forced
Emperor-payment Betrayal now has a connected
[provisional full-price funding path](NEXUS_EMPEROR_RULES.md#recommended-original-full-price-funding--14-october-2026).
The [Secret Ally source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price)
retains its distinct seller-payment boundary.
## Prototype entry and evidence
+14
View File
@@ -66,6 +66,20 @@ These decisions do not open public module/faction or complete-mode gates.
These decisions do not open public module/faction or complete-mode gates.
## Recommended Emperor compulsory funding — 14 October 2026
The content-first conservative prototype forces exactly the original full
positive normal-auction price for native Emperor's ally, not only buyer
shortfall. Matching prepaid aid is reused; only missing credit comes from
Emperor's own spice before original purchase/income. No excessive gift,
bank-created payment, borrowing against future income or refund is inferred.
Fully prepaid or insufficient-own-wallet Use is unavailable; Pass retains the
card and original ordinary winner payment. Public possible-holder storm-order
acknowledgements protect hidden faces. This is an explicit provisional
implementation recommendation, not publisher clarification or a separately
answered numerical ruling; special seller composition remains open.
[Connected source and proof](NEXUS_EMPEROR_RULES.md#recommended-original-full-price-funding--14-october-2026).
## Recommended Fremen Nexus quota — 14 October 2026
The user's choose-recommended-option instruction selects the provisional
+1
View File
@@ -109,6 +109,7 @@ const decisions = {
wormRide: true,
nexusFremenCunningOffer: true,
nexusFremenProtection: true,
nexusEmperorPayment: true,
nexusFremenCunningRide: true,
moritaniRetention: true,
moritaniSpecialKarama: true,
+3
View File
@@ -2690,6 +2690,9 @@ function policyActions(g: GameView): Action[] {
? [{ type: 'nexusFremenProtection', event: offer.event, use: offer.canUse }]
: [];
}
if (d.kind === 'nexusEmperorPayment')
return [{ type: 'nexusEmperorPayment', event: d.event,
use: !!g.nexusEmperorPayment?.canUse && !g.nexusEmperorPayment.blocked }];
if (d.kind === 'nexusFremenCunningOffer')
return [{ type: 'decision', event: d.event,
accept: g.nexusCards?.card === 'fremen' && me.faction === 'fremen' &&
+150
View File
@@ -46,6 +46,7 @@ import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySig
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase';
import { createEmperorPaymentReceipt, validateEmperorPaymentReceipt, emperorPaymentUseBlock, emperorPaymentSourceQuote, requiredEmperorPaymentResponders, emperorPaymentReceiptSignature, type EmperorPaymentReceipt } from './nexus-emperor-payment';
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, createFremenNexusRevival, upgradeFremenNexusRevival, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
import { createNexusFremenProtection, currentNexusFremenProtectionOwner, answerNexusFremenProtection, validateNexusFremenProtection, type NexusFremenProtectionFrame, type NexusFremenProtectionInput } from './nexus-fremen-protection';
@@ -931,6 +932,7 @@ export type Decision =
}
| { kind: 'guildTiming'; player: string; next: string; following: string }
| { kind: 'auctionPayment'; player: string }
| { kind: 'nexusEmperorPayment'; player: string; event: string }
| {
kind: 'advisor';
player: string;
@@ -1195,6 +1197,9 @@ export type Game = {
quote: Extract<RicheseSettlementQuote, {kind:'sold'}>;
signature: string;
} | null;
/** Original normal-auction obligation; neutral acknowledgements precede funding. */
nexusEmperorPayment?: { sequence: number; current: string | null; completed: EmperorPaymentReceipt[] };
pendingNexusEmperorPayment?: { receipt: EmperorPaymentReceipt } | null;
/** Explicit development profile; never enabled by a public start or player action. */
kullPreview?: boolean;
nexusKullPreview?: boolean;
@@ -12632,6 +12637,112 @@ function stampAuctionBureaucrat(g:Game,p:Player,amount:number,allyPayment:number
const {payee,shares} = auctionBureaucratParties(g,p,amount,allyPayment,seller);
return payee ? stampBureaucratContributors(g,'auction',payee,shares,allyPayment > 0) : {};
}
const EMPEROR_PAYMENT_POLICY = 'Provisional full-price funding: Emperor covers this original allied purchase, reusing prepaid aid and adding only the missing spice. No extra gift or bank subsidy is created; original acquisition and native income continue.';
function nexusEmperorPaymentIntegrity(g: Game): void {
const cursor = g.nexusEmperorPayment, frame = g.pendingNexusEmperorPayment;
if (!cursor) {
requireRule(!frame && g.decision?.kind !== 'nexusEmperorPayment',
'Emperor compulsory funding lost its original obligation.');
return;
}
requireRule(g.nexusCards?.cards && Object.keys(cursor).sort().join(',') === 'completed,current,sequence' &&
Number.isSafeInteger(cursor.sequence) && cursor.sequence >= 1 && Array.isArray(cursor.completed) &&
cursor.sequence === cursor.completed.length + Number(!!frame),
'Emperor compulsory funding lost its original occurrence history.');
const events = new Set<string>();
for (const receipt of cursor.completed) {
nexusRule(() => validateEmperorPaymentReceipt(g, receipt));
requireRule(receipt.stage !== 'pending' && !events.has(receipt.event),
'Emperor compulsory funding repeated or reopened a completed obligation.');
events.add(receipt.event);
}
if (!frame) {
requireRule(cursor.current === null && g.decision?.kind !== 'nexusEmperorPayment',
'Emperor compulsory funding lost its pending source.');
return;
}
requireRule(Object.keys(frame).join(',') === 'receipt' &&
frame.receipt.stage === 'pending' && cursor.current === frame.receipt.event &&
!events.has(frame.receipt.event), 'Emperor compulsory funding lost its pending receipt.');
nexusRule(() => validateEmperorPaymentReceipt(g, frame.receipt));
const owner = frame.receipt.required[frame.receipt.passed.length];
const continuation = g.pendingTreacheryDiscard?.continuation;
const decision = g.decision?.kind === 'nexusEmperorPayment' ? g.decision :
continuation?.kind === 'truthtranceDiscard' ? continuation.resume.decision : null;
requireRule(decision?.kind === 'nexusEmperorPayment' && decision.event === frame.receipt.event &&
decision.player === owner && g.active === owner,
'Emperor compulsory funding lost its original acknowledgement owner.');
}
function nexusEmperorPaymentOffer(g: Game, id: string) {
const receipt = g.pendingNexusEmperorPayment?.receipt;
if (!receipt || g.decision?.kind !== 'nexusEmperorPayment' || g.decision.player !== id ||
g.nexusCards?.cards?.hands[id] !== 'emperor') return null;
const blocked = nexusRule(() => emperorPaymentUseBlock(g, receipt, id));
return { event: receipt.event, canUse: !blocked, blocked, policy: EMPEROR_PAYMENT_POLICY,
price: receipt.price, emperor: receipt.emperor, winner: receipt.winner };
}
function beginNexusEmperorPayment(g: Game): boolean {
if (!g.nexusCards?.cards) return false;
const source = nexusRule(() => emperorPaymentSourceQuote(g));
if (!source) return false;
const last = g.nexusEmperorPayment?.completed.at(-1);
if (last?.turn === g.turn && last.auctionIndex === source.auctionIndex &&
last.card === source.card && last.winner === source.winner && last.price === source.price) return false;
const required = nexusRule(() => requiredEmperorPaymentResponders(g));
if (!required.length) return false;
const receipt = nexusRule(() => createEmperorPaymentReceipt(g, crypto.randomUUID(), required));
const cursor = g.nexusEmperorPayment ??= { sequence: 0, current: null, completed: [] };
cursor.sequence++;
cursor.current = receipt.event;
g.pendingNexusEmperorPayment = { receipt };
g.active = required[0];
g.decision = { kind: 'nexusEmperorPayment', player: required[0], event: receipt.event };
return true;
}
function decideNexusEmperorPayment(g: Game, p: Player, action: Action): void {
const receipt = g.pendingNexusEmperorPayment!.receipt;
requireRule(action.type === 'nexusEmperorPayment' && typeof action.use === 'boolean' &&
Object.keys(action).sort().join(',') === 'event,type,use' && action.event === receipt.event &&
receipt.required[receipt.passed.length] === p.id,
'Acknowledge only your current original allied purchase.');
requireRule(!g.truthtrance && !pendingNexusTraitors(g),
'Finish Truthtrance and private Traitor returns before the auction reaction.');
if (action.use) {
const blocked = nexusRule(() => emperorPaymentUseBlock(g, receipt, p.id));
requireRule(!blocked, blocked ?? 'This card cannot fund the original purchase.');
}
const passed = action.use ? receipt.passed : [...receipt.passed, p.id];
const stage = action.use ? 'used' : passed.length === receipt.required.length ? 'passed' : 'pending';
const next: EmperorPaymentReceipt = { ...receipt, passed, stage,
owner: action.use ? p.id : null, signature: '' };
next.signature = emperorPaymentReceiptSignature(next);
nexusRule(() => validateEmperorPaymentReceipt(g, next));
if (stage === 'pending') {
g.pendingNexusEmperorPayment!.receipt = next;
const owner = next.required[next.passed.length];
g.active = owner;
g.decision = { kind: 'nexusEmperorPayment', player: owner, event: next.event };
return;
}
const source = nexusRule(() => emperorPaymentSourceQuote(g));
requireRule(source, 'The original auction funding obligation is no longer present.');
if (action.use) {
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
getPlayer(g, source.emperor).spice -= source.missing;
const credit = g.aid[source.emperor];
g.aid[source.emperor] = { recipient: source.winner,
amount: (credit?.recipient === source.winner ? credit.amount : 0) + source.missing };
g.auction!.allyPayment = source.price;
log(g, `${p.name} spent Emperor Nexus Betrayal to compel the original ${source.price}-spice allied purchase. ${source.prepaid} prepaid spice is reused and Emperor supplies ${source.missing}; no extra gift or bank subsidy is created. Provisional full-price interpretation.`);
}
g.nexusEmperorPayment!.completed.push(next);
g.nexusEmperorPayment!.current = null;
g.pendingNexusEmperorPayment = null;
g.active = source.winner;
g.decision = null;
if (action.use) settleAuction(g, false);
else recoverAuctionPayment(g, getPlayer(g, source.winner));
}
function auctionNext(g: Game) {
const a = g.auction!;
const eligible = g.order.filter((id) => {
@@ -12653,6 +12764,7 @@ function auctionNext(g: Game) {
finishNormalBidding(g);
return;
}
if (beginNexusEmperorPayment(g)) return;
g.decision = { kind: 'auctionPayment', player: next.player };
g.active = next.player;
recoverAuctionPayment(g, getPlayer(g, next.player));
@@ -28476,6 +28588,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
nexusEmperorPaymentIntegrity(state);
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
@@ -28566,6 +28679,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
if (g.pendingNexusIxianReplacement) return g;
if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;}
if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;}
if (g.pendingNexusEmperorPayment) {
finishActionContinuations(g);
nexusEmperorPaymentIntegrity(g);
return g;
}
if (g.pendingKull) {
settleAutomaticContinuations(g);
if (g.pendingKull) { kullIntegrity(g); marketGholaIntegrity(g); return g; }
@@ -28682,6 +28800,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
nexusEcazDukeIntegrity(g);
richesePairIntegrity(g);
richeseBetrayalIntegrity(g);
nexusEmperorPaymentIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
homeworldBattleLossIntegrity(g);
@@ -28711,6 +28830,7 @@ function finishActionContinuations(g: Game) {
finishTreacheryDiscard(g);
if (g.pendingTreacheryDiscard?.reaction) return;
requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.');
if (g.pendingNexusEmperorPayment) return;
if (pendingNexusTraitors(g)) return;
observeOccupation(g);
resumeHomeworldRevivalReturn(g);
@@ -28770,6 +28890,7 @@ function settleAutomaticContinuations(g: Game) {
if (g.pendingNexusIxianReplacement) return;
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingNexusEmperorPayment) return;
if (g.pendingKull?.stage === 'offer') return;
if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return;
@@ -28779,6 +28900,7 @@ function settleAutomaticContinuations(g: Game) {
if (g.pendingGuildBetrayal) return;
if (g.pendingNexusIxianReplacement) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingNexusEmperorPayment) return;
syncTupileSlots(g);
prepareTupileCleanup(g);
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return;
@@ -28837,6 +28959,7 @@ function settleAutomaticContinuations(g: Game) {
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return;
if (g.pendingNexusEmperorPayment) return;
// Opposing/automatic consequences can release a now-impossible promise;
// they are not the original actor voluntarily spending a promised resource.
reconcileBattlePromises(g);
@@ -28860,6 +28983,7 @@ export function normalizeAutomaticGame(state: Game): Game {
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
nexusEmperorPaymentIntegrity(state);
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
@@ -28900,6 +29024,11 @@ export function normalizeAutomaticGame(state: Game): Game {
if (g.pendingNexusIxianReplacement) return g;
if (g.pendingGuildBetrayal) return g;
if (g.pendingRicheseBetrayal) return g;
if (g.pendingNexusEmperorPayment) {
finishActionContinuations(g);
nexusEmperorPaymentIntegrity(g);
return g;
}
if (g.pendingKull?.stage === 'offer') return g;
ornithopterIntegrity(g);
lateDefenseIntegrity(g);
@@ -28930,6 +29059,7 @@ export function normalizeAutomaticGame(state: Game): Game {
nexusEcazDukeIntegrity(g);
richesePairIntegrity(g);
richeseBetrayalIntegrity(g);
nexusEmperorPaymentIntegrity(g);
marketGholaIntegrity(g);
prepareTupileCleanup(g);
homeworldRule(() => homeworldGameIntegrity(g));
@@ -28965,6 +29095,17 @@ function applyActionInner(
'Finish the original occupied Tupile hand cleanup before another action.');
ecazTreacheryIntegrity(state);
const g = structuredClone(state);
if (g.pendingNexusEmperorPayment && !g.truthtrance && !pendingNexusTraitors(g)) {
const p = getPlayer(g, id);
const opensTruth = action?.type === 'card' && !action.mode &&
p.hand.some(card => card.id === action.card && (card.effect === 'truthtrance' ||
Array.isArray(action.shrineTruthtrance) && action.shrineTruthtrance.includes(card.id)));
if (!opensTruth) {
if (action?.type === 'advanceBots') return g;
decideNexusEmperorPayment(g, p, action);
return g;
}
}
if (g.pendingNexusHarkonnenBetrayal) {
getPlayer(g,id);
if (action?.type === 'advanceBots') return g;
@@ -29139,6 +29280,11 @@ function applyActionInner(
throw error;
}
requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing play.');
if (g.pendingNexusEmperorPayment) {
if (action.type === 'advanceBots') return g;
decideNexusEmperorPayment(g, p, action);
return g;
}
if (t === 'card' && action.card === RECRUITS_CARD_ID) {
requireRule(
Object.keys(action).sort().join(',') === 'card,type',
@@ -29480,6 +29626,8 @@ function applyActionInner(
const decision = g.decision;
requireRule(decision.kind !== 'nexusFremenProtection',
'Use only the original worm protection event with Use or Pass.');
requireRule(decision.kind !== 'nexusEmperorPayment',
'Use only the original allied-purchase event with Use or Pass.');
if (decision.kind === 'nexusIxianReplacement') {
requireRule(false, 'Use only the current purchased-card event with Use or Pass.');
}
@@ -32916,6 +33064,7 @@ export function viewGame(state: Game, id: string) {
guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state);
richeseBetrayalIntegrity(state);
nexusEmperorPaymentIntegrity(state);
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
@@ -33358,6 +33507,7 @@ export function viewGame(state: Game, id: string) {
nexusEcazDuke: nexusRule(() => quoteNexusEcazDuke(g, id)),
nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id),
nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id),
nexusEmperorPayment: nexusEmperorPaymentOffer(g, id),
nexusBgBetrayal: nexusBgBetrayalOffer(g, id),
nexusBorrowedVoice: nexusBorrowedVoiceOffer(g, id),
nexusFremenRevival: fremenNexusRevivalOffer(g,id),
+231
View File
@@ -0,0 +1,231 @@
import type { Game } from './engine';
export type EmperorPaymentSource = {
auctionIndex: number;
card: string;
winner: string;
price: number;
emperor: string;
ally: string;
allyPayment: number;
aid: { recipient: string | null; amount: number };
emperorSpice: number;
};
export type EmperorPaymentQuote = EmperorPaymentSource & {
prepaid: number;
missing: number;
blocked: string | null;
};
/** Original obligation and public acknowledgement snapshot, not a new bid or sale. */
export type EmperorPaymentReceipt = EmperorPaymentSource & {
event: string;
turn: number;
phase: 3;
order: string[];
holders: string[];
required: string[];
/** Pass answers only; a Use owner is the next member of the original queue. */
passed: string[];
stage: 'pending' | 'passed' | 'used';
owner: string | null;
signature: string;
};
const object = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const text = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const count = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const ids = (value: unknown): value is string[] =>
Array.isArray(value) && value.every(text) && new Set(value).size === value.length;
const sameIds = (a: readonly string[], b: readonly string[]): boolean =>
a.length === b.length && a.every((id, index) => id === b[index]);
function requirePayment(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
/** A visible conservative full-obligation policy, not a publisher timing ruling.
* Prepaid matching Emperor aid is already out of their wallet. Reuse it before
* taking only the missing credit from their own spice; never count future income.
* The engine alone establishes that ordinary bidding has reached its winner. */
export function emperorPaymentSourceQuote(g: Game): EmperorPaymentQuote | null {
const auction = g.auction;
if (g.status !== 'playing' || g.phase !== 3 || !count(g.turn) || g.turn === 0 ||
!auction || g.currentAuctionSale || g.richeseAuction ||
!Array.isArray(auction.cards) || !count(auction.index) ||
auction.index >= auction.cards.length || !count(auction.bid) || auction.bid === 0 ||
!text(auction.cards[auction.index]?.id) || !text(auction.bidder)) return null;
const winner = g.players.find(player => player.id === auction.bidder);
const emperors = g.players.filter(player => player.faction === 'emperor');
const emperor = emperors[0];
if (!winner || emperors.length !== 1 || !emperor || winner.id === emperor.id ||
winner.ally !== emperor.id || emperor.ally !== winner.id ||
!count(emperor.spice)) return null;
const allyPayment = auction.allyPayment ?? 0;
if (!count(allyPayment) || allyPayment > auction.bid) return null;
const credit = g.aid?.[emperor.id];
if (credit && (!object(credit) || !text(credit.recipient) || !count(credit.amount))) return null;
const card = auction.cards[auction.index].id;
// Sold earlier entries are aliases, not physical custody. A still-unsold
// duplicate or a card already acquired by a seat cannot be this original lot.
for (let index = auction.index + 1; index < auction.cards.length; index++)
if (auction.cards[index]?.id === card) return null;
if (g.players.some(player => player.hand?.some(held => held.id === card)) ||
g.deck?.some(held => held.id === card) || g.discard?.some(held => held.id === card)) return null;
const prepaid = credit?.recipient === winner.id ? Math.min(credit.amount, auction.bid) : 0;
const missing = auction.bid - prepaid;
let blocked: string | null = null;
if (credit && credit.amount > 0 && credit.recipient !== winner.id)
blocked = 'The Emperor’s prepaid aid belongs to another recipient.';
else if (allyPayment > prepaid)
blocked = 'The original allied bid has lost its committed prepaid aid.';
else if (missing === 0)
blocked = 'The Emperor has already prepaid the full original price.';
else if (emperor.spice < missing)
blocked = 'The Emperor cannot cover the missing full-price funding from their own spice.';
return {
auctionIndex: auction.index, card, winner: winner.id, price: auction.bid,
emperor: emperor.id, ally: winner.id, allyPayment,
aid: { recipient: credit?.recipient ?? null, amount: credit?.amount ?? 0 },
emperorSpice: emperor.spice, prepaid, missing, blocked,
};
}
/** Public roster only: card identity, bidder hand and native wallet do not
* determine who must acknowledge the original obligation. */
export function requiredEmperorPaymentResponders(g: Game): string[] {
if (!g.nexusCards?.cards || !g.auction?.bidder ||
!ids(g.order) || g.order.length !== g.players.length ||
!ids(g.players.map(player => player.id)) ||
!g.order.every(id => g.players.some(player => player.id === id))) return [];
const emperors = g.players.filter(player => player.faction === 'emperor');
if (emperors.length !== 1) return [];
const emperor = emperors[0];
const winner = g.players.find(player => player.id === g.auction!.bidder);
if (!winner || winner.id === emperor.id || winner.ally !== emperor.id ||
emperor.ally !== winner.id) return [];
return g.order.filter(id => {
const player = g.players.find(seat => seat.id === id)!;
return id !== emperor.id && id !== winner.id && !player.ally &&
!!g.nexusCards!.cards!.hands[id];
});
}
export function emperorPaymentReceiptSignature(receipt: EmperorPaymentReceipt): string {
return JSON.stringify(['nexusEmperorPaymentReceipt', receipt.event, receipt.turn,
receipt.phase, receipt.auctionIndex, receipt.card, receipt.winner, receipt.price,
receipt.emperor, receipt.ally, receipt.allyPayment, receipt.aid.recipient,
receipt.aid.amount, receipt.emperorSpice, receipt.order, receipt.holders,
receipt.required, receipt.passed, receipt.stage, receipt.owner]);
}
export function createEmperorPaymentReceipt(
g: Game, event: string, required: readonly string[],
): EmperorPaymentReceipt {
const quote = emperorPaymentSourceQuote(g);
requirePayment(quote && text(event) && ids(required) && required.length > 0 &&
sameIds(required, requiredEmperorPaymentResponders(g)),
'Emperor payment needs the original ordinary winning obligation and public responders.');
const receipt: EmperorPaymentReceipt = {
event, turn: g.turn, phase: 3, auctionIndex: quote.auctionIndex,
card: quote.card, winner: quote.winner, price: quote.price,
emperor: quote.emperor, ally: quote.ally, allyPayment: quote.allyPayment,
aid: { ...quote.aid }, emperorSpice: quote.emperorSpice,
order: [...g.order],
holders: g.order.filter(id => !!g.nexusCards!.cards!.hands[id]),
required: [...required], passed: [], stage: 'pending', owner: null, signature: '',
};
receipt.signature = emperorPaymentReceiptSignature(receipt);
return receipt;
}
function sameSource(receipt: EmperorPaymentReceipt, quote: EmperorPaymentQuote): boolean {
return receipt.auctionIndex === quote.auctionIndex && receipt.card === quote.card &&
receipt.winner === quote.winner && receipt.price === quote.price &&
receipt.emperor === quote.emperor && receipt.ally === quote.ally &&
receipt.allyPayment === quote.allyPayment &&
receipt.aid.recipient === quote.aid.recipient && receipt.aid.amount === quote.aid.amount &&
receipt.emperorSpice === quote.emperorSpice;
}
/** Pending receipts bind the actual original source. Completed history retains
* its own original facts, not future wallets, alliances, or physical hand zones. */
export function validateEmperorPaymentReceipt(g: Game, receipt: EmperorPaymentReceipt): void {
requirePayment(object(receipt) && Object.keys(receipt).sort().join(',') ===
'aid,ally,allyPayment,auctionIndex,card,emperor,emperorSpice,event,holders,order,owner,passed,phase,price,required,signature,stage,turn,winner' &&
text(receipt.event) && count(receipt.turn) && receipt.turn > 0 && receipt.turn <= g.turn &&
receipt.phase === 3 && (receipt.turn < g.turn || g.phase >= 3) &&
count(receipt.auctionIndex) && text(receipt.card) && text(receipt.winner) &&
count(receipt.price) && receipt.price > 0 && text(receipt.emperor) &&
receipt.emperor !== receipt.winner && receipt.ally === receipt.winner &&
count(receipt.allyPayment) && receipt.allyPayment <= receipt.price &&
object(receipt.aid) && Object.keys(receipt.aid).sort().join(',') === 'amount,recipient' &&
count(receipt.aid.amount) && (receipt.aid.recipient === null
? receipt.aid.amount === 0 : text(receipt.aid.recipient) &&
g.players.some(player => player.id === receipt.aid.recipient)) &&
count(receipt.emperorSpice) && ids(receipt.order) && ids(receipt.holders) &&
ids(receipt.required) && ids(receipt.passed) && receipt.required.length > 0 &&
receipt.order.length === g.players.length &&
receipt.order.every(id => g.players.some(player => player.id === id)) &&
g.players.some(player => player.id === receipt.emperor && player.faction === 'emperor') &&
receipt.order.includes(receipt.winner) &&
sameIds(receipt.holders, receipt.order.filter(id => receipt.holders.includes(id))) &&
sameIds(receipt.required, receipt.order.filter(id => receipt.required.includes(id))) &&
receipt.required.every(id => receipt.holders.includes(id) &&
id !== receipt.emperor && id !== receipt.winner) &&
receipt.passed.length <= receipt.required.length &&
receipt.passed.every((id, index) => id === receipt.required[index]) &&
((receipt.stage === 'pending' && receipt.owner === null && receipt.passed.length < receipt.required.length) ||
(receipt.stage === 'passed' && receipt.owner === null && receipt.passed.length === receipt.required.length) ||
(receipt.stage === 'used' && text(receipt.owner) &&
receipt.owner === receipt.required[receipt.passed.length])) &&
text(receipt.signature) && receipt.signature === emperorPaymentReceiptSignature(receipt),
'Emperor payment lost its strict original obligation or ordered acknowledgements.');
if (receipt.stage === 'pending') {
const quote = emperorPaymentSourceQuote(g);
requirePayment(receipt.turn === g.turn && quote && sameSource(receipt, quote) &&
sameIds(receipt.order, g.order),
'Emperor payment lost its original current auction, alliance, aid or native spice.');
}
if (receipt.stage === 'used') {
const prepaid = receipt.aid.recipient === receipt.winner
? Math.min(receipt.aid.amount, receipt.price) : 0;
requirePayment((receipt.aid.recipient === null || receipt.aid.recipient === receipt.winner ||
receipt.aid.amount === 0) && receipt.allyPayment <= prepaid &&
prepaid < receipt.price && receipt.emperorSpice >= receipt.price - prepaid,
'Emperor payment Use lacks its original full-price funding.');
}
}
/** Pure entitlement check. The engine spends the one physical card and commits
* the additional credit only after this succeeds and its native checks pass. */
export function emperorPaymentUseBlock(
g: Game, receipt: EmperorPaymentReceipt, owner: string,
): string | null {
try { validateEmperorPaymentReceipt(g, receipt); }
catch (error) { return error instanceof Error ? error.message : 'Invalid Emperor payment obligation.'; }
const pending = g.pendingNexusEmperorPayment?.receipt;
try {
if (pending && pending !== receipt) validateEmperorPaymentReceipt(g, pending);
} catch (error) {
return error instanceof Error ? error.message : 'Invalid current Emperor payment obligation.';
}
if (receipt.stage !== 'pending' || g.nexusEmperorPayment?.current !== receipt.event ||
!pending || pending.event !== receipt.event || pending.signature !== receipt.signature ||
g.nexusEmperorPayment?.completed.some(completed => completed.event === receipt.event) ||
receipt.required[receipt.passed.length] !== owner || g.active !== owner ||
g.decision?.kind !== 'nexusEmperorPayment' || g.decision.player !== owner ||
g.decision.event !== receipt.event)
return 'Only the current original Emperor payment responder may use this obligation.';
if (g.truthtrance || g.nexusTraitorExchanges?.some(exchange => exchange.stage === 'return') ||
g.nexusCards?.phase?.stage === 'drawing' || g.response || g.pendingKarama)
return 'Finish the current question, card return or response first.';
const player = g.players.find(seat => seat.id === owner);
if (!player || player.ally || owner === receipt.emperor || owner === receipt.winner ||
g.nexusCards?.cards?.hands[owner] !== 'emperor')
return 'Use requires your actual unallied Emperor Nexus Betrayal card.';
const quote = emperorPaymentSourceQuote(g);
if (!quote || !sameSource(receipt, quote)) return 'The original Emperor payment source changed.';
return quote.blocked;
}
+7 -6
View File
@@ -4568,8 +4568,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'With BG absent, every fresh Nexus battle asks both combatants in attacker order before Prescience, Nexus inspection or sealed plans. Continue preserves the card. The actual unallied BG-card holder may spend it to require or forbid one canonical type against the opponent. Under the explicit provisional recommendation, original Karama can cancel this named Voice advantage; the Nexus stays spent, only Voice is removed and native preparation continues. Existing in-flight preparation is not retrofitted. Native compliance and special/mode restrictions remain; this does not complete other unfinished effects.',
'Bene Gesserit Betrayal can prevent the native Voice while the ordinary pre-plan response is open. In Nexus games every seat responds even without a canceling card, so the window does not reveal the hidden holder. An unallied opponent spends one physical Bene Gesserit Nexus card to remove only that declared Voice; Prescience and plan commitments continue. Truthtrance has priority. This path does not grant borrowed Voice.',
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. The separate Secret Ally revival and normal bank-auction purchase have connected controls; seller-paid and forced Emperor payments remain unfinished.',
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. Secret Ally revival and normal bank-auction purchase have connected controls; normal forced allied funding is the separate operation below. Seller-paid Secret Ally purchases remain unfinished.',
'Emperor Betrayal has a battle alternative in Advanced play: an unallied opponent holding the Emperor Nexus card can spend it during the ordinary pre-plan Sardaukar response to suppress actual Sardaukar strength for this battle. Every seat receives the same response opportunity even with no card, keeping hidden custody private. The starred pieces remain physical Sardaukar; no card grants a new force or cancels their separate support benefit.',
'During an original positive normal-auction purchase by Emperor’s ally, possible unallied held-Nexus rivals acknowledge in storm order before winner payment, regardless of secret face. Only the current actual Emperor-card holder may Use; Pass keeps cards and original payment. Under the provisional conservative full-price interpretation, native Emperor covers the full original price, reusing prepaid aid and adding only missing own spice. The buyer keeps their own spice and acquires one original card. Native Emperor income remains separately cancelable without refund; original next-lot inspection continues. No excess gift, bank-created subsidy or future-income borrowing is granted. Underfunded/fully prepaid Use is unavailable; special seller lots and full combinations remain open.',
'With Fremen absent, an actual unallied Fremen-card holder may enable a fixed ordinary free quota of three at a quiet Revival boundary, then use normal partial/typed revival controls. Activation moves no counters, leader or spice. Actual prior free use counts; paid returns keep their cost and total usage. Native total limits, higher free rates, typed caps, prevention, Homeworld deposits/deployment, Axlotl activity and once-only Tleilaxu income remain original. Recruits doubles its native rate, not this fixed quota. Useless or unknown-ledger grants are unavailable. This is the visibly provisional recommended interpretation; strict old physical receipts adopt metadata without replay. Independent sandworm protection remains the separate operation below.',
'Before one accepted worm devours forces with Fremen absent, publicly possible unallied threatened held-Nexus seats acknowledge in original storm order regardless of secret face. Only the actual Fremen holder may spend to save their own groups. Pass keeps the card; all acknowledgements precede casualties. Under the explicit provisional independent-operation policy, no extra Karama provider or riding is invented. Original other casualties, spice removal, Great Maker vote and next blow/Nexus remain. Protected No-Field uses native protected-victim skipping; wider marker/module acceptance remains open.',
'Native Fremen Cunning has a bounded classic Nexus path, optionally retaining original Tech Tokens and Advanced Stronghold Cards: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement or changing original component custody/income. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker, dynamic destinations and other optional-module combinations await integration.',
@@ -4597,11 +4598,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Existing physical effects remain. Fremen Secret Ally now enables a fixed ordinary free quota3 without moving forces, then original partial/typed revival preserves prior actual free/paid usage, native limits/rates/Recruits, prevention, income, Tech and Homeworld continuation. Strict old physical receipts adopt source metadata without replay. Removed early-only direct-transfer action/selector. Borrowed Voice, independent protection and fresh/storm BG Cunning retain their separate provisional policies. Wider combinations and custody remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Fremen activation visibly explains the provisional total-free-quota policy, returns no counters and hands over to original partial force/elite controls and native Tleilaxu Allow decisions. Real390px one-plus-two returns exhaust the quota and disable further returns. Existing Voice/protection/Cunning controls and shared native movement route guidance remain. Complete modes and wider module controls are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing minimal legal profiles retain original responses and continuation. Fremen holders use the shared event-only quota activation before original ordinary revival selection rather than a Nexus-only elite action. Genuine Basic/Advanced classic/native-Tleilaxu Easy paths return eligible free counters; strategy and four-tier calibration remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve faces retain remaining custody, inquiry, native nested Cunning and combination questions. Source documents distinguish cancellable named Voice, independent protection and the recommended in-place fresh/storm Cunning exceptions without claiming publisher answers. Actual Advanced card-distance chronology and native ordinary restrictions are explicit. Existing Richese/Guild contracts and complete-mode gates remain.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Quota affected20-file union329/329 passes with genuine Basic/Advanced classic/native-Tleilaxu one-plus-two, late free/paid returns, expiry, original income, typed module consumers and legacy metadata adoption. Types/lint pass. Four actual API profiles reach Movement after late activation. Actual390px activation then one/two returns with original Tleilaxu Allow decisions preserves spice13, Tanks8→5/reserves10→13/free3 and native income1 once; one discard, no overflow/browser errors. Prior BG Cunning65/65 evidence remains. Wider combinations, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts'] },
{area:'Implementation',status:'Partial',detail:'Existing physical effects and Fremen fixed ordinary quota remain. Emperor Betrayal now binds original positive normal-auction allied obligation, neutral possible-holder acknowledgements, exact original lot/price/alliance/escrow/wallet and one physical card. Provisional full-price funding reuses prepaid aid, adds only missing own spice and calls native acquisition/income/next-lot continuation. Truthtrance discard restoration is retained; no refund, new bid, excessive gift or bank subsidy. Special seller composition, wider combinations and custody remain open.',evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts']},
{area:'Player controls',status:'Partial',detail:'Emperor payment uses neutral original-event Continue and a current-holder-only Use with visible full-price interpretation and blocking reason. Actual390px neutral/pass/use followed by original income Allow or printed Karama cancellation preserves native acquisition and next-lot inspection. Existing Fremen normal partial controls, Voice/protection/Cunning and movement guidance remain. Wider controls and complete-mode acceptance stay open.',evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx']},
{area:'AI',status:'Partial',detail:'Existing minimal legal profiles retain original responses and continuation. Fremen holders enable ordinary quota before native revival selection. Emperor compulsory-funding responders pass from the public event unless their current private offer permits Use. Genuine Easy neutral/pass/use and original paid acquisition are exercised; strategy and four-tier calibration remain deferred and unfinished.',evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts']},
{area:'Documentation',status:'Partial',detail:'Common rules and twelve faces retain remaining custody, inquiry, nested Cunning and combinations. Contracts distinguish named borrowed Voice cancellation, independent worm protection, fresh/storm BG card exception, Fremen ordinary fixed quota and Emperor original full-price funding. Interpretations are visibly provisional, not publisher answers or separately answered numerical rulings. Special seller composition and full-mode gates remain.',evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md']},
{area:'Verification',status:'Partial',detail:'Compulsory-funding affected10-file union155/155 passes. Genuine Basic/Advanced setup, real worm/pact/closing draw and Guild price2 auction exercise prepaid0/1, allpass0/1/2, original income allow/cancel, Truthtrance restore and immutable event/actor/key refusals. Four API profiles and two real390px allow/cancel surfaces retain buyer6, reuse prepaid1/add missing1, acquire one card/spend one Nexus and restore lot1 inspection. Native Emperor10 returns on allowance or stays8 after cancellation; no refund/overflow/browser errors. Types/lint/build pass. Prior quota329/329 and BG Cunning65/65 evidence remains; special seller, full combinations, assurance and deployed acceptance remain open.',evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md']},
],
},
{
+1
View File
@@ -90,6 +90,7 @@ const DECISIONS = {
wormRide: true,
nexusFremenCunningOffer: true,
nexusFremenProtection: true,
nexusEmperorPayment: true,
nexusFremenCunningRide: true,
moritaniRetention: true,
moritaniSpecialKarama: true,
+213
View File
@@ -0,0 +1,213 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
type Action, type Game, type Player,
} from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { validateNexusCards } from '../game/nexus-cards';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type NexusEmperorPaymentStep = { actor: string; action: Action };
export type NexusEmperorPaymentTransition = { before: Game; step: NexusEmperorPaymentStep; after: Game };
export type NexusEmperorPaymentFixture = {
game: Game;
owner: string;
winner: string;
emperor: string;
neutral: string;
event: string;
price: number;
acquired: string;
karama: string;
originalCards: string[];
actions: NexusEmperorPaymentStep[];
staging: string[];
chronology: {
initial: Game;
afterSetup: Game;
firstMentat: NexusEmperorPaymentTransition;
worm: Game;
alliance: Game;
draw: NexusEmperorPaymentTransition;
afterClosingDraw: Game;
beforeFunding: Game;
beforeBid: Game;
};
wallets: { winner: number; emperorBeforeAid: number; emperor: number; matchingAid: number };
};
export function nexusEmperorPaymentPlayer(game: Game, id: string): Player {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing original Emperor-payment seat ${id}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard;
/** Fix only scalar native randomness. UUIDs and all other native entropy remain real. */
function scalarEntropy<T>(operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function stepNexusEmperorPayment(game: Game, step: NexusEmperorPaymentStep,
actions?: NexusEmperorPaymentStep[]): Game {
actions?.push(structuredClone(step));
return scalarEntropy(() => applyAction(game, step.actor, step.action));
}
export function nextNexusEmperorPaymentStep(game: Game): NexusEmperorPaymentStep {
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
if (!game.phaseOpening && !game.response && game.decision?.kind === 'nexusEmperorPayment')
return { actor: game.decision.player,
action: { type: 'nexusEmperorPayment', event: game.decision.event, use: false } };
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next, 'The genuine Emperor-payment chronology must not require a manufactured battle plan');
return next;
}
export function advanceNexusEmperorPayment(game: Game, until: (game: Game) => boolean,
actions?: NexusEmperorPaymentStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = stepNexusEmperorPayment(game, nextNexusEmperorPaymentStep(game), actions);
}
throw new Error('The original Emperor-payment chronology did not reach its requested boundary');
}
/** Physical ownership zones only: paid auction prefixes are references, not duplicates. */
export function nexusEmperorPaymentCards(game: Game): string[] {
return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])]
.map(card => card.id).sort();
}
export function nexusEmperorPaymentInventory(game: Game): void {
validateNexusCards(game.nexusCards!.cards!, game.players);
assert.deepEqual(nexusEmperorPaymentCards(game), treacheryDeck().map(card => card.id).sort());
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0), 5);
}
}
function frontOriginalSpice(game: Game, kinds: ('land' | 'worm')[]): void {
const prefix = kinds.map(kind => {
const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
});
game.spiceDeck.unshift(...prefix);
}
/** Genuine four-seat Basic/Advanced native history. Only still-undealt physical
* singleton order is controlled; no hand, wallet, army, phase, alliance or receipt
* is assigned. The original first turn ends before the later real worm/alliance. */
export function createNexusEmperorPaymentFixture(options: {
advanced?: boolean; prepaidAid?: number; neutralHolder?: boolean; truthtrance?: boolean;
} = {}): NexusEmperorPaymentFixture {
const owner = 'atreides', emperor = 'emperor', winner = 'guild', neutral = 'harkonnen';
const price = 2, matchingAid = options.prepaidAid ?? 0;
assert.ok(Number.isInteger(matchingAid) && matchingAid >= 0 && matchingAid <= price);
const actions: NexusEmperorPaymentStep[] = [], staging: string[] = [];
let game = createGame('NEXUSEMPERORPAYMENT', newPlayer(owner, 'Atreides', 'atreides'), options.advanced ?? false);
joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
joinGame(game, newPlayer(winner, 'Guild', 'guild'));
joinGame(game, newPlayer(neutral, 'Harkonnen', 'harkonnen'));
game.nexusCards = { cards: null, phase: null };
for (const player of game.players) game = stepNexusEmperorPayment(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = scalarEntropy(() => initializeNexusGameForAudit(game));
assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0));
// Both printed Karama cards are actually dealt by the original starting-card
// routine: one keeps Guild's ordinary payment choice, one can cancel income.
const prefix = [];
for (const kind of ['worthless', 'worthless', 'karama', 'karama', options.truthtrance ? 'truthtrance' : 'worthless']) {
const index = game.deck.findIndex(card => kind === 'worthless' ? card.kind === kind : card.effect === kind);
assert.ok(index >= 0);
prefix.push(game.deck.splice(index, 1)[0]);
}
game.deck.unshift(...prefix);
staging.push('Ordered five original undealt starting Treachery singletons before their native setup deal.');
const initial = structuredClone(game);
game = advanceNexusEmperorPayment(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
const karama = nexusEmperorPaymentPlayer(game, neutral).hand.find(card => card.effect === 'karama')!.id;
assert.ok(nexusEmperorPaymentPlayer(game, winner).hand.some(card => card.effect === 'karama'));
game = advanceNexusEmperorPayment(game, state => state.phase === 1 && clean(state), actions);
frontOriginalSpice(game, ['land', 'land']);
staging.push('Ordered original undealt non-worm first-turn Spice Cards; no first-turn Nexus manufactured.');
let firstMentat: NexusEmperorPaymentTransition | undefined;
while (game.turn === 1) {
const before = structuredClone(game), step = nextNexusEmperorPaymentStep(game);
game = stepNexusEmperorPayment(game, step, actions);
if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) };
}
assert.ok(firstMentat);
game = advanceNexusEmperorPayment(game, state => state.turn === 2 && state.phase === 1 && clean(state), actions);
frontOriginalSpice(game, ['worm', 'land', 'land']);
staging.push('Ordered original still-undealt turn-two worm and subsequent land singletons.');
game = advanceNexusEmperorPayment(game, state => !!state.nexus && !state.spiceWindow &&
!state.spiceResolution && clean(state), actions);
const worm = structuredClone(game);
for (const [actor, target] of [[emperor, winner], [winner, emperor]])
game = stepNexusEmperorPayment(game, { actor, action: { type: 'alliance', target } }, actions);
assert.equal(nexusEmperorPaymentPlayer(game, emperor).ally, winner);
assert.equal(nexusEmperorPaymentPlayer(game, winner).ally, emperor);
const alliance = structuredClone(game);
game = advanceNexusEmperorPayment(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), actions);
const cards = game.nexusCards!.cards!;
const index = cards.deck.indexOf('emperor'); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
staging.push('Ordered original undealt Emperor Nexus singleton immediately before its real closing draw.');
const beforeDraw = structuredClone(game);
const drawStep: NexusEmperorPaymentStep = { actor: owner, action: { type: 'nexusCardChoice',
turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
game = stepNexusEmperorPayment(game, drawStep, actions);
assert.equal(game.nexusCards!.cards!.hands[owner], 'emperor');
const draw = { before: beforeDraw, step: drawStep, after: structuredClone(game) };
if (options.neutralHolder ?? true) {
const irrelevant = game.nexusCards!.cards!.deck.indexOf('fremen'); assert.ok(irrelevant >= 0);
game.nexusCards!.cards!.deck.unshift(game.nexusCards!.cards!.deck.splice(irrelevant, 1)[0]);
staging.push('Ordered original undealt irrelevant Fremen Nexus singleton for the other unallied closing draw.');
game = stepNexusEmperorPayment(game, { actor: neutral, action: { type: 'nexusCardChoice',
turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, actions);
} else game = stepNexusEmperorPayment(game, { actor: neutral, action: { type: 'nexusCardChoice',
turn: game.turn, card: null, choice: 'keep', ownRedraws: 0 } }, actions);
const afterClosingDraw = structuredClone(game);
game = advanceNexusEmperorPayment(game, state => state.phase === 3 && !!state.auction && clean(state), actions);
assert.equal(game.turn, 2);
assert.ok(game.order.indexOf(neutral) < game.order.indexOf(owner), 'The real later storm order provides a neutral responder before Use');
const beforeFunding = structuredClone(game);
const emperorBeforeAid = nexusEmperorPaymentPlayer(game, emperor).spice;
if (matchingAid) game = stepNexusEmperorPayment(game, { actor: emperor,
action: { type: 'pledgeAid', amount: matchingAid } }, actions);
while (game.auction!.active !== winner)
game = stepNexusEmperorPayment(game, { actor: game.auction!.active, action: { type: 'passBid' } }, actions);
const beforeBid = structuredClone(game), acquired = game.auction!.cards[game.auction!.index].id;
game = stepNexusEmperorPayment(game, { actor: winner, action: { type: 'bid', amount: price, allyPayment: matchingAid } }, actions);
game = advanceNexusEmperorPayment(game, state => state.decision?.kind === 'nexusEmperorPayment', actions);
assert.equal(game.auction!.bid, price); assert.equal(game.auction!.bidder, winner);
assert.equal(game.auction!.cards[game.auction!.index].id, acquired);
assert.equal(nexusEmperorPaymentPlayer(game, winner).hand.some(card => card.id === acquired), false);
const decision = game.decision;
assert.ok(decision?.kind === 'nexusEmperorPayment');
nexusEmperorPaymentInventory(game);
return { game, owner, emperor, winner, neutral, event: decision.event, price, acquired, karama,
originalCards: nexusEmperorPaymentCards(game), actions, staging,
chronology: { initial, afterSetup, firstMentat, worm, alliance, draw, afterClosingDraw, beforeFunding, beforeBid },
wallets: { winner: nexusEmperorPaymentPlayer(game, winner).spice, emperorBeforeAid,
emperor: nexusEmperorPaymentPlayer(game, emperor).spice, matchingAid } };
}
+2
View File
@@ -93,6 +93,8 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
const actor = decision.player;
if (decision.kind === 'nexusFremenProtection')
return { actor, action: { type: 'nexusFremenProtection', event: decision.event, use: false } };
if (decision.kind === 'nexusEmperorPayment')
return { actor, action: { type: 'nexusEmperorPayment', event: decision.event, use: false } };
if (decision.kind === 'ixSetup') return { actor, action: { type: 'decision', card: game.ixSetupCards![0].id } };
if (decision.kind === 'mobileStronghold') return { actor, action: decision.placement ? { type: 'decision', location: 'polar_sink:0' } : { type: 'decision', decline: true } };
if (decision.kind === 'ixTechnology' || decision.kind === 'ixSubstitution' || decision.kind === 'captureOffer' || decision.kind === 'fullPlanOffer' || decision.kind === 'choamTradeReply')
+195
View File
@@ -0,0 +1,195 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
createNexusEmperorPaymentFixture, nexusEmperorPaymentCards, nexusEmperorPaymentInventory,
nexusEmperorPaymentPlayer, stepNexusEmperorPayment,
} from './fixture-nexus-emperor-payment';
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
const original = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, original, 'A rejected activation must not mutate its original source');
}
for (const advanced of [false, true]) {
for (const prepaidAid of [0, 1]) for (const cancelIncome of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} genuine allied purchase reuses ${prepaidAid} prepaid spice and ${cancelIncome ? 'cancels' : 'allows'} original Emperor income`, () => {
const f = createNexusEmperorPaymentFixture({ advanced, prepaidAid });
assert.equal(f.game.decision!.player, f.neutral);
assert.deepEqual(f.game.pendingNexusEmperorPayment!.receipt.required, [f.neutral, f.owner]);
for (const actor of [f.neutral, f.winner, f.emperor, f.owner])
assert.equal(viewGame(f.game, actor).nexusEmperorPayment, null,
'No private Emperor offer appears before its holder is current');
rejectsUnchanged(f.game, f.owner, { type: 'nexusEmperorPayment', event: f.event, use: true });
rejectsUnchanged(f.game, f.neutral, { type: 'nexusEmperorPayment', event: f.event, use: true });
const beforeNeutral = structuredClone(f.game);
let game = stepNexusEmperorPayment(f.game, { actor: f.neutral,
action: { type: 'nexusEmperorPayment', event: f.event, use: false } });
assert.equal(game.decision!.kind, 'nexusEmperorPayment');
assert.equal(game.decision!.player, f.owner);
assert.deepEqual(game.players, beforeNeutral.players, 'Neutral Continue changes neither wallet nor card custody');
assert.deepEqual(game.auction, beforeNeutral.auction, 'Neutral Continue cannot create a new bid');
const offer = viewGame(game, f.owner).nexusEmperorPayment!;
assert.equal(offer.canUse, true); assert.equal(offer.blocked, null);
for (const actor of [f.neutral, f.winner, f.emperor])
assert.equal(viewGame(game, actor).nexusEmperorPayment, null);
rejectsUnchanged(game, f.owner, { type: 'nexusEmperorPayment', event: `${f.event}:stale`, use: true });
game = stepNexusEmperorPayment(game, { actor: f.owner,
action: { type: 'nexusEmperorPayment', event: f.event, use: true } });
assert.equal(game.pendingNexusEmperorPayment, null);
assert.equal(game.nexusEmperorPayment!.current, null);
const receipt = game.nexusEmperorPayment!.completed.at(-1)!;
assert.equal(receipt.event, f.event); assert.equal(receipt.stage, 'used');
assert.equal(receipt.owner, f.owner); assert.deepEqual(receipt.passed, [f.neutral]);
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
assert.equal(game.nexusCards!.cards!.hands[f.neutral], 'fremen');
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner,
'Full forced funding leaves the buyer’s original balance unchanged');
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor - (f.price - prepaidAid),
'Prepaid credit is already deducted; only the missing spice is newly escrowed');
assert.deepEqual(game.aid[f.emperor], { recipient: f.winner, amount: 0 });
assert.equal(game.auction!.bid, f.price); assert.equal(game.auction!.bidder, f.winner);
assert.equal(game.auction!.allyPayment, f.price);
assert.equal(game.auction!.index, f.game.auction!.index);
assert.equal(game.currentAuctionSale!.origin, 'normal');
assert.equal(game.currentAuctionSale!.winner, f.winner);
assert.equal(game.currentAuctionSale!.amount, f.price); assert.equal(game.currentAuctionSale!.free, false);
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(card => card.id === f.acquired).length, 1);
assert.equal(game.response!.kind, 'emperorIncome', 'Only the original native income cancellation window opens');
assert.equal(game.response!.owner, f.emperor);
assert.ok(!game.pendingKarama);
rejectsUnchanged(game, f.owner, { type: 'nexusEmperorPayment', event: f.event, use: true });
if (cancelIncome) {
game = stepNexusEmperorPayment(game, { actor: f.neutral,
action: { type: 'card', mode: 'cancel', card: f.karama } });
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
} else {
while (game.response?.kind === 'emperorIncome') {
const actor = game.players.find(player => !game.response!.passed.includes(player.id))!.id;
game = stepNexusEmperorPayment(game, { actor, action: { type: 'passResponse' } });
}
}
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice,
f.wallets.emperorBeforeAid - (cancelIncome ? f.price : 0),
'Original income is credited once or canceled without refunding the original forced payment');
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner);
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(card => card.id === f.acquired).length, 1);
assert.equal(game.auction!.index, f.game.auction!.index + 1, 'Original successor lot advances exactly once');
assert.equal(game.response!.kind, 'atreidesAuction', 'Original next-lot inspection is restored');
assert.equal(game.nexusEmperorPayment!.completed.length, 1);
assert.deepEqual(nexusEmperorPaymentCards(game), f.originalCards);
nexusEmperorPaymentInventory(game);
},
);
for (const prepaidAid of [0, 1, 2]) void test(
`${advanced ? 'Advanced' : 'Basic'} every real acknowledgement passes and preserves ordinary payment with ${prepaidAid} pledged spice`, () => {
const f = createNexusEmperorPaymentFixture({ advanced, prepaidAid });
let game = f.game;
while (game.decision?.kind === 'nexusEmperorPayment') {
const actor = game.decision.player;
if (actor === f.owner && prepaidAid === f.price) {
assert.equal(viewGame(game, actor).nexusEmperorPayment!.canUse, false);
rejectsUnchanged(game, actor, { type: 'nexusEmperorPayment', event: f.event, use: true });
}
game = stepNexusEmperorPayment(game, { actor,
action: { type: 'nexusEmperorPayment', event: f.event, use: false } });
}
assert.equal(game.decision!.kind, 'auctionPayment'); assert.equal(game.decision!.player, f.winner);
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner);
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor);
assert.equal(game.nexusCards!.cards!.hands[f.owner], 'emperor');
assert.equal(game.nexusEmperorPayment!.completed.at(-1)!.stage, 'passed');
assert.equal(game.auction!.bid, f.price); assert.equal(game.auction!.allyPayment, prepaidAid);
game = stepNexusEmperorPayment(game, { actor: f.winner, action: { type: 'decision', karama: false } });
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner - f.price + prepaidAid);
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor);
assert.equal(game.response!.kind, 'emperorIncome');
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(card => card.id === f.acquired).length, 1);
assert.equal(game.nexusEmperorPayment!.completed.length, 1, 'Ordinary settlement does not reopen the same obligation');
nexusEmperorPaymentInventory(game);
},
);
}
void test('real neutral response rejects stale events, wrong actors, extra keys and coercion atomically', () => {
const f = createNexusEmperorPaymentFixture();
for (const actor of [f.owner, f.emperor, f.winner])
rejectsUnchanged(f.game, actor, { type: 'nexusEmperorPayment', event: f.event, use: false });
for (const action of [
{ type: 'nexusEmperorPayment', event: 'not-the-original-event', use: false },
{ type: 'nexusEmperorPayment', event: f.event, use: false, amount: 2 },
{ type: 'nexusEmperorPayment', event: f.event, use: false, policy: 'full' },
{ type: 'nexusEmperorPayment', event: f.event, use: 'false' },
{ type: 'nexusEmperorPayment', event: f.event },
{ type: 'decision', event: f.event, use: false },
]) rejectsUnchanged(f.game, f.neutral, action);
const ownerTurn = stepNexusEmperorPayment(f.game, { actor: f.neutral,
action: { type: 'nexusEmperorPayment', event: f.event, use: false } });
rejectsUnchanged(ownerTurn, f.neutral, { type: 'nexusEmperorPayment', event: f.event, use: false });
rejectsUnchanged(ownerTurn, f.owner, { type: 'nexusEmperorPayment', event: f.event, use: true, amount: 2 });
});
void test('a real closing draw without the irrelevant holder gives only the actual remaining public responder', () => {
const f = createNexusEmperorPaymentFixture({ neutralHolder: false });
assert.equal(f.game.decision!.player, f.owner);
assert.deepEqual(f.game.pendingNexusEmperorPayment!.receipt.required, [f.owner]);
assert.equal(viewGame(f.game, f.owner).nexusEmperorPayment!.canUse, true);
assert.equal(f.game.nexusCards!.cards!.hands[f.neutral], null);
const game = stepNexusEmperorPayment(f.game, { actor: f.owner,
action: { type: 'nexusEmperorPayment', event: f.event, use: true } });
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner);
assert.equal(game.response!.kind, 'emperorIncome');
nexusEmperorPaymentInventory(game);
});
void test('naturally setup-dealt Truthtrance has priority over the original acknowledgement and preserves its event', () => {
const f = createNexusEmperorPaymentFixture({ truthtrance: true });
const card = nexusEmperorPaymentPlayer(f.game, f.neutral).hand.find(held => held.effect === 'truthtrance')!;
assert.ok(card);
let game = stepNexusEmperorPayment(f.game, { actor: f.neutral, action: { type: 'card', card: card.id } });
assert.equal(game.truthtrance!.stage, 'priority');
assert.equal(game.pendingNexusEmperorPayment!.receipt.event, f.event);
rejectsUnchanged(game, f.neutral, { type: 'nexusEmperorPayment', event: f.event, use: false });
rejectsUnchanged(game, f.owner, { type: 'nexusEmperorPayment', event: f.event, use: true });
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id;
game = stepNexusEmperorPayment(game, { actor, action: { type: 'truthPass' } });
}
game = stepNexusEmperorPayment(game, { actor: f.neutral, action: { type: 'truthAsk',
question: { kind: 'fact', target: f.winner, fact: { kind: 'hand', name: 'Karama' } } } });
assert.equal(viewGame(game, f.winner).truthAnswer, 'yes');
game = stepNexusEmperorPayment(game, { actor: f.winner, action: { type: 'truthAnswer', answer: 'yes' } });
assert.equal(game.truthtrance, null);
assert.equal(game.decision!.kind, 'nexusEmperorPayment');
assert.equal(game.decision!.player, f.neutral);
assert.equal(game.pendingNexusEmperorPayment!.receipt.event, f.event);
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner);
assert.equal(nexusEmperorPaymentPlayer(game, f.emperor).spice, f.wallets.emperor);
assert.equal(game.discard.filter(held => held.id === card.id).length, 1);
game = stepNexusEmperorPayment(game, { actor: f.neutral,
action: { type: 'nexusEmperorPayment', event: f.event, use: false } });
game = stepNexusEmperorPayment(game, { actor: f.owner,
action: { type: 'nexusEmperorPayment', event: f.event, use: true } });
assert.equal(game.response!.kind, 'emperorIncome');
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).hand.filter(held => held.id === f.acquired).length, 1);
nexusEmperorPaymentInventory(game);
});
void test('original Easy bot policy passes for an irrelevant real holder then uses its own eligible Emperor Nexus', () => {
const f = createNexusEmperorPaymentFixture();
let game = f.game;
for (const actor of [f.neutral, f.owner]) {
const view = viewGame(game, actor);
view.players.find(player => player.id === actor)!.bot = 'Easy';
const action = botActions(view)[0];
game = stepNexusEmperorPayment(game, { actor, action });
}
assert.equal(game.response!.kind, 'emperorIncome');
assert.equal(nexusEmperorPaymentPlayer(game, f.winner).spice, f.wallets.winner);
assert.equal(game.nexusEmperorPayment!.completed.at(-1)!.stage, 'used');
nexusEmperorPaymentInventory(game);
});
+306
View File
@@ -0,0 +1,306 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import type { Game } from '../game/engine';
import { createNexusCards, discardNexusCard } from '../game/nexus-cards';
import {
createEmperorPaymentReceipt,
emperorPaymentReceiptSignature,
emperorPaymentSourceQuote,
emperorPaymentUseBlock,
requiredEmperorPaymentResponders,
validateEmperorPaymentReceipt,
type EmperorPaymentReceipt,
} from '../game/nexus-emperor-payment';
/** Pure helper inputs, not a staged runtime game or an engine-action fixture. */
function obligation(price = 8, spice = 8, prepaid = 0, committed = 0): Game {
const players = [
{ id: 'emperor', faction: 'emperor' as const, ally: 'buyer', spice, hand: [] },
{ id: 'buyer', faction: 'atreides' as const, ally: 'emperor', spice: price, hand: [] },
{ id: 'first', faction: 'harkonnen' as const, ally: null, spice: 0, hand: [] },
{ id: 'owner', faction: 'guild' as const, ally: null, spice: 0, hand: [] },
{ id: 'allied', faction: 'fremen' as const, ally: 'partner', spice: 0, hand: [] },
{ id: 'partner', faction: 'beneGesserit' as const, ally: 'allied', spice: 0, hand: [] },
];
const cards = createNexusCards(players, () => 0.5);
for (const [owner, face] of [['first', 'harkonnen'], ['owner', 'emperor'], ['allied', 'atreides']] as const) {
cards.deck.splice(cards.deck.indexOf(face), 1);
cards.hands[owner] = face;
}
const deck = baseDeck();
const card = deck.shift()!;
return {
status: 'playing', phase: 3, turn: 2, players,
order: ['first', 'emperor', 'allied', 'owner', 'buyer', 'partner'],
active: 'buyer', deck, discard: [],
auction: { cards: [card], index: 0, bid: price, bidder: 'buyer',
active: 'buyer', opener: 0, passed: ['first', 'owner', 'allied', 'partner', 'emperor'],
allyPayment: committed },
aid: prepaid ? { emperor: { recipient: 'buyer', amount: prepaid } } : {},
currentAuctionSale: null, richeseAuction: null, decision: null, response: null,
nexusCards: { cards, phase: null },
} as unknown as Game;
}
function open(g: Game): EmperorPaymentReceipt {
const receipt = createEmperorPaymentReceipt(g, 'original-obligation', requiredEmperorPaymentResponders(g));
g.nexusEmperorPayment = { sequence: 1, current: receipt.event, completed: [] };
g.pendingNexusEmperorPayment = { receipt };
g.active = receipt.required[receipt.passed.length];
g.decision = { kind: 'nexusEmperorPayment', player: g.active, event: receipt.event };
return receipt;
}
function passFirst(g: Game, receipt: EmperorPaymentReceipt): EmperorPaymentReceipt {
const next: EmperorPaymentReceipt = { ...receipt, passed: ['first'] };
next.signature = emperorPaymentReceiptSignature(next);
g.pendingNexusEmperorPayment = { receipt: next };
g.active = 'owner';
g.decision = { kind: 'nexusEmperorPayment', player: 'owner', event: next.event };
return next;
}
void test('full price, not the buyer shortfall, determines native funding without mutation', () => {
const g = obligation(8, 8);
g.players.find(player => player.id === 'buyer')!.spice = 100;
const before = structuredClone(g);
const quote = emperorPaymentSourceQuote(g)!;
assert.equal(quote.price, 8);
assert.equal(quote.prepaid, 0);
assert.equal(quote.missing, 8);
assert.equal(quote.emperorSpice, 8);
assert.equal(quote.blocked, null);
assert.deepEqual(g, before);
const receipt = passFirst(g, open(g));
assert.equal(emperorPaymentUseBlock(g, receipt, 'owner'), null);
assert.equal(g.players.find(player => player.id === 'emperor')!.spice, 8);
assert.equal(g.auction!.bid, 8);
assert.equal(g.auction!.allyPayment, 0);
});
void test('all matching prepaid aid counts, including more than the original committed split', () => {
for (const [prepaid, committed, missing] of [[3, 0, 5], [3, 2, 5], [7, 7, 1]] as const) {
const g = obligation(8, missing, prepaid, committed);
const quote = emperorPaymentSourceQuote(g)!;
assert.equal(quote.prepaid, prepaid);
assert.equal(quote.missing, missing);
assert.equal(quote.allyPayment, committed);
assert.equal(quote.blocked, null);
const receipt = passFirst(g, open(g));
assert.equal(emperorPaymentUseBlock(g, receipt, 'owner'), null);
assert.deepEqual(g.aid.emperor, { recipient: 'buyer', amount: prepaid });
}
});
void test('full prepaid price and excess escrow require no extra funding or refund', () => {
for (const prepaid of [8, 12]) {
const g = obligation(8, 0, prepaid, 2);
const quote = emperorPaymentSourceQuote(g)!;
assert.equal(quote.prepaid, 8);
assert.equal(quote.missing, 0);
assert.ok(quote.blocked);
const receipt = passFirst(g, open(g));
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
assert.equal(g.aid.emperor.amount, prepaid);
assert.equal(receipt.aid.amount, prepaid);
}
});
void test('native own spice must cover the entire missing price, never eventual income', () => {
for (const [price, spice, prepaid] of [[8, 7, 0], [8, 4, 3], [100, 1, 50]] as const) {
const g = obligation(price, spice, prepaid);
const quote = emperorPaymentSourceQuote(g)!;
assert.equal(quote.missing, price - prepaid);
assert.ok(quote.blocked);
const receipt = passFirst(g, open(g));
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
assert.equal(g.players.find(player => player.id === 'emperor')!.spice, spice);
}
});
void test('safe-integer edge arithmetic subtracts credit without overflowing a wallet sum', () => {
const g = obligation(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER - 1, 1);
const quote = emperorPaymentSourceQuote(g)!;
assert.equal(quote.missing, Number.MAX_SAFE_INTEGER - 1);
assert.equal(quote.blocked, null);
const receipt = passFirst(g, open(g));
assert.equal(emperorPaymentUseBlock(g, receipt, 'owner'), null);
});
void test('wrong-recipient escrow and lost committed credit cannot fund the original invoice', () => {
const g = obligation(8, 8, 3);
g.aid.emperor.recipient = 'first';
assert.equal(emperorPaymentSourceQuote(g)!.prepaid, 0);
assert.equal(emperorPaymentSourceQuote(g)!.missing, 8);
assert.ok(emperorPaymentSourceQuote(g)!.blocked);
const receipt = passFirst(g, open(g));
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
const lost = obligation(8, 8, 1, 2);
assert.ok(emperorPaymentSourceQuote(lost)!.blocked);
const lostReceipt = passFirst(lost, open(lost));
assert.ok(emperorPaymentUseBlock(lost, lostReceipt, 'owner'));
});
void test('public storm roster excludes native Emperor, buyer, allies and empty holders independent of faces', () => {
const g = obligation();
assert.deepEqual(requiredEmperorPaymentResponders(g), ['first', 'owner']);
const hands = g.nexusCards!.cards!.hands;
[hands.first, hands.owner] = [hands.owner, hands.first];
g.players.find(player => player.id === 'emperor')!.spice = 0;
assert.deepEqual(requiredEmperorPaymentResponders(g), ['first', 'owner']);
hands.first = null;
assert.deepEqual(requiredEmperorPaymentResponders(g), ['owner']);
g.order.reverse();
hands.first = 'emperor';
assert.deepEqual(requiredEmperorPaymentResponders(g), ['owner', 'first']);
});
void test('source clears outside the original positive normal mutual-allied obligation', () => {
const changes: ((g: Game) => void)[] = [
g => { g.phase = 4; },
g => { g.auction = null; },
g => { g.auction!.bid = 0; },
g => { g.auction!.bidder = 'emperor'; },
g => { g.players.find(player => player.id === 'emperor')!.ally = null; },
g => { g.players.find(player => player.id === 'buyer')!.ally = null; },
g => { g.currentAuctionSale = { origin: 'normal', winner: 'buyer', amount: 8, free: false, seller: null }; },
g => { g.richeseAuction = {} as NonNullable<Game['richeseAuction']>; },
];
for (const change of changes) {
const g = obligation();
change(g);
assert.equal(emperorPaymentSourceQuote(g), null);
}
});
void test('current lot cannot be already acquired or duplicate an unsold physical card', () => {
for (const zone of ['hand', 'deck', 'discard', 'unsold'] as const) {
const g = obligation();
const card = g.auction!.cards[0];
if (zone === 'hand') g.players[1].hand.push(card);
else if (zone === 'unsold') g.auction!.cards.push(card);
else g[zone].push(card);
assert.equal(emperorPaymentSourceQuote(g), null);
}
const g = obligation();
g.auction!.cards.unshift(g.auction!.cards[0]);
g.auction!.index = 1;
assert.ok(emperorPaymentSourceQuote(g));
});
void test('Use belongs only to the current exact physical holder and freezes the original event', () => {
const g = obligation();
const initial = open(g);
assert.ok(emperorPaymentUseBlock(g, initial, 'owner'));
assert.ok(emperorPaymentUseBlock(g, initial, 'first'));
const receipt = passFirst(g, initial);
assert.equal(emperorPaymentUseBlock(g, receipt, 'owner'), null);
assert.ok(emperorPaymentUseBlock(g, initial, 'owner'));
g.nexusEmperorPayment!.current = 'another-obligation';
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
g.nexusEmperorPayment!.current = receipt.event;
g.decision = { kind: 'auctionPayment', player: 'buyer' };
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
g.decision = { kind: 'nexusEmperorPayment', player: 'owner', event: receipt.event };
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'owner', g.players);
assert.ok(emperorPaymentUseBlock(g, receipt, 'owner'));
});
void test('original acknowledgement membership survives an earlier holder spending their card', () => {
const g = obligation();
const receipt = passFirst(g, open(g));
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'first', g.players);
assert.deepEqual(requiredEmperorPaymentResponders(g), ['owner']);
validateEmperorPaymentReceipt(g, receipt);
assert.deepEqual(receipt.required, ['first', 'owner']);
assert.equal(emperorPaymentUseBlock(g, receipt, 'owner'), null);
});
void test('snapshot is detached and pending validation rejects changes to each original source fact', () => {
const changes: ((g: Game) => void)[] = [
g => { g.auction!.bid++; },
g => { g.auction!.cards[0] = g.deck.shift()!; },
g => { g.auction!.cards.unshift(g.deck.shift()!); g.auction!.index = 1; },
g => { g.auction!.bidder = 'first'; },
g => { g.auction!.allyPayment = 1; },
g => { g.aid.emperor.amount++; },
g => { g.aid.emperor.recipient = 'first'; },
g => { g.players.find(player => player.id === 'emperor')!.spice++; },
g => { g.players.find(player => player.id === 'buyer')!.ally = null; },
];
for (const change of changes) {
const g = obligation(8, 8, 2);
const receipt = open(g);
const snapshot = structuredClone(receipt);
change(g);
assert.deepEqual(receipt, snapshot);
assert.throws(() => validateEmperorPaymentReceipt(g, receipt));
assert.ok(emperorPaymentUseBlock(g, receipt, 'first'));
}
const g = obligation();
const required = requiredEmperorPaymentResponders(g);
const receipt = createEmperorPaymentReceipt(g, 'original', required);
required.reverse();
assert.deepEqual(receipt.required, ['first', 'owner']);
});
void test('duplicate, reordered and incomplete acknowledgements and unsigned snapshots fail closed', () => {
const g = obligation();
const original = open(g);
for (const changed of [
{ passed: ['first', 'first'] }, { passed: ['owner'] },
{ required: ['first', 'first'] }, { holders: ['first', 'first'] },
{ stage: 'passed' }, { stage: 'used', owner: 'owner' },
{ price: 0 }, { emperorSpice: -1 }, { phase: 4 },
{ aid: { recipient: null, amount: 1 } }, { extra: true },
]) {
const receipt = { ...structuredClone(original), ...changed } as EmperorPaymentReceipt;
receipt.signature = emperorPaymentReceiptSignature(receipt);
assert.throws(() => validateEmperorPaymentReceipt(g, receipt));
}
const unsigned = structuredClone(original);
unsigned.passed.push('first');
assert.throws(() => validateEmperorPaymentReceipt(g, unsigned));
const current = passFirst(g, original);
g.pendingNexusEmperorPayment!.receipt.passed.push('first');
assert.ok(emperorPaymentUseBlock(g, current, 'owner'));
});
void test('completed Use and allpass receipts validate history without requiring old wallets or custody', () => {
for (const stage of ['used', 'passed'] as const) {
const g = obligation(8, 5, 3, 2);
const original = open(g);
const receipt: EmperorPaymentReceipt = stage === 'used'
? { ...original, passed: ['first'], stage, owner: 'owner' }
: { ...original, passed: ['first', 'owner'], stage, owner: null };
receipt.signature = emperorPaymentReceiptSignature(receipt);
validateEmperorPaymentReceipt(g, receipt);
g.auction = null;
g.aid = {};
g.players.forEach(player => { player.spice = 0; player.ally = null; });
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'owner', g.players);
g.turn++;
g.phase = 1;
g.order.reverse();
validateEmperorPaymentReceipt(g, JSON.parse(JSON.stringify(receipt)) as EmperorPaymentReceipt);
if (stage === 'used') {
const unfunded = { ...receipt, emperorSpice: 4 };
unfunded.signature = emperorPaymentReceiptSignature(unfunded);
assert.throws(() => validateEmperorPaymentReceipt(g, unfunded));
}
}
});
void test('malformed price, split and escrow counts never produce an arithmetic entitlement', () => {
for (const invalid of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
for (const field of ['price', 'split', 'spice', 'escrow'] as const) {
const g = obligation(8, 8, 3);
if (field === 'price') g.auction!.bid = invalid;
else if (field === 'split') g.auction!.allyPayment = invalid;
else if (field === 'spice') g.players[0].spice = invalid;
else g.aid.emperor.amount = invalid;
assert.equal(emperorPaymentSourceQuote(g), null);
}
}
});