Integrate Ixian Nexus Betrayal at original native attempts

This commit is contained in:
Quintine committed 2026-10-01 21:58:07 +10:00
1 parent 49c99de3e2
commit cdfe0e5370
25 files changed
+2824 -63

No files matched your search

+13
View File
@@ -189,6 +189,19 @@ Full hands and same-card redraw from an exhausted deck are supported.
Harkonnen buyers and special sale origins remain guarded; see Harkonnen buyers and special sale origins remain guarded; see
[verified source, privacy and runtime boundaries](docs/NEXUS_IXIAN_REPLACEMENT_RULES.md). [verified source, privacy and runtime boundaries](docs/NEXUS_IXIAN_REPLACEMENT_RULES.md).
For local **Ixian Nexus Betrayal**, use
`node --import tsx tools/start-prototype.ts --profile ixian-betrayal`
on a fresh ready Basic/Advanced lobby with native Ixians, otherwise classic
factions or Tleilaxu, the forty-seven-card Ix Treachery Deck and Nexus alone.
Exact-version/private-backup safeguards apply; no existing-game retrofit or
public start is enabled. Native alliance-qualified Nexus rights still apply.
Original Karama counters resolve first; neutral rival acknowledgements then
offer **Pass** or privately eligible **Use Ixian Nexus** before the original
Bidding inspection/draw or Advanced Technology exchange. Use spends one
physical Nexus and prevents only that attempt; all-pass keeps its native
effect and continuation. See the
[bounded verified runtime](docs/NEXUS_CARD_RUNTIME.md#bounded-ixian-betrayal-local-runtime--1-october-2026).
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).
+29 -3
View File
@@ -82,6 +82,7 @@ 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 { NexusIxianReplacement } from './nexus-ixian-replacement';
import { NexusIxianBetrayal } from './nexus-ixian-betrayal';
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';
@@ -306,8 +307,11 @@ 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 || !!g.nexusIxianReplacement; const ixianBetrayalOwnsControls = !!g.nexusIxianBetrayalReaction &&
const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement; !g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
!g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending;
const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls;
const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls;
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;
@@ -784,6 +788,15 @@ export function GameTable({
!botGroundMoveAllowed(g, me, key, boardLocation(selected, sector), 0), !botGroundMoveAllowed(g, me, key, boardLocation(selected, sector), 0),
); );
const act = (a: Action) => { const act = (a: Action) => {
// Control of one's own seat is independent of every gameplay response.
if (a.type === 'setAutopilot') { void send(a); return; }
const ixianReaction = g.nexusIxianBetrayalReaction;
if (ixianReaction && ixianBetrayalOwnsControls) {
if (reactionBusy || g.status !== 'playing' || !ixianReaction.canPass || ixianReaction.hasPassed ||
(a.type !== 'nexusIxianBetrayalPass' && a.type !== 'nexusIxianBetrayalUse') ||
a.event !== ixianReaction.event ||
(a.type === 'nexusIxianBetrayalUse' && (!ixianReaction.canUse || ixianReaction.blocked))) return;
}
const guildReaction = g.guildBetrayalReaction; const guildReaction = g.guildBetrayalReaction;
if (guildReaction) { if (guildReaction) {
if (reactionBusy || g.status !== 'playing' || !guildReaction.canPass || guildReaction.hasPassed || if (reactionBusy || g.status !== 'playing' || !guildReaction.canPass || guildReaction.hasPassed ||
@@ -1029,6 +1042,8 @@ export function GameTable({
faction(g.players.find((p) => p.id === id)!.faction).name, faction(g.players.find((p) => p.id === id)!.faction).name,
) )
.join(' + ') || 'No winner' .join(' + ') || 'No winner'
: ixianBetrayalOwnsControls
? 'Native Ixian advantage acknowledgement'
: g.guildBetrayalReaction : g.guildBetrayalReaction
? 'Shipment payment acknowledgement' ? 'Shipment payment acknowledgement'
: g.richeseBetrayalReaction : g.richeseBetrayalReaction
@@ -1093,6 +1108,15 @@ export function GameTable({
<a href="/rules?topic=nexus-ixian-replacement#nexus-ixian-replacement">Preview rules and limits</a> <a href="/rules?topic=nexus-ixian-replacement#nexus-ixian-replacement">Preview rules and limits</a>
</p> </p>
)} )}
{g.nexusIxianBetrayalPreview && (
<p className="notice" role="status">
Ixian Nexus Betrayal development preview · native Ixian Bidding extra-card
inspection/draw and Advanced Technology exchange prevention. Native Ixians,
permitted classic/Tleilaxu seats, physical Ixian Treachery and Nexus only.
Native Karama responses finish first. Not complete Ixian or certified Nexus rules.{' '}
<a href="/rules?topic=nexus-ixian-betrayal#nexus-ixian-betrayal">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) => (
@@ -1125,7 +1149,7 @@ export function GameTable({
<SpiceCardInspector card={g.spicePeek} context="Private foresight" /> <SpiceCardInspector card={g.spicePeek} context="Private foresight" />
</section> </section>
)} )}
<SeatAutopilot game={g} act={act} busy={transportBusy || !!g.guildBetrayalReaction} /> <SeatAutopilot game={g} act={act} busy={transportBusy} />
<DukeVidal game={g} /> <DukeVidal game={g} />
<EcazLoyaltyCard loyalty={g.ecazLoyalty} /> <EcazLoyaltyCard loyalty={g.ecazLoyalty} />
<MoritaniTerrorSupply game={g} /> <MoritaniTerrorSupply game={g} />
@@ -1739,6 +1763,8 @@ export function GameTable({
<ChoamKull game={g} act={act} busy={reactionBusy} /> <ChoamKull game={g} act={act} busy={reactionBusy} />
) : g.nexusIxianReplacement ? ( ) : g.nexusIxianReplacement ? (
<NexusIxianReplacement game={g} act={act} busy={reactionBusy} /> <NexusIxianReplacement game={g} act={act} busy={reactionBusy} />
) : ixianBetrayalOwnsControls ? (
<NexusIxianBetrayal 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 ? (
+60
View File
@@ -0,0 +1,60 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { nexusCardMode } from '@/game/nexus-cards';
import { NexusCardFace } from './nexus-cards';
import { Button } from './ui/button';
export function NexusIxianBetrayal({ game, act, busy }: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const reaction = game.nexusIxianBetrayalReaction;
if (!reaction) return null;
const own = game.players.find((player) => player.id === game.me);
const provider = game.players.find((player) => player.id === reaction.provider);
const canRespond = !!reaction.event && reaction.canPass && !reaction.hasPassed;
const canUse = canRespond && reaction.canUse && !reaction.blocked;
const priorityBlocked = game.automaticContinuationPending || !!game.truthtrance ||
!!game.response || !!game.decision || !!game.phaseOpening ||
!!game.nexusCards?.waiting.length || !!game.nexusTraitors?.pending;
const disabled = busy || game.status !== 'playing' || game.phase !== 3 || priorityBlocked ||
!!game.roomControl?.paused || !!game.roomControl?.closed || !!own?.autopilot;
const ownMode = game.nexusCards?.card === 'ixians' && own
? nexusCardMode('ixians', own.faction, game.players.map((player) => player.faction))
: undefined;
const advantage = reaction.kind === 'bidding' ? 'Bidding' : 'Technology';
return (
<section aria-label="Native Ixian advantage acknowledgement" data-testid="nexus-ixian-betrayal" className="notice min-w-0 space-y-3">
<h3>Native Ixian advantage acknowledgement · {advantage}</h3>
<p>{provider?.name ?? 'The Ixian player'} has a pending native {advantage} attempt.
{reaction.kind === 'bidding'
? ' The extra-card draw and inspection have not happened.'
: ' The declared exchange has not happened.'}</p>
<p className="fine">Every publicly possible unallied responder acknowledges this boundary, regardless of their secret Nexus identity.
After all acknowledgements, the original advantage continues once. Preventing it closes only this attempt, not the whole phase.
The native Karama response finishes first; this acknowledgement is not another Karama counter window.</p>
{game.nexusCards?.card === 'ixians' && <>
<NexusCardFace card="ixians" mode={ownMode} />
{ownMode === 'betrayal' && <p className="fine">Spend your own Ixian Nexus to prevent this one native {advantage} advantage.
{' '}<a href="/rules?topic=nexus-ixian-betrayal#nexus-ixian-betrayal">Betrayal timing and preview limits</a>
</p>}
</>}
{!canRespond ? (
<p aria-live="polite">{reaction.hasPassed ? 'You have passed. Waiting for the remaining acknowledgements.' : 'Waiting for the required acknowledgements.'}</p>
) : <>
{reaction.blocked && <p className="fine">{reaction.blocked}</p>}
<Button data-testid="nexus-ixian-betrayal-pass" className="game-action min-h-11 whitespace-normal" disabled={disabled}
onClick={() => { if (!disabled) act({ type: 'nexusIxianBetrayalPass', event: reaction.event }); }}>
Pass · allow the original advantage
</Button>
{canUse && <Button data-testid="nexus-ixian-betrayal-use" className="game-action min-h-11 whitespace-normal" disabled={disabled}
onClick={() => { if (!disabled) act({ type: 'nexusIxianBetrayalUse', event: reaction.event }); }}>
Use Ixian Nexus · prevent this {advantage} advantage
</Button>}
</>}
</section>
);
}
+7
View File
@@ -39,6 +39,13 @@ export function projectAdminRoom(row: DirectoryRow): AdminRoom {
else if (g.battle) pending = { label: 'Battle decisions', owners: [g.battle.attacker, g.battle.defender].filter(seated) }; else if (g.battle) pending = { label: 'Battle decisions', owners: [g.battle.attacker, g.battle.defender].filter(seated) };
else if (g.nexus) pending = { label: 'Nexus negotiations', owners: [] }; else if (g.nexus) pending = { label: 'Nexus negotiations', owners: [] };
else if (g.phase === 0 && g.stormDialers?.length) pending = { label: 'Storm dials', owners: g.stormDialers.filter(id => g.stormDials?.[id] === undefined).filter(seated) }; else if (g.phase === 0 && g.stormDialers?.length) pending = { label: 'Storm dials', owners: g.stormDialers.filter(id => g.stormDials?.[id] === undefined).filter(seated) };
else if (g.pendingNexusIxianBetrayal) {
const { required, passed } = g.pendingNexusIxianBetrayal;
const unfinished = Array.isArray(required) && Array.isArray(passed)
? [...new Set(required.filter(seated).filter(id => !passed.includes(id)))]
: [];
pending = { label: 'Nexus acknowledgement', owners: unfinished.length === 1 ? unfinished : [] };
}
else if (g.auction) pending = { label: 'Auction decision', owners: seated(g.auction.active) ? [g.auction.active] : [] }; else if (g.auction) pending = { label: 'Auction decision', owners: seated(g.auction.active) ? [g.auction.active] : [] };
else if (seated(g.active)) pending = { label: 'Turn decision', owners: [g.active] }; else if (seated(g.active)) pending = { label: 'Turn decision', owners: [g.active] };
return { ...base, status: g.status, host: seated(g.host) ? g.host : null, players, return { ...base, status: g.status, host: seated(g.host) ? g.host : null, players,
+28
View File
@@ -16,6 +16,34 @@ open mode gates.
## Current checkpoint and work ## Current checkpoint and work
Ixian Nexus Betrayal now connects both actual native attempts: Bidding's
extra-card inspection and Advanced Technology. Fresh local `ixian-betrayal`
uses native Ix/allowed classic or Tleilaxu, the physical Ix deck and Nexus only.
Native Karama counters resolve first; uniform public possible holders then
acknowledge before the draw/exchange. Denial/allow uses the original transition
once with exact Nexus cost, private controls, legal bots and saved continuation.
This bounded ordering/privacy is an inference, not universal publisher priority.
Original BG conversion and Harkonnen exchange suspensions retain native source
ownership; legitimate pre-offer loss of a declared Technology card retains its
spent attempt without a swap or fabricated Nexus gate. Public administrator
progress follows the same unfinished acknowledgements as the player table.
Final scoped native and privacy/administrator reviews have no finding.
Types, lint, **6,315 offline tests**, **167 affected cases**, final guide
**10/10** and build pass. Eight actual native scenarios plus both BG
Worthless-Karama interruptions retain original stock and continuation.
Four privately backed-up exact-version CLI/390px rooms prove Basic Bidding
Use, Advanced Technology Use, wrong-face Bidding Pass and two-receiver
Technology all-pass with partial refresh. Read-only proof retains all
forty-seven Treachery/twelve Nexus cards and fourteen earlier QA games.
The bounded path is **Partial development verified**, not full Ixian/Nexus
completion, a public start or deployed acceptance.
After the final isolated worker reload, all **55 original seats in eighteen
rooms** reauthenticate; exact saved proof preserves all four outcomes and
fourteen earlier games. The actual phone guide shows **Partial / Development:
Verified** at 390px without horizontal overflow. Current HTTP integration is
**52/55**, not green: three requests returned the known Wrangler proxy 503.
No authorization weakening, automatic write retry, reset or deployment follows.
Five implementation lanes connected the missing Ixian Nexus Secret Ally Five implementation lanes connected the missing Ixian Nexus Secret Ally
purchased-card replacement through fresh local `ixian-replacement`: exact purchased-card replacement through fresh local `ixian-replacement`: exact
normal paid/printed-Karama source, own private controls, native discard/draw normal paid/printed-Karama source, own private controls, native discard/draw
+61
View File
@@ -1,5 +1,66 @@
# Dune implementation status # Dune implementation status
## 1 October 2026 — Ixian Nexus Betrayal at original native attempts
Fresh local `ixian-betrayal` integrates native Ixian Bidding inspection/draw in
Basic/Advanced and Advanced Technology exchange before the native effect.
It admits unique 2..6 seats with native Ixians and otherwise classic/Tleilaxu,
the physical Ix47 Treachery Deck and Nexus alone. Natural alliance-qualified
Nexus draw remains required; no initial deal, old-save retrofit or public
start is enabled. Richese/special lots and wider modules remain excluded.
Actual native printed/BG Karama counters resolve first. Native denial spends
no Nexus and opens no new gate. After all native passes, every publicly possible
unallied non-Ixian held-Nexus owner gets a face-neutral acknowledgement.
Privately eligible Use spends the actual singleton Ixian Nexus and prevents
only this attempt; all-pass allows the original native transition once.
Exact source/context/canonical stock/history checks reject stale, foreign,
orphaned or changed committed parents without mutation. Nonrecursive history
retains bounded serialization and later physical card recycling. Bots consume
only canonical private Use/Pass projections; strategy remains uncalibrated.
Independent review repaired original BG Worthless-Karama response ownership,
retained native Harkonnen hand-exchange suspension, and restored the existing
Technology fizzle when a legal pre-offer exchange removes the declared card.
The spent native attempt stays used; no swap, Nexus spend or fabricated gate
follows. Already-open Nexus source corruption still rejects. Administrator
progress now uses only public unfinished acknowledgements, matching the player
table instead of the original ordinary bidder; independent autopilot controls
remain available during reactive gates. Both final scoped reviews report no
findings.
Parent verification: types/lint and **6,315/6,315** offline tests pass; the
affected nine-file union is **167/167**. Final reference **10/10** and build
pass. Actual scripts exercise eight Basic/Advanced allow/prevent/partial-pass
scenarios plus both genuine BG Worthless counter interruptions; public
administrator ownership goes from two responders to the actual sole remainder.
Four exact-version private-backed-up CLI rooms continue their actual undealt
setup without redealing, then declare/respond through the real HTTP API:
- `MVLVQHVB` v11: Basic Bidding human Use spends Nexus, draws only the normal
allocation and resumes the original auction without private Ix inspection.
- `T2XSABT4` v11: Advanced Technology human Use retains the selected hand card
and unseen lot, keeps the attempt spent and resumes original inspection.
- `QBVHGDD5` v13: wrong-face Bidding Pass retains Nexus, then human Ix returns
Shield from the genuine extra-card inspection and declines Technology.
- `7NKT45P7` v14: two wrong-face receivers Pass; partial refresh preserves the
first acknowledgement and exposes only the sole public remaining owner.
All-pass performs the original physical Technology swap once.
Actual 390px controls/own inspector/refresh/native suffix and final expanded
**Partial / Development: Verified** guide are observed. Read-only saved proof
retains all forty-seven Treachery/twelve Nexus cards, unchanged spice/force
cost, exact native suffix/history and fourteen earlier QA games. After the
necessary final isolated worker reload, all **55 original seats in eighteen
rooms** reauthenticate and all saved outcomes remain unchanged.
Current HTTP integration is **52/55**, not green: three admin/discussion
requests returned the known Wrangler proxy 503. No unsafe write retry,
authorization change, game reset or deployment workaround is introduced.
Complete Ixian/Nexus games, wider modules, public starts, strategic calibration
and live acceptance remain open. The user removed the five-agent minimum:
future waves choose useful agent count for speed and integration cost.
## 1 October 2026 — exact Ixian Nexus purchased-card replacement ## 1 October 2026 — exact Ixian Nexus purchased-card replacement
Five implementation lanes integrated runtime, pure source/custody/history, Five implementation lanes integrated runtime, pure source/custody/history,
+92 -1
View File
@@ -1,6 +1,6 @@
# Nexus cards: primary-source contract # Nexus cards: primary-source contract
Source audit, 10 September 2026. This document establishes the printed component inventory and implementation contract; it does not claim runtime support or lift expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module. Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
## Authority and acquisition ## Authority and acquisition
@@ -53,6 +53,97 @@ Every row below is a semantic transcription of the photographed face, not a repr
Source for the entire table: [the twelve original printed components](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani). All card identities appear once; the photograph is arranged Moritani/Tleilaxu/Richese/Ixians, Atreides/Bene Gesserit/CHOAM/Emperor, Guild/Ecaz/Harkonnen/Fremen. Source for the entire table: [the twelve original printed components](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani). All card identities appear once; the photograph is arranged Moritani/Tleilaxu/Richese/Ixians, Atreides/Bene Gesserit/CHOAM/Emperor, Guild/Ecaz/Harkonnen/Fremen.
## Bounded Ixian Betrayal source protocol — 1 October 2026
**Both printed alternatives are connected in the fresh local `ixian-betrayal`
prototype:** prevent the native Ixian Bidding advantage, or prevent the native
Advanced Technology advantage. This is an original-attempt interception, not
just an inspectable catalog entry. Verification is **Partial**, with development
stage **Verified** for genuine native, private, recovery, CLI and phone proof;
it does not certify complete Ixian/Nexus play or deployment.
The [runtime contract](NEXUS_CARD_RUNTIME.md#bounded-ixian-betrayal-local-runtime--1-october-2026)
and internal `nexus-ixian-betrayal` topic describe the usable controls.
### Source order and authority
- **Face:** the [photographed publisher-authored Ixian Nexus face](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani)
grants either prevention alternative and expressly says one card cannot
prevent both. The photograph, not uploader commentary, supplies this panel.
- **E3:** [Ecaz & Moritani rules, printed pp.11,16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11)
supply the separate physical Nexus inventory, unallied custody, opposing
native faction mode, discard-on-use and qualifying closing-phase draw.
- **E1:** [Ixians & Tleilaxu rules, printed pp.8–9,12](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=8)
supply native auction preparation, the extra inspection card and return,
Advanced Technology before bidding and Atreides inspection, and native
cancellation. The [existing auction audit](RICHESE_AUCTION_RULES.md#evidence-and-limits)
records the retrieved publisher text and its limits.
- **FAQ:** [November 2020 FAQ, printed pp.6–7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=6)
distinguishes cancelable native Ixian advantages from other acquisition
effects. **E2**, [CHOAM & Richese rules, printed pp.10–11](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=10),
does not settle Technology's substituted cache/Black Market custody;
its Ixian ally replacement answer is not a Technology prohibition.
The adopted user-owned `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf` remains
the source for relevant Advanced core defaults, not this Nexus face. It supplies
no Ixian Nexus alternative, duration or response-priority ruling and is not
republished. Read the face with the native attempt's own source, rather than
borrowing another Nexus family's ban, response or cost.
### One current native attempt, one physical cost
Use requires the actual unallied non-Ixian holder of the singleton Ixian Nexus
while another seat is native Ixians. It discards that Nexus exactly once,
spending no Treachery Card, Karama, activation spice or force. Pass retains the
Nexus. The source fixes which alternative applies: the holder cannot choose
another attempt, draw count, hand card, lot, price or provider.
For **Bidding**, prevention takes the original native denied path before any
auction draw: draw exactly the normal allocated pool count, without the extra
inspection card, Ixian private inspection or return selector. All-pass takes
the original allowed path, including the real extra draw, private return and
normal auction continuation. Physical deck depletion remains the native
allocation's responsibility; this adapter does not manufacture cards.
For **Advanced Technology**, prevention occurs after the actual accepted native
declaration but before its swap or Atreides peek. Keep the selected Ixian hand
card and unseen lot in their original custody, preserve the declaration's
already-spent once-per-turn attempt, then resume the normal Atreides/auction
continuation once. All-pass performs the real declared exchange and its normal
continuation once. Declining the native Technology offer before declaration
still saves that option. Basic offers only the Bidding alternative.
Neither alternative undoes completed draws, selections, exchanges, peeks or
purchases, revokes other Ixian powers, or establishes a whole-phase ban.
Later legally available native attempts can create new events, including after
later physical Nexus recycling. General duration over other multiple-lot or
combined-module situations remains unresolved outside this current-attempt
profile; the word “advantage” is not an entire-phase ruling.
### Native counters first, then neutral acknowledgement
Resolve the existing native Karama window first, including its legal Advanced
Bene Gesserit substitution protocol. A successful native counter completes
the native denied transition once, with **no Nexus gate or Nexus cost**.
Only after all native counter passes, before draw/exchange/Atreides peek,
do publicly possible unallied non-Ixian seats with public held-Nexus presence
receive a uniform neutral acknowledgement. Irrelevant concealed faces also
receive Pass; only the canonical eligible owner's private view permits Use.
No publicly possible seat means immediate native allowance. All required
passes allow the original attempt once; accepted Use denies it once.
**Native-counter-first and uniform acknowledgement are explicit bounded
privacy/ordering inferences for this prototype, not publisher-prescribed
universal priority or resolution of other pending stacks.** No additional
Karama counter to the direct Nexus prevention is invented, and no blanket
Nexus-immunity rule follows. The native counter's cost and the independent
physical Nexus cost remain distinct.
The public event names only the original publicly announced kind and native
provider. It reveals no selected hand card, unseen lot, extra inspection
faces, deck order, private source receipt, rival Nexus identity or private
eligibility reason. Existing inspection of one's own actually held Nexus
remains authorized; a neutral prompt grants no new inspection entitlement.
## Bounded CHOAM Betrayal runtime ## Bounded CHOAM Betrayal runtime
The unallied holder of the CHOAM Nexus card may use its Betrayal panel when CHOAM is seated and holds at least one Treachery Card. The printed panel names no phase, so the current prototype offers it at a clean playing boundary, not during another decision, response or automatic continuation. The server samples one card uniformly from CHOAM's actual hand; neither the holder nor the bot chooses its identity. The physical Nexus card and sampled Treachery Card are discarded once. CHOAM receives no spice, and this panel does not create an additional Karama reaction. The ordinary fresh-discard continuation handles any applicable discard consequence. The unallied holder of the CHOAM Nexus card may use its Betrayal panel when CHOAM is seated and holds at least one Treachery Card. The printed panel names no phase, so the current prototype offers it at a clean playing boundary, not during another decision, response or automatic continuation. The server samples one card uniformly from CHOAM's actual hand; neither the holder nor the bot chooses its identity. The physical Nexus card and sampled Treachery Card are discarded once. CHOAM receives no spice, and this panel does not create an additional Karama reaction. The ordinary fresh-discard continuation handles any applicable discard consequence.
+123
View File
@@ -1,5 +1,16 @@
# Nexus cards: common lifecycle checkpoint # Nexus cards: common lifecycle checkpoint
**Later Ixian Betrayal prototype:** the fresh local `ixian-betrayal` profile
connects both printed alternatives to original native attempts: Bidding
extra-card inspection/draw and Advanced Technology exchange. Native counters
resolve first; a uniform neutral acknowledgement then precedes the native
effect. These are explicit bounded privacy/ordering inferences, not publisher
priority. Verification is **Partial**, with development stage **Verified** for
the bounded native/private/recovery/CLI/phone path recorded below.
See the [source protocol](NEXUS_CARD_RULES.md#bounded-ixian-betrayal-source-protocol--1-october-2026)
and [current local contract](#bounded-ixian-betrayal-local-runtime--1-october-2026);
the historical lifecycle counts below do not verify this later feature.
**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. **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.
@@ -57,3 +68,115 @@ The panel also shows deck/discard/held counts and the names of players still cho
## Remaining release gates ## Remaining release gates
Every effect family still needs its own original-action timing, cancellation, payment/discard/force or leader custody, hidden-information, bot, UI and recovery integration. Atreides' inspected elements are a distinct upcoming family; they are not enabled merely because the panel is displayed. Existing unresolved Homeworld, revival, No-Field and occupation interpretations remain isolated. Original-format saved games without this optional module are not retroactively dealt cards, and arbitrary saved JSON is not a substitute for a genuine setup/draw history. Every effect family still needs its own original-action timing, cancellation, payment/discard/force or leader custody, hidden-information, bot, UI and recovery integration. Atreides' inspected elements are a distinct upcoming family; they are not enabled merely because the panel is displayed. Existing unresolved Homeworld, revival, No-Field and occupation interpretations remain isolated. Original-format saved games without this optional module are not retroactively dealt cards, and arbitrary saved JSON is not a substitute for a genuine setup/draw history.
## Bounded Ixian Betrayal local runtime — 1 October 2026
### Fresh local entry and natural card rights
The `ixian-betrayal` profile admits a fresh ready two-to-six-seat Basic or
Advanced lobby with unique factions, native Ixians and otherwise classic
factions or Tleilaxu. It uses the actual forty-seven-card Ix Treachery Deck and
the independent Nexus module. It excludes CHOAM, Richese, Ecaz and Moritani
factions/decks/variants, Homeworlds, Leader Skills, Semuta, Discoveries, Tech
Tokens, Stronghold Cards and other optional overlays. This is a private local
development entry, not a public start, reset, saved-game retrofit or approval
of complete Ixian/Nexus play.
`initializeIxianNexusBetrayalGameForAudit` in `game/engine.ts` initializes that
fresh boundary. `tools/prototype-room.ts` and `tools/start-prototype.ts` retain
the exact-version and private-backup safeguards of other prototype entries.
For an existing named fresh lobby, use the starter's `--profile ixian-betrayal
--db PATH --room CODE --version NUMBER --out /private/new-directory` options;
the output directory must satisfy the existing private backup contract.
Original room IDs, seats, names, rules and setup custody are preserved.
There is **no starting Nexus deal or automatic draw**. Complete genuine setup,
then the normal qualifying Spice Blow and Nexus phase: a Nexus must have
occurred, negotiations and earlier responses must finish, and at least one
alliance must exist at the whole phase's close. An unallied seat may then draw
through the normal private policy. Three actual seats are the minimum for
one unallied receiver alongside a two-seat alliance; four are needed for two
simultaneous unallied receivers. A two-seat profile can continue base play but
does not fabricate an unreachable Betrayal card.
### Original-source continuation and controls
`game/nexus-ixian-betrayal.ts` supplies the bounded source/custody protocol;
the original native engine callback owns the actual allow/deny continuation.
The source distinguishes `bidding` from `technology`, binding the original
provider, native window, turn/phase and required public responders. It does
not replay a raw action, snapshot/undo the whole game, duplicate native draw
algorithms or fabricate a Karama-cost card.
1. Complete the original native Karama responses first. A successful printed
or legal Advanced BG counter takes the native denied path once without
opening a Nexus gate or spending Nexus.
2. After native passes, pause before the original Bidding draw or declared
Technology swap/Atreides peek. Publicly possible unallied non-Ixian held-
Nexus seats acknowledge uniformly, regardless of their concealed face.
Native role and publicly impossible source/seat membership can omit an
impossible offer; the concealed Nexus identity cannot determine the pause.
3. Pass retains Nexus and records that seat's acknowledgement for this event.
Only an actually eligible unallied rival Ixian-card holder receives private
Use. Use spends exactly one physical Nexus and denies the current native
attempt. All required passes allow it; no possible seats allow immediately.
4. Bidding denial draws only the original normal allocated count, without
private Ixian inspection/return. Allowance retains the real native extra
draw and selection. Technology denial preserves both original card
locations and the declaration's used attempt; allowance swaps them.
Both branches resume the original normal auction/Atreides suffix once.
The table uses `GameView.nexusIxianBetrayalReaction` and the preview marker.
The reaction carries only the public event, kind and provider plus that
viewer's own `canPass`, `hasPassed`, `canUse` and private `blocked` reason.
`nexusIxianBetrayalPass` and `nexusIxianBetrayalUse` submit exactly the offered
event, not client-selected source, draw count, card ID, price or provider.
Multiple outstanding public responders have no fabricated sole owner; the
last remaining public responder may own the acknowledgement.
The controls explain the selected native alternative and distinguish Pass
from private eligible Use. The holder's existing own-Nexus inspector shows
their actually held face; an irrelevant-face prompt cannot grant an Ixian
identity inspector. Neither the prompt nor the rival view reveals selected
Ixian cards, unseen lots, inspection faces, deck order, private source or
other seats' eligibility reasons. The bounded native-counter-first/neutral-
acknowledgement policy is an application inference, not universal publisher
priority, blanket Nexus immunity or an invented Karama response to Nexus.
### Saved boundaries and remaining acceptance
Refresh preserves the same pending source, passes and event. A completed
event expires; it cannot reopen or replay the original declaration, counter
cost, Nexus spend, draw, exchange or suffix. Closed history uses nonrecursive
bounded source links, so later real card recycling does not require an old
card to remain forever in discard or recursively copy prior signatures.
These are consistency safeguards, not cryptographic authentication of
arbitrarily fabricated saved JSON.
The pending source blocks ordinary bids, draws, exchanges and competing card
mutation while preserving existing response/decision/Truthtrance priority.
A missing or orphaned source must reject, not silently allow or heal during
a read. Independent own-seat `setAutopilot` remains available to every seat.
Easy, Medium, Hard and Brutal use only their own projected legal Use/Pass
choices: minimal continuation, not calibrated Nexus strategy.
Verification is **Partial**, with development stage **Verified** for this bounded
profile. The frozen parent check passed **6,315/6,315** offline tests, types and
lint; the affected nine-file union passed **167/167**. Actual native scripts
exercised eight allow/prevent/partial-pass cases and both original BG
Worthless-Karama interruptions. Genuine Harkonnen exchange and public
administrator acknowledgement ownership retain the original continuation.
Four exact-version, backed-up CLI rooms exercised 390px human Use, wrong-face
Pass, two-receiver partial refresh, original Ixian return and Technology
exchange. Read-only saved-state proof retained all forty-seven Treachery and
twelve Nexus cards, original wallets/forces and fourteen earlier QA games.
Earlier checkpoints and image publication do not deploy this path or certify
complete Ixian/Nexus play; wider and deployed acceptance remain open.
Both alternatives concern **one current attempt**, not a whole-phase ban or
retroactive undo of completed inspection, selection, exchange or purchase.
Advanced Technology on Richese cache/Black Market lots remains outside the
profile and unresolved; its special-lot decline continuation elsewhere is not
an exchange ruling. General multiple-lot duration, competing effect priority,
other modules/rosters, complete Ixian/Nexus games, strategy calibration,
public release and live deployment remain separate open gates.
+1
View File
@@ -110,6 +110,7 @@ independent decisions are not reconciled by this cutover.
| 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) | | 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) |
| Ixian Nexus Betrayal: Bidding or Technology | Fresh native-Ixian/classic-or-Tleilaxu Basic/Advanced local `ixian-betrayal` uses Ix47 Treachery and Nexus alone, with no initial Nexus deal or old-save retrofit. Resolve actual native Karama/BG counters first; a native denial opens no Nexus gate or cost. After all native passes, uniform publicly possible unallied non-Ixian held-Nexus seats acknowledge before the native effect; concealed face governs only private Use. These are bounded privacy/ordering inferences, not publisher universal priority. One physical Ixian Nexus denies one current Bidding attempt (normal count, no Ixian inspection/return) or one Advanced Technology declaration (both card locations preserved, attempt remains used); all-pass allows the original effect and suffix once. No whole-phase ban, completed undo, invented direct-Nexus Karama counter or blanket immunity. Richese special-lot exchange, wider priority/modules, complete games, strategy and public/live release remain open; verification is Planned pending frozen direct evidence. | [Photographed face and native source protocol](NEXUS_CARD_RULES.md#bounded-ixian-betrayal-source-protocol--1-october-2026), [fresh local runtime and acceptance](NEXUS_CARD_RUNTIME.md#bounded-ixian-betrayal-local-runtime--1-october-2026), [five-facet internal topic](../game/reference.ts) |
| 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) |
+17
View File
@@ -0,0 +1,17 @@
import type { Action, GameView } from './engine';
/** All profiles consume only their own legal projection. No source card,
* rival hand or Nexus face is needed to acknowledge the native attempt. */
export function botNexusIxianBetrayalActions(game: GameView): Action[] {
const reaction = game.nexusIxianBetrayalReaction;
if (game.status !== 'playing' || game.phase !== 3 || !reaction?.event ||
!reaction.canPass || reaction.hasPassed || game.roomControl?.paused || game.roomControl?.closed ||
game.automaticContinuationPending || game.truthtrance || game.response || game.decision ||
game.phaseOpening || game.nexusCards?.waiting.length || game.nexusTraitors?.pending) return [];
const own = game.players.find((player) => player.id === game.me);
if (!(own?.bot ?? own?.autopilot)) return [];
return [{
type: reaction.canUse && !reaction.blocked ? 'nexusIxianBetrayalUse' : 'nexusIxianBetrayalPass',
event: reaction.event,
}];
}
+8
View File
@@ -105,6 +105,7 @@ import { botSemutaActions } from './bot-semuta';
import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kull'; import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kull';
import { botNexusRicheseBetrayalActions } from './bot-nexus-richese-betrayal'; import { botNexusRicheseBetrayalActions } from './bot-nexus-richese-betrayal';
import { botNexusGuildBetrayalActions } from './bot-nexus-guild-betrayal'; import { botNexusGuildBetrayalActions } from './bot-nexus-guild-betrayal';
import { botNexusIxianBetrayalActions } from './bot-nexus-ixian-betrayal';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter'; import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors'; import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat'; import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
@@ -3831,6 +3832,13 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */ /** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] { export function botActions(g: GameView): Action[] {
if (g.nexusIxianBetrayalReaction) {
if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
g.automaticContinuationPending) return [];
// Original native counters and existing private decisions retain priority.
if (g.response || g.truthtrance || g.decision) return policyActions(g);
return botNexusIxianBetrayalActions(g);
}
if (g.guildBetrayalReaction) return botNexusGuildBetrayalActions(g); if (g.guildBetrayalReaction) return botNexusGuildBetrayalActions(g);
if (g.richeseBetrayalReaction) return botNexusRicheseBetrayalActions(g); if (g.richeseBetrayalReaction) return botNexusRicheseBetrayalActions(g);
if (g.kullReaction) return botChoamKullActions(g); if (g.kullReaction) return botChoamKullActions(g);
+232 -50
View File
@@ -48,6 +48,7 @@ import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGui
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 { createIxianReplacementSource, validateIxianReplacementSource, closeIxianReplacementSource, validateIxianReplacementHistory, initialIxianReplacementCursor, ixianReplacementEvent, type IxianReplacementSource, type IxianReplacementReceipt, type IxianReplacementCursor } from './nexus-ixian-replacement';
import { createIxianBetrayalSource, validateIxianBetrayalFrame, initialIxianBetrayalCursor, closeIxianBetrayalSource, validateIxianBetrayalHistory, ixianBetrayalResponders, ixianBetrayalEligible, type IxianBetrayalSource, type IxianBetrayalCursor, type IxianBetrayalReceipt } from './nexus-ixian-betrayal';
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';
@@ -1128,6 +1129,16 @@ export type Game = {
pendingNexusIxianReplacement?: IxianReplacementSource | null; pendingNexusIxianReplacement?: IxianReplacementSource | null;
nexusIxianReplacementHistory?: IxianReplacementReceipt[]; nexusIxianReplacementHistory?: IxianReplacementReceipt[];
nexusIxianReplacementCursor?: IxianReplacementCursor; nexusIxianReplacementCursor?: IxianReplacementCursor;
/** Fresh native Ixian attempt prevention; never inferred from held cards. */
nexusIxianBetrayalPreview?: true;
pendingNexusIxianBetrayal?: {
source: IxianBetrayalSource;
continuation: ResponseWindow;
required: string[];
passed: string[];
} | null;
nexusIxianBetrayalHistory?: IxianBetrayalReceipt[];
nexusIxianBetrayalCursor?: IxianBetrayalCursor;
kullSequence?: number; kullSequence?: number;
kullRestrictions?: KullPhaseRestriction[]; kullRestrictions?: KullPhaseRestriction[];
pendingKull?: { pendingKull?: {
@@ -8114,6 +8125,159 @@ 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);
} }
/** Offline-only, fresh native Ixian/Ix47 + Nexus profile. */
export function initializeIxianNexusBetrayalGameForAudit(state: Game): Game {
requireRule(ixianNexusBetrayalModeSupported(state) &&
state.nexusIxianBetrayalPreview === undefined && state.pendingNexusIxianBetrayal === undefined &&
state.nexusIxianBetrayalHistory === undefined && state.nexusIxianBetrayalCursor === undefined &&
state.nexusCards?.cards === null && state.nexusCards.phase === null,
'Ixian Nexus Betrayal needs a fresh native Ixian lobby with only Ix Treachery and Nexus Cards.');
requireFreshBaseRuntime(state);
const g = initializeSetupGameForAudit(state, false, true, false, false, false, false, true);
g.nexusIxianBetrayalPreview = true;
g.pendingNexusIxianBetrayal = null;
g.nexusIxianBetrayalHistory = [];
g.nexusIxianBetrayalCursor = initialIxianBetrayalCursor();
// This profile does not opt into the optional Sandtrout Spice card.
g.spiceDeck = shuffle(spiceDeck());
return g;
}
function ixianNexusBetrayalModeSupported(g: Game): boolean {
return typeof g.advanced === 'boolean' && !!g.nexusCards &&
g.expansions.length === 1 && g.expansions[0] === 'ix' &&
g.players.length >= 2 && g.players.length <= 6 &&
new Set(g.players.map(p => p.faction)).size === g.players.length &&
!!byFaction(g, 'ixians') &&
g.players.every(p => CLASSIC_FACTIONS[p.faction] || p.faction === 'ixians' || p.faction === 'tleilaxu') &&
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
!g.discoveryStash && !g.greatMaker && !g.techTokens && !g.strongholdCards &&
!g.ecazTreachery && !g.semutaPreview && !g.kullPreview && !g.nexusKullPreview &&
!g.richeseBetrayalPreview && !g.guildBetrayalPreview && !g.nexusIxianReplacementPreview &&
!g.moritaniAssassinatePreview;
}
function ixianNexusBetrayalContext(g: Game) {
return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,
sequence:g.nexusIxianBetrayalCursor!.sequence,players:g.players,
cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g)};
}
function ixianNexusBetrayalAuthority(g: Game, response: ResponseWindow) {
requireRule(response.kind === 'ixAuction' || response.kind === 'ixTechnology',
'The saved Nexus continuation is not an original native Ixian window.');
requireRule(Object.keys(response).sort().join(',') === 'kind,owner,passed',
'The native Ixian continuation changed its original response shape.');
if (response.kind === 'ixAuction') currentIxAuctionDrawQuote(g,response,false);
else {
auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g,response));
requireRule(getPlayer(g,response.owner).hand.filter(c => c.id === g.pendingIxTechnology!.card).length === 1,
'The original Ixian Technology declaration lost its selected hand card.');
}
return {kind:response.kind === 'ixAuction' ? 'bidding' as const : 'technology' as const,
provider:response.owner,nativeWindow:response.kind,
nativeContext:JSON.stringify({ixAuction:g.ixAuction,pendingIxTechnology:g.pendingIxTechnology,
ixTechnologyTurn:g.ixTechnologyTurn,auction:g.auction}),
nativeRequired:g.players.filter(p => responseCancelCards(g,p,response).length > 0).map(p => p.id),
nativePassed:response.passed};
}
function ixianNexusBetrayalParent(g: Game, response: ResponseWindow): string {
return JSON.stringify({continuation:response,status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,
active:g.active,order:g.order,auction:g.auction,sale:g.currentAuctionSale,ixAuction:g.ixAuction,
ixKnown:g.ixAuctionKnown,technology:g.pendingIxTechnology,technologyTurn:g.ixTechnologyTurn,
players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand,
forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,advisors:p.advisors})),
deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards,decision:g.decision,response:g.response,
phaseOpening:g.phaseOpening,truthtrance:g.truthtrance,pendingKarama:g.pendingKarama,
pendingNullentropy:g.pendingNullentropy,pendingTreacheryDiscard:g.pendingTreacheryDiscard});
}
function ixianNexusBetrayalIntegrity(g: Game): void {
const frame = g.pendingNexusIxianBetrayal;
if (!g.nexusIxianBetrayalPreview) {
requireRule(g.nexusIxianBetrayalPreview === undefined && !frame &&
g.nexusIxianBetrayalHistory === undefined && g.nexusIxianBetrayalCursor === undefined,
'Ixian Nexus Betrayal lost its explicit fresh profile.');
return;
}
requireRule(ixianNexusBetrayalModeSupported(g) && g.nexusIxianBetrayalCursor &&
Array.isArray(g.nexusIxianBetrayalHistory), 'The native Ixian Nexus profile lost its history or cursor.');
nexusCardsIntegrity(g);
nexusRule(() => validateIxianBetrayalHistory(g.nexusIxianBetrayalHistory!,
g.nexusIxianBetrayalCursor!,physicalTreacheryCards(g)));
if (!frame) {
if (g.ixAuction?.cards.length === 0 || g.pendingIxTechnology) {
const continuation = g.pendingTreacheryDiscard?.continuation;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null];
const provider = byFaction(g, 'ixians')!.id;
const ownsWindow = (kind: 'ixAuction' | 'ixTechnology') => contexts.some(context => {
const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
const original = pending?.use.kind === 'cancel' ? pending.use.response : null;
return (context?.response?.kind === kind && context.response.owner === provider) ||
(original?.kind === kind && original.owner === provider);
});
requireRule((!g.ixAuction || g.ixAuction.cards.length > 0 || ownsWindow('ixAuction')) &&
(!g.pendingIxTechnology || ownsWindow('ixTechnology')),
'The native Ixian attempt lost its original counter or Nexus acknowledgement.');
}
return;
}
requireRule(Object.keys(frame).sort().join(',') === 'continuation,passed,required,source' &&
Array.isArray(frame.required) && Array.isArray(frame.passed) && frame.required.length > 0 &&
frame.required.some(id => !frame.passed.includes(id)) &&
!g.response && !g.decision && !g.phaseOpening && !g.truthtrance &&
!g.pendingKarama && !g.pendingNullentropy && !g.pendingTreacheryDiscard,
'The Ixian Nexus acknowledgement lost its original native priority.');
nexusRule(() => validateIxianBetrayalFrame(ixianNexusBetrayalContext(g),frame.source,
ixianNexusBetrayalAuthority(g,frame.continuation),ixianNexusBetrayalParent(g,frame.continuation),
frame.required,frame.passed));
}
function offerIxianNexusBetrayal(g: Game,response: ResponseWindow): boolean {
if (!g.nexusIxianBetrayalPreview) return false;
if (response.kind === 'ixTechnology') {
auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, response));
// Native interruptions may legally move the declared card before counters
// finish. That spent attempt fizzles without exposing a Nexus offer.
if (!getPlayer(g, response.owner).hand.some(c => c.id === g.pendingIxTechnology!.card)) return false;
}
const required = nexusRule(() => ixianBetrayalResponders(g.nexusCards!.cards!,g.players));
if (!required.length) return false;
const source = nexusRule(() => createIxianBetrayalSource(ixianNexusBetrayalContext(g),
ixianNexusBetrayalAuthority(g,response),ixianNexusBetrayalParent(g,response)));
g.pendingNexusIxianBetrayal = {source,continuation:structuredClone(response),required,passed:[]};
return true;
}
function ixianNexusBetrayalView(g: Game,id: string) {
const frame = g.pendingNexusIxianBetrayal;
if (!frame) return null;
const hasPassed = frame.passed.includes(id), canPass = frame.required.includes(id) && !hasPassed;
const eligible = canPass && nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id));
return {event:frame.source.event,kind:frame.source.kind,provider:frame.source.provider,
canPass,hasPassed,canUse:eligible,blocked:null as string | null};
}
function decideIxianNexusBetrayal(g: Game,id: string,action: Action): void {
ixianNexusBetrayalIntegrity(g);
const frame = g.pendingNexusIxianBetrayal!;
requireRule(['nexusIxianBetrayalPass','nexusIxianBetrayalUse'].includes(action?.type) &&
action.event === frame.source.event && Object.keys(action).length === 2 &&
Object.keys(action).every(key => key === 'type' || key === 'event') &&
frame.required.includes(id) && !frame.passed.includes(id),
'A current required seat must acknowledge only the original Ixian event.');
const use = action.type === 'nexusIxianBetrayalUse';
if (use) requireRule(nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id)),
'You do not own an eligible unallied Ixian Nexus card.');
if (!use) {
frame.passed.push(id);
if (frame.required.some(seat => !frame.passed.includes(seat))) return;
}
// Validation above precedes the one physical Nexus cost and native RNG.
const closed = nexusRule(() => closeIxianBetrayalSource(frame.source,use ? 'use' : 'pass',
use ? id : null,g.nexusIxianBetrayalCursor!));
if (use) g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,id,g.players));
g.nexusIxianBetrayalHistory!.push(closed.receipt);
g.nexusIxianBetrayalCursor = closed.cursor;
g.pendingNexusIxianBetrayal = null;
if (use) log(g,`${getPlayer(g,id).name} spent Ixian Nexus Betrayal to prevent the original native ${frame.source.kind} attempt.`);
finishIxianNativeAttempt(g,frame.continuation,use);
}
/** Fresh local classic profile only; no public start or existing-game retrofit. */ /** Fresh local classic profile only; no public start or existing-game retrofit. */
export function initializeIxianNexusReplacementGameForAudit(state: Game): Game { export function initializeIxianNexusReplacementGameForAudit(state: Game): Game {
requireRule(!state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement && requireRule(!state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement &&
@@ -11504,6 +11668,50 @@ function currentIxAuctionDrawQuote(
throw error; throw error;
} }
} }
/** The original native suffix, shared by native Karama and Nexus closure.
* It consumes the accepted attempt directly; no declaration is replayed. */
function finishIxianNativeAttempt(g: Game, response: ResponseWindow, canceled: boolean): void {
if (response.kind === 'ixAuction') {
const request = currentIxAuctionDrawQuote(g, response, canceled)!;
const pending = g.ixAuction!;
const cards: Card[] = [];
for (let i = 0; i < request.drawCount; i++) {
const card = draw(g);
if (card) cards.push(card);
}
if (!canceled && cards.length > 1) {
pending.cards = cards;
g.decision = { kind: 'ixAuction', player: response.owner };
} else {
g.ixAuction = null;
setAuction(g, cards);
}
return;
}
requireRule(response.kind === 'ixTechnology', 'Only an original Ixian native attempt may resume.');
const cancellation = auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, response))!;
if (canceled) {
g.pendingIxTechnology = cancellation.pendingIxTechnology;
g.auction!.peekKnown = cancellation.peek.peekKnown;
g.response = cancellation.peek.response;
log(g, 'Ixian auction substitution did not occur.');
return;
}
const pending = g.pendingIxTechnology!;
const ixians = getPlayer(g, response.owner);
const replacement = ixians.hand.find(c => c.id === pending.card);
if (replacement) {
const a = g.auction!, taken = a.cards[a.index];
ixians.hand = ixians.hand.filter(c => c.id !== replacement.id);
ixians.hand.push(taken);
a.cards[a.index] = replacement;
if (g.ixAuctionKnown?.turn === g.turn)
g.ixAuctionKnown.cards = [...g.ixAuctionKnown.cards.filter(c => c.id !== taken.id && c.id !== replacement.id), replacement];
log(g, `${ixians.name} exchanged a hand card for the upcoming auction card.`);
}
g.pendingIxTechnology = null;
offerAuctionPeek(g);
}
function ixSubstitutionCancellationQuote( function ixSubstitutionCancellationQuote(
g: Game, g: Game,
response: ResponseWindow, response: ResponseWindow,
@@ -21588,9 +21796,6 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
const auctionCancellation = canceled const auctionCancellation = canceled
? validateAuctionContinuationCancellation(g, response) ? validateAuctionContinuationCancellation(g, response)
: null; : null;
const technologyCancellation = canceled
? auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, response))
: null;
const richeseCancellation = canceled const richeseCancellation = canceled
? currentRicheseCancellationQuote(g, response) ? currentRicheseCancellationQuote(g, response)
: null; : null;
@@ -22044,52 +22249,9 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
}; };
else settleRicheseLot(g); else settleRicheseLot(g);
} }
} else if (response.kind === 'ixAuction') { } else if (response.kind === 'ixAuction' || response.kind === 'ixTechnology') {
const request = currentIxAuctionDrawQuote(g, response, canceled)!; if (!canceled && offerIxianNexusBetrayal(g, response)) return;
const pending = g.ixAuction!; finishIxianNativeAttempt(g, response, canceled);
const cards: Card[] = [];
for (let i = 0; i < request.drawCount; i++) {
const card = draw(g);
if (card) cards.push(card);
}
if (!canceled && cards.length > 1) {
pending.cards = cards;
g.decision = { kind: 'ixAuction', player: response.owner };
} else {
g.ixAuction = null;
setAuction(g, cards);
}
} else if (response.kind === 'ixTechnology') {
if (technologyCancellation) {
g.pendingIxTechnology = technologyCancellation.pendingIxTechnology;
g.auction!.peekKnown = technologyCancellation.peek.peekKnown;
g.response = technologyCancellation.peek.response;
log(g, 'Ixian auction substitution did not occur.');
return;
}
const pending = g.pendingIxTechnology!;
const ixians = getPlayer(g, response.owner);
const replacement = ixians.hand.find((c) => c.id === pending.card);
if (!canceled && replacement) {
const a = g.auction!,
taken = a.cards[a.index];
ixians.hand = ixians.hand.filter((c) => c.id !== replacement.id);
ixians.hand.push(taken);
a.cards[a.index] = replacement;
if (g.ixAuctionKnown?.turn === g.turn)
g.ixAuctionKnown.cards = [
...g.ixAuctionKnown.cards.filter(
(c) => c.id !== taken.id && c.id !== replacement.id,
),
replacement,
];
log(
g,
`${ixians.name} exchanged a hand card for the upcoming auction card.`,
);
} else log(g, 'Ixian auction substitution did not occur.');
g.pendingIxTechnology = null;
offerAuctionPeek(g);
} else if (response.kind === 'ixAllyCard') { } else if (response.kind === 'ixAllyCard') {
const pending = g.pendingIxAlly!; const pending = g.pendingIxAlly!;
const buyer = getPlayer(g, pending.player); const buyer = getPlayer(g, pending.player);
@@ -25302,6 +25464,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 {
ixianNexusBetrayalIntegrity(state);
ixianNexusReplacementIntegrity(state); ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state); guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state); state = migratePrintedKullCounter(state);
@@ -25389,6 +25552,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);
ixianNexusBetrayalIntegrity(g);
if (g.pendingNexusIxianBetrayal) return g;
ixianNexusReplacementIntegrity(g); ixianNexusReplacementIntegrity(g);
if (g.pendingNexusIxianReplacement) return g; if (g.pendingNexusIxianReplacement) return g;
if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;} if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;}
@@ -25524,6 +25689,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return g; return g;
} }
function finishActionContinuations(g: Game) { function finishActionContinuations(g: Game) {
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingGuildBetrayal) return; if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return; if (g.pendingRicheseBetrayal) return;
if (g.pendingKull) return; if (g.pendingKull) return;
@@ -25588,6 +25754,7 @@ function finishActionContinuations(g: Game) {
finishMoritaniPlacement(g); finishMoritaniPlacement(g);
} }
function settleAutomaticContinuations(g: Game) { function settleAutomaticContinuations(g: Game) {
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingNexusIxianReplacement) return; if (g.pendingNexusIxianReplacement) return;
if (g.pendingGuildBetrayal) return; if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return; if (g.pendingRicheseBetrayal) return;
@@ -25595,6 +25762,7 @@ function settleAutomaticContinuations(g: Game) {
if (pendingNexusTraitors(g)) return; if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return; if (g.pendingNullentropy) return;
for (let iteration = 0; iteration < 128; iteration++) { for (let iteration = 0; iteration < 128; iteration++) {
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingGuildBetrayal) return; if (g.pendingGuildBetrayal) return;
if (g.pendingNexusIxianReplacement) return; if (g.pendingNexusIxianReplacement) return;
if (g.pendingRicheseBetrayal) return; if (g.pendingRicheseBetrayal) return;
@@ -25619,6 +25787,7 @@ function settleAutomaticContinuations(g: Game) {
finishResponse(g, false); finishResponse(g, false);
} else if (!finishAutomaticDecision(g)) return; } else if (!finishAutomaticDecision(g)) return;
finishActionContinuations(g); finishActionContinuations(g);
if (g.pendingNexusIxianBetrayal) return;
if (g.pendingGuildBetrayal) return; if (g.pendingGuildBetrayal) return;
if (g.pendingRicheseBetrayal) return; if (g.pendingRicheseBetrayal) return;
// Opposing/automatic consequences can release a now-impossible promise; // Opposing/automatic consequences can release a now-impossible promise;
@@ -25636,6 +25805,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 {
ixianNexusBetrayalIntegrity(state);
ixianNexusReplacementIntegrity(state); ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state); guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state); state = migratePrintedKullCounter(state);
@@ -25673,6 +25843,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.pendingNexusIxianBetrayal) return g;
if (g.pendingNexusIxianReplacement) return g; 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;
@@ -25726,6 +25897,12 @@ function applyActionInner(
): Game { ): Game {
ecazTreacheryIntegrity(state); ecazTreacheryIntegrity(state);
const g = structuredClone(state); const g = structuredClone(state);
if (g.pendingNexusIxianBetrayal) {
getPlayer(g,id);
if (action?.type === 'advanceBots') return g;
decideIxianNexusBetrayal(g,id,action);
return g;
}
if (g.pendingNexusIxianReplacement) { if (g.pendingNexusIxianReplacement) {
getPlayer(g,id); getPlayer(g,id);
if (action?.type === 'advanceBots') return g; if (action?.type === 'advanceBots') return g;
@@ -29579,6 +29756,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) {
ixianNexusBetrayalIntegrity(state);
ixianNexusReplacementIntegrity(state); ixianNexusReplacementIntegrity(state);
guildBetrayalIntegrity(state); guildBetrayalIntegrity(state);
state = migratePrintedKullCounter(state); state = migratePrintedKullCounter(state);
@@ -29953,6 +30131,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),
nexusIxianBetrayalPreview: !!g.nexusIxianBetrayalPreview,
nexusIxianBetrayalReaction: ixianNexusBetrayalView(g,id),
nexusIxianReplacementPreview: !!g.nexusIxianReplacementPreview, nexusIxianReplacementPreview: !!g.nexusIxianReplacementPreview,
nexusIxianReplacement: ixianNexusReplacementView(g,id), nexusIxianReplacement: ixianNexusReplacementView(g,id),
nexusChoamTrade: currentNexusChoamTrade(g, id), nexusChoamTrade: currentNexusChoamTrade(g, id),
@@ -30022,7 +30202,9 @@ export function viewGame(state: Game, id: string) {
storm: g.status === 'lobby' ? STORM_START_SECTOR : g.storm, storm: g.status === 'lobby' ? STORM_START_SECTOR : g.storm,
playerPositions: normalizedPlayerPositions(g), playerPositions: normalizedPlayerPositions(g),
order: g.order, order: g.order,
active: g.active, active: g.pendingNexusIxianBetrayal
? (() => {const remaining = g.pendingNexusIxianBetrayal.required.filter(seat => !g.pendingNexusIxianBetrayal!.passed.includes(seat)); return remaining.length === 1 ? remaining[0] : null;})()
: g.active,
movementRemaining: g.movementRemaining ?? null, movementRemaining: g.movementRemaining ?? null,
guildTimingLocked: g.guildTimingLocked ?? false, guildTimingLocked: g.guildTimingLocked ?? false,
ready: g.ready, ready: g.ready,
+288
View File
@@ -0,0 +1,288 @@
import { treacheryDeck, type Card } from './cards';
import {
nexusCardMode, validateNexusCards, validateNexusPlayers,
type NexusPlayer, type NexusState,
} from './nexus-cards';
export type IxianBetrayalKind = 'bidding' | 'technology';
/** Independently produced by the native adapter after its original counters.
* Native quotes, not the saved source, authorize draw/exchange continuations. */
export type IxianBetrayalAuthority = {
kind: IxianBetrayalKind;
provider: string;
nativeWindow: 'ixAuction' | 'ixTechnology';
nativeContext: string;
nativeRequired: readonly string[];
nativePassed: readonly string[];
};
export type IxianBetrayalContext = {
status: string;
phase: number;
turn: number;
advanced: boolean;
sequence: number;
players: readonly NexusPlayer[];
cards: NexusState;
/** Every live physical Treachery location once; no auction receipt aliases. */
physicalCards: readonly Card[];
};
export type IxianBetrayalSource = IxianBetrayalAuthority & {
version: 1;
event: string;
turn: number;
phase: 3;
advanced: boolean;
sequence: number;
required: readonly string[];
eligible: string | null;
parent: string;
signature: string;
};
export type IxianBetrayalCursor = { sequence: number; signature: string };
export type IxianBetrayalReceipt = {
source: IxianBetrayalSource;
outcome: 'pass' | 'use';
holder: string | null;
previous: string;
signature: string;
};
export class IxianBetrayalError extends Error {
constructor(message: string) {
super(message);
this.name = 'IxianBetrayalError';
}
}
const canonical = new Map(treacheryDeck(['ix']).map(card => [card.id, card]));
const genesis = 'ixian-betrayal:0';
const permitted: Readonly<Record<string, true>> = {
atreides: true, harkonnen: true, emperor: true, fremen: true, guild: true,
beneGesserit: true, ixians: true, tleilaxu: true,
};
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 &&
!['__proto__', 'constructor', 'prototype'].includes(value);
const whole = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const array = (value: unknown): boolean => Array.isArray(value);
function requireBetrayal(value: unknown, message: string): asserts value {
if (!value) throw new IxianBetrayalError(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 members(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.every(text) && new Set(value).size === value.length;
}
function sameMembers(one: readonly string[], two: readonly string[]): boolean {
return one.length === two.length && one.every((id, index) => id === two[index]);
}
function canonicalCard(value: unknown): value is Card {
if (!plain(value) || !text(value.id)) return false;
const expected = canonical.get(value.id);
return !!expected && exact(value, ['id', 'name', 'kind',
...(expected.effect === undefined ? [] : ['effect'])]) &&
value.name === expected.name && value.kind === expected.kind && value.effect === expected.effect;
}
function validatePhysical(cards: readonly Card[]): void {
requireBetrayal(Array.isArray(cards) && cards.length === canonical.size &&
cards.every(canonicalCard) && new Set(cards.map(card => card.id)).size === canonical.size,
'Ixian Betrayal requires the complete canonical Ixian deck in unique physical custody.');
}
function validateRoster(players: readonly NexusPlayer[]): void {
validateNexusPlayers(players);
requireBetrayal(players.every(player => Object.hasOwn(permitted, player.faction)) &&
players.filter(player => player.faction === 'ixians').length === 1,
'Ixian Betrayal needs its native Ixian and permitted classic or Tleilaxu seats.');
}
/** A neutral acknowledgment depends on public held-card presence, never its face. */
export function ixianBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[]): string[] {
validateRoster(players);
validateNexusCards(cards, players);
return players.filter(player => !player.ally && player.faction !== 'ixians' &&
cards.hands[player.id] !== null).map(player => player.id);
}
export function ixianBetrayalEligible(
cards: NexusState, players: readonly NexusPlayer[], holder: string,
): boolean {
validateRoster(players);
validateNexusCards(cards, players);
const seat = players.find(player => player.id === holder);
return !!seat && !seat.ally && seat.faction !== 'ixians' && cards.hands[holder] === 'ixians' &&
nexusCardMode('ixians', seat.faction, players.map(player => player.faction)) === 'betrayal';
}
/** Only public original native attempt facts appear in the event. */
export function ixianBetrayalEvent(
turn: number, sequence: number, kind: IxianBetrayalKind, provider: string,
): string {
requireBetrayal(whole(turn) && turn > 0 && whole(sequence) && text(provider) &&
(kind === 'bidding' || kind === 'technology'), 'Ixian Betrayal needs its original public event.');
return JSON.stringify(['nexusIxianBetrayal', turn, sequence, kind, provider]);
}
function validateAuthority(authority: IxianBetrayalAuthority): void {
requireBetrayal(exact(authority, ['kind', 'provider', 'nativeWindow', 'nativeContext',
'nativeRequired', 'nativePassed']) && text(authority.provider) && text(authority.nativeContext) &&
members(authority.nativeRequired) && members(authority.nativePassed) &&
authority.nativeRequired.every(id => authority.nativePassed.includes(id)) &&
((authority.kind === 'bidding' && authority.nativeWindow === 'ixAuction') ||
(authority.kind === 'technology' && authority.nativeWindow === 'ixTechnology')),
'Ixian Betrayal must follow the settled original native counter window.');
}
function sourceAuthority(source: IxianBetrayalSource): IxianBetrayalAuthority {
return { kind: source.kind, provider: source.provider, nativeWindow: source.nativeWindow,
nativeContext: source.nativeContext, nativeRequired: source.nativeRequired, nativePassed: source.nativePassed };
}
function sourceSignature(source: IxianBetrayalSource): string {
return JSON.stringify(['ixianBetrayalSource', source.version, source.event, source.turn,
source.phase, source.advanced, source.sequence, source.kind, source.provider,
source.nativeWindow, source.nativeContext, source.nativeRequired, source.nativePassed,
source.required, source.eligible, source.parent]);
}
function savedSource(value: unknown): value is IxianBetrayalSource {
return exact(value, ['version', 'event', 'turn', 'phase', 'advanced', 'sequence',
'kind', 'provider', 'nativeWindow', 'nativeContext', 'nativeRequired', 'nativePassed',
'required', 'eligible', 'parent', 'signature']) && value.version === 1 && whole(value.turn) &&
value.turn > 0 && value.phase === 3 && whole(value.sequence) && typeof value.advanced === 'boolean' &&
text(value.event) && text(value.parent) && text(value.signature) && members(value.required) &&
(value.eligible === null || text(value.eligible)) && text(value.provider) && text(value.nativeContext) &&
members(value.nativeRequired) && members(value.nativePassed) &&
(value.kind === 'bidding' || value.kind === 'technology') &&
(value.nativeWindow === 'ixAuction' || value.nativeWindow === 'ixTechnology');
}
function validateSavedSource(value: unknown): asserts value is IxianBetrayalSource {
requireBetrayal(savedSource(value), 'Ixian Betrayal lost its original canonical source.');
const source = value;
validateAuthority(sourceAuthority(source));
requireBetrayal((source.kind !== 'technology' || source.advanced) &&
!source.required.includes(source.provider) &&
(source.eligible === null || source.required.includes(source.eligible)) &&
source.event === ixianBetrayalEvent(source.turn, source.sequence, source.kind, source.provider) &&
source.signature === sourceSignature(source),
'Ixian Betrayal changed its original event or native source.');
}
function validateContext(ctx: IxianBetrayalContext): void {
requireBetrayal(plain(ctx) && ctx.status === 'playing' && ctx.phase === 3 && whole(ctx.turn) &&
ctx.turn > 0 && whole(ctx.sequence) && typeof ctx.advanced === 'boolean',
'Ixian Betrayal needs its current Bidding phase and independent cursor.');
validateRoster(ctx.players);
validateNexusCards(ctx.cards, ctx.players);
validatePhysical(ctx.physicalCards);
}
export function validateIxianBetrayalSource(
ctx: IxianBetrayalContext, source: IxianBetrayalSource,
authority: IxianBetrayalAuthority, parent: string,
): void {
validateSavedSource(source);
validateContext(ctx);
validateAuthority(authority);
requireBetrayal(source.turn === ctx.turn && source.sequence === ctx.sequence &&
source.advanced === ctx.advanced && source.parent === parent && text(parent) &&
ctx.players.find(player => player.id === source.provider)?.faction === 'ixians' &&
source.nativeRequired.every(id => ctx.players.some(player => player.id === id)) &&
source.nativePassed.every(id => ctx.players.some(player => player.id === id)) &&
source.kind === authority.kind && source.provider === authority.provider &&
source.nativeWindow === authority.nativeWindow && source.nativeContext === authority.nativeContext &&
sameMembers(source.nativeRequired, authority.nativeRequired) &&
sameMembers(source.nativePassed, authority.nativePassed) &&
sameMembers(source.required, ixianBetrayalResponders(ctx.cards, ctx.players)) &&
source.eligible === (ctx.players.find(player => !player.ally && player.faction !== 'ixians' &&
ctx.cards.hands[player.id] === 'ixians')?.id ?? null),
'Ixian Betrayal no longer matches its current native attempt, parent or public responders.');
}
export function createIxianBetrayalSource(
ctx: IxianBetrayalContext, authority: IxianBetrayalAuthority, parent: string,
): IxianBetrayalSource {
validateContext(ctx);
validateAuthority(authority);
const source: IxianBetrayalSource = {
...authority, nativeRequired: [...authority.nativeRequired], nativePassed: [...authority.nativePassed],
version: 1, event: ixianBetrayalEvent(ctx.turn, ctx.sequence, authority.kind, authority.provider),
turn: ctx.turn, phase: 3, advanced: ctx.advanced, sequence: ctx.sequence,
required: ixianBetrayalResponders(ctx.cards, ctx.players),
eligible: ctx.players.find(player => !player.ally && player.faction !== 'ixians' &&
ctx.cards.hands[player.id] === 'ixians')?.id ?? null,
parent, signature: '',
};
source.signature = sourceSignature(source);
validateIxianBetrayalSource(ctx, source, authority, parent);
return source;
}
/** Validate before any Nexus cost or native continuation, including Pass. */
export function validateIxianBetrayalFrame(
ctx: IxianBetrayalContext, source: IxianBetrayalSource,
authority: IxianBetrayalAuthority, parent: string,
required: readonly string[], passed: readonly string[],
): void {
validateIxianBetrayalSource(ctx, source, authority, parent);
requireBetrayal(members(required) && members(passed) && sameMembers(required, source.required) &&
passed.every(id => required.includes(id)),
'Ixian Betrayal lost its exact public required or passed membership.');
}
export function initialIxianBetrayalCursor(): IxianBetrayalCursor {
return { sequence: 0, signature: genesis };
}
function validateCursor(value: unknown): asserts value is IxianBetrayalCursor {
requireBetrayal(exact(value, ['sequence', 'signature']) && whole(value.sequence) && text(value.signature) &&
(value.sequence !== 0 || value.signature === genesis),
'Ixian Betrayal lost its independent closed-event cursor.');
}
function copySource(source: IxianBetrayalSource): IxianBetrayalSource {
return { ...source, nativeRequired: [...source.nativeRequired], nativePassed: [...source.nativePassed],
required: [...source.required] };
}
function receiptSignature(receipt: IxianBetrayalReceipt): string {
return JSON.stringify(['ixianBetrayalReceipt', receipt.source.signature, receipt.outcome,
receipt.holder, receipt.previous]);
}
/** A bounded previous-event link, not the previous receipt's full signature. */
function receiptHead(receipt: IxianBetrayalReceipt): string {
return JSON.stringify(['ixianBetrayalHead', receipt.source.event, receipt.outcome, receipt.holder]);
}
export function closeIxianBetrayalSource(
source: IxianBetrayalSource, outcome: 'pass' | 'use', holder: string | null,
previous: IxianBetrayalCursor,
): { receipt: IxianBetrayalReceipt; cursor: IxianBetrayalCursor } {
validateSavedSource(source);
validateCursor(previous);
requireBetrayal(source.sequence === previous.sequence && previous.sequence < Number.MAX_SAFE_INTEGER &&
(outcome === 'pass' ? holder === null : outcome === 'use' && text(holder) && holder === source.eligible),
'Ixian Betrayal cannot close twice or lose its actual response outcome.');
const receipt: IxianBetrayalReceipt = {
source: copySource(source), outcome, holder, previous: previous.signature, signature: '',
};
receipt.signature = receiptSignature(receipt);
return { receipt, cursor: { sequence: previous.sequence + 1, signature: receiptHead(receipt) } };
}
/** Historical custody is not current ownership: the physical Nexus or Treachery
* cards may since have been discarded, transferred and recycled. These links
* check consistency, not authentication of arbitrary rewritten saved games. */
export function validateIxianBetrayalHistory(
history: readonly IxianBetrayalReceipt[], cursor: IxianBetrayalCursor,
physical?: readonly Card[],
): void {
validateCursor(cursor);
requireBetrayal(array(history) && history.length === cursor.sequence,
'Ixian Betrayal history has missing or duplicate closed attempts.');
if (physical !== undefined) validatePhysical(physical);
let previous = genesis, turn = 0;
const events = new Set<string>();
for (const [index, receipt] of history.entries()) {
requireBetrayal(exact(receipt, ['source', 'outcome', 'holder', 'previous', 'signature']),
'Ixian Betrayal history has a malformed receipt.');
validateSavedSource(receipt.source);
requireBetrayal(receipt.source.sequence === index && receipt.source.turn >= turn &&
!events.has(receipt.source.event) && receipt.previous === previous &&
(receipt.outcome === 'pass' ? receipt.holder === null : receipt.outcome === 'use' &&
text(receipt.holder) && receipt.holder === receipt.source.eligible) &&
receipt.signature === receiptSignature(receipt),
'Ixian Betrayal history changed its native attempt, outcome or continuation chain.');
events.add(receipt.source.event);
previous = receiptHead(receipt);
turn = receipt.source.turn;
}
requireBetrayal(cursor.signature === previous,
'Ixian Betrayal history no longer matches its independent cursor.');
}
+49 -7
View File
@@ -128,6 +128,7 @@ const phaseDetails = [
'The highlighted player may raise the bid or pass. A new high bid clears previous passes.', 'The highlighted player may raise the bid or pass. A new high bid clears previous passes.',
'Holding Karama permits a bid above your available spice. If outbid, keep the card. A winning bid above your funds requires Karama.', 'Holding Karama permits a bid above your available spice. If outbid, keep the card. A winning bid above your funds requires Karama.',
'After everyone else passes, the winner chooses spice payment or Karama. The table then resolves applicable Emperor-income and Harkonnen-bonus responses.', 'After everyone else passes, the winner chooses spice payment or Karama. The table then resolves applicable Emperor-income and Harkonnen-bonus responses.',
'In the fresh local ixian-betrayal preview, native Ixian Bidding inspection and Advanced Technology first finish their original Karama responses, then pause for neutral Nexus acknowledgements before their effects. A legal rival Use prevents only the current attempt; all required passes allow it. See Ixian Nexus Betrayal for both alternatives, private-card limits and the unchanged public release gate.',
'In Homeworld development tables with Emperor, the last completed auction opens End of Bidding. High Kaitain paid discards and CHOAM closing sales or trades may occur in either order. Finish your opportunity when ready; another card action clears readiness before the phase advances.', 'In Homeworld development tables with Emperor, the last completed auction opens End of Bidding. High Kaitain paid discards and CHOAM closing sales or trades may occur in either order. Finish your opportunity when ready; another card action clears readiness before the phase advances.',
], ],
[ [
@@ -214,6 +215,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'ecaz-ambassadors', 'ecaz-ambassadors',
'richese-cards', 'richese-cards',
'nexus-richese-betrayal', 'nexus-richese-betrayal',
'nexus-ixian-betrayal',
'richese-acquisition', 'richese-acquisition',
'richese-gift', 'richese-gift',
'distrans-transfer', 'distrans-transfer',
@@ -1652,9 +1654,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'A full Ixian hand does not prevent its inspection advantage. The number of auction cards is determined by factions able to bid. The private pool record is unordered and includes cards already auctioned; it is not a view of another player’s hand or the upcoming card.', 'A full Ixian hand does not prevent its inspection advantage. The number of auction cards is determined by factions able to bid. The private pool record is unordered and includes cards already auctioned; it is not a view of another player’s hand or the upcoming card.',
'In advanced play, before bidding on a card and before Atreides gets its peek, Ixians may offer one hand card for the upcoming unseen card. Declining saves the option for a later card. Declaring an exchange spends the once-per-round attempt and opens a separate Karama response; cancellation keeps both cards where they are.', 'In advanced play, before bidding on a card and before Atreides gets its peek, Ixians may offer one hand card for the upcoming unseen card. Declining saves the option for a later card. Declaring an exchange spends the once-per-round attempt and opens a separate Karama response; cancellation keeps both cards where they are.',
'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 separate fresh ixian-betrayal profile connects both Nexus Betrayal alternatives to those original native attempts. After native counters pass, a neutral acknowledgement precedes the draw or declared Advanced exchange. Use prevents just that attempt: ordinary Bidding count without Ixian inspection, or unchanged hand/lot custody with the Technology declaration still used. All-pass keeps the native advantage. This bounded ordering/privacy convention is an inference, not a universal publisher priority.',
'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', 'nexus-ixian-replacement'], related: ['bidding', 'setup', 'ix-forces', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
}, },
{ {
id: 'mobile-stronghold', id: 'mobile-stronghold',
@@ -2119,11 +2122,13 @@ export const RULE_TOPICS: RuleTopic[] = [
] ]
: phase === 4 : phase === 4
? ['ecaz-ambassadors', 'tleilaxu-revival'] ? ['ecaz-ambassadors', 'tleilaxu-revival']
: phase === 1 : phase === 3
? ['card-harvester', 'faction-fremen'] ? ['ix-technology', 'nexus-ixian-betrayal', 'nexus-ixian-replacement', 'richese-cards']
: phase === 0 : phase === 1
? ['storm-cards', 'advanced-storm-spice', 'faction-fremen'] ? ['card-harvester', 'faction-fremen']
: undefined, : phase === 0
? ['storm-cards', 'advanced-storm-spice', 'faction-fremen']
: undefined,
}), }),
), ),
{ {
@@ -4211,6 +4216,42 @@ export const RULE_TOPICS: RuleTopic[] = [
{ 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'] }, { 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-ixian-betrayal',
title: 'Ixian Nexus Betrayal: prevent Bidding or Technology',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Verified',
summary: 'In the fresh local ixian-betrayal preview, an unallied rival may spend the physical Ixian Nexus to prevent one original native Bidding inspection/draw attempt or one Advanced Technology exchange before its effect.',
searchText: 'Ixian Nexus Betrayal Bidding extra card inspection draw Advanced Technology exchange native counter neutral acknowledgement',
steps: [
'This fresh local profile admits two to six unique Basic or Advanced seats with native Ixians and otherwise classic factions or Tleilaxu, the physical forty-seven-card Ix Treachery Deck and Nexus alone. CHOAM, Richese, Ecaz, Moritani, Homeworlds, Leader Skills, Semuta, Discoveries and other optional overlays are excluded. It is not a public start, reset or upgrade to an old save.',
'Nexus has no initial deal or automatic draw. Finish a genuine qualifying closing Nexus with an alliance before an unallied seat may draw. One natural receiver needs at least three actual seats; two simultaneous receivers need four. A two-seat profile can continue ordinary play without fabricating an unreachable Betrayal card.',
'The photographed publisher face grants both alternatives, but one card cannot prevent both. Remain unallied and hold the actual singleton Ixian Nexus while another seat is native Ixians. Use discards that physical Nexus once, without a Treachery Card, Karama, activation spice, hand slot or force cost. Cunning and absent-Ixian Secret Ally remain separate modes.',
'The current original native attempt determines the alternative. Bidding works in Basic and Advanced; Technology requires Advanced and an actual accepted exchange declaration before bidding or Atreides inspection. You cannot choose a different source, provider, draw count, hand card, lot, price or completed attempt.',
'Resolve the original native Karama window first, including legal Advanced Bene Gesserit substitutions. A successful native counter takes the original denied transition once without opening a Nexus gate or spending Nexus. After all native passes, pause before the actual draw, exchange or Atreides peek.',
'Original native response ownership survives legal BG Worthless-Karama conversion and hand-exchange suspension. If a legal pre-offer Harkonnen exchange removes the declared Technology card, the native attempt completes without an exchange or Nexus offer; the declared attempt stays used. An already-open Nexus source still rejects changed card custody or parent state.',
'All publicly possible unallied non-Ixian seats with public held-Nexus presence receive the same Native Ixian advantage acknowledgement, irrespective of secret face. An irrelevant-face seat may Pass; only the canonical privately eligible owner sees Use and their own unavailable reason. Native-counter-first and neutral acknowledgement are explicit bounded privacy/ordering inferences, not publisher-prescribed universal priority or a ruling for other pending stacks.',
'Pass · allow the original advantage spends nothing and retains Nexus. Your saved pass cannot be undone for this event. All required passes allow the original native effect once; no publicly possible responders allow it immediately. Use Ixian Nexus · prevent this Bidding advantage or Technology advantage spends the singleton card and denies just this source.',
'Bidding prevention draws exactly the normal allocated count, without an extra inspection card, Ixian private inspection or return selector. All-pass retains the native extra draw, private top/bottom return and auction continuation. The real native deck allocation still governs depleted stock; Nexus does not manufacture cards or rebuild the pool.',
'Technology prevention keeps the selected Ixian hand card and unseen lot where they were, preserves the declaration’s spent once-per-turn attempt, then resumes the original normal Atreides/auction continuation once. All-pass performs the actual declared exchange and its continuation once. A native decline before declaration still saves the option.',
'This is one current attempt, not a whole-phase ban, removal of all Ixian powers or undo of already completed draws, selections, exchanges, inspections or purchases. A later legally available native attempt may open a new event, including after later physical card recycling. General duration over other multiple-lot situations remains outside this bounded profile.',
'The acknowledgement exposes only its public event, Bidding/Technology kind and native provider. It reveals no selected Ixian hand card, unseen lot, extra inspection faces, deck order, rival Nexus identity, private source or rival eligibility reason. Inspect only your own actually held Nexus through its existing inspector; a neutral prompt grants no new card entitlement.',
'Both actions submit only the offered event. Refresh resumes the same source and passes without another declaration, native counter cost, Nexus spend, draw, swap or suffix. Closed events expire; nonrecursive saved history remains valid when cards later move or recycle. Stale, foreign, malformed or orphaned sources reject instead of silently allowing the native effect.',
'Finish the pending source before ordinary bidding, draws, exchanges or competing card mutation. Existing responses, decisions and Truthtrance retain priority; independent own-seat autopilot changes remain available to every seat. No additional Karama counter to direct Nexus prevention is invented, and no blanket Nexus immunity follows.',
'Easy, Medium, Hard and Brutal use their own projected legal Use/Pass choices for minimal continuation, not calibrated Nexus strategy. Frozen native, private controls, bots, authenticated recovery, exact-version CLI and phone evidence verify this bounded path. Raw administrator progress follows the same public unfinished acknowledgements; full modes and deployment remain separate gates.',
'Advanced Technology on Richese cache/Black Market lots remains unresolved and excluded; its separate saved decline elsewhere is not an exchange ruling. General competing-effect priority, combined modules, complete Ixian/Nexus games, strategic calibration, public release and live deployment remain separate gates. The adopted unofficial Advanced PDF supplies core defaults, not this Nexus face or a new Nexus ruling.',
],
example: 'After native counters pass, a rival spends Ixian Nexus on Bidding: only the ordinary allocated auction cards are drawn and Ixians inspect none. On an Advanced Technology declaration instead, Use keeps both cards in place and the declared attempt used; the normal Atreides/auction suffix follows. Passing permits the corresponding original advantage unchanged.',
related: ['nexus-cards', 'bidding', 'ix-technology', 'ix-deck', 'card-karama', 'advanced-beneGesserit', 'privacy', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Both current-attempt alternatives bind original native source and continuation, separate singleton Nexus cost, native counters first, uniform public membership and nonrecursive pending/closed history. Wider producers, duration and combined modules remain excluded.', evidence: ['game/nexus-ixian-betrayal.ts', 'game/engine.ts', 'game/ix-auction-draw-quote.ts', 'game/ix-technology-cancellation.ts', 'tools/prototype-room.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Neutral native-advantage acknowledgement separates public Pass/wait from private eligible Use and own-held Nexus inspection, with event-only actions, no rival card/source disclosure and pending native controls suspended. Broader presentation acceptance remains open.', evidence: ['components/nexus-ixian-betrayal.tsx', 'components/game-table.tsx', 'game/table-turn.ts'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal policies consume only canonical own-view Use/Pass eligibility and preserve pending priority. Full Nexus strategy and difficulty calibration remain unfinished.', evidence: ['game/bot-nexus-ixian-betrayal.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Photographed face, native source order, both alternatives, current-attempt duration, physical cost, inferred privacy/ordering, fresh natural Nexus rights and explicit Richese/module/public/deployment boundaries are recorded without PDF Nexus authority or full-family claims.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_CARD_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Genuine native Basic/Advanced Bidding and Advanced Technology allow/prevent, printed/BG priority, exact stock/count/attempt/suffix, public face parity, legal bots and authenticated restart/races/rejection pass. Actual backed-up CLI/phone Use/Pass/partial refresh and saved stock prove both endpoints. Native Harkonnen/BG and public admin ownership regressions retain prior continuation; wider modes and deployment remain open.', evidence: ['tests/nexus-ixian-betrayal.test.ts', 'tests/nexus-ixian-betrayal-runtime.test.ts', 'tests/fixture-nexus-ixian-betrayal.ts', 'tests/nexus-ixian-betrayal-controls.test.tsx', 'tests/bot-nexus-ixian-betrayal.test.ts', 'tests/nexus-ixian-betrayal-recovery.test.ts', 'tests/prototype-room.test.ts', 'tests/ix-technology.test.ts', 'tests/admin-directory.test.ts'] },
],
},
{ {
id: 'nexus-cards', id: 'nexus-cards',
title: 'Nexus Cards', title: 'Nexus Cards',
@@ -4236,6 +4277,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.', '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.',
'Ixian Betrayal has a separate fresh native-Ixian ixian-betrayal profile for both original Bidding inspection/draw and Advanced Technology attempts. Native counters resolve first; then every publicly possible unallied rival held-Nexus seat acknowledges uniformly before the effect. A legal Use spends one physical Ixian Nexus to deny only that attempt; all-pass preserves the native effect once. No completed-card undo or whole-phase ban is added. This bounded ordering/privacy is an inference, not universal publisher priority; see its bounded verified native/private/recovery/phone evidence and unchanged Richese/module/public/deployment gates.',
'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.',
@@ -4266,7 +4308,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', 'nexus-ixian-replacement'], related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
checklist: [ 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'] },
+4 -1
View File
@@ -12,7 +12,7 @@ type TurnView = Pick<
| 'phaseOpening' | 'phaseOpening'
| 'automaticContinuationPending' | 'automaticContinuationPending'
> & > &
Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement'>>; Partial<Pick<GameView, 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement' | 'nexusIxianBetrayalReaction'>>;
/** 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,9 @@ 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;
// active is the runtime's public sole unfinished acknowledgement seat,
// never the private card holder or native advantage provider.
if (g.nexusIxianBetrayalReaction) return seated(g.active);
if (g.nexusIxianReplacement) return seated(g.nexusIxianReplacement.buyer); 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) {
+92
View File
@@ -4,6 +4,9 @@ import { DatabaseSync } from 'node:sqlite';
import { adminStore, pauseAdminBatch } from './admin-access-fixture'; import { adminStore, pauseAdminBatch } from './admin-access-fixture';
import { adminLogin, requireAdmin, adminLogoutAll, AdminError } from '../db/admin-access'; import { adminLogin, requireAdmin, adminLogoutAll, AdminError } from '../db/admin-access';
import { projectAdminRoom, readAdminDirectory } from '../db/admin-directory'; import { projectAdminRoom, readAdminDirectory } from '../db/admin-directory';
import { applyAction, viewGame } from '../game/engine';
import { tableActionOwner } from '../game/table-turn';
import { createIxianNexusBetrayalFixture } from './fixture-nexus-ixian-betrayal';
const state = (name = 'Directory QA', status = 'lobby', advanced = false) => ({ const state = (name = 'Directory QA', status = 'lobby', advanced = false) => ({
status, advanced, host: 'p1', turn: 1, phase: 0, expansions: [], status, advanced, host: 'p1', turn: 1, phase: 0, expansions: [],
@@ -48,6 +51,95 @@ void test('admin directory allowlist reveals no private state, role power or sea
assert.deepEqual(decision.pending, { label: 'Special decision', owners: ['p1'] }); assert.deepEqual(decision.pending, { label: 'Special decision', owners: ['p1'] });
assert.equal(JSON.stringify(decision).includes('SECRET'), false); assert.equal(JSON.stringify(decision).includes('SECRET'), false);
}); });
const project = (value: unknown) => projectAdminRoom({ code: 'IXADMINA', version: 3, updated_at: 100, state: JSON.stringify(value) });
void test('directory ownership follows genuine shared and sole Ixian acknowledgements instead of the native bidder', () => {
for (const kind of ['technology', 'bidding'] as const) {
const f = createIxianNexusBetrayalFixture({ kind, receiverCount: 2 });
assert.equal(f.required.length, 2);
const shared = project(f.game);
assert.deepEqual(shared.pending.owners, []);
for (const player of f.game.players) assert.equal(tableActionOwner(viewGame(f.game, player.id)), null);
const partial = applyAction(f.game, f.holder, { type: 'nexusIxianBetrayalPass', event: f.event });
const remaining = f.required.find(id => id !== f.holder)!;
const sole = project(partial);
assert.deepEqual(sole.pending.owners, [remaining]);
assert.notEqual(remaining, f.provider);
for (const player of partial.players) assert.equal(tableActionOwner(viewGame(partial, player.id)), remaining);
assert.equal(sole.pending.label, shared.pending.label, 'Passing does not reveal which private card can prevent the attempt.');
const privateChanges = {
...partial,
players: partial.players.map(player => ({ ...player, hand: ['SECRET_HAND'], spice: 'SECRET_PRICE' })),
auction: partial.auction && { ...partial.auction, price: 'SECRET_PRICE' },
pendingNexusIxianBetrayal: {
...partial.pendingNexusIxianBetrayal,
source: { eligible: 'SECRET_ELIGIBILITY', nativeContext: 'SECRET_NATIVE', parent: 'SECRET_PARENT', card: 'SECRET_SOURCE_CARD' },
},
};
assert.deepEqual(project(privateChanges), sole, 'Admin metadata depends on public membership, not private source validity or custody.');
assert.equal(JSON.stringify(sole).includes('SECRET'), false);
}
});
void test('an irrelevant held Nexus face has the same admin acknowledgement metadata as an eligible face', () => {
for (const kind of ['technology', 'bidding'] as const) {
const eligible = createIxianNexusBetrayalFixture({ kind, receiverCount: 2, face: 'ixians', secondFace: 'guild' });
const irrelevant = createIxianNexusBetrayalFixture({ kind, receiverCount: 2, face: 'richese', secondFace: 'guild' });
assert.equal(viewGame(eligible.game, eligible.holder).nexusIxianBetrayalReaction!.canUse, true);
assert.equal(viewGame(irrelevant.game, irrelevant.holder).nexusIxianBetrayalReaction!.canUse, false);
assert.deepEqual(project(eligible.game), project(irrelevant.game));
const eligiblePartial = applyAction(eligible.game, eligible.holder, { type: 'nexusIxianBetrayalPass', event: eligible.event });
const irrelevantPartial = applyAction(irrelevant.game, irrelevant.holder, { type: 'nexusIxianBetrayalPass', event: irrelevant.event });
assert.deepEqual(project(eligiblePartial), project(irrelevantPartial));
}
});
void test('Ixian admin acknowledgements preserve lifecycle and higher public pending priorities', () => {
const input = {
...state('Priority QA', 'playing'), phase: 3,
pendingNexusIxianBetrayal: { required: ['p1'], passed: [], source: 'SECRET_SOURCE' },
auction: { active: 'p1', price: 'SECRET_PRICE' },
};
const higherPriority = [
{ status: 'finished' }, { status: 'lobby' }, { status: 'setup' },
{ pendingTreacheryDiscard: { card: 'SECRET_CARD' } },
{ response: { kind: 'SECRET_RESPONSE' } }, { phaseOpening: { passed: [] } },
{ truthtrance: { question: 'SECRET_QUESTION' } },
{ decision: { player: 'p1', card: 'SECRET_CARD' } },
{ battle: { attacker: 'p1', defender: 'UNSEATED_SECRET' } },
{ nexus: true },
{ phase: 0, stormDialers: ['p1'], stormDials: {} },
];
for (const priority of higherPriority) {
const value = { ...input, ...priority };
const withGate = project(value);
const withoutGate = project({ ...value, pendingNexusIxianBetrayal: null });
assert.deepEqual(withGate, withoutGate);
assert.equal(JSON.stringify(withGate).includes('SECRET'), false);
}
});
void test('malformed or orphan Ixian membership cannot disclose an unseated owner or fall through to the bidder', () => {
const input = { ...state('Membership QA', 'playing'), phase: 3, auction: { active: 'p1' } };
for (const membership of [
{ required: ['UNSEATED_SECRET'], passed: [] },
{ required: [null, 17, { id: 'SECRET_OBJECT' }], passed: [] },
{ required: [], passed: [] },
{ required: ['p1'], passed: ['p1'] },
{ required: 'SECRET_REQUIRED', passed: [] },
{ required: ['p1'], passed: 'SECRET_PASSED' },
{},
]) {
const output = project({ ...input, pendingNexusIxianBetrayal: { ...membership, source: 'SECRET_SOURCE' } });
assert.deepEqual(output.pending.owners, []);
assert.equal(output.status, 'playing', 'Malformed optional membership does not hide otherwise readable public room metadata.');
assert.equal(JSON.stringify(output).includes('SECRET'), false);
}
const duplicate = project({ ...input, pendingNexusIxianBetrayal: { required: ['p1', 'p1', 'UNSEATED_SECRET'], passed: [] } });
assert.deepEqual(duplicate.pending.owners, ['p1']);
});
void test('admin directory filters, stable pages, literal wildcard search and nested malformed saves', async () => { void test('admin directory filters, stable pages, literal wildcard search and nested malformed saves', async () => {
const f = await fixture(); const f = await fixture();
try { try {
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, 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 { createIxianNexusBetrayalFixture } from './fixture-nexus-ixian-betrayal';
const scenarios = [
{ kind: 'bidding', advanced: false },
{ kind: 'bidding', advanced: true },
{ kind: 'technology', advanced: true },
] as const;
void test('all four profiles prevent both actual native advantages using only their own legal source projection', () => {
for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) {
const fixture = createIxianNexusBetrayalFixture(scenario);
const own = viewGame(fixture.game, fixture.holder);
own.players.find(seat => seat.id === fixture.holder)!.bot = difficulty;
for (const rival of own.players.filter(seat => seat.id !== own.me)) {
delete rival.hand;
delete rival.spice;
}
const actions = botActions(own);
assert.deepEqual(actions, [{ type: 'nexusIxianBetrayalUse', event: fixture.event }]);
const result = applyAction(fixture.game, fixture.holder, actions[0]);
assert.equal(viewGame(result, fixture.holder).nexusIxianBetrayalReaction, null);
assert.equal(result.nexusCards!.cards!.hands[fixture.holder], null);
assert.ok(result.nexusCards!.cards!.discard.includes('ixians'));
assert.deepEqual(result.players.map(seat => ({ id: seat.id, spice: seat.spice, hand: seat.hand })),
fixture.game.players.map(seat => ({ id: seat.id, spice: seat.spice, hand: seat.hand })));
if (scenario.kind === 'bidding') {
assert.equal(result.auction!.cards.length, fixture.nativeCount);
assert.equal(result.ixAuction, null);
} else {
assert.equal(result.pendingIxTechnology, null);
assert.equal(result.ixTechnologyTurn, result.turn);
assert.deepEqual(result.auction!.cards, fixture.game.auction!.cards);
}
}
});
void test('all profiles acknowledge irrelevant faces and every required seat exactly once without bidding fallthrough or deadlock', () => {
for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) {
const fixture = createIxianNexusBetrayalFixture({ ...scenario, face: 'richese', secondFace: 'guild', receiverCount: 2 });
let game = structuredClone(fixture.game);
for (const seat of game.players) {
const own = viewGame(game, seat.id);
own.players.find(player => player.id === seat.id)!.bot = difficulty;
const reaction = own.nexusIxianBetrayalReaction;
if (!reaction) break;
const actions = botActions(own);
if (!reaction.canPass || reaction.hasPassed) {
assert.deepEqual(actions, []);
continue;
}
assert.deepEqual(actions, [{ type: 'nexusIxianBetrayalPass', event: fixture.event }]);
game = applyAction(game, seat.id, actions[0]);
const passed = viewGame(game, seat.id);
passed.players.find(player => player.id === seat.id)!.bot = difficulty;
if (passed.nexusIxianBetrayalReaction) assert.deepEqual(botActions(passed), []);
}
assert.equal(viewGame(game, fixture.holder).nexusIxianBetrayalReaction, null);
assert.equal(game.nexusCards!.cards!.hands[fixture.holder], 'richese');
if (scenario.kind === 'bidding') {
assert.equal(game.decision?.kind, 'ixAuction');
assert.equal(game.ixAuction!.cards.length, fixture.nativeCount + 1);
} else {
assert.ok(fixture.selected);
assert.equal(game.auction!.cards[game.auction!.index].id, fixture.selected.id);
assert.equal(game.players.find(seat => seat.id === fixture.provider)!.hand.some(card => card.id === fixture.selected!.id), false);
assert.equal(game.pendingIxTechnology, null);
}
}
});
void test('seat-private ownership, expired events and room scheduling gates override held-card hints for every policy', () => {
for (const difficulty of DIFFICULTIES) {
const fixture = createIxianNexusBetrayalFixture();
const own: GameView = viewGame(fixture.game, fixture.holder);
own.players.find(seat => seat.id === own.me)!.bot = difficulty;
for (const reason of ['blocked', 'canUse'] as const) {
const blocked = structuredClone(own);
if (reason === 'blocked') blocked.nexusIxianBetrayalReaction!.blocked = 'A committed promise reserves this Nexus card.';
else blocked.nexusIxianBetrayalReaction!.canUse = false;
assert.deepEqual(botActions(blocked), [{ type: 'nexusIxianBetrayalPass', event: fixture.event }]);
}
for (const reason of ['canPass', 'hasPassed', 'event', 'paused', 'closed', 'automatic', 'finished'] as const) {
const blocked = structuredClone(own);
if (reason === 'canPass') blocked.nexusIxianBetrayalReaction!.canPass = false;
else if (reason === 'hasPassed') blocked.nexusIxianBetrayalReaction!.hasPassed = true;
else if (reason === 'event') blocked.nexusIxianBetrayalReaction!.event = '';
else if (reason === 'paused' || reason === 'closed') blocked.roomControl = { ...DEFAULT_ROOM_CONTROL, [reason]: true };
else if (reason === 'automatic') blocked.automaticContinuationPending = true;
else blocked.status = 'finished';
assert.deepEqual(botActions(blocked), []);
}
for (const seat of fixture.game.players.filter(seat => seat.id !== fixture.holder)) {
const rival = viewGame(fixture.game, seat.id);
rival.players.find(player => player.id === seat.id)!.bot = difficulty;
assert.deepEqual(botActions(rival), []);
}
}
});
void test('human seats wait for delegation; the real bot runner executes Use or every neutral Pass for all profiles', () => {
for (const difficulty of DIFFICULTIES) for (const scenario of scenarios) {
for (const face of ['ixians', 'richese'] as const) {
const fixture = createIxianNexusBetrayalFixture({ ...scenario, face, secondFace: 'guild', receiverCount: 2 });
const human = viewGame(fixture.game, fixture.holder);
const seat = human.players.find(player => player.id === human.me)!;
delete seat.bot;
delete seat.autopilot;
assert.deepEqual(botActions(human), []);
let delegated = fixture.game;
for (const id of fixture.required) delegated = applyAction(delegated, id, { type: 'setAutopilot', difficulty });
let result = delegated;
for (let step = 0; step < fixture.required.length && viewGame(result, fixture.holder).nexusIxianBetrayalReaction; step++) {
result = runBots(result, 1);
}
assert.equal(viewGame(result, fixture.holder).nexusIxianBetrayalReaction, null);
assert.equal(result.nexusCards!.cards!.hands[fixture.holder], face === 'ixians' ? null : 'richese');
if (scenario.kind === 'bidding') {
assert.equal(face === 'ixians' ? result.auction!.cards.length : result.ixAuction!.cards.length,
fixture.nativeCount + (face === 'ixians' ? 0 : 1));
} else {
assert.equal(result.auction!.cards[result.auction!.index].id,
face === 'ixians' ? fixture.game.auction!.cards[fixture.game.auction!.index].id : fixture.selected!.id);
}
}
}
});
+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import {applyAction,createGame,initializeIxianNexusBetrayalGameForAudit,joinGame,newPlayer,viewGame,type Action,type Game} from '../game/engine';
import {botActions} from '../game/bots';
import type {Card} from '../game/cards';
export type IxianBetrayalFixtureOptions = {initial?:Game;kind?:'bidding'|'technology';advanced?:boolean;face?:'ixians'|'richese';secondFace?:'guild'|'ixians'|'richese';seatIds?:string[];receiverCount?:1|2};
export type IxianBetrayalFixture = {beforeNativeAttempt:Game;actor:string;declarationAction:Action;beforeNativeCounter:Game;game:Game;event:string;provider:string;holder:string;required:string[];kind:'bidding'|'technology';selected:Card|null;nativeCount:number};
export function settleIxianBetrayalNativeCounters(state:Game):Game {
let game=state;
for(let i=0;i<100;i++) {
if(game.pendingNexusIxianBetrayal || !game.response) return game;
const seat=game.players.find(p=>!game.response!.passed.includes(p.id));
assert.ok(seat);
game=applyAction(game,seat.id,{type:'passResponse'});
}
throw new Error('The original native counters did not settle.');
}
function nativeStep(game:Game):{actor:string;action:Action} {
if(game.pendingNexusIxianBetrayal) {
const frame=game.pendingNexusIxianBetrayal;
return {actor:frame.required.find(id=>!frame.passed.includes(id))!,action:{type:'nexusIxianBetrayalPass',event:frame.source.event}};
}
if(game.nexusCards?.phase?.stage==='drawing') {
const phase=game.nexusCards.phase;
const actor=phase.eligible.find(id=>!phase.done.includes(id))!;
return {actor,action:{type:'nexusCardChoice',turn:phase.turn,card:game.nexusCards.cards!.hands[actor],choice:'keep',ownRedraws:0}};
}
if(game.pendingTreacheryDiscard) return {actor:game.players[0].id,action:{type:'advanceBots'}};
if(game.response) return {actor:game.players.find(p=>!game.response!.passed.includes(p.id))!.id,action:{type:'passResponse'}};
if(game.phaseOpening) return {actor:game.players.find(p=>!game.phaseOpening!.passed.includes(p.id))!.id,action:{type:'ready'}};
if(game.decision?.kind==='ixTechnology')
return {actor:game.decision.player,action:{type:'decision',decline:true}};
if(game.status==='playing' && !game.decision) {
if(game.phase===0 && game.stormPending===null) return {actor:game.stormDialers.find(id=>game.stormDials[id]===undefined)!,action:{type:'stormDial',amount:game.turn===1?0:1}};
if(game.phase===3 && game.auction) return {actor:game.auction.active,action:{type:'passBid'}};
if(game.phase===5) return {actor:game.active!,action:{type:'endMovement'}};
if(game.phase!==6 || !game.active) {
const seat=game.players.find(p=>!game.ready.includes(p.id));
if(seat) return {actor:seat.id,action:{type:'ready'}};
}
}
for(const p of game.players) {
if(game.decision&&game.decision.player!==p.id) continue;
const view=viewGame(game,p.id);
view.players.find(s=>s.id===p.id)!.bot='Easy';
const actions=botActions(view);
const action=actions.find(a=>a.type==='ready')??actions[0];
if(action) return {actor:p.id,action};
}
throw new Error(`No native source action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
}
function step(game:Game):Game {const next=nativeStep(game);return applyAction(game,next.actor,next.action);}
/** Conserved original inventory, genuine setup, full first turn, settled nonholder
* alliance and actual Nexus draw. No phases, cards, forces or sources are injected. */
export function createIxianNexusBetrayalFixture(options:IxianBetrayalFixtureOptions={}):IxianBetrayalFixture {
const kind=options.kind??'bidding', face=options.face??'ixians';
let game:Game;
if(options.initial) {
game=structuredClone(options.initial);
assert.ok(game.status==='lobby'||(game.status==='setup'&&game.nexusIxianBetrayalPreview&&game.turn===1&&game.phase===0&&game.players.every(p=>p.hand.length===0)),'Only an admitted fresh undealt profile may continue.');
if(options.advanced!==undefined) assert.equal(game.advanced,options.advanced);
} else {
const ids=options.seatIds??(options.receiverCount===2?['p0','p1','p2','p3']:['p0','p1','p2']);
game=createGame('IXIANBETRAYAL',newPlayer(ids[0],'Atreides','atreides'),options.advanced??kind==='technology',['ix']);
joinGame(game,newPlayer(ids[1],'Ixians','ixians'));
joinGame(game,newPlayer(ids[2],'Guild','guild'));
if(ids.length>3) joinGame(game,newPlayer(ids[3],'Emperor','emperor'));
}
if(kind==='technology') assert.equal(game.advanced,true);
const provider=game.players.find(p=>p.faction==='ixians')!.id;
const holder=game.players.find(p=>p.faction==='atreides')?.id??game.players.find(p=>p.id!==provider)!.id;
const second=options.receiverCount===2?game.players.find(p=>p.id!==holder&&p.id!==provider)!.id:null;
const partners=game.players.filter(p=>p.id!==holder&&p.id!==second);
assert.ok(partners.length>=2,'Original Nexus draw needs two nonreceiver allies.');
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=initializeIxianNexusBetrayalGameForAudit(game);
}
// A native Ix setup chooses from conserved faces. Printed-counter profiles
// reserve a Karama in the shuffled non-Ix deal; Advanced BG-counter profiles
// give every non-Ix seat an original worthless card for its real conversion.
const karam=game.deck.filter(c=>c.effect==='karama');
assert.ok(karam.length>=2);
const other=game.deck.filter(c=>c.effect!=='karama');
if(game.advanced&&game.players.some(p=>p.faction==='beneGesserit')) {
const worthless=other.filter(c=>c.kind==='worthless');
const ixCard=karam[0];
assert.ok(worthless.length>=game.players.length-1);
const dealt=[ixCard,...worthless.slice(0,game.players.length-1)];
game.deck=[...dealt,...game.deck.filter(c=>!dealt.includes(c))];
} else {
game.deck=[karam[0],...other.slice(0,game.players.length-1),karam[1],...other.slice(game.players.length-1),...karam.slice(2)];
}
const nx=game.nexusCards!.cards!, faces:typeof nx.deck=[face,...(second?[options.secondFace??(face==='ixians'?'richese':'ixians')]:[])];
assert.equal(new Set(faces).size,faces.length,'Only distinct conserved singleton Nexus faces may be ordered.');
nx.deck=[...faces,...nx.deck.filter(c=>!faces.includes(c))];
for(let i=0;game.status==='setup'&&i<150;i++) {
if(game.decision?.kind==='ixSetup') {
const bgCounter=game.advanced&&game.players.some(p=>p.faction==='beneGesserit');
const card=game.ixSetupCards!.find(c=>bgCounter?c.effect==='karama':c.effect!=='karama')!;
game=applyAction(game,provider,{type:'decision',card:card.id});
} else game=step(game);
}
assert.equal(game.status,'playing');
for(let pos=0;pos<2;pos++) {
const index=game.spiceDeck.findIndex((c,i)=>i>=pos&&'territory'in c);
assert.ok(index>=pos);game.spiceDeck.splice(pos,0,game.spiceDeck.splice(index,1)[0]);
}
for(let i=0;!(game.turn===2&&game.phase===0&&!game.phaseOpening&&!game.response&&!game.decision)&&i<1500;i++) game=step(game);
assert.equal(game.turn,2);
for(let i=0;game.phase!==1&&i<200;i++) game=step(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 i=0;!(game.nexus&&!game.spiceWindow&&!game.spiceResolution&&!game.phaseOpening&&!game.response&&!game.decision)&&i<200;i++) game=step(game);
assert.equal(game.nexus,true);
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 i=0;game.nexusCards?.phase?.stage!=='drawing'&&i<200;i++) game=step(game);
assert.equal(game.nexusCards!.phase!.stage,'drawing');
game=applyAction(game,holder,{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
if(second) game=applyAction(game,second,{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
while(game.nexusCards!.phase!.stage==='drawing') game=step(game);
assert.equal(game.nexusCards!.phase!.stage,'complete');
assert.equal(game.nexusCards!.cards!.hands[holder],face);
let beforeNativeAttempt:Game|undefined, declarationAction:Action|undefined, actor:string|undefined;
for(let i=0;i<400;i++) {
if(kind==='technology'&&game.decision?.kind==='ixTechnology') {
beforeNativeAttempt=structuredClone(game);actor=provider;
declarationAction={type:'decision',card:game.players.find(p=>p.id===provider)!.hand[0].id};
game=applyAction(game,actor,declarationAction);break;
}
const next=nativeStep(game),before=structuredClone(game);
game=applyAction(game,next.actor,next.action);
if(kind==='bidding'&&game.response?.kind==='ixAuction') {beforeNativeAttempt=before;actor=next.actor;declarationAction=next.action;break;}
}
assert.ok(beforeNativeAttempt&&declarationAction&&actor);
assert.equal(game.response?.kind,kind==='bidding'?'ixAuction':'ixTechnology');
const beforeNativeCounter=structuredClone(game),nativeCount=game.ixAuction?.count??game.auction!.cards.length;
const selected=kind==='technology'?structuredClone(game.players.find(p=>p.id===provider)!.hand.find(c=>c.id===game.pendingIxTechnology!.card)!):null;
game=settleIxianBetrayalNativeCounters(game);
const frame=game.pendingNexusIxianBetrayal;assert.ok(frame);
return {beforeNativeAttempt,actor,declarationAction,beforeNativeCounter,game,event:frame.source.event,provider,holder,required:[...frame.required],kind,selected,nativeCount};
}
@@ -0,0 +1,182 @@
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, Game, GameView } from '../game/engine';
import { tableActionOwner } from '../game/table-turn';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
import { createIxianNexusBetrayalFixture } from './fixture-nexus-ixian-betrayal';
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 };
},
});
// Static UI imports would resolve before these Node-only Next/CSS hooks are
// installed; these imports intentionally exercise the platform loading boundary.
const { NexusIxianBetrayal } = await import('../components/nexus-ixian-betrayal');
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(NexusIxianBetrayal({ game, busy, act(value) { action = value; } }), `nexus-ixian-betrayal-${choice}`);
return action;
}
function markup(game: GameView): string {
return renderToStaticMarkup(createElement(NexusIxianBetrayal, { game, busy: false, act() {} }));
}
function table(game: GameView, busy = false): string {
return renderToStaticMarkup(createElement(GameTable, { game, busy, send: async () => {}, onExit() {} }));
}
function passAll(original: Game): Game {
let game = structuredClone(original);
for (const seat of game.players) {
const action = select(viewGame(game, seat.id), 'pass');
if (action) game = applyAction(game, seat.id, action);
}
return game;
}
const scenarios = [
{ kind: 'bidding', advanced: false },
{ kind: 'bidding', advanced: true },
{ kind: 'technology', advanced: true },
] as const;
for (const scenario of scenarios) {
void test(`${scenario.advanced ? 'Advanced' : 'Basic'} ${scenario.kind} human Use closes exactly the native advantage; Pass resumes it`, () => {
const fixture = createIxianNexusBetrayalFixture(scenario);
const own = viewGame(fixture.game, fixture.holder);
const action = select(own, 'use');
assert.deepEqual(action, { type: 'nexusIxianBetrayalUse', event: fixture.event });
assert.ok(action);
const denied = applyAction(fixture.game, fixture.holder, action);
const allowed = passAll(fixture.game);
assert.equal(viewGame(denied, fixture.holder).nexusIxianBetrayalReaction, null);
assert.equal(viewGame(allowed, fixture.holder).nexusIxianBetrayalReaction, null);
assert.equal(denied.nexusCards!.cards!.hands[fixture.holder], null);
assert.equal(allowed.nexusCards!.cards!.hands[fixture.holder], 'ixians');
assert.deepEqual(denied.players.map(seat => ({ id: seat.id, spice: seat.spice, forces: seat.forces, reserves: seat.reserves })),
allowed.players.map(seat => ({ id: seat.id, spice: seat.spice, forces: seat.forces, reserves: seat.reserves })));
if (scenario.kind === 'bidding') {
assert.equal(denied.ixAuction, null);
assert.equal(denied.auction!.cards.length, fixture.nativeCount);
assert.equal(allowed.decision?.kind, 'ixAuction');
assert.equal(allowed.ixAuction!.cards.length, fixture.nativeCount + 1);
} else {
assert.ok(fixture.selected);
const nativeBefore = fixture.game.players.find(seat => seat.id === fixture.provider)!;
assert.deepEqual(denied.players.find(seat => seat.id === fixture.provider)!.hand, nativeBefore.hand);
assert.deepEqual(denied.auction!.cards, fixture.game.auction!.cards);
assert.equal(allowed.players.find(seat => seat.id === fixture.provider)!.hand.some(card => card.id === fixture.selected!.id), false);
assert.equal(allowed.auction!.cards[allowed.auction!.index].id, fixture.selected.id);
assert.equal(denied.ixTechnologyTurn, fixture.game.turn);
assert.equal(allowed.ixTechnologyTurn, fixture.game.turn);
}
assert.equal(select(viewGame(denied, fixture.holder), 'use'), undefined);
assert.doesNotMatch(table(viewGame(denied, fixture.holder)), /data-testid="nexus-ixian-betrayal"/);
const before = JSON.stringify(denied);
assert.throws(() => applyAction(denied, fixture.holder, action));
assert.equal(JSON.stringify(denied), before);
});
}
void test('irrelevant held faces receive the same public acknowledgement without a private Ixian inspector or rival Use', () => {
for (const scenario of scenarios) {
const fixture = createIxianNexusBetrayalFixture(scenario);
const irrelevant = createIxianNexusBetrayalFixture({ ...scenario, face: 'richese' });
const own = viewGame(fixture.game, fixture.holder);
const neutral = viewGame(irrelevant.game, irrelevant.holder);
assert.match(markup(own), /aria-label="Inspect Ixians Nexus card"/);
assert.ok(select(own, 'use'));
assert.ok(select(neutral, 'pass'));
assert.equal(select(neutral, 'use'), undefined);
assert.doesNotMatch(markup(neutral), /Inspect Ixians Nexus card|Betrayal timing and preview limits/);
for (const seat of fixture.game.players.filter(seat => seat.id !== fixture.holder)) {
const rival = viewGame(fixture.game, seat.id);
assert.equal(select(rival, 'use'), undefined);
assert.doesNotMatch(markup(rival), /Inspect Ixians Nexus card/);
assert.equal(rival.nexusIxianBetrayalReaction!.blocked, null);
}
const publicOffer = (view: GameView) => {
const reaction = view.nexusIxianBetrayalReaction!;
return { event: reaction.event, kind: reaction.kind, provider: reaction.provider, canPass: reaction.canPass, hasPassed: reaction.hasPassed };
};
assert.deepEqual(publicOffer(own), publicOffer(neutral));
assert.doesNotMatch(markup(own), /Inspect Treachery|Your purchased Treachery|private auction card/);
}
});
void test('multiple public responders have no owner until only one acknowledgement remains', () => {
const fixture = createIxianNexusBetrayalFixture({ receiverCount: 2 });
for (const seat of fixture.game.players) assert.equal(tableActionOwner(viewGame(fixture.game, seat.id)), null);
const first = fixture.required[0];
const action = select(viewGame(fixture.game, first), 'pass');
assert.ok(action);
const next = applyAction(fixture.game, first, action);
for (const seat of next.players) assert.equal(tableActionOwner(viewGame(next, seat.id)), fixture.required[1]);
assert.equal(select(viewGame(next, first), 'pass'), undefined);
assert.equal(select(viewGame(next, first), 'use'), undefined);
});
void test('actual table reserves bids and voluntary card mutations; transport, room and autopilot locks stop response dispatch', () => {
for (const scenario of scenarios) {
const fixture = createIxianNexusBetrayalFixture(scenario);
const own: GameView = viewGame(fixture.game, fixture.holder);
const html = table(own);
assert.match(html, /data-testid="nexus-ixian-betrayal"/);
assert.doesNotMatch(html, /<button[^>]*>[^<]*(?:Bid |Place bid|Play Karama|Make a bribe|Pledge|Search)/i);
for (const restriction of ['paused', 'closed', 'autopilot', 'busy'] as const) {
const blocked = structuredClone(own);
if (restriction === 'autopilot') blocked.players.find(seat => seat.id === own.me)!.autopilot = 'Easy';
else if (restriction !== 'busy') blocked.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
for (const choice of ['use', 'pass'] as const) assert.equal(select(blocked, choice, restriction === 'busy'), undefined);
}
const blocked = structuredClone(own);
blocked.nexusIxianBetrayalReaction!.blocked = 'A committed promise reserves this Nexus card.';
assert.equal(select(blocked, 'use'), undefined);
assert.ok(select(blocked, 'pass'));
const native = viewGame(fixture.beforeNativeCounter, fixture.holder);
assert.equal(native.nexusIxianBetrayalReaction, null);
assert.doesNotMatch(table(native), /data-testid="nexus-ixian-betrayal"/);
assert.match(table(native), /Power response/);
}
});
void test('every seat can regain independent control while the native acknowledgement remains unchanged', () => {
const fixture = createIxianNexusBetrayalFixture({ receiverCount: 2 });
for (const seat of fixture.game.players) {
const delegated = applyAction(fixture.game, seat.id, { type: 'setAutopilot', difficulty: 'Hard' });
const view = viewGame(delegated, seat.id);
const takeBackButton = [...table(view).matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)]
.map(match => match[0]).find(button => button.includes('Take back control'));
assert.ok(takeBackButton);
assert.doesNotMatch(takeBackButton, /disabled=/);
const resumed = applyAction(delegated, seat.id, { type: 'setAutopilot', difficulty: null });
assert.equal(resumed.players.find(player => player.id === seat.id)!.autopilot, undefined);
assert.deepEqual(resumed.pendingNexusIxianBetrayal, fixture.game.pendingNexusIxianBetrayal);
assert.deepEqual(resumed.deck, fixture.game.deck);
}
});
aliases.deregister();
+567
View File
@@ -0,0 +1,567 @@
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 {
createIxianNexusBetrayalFixture,
type IxianBetrayalFixture,
type IxianBetrayalFixtureOptions as Options,
} from './fixture-nexus-ixian-betrayal';
type SqlRow = Record<string, SQLOutputValue>;
interface Fixture extends IxianBetrayalFixture {
sqlite: DatabaseSync;
restart(): typeof Rooms;
hooks: { beforeWrite?: () => Promise<void> };
writes: { expected: number; changes: number }[];
code: string;
tokens: string[];
auths: SeatAuth[];
save(game: Game): void;
protectedCredentials: { seats: SqlRow[]; entries: SqlRow[]; keys: SqlRow[]; receipts: SqlRow[] };
unrelatedCode: string;
unrelatedToken: string;
unrelatedRow: SqlRow | undefined;
stock: string[];
initial: Game;
}
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
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;
}
function physical(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'one physical custodian per native Treachery card');
return ids;
}
async function fixture(t: test.TestContext, options: Options & { bgCounter?: true } = {}): Promise<Fixture> {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Native Ixian recovery', 'ixians', options.advanced ?? true, ['ix']);
const code = made.view.code;
const tokens = [made.token];
for (const faction of options.receiverCount === 2 ? ['atreides', 'emperor', 'guild'] as const
: options.bgCounter ? ['atreides', 'beneGesserit'] as const : ['atreides', 'emperor'] as const) {
const joined = await store.rooms.joinRoom(code, faction, faction);
assert.ok(joined.token);
tokens.push(joined.token);
}
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const admitted = await store.rooms.readRoom(code);
const native = createIxianNexusBetrayalFixture({ ...options, initial: admitted });
const parent = native.beforeNativeAttempt;
assert.equal(parent.code, admitted.code);
assert.equal(parent.host, admitted.host);
assert.equal(parent.advanced, admitted.advanced);
assert.deepEqual(parent.expansions, admitted.expansions);
assert.deepEqual(parent.playerPositions, admitted.playerPositions);
assert.deepEqual(parent.players.map(p => [p.id, p.name, p.faction]), admitted.players.map(p => [p.id, p.name, p.faction]));
const save = (game: Game) => {
assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?')
.run(JSON.stringify(game), game.version, code).changes, 1);
};
save(parent);
const unrelated = await store.rooms.createRoom('Unrelated preserved lobby', 'guild', false, []);
const protectedCredentials = credentials(store.sqlite);
const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(unrelated.view.code);
const f: Fixture = { ...store, ...native, code, tokens, auths, save,
protectedCredentials, unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token,
unrelatedRow, stock: physical(parent), initial: parent };
f.initial = await openGate(f, parent);
assert.equal(viewGame(f.initial, native.holder).nexusIxianBetrayalReaction?.event, native.event);
store.writes.length = 0;
return f;
}
function protectedRows(f: Fixture) {
assert.deepEqual(credentials(f.sqlite), f.protectedCredentials);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.unrelatedCode), f.unrelatedRow);
}
function inventory(f: Fixture, game: Game) {
assert.deepEqual(physical(game), f.stock);
assert.equal(f.stock.length, 47);
assert.ok(game.nexusCards?.cards);
validateNexusCards(game.nexusCards.cards, game.players);
assert.deepEqual([...game.nexusCards.cards.deck, ...game.nexusCards.cards.discard,
...Object.values(game.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, game: Game, id: string, action: Action) {
await f.restart().act(f.code, await authenticate(f, id), game.version, action, clock);
return f.restart().readRoom(f.code);
}
function unchangedHistory(f: Fixture, game: Game) {
assert.deepEqual(game.nexusIxianBetrayalHistory, f.beforeNativeAttempt.nexusIxianBetrayalHistory);
assert.deepEqual(game.nexusIxianBetrayalCursor, f.beforeNativeAttempt.nexusIxianBetrayalCursor);
}
function closedHistory(f: Fixture, game: Game, used: boolean, gate = f.initial) {
const prior = f.beforeNativeAttempt.nexusIxianBetrayalHistory!;
const cursor = f.beforeNativeAttempt.nexusIxianBetrayalCursor!;
const history = game.nexusIxianBetrayalHistory!;
assert.equal(history.length, prior.length + 1);
assert.deepEqual(history.slice(0, prior.length), prior);
const receipt = history[prior.length];
assert.deepEqual(receipt.source, gate.pendingNexusIxianBetrayal!.source);
assert.equal(receipt.source.event, f.event);
assert.equal(receipt.source.sequence, cursor.sequence);
assert.equal(receipt.outcome, used ? 'use' : 'pass');
assert.equal(receipt.holder, used ? f.holder : null);
assert.equal(receipt.previous, cursor.signature);
assert.equal(game.nexusIxianBetrayalCursor!.sequence, cursor.sequence + 1);
assert.notEqual(game.nexusIxianBetrayalCursor!.signature, cursor.signature);
}
async function openGate(f: Fixture, parent: Game) {
let game = await act(f, parent, f.actor, f.declarationAction);
assert.ok(game.response, 'the original native Karama counter window opens first');
assert.equal(viewGame(game, f.holder).nexusIxianBetrayalReaction, null);
for (let step = 0; game.response && step < 20; step++) {
const responder = game.players.find(p => !game.response!.passed.includes(p.id));
assert.ok(responder);
game = await act(f, game, responder.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
unchangedHistory(f, game);
assert.ok(viewGame(game, f.holder).nexusIxianBetrayalReaction);
return game;
}
async function rejected(f: Fixture, game: Game, auth: SeatAuth, action: Action, version = game.version) {
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const changes = totalChanges(f.sqlite);
await assert.rejects(f.restart().act(f.code, auth, version, action, clock));
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
assert.equal(totalChanges(f.sqlite), changes, 'rejected action writes no durable resource table');
protectedRows(f);
}
async function restored(f: Fixture, game: Game) {
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const changes = totalChanges(f.sqlite);
assert.deepEqual(plain(await f.restart().readRoom(f.code)), plain(game));
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(game, auth.playerId)));
assert.equal(view.nexusIxianBetrayalPreview, true);
for (const key of ['pendingNexusIxianBetrayal', 'nexusIxianBetrayalHistory', 'nexusIxianBetrayalCursor'])
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);
const reaction = view.nexusIxianBetrayalReaction;
if (reaction) {
assert.deepEqual(Object.keys(reaction).sort(), ['event', 'kind', 'provider', 'canPass', 'hasPassed', 'canUse', 'blocked'].sort());
assert.equal(reaction.event, f.event);
assert.equal(reaction.kind, f.kind);
assert.equal(reaction.provider, f.provider);
if (!f.required.includes(auth.playerId)) {
assert.equal(reaction.canPass, false);
assert.equal(reaction.canUse, false);
assert.equal(reaction.blocked, null);
}
}
}
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
assert.equal(totalChanges(f.sqlite), changes, 'refresh cannot autoallow or consume the original attempt');
inventory(f, game);
}
async function closeGate(f: Fixture, game: Game, used: boolean) {
if (used) return act(f, game, f.holder, { type: 'nexusIxianBetrayalUse', event: f.event });
let next = game;
for (const id of f.required)
next = await act(f, next, id, { type: 'nexusIxianBetrayalPass', event: f.event });
return next;
}
function settled(f: Fixture, game: Game, used: boolean) {
assert.equal(game.pendingNexusIxianBetrayal ?? null, null);
assert.equal(viewGame(game, f.holder).nexusIxianBetrayalReaction, null);
assert.equal(game.nexusCards!.cards!.hands[f.holder], used ? null : f.initial.nexusCards!.cards!.hands[f.holder]);
assert.deepEqual(game.nexusCards!.cards!.discard,
[...f.initial.nexusCards!.cards!.discard, ...(used ? ['ixians'] : [])]);
assert.deepEqual(game.players.map(p => [p.id, p.spice, p.reserves, p.forces]),
f.initial.players.map(p => [p.id, p.spice, p.reserves, p.forces]), 'Nexus costs no spice or forces');
assert.deepEqual(game.discard, f.initial.discard, 'no Treachery card pays this reaction');
const native = game.players.find(p => p.id === f.provider)!;
const previous = f.initial.players.find(p => p.id === f.provider)!;
if (f.kind === 'bidding') {
assert.equal(game.deck.length, f.initial.deck.length - f.nativeCount - Number(!used));
assert.equal(game.ixAuction?.cards.length ?? 0, used ? 0 : f.nativeCount + 1);
assert.equal(game.auction?.cards.length ?? 0, used ? f.nativeCount : 0);
assert.deepEqual(native.hand, previous.hand);
} else {
assert.ok(f.selected);
assert.equal(game.pendingIxTechnology ?? null, null);
assert.equal(game.ixTechnologyTurn, game.turn, 'the accepted once-per-turn attempt remains spent');
assert.equal(game.auction!.index, f.initial.auction!.index);
assert.equal(game.auction!.bid, f.initial.auction!.bid);
assert.equal(game.auction!.bidder, f.initial.auction!.bidder);
const originalLot = f.initial.auction!.cards[f.initial.auction!.index];
assert.equal(native.hand.some(card => card.id === f.selected!.id), used);
assert.equal(native.hand.some(card => card.id === originalLot.id), !used);
assert.equal(game.auction!.cards[game.auction!.index].id, used ? originalLot.id : f.selected.id);
assert.equal(game.deck.length, f.initial.deck.length);
}
closedHistory(f, game, used);
inventory(f, game);
}
for (const [kind, advanced] of [['bidding', false], ['bidding', true], ['technology', true]] as const) {
for (const used of [false, true]) {
void test(`SQLite actual ${advanced ? 'Advanced' : 'Basic'} native ${kind} ${used ? 'prevent' : 'allow'} restores and resumes once`, async t => {
const f = await fixture(t, { kind, advanced });
await restored(f, f.initial);
const done = await closeGate(f, f.initial, used);
assert.equal(done.version, f.initial.version + 1);
settled(f, done, used);
await restored(f, done);
for (const type of ['nexusIxianBetrayalPass', 'nexusIxianBetrayalUse'] as const)
await rejected(f, done, await authenticate(f, f.holder), { type, event: f.event });
});
}
}
for (const kind of ['bidding', 'technology'] as const) {
void test(`SQLite ${kind} stale, foreign, wrong role, unknown fields and revoked credentials cost nothing`, async t => {
const f = await fixture(t, { kind });
const holder = await authenticate(f, f.holder);
const provider = await authenticate(f, f.provider);
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const changes = totalChanges(f.sqlite);
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.holder, tokenHash: foreign.tokenHash }));
assert.equal(totalChanges(f.sqlite), changes);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
const use: Action = { type: 'nexusIxianBetrayalUse', event: f.event };
await rejected(f, f.initial, holder, use, f.initial.version - 1);
await rejected(f, f.initial, foreign, use);
await rejected(f, f.initial, provider, use);
await rejected(f, f.initial, provider, { type: 'nexusIxianBetrayalPass', event: f.event });
for (const action of [{ ...use, event: `${f.event}:expired` }, { ...use, kind },
{ ...use, provider: f.provider }, { ...use, card: f.initial.deck[0].id }, { ...use, price: 0 }])
await rejected(f, f.initial, holder, action as Action);
for (const action of [
{ type: 'passBid' }, { type: 'passResponse' }, { type: 'ready' },
{ type: 'decision', card: f.initial.players.find(p => p.id === f.provider)!.hand[0].id },
])
await rejected(f, f.initial, provider, action as Action);
await restored(f, f.initial);
f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.holder);
f.protectedCredentials = credentials(f.sqlite);
const changesAfterRevocation = totalChanges(f.sqlite);
await assert.rejects(f.restart().authenticate(f.code, f.tokens[f.auths.findIndex(auth => auth.playerId === f.holder)]));
assert.equal(totalChanges(f.sqlite), changesAfterRevocation);
await assert.rejects(f.restart().readSeatView(f.code, holder));
await rejected(f, f.initial, holder, use);
await rejected(f, f.initial, holder, { type: 'nexusIxianBetrayalPass', event: f.event });
});
void test(`SQLite competing ${kind} Pass/Use commits one native continuation`, async t => {
const f = await fixture(t, { kind });
assert.equal(f.required.length, 1);
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.holder);
results = await Promise.allSettled(['nexusIxianBetrayalPass', 'nexusIxianBetrayalUse'].map(type =>
f.restart().act(f.code, auth, f.initial.version, { type, event: f.event } as 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);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, f.initial.version + 1);
settled(f, done, results[1].status === 'fulfilled');
await restored(f, done);
});
void test(`SQLite ${kind} revocation at final CAS preserves the pending native source`, async t => {
const f = await fixture(t, { kind });
const auth = await authenticate(f, f.holder);
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const changes = totalChanges(f.sqlite);
let revoked = false;
f.hooks.beforeWrite = async () => {
assert.equal(revoked, false);
revoked = true;
f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE room_code=? AND player_id=?').run(f.code, f.holder);
f.protectedCredentials = credentials(f.sqlite);
};
try {
await assert.rejects(f.restart().act(f.code, auth, f.initial.version,
{ type: 'nexusIxianBetrayalUse', event: f.event }, clock));
} finally { delete f.hooks.beforeWrite; }
assert.equal(revoked, true);
assert.equal(totalChanges(f.sqlite), Number(changes) + 1, 'only explicit revocation is durable');
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
inventory(f, await f.restart().readRoom(f.code));
await assert.rejects(f.restart().readSeatView(f.code, auth));
});
}
for (const kind of ['bidding', 'technology'] as const) {
void test(`SQLite original ${kind} printed Karama preempts Nexus without spending its face or opening a gate`, async t => {
const f = await fixture(t, { kind });
f.save(f.beforeNativeAttempt);
const declared = await act(f, f.beforeNativeAttempt, f.actor, f.declarationAction);
const counter = declared.players.find(p => p.id !== f.provider && p.hand.some(card => card.effect === 'karama'));
assert.ok(counter);
const karama = counter.hand.find(card => card.effect === 'karama')!;
let game = await act(f, declared, counter.id, { type: 'card', mode: 'cancel', card: karama.id });
for (let step = 0; game.response && step < 20; step++) {
const responder = game.players.find(p => !game.response!.passed.includes(p.id));
assert.ok(responder);
game = await act(f, game, responder.id, { type: 'passResponse' });
}
assert.equal(viewGame(game, f.holder).nexusIxianBetrayalReaction, null);
unchangedHistory(f, game);
assert.deepEqual(game.nexusCards!.cards, f.beforeNativeAttempt.nexusCards!.cards);
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
if (kind === 'bidding') {
assert.equal(game.deck.length, declared.deck.length - f.nativeCount);
assert.equal(game.ixAuction ?? null, null);
assert.equal(game.auction!.cards.length, f.nativeCount);
} else {
assert.equal(game.ixTechnologyTurn, game.turn);
assert.equal(game.pendingIxTechnology ?? null, null);
assert.deepEqual(game.players.find(p => p.id === f.provider)!.hand,
declared.players.find(p => p.id === f.provider)!.hand);
assert.equal(game.auction!.cards[game.auction!.index].id, declared.auction!.cards[declared.auction!.index].id);
}
await restored(f, game);
});
void test(`SQLite ${kind} relevant and irrelevant owned faces expose identical rival acknowledgments`, async t => {
const f = await fixture(t, { kind });
// Vary only owned physical custody before the original native declaration.
// Do not re-sign an already authorized source or manufacture another hand.
const parent = structuredClone(f.beforeNativeAttempt);
const cards = parent.nexusCards!.cards!;
const index = cards.deck.indexOf('richese');
assert.ok(index >= 0);
assert.equal(cards.hands[f.holder], 'ixians');
cards.hands[f.holder] = 'richese';
cards.deck[index] = 'ixians';
f.save(parent);
const irrelevant = await openGate(f, parent);
assert.equal(viewGame(irrelevant, f.holder).nexusIxianBetrayalReaction!.event, f.event);
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.holder) assert.deepEqual(plain(changed), plain(original));
else {
assert.equal(changed.nexusIxianBetrayalReaction!.canPass, true);
assert.equal(changed.nexusIxianBetrayalReaction!.canUse, false);
}
}
await rejected(f, irrelevant, await authenticate(f, f.holder), { type: 'nexusIxianBetrayalUse', event: f.event });
const done = await closeGate(f, irrelevant, false);
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
assert.deepEqual(done.nexusCards!.cards!.discard, irrelevant.nexusCards!.cards!.discard);
closedHistory(f, done, false, irrelevant);
await restored(f, done);
});
void test(`SQLite ${kind} pending source, native custody, cursor and profile corruption never heals or writes`, async t => {
const f = await fixture(t, { kind });
const changes: { name: string; change: (game: Game) => void }[] = [
{ name: 'missing source', change: game => { delete game.pendingNexusIxianBetrayal; } },
{ name: 'missing cursor', change: game => { delete game.nexusIxianBetrayalCursor; } },
{ name: 'missing history', change: game => { delete game.nexusIxianBetrayalHistory; } },
{ name: 'cursor sequence', change: game => { game.nexusIxianBetrayalCursor!.sequence++; } },
{ name: 'parent link', change: game => { game.pendingNexusIxianBetrayal!.source.parent += ':forged'; } },
{ name: 'native source', change: game => { game.pendingNexusIxianBetrayal!.source.nativeContext += ':forged'; } },
{ name: 'native provider', change: game => { game.pendingNexusIxianBetrayal!.source.provider = f.holder; } },
{ name: 'native kind', change: game => { game.pendingNexusIxianBetrayal!.source.kind = kind === 'bidding' ? 'technology' : 'bidding'; } },
{ name: 'missing native passes', change: game => { game.pendingNexusIxianBetrayal!.source.nativePassed = []; } },
{ name: 'native continuation', change: game => { game.pendingNexusIxianBetrayal!.continuation.owner = f.holder; } },
{ name: 'required membership', change: game => { game.pendingNexusIxianBetrayal!.required = [f.provider]; } },
{ name: 'invented pass', change: game => { game.pendingNexusIxianBetrayal!.passed = [f.provider]; } },
{ name: 'lost profile', change: game => { delete game.nexusIxianBetrayalPreview; } },
{ name: 'native roster', change: game => { game.players.find(p => p.id === f.provider)!.faction = 'guild'; } },
{ name: 'foreign expansion', change: game => { game.expansions.push('choam'); } },
{ name: 'wrong phase', change: game => { game.phase++; } },
{ name: 'wrong turn', change: game => { game.turn++; } },
{ name: 'duplicate Nx stock', change: game => { game.nexusCards!.cards!.deck.push('ixians'); } },
{ name: 'missing Treachery stock', change: game => { game.deck.pop(); } },
{ name: 'canonical Treachery face', change: game => { game.deck[0].name += ' forged'; } },
...(kind === 'technology' ? [
{ name: 'accepted native attempt', change: (game: Game) => { delete game.ixTechnologyTurn; } },
{ name: 'selected native card', change: (game: Game) => { game.pendingIxTechnology!.card = game.deck[0].id; } },
] : [{ name: 'native draw count', change: (game: Game) => { game.ixAuction!.count++; } }]),
];
for (const { name, change } of changes) {
const corrupt = structuredClone(f.initial);
change(corrupt);
f.save(corrupt);
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const writes = totalChanges(f.sqlite);
const 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 type of ['nexusIxianBetrayalPass', 'nexusIxianBetrayalUse'] as const)
await rejected(f, corrupt, await authenticate(f, f.holder), { type, event: f.event });
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before, name);
assert.equal(totalChanges(f.sqlite), writes, name);
assert.deepEqual(corrupt, original);
protectedRows(f);
}
});
void test(`SQLite ${kind} two genuine unallied receivers restore partial pass and reject replay`, async t => {
const f = await fixture(t, { kind, receiverCount: 2 });
assert.equal(f.initial.players.length, 4);
assert.equal(f.required.length, 2);
const irrelevant = f.required.find(id => id !== f.holder)!;
const first = await act(f, f.initial, irrelevant, { type: 'nexusIxianBetrayalPass', event: f.event });
assert.ok(first.pendingNexusIxianBetrayal);
assert.equal(viewGame(first, irrelevant).nexusIxianBetrayalReaction!.hasPassed, true);
unchangedHistory(f, first);
assert.deepEqual(first.nexusCards!.cards, f.initial.nexusCards!.cards);
await restored(f, first);
await rejected(f, first, await authenticate(f, irrelevant), { type: 'nexusIxianBetrayalPass', event: f.event });
const done = await act(f, first, f.holder, { type: 'nexusIxianBetrayalUse', event: f.event });
settled(f, done, true);
await restored(f, done);
});
for (const used of [false, true]) {
void test(`SQLite ${kind} ${used ? 'use' : 'pass'} history survives later physical recycling and rejects corrupted links`, async t => {
const f = await fixture(t, { kind });
const done = await closeGate(f, f.initial, used);
settled(f, done, used);
await restored(f, done);
const later = structuredClone(done);
if (used) {
const cards = later.nexusCards!.cards!;
const index = cards.discard.indexOf('ixians');
assert.ok(index >= 0);
cards.deck.push(...cards.discard.splice(index, 1));
}
// Preserve physical custody while representing later native card circulation.
const card = later.deck.pop()!;
later.discard.push(card);
f.save(later);
await restored(f, later);
assert.deepEqual(later.nexusIxianBetrayalHistory, done.nexusIxianBetrayalHistory);
assert.deepEqual(later.nexusIxianBetrayalCursor, done.nexusIxianBetrayalCursor);
for (const type of ['nexusIxianBetrayalPass', 'nexusIxianBetrayalUse'] as const)
await rejected(f, later, await authenticate(f, f.holder), { type, event: f.event });
for (const change of [
(game: Game) => { game.nexusIxianBetrayalHistory = []; },
(game: Game) => { delete game.nexusIxianBetrayalCursor; },
(game: Game) => { game.nexusIxianBetrayalCursor!.signature += ':forged'; },
...done.nexusIxianBetrayalHistory!.flatMap((_receipt, index) => [
(game: Game) => { game.nexusIxianBetrayalHistory![index].signature += ':forged'; },
(game: Game) => { game.nexusIxianBetrayalHistory![index].source.parent += ':forged'; },
(game: Game) => { game.nexusIxianBetrayalHistory![index].source.nativeContext += ':forged'; },
]),
]) {
const corrupt = structuredClone(later);
change(corrupt);
f.save(corrupt);
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const writes = 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.holder), { type: 'nexusIxianBetrayalUse', event: f.event });
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
assert.equal(totalChanges(f.sqlite), writes);
}
});
}
}
for (const kind of ['bidding', 'technology'] as const) {
void test(`SQLite Advanced BG conversion counters the actual ${kind} attempt before Nexus`, async t => {
const f = await fixture(t, { kind, advanced: true, bgCounter: true });
f.save(f.beforeNativeAttempt);
const declared = await act(f, f.beforeNativeAttempt, f.actor, f.declarationAction);
const counter = declared.players.find(p => p.faction === 'beneGesserit')!;
const card = counter.hand.find(value => value.effect !== 'karama');
assert.ok(card, 'genuine initial BG Treachery card fuels its native conversion');
let game = await act(f, declared, counter.id, { type: 'card', mode: 'cancel', card: card.id });
for (let step = 0; game.response && step < 20; step++) {
const responder = game.players.find(p => !game.response!.passed.includes(p.id));
assert.ok(responder);
game = await act(f, game, responder.id, { type: 'passResponse' });
}
assert.deepEqual(game.nexusCards!.cards, f.beforeNativeAttempt.nexusCards!.cards);
unchangedHistory(f, game);
assert.equal(viewGame(game, f.holder).nexusIxianBetrayalReaction, null);
assert.equal(game.discard.filter(value => value.id === card.id).length, 1);
if (kind === 'bidding') {
assert.equal(game.deck.length, declared.deck.length - f.nativeCount);
assert.equal(game.auction!.cards.length, f.nativeCount);
assert.equal(game.ixAuction ?? null, null);
} else {
assert.equal(game.ixTechnologyTurn, game.turn);
assert.equal(game.pendingIxTechnology ?? null, null);
assert.deepEqual(game.players.find(p => p.id === f.provider)!.hand,
declared.players.find(p => p.id === f.provider)!.hand);
assert.deepEqual(game.auction!.cards, declared.auction!.cards);
}
await restored(f, game);
});
}
for (const kind of ['bidding', 'technology'] as const) {
void test(`SQLite ${kind} racing two receiver passes retains the first acknowledgment and resumes only after a fresh second CAS`, async t => {
const f = await fixture(t, { kind, receiverCount: 2 });
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const auths = await Promise.all(f.required.map(id => authenticate(f, id)));
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
let results: PromiseSettledResult<unknown>[];
try {
results = await Promise.allSettled(auths.map(auth =>
f.restart().act(f.code, auth, f.initial.version,
{ type: 'nexusIxianBetrayalPass', event: f.event }, 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);
const first = await f.restart().readRoom(f.code);
const winner = f.required[results.findIndex(result => result.status === 'fulfilled')];
const loser = f.required[results.findIndex(result => result.status === 'rejected')];
assert.deepEqual(first.pendingNexusIxianBetrayal!.passed, [winner]);
assert.equal(first.version, f.initial.version + 1);
unchangedHistory(f, first);
assert.deepEqual(first.nexusCards!.cards, f.initial.nexusCards!.cards);
await restored(f, first);
await rejected(f, first, await authenticate(f, winner), { type: 'nexusIxianBetrayalPass', event: f.event });
const done = await act(f, first, loser, { type: 'nexusIxianBetrayalPass', event: f.event });
assert.equal(done.version, f.initial.version + 2);
settled(f, done, false);
await restored(f, done);
});
}
+225
View File
@@ -0,0 +1,225 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction,createGame,initializeIxianNexusBetrayalGameForAudit,joinGame,newPlayer,normalizeAutomaticGame,viewGame,type Game,type Action} from '../game/engine';
import {createIxianNexusBetrayalFixture,settleIxianBetrayalNativeCounters} from './fixture-nexus-ixian-betrayal';
const reload=(g:Game):Game=>JSON.parse(JSON.stringify(g));
const owner=(g:Game,id:string)=>g.players.find(p=>p.id===id)!;
function passNexus(state:Game):Game {
let g=state;
while(g.pendingNexusIxianBetrayal) {const f=g.pendingNexusIxianBetrayal;g=applyAction(g,f.required.find(id=>!f.passed.includes(id))!,{type:'nexusIxianBetrayalPass',event:f.source.event});}
return g;
}
function reject(g:Game,id:string,a:Action):void {const before=structuredClone(g);assert.throws(()=>applyAction(g,id,a));assert.deepEqual(g,before);}
const ledger=(g:Game)=>g.players.map(p=>({id:p.id,spice:p.spice,forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,hand:p.hand}));
for(const [kind,advanced] of [['bidding',false],['bidding',true],['technology',true]] as const) for(const use of [false,true]) {
void test(`${advanced?'Advanced':'Basic'} original ${kind} ${use?'prevention':'allowance'} resumes its native suffix once`,()=>{
const f=createIxianNexusBetrayalFixture({kind,advanced});
assert.deepEqual(settleIxianBetrayalNativeCounters(applyAction(f.beforeNativeAttempt,f.actor,f.declarationAction)),f.game);
const before=reload(f.game),nx=before.nexusCards!.cards!;
const g=use?applyAction(before,f.holder,{type:'nexusIxianBetrayalUse',event:f.event}):passNexus(before);
assert.equal(g.pendingNexusIxianBetrayal,null);
assert.equal(g.nexusIxianBetrayalHistory!.at(-1)!.outcome,use?'use':'pass');
assert.equal(g.nexusIxianBetrayalCursor!.sequence,before.nexusIxianBetrayalCursor!.sequence+1);
assert.deepEqual(g.discard,before.discard);
assert.deepEqual(g.players.map(p=>({spice:p.spice,forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites})),before.players.map(p=>({spice:p.spice,forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites})));
assert.equal(g.nexusCards!.cards!.discard.length,nx.discard.length+(use?1:0));
assert.equal(g.nexusCards!.cards!.hands[f.holder],use?null:'ixians');
if(kind==='bidding') {
assert.deepEqual(ledger(g),ledger(before));
assert.equal(before.deck.length-g.deck.length,f.nativeCount+(use?0:1));
if(use) {assert.equal(g.ixAuction,null);assert.equal(g.auction!.cards.length,f.nativeCount);assert.notEqual(g.decision?.kind,'ixAuction');}
else {assert.equal(g.decision?.kind,'ixAuction');assert.equal(g.ixAuction!.cards.length,f.nativeCount+1);assert.equal(g.auction,null);}
} else {
assert.equal(g.ixTechnologyTurn,g.turn);assert.equal(g.pendingIxTechnology,null);
assert.equal(g.auction!.index,before.auction!.index);
assert.equal(g.auction!.bid,before.auction!.bid);assert.equal(g.auction!.bidder,before.auction!.bidder);
const original=before.auction!.cards[before.auction!.index];
const expectedCards=[...before.auction!.cards];
if(!use) expectedCards[before.auction!.index]=f.selected!;
assert.deepEqual(g.auction!.cards,expectedCards);
if(use) assert.deepEqual(owner(g,f.provider).hand,owner(before,f.provider).hand);
else {assert.ok(owner(g,f.provider).hand.some(c=>c.id===original.id));assert.ok(!owner(g,f.provider).hand.some(c=>c.id===f.selected!.id));}
assert.deepEqual(g.deck,before.deck);
const atreides=before.players.find(p=>p.faction==='atreides')!.id;
assert.equal(viewGame(before,atreides).auction!.card,null,'the native peek cannot precede the Nexus acknowledgment');
const settled=settleIxianBetrayalNativeCounters(g);
assert.equal(settled.auction!.peekKnown,true);
assert.equal(settled.response,null);
assert.deepEqual(viewGame(settled,atreides).auction!.card,use?original:f.selected);
for(const rival of settled.players.filter(p=>p.id!==atreides))
assert.equal(viewGame(settled,rival.id).auction!.card,null);
assert.deepEqual(settled.auction!.cards,expectedCards);
assert.equal(settled.auction!.index,before.auction!.index);
assert.deepEqual(ledger(settled),ledger(g));
assert.deepEqual(settled.deck,g.deck);
assert.deepEqual(settled.discard,g.discard);
assert.deepEqual(settled.nexusCards,g.nexusCards);
assert.deepEqual(settled.nexusIxianBetrayalHistory,g.nexusIxianBetrayalHistory);
assert.deepEqual(settleIxianBetrayalNativeCounters(settled),settled);
}
reject(g,f.holder,{type:'nexusIxianBetrayalUse',event:f.event});
reject(g,f.holder,{type:'nexusIxianBetrayalPass',event:f.event});
viewGame(reload(g),f.holder);
});
}
for(const kind of ['bidding','technology'] as const) void test(`printed native Karama preempts ${kind} with no Nexus gate or cost`,()=>{
const f=createIxianNexusBetrayalFixture({kind,advanced:true}),before=f.beforeNativeCounter;
const seat=before.players.find(p=>p.id!==f.provider&&p.hand.some(c=>c.effect==='karama'))!;
assert.ok(seat);
const card=seat.hand.find(c=>c.effect==='karama')!;
const g=applyAction(before,seat.id,{type:'card',mode:'cancel',card:card.id});
assert.equal(g.pendingNexusIxianBetrayal,null);
assert.deepEqual(g.nexusCards,before.nexusCards);
assert.deepEqual(g.nexusIxianBetrayalHistory,before.nexusIxianBetrayalHistory);
assert.ok(g.discard.some(c=>c.id===card.id));
if(kind==='bidding') {assert.equal(g.ixAuction,null);assert.equal(g.auction!.cards.length,f.nativeCount);}
else {assert.equal(g.pendingIxTechnology,null);assert.equal(g.ixTechnologyTurn,g.turn);assert.deepEqual(owner(g,f.provider).hand,owner(before,f.provider).hand);assert.deepEqual(g.auction!.cards,before.auction!.cards);}
});
for(const kind of ['bidding','technology'] as const) void test(`native BG Worthless Karama suspends and cancels ${kind} before any Nexus acknowledgment`,()=>{
const lobby=createGame('BGCOUNTER',newPlayer('ix','Ixians','ixians'),true,['ix']);
joinGame(lobby,newPlayer('a','Atreides','atreides'));
joinGame(lobby,newPlayer('bg','Bene Gesserit','beneGesserit'));
joinGame(lobby,newPlayer('tl','Tleilaxu','tleilaxu'));
const f=createIxianNexusBetrayalFixture({initial:lobby,kind,receiverCount:2});
const before=f.beforeNativeCounter,fuel=owner(before,'bg').hand.find(c=>c.kind==='worthless')!;
assert.ok(fuel);
let g=applyAction(before,'bg',{type:'card',mode:'cancel',card:fuel.id});
assert.equal(g.response?.kind,'worthlessKarama');
assert.equal(g.pendingKarama?.use.kind,'cancel');
for(const seat of g.players) {
assert.equal(viewGame(g,seat.id).nexusIxianBetrayalReaction,null);
assert.equal(viewGame(g,seat.id).auction?.card??null,null);
}
g=settleIxianBetrayalNativeCounters(reload(g));
assert.equal(g.pendingKarama,null);
assert.equal(g.pendingNexusIxianBetrayal,null);
assert.deepEqual(g.nexusCards,before.nexusCards);
assert.deepEqual(g.nexusIxianBetrayalHistory,before.nexusIxianBetrayalHistory);
assert.deepEqual(g.nexusIxianBetrayalCursor,before.nexusIxianBetrayalCursor);
assert.equal(g.discard.filter(c=>c.id===fuel.id).length,1);
if(kind==='bidding') {
assert.equal(g.ixAuction,null);
assert.equal(g.auction!.cards.length,f.nativeCount);
assert.equal(before.deck.length-g.deck.length,f.nativeCount);
} else {
assert.equal(g.pendingIxTechnology,null);
assert.equal(g.ixTechnologyTurn,g.turn);
assert.deepEqual(g.auction!.cards,before.auction!.cards);
assert.deepEqual(owner(g,f.provider).hand,owner(before,f.provider).hand);
assert.deepEqual(g.deck,before.deck);
assert.equal(viewGame(g,'a').auction!.card!.id,before.auction!.cards[before.auction!.index].id);
}
});
for(const returnDeclared of [false,true]) void test(`native Harkonnen exchange ${returnDeclared?'restores':'removes'} a paused declared Technology card without stranding its original continuation`,()=>{
const lobby=createGame('HARKCOUNTER',newPlayer('h','Harkonnen','harkonnen'),true,['ix']);
joinGame(lobby,newPlayer('ix','Ixians','ixians'));
joinGame(lobby,newPlayer('a','Atreides','atreides'));
joinGame(lobby,newPlayer('g','Guild','guild'));
const f=createIxianNexusBetrayalFixture({initial:lobby,kind:'technology'});
const before=f.beforeNativeCounter,karama=owner(before,'h').hand.find(c=>c.effect==='karama')!;
const returned=owner(before,'h').hand.find(c=>c.id!==karama?.id)!;
assert.ok(karama);assert.ok(returned);
assert.equal(owner(before,f.provider).hand.length,1);
const original=before.auction!.cards[before.auction!.index],declared=f.selected!;
let g=applyAction(before,'h',{type:'card',mode:'special',card:karama.id,target:f.provider,amount:1});
assert.equal(g.decision?.kind,'handExchange');
assert.equal(g.pendingExchange!.response!.kind,'ixTechnology');
assert.ok(owner(g,'h').hand.some(c=>c.id===declared.id));
for(const seat of g.players) assert.equal(viewGame(g,seat.id).nexusIxianBetrayalReaction,null);
const orphan=reload(g);
orphan.pendingExchange!.response!.owner=f.holder;
assert.throws(()=>viewGame(orphan,f.holder));
assert.throws(()=>normalizeAutomaticGame(orphan));
reject(orphan,'h',{type:'decision',returnCards:[returned.id]});
g=applyAction(reload(g),'h',{type:'decision',returnCards:[returnDeclared?declared.id:returned.id]});
g=settleIxianBetrayalNativeCounters(g);
assert.deepEqual(g.nexusCards,before.nexusCards);
assert.deepEqual(g.deck,before.deck);
if(returnDeclared) {
assert.equal(g.pendingNexusIxianBetrayal!.source.kind,'technology');
assert.equal(viewGame(g,'a').auction!.card,null);
g=settleIxianBetrayalNativeCounters(passNexus(g));
assert.equal(g.auction!.cards[g.auction!.index].id,declared.id);
assert.equal(owner(g,f.provider).hand[0].id,original.id);
assert.equal(g.nexusIxianBetrayalCursor!.sequence,before.nexusIxianBetrayalCursor!.sequence+1);
} else {
assert.equal(g.pendingNexusIxianBetrayal,null);
assert.deepEqual(g.nexusIxianBetrayalHistory,before.nexusIxianBetrayalHistory);
assert.deepEqual(g.nexusIxianBetrayalCursor,before.nexusIxianBetrayalCursor);
assert.deepEqual(g.auction!.cards,before.auction!.cards);
assert.equal(owner(g,f.provider).hand[0].id,returned.id);
assert.ok(owner(g,'h').hand.some(c=>c.id===declared.id));
}
assert.equal(g.pendingExchange,null);
assert.equal(g.pendingIxTechnology,null);
assert.equal(g.ixTechnologyTurn,g.turn);
assert.equal(g.discard.filter(c=>c.id===karama.id).length,1);
assert.equal(g.auction!.peekKnown,true);
assert.equal(viewGame(g,'a').auction!.card!.id,returnDeclared?declared.id:original.id);
assert.deepEqual(settleIxianBetrayalNativeCounters(g),g);
});
void test('neutral required membership, public source and rivals are independent of held face',()=>{
const ix=createIxianNexusBetrayalFixture({receiverCount:2}),irrelevant=createIxianNexusBetrayalFixture({receiverCount:2,face:'richese'});
assert.deepEqual(ix.required,irrelevant.required);assert.equal(ix.required.length,2);
for(const id of ix.game.players.filter(p=>!ix.required.includes(p.id)).map(p=>p.id)) {
assert.deepEqual(viewGame(ix.game,id).nexusIxianBetrayalReaction,viewGame(irrelevant.game,id).nexusIxianBetrayalReaction);
const text=JSON.stringify(viewGame(ix.game,id));
assert.ok(!text.includes(ix.game.pendingNexusIxianBetrayal!.source.parent));
assert.ok(!text.includes('nativeContext'));
}
assert.equal(viewGame(ix.game,ix.holder).nexusIxianBetrayalReaction!.canUse,true);
assert.equal(viewGame(irrelevant.game,irrelevant.holder).nexusIxianBetrayalReaction!.canUse,false);
assert.equal(viewGame(ix.game,ix.holder).active,null);
const passed=applyAction(ix.game,ix.holder,{type:'nexusIxianBetrayalPass',event:ix.event});
assert.equal(viewGame(passed,ix.holder).active,ix.required.find(id=>id!==ix.holder));
reject(passed,ix.holder,{type:'nexusIxianBetrayalUse',event:ix.event});
});
void test('all seats retain independent AI control while native resources and response event are locked',()=>{
const f=createIxianNexusBetrayalFixture({receiverCount:2});
for(const seat of f.game.players) {
const controlled=applyAction(f.game,seat.id,{type:'setAutopilot',difficulty:'Hard'});
assert.deepEqual(controlled.pendingNexusIxianBetrayal,f.game.pendingNexusIxianBetrayal);
assert.deepEqual(ledger(controlled),ledger(f.game));
assert.equal(owner(controlled,seat.id).autopilot,'Hard');
viewGame(controlled,seat.id);
}
for(const a of [{type:'bid',amount:1},{type:'passResponse'},{type:'ready'},{type:'decision',card:owner(f.game,f.provider).hand[0].id}]) reject(f.game,f.holder,a);
});
void test('wrong role, wrong event and selector-bearing actions reject without cost or native mutation',()=>{
const f=createIxianNexusBetrayalFixture();
for(const a of [{type:'nexusIxianBetrayalUse',event:'expired'}, {type:'nexusIxianBetrayalUse',event:f.event,card:'ixians'}, {type:'nexusIxianBetrayalPass',event:f.event,kind:'bidding'}, {type:'nexusIxianBetrayalUse',event:f.event,provider:f.provider}]) reject(f.game,f.holder,a);
reject(f.game,f.provider,{type:'nexusIxianBetrayalPass',event:f.event});
reject(f.game,f.provider,{type:'nexusIxianBetrayalUse',event:f.event});
});
for(const kind of ['bidding','technology'] as const) void test(`${kind} rejects orphan, parent, physical cost and native declaration corruption at read and action boundaries`,()=>{
const f=createIxianNexusBetrayalFixture({kind,advanced:true});
const mutations:((g:Game)=>void)[]=[g=>{g.pendingNexusIxianBetrayal=null;},g=>{delete g.nexusIxianBetrayalPreview;},g=>{g.nexusIxianBetrayalCursor!.sequence++;},g=>{g.pendingNexusIxianBetrayal!.source.parent+='changed';},g=>{g.deck[0].name='forged';},g=>{owner(g,f.provider).spice++;},g=>{g.nexusCards!.cards!.hands[f.holder]=null;},g=>{g.pendingNexusIxianBetrayal!.continuation.owner=f.holder;},g=>{g.turn++;}];
if(kind==='technology') mutations.push(g=>{g.ixTechnologyTurn=0;},g=>{g.pendingIxTechnology!.card=owner(g,f.holder).hand[0].id;},g=>{
const hand=owner(g,f.provider).hand,index=hand.findIndex(c=>c.id===f.selected!.id);
g.discard.push(hand.splice(index,1)[0]);
});
else mutations.push(g=>{g.ixAuction!.count++;});
for(const corrupt of mutations) {
const g=reload(f.game);corrupt(g);const before=structuredClone(g);
assert.throws(()=>viewGame(g,f.holder));assert.throws(()=>normalizeAutomaticGame(g));
reject(g,f.holder,{type:'nexusIxianBetrayalUse',event:f.event});assert.deepEqual(g,before);
}
});
void test('fresh CLI setup continuation preserves original identities, dealt traitor choices and profile without a redeal',()=>{
let lobby=createGame('ORIGINALIX',newPlayer('native','Original Ix','ixians'),true,['ix']);
joinGame(lobby,newPlayer('receiver','Original Atreides','atreides'));
joinGame(lobby,newPlayer('partner','Original Emperor','emperor'));
for(const p of lobby.players) lobby=applyAction(lobby,p.id,{type:'ready'});
lobby.nexusCards={cards:null,phase:null};
const setup=initializeIxianNexusBetrayalGameForAudit(lobby);
assert.equal(setup.status,'setup');assert.equal(setup.nexusCards!.cards!.hands.receiver,null);
const original=structuredClone(setup);
assert.throws(()=>initializeIxianNexusBetrayalGameForAudit(setup));
assert.deepEqual(setup,original);
const fixture=createIxianNexusBetrayalFixture({initial:setup,kind:'technology'});
assert.equal(fixture.game.code,setup.code);assert.equal(fixture.game.host,setup.host);
assert.equal(fixture.game.advanced,setup.advanced);
assert.deepEqual(fixture.game.players.map(p=>[p.id,p.name,p.faction]),setup.players.map(p=>[p.id,p.name,p.faction]));
for(const p of setup.players) assert.ok(fixture.game.players.find(s=>s.id===p.id)!.traitors.every(id=>p.traitorChoices.includes(id)));
assert.deepEqual(setup,original);
});
+297
View File
@@ -0,0 +1,297 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { treacheryDeck } from '../game/cards';
import { NEXUS_FACTIONS, type NexusState } from '../game/nexus-cards';
import {
IxianBetrayalError, closeIxianBetrayalSource, createIxianBetrayalSource,
initialIxianBetrayalCursor, ixianBetrayalEligible, ixianBetrayalResponders,
validateIxianBetrayalFrame, validateIxianBetrayalHistory, validateIxianBetrayalSource,
type IxianBetrayalAuthority, type IxianBetrayalContext, type IxianBetrayalKind,
type IxianBetrayalReceipt,
} from '../game/nexus-ixian-betrayal';
const parent = 'current-original-native-attempt-parent';
function context(face: 'ixians' | 'richese' = 'ixians'): IxianBetrayalContext {
return {
status: 'playing', phase: 3, turn: 2, advanced: true, sequence: 0,
players: [
{ id: 'ix', faction: 'ixians' }, { id: 'holder', faction: 'atreides' },
{ id: 'other', faction: 'fremen' }, { id: 'ally', faction: 'guild' },
],
cards: {
version: 1, deck: NEXUS_FACTIONS.filter(card => card !== face && card !== 'harkonnen'),
discard: [], hands: { ix: null, holder: face, other: 'harkonnen', ally: null },
},
physicalCards: treacheryDeck(['ix']),
};
}
function authority(kind: IxianBetrayalKind = 'bidding'): IxianBetrayalAuthority {
return {
kind, provider: 'ix', nativeWindow: kind === 'bidding' ? 'ixAuction' : 'ixTechnology',
nativeContext: kind === 'bidding' ? 'undrawn-native-pool' : 'declared-unused-lot-exchange',
nativeRequired: ['holder'], nativePassed: ['other', 'holder'],
};
}
function freeze(value: unknown): void {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return;
for (const child of Object.values(value)) freeze(child);
Object.freeze(value);
}
for (const kind of ['bidding', 'technology'] as const) {
void test(`${kind} authorizes only the current native provider, phase, context and cursor`, () => {
const ctx = context(), native = authority(kind);
const source = createIxianBetrayalSource(ctx, native, parent);
const invalidContexts: IxianBetrayalContext[] = [
{ ...ctx, status: 'setup' }, { ...ctx, phase: 4 }, { ...ctx, turn: ctx.turn + 1 },
{ ...ctx, sequence: 1 }, { ...ctx, advanced: false },
{ ...ctx, players: ctx.players.map(player => player.id === 'ix'
? { ...player, faction: 'emperor' } : player) },
];
for (const changed of invalidContexts) {
assert.throws(() => validateIxianBetrayalSource(changed, source, native, parent));
}
const invalidAuthorities: IxianBetrayalAuthority[] = [
{ ...native, provider: 'holder' }, { ...native, nativeContext: 'another-native-attempt' },
{ ...native, nativePassed: ['holder'] }, { ...native, nativeRequired: [] },
{ ...native, kind: kind === 'bidding' ? 'technology' : 'bidding' },
];
for (const changed of invalidAuthorities) {
assert.throws(() => validateIxianBetrayalSource(ctx, source, changed, parent), IxianBetrayalError);
}
assert.throws(() => validateIxianBetrayalSource(ctx, source, native, 'different-parent'), IxianBetrayalError);
assert.deepEqual(source.required, ['holder', 'other']);
});
void test(`${kind} cannot precede its original native counter completion or change native window`, () => {
const ctx = context(), native = authority(kind);
const invalidAuthorities: IxianBetrayalAuthority[] = [
{ ...native, nativePassed: ['other'] },
{ ...native, nativePassed: ['holder', 'holder'] },
{ ...native, nativeRequired: ['holder', 'holder'] },
{ ...native, nativeRequired: ['absent'], nativePassed: ['absent'] },
{ ...native, nativePassed: ['holder', 'absent'] },
{ ...native, nativeWindow: kind === 'bidding' ? 'ixTechnology' : 'ixAuction' },
{ ...native, provider: 'other' }, { ...native, nativeContext: '' },
];
for (const changed of invalidAuthorities) {
assert.throws(() => createIxianBetrayalSource(ctx, changed, parent), IxianBetrayalError);
}
const noNativeCounter = { ...native, nativeRequired: [], nativePassed: [] };
const source = createIxianBetrayalSource(ctx, noNativeCounter, parent);
validateIxianBetrayalFrame(ctx, source, noNativeCounter, parent, ['holder', 'other'], []);
});
void test(`${kind} requires exact public responder and unique passed membership before any continuation`, () => {
const ctx = context(), native = authority(kind);
const source = createIxianBetrayalSource(ctx, native, parent);
validateIxianBetrayalFrame(ctx, source, native, parent, ['holder', 'other'], ['other']);
for (const [required, passed] of [
[['holder'], []], [['other', 'holder'], []], [['holder', 'other', 'ally'], []],
[['holder', 'other', 'other'], []], [['holder', 'other'], ['holder', 'holder']],
[['holder', 'other'], ['ix']], [['holder', 'other'], ['absent']],
]) {
assert.throws(() => validateIxianBetrayalFrame(ctx, source, native, parent, required, passed), IxianBetrayalError);
}
const missingCard = structuredClone(ctx);
missingCard.cards.hands.other = null;
missingCard.cards.deck.push('harkonnen');
assert.throws(() => validateIxianBetrayalFrame(missingCard, source, native, parent,
['holder', 'other'], []), IxianBetrayalError);
const allied = structuredClone(ctx);
allied.players = allied.players.map(player => player.id === 'holder' ? { ...player, ally: 'ally' }
: player.id === 'ally' ? { ...player, ally: 'holder' } : player);
assert.throws(() => validateIxianBetrayalFrame(allied, source, native, parent,
['holder', 'other'], []), IxianBetrayalError);
});
void test(`${kind} rejects corrupted saved event, descriptor, role, membership and extra authority fields`, () => {
const ctx = context(), native = authority(kind);
const source = createIxianBetrayalSource(ctx, native, parent);
for (const patch of [
{ event: 'invented-event' }, { provider: 'other' }, { sequence: 1 }, { turn: 1 },
{ kind: 'invented' }, { nativeContext: 'substituted-card' }, { signature: 'invented-signature' },
{ required: ['holder'] }, { eligible: 'other' }, { nativePassed: [] }, { extra: 'client-chosen-source' },
]) {
const corrupt = Object.assign(structuredClone(source), patch);
assert.throws(() => validateIxianBetrayalFrame(ctx, corrupt, native, parent,
['holder', 'other'], []), IxianBetrayalError);
}
assert.throws(() => createIxianBetrayalSource(ctx,
Object.assign({}, native, { selectedCard: 'client-chosen-card' }), parent), IxianBetrayalError);
assert.throws(() => createIxianBetrayalSource(ctx, native, ''), IxianBetrayalError);
});
}
void test('Basic authorizes native Bidding but never Technology', () => {
const ctx = { ...context(), advanced: false };
const bidding = createIxianBetrayalSource(ctx, authority('bidding'), parent);
validateIxianBetrayalSource(ctx, bidding, authority('bidding'), parent);
assert.throws(() => createIxianBetrayalSource(ctx, authority('technology'), parent), IxianBetrayalError);
});
void test('public held-card parity does not authorize an irrelevant face, owner or allied holder', () => {
const ix = context(), irrelevant = context('richese');
const realSource = createIxianBetrayalSource(ix, authority(), parent);
const irrelevantSource = createIxianBetrayalSource(irrelevant, authority(), parent);
assert.equal(realSource.event, irrelevantSource.event);
assert.deepEqual(realSource.required, irrelevantSource.required);
assert.throws(() => closeIxianBetrayalSource(irrelevantSource, 'use', 'holder',
initialIxianBetrayalCursor()), IxianBetrayalError);
assert.deepEqual(ixianBetrayalResponders(ix.cards, ix.players), ['holder', 'other']);
assert.deepEqual(ixianBetrayalResponders(irrelevant.cards, irrelevant.players), ['holder', 'other']);
assert.equal(ixianBetrayalEligible(ix.cards, ix.players, 'holder'), true);
assert.equal(ixianBetrayalEligible(irrelevant.cards, irrelevant.players, 'holder'), false);
for (const holder of ['ix', 'other', 'ally', 'absent']) {
assert.equal(ixianBetrayalEligible(ix.cards, ix.players, holder), false);
}
const allied = context();
allied.players = allied.players.map(player => player.id === 'holder' ? { ...player, ally: 'ally' }
: player.id === 'ally' ? { ...player, ally: 'holder' } : player);
assert.deepEqual(ixianBetrayalResponders(allied.cards, allied.players), ['other']);
assert.equal(ixianBetrayalEligible(allied.cards, allied.players, 'holder'), false);
const nativeHeld = context();
nativeHeld.cards.hands.holder = null;
nativeHeld.cards.hands.ix = 'ixians';
assert.deepEqual(ixianBetrayalResponders(nativeHeld.cards, nativeHeld.players), ['other']);
assert.equal(ixianBetrayalEligible(nativeHeld.cards, nativeHeld.players, 'ix'), false);
});
void test('Nexus eligibility cannot accept duplicate, missing, foreign-seat or unknown physical stock', () => {
const variants: NexusState[] = [];
const duplicate = context().cards;
duplicate.deck.push('ixians'); variants.push(duplicate);
const missing = context().cards;
missing.hands.holder = null; variants.push(missing);
const foreign = context().cards;
foreign.hands.foreign = null; variants.push(foreign);
const wrongVersion = context().cards;
Object.assign(wrongVersion, { version: 2 }); variants.push(wrongVersion);
const unknown = context().cards;
Object.assign(unknown.deck, { 0: 'nonphysical-face' }); variants.push(unknown);
for (const cards of variants) {
assert.throws(() => ixianBetrayalEligible(cards, context().players, 'holder'));
assert.throws(() => ixianBetrayalResponders(cards, context().players));
}
const ctx = context();
ctx.players = ctx.players.map(player => player.id === 'other' ? { ...player, ally: 'holder' } : player);
assert.throws(() => ixianBetrayalEligible(ctx.cards, ctx.players, 'holder'));
});
void test('both native kinds require canonical complete Ix47 stock, not matching fabricated descriptors', () => {
for (const kind of ['bidding', 'technology'] as const) {
const native = authority(kind);
for (const patch of [
{ name: 'Substituted face' }, { kind: 'special' }, { effect: 'fabricated' },
{ id: 'foreign-physical-card' }, { extra: 'private-descriptor-field' },
]) {
const ctx = context();
const corrupt = Object.assign({}, ctx.physicalCards[0], patch);
ctx.physicalCards = [corrupt, ...ctx.physicalCards.slice(1)];
assert.throws(() => createIxianBetrayalSource(ctx, native, parent), IxianBetrayalError);
}
for (const cards of [treacheryDeck(), treacheryDeck(['ix']).slice(1),
[...treacheryDeck(['ix']), treacheryDeck(['ix'])[0]],
treacheryDeck(['ix']).map((card, index) => index === 0 ? treacheryDeck(['ix'])[1] : card),
]) {
assert.throws(() => createIxianBetrayalSource({ ...context(), physicalCards: cards }, native, parent), IxianBetrayalError);
}
const excluded = context();
excluded.players = excluded.players.map(player => player.id === 'other' ? { ...player, faction: 'richese' } : player);
assert.throws(() => createIxianBetrayalSource(excluded, native, parent), IxianBetrayalError);
}
});
void test('creation, validation and closure accept frozen inputs without aliasing source memberships', () => {
const ctx = context(), native = authority();
const snapshot = structuredClone({ ctx, native });
freeze(ctx); freeze(native);
const source = createIxianBetrayalSource(ctx, native, parent);
freeze(source);
const cursor = initialIxianBetrayalCursor(); freeze(cursor);
const closed = closeIxianBetrayalSource(source, 'use', 'holder', cursor);
validateIxianBetrayalFrame(ctx, source, native, parent, source.required, ['other']);
validateIxianBetrayalHistory([closed.receipt], closed.cursor, ctx.physicalCards);
assert.deepEqual({ ctx, native }, snapshot);
assert.deepEqual(cursor, initialIxianBetrayalCursor());
assert.notEqual(closed.receipt.source, source);
for (const key of ['required', 'nativeRequired', 'nativePassed'] as const) {
assert.notEqual(closed.receipt.source[key], source[key]);
assert.deepEqual(closed.receipt.source[key], source[key]);
}
});
void test('closure enforces use/pass outcomes, required holder and independent exactly-once cursor', () => {
const source = createIxianBetrayalSource(context(), authority(), parent);
const cursor = initialIxianBetrayalCursor();
for (const holder of ['ix', 'other', 'ally', 'absent', null]) {
assert.throws(() => closeIxianBetrayalSource(source, 'use', holder, cursor), IxianBetrayalError);
}
assert.throws(() => closeIxianBetrayalSource(source, 'pass', 'holder', cursor), IxianBetrayalError);
const used = closeIxianBetrayalSource(source, 'use', 'holder', cursor);
assert.equal(used.cursor.sequence, 1);
assert.throws(() => closeIxianBetrayalSource(source, 'use', 'holder', used.cursor), IxianBetrayalError);
assert.throws(() => validateIxianBetrayalSource({ ...context(), sequence: 1 }, source, authority(), parent), IxianBetrayalError);
const passed = closeIxianBetrayalSource(source, 'pass', null, cursor);
validateIxianBetrayalHistory([passed.receipt], passed.cursor);
assert.throws(() => validateIxianBetrayalHistory([used.receipt], passed.cursor), IxianBetrayalError);
});
void test('closed history remains valid after physical Nexus recycling and a later holder change', () => {
const ctx = context();
const first = createIxianBetrayalSource(ctx, authority(), parent);
const closed = closeIxianBetrayalSource(first, 'use', 'holder', initialIxianBetrayalCursor());
ctx.cards.hands.holder = null;
ctx.cards.discard.push('ixians');
validateIxianBetrayalHistory([closed.receipt], closed.cursor, [...ctx.physicalCards].reverse());
assert.equal(ixianBetrayalEligible(ctx.cards, ctx.players, 'holder'), false);
// A later settled Nexus can recycle the same physical card into another hand.
ctx.cards.discard.pop();
ctx.cards.deck.push('harkonnen');
ctx.cards.hands.other = 'ixians';
ctx.turn++; ctx.sequence = closed.cursor.sequence;
const second = createIxianBetrayalSource(ctx, authority('technology'), 'later-native-parent');
const later = closeIxianBetrayalSource(second, 'use', 'other', closed.cursor);
assert.equal(ixianBetrayalEligible(ctx.cards, ctx.players, 'other'), true);
validateIxianBetrayalHistory([closed.receipt, later.receipt], later.cursor, ctx.physicalCards);
assert.throws(() => validateIxianBetrayalSource(ctx, first, authority(), parent), IxianBetrayalError);
});
void test('history rejects deletion, duplicate/reordered closure, forged outcomes and disconnected cursor', () => {
const ctx = context();
const first = closeIxianBetrayalSource(createIxianBetrayalSource(ctx, authority(), parent),
'use', 'holder', initialIxianBetrayalCursor());
ctx.sequence = first.cursor.sequence;
const second = closeIxianBetrayalSource(createIxianBetrayalSource(ctx, authority('technology'), parent),
'pass', null, first.cursor);
const history = [first.receipt, second.receipt];
for (const corrupt of [[], [second.receipt], [first.receipt, first.receipt],
[second.receipt, first.receipt], [first.receipt, second.receipt, second.receipt]]) {
assert.throws(() => validateIxianBetrayalHistory(corrupt, second.cursor), IxianBetrayalError);
}
for (const patch of [{ holder: 'other' }, { outcome: 'pass' }, { previous: 'unrelated-head' },
{ signature: 'forged' }, { extra: 'hidden-current-ownership' }]) {
const changed = Object.assign(structuredClone(first.receipt), patch);
assert.throws(() => validateIxianBetrayalHistory([changed, second.receipt], second.cursor), IxianBetrayalError);
}
assert.throws(() => validateIxianBetrayalHistory(history, first.cursor), IxianBetrayalError);
assert.throws(() => validateIxianBetrayalHistory(history, { sequence: 2, signature: 'disconnected' }), IxianBetrayalError);
});
void test('persisted multi-attempt history grows linearly with bounded predecessor links', () => {
const ctx = context();
const history: IxianBetrayalReceipt[] = [];
let cursor = initialIxianBetrayalCursor();
for (let index = 0; index < 80; index++) {
ctx.turn = 1 + Math.floor(index / 8); ctx.sequence = cursor.sequence;
const source = createIxianBetrayalSource(ctx, authority(index % 2 ? 'technology' : 'bidding'), parent);
const closed = closeIxianBetrayalSource(source, index % 2 ? 'use' : 'pass', index % 2 ? 'holder' : null, cursor);
history.push(closed.receipt); cursor = closed.cursor;
}
validateIxianBetrayalHistory(history, cursor, ctx.physicalCards);
const firstSize = JSON.stringify(history[0]).length;
assert.ok(JSON.stringify(history.at(-1)).length < firstSize * 2,
'later saved attempts must not recursively embed previous full receipts');
assert.ok(JSON.stringify(history).length < firstSize * history.length * 2,
'a full game horizon must remain linearly serializable for saved-room recovery');
});
+165
View File
@@ -19,6 +19,7 @@ import {
startPrototypeRoom, startPrototypeRoom,
} from '../tools/prototype-room'; } from '../tools/prototype-room';
import { NEXUS_FACTIONS } from '../game/nexus-cards'; import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { createLeaderSkills } from '../game/leader-skills';
function fixture(t: test.TestContext) { function fixture(t: test.TestContext) {
const db = new DatabaseSync(':memory:'); const db = new DatabaseSync(':memory:');
@@ -487,3 +488,167 @@ void test('Ixian replacement CLI takes a private exact-version backup before sta
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after); 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); assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
}); });
for (const advanced of [false, true]) {
for (const seats of [2, 3, 4, 6]) {
void test(`Ixian Betrayal fresh ${advanced ? 'Advanced' : 'Basic'} ${seats}-seat entry keeps original identity and undealt Nexus`, t => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('i', 'Native Ixians', 'ixians'), advanced, ['ix']);
const factions = ['atreides', 'emperor', 'guild', 'tleilaxu', 'fremen'] as const;
for (let index = 0; index < seats - 1; index++)
joinGame(game, newPlayer(`p${index}`, `Original ${index}`, factions[index]));
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const credentials = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 6, 'ixian-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
const started = startPrototypeRoom(db, game.code, 7, 'ixian-betrayal');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(started.version, 8);
assert.equal(saved.version, 8);
assert.equal(saved.status, 'setup');
assert.equal(saved.code, game.code);
assert.equal(saved.host, game.host);
assert.equal(saved.advanced, game.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.nexusIxianBetrayalPreview, true);
const physical = [...saved.deck, ...saved.discard, ...saved.players.flatMap(p => p.hand), ...(saved.ixSetupCards ?? [])];
assert.equal(physical.length, 47);
assert.equal(new Set(physical.map(card => card.id)).size, 47);
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort(), [...NEXUS_FACTIONS].sort());
assert.deepEqual(saved.nexusCards!.cards!.discard, []);
for (const p of saved.players) {
assert.equal(saved.nexusCards!.cards!.hands[p.id], null);
const view = viewGame(saved, p.id);
assert.equal(view.nexusIxianBetrayalPreview, true);
assert.equal(view.nexusIxianBetrayalReaction, null);
assert.equal(view.nexusCards!.card, null);
assert.equal(Object.hasOwn(view.nexusCards!, 'deck'), false);
}
const after = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'ixian-betrayal'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'ix'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
}
void test('Ixian Betrayal rejects incompatible original lobbies and public start without durable changes', t => {
const { db } = fixture(t);
const native = createGame('PROTOTYP', newPlayer('i', 'Ixians', 'ixians'), true, ['ix']);
joinGame(native, newPlayer('a', 'Atreides', 'atreides'));
for (const p of native.players) p.ready = true;
const alterations: ((g: Game) => void)[] = [
g => { g.players[0].ready = false; },
g => { g.code = 'NOTMATCH'; },
g => { g.players[0].faction = 'emperor'; },
g => { g.players[1].faction = 'ixians'; },
g => { g.players[1].faction = 'choam'; },
g => { g.players[1].faction = 'richese'; },
g => { g.players[1].faction = 'ecaz'; },
g => { g.players[1].faction = 'moritani'; },
g => { g.expansions = []; },
g => { g.expansions = ['ix', 'choam']; },
g => { g.expansions = ['ix', 'ecaz']; },
g => { g.discoveryEnabled = true; },
g => { g.ecazTreachery = true; },
g => { g.semutaPreview = true; },
g => { g.nexusIxianReplacementPreview = true; },
g => { g.nexusIxianBetrayalPreview = true; },
g => { g.kullPreview = true; },
g => { g.nexusKullPreview = true; },
g => { g.guildBetrayalPreview = true; },
g => { g.richeseBetrayalPreview = true; },
g => { g.moritaniAssassinatePreview = true; },
g => { g.leaderSkills = createLeaderSkills(() => 0.5); },
g => { g.players.pop(); },
g => { g.players.push(...Array.from({ length: 5 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
];
const modules = ['homeworlds', 'techTokens', 'strongholdCards'].map(type => {
const game = applyAction(native, native.host, { type, enabled: true });
for (const p of game.players) p.ready = true;
return game;
});
const candidates = alterations.map(change => {
const game = structuredClone(native);
change(game);
return game;
}).concat(modules);
for (const game of candidates) {
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), native.code);
const before = db.prepare('SELECT * FROM rooms ORDER BY code').all();
const credentials = db.prepare('SELECT * FROM seats').all();
const changes = db.prepare('SELECT total_changes() AS changes').get()!.changes;
assert.throws(() => startPrototypeRoom(db, native.code, 7, 'ixian-betrayal'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), credentials);
assert.equal(db.prepare('SELECT total_changes() AS changes').get()!.changes, changes);
}
const original = structuredClone(native);
for (const action of [{ type: 'start' }, { type: 'start', advancedPreview: true }])
assert.throws(() => applyAction(native, native.host, action));
assert.deepEqual(native, original);
});
void test('Ixian Betrayal CLI preserves a private consistent exact-version backup and never overwrites proof or resets a started room', t => {
const directory = mkdtempSync(join(tmpdir(), 'dune-ixian-betrayal-'));
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('i', 'Native Ixians', 'ixians'), true, ['ix']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
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 = createGame('KEEPGAME', newPlayer('a', 'Atreides', 'atreides'), true);
joinGame(other, newPlayer('e', 'Emperor', 'emperor'));
for (const p of other.players) p.ready = true;
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 credentials = 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-betrayal', '--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(), credentials);
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(), credentials);
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.nexusIxianBetrayalPreview, true);
assert.equal(saved.status, 'setup');
assert.equal(saved.advanced, game.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.notEqual(invoke(8, output).status, 0);
assert.notEqual(invoke(7, join(directory, 'stale-proof')).status, 0);
assert.notEqual(invoke(8, join(directory, 'redeal-proof')).status, 0);
assert.deepEqual(current.prepare('SELECT * FROM rooms ORDER BY code').all(), after);
assert.deepEqual(current.prepare('SELECT * FROM seats ORDER BY player_id').all(), credentials);
});
+7
View File
@@ -13,6 +13,7 @@ import {
initializeNexusKullGameForAudit, initializeNexusKullGameForAudit,
initializeGuildBetrayalGameForAudit, initializeGuildBetrayalGameForAudit,
initializeIxianNexusReplacementGameForAudit, initializeIxianNexusReplacementGameForAudit,
initializeIxianNexusBetrayalGameForAudit,
viewGame, viewGame,
type Game, type Game,
} from '../game/engine'; } from '../game/engine';
@@ -31,6 +32,7 @@ export const PROTOTYPE_PROFILES = [
'nexus-kull', 'nexus-kull',
'guild-betrayal', 'guild-betrayal',
'ixian-replacement', 'ixian-replacement',
'ixian-betrayal',
] 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 {
@@ -101,6 +103,11 @@ export function startPrototypeRoom(
...initial, ...initial,
nexusCards: initial.nexusCards ?? { cards: null, phase: null }, nexusCards: initial.nexusCards ?? { cards: null, phase: null },
}) })
: profile === 'ixian-betrayal'
? initializeIxianNexusBetrayalGameForAudit({
...initial,
nexusCards: initial.nexusCards ?? { cards: null, phase: null },
})
: profile === 'factions' : profile === 'factions'
? initializeFactionExpansionsGameForAudit(initial) ? initializeFactionExpansionsGameForAudit(initial)
: profile === 'nexus' : profile === 'nexus'
+1 -1
View File
@@ -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|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.', '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|ixian-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. 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. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
); );
return; return;
} }