Integrate exact Ixian Nexus purchased-card replacement
This commit is contained in:
1 parent
c43ab64872
commit
a2b04dbfba
26 files changed
+4588
-34
No files matched your search
@@ -176,6 +176,19 @@ this is not a second shipment, free upfront financing or extra Guild income.
|
|||||||
See [payment custody and source boundaries](docs/NEXUS_GUILD_RULES.md#betrayal-payment-replacement).
|
See [payment custody and source boundaries](docs/NEXUS_GUILD_RULES.md#betrayal-payment-replacement).
|
||||||
Existing games and public start gates remain unchanged.
|
Existing games and public start gates remain unchanged.
|
||||||
|
|
||||||
|
For local **Ixian Nexus purchased-card replacement**, use
|
||||||
|
`node --import tsx tools/start-prototype.ts --profile ixian-replacement`
|
||||||
|
on a fresh ready classic Basic/Advanced lobby with base Treachery and Nexus
|
||||||
|
alone. Exact-version/private-backup safeguards apply; existing games and
|
||||||
|
public starts are unchanged. A real alliance-qualified Nexus must first
|
||||||
|
give an unallied buyer its card, so effect scenarios need at least three
|
||||||
|
seats. **Keep purchased card** retains both cards; eligible **Use Ixian Nexus**
|
||||||
|
discards exactly the normal paid/printed-Karama purchase before drawing the
|
||||||
|
real deck top, with the original payment and auction continuation once.
|
||||||
|
Full hands and same-card redraw from an exhausted deck are supported.
|
||||||
|
Harkonnen buyers and special sale origins remain guarded; see
|
||||||
|
[verified source, privacy and runtime boundaries](docs/NEXUS_IXIAN_REPLACEMENT_RULES.md).
|
||||||
|
|
||||||
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
|
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
|
||||||
counts separately from hands, auction cards and discards. See
|
counts separately from hands, auction cards and discards. See
|
||||||
[draw-pile counts](docs/DRAW_PILES.md).
|
[draw-pile counts](docs/DRAW_PILES.md).
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import { SemutaReaction } from './semuta-reaction';
|
|||||||
import { ChoamKull } from './choam-kull';
|
import { ChoamKull } from './choam-kull';
|
||||||
import { NexusRicheseBetrayal } from './nexus-richese-betrayal';
|
import { NexusRicheseBetrayal } from './nexus-richese-betrayal';
|
||||||
import { NexusGuildBetrayal } from './nexus-guild-betrayal';
|
import { NexusGuildBetrayal } from './nexus-guild-betrayal';
|
||||||
|
import { NexusIxianReplacement } from './nexus-ixian-replacement';
|
||||||
import { OrnithopterMovement } from './ornithopter-movement';
|
import { OrnithopterMovement } from './ornithopter-movement';
|
||||||
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
|
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
|
||||||
import { PlanetologistMovement } from './planetologist-movement';
|
import { PlanetologistMovement } from './planetologist-movement';
|
||||||
@@ -305,8 +306,8 @@ export function GameTable({
|
|||||||
);
|
);
|
||||||
const reactionBusy =
|
const reactionBusy =
|
||||||
transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot;
|
transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot;
|
||||||
const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction;
|
const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement;
|
||||||
const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction;
|
const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement;
|
||||||
const canActivateKarama = (card: Card) =>
|
const canActivateKarama = (card: Card) =>
|
||||||
!g.karamaBlocked && canUseAsKaramaRole(g, me, card);
|
!g.karamaBlocked && canUseAsKaramaRole(g, me, card);
|
||||||
const traitorBattle = g.battle;
|
const traitorBattle = g.battle;
|
||||||
@@ -790,6 +791,13 @@ export function GameTable({
|
|||||||
a.event !== guildReaction.event ||
|
a.event !== guildReaction.event ||
|
||||||
(a.type === 'guildBetrayalUse' && (!guildReaction.canUse || guildReaction.blocked))) return;
|
(a.type === 'guildBetrayalUse' && (!guildReaction.canUse || guildReaction.blocked))) return;
|
||||||
}
|
}
|
||||||
|
const replacement = g.nexusIxianReplacement;
|
||||||
|
if (replacement && a.type !== 'setAutopilot') {
|
||||||
|
if (reactionBusy || g.status !== 'playing' || !replacement.canPass || replacement.buyer !== me.id ||
|
||||||
|
(a.type !== 'nexusIxianReplacementPass' && a.type !== 'nexusIxianReplacementUse') ||
|
||||||
|
a.event !== replacement.event ||
|
||||||
|
(a.type === 'nexusIxianReplacementUse' && (!replacement.canUse || replacement.blocked))) return;
|
||||||
|
}
|
||||||
void send(a);
|
void send(a);
|
||||||
};
|
};
|
||||||
const choose = (id: string) => {
|
const choose = (id: string) => {
|
||||||
@@ -1077,6 +1085,14 @@ export function GameTable({
|
|||||||
<a href="/rules?topic=nexus-guild-betrayal#nexus-guild-betrayal">Preview rules and limits</a>
|
<a href="/rules?topic=nexus-guild-betrayal#nexus-guild-betrayal">Preview rules and limits</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{g.nexusIxianReplacementPreview && (
|
||||||
|
<p className="notice" role="status">
|
||||||
|
Ixian Nexus purchased-card replacement development preview · classic Basic/Advanced,
|
||||||
|
base Treachery and Nexus only. Normal paid and printed Karama purchases are supported;
|
||||||
|
Harkonnen buyers and special auctions are guarded. Not complete Ixian or certified Nexus rules.{' '}
|
||||||
|
<a href="/rules?topic=nexus-ixian-replacement#nexus-ixian-replacement">Preview rules and limits</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{g.status !== 'lobby' && (
|
{g.status !== 'lobby' && (
|
||||||
<nav className="phase-track" aria-label="Turn phases">
|
<nav className="phase-track" aria-label="Turn phases">
|
||||||
{PHASES.map((p, i) => (
|
{PHASES.map((p, i) => (
|
||||||
@@ -1721,6 +1737,8 @@ export function GameTable({
|
|||||||
<NexusRicheseBetrayal game={g} act={act} busy={reactionBusy} />
|
<NexusRicheseBetrayal game={g} act={act} busy={reactionBusy} />
|
||||||
) : g.kullReaction ? (
|
) : g.kullReaction ? (
|
||||||
<ChoamKull game={g} act={act} busy={reactionBusy} />
|
<ChoamKull game={g} act={act} busy={reactionBusy} />
|
||||||
|
) : g.nexusIxianReplacement ? (
|
||||||
|
<NexusIxianReplacement game={g} act={act} busy={reactionBusy} />
|
||||||
) : g.nexusCards?.waiting.length ? (
|
) : g.nexusCards?.waiting.length ? (
|
||||||
<p className="muted">The next phase begins when the remaining Nexus card choices are finished.</p>
|
<p className="muted">The next phase begins when the remaining Nexus card choices are finished.</p>
|
||||||
) : g.truthtrance ? (
|
) : g.truthtrance ? (
|
||||||
@@ -2607,6 +2625,8 @@ export function GameTable({
|
|||||||
{g.players.find((p) => p.id === g.decision?.player)?.name} to
|
{g.players.find((p) => p.id === g.decision?.player)?.name} to
|
||||||
complete this decision.
|
complete this decision.
|
||||||
</p>
|
</p>
|
||||||
|
) : g.decision.kind === 'nexusIxianReplacement' ? (
|
||||||
|
<NexusIxianReplacement game={g} act={act} busy={reactionBusy} />
|
||||||
) : g.decision.kind === 'nullentropy' ? (
|
) : g.decision.kind === 'nullentropy' ? (
|
||||||
<NullentropySearch game={g} act={act} busy={busy} />
|
<NullentropySearch game={g} act={act} busy={busy} />
|
||||||
) : g.decision.kind === 'richeseAllyOpportunity' ? (
|
) : g.decision.kind === 'richeseAllyOpportunity' ? (
|
||||||
@@ -3484,7 +3504,7 @@ export function GameTable({
|
|||||||
accept: false,
|
accept: false,
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : g.decision.kind === 'battleCards' && (
|
||||||
<>
|
<>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
You may keep or discard each card played in this battle.
|
You may keep or discard each card played in this battle.
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { Action, GameView } from '@/game/engine';
|
||||||
|
import { nexusCardMode } from '@/game/nexus-cards';
|
||||||
|
import { CardInspector, CardRules } from './card-inspector';
|
||||||
|
import { NexusCardFace } from './nexus-cards';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
/** The public boundary is neutral; only the buyer's projection authorizes a choice. */
|
||||||
|
export function NexusIxianReplacement({ game, act, busy }: {
|
||||||
|
game: GameView;
|
||||||
|
act: (action: Action) => void;
|
||||||
|
busy: boolean;
|
||||||
|
}) {
|
||||||
|
const offer = game.nexusIxianReplacement;
|
||||||
|
if (!offer) return null;
|
||||||
|
const buyer = game.players.find((player) => player.id === offer.buyer);
|
||||||
|
const own = game.players.find((player) => player.id === game.me);
|
||||||
|
const canRespond = !!offer.event && offer.buyer === game.me && offer.canPass;
|
||||||
|
const canUse = canRespond && offer.canUse && !offer.blocked && !!offer.purchased;
|
||||||
|
const priorityBlocked = !!game.truthtrance || !!game.response || !!game.phaseOpening ||
|
||||||
|
!!game.automaticContinuationPending || !!game.kullReaction || !!game.kullCounterEvent ||
|
||||||
|
!!game.richeseBetrayalReaction || !!game.guildBetrayalReaction || !!game.semutaReaction ||
|
||||||
|
!!game.nexusCards?.waiting.length || !!game.nexusTraitors?.pending ||
|
||||||
|
(!!game.decision && (game.decision.kind !== 'nexusIxianReplacement' ||
|
||||||
|
game.decision.player !== offer.buyer || game.decision.event !== offer.event));
|
||||||
|
const disabled = busy || game.status !== 'playing' || game.phase !== 3 || priorityBlocked ||
|
||||||
|
!!game.roomControl?.paused || !!game.roomControl?.closed || !!own?.autopilot;
|
||||||
|
const ownIxianMode = game.nexusCards?.card === 'ixians' && own
|
||||||
|
? nexusCardMode('ixians', own.faction, game.players.map((player) => player.faction))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Purchased card choice" data-testid="nexus-ixian-replacement" className="notice min-w-0 space-y-3">
|
||||||
|
<h3>Purchased card choice</h3>
|
||||||
|
<p>{buyer?.name ?? 'The buyer'} chooses whether to keep the completed purchase before the auction continues.</p>
|
||||||
|
<p className="fine">
|
||||||
|
This neutral development-preview boundary is offered independently of the buyer’s private Nexus face.
|
||||||
|
Only the buyer can choose. The original payment stays paid; this is not another purchase or auction.{' '}
|
||||||
|
<a href="/rules?topic=nexus-ixian-replacement#nexus-ixian-replacement">Purchased-card timing and preview limits</a>
|
||||||
|
</p>
|
||||||
|
{canRespond && offer.purchased && (
|
||||||
|
<article aria-label="Your purchased Treachery Card" className="treachery-card">
|
||||||
|
<h4>{offer.purchased.name}</h4>
|
||||||
|
<CardRules card={offer.purchased} />
|
||||||
|
<CardInspector card={offer.purchased} />
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
{game.nexusCards?.card === 'ixians' && (
|
||||||
|
<div aria-label="Your private Ixian Nexus card">
|
||||||
|
<NexusCardFace card="ixians" mode={ownIxianMode} />
|
||||||
|
{canRespond && <p className="fine">
|
||||||
|
Secret Ally requires you to be unallied with native Ixians absent. In this preview,
|
||||||
|
eligible Use spends your Ixian Nexus and discards only the exact Treachery Card just purchased,
|
||||||
|
then draws the actual next card from the Treachery Deck. It does not take an unsold auction card.
|
||||||
|
If the deck is empty, the discard is reshuffled after your purchased card enters it;
|
||||||
|
drawing that same physical card again is legal. Keeping retains both cards.
|
||||||
|
</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!canRespond ? (
|
||||||
|
<p aria-live="polite">Waiting for {buyer?.name ?? 'the buyer'}’s purchased card choice.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{offer.blocked && <p className="fine">{offer.blocked}</p>}
|
||||||
|
<Button data-testid="nexus-ixian-replacement-pass" className="game-action min-h-11 whitespace-normal" disabled={disabled}
|
||||||
|
onClick={() => { if (!disabled) act({ type: 'nexusIxianReplacementPass', event: offer.event }); }}>
|
||||||
|
Keep purchased card
|
||||||
|
</Button>
|
||||||
|
{canUse && (
|
||||||
|
<Button data-testid="nexus-ixian-replacement-use" className="game-action min-h-11 whitespace-normal" disabled={disabled}
|
||||||
|
onClick={() => { if (!disabled) act({ type: 'nexusIxianReplacementUse', event: offer.event }); }}>
|
||||||
|
Use Ixian Nexus · replace purchased card
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+22
-9
@@ -16,15 +16,28 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
Five implementation lanes now connect the missing Ixian Nexus Secret Ally
|
Five implementation lanes connected the missing Ixian Nexus Secret Ally
|
||||||
purchased-card replacement in a fresh local `ixian-replacement` prototype:
|
purchased-card replacement through fresh local `ixian-replacement`: exact
|
||||||
exact paid normal-auction source, private neutral buyer controls, pure
|
normal paid/printed-Karama source, own private controls, native discard/draw
|
||||||
physical custody/history, legal bots, authenticated recovery/CLI entry and
|
and original fee/income/auction suffix once. Full hands and genuinely
|
||||||
existing source guidance. Original fees and native auction continuation
|
depleted-pool same-card redraw are supported. Native Nexus still requires
|
||||||
must complete once. Richese-origin and Harkonnen-buyer priority questions
|
a settled alliance and has no initial deal; actual effects need three seats.
|
||||||
remain gated; no public start, existing-game retrofit or full-card claim.
|
Richese origins, Harkonnen buyers, wider modules and public starts stay gated.
|
||||||
The parent retains actual runtime/UI, frozen checks/review and shipping.
|
Independent review repaired recursive saved-history growth and silently
|
||||||
This next wave is **in progress**, not exercised or completed evidence.
|
dropped seat-control actions. Nine Pass records now serialize to 134,145
|
||||||
|
bytes rather than 7,030,825; nonrecursive links retain source/replay checks.
|
||||||
|
Actual pending-choice Take back control now sends one 200 and preserves
|
||||||
|
the same source. Both final scoped rules/privacy reviews have no finding.
|
||||||
|
Types, lint, **6,227 offline tests**, **73 selected cases** and build pass.
|
||||||
|
Fourteen standalone native scenarios retain exact original ledger/custody,
|
||||||
|
including captured legal six-seat depletion with four reserved lots.
|
||||||
|
Three privately backed-up same-setup CLI/390-pixel QA rooms prove Basic
|
||||||
|
paid Use, Advanced printed free Use, irrelevant-face Keep, inspection,
|
||||||
|
takeback and refresh. Saved proof preserves Treachery/Nexus/forces/elites
|
||||||
|
and eleven prior QA rooms; all **42 original seats in fourteen rooms**
|
||||||
|
reauthenticate after isolated build reload. Actual expanded guide shows
|
||||||
|
Partial / Development Verified. Local HTTP is **51/55**, four POST 503s,
|
||||||
|
not green. Full families, modes, strategy and live acceptance remain open.
|
||||||
|
|
||||||
Five implementation lanes reconciled two source-clear Advanced defaults
|
Five implementation lanes reconciled two source-clear Advanced defaults
|
||||||
from the authorized revised rulebook: all new post-first Storm openings
|
from the authorized revised rulebook: all new post-first Storm openings
|
||||||
|
|||||||
@@ -1,5 +1,81 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 1 October 2026 — exact Ixian Nexus purchased-card replacement
|
||||||
|
|
||||||
|
Five implementation lanes integrated runtime, pure source/custody/history,
|
||||||
|
actual controls/four minimal legal bots, authenticated SQLite/CLI entry and
|
||||||
|
existing source guidance. Fresh `ixian-replacement` admits unique classic
|
||||||
|
2..6-seat Basic/Advanced with base Treachery/Nexus alone; no public start,
|
||||||
|
existing-game retrofit or wider-module activation. Normal paid and actual
|
||||||
|
printed Karama purchases, including direct zero-bid free acquisition, are
|
||||||
|
connected. Harkonnen buyers and Richese/special origins remain guarded by
|
||||||
|
their material priority/source questions. Native Ixian allied replacement
|
||||||
|
remains a separate ability, not an alias or a Nexus Karama response.
|
||||||
|
|
||||||
|
The typed source binds the exact canonical purchased physical ID, original
|
||||||
|
normal lot/payment and independently progressed cursor before Nexus cost
|
||||||
|
or draw. A publicly possible unallied held-Nexus buyer receives a uniform
|
||||||
|
neutral choice, regardless of private face. Only that buyer receives its
|
||||||
|
owned purchase and canonical eligibility; rivals get no face, private price,
|
||||||
|
deck, receipt or blocked reason. Use spends one physical Ixian Nexus,
|
||||||
|
discards that purchase first, draws the actual deck top and continues the
|
||||||
|
native sale once; Keep retains both cards. No refund, extra fee or re-auction.
|
||||||
|
Full hands and recycled same-card draw remain legal. Own-seat control is
|
||||||
|
independent of the pending gameplay lock.
|
||||||
|
|
||||||
|
Initial fixture failures assumed a Nexus draw without an alliance. Native
|
||||||
|
publisher semantics were preserved: no initial deal or automatic empty-hand
|
||||||
|
draw, and a qualifying closing phase requires a settled alliance. Genuine
|
||||||
|
nonrecipient allies now open the draw. Original rosters are selected before
|
||||||
|
creation, not added to an admitted game: effect scenarios need three seats,
|
||||||
|
or four for simultaneous unallied recipients. The initializer still admits
|
||||||
|
two-seat base continuation without inventing an unreachable Secret Ally.
|
||||||
|
CLI-produced undealt native setup continues without reinitializing/redealing.
|
||||||
|
|
||||||
|
Independent review found recursive history and an enabled-but-dropped
|
||||||
|
Take back control action. Actual nine-Pass reproduction grew to 7,030,825
|
||||||
|
bytes; bounded nonrecursive event/outcome/replacement head links now use
|
||||||
|
134,145, with a permanent linear-history/corruption regression. Actual
|
||||||
|
390-pixel before-fix Take back control sent no POST and retained Easy;
|
||||||
|
after-fix it sends one 200, restores the same seat controller and retains
|
||||||
|
the same paid event. Both final source/persistence/privacy rereviews are clean.
|
||||||
|
Exhaustive decision dispatch/shape consumers are migrated, and incidental
|
||||||
|
attribute-order/attempted-SQL-write pins are removed rather than re-pinned.
|
||||||
|
|
||||||
|
Counterfeit depleted tests inflated the allocated row. Those were replaced
|
||||||
|
with a captured native six-seat seed-11 turn-five payment source reached by
|
||||||
|
real auctions, legal Battle Plans and winner discards: seventeen held cards,
|
||||||
|
twelve discards, four reserved lots, empty draw deck, exactly all 33 cards.
|
||||||
|
Actual payment plus controlled native shuffle proves original-card redraw
|
||||||
|
and existing-discard draw while keeping unsold lots and force stock intact.
|
||||||
|
The permanent JSON fixture is synthetic QA source, not human game data.
|
||||||
|
|
||||||
|
Final broad check passes types, lint and **6,227 offline cases**. The six-file
|
||||||
|
selected union passes **73/73**; final reference check **10/10** and build
|
||||||
|
pass. Fourteen actual standalone native Basic/Advanced paid/free Use/Keep,
|
||||||
|
wrong-face, full-hand and two legal depletion outcomes preserve original
|
||||||
|
ledger, Treachery/forces/elites, Nexus cost, JSON and repeat rejection.
|
||||||
|
Authenticated SQLite covers original payment through restart, Pass/Use CAS,
|
||||||
|
foreign/revoked credentials, private-face parity, source/cursor corruption,
|
||||||
|
zero durable rejection and later physical recycling.
|
||||||
|
|
||||||
|
Actual ready rooms `GTCHEGKU`, `JUXLJXHC` and `HJ5Q64T5` entered once by
|
||||||
|
exact-version CLI with private consistent backups, then continued the same
|
||||||
|
native setup/IDs/circles before real API payment and phone choices.
|
||||||
|
Basic paid Use changes buyer 10->8 and Emperor 10->12 exactly once;
|
||||||
|
Advanced printed free Use keeps buyer 12 and skips purchase income;
|
||||||
|
irrelevant-face Keep retains its original physical purchase and Richese Nexus,
|
||||||
|
with no Use or Ixian inspector. Inspection, native income and refresh work.
|
||||||
|
Read-only saved proof conserves original native Treachery/Nexus/force/elite
|
||||||
|
inventories and eleven prior QA rooms. All 42 original seats in fourteen
|
||||||
|
rooms authenticate after required isolated build reloads. Actual expanded
|
||||||
|
internal guidance retains Partial / Development Verified boundaries.
|
||||||
|
|
||||||
|
Local built-worker HTTP is **51/55**, four known POST 503s, not green.
|
||||||
|
No unsafe retries, authorization weakening, database reset or deployed
|
||||||
|
acceptance is inferred. Broader Ixian/Nexus effects, special origins,
|
||||||
|
Harkonnen priority, combined games, strategy and public/live gates remain open.
|
||||||
|
|
||||||
## 1 October 2026 — authorized Advanced Storm and allied separation defaults
|
## 1 October 2026 — authorized Advanced Storm and allied separation defaults
|
||||||
|
|
||||||
Five implementation lanes connected the user-supplied revised Advanced
|
Five implementation lanes connected the user-supplied revised Advanced
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ The private holder offer contains CHOAM's name; it includes the hand count only
|
|||||||
|
|
||||||
Later focused source contracts cover [Emperor strength, purchases and revival](NEXUS_EMPEROR_RULES.md), [Ixian purchased-card replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Fremen worm and revival effects](NEXUS_FREMEN_RULES.md), and [Richese's separate shipment and auction panels](NEXUS_RICHESE_RULES.md). These audits distinguish implemented work from remaining source/timing questions; current runtime evidence is tracked in [implementation status](IMPLEMENTATION_STATUS.md). Bounded Richese auction and Guild shipment Betrayal checkpoints have their own direct local verification below; neither inherits certification of its complete faction, Nexus family or live deployment from earlier evidence.
|
Later focused source contracts cover [Emperor strength, purchases and revival](NEXUS_EMPEROR_RULES.md), [Ixian purchased-card replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Fremen worm and revival effects](NEXUS_FREMEN_RULES.md), and [Richese's separate shipment and auction panels](NEXUS_RICHESE_RULES.md). These audits distinguish implemented work from remaining source/timing questions; current runtime evidence is tracked in [implementation status](IMPLEMENTATION_STATUS.md). Bounded Richese auction and Guild shipment Betrayal checkpoints have their own direct local verification below; neither inherits certification of its complete faction, Nexus family or live deployment from earlier evidence.
|
||||||
|
|
||||||
|
The [bounded Ixian Secret Ally replacement integration](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) now connects genuine normal paid and printed Karama-paid purchases in a fresh classic/base-deck/Nexus-only local profile. It replaces exactly the purchased physical card, keeps the original settlement once and permits full-hand and genuinely depleted-deck same-card redraw. Harkonnen buyers, cache/Black Market/special origins and combined modules remain guarded. Neutral buyer acknowledgement is an inferred development privacy convention, not publisher policy. Bounded native, private, authenticated recovery, CLI and phone verification is Partial; full-module and deployed acceptance remain open. The adopted unofficial Advanced core PDF contains no Nexus face and settles none of these pending Nexus boundaries.
|
||||||
|
|
||||||
**Atreides is the first complete effect family to implement.** It has independent rulebook and physical-face evidence, and the engine already has private plan-element inspection. The Nexus effect needs its own cause and receipt rather than temporarily changing the inspecting faction. The second element must differ from the already inspected element. Preserve the original disclosed value if an allowed later battle edit occurs; follow the existing plan commitment rules instead of refreshing an old observation. The card specifies an element, not the entire hand or battle plan. A weapon/defense request uses actual played-card classification, including the published Reinforcements/Harass exclusions, rather than leaking a card merely because it occupies that slot.
|
**Atreides is the first complete effect family to implement.** It has independent rulebook and physical-face evidence, and the engine already has private plan-element inspection. The Nexus effect needs its own cause and receipt rather than temporarily changing the inspecting faction. The second element must differ from the already inspected element. Preserve the original disclosed value if an allowed later battle edit occurs; follow the existing plan commitment rules instead of refreshing an old observation. The card specifies an element, not the entire hand or battle plan. A weapon/defense request uses actual played-card classification, including the published Reinforcements/Harass exclusions, rather than leaking a card merely because it occupies that slot.
|
||||||
|
|
||||||
**Cancellation needs the original attempted effect.** Betrayal is not a blanket Karama flag. For Atreides, identify the inspection attempt being prevented; for Voice, the command; for a traitor/Face Dancer, the actual revealed identity; for economy, the original invoice and settlement. Do not refund or replay a completed payment merely because the next input happens to be a Nexus play. Effects with no printed phase still need a safe explicit action boundary and cannot bypass another player's committed private choice.
|
**Cancellation needs the original attempted effect.** Betrayal is not a blanket Karama flag. For Atreides, identify the inspection attempt being prevented; for Voice, the command; for a traitor/Face Dancer, the actual revealed identity; for economy, the original invoice and settlement. Do not refund or replay a completed payment merely because the next input happens to be a Nexus play. Effects with no printed phase still need a safe explicit action boundary and cannot bypass another player's committed private choice.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Nexus cards: common lifecycle checkpoint
|
# Nexus cards: common lifecycle checkpoint
|
||||||
|
|
||||||
|
**Later Ixian Secret Ally replacement prototype:** [NEXUS_IXIAN_REPLACEMENT_RULES.md](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) records the fresh local `ixian-replacement` profile: exact just-purchased normal paid/printed Karama card, discard before private deck draw, full-hand and genuinely depleted-deck same-card behavior, unchanged original payment and native sale continuation once. Harkonnen buyers, cache/Black Market/special origins and combined modules remain guarded. The neutral buyer choice is an inferred development privacy convention, not publisher timing. Its bounded native/SQLite/four-bot/CLI/phone evidence is **Partial development verified**; this historical lifecycle checkpoint does not certify full effects or enable public/module starts.
|
||||||
|
|
||||||
The later [Emperor Secret Ally revival prototype](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) adds exactly three additional free force returns, separate ordinary allowances, private controls and saved continuation. Its purchase alternative and full module acceptance remain pending.
|
The later [Emperor Secret Ally revival prototype](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) adds exactly three additional free force returns, separate ordinary allowances, private controls and saved continuation. Its purchase alternative and full module acceptance remain pending.
|
||||||
|
|
||||||
**Later Guild Secret Ally runtime:** [NEXUS_GUILD_SECRET_ALLY_RUNTIME.md](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) records explicitly selected Guild prices, cross/return routes, typed Homeworld sources, paid Fremen reinforcement and committed shipment history. The card changes one shipment, without creating a native Guild or a real alliance. The Homeworld-return ruling and full module completion remain pending.
|
**Later Guild Secret Ally runtime:** [NEXUS_GUILD_SECRET_ALLY_RUNTIME.md](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) records explicitly selected Guild prices, cross/return routes, typed Homeworld sources, paid Fremen reinforcement and committed shipment history. The card changes one shipment, without creating a native Guild or a real alliance. The Homeworld-return ruling and full module completion remain pending.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Ixian Nexus Secret Ally: purchased-card replacement
|
# Ixian Nexus Secret Ally: purchased-card replacement
|
||||||
|
|
||||||
Source audit: 2026-09-10. This is a source contract and proposed implementation boundary, not a runtime or expansion-completion claim. Existing [Nexus release gates](NEXUS_CARD_RUNTIME.md) remain in force.
|
Source audit: 2026-09-10. Bounded local prototype integration and verification: 2026-10-01. **Partial development verified**, not complete Ixian/Nexus family or expansion acceptance. This document records the source and runtime boundary of the fresh `ixian-replacement` profile. Existing [Nexus release gates](NEXUS_CARD_RUNTIME.md) remain in force.
|
||||||
|
|
||||||
## Primary evidence
|
## Primary evidence
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ Source audit: 2026-09-10. This is a source contract and proposed implementation
|
|||||||
|
|
||||||
Direct official PDF retrieval returned 403. Several older temporary `.pdf` paths contain HTML error pages; they were not treated as readable PDFs. No tournament rewrite or community compilation supplies a ruling here.
|
Direct official PDF retrieval returned 403. Several older temporary `.pdf` paths contain HTML error pages; they were not treated as readable PDFs. No tournament rewrite or community compilation supplies a ruling here.
|
||||||
|
|
||||||
|
The user-supplied `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf` is the adopted source for relevant Advanced core rules, including later Storm determination. It does not contain the Nexus Cards or this face. It therefore supplies no new Ixian replacement, cache-scope, bonus-order or Nexus-cancellation ruling; the photographed publisher card and cited publisher rules remain this feature's evidence.
|
||||||
|
|
||||||
## Printed operation
|
## Printed operation
|
||||||
|
|
||||||
The face permits the holder, when Ixians are absent, to discard the Treachery Card just purchased during Bidding and then draw the top Treachery Deck card. This identifies one particular purchase, not an arbitrary held card. The replacement is optional, but playing it spends the Nexus Card. No spice refund, extra payment, free choice from the deck, or second replacement is printed. The panel has no Advanced-only qualification. [Face](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani)
|
The face permits the holder, when Ixians are absent, to discard the Treachery Card just purchased during Bidding and then draw the top Treachery Deck card. This identifies one particular purchase, not an arbitrary held card. The replacement is optional, but playing it spends the Nexus Card. No spice refund, extra payment, free choice from the deck, or second replacement is printed. The panel has no Advanced-only qualification. [Face](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani)
|
||||||
@@ -48,16 +50,81 @@ November's Karama table expressly permits stopping the native Ixian ally from di
|
|||||||
|
|
||||||
**Nullentropy and custody interruptions:** a paid Box search freezes the searched pile in the existing subsystem. The replacement cannot draw or reshuffle that pile while browsing. Conversely, playing or transferring the just-purchased card cannot leave a live replacement offer pointing at custody it no longer owns. Preserve or settle the exact purchase continuation; do not reopen an expired purchase from an arbitrary historical card ID. Whether a player can interleave a particular anytime power before exercising an immediate purchase right needs an explicit sequencing policy, not an invisible loss of the right. See the [Box audit](NULLENTROPY_BOX_ENGINE_AUDIT.md).
|
**Nullentropy and custody interruptions:** a paid Box search freezes the searched pile in the existing subsystem. The replacement cannot draw or reshuffle that pile while browsing. Conversely, playing or transferring the just-purchased card cannot leave a live replacement offer pointing at custody it no longer owns. Preserve or settle the exact purchase continuation; do not reopen an expired purchase from an arbitrary historical card ID. Whether a player can interleave a particular anytime power before exercising an immediate purchase right needs an explicit sequencing policy, not an invisible loss of the right. See the [Box audit](NULLENTROPY_BOX_ENGINE_AUDIT.md).
|
||||||
|
|
||||||
**Private opportunity timing:** a pause offered only to the holder may reveal possession of a specific secret Nexus identity. Reuse the project's existing unresolved private-response timing policy rather than inventing compulsory confirmations or claiming every buyer has an actual choice. No duplicate user question was sent by this audit.
|
**Private opportunity timing:** a pause offered only to the eligible Ixian-card holder may reveal the secret identity. This bounded prototype instead gives every publicly unallied supported buyer with public held-Nexus presence the same neutral **Purchased card choice**, independently of its hidden face. An irrelevant-face buyer can Pass but cannot Use. This is an inferred development privacy convention, not publisher-prescribed timing, a ruling on all anytime interleavings or approval of every unresolved private-response design. The Harkonnen-buyer and unsupported-origin guards depend on public faction/origin, not secret face.
|
||||||
|
|
||||||
## Proposed pure and engine contract
|
## Bounded local runtime contract
|
||||||
|
|
||||||
The following is an implementation design, not additional rule text. It deliberately keeps purchase eligibility separate from physical replacement so unresolved origin or ordering policy does not contaminate custody.
|
The fresh `ixian-replacement` profile is a working development integration of the independently printed Secret Ally grant. It admits two through six unique classic factions in Basic or Advanced with the base Treachery deck and Nexus alone. Native Ixians, expansion factions/decks, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and other module overlays are excluded. It does not retrofit an in-play game, enable a public start, reset a saved room or change existing native Ixian allied replacement.
|
||||||
|
|
||||||
1. Create an immutable purchase receipt from the real completed acquisition: version, event, buyer, turn, phase, sale origin, sale event/lot, purchased physical ID, original paid amount and contribution/recipient evidence. Record the original Harkonnen-bonus entitlement separately. Never reconstruct the target from the last card in the current hand.
|
There is no starting Nexus deal. Native draws require a qualifying closing
|
||||||
2. A pure replacement helper accepts an already authorized receipt, exact canonical hand/deck/discard, the exact purchased ID and a shuffle function. Reject duplicate physical custody and wrong target before RNG. Remove that held card, append its discard, recycle only if necessary, transfer exactly the top card, and return immutable resulting custody plus a private outcome receipt. If discard reactions suspend the operation, split disposal and draw with an independently bound pending stage.
|
Nexus with a settled alliance, so a two-seat profile can continue its base
|
||||||
3. The engine owns common Nexus eligibility, sale-origin policy, immediate opportunity lifetime, response/decision preservation, Nexus-card disposal, normal discard hooks, bonus ordering and payment continuation. A declined opportunity closes for that purchase without spending the Nexus card. An accepted play cannot reopen after receipt deletion, replay, a later purchase or JSON reload.
|
game but cannot naturally produce an unallied Secret Ally holder. Actual
|
||||||
4. Bind latest progress independently of the detailed receipt. Historical outcomes must survive later transfers and deck recycling without requiring the old card to remain in discard. Signatures are consistency evidence, not cryptographic protection against arbitrary rewritten saves.
|
replacement scenarios need at least three seats; two simultaneous unallied
|
||||||
5. Project the purchased face and replacement result only to entitled owners. A public discarded identity does not authorize exporting replacement identity, deck order, private receipt signatures or old hand contents. Other seats may receive ordinary counts and a played-Nexus event. Do not grant Atreides automatic inspection of a card drawn outside its auction inspection opportunity.
|
recipients need at least four. Fixtures form genuine nonrecipient alliances
|
||||||
|
and never add seats to an admitted game or fabricate Nexus hands.
|
||||||
|
|
||||||
Meaningful verification should include genuine ordinary and Karama purchases, both source-supported Richese origins once policy is settled, Harkonnen bonus/cancellation/cap sequencing, empty deck with same-card redraw, canonical expansion cards, poison discard hooks, reserved-card interruption, all-seat privacy, replay/stale rejection without mutation, JSON recovery and production SQLite races. This audit implements none of those paths and does not certify their integration.
|
### Connected purchase and choice
|
||||||
|
|
||||||
|
1. Only an actual normal-auction purchase is connected: paid spice or a genuine printed Karama-paid purchase, including direct printed Karama acquisition before a positive bid is recorded. The free purchase must originate from the actual printed Karama; native Bene Gesserit Worthless/Truthtrance substitutions continue unchanged without this new offer. This is a bounded producer restriction, not a ruling that those acquisitions cease to be purchases. The original source, purchased physical card and native sale continuation are bound after the genuine acquisition. Gifts, bonus draws, Technology swaps, old hand cards and historical purchase IDs cannot create an opportunity.
|
||||||
|
2. A Harkonnen **buyer** and Richese cache, Black Market or special-acquisition origins are guarded, not adjudicated. A Harkonnen rival is allowed and retains its ordinary native play outside this offer. These bounds preserve the unresolved independent Nexus cache scope and Harkonnen bonus priority above; they are not printed prohibitions or imports of the native `ixAllyCard` origin filter.
|
||||||
|
3. Every publicly possible supported buyer receives the same event and public buyer identity, regardless of its secret Nexus face. Only that buyer receives Pass and the exact already-owned purchased card for inspection; only a truly eligible unallied holder of the physical Ixian Nexus receives Use. Opponents receive neither the purchased/replacement face nor private price, receipt, deck order or eligibility reason. The buyer may inspect its own actually held Nexus through the existing authorized inspector; the neutral prompt grants no rival entitlement.
|
||||||
|
4. **Pass** retains the purchased card and Nexus, closes this opportunity and continues the original sale once. **Use** spends that singleton Nexus once, discards exactly the just-purchased physical Treachery Card, then draws the actual next deck card privately. There is no card selector, arbitrary hand exchange, new Karama counter, refund, repayment or re-auction.
|
||||||
|
5. The discard opens a full legal hand's slot before the draw. An exhausted deck recycles eligible Treachery discards, including that just-discarded card; drawing the same physical card again is legal. The unsold auction row is not the draw deck. Earlier Atreides auction inspection does not grant a new peek of this replacement.
|
||||||
|
6. The original funded amount, own/authorized-ally contribution and recorded free/paid source are preserved. Already completed payment is not repeated, and original income and native auction suffix run once through their genuine continuation. Other response/decision priorities remain native: finish the source-bound choice before another payment, lot, transfer or card effect can change its reserved source. No expiry bypass or raw-action replay is added.
|
||||||
|
7. Refresh restores the same pending event or closed outcome without another payment, shuffle, draw or Nexus spend. Closed, stale, foreign or malformed events cannot reopen that purchase. Historical receipts remain valid after later transfers, reshuffles and same-card recycling; they do not require an old purchased card to stay in discard forever. Cursor/descriptor consistency is not authentication against arbitrary rewritten saves; authenticated sessions and production compare-and-swap remain required.
|
||||||
|
|
||||||
|
### Public APIs and ownership
|
||||||
|
|
||||||
|
- `initializeIxianNexusReplacementGameForAudit(state: Game): Game` in `game/engine.ts` is the fresh-only initializer; `Game.nexusIxianReplacementPreview?: true` marks the admitted profile. Genuine setup and Nexus dealing remain native.
|
||||||
|
- `GameView.nexusIxianReplacementPreview` reports the profile. `GameView.nexusIxianReplacement` is the neutral event/buyer view with `canPass`, `canUse`, `blocked` and `purchased`; rivals have no face or private blocked reason.
|
||||||
|
- `{ type: 'nexusIxianReplacementPass', event }` and `{ type: 'nexusIxianReplacementUse', event }` accept only that event-bound shape. There is no client-chosen purchased ID, replacement, recipient, price or Nexus identity.
|
||||||
|
- `game/nexus-ixian-replacement.ts` owns canonical physical/source/custody/history validation through `createIxianReplacementSource`, `validateIxianReplacementSource`, `closeIxianReplacementSource` and `validateIxianReplacementHistory`, with `initialIxianReplacementCursor` and `ixianReplacementEvent` for bounded progress. The source snapshots the exact purchased descriptor, not a whole-Game undo or payment replay. Canonical descriptor, original parent, owned exact target, legal hand cap and single physical custody are validated before Nexus cost or randomness. The closed receipt chain and independent cursor do not require an old discard to remain in its historical location. The engine alone owns purchase authorization, original payment and native discard/draw/sale continuation. Existing native Ixian ally response remains a distinct cancelable ability, not an alias or fallback for the absent-faction Nexus grant.
|
||||||
|
- `components/nexus-ixian-replacement.tsx` and `components/game-table.tsx` own the private inspector and neutral controls; `game/bots.ts` consumes each bot's own legal projection. Easy, Medium, Hard and Brutal have minimal legal Use/Pass policies, not calibrated Nexus strategy.
|
||||||
|
- The local CLI entry is `node --import tsx tools/start-prototype.ts --profile ixian-replacement --db PATH --room CODE --version NUMBER --out /private/new-directory`. It requires the exact current room version, a fresh supported setup and a private new backup/kit directory; it preserves room/seat/setup identity and existing saves rather than replacing or resetting a game. `tools/prototype-room.ts` owns admission and the private backup transaction.
|
||||||
|
|
||||||
|
## Verification and retained release gates
|
||||||
|
|
||||||
|
**Partial development verified.** Types, lint, 6,227 offline cases and the
|
||||||
|
73-case selected six-file union pass. Actual standalone native execution
|
||||||
|
covers fourteen Basic/Advanced paid/printed-Karama Use/Pass, wrong-face
|
||||||
|
Keep, full-hand and depleted-source outcomes with exact original ledger,
|
||||||
|
physical Treachery/Nexus/force/elite custody and replay rejection.
|
||||||
|
|
||||||
|
The depleted source is captured from genuine six-seat play, seed 11:
|
||||||
|
turn five, native `auctionPayment`, seventeen held cards, twelve actual
|
||||||
|
discards, four native reserved lots and an empty draw deck. All 33 cards
|
||||||
|
are present. Legal purchases, Battle Plans and winner discards produced
|
||||||
|
it; no row inflation, stock deletion or phase rewrite was used.
|
||||||
|
`tests/fixtures/nexus-ixian-native-depleted.json` then exercises the actual
|
||||||
|
payment and native replenishment shuffle, including exact same-card redraw.
|
||||||
|
|
||||||
|
Independent rules/privacy rereviews have no finding after two actual fixes.
|
||||||
|
Nine retained purchase records grew to 7,030,825 bytes under recursive
|
||||||
|
signature nesting; nonrecursive result-head links reduce them to 134,145
|
||||||
|
bytes. A permanent linear-history regression retains corruption/replay
|
||||||
|
checks without a cryptographic-authentication claim.
|
||||||
|
Actual phone Take back control previously sent no POST during a pending
|
||||||
|
choice; it now sends one 200, restores the original seat controller and
|
||||||
|
retains that same paid-source event. Gameplay locks still protect the offer.
|
||||||
|
Incidental disabled-attribute-order and attempted-SQL-write pins were removed;
|
||||||
|
actual room/eligibility locks, durable `total_changes()` and credential CAS
|
||||||
|
remain checked.
|
||||||
|
|
||||||
|
Three same-admitted setup/seat/circle QA rooms were privately backed up and
|
||||||
|
entered by exact-version CLI: `GTCHEGKU` Basic paid Use plus independent
|
||||||
|
takeback, `JUXLJXHC` Advanced printed free Use, and `HJ5Q64T5` Basic
|
||||||
|
irrelevant-face Keep. Actual 390-pixel inspection, actions, native income
|
||||||
|
and refresh retain the source. The wrong-face owner receives no Use or
|
||||||
|
Ixian inspector. Read-only saved proof conserves original native inventories,
|
||||||
|
exact spice and one closed outcome; all eleven previous QA rooms are unchanged.
|
||||||
|
|
||||||
|
Repository evidence: `tests/nexus-ixian-replacement.test.ts` (pure source,
|
||||||
|
custody/history), `tests/nexus-ixian-replacement-engine.test.ts` and genuine
|
||||||
|
`tests/fixture-nexus-ixian-replacement.ts` (native acquisition/continuation),
|
||||||
|
`tests/nexus-ixian-replacement-controls.test.tsx` (private controls),
|
||||||
|
`tests/bot-nexus-ixian-replacement.test.ts` (four legal policies),
|
||||||
|
`tests/nexus-ixian-replacement-recovery.test.ts` (authenticated SQLite) and
|
||||||
|
`tests/prototype-room.test.ts` (fresh profile and exact-version backed-up CLI).
|
||||||
|
This evidence is not complete family/module or deployed acceptance.
|
||||||
|
|
||||||
|
Native Harkonnen-buyer bonus/cancellation/cap sequencing, independent Richese cache/Black Market/special scope, expansion-card discard reactions, Semuta/Ecaz priority and anytime interleaving remain pending outside this profile. Full Ixian/Nexus families, combined modules, public starts, complete games and live release acceptance remain separate gates.
|
||||||
@@ -65,9 +65,11 @@ reevaluated. The own-turn warning exposes only affected public territory
|
|||||||
names through `GameView.advancedAllySeparation`; the ordinary `endMovement`
|
names through `GameView.advancedAllySeparation`; the ordinary `endMovement`
|
||||||
action stays unchanged, with no confirmation for mandatory losses.
|
action stays unchanged, with no confirmation for mandatory losses.
|
||||||
|
|
||||||
Verification of this cutover is **Planned** pending the coordinator's
|
Verification of this cutover is **Partial development verified**: genuine
|
||||||
frozen native, authenticated SQLite and phone/browser evidence. Existing
|
native, authenticated SQLite, private/control/bot and phone evidence is
|
||||||
focused tests and old calibration counts do not verify these new defaults.
|
recorded in [the checkpoint log](IMPLEMENTATION_STATUS.md#1-october-2026--authorized-advanced-storm-and-allied-separation-defaults).
|
||||||
|
Code `e1056a4` has exact-source successful isolated container verification
|
||||||
|
and publication; that does not update the last observed live revision.
|
||||||
Public, faction, optional-module and full-mode readiness gates are unchanged.
|
Public, faction, optional-module and full-mode readiness gates are unchanged.
|
||||||
Secrecy (physical page 16), Duke Vidal's ghola exception (30), Kull and other
|
Secrecy (physical page 16), Duke Vidal's ghola exception (30), Kull and other
|
||||||
independent decisions are not reconciled by this cutover.
|
independent decisions are not reconciled by this cutover.
|
||||||
@@ -107,6 +109,7 @@ independent decisions are not reconciled by this cutover.
|
|||||||
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
|
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
|
||||||
| Richese Nexus Betrayal auctions | Pause at a valid original prepayment quote. Public self-cache veto discards the exact card without debit/acquisition; another buyer's positive cache/Black Market invoice keeps price/contributions/delivery and earned Harkonnen bonus while overriding the recipient to bank. All publicly possible unallied other-faction held-Nexus seats acknowledge uniformly; private identity controls only own Use. This neutral protocol is an application choice, not publisher timing. Normal hidden-family self-purchases, special-Karama veto and unsupported overlays remain excluded; no native Karama exception certifies them. Bounded engine/SQL/bot/phone scenarios are verified locally; broader verification remains Partial and public starts/full Nexus gates stay closed. | [Printed face, profile, API and saved continuation](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026), [common privacy protocol](NEXUS_CARD_RULES.md#richese-betrayal-prepayment-protocol), [auction source boundaries](RICHESE_AUCTION_RULES.md) |
|
| Richese Nexus Betrayal auctions | Pause at a valid original prepayment quote. Public self-cache veto discards the exact card without debit/acquisition; another buyer's positive cache/Black Market invoice keeps price/contributions/delivery and earned Harkonnen bonus while overriding the recipient to bank. All publicly possible unallied other-faction held-Nexus seats acknowledge uniformly; private identity controls only own Use. This neutral protocol is an application choice, not publisher timing. Normal hidden-family self-purchases, special-Karama veto and unsupported overlays remain excluded; no native Karama exception certifies them. Bounded engine/SQL/bot/phone scenarios are verified locally; broader verification remains Partial and public starts/full Nexus gates stay closed. | [Printed face, profile, API and saved continuation](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026), [common privacy protocol](NEXUS_CARD_RULES.md#richese-betrayal-prepayment-protocol), [auction source boundaries](RICHESE_AUCTION_RULES.md) |
|
||||||
| Guild Nexus Betrayal shipment payment | Replace one original funded positive fee in full, including own pre-funded payment and the printed full occupied-Junction override; preserve original payer/contributions, consume already-debited donor escrow once and deliver the original physical shipment once. Native applicable stop/rate responses precede the bounded gate; zero/stopped shipments supply no award or Nexus cost. Public possible held-Nexus seats acknowledge uniformly; private Use changes no public price/source disclosure. Fresh classic/native-Guild/Nexus with optional genuinely seeded Homeworlds only; unsupported native Cunning/Richese shipment overlays block before card/parent commitment, and restored transport independently requires the native Guild grant. Bounded engine/SQL/bot/CLI/phone custody and refresh are locally verified; no new priority/immunity, ordinary occupation/rounding ruling or extra shipment. Broad/public/live gates remain closed. | [Printed source and bounded contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal), [common protocol](NEXUS_CARD_RULES.md#guild-betrayal-funded-payment-protocol), [internal checklist](../game/reference.ts) |
|
| Guild Nexus Betrayal shipment payment | Replace one original funded positive fee in full, including own pre-funded payment and the printed full occupied-Junction override; preserve original payer/contributions, consume already-debited donor escrow once and deliver the original physical shipment once. Native applicable stop/rate responses precede the bounded gate; zero/stopped shipments supply no award or Nexus cost. Public possible held-Nexus seats acknowledge uniformly; private Use changes no public price/source disclosure. Fresh classic/native-Guild/Nexus with optional genuinely seeded Homeworlds only; unsupported native Cunning/Richese shipment overlays block before card/parent commitment, and restored transport independently requires the native Guild grant. Bounded engine/SQL/bot/CLI/phone custody and refresh are locally verified; no new priority/immunity, ordinary occupation/rounding ruling or extra shipment. Broad/public/live gates remain closed. | [Printed source and bounded contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal), [common protocol](NEXUS_CARD_RULES.md#guild-betrayal-funded-payment-protocol), [internal checklist](../game/reference.ts) |
|
||||||
|
| Ixian Nexus Secret Ally replacement | Fresh classic/base-deck/Nexus-only Basic or Advanced local `ixian-replacement` profile binds one genuine normal paid/printed Karama purchase. Discard exactly that purchased physical card before privately drawing the actual deck top; full-hand and exhausted-deck same-card redraw are legal. Keep original payment, income and native auction suffix once; no refund, re-auction or new native Ixian Karama response. Every publicly possible supported unallied held-Nexus buyer gets the same neutral choice irrespective of hidden face; only the owner has Pass/entitled inspection and private eligible Use. This is an inferred development privacy convention, not publisher timing. Harkonnen buyers, Richese cache/Black Market/special origins, anytime overlays and combined modules stay guarded pending their independent source/priority questions. Existing native Ixian ally replacement is distinct. Verification Planned; no retrofit, public start or full-family certification. | [Photographed face and bounded runtime contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) |
|
||||||
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
|
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
|
||||||
| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) |
|
| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) |
|
||||||
| Basic CHOAM / Leader Skills | Compose existing ordinary skills with native sales and own-leader Ghola revival. Finish the private optional replacement before the exact suspended sale resumes. The free revival remains completed if the resumed sale is canceled. Basic uses five ordinary leaders and 35 Treachery Cards; Auditor remains Advanced and ineligible for skills. | [Integration and source contracts](CHOAM_LEADER_SKILLS.md) |
|
| Basic CHOAM / Leader Skills | Compose existing ordinary skills with native sales and own-leader Ghola revival. Finish the private optional replacement before the exact suspended sale resumes. The free revival remains completed if the resumed sale is canceled. Basic uses five ordinary leaders and 35 Treachery Cards; Auditor remains Advanced and ineligible for skills. | [Integration and source contracts](CHOAM_LEADER_SKILLS.md) |
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const decisions = {
|
|||||||
capturedLeader: true,
|
capturedLeader: true,
|
||||||
guildTiming: true,
|
guildTiming: true,
|
||||||
auctionPayment: true,
|
auctionPayment: true,
|
||||||
|
nexusIxianReplacement: true,
|
||||||
advisor: true,
|
advisor: true,
|
||||||
intrusion: true,
|
intrusion: true,
|
||||||
advisorBattle: true,
|
advisorBattle: true,
|
||||||
|
|||||||
+21
-1
@@ -1513,6 +1513,9 @@ function policyActions(g: GameView): Action[] {
|
|||||||
if (g.decision) {
|
if (g.decision) {
|
||||||
if (g.decision.player !== me.id) return [];
|
if (g.decision.player !== me.id) return [];
|
||||||
const d = g.decision;
|
const d = g.decision;
|
||||||
|
// The dedicated pending-purchase policy owns event-only choices.
|
||||||
|
// A decision without its private view offer must not become an ordinary action.
|
||||||
|
if (d.kind === 'nexusIxianReplacement') return [];
|
||||||
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival' || d.kind === 'mentatQuestion' || d.kind === 'bureaucratPayment') return [];
|
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival' || d.kind === 'mentatQuestion' || d.kind === 'bureaucratPayment') return [];
|
||||||
if (d.kind === 'harassWithdraw') {
|
if (d.kind === 'harassWithdraw') {
|
||||||
const offer = g.battle?.harassAllocation;
|
const offer = g.battle?.harassAllocation;
|
||||||
@@ -2669,7 +2672,7 @@ function policyActions(g: GameView): Action[] {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return [
|
if (d.kind === 'wormRide') return [
|
||||||
...destinations(g)
|
...destinations(g)
|
||||||
.filter(
|
.filter(
|
||||||
(to) =>
|
(to) =>
|
||||||
@@ -3834,6 +3837,23 @@ export function botActions(g: GameView): Action[] {
|
|||||||
if (g.kullCounterEvent)
|
if (g.kullCounterEvent)
|
||||||
return botChoamKullCounterActions(g);
|
return botChoamKullCounterActions(g);
|
||||||
if (g.semutaReaction) return botSemutaActions(g);
|
if (g.semutaReaction) return botSemutaActions(g);
|
||||||
|
const replacement = g.nexusIxianReplacement;
|
||||||
|
if (replacement) {
|
||||||
|
const own = g.players.find((player) => player.id === g.me);
|
||||||
|
if (!(own?.bot ?? own?.autopilot) || g.status !== 'playing' || g.phase !== 3 ||
|
||||||
|
g.roomControl?.paused || g.roomControl?.closed || g.automaticContinuationPending ||
|
||||||
|
g.truthtrance || g.response || g.phaseOpening || g.nexusCards?.waiting.length ||
|
||||||
|
g.nexusTraitors?.pending || !replacement.event || replacement.buyer !== g.me || !replacement.canPass ||
|
||||||
|
(g.decision && (g.decision.kind !== 'nexusIxianReplacement' ||
|
||||||
|
g.decision.player !== g.me || g.decision.event !== replacement.event))) return [];
|
||||||
|
// Value only the authorized purchased card; the runtime owns custody,
|
||||||
|
// eligibility, original payment and the native continuation.
|
||||||
|
const use = replacement.canUse && !replacement.blocked && replacement.purchased &&
|
||||||
|
(replacement.purchased.kind === 'worthless' ||
|
||||||
|
technologyCardValue(g, replacement.purchased) < (rank(g) === 0 ? 0.5 : 4));
|
||||||
|
return [{ type: use ? 'nexusIxianReplacementUse' : 'nexusIxianReplacementPass', event: replacement.event }];
|
||||||
|
}
|
||||||
|
if (g.decision?.kind === 'nexusIxianReplacement') return [];
|
||||||
const fremenRevival = g.nexusFremenRevival;
|
const fremenRevival = g.nexusFremenRevival;
|
||||||
if (fremenRevival && !fremenRevival.blocked && fremenRevival.eliteOptions.length) {
|
if (fremenRevival && !fremenRevival.blocked && fremenRevival.eliteOptions.length) {
|
||||||
const own = g.players.find(player => player.id === g.me);
|
const own = g.players.find(player => player.id === g.me);
|
||||||
|
|||||||
+151
-4
@@ -47,6 +47,7 @@ import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, morit
|
|||||||
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
|
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
|
||||||
import { shipmentAvailable } from './shipment-opportunity';
|
import { shipmentAvailable } from './shipment-opportunity';
|
||||||
import { createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible, guildBetrayalResponders, validateGuildBetrayalInvoice, validateGuildBetrayalHistory, nexusGuildBetrayalSourceReceipt, type GuildBetrayalInvoice, type GuildBetrayalCursor, type GuildBetrayalReceipt, type GuildBetrayalAuthority } from './nexus-guild-betrayal';
|
import { createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible, guildBetrayalResponders, validateGuildBetrayalInvoice, validateGuildBetrayalHistory, nexusGuildBetrayalSourceReceipt, type GuildBetrayalInvoice, type GuildBetrayalCursor, type GuildBetrayalReceipt, type GuildBetrayalAuthority } from './nexus-guild-betrayal';
|
||||||
|
import { createIxianReplacementSource, validateIxianReplacementSource, closeIxianReplacementSource, validateIxianReplacementHistory, initialIxianReplacementCursor, ixianReplacementEvent, type IxianReplacementSource, type IxianReplacementReceipt, type IxianReplacementCursor } from './nexus-ixian-replacement';
|
||||||
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
|
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
|
||||||
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
|
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
|
||||||
import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections';
|
import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections';
|
||||||
@@ -806,6 +807,7 @@ export type Decision =
|
|||||||
| { kind: 'ixTechnology'; player: string }
|
| { kind: 'ixTechnology'; player: string }
|
||||||
| { kind: 'ixRicheseTechnology'; player: string; event: string; source: 'cache' | 'blackMarket' }
|
| { kind: 'ixRicheseTechnology'; player: string; event: string; source: 'cache' | 'blackMarket' }
|
||||||
| { kind: 'ixAllyCard'; player: string }
|
| { kind: 'ixAllyCard'; player: string }
|
||||||
|
| { kind: 'nexusIxianReplacement'; player: string; event: string }
|
||||||
| { kind: 'mobileStronghold'; player: string; placement: boolean }
|
| { kind: 'mobileStronghold'; player: string; placement: boolean }
|
||||||
| {
|
| {
|
||||||
kind: 'ixSubstitution';
|
kind: 'ixSubstitution';
|
||||||
@@ -1121,6 +1123,11 @@ export type Game = {
|
|||||||
/** Explicit development profile; never enabled by a public start or player action. */
|
/** Explicit development profile; never enabled by a public start or player action. */
|
||||||
kullPreview?: boolean;
|
kullPreview?: boolean;
|
||||||
nexusKullPreview?: boolean;
|
nexusKullPreview?: boolean;
|
||||||
|
/** Fresh bounded classic Nexus purchased-card prototype; never inferred for old saves. */
|
||||||
|
nexusIxianReplacementPreview?: true;
|
||||||
|
pendingNexusIxianReplacement?: IxianReplacementSource | null;
|
||||||
|
nexusIxianReplacementHistory?: IxianReplacementReceipt[];
|
||||||
|
nexusIxianReplacementCursor?: IxianReplacementCursor;
|
||||||
kullSequence?: number;
|
kullSequence?: number;
|
||||||
kullRestrictions?: KullPhaseRestriction[];
|
kullRestrictions?: KullPhaseRestriction[];
|
||||||
pendingKull?: {
|
pendingKull?: {
|
||||||
@@ -8107,6 +8114,122 @@ export function initializeNexusGameForAudit(state: Game): Game {
|
|||||||
'Enable Nexus cards in a fresh audit lobby first.');
|
'Enable Nexus cards in a fresh audit lobby first.');
|
||||||
return initializeSetupGameForAudit(state, !!state.homeworlds, true);
|
return initializeSetupGameForAudit(state, !!state.homeworlds, true);
|
||||||
}
|
}
|
||||||
|
/** Fresh local classic profile only; no public start or existing-game retrofit. */
|
||||||
|
export function initializeIxianNexusReplacementGameForAudit(state: Game): Game {
|
||||||
|
requireRule(!state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement &&
|
||||||
|
state.nexusIxianReplacementHistory === undefined && state.nexusIxianReplacementCursor === undefined &&
|
||||||
|
ixianNexusReplacementModeSupported(state),
|
||||||
|
'Ixian Nexus replacement needs a fresh classic lobby with only base Treachery and Nexus cards.');
|
||||||
|
const g = initializeNexusGameForAudit(state);
|
||||||
|
g.nexusIxianReplacementPreview = true;
|
||||||
|
g.pendingNexusIxianReplacement = null;
|
||||||
|
g.nexusIxianReplacementHistory = [];
|
||||||
|
g.nexusIxianReplacementCursor = initialIxianReplacementCursor();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
function ixianNexusReplacementModeSupported(g: Game): boolean {
|
||||||
|
return !!g.nexusCards && g.expansions.length === 0 && g.players.length >= 2 && g.players.length <= 6 &&
|
||||||
|
new Set(g.players.map(p => p.faction)).size === g.players.length &&
|
||||||
|
g.players.every(p => CLASSIC_FACTIONS[p.faction] === true) &&
|
||||||
|
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
|
||||||
|
!g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.mobileStronghold && !g.sandtrout &&
|
||||||
|
!g.kullPreview && !g.nexusKullPreview && !g.semutaPreview && !g.richeseBetrayalPreview &&
|
||||||
|
!g.guildBetrayalPreview && !g.moritaniAssassinatePreview;
|
||||||
|
}
|
||||||
|
function ixianNexusReplacementParent(g: Game): string {
|
||||||
|
return JSON.stringify({status:g.status,turn:g.turn,phase:g.phase,advanced:g.advanced,
|
||||||
|
order:g.order,active:g.active,auction:g.auction,sale:g.currentAuctionSale,
|
||||||
|
players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand})),
|
||||||
|
deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards,
|
||||||
|
decision:g.decision,response:g.response,phaseOpening:g.phaseOpening});
|
||||||
|
}
|
||||||
|
function ixianNexusReplacementContext(g: Game) {
|
||||||
|
requireRule(g.auction, 'The Nexus purchased-card choice lost its normal auction.');
|
||||||
|
return {status:g.status,phase:g.phase,turn:g.turn,sequence:g.nexusIxianReplacementCursor!.sequence,
|
||||||
|
auction:g.auction,sale:g.currentAuctionSale ?? null,
|
||||||
|
players:g.players.map(p => ({id:p.id,hand:p.hand,handLimit:handLimit(p)})),
|
||||||
|
physicalCards:physicalTreacheryCards(g)};
|
||||||
|
}
|
||||||
|
function ixianNexusReplacementIntegrity(g: Game): void {
|
||||||
|
const pending = g.pendingNexusIxianReplacement;
|
||||||
|
if (!g.nexusIxianReplacementPreview) {
|
||||||
|
requireRule(g.nexusIxianReplacementPreview === undefined && !pending &&
|
||||||
|
g.nexusIxianReplacementHistory === undefined && g.nexusIxianReplacementCursor === undefined &&
|
||||||
|
g.decision?.kind !== 'nexusIxianReplacement',
|
||||||
|
'The Ixian Nexus replacement lost its explicit fresh profile.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireRule(ixianNexusReplacementModeSupported(g) && Array.isArray(g.nexusIxianReplacementHistory) &&
|
||||||
|
g.nexusIxianReplacementCursor, 'The Ixian Nexus replacement profile or history is invalid.');
|
||||||
|
nexusRule(() => validateIxianReplacementHistory(g.nexusIxianReplacementHistory!,
|
||||||
|
g.nexusIxianReplacementCursor!,physicalTreacheryCards(g)));
|
||||||
|
if (!pending) {
|
||||||
|
requireRule(g.decision?.kind !== 'nexusIxianReplacement', 'The purchased-card choice lost its source.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buyer = getPlayer(g,pending.buyer);
|
||||||
|
requireRule(!buyer.ally && buyer.faction !== 'harkonnen' && !!g.nexusCards?.cards?.hands[buyer.id] &&
|
||||||
|
g.decision?.kind === 'nexusIxianReplacement' && g.decision.player === buyer.id &&
|
||||||
|
g.decision.event === pending.event &&
|
||||||
|
Object.keys(g.decision).every(key => ['kind','player','event'].includes(key)) &&
|
||||||
|
!g.response && !g.phaseOpening && !g.truthtrance && !g.pendingKarama && !g.pendingNullentropy &&
|
||||||
|
!g.pendingTreacheryDiscard && !g.richeseAuction,
|
||||||
|
'The purchased-card choice lost its buyer or native decision priority.');
|
||||||
|
nexusRule(() => validateIxianReplacementSource(ixianNexusReplacementContext(g),pending,
|
||||||
|
ixianNexusReplacementParent(g)));
|
||||||
|
}
|
||||||
|
function offerIxianNexusReplacement(g: Game, printedKarama: boolean): boolean {
|
||||||
|
if (!g.nexusIxianReplacementPreview) return false;
|
||||||
|
const sale = g.currentAuctionSale;
|
||||||
|
const buyer = sale && getPlayer(g,sale.winner);
|
||||||
|
if (!sale || !buyer || sale.origin !== 'normal' || (sale.free && !printedKarama) ||
|
||||||
|
buyer.faction === 'harkonnen' || buyer.ally || !g.nexusCards?.cards?.hands[buyer.id]) return false;
|
||||||
|
requireRule(!g.response && !g.phaseOpening && !g.truthtrance && !g.pendingKarama,
|
||||||
|
'Finish the original auction priority before offering the purchased-card choice.');
|
||||||
|
const event = ixianReplacementEvent(g.turn,buyer.id,g.nexusIxianReplacementCursor!.sequence,g.auction!.index);
|
||||||
|
g.decision = {kind:'nexusIxianReplacement',player:buyer.id,event};
|
||||||
|
g.active = buyer.id;
|
||||||
|
g.pendingNexusIxianReplacement = nexusRule(() => createIxianReplacementSource(
|
||||||
|
ixianNexusReplacementContext(g),event,ixianNexusReplacementParent(g)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function ixianNexusReplacementView(g: Game,id: string) {
|
||||||
|
const source = g.pendingNexusIxianReplacement;
|
||||||
|
if (!source) return null;
|
||||||
|
const own = source.buyer === id;
|
||||||
|
return {event:source.event,buyer:source.buyer,canPass:own,
|
||||||
|
canUse:own && g.nexusCards?.cards?.hands[id] === 'ixians',
|
||||||
|
blocked:null as string | null,purchased:own ? structuredClone(source.card) : null};
|
||||||
|
}
|
||||||
|
function decideIxianNexusReplacement(g: Game,id: string,action: Action): void {
|
||||||
|
ixianNexusReplacementIntegrity(g);
|
||||||
|
const source = g.pendingNexusIxianReplacement!;
|
||||||
|
requireRule(source.buyer === id && action.event === source.event &&
|
||||||
|
['nexusIxianReplacementPass','nexusIxianReplacementUse'].includes(action.type) &&
|
||||||
|
Object.keys(action).length === 2 && Object.keys(action).every(key => ['type','event'].includes(key)),
|
||||||
|
'Choose only the current owned purchased-card event with Use or Pass.');
|
||||||
|
let replacement: Card | null = null;
|
||||||
|
const use = action.type === 'nexusIxianReplacementUse';
|
||||||
|
if (use) {
|
||||||
|
requireRule(g.nexusCards?.cards?.hands[id] === 'ixians', 'You do not hold the eligible Ixian Nexus card.');
|
||||||
|
// All original-source/custody checks precede either the singleton cost or RNG.
|
||||||
|
g.nexusCards.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,id,g.players));
|
||||||
|
const buyer = getPlayer(g,id);
|
||||||
|
discard(g,buyer,source.card.id);
|
||||||
|
replacement = draw(g) ?? null;
|
||||||
|
requireRule(replacement, 'The purchased-card discard must supply a replacement draw.');
|
||||||
|
buyer.hand.push(replacement);
|
||||||
|
}
|
||||||
|
const closed = nexusRule(() => closeIxianReplacementSource(source,use ? 'use' : 'pass',
|
||||||
|
replacement,g.nexusIxianReplacementCursor!));
|
||||||
|
g.nexusIxianReplacementHistory!.push(closed.receipt);
|
||||||
|
g.nexusIxianReplacementCursor = closed.cursor;
|
||||||
|
g.pendingNexusIxianReplacement = null;
|
||||||
|
g.decision = null;
|
||||||
|
if (use) log(g,`${getPlayer(g,id).name} spent Ixian Nexus Secret Ally to discard the purchased Treachery Card and draw a private replacement.`,
|
||||||
|
{faction:getPlayer(g,id).faction,name:'Nexus purchased-card replacement'});
|
||||||
|
continueAuctionSale(g,source.free);
|
||||||
|
}
|
||||||
/** Opt-in fresh classic setup through the real physical Nexus/Homeworld setup. */
|
/** Opt-in fresh classic setup through the real physical Nexus/Homeworld setup. */
|
||||||
export function initializeGuildBetrayalGameForAudit(state: Game): Game {
|
export function initializeGuildBetrayalGameForAudit(state: Game): Game {
|
||||||
requireRule(!state.guildBetrayalPreview && !state.guildBetrayal && !state.pendingGuildBetrayal &&
|
requireRule(!state.guildBetrayalPreview && !state.guildBetrayal && !state.pendingGuildBetrayal &&
|
||||||
@@ -10735,7 +10858,7 @@ function auctionNext(g: Game) {
|
|||||||
a.active = next.player;
|
a.active = next.player;
|
||||||
g.active = a.active;
|
g.active = a.active;
|
||||||
}
|
}
|
||||||
function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false) {
|
function settleAuction(g: Game, free = false, automatic = false, emperorNexus = false, printedKarama = false) {
|
||||||
const a = g.auction!;
|
const a = g.auction!;
|
||||||
const winner = getPlayer(g, a.bidder!);
|
const winner = getPlayer(g, a.bidder!);
|
||||||
if (!free) payWithAlly(g, winner, a.bid, a.allyPayment ?? 0);
|
if (!free) payWithAlly(g, winner, a.bid, a.allyPayment ?? 0);
|
||||||
@@ -10759,7 +10882,7 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus =
|
|||||||
if (ixians && winner.ally === ixians.id) {
|
if (ixians && winner.ally === ixians.id) {
|
||||||
g.pendingIxAlly = { player: winner.id, card: a.cards[a.index].id, free };
|
g.pendingIxAlly = { player: winner.id, card: a.cards[a.index].id, free };
|
||||||
g.decision = { kind: 'ixAllyCard', player: winner.id };
|
g.decision = { kind: 'ixAllyCard', player: winner.id };
|
||||||
} else continueAuctionSale(g, free);
|
} else if (!offerIxianNexusReplacement(g,printedKarama)) continueAuctionSale(g, free);
|
||||||
}
|
}
|
||||||
function currentAuctionContinuationQuote(
|
function currentAuctionContinuationQuote(
|
||||||
g: Game,
|
g: Game,
|
||||||
@@ -12030,7 +12153,7 @@ function completeKarama(g: Game, p: Player, use: KaramaUse, card?: Card) {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (use.kind === 'purchase') g.auction!.bidder = p.id;
|
if (use.kind === 'purchase') g.auction!.bidder = p.id;
|
||||||
settleAuction(g, true);
|
settleAuction(g, true, false, false, card?.effect === 'karama');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function auctionSpicePaymentFunded(g: Game, p: Player) {
|
function auctionSpicePaymentFunded(g: Game, p: Player) {
|
||||||
@@ -25179,6 +25302,7 @@ function normalizeCardNames(g: Game) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||||
|
ixianNexusReplacementIntegrity(state);
|
||||||
guildBetrayalIntegrity(state);
|
guildBetrayalIntegrity(state);
|
||||||
state = migratePrintedKullCounter(state);
|
state = migratePrintedKullCounter(state);
|
||||||
richeseBetrayalIntegrity(state);
|
richeseBetrayalIntegrity(state);
|
||||||
@@ -25265,6 +25389,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
|||||||
return normalizeAutomaticGame(state);
|
return normalizeAutomaticGame(state);
|
||||||
}
|
}
|
||||||
const g = applyActionInner(state, id, action);
|
const g = applyActionInner(state, id, action);
|
||||||
|
ixianNexusReplacementIntegrity(g);
|
||||||
|
if (g.pendingNexusIxianReplacement) return g;
|
||||||
if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;}
|
if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;}
|
||||||
if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;}
|
if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;}
|
||||||
if (g.pendingKull) {
|
if (g.pendingKull) {
|
||||||
@@ -25462,6 +25588,7 @@ function finishActionContinuations(g: Game) {
|
|||||||
finishMoritaniPlacement(g);
|
finishMoritaniPlacement(g);
|
||||||
}
|
}
|
||||||
function settleAutomaticContinuations(g: Game) {
|
function settleAutomaticContinuations(g: Game) {
|
||||||
|
if (g.pendingNexusIxianReplacement) return;
|
||||||
if (g.pendingGuildBetrayal) return;
|
if (g.pendingGuildBetrayal) return;
|
||||||
if (g.pendingRicheseBetrayal) return;
|
if (g.pendingRicheseBetrayal) return;
|
||||||
if (g.pendingKull?.stage === 'offer') return;
|
if (g.pendingKull?.stage === 'offer') return;
|
||||||
@@ -25469,6 +25596,7 @@ function settleAutomaticContinuations(g: Game) {
|
|||||||
if (g.pendingNullentropy) return;
|
if (g.pendingNullentropy) return;
|
||||||
for (let iteration = 0; iteration < 128; iteration++) {
|
for (let iteration = 0; iteration < 128; iteration++) {
|
||||||
if (g.pendingGuildBetrayal) return;
|
if (g.pendingGuildBetrayal) return;
|
||||||
|
if (g.pendingNexusIxianReplacement) return;
|
||||||
if (g.pendingRicheseBetrayal) return;
|
if (g.pendingRicheseBetrayal) return;
|
||||||
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
|
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
|
||||||
const response = g.response;
|
const response = g.response;
|
||||||
@@ -25508,6 +25636,7 @@ function settleAutomaticContinuations(g: Game) {
|
|||||||
}
|
}
|
||||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||||
export function normalizeAutomaticGame(state: Game): Game {
|
export function normalizeAutomaticGame(state: Game): Game {
|
||||||
|
ixianNexusReplacementIntegrity(state);
|
||||||
guildBetrayalIntegrity(state);
|
guildBetrayalIntegrity(state);
|
||||||
state = migratePrintedKullCounter(state);
|
state = migratePrintedKullCounter(state);
|
||||||
richeseBetrayalIntegrity(state);
|
richeseBetrayalIntegrity(state);
|
||||||
@@ -25544,6 +25673,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
|||||||
ecazCollectionIntegrity(state);
|
ecazCollectionIntegrity(state);
|
||||||
ecazAllianceIntegrity(state);
|
ecazAllianceIntegrity(state);
|
||||||
const g = structuredClone(state);
|
const g = structuredClone(state);
|
||||||
|
if (g.pendingNexusIxianReplacement) return g;
|
||||||
if (g.pendingGuildBetrayal) return g;
|
if (g.pendingGuildBetrayal) return g;
|
||||||
if (g.pendingRicheseBetrayal) return g;
|
if (g.pendingRicheseBetrayal) return g;
|
||||||
if (g.pendingKull?.stage === 'offer') return g;
|
if (g.pendingKull?.stage === 'offer') return g;
|
||||||
@@ -25596,6 +25726,12 @@ function applyActionInner(
|
|||||||
): Game {
|
): Game {
|
||||||
ecazTreacheryIntegrity(state);
|
ecazTreacheryIntegrity(state);
|
||||||
const g = structuredClone(state);
|
const g = structuredClone(state);
|
||||||
|
if (g.pendingNexusIxianReplacement) {
|
||||||
|
getPlayer(g,id);
|
||||||
|
if (action?.type === 'advanceBots') return g;
|
||||||
|
decideIxianNexusReplacement(g,id,action);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
if (g.pendingGuildBetrayal) {
|
if (g.pendingGuildBetrayal) {
|
||||||
const player = getPlayer(g, id);
|
const player = getPlayer(g, id);
|
||||||
if (action?.type === 'advanceBots') return g;
|
if (action?.type === 'advanceBots') return g;
|
||||||
@@ -25670,6 +25806,8 @@ function applyActionInner(
|
|||||||
);
|
);
|
||||||
requireRule(g.status !== 'finished', 'This game has ended.');
|
requireRule(g.status !== 'finished', 'This game has ended.');
|
||||||
const t = action.type;
|
const t = action.type;
|
||||||
|
requireRule(t !== 'nexusIxianReplacementPass' && t !== 'nexusIxianReplacementUse',
|
||||||
|
'This purchased-card choice is no longer pending.');
|
||||||
requireRule(t !== 'guildBetrayalPass' && t !== 'guildBetrayalUse',
|
requireRule(t !== 'guildBetrayalPass' && t !== 'guildBetrayalUse',
|
||||||
'This Guild Betrayal opportunity is no longer pending.');
|
'This Guild Betrayal opportunity is no longer pending.');
|
||||||
requireRule(t !== 'richeseBetrayalPass' && t !== 'richeseBetrayalUse',
|
requireRule(t !== 'richeseBetrayalPass' && t !== 'richeseBetrayalUse',
|
||||||
@@ -26063,6 +26201,9 @@ function applyActionInner(
|
|||||||
}
|
}
|
||||||
if (g.decision) {
|
if (g.decision) {
|
||||||
const decision = g.decision;
|
const decision = g.decision;
|
||||||
|
if (decision.kind === 'nexusIxianReplacement') {
|
||||||
|
requireRule(false, 'Use only the current purchased-card event with Use or Pass.');
|
||||||
|
}
|
||||||
requireRule(
|
requireRule(
|
||||||
t === 'decision' && decision.player === id,
|
t === 'decision' && decision.player === id,
|
||||||
'Waiting for the player with the pending decision.',
|
'Waiting for the player with the pending decision.',
|
||||||
@@ -27372,7 +27513,7 @@ function applyActionInner(
|
|||||||
return g;
|
return g;
|
||||||
} else log(g, `${p.name} declined the sandworm ride.`);
|
} else log(g, `${p.name} declined the sandworm ride.`);
|
||||||
nextWormRide(g);
|
nextWormRide(g);
|
||||||
} else {
|
} else if (decision.kind === 'battleCards') {
|
||||||
requireRule(
|
requireRule(
|
||||||
Array.isArray(action.discard) &&
|
Array.isArray(action.discard) &&
|
||||||
action.discard.every(
|
action.discard.every(
|
||||||
@@ -27405,6 +27546,9 @@ function applyActionInner(
|
|||||||
played: [...decision.cards],
|
played: [...decision.cards],
|
||||||
});
|
});
|
||||||
else finishBattle(g);
|
else finishBattle(g);
|
||||||
|
} else {
|
||||||
|
const unhandled: never = decision;
|
||||||
|
throw new Error(`Unsupported pending decision: ${String(unhandled)}`);
|
||||||
}
|
}
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
@@ -29435,6 +29579,7 @@ function applyActionInner(
|
|||||||
throw new RuleError('That action is not available.');
|
throw new RuleError('That action is not available.');
|
||||||
}
|
}
|
||||||
export function viewGame(state: Game, id: string) {
|
export function viewGame(state: Game, id: string) {
|
||||||
|
ixianNexusReplacementIntegrity(state);
|
||||||
guildBetrayalIntegrity(state);
|
guildBetrayalIntegrity(state);
|
||||||
state = migratePrintedKullCounter(state);
|
state = migratePrintedKullCounter(state);
|
||||||
richeseBetrayalIntegrity(state);
|
richeseBetrayalIntegrity(state);
|
||||||
@@ -29808,6 +29953,8 @@ export function viewGame(state: Game, id: string) {
|
|||||||
? {...nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},g.discoveryEntry!,id)),event:g.decision.event} : null,
|
? {...nexusRule(() => quoteDiscoveryEntry({...g,discoveries:g.discoveries!},g.discoveryEntry!,id)),event:g.decision.event} : null,
|
||||||
greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null,
|
greatMaker: g.greatMaker ? { event:g.greatMaker.event, turn:g.greatMaker.turn, stage:g.greatMaker.stage, votes:structuredClone(g.greatMaker.votes), order:[...g.greatMaker.order], ride:greatMakerRideOptions(g,id) } : null,
|
||||||
nexusCards: projectedNexusCards(g, id),
|
nexusCards: projectedNexusCards(g, id),
|
||||||
|
nexusIxianReplacementPreview: !!g.nexusIxianReplacementPreview,
|
||||||
|
nexusIxianReplacement: ixianNexusReplacementView(g,id),
|
||||||
nexusChoamTrade: currentNexusChoamTrade(g, id),
|
nexusChoamTrade: currentNexusChoamTrade(g, id),
|
||||||
nexusChoamInspection: quoteNexusChoamInspection(g, id),
|
nexusChoamInspection: quoteNexusChoamInspection(g, id),
|
||||||
nexusChoamBetrayal: quoteNexusChoamBetrayal(g, id,
|
nexusChoamBetrayal: quoteNexusChoamBetrayal(g, id,
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { baseDeck, type Card } from './cards';
|
||||||
|
|
||||||
|
export class IxianReplacementError extends Error {}
|
||||||
|
|
||||||
|
export type IxianReplacementContext = {
|
||||||
|
status: string;
|
||||||
|
phase: number;
|
||||||
|
turn: number;
|
||||||
|
sequence: number;
|
||||||
|
auction: {
|
||||||
|
cards: readonly Card[];
|
||||||
|
index: number;
|
||||||
|
bidder: string | null;
|
||||||
|
bid: number;
|
||||||
|
} | null;
|
||||||
|
sale: {
|
||||||
|
winner: string;
|
||||||
|
amount: number;
|
||||||
|
free: boolean;
|
||||||
|
origin: 'normal' | 'cache' | 'blackMarket';
|
||||||
|
seller: string | null;
|
||||||
|
} | null;
|
||||||
|
players: readonly {
|
||||||
|
id: string;
|
||||||
|
hand: readonly Card[];
|
||||||
|
handLimit: number;
|
||||||
|
}[];
|
||||||
|
/** Live ownership only: delivered auction descriptors are receipt aliases. */
|
||||||
|
physicalCards: readonly Card[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IxianReplacementSource = {
|
||||||
|
version: 1;
|
||||||
|
event: string;
|
||||||
|
buyer: string;
|
||||||
|
turn: number;
|
||||||
|
sequence: number;
|
||||||
|
auctionIndex: number;
|
||||||
|
amount: number;
|
||||||
|
free: boolean;
|
||||||
|
card: Card;
|
||||||
|
parent: string;
|
||||||
|
signature: string;
|
||||||
|
};
|
||||||
|
export type IxianReplacementCursor = { sequence: number; signature: string };
|
||||||
|
export type IxianReplacementReceipt = {
|
||||||
|
source: IxianReplacementSource;
|
||||||
|
outcome: 'pass' | 'use';
|
||||||
|
replacement: Card | null;
|
||||||
|
previous: string;
|
||||||
|
signature: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canonical: Readonly<Record<string, Card>> =
|
||||||
|
Object.fromEntries(baseDeck().map(card => [card.id, card]));
|
||||||
|
const genesis = 'ixian-replacement:0';
|
||||||
|
const plain = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value) &&
|
||||||
|
[Object.prototype, null].includes(Object.getPrototypeOf(value));
|
||||||
|
const text = (value: unknown): value is string =>
|
||||||
|
typeof value === 'string' && value.trim().length > 0;
|
||||||
|
const whole = (value: unknown): value is number =>
|
||||||
|
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||||
|
const array = (value: unknown): boolean => Array.isArray(value);
|
||||||
|
function requireReplacement(value: unknown, message: string): asserts value {
|
||||||
|
if (!value) throw new IxianReplacementError(message);
|
||||||
|
}
|
||||||
|
function exact(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
|
||||||
|
return plain(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
||||||
|
}
|
||||||
|
function canonicalCard(value: unknown): value is Card {
|
||||||
|
if (!plain(value) || !text(value.id)) return false;
|
||||||
|
const expected = Object.hasOwn(canonical, value.id) ? canonical[value.id] : undefined;
|
||||||
|
return !!expected &&
|
||||||
|
exact(value, ['id', 'name', 'kind', ...(expected.effect === undefined ? [] : ['effect'])]) &&
|
||||||
|
value.name === expected.name && value.kind === expected.kind &&
|
||||||
|
value.effect === expected.effect;
|
||||||
|
}
|
||||||
|
function sameCard(one: Card, two: Card): boolean {
|
||||||
|
return one.id === two.id && one.name === two.name &&
|
||||||
|
one.kind === two.kind && one.effect === two.effect;
|
||||||
|
}
|
||||||
|
function cardTuple(card: Card): unknown[] {
|
||||||
|
return [card.id, card.name, card.kind, card.effect ?? null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public identity contains no hidden face, price, parent or deck order. */
|
||||||
|
export function ixianReplacementEvent(
|
||||||
|
turn: number, buyer: string, sequence: number, auctionIndex: number,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify(['ixianReplacement', turn, buyer, sequence, auctionIndex]);
|
||||||
|
}
|
||||||
|
function sourceSignature(source: IxianReplacementSource): string {
|
||||||
|
return JSON.stringify([
|
||||||
|
'ixianReplacementSource', source.version, source.event, source.buyer,
|
||||||
|
source.turn, source.sequence, source.auctionIndex, source.amount,
|
||||||
|
source.free, cardTuple(source.card), source.parent,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function savedSource(value: unknown): value is IxianReplacementSource {
|
||||||
|
return exact(value, [
|
||||||
|
'version', 'event', 'buyer', 'turn', 'sequence', 'auctionIndex',
|
||||||
|
'amount', 'free', 'card', 'parent', 'signature',
|
||||||
|
]) && value.version === 1 && text(value.event) && text(value.buyer) &&
|
||||||
|
whole(value.turn) && value.turn > 0 && whole(value.sequence) &&
|
||||||
|
whole(value.auctionIndex) && whole(value.amount) &&
|
||||||
|
typeof value.free === 'boolean' && (value.free || value.amount > 0) &&
|
||||||
|
canonicalCard(value.card) &&
|
||||||
|
text(value.parent) && text(value.signature);
|
||||||
|
}
|
||||||
|
function validateSavedSource(value: unknown): asserts value is IxianReplacementSource {
|
||||||
|
requireReplacement(savedSource(value), 'The replacement lost its canonical normal purchase receipt.');
|
||||||
|
requireReplacement(
|
||||||
|
value.event === ixianReplacementEvent(value.turn, value.buyer, value.sequence, value.auctionIndex) &&
|
||||||
|
value.signature === sourceSignature(value),
|
||||||
|
'The replacement purchase receipt changed its original source.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function validatePhysical(physical: readonly Card[]): void {
|
||||||
|
requireReplacement(
|
||||||
|
Array.isArray(physical) && physical.every(canonicalCard) &&
|
||||||
|
new Set(physical.map(card => card.id)).size === physical.length,
|
||||||
|
'Replacement requires canonical cards with single physical custody.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorize the original paid/Karama NORMAL sale, not an arbitrary held card.
|
||||||
|
* The engine supplies its current source-specific parent and live custody census.
|
||||||
|
* Neither payment nor draw allocation is reconstructed here. */
|
||||||
|
export function validateIxianReplacementSource(
|
||||||
|
ctx: IxianReplacementContext, source: IxianReplacementSource, parent: string,
|
||||||
|
): void {
|
||||||
|
validateSavedSource(source);
|
||||||
|
requireReplacement(
|
||||||
|
ctx.status === 'playing' && ctx.phase === 3 && whole(ctx.turn) &&
|
||||||
|
source.turn === ctx.turn && whole(ctx.sequence) && source.sequence === ctx.sequence &&
|
||||||
|
text(parent) && source.parent === parent,
|
||||||
|
'The purchased card choice has lost its current parent or cursor.',
|
||||||
|
);
|
||||||
|
const auction = ctx.auction, sale = ctx.sale;
|
||||||
|
requireReplacement(
|
||||||
|
auction && sale && sale.origin === 'normal' && sale.seller === null &&
|
||||||
|
sale.winner === source.buyer && sale.amount === source.amount && sale.free === source.free &&
|
||||||
|
auction.bidder === source.buyer && auction.bid === source.amount &&
|
||||||
|
whole(auction.index) && auction.index === source.auctionIndex && Array.isArray(auction.cards) &&
|
||||||
|
canonicalCard(auction.cards[auction.index]) && sameCard(auction.cards[auction.index], source.card),
|
||||||
|
'Replacement must retain its exact original normal auction purchase.',
|
||||||
|
);
|
||||||
|
validatePhysical(ctx.physicalCards);
|
||||||
|
requireReplacement(
|
||||||
|
array(ctx.players) && ctx.players.every(player =>
|
||||||
|
plain(player) && text(player.id) && whole(player.handLimit) && player.handLimit > 0 &&
|
||||||
|
Array.isArray(player.hand) && player.hand.every(canonicalCard)) &&
|
||||||
|
new Set(ctx.players.map(player => player.id)).size === ctx.players.length,
|
||||||
|
'Replacement needs its original buyer and canonical hand custody.',
|
||||||
|
);
|
||||||
|
const buyer = ctx.players.find(player => player.id === source.buyer);
|
||||||
|
requireReplacement(
|
||||||
|
buyer && buyer.hand.length <= buyer.handLimit &&
|
||||||
|
buyer.hand.filter(card => card.id === source.card.id).length === 1 &&
|
||||||
|
buyer.hand.some(card => sameCard(card, source.card)),
|
||||||
|
'The exact purchased card must remain owned in a legal hand.',
|
||||||
|
);
|
||||||
|
const held = ctx.players.flatMap(player => player.hand);
|
||||||
|
requireReplacement(
|
||||||
|
new Set(held.map(card => card.id)).size === held.length &&
|
||||||
|
held.every(card => ctx.physicalCards.some(physical => sameCard(card, physical))),
|
||||||
|
'The purchased card and hands must retain unique physical custody.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIxianReplacementSource(
|
||||||
|
ctx: IxianReplacementContext, event: string, parent: string,
|
||||||
|
): IxianReplacementSource {
|
||||||
|
const auction = ctx.auction, sale = ctx.sale;
|
||||||
|
requireReplacement(
|
||||||
|
auction && sale && Array.isArray(auction.cards) && whole(auction.index) &&
|
||||||
|
canonicalCard(auction.cards[auction.index]),
|
||||||
|
'Replacement needs an actual canonical purchased auction card.',
|
||||||
|
);
|
||||||
|
const source: IxianReplacementSource = {
|
||||||
|
version: 1, event, buyer: sale.winner, turn: ctx.turn, sequence: ctx.sequence,
|
||||||
|
auctionIndex: auction.index, amount: sale.amount, free: sale.free,
|
||||||
|
card: { ...auction.cards[auction.index] }, parent, signature: '',
|
||||||
|
};
|
||||||
|
source.signature = sourceSignature(source);
|
||||||
|
validateIxianReplacementSource(ctx, source, parent);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialIxianReplacementCursor(): IxianReplacementCursor {
|
||||||
|
return { sequence: 0, signature: genesis };
|
||||||
|
}
|
||||||
|
function validateCursor(value: unknown): asserts value is IxianReplacementCursor {
|
||||||
|
requireReplacement(
|
||||||
|
exact(value, ['sequence', 'signature']) && whole(value.sequence) && text(value.signature) &&
|
||||||
|
(value.sequence !== 0 || value.signature === genesis),
|
||||||
|
'The replacement history lost its independent cursor.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function receiptSignature(receipt: IxianReplacementReceipt): string {
|
||||||
|
return JSON.stringify([
|
||||||
|
'ixianReplacementReceipt', receipt.source.signature, receipt.outcome,
|
||||||
|
receipt.replacement === null ? null : cardTuple(receipt.replacement), receipt.previous,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link to one completed event, not a recursively serialized prior receipt. */
|
||||||
|
function receiptHead(receipt: IxianReplacementReceipt): string {
|
||||||
|
return JSON.stringify(['ixianReplacementHead', receipt.source.event,
|
||||||
|
receipt.outcome, receipt.replacement?.id ?? null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close AFTER native discard/draw. Same-card redraw is legal after replenishment.
|
||||||
|
* No source/history object aliases are returned and no input is mutated. */
|
||||||
|
export function closeIxianReplacementSource(
|
||||||
|
source: IxianReplacementSource,
|
||||||
|
outcome: 'pass' | 'use',
|
||||||
|
replacement: Card | null,
|
||||||
|
previous: IxianReplacementCursor,
|
||||||
|
): { receipt: IxianReplacementReceipt; cursor: IxianReplacementCursor } {
|
||||||
|
validateSavedSource(source);
|
||||||
|
validateCursor(previous);
|
||||||
|
requireReplacement(
|
||||||
|
source.sequence === previous.sequence && previous.sequence < Number.MAX_SAFE_INTEGER &&
|
||||||
|
(outcome === 'pass' ? replacement === null : outcome === 'use' && canonicalCard(replacement)),
|
||||||
|
'The replacement cannot close twice or lose its actual draw outcome.',
|
||||||
|
);
|
||||||
|
const receipt: IxianReplacementReceipt = {
|
||||||
|
source: { ...source, card: { ...source.card } }, outcome,
|
||||||
|
replacement: replacement === null ? null : { ...replacement },
|
||||||
|
previous: previous.signature, signature: '',
|
||||||
|
};
|
||||||
|
receipt.signature = receiptSignature(receipt);
|
||||||
|
return { receipt, cursor: { sequence: previous.sequence + 1, signature: receiptHead(receipt) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Historical evidence is independent of present-day card locations: cards may
|
||||||
|
* be transferred, discarded, recycled and purchased again in a later event.
|
||||||
|
* Signatures prove consistency, not authentication of arbitrarily rewritten saves. */
|
||||||
|
export function validateIxianReplacementHistory(
|
||||||
|
history: readonly IxianReplacementReceipt[],
|
||||||
|
cursor: IxianReplacementCursor,
|
||||||
|
physical?: readonly Card[],
|
||||||
|
): void {
|
||||||
|
validateCursor(cursor);
|
||||||
|
requireReplacement(array(history) && history.length === cursor.sequence,
|
||||||
|
'The replacement history has missing or duplicate closed choices.');
|
||||||
|
if (physical !== undefined) validatePhysical(physical);
|
||||||
|
let previous = genesis, turn = 0;
|
||||||
|
const events = new Set<string>();
|
||||||
|
for (const [index, receipt] of history.entries()) {
|
||||||
|
requireReplacement(
|
||||||
|
exact(receipt, ['source', 'outcome', 'replacement', 'previous', 'signature']),
|
||||||
|
'The replacement history has a malformed outcome.',
|
||||||
|
);
|
||||||
|
validateSavedSource(receipt.source);
|
||||||
|
requireReplacement(
|
||||||
|
receipt.source.sequence === index && receipt.source.turn >= turn &&
|
||||||
|
!events.has(receipt.source.event) && receipt.previous === previous &&
|
||||||
|
(receipt.outcome === 'pass' ? receipt.replacement === null :
|
||||||
|
receipt.outcome === 'use' && canonicalCard(receipt.replacement)) &&
|
||||||
|
receipt.signature === receiptSignature(receipt),
|
||||||
|
'The replacement history changed its purchase, outcome or continuation chain.',
|
||||||
|
);
|
||||||
|
events.add(receipt.source.event);
|
||||||
|
previous = receiptHead(receipt);
|
||||||
|
turn = receipt.source.turn;
|
||||||
|
}
|
||||||
|
requireReplacement(cursor.signature === previous,
|
||||||
|
'The replacement history no longer matches its closed-choice cursor.');
|
||||||
|
}
|
||||||
+34
-2
@@ -1654,7 +1654,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'After a paid or Karama-paid purchase, an Ixian ally may keep the purchased card or request its replacement. A response resolves before discarding that exact card and drawing the deck’s top card privately. Payment still settles normally. Harkonnen’s separate bonus draw follows the replacement.',
|
'After a paid or Karama-paid purchase, an Ixian ally may keep the purchased card or request its replacement. A response resolves before discarding that exact card and drawing the deck’s top card privately. Payment still settles normally. Harkonnen’s separate bonus draw follows the replacement.',
|
||||||
'The controls preserve decisions and private cards across reconnects. Complete expansion starts, Richese/other auction modes, exact phase-opening ordering, short-deck behavior and interruption timing remain under audit.',
|
'The controls preserve decisions and private cards across reconnects. Complete expansion starts, Richese/other auction modes, exact phase-opening ordering, short-deck behavior and interruption timing remain under audit.',
|
||||||
],
|
],
|
||||||
related: ['bidding', 'setup', 'ix-forces'],
|
related: ['bidding', 'setup', 'ix-forces', 'nexus-ixian-replacement'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mobile-stronghold',
|
id: 'mobile-stronghold',
|
||||||
@@ -4180,6 +4180,37 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{ area: 'Verification', status: 'Partial', detail: '147 selected offline cases and 6,114 full offline checks cover four native adapters, funded own refunds, original escrow, full low-Junction override, face-neutral acknowledgement, four legal bot profiles, actual handlers, authenticated restart/races and independent native-grant/orphan rejection. Eight real backed-up CLI entries and 390-pixel controls prove own/other/escrow/Guild-return/Homeworld/Junction Use, all-pass, free Fremen and refresh with exact saved force/card/spice custody. Local HTTP 48/55 has five 503s and two timeouts; broader and deployed acceptance remain open.', evidence: ['tests/nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-engine.test.ts', 'tests/nexus-guild-betrayal-controls.test.tsx', 'tests/bot-nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-recovery.test.ts', 'docs/NEXUS_GUILD_RULES.md'] },
|
{ area: 'Verification', status: 'Partial', detail: '147 selected offline cases and 6,114 full offline checks cover four native adapters, funded own refunds, original escrow, full low-Junction override, face-neutral acknowledgement, four legal bot profiles, actual handlers, authenticated restart/races and independent native-grant/orphan rejection. Eight real backed-up CLI entries and 390-pixel controls prove own/other/escrow/Guild-return/Homeworld/Junction Use, all-pass, free Fremen and refresh with exact saved force/card/spice custody. Local HTTP 48/55 has five 503s and two timeouts; broader and deployed acceptance remain open.', evidence: ['tests/nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-engine.test.ts', 'tests/nexus-guild-betrayal-controls.test.tsx', 'tests/bot-nexus-guild-betrayal.test.ts', 'tests/nexus-guild-betrayal-recovery.test.ts', 'docs/NEXUS_GUILD_RULES.md'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'nexus-ixian-replacement',
|
||||||
|
title: 'Ixian Nexus Secret Ally: replace the just-purchased card',
|
||||||
|
category: 'Advanced & expansions',
|
||||||
|
coverage: 'Partial',
|
||||||
|
developmentStage: 'Verified',
|
||||||
|
summary: 'In the fresh local ixian-replacement preview, an unallied holder with Ixians absent may spend the physical Ixian Nexus to discard exactly the card just bought in a normal auction and draw the next Treachery deck card privately.',
|
||||||
|
searchText: 'Ixian Nexus Secret Ally purchased card replacement normal auction Karama full hand empty deck same card',
|
||||||
|
steps: [
|
||||||
|
'This is a fresh local development profile, not a public Nexus start or an upgrade to saved games. It admits two to six unique classic-faction seats in Basic or Advanced with the base Treachery deck and Nexus alone. Native Ixians, expansion factions/decks, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and other optional overlays stay outside the boundary.',
|
||||||
|
'Nexus has no starting deal. A draw requires a qualifying closing Nexus with an alliance. A two-seat profile can continue its base game but cannot naturally create an unallied Secret Ally holder; actual replacement needs at least three seats, or four for two simultaneous unallied recipients.',
|
||||||
|
'The photographed publisher face grants an independent Secret Ally operation when Ixians are absent. Remain unallied and hold the actual singleton Ixian Nexus. Native Ixian allied replacement is a different cancelable advantage and is not removed or used as a fallback. The adopted unofficial Advanced rulebook supplies relevant core rules, not this absent Nexus face or a new Nexus ruling.',
|
||||||
|
'Wait for a genuine normal-auction purchase, paid with spice or through an actual printed Karama purchase, including direct printed acquisition before a positive bid. Native Bene Gesserit Worthless/Truthtrance substitutions continue unchanged without this bounded offer; that producer limit is not a ruling that they are not purchases. A Harkonnen buyer and cache, Black Market or special-purchase origins are guarded pending bonus priority and independent Nexus cache-scope rulings. A Harkonnen rival remains allowed. Gifts, bonus draws, Technology swaps and old hand cards are not eligible purchases.',
|
||||||
|
'Every publicly unallied supported buyer with public held-Nexus presence receives the same neutral Purchased card choice, irrespective of the secret face. An irrelevant-face buyer may Pass but cannot Use. This is an inferred development privacy convention, not publisher-prescribed timing or a general resolution of pending anytime and response-priority questions.',
|
||||||
|
'Only the buyer may inspect its already-owned purchased card in this choice. You may inspect your own actually held Nexus through the existing authorized inspector; the prompt does not identify anybody else’s Nexus. Rivals receive no purchased or replacement face, private price, receipt, deck order or eligibility reason. Earlier Atreides auction inspection does not grant a peek of the replacement.',
|
||||||
|
'Pass keeps your purchased card and Nexus and closes this purchase opportunity. Use spends your physical Ixian Nexus once and discards exactly that just-purchased Treachery Card before drawing the actual next deck card. You cannot select another held card, replacement face, price or recipient; both actions bind only the offered event.',
|
||||||
|
'A full legal hand can replace because the discard opens its slot first. If the draw deck is empty, recycle eligible Treachery discards including the card just discarded: the same physical card can legally return. The unsold auction row is not the draw deck, and no different-card guarantee or new auction is created.',
|
||||||
|
'Keep the original payment, funded contribution and free/paid source. There is no refund or second debit. Original income and the native auction continuation finish once after the choice, preserving existing response and decision priority. Other lot, card, transfer and payment actions cannot bypass the pending source lock; independent own-seat autopilot changes remain available.',
|
||||||
|
'Refresh restores the same pending event or closed outcome without another payment, draw, shuffle or Nexus spend. A later genuine purchase may create a new event; closed, stale, foreign or malformed events cannot reopen an old one. Later transfers and reshuffles do not invalidate history merely because the old physical card has moved again. Receipt links are nonrecursive so saved history stays linear across retained purchases.',
|
||||||
|
'Easy, Medium, Hard and Brutal use their own projected legal Use/Pass choices. These are minimal legal policies, not calibrated Nexus strategy. Frozen native, private controls, actual local CLI/phone and authenticated recovery evidence verifies only this bounded path. Full faction/module games, wider compositions, public starts and deployed acceptance remain separate gates.',
|
||||||
|
],
|
||||||
|
example: 'You buy a card for four spice and inspect it privately. Pass keeps it and the Nexus. Use keeps the original four-spice purchase settled, spends Ixian Nexus, discards that exact card and draws the next deck card. If the draw deck is empty, its new discard joins the actual recycled pile and can be drawn again.',
|
||||||
|
related: ['nexus-cards', 'bidding', 'ix-technology', 'card-karama', 'alliance-funding', 'privacy', 'implementation-checklist'],
|
||||||
|
checklist: [
|
||||||
|
{ area: 'Implementation', status: 'Partial', detail: 'The fresh bounded profile binds the exact canonical normal paid/printed Karama purchase, physical replacement and closed-event history to native payment/discard/draw/auction continuation. Harkonnen buyers, special origins and combined modules remain guarded.', evidence: ['game/nexus-ixian-replacement.ts', 'game/engine.ts', 'tools/prototype-room.ts'] },
|
||||||
|
{ area: 'Player controls', status: 'Partial', detail: 'Neutral Purchased card choice separates buyer-only purchased-card inspection and legal Use/Pass from public waiting; own-held Nexus inspection grants no rival entitlement. Broader presentation acceptance remains open.', evidence: ['components/nexus-ixian-replacement.tsx', 'components/game-table.tsx'] },
|
||||||
|
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal policies consume only their own projected event and eligibility while the underlying auction waits. Full strategy and difficulty calibration remain unfinished.', evidence: ['game/bots.ts'] },
|
||||||
|
{ area: 'Documentation', status: 'Partial', detail: 'Photographed face, exact physical target, full-hand/empty-deck rules, original settlement once, privacy convention and unresolved cache/Harkonnen/priority boundaries are recorded without PDF Nexus authority or full-family claims.', evidence: ['docs/NEXUS_IXIAN_REPLACEMENT_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] },
|
||||||
|
{ area: 'Verification', status: 'Partial', detail: 'Genuine paid/printed Karama acquisition, exact target/custody, full-hand and a captured native depleted pool cover same-card redraw, unsold-row exclusion, original ledger once, privacy, replay and four legal bots. Authenticated SQLite covers restart, races and durable rejection. Actual backed-up CLI/phone Use, Keep, takeback and refresh preserve original seats and stock. Wider modes and deployment remain open.', evidence: ['tests/nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-engine.test.ts', 'tests/fixtures/nexus-ixian-native-depleted.json', 'tests/fixture-nexus-ixian-replacement.ts', 'tests/nexus-ixian-replacement-controls.test.tsx', 'tests/bot-nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-recovery.test.ts', 'tests/prototype-room.test.ts', 'docs/NEXUS_IXIAN_REPLACEMENT_RULES.md'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'nexus-cards',
|
id: 'nexus-cards',
|
||||||
title: 'Nexus Cards',
|
title: 'Nexus Cards',
|
||||||
@@ -4204,6 +4235,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.',
|
'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.',
|
||||||
'Tleilaxu Cunning sets aside all your revealed Face Dancers, draws their replacements secretly, and only then shuffles the set-aside cards into the Traitor Deck. Unrevealed dancers stay in your hand. This single operation does not use or renew your ordinary Mentat replacement; the native all-three-revealed reset keeps its separate shuffle-before-draw order.',
|
'Tleilaxu Cunning sets aside all your revealed Face Dancers, draws their replacements secretly, and only then shuffles the set-aside cards into the Traitor Deck. Unrevealed dancers stay in your hand. This single operation does not use or renew your ordinary Mentat replacement; the native all-three-revealed reset keeps its separate shuffle-before-draw order.',
|
||||||
'Ixian Cunning is used in your battle before submitting your Battle Plan. Your Suboids then count as strength one without spice support in every battle for the rest of the turn, in Basic and Advanced. Cyborg strength and support are unchanged. Earlier inspected or promised plan elements remain binding; you cannot activate the card if the new strength would make them impossible.',
|
'Ixian Cunning is used in your battle before submitting your Battle Plan. Your Suboids then count as strength one without spice support in every battle for the rest of the turn, in Basic and Advanced. Cyborg strength and support are unchanged. Earlier inspected or promised plan elements remain binding; you cannot activate the card if the new strength would make them impossible.',
|
||||||
|
'Ixian Secret Ally has a fresh local ixian-replacement profile for normal paid or printed Karama-paid auction purchases in classic base-deck/Nexus Basic or Advanced games. A supported unallied buyer may spend its actual Nexus to discard exactly the just-purchased card and draw the real deck top privately; Pass keeps both cards. Full-hand replacement and empty-deck same-card redraw are allowed, and original payment/income/auction continuation occur once. Harkonnen buyers, cache/Black Market/special origins and combined modules remain guarded. The neutral buyer choice is an inferred privacy convention, not publisher timing. See the dedicated guide for bounded verified native, private, recovery and phone evidence; this is not full-module certification.',
|
||||||
'Tleilaxu Secret Ally revival remains unavailable pending its ordinary-allowance, leader-eligibility and optional-leader rulings. The printed fixed force price does not resolve those questions.',
|
'Tleilaxu Secret Ally revival remains unavailable pending its ordinary-allowance, leader-eligibility and optional-leader rulings. The printed fixed force price does not resolve those questions.',
|
||||||
'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.',
|
'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.',
|
||||||
'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.',
|
'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.',
|
||||||
@@ -4234,7 +4266,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
|
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
|
||||||
'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.',
|
'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.',
|
||||||
],
|
],
|
||||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal'],
|
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement'],
|
||||||
checklist: [
|
checklist: [
|
||||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment and bounded auction Betrayal, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/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/engine.ts'] },
|
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment and bounded auction Betrayal, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/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/engine.ts'] },
|
||||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', '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'] },
|
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', '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'] },
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const DECISIONS = {
|
|||||||
capturedLeader: true,
|
capturedLeader: true,
|
||||||
guildTiming: true,
|
guildTiming: true,
|
||||||
auctionPayment: true,
|
auctionPayment: true,
|
||||||
|
nexusIxianReplacement: true,
|
||||||
advisor: true,
|
advisor: true,
|
||||||
intrusion: true,
|
intrusion: true,
|
||||||
advisorBattle: true,
|
advisorBattle: true,
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,7 @@ type TurnView = Pick<
|
|||||||
| 'phaseOpening'
|
| 'phaseOpening'
|
||||||
| 'automaticContinuationPending'
|
| 'automaticContinuationPending'
|
||||||
> &
|
> &
|
||||||
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction'>>;
|
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement'>>;
|
||||||
|
|
||||||
/** The single current decision owner for the table banner and seat highlight.
|
/** The single current decision owner for the table banner and seat highlight.
|
||||||
* Shared response windows have no single acting seat. Only public view fields
|
* Shared response windows have no single acting seat. Only public view fields
|
||||||
@@ -32,6 +32,7 @@ export function tableActionOwner(g: TurnView): string | null {
|
|||||||
return null;
|
return null;
|
||||||
const seated = (id: string | null | undefined) =>
|
const seated = (id: string | null | undefined) =>
|
||||||
g.players.some((p) => p.id === id) ? id! : null;
|
g.players.some((p) => p.id === id) ? id! : null;
|
||||||
|
if (g.nexusIxianReplacement) return seated(g.nexusIxianReplacement.buyer);
|
||||||
if (g.decision) return seated(g.decision.player);
|
if (g.decision) return seated(g.decision.player);
|
||||||
if (g.biddingEnd) {
|
if (g.biddingEnd) {
|
||||||
const pending = g.biddingEnd.owners.filter(
|
const pending = g.biddingEnd.owners.filter(
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { applyAction, viewGame } from '../game/engine';
|
||||||
|
import type { GameView } from '../game/engine';
|
||||||
|
import { botActions, runBots } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
|
||||||
|
import { createIxianNexusReplacementFixture, settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
|
||||||
|
|
||||||
|
void test('all four profiles replace the authorized purchased worthless card and preserve the funded native sale', () => {
|
||||||
|
for (const profile of DIFFICULTIES) for (const advanced of [false, true]) for (const karama of [false, true]) {
|
||||||
|
const fixture = createIxianNexusReplacementFixture({ advanced, karama, purchasedKind: 'worthless' });
|
||||||
|
const own = viewGame(fixture.game, fixture.buyer);
|
||||||
|
own.players.find(player => player.id === fixture.buyer)!.bot = profile;
|
||||||
|
const actions = botActions(own);
|
||||||
|
assert.equal(actions.length, 1);
|
||||||
|
assert.equal(actions[0].type, 'nexusIxianReplacementUse');
|
||||||
|
const result = settleIxianReplacementFixture(applyAction(fixture.game, fixture.buyer, actions[0]));
|
||||||
|
const kept = settleIxianReplacementFixture(applyAction(fixture.game, fixture.buyer, { type: 'nexusIxianReplacementPass', event: fixture.event }));
|
||||||
|
assert.equal(viewGame(result, fixture.buyer).nexusIxianReplacement, null);
|
||||||
|
assert.equal(result.nexusCards?.cards?.hands[fixture.buyer], null);
|
||||||
|
assert.equal(result.players.find(player => player.id === fixture.buyer)!.hand.length,
|
||||||
|
fixture.game.players.find(player => player.id === fixture.buyer)!.hand.length);
|
||||||
|
const nextCard = fixture.nextCard;
|
||||||
|
assert.ok(nextCard);
|
||||||
|
assert.ok(result.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === nextCard.id));
|
||||||
|
assert.deepEqual(result.players.map(player => ({ id: player.id, spice: player.spice })),
|
||||||
|
kept.players.map(player => ({ id: player.id, spice: player.spice })));
|
||||||
|
assert.equal(result.auction?.index, kept.auction?.index);
|
||||||
|
assert.equal(result.phase, kept.phase);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('all profiles prioritize neutral wrong-face Pass over native bids and retain both owned cards', () => {
|
||||||
|
for (const profile of DIFFICULTIES) for (const endingAuction of [false, true]) {
|
||||||
|
const fixture = createIxianNexusReplacementFixture({ face: 'richese', endingAuction });
|
||||||
|
const own = viewGame(fixture.game, fixture.buyer);
|
||||||
|
own.players.find(player => player.id === fixture.buyer)!.bot = profile;
|
||||||
|
const actions = botActions(own);
|
||||||
|
assert.equal(actions.length, 1);
|
||||||
|
assert.equal(actions[0].type, 'nexusIxianReplacementPass');
|
||||||
|
const result = settleIxianReplacementFixture(applyAction(fixture.game, fixture.buyer, actions[0]));
|
||||||
|
assert.equal(viewGame(result, fixture.buyer).nexusIxianReplacement, null);
|
||||||
|
assert.equal(result.nexusCards?.cards?.hands[fixture.buyer], 'richese');
|
||||||
|
assert.ok(result.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.purchased.id));
|
||||||
|
if (endingAuction) assert.equal(result.auction, null);
|
||||||
|
const before = JSON.stringify(result);
|
||||||
|
assert.throws(() => applyAction(result, fixture.buyer, actions[0]));
|
||||||
|
assert.equal(JSON.stringify(result), before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('private eligibility and sole buyer ownership override held-face hints for every profile', () => {
|
||||||
|
for (const profile of DIFFICULTIES) {
|
||||||
|
const fixture = createIxianNexusReplacementFixture();
|
||||||
|
const own = viewGame(fixture.game, fixture.buyer);
|
||||||
|
own.players.find(player => player.id === fixture.buyer)!.bot = profile;
|
||||||
|
for (const restriction of ['canUse', 'blocked'] as const) {
|
||||||
|
const blocked = structuredClone(own);
|
||||||
|
if (restriction === 'canUse') blocked.nexusIxianReplacement!.canUse = false;
|
||||||
|
else blocked.nexusIxianReplacement!.blocked = 'The current source is reserved.';
|
||||||
|
const actions = botActions(blocked);
|
||||||
|
assert.equal(actions.length, 1);
|
||||||
|
assert.equal(actions[0].type, 'nexusIxianReplacementPass');
|
||||||
|
const result = applyAction(fixture.game, fixture.buyer, actions[0]);
|
||||||
|
assert.equal(result.nexusCards?.cards?.hands[fixture.buyer], 'ixians');
|
||||||
|
assert.ok(result.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.purchased.id));
|
||||||
|
}
|
||||||
|
own.nexusIxianReplacement!.canPass = false;
|
||||||
|
assert.deepEqual(botActions(own), []);
|
||||||
|
for (const rival of fixture.game.players.filter(player => player.id !== fixture.buyer)) {
|
||||||
|
const view = viewGame(fixture.game, rival.id);
|
||||||
|
view.players.find(player => player.id === rival.id)!.bot = profile;
|
||||||
|
assert.deepEqual(botActions(view), []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('human ownership, room locks and delegated buyers never cause a pending-source bot wait loop', () => {
|
||||||
|
for (const profile of DIFFICULTIES) {
|
||||||
|
const fixture = createIxianNexusReplacementFixture({ endingAuction: true });
|
||||||
|
const own: GameView = viewGame(fixture.game, fixture.buyer);
|
||||||
|
const seat = own.players.find(player => player.id === fixture.buyer)!;
|
||||||
|
delete seat.bot;
|
||||||
|
delete seat.autopilot;
|
||||||
|
assert.deepEqual(botActions(own), []);
|
||||||
|
seat.autopilot = profile;
|
||||||
|
for (const restriction of ['paused', 'closed'] as const) {
|
||||||
|
const locked = structuredClone(own);
|
||||||
|
locked.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
|
||||||
|
assert.deepEqual(botActions(locked), []);
|
||||||
|
}
|
||||||
|
const delegated = structuredClone(fixture.game);
|
||||||
|
for (const player of delegated.players) player.bot = profile;
|
||||||
|
const result = settleIxianReplacementFixture(runBots(delegated, 1));
|
||||||
|
assert.equal(viewGame(result, fixture.buyer).nexusIxianReplacement, null);
|
||||||
|
assert.equal(result.nexusCards?.cards?.hands[fixture.buyer], null);
|
||||||
|
assert.equal(result.auction, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyAction, createGame, initializeIxianNexusReplacementGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import type { Card } from '../game/cards';
|
||||||
|
import { advanceToNextStorm } from './fixture-advanced-source';
|
||||||
|
|
||||||
|
export const IXIAN_REPLACEMENT_FACTIONS = ['atreides', 'emperor', 'guild'] as const;
|
||||||
|
export type IxianReplacementFixtureOptions = {
|
||||||
|
initial?: Game;
|
||||||
|
advanced?: boolean;
|
||||||
|
karama?: boolean;
|
||||||
|
face?: 'ixians' | 'richese';
|
||||||
|
seatIds?: string[];
|
||||||
|
purchasedKind?: 'worthless' | 'shield';
|
||||||
|
endingAuction?: boolean;
|
||||||
|
fullHand?: boolean;
|
||||||
|
otherDraw?: {player: string; face: 'ixians' | 'richese'};
|
||||||
|
};
|
||||||
|
export type IxianReplacementFixture = {
|
||||||
|
beforePayment: Game;
|
||||||
|
game: Game;
|
||||||
|
buyer: string;
|
||||||
|
event: string;
|
||||||
|
purchased: Card;
|
||||||
|
paymentAction: Action;
|
||||||
|
beforeSpice: number;
|
||||||
|
paidSpice: number;
|
||||||
|
emperor: string | null;
|
||||||
|
beforeEmperorSpice: number | null;
|
||||||
|
nextCard: Card | null;
|
||||||
|
auctionIndex: number;
|
||||||
|
};
|
||||||
|
export function settleIxianReplacementFixture(state: Game): Game {
|
||||||
|
let game = state;
|
||||||
|
for (let step = 0; step < 100; step++) {
|
||||||
|
if (game.pendingTreacheryDiscard) game = applyAction(game, game.players[0].id, {type:'advanceBots'});
|
||||||
|
else if (game.response) game = applyAction(game,
|
||||||
|
game.players.find(p => !game.response!.passed.includes(p.id))!.id, {type:'passResponse'});
|
||||||
|
else return game;
|
||||||
|
}
|
||||||
|
throw new Error('The original native auction response did not settle.');
|
||||||
|
}
|
||||||
|
function stepNative(game: Game): Game {
|
||||||
|
if (game.response || game.pendingTreacheryDiscard) return settleIxianReplacementFixture(game);
|
||||||
|
if (game.phaseOpening) return applyAction(game,
|
||||||
|
game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!.id, {type:'ready'});
|
||||||
|
if (game.status === 'playing' && !game.decision) {
|
||||||
|
if (game.phase === 0 && game.stormPending === null) {
|
||||||
|
const id = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||||
|
assert.ok(id, 'The native Storm source must supply the next actual dialer.');
|
||||||
|
return applyAction(game,id,{type:'stormDial',amount:game.turn === 1 ? 0 : 1});
|
||||||
|
}
|
||||||
|
if (game.phase === 3 && game.auction) return applyAction(game,game.auction.active,{type:'passBid'});
|
||||||
|
if (game.phase === 5) return applyAction(game,game.active!,{type:'endMovement'});
|
||||||
|
}
|
||||||
|
for (const p of game.players) {
|
||||||
|
const view = viewGame(game,p.id);
|
||||||
|
view.players.find(seat => seat.id === p.id)!.bot = 'Easy';
|
||||||
|
const actions = botActions(view);
|
||||||
|
const action = actions.find(a => a.type === 'ready') ?? actions[0];
|
||||||
|
if (action) return applyAction(game,p.id,action);
|
||||||
|
}
|
||||||
|
throw new Error(`Native Ixian fixture stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||||
|
}
|
||||||
|
/** Conserves the original undealt inventory, putting the buyer's printed Karama
|
||||||
|
* into the genuine starting deal rather than manufacturing an owned card. */
|
||||||
|
function orderStartingCards(game: Game,buyer: string): void {
|
||||||
|
const pool = [...game.deck];
|
||||||
|
const front: Card[] = [];
|
||||||
|
for (const p of game.players) {
|
||||||
|
const quantity = p.faction === 'harkonnen' ? 2 : 1;
|
||||||
|
for (let i = 0; i < quantity; i++) {
|
||||||
|
const index = pool.findIndex(c => p.id === buyer && i === 0 ? c.effect === 'karama' : c.effect !== 'karama');
|
||||||
|
assert.ok(index >= 0);
|
||||||
|
front.push(pool.splice(index,1)[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
game.deck = [...front,...pool];
|
||||||
|
}
|
||||||
|
/** Genuine fresh setup, native first turn, printed Nexus drawing and normal
|
||||||
|
* winner declaration. The returned parent is before the original payment. */
|
||||||
|
export function createIxianNexusReplacementFixture(options: IxianReplacementFixtureOptions = {}): IxianReplacementFixture {
|
||||||
|
let game: Game;
|
||||||
|
if (options.initial) {
|
||||||
|
game = structuredClone(options.initial);
|
||||||
|
assert.ok(game.status === 'lobby' || (game.status === 'setup' &&
|
||||||
|
game.nexusIxianReplacementPreview === true && game.turn === 1 && game.phase === 0 &&
|
||||||
|
game.players.every(player => player.hand.length === 0)),
|
||||||
|
'Continue only an admitted fresh lobby or its undealt native prototype setup.');
|
||||||
|
if (options.advanced !== undefined) assert.equal(game.advanced,options.advanced);
|
||||||
|
} else {
|
||||||
|
const ids = options.seatIds ?? ['p0','p1','p2'];
|
||||||
|
assert.equal(ids.length,3);
|
||||||
|
game = createGame('IXIANREPLACE',newPlayer(ids[0],'Atreides','atreides'),options.advanced ?? false,[]);
|
||||||
|
joinGame(game,newPlayer(ids[1],'Emperor','emperor'));
|
||||||
|
joinGame(game,newPlayer(ids[2],'Guild','guild'));
|
||||||
|
}
|
||||||
|
const buyer = game.players.find(p => p.faction === 'atreides')?.id ??
|
||||||
|
game.players.find(p => p.faction !== 'harkonnen')!.id;
|
||||||
|
const face = options.face ?? 'ixians';
|
||||||
|
if (options.otherDraw) {
|
||||||
|
assert.ok(game.players.some(p => p.id === options.otherDraw!.player));
|
||||||
|
assert.notEqual(options.otherDraw.player,buyer);
|
||||||
|
assert.notEqual(options.otherDraw.face,face);
|
||||||
|
}
|
||||||
|
const partners = game.players.filter(p => p.id !== buyer && p.id !== options.otherDraw?.player);
|
||||||
|
assert.ok(partners.length >= 2,
|
||||||
|
'Native Nexus draws require an alliance between two seats other than the requested recipients.');
|
||||||
|
if (game.status === 'lobby') {
|
||||||
|
for (const p of game.players) if (!p.ready) game = applyAction(game,p.id,{type:'ready'});
|
||||||
|
game.nexusCards ??= {cards:null,phase:null};
|
||||||
|
game = initializeIxianNexusReplacementGameForAudit(game);
|
||||||
|
}
|
||||||
|
orderStartingCards(game,buyer);
|
||||||
|
// Prepare the conserved faces before any native Nexus acquisition. Setup
|
||||||
|
// initializes an empty Nexus hand; only a qualifying closing Nexus draws.
|
||||||
|
const nexus = game.nexusCards!.cards!;
|
||||||
|
const faces: typeof nexus.deck = [face,...(options.otherDraw ? [options.otherDraw.face] : [])];
|
||||||
|
assert.ok(faces.every(card => nexus.deck.includes(card)));
|
||||||
|
nexus.deck = [...faces,...nexus.deck.filter(card => !faces.includes(card))];
|
||||||
|
for (let step = 0; game.status === 'setup' && step < 100; step++) game = stepNative(game);
|
||||||
|
assert.equal(game.status,'playing');
|
||||||
|
// A complete native first turn supplies the next-turn source; no phase/turn
|
||||||
|
// assignment or Basic-to-Advanced flag flip is used.
|
||||||
|
for (let position = 0; position < 2; position++) {
|
||||||
|
const land = game.spiceDeck.findIndex((c,index) => index >= position && 'territory' in c);
|
||||||
|
assert.ok(land >= position);
|
||||||
|
game.spiceDeck.splice(position,0,game.spiceDeck.splice(land,1)[0]);
|
||||||
|
}
|
||||||
|
if (options.fullHand) {
|
||||||
|
for (let step = 0; step < 1200; step++) {
|
||||||
|
if (game.turn === 2 && game.phase === 0 && !game.phaseOpening && !game.response && !game.decision) break;
|
||||||
|
if (game.decision?.kind === 'auctionPayment')
|
||||||
|
game = applyAction(game,game.decision.player,{type:'decision',karama:false});
|
||||||
|
else if (!game.phaseOpening && !game.response && !game.decision && game.phase === 3 &&
|
||||||
|
game.auction?.active === buyer && game.auction.bid === 0 &&
|
||||||
|
game.players.find(p => p.id === buyer)!.hand.length < 3)
|
||||||
|
game = applyAction(game,buyer,{type:'bid',amount:2});
|
||||||
|
else game = stepNative(game);
|
||||||
|
}
|
||||||
|
assert.equal(game.players.find(p => p.id === buyer)!.hand.length,3);
|
||||||
|
} else game = advanceToNextStorm(game);
|
||||||
|
assert.equal(game.turn,2);
|
||||||
|
for (let step = 0; game.phase !== 1 && step < 200; step++) game = stepNative(game);
|
||||||
|
const worm = game.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed);
|
||||||
|
assert.ok(worm >= 0);
|
||||||
|
game.spiceDeck.splice(0,0,game.spiceDeck.splice(worm,1)[0]);
|
||||||
|
for (let step = 0; !(game.nexus && !game.spiceWindow && !game.spiceResolution &&
|
||||||
|
!game.phaseOpening && !game.response && !game.decision) && step < 200; step++) game = stepNative(game);
|
||||||
|
assert.equal(game.phase,1);
|
||||||
|
assert.equal(game.nexus,true);
|
||||||
|
// The publisher lifecycle grants no card without an alliance. Negotiate it
|
||||||
|
// through the actual native actions, leaving every requested recipient free.
|
||||||
|
game = applyAction(game,partners[0].id,{type:'alliance',target:partners[1].id});
|
||||||
|
game = applyAction(game,partners[1].id,{type:'alliance',target:partners[0].id});
|
||||||
|
for (let step = 0; game.nexusCards?.phase?.stage !== 'drawing' && step < 200; step++) game = stepNative(game);
|
||||||
|
assert.equal(game.nexusCards?.phase?.stage,'drawing');
|
||||||
|
game = applyAction(game,buyer,{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||||
|
assert.equal(game.nexusCards!.cards!.hands[buyer],face);
|
||||||
|
for (const id of game.nexusCards!.phase!.eligible) {
|
||||||
|
if (game.nexusCards!.phase!.done.includes(id)) continue;
|
||||||
|
const other: IxianReplacementFixtureOptions['otherDraw'] | null =
|
||||||
|
options.otherDraw?.player === id ? options.otherDraw : null;
|
||||||
|
game = applyAction(game,id,{type:'nexusCardChoice',turn:game.turn,card:null,choice:other ? 'draw' : 'keep',ownRedraws:0});
|
||||||
|
if (other) assert.equal(game.nexusCards!.cards!.hands[id],other.face);
|
||||||
|
}
|
||||||
|
// Reorder only the unplayed deck, before native auction allocation.
|
||||||
|
const kind = options.purchasedKind ?? 'worthless';
|
||||||
|
const wanted = game.deck.findIndex(c => c.kind === kind);
|
||||||
|
assert.ok(wanted >= 0);
|
||||||
|
const auctionOffset = options.endingAuction ? game.players.filter(p => p.hand.length < (p.faction === 'harkonnen' ? 8 : 4)).length - 1 : 0;
|
||||||
|
game.deck.splice(auctionOffset,0,game.deck.splice(wanted,1)[0]);
|
||||||
|
for (let step = 0; !(game.decision?.kind === 'auctionPayment' && game.decision.player === buyer) && step < 300; step++) {
|
||||||
|
if (game.decision?.kind === 'auctionPayment')
|
||||||
|
game = applyAction(game,game.decision.player,{type:'decision',karama:false});
|
||||||
|
else if (game.phaseOpening || game.response || game.decision || !game.auction) game = stepNative(game);
|
||||||
|
else if (options.endingAuction && game.auction.index < game.auction.cards.length - 1) {
|
||||||
|
const active = game.players.find(p => p.id === game.auction!.active)!;
|
||||||
|
game = applyAction(game,active.id,active.id !== buyer && active.faction !== 'harkonnen' &&
|
||||||
|
game.auction.bid === 0 ? {type:'bid',amount:1} : {type:'passBid'});
|
||||||
|
}
|
||||||
|
else if (game.auction.active === buyer && game.auction.bid === 0)
|
||||||
|
game = applyAction(game,buyer,{type:'bid',amount:2});
|
||||||
|
else game = applyAction(game,game.auction.active,{type:'passBid'});
|
||||||
|
}
|
||||||
|
assert.deepEqual(game.decision,{kind:'auctionPayment',player:buyer});
|
||||||
|
assert.equal(game.auction!.bidder,buyer);
|
||||||
|
assert.equal(game.auction!.bid,2);
|
||||||
|
assert.equal(game.auction!.allyPayment ?? 0,0);
|
||||||
|
const beforePayment = structuredClone(game);
|
||||||
|
const owner = game.players.find(p => p.id === buyer)!;
|
||||||
|
const emperor = game.players.find(p => p.faction === 'emperor')?.id ?? null;
|
||||||
|
const beforeEmperorSpice = emperor ? game.players.find(p => p.id === emperor)!.spice : null;
|
||||||
|
const beforeSpice = owner.spice;
|
||||||
|
const purchased = structuredClone(game.auction!.cards[game.auction!.index]);
|
||||||
|
const paymentAction: Action = options.karama
|
||||||
|
? {type:'decision',karama:true,card:owner.hand.find(c => c.effect === 'karama')!.id}
|
||||||
|
: {type:'decision',karama:false};
|
||||||
|
game = applyAction(game,buyer,paymentAction);
|
||||||
|
assert.ok(game.pendingNexusIxianReplacement);
|
||||||
|
const event = game.pendingNexusIxianReplacement.event;
|
||||||
|
return {beforePayment,game,buyer,event,purchased,paymentAction,beforeSpice,
|
||||||
|
paidSpice:game.players.find(p => p.id === buyer)!.spice,emperor,beforeEmperorSpice,
|
||||||
|
nextCard:game.deck[0] ? structuredClone(game.deck[0]) : null,auctionIndex:beforePayment.auction!.index};
|
||||||
|
}
|
||||||
+2189
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,137 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { registerHooks } from 'node:module';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Children, createElement, isValidElement } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { applyAction, viewGame } from '../game/engine';
|
||||||
|
import type { Action, GameView } from '../game/engine';
|
||||||
|
import { tableActionOwner } from '../game/table-turn';
|
||||||
|
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
|
||||||
|
import { createIxianNexusReplacementFixture, settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
|
||||||
|
|
||||||
|
const aliases = registerHooks({
|
||||||
|
resolve(specifier, context, next) {
|
||||||
|
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||||
|
},
|
||||||
|
load(url, context, next) {
|
||||||
|
if (!url.endsWith('.module.css')) return next(url, context);
|
||||||
|
const css = readFileSync(new URL(url), 'utf8');
|
||||||
|
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||||
|
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// UI modules need the Next/CSS test-loader boundary registered above;
|
||||||
|
// static imports would resolve those modules before their platform shims.
|
||||||
|
const { NexusIxianReplacement } = await import('../components/nexus-ixian-replacement');
|
||||||
|
const { GameTable } = await import('../components/game-table');
|
||||||
|
|
||||||
|
function click(node: ReactNode, selector: string): boolean {
|
||||||
|
for (const child of Children.toArray(node)) {
|
||||||
|
if (!isValidElement<{ children?: ReactNode; onClick?: () => void; 'data-testid'?: string }>(child)) continue;
|
||||||
|
if (child.props['data-testid'] === selector && child.props.onClick) {
|
||||||
|
child.props.onClick();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (click(child.props.children, selector)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function select(game: GameView, choice: 'use' | 'pass', busy = false): Action | undefined {
|
||||||
|
let action: Action | undefined;
|
||||||
|
click(NexusIxianReplacement({ game, busy, act(value) { action = value; } }), `nexus-ixian-replacement-${choice}`);
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
function markup(game: GameView): string {
|
||||||
|
return renderToStaticMarkup(createElement(NexusIxianReplacement, { game, busy: false, act() {} }));
|
||||||
|
}
|
||||||
|
function table(game: GameView, busy = false): string {
|
||||||
|
return renderToStaticMarkup(createElement(GameTable, { game, busy, send: async () => {}, onExit() {} }));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('only the buyer sees the purchased face and eligibility; wrong-face buyers can keep without an Ixian inspector', () => {
|
||||||
|
const fixture = createIxianNexusReplacementFixture();
|
||||||
|
const own = viewGame(fixture.game, fixture.buyer);
|
||||||
|
assert.match(markup(own), /aria-label="Your purchased Treachery Card"/);
|
||||||
|
assert.match(markup(own), /aria-label="Inspect Ixians Nexus card"/);
|
||||||
|
const use = select(own, 'use');
|
||||||
|
assert.ok(use);
|
||||||
|
const used = applyAction(fixture.game, fixture.buyer, use);
|
||||||
|
assert.equal(viewGame(used, fixture.buyer).nexusIxianReplacement, null);
|
||||||
|
assert.equal(used.nexusCards?.cards?.hands[fixture.buyer], null);
|
||||||
|
assert.equal(used.players.find(player => player.id === fixture.buyer)!.hand.length,
|
||||||
|
fixture.game.players.find(player => player.id === fixture.buyer)!.hand.length);
|
||||||
|
for (const rival of fixture.game.players.filter(player => player.id !== fixture.buyer)) {
|
||||||
|
const view = viewGame(fixture.game, rival.id);
|
||||||
|
assert.equal(view.nexusIxianReplacement!.purchased, null);
|
||||||
|
assert.equal(view.nexusIxianReplacement!.blocked, null);
|
||||||
|
assert.equal(select(view, 'use'), undefined);
|
||||||
|
assert.equal(select(view, 'pass'), undefined);
|
||||||
|
assert.doesNotMatch(markup(view), /aria-label="Your purchased Treachery Card"/);
|
||||||
|
if (view.nexusCards?.card !== 'ixians') assert.doesNotMatch(markup(view), /aria-label="Inspect Ixians Nexus card"/);
|
||||||
|
assert.equal(tableActionOwner(view), fixture.buyer);
|
||||||
|
}
|
||||||
|
const irrelevant = createIxianNexusReplacementFixture({ face: 'richese' });
|
||||||
|
const neutral = viewGame(irrelevant.game, irrelevant.buyer);
|
||||||
|
assert.equal(select(neutral, 'use'), undefined);
|
||||||
|
assert.doesNotMatch(markup(neutral), /aria-label="Inspect Ixians Nexus card"/);
|
||||||
|
const keep = select(neutral, 'pass');
|
||||||
|
assert.ok(keep);
|
||||||
|
const kept = applyAction(irrelevant.game, irrelevant.buyer, keep);
|
||||||
|
assert.ok(kept.players.find(player => player.id === irrelevant.buyer)!.hand.some(card => card.id === irrelevant.purchased.id));
|
||||||
|
assert.equal(kept.nexusCards?.cards?.hands[irrelevant.buyer], 'richese');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('paid and printed Karama human choices resume the native sale once; saved events cannot buy or draw again', () => {
|
||||||
|
for (const advanced of [false, true]) for (const karama of [false, true]) for (const choice of ['use', 'pass'] as const) {
|
||||||
|
const fixture = createIxianNexusReplacementFixture({ advanced, karama, endingAuction: true });
|
||||||
|
const action = select(viewGame(fixture.game, fixture.buyer), choice);
|
||||||
|
assert.ok(action);
|
||||||
|
const result = settleIxianReplacementFixture(applyAction(fixture.game, fixture.buyer, action));
|
||||||
|
const pass = select(viewGame(fixture.game, fixture.buyer), 'pass');
|
||||||
|
assert.ok(pass);
|
||||||
|
const original = settleIxianReplacementFixture(applyAction(fixture.game, fixture.buyer, pass));
|
||||||
|
assert.equal(viewGame(result, fixture.buyer).nexusIxianReplacement, null);
|
||||||
|
assert.deepEqual(result.players.map(player => ({ id: player.id, spice: player.spice })),
|
||||||
|
original.players.map(player => ({ id: player.id, spice: player.spice })));
|
||||||
|
assert.equal(result.phase, original.phase);
|
||||||
|
assert.equal(result.auction?.index, original.auction?.index);
|
||||||
|
assert.equal(result.auction, null);
|
||||||
|
if (choice === 'pass') assert.ok(result.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.purchased.id));
|
||||||
|
const saved = JSON.parse(JSON.stringify(result));
|
||||||
|
const before = JSON.stringify(saved);
|
||||||
|
assert.throws(() => applyAction(saved, fixture.buyer, action));
|
||||||
|
assert.equal(JSON.stringify(saved), before);
|
||||||
|
assert.equal(select(viewGame(saved, fixture.buyer), choice), undefined);
|
||||||
|
assert.doesNotMatch(table(viewGame(saved, fixture.buyer)), /data-testid="nexus-ixian-replacement"/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('pending purchase reserves actual table controls and respects busy, room and delegation locks', () => {
|
||||||
|
const fixture = createIxianNexusReplacementFixture();
|
||||||
|
const own: GameView = viewGame(fixture.game, fixture.buyer);
|
||||||
|
assert.equal(tableActionOwner(own), fixture.buyer);
|
||||||
|
const html = table(own);
|
||||||
|
assert.match(html, /data-testid="nexus-ixian-replacement"/);
|
||||||
|
assert.doesNotMatch(html, /<button[^>]*>[^<]*(?:Bid |Place bid|Play Karama|Make a bribe|Pledge|Search)/i);
|
||||||
|
assert.match(html, /aria-label="Inspect Ixians Nexus card"/);
|
||||||
|
for (const restriction of ['paused', 'closed', 'autopilot', 'busy'] as const) {
|
||||||
|
const restricted = structuredClone(own);
|
||||||
|
if (restriction === 'autopilot') restricted.players.find(player => player.id === own.me)!.autopilot = 'Easy';
|
||||||
|
else if (restriction !== 'busy') restricted.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
|
||||||
|
for (const choice of ['use', 'pass'] as const) {
|
||||||
|
assert.equal(select(restricted, choice, restriction === 'busy'), undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const blocked = structuredClone(own);
|
||||||
|
blocked.nexusIxianReplacement!.blocked = 'The purchased card is no longer reserved.';
|
||||||
|
assert.equal(select(blocked, 'use'), undefined);
|
||||||
|
assert.ok(select(blocked, 'pass'));
|
||||||
|
const stopped = structuredClone(own);
|
||||||
|
stopped.status = 'finished';
|
||||||
|
assert.equal(select(stopped, 'use'), undefined);
|
||||||
|
assert.equal(select(stopped, 'pass'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
aliases.deregister();
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { applyAction, createGame, initializeIxianNexusReplacementGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { baseDeck } from '../game/cards';
|
||||||
|
import { createIxianNexusReplacementFixture, settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement';
|
||||||
|
|
||||||
|
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||||
|
const owner = (game: Game,id: string) => game.players.find(p => p.id === id)!;
|
||||||
|
function inventory(game: Game): void {
|
||||||
|
const physical = [...game.players.flatMap(p => p.hand),...game.deck,...game.discard,
|
||||||
|
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
|
||||||
|
assert.deepEqual(physical.map(c => c.id).sort(),baseDeck().map(c => c.id).sort());
|
||||||
|
}
|
||||||
|
function reject(game: Game,id: string,action: Action): void {
|
||||||
|
const before = reload(game);
|
||||||
|
assert.throws(() => applyAction(game,id,action));
|
||||||
|
assert.deepEqual(game,before,'A rejected declaration must spend no Nexus, draw or spice.');
|
||||||
|
}
|
||||||
|
/** Captured from genuine six-seat native play: seeded setup, paid auctions,
|
||||||
|
* legal battle plans and winner discards. No row inflation or card deletion. */
|
||||||
|
function depletedNativeParent(): Game {
|
||||||
|
const game = normalizeAutomaticGame(JSON.parse(readFileSync(
|
||||||
|
new URL('./fixtures/nexus-ixian-native-depleted.json', import.meta.url), 'utf8')));
|
||||||
|
assert.equal(game.deck.length, 0);
|
||||||
|
assert.equal(game.decision?.kind, 'auctionPayment');
|
||||||
|
inventory(game);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const advanced of [false,true]) for (const karama of [false,true]) {
|
||||||
|
void test(`${advanced ? 'Advanced' : 'Basic'} ${karama ? 'printed Karama' : 'paid'} exact purchased replacement keeps the original ledger and native suffix once`,() => {
|
||||||
|
const fixture = createIxianNexusReplacementFixture({advanced,karama});
|
||||||
|
const {game,beforePayment,buyer,event,purchased} = fixture;
|
||||||
|
assert.equal(owner(game,buyer).spice,fixture.beforeSpice - (karama ? 0 : 2));
|
||||||
|
assert.equal(owner(game,fixture.emperor!).spice,fixture.beforeEmperorSpice);
|
||||||
|
assert.deepEqual(game.currentAuctionSale,{winner:buyer,amount:2,free:karama,origin:'normal',seller:null});
|
||||||
|
assert.deepEqual(owner(game,buyer).hand,[...owner(beforePayment,buyer).hand.filter(c => !karama || c.effect !== 'karama'),purchased]);
|
||||||
|
assert.deepEqual(normalizeAutomaticGame(reload(game)),game,'Normalization cannot bypass the neutral owner decision.');
|
||||||
|
const pass = settleIxianReplacementFixture(applyAction(reload(game),buyer,{type:'nexusIxianReplacementPass',event}));
|
||||||
|
const use = settleIxianReplacementFixture(applyAction(reload(game),buyer,{type:'nexusIxianReplacementUse',event}));
|
||||||
|
assert.ok(fixture.nextCard);
|
||||||
|
assert.deepEqual(owner(use,buyer).hand,[...owner(beforePayment,buyer).hand.filter(c => !karama || c.effect !== 'karama'),fixture.nextCard]);
|
||||||
|
assert.equal(owner(use,buyer).hand.some(c => c.id === purchased.id),false);
|
||||||
|
assert.equal(use.discard.some(c => c.id === purchased.id),true);
|
||||||
|
assert.equal(use.nexusCards!.cards!.hands[buyer],null);
|
||||||
|
assert.equal(use.nexusCards!.cards!.discard.filter(face => face === 'ixians').length,1);
|
||||||
|
assert.equal(pass.nexusCards!.cards!.hands[buyer],'ixians');
|
||||||
|
assert.equal(owner(pass,buyer).hand.some(c => c.id === purchased.id),true);
|
||||||
|
assert.equal(owner(pass,buyer).spice,fixture.paidSpice);
|
||||||
|
assert.equal(owner(use,buyer).spice,fixture.paidSpice);
|
||||||
|
assert.equal(owner(use,fixture.emperor!).spice,fixture.beforeEmperorSpice! + (karama ? 0 : 2));
|
||||||
|
assert.equal(owner(pass,fixture.emperor!).spice,owner(use,fixture.emperor!).spice);
|
||||||
|
assert.equal(use.auction?.index,fixture.auctionIndex + 1);
|
||||||
|
assert.equal(pass.auction?.index,use.auction?.index);
|
||||||
|
assert.equal(use.currentAuctionSale,null);
|
||||||
|
assert.equal(use.pendingNexusIxianReplacement,null);
|
||||||
|
assert.equal(use.nexusIxianReplacementHistory!.length,1);
|
||||||
|
assert.equal(use.nexusIxianReplacementHistory![0].source.card.id,purchased.id);
|
||||||
|
assert.deepEqual(use.nexusIxianReplacementHistory![0].replacement,fixture.nextCard);
|
||||||
|
const unsold = game.auction!.cards.slice(game.auction!.index + 1);
|
||||||
|
assert.ok(unsold.every(c => c.id !== fixture.nextCard!.id),'Replacement comes from the draw deck, never the unsold auction row.');
|
||||||
|
inventory(use); inventory(pass);
|
||||||
|
const restored = reload(use);
|
||||||
|
assert.deepEqual(normalizeAutomaticGame(restored),use);
|
||||||
|
reject(restored,buyer,{type:'nexusIxianReplacementUse',event});
|
||||||
|
reject(restored,buyer,{type:'nexusIxianReplacementPass',event});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('normal purchased replacement at a genuine full hand is discard-first and net zero',() => {
|
||||||
|
const f = createIxianNexusReplacementFixture({fullHand:true});
|
||||||
|
assert.equal(owner(f.beforePayment,f.buyer).hand.length,3);
|
||||||
|
assert.equal(owner(f.game,f.buyer).hand.length,4);
|
||||||
|
const use = applyAction(f.game,f.buyer,{type:'nexusIxianReplacementUse',event:f.event});
|
||||||
|
assert.equal(owner(use,f.buyer).hand.length,4);
|
||||||
|
assert.deepEqual(owner(use,f.buyer).hand,[...owner(f.beforePayment,f.buyer).hand,f.nextCard]);
|
||||||
|
inventory(use);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native depleted purchase can redraw its exact discarded physical card', t => {
|
||||||
|
const parent = depletedNativeParent();
|
||||||
|
const buyer = parent.decision!.player;
|
||||||
|
const purchased = parent.auction!.cards[parent.auction!.index];
|
||||||
|
const originalPool = structuredClone(parent.auction!.cards);
|
||||||
|
const paid = applyAction(parent, buyer, {type:'decision', karama:false});
|
||||||
|
const event = paid.pendingNexusIxianReplacement!.event;
|
||||||
|
let first = true;
|
||||||
|
t.mock.method(crypto, 'getRandomValues', (values: Uint32Array) => {
|
||||||
|
assert.ok(values instanceof Uint32Array);
|
||||||
|
values.fill(first ? 0 : 0xffffffff);
|
||||||
|
first = false;
|
||||||
|
return values;
|
||||||
|
});
|
||||||
|
const use = applyAction(reload(paid), buyer, {type:'nexusIxianReplacementUse', event});
|
||||||
|
assert.deepEqual(owner(use, buyer).hand, owner(paid, buyer).hand);
|
||||||
|
assert.equal(use.nexusIxianReplacementHistory!.at(-1)!.replacement?.id, purchased.id);
|
||||||
|
assert.equal(use.nexusCards!.cards!.hands[buyer], null);
|
||||||
|
assert.equal(use.discard.length, 0);
|
||||||
|
assert.equal(use.deck.length, paid.discard.length);
|
||||||
|
assert.deepEqual(use.auction!.cards, originalPool, 'native reserved lots stay intact');
|
||||||
|
inventory(use);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native depleted purchase draws from canonical discard without taking an unsold lot', t => {
|
||||||
|
const parent = depletedNativeParent();
|
||||||
|
const buyer = parent.decision!.player;
|
||||||
|
const expected = parent.discard[0];
|
||||||
|
const unsold = parent.auction!.cards.slice(parent.auction!.index + 1).map(card => card.id);
|
||||||
|
const paid = applyAction(parent, buyer, {type:'decision', karama:false});
|
||||||
|
t.mock.method(crypto, 'getRandomValues', (values: Uint32Array) => {
|
||||||
|
assert.ok(values instanceof Uint32Array);
|
||||||
|
values.fill(0xffffffff);
|
||||||
|
return values;
|
||||||
|
});
|
||||||
|
const use = applyAction(paid, buyer, {type:'nexusIxianReplacementUse', event:paid.pendingNexusIxianReplacement!.event});
|
||||||
|
const drawn = use.nexusIxianReplacementHistory!.at(-1)!.replacement!;
|
||||||
|
assert.equal(drawn.id, expected.id);
|
||||||
|
assert.equal(unsold.includes(drawn.id), false);
|
||||||
|
assert.equal(use.discard.length, 0);
|
||||||
|
assert.deepEqual(use.deck.map(card => card.id).sort(),
|
||||||
|
[...paid.discard.map(card => card.id), paid.pendingNexusIxianReplacement!.card.id]
|
||||||
|
.filter(id => id !== drawn.id).sort());
|
||||||
|
inventory(use);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Use/Pass accepts only the owned live event, never a held-card selector, replay or priority bypass',() => {
|
||||||
|
const f = createIxianNexusReplacementFixture();
|
||||||
|
const rival = f.game.players.find(p => p.id !== f.buyer)!.id;
|
||||||
|
for (const action of [
|
||||||
|
{type:'nexusIxianReplacementUse',event:f.event,card:owner(f.beforePayment,f.buyer).hand[0].id},
|
||||||
|
{type:'nexusIxianReplacementPass',event:f.event,accept:true},
|
||||||
|
{type:'nexusIxianReplacementUse',event:'old-event'},
|
||||||
|
{type:'decision',accept:true}, {type:'ready'}, {type:'passBid'},
|
||||||
|
{type:'card',card:owner(f.beforePayment,f.buyer).hand[0].id,mode:'purchase'},
|
||||||
|
]) reject(f.game,f.buyer,action);
|
||||||
|
reject(f.game,rival,{type:'nexusIxianReplacementUse',event:f.event});
|
||||||
|
reject(f.game,rival,{type:'nexusIxianReplacementPass',event:f.event});
|
||||||
|
assert.deepEqual(applyAction(f.game,f.buyer,{type:'advanceBots'}),f.game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('first payment, reload and precommit all require the same canonical physical original purchased source',() => {
|
||||||
|
const f = createIxianNexusReplacementFixture();
|
||||||
|
const wrongDescriptor = reload(f.beforePayment);
|
||||||
|
wrongDescriptor.auction!.cards[wrongDescriptor.auction!.index].name = 'Forged purchase';
|
||||||
|
reject(wrongDescriptor,f.buyer,f.paymentAction);
|
||||||
|
const corruptions: ((g: Game) => void)[] = [
|
||||||
|
g => { owner(g,f.buyer).hand = owner(g,f.buyer).hand.filter(c => c.id !== f.purchased.id); },
|
||||||
|
g => { g.deck.push({...f.purchased}); },
|
||||||
|
g => { owner(g,f.buyer).hand.find(c => c.id === f.purchased.id)!.kind = 'lasgun'; },
|
||||||
|
g => { g.currentAuctionSale!.amount++; },
|
||||||
|
g => { g.currentAuctionSale!.free = !g.currentAuctionSale!.free; },
|
||||||
|
g => { g.currentAuctionSale!.origin = 'cache'; },
|
||||||
|
g => { g.auction!.cards[g.auction!.index] = {...g.deck[0]}; },
|
||||||
|
g => { g.auction!.index++; },
|
||||||
|
g => { g.deck.reverse(); },
|
||||||
|
g => { owner(g,f.buyer).spice++; },
|
||||||
|
g => { g.pendingNexusIxianReplacement!.parent += 'changed'; },
|
||||||
|
g => { g.nexusIxianReplacementCursor!.sequence++; },
|
||||||
|
g => { owner(g,f.buyer).faction = 'harkonnen'; },
|
||||||
|
];
|
||||||
|
for (const corrupt of corruptions) {
|
||||||
|
const saved = reload(f.game);
|
||||||
|
corrupt(saved);
|
||||||
|
assert.throws(() => viewGame(saved,f.buyer));
|
||||||
|
assert.throws(() => normalizeAutomaticGame(saved));
|
||||||
|
reject(saved,f.buyer,{type:'nexusIxianReplacementUse',event:f.event});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('public neutral offer is independent of secret held face and rivals see neither purchase nor eligibility',() => {
|
||||||
|
const ix = createIxianNexusReplacementFixture();
|
||||||
|
const irrelevant = createIxianNexusReplacementFixture({face:'richese'});
|
||||||
|
const rival = ix.game.players.find(p => p.id !== ix.buyer)!.id;
|
||||||
|
assert.deepEqual(viewGame(ix.game,rival).nexusIxianReplacement,viewGame(irrelevant.game,rival).nexusIxianReplacement);
|
||||||
|
assert.deepEqual(viewGame(ix.game,rival).nexusIxianReplacement,
|
||||||
|
{event:ix.event,buyer:ix.buyer,canPass:false,canUse:false,blocked:null,purchased:null});
|
||||||
|
assert.equal(viewGame(ix.game,ix.buyer).nexusIxianReplacement!.canUse,true);
|
||||||
|
const otherOffer = viewGame(irrelevant.game,irrelevant.buyer).nexusIxianReplacement!;
|
||||||
|
assert.equal(otherOffer.canPass,true);
|
||||||
|
assert.equal(otherOffer.canUse,false);
|
||||||
|
assert.equal(otherOffer.blocked,null);
|
||||||
|
reject(irrelevant.game,irrelevant.buyer,{type:'nexusIxianReplacementUse',event:irrelevant.event});
|
||||||
|
const pass = applyAction(irrelevant.game,irrelevant.buyer,{type:'nexusIxianReplacementPass',event:irrelevant.event});
|
||||||
|
assert.equal(pass.nexusCards!.cards!.hands[irrelevant.buyer],'richese');
|
||||||
|
assert.ok(owner(pass,irrelevant.buyer).hand.some(c => c.id === irrelevant.purchased.id));
|
||||||
|
const use = applyAction(ix.game,ix.buyer,{type:'nexusIxianReplacementUse',event:ix.event});
|
||||||
|
const rivalView = viewGame(use,rival);
|
||||||
|
assert.equal(rivalView.nexusIxianReplacement,null);
|
||||||
|
assert.equal(rivalView.players.find(p => p.id === ix.buyer)!.hand,undefined);
|
||||||
|
assert.equal(JSON.stringify(rivalView).includes(ix.nextCard!.id),false);
|
||||||
|
assert.equal(rivalView.decision?.kind === 'nexusIxianReplacement',false);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('closed histories survive later card transfers and recycling but cannot lose their independent cursor',() => {
|
||||||
|
const f = createIxianNexusReplacementFixture();
|
||||||
|
let game = settleIxianReplacementFixture(applyAction(f.game,f.buyer,{type:'nexusIxianReplacementUse',event:f.event}));
|
||||||
|
const rival = game.players.find(p => p.id !== f.buyer)!.id;
|
||||||
|
const replacement = owner(game,f.buyer).hand.find(c => c.id === f.nextCard!.id)!;
|
||||||
|
owner(game,f.buyer).hand = owner(game,f.buyer).hand.filter(c => c.id !== replacement.id);
|
||||||
|
owner(game,rival).hand.push(replacement);
|
||||||
|
game.deck.push(...game.discard.splice(0));
|
||||||
|
game = reload(game);
|
||||||
|
assert.equal(viewGame(game,f.buyer).nexusIxianReplacement,null);
|
||||||
|
inventory(game);
|
||||||
|
reject(game,f.buyer,{type:'nexusIxianReplacementUse',event:f.event});
|
||||||
|
const dropped = reload(game);
|
||||||
|
dropped.nexusIxianReplacementHistory = [];
|
||||||
|
assert.throws(() => viewGame(dropped,f.buyer));
|
||||||
|
const duplicated = reload(game);
|
||||||
|
duplicated.nexusIxianReplacementHistory!.push({...duplicated.nexusIxianReplacementHistory![0]});
|
||||||
|
assert.throws(() => normalizeAutomaticGame(duplicated));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('fresh initializer preserves admitted identities and fences classic-only modules and in-play retrofit',() => {
|
||||||
|
let lobby = createGame('SAMEID',newPlayer('host','Atreides','atreides'),true,[]);
|
||||||
|
joinGame(lobby,newPlayer('guest','Emperor','emperor'));
|
||||||
|
joinGame(lobby,newPlayer('guild','Guild','guild'));
|
||||||
|
for (const p of lobby.players) lobby = applyAction(lobby,p.id,{type:'ready'});
|
||||||
|
lobby.nexusCards = {cards:null,phase:null};
|
||||||
|
const initial = reload(lobby);
|
||||||
|
const fixture = createIxianNexusReplacementFixture({initial:lobby});
|
||||||
|
assert.equal(fixture.game.code,initial.code);
|
||||||
|
assert.equal(fixture.game.advanced,true);
|
||||||
|
assert.deepEqual(fixture.game.players.map(p => [p.id,p.name,p.faction]),initial.players.map(p => [p.id,p.name,p.faction]));
|
||||||
|
assert.deepEqual(fixture.game.playerPositions,initial.playerPositions);
|
||||||
|
assert.deepEqual(lobby,initial);
|
||||||
|
assert.throws(() => initializeIxianNexusReplacementGameForAudit(fixture.game));
|
||||||
|
const expansion = reload(lobby);
|
||||||
|
expansion.expansions = ['ix'];
|
||||||
|
assert.throws(() => initializeIxianNexusReplacementGameForAudit(expansion));
|
||||||
|
const overlay = reload(lobby);
|
||||||
|
overlay.ecazTreachery = true;
|
||||||
|
assert.throws(() => initializeIxianNexusReplacementGameForAudit(overlay));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const karama of [false,true]) void test(`last native normal auction lot ${karama ? 'Karama' : 'paid'} completes once after Use`,() => {
|
||||||
|
const f = createIxianNexusReplacementFixture({karama,endingAuction:true});
|
||||||
|
assert.equal(f.game.auction!.index,f.game.auction!.cards.length - 1);
|
||||||
|
const game = settleIxianReplacementFixture(applyAction(f.game,f.buyer,{type:'nexusIxianReplacementUse',event:f.event}));
|
||||||
|
assert.equal(game.auction,null);
|
||||||
|
assert.equal(game.currentAuctionSale,null);
|
||||||
|
assert.equal(owner(game,f.buyer).spice,f.paidSpice);
|
||||||
|
assert.equal(owner(game,f.emperor!).spice,f.beforeEmperorSpice! + (karama ? 0 : 2));
|
||||||
|
assert.equal(game.phase,4);
|
||||||
|
inventory(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a later genuine purchase creates a new source while the closed prior event stays unusable',() => {
|
||||||
|
const f = createIxianNexusReplacementFixture();
|
||||||
|
let game = settleIxianReplacementFixture(applyAction(f.game,f.buyer,{type:'nexusIxianReplacementPass',event:f.event}));
|
||||||
|
const firstCard = f.purchased.id;
|
||||||
|
for (let step = 0; game.auction!.active !== f.buyer && step < 20; step++)
|
||||||
|
game = applyAction(game,game.auction!.active,{type:'passBid'});
|
||||||
|
game = applyAction(game,f.buyer,{type:'bid',amount:2});
|
||||||
|
for (let step = 0; game.decision?.kind !== 'auctionPayment' && step < 20; step++)
|
||||||
|
game = applyAction(game,game.auction!.active,{type:'passBid'});
|
||||||
|
const originalBalance = owner(game,f.buyer).spice;
|
||||||
|
const secondPurchased = game.auction!.cards[game.auction!.index].id;
|
||||||
|
game = applyAction(game,f.buyer,{type:'decision',karama:false});
|
||||||
|
const nextEvent = game.pendingNexusIxianReplacement!.event;
|
||||||
|
assert.notEqual(nextEvent,f.event);
|
||||||
|
assert.equal(game.pendingNexusIxianReplacement!.sequence,1);
|
||||||
|
reject(game,f.buyer,{type:'nexusIxianReplacementUse',event:f.event});
|
||||||
|
const use = applyAction(game,f.buyer,{type:'nexusIxianReplacementUse',event:nextEvent});
|
||||||
|
assert.equal(owner(use,f.buyer).spice,originalBalance - 2);
|
||||||
|
assert.ok(owner(use,f.buyer).hand.some(c => c.id === firstCard));
|
||||||
|
assert.equal(owner(use,f.buyer).hand.some(c => c.id === secondPurchased),false);
|
||||||
|
assert.deepEqual(use.nexusIxianReplacementHistory!.map(r => r.outcome),['pass','use']);
|
||||||
|
inventory(use);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a genuine Harkonnen buyer holding Ixian Nexus gets native purchase/bonus only, never an unresolved prototype offer',() => {
|
||||||
|
const lobby = createGame('HARKBUYER',newPlayer('a','Atreides','atreides'),false,[]);
|
||||||
|
joinGame(lobby,newPlayer('e','Emperor','emperor'));
|
||||||
|
joinGame(lobby,newPlayer('h','Harkonnen','harkonnen'));
|
||||||
|
joinGame(lobby,newPlayer('g','Guild','guild'));
|
||||||
|
const f = createIxianNexusReplacementFixture({initial:lobby,face:'richese',otherDraw:{player:'h',face:'ixians'}});
|
||||||
|
let game = settleIxianReplacementFixture(applyAction(f.game,f.buyer,{type:'nexusIxianReplacementPass',event:f.event}));
|
||||||
|
assert.equal(game.nexusCards!.cards!.hands.h,'ixians');
|
||||||
|
for (let step = 0; game.auction!.active !== 'h' && step < 20; step++)
|
||||||
|
game = applyAction(game,game.auction!.active,{type:'passBid'});
|
||||||
|
const balance = owner(game,'h').spice;
|
||||||
|
const purchased = game.auction!.cards[game.auction!.index].id;
|
||||||
|
game = applyAction(game,'h',{type:'bid',amount:2});
|
||||||
|
for (let step = 0; game.auction?.bidder === 'h' && !game.currentAuctionSale &&
|
||||||
|
game.auction.index === f.auctionIndex + 1 && step < 30; step++) {
|
||||||
|
if (game.decision?.kind === 'auctionPayment')
|
||||||
|
game = applyAction(game,'h',{type:'decision',karama:false});
|
||||||
|
else game = applyAction(game,game.auction.active,{type:'passBid'});
|
||||||
|
}
|
||||||
|
assert.equal(game.pendingNexusIxianReplacement,null);
|
||||||
|
assert.equal(viewGame(game,'a').nexusIxianReplacement,null);
|
||||||
|
assert.equal(viewGame(game,'h').nexusIxianReplacement,null);
|
||||||
|
game = settleIxianReplacementFixture(game);
|
||||||
|
assert.equal(owner(game,'h').spice,balance - 2);
|
||||||
|
assert.ok(owner(game,'h').hand.some(c => c.id === purchased));
|
||||||
|
assert.equal(owner(game,'h').hand.length,4,'The actual native Harkonnen bonus remains active.');
|
||||||
|
assert.equal(game.nexusCards!.cards!.hands.h,'ixians');
|
||||||
|
assert.equal(game.nexusIxianReplacementHistory!.length,1);
|
||||||
|
inventory(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('direct printed Karama acquisition preserves the original zero bid and native free continuation',() => {
|
||||||
|
const f = createIxianNexusReplacementFixture();
|
||||||
|
let game = settleIxianReplacementFixture(applyAction(f.game,f.buyer,{type:'nexusIxianReplacementPass',event:f.event}));
|
||||||
|
assert.equal(game.auction!.bid,0);
|
||||||
|
const spice = owner(game,f.buyer).spice;
|
||||||
|
const original = {...game.auction!.cards[game.auction!.index]};
|
||||||
|
const next = {...game.deck[0]};
|
||||||
|
const karama = owner(game,f.buyer).hand.find(c => c.effect === 'karama')!;
|
||||||
|
game = applyAction(game,f.buyer,{type:'card',card:karama.id,mode:'purchase'});
|
||||||
|
const source = game.pendingNexusIxianReplacement!;
|
||||||
|
assert.equal(source.amount,0);
|
||||||
|
assert.equal(source.free,true);
|
||||||
|
assert.deepEqual(source.card,original);
|
||||||
|
const use = settleIxianReplacementFixture(applyAction(game,f.buyer,{type:'nexusIxianReplacementUse',event:source.event}));
|
||||||
|
assert.equal(owner(use,f.buyer).spice,spice);
|
||||||
|
assert.equal(owner(use,f.buyer).hand.some(c => c.id === original.id),false);
|
||||||
|
assert.ok(owner(use,f.buyer).hand.some(c => c.id === next.id));
|
||||||
|
assert.equal(use.nexusIxianReplacementHistory![1].source.amount,0);
|
||||||
|
inventory(use);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('nonprinted Bene Gesserit Karama substitution continues native sale without enabling the bounded offer',() => {
|
||||||
|
const lobby = createGame('BGCONVERT',newPlayer('b','Bene Gesserit','beneGesserit'),true,[]);
|
||||||
|
joinGame(lobby,newPlayer('e','Emperor','emperor'));
|
||||||
|
joinGame(lobby,newPlayer('g','Guild','guild'));
|
||||||
|
const f = createIxianNexusReplacementFixture({initial:lobby});
|
||||||
|
const parent = reload(f.beforePayment);
|
||||||
|
const index = parent.deck.findIndex(c => c.kind === 'worthless');
|
||||||
|
assert.ok(index >= 0);
|
||||||
|
const worthless = parent.deck.splice(index,1)[0];
|
||||||
|
owner(parent,f.buyer).hand.push(worthless);
|
||||||
|
const balance = owner(parent,f.buyer).spice;
|
||||||
|
let game = applyAction(parent,f.buyer,{type:'decision',karama:true,card:worthless.id});
|
||||||
|
game = settleIxianReplacementFixture(game);
|
||||||
|
assert.equal(game.pendingNexusIxianReplacement,null);
|
||||||
|
assert.equal(viewGame(game,f.buyer).nexusIxianReplacement,null);
|
||||||
|
assert.equal(game.nexusCards!.cards!.hands[f.buyer],'ixians');
|
||||||
|
assert.equal(owner(game,f.buyer).spice,balance);
|
||||||
|
assert.ok(owner(game,f.buyer).hand.some(c => c.id === f.purchased.id));
|
||||||
|
assert.equal(game.nexusIxianReplacementHistory!.length,0);
|
||||||
|
inventory(game);
|
||||||
|
});
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
|
||||||
|
import { viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||||
|
import type { RoomsClock, SeatAuth } from '../db/rooms';
|
||||||
|
import type * as Rooms from '../db/rooms';
|
||||||
|
import { unitStore } from './fixture-nexus-room-store';
|
||||||
|
import {
|
||||||
|
createIxianNexusReplacementFixture,
|
||||||
|
type IxianReplacementFixture,
|
||||||
|
type IxianReplacementFixtureOptions as Options,
|
||||||
|
} from './fixture-nexus-ixian-replacement';
|
||||||
|
|
||||||
|
type SqlRow = Record<string, SQLOutputValue>;
|
||||||
|
interface Fixture extends IxianReplacementFixture {
|
||||||
|
sqlite: DatabaseSync;
|
||||||
|
restart(): typeof Rooms;
|
||||||
|
hooks: { beforeWrite?: () => Promise<void> };
|
||||||
|
writes: { expected: number; changes: number }[];
|
||||||
|
initial: Game;
|
||||||
|
code: string;
|
||||||
|
tokens: string[];
|
||||||
|
auths: SeatAuth[];
|
||||||
|
save(g: Game): void;
|
||||||
|
credentialRows: { seats: SqlRow[]; entries: SqlRow[]; keys: SqlRow[]; receipts: SqlRow[] };
|
||||||
|
unrelatedCode: string;
|
||||||
|
unrelatedToken: string;
|
||||||
|
unrelatedRow: SqlRow | undefined;
|
||||||
|
stock: string[];
|
||||||
|
}
|
||||||
|
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||||
|
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||||
|
const pass = (event: string): Action => ({ type: 'nexusIxianReplacementPass', event });
|
||||||
|
const use = (event: string): Action => ({ type: 'nexusIxianReplacementUse', event });
|
||||||
|
function player(g: Game, id: string) {
|
||||||
|
const found = g.players.find(p => p.id === id);
|
||||||
|
assert.ok(found);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
function physical(g: Game) {
|
||||||
|
// A delivered normal sale's current auction descriptor is a receipt alias,
|
||||||
|
// not another owned component. Unsold later lots remain physical cards.
|
||||||
|
const ids = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
|
||||||
|
...(g.auction?.cards.slice(g.auction.index + Number(g.currentAuctionSale?.origin === 'normal')) ?? [])]
|
||||||
|
.map(card => card.id).sort();
|
||||||
|
assert.equal(new Set(ids).size, ids.length, 'one physical custodian per Treachery card');
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
function credentials(sqlite: DatabaseSync) {
|
||||||
|
return {
|
||||||
|
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
|
||||||
|
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,operation_hash').all(),
|
||||||
|
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(),
|
||||||
|
receipts: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function totalChanges(sqlite: DatabaseSync) {
|
||||||
|
const value = sqlite.prepare('SELECT total_changes() AS changes').get()?.changes;
|
||||||
|
assert.equal(typeof value, 'number');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
async function fixture(t: test.TestContext, options: Options = {}): Promise<Fixture> {
|
||||||
|
const store = unitStore();
|
||||||
|
t.after(() => store.sqlite.close());
|
||||||
|
const made = await store.rooms.createRoom('Ixian replacement recovery', 'atreides', options.advanced ?? true, []);
|
||||||
|
const code = made.view.code;
|
||||||
|
const joined = await store.rooms.joinRoom(code, 'Emperor', 'emperor');
|
||||||
|
assert.ok(joined.token);
|
||||||
|
const guild = await store.rooms.joinRoom(code, 'Guild', 'guild');
|
||||||
|
assert.ok(guild.token);
|
||||||
|
const tokens = [made.token, joined.token, guild.token];
|
||||||
|
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||||
|
const admitted = await store.rooms.readRoom(code);
|
||||||
|
const native = createIxianNexusReplacementFixture({ ...options, initial: admitted });
|
||||||
|
assert.equal(native.beforePayment.code, code);
|
||||||
|
assert.equal(native.beforePayment.host, admitted.host);
|
||||||
|
assert.equal(native.beforePayment.advanced, admitted.advanced);
|
||||||
|
assert.deepEqual(native.beforePayment.expansions, admitted.expansions);
|
||||||
|
assert.deepEqual(native.beforePayment.players.map(p => [p.id, p.name, p.faction]),
|
||||||
|
admitted.players.map(p => [p.id, p.name, p.faction]));
|
||||||
|
assert.deepEqual(native.beforePayment.playerPositions, admitted.playerPositions);
|
||||||
|
const save = (g: Game) => {
|
||||||
|
assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?')
|
||||||
|
.run(JSON.stringify(g), g.version, code).changes, 1);
|
||||||
|
};
|
||||||
|
save(native.beforePayment);
|
||||||
|
const unrelated = await store.rooms.createRoom('Preserved unrelated lobby', 'guild', false, []);
|
||||||
|
const credentialRows = credentials(store.sqlite);
|
||||||
|
const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(unrelated.view.code);
|
||||||
|
const buyerIndex = auths.findIndex(auth => auth.playerId === native.buyer);
|
||||||
|
assert.ok(buyerIndex >= 0);
|
||||||
|
await store.restart().act(code, await store.restart().authenticate(code, tokens[buyerIndex]),
|
||||||
|
native.beforePayment.version, native.paymentAction, clock);
|
||||||
|
const initial = await store.restart().readRoom(code);
|
||||||
|
const opportunity = viewGame(initial, native.buyer).nexusIxianReplacement;
|
||||||
|
assert.ok(opportunity);
|
||||||
|
assert.equal(opportunity.event, native.event);
|
||||||
|
assert.deepEqual(opportunity.purchased, native.purchased);
|
||||||
|
assert.equal(opportunity.canPass, true);
|
||||||
|
assert.equal(opportunity.canUse, true);
|
||||||
|
store.writes.length = 0;
|
||||||
|
return { ...store, ...native, initial, code, tokens, auths, save, credentialRows,
|
||||||
|
unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token, unrelatedRow,
|
||||||
|
stock: physical(initial) };
|
||||||
|
}
|
||||||
|
const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
|
||||||
|
function protectedRows(f: Fixture) {
|
||||||
|
assert.deepEqual(credentials(f.sqlite), f.credentialRows);
|
||||||
|
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.unrelatedCode), f.unrelatedRow);
|
||||||
|
}
|
||||||
|
function inventory(f: Fixture, g: Game) {
|
||||||
|
assert.deepEqual(physical(g), f.stock);
|
||||||
|
assert.ok(g.nexusCards?.cards);
|
||||||
|
validateNexusCards(g.nexusCards.cards, g.players);
|
||||||
|
assert.deepEqual([...g.nexusCards.cards.deck, ...g.nexusCards.cards.discard,
|
||||||
|
...Object.values(g.nexusCards.cards.hands).filter(face => face !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||||
|
protectedRows(f);
|
||||||
|
}
|
||||||
|
async function authenticate(f: Fixture, id: string) {
|
||||||
|
const index = f.auths.findIndex(auth => auth.playerId === id);
|
||||||
|
assert.ok(index >= 0);
|
||||||
|
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
|
||||||
|
assert.equal(auth.playerId, id);
|
||||||
|
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
async function act(f: Fixture, g: Game, action: Action) {
|
||||||
|
await f.restart().act(f.code, await authenticate(f, f.buyer), g.version, action, clock);
|
||||||
|
return f.restart().readRoom(f.code);
|
||||||
|
}
|
||||||
|
async function closeSale(f: Fixture, state: Game) {
|
||||||
|
let game = state;
|
||||||
|
for (let step = 0; game.response && step < 20; step++) {
|
||||||
|
const responder = game.players.find(p => !game.response!.passed.includes(p.id));
|
||||||
|
assert.ok(responder);
|
||||||
|
await f.restart().act(f.code, await authenticate(f, responder.id), game.version,
|
||||||
|
{ type: 'passResponse' }, clock);
|
||||||
|
game = await f.restart().readRoom(f.code);
|
||||||
|
}
|
||||||
|
assert.equal(game.response, null, 'the original auction suffix must finish');
|
||||||
|
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
async function rejected(f: Fixture, g: Game, auth: SeatAuth, action: Action, version = g.version) {
|
||||||
|
const before = row(f), changes = totalChanges(f.sqlite);
|
||||||
|
await assert.rejects(f.restart().act(f.code, auth, version, action, clock));
|
||||||
|
assert.deepEqual(row(f), before);
|
||||||
|
assert.equal(totalChanges(f.sqlite), changes, 'invalid action writes no durable table');
|
||||||
|
protectedRows(f);
|
||||||
|
}
|
||||||
|
async function restored(f: Fixture, g: Game) {
|
||||||
|
const before = row(f), changes = totalChanges(f.sqlite);
|
||||||
|
assert.deepEqual(plain(await f.restart().readRoom(f.code)), plain(g));
|
||||||
|
for (const auth of f.auths) {
|
||||||
|
const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
|
||||||
|
assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId)));
|
||||||
|
assert.equal(view.nexusIxianReplacementPreview, true);
|
||||||
|
for (const key of ['pendingNexusIxianReplacement', 'nexusIxianReplacementHistory', 'nexusIxianReplacementCursor'])
|
||||||
|
assert.equal(Object.hasOwn(view, key), false);
|
||||||
|
for (const rival of view.players.filter(p => p.id !== auth.playerId)) {
|
||||||
|
for (const key of ['hand', 'traitors', 'spice']) assert.equal(Object.hasOwn(rival, key), false);
|
||||||
|
}
|
||||||
|
if (view.nexusIxianReplacement) {
|
||||||
|
const offer = view.nexusIxianReplacement;
|
||||||
|
assert.deepEqual(Object.keys(offer).sort(), ['event', 'buyer', 'canPass', 'canUse', 'blocked', 'purchased'].sort());
|
||||||
|
assert.equal(offer.event, f.event);
|
||||||
|
assert.equal(offer.buyer, f.buyer);
|
||||||
|
assert.equal(offer.canPass, auth.playerId === f.buyer);
|
||||||
|
if (auth.playerId !== f.buyer) {
|
||||||
|
assert.equal(offer.canUse, false);
|
||||||
|
assert.equal(offer.blocked, null);
|
||||||
|
assert.equal(offer.purchased, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
|
||||||
|
assert.deepEqual(row(f), before, 'restart/refresh cannot consume an optional response');
|
||||||
|
assert.equal(totalChanges(f.sqlite), changes);
|
||||||
|
inventory(f, g);
|
||||||
|
}
|
||||||
|
function settled(f: Fixture, g: Game, used: boolean) {
|
||||||
|
assert.equal(viewGame(g, f.buyer).nexusIxianReplacement, null);
|
||||||
|
assert.equal(player(g, f.buyer).spice, f.paidSpice, 'original funded payment is not repeated or refunded');
|
||||||
|
const emperor = g.players.find(p => p.faction === 'emperor');
|
||||||
|
assert.ok(emperor);
|
||||||
|
const paid = f.beforeSpice - f.paidSpice;
|
||||||
|
assert.equal(emperor.spice, player(f.beforePayment, emperor.id).spice + paid,
|
||||||
|
'native Emperor sale income runs exactly once');
|
||||||
|
const original = player(f.initial, f.buyer).hand.map(card => card.id);
|
||||||
|
const expected = used ? original.filter(id => id !== f.purchased.id).concat(f.initial.deck[0].id) : original;
|
||||||
|
assert.deepEqual(player(g, f.buyer).hand.map(card => card.id).sort(), expected.sort());
|
||||||
|
assert.equal(g.nexusCards!.cards!.hands[f.buyer], used ? null : 'ixians');
|
||||||
|
assert.equal(g.nexusCards!.cards!.discard.filter(face => face === 'ixians').length, Number(used));
|
||||||
|
assert.equal(g.discard.filter(card => card.id === f.purchased.id).length, Number(used));
|
||||||
|
assert.equal(g.currentAuctionSale, null);
|
||||||
|
if (g.auction) assert.equal(g.auction.index, f.initial.auction!.index + 1);
|
||||||
|
inventory(f, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const advanced of [false, true]) {
|
||||||
|
for (const karama of [false, true]) {
|
||||||
|
for (const used of [false, true]) {
|
||||||
|
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} ${karama ? 'printed Karama' : 'paid'} purchase ${used ? 'Use' : 'Pass'} survives auth restart and runs its original suffix once`, async t => {
|
||||||
|
const f = await fixture(t, { advanced, karama });
|
||||||
|
assert.equal(player(f.initial, f.buyer).spice, f.paidSpice);
|
||||||
|
await restored(f, f.initial);
|
||||||
|
const closed = await act(f, f.initial, used ? use(f.event) : pass(f.event));
|
||||||
|
assert.equal(closed.version, f.initial.version + 1);
|
||||||
|
await restored(f, closed);
|
||||||
|
const done = await closeSale(f, closed);
|
||||||
|
settled(f, done, used);
|
||||||
|
await restored(f, done);
|
||||||
|
for (const action of [pass(f.event), use(f.event)])
|
||||||
|
await rejected(f, done, await authenticate(f, f.buyer), action);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('SQLite competing Pass/Use CAS commits one replacement outcome and one original auction suffix', async t => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
let arrivals = 0;
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
f.hooks.beforeWrite = async () => {
|
||||||
|
if (++arrivals === 2) release();
|
||||||
|
await gate;
|
||||||
|
};
|
||||||
|
let results: PromiseSettledResult<unknown>[];
|
||||||
|
try {
|
||||||
|
const auth = await authenticate(f, f.buyer);
|
||||||
|
results = await Promise.allSettled([pass(f.event), use(f.event)].map(action =>
|
||||||
|
f.restart().act(f.code, auth, f.initial.version, action, clock).finally(release)));
|
||||||
|
} finally {
|
||||||
|
delete f.hooks.beforeWrite;
|
||||||
|
}
|
||||||
|
assert.equal(arrivals, 2);
|
||||||
|
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||||
|
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
|
||||||
|
assert.deepEqual(f.writes.map(write => write.changes).sort((one, two) => one - two), [0, 1]);
|
||||||
|
const closed = await f.restart().readRoom(f.code);
|
||||||
|
assert.equal(closed.version, f.initial.version + 1);
|
||||||
|
await restored(f, closed);
|
||||||
|
const done = await closeSale(f, closed);
|
||||||
|
settled(f, done, results[1].status === 'fulfilled');
|
||||||
|
await restored(f, done);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('SQLite stale, wrong buyer, foreign seat, substituted hand card and unknown payload reject without durable writes', async t => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const buyer = await authenticate(f, f.buyer);
|
||||||
|
const other = f.auths.find(auth => auth.playerId !== f.buyer)!;
|
||||||
|
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
|
||||||
|
const changes = totalChanges(f.sqlite), before = row(f);
|
||||||
|
await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken));
|
||||||
|
await assert.rejects(f.restart().readSeatView(f.code, foreign));
|
||||||
|
await assert.rejects(f.restart().readSeatView(f.code, { playerId: f.buyer, tokenHash: foreign.tokenHash }));
|
||||||
|
assert.equal(totalChanges(f.sqlite), changes);
|
||||||
|
assert.deepEqual(row(f), before);
|
||||||
|
await rejected(f, f.initial, buyer, use(f.event), f.initial.version - 1);
|
||||||
|
await rejected(f, f.initial, foreign, use(f.event));
|
||||||
|
await rejected(f, f.initial, other, pass(f.event));
|
||||||
|
await rejected(f, f.initial, other, use(f.event));
|
||||||
|
for (const action of [pass(`${f.event}:expired`), use(`${f.event}:expired`),
|
||||||
|
{ ...use(f.event), card: player(f.initial, f.buyer).hand.find(card => card.id !== f.purchased.id)!.id },
|
||||||
|
{ ...use(f.event), price: 0 }, { ...pass(f.event), recipient: f.buyer }])
|
||||||
|
await rejected(f, f.initial, buyer, action as Action);
|
||||||
|
await restored(f, f.initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('SQLite cached authentication revoked after restart cannot inspect or consume the purchase', async t => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const cached = await authenticate(f, f.buyer);
|
||||||
|
f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.buyer);
|
||||||
|
f.credentialRows = credentials(f.sqlite);
|
||||||
|
const before = row(f), changes = totalChanges(f.sqlite);
|
||||||
|
const index = f.auths.findIndex(auth => auth.playerId === f.buyer);
|
||||||
|
await assert.rejects(f.restart().authenticate(f.code, f.tokens[index]));
|
||||||
|
await assert.rejects(f.restart().readSeatView(f.code, cached));
|
||||||
|
await rejected(f, f.initial, cached, use(f.event));
|
||||||
|
await rejected(f, f.initial, cached, pass(f.event));
|
||||||
|
assert.deepEqual(row(f), before);
|
||||||
|
assert.equal(totalChanges(f.sqlite), changes);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('SQLite neutral purchase response reveals no relevant-face distinction to other seats', async t => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
// Vary only the owned physical face in the same genuine pre-payment
|
||||||
|
// parent, then let the original native payment authorize its own receipt.
|
||||||
|
const alternateParent = structuredClone(f.beforePayment);
|
||||||
|
const cards = alternateParent.nexusCards!.cards!;
|
||||||
|
const index = cards.deck.indexOf('richese');
|
||||||
|
assert.ok(index >= 0);
|
||||||
|
assert.equal(cards.hands[f.buyer], 'ixians');
|
||||||
|
cards.hands[f.buyer] = 'richese';
|
||||||
|
cards.deck[index] = 'ixians';
|
||||||
|
f.save(alternateParent);
|
||||||
|
await f.restart().act(f.code, await authenticate(f, f.buyer), alternateParent.version,
|
||||||
|
f.paymentAction, clock);
|
||||||
|
const irrelevant = await f.restart().readRoom(f.code);
|
||||||
|
assert.equal(irrelevant.version, f.initial.version);
|
||||||
|
assert.equal(viewGame(irrelevant, f.buyer).nexusIxianReplacement!.event, f.event);
|
||||||
|
f.writes.length = 0;
|
||||||
|
await restored(f, irrelevant);
|
||||||
|
for (const auth of f.auths) {
|
||||||
|
const original = viewGame(f.initial, auth.playerId);
|
||||||
|
const changed = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
|
||||||
|
if (auth.playerId !== f.buyer) assert.deepEqual(plain(changed), plain(original));
|
||||||
|
else {
|
||||||
|
assert.equal(changed.nexusIxianReplacement!.canPass, true);
|
||||||
|
assert.equal(changed.nexusIxianReplacement!.canUse, false);
|
||||||
|
assert.deepEqual(changed.nexusIxianReplacement!.purchased, original.nexusIxianReplacement!.purchased);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await rejected(f, irrelevant, await authenticate(f, f.buyer), use(f.event));
|
||||||
|
const done = await act(f, irrelevant, pass(f.event));
|
||||||
|
assert.equal(player(done, f.buyer).spice, f.paidSpice);
|
||||||
|
assert.equal(done.nexusCards!.cards!.hands[f.buyer], 'richese');
|
||||||
|
assert.equal(player(done, f.buyer).hand.some(card => card.id === f.purchased.id), true);
|
||||||
|
await restored(f, done);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('SQLite current purchase source, parent, canonical face and exact owned reservation reject corruption before disclosure or writes', async t => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const mutations: { name: string; change: (g: Game) => void }[] = [
|
||||||
|
{ name: 'missing source', change: g => { delete g.pendingNexusIxianReplacement; } },
|
||||||
|
{ name: 'missing history', change: g => { delete g.nexusIxianReplacementHistory; } },
|
||||||
|
{ name: 'missing cursor', change: g => { delete g.nexusIxianReplacementCursor; } },
|
||||||
|
{ name: 'cursor sequence', change: g => { g.nexusIxianReplacementCursor!.sequence++; } },
|
||||||
|
{ name: 'source parent', change: g => { g.pendingNexusIxianReplacement!.parent += ':forged'; } },
|
||||||
|
{ name: 'source amount', change: g => { g.pendingNexusIxianReplacement!.amount++; } },
|
||||||
|
{ name: 'source payment method', change: g => { g.pendingNexusIxianReplacement!.free = true; } },
|
||||||
|
{ name: 'source canonical descriptor', change: g => { g.pendingNexusIxianReplacement!.card.name += ' forged'; } },
|
||||||
|
{ name: 'source buyer', change: g => {
|
||||||
|
g.pendingNexusIxianReplacement!.buyer = g.players.find(p => p.id !== f.buyer)!.id;
|
||||||
|
} },
|
||||||
|
{ name: 'native amount', change: g => { g.auction!.bid++; } },
|
||||||
|
{ name: 'native sale amount', change: g => { g.currentAuctionSale!.amount++; } },
|
||||||
|
{ name: 'native source origin', change: g => { g.currentAuctionSale!.origin = 'cache'; } },
|
||||||
|
{ name: 'native reserved lot', change: g => { g.auction!.cards[g.auction!.index] = structuredClone(g.deck[0]); } },
|
||||||
|
{ name: 'held canonical face', change: g => {
|
||||||
|
player(g, f.buyer).hand.find(card => card.id === f.purchased.id)!.name += ' forged';
|
||||||
|
} },
|
||||||
|
{ name: 'lost exact purchased custody', change: g => {
|
||||||
|
const owner = player(g, f.buyer);
|
||||||
|
const index = owner.hand.findIndex(card => card.id === f.purchased.id);
|
||||||
|
assert.ok(index >= 0);
|
||||||
|
player(g, g.players.find(p => p.id !== f.buyer)!.id).hand.push(...owner.hand.splice(index, 1));
|
||||||
|
} },
|
||||||
|
{ name: 'duplicated exact purchased custody', change: g => {
|
||||||
|
g.deck.push(structuredClone(player(g, f.buyer).hand.find(card => card.id === f.purchased.id)!));
|
||||||
|
} },
|
||||||
|
];
|
||||||
|
for (const { name, change } of mutations) {
|
||||||
|
const corrupt = structuredClone(f.initial);
|
||||||
|
change(corrupt);
|
||||||
|
f.save(corrupt);
|
||||||
|
f.writes.length = 0;
|
||||||
|
const before = row(f), changes = totalChanges(f.sqlite), original = plain(corrupt);
|
||||||
|
for (const auth of f.auths)
|
||||||
|
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
|
||||||
|
for (const action of [use(f.event), pass(f.event), { type: 'passBid' } as Action])
|
||||||
|
await rejected(f, corrupt, await authenticate(f, f.buyer), action);
|
||||||
|
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
|
||||||
|
assert.deepEqual(row(f), before, name);
|
||||||
|
assert.equal(totalChanges(f.sqlite), changes, name);
|
||||||
|
assert.equal(f.writes.length, 0, name);
|
||||||
|
assert.deepEqual(corrupt, original, 'recovery never mutates the caller snapshot');
|
||||||
|
protectedRows(f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const used of [false, true]) {
|
||||||
|
void test(`SQLite closed normal auction retains its canonical ${used ? 'replacement' : 'pass'} source history after card and Nexus recycling`, async t => {
|
||||||
|
const f = await fixture(t, { endingAuction: true });
|
||||||
|
const closed = await act(f, f.initial, used ? use(f.event) : pass(f.event));
|
||||||
|
await restored(f, closed);
|
||||||
|
const done = await closeSale(f, closed);
|
||||||
|
assert.equal(done.auction, null, 'the normal auction actually closes');
|
||||||
|
assert.equal(done.currentAuctionSale, null);
|
||||||
|
assert.equal(done.nexusIxianReplacementHistory!.length, 1);
|
||||||
|
const receipt = done.nexusIxianReplacementHistory![0];
|
||||||
|
assert.equal(receipt.outcome, used ? 'use' : 'pass');
|
||||||
|
assert.deepEqual(receipt.source, f.initial.pendingNexusIxianReplacement);
|
||||||
|
assert.deepEqual(receipt.source.card, f.purchased);
|
||||||
|
assert.equal(receipt.source.amount, f.beforePayment.auction!.bid);
|
||||||
|
assert.equal(receipt.source.free, false);
|
||||||
|
assert.equal(receipt.source.buyer, f.buyer);
|
||||||
|
assert.equal(receipt.source.auctionIndex, f.auctionIndex);
|
||||||
|
await restored(f, done);
|
||||||
|
const later = structuredClone(done);
|
||||||
|
// Conserved physical recycling models later custody, without recreating
|
||||||
|
// components or altering a historical receipt's original source.
|
||||||
|
if (used) {
|
||||||
|
const index = later.discard.findIndex(card => card.id === f.purchased.id);
|
||||||
|
assert.ok(index >= 0);
|
||||||
|
later.deck.push(...later.discard.splice(index, 1));
|
||||||
|
const nexus = later.nexusCards!.cards!;
|
||||||
|
const nexusIndex = nexus.discard.indexOf('ixians');
|
||||||
|
assert.ok(nexusIndex >= 0);
|
||||||
|
nexus.deck.push(...nexus.discard.splice(nexusIndex, 1));
|
||||||
|
} else {
|
||||||
|
const owner = player(later, f.buyer);
|
||||||
|
const index = owner.hand.findIndex(card => card.id === f.purchased.id);
|
||||||
|
assert.ok(index >= 0);
|
||||||
|
player(later, later.players.find(p => p.id !== f.buyer)!.id).hand.push(...owner.hand.splice(index, 1));
|
||||||
|
}
|
||||||
|
f.save(later);
|
||||||
|
f.writes.length = 0;
|
||||||
|
await restored(f, later);
|
||||||
|
assert.deepEqual(later.nexusIxianReplacementHistory, done.nexusIxianReplacementHistory);
|
||||||
|
for (const action of [use(f.event), pass(f.event)])
|
||||||
|
await rejected(f, later, await authenticate(f, f.buyer), action);
|
||||||
|
for (const change of [
|
||||||
|
(g: Game) => { g.nexusIxianReplacementHistory = []; },
|
||||||
|
(g: Game) => { delete g.nexusIxianReplacementCursor; },
|
||||||
|
(g: Game) => { g.nexusIxianReplacementHistory![0].source.amount++; },
|
||||||
|
(g: Game) => { g.nexusIxianReplacementHistory![0].source.card.name += ' forged'; },
|
||||||
|
(g: Game) => { g.nexusIxianReplacementHistory![0].source.parent += ':forged'; },
|
||||||
|
(g: Game) => { g.nexusIxianReplacementHistory![0].signature += ':forged'; },
|
||||||
|
]) {
|
||||||
|
const corrupt = structuredClone(later);
|
||||||
|
change(corrupt);
|
||||||
|
f.save(corrupt);
|
||||||
|
f.writes.length = 0;
|
||||||
|
const before = row(f), changes = totalChanges(f.sqlite);
|
||||||
|
for (const auth of f.auths)
|
||||||
|
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)));
|
||||||
|
await rejected(f, corrupt, await authenticate(f, f.buyer), use(f.event));
|
||||||
|
assert.deepEqual(row(f), before);
|
||||||
|
assert.equal(totalChanges(f.sqlite), changes);
|
||||||
|
assert.equal(f.writes.length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('SQLite credential revocation racing the final replacement CAS leaves the purchased source and all cards untouched', async t => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const auth = await authenticate(f, f.buyer);
|
||||||
|
const before = row(f), changes = totalChanges(f.sqlite);
|
||||||
|
let revoked = false;
|
||||||
|
f.hooks.beforeWrite = async () => {
|
||||||
|
assert.equal(revoked, false);
|
||||||
|
revoked = true;
|
||||||
|
assert.equal(f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?')
|
||||||
|
.run(f.code, f.buyer).changes, 1);
|
||||||
|
f.credentialRows = credentials(f.sqlite);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await assert.rejects(f.restart().act(f.code, auth, f.initial.version, use(f.event), clock));
|
||||||
|
} finally {
|
||||||
|
delete f.hooks.beforeWrite;
|
||||||
|
}
|
||||||
|
assert.equal(revoked, true);
|
||||||
|
assert.deepEqual(f.writes.map(write => write.changes), [0]);
|
||||||
|
assert.equal(totalChanges(f.sqlite), Number(changes) + 1, 'only the deliberate credential revocation is durable');
|
||||||
|
assert.deepEqual(row(f), before);
|
||||||
|
const recovered = await f.restart().readRoom(f.code);
|
||||||
|
assert.deepEqual(plain(recovered), plain(f.initial));
|
||||||
|
inventory(f, recovered);
|
||||||
|
await assert.rejects(f.restart().readSeatView(f.code, auth));
|
||||||
|
});
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { baseDeck, type Card } from '../game/cards';
|
||||||
|
import {
|
||||||
|
IxianReplacementError,
|
||||||
|
closeIxianReplacementSource,
|
||||||
|
createIxianReplacementSource,
|
||||||
|
initialIxianReplacementCursor,
|
||||||
|
ixianReplacementEvent,
|
||||||
|
validateIxianReplacementHistory,
|
||||||
|
validateIxianReplacementSource,
|
||||||
|
type IxianReplacementContext,
|
||||||
|
type IxianReplacementReceipt,
|
||||||
|
} from '../game/nexus-ixian-replacement';
|
||||||
|
|
||||||
|
const [purchased, older, top, unsold] = baseDeck();
|
||||||
|
const parent = 'original-native-paid-sale-parent';
|
||||||
|
|
||||||
|
function context(): IxianReplacementContext {
|
||||||
|
return {
|
||||||
|
status: 'playing', phase: 3, turn: 1, sequence: 0,
|
||||||
|
auction: { cards: [{ ...purchased }, { ...unsold }], index: 0, bidder: 'buyer', bid: 4 },
|
||||||
|
sale: { winner: 'buyer', amount: 4, free: false, origin: 'normal', seller: null },
|
||||||
|
players: [
|
||||||
|
{ id: 'buyer', hand: [{ ...older }, { ...purchased }], handLimit: 4 },
|
||||||
|
{ id: 'rival', hand: [], handLimit: 4 },
|
||||||
|
],
|
||||||
|
physicalCards: [{ ...older }, { ...purchased }, { ...top }, { ...unsold }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function source(ctx: IxianReplacementContext = context()) {
|
||||||
|
return createIxianReplacementSource(
|
||||||
|
ctx, ixianReplacementEvent(ctx.turn, 'buyer', ctx.sequence, 0), parent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('retained purchases keep saved receipt history linear through the game horizon', () => {
|
||||||
|
const physical = baseDeck();
|
||||||
|
const history: IxianReplacementReceipt[] = [];
|
||||||
|
let cursor = initialIxianReplacementCursor();
|
||||||
|
for (let turn = 2; turn <= 10; turn++) {
|
||||||
|
const ctx = context();
|
||||||
|
ctx.turn = turn;
|
||||||
|
ctx.sequence = cursor.sequence;
|
||||||
|
ctx.auction!.cards = [purchased, top, unsold];
|
||||||
|
ctx.players = [...ctx.players, { id: 'guild', hand: [], handLimit: 4 }];
|
||||||
|
ctx.physicalCards = physical;
|
||||||
|
const original = createIxianReplacementSource(ctx,
|
||||||
|
ixianReplacementEvent(turn, 'buyer', cursor.sequence, 0), JSON.stringify(ctx));
|
||||||
|
const closed = closeIxianReplacementSource(original, 'pass', null, cursor);
|
||||||
|
cursor = closed.cursor;
|
||||||
|
history.push(closed.receipt);
|
||||||
|
validateIxianReplacementHistory(history, cursor, physical);
|
||||||
|
}
|
||||||
|
const firstBytes = JSON.stringify(history[0]).length;
|
||||||
|
assert.ok(JSON.stringify(history.at(-1)).length <= firstBytes * 2,
|
||||||
|
'a receipt must not embed recursively escaped predecessor receipts');
|
||||||
|
assert.ok(JSON.stringify(history).length <= history.length * firstBytes * 2,
|
||||||
|
'persisted history must grow with purchases, not exponentially');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a live purchase cannot target another held card or a card no longer held by its buyer', () => {
|
||||||
|
const original = source();
|
||||||
|
const missing = context();
|
||||||
|
missing.players = missing.players.map(player => player.id === 'buyer'
|
||||||
|
? { ...player, hand: [{ ...older }] }
|
||||||
|
: { ...player, hand: [{ ...purchased }] });
|
||||||
|
assert.throws(() => validateIxianReplacementSource(missing, original, parent), IxianReplacementError);
|
||||||
|
const substituted = { ...original, card: { ...older } };
|
||||||
|
assert.throws(() => validateIxianReplacementSource(context(), substituted, parent), IxianReplacementError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('canonical descriptors cannot be redefined by a matching auction row and hand', () => {
|
||||||
|
for (const patch of [
|
||||||
|
{ name: 'Another face' }, { kind: 'special' }, { effect: 'fabricated' },
|
||||||
|
{ id: 'nonphysical-card' }, { extra: 'not-a-card-field' },
|
||||||
|
]) {
|
||||||
|
const ctx = context();
|
||||||
|
const corrupt = { ...purchased, ...patch } as Card;
|
||||||
|
ctx.auction!.cards = [corrupt, unsold];
|
||||||
|
ctx.players = [{ ...ctx.players[0], hand: [older, corrupt] }, ctx.players[1]];
|
||||||
|
ctx.physicalCards = [older, corrupt, top, unsold];
|
||||||
|
assert.throws(() => source(ctx), IxianReplacementError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('duplicate physical cards reject even when the purchased hand itself looks legal', () => {
|
||||||
|
for (const duplicate of [purchased, older, top, unsold]) {
|
||||||
|
const ctx = context();
|
||||||
|
ctx.physicalCards = [...ctx.physicalCards, { ...duplicate }];
|
||||||
|
assert.throws(() => source(ctx), IxianReplacementError);
|
||||||
|
}
|
||||||
|
const anotherHolder = context();
|
||||||
|
anotherHolder.players = [anotherHolder.players[0], {
|
||||||
|
...anotherHolder.players[1], hand: [{ ...purchased }],
|
||||||
|
}];
|
||||||
|
assert.throws(() => source(anotherHolder), IxianReplacementError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a legal full hand permits net-zero replacement but an already over-cap hand does not', () => {
|
||||||
|
const full = context();
|
||||||
|
full.players = [{ ...full.players[0], handLimit: 2 }, full.players[1]];
|
||||||
|
const original = source(full);
|
||||||
|
const before = structuredClone(full);
|
||||||
|
const result = closeIxianReplacementSource(original, 'use', top, initialIxianReplacementCursor());
|
||||||
|
assert.equal(result.receipt.source.card.id, purchased.id);
|
||||||
|
assert.equal(result.receipt.replacement?.id, top.id);
|
||||||
|
assert.deepEqual(full, before);
|
||||||
|
const overCap = { ...full, players: [{ ...full.players[0], handLimit: 1 }, full.players[1]] };
|
||||||
|
assert.throws(() => source(overCap), IxianReplacementError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('paid and printed Karama sources retain their native amount without recalculating payment', () => {
|
||||||
|
const paid = source();
|
||||||
|
assert.equal(paid.amount, 4);
|
||||||
|
assert.equal(paid.free, false);
|
||||||
|
for (const bid of [0, 4]) {
|
||||||
|
const karama = context();
|
||||||
|
karama.auction = { ...karama.auction!, bid };
|
||||||
|
karama.sale = { ...karama.sale!, amount: bid, free: true };
|
||||||
|
const receipt = source(karama);
|
||||||
|
assert.equal(receipt.amount, bid);
|
||||||
|
assert.equal(receipt.free, true);
|
||||||
|
}
|
||||||
|
const unpaid = context();
|
||||||
|
unpaid.auction = { ...unpaid.auction!, bid: 0 };
|
||||||
|
unpaid.sale = { ...unpaid.sale!, amount: 0 };
|
||||||
|
assert.throws(() => source(unpaid), IxianReplacementError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('current source must preserve normal origin, bidder, lot, price, payment kind, parent and cursor', () => {
|
||||||
|
const original = source();
|
||||||
|
const variants: IxianReplacementContext[] = [
|
||||||
|
{ ...context(), status: 'finished' },
|
||||||
|
{ ...context(), phase: 4 },
|
||||||
|
{ ...context(), turn: 2 },
|
||||||
|
{ ...context(), sequence: 1 },
|
||||||
|
{ ...context(), auction: null },
|
||||||
|
{ ...context(), sale: null },
|
||||||
|
{ ...context(), auction: { ...context().auction!, bidder: 'rival' } },
|
||||||
|
{ ...context(), auction: { ...context().auction!, index: 1 } },
|
||||||
|
{ ...context(), auction: { ...context().auction!, bid: 5 } },
|
||||||
|
{ ...context(), sale: { ...context().sale!, winner: 'rival' } },
|
||||||
|
{ ...context(), sale: { ...context().sale!, amount: 5 } },
|
||||||
|
{ ...context(), sale: { ...context().sale!, free: true } },
|
||||||
|
{ ...context(), sale: { ...context().sale!, origin: 'cache' } },
|
||||||
|
{ ...context(), sale: { ...context().sale!, origin: 'blackMarket' } },
|
||||||
|
{ ...context(), sale: { ...context().sale!, seller: 'rival' } },
|
||||||
|
];
|
||||||
|
for (const ctx of variants)
|
||||||
|
assert.throws(() => validateIxianReplacementSource(ctx, original, parent), IxianReplacementError);
|
||||||
|
assert.throws(() => validateIxianReplacementSource(context(), original, 'changed-parent'), IxianReplacementError);
|
||||||
|
assert.throws(() => createIxianReplacementSource(context(), 'stale-event', parent), IxianReplacementError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('an empty deck permits newly discarded purchased card redraw; unsold row is not duplicate custody', () => {
|
||||||
|
const ctx = context();
|
||||||
|
// The physical census has only hands and the separate unsold row: no draw deck.
|
||||||
|
ctx.physicalCards = [older, purchased, unsold];
|
||||||
|
const original = source(ctx);
|
||||||
|
const result = closeIxianReplacementSource(original, 'use', purchased, initialIxianReplacementCursor());
|
||||||
|
assert.equal(result.receipt.replacement?.id, purchased.id);
|
||||||
|
validateIxianReplacementHistory([result.receipt], result.cursor, [older, purchased, unsold]);
|
||||||
|
// Counting the delivered row alias again is a real custody error.
|
||||||
|
assert.throws(() => source({ ...ctx, physicalCards: [...ctx.physicalCards, purchased] }), IxianReplacementError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('pass closes without a draw, and neither pass nor use can reuse an already closed cursor', () => {
|
||||||
|
const original = source();
|
||||||
|
const passed = closeIxianReplacementSource(original, 'pass', null, initialIxianReplacementCursor());
|
||||||
|
validateIxianReplacementHistory([passed.receipt], passed.cursor);
|
||||||
|
assert.throws(() => closeIxianReplacementSource(original, 'use', top, passed.cursor), IxianReplacementError);
|
||||||
|
assert.throws(() => closeIxianReplacementSource(original, 'pass', null, passed.cursor), IxianReplacementError);
|
||||||
|
assert.throws(() => closeIxianReplacementSource(original, 'pass', top, initialIxianReplacementCursor()), IxianReplacementError);
|
||||||
|
assert.throws(() => closeIxianReplacementSource(original, 'use', null, initialIxianReplacementCursor()), IxianReplacementError);
|
||||||
|
assert.throws(() => closeIxianReplacementSource(original, 'use', { ...top, name: 'wrong' }, initialIxianReplacementCursor()), IxianReplacementError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('closed receipts survive JSON reload, transfer and a later purchase of the same recycled physical card', () => {
|
||||||
|
const first = closeIxianReplacementSource(source(), 'use', top, initialIxianReplacementCursor());
|
||||||
|
const later = context();
|
||||||
|
later.turn = 2;
|
||||||
|
later.sequence = first.cursor.sequence;
|
||||||
|
const second = closeIxianReplacementSource(source(later), 'pass', null, first.cursor);
|
||||||
|
const history = JSON.parse(JSON.stringify([first.receipt, second.receipt]));
|
||||||
|
const cursor = JSON.parse(JSON.stringify(second.cursor));
|
||||||
|
// Historical purchased and drawn cards can now be anywhere in the live census.
|
||||||
|
validateIxianReplacementHistory(history, cursor, [top, unsold, purchased, older]);
|
||||||
|
assert.equal(history[0].source.card.id, history[1].source.card.id);
|
||||||
|
assert.notEqual(history[0].source.event, history[1].source.event);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('history removal, reordering, altered source/outcome and a changed independent cursor reject', () => {
|
||||||
|
const first = closeIxianReplacementSource(source(), 'use', top, initialIxianReplacementCursor());
|
||||||
|
const ctx = context();
|
||||||
|
ctx.turn = 2;
|
||||||
|
ctx.sequence = 1;
|
||||||
|
const second = closeIxianReplacementSource(source(ctx), 'pass', null, first.cursor);
|
||||||
|
const history = [first.receipt, second.receipt];
|
||||||
|
const invalid: IxianReplacementReceipt[][] = [
|
||||||
|
[], [first.receipt], [second.receipt, first.receipt], [first.receipt, first.receipt],
|
||||||
|
[first.receipt, { ...second.receipt, previous: 'unrelated-chain' }],
|
||||||
|
[first.receipt, { ...second.receipt, source: { ...second.receipt.source, amount: 7 } }],
|
||||||
|
[first.receipt, { ...second.receipt, replacement: top }],
|
||||||
|
[{ ...first.receipt, replacement: older }, second.receipt],
|
||||||
|
];
|
||||||
|
for (const records of invalid)
|
||||||
|
assert.throws(() => validateIxianReplacementHistory(records, second.cursor), IxianReplacementError);
|
||||||
|
assert.throws(() => validateIxianReplacementHistory(history, { ...second.cursor, signature: 'changed-head' }), IxianReplacementError);
|
||||||
|
assert.throws(() => validateIxianReplacementHistory(history, { ...second.cursor, sequence: 1 }), IxianReplacementError);
|
||||||
|
assert.throws(() => validateIxianReplacementHistory(history, initialIxianReplacementCursor()), IxianReplacementError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('receipt snapshots cannot be mutated through original source or native draw references', () => {
|
||||||
|
const ctx = context();
|
||||||
|
const before = structuredClone(ctx);
|
||||||
|
const original = source(ctx);
|
||||||
|
const drawn = { ...top };
|
||||||
|
const result = closeIxianReplacementSource(original, 'use', drawn, initialIxianReplacementCursor());
|
||||||
|
assert.deepEqual(ctx, before);
|
||||||
|
original.card.name = 'mutated original source reference';
|
||||||
|
drawn.name = 'mutated native draw reference';
|
||||||
|
assert.equal(result.receipt.source.card.name, purchased.name);
|
||||||
|
assert.equal(result.receipt.replacement?.name, top.name);
|
||||||
|
validateIxianReplacementHistory([result.receipt], result.cursor);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('unknown saved receipt fields and malformed canonical custody fail closed', () => {
|
||||||
|
const original = source();
|
||||||
|
assert.throws(() => validateIxianReplacementSource(context(), { ...original, extra: true } as never, parent), IxianReplacementError);
|
||||||
|
assert.throws(() => validateIxianReplacementSource(context(), null as never, parent), IxianReplacementError);
|
||||||
|
const result = closeIxianReplacementSource(original, 'pass', null, initialIxianReplacementCursor());
|
||||||
|
assert.throws(() => validateIxianReplacementHistory([{ ...result.receipt, extra: true } as never], result.cursor), IxianReplacementError);
|
||||||
|
assert.throws(() => validateIxianReplacementHistory([result.receipt], { ...result.cursor, extra: true } as never), IxianReplacementError);
|
||||||
|
assert.throws(() => validateIxianReplacementHistory([result.receipt], result.cursor, [{ ...purchased, effect: 'forged' }]), IxianReplacementError);
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync, statSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import {
|
import {
|
||||||
applyAction,
|
applyAction,
|
||||||
createGame,
|
createGame,
|
||||||
@@ -365,3 +370,120 @@ for (const advanced of [false, true]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const advanced of [false, true]) {
|
||||||
|
void test(`Ixian Nexus replacement ${advanced ? 'Advanced' : 'Basic'} starts only the exact fresh classic lobby without changing seats or native options`, t => {
|
||||||
|
const { db } = fixture(t);
|
||||||
|
const game = createGame('PROTOTYP', newPlayer('i', 'Atreides', 'atreides'), advanced);
|
||||||
|
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||||
|
for (const p of game.players) p.ready = true;
|
||||||
|
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
|
||||||
|
const seats = db.prepare('SELECT * FROM seats').all();
|
||||||
|
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
|
||||||
|
const lobbyRows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
assert.throws(() => startPrototypeRoom(db, game.code, 6, 'ixian-replacement'));
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), lobbyRows);
|
||||||
|
const result = startPrototypeRoom(db, game.code, 7, 'ixian-replacement');
|
||||||
|
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
|
||||||
|
assert.equal(result.version, 8);
|
||||||
|
assert.equal(saved.status, 'setup');
|
||||||
|
assert.equal(saved.code, game.code);
|
||||||
|
assert.equal(saved.host, game.host);
|
||||||
|
assert.equal(saved.advanced, advanced);
|
||||||
|
assert.deepEqual(saved.expansions, game.expansions);
|
||||||
|
assert.deepEqual(saved.playerPositions, game.playerPositions);
|
||||||
|
assert.deepEqual(saved.players.map(p => [p.id, p.name, p.faction]),
|
||||||
|
game.players.map(p => [p.id, p.name, p.faction]));
|
||||||
|
assert.equal(saved.nexusIxianReplacementPreview, true);
|
||||||
|
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort(), [...NEXUS_FACTIONS].sort());
|
||||||
|
for (const p of saved.players) {
|
||||||
|
const view = viewGame(saved, p.id);
|
||||||
|
assert.equal(view.nexusIxianReplacementPreview, true);
|
||||||
|
assert.equal(view.nexusCards!.card, null);
|
||||||
|
assert.equal(Object.hasOwn(view.nexusCards!, 'deck'), false);
|
||||||
|
}
|
||||||
|
const startedRows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'ixian-replacement'));
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), startedRows);
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('Ixian Nexus replacement rejects unready, expansion, native Ixian and Homeworld lobbies without reconfiguring them', t => {
|
||||||
|
const { db } = fixture(t);
|
||||||
|
const classic = createGame('PROTOTYP', newPlayer('i', 'Atreides', 'atreides'), true);
|
||||||
|
joinGame(classic, newPlayer('e', 'Emperor', 'emperor'));
|
||||||
|
for (const p of classic.players) p.ready = true;
|
||||||
|
const unready = structuredClone(classic);
|
||||||
|
unready.players[0].ready = false;
|
||||||
|
const expansion = structuredClone(classic);
|
||||||
|
expansion.expansions = ['ix'];
|
||||||
|
const native = createGame('PROTOTYP', newPlayer('i', 'Ixians', 'ixians'), true, ['ix']);
|
||||||
|
joinGame(native, newPlayer('e', 'Emperor', 'emperor'));
|
||||||
|
for (const p of native.players) p.ready = true;
|
||||||
|
const homeworld = applyAction(classic, classic.host, { type: 'homeworlds', enabled: true });
|
||||||
|
for (const p of homeworld.players) p.ready = true;
|
||||||
|
for (const game of [unready, expansion, native, homeworld]) {
|
||||||
|
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
|
||||||
|
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
const seats = db.prepare('SELECT * FROM seats').all();
|
||||||
|
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'ixian-replacement'));
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rooms);
|
||||||
|
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Ixian replacement CLI takes a private exact-version backup before starting and cannot overwrite a started game or prior proof', t => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'dune-ixian-replacement-'));
|
||||||
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const database = join(directory, 'rooms.sqlite');
|
||||||
|
const db = new DatabaseSync(database);
|
||||||
|
db.exec('CREATE TABLE rooms(code TEXT PRIMARY KEY,state TEXT,version INTEGER,updated_at INTEGER); CREATE TABLE seats(room_code TEXT,player_id TEXT,token_hash TEXT);');
|
||||||
|
const game = createGame('PROTOTYP', newPlayer('a', 'Atreides', 'atreides'), true);
|
||||||
|
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||||
|
for (const p of game.players) p.ready = true;
|
||||||
|
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(game.code, JSON.stringify(game), 7, 100);
|
||||||
|
const other = structuredClone(game);
|
||||||
|
other.code = 'KEEPGAME';
|
||||||
|
db.prepare('INSERT INTO rooms VALUES(?,?,?,?)').run(other.code, JSON.stringify(other), 9, 50);
|
||||||
|
startPrototypeRoom(db, other.code, 9, 'ixian-replacement');
|
||||||
|
for (const p of game.players)
|
||||||
|
db.prepare('INSERT INTO seats VALUES(?,?,?)').run(game.code, p.id, 'isolated-test-' + p.id);
|
||||||
|
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
const seats = db.prepare('SELECT * FROM seats ORDER BY player_id').all();
|
||||||
|
db.close();
|
||||||
|
const output = join(directory, 'private-proof');
|
||||||
|
const invoke = (version: number, out: string) => spawnSync(process.execPath, [
|
||||||
|
'--import', 'tsx', fileURLToPath(new URL('../tools/start-prototype.ts', import.meta.url)),
|
||||||
|
'--profile', 'ixian-replacement', '--db', database, '--room', game.code,
|
||||||
|
'--version', String(version), '--out', out,
|
||||||
|
], { encoding: 'utf8', timeout: 120000 });
|
||||||
|
const started = invoke(7, output);
|
||||||
|
assert.equal(started.status, 0, started.stderr);
|
||||||
|
assert.equal(statSync(output).mode & 0o777, 0o700);
|
||||||
|
for (const name of ['games.sqlite', 'snapshot.json', 'prototype.json'])
|
||||||
|
assert.equal(statSync(join(output, name)).mode & 0o777, 0o600);
|
||||||
|
const backup = new DatabaseSync(join(output, 'games.sqlite'), { readOnly: true });
|
||||||
|
assert.deepEqual(backup.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
|
||||||
|
assert.deepEqual(backup.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
|
||||||
|
backup.close();
|
||||||
|
const current = new DatabaseSync(database, { readOnly: true });
|
||||||
|
t.after(() => current.close());
|
||||||
|
const after = current.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||||
|
assert.deepEqual(current.prepare('SELECT * FROM rooms WHERE code=?').get(other.code), before.find(row => row.code === other.code));
|
||||||
|
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
|
||||||
|
const savedRow = current.prepare('SELECT state,version FROM rooms WHERE code=?').get(game.code)!;
|
||||||
|
assert.equal(savedRow.version, 8);
|
||||||
|
const saved = JSON.parse(savedRow.state as string) as Game;
|
||||||
|
assert.equal(saved.nexusIxianReplacementPreview, true);
|
||||||
|
assert.equal(saved.status, 'setup');
|
||||||
|
assert.equal(saved.advanced, game.advanced);
|
||||||
|
assert.deepEqual(saved.playerPositions, game.playerPositions);
|
||||||
|
assert.deepEqual(saved.players.map(p => [p.id,p.name,p.faction]), game.players.map(p => [p.id,p.name,p.faction]));
|
||||||
|
assert.notEqual(invoke(8, output).status, 0, 'existing proof directory cannot be reused');
|
||||||
|
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0, 'stale backup version cannot be admitted');
|
||||||
|
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0, 'current version cannot redeal a started game');
|
||||||
|
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
|
||||||
|
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
initializeRicheseBetrayalGameForAudit,
|
initializeRicheseBetrayalGameForAudit,
|
||||||
initializeNexusKullGameForAudit,
|
initializeNexusKullGameForAudit,
|
||||||
initializeGuildBetrayalGameForAudit,
|
initializeGuildBetrayalGameForAudit,
|
||||||
|
initializeIxianNexusReplacementGameForAudit,
|
||||||
viewGame,
|
viewGame,
|
||||||
type Game,
|
type Game,
|
||||||
} from '../game/engine';
|
} from '../game/engine';
|
||||||
@@ -29,6 +30,7 @@ export const PROTOTYPE_PROFILES = [
|
|||||||
'richese-betrayal',
|
'richese-betrayal',
|
||||||
'nexus-kull',
|
'nexus-kull',
|
||||||
'guild-betrayal',
|
'guild-betrayal',
|
||||||
|
'ixian-replacement',
|
||||||
] as const;
|
] as const;
|
||||||
export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number];
|
export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number];
|
||||||
export function isPrototypeProfile(value: string): value is PrototypeProfile {
|
export function isPrototypeProfile(value: string): value is PrototypeProfile {
|
||||||
@@ -94,6 +96,11 @@ export function startPrototypeRoom(
|
|||||||
...initial,
|
...initial,
|
||||||
nexusCards: initial.nexusCards ?? { cards: null, phase: null },
|
nexusCards: initial.nexusCards ?? { cards: null, phase: null },
|
||||||
})
|
})
|
||||||
|
: profile === 'ixian-replacement'
|
||||||
|
? initializeIxianNexusReplacementGameForAudit({
|
||||||
|
...initial,
|
||||||
|
nexusCards: initial.nexusCards ?? { cards: null, phase: null },
|
||||||
|
})
|
||||||
: profile === 'factions'
|
: profile === 'factions'
|
||||||
? initializeFactionExpansionsGameForAudit(initial)
|
? initializeFactionExpansionsGameForAudit(initial)
|
||||||
: profile === 'nexus'
|
: profile === 'nexus'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
if (values.help) {
|
if (values.help) {
|
||||||
console.log(
|
console.log(
|
||||||
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions|nexus|moritani-assassinate|ecaz-treachery|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user