Implement Harkonnen Nexus exchanges with durable traitor declarations
This commit is contained in:
1 parent
d820c8a414
commit
d5830d260c
20 files changed
+3158
-10
No files matched your search
@@ -10,6 +10,7 @@ import { CaladanReinforcement } from './caladan-reinforcement';
|
||||
import { GrummanCollection } from './grumman-collection';
|
||||
import { TupileIntelligence } from './tupile-intelligence';
|
||||
import { NexusCards } from './nexus-cards';
|
||||
import { NexusTraitors } from './nexus-traitors';
|
||||
import { TerrorBoardMarkers } from './terror-board-markers';
|
||||
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
|
||||
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
|
||||
@@ -669,7 +670,7 @@ export function GameTable({
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
}
|
||||
disabled={busy || !!g.truthtrance || disabled || shipmentBlocked}
|
||||
disabled={busy || !!g.truthtrance || !!g.nexusTraitors?.pending || disabled || shipmentBlocked}
|
||||
onClick={() => act(a)}
|
||||
>
|
||||
{text}
|
||||
@@ -1414,6 +1415,7 @@ export function GameTable({
|
||||
</div>
|
||||
<PrivateBattlePlan game={g} />
|
||||
<NexusCards game={g} act={act} busy={busy} />
|
||||
<NexusTraitors game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
{!g.nexusCards?.waiting.length && <TupileIntelligence game={g} act={act} busy={busy} />}
|
||||
{g.biddingEnd && <BiddingEnd game={g} act={act} busy={busy} />}
|
||||
{g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && (
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { faction } from '@/game/catalog';
|
||||
import { leaderStrengthLabel } from '@/game/cards';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import {
|
||||
nexusTraitorDrawAction,
|
||||
nexusTraitorReturnAction,
|
||||
} from '@/game/nexus-traitor-options';
|
||||
import {
|
||||
LeaderInspector,
|
||||
type LeaderDisplayIdentity,
|
||||
} from './leader-inspector';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
|
||||
function identityFor(game: GameView, id: string): LeaderDisplayIdentity | null {
|
||||
if (id === CHEAP_HERO_TRAITOR)
|
||||
return {
|
||||
name: 'Cheap Hero / Heroine',
|
||||
factionName: 'Any faction',
|
||||
strength: 0,
|
||||
cheapHero: true,
|
||||
};
|
||||
const leader = game.allLeaders.find((leader) => leader.id === id);
|
||||
return leader
|
||||
? {
|
||||
id: leader.id,
|
||||
name: leader.name,
|
||||
factionName: faction(leader.faction).name,
|
||||
strength: leaderStrengthLabel(leader),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function NexusTraitors({ game, act, busy }: Props) {
|
||||
const state = game.nexusTraitors;
|
||||
if (!state) return null;
|
||||
if (state.pending) {
|
||||
// Branch on the public owner before touching private choice identities.
|
||||
if (state.pending.owner !== game.me)
|
||||
return (
|
||||
<section aria-label="Harkonnen Nexus exchange">
|
||||
<p className="notice">
|
||||
Waiting for{' '}
|
||||
{game.players.find((player) => player.id === state.pending!.owner)
|
||||
?.name ?? 'the card holder'}{' '}
|
||||
to return {state.pending.count}{' '}
|
||||
{state.pending.count === 1 ? 'card' : 'cards'} privately.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<ReturnCards
|
||||
key={state.pending.event}
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const offer = state.offer;
|
||||
if (!offer) return null;
|
||||
const action = nexusTraitorDrawAction(game, offer.event, offer.mode);
|
||||
const dancers =
|
||||
game.players.find((player) => player.id === game.me)?.faction ===
|
||||
'tleilaxu';
|
||||
const label = dancers ? 'Face Dancer' : 'Traitor';
|
||||
return (
|
||||
<section
|
||||
aria-label="Harkonnen Nexus exchange"
|
||||
className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3"
|
||||
>
|
||||
<h3>
|
||||
Harkonnen Nexus ·{' '}
|
||||
{offer.mode === 'cunning'
|
||||
? 'Cunning'
|
||||
: offer.mode === 'secretAlly'
|
||||
? 'Secret Ally'
|
||||
: 'Betrayal'}
|
||||
</h3>
|
||||
{offer.blocked ? (
|
||||
<p className="fine">{offer.blocked}</p>
|
||||
) : offer.mode === 'betrayal' ? (
|
||||
<p className="fine">
|
||||
The traitor cancellation response is not available yet.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
Draw {offer.draw} {label} {offer.draw === 1 ? 'card' : 'cards'},
|
||||
then choose {offer.draw} from your hand to shuffle back. Newly drawn
|
||||
cards may be returned.
|
||||
</p>
|
||||
<p className="fine">
|
||||
This uses your Harkonnen Nexus card. Your selections remain private.
|
||||
</p>
|
||||
<Button
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={busy || !action}
|
||||
onClick={() => {
|
||||
if (!busy && action) act(action);
|
||||
}}
|
||||
>
|
||||
Draw {offer.draw} {label} {offer.draw === 1 ? 'card' : 'cards'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReturnCards({ game, act, busy }: Props) {
|
||||
const pending = game.nexusTraitors!.pending!;
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const dancers =
|
||||
game.players.find((player) => player.id === game.me)?.faction ===
|
||||
'tleilaxu';
|
||||
const label = dancers ? 'Face Dancer' : 'Traitor';
|
||||
const action = nexusTraitorReturnAction(game, pending.event, selected);
|
||||
const blocked = busy || !!game.truthtrance;
|
||||
return (
|
||||
<section
|
||||
aria-label="Choose Nexus cards to return"
|
||||
className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3"
|
||||
>
|
||||
<h3>
|
||||
Return {pending.count} {label} {pending.count === 1 ? 'card' : 'cards'}
|
||||
</h3>
|
||||
<p>
|
||||
Choose exactly {pending.count} to shuffle into the Traitor Deck. Your
|
||||
other cards stay in your hand.
|
||||
</p>
|
||||
<p className="fine">
|
||||
The previous game action is paused until this return finishes.
|
||||
</p>
|
||||
{dancers && (
|
||||
<p className="fine">
|
||||
Previously revealed Face Dancers are not available for this return.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{pending.choices.map((choice) => {
|
||||
const identity = identityFor(game, choice.id);
|
||||
if (!identity)
|
||||
return (
|
||||
<p key={choice.id} className="notice">
|
||||
This card’s identity is unavailable. Refresh before choosing it.
|
||||
</p>
|
||||
);
|
||||
const checked = selected.includes(choice.id);
|
||||
return (
|
||||
<div
|
||||
key={choice.id}
|
||||
className="flex flex-col items-start gap-2 rounded border border-[#a88b60]/40 p-3"
|
||||
>
|
||||
<label
|
||||
aria-label={`Return ${identity.name}, ${identity.factionName}, strength ${identity.strength}`}
|
||||
className="flex min-h-11 w-full cursor-pointer items-center gap-3 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0"
|
||||
checked={checked}
|
||||
disabled={
|
||||
blocked || (!checked && selected.length >= pending.count)
|
||||
}
|
||||
onChange={() =>
|
||||
setSelected((old) =>
|
||||
old.includes(choice.id)
|
||||
? old.filter((id) => id !== choice.id)
|
||||
: [...old, choice.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{identity.name}</strong>
|
||||
<span className="block text-sm text-[#b6b6a4]">
|
||||
{identity.factionName} · Strength {identity.strength}
|
||||
{choice.drawn ? ' · Newly drawn' : ''}
|
||||
{choice.revealed ? ' · Previously revealed' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<LeaderInspector
|
||||
identity={identity}
|
||||
kind={dancers ? 'faceDancer' : 'traitor'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="fine" aria-live="polite">
|
||||
{selected.length} of {pending.count} selected.
|
||||
</p>
|
||||
{game.truthtrance && (
|
||||
<p className="notice">
|
||||
Finish Truthtrance before returning these cards.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="game-action min-h-11 whitespace-normal"
|
||||
disabled={blocked || !action}
|
||||
onClick={() => {
|
||||
if (!blocked && action) act(action);
|
||||
}}
|
||||
>
|
||||
Return selected {pending.count === 1 ? 'card' : 'cards'}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Component inventory and verification ledger
|
||||
|
||||
**Nexus update, 10 September 2026:** all twelve physical Nexus identities and all thirty-six effect panels are verified against the original printed components. The separate inventory preserves one card per faction, independent of the selected roster; held cards remain private. The internal rules gallery and owner panel provide complete original-language effect text, existing bundled faction artwork and enlarged inspection. Common draws, replacement, preselected own-faction redraw, alliance forfeiture and saved phase progress are integrated. Atreides Cunning and Secret Ally now have inspected battle controls and private history; Betrayal and the other effect families and complete module games remain unfinished and gated. See [NEXUS_ATREIDES_RUNTIME.md](NEXUS_ATREIDES_RUNTIME.md), [NEXUS_CARD_RULES.md](NEXUS_CARD_RULES.md) and [NEXUS_CARD_RUNTIME.md](NEXUS_CARD_RUNTIME.md) for current verification and limits.
|
||||
**Nexus update, 10 September 2026:** all twelve physical Nexus identities and all thirty-six effect panels are verified against the original printed components. The separate inventory preserves one card per faction, independent of the selected roster; held cards remain private. The internal rules gallery and owner panel provide complete original-language effect text, existing bundled faction artwork and enlarged inspection. Common draws, replacement, preselected own-faction redraw, alliance forfeiture and saved phase progress are integrated. Atreides Cunning and Secret Ally have inspected battle controls and private history. Harkonnen Cunning and Secret Ally now draw from the physical Traitor Deck before an exact private return; Tleilaxu receives Face Dancers while previously revealed dancers stay protected. The owner selects actual leader identities with enlarged inspection, and observers receive only pending metadata. This adds no duplicate physical deck or new artwork. Betrayal, remaining effects and complete module games remain unfinished and gated. See [NEXUS_HARKONNEN_RUNTIME.md](NEXUS_HARKONNEN_RUNTIME.md), [NEXUS_ATREIDES_RUNTIME.md](NEXUS_ATREIDES_RUNTIME.md), [NEXUS_CARD_RULES.md](NEXUS_CARD_RULES.md) and [NEXUS_CARD_RUNTIME.md](NEXUS_CARD_RUNTIME.md) for current verification and limits; final Harkonnen checkpoint acceptance is pending.
|
||||
|
||||
**Homeworld update, 9 September 2026:** all 13 cards and 26 physical faces are verified, including native population/battle values and occupied income symbols. The internal gallery supplies both faces, complete original gameplay prose, enlarged inspection and thirteen original bundled illustrations. Separate typed custody and population helpers are tested; actual Homeworld gameplay, occupation and effect integration remain unfinished and gated. See [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md) and [HOMEWORLD_COMPONENT_AUDIT.md](HOMEWORLD_COMPONENT_AUDIT.md). Earlier missing-Homeworld statements below are historical.
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Dune implementation status
|
||||
|
||||
**Harkonnen Nexus exchange checkpoint, 10 September 2026:** [NEXUS_HARKONNEN_RUNTIME.md](NEXUS_HARKONNEN_RUNTIME.md) records draw-first Cunning and absent-faction Secret Ally, exact private returns, protected revealed Face Dancers, saved suspended actions and durable prior traitor declarations. Shared controls and all four bot profiles use projected identities. Independent review fixed lost preceding decisions and the paid Nullentropy action gate. **Types, lint, all 3,885 offline cases, production build and 40 HTTP/session cases pass.** Forty-six new cases cover rules, consumers, continuations and concurrent SQLite recovery. Two three-seat browser rooms passed draw/return, private restoration and desktop/phone inspection. All **3,295** preexisting room versions/hashes remain unchanged. Betrayal remains blocked pending the existing private response timing decision; complete Nexus and expansion games remain unfinished, and no release gate opens. The server was reused, no restart was due, and automation remains removed.
|
||||
|
||||
**Atreides Nexus inspection checkpoint, 10 September 2026:** [NEXUS_ATREIDES_RUNTIME.md](NEXUS_ATREIDES_RUNTIME.md) records Cunning's distinct second commitment and absent-faction Secret Ally, physical card disposal, No-Field distinctions, private history, all four editor locks and bot handling. Independent review fixed lost-response bypass, first-answer drift and a sealed plan contradicting its signed observation; legitimate Residual reanswers preserve the first historical disclosure. **Types, lint, all 3,839 offline tests, production build and 40 HTTP/session tests pass.** Forty-four new cases include concurrent SQLite recovery and four independent corruption regressions. Three-seat keyboard play, answer, protected plan submission and desktop/phone refresh passed. All **3,234** preexisting room versions/hashes survived the scheduled backup/restart and final comparison. Betrayal response timing, Residual after two answered Cunning fields, other card families and complete module games remain explicitly unfinished; no release gate opened. Automation stays removed; verified checkpoints are authorized for push without another confirmation.
|
||||
|
||||
**Nexus ownership and complete component reference checkpoint, 10 September 2026:** [NEXUS_CARD_RUNTIME.md](NEXUS_CARD_RUNTIME.md) records the twelve-card physical deck, whole-phase Basic/Advanced draw opportunity, private replacement policy, all three alliance-forfeiture paths and complete inspectable 36-panel reference. Review fixed real Storm-entry initialization, edited progress/replay acceptance, one-way alliances and the private redraw prompt's public timing leak. Pre-draw preferences resolve without extra confirmations. **Types, lint, all 3,795 offline tests, production build and 40 HTTP/session tests pass.** Three-seat keyboard draw/redraw/refresh and all twelve normal/enlarged desktop/phone cards passed; independent review inspected 48 renders. All **3,173** preexisting room versions/hashes remain unchanged. All individual Nexus effects, Discoveries and complete expansion games remain unfinished; no release gate opened. Existing server reused, no restart due, no automation recreated.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Harkonnen Nexus: source and implementation contract
|
||||
|
||||
Source audit, 10 September 2026. This is a rules contract, not a runtime completion claim. The [common Nexus lifecycle](NEXUS_CARD_RULES.md) and existing expansion release gates still apply. The pending reaction-privacy question remains unresolved; this audit does not authorize a Betrayal reaction policy.
|
||||
|
||||
## Primary authority
|
||||
|
||||
- [The original printed Nexus components](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani): the Harkonnen face was freshly inspected in the previously acquired 3024×4032 photograph, `/tmp/dune-nexus-cards.jpg`. Its three panels establish the effects below. The photographed publisher text is the authority, not the uploader's commentary.
|
||||
- [GF9 Ecaz & Moritani, pp.11 and 16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11): common modes, card disposal and the explicit Tleilaxu/Harkonnen Secret Ally FAQ. Fresh official indexed text and the previously acquired publisher-authored PDF mirror were inspected; direct official retrieval returned 403. Acquisition details are in the common contract.
|
||||
- [GF9 base rules, pp.11 and 17](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=11): traitor revelation and battle consequences, Harkonnen's four starting traitors and allied use. [GF9 quick-start, p.4](https://www.gf9games.com/dune/wp-content/uploads/Quick-start-Guide.pdf#page=4) establishes the participating-faction deck and return of unselected setup cards.
|
||||
- [GF9 Ixians & Tleilaxu, pp.4, 6–7 and Karama table](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=6): shared Traitor Deck, three Face Dancers, reveal/replacement cycle and ordinary Mentat replacement. [GF9 CHOAM & Richese, p.12](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=12) supplies the related Rihani Decipherer ruling.
|
||||
- [GF9 November 2020 FAQ, pp.3, 7–8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=3): retained traitor identities after revival, Karama boundaries, unused-card privacy and Truthtrance. Fresh publisher-indexed text was retrieved. The earlier April FAQ was encountered but is not used to override November's cancellation rules.
|
||||
|
||||
## Printed effects
|
||||
|
||||
| Mode | Eligibility and timing | Physical operation |
|
||||
| --- | --- | --- |
|
||||
| Cunning | Harkonnen holds its own Nexus card; at any time. | Draw one Traitor Card into the hand, then secretly select any one held Traitor Card and shuffle it into the Traitor Deck. |
|
||||
| Secret Ally | Harkonnen is absent; during Mentat Pause. | Draw two Traitor Cards into the hand, then select two held Traitor Cards and shuffle them into the Traitor Deck. |
|
||||
| Betrayal | Another player controls Harkonnen; a Harkonnen traitor has been revealed. | Cancel that traitor, shuffle its card into the Traitor Deck, and give Harkonnen a replacement draw during Mentat Pause. |
|
||||
|
||||
These are mandatory draw/return counts once played. Newly drawn identities are in the hand before selection, so they may be returned. Neither exchange permanently increases hand size. The card does not restrict returns to newly drawn cards or currently living leaders. Betrayal's replacement is delayed, whereas returning its canceled identity is immediate. [Printed Harkonnen face](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani)
|
||||
|
||||
The common rules make these modes mutually exclusive and consume the Nexus card on play. Secret Ally does not create a formal alliance. The E3 FAQ expressly classifies Tleilaxu's retained card as a Face Dancer. It does not describe return eligibility for already revealed dancers. [E3, pp.11,16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=16)
|
||||
|
||||
## Supported composition and implementation requirements
|
||||
|
||||
**Exchange transaction.** Keep one immutable draw receipt containing the original owner, mode, turn/phase and physical draw group. Suspend the original continuation while the owner chooses exactly the required number of distinct currently held identities. Bind the return to that event; complete custody transfer, shuffle and resume once. Reconnects and retries must not redraw. There is a real choice after the draw, so this pending choice is necessary; no additional acknowledgement is needed afterward. This is implementation composition of the printed sequence.
|
||||
|
||||
**Private custody.** Project drawn cards and return options only to the holder. Other seats may see the publicly played Nexus mode and a pending exchange, but not the drawn, returned or remaining identities, deck order, or whether a drawn card matched a combatant. Do not treat a previously exposed ordinary traitor as discarded: the November FAQ says that identity remains a traitor after its leader dies and revives. It also prohibits retaining unused Traitor Cards as reference material. An old private inspection receipt is a historical observation, not proof of current custody. [November FAQ, pp.3,8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=3)
|
||||
|
||||
**Battle cancellation.** Bind Betrayal to the actual revealed identity and Harkonnen caller before committing the traitor outcome. Base rules give a called traitor victory and specific casualty/spice consequences; a double call has a different outcome. Cancellation must therefore remove the targeted call and resolve the surviving situation once, not refund an already resolved battle. Harkonnen can call on an ally's behalf, so an implementation must retain caller and beneficiary separately. [Base rules, pp.11,17](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=17)
|
||||
|
||||
**Deck inventory.** Draw from the existing physical reserve, not from current leader discs: capture, gholas and death do not manufacture Traitor Cards. Setup removes absent factions and returns unselected identities; Tleilaxu draws its three from that shared deck afterward. [Quick-start p.4](https://www.gf9games.com/dune/wp-content/uploads/Quick-start-Guide.pdf#page=4), [E1 pp.4,6](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=6)
|
||||
|
||||
**Setup order follow-up.** Fresh inspection of base p.6 confirms that unchosen setup cards return face down to the **bottom** of the Traitor Deck. Quick-start p.4 agrees. E1 p.6 adds a shuffle after those returns specifically before Tleilaxu draws its initial three. Neither E3's common Nexus rules nor the Harkonnen face orders an additional setup shuffle or a shuffle before its initial exchange draw. Preserve the existing non-Tleilaxu reserve order in fresh and legacy rooms; append actual unchosen cards at setup, then draw from that saved reserve. There is no source basis for a corrective reshuffle migration. The physical order of simultaneous players' setup returns is not separately specified; serialized return order does not justify adding a shuffle. [Base p.6](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=6), [Quick-start p.4](https://www.gf9games.com/dune/wp-content/uploads/Quick-start-Guide.pdf#page=4), [E1 p.6](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=6)
|
||||
|
||||
No retrieved rule supplies an empty-Traitor-Deck fallback. For the ordinary supported setup this is not an expected shortage: with `n >= 3` five-leader factions, held cards total `n + 3H + 2T`, where `H` and `T` indicate Harkonnen and Tleilaxu. The smallest resulting reserve is seven. These exchanges have zero net growth; Betrayal temporarily increases reserve size. This arithmetic is a bounded inventory argument, not a rule for other variants. Reject undersized/inconsistent custody before spending the Nexus card; never recycle another player's held cards or silently draw fewer.
|
||||
|
||||
## Tleilaxu and shared-deck boundaries
|
||||
|
||||
E1 distinguishes two operations: after all three dancers reveal, return all three, shuffle and draw three; during Mentat, optionally return one **unrevealed** dancer, shuffle and draw one. Revealed dancers ordinarily stay until the full cycle completes. Its Karama table permits stopping ordinary Mentat replacement but excludes other Face Dancer effects. Harkonnen traitor revelation itself is not an ordinary Karama target. These specific entries do not establish blanket Nexus immunity. [E1 p.7 and Karama table](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7)
|
||||
|
||||
The Rihani Decipherer FAQ classifies its acquired identity as a Face Dancer and explicitly restricts replacement to an unrevealed dancer. This is relevant evidence for preserving revealed custody, but is a ruling for that skill. The Harkonnen Nexus FAQ states only the resulting card type. Compose it with E1's existing restriction: return options include newly drawn cards and existing unrevealed dancers; preserve prior revealed dancers. The two new cards guarantee at least two valid return choices, so this supports the full exchange rather than blocking Tleilaxu from playing it. No inspected passage explicitly classifies revealed dancers as being outside a player's hand; the justification is their protected revealed status. [E2 p.12](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=12), [E3 p.16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=16)
|
||||
|
||||
## Timing composition
|
||||
|
||||
Cunning's anytime permission supports play off turn and after battle plans reveal. Before a traitor declaration, the resulting held identities determine whether Harkonnen can call. After a declaration, preserve the committed call and its original physical identity independently of subsequent hand changes. An exchange changes custody; it does not contain Betrayal's explicit cancellation instruction. A declared identity returned through Cunning therefore must not retract the call or make its original declaration fail retroactive validation. This is composition of the printed effects with an immutable digital declaration, not a separate publisher FAQ about sealed requests.
|
||||
|
||||
During Mentat, Tleilaxu can select the order of its own ordinary cycling and Secret Ally exchange. Complete each operation before another deck action begins; do not interleave another draw between Secret Ally's two-card draw and return. No inspected source imposes a priority between these two optional actions. This player-controlled sequencing avoids silently fixing a priority in the phase serializer. It does not resolve competing mandatory draws belonging to different players.
|
||||
|
||||
## Material unresolved timing
|
||||
|
||||
- No retrieved primary ruling orders Betrayal's Mentat replacement against Tleilaxu's ordinary cycling or other Traitor Deck consumers. Those shuffles can change the next identity drawn. Do not describe a chosen serializer as publisher-mandated priority. Secret Ally and Betrayal themselves cannot compete: they require opposite faction-presence states and use the same unique Nexus card.
|
||||
- Likewise, the sources do not establish arbitrary interruption of another player's owned decision or reversal of a completed battle. A full anytime implementation must specify continuation ownership and interaction with existing Truthtrance promises without releasing those promises merely to make an exchange legal.
|
||||
- The existing unanswered reaction-privacy question still blocks choosing a Betrayal window policy. Do not repeat that question or infer an answer from this audit. Ordinary victory/end-turn advancement must not silently erase a pending Mentat exchange or an earned replacement obligation.
|
||||
|
||||
Focused verification should cover draw-before-return (including returning new cards), zero net hand growth, actual Tleilaxu custody, every-seat privacy, exact restoration of suspended decisions, immutable rejection, and once-only saved shuffle/replacement receipts. These checks do not certify all Nexus effects or expansion combinations.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Harkonnen Nexus exchange checkpoint
|
||||
|
||||
10 September 2026. Harkonnen Cunning and absent-faction Secret Ally now use the physical Traitor Deck, an owned draw-first return choice, saved continuation evidence and private controls. This extends the [common Nexus lifecycle](NEXUS_CARD_RUNTIME.md) and [Atreides inspections](NEXUS_ATREIDES_RUNTIME.md). [NEXUS_HARKONNEN_RULES.md](NEXUS_HARKONNEN_RULES.md) records the printed sources, composed interactions and unresolved timing. Betrayal, other unfinished effects and complete module games remain gated; this checkpoint does not open public Nexus or expansion starts.
|
||||
|
||||
## Physical exchange
|
||||
|
||||
Cunning draws one card into Harkonnen's hand before the owner chooses one held identity to return. Secret Ally requires Harkonnen absent and Mentat Pause, draws two first, and then returns exactly two distinct held identities. Newly drawn identities can be returned. The Nexus card is spent when the draw commits; the Traitor Deck is shuffled only when the return commits. The completed exchange restores the original hand size.
|
||||
|
||||
The exchange uses the saved reserve order. It does not rebuild the deck from living leader discs, captured leaders or current hands. The printed participating-faction inventory includes the Advanced CHOAM Auditor and the expansion's shared Cheap Hero identity when applicable. Setup returns remain at the bottom; Tleilaxu's separately sourced setup shuffle remains in its own setup path. There is no corrective reshuffle migration or invented empty-deck fallback.
|
||||
|
||||
For Tleilaxu, Secret Ally draws Face Dancers. Existing revealed dancers retain their identities and revealed flags and cannot be selected for this return. Existing unrevealed and newly drawn dancers are eligible; retained new dancers begin unrevealed. This protection composes the specific Face Dancer rules and related FAQ evidence described in the source audit. It is not a claim that the Harkonnen card itself explicitly discusses previously revealed dancers.
|
||||
|
||||
## Interrupted actions and declarations
|
||||
|
||||
Cunning can start off turn and across a pending response or owned decision. The engine preserves the preceding response, decision, phase opening, Karama/discard context and battle rather than resolving them to make room. While the exchange is pending, ordinary actions and automatic advancement wait for its exact event-bound return. Live Truthtrance takes priority; it does not release existing promises. Closing common Nexus draws and another exchange also prevent starting a new exchange.
|
||||
|
||||
The saved exchange history, pending marker and parent signature must agree on the owner, turn, phase, physical source and suspended control context. A reload projects the existing choice without redrawing or reshuffling. These canonical signatures detect inconsistent saved fields; they are not cryptographic authentication against a deliberately rewritten save.
|
||||
|
||||
A traitor declaration binds the original voter, beneficiary, target, revealed leader and physical traitor identity independently of later custody. Returning an already declared identity through Cunning does not cancel its call. Conversely, an earlier declined call remains false even if the exchange draws the matching identity. Allied Harkonnen declarations distinguish caller from beneficiary, and Cheap Hero declarations retain the actual sealed card and shared traitor identity. This exchange is not Betrayal's explicit cancellation effect.
|
||||
|
||||
## Private controls and bots
|
||||
|
||||
The holder sees actual eligible identities, names, factions, strengths, newly drawn status and enlarged Traitor or Face Dancer inspection. No return is preselected: the owner chooses the exact required count and submits once. Observers receive only pending owner/mode/count metadata and a waiting notice; no draw, return or reserve identities are projected or logged publicly.
|
||||
|
||||
All four bot profiles consume the same private offer and return choices. The return policy prefers own-faction, dead or lower-strength identities and preserves a matching publicly revealed opposing leader when possible. Optional Cunning is used before an unanswered public traitor call or during Mentat; Secret Ally follows its Mentat restriction. This is a legal policy, not new strategy calibration. Pending exchange handling overrides old automatic flags. Its defensive Truthtrance policy takes precedence over a preserved decision. The existing paid Nullentropy search restriction on new Treachery effects remains in force; this checkpoint does not open Truthtrance inside that search.
|
||||
|
||||
## Verified checkpoint
|
||||
|
||||
- The new controls/bot suites passed **8/8** cases. They include actual Cunning and Tleilaxu exchanges for all four profiles with physical inventory checks, exact selection, observer getter traps and the Truthtrance/Nullentropy priority regression.
|
||||
- A combined run of those suites, existing Atreides inspection controls/bots and full-plan regressions passed **33/33** cases. Owned-file type-aware lint passed. Existing full-table SSR tests emit the preexisting React SVG title-array warning; the standalone exchange controls do not.
|
||||
- **All 3,885 offline cases, types, lint, production build and 40 HTTP/session cases pass.** This checkpoint adds 46 cases: 10 pure exchange, 9 declaration, 12 engine, 8 controls/bots, 4 continuation and 3 production in-memory SQLite recovery cases. Independent review found and fixed an unbound preceding decision and the paid Nullentropy early-action gate.
|
||||
- SQLite checks preserve exact private custody after lost draw/return responses, accept only one competing return/shuffle and reject damaged parent evidence before writes. Unrelated rooms remain unchanged.
|
||||
- Browser acceptance used two isolated three-seat rooms: Cunning preserved and restored a live Prescience cancellation response; Tleilaxu Secret Ally exposed four eligible choices while protecting one revealed Face Dancer. Explicit one/two-card selection, phone layout, desktop/phone enlarged inspection and six authenticated seat restorations passed with no page errors. The inspection script initially used an incorrect accessible button name; it resumed the existing pending choice after correction without repeating the draw.
|
||||
- All **3,295** preexisting room versions and state hashes remained unchanged. The HTTP suite added 30 isolated rooms and browser acceptance added two. The existing server was reused; no hourly restart was due during this checkpoint and no automation was recreated.
|
||||
|
||||
Implementation: [exchange module](../game/nexus-traitor-exchange.ts), [declaration module](../game/traitor-declarations.ts), [shared options](../game/nexus-traitor-options.ts), [controls](../components/nexus-traitors.tsx), [engine](../game/engine.ts). Tests: [pure exchange](../tests/nexus-traitor-exchange.test.ts), [declarations](../tests/traitor-declarations.test.ts), [engine](../tests/nexus-traitor-engine.test.ts), [controls](../tests/nexus-traitor-controls.test.ts), [bots](../tests/nexus-traitor-bots.test.ts), [SQLite recovery](../tests/nexus-traitor-recovery.test.ts), [continuations](../tests/nexus-traitor-continuations.test.ts).
|
||||
|
||||
Betrayal remains blocked pending the existing private response timing decision. Its cancellation and delayed Mentat replacement are not implemented by this exchange. No arbitrary priority between its future replacement and other deck consumers has been adopted. Complete Nexus games and full expansion combinations remain separate release requirements.
|
||||
@@ -1,4 +1,5 @@
|
||||
import { nexusCardBotActions } from './nexus-card-options';
|
||||
import { nexusTraitorBotActions } from './nexus-traitor-options';
|
||||
import { tupileIntelligenceActions } from './tupile-intelligence-options';
|
||||
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
|
||||
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
|
||||
@@ -3314,8 +3315,14 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
|
||||
|
||||
/** Obligations apply across policy branches, including choosing to move first. */
|
||||
export function botActions(g: GameView): Action[] {
|
||||
if (g.nexusTraitors?.pending)
|
||||
return g.truthtrance
|
||||
? policyActions({ ...g, decision: null })
|
||||
: nexusTraitorBotActions(g);
|
||||
if (g.automaticContinuationPending) return [];
|
||||
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
|
||||
const traitorExchange = nexusTraitorBotActions(g);
|
||||
if (traitorExchange.length) return traitorExchange;
|
||||
const nexus = g.nexusAtreides;
|
||||
if (
|
||||
g.status === 'playing' && g.phase === 6 && nexus && !nexus.blocked &&
|
||||
|
||||
+170
-2
@@ -1,4 +1,6 @@
|
||||
import { createNexusInspection, allowNexusInspection, answerNexusInspection, reopenNexusInspection, cancelNexusInspection, reopenNexusNative, answerNexusNative, validateNexusInspection, committedPlanElements, type NexusInspection, type BattleInspectionContext } from './battle-inspections';
|
||||
import { beginNexusTraitorExchange, finishNexusTraitorExchange, validateNexusTraitorExchange, validateNexusTraitorHistory, validateNexusTraitorSnapshot, type NexusTraitorExchange, type NexusTraitorSnapshot } from './nexus-traitor-exchange';
|
||||
import { createTraitorDeclaration, validateTraitorDeclarations, type TraitorDeclaration, type TraitorDeclarationContext } from './traitor-declarations';
|
||||
import { createNexusCards, validateNexusCards, drawNexusCard, replaceNexusCard, discardNexusCard, projectNexusCards, nexusCardMode, type NexusState } from './nexus-cards';
|
||||
import { createNexusCardPhase, markNexusCardOccurred, validateNexusCardPhase, closeNexusCardPhase, nexusCardChoices, finishNexusCardChoice, type NexusCardPhase, type NexusCardChoice } from './nexus-card-phase';
|
||||
import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history';
|
||||
@@ -536,6 +538,8 @@ export type Battle = {
|
||||
nexusInspection?: NexusInspection;
|
||||
/** Independent presence marker: deleting a spent-card record is not a legacy save. */
|
||||
nexusInspectionUsed?: string;
|
||||
traitorDeclarationVersion?: 1;
|
||||
traitorDeclarations?: Record<string, TraitorDeclaration>;
|
||||
homeworldDefensePassed?: string[];
|
||||
event?: string;
|
||||
strongholdCopy?: StrongholdId;
|
||||
@@ -1253,6 +1257,9 @@ export type Game = {
|
||||
strongholdCards?: StrongholdState | null;
|
||||
/** Optional independent module; complete effect coverage is still release-gated. */
|
||||
nexusCards?: { cards: NexusState | null; phase: NexusCardPhase | null } | null;
|
||||
nexusTraitorExchanges?: NexusTraitorExchange[];
|
||||
nexusTraitorPending?: string | null;
|
||||
nexusTraitorParent?: { event: string; signature: string } | null;
|
||||
/** Null/absent disables the module; custody is installed at force placement. */
|
||||
homeworlds?: { custody: HomeworldCustody | null; historyVersion?: 1 } | null;
|
||||
homeworldOccupationHistory?: HomeworldOccupationHistory;
|
||||
@@ -1489,6 +1496,151 @@ function projectedNexusCards(g: Game, id: string) {
|
||||
choices: nexusCardChoices(nexus.phase, nexus.cards, id),
|
||||
waiting: nexus.phase?.stage === 'drawing' ? nexus.phase.eligible.filter(owner => !nexus.phase!.done.includes(owner)) : [] };
|
||||
}
|
||||
function nexusTraitorUniverse(g: Game) {
|
||||
// This describes the printed setup inventory; it never regenerates the deck
|
||||
// from current hands, captured leaders or Face Dancer custody.
|
||||
return traitorDeck(g.players.map(p => ({ leaders: [
|
||||
...leaders(p.faction), ...(g.advanced && p.faction === 'choam' ? [createAuditorLeader()] : []),
|
||||
] })), g.expansions.includes('ix'));
|
||||
}
|
||||
function nexusTraitorSnapshot(g: Game): NexusTraitorSnapshot {
|
||||
return { reserve: [...(g.traitorReserve ?? [])], players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, traitors: [...p.traitors],
|
||||
...(p.faceDancers ? { faceDancers: structuredClone(p.faceDancers) } : {}),
|
||||
})) };
|
||||
}
|
||||
function pendingNexusTraitors(g: Game) {
|
||||
return g.nexusTraitorExchanges?.find(exchange => exchange.stage === 'return') ?? null;
|
||||
}
|
||||
function nexusTraitorParentSignature(g: Game, event: string) {
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const context = continuation && 'resume' in continuation ? continuation.resume : g;
|
||||
const resume = { response: context.response ?? null, decision: context.decision ?? null,
|
||||
pendingKarama: 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] ?? null : null,
|
||||
phaseOpening: 'phaseOpening' in context ? context.phaseOpening as Game['phaseOpening'] ?? null : null };
|
||||
const battle = g.battle ? Object.fromEntries(Object.entries(g.battle).filter(([key]) => key !== 'truthPromises')) : null;
|
||||
return JSON.stringify({ event, turn: g.turn, phase: g.phase, status: g.status, active: g.active,
|
||||
order: g.order, ready: g.ready, battle, nullentropy: g.pendingNullentropy ?? null,
|
||||
controls: nullentropyParentSignature(g, resume, event) });
|
||||
}
|
||||
function traitorDeclarationContext(g: Game): TraitorDeclarationContext {
|
||||
const b = g.battle!;
|
||||
return { event: b.event!, attacker: b.attacker, defender: b.defender, plans: b.plans,
|
||||
players: g.players.map(({id,faction,ally,traitors}) => ({id,faction,ally,traitors})),
|
||||
heroLeaderIds: physicalTreacheryCards(g).filter(c => c.kind === 'hero').map(c => c.id),
|
||||
};
|
||||
}
|
||||
function traitorDeclarationIntegrity(g: Game) {
|
||||
const b = g.battle;
|
||||
if (!b) return;
|
||||
if (b.traitorDeclarationVersion === undefined && b.traitorDeclarations === undefined) return;
|
||||
nexusRule(() => validateTraitorDeclarations(traitorDeclarationContext(g), b.traitorCalls,
|
||||
b.traitorDeclarations, b.traitorDeclarationVersion));
|
||||
}
|
||||
function ensureTraitorDeclarations(g: Game) {
|
||||
const b = g.battle;
|
||||
if (!b?.revealed || b.traitorDeclarationVersion) return;
|
||||
requireRule(b.event, 'This battle predates the durable traitor declaration record.');
|
||||
b.traitorDeclarationVersion = 1;
|
||||
b.traitorDeclarations = {};
|
||||
for (const [voter, called] of Object.entries(b.traitorCalls))
|
||||
if (called) b.traitorDeclarations[voter] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), voter));
|
||||
}
|
||||
function nexusTraitorIntegrity(g: Game) {
|
||||
const history = g.nexusTraitorExchanges;
|
||||
if (!history) {
|
||||
requireRule(!g.nexusTraitorPending && !g.nexusTraitorParent, 'The pending Nexus exchange has lost its physical draw record.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.nexusCards?.cards && Array.isArray(history), 'Nexus exchanges require the original card module.');
|
||||
const universe = nexusTraitorUniverse(g);
|
||||
nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g), universe));
|
||||
const events = new Set<string>();
|
||||
for (const exchange of history) {
|
||||
requireRule(!events.has(exchange.event) && exchange.turn <= g.turn,
|
||||
'Nexus exchange history is duplicated or belongs to a future turn.');
|
||||
events.add(exchange.event);
|
||||
if (exchange.stage === 'complete') nexusRule(() => validateNexusTraitorHistory(universe, exchange));
|
||||
else requireRule(exchange.stage === 'return', 'Invalid Nexus exchange progress.');
|
||||
}
|
||||
const pending = history.filter(exchange => exchange.stage === 'return');
|
||||
requireRule(pending.length <= 1 && (pending[0]?.event ?? null) === (g.nexusTraitorPending ?? null),
|
||||
'The Nexus exchange pending marker contradicts its draw history.');
|
||||
if (pending.length) {
|
||||
const exchange = pending[0];
|
||||
requireRule(g.status === 'playing' && exchange === history.at(-1) && exchange.turn === g.turn && exchange.phase === g.phase &&
|
||||
g.nexusCards.cards.discard.includes('harkonnen'), 'The pending Nexus return has lost its turn, phase or spent card.');
|
||||
nexusRule(() => validateNexusTraitorExchange(nexusTraitorSnapshot(g), universe, exchange));
|
||||
requireRule(g.nexusTraitorParent?.event === exchange.event &&
|
||||
g.nexusTraitorParent.signature === nexusTraitorParentSignature(g, exchange.event),
|
||||
'The Nexus exchange has lost or changed its preceding game decision.');
|
||||
} else requireRule(!g.nexusTraitorParent, 'A completed Nexus return cannot retain a suspended decision.');
|
||||
traitorDeclarationIntegrity(g);
|
||||
}
|
||||
function nexusTraitorOffer(g: Game, id: string) {
|
||||
if (g.nexusCards?.cards?.hands[id] !== 'harkonnen') return null;
|
||||
const player = getPlayer(g, id), mode = nexusCardMode('harkonnen', player.faction, g.players.map(p => p.faction));
|
||||
let blocked: string | null = null;
|
||||
if (g.status !== 'playing') blocked = 'Nexus effects belong to the started game.';
|
||||
else if (player.ally) blocked = 'Allied players cannot retain or play a Nexus card.';
|
||||
else if (g.truthtrance) blocked = 'Finish the active Truthtrance first.';
|
||||
else if (g.nexusCards.phase?.stage === 'drawing') blocked = 'Finish the closing Nexus draws first.';
|
||||
else if (pendingNexusTraitors(g)) blocked = 'Finish the current Nexus return first.';
|
||||
else if (mode === 'betrayal') blocked = 'Betrayal reactions await the private response timing decision.';
|
||||
else if (mode === 'secretAlly' && g.phase !== 8) blocked = 'Use Secret Ally during Mentat Pause.';
|
||||
const draw = mode === 'secretAlly' ? 2 : 1;
|
||||
return { event: JSON.stringify(['nexusTraitors', g.turn, g.phase, g.nexusTraitorExchanges?.length ?? 0, id]), mode, draw, blocked };
|
||||
}
|
||||
function applyNexusTraitorSnapshot(g: Game, snapshot: NexusTraitorSnapshot) {
|
||||
g.traitorReserve = [...snapshot.reserve];
|
||||
for (const entry of snapshot.players) {
|
||||
const player = getPlayer(g, entry.id);
|
||||
player.traitors = [...entry.traitors];
|
||||
if (entry.faceDancers) player.faceDancers = structuredClone(entry.faceDancers);
|
||||
}
|
||||
}
|
||||
function playNexusTraitorDraw(g: Game, p: Player, action: Action) {
|
||||
const offer = nexusTraitorOffer(g, p.id);
|
||||
requireRule(offer && !offer.blocked && offer.mode !== 'betrayal' &&
|
||||
action.event === offer.event && action.mode === offer.mode && Object.keys(action).sort().join(',') === 'event,mode,type',
|
||||
offer?.blocked ?? 'This Nexus Traitor draw is stale or belongs to another player.');
|
||||
const result = nexusRule(() => beginNexusTraitorExchange(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), {
|
||||
event: offer.event, owner: p.id, mode: offer.mode as 'cunning' | 'secretAlly', turn: g.turn, phase: g.phase,
|
||||
}));
|
||||
ensureTraitorDeclarations(g);
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
applyNexusTraitorSnapshot(g, result.state);
|
||||
(g.nexusTraitorExchanges ??= []).push(result.exchange);
|
||||
g.nexusTraitorPending = result.exchange.event;
|
||||
g.nexusTraitorParent = { event: result.exchange.event, signature: nexusTraitorParentSignature(g, result.exchange.event) };
|
||||
log(g, `${p.name} played the Harkonnen Nexus Card: ${offer.mode === 'cunning' ? 'Cunning' : 'Secret Ally'}. They drew ${offer.draw} private ${p.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} ${offer.draw === 1 ? 'Card' : 'Cards'} and must choose ${offer.draw} to return before play continues.`,
|
||||
{ faction: p.faction, name: 'Harkonnen Nexus' });
|
||||
}
|
||||
function finishNexusTraitorReturn(g: Game, p: Player, action: Action) {
|
||||
const pending = pendingNexusTraitors(g);
|
||||
requireRule(pending?.owner === p.id && action.event === pending.event &&
|
||||
Object.keys(action).sort().join(',') === 'cards,event,type' && Array.isArray(action.cards),
|
||||
'This Nexus return is stale or belongs to another player.');
|
||||
const result = nexusRule(() => finishNexusTraitorExchange(nexusTraitorSnapshot(g), nexusTraitorUniverse(g),
|
||||
pending, action.cards as string[], random));
|
||||
applyNexusTraitorSnapshot(g, result.state);
|
||||
g.nexusTraitorExchanges![g.nexusTraitorExchanges!.length - 1] = result.exchange;
|
||||
g.nexusTraitorPending = null;
|
||||
g.nexusTraitorParent = null;
|
||||
log(g, `${p.name} secretly returned ${result.exchange.returned.length} ${p.faction === 'tleilaxu' ? 'Face Dancer' : 'Traitor'} ${result.exchange.returned.length === 1 ? 'Card' : 'Cards'}. The Traitor Deck was shuffled; their other cards and any prior traitor declaration remain unchanged.`);
|
||||
}
|
||||
function projectedNexusTraitors(g: Game, id: string) {
|
||||
if (!g.nexusCards?.cards) return null;
|
||||
const pending = pendingNexusTraitors(g);
|
||||
const owner = pending ? getPlayer(g, pending.owner) : null;
|
||||
return { offer: nexusTraitorOffer(g, id), pending: pending ? {
|
||||
event: pending.event, owner: pending.owner, mode: pending.mode, count: pending.drawn.length,
|
||||
choices: pending.owner !== id ? [] : (owner!.faction === 'tleilaxu'
|
||||
? owner!.faceDancers!.filter(card => !card.revealed).map(card => ({id:card.leader, revealed:card.revealed, drawn:pending.drawn.includes(card.leader)}))
|
||||
: owner!.traitors.map(card => ({id:card, revealed:false, drawn:pending.drawn.includes(card)}))),
|
||||
} : null };
|
||||
}
|
||||
|
||||
function battleInspectionContext(g: Game): BattleInspectionContext {
|
||||
const b = g.battle!;
|
||||
return { event: b.event!, attacker: b.attacker, defender: b.defender,
|
||||
@@ -1536,6 +1688,7 @@ function nexusAtreidesOffer(g: Game, id: string) {
|
||||
blocked = 'Play this Nexus Card before the current battle plans are revealed.';
|
||||
else if (p.ally) blocked = 'Allied players cannot use a Nexus Card.';
|
||||
else if (b.nexusInspection || b.nexusInspectionUsed) blocked = 'This battle already used the Atreides Nexus Card.';
|
||||
else if (pendingNexusTraitors(g)) blocked = 'Finish the private Nexus card return first.';
|
||||
else if (g.truthtrance || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy)
|
||||
blocked = 'Finish the current interaction before playing this Nexus Card.';
|
||||
else if (mode === 'betrayal') {
|
||||
@@ -11498,6 +11651,8 @@ function gholaOptions(g: Game, p: Player) {
|
||||
}
|
||||
function marketGholaIntegrity(g: Game) {
|
||||
nexusCardsIntegrity(g);
|
||||
nexusTraitorIntegrity(g);
|
||||
traitorDeclarationIntegrity(g);
|
||||
nexusInspectionIntegrity(g);
|
||||
homeworldHistoryIntegrity(g);
|
||||
grummanCollectionIntegrity(g);
|
||||
@@ -12484,7 +12639,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
id,
|
||||
beneficiary: traitorBeneficiary(g, b, id)!,
|
||||
called: id === canceledVoter ? false : b.traitorCalls[id],
|
||||
traitors: getPlayer(g, id).traitors,
|
||||
traitors: b.traitorDeclarations?.[id] ? [b.traitorDeclarations[id].identity] : getPlayer(g, id).traitors,
|
||||
})),
|
||||
participants: g.players.map((p) => ({
|
||||
id: p.id,
|
||||
@@ -16508,6 +16663,7 @@ function finishActionContinuations(g: Game) {
|
||||
// discard. Retire that next physical batch before exposing optional choices.
|
||||
for (let i = 0; g.pendingTreacheryDiscard && i < 16; i++) finishTreacheryDiscard(g);
|
||||
requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.');
|
||||
if (pendingNexusTraitors(g)) return;
|
||||
observeOccupation(g);
|
||||
resumeHomeworldRevivalReturn(g);
|
||||
resumeHomeworldVictoryReturn(g);
|
||||
@@ -16549,6 +16705,7 @@ function finishActionContinuations(g: Game) {
|
||||
settleAdvisors(g);
|
||||
}
|
||||
function settleAutomaticContinuations(g: Game) {
|
||||
if (pendingNexusTraitors(g)) return;
|
||||
if (g.pendingNullentropy) return;
|
||||
for (let iteration = 0; iteration < 128; iteration++) {
|
||||
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
|
||||
@@ -16637,8 +16794,9 @@ function applyActionInner(
|
||||
stoneBurnerIntegrity(g);
|
||||
strongholdIntegrity(g);
|
||||
auditorIntegrity(g);
|
||||
if (g.pendingNullentropy) {
|
||||
if (g.pendingNullentropy && !['nexusTraitorDraw', 'nexusTraitorReturn'].includes(action?.type)) {
|
||||
if (action?.type === 'advanceBots') return g;
|
||||
requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing the paid search.');
|
||||
requireRule(
|
||||
action?.type === 'decision',
|
||||
'Finish the paid Nullentropy Box search before another game action.',
|
||||
@@ -16693,6 +16851,11 @@ function applyActionInner(
|
||||
if (t === 'advanceBots') return g;
|
||||
if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; }
|
||||
requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.');
|
||||
if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; }
|
||||
if (t === 'nexusTraitorReturn') {
|
||||
requireRule(!g.truthtrance, 'Finish the active Truthtrance before returning Nexus cards.');
|
||||
finishNexusTraitorReturn(g, p, action); return g;
|
||||
}
|
||||
if (t === 'nexusAtreides') { playNexusAtreides(g, p, action); return g; }
|
||||
requireRule(
|
||||
!(
|
||||
@@ -16721,6 +16884,7 @@ function applyActionInner(
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing play.');
|
||||
if (t === 'card' && action.card === 'richese-juice-of-sapho') {
|
||||
playSapho(g, p, action);
|
||||
return g;
|
||||
@@ -19686,6 +19850,7 @@ function applyActionInner(
|
||||
'Traitor decision is not available.',
|
||||
);
|
||||
requireRule(typeof action.call === 'boolean', 'Choose reveal or decline.');
|
||||
if (g.nexusCards?.cards) ensureTraitorDeclarations(g);
|
||||
if (action.call) {
|
||||
const other =
|
||||
b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker;
|
||||
@@ -19702,6 +19867,8 @@ function applyActionInner(
|
||||
'You do not hold that leader as a traitor.',
|
||||
);
|
||||
}
|
||||
if (action.call && b.traitorDeclarationVersion)
|
||||
b.traitorDeclarations![id] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), id));
|
||||
b.traitorCalls[id] = action.call;
|
||||
if (
|
||||
action.call &&
|
||||
@@ -20151,6 +20318,7 @@ export function viewGame(state: Game, id: string) {
|
||||
techTokens: g.techTokens ?? null,
|
||||
strongholdCards: g.strongholdCards ?? null,
|
||||
nexusCards: projectedNexusCards(g, id),
|
||||
nexusTraitors: projectedNexusTraitors(g, id),
|
||||
nexusAtreides: nexusAtreidesOffer(g, id),
|
||||
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
|
||||
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
|
||||
export type NexusExchangeFaceDancer = { leader: string; revealed: boolean };
|
||||
export type NexusExchangePlayer = {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
traitors: string[];
|
||||
faceDancers?: NexusExchangeFaceDancer[];
|
||||
};
|
||||
export type NexusTraitorSnapshot = {
|
||||
reserve: string[];
|
||||
players: NexusExchangePlayer[];
|
||||
};
|
||||
export type NexusTraitorExchange = {
|
||||
version: 1;
|
||||
event: string;
|
||||
owner: string;
|
||||
mode: 'cunning' | 'secretAlly';
|
||||
turn: number;
|
||||
phase: number;
|
||||
stage: 'return' | 'complete';
|
||||
drawn: string[];
|
||||
before: string[] | NexusExchangeFaceDancer[];
|
||||
returned: string[];
|
||||
source: {
|
||||
ownerFaction: FactionId;
|
||||
reserve: string[];
|
||||
others: NexusExchangePlayer[];
|
||||
};
|
||||
settledReserve?: string[];
|
||||
signature: string;
|
||||
};
|
||||
export type NexusTraitorExchangeResult = {
|
||||
state: NexusTraitorSnapshot;
|
||||
exchange: NexusTraitorExchange;
|
||||
};
|
||||
function requireExchange(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
function plain(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(value))
|
||||
);
|
||||
}
|
||||
function exact(value: unknown, keys: string[]): boolean {
|
||||
return (
|
||||
plain(value) &&
|
||||
Object.keys(value).sort().join(',') === [...keys].sort().join(',')
|
||||
);
|
||||
}
|
||||
function identifier(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.trim().length > 0 &&
|
||||
!['__proto__', 'constructor', 'prototype'].includes(value)
|
||||
);
|
||||
}
|
||||
function ids(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(identifier) &&
|
||||
new Set(value).size === value.length
|
||||
);
|
||||
}
|
||||
function equal(one: unknown, two: unknown): boolean {
|
||||
return JSON.stringify(one) === JSON.stringify(two);
|
||||
}
|
||||
function playerTuple(player: NexusExchangePlayer): unknown[] {
|
||||
return [
|
||||
player.id,
|
||||
player.faction,
|
||||
player.traitors,
|
||||
player.faceDancers?.map((card) => [card.leader, card.revealed]) ?? null,
|
||||
];
|
||||
}
|
||||
function validatePlayer(player: NexusExchangePlayer): void {
|
||||
requireExchange(
|
||||
exact(player, [
|
||||
'id',
|
||||
'faction',
|
||||
'traitors',
|
||||
...(Object.hasOwn(player, 'faceDancers') ? ['faceDancers'] : []),
|
||||
]) &&
|
||||
identifier(player.id) &&
|
||||
FACTIONS.some((f) => f.id === player.faction) &&
|
||||
ids(player.traitors),
|
||||
'Invalid traitor exchange seat custody.',
|
||||
);
|
||||
if (player.faction === 'tleilaxu') {
|
||||
requireExchange(
|
||||
player.traitors.length === 0 &&
|
||||
Array.isArray(player.faceDancers) &&
|
||||
player.faceDancers.every(
|
||||
(card) =>
|
||||
exact(card, ['leader', 'revealed']) &&
|
||||
identifier(card.leader) &&
|
||||
typeof card.revealed === 'boolean',
|
||||
) &&
|
||||
new Set(player.faceDancers.map((card) => card.leader)).size ===
|
||||
player.faceDancers.length,
|
||||
'Tleilaxu must preserve physical Face Dancer identities and statuses.',
|
||||
);
|
||||
} else
|
||||
requireExchange(
|
||||
!Object.hasOwn(player, 'faceDancers'),
|
||||
'Only Tleilaxu may hold Face Dancers.',
|
||||
);
|
||||
}
|
||||
export function validateNexusTraitorSnapshot(
|
||||
state: NexusTraitorSnapshot,
|
||||
universe: readonly string[],
|
||||
): void {
|
||||
requireExchange(
|
||||
ids(universe) &&
|
||||
universe.length > 0 &&
|
||||
exact(state, ['reserve', 'players']) &&
|
||||
ids(state.reserve) &&
|
||||
Array.isArray(state.players) &&
|
||||
state.players.length >= 2 &&
|
||||
state.players.length <= 6,
|
||||
'Invalid traitor exchange physical inventory.',
|
||||
);
|
||||
for (const player of state.players) validatePlayer(player);
|
||||
requireExchange(
|
||||
new Set(state.players.map((p) => p.id)).size === state.players.length &&
|
||||
new Set(state.players.map((p) => p.faction)).size ===
|
||||
state.players.length,
|
||||
'Traitor exchange seats must have distinct identities and factions.',
|
||||
);
|
||||
const physical = [
|
||||
...state.reserve,
|
||||
...state.players.flatMap((p) => [
|
||||
...p.traitors,
|
||||
...(p.faceDancers ?? []).map((c) => c.leader),
|
||||
]),
|
||||
];
|
||||
requireExchange(
|
||||
physical.length === universe.length &&
|
||||
new Set(physical).size === physical.length &&
|
||||
physical.every((card) => universe.includes(card)),
|
||||
'The complete physical traitor census must remain intact.',
|
||||
);
|
||||
}
|
||||
function count(mode: NexusTraitorExchange['mode']): number {
|
||||
return mode === 'cunning' ? 1 : 2;
|
||||
}
|
||||
function hand(
|
||||
player: NexusExchangePlayer,
|
||||
): string[] | NexusExchangeFaceDancer[] {
|
||||
return player.faction === 'tleilaxu' ? player.faceDancers! : player.traitors;
|
||||
}
|
||||
function returnable(value: string[] | NexusExchangeFaceDancer[]): string[] {
|
||||
return value
|
||||
.filter((card) => typeof card === 'string' || !card.revealed)
|
||||
.map((card) => (typeof card === 'string' ? card : card.leader));
|
||||
}
|
||||
function setHand(
|
||||
player: NexusExchangePlayer,
|
||||
value: (string | NexusExchangeFaceDancer)[],
|
||||
): void {
|
||||
if (player.faction === 'tleilaxu')
|
||||
player.faceDancers = structuredClone(value) as NexusExchangeFaceDancer[];
|
||||
else player.traitors = [...value] as string[];
|
||||
}
|
||||
function augmented(
|
||||
exchange: NexusTraitorExchange,
|
||||
player: NexusExchangePlayer,
|
||||
): string[] | NexusExchangeFaceDancer[] {
|
||||
return player.faction === 'tleilaxu'
|
||||
? [
|
||||
...(structuredClone(exchange.before) as NexusExchangeFaceDancer[]),
|
||||
...exchange.drawn.map((leader) => ({ leader, revealed: false })),
|
||||
]
|
||||
: [...(exchange.before as string[]), ...exchange.drawn];
|
||||
}
|
||||
function signature(exchange: Omit<NexusTraitorExchange, 'signature'>): string {
|
||||
return JSON.stringify([
|
||||
'nexusTraitorExchange',
|
||||
exchange.version,
|
||||
exchange.event,
|
||||
exchange.owner,
|
||||
exchange.mode,
|
||||
exchange.turn,
|
||||
exchange.phase,
|
||||
exchange.stage,
|
||||
exchange.drawn,
|
||||
exchange.before.map((card) =>
|
||||
typeof card === 'string' ? card : [card.leader, card.revealed],
|
||||
),
|
||||
exchange.returned,
|
||||
exchange.source.ownerFaction,
|
||||
exchange.source.reserve,
|
||||
exchange.source.others.map(playerTuple),
|
||||
exchange.settledReserve ?? null,
|
||||
]);
|
||||
}
|
||||
function validateMode(
|
||||
state: NexusTraitorSnapshot,
|
||||
owner: string,
|
||||
mode: NexusTraitorExchange['mode'],
|
||||
): NexusExchangePlayer {
|
||||
const player = state.players.find((p) => p.id === owner);
|
||||
requireExchange(
|
||||
player && ['cunning', 'secretAlly'].includes(mode),
|
||||
'Choose a supported Harkonnen Nexus exchange.',
|
||||
);
|
||||
requireExchange(
|
||||
mode === 'cunning'
|
||||
? player.faction === 'harkonnen'
|
||||
: !state.players.some((p) => p.faction === 'harkonnen'),
|
||||
'The Harkonnen Nexus exchange must match the seated factions.',
|
||||
);
|
||||
return player;
|
||||
}
|
||||
export function validateNexusTraitorExchange(
|
||||
state: NexusTraitorSnapshot,
|
||||
universe: readonly string[],
|
||||
exchange: NexusTraitorExchange,
|
||||
): void {
|
||||
validateNexusTraitorSnapshot(state, universe);
|
||||
requireExchange(
|
||||
exact(exchange, [
|
||||
'version',
|
||||
'event',
|
||||
'owner',
|
||||
'mode',
|
||||
'turn',
|
||||
'phase',
|
||||
'stage',
|
||||
'drawn',
|
||||
'before',
|
||||
'returned',
|
||||
'source',
|
||||
'signature',
|
||||
...(Object.hasOwn(exchange, 'settledReserve') ? ['settledReserve'] : []),
|
||||
]) &&
|
||||
exchange.version === 1 &&
|
||||
identifier(exchange.event) &&
|
||||
identifier(exchange.owner) &&
|
||||
Number.isSafeInteger(exchange.turn) &&
|
||||
exchange.turn > 0 &&
|
||||
Number.isSafeInteger(exchange.phase) &&
|
||||
exchange.phase >= 0 &&
|
||||
exchange.phase <= 8 &&
|
||||
['return', 'complete'].includes(exchange.stage) &&
|
||||
ids(exchange.drawn) &&
|
||||
ids(exchange.returned) &&
|
||||
Array.isArray(exchange.before) &&
|
||||
exact(exchange.source, ['ownerFaction', 'reserve', 'others']) &&
|
||||
ids(exchange.source.reserve) &&
|
||||
Array.isArray(exchange.source.others),
|
||||
'Invalid saved Nexus traitor exchange.',
|
||||
);
|
||||
const player = validateMode(state, exchange.owner, exchange.mode);
|
||||
requireExchange(
|
||||
exchange.drawn.length === count(exchange.mode) &&
|
||||
equal(
|
||||
exchange.drawn,
|
||||
exchange.source.reserve.slice(0, count(exchange.mode)),
|
||||
),
|
||||
'A Nexus exchange must draw its full original group before returning cards.',
|
||||
);
|
||||
requireExchange(
|
||||
exchange.source.ownerFaction === player.faction,
|
||||
'The exchange owner faction must match its original custody.',
|
||||
);
|
||||
const originalOwner = structuredClone(player);
|
||||
setHand(originalOwner, exchange.before);
|
||||
validatePlayer(originalOwner);
|
||||
const original: NexusTraitorSnapshot = {
|
||||
reserve: exchange.source.reserve,
|
||||
players: state.players.map((p) =>
|
||||
p.id === exchange.owner
|
||||
? originalOwner
|
||||
: exchange.source.others.find((other) => other.id === p.id)!,
|
||||
),
|
||||
};
|
||||
requireExchange(
|
||||
exchange.source.others.length === state.players.length - 1 &&
|
||||
equal(
|
||||
exchange.source.others.map(playerTuple),
|
||||
state.players.filter((p) => p.id !== exchange.owner).map(playerTuple),
|
||||
),
|
||||
'A Nexus exchange cannot alter another player’s held cards.',
|
||||
);
|
||||
validateNexusTraitorSnapshot(original, universe);
|
||||
const pool = augmented(exchange, player);
|
||||
requireExchange(
|
||||
exchange.returned.every((card) => returnable(pool).includes(card)),
|
||||
'Return only cards from the augmented owner hand.',
|
||||
);
|
||||
if (exchange.stage === 'return')
|
||||
requireExchange(
|
||||
exchange.returned.length === 0 &&
|
||||
!Object.hasOwn(exchange, 'settledReserve') &&
|
||||
equal(hand(player), pool) &&
|
||||
equal(
|
||||
state.reserve,
|
||||
exchange.source.reserve.slice(count(exchange.mode)),
|
||||
),
|
||||
'The pending exchange must preserve every drawn and originally held card.',
|
||||
);
|
||||
else {
|
||||
const retained = pool.filter(
|
||||
(card) =>
|
||||
!exchange.returned.includes(
|
||||
typeof card === 'string' ? card : card.leader,
|
||||
),
|
||||
);
|
||||
requireExchange(
|
||||
exchange.returned.length === count(exchange.mode) &&
|
||||
ids(exchange.settledReserve) &&
|
||||
equal(hand(player), retained) &&
|
||||
equal(state.reserve, exchange.settledReserve) &&
|
||||
equal(
|
||||
[...exchange.settledReserve].sort(),
|
||||
[
|
||||
...exchange.source.reserve.slice(count(exchange.mode)),
|
||||
...exchange.returned,
|
||||
].sort(),
|
||||
),
|
||||
'The completed exchange must retain its exact selection and shuffled reserve.',
|
||||
);
|
||||
}
|
||||
requireExchange(
|
||||
exchange.signature === signature(exchange),
|
||||
'The Nexus traitor exchange has lost its original history.',
|
||||
);
|
||||
}
|
||||
export function beginNexusTraitorExchange(
|
||||
state: NexusTraitorSnapshot,
|
||||
universe: readonly string[],
|
||||
input: Pick<
|
||||
NexusTraitorExchange,
|
||||
'event' | 'owner' | 'mode' | 'turn' | 'phase'
|
||||
>,
|
||||
): NexusTraitorExchangeResult {
|
||||
validateNexusTraitorSnapshot(state, universe);
|
||||
const player = validateMode(state, input.owner, input.mode);
|
||||
requireExchange(
|
||||
state.reserve.length >= count(input.mode),
|
||||
'The Traitor Deck must contain the full Nexus draw before any return.',
|
||||
);
|
||||
const exchange: NexusTraitorExchange = {
|
||||
version: 1,
|
||||
...input,
|
||||
stage: 'return',
|
||||
drawn: state.reserve.slice(0, count(input.mode)),
|
||||
before: structuredClone(hand(player)),
|
||||
returned: [],
|
||||
source: {
|
||||
ownerFaction: player.faction,
|
||||
reserve: [...state.reserve],
|
||||
others: structuredClone(
|
||||
state.players.filter((p) => p.id !== input.owner),
|
||||
),
|
||||
},
|
||||
signature: '',
|
||||
};
|
||||
const next = structuredClone(state);
|
||||
next.reserve = next.reserve.slice(count(input.mode));
|
||||
setHand(
|
||||
next.players.find((p) => p.id === input.owner)!,
|
||||
augmented(exchange, player),
|
||||
);
|
||||
exchange.signature = signature(exchange);
|
||||
validateNexusTraitorExchange(next, universe, exchange);
|
||||
return { state: next, exchange };
|
||||
}
|
||||
export function finishNexusTraitorExchange(
|
||||
state: NexusTraitorSnapshot,
|
||||
universe: readonly string[],
|
||||
exchange: NexusTraitorExchange,
|
||||
selected: readonly string[],
|
||||
rng: () => number,
|
||||
): NexusTraitorExchangeResult {
|
||||
validateNexusTraitorExchange(state, universe, exchange);
|
||||
requireExchange(
|
||||
exchange.stage === 'return',
|
||||
'This Nexus exchange is already complete.',
|
||||
);
|
||||
const player = state.players.find((p) => p.id === exchange.owner)!;
|
||||
requireExchange(
|
||||
ids(selected) &&
|
||||
selected.length === count(exchange.mode) &&
|
||||
selected.every((card) => returnable(hand(player)).includes(card)),
|
||||
'Return the exact number of distinct cards from your augmented hand.',
|
||||
);
|
||||
const next = structuredClone(state);
|
||||
const retained = hand(player).filter(
|
||||
(card) => !selected.includes(typeof card === 'string' ? card : card.leader),
|
||||
);
|
||||
setHand(
|
||||
next.players.find((p) => p.id === exchange.owner)!,
|
||||
retained,
|
||||
);
|
||||
next.reserve.push(...selected);
|
||||
for (let i = next.reserve.length - 1; i > 0; i--) {
|
||||
const random = rng();
|
||||
requireExchange(
|
||||
typeof random === 'number' &&
|
||||
Number.isFinite(random) &&
|
||||
random >= 0 &&
|
||||
random < 1,
|
||||
'Invalid Nexus exchange shuffle randomness.',
|
||||
);
|
||||
const j = Math.floor(random * (i + 1));
|
||||
[next.reserve[i], next.reserve[j]] = [next.reserve[j], next.reserve[i]];
|
||||
}
|
||||
const complete: NexusTraitorExchange = {
|
||||
...structuredClone(exchange),
|
||||
stage: 'complete',
|
||||
returned: [...selected],
|
||||
settledReserve: [...next.reserve],
|
||||
signature: '',
|
||||
};
|
||||
complete.signature = signature(complete);
|
||||
validateNexusTraitorExchange(next, universe, complete);
|
||||
return { state: next, exchange: complete };
|
||||
}
|
||||
|
||||
/** Completed receipts prove the original transition without freezing later
|
||||
* global custody. Validate the live census separately after subsequent play. */
|
||||
export function validateNexusTraitorHistory(
|
||||
universe: readonly string[],
|
||||
exchange: NexusTraitorExchange,
|
||||
): void {
|
||||
requireExchange(
|
||||
exchange?.stage === 'complete' &&
|
||||
exchange.source &&
|
||||
Array.isArray(exchange.before) &&
|
||||
Array.isArray(exchange.returned),
|
||||
'Only a completed Nexus exchange has an independent history.',
|
||||
);
|
||||
const owner: NexusExchangePlayer = {
|
||||
id: exchange.owner,
|
||||
faction: exchange.source.ownerFaction,
|
||||
traitors: [],
|
||||
};
|
||||
setHand(owner, exchange.before);
|
||||
const retained = augmented(exchange, owner).filter(
|
||||
(card) =>
|
||||
!exchange.returned.includes(
|
||||
typeof card === 'string' ? card : card.leader,
|
||||
),
|
||||
);
|
||||
setHand(owner, retained);
|
||||
validateNexusTraitorExchange(
|
||||
{
|
||||
reserve: exchange.settledReserve!,
|
||||
players: [owner, ...structuredClone(exchange.source.others)],
|
||||
},
|
||||
universe,
|
||||
exchange,
|
||||
);
|
||||
}
|
||||
|
||||
export function nexusTraitorReturnChoices(
|
||||
state: NexusTraitorSnapshot,
|
||||
universe: readonly string[],
|
||||
exchange: NexusTraitorExchange,
|
||||
): string[] {
|
||||
validateNexusTraitorExchange(state, universe, exchange);
|
||||
return exchange.stage === 'return'
|
||||
? returnable(hand(state.players.find((p) => p.id === exchange.owner)!))
|
||||
: [];
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
import { nexusCardMode } from './nexus-cards';
|
||||
import { CHEAP_HERO_TRAITOR, matchingTraitor } from './traitors';
|
||||
|
||||
function quiet(game: GameView): boolean {
|
||||
return (
|
||||
game.status === 'playing' &&
|
||||
!game.automaticContinuationPending &&
|
||||
!game.truthtrance &&
|
||||
!game.response &&
|
||||
!game.decision &&
|
||||
!game.phaseOpening &&
|
||||
!game.nexusCards?.waiting.length
|
||||
);
|
||||
}
|
||||
|
||||
/** The private server offer, not another player's hand, authorizes a draw. */
|
||||
export function nexusTraitorDrawAction(
|
||||
game: GameView,
|
||||
event: string,
|
||||
mode: 'cunning' | 'secretAlly' | 'betrayal',
|
||||
): Action | null {
|
||||
const offer = game.nexusTraitors?.offer;
|
||||
const owner = game.players.find((player) => player.id === game.me);
|
||||
if (
|
||||
game.status !== 'playing' ||
|
||||
game.truthtrance ||
|
||||
game.nexusCards?.waiting.length ||
|
||||
game.nexusTraitors?.pending ||
|
||||
!owner ||
|
||||
!event ||
|
||||
!offer ||
|
||||
offer.event !== event ||
|
||||
offer.mode !== mode ||
|
||||
offer.blocked ||
|
||||
mode === 'betrayal' ||
|
||||
game.nexusCards?.card !== 'harkonnen' ||
|
||||
owner.ally ||
|
||||
nexusCardMode(
|
||||
'harkonnen',
|
||||
owner.faction,
|
||||
game.players.map((player) => player.faction),
|
||||
) !== mode ||
|
||||
offer.draw !== (mode === 'cunning' ? 1 : 2) ||
|
||||
(mode === 'secretAlly' && game.phase !== 8)
|
||||
)
|
||||
return null;
|
||||
return { type: 'nexusTraitorDraw', event, mode };
|
||||
}
|
||||
|
||||
/** Return only the explicitly projected physical choices, never a reconstructed
|
||||
* traitor/Face Dancer hand. A returned new draw is as legal as an old identity. */
|
||||
export function nexusTraitorReturnAction(
|
||||
game: GameView,
|
||||
event: string,
|
||||
cards: readonly string[],
|
||||
): Action | null {
|
||||
const pending = game.nexusTraitors?.pending;
|
||||
if (
|
||||
game.status !== 'playing' ||
|
||||
game.truthtrance ||
|
||||
!pending ||
|
||||
pending.owner !== game.me ||
|
||||
!event ||
|
||||
pending.event !== event ||
|
||||
!game.players.some((player) => player.id === game.me) ||
|
||||
!['cunning', 'secretAlly'].includes(pending.mode) ||
|
||||
pending.count !== (pending.mode === 'cunning' ? 1 : 2) ||
|
||||
(pending.mode === 'secretAlly' && game.phase !== 8) ||
|
||||
cards.length !== pending.count ||
|
||||
new Set(cards).size !== cards.length ||
|
||||
!cards.every((id) => pending.choices.some((choice) => choice.id === id))
|
||||
)
|
||||
return null;
|
||||
return { type: 'nexusTraitorReturn', event, cards: [...cards] };
|
||||
}
|
||||
|
||||
function returnPreference(game: GameView, id: string): number {
|
||||
const owner = game.players.find((player) => player.id === game.me)!;
|
||||
const battle = game.battle;
|
||||
if (
|
||||
battle?.revealed &&
|
||||
[battle.attacker, battle.defender].includes(owner.id)
|
||||
) {
|
||||
const target =
|
||||
battle.attacker === owner.id ? battle.defender : battle.attacker;
|
||||
const leader = battle.plans[target]?.leader;
|
||||
if (
|
||||
leader &&
|
||||
matchingTraitor(
|
||||
[id],
|
||||
leader,
|
||||
battle.cards.find((card) => card.id === leader),
|
||||
)
|
||||
)
|
||||
return 1000;
|
||||
}
|
||||
if (id === CHEAP_HERO_TRAITOR) return 0;
|
||||
const identity = game.allLeaders.find((leader) => leader.id === id);
|
||||
const publicLeader = game.players
|
||||
.flatMap((player) => player.leaders)
|
||||
.find((leader) => leader.id === id);
|
||||
return (
|
||||
(identity?.faction === owner.faction ? -100 : 0) +
|
||||
(publicLeader?.dead ? -20 : 0) +
|
||||
(identity?.strength ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared legal policy only; ranking uses public leaders and this viewer's
|
||||
* authorized choices. It makes no claim of calibrated faction strategy. */
|
||||
export function nexusTraitorBotActions(game: GameView): Action[] {
|
||||
const pending = game.nexusTraitors?.pending;
|
||||
if (pending) {
|
||||
if (
|
||||
pending.owner !== game.me ||
|
||||
game.truthtrance ||
|
||||
game.status !== 'playing'
|
||||
)
|
||||
return [];
|
||||
const cards = [...pending.choices]
|
||||
.sort(
|
||||
(one, two) =>
|
||||
returnPreference(game, one.id) - returnPreference(game, two.id),
|
||||
)
|
||||
.slice(0, pending.count)
|
||||
.map((choice) => choice.id);
|
||||
const action = nexusTraitorReturnAction(game, pending.event, cards);
|
||||
return action ? [action] : [];
|
||||
}
|
||||
const offer = game.nexusTraitors?.offer;
|
||||
if (!offer || !quiet(game)) return [];
|
||||
if (offer.mode === 'cunning') {
|
||||
const battle = game.battle;
|
||||
const beforeCall =
|
||||
game.phase === 6 &&
|
||||
battle?.revealed &&
|
||||
[battle.attacker, battle.defender].includes(game.me) &&
|
||||
battle.traitorVoters.includes(game.me) &&
|
||||
!battle.traitorSubmitted.includes(game.me);
|
||||
if (!beforeCall && game.phase !== 8) return [];
|
||||
}
|
||||
const action = nexusTraitorDrawAction(game, offer.event, offer.mode);
|
||||
return action ? [action] : [];
|
||||
}
|
||||
+9
-6
@@ -3340,7 +3340,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
searchText: NEXUS_CARD_REFERENCE.map(card => `${card.faction} ${card.betrayal} ${card.cunning} ${card.secretAlly}`).join(' '),
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and Atreides inspection controls are integrated. Other effects and complete module play remain in development.',
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws, Atreides inspections and Harkonnen traitor exchanges are integrated. Other effects and complete module play remain in development.',
|
||||
steps: [
|
||||
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
|
||||
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
|
||||
@@ -3353,15 +3353,18 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.',
|
||||
'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.',
|
||||
'Residual Poison cannot follow a committed leader. If it makes a single nonleader inspection impossible, the same field is answered again and the earlier observation remains private history. Residual Poison after two completed Cunning inspections awaits a ruling on incompatible surviving commitments.',
|
||||
'Harkonnen Cunning draws one Traitor Card before you choose one held card to return. It can pause another action, including a response or decision. Finish the private return before that action continues; active Truthtrance takes priority. Returning an identity already declared as a traitor does not cancel that declaration.',
|
||||
'With Harkonnen absent, Secret Ally draws two Traitor Cards during Mentat Pause, then you choose exactly two distinct held identities to return. Newly drawn cards may be returned. Tleilaxu receives Face Dancers and can return newly drawn or existing unrevealed dancers; previously revealed dancers stay protected.',
|
||||
'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.',
|
||||
'Betrayal reactions await a private response timing decision. Other factions’ Nexus effects and complete module games are unfinished, so public module starts remain disabled.',
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle plus Atreides Cunning and Secret Ally inspections, distinct commitments and cancellation. Betrayal and remaining card families are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private card lifecycle, Atreides element selection, owned answers, private history and all four plan locks. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles handle lifecycle, offered Atreides inspections and both binding answers using only authorized views. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and Atreides inspection interactions are recorded with explicit unresolved boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Custody, phase, inspection, controls, bot and concurrent recovery regressions; complete module games remain a release requirement.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen draw-first exchanges and preserved traitor declarations. Betrayal and remaining card families are unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, Atreides answers/history/plan locks, and exact Harkonnen returns with Traitor or Face Dancer inspection. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All profiles handle lifecycle, Atreides commitments and Harkonnen exchanges using authorized choices and public leaders. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces, Atreides inspections and Harkonnen exchanges are recorded with explicit unresolved boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_HARKONNEN_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Custody, phase, inspection, exchange controls, bot and concurrent recovery regressions; final Harkonnen acceptance and complete module games remain separate requirements.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-traitor-controls.test.ts', 'tests/nexus-traitor-bots.test.ts', 'tests/nexus-traitor-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
|
||||
export type TraitorDeclarationContext = {
|
||||
event: string;
|
||||
attacker: string;
|
||||
defender: string;
|
||||
plans: Record<string, { leader: string | null; kwisatz?: boolean }>;
|
||||
players: readonly {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
ally?: string | null;
|
||||
traitors: readonly string[];
|
||||
}[];
|
||||
heroLeaderIds: readonly string[];
|
||||
};
|
||||
|
||||
export type TraitorDeclaration = {
|
||||
event: string;
|
||||
voter: string;
|
||||
beneficiary: string;
|
||||
target: string;
|
||||
leader: string;
|
||||
identity: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
const has = (object: object, key: string) =>
|
||||
Object.prototype.hasOwnProperty.call(object, key);
|
||||
function requireDeclaration(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
function validId(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
function validateContext(context: TraitorDeclarationContext): void {
|
||||
requireDeclaration(
|
||||
context &&
|
||||
validId(context.event) &&
|
||||
validId(context.attacker) &&
|
||||
validId(context.defender) &&
|
||||
context.attacker !== context.defender &&
|
||||
Array.isArray(context.players) &&
|
||||
context.players.length >= 2 &&
|
||||
context.players.length <= 6 &&
|
||||
context.plans &&
|
||||
Array.isArray(context.heroLeaderIds) &&
|
||||
context.heroLeaderIds.every(validId) &&
|
||||
new Set(context.heroLeaderIds).size === context.heroLeaderIds.length,
|
||||
'Invalid traitor declaration battle context.',
|
||||
);
|
||||
const ids = new Set<string>();
|
||||
const factions = new Set<FactionId>();
|
||||
for (const player of context.players) {
|
||||
requireDeclaration(
|
||||
player &&
|
||||
validId(player.id) &&
|
||||
!ids.has(player.id) &&
|
||||
FACTIONS.some((faction) => faction.id === player.faction) &&
|
||||
!factions.has(player.faction),
|
||||
'Invalid traitor declaration roster.',
|
||||
);
|
||||
ids.add(player.id);
|
||||
factions.add(player.faction);
|
||||
}
|
||||
for (const id of [context.attacker, context.defender]) {
|
||||
const plan = has(context.plans, id) ? context.plans[id] : undefined;
|
||||
requireDeclaration(
|
||||
ids.has(id) &&
|
||||
plan &&
|
||||
(plan.leader === null || validId(plan.leader)) &&
|
||||
(plan.kwisatz === undefined || typeof plan.kwisatz === 'boolean'),
|
||||
'A traitor declaration requires both sealed battle plans.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function declaredElements(context: TraitorDeclarationContext, voter: string) {
|
||||
const owner = context.players.find((player) => player.id === voter);
|
||||
requireDeclaration(owner, 'The traitor declaration voter is not seated.');
|
||||
const combatants = [context.attacker, context.defender];
|
||||
const beneficiary = combatants.includes(voter) ? voter : owner.ally;
|
||||
requireDeclaration(
|
||||
beneficiary &&
|
||||
combatants.includes(beneficiary) &&
|
||||
(beneficiary === voter || owner.faction === 'harkonnen'),
|
||||
'Only a combatant or its Harkonnen ally can declare this traitor.',
|
||||
);
|
||||
const target =
|
||||
beneficiary === context.attacker ? context.defender : context.attacker;
|
||||
const plan = context.plans[target];
|
||||
requireDeclaration(
|
||||
validId(plan.leader) && !plan.kwisatz,
|
||||
'A traitor declaration requires an unprotected opposing leader.',
|
||||
);
|
||||
return {
|
||||
beneficiary,
|
||||
target,
|
||||
leader: plan.leader,
|
||||
identity: context.heroLeaderIds.includes(plan.leader)
|
||||
? CHEAP_HERO_TRAITOR
|
||||
: plan.leader,
|
||||
};
|
||||
}
|
||||
|
||||
/** Canonical corruption evidence, not authentication against rewritten saves. */
|
||||
function signature(
|
||||
context: TraitorDeclarationContext,
|
||||
receipt: Omit<TraitorDeclaration, 'signature'>,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
1,
|
||||
receipt.event,
|
||||
receipt.voter,
|
||||
receipt.beneficiary,
|
||||
receipt.target,
|
||||
receipt.leader,
|
||||
receipt.identity,
|
||||
context.players
|
||||
.map(({ id, faction }) => [id, faction])
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function createTraitorDeclaration(
|
||||
context: TraitorDeclarationContext,
|
||||
voter: string,
|
||||
): TraitorDeclaration {
|
||||
validateContext(context);
|
||||
const elements = declaredElements(context, voter);
|
||||
const held = context.players.find((player) => player.id === voter)!.traitors;
|
||||
requireDeclaration(
|
||||
Array.isArray(held) && held.includes(elements.identity),
|
||||
'The declaring player must hold the matching physical traitor identity.',
|
||||
);
|
||||
const receipt = { event: context.event, voter, ...elements, signature: '' };
|
||||
receipt.signature = signature(context, receipt);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
/** Later hand exchanges do not retract an already committed declaration. */
|
||||
export function validateTraitorDeclaration(
|
||||
context: TraitorDeclarationContext,
|
||||
receipt: TraitorDeclaration,
|
||||
): void {
|
||||
validateContext(context);
|
||||
requireDeclaration(
|
||||
receipt &&
|
||||
Object.keys(receipt).sort().join(',') ===
|
||||
'beneficiary,event,identity,leader,signature,target,voter' &&
|
||||
receipt.event === context.event &&
|
||||
validId(receipt.voter),
|
||||
'Invalid traitor declaration receipt.',
|
||||
);
|
||||
const expected = declaredElements(context, receipt.voter);
|
||||
requireDeclaration(
|
||||
receipt.beneficiary === expected.beneficiary &&
|
||||
receipt.target === expected.target &&
|
||||
receipt.leader === expected.leader &&
|
||||
receipt.identity === expected.identity &&
|
||||
receipt.signature === signature(context, receipt),
|
||||
'The traitor declaration lost its original battle or identity binding.',
|
||||
);
|
||||
}
|
||||
|
||||
/** Missing version AND map are the explicit legacy compatibility boundary. */
|
||||
export function validateTraitorDeclarations(
|
||||
context: TraitorDeclarationContext,
|
||||
calls: Readonly<Record<string, boolean | undefined>>,
|
||||
receipts: Readonly<Record<string, TraitorDeclaration>> | undefined,
|
||||
version?: 1,
|
||||
): void {
|
||||
if (version === undefined && receipts === undefined) return;
|
||||
requireDeclaration(
|
||||
version === 1 &&
|
||||
receipts &&
|
||||
typeof receipts === 'object' &&
|
||||
!Array.isArray(receipts) &&
|
||||
calls &&
|
||||
typeof calls === 'object' &&
|
||||
!Array.isArray(calls),
|
||||
'The traitor declaration version or receipt map is missing.',
|
||||
);
|
||||
validateContext(context);
|
||||
for (const [voter, called] of Object.entries(calls)) {
|
||||
requireDeclaration(
|
||||
context.players.some((player) => player.id === voter) &&
|
||||
(called === undefined || typeof called === 'boolean'),
|
||||
'Invalid traitor declaration call.',
|
||||
);
|
||||
if (called === true)
|
||||
requireDeclaration(
|
||||
has(receipts, voter),
|
||||
'A called traitor has no declaration receipt.',
|
||||
);
|
||||
}
|
||||
for (const [voter, receipt] of Object.entries(receipts)) {
|
||||
requireDeclaration(
|
||||
has(calls, voter) &&
|
||||
calls[voter] !== undefined &&
|
||||
receipt?.voter === voter,
|
||||
'A traitor declaration receipt has no matching declared call.',
|
||||
);
|
||||
validateTraitorDeclaration(context, receipt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeNexusGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { botActions } from '../game/bots';
|
||||
import { createAuditorLeader, leaders, treacheryDeck } from '../game/cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { drawNexusCard, validateNexusCards } from '../game/nexus-cards';
|
||||
import { validateNexusTraitorSnapshot } from '../game/nexus-traitor-exchange';
|
||||
import {
|
||||
enterNexusSpice,
|
||||
finishNexusSpice,
|
||||
orderNexusSpice,
|
||||
} from './fixture-nexus-cards';
|
||||
|
||||
export function nexusTraitorFixture(
|
||||
options: {
|
||||
ownerFaction?: FactionId;
|
||||
opponentFaction?: FactionId;
|
||||
advanced?: boolean;
|
||||
ix?: boolean;
|
||||
seatIds?: [string, string, string];
|
||||
phase?: number;
|
||||
} = {},
|
||||
): Game {
|
||||
const [p, q, r] = options.seatIds ?? ['p', 'q', 'r'];
|
||||
let g = createGame(
|
||||
'TRAITORAUDIT',
|
||||
newPlayer(p, 'Owner', 'harkonnen'),
|
||||
options.advanced ?? false,
|
||||
options.ix ? ['ix'] : [],
|
||||
);
|
||||
joinGame(g, newPlayer(q, 'Opponent', 'guild'));
|
||||
joinGame(g, newPlayer(r, 'Observer', 'fremen'));
|
||||
// Final faction is fixed before the genuine component/setup initializer.
|
||||
g.players[0] = newPlayer(p, 'Owner', options.ownerFaction ?? 'harkonnen');
|
||||
if (options.opponentFaction)
|
||||
g.players[1] = newPlayer(q, 'Opponent', options.opponentFaction);
|
||||
for (const player of g.players)
|
||||
g = applyAction(g, player.id, { type: 'ready' });
|
||||
g.nexusCards = { cards: null, phase: null };
|
||||
g = initializeNexusGameForAudit(g);
|
||||
for (let i = 0; g.status === 'setup' && i < 80; i++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of g.players) {
|
||||
const v = viewGame(g, player.id);
|
||||
v.players.find((seat) => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(v)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, player.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
for (const player of g.players) g.deck.push(...player.hand.splice(0));
|
||||
if (
|
||||
options.ix ||
|
||||
(g.advanced && g.players.some((p) => p.faction === 'choam'))
|
||||
) {
|
||||
// Resolve actual CHOAM markets and Ix phase openings before advancing.
|
||||
for (let i = 0; g.phase === 0 && i < 30; i++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const v = viewGame(g, p.id);
|
||||
v.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(v)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
next,
|
||||
`Storm stalled at ${g.decision?.kind}/${g.response?.kind}`,
|
||||
);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.phase, 1);
|
||||
} else g = enterNexusSpice(g);
|
||||
orderNexusSpice(g, ['land', 'land']);
|
||||
if (
|
||||
options.ix ||
|
||||
(g.advanced && g.players.some((p) => p.faction === 'choam'))
|
||||
) {
|
||||
for (let i = 0; g.phase === 1 && i < 40; i++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const v = viewGame(g, p.id);
|
||||
v.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(v)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
next,
|
||||
`Spice phase stalled at ${g.decision?.kind}/${g.response?.kind}`,
|
||||
);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.phase, 2);
|
||||
} else g = finishNexusSpice(g);
|
||||
Object.assign(g, {
|
||||
phase: options.phase ?? 8,
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
active: p,
|
||||
storm: 18,
|
||||
});
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.deck = [
|
||||
'harkonnen',
|
||||
...cards.deck.filter((card) => card !== 'harkonnen'),
|
||||
];
|
||||
g.nexusCards!.cards = drawNexusCard(cards, p, g.players, () => 0);
|
||||
nexusTraitorInventory(g);
|
||||
return g;
|
||||
}
|
||||
export function nexusTraitorInventory(g: Game): void {
|
||||
validateNexusCards(g.nexusCards!.cards!, g.players);
|
||||
const universe = traitorDeck(
|
||||
g.players.map((p) => ({
|
||||
leaders: [
|
||||
...leaders(p.faction),
|
||||
...(g.advanced && p.faction === 'choam' ? [createAuditorLeader()] : []),
|
||||
],
|
||||
})),
|
||||
g.expansions.includes('ix'),
|
||||
);
|
||||
validateNexusTraitorSnapshot(
|
||||
{
|
||||
reserve: g.traitorReserve!,
|
||||
players: g.players.map((p) => ({
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
traitors: [...p.traitors],
|
||||
...(p.faceDancers
|
||||
? { faceDancers: structuredClone(p.faceDancers) }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
universe,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
|
||||
.map((c) => c.id)
|
||||
.sort(),
|
||||
treacheryDeck(g.expansions)
|
||||
.map((c) => c.id)
|
||||
.sort(),
|
||||
);
|
||||
for (const p of g.players)
|
||||
assert.equal(
|
||||
p.reserves +
|
||||
p.tanks +
|
||||
Object.values(p.forces).reduce((sum, n) => sum + n, 0),
|
||||
20,
|
||||
);
|
||||
}
|
||||
export function nexusTraitorDraw(g: Game, id = g.players[0].id): Game {
|
||||
const offer = viewGame(g, id).nexusTraitors!.offer!;
|
||||
assert.equal(offer.blocked, null);
|
||||
return applyAction(g, id, {
|
||||
type: 'nexusTraitorDraw',
|
||||
event: offer.event,
|
||||
mode: offer.mode,
|
||||
});
|
||||
}
|
||||
export function nexusTraitorReturn(
|
||||
g: Game,
|
||||
cards: string[],
|
||||
id = g.players[0].id,
|
||||
): Game {
|
||||
return applyAction(g, id, {
|
||||
type: 'nexusTraitorReturn',
|
||||
event: viewGame(g, id).nexusTraitors!.pending!.event,
|
||||
cards,
|
||||
});
|
||||
}
|
||||
/** Preserve every physical traitor identity while staging a known held traitor. */
|
||||
export function holdNexusTraitor(g: Game, id: string, card: string): void {
|
||||
const owner = g.players.find((p) => p.id === id)!;
|
||||
if (owner.traitors.includes(card)) return;
|
||||
const replace = owner.traitors[0];
|
||||
assert.ok(replace);
|
||||
const reserveIndex = g.traitorReserve!.indexOf(card);
|
||||
if (reserveIndex >= 0) g.traitorReserve![reserveIndex] = replace;
|
||||
else {
|
||||
const other = g.players.find((p) => p.traitors.includes(card));
|
||||
assert.ok(other);
|
||||
other.traitors[other.traitors.indexOf(card)] = replace;
|
||||
}
|
||||
owner.traitors[0] = card;
|
||||
}
|
||||
export function nexusTraitorBattle(g: Game, reveal = true): Game {
|
||||
const [owner, target] = g.players;
|
||||
Object.assign(g, {
|
||||
phase: 6,
|
||||
active: owner.id,
|
||||
order: g.players.map((p) => p.id),
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
for (const p of g.players) {
|
||||
p.forces = {};
|
||||
p.reserves = 20;
|
||||
p.tanks = 0;
|
||||
}
|
||||
for (const p of [owner, target]) {
|
||||
p.forces = { 'pasty_mesa:5': 5 };
|
||||
p.reserves = 15;
|
||||
}
|
||||
g = applyAction(g, owner.id, {
|
||||
type: 'chooseBattle',
|
||||
territory: 'pasty_mesa',
|
||||
target: target.id,
|
||||
});
|
||||
for (const id of [owner.id, target.id])
|
||||
if (g.battle!.preLeader && !g.battle!.preLeader.closed)
|
||||
g = applyAction(g, id, {
|
||||
type: 'battlePreparationReady',
|
||||
event: g.battle!.event,
|
||||
});
|
||||
if (!reveal) return g;
|
||||
while (g.battle!.preparation)
|
||||
g = applyAction(g, g.battle!.preparation.owner, {
|
||||
type: 'declineBattlePower',
|
||||
});
|
||||
for (const id of [owner.id, target.id])
|
||||
g = applyAction(g, id, {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: g.players.find((p) => p.id === id)!.leaders[0].id,
|
||||
});
|
||||
assert.equal(g.battle!.revealed, true);
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type GameView } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
nexusTraitorBotActions,
|
||||
nexusTraitorDrawAction,
|
||||
nexusTraitorReturnAction,
|
||||
} from '../game/nexus-traitor-options';
|
||||
import {
|
||||
nexusTraitorDraw,
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorInventory,
|
||||
} from './fixture-nexus-traitors';
|
||||
|
||||
void test('all four profiles complete actual Cunning and Face Dancer exchanges with conserved custody', () => {
|
||||
for (const ownerFaction of ['harkonnen', 'tleilaxu'] as const) {
|
||||
const initial = nexusTraitorFixture({ ownerFaction });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = viewGame(initial, 'p');
|
||||
v.players[0].bot = difficulty;
|
||||
const before = structuredClone(v);
|
||||
const begin = botActions(v);
|
||||
assert.equal(begin.length, 1);
|
||||
assert.equal(begin[0].type, 'nexusTraitorDraw');
|
||||
assert.deepEqual(v, before);
|
||||
const drawn = applyAction(initial, 'p', begin[0]);
|
||||
const pending = viewGame(drawn, 'p');
|
||||
pending.players[0].bot = difficulty;
|
||||
const returns = botActions(pending);
|
||||
assert.equal(returns.length, 1);
|
||||
assert.equal(returns[0].type, 'nexusTraitorReturn');
|
||||
const cards = returns[0].cards as string[];
|
||||
assert.equal(cards.length, ownerFaction === 'harkonnen' ? 1 : 2);
|
||||
assert.equal(new Set(cards).size, cards.length);
|
||||
assert.deepEqual(
|
||||
returns[0],
|
||||
nexusTraitorReturnAction(
|
||||
pending,
|
||||
pending.nexusTraitors!.pending!.event,
|
||||
cards,
|
||||
),
|
||||
);
|
||||
const settled = applyAction(drawn, 'p', returns[0]);
|
||||
nexusTraitorInventory(settled);
|
||||
assert.equal(viewGame(settled, 'p').nexusTraitors!.pending, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('pending exchange outranks preserved controls and automatic flags while Truth outranks exchange and Nullentropy', () => {
|
||||
const g = nexusTraitorDraw(nexusTraitorFixture());
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = viewGame(g, 'p');
|
||||
v.players[0].bot = difficulty;
|
||||
v.automaticContinuationPending = true;
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
assert.equal(botActions(v)[0]?.type, 'nexusTraitorReturn');
|
||||
v.truthtrance = {
|
||||
stage: 'priority',
|
||||
queue: [],
|
||||
passed: [],
|
||||
question: null,
|
||||
};
|
||||
assert.deepEqual(botActions(v), [{ type: 'truthPass' }]);
|
||||
v.truthtrance.passed = ['p'];
|
||||
assert.deepEqual(botActions(v), []);
|
||||
}
|
||||
for (const id of ['q', 'r']) {
|
||||
const v = viewGame(g, id);
|
||||
v.players.find((p) => p.id === id)!.bot = 'Brutal';
|
||||
Object.defineProperty(v.nexusTraitors!.pending!, 'choices', {
|
||||
get() {
|
||||
throw new Error('Private exchange choices read');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(botActions(v), []);
|
||||
}
|
||||
});
|
||||
|
||||
/** Ranking inputs here are explicit authorized projections. Actual exchange
|
||||
* execution/custody is covered above; these staged views isolate preferences. */
|
||||
function rankingFixture(): GameView {
|
||||
const g = nexusTraitorFixture({ phase: 6 });
|
||||
g.battle = {
|
||||
event: 'ranking-battle',
|
||||
territory: 'arrakeen',
|
||||
attacker: 'p',
|
||||
defender: 'q',
|
||||
prepared: true,
|
||||
plans: {},
|
||||
revealed: false,
|
||||
traitorCalls: {},
|
||||
};
|
||||
const v = viewGame(g, 'p');
|
||||
v.nexusTraitors = {
|
||||
offer: null,
|
||||
pending: {
|
||||
event: 'ranking-exchange',
|
||||
owner: 'p',
|
||||
mode: 'cunning',
|
||||
count: 1,
|
||||
choices: [
|
||||
v.players[0].leaders[0].id,
|
||||
v.players[1].leaders[0].id,
|
||||
v.players[2].leaders[0].id,
|
||||
].map((id) => ({ id, revealed: false, drawn: false })),
|
||||
},
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
void test('ranking reads only authorized choices and public leaders, preserving a revealed opposing traitor', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = rankingFixture();
|
||||
v.players[0].bot = difficulty;
|
||||
const pending = v.nexusTraitors!.pending!;
|
||||
const own = pending.choices[0].id;
|
||||
const opposing = pending.choices[1].id;
|
||||
const other = pending.choices[2].id;
|
||||
for (const player of v.players.slice(1))
|
||||
for (const key of ['hand', 'traitors', 'faceDancers', 'spice'])
|
||||
Object.defineProperty(player, key, {
|
||||
get() {
|
||||
throw new Error(`Read opponent ${key}`);
|
||||
},
|
||||
});
|
||||
const b = v.battle!;
|
||||
Object.defineProperty(b, 'plans', {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error('Read unrevealed plans');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(botActions(v), [
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [own] },
|
||||
]);
|
||||
// A public dead leader is expendable when no own identity is offered.
|
||||
pending.choices = pending.choices.slice(1);
|
||||
v.players[2].leaders[0].dead = true;
|
||||
assert.deepEqual(botActions(v), [
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [other] },
|
||||
]);
|
||||
Object.defineProperty(b, 'plans', {
|
||||
value: { q: { leader: other, dial: 0, weapon: null, defense: null } },
|
||||
writable: true,
|
||||
});
|
||||
b.revealed = true;
|
||||
assert.deepEqual(botActions(v), [
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [opposing] },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('optional bot Cunning waits for an unreplied public traitor call or Mentat; authoritative blocks remain binding', () => {
|
||||
const v = rankingFixture();
|
||||
v.nexusTraitors = {
|
||||
offer: { event: 'offer', mode: 'cunning', draw: 1, blocked: null },
|
||||
pending: null,
|
||||
};
|
||||
const b = v.battle!;
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
b.revealed = true;
|
||||
b.traitorVoters = ['p', 'q'];
|
||||
b.traitorSubmitted = [];
|
||||
assert.deepEqual(nexusTraitorBotActions(v), [
|
||||
{ type: 'nexusTraitorDraw', event: 'offer', mode: 'cunning' },
|
||||
]);
|
||||
b.traitorSubmitted = ['p'];
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
v.phase = 8;
|
||||
assert.equal(nexusTraitorBotActions(v)[0]?.type, 'nexusTraitorDraw');
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
assert.ok(nexusTraitorDrawAction(v, 'offer', 'cunning'));
|
||||
v.decision = null;
|
||||
v.nexusTraitors.offer!.blocked = 'Exchange unavailable.';
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
const secret = viewGame(
|
||||
nexusTraitorFixture({ ownerFaction: 'tleilaxu' }),
|
||||
'p',
|
||||
);
|
||||
assert.equal(nexusTraitorBotActions(secret)[0]?.type, 'nexusTraitorDraw');
|
||||
secret.phase = 7;
|
||||
assert.deepEqual(nexusTraitorBotActions(secret), []);
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorBattle,
|
||||
nexusTraitorDraw,
|
||||
nexusTraitorReturn,
|
||||
nexusTraitorInventory,
|
||||
} from './fixture-nexus-traitors';
|
||||
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
|
||||
|
||||
function hold(g: Game, owner: string, effect: string): string {
|
||||
const source = g.richeseCache?.some((card) => card.effect === effect)
|
||||
? g.richeseCache
|
||||
: g.deck;
|
||||
const index = source.findIndex((card) => card.effect === effect);
|
||||
assert.ok(index >= 0, `Missing physical ${effect}`);
|
||||
const card = source.splice(index, 1)[0];
|
||||
g.players.find((p) => p.id === owner)!.hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
function returnAction(g: Game): Action {
|
||||
const exchange = g.nexusTraitorExchanges!.at(-1)!;
|
||||
return {
|
||||
type: 'nexusTraitorReturn',
|
||||
event: exchange.event,
|
||||
cards: [...exchange.drawn],
|
||||
};
|
||||
}
|
||||
function rejectEverywhere(g: Game): void {
|
||||
const before = JSON.stringify(g);
|
||||
for (const player of g.players) {
|
||||
assert.throws(() => viewGame(g, player.id));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
assert.throws(() => normalizeAutomaticGame(g));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
assert.throws(() => applyAction(g, 'p', returnAction(g)));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
function pendingResponse(): Game {
|
||||
let g = nexusTraitorFixture({ opponentFaction: 'atreides' });
|
||||
hold(g, 'r', 'karama');
|
||||
g = nexusTraitorBattle(g, false);
|
||||
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
|
||||
assert.equal(g.response?.kind, 'prescience');
|
||||
return g;
|
||||
}
|
||||
function beginBox(): Game {
|
||||
let g = nexusTraitorFixture({ opponentFaction: 'richese' });
|
||||
const box = hold(g, 'q', 'nullentropyBox');
|
||||
for (const kind of ['shield', 'projectile']) {
|
||||
const index = g.deck.findIndex((card) => card.kind === kind);
|
||||
assert.ok(index >= 0);
|
||||
g.discard.push(g.deck.splice(index, 1)[0]);
|
||||
}
|
||||
g.players[1].spice = 10;
|
||||
g = applyAction(g, 'q', { type: 'card', card: box });
|
||||
assert.equal(g.decision?.kind, 'nullentropy');
|
||||
assert.ok(g.pendingNullentropy);
|
||||
return g;
|
||||
}
|
||||
function cards(g: Game): string[] {
|
||||
return [
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...(g.richeseCache ?? []),
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort();
|
||||
}
|
||||
function truthPriority(g: Game): Game {
|
||||
for (let i = 0; g.truthtrance?.stage === 'priority' && i < 12; i++) {
|
||||
const owner = g.players.find((p) => !g.truthtrance!.passed.includes(p.id));
|
||||
assert.ok(owner);
|
||||
g = applyAction(g, owner.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(g.truthtrance?.stage, 'ask');
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('Cunning cannot lose or rebind its real suspended Prescience cancellation response', () => {
|
||||
const original = pendingResponse();
|
||||
const pending = nexusTraitorDraw(original);
|
||||
for (const mutate of [
|
||||
(g: Game) => {
|
||||
g.response = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.response!.owner = 'r';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.response!.passed.push('r');
|
||||
},
|
||||
(g: Game) => {
|
||||
g.battle!.preparation!.owner = 'q';
|
||||
},
|
||||
]) {
|
||||
const changed = nexusReload(pending);
|
||||
mutate(changed);
|
||||
rejectEverywhere(changed);
|
||||
}
|
||||
let g = nexusTraitorReturn(
|
||||
normalizeAutomaticGame(nexusReload(pending)),
|
||||
pending.nexusTraitorExchanges![0].drawn,
|
||||
);
|
||||
assert.deepEqual(g.response, original.response);
|
||||
assert.deepEqual(g.battle, original.battle);
|
||||
g = nexusAllow(g);
|
||||
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 0 });
|
||||
assert.equal(g.battle!.prescience!.value, 0);
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
|
||||
void test('a pending Nexus exchange requires its original parent and pending markers', () => {
|
||||
const pending = nexusTraitorDraw(pendingResponse());
|
||||
for (const mutate of [
|
||||
(g: Game) => {
|
||||
delete g.nexusTraitorParent;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusTraitorParent = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusTraitorParent!.event = 'another-exchange';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusTraitorParent!.signature = 'changed';
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.nexusTraitorPending;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.nexusTraitorParent;
|
||||
delete g.nexusTraitorPending;
|
||||
},
|
||||
]) {
|
||||
const changed = nexusReload(pending);
|
||||
mutate(changed);
|
||||
rejectEverywhere(changed);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real Nullentropy search remains paid once and intact through a Nexus exchange and JSON reload', () => {
|
||||
const original = beginBox();
|
||||
const inventory = cards(original);
|
||||
const paid = original.players[1].spice;
|
||||
const pending = nexusTraitorDraw(original);
|
||||
const beforeEarlyChoice = JSON.stringify(pending);
|
||||
assert.throws(
|
||||
() =>
|
||||
applyAction(pending, 'q', {
|
||||
type: 'decision',
|
||||
event: pending.pendingNullentropy!.event,
|
||||
card: pending.discard[0].id,
|
||||
}),
|
||||
/Nexus|return/i,
|
||||
);
|
||||
assert.equal(JSON.stringify(pending), beforeEarlyChoice);
|
||||
for (const mutate of [
|
||||
(g: Game) => {
|
||||
g.decision = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.decision!.player = 'r';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingNullentropy = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingNullentropy!.resume.response = {
|
||||
kind: 'guildIncome',
|
||||
owner: 'p',
|
||||
amount: 3,
|
||||
passed: [],
|
||||
};
|
||||
},
|
||||
]) {
|
||||
const changed = nexusReload(pending);
|
||||
mutate(changed);
|
||||
rejectEverywhere(changed);
|
||||
}
|
||||
let g = nexusTraitorReturn(
|
||||
normalizeAutomaticGame(nexusReload(pending)),
|
||||
pending.nexusTraitorExchanges![0].drawn,
|
||||
);
|
||||
assert.deepEqual(
|
||||
g.pendingNullentropy,
|
||||
nexusReload(original).pendingNullentropy,
|
||||
);
|
||||
assert.deepEqual(g.decision, original.decision);
|
||||
assert.equal(g.players[1].spice, paid);
|
||||
assert.equal(paid, 8);
|
||||
const selected = g.discard[0].id;
|
||||
g = applyAction(g, 'q', {
|
||||
type: 'decision',
|
||||
event: g.pendingNullentropy!.event,
|
||||
card: selected,
|
||||
});
|
||||
assert.equal(g.pendingNullentropy, null);
|
||||
assert.equal(g.players[1].spice, paid);
|
||||
assert.ok(g.players[1].hand.some((card) => card.id === selected));
|
||||
assert.equal(
|
||||
g.discard.filter((card) => card.effect === 'nullentropyBox').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(cards(g), inventory);
|
||||
});
|
||||
|
||||
void test('a real Truthtrance overlay restores the exchange and preceding response without consuming either twice', () => {
|
||||
const initial = pendingResponse();
|
||||
const truth = hold(initial, 'r', 'truthtrance');
|
||||
const pending = nexusTraitorDraw(initial);
|
||||
const originalBattle = structuredClone(pending.battle);
|
||||
const originalResponse = structuredClone(pending.response);
|
||||
const originalExchange = structuredClone(pending.nexusTraitorExchanges);
|
||||
const originalParent = structuredClone(pending.nexusTraitorParent);
|
||||
let g = applyAction(pending, 'r', { type: 'card', card: truth });
|
||||
g = truthPriority(normalizeAutomaticGame(nexusReload(g)));
|
||||
g = applyAction(g, 'r', {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'fact',
|
||||
target: 'p',
|
||||
fact: { kind: 'traitor', leader: g.players[0].traitors[0] },
|
||||
},
|
||||
});
|
||||
g = applyAction(nexusReload(g), 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(g.truthtrance, null);
|
||||
assert.equal(g.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(g.response, originalResponse);
|
||||
assert.deepEqual(g.battle, originalBattle);
|
||||
assert.deepEqual(g.nexusTraitorExchanges, originalExchange);
|
||||
assert.deepEqual(g.nexusTraitorParent, originalParent);
|
||||
assert.equal(g.discard.filter((card) => card.id === truth).length, 1);
|
||||
assert.equal(viewGame(g, 'r').nexusTraitors!.pending!.choices.length, 0);
|
||||
g = nexusTraitorReturn(
|
||||
normalizeAutomaticGame(nexusReload(g)),
|
||||
g.nexusTraitorExchanges![0].drawn,
|
||||
);
|
||||
assert.deepEqual(g.response, originalResponse);
|
||||
assert.equal(g.nexusTraitorParent, null);
|
||||
assert.equal(g.nexusTraitorExchanges![0].stage, 'complete');
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { viewGame, type GameView } from '../game/engine';
|
||||
import {
|
||||
nexusTraitorDrawAction,
|
||||
nexusTraitorReturnAction,
|
||||
} from '../game/nexus-traitor-options';
|
||||
import {
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorDraw,
|
||||
} from './fixture-nexus-traitors';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
|
||||
context,
|
||||
);
|
||||
},
|
||||
});
|
||||
const { NexusTraitors } = await import('../components/nexus-traitors');
|
||||
aliases.deregister();
|
||||
|
||||
function html(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(NexusTraitors, { game, busy, act() {} }),
|
||||
);
|
||||
}
|
||||
|
||||
void test('Nexus draw control uses the private current offer and keeps human Cunning available over preserved controls', () => {
|
||||
const v = viewGame(nexusTraitorFixture(), 'p');
|
||||
const offer = v.nexusTraitors!.offer!;
|
||||
const expected = {
|
||||
type: 'nexusTraitorDraw',
|
||||
event: offer.event,
|
||||
mode: 'cunning',
|
||||
};
|
||||
assert.deepEqual(nexusTraitorDrawAction(v, offer.event, 'cunning'), expected);
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
v.automaticContinuationPending = true;
|
||||
assert.deepEqual(nexusTraitorDrawAction(v, offer.event, 'cunning'), expected);
|
||||
assert.match(html(v), /Draw 1 Traitor card/);
|
||||
assert.match(html(v, true), /disabled/);
|
||||
assert.equal(nexusTraitorDrawAction(v, 'stale', 'cunning'), null);
|
||||
assert.equal(nexusTraitorDrawAction(v, offer.event, 'secretAlly'), null);
|
||||
offer.blocked = 'Finish the current Nexus card draw.';
|
||||
assert.equal(nexusTraitorDrawAction(v, offer.event, 'cunning'), null);
|
||||
assert.match(html(v), /Finish the current Nexus card draw/);
|
||||
offer.blocked = null;
|
||||
v.nexusCards!.card = 'atreides';
|
||||
assert.equal(nexusTraitorDrawAction(v, offer.event, 'cunning'), null);
|
||||
});
|
||||
|
||||
void test('owner exchange renders real identities and explicit choice without automatic confirmation', () => {
|
||||
const v = viewGame(nexusTraitorDraw(nexusTraitorFixture()), 'p');
|
||||
const pending = v.nexusTraitors!.pending!;
|
||||
const rendered = html(v);
|
||||
assert.equal(
|
||||
(rendered.match(/type="checkbox"/g) ?? []).length,
|
||||
pending.choices.length,
|
||||
);
|
||||
assert.doesNotMatch(rendered, /checked=""/);
|
||||
assert.match(rendered, /0 of 1 selected/);
|
||||
assert.match(rendered, /previous game action is paused/);
|
||||
assert.match(rendered, /disabled=""[^>]*>Return selected card/);
|
||||
for (const choice of pending.choices) {
|
||||
const identity = v.allLeaders.find((leader) => leader.id === choice.id)!;
|
||||
assert.ok(rendered.includes(identity.name));
|
||||
assert.ok(rendered.includes(`Inspect traitor: ${identity.name}`));
|
||||
assert.ok(rendered.includes(`Strength ${identity.strength}`));
|
||||
}
|
||||
const drawn = pending.choices.find((choice) => choice.drawn)!.id;
|
||||
assert.deepEqual(nexusTraitorReturnAction(v, pending.event, [drawn]), {
|
||||
type: 'nexusTraitorReturn',
|
||||
event: pending.event,
|
||||
cards: [drawn],
|
||||
});
|
||||
});
|
||||
|
||||
void test('Face Dancer exchange requires exactly two projected choices and excludes previously revealed dancers', () => {
|
||||
const g = nexusTraitorFixture({ ownerFaction: 'tleilaxu' });
|
||||
g.players[0].faceDancers![0].revealed = true;
|
||||
const excluded = g.players[0].faceDancers![0].leader;
|
||||
const v = viewGame(nexusTraitorDraw(g), 'p');
|
||||
const pending = v.nexusTraitors!.pending!;
|
||||
const selected = pending.choices.slice(0, 2).map((choice) => choice.id);
|
||||
assert.equal(pending.count, 2);
|
||||
assert.ok(!pending.choices.some((choice) => choice.id === excluded));
|
||||
assert.match(html(v), /Inspect Face Dancer:/);
|
||||
assert.match(html(v), /Previously revealed Face Dancers are not available/);
|
||||
assert.ok(nexusTraitorReturnAction(v, pending.event, selected));
|
||||
for (const cards of [
|
||||
[],
|
||||
selected.slice(0, 1),
|
||||
[selected[0], selected[0]],
|
||||
[selected[0], excluded],
|
||||
[...selected, excluded],
|
||||
])
|
||||
assert.equal(nexusTraitorReturnAction(v, pending.event, cards), null);
|
||||
assert.equal(nexusTraitorReturnAction(v, 'old-exchange', selected), null);
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
v.automaticContinuationPending = true;
|
||||
assert.ok(nexusTraitorReturnAction(v, pending.event, selected));
|
||||
v.truthtrance = { stage: 'priority', queue: [], passed: [], question: null };
|
||||
assert.equal(nexusTraitorReturnAction(v, pending.event, selected), null);
|
||||
assert.match(html(v), /Finish Truthtrance before returning/);
|
||||
});
|
||||
|
||||
void test('observer exchange controls never read private card choices', () => {
|
||||
const g = nexusTraitorDraw(nexusTraitorFixture());
|
||||
for (const id of ['q', 'r']) {
|
||||
const v = viewGame(g, id);
|
||||
const pending = v.nexusTraitors!.pending!;
|
||||
assert.deepEqual(pending.choices, []);
|
||||
Object.defineProperty(pending, 'choices', {
|
||||
get() {
|
||||
throw new Error('Read another seat’s choices');
|
||||
},
|
||||
});
|
||||
const rendered = html(v);
|
||||
assert.match(rendered, /Waiting for Owner to return 1 card privately/);
|
||||
assert.doesNotMatch(
|
||||
rendered,
|
||||
/checkbox|Inspect traitor|Strength|Newly drawn/,
|
||||
);
|
||||
assert.equal(
|
||||
nexusTraitorReturnAction(v, pending.event, ['anything']),
|
||||
null,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorInventory,
|
||||
nexusTraitorDraw,
|
||||
nexusTraitorReturn,
|
||||
nexusTraitorBattle,
|
||||
holdNexusTraitor,
|
||||
} from './fixture-nexus-traitors';
|
||||
import { nexusReload, nexusReject, nexusAllow } from './fixture-nexus-cards';
|
||||
|
||||
void test('real Cunning draws before return and resumes from JSON with one physical Nexus card spent', () => {
|
||||
let g = nexusTraitorFixture();
|
||||
const before = [...g.players[0].traitors],
|
||||
top = g.traitorReserve![0],
|
||||
reserve = [...g.traitorReserve!];
|
||||
g = nexusTraitorDraw(g);
|
||||
assert.deepEqual(g.players[0].traitors, [...before, top]);
|
||||
assert.deepEqual(g.traitorReserve, reserve.slice(1));
|
||||
assert.equal(g.nexusCards!.cards!.hands.p, null);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((c) => c === 'harkonnen').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
viewGame(g, 'p').nexusTraitors!.pending!.choices.filter((c) => c.drawn)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
||||
g = nexusTraitorReturn(nexusReload(g), [before[0]]);
|
||||
assert.equal(g.nexusTraitorPending, null);
|
||||
assert.equal(g.nexusTraitorExchanges![0].stage, 'complete');
|
||||
assert.deepEqual(g.players[0].traitors, [...before.slice(1), top]);
|
||||
assert.deepEqual(
|
||||
[...g.traitorReserve!].sort(),
|
||||
[...reserve.slice(1), before[0]].sort(),
|
||||
);
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
void test('Secret Ally draws two only in Mentat Pause and safely supports Tleilaxu revealed Face Dancers', () => {
|
||||
for (const faction of ['emperor', 'tleilaxu'] as const) {
|
||||
let g = nexusTraitorFixture({ ownerFaction: faction });
|
||||
if (faction === 'tleilaxu') g.players[0].faceDancers![0].revealed = true;
|
||||
const before = nexusReload(g),
|
||||
drawn = g.traitorReserve!.slice(0, 2);
|
||||
g = nexusTraitorDraw(g);
|
||||
assert.deepEqual(g.traitorReserve, before.traitorReserve!.slice(2));
|
||||
const pending = viewGame(g, 'p').nexusTraitors!.pending!;
|
||||
assert.equal(pending.count, 2);
|
||||
assert.deepEqual(
|
||||
pending.choices.filter((c) => c.drawn).map((c) => c.id),
|
||||
drawn,
|
||||
);
|
||||
if (faction === 'tleilaxu') {
|
||||
assert.equal(g.players[0].faceDancers!.length, 5);
|
||||
assert.equal(
|
||||
pending.choices.some(
|
||||
(c) => c.id === before.players[0].faceDancers![0].leader,
|
||||
),
|
||||
false,
|
||||
);
|
||||
nexusReject(
|
||||
g,
|
||||
'p',
|
||||
{
|
||||
type: 'nexusTraitorReturn',
|
||||
event: pending.event,
|
||||
cards: [before.players[0].faceDancers![0].leader, drawn[0]],
|
||||
},
|
||||
/Return|return/,
|
||||
);
|
||||
}
|
||||
g = nexusTraitorReturn(nexusReload(g), drawn);
|
||||
assert.deepEqual(g.players[0].traitors, before.players[0].traitors);
|
||||
assert.deepEqual(g.players[0].faceDancers, before.players[0].faceDancers);
|
||||
nexusTraitorInventory(g);
|
||||
}
|
||||
const early = nexusTraitorFixture({ ownerFaction: 'emperor', phase: 6 }),
|
||||
offer = viewGame(early, 'p').nexusTraitors!.offer!;
|
||||
assert.match(offer.blocked!, /Mentat/);
|
||||
nexusReject(
|
||||
early,
|
||||
'p',
|
||||
{ type: 'nexusTraitorDraw', event: offer.event, mode: 'secretAlly' },
|
||||
/Mentat/,
|
||||
);
|
||||
});
|
||||
void test('drawn and returnable identities remain private across every seat and JSON saves', () => {
|
||||
const initial = nexusTraitorFixture(),
|
||||
alternative = nexusReload(initial);
|
||||
[alternative.traitorReserve![0], alternative.traitorReserve![1]] = [
|
||||
alternative.traitorReserve![1],
|
||||
alternative.traitorReserve![0],
|
||||
];
|
||||
const one = nexusTraitorDraw(initial),
|
||||
two = nexusTraitorDraw(alternative);
|
||||
assert.notDeepEqual(
|
||||
viewGame(one, 'p').nexusTraitors!.pending!.choices,
|
||||
viewGame(two, 'p').nexusTraitors!.pending!.choices,
|
||||
);
|
||||
for (const id of ['q', 'r']) {
|
||||
assert.deepEqual(
|
||||
viewGame(one, id).nexusTraitors,
|
||||
viewGame(two, id).nexusTraitors,
|
||||
);
|
||||
assert.deepEqual(viewGame(one, id).nexusTraitors!.pending!.choices, []);
|
||||
assert.equal(
|
||||
Object.hasOwn(viewGame(one, id), 'nexusTraitorExchanges'),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
viewGame(nexusReload(one), 'p').nexusTraitors,
|
||||
viewGame(one, 'p').nexusTraitors,
|
||||
);
|
||||
});
|
||||
void test('stale, foreign, malformed and replayed draw/return actions reject unchanged', () => {
|
||||
let g = nexusTraitorFixture();
|
||||
const offer = viewGame(g, 'p').nexusTraitors!.offer!;
|
||||
for (const [id, action] of [
|
||||
['p', { type: 'nexusTraitorDraw', event: 'old', mode: 'cunning' }],
|
||||
['q', { type: 'nexusTraitorDraw', event: offer.event, mode: 'cunning' }],
|
||||
['p', { type: 'nexusTraitorDraw', event: offer.event, mode: 'secretAlly' }],
|
||||
[
|
||||
'p',
|
||||
{
|
||||
type: 'nexusTraitorDraw',
|
||||
event: offer.event,
|
||||
mode: 'cunning',
|
||||
cards: [],
|
||||
},
|
||||
],
|
||||
] as Array<[string, Action]>)
|
||||
nexusReject(g, id, action, /Nexus|Traitor/);
|
||||
g = nexusTraitorDraw(g);
|
||||
const pending = viewGame(g, 'p').nexusTraitors!.pending!,
|
||||
id = pending.choices[0].id;
|
||||
for (const [owner, action] of [
|
||||
['q', { type: 'nexusTraitorReturn', event: pending.event, cards: [id] }],
|
||||
['p', { type: 'nexusTraitorReturn', event: 'old', cards: [id] }],
|
||||
['p', { type: 'nexusTraitorReturn', event: pending.event, cards: [] }],
|
||||
[
|
||||
'p',
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [id, id] },
|
||||
],
|
||||
[
|
||||
'p',
|
||||
{
|
||||
type: 'nexusTraitorReturn',
|
||||
event: pending.event,
|
||||
cards: [g.players[1].traitors[0]],
|
||||
},
|
||||
],
|
||||
] as Array<[string, Action]>)
|
||||
nexusReject(g, owner, action, /Nexus|Return|return/);
|
||||
g = nexusTraitorReturn(g, [id]);
|
||||
nexusReject(
|
||||
g,
|
||||
'p',
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [id] },
|
||||
/Nexus|return/,
|
||||
);
|
||||
nexusReject(
|
||||
g,
|
||||
'p',
|
||||
{ type: 'nexusTraitorDraw', event: offer.event, mode: 'cunning' },
|
||||
/Nexus|Traitor/,
|
||||
);
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
void test('pending exchange blocks ordinary actions and normalization until the owner finishes', () => {
|
||||
const g = nexusTraitorDraw(nexusTraitorFixture());
|
||||
for (const id of ['p', 'q', 'r'])
|
||||
nexusReject(g, id, { type: 'ready' }, /Nexus|return/);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
||||
assert.deepEqual(applyAction(g, 'q', { type: 'advanceBots' }), g);
|
||||
});
|
||||
void test('all four bot levels draw and return through the shared physical offers', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let g = nexusTraitorFixture();
|
||||
g.players[0].bot = difficulty;
|
||||
const draw = botActions(viewGame(g, 'p')).find(
|
||||
(a) => a.type === 'nexusTraitorDraw',
|
||||
);
|
||||
assert.ok(draw, difficulty);
|
||||
g = applyAction(g, 'p', draw);
|
||||
const back = botActions(viewGame(g, 'p')).find(
|
||||
(a) => a.type === 'nexusTraitorReturn',
|
||||
);
|
||||
assert.ok(back, difficulty);
|
||||
g = applyAction(g, 'p', back);
|
||||
assert.equal(g.nexusTraitorPending, null);
|
||||
nexusTraitorInventory(g);
|
||||
}
|
||||
});
|
||||
void test('altered pending marker, source, hand and completed selection reject save recovery immutably', () => {
|
||||
const pending = nexusTraitorDraw(nexusTraitorFixture());
|
||||
for (const mutate of [
|
||||
(g: Game) => {
|
||||
delete g.nexusTraitorPending;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.nexusTraitorExchanges;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusTraitorExchanges![0].drawn[0] = 'forged';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusTraitorExchanges![0].source.reserve.reverse();
|
||||
},
|
||||
(g: Game) => {
|
||||
g.players[0].traitors.reverse();
|
||||
},
|
||||
]) {
|
||||
const g = nexusReload(pending);
|
||||
mutate(g);
|
||||
const before = nexusReload(g);
|
||||
assert.throws(() => viewGame(g, 'p'));
|
||||
assert.throws(() => normalizeAutomaticGame(g));
|
||||
assert.throws(() => nexusTraitorReturn(g, [g.players[0].traitors[0]]));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
const done = nexusTraitorReturn(pending, [pending.players[0].traitors[0]]);
|
||||
done.nexusTraitorExchanges![0].returned = ['forged'];
|
||||
assert.throws(() => viewGame(done, 'p'));
|
||||
});
|
||||
void test('a declared traitor remains effective when Cunning returns that physical identity before the other call', () => {
|
||||
let g = nexusTraitorFixture();
|
||||
const target = g.players[1].leaders[0].id;
|
||||
holdNexusTraitor(g, 'p', target);
|
||||
g = nexusTraitorBattle(g);
|
||||
g = applyAction(g, 'p', { type: 'traitorCall', call: true });
|
||||
assert.equal(g.battle!.traitorCalls.p, true);
|
||||
g = nexusTraitorDraw(g);
|
||||
g = nexusTraitorReturn(g, [target]);
|
||||
assert.equal(g.players[0].traitors.includes(target), false);
|
||||
assert.equal(g.battle!.traitorDeclarations!.p.identity, target);
|
||||
g = applyAction(nexusReload(g), 'q', { type: 'traitorCall', call: false });
|
||||
assert.equal(g.players[1].tanks, 5);
|
||||
assert.match(g.log.map((entry) => entry.text).join('\n'), /traitor/i);
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
void test('an earlier declined traitor call stays false after Cunning draws the matching identity', () => {
|
||||
let g = nexusTraitorFixture();
|
||||
const target = g.players[1].leaders[0].id;
|
||||
holdNexusTraitor(g, 'p', target);
|
||||
const replacement = g.traitorReserve![0];
|
||||
g.players[0].traitors[g.players[0].traitors.indexOf(target)] = replacement;
|
||||
g.traitorReserve![0] = target;
|
||||
g = nexusTraitorBattle(g);
|
||||
g = applyAction(g, 'p', { type: 'traitorCall', call: false });
|
||||
g = nexusTraitorDraw(g);
|
||||
assert.ok(g.players[0].traitors.includes(target));
|
||||
g = nexusTraitorReturn(g, [
|
||||
g.players[0].traitors.find((id) => id !== target)!,
|
||||
]);
|
||||
assert.equal(g.battle!.traitorCalls.p, false);
|
||||
nexusReject(g, 'p', { type: 'traitorCall', call: true }, /Traitor decision/);
|
||||
g = applyAction(g, 'q', { type: 'traitorCall', call: false });
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
void test('Cunning suspends a real native Prescience response without allowing its automatic passes or answer during return', () => {
|
||||
let g = nexusTraitorFixture({ opponentFaction: 'atreides' });
|
||||
const karamaIndex = g.deck.findIndex((c) => c.effect === 'karama');
|
||||
assert.ok(karamaIndex >= 0);
|
||||
g.players[2].hand.push(g.deck.splice(karamaIndex, 1)[0]);
|
||||
g = nexusTraitorBattle(g, false);
|
||||
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
|
||||
assert.equal(g.response?.kind, 'prescience');
|
||||
const response = structuredClone(g.response),
|
||||
preparation = structuredClone(g.battle!.preparation);
|
||||
g = nexusTraitorDraw(g);
|
||||
assert.deepEqual(g.response, response);
|
||||
assert.deepEqual(g.battle!.preparation, preparation);
|
||||
nexusReject(g, 'r', { type: 'passResponse' }, /Nexus|return/);
|
||||
nexusReject(g, 'p', { type: 'prescienceAnswer', value: 0 }, /Nexus|return/);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
||||
g = nexusTraitorReturn(g, [g.players[0].traitors[0]]);
|
||||
g = nexusAllow(g);
|
||||
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 0 });
|
||||
assert.equal(g.battle!.prescience!.value, 0);
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
|
||||
void test('Cunning holds a real post-victory capture decision until its private return finishes', () => {
|
||||
let g = nexusTraitorFixture({ advanced: true });
|
||||
const target = g.players[1].leaders[0].id;
|
||||
holdNexusTraitor(g, 'p', target);
|
||||
g = nexusTraitorBattle(g);
|
||||
g = applyAction(g, 'p', { type: 'traitorCall', call: true });
|
||||
g = applyAction(g, 'q', { type: 'traitorCall', call: false });
|
||||
assert.equal(g.decision?.kind, 'captureOffer');
|
||||
const decision = structuredClone(g.decision);
|
||||
g = nexusTraitorDraw(g);
|
||||
assert.deepEqual(g.decision, decision);
|
||||
nexusReject(g, 'p', { type: 'decision', accept: false }, /Nexus|return/);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)).decision, decision);
|
||||
g = nexusTraitorReturn(g, [g.players[0].traitors[0]]);
|
||||
assert.deepEqual(g.decision, decision);
|
||||
g = applyAction(g, 'p', { type: 'decision', accept: false });
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
|
||||
void test('fresh Advanced CHOAM and Ix fixtures retain the printed Auditor and Cheap Hero traitor census', () => {
|
||||
for (const options of [
|
||||
{ advanced: true, ownerFaction: 'choam' as const },
|
||||
{ ix: true },
|
||||
]) {
|
||||
let g = nexusTraitorFixture(options);
|
||||
const all = [...g.traitorReserve!, ...g.players.flatMap((p) => p.traitors)];
|
||||
if (options.ix) assert.ok(all.includes('cheap-hero-traitor'));
|
||||
else
|
||||
assert.ok(
|
||||
all.includes(
|
||||
g.players[0].leaders.find((l) => l.id.includes('auditor'))!.id,
|
||||
),
|
||||
);
|
||||
g = nexusTraitorDraw(g);
|
||||
const selected = viewGame(g, 'p')
|
||||
.nexusTraitors!.pending!.choices.slice(0, options.ownerFaction ? 2 : 1)
|
||||
.map((c) => c.id);
|
||||
g = nexusTraitorReturn(g, selected);
|
||||
nexusTraitorInventory(g);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
beginNexusTraitorExchange,
|
||||
finishNexusTraitorExchange,
|
||||
validateNexusTraitorExchange,
|
||||
validateNexusTraitorSnapshot,
|
||||
validateNexusTraitorHistory,
|
||||
nexusTraitorReturnChoices,
|
||||
type NexusTraitorSnapshot,
|
||||
type NexusTraitorExchange,
|
||||
} from '../game/nexus-traitor-exchange';
|
||||
|
||||
const universe = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
|
||||
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
function fixture(
|
||||
mode: 'cunning' | 'secretAlly' = 'cunning',
|
||||
dancer = false,
|
||||
): NexusTraitorSnapshot {
|
||||
return {
|
||||
reserve: ['e', 'f', 'g', 'h'],
|
||||
players: [
|
||||
dancer
|
||||
? {
|
||||
id: 'owner',
|
||||
faction: 'tleilaxu',
|
||||
traitors: [],
|
||||
faceDancers: [
|
||||
{ leader: 'a', revealed: true },
|
||||
{ leader: 'b', revealed: false },
|
||||
{ leader: 'c', revealed: true },
|
||||
],
|
||||
}
|
||||
: {
|
||||
id: 'owner',
|
||||
faction: mode === 'cunning' ? 'harkonnen' : 'emperor',
|
||||
traitors: ['a', 'b', 'c'],
|
||||
},
|
||||
{ id: 'other', faction: 'guild', traitors: ['d'] },
|
||||
],
|
||||
};
|
||||
}
|
||||
function input(mode: 'cunning' | 'secretAlly' = 'cunning') {
|
||||
return { event: 'exchange-1', owner: 'owner', mode, turn: 3, phase: 8 };
|
||||
}
|
||||
void test('Cunning draws the original top card before return and shuffles only after choosing from the augmented hand', () => {
|
||||
const initial = fixture(),
|
||||
before = reload(initial);
|
||||
const begun = beginNexusTraitorExchange(initial, universe, input());
|
||||
assert.deepEqual(initial, before);
|
||||
assert.deepEqual(begun.exchange.before, ['a', 'b', 'c']);
|
||||
assert.deepEqual(begun.exchange.drawn, ['e']);
|
||||
assert.deepEqual(begun.state.players[0].traitors, ['a', 'b', 'c', 'e']);
|
||||
assert.deepEqual(begun.state.reserve, ['f', 'g', 'h']);
|
||||
assert.deepEqual(
|
||||
nexusTraitorReturnChoices(begun.state, universe, begun.exchange),
|
||||
['a', 'b', 'c', 'e'],
|
||||
);
|
||||
let calls = 0;
|
||||
const done = finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['b'],
|
||||
() => {
|
||||
calls++;
|
||||
return 0.999;
|
||||
},
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
assert.deepEqual(done.state.reserve, ['f', 'g', 'h', 'b']);
|
||||
assert.deepEqual(done.state.players[0].traitors, ['a', 'c', 'e']);
|
||||
assert.deepEqual(done.state.players[1], initial.players[1]);
|
||||
assert.deepEqual(begun.state.players[0].traitors, ['a', 'b', 'c', 'e']);
|
||||
validateNexusTraitorExchange(
|
||||
reload(done.state),
|
||||
universe,
|
||||
reload(done.exchange),
|
||||
);
|
||||
validateNexusTraitorHistory(universe, reload(done.exchange));
|
||||
assert.throws(() =>
|
||||
finishNexusTraitorExchange(
|
||||
done.state,
|
||||
universe,
|
||||
done.exchange,
|
||||
['e'],
|
||||
() => 0,
|
||||
),
|
||||
);
|
||||
});
|
||||
void test('Secret Ally draws both cards first and allows returning either original or newly drawn identities', () => {
|
||||
const begun = beginNexusTraitorExchange(
|
||||
fixture('secretAlly'),
|
||||
universe,
|
||||
input('secretAlly'),
|
||||
);
|
||||
assert.deepEqual(begun.exchange.drawn, ['e', 'f']);
|
||||
assert.deepEqual(begun.state.players[0].traitors, ['a', 'b', 'c', 'e', 'f']);
|
||||
for (const selected of [
|
||||
['a', 'f'],
|
||||
['e', 'f'],
|
||||
['a', 'b'],
|
||||
]) {
|
||||
const done = finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
selected,
|
||||
() => 0,
|
||||
);
|
||||
assert.equal(done.state.players[0].traitors.length, 3);
|
||||
assert.deepEqual(done.exchange.returned, selected);
|
||||
assert.ok(selected.every((id) => done.state.reserve.includes(id)));
|
||||
validateNexusTraitorHistory(universe, done.exchange);
|
||||
}
|
||||
});
|
||||
void test('Tleilaxu retains original revealed flags and protects those cards while new retained Face Dancers start unrevealed', () => {
|
||||
const initial = fixture('secretAlly', true);
|
||||
const begun = beginNexusTraitorExchange(
|
||||
initial,
|
||||
universe,
|
||||
input('secretAlly'),
|
||||
);
|
||||
assert.deepEqual(begun.state.players[0].faceDancers, [
|
||||
...initial.players[0].faceDancers!,
|
||||
{ leader: 'e', revealed: false },
|
||||
{ leader: 'f', revealed: false },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
nexusTraitorReturnChoices(begun.state, universe, begun.exchange),
|
||||
['b', 'e', 'f'],
|
||||
);
|
||||
assert.throws(() =>
|
||||
finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['a', 'e'],
|
||||
() => {
|
||||
throw Error('Invalid selection cannot shuffle');
|
||||
},
|
||||
),
|
||||
);
|
||||
const done = finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['b', 'e'],
|
||||
() => 0.999,
|
||||
);
|
||||
assert.deepEqual(done.state.players[0].faceDancers, [
|
||||
{ leader: 'a', revealed: true },
|
||||
{ leader: 'c', revealed: true },
|
||||
{ leader: 'f', revealed: false },
|
||||
]);
|
||||
assert.deepEqual(done.state.players[0].traitors, []);
|
||||
validateNexusTraitorHistory(universe, reload(done.exchange));
|
||||
});
|
||||
void test('revealed Tleilaxu cards stay protected even when all original cards have revealed', () => {
|
||||
const initial = fixture('secretAlly', true);
|
||||
initial.players[0].faceDancers!.forEach((c) => {
|
||||
c.revealed = true;
|
||||
});
|
||||
const begun = beginNexusTraitorExchange(
|
||||
initial,
|
||||
universe,
|
||||
input('secretAlly'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
nexusTraitorReturnChoices(begun.state, universe, begun.exchange),
|
||||
['e', 'f'],
|
||||
);
|
||||
const done = finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['e', 'f'],
|
||||
() => 0.999,
|
||||
);
|
||||
assert.deepEqual(
|
||||
done.state.players[0].faceDancers,
|
||||
initial.players[0].faceDancers,
|
||||
);
|
||||
});
|
||||
void test('insufficient reserve cannot use returns to fund the original draw', () => {
|
||||
const state = fixture('secretAlly');
|
||||
state.players[1].traitors.push(...state.reserve.splice(0, 3));
|
||||
const before = reload(state);
|
||||
assert.throws(() =>
|
||||
beginNexusTraitorExchange(state, universe, input('secretAlly')),
|
||||
);
|
||||
assert.deepEqual(state, before);
|
||||
});
|
||||
void test('wrong faction modes, malformed metadata and unsupported Betrayal reject before changing custody', () => {
|
||||
const state = fixture(),
|
||||
before = reload(state);
|
||||
for (const change of [
|
||||
{ mode: 'secretAlly' },
|
||||
{ mode: 'betrayal' },
|
||||
{ owner: 'other' },
|
||||
{ owner: 'missing' },
|
||||
{ turn: 0 },
|
||||
{ turn: Infinity },
|
||||
{ phase: 9 },
|
||||
{ phase: 1.5 },
|
||||
{ event: '' },
|
||||
])
|
||||
assert.throws(() =>
|
||||
beginNexusTraitorExchange(state, universe, {
|
||||
...input(),
|
||||
...change,
|
||||
} as ReturnType<typeof input>),
|
||||
);
|
||||
assert.deepEqual(state, before);
|
||||
});
|
||||
void test('invalid exact return counts, duplicates, foreign cards and invalid randomness reject immutable snapshots', () => {
|
||||
const begun = beginNexusTraitorExchange(
|
||||
fixture('secretAlly'),
|
||||
universe,
|
||||
input('secretAlly'),
|
||||
);
|
||||
const before = reload(begun);
|
||||
for (const selected of [
|
||||
[],
|
||||
['a'],
|
||||
['a', 'a'],
|
||||
['a', 'b', 'c'],
|
||||
['a', 'd'],
|
||||
['a', 'unknown'],
|
||||
])
|
||||
assert.throws(() =>
|
||||
finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
selected,
|
||||
() => {
|
||||
throw Error('No RNG on rejected selection');
|
||||
},
|
||||
),
|
||||
);
|
||||
for (const value of [-1, 1, NaN, Infinity])
|
||||
assert.throws(() =>
|
||||
finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['a', 'b'],
|
||||
() => value,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(begun, before);
|
||||
});
|
||||
void test('physical census rejects duplicate, missing and unknown cards and conflicting typed hands', () => {
|
||||
for (const mutate of [
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.reserve[0] = 'a';
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.reserve.pop();
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.reserve[0] = 'unknown';
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.players[1].id = s.players[0].id;
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.players[0].id = '__proto__';
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.players[0].faceDancers = [];
|
||||
},
|
||||
]) {
|
||||
const state = fixture();
|
||||
mutate(state);
|
||||
const before = reload(state);
|
||||
assert.throws(() => validateNexusTraitorSnapshot(state, universe));
|
||||
assert.deepEqual(state, before);
|
||||
}
|
||||
const dancer = fixture('secretAlly', true);
|
||||
dancer.players[0].traitors = ['a'];
|
||||
assert.throws(() => validateNexusTraitorSnapshot(dancer, universe));
|
||||
assert.throws(() =>
|
||||
validateNexusTraitorSnapshot(fixture(), [...universe, 'a']),
|
||||
);
|
||||
});
|
||||
void test('pending state binds original reserve order, owner augmented hand and every other player', () => {
|
||||
const begun = beginNexusTraitorExchange(fixture(), universe, input());
|
||||
for (const mutate of [
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.reserve.reverse();
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.players[0].traitors.reverse();
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
[s.players[1].traitors[0], s.reserve[0]] = [
|
||||
s.reserve[0],
|
||||
s.players[1].traitors[0],
|
||||
];
|
||||
},
|
||||
]) {
|
||||
const state = reload(begun.state);
|
||||
mutate(state);
|
||||
validateNexusTraitorSnapshot(state, universe);
|
||||
assert.throws(() =>
|
||||
validateNexusTraitorExchange(state, universe, begun.exchange),
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('signed completed history rejects alteration but remains valid after later legitimate card custody changes', () => {
|
||||
const begun = beginNexusTraitorExchange(fixture(), universe, input());
|
||||
const done = finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['a'],
|
||||
() => 0.999,
|
||||
);
|
||||
for (const mutate of [
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.stage = 'return';
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.returned = ['b'];
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.drawn = ['f'];
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.before.reverse();
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.source.reserve.reverse();
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.settledReserve!.reverse();
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.turn++;
|
||||
},
|
||||
]) {
|
||||
const receipt = reload(done.exchange);
|
||||
mutate(receipt);
|
||||
assert.throws(() => validateNexusTraitorHistory(universe, receipt));
|
||||
}
|
||||
const later = reload(done.state);
|
||||
[later.players[1].traitors[0], later.reserve[0]] = [
|
||||
later.reserve[0],
|
||||
later.players[1].traitors[0],
|
||||
];
|
||||
validateNexusTraitorSnapshot(later, universe);
|
||||
validateNexusTraitorHistory(universe, done.exchange);
|
||||
assert.throws(() =>
|
||||
validateNexusTraitorExchange(later, universe, done.exchange),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { nexusTraitorFixture, nexusTraitorDraw, nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
async function fixture(initial = nexusTraitorFixture()) {
|
||||
const store = unitStore();
|
||||
const made = await store.rooms.createRoom('Traitor SQL', 'harkonnen', false, []);
|
||||
const code = made.view.code, tokens = [made.token];
|
||||
for (const faction of ['guild', 'fremen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const ids = ['p', 'q', 'r'];
|
||||
for (const [i, token] of tokens.entries()) {
|
||||
const auth = await store.rooms.authenticate(code, token);
|
||||
store.sqlite.prepare('UPDATE seats SET player_id = ? WHERE room_code = ? AND player_id = ?')
|
||||
.run(ids[i], code, auth.playerId);
|
||||
}
|
||||
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
initial.code = code; initial.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(initial), initial.version, code);
|
||||
store.writes.length = 0;
|
||||
return { ...store, code, tokens, auths, ids, initial };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>> & { sqlite: DatabaseSync };
|
||||
const row = (f: Fixture) => f.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(f.code);
|
||||
function draw(g: Game): Action {
|
||||
const offer = viewGame(g, 'p').nexusTraitors!.offer!;
|
||||
return { type: 'nexusTraitorDraw', event: offer.event, mode: offer.mode };
|
||||
}
|
||||
const giveBack = (g: Game, card: string): Action => ({ type: 'nexusTraitorReturn', event: g.nexusTraitorPending, cards: [card] });
|
||||
async function restored(f: Fixture, g: Game) {
|
||||
const before = row(f), rooms = f.restart();
|
||||
for (const [i, token] of f.tokens.entries()) {
|
||||
const auth = await rooms.authenticate(f.code, token);
|
||||
const view = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(g, f.ids[i]));
|
||||
if (i && view.nexusTraitors?.pending) assert.deepEqual(view.nexusTraitors.pending.choices, []);
|
||||
}
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), before);
|
||||
nexusTraitorInventory(g);
|
||||
}
|
||||
void test('Nexus traitor SQL lost draw and return responses restore exact private custody without redrawing or reshuffling', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const action = draw(f.initial);
|
||||
await f.rooms.act(f.code, f.auths[0], f.initial.version, action, clock);
|
||||
let g = await f.restart().readRoom(f.code);
|
||||
assert.equal(g.players[0].traitors.length, 5);
|
||||
await restored(f, g);
|
||||
let before = row(f);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[0], f.initial.version, action, clock));
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[0], g.version, action, clock));
|
||||
assert.deepEqual(row(f), before);
|
||||
const returned = giveBack(g, g.nexusTraitorExchanges![0].drawn[0]);
|
||||
const version = g.version;
|
||||
await f.rooms.act(f.code, f.auths[0], version, returned, clock);
|
||||
g = await f.restart().readRoom(f.code); before = row(f);
|
||||
assert.equal(g.players[0].traitors.length, 4);
|
||||
assert.equal(g.nexusTraitorExchanges![0].stage, 'complete');
|
||||
await restored(f, g);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[0], version, returned, clock));
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[0], g.version, returned, clock));
|
||||
assert.deepEqual(row(f), before);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
void test('Nexus traitor SQL competing returns commit one selection and one shuffle while preserving unrelated rooms', async () => {
|
||||
const f = await fixture(nexusTraitorDraw(nexusTraitorFixture()));
|
||||
try {
|
||||
const other = await f.rooms.createRoom('Unrelated', 'atreides', false, []);
|
||||
const unrelated = f.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(other.view.code);
|
||||
f.writes.length = 0;
|
||||
let arrivals = 0; let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
|
||||
const results = await Promise.allSettled(f.initial.players[0].traitors.slice(0, 2).map(card =>
|
||||
f.restart().act(f.code, f.auths[0], f.initial.version, giveBack(f.initial, card), clock)));
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
const g = await f.rooms.readRoom(f.code);
|
||||
assert.equal(g.version, f.initial.version + 1);
|
||||
assert.equal(g.nexusTraitorExchanges!.length, 1);
|
||||
assert.equal(g.nexusTraitorExchanges![0].returned.length, 1);
|
||||
await restored(f, g);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(other.view.code), unrelated);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
void test('Nexus traitor SQL rejects lost preceding-decision evidence before reads or writes', async () => {
|
||||
const f = await fixture(nexusTraitorDraw(nexusTraitorFixture()));
|
||||
try {
|
||||
for (const damage of [
|
||||
(g: Game) => { g.nexusTraitorParent = null; },
|
||||
(g: Game) => { g.ready.push('q'); },
|
||||
(g: Game) => { g.nexusTraitorExchanges![0].drawn[0] = 'missing'; },
|
||||
]) {
|
||||
const bad = structuredClone(f.initial); damage(bad);
|
||||
f.sqlite.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(JSON.stringify(bad), f.code);
|
||||
f.writes.length = 0; const before = row(f);
|
||||
await assert.rejects(f.restart().readSeatView(f.code, f.auths[0]));
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[0], bad.version, giveBack(f.initial, f.initial.players[0].traitors[0]), clock));
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
}
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createTraitorDeclaration,
|
||||
validateTraitorDeclaration,
|
||||
validateTraitorDeclarations,
|
||||
type TraitorDeclarationContext,
|
||||
type TraitorDeclaration,
|
||||
} from '../game/traitor-declarations';
|
||||
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
|
||||
|
||||
const context = (harkonnen = false): TraitorDeclarationContext => ({
|
||||
event: 'battle-4',
|
||||
attacker: 'a',
|
||||
defender: 'g',
|
||||
plans: { a: { leader: 'a-leader' }, g: { leader: 'g-leader' } },
|
||||
players: [
|
||||
{
|
||||
id: 'a',
|
||||
faction: 'atreides',
|
||||
ally: 'h',
|
||||
traitors: harkonnen ? ['a-other'] : ['g-leader'],
|
||||
},
|
||||
{ id: 'g', faction: 'guild', traitors: ['a-leader'] },
|
||||
{
|
||||
id: 'h',
|
||||
faction: 'harkonnen',
|
||||
ally: 'a',
|
||||
traitors: harkonnen ? ['g-leader'] : ['h-other'],
|
||||
},
|
||||
],
|
||||
heroLeaderIds: [],
|
||||
});
|
||||
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
void test('declarations preserve a valid call after its original identity leaves the hand', () => {
|
||||
for (const voter of ['a', 'g', 'h']) {
|
||||
const c = context(voter === 'h');
|
||||
const before = JSON.stringify(c);
|
||||
const receipt = createTraitorDeclaration(c, voter);
|
||||
assert.equal(JSON.stringify(c), before);
|
||||
c.players.find((p) => p.id === voter)!.traitors = ['other-identity'];
|
||||
const changed = JSON.stringify(c);
|
||||
validateTraitorDeclaration(c, reload(receipt));
|
||||
validateTraitorDeclarations(c, { [voter]: true }, { [voter]: receipt }, 1);
|
||||
assert.equal(JSON.stringify(c), changed);
|
||||
assert.throws(() => createTraitorDeclaration(c, voter), /must hold/);
|
||||
assert.equal(receipt.identity, voter === 'g' ? 'a-leader' : 'g-leader');
|
||||
}
|
||||
});
|
||||
|
||||
void test('an allied Harkonnen declaration binds its beneficiary separately from its voter', () => {
|
||||
const c = context(true);
|
||||
const receipt = createTraitorDeclaration(c, 'h');
|
||||
assert.equal(receipt.voter, 'h');
|
||||
assert.equal(receipt.beneficiary, 'a');
|
||||
assert.equal(receipt.target, 'g');
|
||||
for (const ally of [null, 'g', 'missing']) {
|
||||
const changed = reload(c);
|
||||
changed.players[2].ally = ally;
|
||||
assert.throws(() => validateTraitorDeclaration(changed, receipt));
|
||||
}
|
||||
const nonHarkonnen = reload(c);
|
||||
nonHarkonnen.players[2].faction = 'fremen';
|
||||
assert.throws(() => createTraitorDeclaration(nonHarkonnen, 'h'));
|
||||
});
|
||||
|
||||
void test('Cheap Hero receipt binds the sealed card ID to the shared physical traitor identity', () => {
|
||||
const c = context();
|
||||
c.plans.g.leader = 'cheap-hero-card-2';
|
||||
c.heroLeaderIds = ['cheap-hero-card-1', 'cheap-hero-card-2'];
|
||||
c.players[0].traitors = [CHEAP_HERO_TRAITOR];
|
||||
const receipt = createTraitorDeclaration(c, 'a');
|
||||
assert.equal(receipt.leader, 'cheap-hero-card-2');
|
||||
assert.equal(receipt.identity, CHEAP_HERO_TRAITOR);
|
||||
c.players[0].traitors = [];
|
||||
validateTraitorDeclaration(c, receipt);
|
||||
c.plans.g.leader = 'cheap-hero-card-1';
|
||||
assert.throws(() => validateTraitorDeclaration(c, receipt));
|
||||
c.plans.g.leader = 'cheap-hero-card-2';
|
||||
c.heroLeaderIds = [];
|
||||
assert.throws(() => validateTraitorDeclaration(c, receipt));
|
||||
});
|
||||
|
||||
void test('missing leaders, Kwisatz protection and missing custody reject creation without mutation', () => {
|
||||
for (const mutate of [
|
||||
(c: TraitorDeclarationContext) => {
|
||||
c.plans.g.leader = null;
|
||||
},
|
||||
(c: TraitorDeclarationContext) => {
|
||||
c.plans.g.kwisatz = true;
|
||||
},
|
||||
(c: TraitorDeclarationContext) => {
|
||||
c.players[0].traitors = [];
|
||||
},
|
||||
]) {
|
||||
const c = context();
|
||||
mutate(c);
|
||||
const before = JSON.stringify(c);
|
||||
assert.throws(() => createTraitorDeclaration(c, 'a'));
|
||||
assert.equal(JSON.stringify(c), before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('receipt edits, deleted fields and extra fields cannot change the committed identity', () => {
|
||||
const c = context();
|
||||
const original = createTraitorDeclaration(c, 'a');
|
||||
for (const field of Object.keys(original)) {
|
||||
for (const remove of [false, true]) {
|
||||
const receipt = reload(original);
|
||||
const writable = receipt as unknown as Record<string, unknown>;
|
||||
if (remove) delete writable[field];
|
||||
else writable[field] = 'changed';
|
||||
const before = JSON.stringify(receipt);
|
||||
assert.throws(() => validateTraitorDeclaration(c, receipt));
|
||||
assert.equal(JSON.stringify(receipt), before);
|
||||
}
|
||||
}
|
||||
assert.throws(() =>
|
||||
validateTraitorDeclaration(c, {
|
||||
...original,
|
||||
hidden: 'unexpected',
|
||||
} as TraitorDeclaration),
|
||||
);
|
||||
});
|
||||
|
||||
void test('battle, target leader, protection and roster corruption reject while seat ordering does not', () => {
|
||||
const c = context();
|
||||
const receipt = createTraitorDeclaration(c, 'a');
|
||||
for (const mutate of [
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.event = 'another-battle';
|
||||
},
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.plans.g.leader = 'replacement';
|
||||
},
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.plans.g.kwisatz = true;
|
||||
},
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.players[2].faction = 'fremen';
|
||||
},
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.players[2].id = 'other-seat';
|
||||
},
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.players[2].id = 'a';
|
||||
},
|
||||
]) {
|
||||
const changed = reload(c);
|
||||
mutate(changed);
|
||||
assert.throws(() => validateTraitorDeclaration(changed, receipt));
|
||||
}
|
||||
c.players = [...c.players].reverse();
|
||||
validateTraitorDeclaration(c, receipt);
|
||||
});
|
||||
|
||||
void test('a canceled false call retains evidence, but a false decision cannot be promoted by a new draw', () => {
|
||||
const c = context(true);
|
||||
const receipt = createTraitorDeclaration(c, 'h');
|
||||
validateTraitorDeclarations(c, { h: false }, { h: receipt }, 1);
|
||||
validateTraitorDeclarations(c, { a: false }, {}, 1);
|
||||
c.players[0].traitors = ['g-leader', 'new-traitor'];
|
||||
assert.throws(() => validateTraitorDeclarations(c, { a: true }, {}, 1));
|
||||
assert.throws(() => validateTraitorDeclarations(c, {}, { h: receipt }, 1));
|
||||
assert.throws(() =>
|
||||
validateTraitorDeclarations(c, { h: undefined }, { h: receipt }, 1),
|
||||
);
|
||||
});
|
||||
|
||||
void test('versioned maps require every true call, exact voter keys and both metadata fields', () => {
|
||||
const c = context();
|
||||
const a = createTraitorDeclaration(c, 'a');
|
||||
const g = createTraitorDeclaration(c, 'g');
|
||||
validateTraitorDeclarations(c, { a: true, g: true }, { a, g }, 1);
|
||||
assert.throws(() =>
|
||||
validateTraitorDeclarations(c, { a: true, g: true }, { a }, 1),
|
||||
);
|
||||
assert.throws(() => validateTraitorDeclarations(c, { a: true }, { a: g }, 1));
|
||||
assert.throws(() =>
|
||||
validateTraitorDeclarations(c, { a: true }, undefined, 1),
|
||||
);
|
||||
assert.throws(() => validateTraitorDeclarations(c, { a: true }, { a }));
|
||||
assert.throws(() =>
|
||||
validateTraitorDeclarations(c, { a: true }, { a }, 2 as 1),
|
||||
);
|
||||
// Both absent is intentionally legacy; this does not certify historical calls.
|
||||
validateTraitorDeclarations(c, { a: true }, undefined);
|
||||
});
|
||||
|
||||
void test('later validation does not read any private traitor hand', () => {
|
||||
const c = context();
|
||||
const receipt = createTraitorDeclaration(c, 'a');
|
||||
for (const player of c.players)
|
||||
Object.defineProperty(player, 'traitors', {
|
||||
get() {
|
||||
throw new Error('Private hand was read');
|
||||
},
|
||||
});
|
||||
validateTraitorDeclaration(c, receipt);
|
||||
validateTraitorDeclarations(c, { a: true }, { a: receipt }, 1);
|
||||
});
|
||||
Reference in new issue
Block a user