diff --git a/components/battle-preparation.tsx b/components/battle-preparation.tsx
index 645ef51b..283e39d0 100644
--- a/components/battle-preparation.tsx
+++ b/components/battle-preparation.tsx
@@ -133,12 +133,44 @@ export function BattlePreparation({
Waiting for {game.players.find((p) => p.id === preparation.owner)?.name}
{preparation.kind === 'voice'
+ || preparation.kind === 'nexusVoice'
? ' to choose or decline the Voice.'
: preparation.kind === 'prescience'
? ' to choose or decline prescience.'
: ' to reveal the requested battle-plan element.'}
);
+ if (preparation.kind === 'nexusVoice') {
+ const offer = game.nexusBorrowedVoice;
+ if (!offer) return Waiting for the borrowed Voice opportunity.
;
+ const selected = offer.choices.find(choice => choice.kind === kind && choice.must === must);
+ return
+ Bene Gesserit Nexus · Secret Ally
+ Both combatants continue in attacker order before Prescience or sealed plans. Only an unallied holder may spend the card.
+ {offer.blocked && {offer.blocked}
}
+ {offer.canUse && <>
+
+
+ Provisional policy: Karama may cancel the Voice. The Nexus card stays spent.
+
+ >}
+
+ ;
+ }
if (preparation.kind === 'voice')
return (
<>
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index a715ac3d..869fcd1f 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -29,6 +29,20 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
+**Bene Gesserit borrowed Voice — 14 October.** The recommended provisional
+policy is now connected: actual unallied absent-BG holder, attacker-first
+neutral continuation before Prescience, one spent-card declaration and the
+original Karama response. Cancellation retains card expenditure and original
+preparation; all-pass preserves the card. Native compliance and special/mode
+restrictions are reused; no in-flight preparation retrofit. Genuine Basic/
+Advanced runtime and four legal profiles pass in the related50-case union;
+types/lint/build pass. Actual390px neutral Continue→projectile command→real
+Karama→native Prescience decline→Seal battle plan preserves one Nexus discard,
+canceled receipt and unchanged forces/spice, without overflow or browser
+errors. QA host configuration errors were repaired before final surface proof.
+Broader combinations, assurance and public/deployed acceptance remain open.
+See [the source-bound contract](NEXUS_BENE_GESSERIT_RULES.md#recommended-borrowed-voice-prototype--14-october-2026).
+
**Tleilaxu Secret Ally combined revival — 14 October.**
The recommended prototype now provides owner-only leader/paid-force controls
in quiet Revival, with the actual Tleilaxu Nexus held and faction absent.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 68623492..69a95f17 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,33 @@
# Dune implementation status
+## 14 October 2026 — Bene Gesserit Secret Ally borrowed Voice
+
+Recommended interpretation: one Karama-cancellable named Voice advantage,
+not blanket Nexus immunity. A uniform attacker-first two-combatant queue opens
+in fresh absent-BG Nexus battles before native/Nexus inspection and sealed
+plans. Owner-only actual-card controls use canonical categories and target/
+mode/compulsion restrictions. Cardless combatants only Continue; Use spends
+the physical card and binds the original command. All-pass preserves it.
+
+Pure source frame and genuine native chronology were delegated as disjoint
+slices; one owner integrated engine, quote, cancellation source, UI and bots.
+Native BG response validation remains unchanged; borrowed responses require
+their actual declared combatant source. Original Karama removes only Voice,
+keeps the spent Nexus/receipt and resumes Prescience. No legacy preparation
+retrofit/reset. Fixture continuation now settles original Guild interception
+and Advanced KH responses rather than bypassing them; deterministic initial
+seats replace repeated candidate-game construction. Wording, fixture-trace
+and incidental nonempty assertions were removed.
+
+Related4-file union50/50; types/lint and build pass. Actual390×844 GameTable
+neutral Continue→holder projectile requirement→auctioned Karama cancellation
+→native Prescience decline→enabled Seal battle plan. Source canceled, command
+removed, one Nexus discarded, original force/spice balances unchanged.
+Final document width390 and browser errors empty after repairing isolated
+QA alias/environment configuration. Proof outside Git; no saved game touched.
+Broader special-command/module interactions, comprehensive assurance, complete
+rules and deployed acceptance remain open.
+
## 14 October 2026 — Tleilaxu Secret Ally free-leader/paid-force revival
Recommended-option priority closes the earlier whole-effect gate under an
diff --git a/docs/NEXUS_BENE_GESSERIT_RULES.md b/docs/NEXUS_BENE_GESSERIT_RULES.md
index 85fc2d46..54a10207 100644
--- a/docs/NEXUS_BENE_GESSERIT_RULES.md
+++ b/docs/NEXUS_BENE_GESSERIT_RULES.md
@@ -55,9 +55,43 @@ unfinished Harkonnen Nexus Traitor return suspends both the offer and Voice
response actions, including the hand-card Karama cancellation, until the
private card return finishes.
-This does not grant absent-BG Secret Ally Voice or change Cunning's advisor
-conversion. Basic and Advanced retain their native Voice legality; the
-optional Nexus module still cannot start as a complete public mode.
+This native Betrayal reaction remains separate from the absent-BG Secret Ally
+prototype below and does not broaden Cunning's advisor conversion.
+Optional Nexus still cannot start as a complete public mode.
+
+## Recommended borrowed Voice prototype — 14 October 2026
+
+The choose-recommended-option instruction selects one **Karama-cancellable
+Voice advantage**. The printed face names that advantage and the general
+one-use rule supports cancellation; no retrieved publisher clarification
+specifically settles this absent-owner combination. The interpretation is
+visible and provisional, not a new publisher answer.
+
+Every fresh Nexus-enabled battle with BG absent opens the same attacker-then-
+defender continuation before native Prescience, Nexus inspection or sealed
+plans, regardless of the hidden card. Only the current unallied combatant
+actually holding BG Nexus may declare one canonical require/forbid command.
+Pass preserves the card; all-pass resumes native preparation. Existing
+in-flight preparations are not retrofitted or reset.
+
+Use discards the physical Nexus once and opens the original Voice Karama
+response with the borrower as owner. Cancellation removes only `battle.voice`,
+keeps the spent card and command receipt, and preserves native Prescience.
+Allow retains the command. Neither outcome reopens the queue. Native command
+classification, target Homeworld restrictions, special-card mode/compulsion
+checks, impossible compliance and existing plan promises are reused.
+Truthtrance and unfinished Traitor exchanges retain priority.
+
+Actual Basic/Advanced setup, paid auction Karama, worm/alliance/closing Nexus
+draw, reserve shipments and original battle chooser feed the focused runtime
+cases. The phone smoke uses the real GameTable: neutral Continue, holder
+projectile requirement, actual Karama cancellation, native Prescience decline
+and enabled Seal battle plan. Nexus remains discarded, source is canceled,
+width/document390 and browser errors empty after QA host configuration repair.
+The four minimal legal policies answer both opportunities. These checks do
+not certify all named-special/module combinations, historical recovery or
+complete rules/deployment.
+
## Material boundaries requiring a ruling
@@ -65,12 +99,12 @@ No retrieved primary clarification resolves these specific combinations. Do not
1. **Newly accompanied groups:** does Cunning's any/all permission override the same-turn advisor lock, or only the ordinary pre-shipment timing? The face does not name that prohibition.
2. **Storm:** does Cunning allow an in-place flip in a storm-locked stronghold? The FAQ's prohibition explicitly addresses the earlier ordinary Battle flip; its rationale concerns inability to battle. Applying it to this later card is plausible, not explicit.
-3. **Borrowed Voice and Karama:** the card expressly grants a named advantage, while the normal native owner is absent. E3's secret-alliance terminology and the general alliance-cancellation rule support cancellation; absence of a native BG owner supports the opposite reading used for Atreides' differently worded effect. No retrieved Nexus FAQ selects one. Do not copy that earlier implementation choice as authority.
+3. **Borrowed Voice and Karama:** no retrieved Nexus FAQ selects an interpretation. The recommended prototype above explicitly chooses cancellation for this named advantage; Atreides' differently worded effect is not authority for it.
The remaining private-reaction policy question applies to other hidden effects; the bounded Voice Betrayal path uses a uniform native response. This audit likewise does not settle historical Homeworld occupation ownership. Ordinary unoccupied, older, storm-free advisor groups are a clear selection subset, but broader claims retain the boundaries above.
## Suggested state and verification contract
-Keep stance choices public and event-bound: owner, turn, own-action identity, selected territory IDs, original counts/stances and settled/canceled result. A retry must not flip twice or consume a second card. For borrowed Voice, keep source and receipt separate from native ability ownership, with cancellation behavior dependent on the unresolved ruling.
+Keep stance choices public and event-bound: owner, turn, own-action identity, selected territory IDs, original counts/stances and settled/canceled result. A retry must not flip twice or consume a second card. Borrowed Voice keeps its original occurrence, ordered passes, source owner, command and settled/canceled receipt separate from native ability ownership.
-Exercise multi-territory and multi-sector selections, current-owner timing, conserved counters, Basic absence, occupied strongholds, No-Field getter traps, stale selection and saved continuations. Borrowed Voice still needs generic and named special-command, impossible-compliance, prior-inspection, sealed-plan and private-hand-permutation checks before implementation.
+Exercise multi-territory and multi-sector stance selections, current-owner timing, conserved counters, Basic absence, occupied strongholds, No-Field getter traps, stale selection and saved continuations. Borrowed Voice's native chronology now exercises generic compliance, impossible compliance, stale/foreign/late rejection, original Karama and all-pass continuation; broader named-special combinations and comprehensive assurance remain due.
diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md
index a8846ef3..535eb065 100644
--- a/docs/NEXUS_CARD_RULES.md
+++ b/docs/NEXUS_CARD_RULES.md
@@ -2,6 +2,18 @@
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
+## Recommended absent-BG borrowed Voice — 14 October 2026
+
+The named-advantage recommendation now connects Secret Ally Voice through
+a uniform attacker-first continuation in every fresh absent-BG Nexus battle,
+before native/Nexus inspection and sealed plans. Only the actual unallied
+holder can declare. The physical card is spent once; original Karama may
+cancel Voice while preserving the spent card, receipt and native preparation.
+All-pass preserves the card. Existing prepared games are not retrofitted.
+This is an explicitly provisional implementation interpretation, not publisher
+clarification or complete-mode acceptance.
+[Source and changed-path proof](NEXUS_BENE_GESSERIT_RULES.md#recommended-borrowed-voice-prototype--14-october-2026).
+
## Moritani Cunning and Grumman user cutover — 14 October 2026
The user selected supply-only Cunning, continued HMS exclusion, an ordinary
@@ -1352,6 +1364,7 @@ The [bounded Ixian Secret Ally replacement integration](NEXUS_IXIAN_REPLACEMENT_
- No retrieved card or E3 FAQ gives a general rule that every Nexus effect is Karama-immune, or that playing one suspends all ordinary responses. Homeworld immunity on the preceding page is limited to Homeworld advantages/penalties. Audit cancellation of an underlying native advantage separately from cancellation of the Nexus card before enabling each family.
- Duration is explicit for several panels but not all. Ixian Betrayal's scope over multiple auction lots, the optional continuation after canceling native Prescience, and exact multi-party cancellation priority require the corresponding existing faction timing contract. Do not make an entire-phase cancellation merely from the word “advantage.”
- Emperor forced ally payment and purchase refunds retain their precise source-invoice requirements. Tleilaxu's combined revival now uses the [explicit recommended policy](NEXUS_TLEILAXU_RULES.md#recommended-combined-revival-prototype--14-october-2026): a mandatory free own leader/ordinary slot and fixed paid physical group sharing an expanded ordinary ceiling, with preserved cycles and typed caps. This is a provisional implementation, not a new publisher answer; the five counters are not free.
+- Bene Gesserit borrowed Voice now follows the [recommended cancellable named-advantage policy](NEXUS_BENE_GESSERIT_RULES.md#recommended-borrowed-voice-prototype--14-october-2026), with neutral pre-Prescience timing and original native command checks. The prototype does not answer generic hidden-reaction priority, fresh advisor/storm permission or broader named-special/module combinations.
- Richese's two-No-Field effect has a bounded native Cunning shipment follow-up, but wider marker/module compositions remain separate. Its [bounded Betrayal auction contract](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026) connects public self-cache veto and other-buyer sale diversion, not normal-deck hidden-family self-purchases or private special-Karama acquisition. Ecaz's Duke override needs explicit restoration/end-turn handling across capture, death and Ghola records. These are material implementation tasks, not reasons to invent substitute effects.
- A complete twelve-card reference/inventory and common lifecycle are substantial first work, but do not alone make all thirty-six panels playable. Keep the public module release gated until every face's supported timing, secrecy, custody, bots, UI, rejection, multiplayer CAS and saved continuation paths are verified. If development supports only Atreides' family first, other faces must remain accurately represented and explicitly unavailable in the audit mode; they must not silently act as blanks in a released module.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 54d09d10..fa52b584 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -548,6 +548,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Occupied Homeworlds | The publisher question about retention after departure, replacement or contest, unique-power composition and shared income remains open; the first-occupier/current-occupier question was already sent. The content-first Basic benefit selector now uses the visibly provisional current-sole policy above (Tupile excepted), which is an implementation choice, not an answer to that question. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
| Nexus responses and composition | Betrayal private-response timing, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations remain source-bound. Tleilaxu Secret Ally now has the explicitly provisional recommended combined policy in its own row below; it is not borrowed authority from native revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and individual faction source/runtime documents |
| Tleilaxu Secret Ally revival | Under choose-recommended-option priority, a quiet unallied actual holder with Tleilaxu absent returns one mandatory own dead leader free, using the ordinary leader slot, and zero-to-five exact counters at one bank spice each. The card shares an ordinary ceiling expanded to at least five; prior normal returns count, larger native/Recruits allowances remain, and the fixed card group is never doubled. Only the unavailable-roster trigger is waived; repeated-death/face-down and special custody rules remain, with original ordinary cycles preserved. No force-only use, native Tleilaxu income or nonexistent cancellation owner is invented. This is provisional implementation policy, not publisher clarification or a separately answered numerical user ruling. | [Recommended combined policy](NEXUS_TLEILAXU_RULES.md#recommended-combined-revival-prototype--14-october-2026) |
+| Bene Gesserit borrowed Voice | Choose-recommended-option priority selects one Karama-cancellable named Voice advantage when BG is absent. Every fresh Nexus battle has the same attacker-first two-combatant continuation before Prescience/inspection/plans, independent of hidden face. Only the actual unallied holder can Use. Declaration spends the Nexus; cancellation removes only Voice and retains the spent card, receipt and native preparation. No retrofit/reset of old preparation. This is explicit provisional implementation policy, not retrieved publisher clarification. | [Recommended borrowed Voice](NEXUS_BENE_GESSERIT_RULES.md#recommended-borrowed-voice-prototype--14-october-2026) |
| Moritani Atomics after alliance change | The publisher’s permanent −1 text does not identify later-ally treatment, and prior requested searches found no clarification. Under choose-recommended-option priority, retain the original affected pair permanently: Moritani and its ally at detonation. Later Nexus and Enemy of My Enemy alliances are allowed without moving the penalty to a new ally; an unallied detonation gains no later ally target. Existing stored activation targets migrate immutably, without replaying effects. This is a conservative provisional implementation interpretation, not publisher clarification or a separately answered numerical user ruling. | [Recommended persistent targets](MORITANI_TERROR_RULES.md#recommended-persistent-activation-targets--14-october-2026) |
| Moritani Sneak Attack entry classification | The printed verb is “Send”; no retrieved ruling classifies the off-planet reserve transfer as a shipment for other arrival powers. The engine now **explicitly adopts** the supported shipment composition: a positive Sneak Attack in Shipment and Movement accrues the Heighliners token once for its owner, then offers the Bene Gesserit Intrusion (Advanced, with fighters present) and the free spiritual-advisor shipment, with the committed Terror entry resolving first; a zero-force resolution accrues nothing and opens no reaction. This order is an implementation interpretation, not a publisher ruling. | [Source boundary and adopted classification](MORITANI_ASSASSINATION_SNEAK.md#adopted-shipment-classification-7-october-2026) |
| Orgiz collection | Basic retains the publisher-p13 per-blow ambiguity and provisional one-transfer-per-positive-deposit mapping. Authorized supplied Advanced rulebook physical24 says each time Collection occurs in a territory containing spice: one transfer per actual positive territory Collection per fighter occupant, never extra transfers for multiple source keys. Canonical ordinary deposit receipts identify actual payers, including co-present collectors; force presence and old wallets alone do not qualify. Under choose-recommended-option priority, owners and eligible foreign ordinary payers resolve in original storm order. Shared Ecaz lots in both modes wait for actual bilateral allocation, then consider positive foreign recipients Ecaz first, ally second; Advanced charges once per territory, Basic counts actually reduced positive source deposits in that territory. Self, zero shares and empty purses are skipped; balances track every transfer without debt. Ordering and the Basic mapping are provisional implementations, not separate numerical user answers or publisher rulings. | [Advanced source cutover and Basic boundary](DISCOVERY_PROTOTYPE.md#authorized-advanced-collection-wording) |
diff --git a/game/bots.ts b/game/bots.ts
index 72c2380e..76edf24b 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -3749,6 +3749,14 @@ function policyActions(g: GameView): Action[] {
if (!b) return botBattleChoices(g);
if (b.preparation) {
if (b.preparation.owner !== me.id) return [];
+ if (b.preparation.kind === 'nexusVoice') {
+ const offer = g.nexusBorrowedVoice;
+ if (!offer || offer.blocked) return [];
+ const command = offer.choices.find(choice => !choice.must && choice.kind === 'shield');
+ return [{ type: 'nexusBorrowedVoice', event: offer.event,
+ use: !!(offer.canUse && command),
+ ...(offer.canUse && command ? { kind: command.kind, must: command.must } : {}) }];
+ }
if (b.preparation.kind === 'voice')
return level === 0 || b.preparation.blocked
? [{ type: 'declineBattlePower' }]
diff --git a/game/combat-response-quote.ts b/game/combat-response-quote.ts
index 1e002554..149a5d86 100644
--- a/game/combat-response-quote.ts
+++ b/game/combat-response-quote.ts
@@ -1,6 +1,7 @@
import { FACTIONS, type FactionId } from './catalog';
import { VOICE_KINDS } from './battle-cards';
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
+import { validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from './nexus-borrowed-voice';
export type CombatCheckKind =
| 'kwisatz'
@@ -14,6 +15,7 @@ export type CombatResponseInput = {
status: string;
phase: number;
advanced: boolean;
+ turn?: number;
territoryIds: readonly string[];
players: readonly {
id: string;
@@ -22,6 +24,8 @@ export type CombatResponseInput = {
specialKaramaUsed?: boolean;
}[];
battle: {
+ event?: string;
+ nexusBorrowedVoice?: NexusBorrowedVoiceFrame;
attacker: string;
defender: string;
territory: string;
@@ -296,10 +300,21 @@ function calculate(
}
const owner = input.players.find((p) => p.id === response.owner),
faction = response.kind === 'voice' ? 'beneGesserit' : 'atreides';
- requireCombat(
- owner?.faction === faction,
- 'Combat preparation response has the wrong faction owner.',
- );
+ const borrowed = response.kind === 'voice' && b.nexusBorrowedVoice;
+ if (borrowed) {
+ requireCombat(typeof input.turn === 'number' && typeof b.event === 'string',
+ 'Borrowed Voice has lost its original battle occurrence.');
+ validateNexusBorrowedVoice({ turn: input.turn,
+ battle: { event: b.event, attacker: b.attacker, defender: b.defender } }, borrowed);
+ requireCombat(borrowed.stage === 'declared' && borrowed.owner === response.owner &&
+ owner && !owner.ally && combatants.includes(owner.id) &&
+ !input.players.some(p => p.faction === 'beneGesserit') && record(b.voice) &&
+ b.voice.target === borrowed.voice?.target && b.voice.kind === borrowed.voice?.kind &&
+ b.voice.must === borrowed.voice?.must,
+ 'Borrowed Voice does not match the declared combatant source.');
+ } else requireCombat(owner?.faction === faction,
+ 'Combat preparation response has the wrong faction owner.');
+ requireCombat(owner, 'Combat preparation response has no owner.');
// Match battlePreparation's existing provider selection, including its
// one-way ally link. CHOAM's queue above independently requires mutual links.
const beneficiary = combatants.includes(owner.id)
diff --git a/game/engine.ts b/game/engine.ts
index f2410f93..f1a2cbc1 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -58,6 +58,7 @@ import { createIxianBetrayalSource, validateIxianBetrayalFrame, initialIxianBetr
import { createHarkonnenBetrayalSource, validateHarkonnenBetrayalFrame, initialHarkonnenBetrayalCursor, closeHarkonnenBetrayalSource, validateHarkonnenBetrayalHistory, harkonnenBetrayalResponders, harkonnenBetrayalEligible, createHarkonnenBetrayalReplacement, validateHarkonnenBetrayalReplacements, drawHarkonnenBetrayalReplacement, type HarkonnenBetrayalSource, type HarkonnenBetrayalCursor, type HarkonnenBetrayalReceipt, type HarkonnenBetrayalReplacement, type HarkonnenBetrayalAuthority } from './nexus-harkonnen-betrayal';
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
import { createNexusRichese, validateNexusRichese, quoteNexusRicheseShipment, type NexusRicheseReceipt } from './nexus-richese';
+import { createNexusBorrowedVoice, currentNexusBorrowedVoiceOwner, passNexusBorrowedVoice, declareNexusBorrowedVoice, finishNexusBorrowedVoice, validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from './nexus-borrowed-voice';
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 { replaceNexusFaceDancers, validateNexusFaceDancerHistory, type NexusFaceDancerReceipt } from './nexus-face-dancers';
@@ -768,8 +769,9 @@ export type Battle = {
required: string[];
passed: string[];
};
+ nexusBorrowedVoice?: NexusBorrowedVoiceFrame;
preparation?: {
- kind: 'voice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer';
+ kind: 'voice' | 'nexusVoice' | 'prescience' | 'prescienceAnswer' | 'nexusPrescienceAnswer';
owner: string;
beneficiary: string;
blocked?: string;
@@ -779,7 +781,7 @@ export type Battle = {
field: PlanField;
value?: string | number | null;
};
- voice?: { target: string; kind: Card['kind'] | 'stoneBurner'; must: boolean };
+ voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon'; must: boolean };
};
export type Auction = {
peekKnown?: boolean;
@@ -2568,6 +2570,94 @@ function playNexusFremenBetrayal(g: Game, p: Player, action: Action) {
: `${p.name} spent Fremen Nexus Betrayal to suppress the Fremen two-territory movement advantage this turn. Independent ornithopters and movement abilities remain available.`,
{ faction: p.faction, name: 'Fremen Nexus Betrayal' });
}
+function quoteVoiceCommand(g: Game, target: string, kind: unknown, must: unknown): NonNullable {
+ const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target));
+ requireRule(occupation.status === 'allowed', occupation.blocked ?? 'This target cannot receive the Voice.');
+ requireRule(typeof kind === 'string' && VOICE_KINDS.includes(kind), 'Choose a card type.');
+ if (kind === 'mirrorWeapon') {
+ const blocked = mirrorWeaponModeBlock(g);
+ requireRule(!blocked, blocked ?? 'Mirror Weapon is unavailable in this battle configuration.');
+ }
+ requireRule(typeof must === 'boolean', 'Choose require or forbid.');
+ if ((kind === 'stoneBurner' || kind === 'mirrorWeapon') && must) {
+ const blocked = stoneCompulsionBlock(g, getPlayer(g, target));
+ requireRule(!blocked, blocked ?? `${kind === 'mirrorWeapon' ? 'Mirror Weapon' : 'Stone Burner'} cannot be compelled in this battle.`);
+ }
+ return { target, kind: kind as NonNullable['kind'], must };
+}
+function nexusBorrowedVoiceIntegrity(g: Game) {
+ const b = g.battle, frame = b?.nexusBorrowedVoice;
+ if (!b || !frame) return;
+ requireRule(b.event && g.nexusCards?.cards && !byFaction(g, 'beneGesserit'),
+ 'Borrowed Voice has lost its absent-faction Nexus module.');
+ nexusRule(() => validateNexusBorrowedVoice({
+ turn: g.turn, battle: { event: b.event!, attacker: b.attacker, defender: b.defender },
+ }, frame));
+ if (frame.stage === 'choose') {
+ const owner = currentNexusBorrowedVoiceOwner(frame);
+ requireRule(b.preparation?.kind === 'nexusVoice' && b.preparation.owner === owner &&
+ b.preparation.beneficiary === owner && !b.voice && !b.prescience &&
+ !b.nexusInspection && !Object.keys(b.plans).length,
+ 'The borrowed Voice queue has lost its original pre-inspection preparation.');
+ } else if (frame.stage !== 'passed') {
+ requireRule(frame.owner && !getPlayer(g, frame.owner).ally &&
+ g.nexusCards.cards.discard.includes('beneGesserit'),
+ 'Borrowed Voice has lost its combatant or spent physical card.');
+ if (frame.stage === 'canceled')
+ requireRule(!b.voice, 'Canceled borrowed Voice cannot regain its command.');
+ else requireRule(b.voice && frame.voice && b.voice.target === frame.voice.target &&
+ b.voice.kind === frame.voice.kind && b.voice.must === frame.voice.must,
+ 'Borrowed Voice has lost or changed its declared command.');
+ if (frame.stage === 'declared')
+ requireRule(g.response?.kind === 'voice' && g.response.owner === frame.owner,
+ 'Borrowed Voice has lost its original cancellation response.');
+ }
+}
+function nexusBorrowedVoiceOffer(g: Game, id: string) {
+ const b = g.battle, frame = b?.nexusBorrowedVoice;
+ if (!b || !frame || currentNexusBorrowedVoiceOwner(frame) !== id ||
+ b.preparation?.kind !== 'nexusVoice') return null;
+ const p = getPlayer(g, id), target = b.attacker === id ? b.defender : b.attacker;
+ const canUse = !p.ally && g.nexusCards?.cards?.hands[id] === 'beneGesserit';
+ const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target));
+ const blocked = g.truthtrance || pendingNexusTraitors(g) || g.response || g.decision ||
+ g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy
+ ? 'Finish the current response before continuing borrowed Voice.' : null;
+ const choices: { kind: NonNullable['kind']; must: boolean }[] = [];
+ if (canUse && occupation.status === 'allowed') for (const kind of VOICE_KINDS) {
+ if (kind === 'mirrorWeapon' && mirrorWeaponModeBlock(g)) continue;
+ choices.push({ kind: kind as NonNullable['kind'], must: false });
+ if (!['stoneBurner', 'mirrorWeapon'].includes(kind) || !stoneCompulsionBlock(g, getPlayer(g, target)))
+ choices.push({ kind: kind as NonNullable['kind'], must: true });
+ }
+ return { event: frame.event, owner: id, target, canUse,
+ blocked: blocked ?? (canUse && occupation.status !== 'allowed' ? occupation.blocked ?? 'This target cannot receive the Voice.' : null),
+ choices };
+}
+function playNexusBorrowedVoice(g: Game, p: Player, action: Action) {
+ const offer = nexusBorrowedVoiceOffer(g, p.id), b = g.battle;
+ requireRule(offer && b && action.event === offer.event && !offer.blocked,
+ 'This borrowed Voice opportunity is stale, blocked, or belongs to another combatant.');
+ requireRule(typeof action.use === 'boolean' && Object.keys(action).every(key =>
+ ['type', 'event', 'use', ...(action.use ? ['kind', 'must'] : [])].includes(key)),
+ 'Choose borrowed Voice or continue without a command.');
+ if (!action.use) {
+ b.nexusBorrowedVoice = nexusRule(() => passNexusBorrowedVoice(b.nexusBorrowedVoice!, p.id));
+ const next = currentNexusBorrowedVoiceOwner(b.nexusBorrowedVoice);
+ if (next) b.preparation = { kind: 'nexusVoice', owner: next, beneficiary: next };
+ else battlePreparation(g, 'prescience');
+ return;
+ }
+ requireRule(offer.canUse, 'Use the held Bene Gesserit Nexus card while unallied.');
+ const voice = quoteVoiceCommand(g, offer.target, action.kind, action.must);
+ b.nexusBorrowedVoice = nexusRule(() => declareNexusBorrowedVoice(b.nexusBorrowedVoice!, p.id, voice));
+ g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
+ b.voice = voice;
+ battlePreparation(g, 'prescience');
+ g.response = { kind: 'voice', owner: p.id, passed: [] };
+ log(g, `${p.name} spent Bene Gesserit Nexus Secret Ally to use the Voice before Prescience. Provisional policy: Karama may cancel the advantage; the Nexus card remains spent.`,
+ { faction: p.faction, name: 'Borrowed Voice' });
+}
function bgBetrayalWindow(g: Game) {
const response = g.response, battle = g.battle;
return !!(g.nexusCards?.cards && g.status === 'playing' && g.phase === 6 &&
@@ -12161,7 +12251,7 @@ function beginBattlePowers(g: Game) {
)
g.decision = { kind: 'choamBattleFunding', player: choam.id };
else {
- battlePreparation(g, 'voice');
+ battlePreparation(g, 'voice', true);
combatResponses(g);
}
}
@@ -20377,6 +20467,7 @@ function currentCombatResponseQuote(
status: g.status,
phase: g.phase,
advanced: g.advanced,
+ turn: g.turn,
...(g.battle?.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
territoryIds: combatLocations(g).map((t) => t.id),
players: g.players.map((p) => ({
@@ -20456,8 +20547,18 @@ function finishBattlePreparation(g: Game) {
currentCombatResponseQuote(g, { kind: 'finishPreparation' }),
);
}
-function battlePreparation(g: Game, kind: 'voice' | 'prescience') {
+function battlePreparation(g: Game, kind: 'voice' | 'prescience', fresh = false) {
const b = g.battle!;
+ if (fresh && kind === 'voice' && g.nexusCards?.cards && !byFaction(g, 'beneGesserit') &&
+ !b.nexusBorrowedVoice && !b.voice && !b.prescience && !b.nexusInspection &&
+ !Object.keys(b.plans).length) {
+ requireRule(b.event, 'Borrowed Voice requires the original battle event.');
+ b.nexusBorrowedVoice = createNexusBorrowedVoice({
+ turn: g.turn, battle: { event: b.event, attacker: b.attacker, defender: b.defender },
+ });
+ b.preparation = { kind: 'nexusVoice', owner: b.attacker, beneficiary: b.attacker };
+ return;
+ }
const owner = byFaction(g, kind === 'voice' ? 'beneGesserit' : 'atreides');
const combatants = [b.attacker, b.defender];
const beneficiary =
@@ -20612,6 +20713,7 @@ function marketGholaIntegrity(g: Game) {
nexusEmperorBetrayalIntegrity(g);
nexusFremenBetrayalIntegrity(g);
nexusBgBetrayalIntegrity(g);
+ nexusBorrowedVoiceIntegrity(g);
nexusFremenRevivalIntegrity(g);
nexusFremenCunningIntegrity(g);
nexusEcazBetrayalIntegrity(g);
@@ -25160,6 +25262,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
canceled,
}),
);
+ if (response.kind === 'voice' && g.battle?.nexusBorrowedVoice?.stage === 'declared')
+ g.battle.nexusBorrowedVoice = finishNexusBorrowedVoice(g.battle.nexusBorrowedVoice, canceled);
} else if (response.kind === 'guildTiming') {
if (canceled) {
const quote = movementCancellationQuote(g, response);
@@ -28839,6 +28943,7 @@ function applyActionInner(
if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; }
if (t === 'nexusFaceDancers') { playNexusFaceDancers(g, p, action); return g; }
if (t === 'nexusTleilaxuRevival') { playNexusTleilaxuRevival(g,p,action); return g; }
+ if (t === 'nexusBorrowedVoice') { playNexusBorrowedVoice(g, p, action); return g; }
if (t === 'nexusSuboids') { playNexusSuboids(g, p, action); return g; }
if (t === 'nexusAdvisors') { playNexusAdvisors(g, p, action); return g; }
if (t === 'nexusSardaukar') { playNexusSardaukar(g,p,action); return g; }
@@ -29678,7 +29783,7 @@ function applyActionInner(
'CHOAM battle funding is unavailable.',
);
pledgeAid(g, p, action.amount);
- battlePreparation(g, 'voice');
+ battlePreparation(g, 'voice', true);
combatResponses(g);
return g;
}
@@ -32298,7 +32403,7 @@ function applyActionInner(
if (t === 'declineBattlePower') {
const b = g.battle;
requireRule(
- b?.preparation?.owner === id && !['prescienceAnswer', 'nexusPrescienceAnswer'].includes(b.preparation.kind),
+ b?.preparation?.owner === id && !['nexusVoice', 'prescienceAnswer', 'nexusPrescienceAnswer'].includes(b.preparation.kind),
'You do not own this battle power decision.',
);
const kind = b.preparation.kind;
@@ -32393,34 +32498,7 @@ function applyActionInner(
'Voice must be chosen by Bene Gesserit before prescience and battle plans.',
);
const target = b.attacker === b.preparation.beneficiary ? b.defender : b.attacker;
- const occupation = homeworldRule(() => quoteOccupiedHomeworldVoice(g, target));
- requireRule(occupation.status === 'allowed', occupation.blocked ?? 'This target cannot receive the Voice.');
- requireRule(
- VOICE_KINDS.includes(String(action.kind)),
- 'Choose a card type.',
- );
- if (action.kind === 'mirrorWeapon') {
- const blocked = mirrorWeaponModeBlock(g);
- requireRule(!blocked, blocked ?? 'Mirror Weapon is unavailable in this battle configuration.');
- }
- requireRule(typeof action.must === 'boolean', 'Choose require or forbid.');
- if ((action.kind === 'stoneBurner' || action.kind === 'mirrorWeapon') && action.must) {
- const target = getPlayer(
- g,
- b.attacker === b.preparation.beneficiary ? b.defender : b.attacker,
- );
- const blocked = stoneCompulsionBlock(g, target);
- requireRule(
- !blocked,
- blocked ?? `${action.kind === 'mirrorWeapon' ? 'Mirror Weapon' : 'Stone Burner'} cannot be compelled in this battle.`,
- );
- }
- b.voice = {
- target:
- b.attacker === b.preparation.beneficiary ? b.defender : b.attacker,
- kind: action.kind as NonNullable['kind'],
- must: action.must,
- };
+ b.voice = quoteVoiceCommand(g, target, action.kind, action.must);
battlePreparation(g, 'prescience');
g.response = { kind: 'voice', owner: id, passed: [] };
return g;
@@ -33133,6 +33211,7 @@ export function viewGame(state: Game, id: string) {
nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id),
nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id),
nexusBgBetrayal: nexusBgBetrayalOffer(g, id),
+ nexusBorrowedVoice: nexusBorrowedVoiceOffer(g, id),
nexusFremenRevival: fremenNexusRevivalOffer(g,id),
nexusMoritani: nexusMoritaniOffer(g,id),
nexusMoritaniBetrayal: moritaniBetrayalOffer(g,id,
diff --git a/game/karama-context.ts b/game/karama-context.ts
index ca3ace26..05e58381 100644
--- a/game/karama-context.ts
+++ b/game/karama-context.ts
@@ -70,6 +70,7 @@ const cancellationSources = {
battle: battleSource(g),
voice: g.battle?.voice ?? null,
preparation: g.battle?.preparation ?? null,
+ borrowedVoice: g.battle?.nexusBorrowedVoice ?? null,
}),
prescience: (g) => ({
battle: battleSource(g),
diff --git a/game/nexus-borrowed-voice.ts b/game/nexus-borrowed-voice.ts
new file mode 100644
index 00000000..7ab6f4e8
--- /dev/null
+++ b/game/nexus-borrowed-voice.ts
@@ -0,0 +1,169 @@
+import { VOICE_KINDS } from './battle-cards';
+import type { Battle } from './engine';
+
+export type NexusBorrowedVoiceFrame = {
+ event: string;
+ turn: number;
+ battle: string;
+ order: [string, string];
+ passed: string[];
+ stage: 'choose' | 'declared' | 'active' | 'canceled' | 'passed';
+ owner: string | null;
+ /** A canceled command remains a receipt; only stage 'active' applies it. */
+ voice: {
+ target: string;
+ kind: NonNullable['kind'];
+ must: boolean;
+ } | null;
+};
+
+export type NexusBorrowedVoiceContext = {
+ turn: number;
+ battle: { event: string; attacker: string; defender: string };
+};
+
+function requireVoice(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new Error(message);
+}
+
+function validId(value: unknown): value is string {
+ return typeof value === 'string' && value.trim().length > 0;
+}
+
+function event(turn: number, battle: string): string {
+ return JSON.stringify(['nexusBorrowedVoice', turn, battle]);
+}
+
+function validateContext(context: NexusBorrowedVoiceContext): void {
+ requireVoice(
+ context && Number.isSafeInteger(context.turn) && context.turn > 0 &&
+ context.battle && validId(context.battle.event) &&
+ validId(context.battle.attacker) && validId(context.battle.defender) &&
+ context.battle.attacker !== context.battle.defender,
+ 'Borrowed Voice needs its original turn and distinct combatants.',
+ );
+}
+
+function validateFrame(frame: NexusBorrowedVoiceFrame): void {
+ requireVoice(
+ frame && typeof frame === 'object' && !Array.isArray(frame) &&
+ Object.keys(frame).sort().join(',') === 'battle,event,order,owner,passed,stage,turn,voice' &&
+ Number.isSafeInteger(frame.turn) && frame.turn > 0 &&
+ validId(frame.battle) && frame.event === event(frame.turn, frame.battle) &&
+ Array.isArray(frame.order) && frame.order.length === 2 &&
+ validId(frame.order[0]) && validId(frame.order[1]) && frame.order[0] !== frame.order[1] &&
+ Array.isArray(frame.passed) && frame.passed.length <= 2 &&
+ (frame.passed.length === 0 || frame.passed[0] === frame.order[0]) &&
+ (frame.passed.length < 2 || frame.passed[1] === frame.order[1]) &&
+ ['choose', 'declared', 'active', 'canceled', 'passed'].includes(frame.stage),
+ 'Borrowed Voice lost its original event or ordered opportunity.',
+ );
+ if (frame.stage === 'choose' || frame.stage === 'passed') {
+ requireVoice(
+ frame.owner === null && frame.voice === null &&
+ (frame.stage === 'passed' ? frame.passed.length === 2 : frame.passed.length < 2),
+ 'An undecided or all-passed borrowed Voice cannot contain a declaration.',
+ );
+ return;
+ }
+ requireVoice(
+ frame.passed.length < 2 && frame.owner === frame.order[frame.passed.length] &&
+ frame.voice && typeof frame.voice === 'object' && !Array.isArray(frame.voice) &&
+ Object.keys(frame.voice).sort().join(',') === 'kind,must,target' &&
+ frame.voice.target === frame.order[1 - frame.passed.length] &&
+ typeof frame.voice.kind === 'string' && VOICE_KINDS.includes(frame.voice.kind) &&
+ typeof frame.voice.must === 'boolean',
+ 'Borrowed Voice needs one ordered owner and a canonical opposing command.',
+ );
+}
+
+export function validateNexusBorrowedVoice(
+ context: NexusBorrowedVoiceContext,
+ frame: NexusBorrowedVoiceFrame,
+): void {
+ validateContext(context);
+ validateFrame(frame);
+ requireVoice(
+ frame.turn === context.turn && frame.battle === context.battle.event &&
+ frame.order[0] === context.battle.attacker && frame.order[1] === context.battle.defender,
+ 'Borrowed Voice must remain bound to its original battle and combatants.',
+ );
+}
+
+export function createNexusBorrowedVoice(
+ context: NexusBorrowedVoiceContext,
+): NexusBorrowedVoiceFrame {
+ validateContext(context);
+ return {
+ event: event(context.turn, context.battle.event),
+ turn: context.turn,
+ battle: context.battle.event,
+ order: [context.battle.attacker, context.battle.defender],
+ passed: [],
+ stage: 'choose',
+ owner: null,
+ voice: null,
+ };
+}
+
+export function currentNexusBorrowedVoiceOwner(frame: NexusBorrowedVoiceFrame): string | null {
+ validateFrame(frame);
+ return frame.stage === 'choose' ? frame.order[frame.passed.length] : null;
+}
+
+export function passNexusBorrowedVoice(
+ frame: NexusBorrowedVoiceFrame,
+ actor: string,
+): NexusBorrowedVoiceFrame {
+ requireVoice(
+ currentNexusBorrowedVoiceOwner(frame) === actor && frame.stage === 'choose',
+ 'Only the current combatant may continue past borrowed Voice.',
+ );
+ const passed = [...frame.passed, actor];
+ return {
+ ...frame,
+ order: [...frame.order],
+ passed,
+ stage: passed.length === 2 ? 'passed' : 'choose',
+ };
+}
+
+export function declareNexusBorrowedVoice(
+ frame: NexusBorrowedVoiceFrame,
+ actor: string,
+ voice: NonNullable,
+): NexusBorrowedVoiceFrame {
+ requireVoice(
+ currentNexusBorrowedVoiceOwner(frame) === actor && frame.stage === 'choose',
+ 'Only the current combatant may declare borrowed Voice once.',
+ );
+ const next: NexusBorrowedVoiceFrame = {
+ ...frame,
+ order: [...frame.order],
+ passed: [...frame.passed],
+ stage: 'declared',
+ owner: actor,
+ voice: voice && { ...voice },
+ };
+ validateFrame(next);
+ return next;
+}
+
+/** Settle the original Voice response exactly once; cancellation never reopens the queue. */
+export function finishNexusBorrowedVoice(
+ frame: NexusBorrowedVoiceFrame,
+ canceled: boolean,
+): NexusBorrowedVoiceFrame {
+ validateFrame(frame);
+ requireVoice(
+ frame.stage === 'declared' && typeof canceled === 'boolean',
+ 'Only an unsettled borrowed Voice declaration can finish its response.',
+ );
+ return {
+ ...frame,
+ order: [...frame.order],
+ passed: [...frame.passed],
+ stage: canceled ? 'canceled' : 'active',
+ voice: { ...frame.voice! },
+ };
+}
diff --git a/game/reference.ts b/game/reference.ts
index 68734381..87ac9879 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4565,7 +4565,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'With Tleilaxu absent, its actual unallied Nexus holder may use a quiet Revival control to return one mandatory own dead leader free and up to five exact physical counters for one bank spice each. The provisional policy uses the ordinary leader slot and expands the shared ordinary ceiling to at least five, counting prior returns and retaining larger native/Recruits allowance. Only the unopened roster trigger is waived: repeated deaths, actual stars/Cyborgs and special custody retain canonical rules, and ordinary cycle history is not fabricated. Zero forces is legal; force-only use is not. Spend the physical card once; native Tleilaxu income and cancellation are not imported.',
'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.',
'One Karama response covers the entire declared territory set. Cancellation keeps every selected group as advisors and leaves the Nexus card spent. Existing territory restrictions still apply; fresh accompanied advisors and storm-related conversion remain unavailable pending their rulings.',
- 'Bene Gesserit Secret Ally borrowed Voice remains unavailable pending its cancellation and timing interpretation. Its printed permission does not make the other unfinished effects available.',
+ 'With BG absent, every fresh Nexus battle asks both combatants in attacker order before Prescience, Nexus inspection or sealed plans. Continue preserves the card. The actual unallied BG-card holder may spend it to require or forbid one canonical type against the opponent. Under the explicit provisional recommendation, original Karama can cancel this named Voice advantage; the Nexus stays spent, only Voice is removed and native preparation continues. Existing in-flight preparation is not retrofitted. Native compliance and special/mode restrictions remain; this does not complete other unfinished effects.',
'Bene Gesserit Betrayal can prevent the native Voice while the ordinary pre-plan response is open. In Nexus games every seat responds even without a canceling card, so the window does not reveal the hidden holder. An unallied opponent spends one physical Bene Gesserit Nexus card to remove only that declared Voice; Prescience and plan commitments continue. Truthtrance has priority. This path does not grant borrowed Voice.',
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. The separate Secret Ally revival and normal bank-auction purchase have connected controls; seller-paid and forced Emperor payments remain unfinished.',
@@ -4596,11 +4596,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle and existing native/borrowed effects remain connected. Tleilaxu Secret Ally now returns one free mandatory own leader with up to five exact paid counters, sharing ordinary force allowance and the leader slot under an explicit provisional policy. Original cycle/custody, typed caps, bank payment, skill return and Homeworld force source remain connected. Borrowed Voice, exceptional custody, worm-protection responses and broader combinations remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'Existing private lifecycle and effect controls remain. Tleilaxu Secret Ally adds owner-only free-leader and exact paid-force/elite selection, authoritative price, zero-force use and a visible provisional-policy explanation. The spent card removes the offer and original Revival Ready remains usable. Remaining borrowed/reactive effects and complete modes are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx'] },
- { area: 'AI', status: 'Partial', detail: 'All four legal profiles retain existing private effect policies and now execute actual Tleilaxu Secret Ally own-leader/five-paid-counter returns through the shared offered selection. Native Cunning priority/private boundaries remain. Complete module strategy and calibration are deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
- { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries. Guild Betrayal separately records full funded payment, own pre-funding, donor escrow once, the printed full-Junction override, four native adapters and neutral privacy; ordinary occupation/rounding and general response-priority questions remain unresolved.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Current Tleilaxu revival batch passes141/141 in9 related files, types/lint and build. Genuine Basic/Advanced weapon death/eight-counter exchange, worm alliance and actual Nexus draw feed leader/paid/zero returns, both shared-allowance orders, stale/invalid rejection and the next real turn. Actual390px Dr. Yueh/five return changes spice11 to6, Tanks8 to3 and reserves10 to15 with original cycle0/deaths1, one card discard and Ready restored; no overflow/errors. Four policies execute the actual return. Earlier mixed-family evidence stays source-bound; complete modes, assurance and deployed acceptance remain open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Existing physical effects remain connected. Tleilaxu Secret Ally retains its provisional shared revival policy. Absent-BG borrowed Voice now uses a neutral attacker-first pre-inspection queue, actual unallied holder and one physical card declaration. Original Karama can cancel only Voice while preserving spent Nexus, receipt and Prescience. Native command checks are shared; old preparations are not retrofitted. Exceptional custody, worm protection and wider combinations remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Existing private lifecycle and Tleilaxu free-leader/paid-counter controls remain. Borrowed Voice adds neutral Continue and current-holder canonical require/forbid selection before Prescience, with a visible provisional cancellation policy. Original Karama and native preparation controls continue. Remaining reactive combinations and complete modes are open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All four minimal legal profiles retain existing effect policies, actual Tleilaxu combined returns, and now answer both borrowed Voice opportunities through their own projected offer. Cardless Continue does not spend; eligible Use opens the original response. Complete strategy and calibration remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Common rules and twelve faces retain source-bound remaining custody, inquiry, nested worm, Great Maker, fresh advisor/storm and combination questions. Borrowed Voice explicitly adopts recommended cancellable named-advantage semantics and a neutral pre-Prescience queue; this is not publisher clarification. Existing Richese and Guild payment/privacy contracts retain their own limits. Full mode and response-priority acceptance remain open.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Borrowed Voice related4-file union50/50, types/lint and build pass. Genuine Basic/Advanced setup, paid Karama, worm/alliance/Nexus draw and reserve shipment produce the original battle; cases exercise order, spend, cancellation, compliance, impossible compliance, all-pass and four legal profiles. Actual390px Continue, projectile command, Karama, native Prescience decline and Seal battle plan preserve one spent Nexus and canceled receipt without overflow/errors after QA configuration repair. Earlier Tleilaxu and mixed-family evidence remains bounded; full modes, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts'] },
],
},
{
diff --git a/tests/fixture-nexus-borrowed-voice.ts b/tests/fixture-nexus-borrowed-voice.ts
new file mode 100644
index 00000000..a88db14f
--- /dev/null
+++ b/tests/fixture-nexus-borrowed-voice.ts
@@ -0,0 +1,372 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
+ type Action, type Game, type Player,
+} from '../game/engine';
+import { territory, validGameLocation } from '../game/board';
+import { defaultVoiceMatch, VOICE_KINDS } from '../game/battle-cards';
+import { treacheryDeck } from '../game/cards';
+import type { FactionId } from '../game/catalog';
+import { validateNexusCards } from '../game/nexus-cards';
+import { shipmentAvailable } from '../game/shipment-opportunity';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+
+/** One real native step: the actor is always the seat the engine expects. */
+export type NexusBorrowedVoiceStep = { actor: string; action: Action };
+export type NexusBorrowedVoiceFixtureOptions = {
+ /** Genuine Basic (default) or Advanced classic rules envelope. */
+ advanced?: boolean;
+ seatIds?: readonly [string, string, string, string];
+};
+export type NexusBorrowedVoiceFixture = {
+ /** Fresh undealt classic lobby. */
+ initial: Game;
+ /** Genuine original Basic/Advanced setup with real deals and traitors. */
+ afterSetup: Game;
+ /** Turn-two Battle Phase before the chooser commits the real battle. */
+ beforeBattle: Game;
+ /** The real battle with the absent-Bene-Gesserit Nexus holder queued. */
+ game: Game;
+ advanced: boolean;
+ /** Physical battle order: the neutral attacker is asked first, the holder second. */
+ attackOrder: [string, string];
+ /** Seated Atreides seat: the neutral aggressor and native Prescience owner. */
+ atreides: string;
+ /** Unallied combatant that drew the physical Bene Gesserit Nexus card. */
+ holder: string;
+ /** Allied seat that bought the real Karama card at the first auction. */
+ canceler: string;
+ /** The canceler's reciprocal Nexus alliance partner. */
+ partner: string;
+ cancelerCard: string;
+ /** Physical card the holder drew from the Nexus deck. */
+ nexusCard: FactionId;
+ /** The command target for the holder's declaration: the other combatant. */
+ target: string;
+ /** A canonical category (not require-blocked by a module) the target's real hand cannot supply. */
+ absentKind: string;
+ /** The target's real printed card, used only to exercise original compliance. */
+ targetCard: string;
+ targetKind: string;
+ actions: NexusBorrowedVoiceStep[];
+ staging: string[];
+ /** Raw JSON-safe scenario facts for the parent's throwaway API and phone smoke. */
+ scenario: Record;
+};
+
+export function nexusBorrowedVoicePlayer(game: Game, id: string): Player {
+ const player = game.players.find(candidate => candidate.id === id);
+ assert.ok(player, `Missing classic seat ${id}`);
+ return player;
+}
+export const nexusBorrowedVoiceClean = (game: Game): boolean =>
+ !game.phaseOpening && !game.response && !game.decision;
+
+
+/** Physical census of the original pieces; no module owner is assumed. */
+export function nexusBorrowedVoiceInventory(game: Game): void {
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
+ assert.deepEqual(cards.map(card => card.id).sort(),
+ treacheryDeck(game.expansions).map(card => card.id).sort(),
+ 'Only original Treachery cards may exist');
+ assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
+ assert.ok(game.nexusCards?.cards, 'The Nexus inventory must exist');
+ validateNexusCards(game.nexusCards.cards, game.players);
+}
+
+/** Scoped producer entropy: every original shuffle keeps its printed order, so
+ * the real deal, real auction, real Nexus draw and real battle chronology stay
+ * reproducible. Only genuine initializers and actions run inside this window. */
+function identityRandom(run: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ crypto.getRandomValues = (array: V): V => {
+ assert.ok(array);
+ new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
+ return array;
+ };
+ try { return run(); }
+ finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+const FACTION_NAMES: Partial> = {
+ atreides: 'Atreides', harkonnen: 'Harkonnen', guild: 'Guild', emperor: 'Emperor',
+};
+
+type Driver = {
+ atreides: string;
+ canceler: string;
+ holder: string | null;
+ territory: string;
+ boughtKarama: boolean;
+};
+
+/** Original continuation plus the three table choices this fixture must make:
+ * the first-auction bid, the closing Nexus draws and the battle chooser. */
+function nextStep(game: Game, driver: Driver): NexusBorrowedVoiceStep | null {
+ if (game.status === 'finished') return null;
+ if (!nexusBorrowedVoiceClean(game)) return nextStrongholdFactionsNativeStep(game);
+ // The closing Nexus card choice is private; the fixture draws the staged physical card.
+ if (nexusBorrowedVoiceClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
+ const phase = game.nexusCards.phase, cards = game.nexusCards.cards!;
+ const actor = phase.eligible.find(id => !phase.done.includes(id));
+ if (actor) return { actor, action: {
+ type: 'nexusCardChoice', turn: phase.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
+ }
+ const auction = game.auction;
+ if (game.turn === 1 && game.phase === 3 && auction && !driver.boughtKarama &&
+ auction.index === 0 && auction.bid === 0 && auction.active === driver.canceler)
+ return { actor: driver.canceler, action: { type: 'bid', amount: 1 } };
+ if (game.turn === 2) {
+ const holder = driver.holder, active = game.active;
+ if (game.phase === 5 && !game.battle && holder && active &&
+ [driver.atreides, holder].includes(active) &&
+ shipmentAvailable(game, nexusBorrowedVoicePlayer(game, active))) {
+ const sector = territory(driver.territory).sectors.find(candidate => candidate !== game.storm);
+ assert.notEqual(sector, undefined, 'The battle territory must keep one non-storm sector');
+ assert.ok(validGameLocation(game, driver.territory, sector!));
+ return { actor: active, action: { type: 'ship', territory: driver.territory,
+ sector: sector!, amount: 1, elite: 0, allyPayment: 0 } };
+ }
+ if (game.phase === 6 && !game.battle && holder && active === driver.atreides)
+ return { actor: active, action: { type: 'chooseBattle',
+ territory: driver.territory, target: holder } };
+ }
+ return nextStrongholdFactionsNativeStep(game);
+}
+function step(game: Game, driver: Driver, actions?: NexusBorrowedVoiceStep[]): Game {
+ const next = nextStep(game, driver);
+ assert.ok(next, `Original continuation stopped before ${game.status}/${game.turn}/${game.phase}`);
+ actions?.push(structuredClone(next));
+ const after = applyAction(game, next.actor, next.action);
+ if (!driver.boughtKarama)
+ driver.boughtKarama = nexusBorrowedVoicePlayer(after, driver.canceler).hand
+ .some(card => card.effect === 'karama');
+ return after;
+}
+function advanceNexusBorrowedVoice(game: Game, until: (state: Game) => boolean,
+ actions: NexusBorrowedVoiceStep[], driver: Driver): Game {
+ for (let count = 0; count < 2600; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished');
+ game = step(game, driver, actions);
+ }
+ throw Error('Original absent-Bene-Gesserit Nexus chronology did not reach its boundary');
+}
+/** Turn-pinned phase boundary; a turn change is an error, not a success. */
+function advanceToPhase(game: Game, phase: number, driver: Driver,
+ actions: NexusBorrowedVoiceStep[]): Game {
+ const turn = game.turn;
+ const reached = advanceNexusBorrowedVoice(game,
+ state => state.turn === turn && state.phase >= phase && nexusBorrowedVoiceClean(state),
+ actions, driver);
+ assert.equal(reached.turn, turn, 'The requested phase belongs to the current turn');
+ return reached;
+}
+
+/** One original undealt Karama singleton to the front of the physical Treachery
+ * Deck; nothing is added, removed or replaced. */
+function frontUndealtKarama(game: Game, staging: string[]): void {
+ const index = game.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(index >= 0, 'The original undealt Treachery Deck still holds a Karama singleton');
+ const [card] = game.deck.splice(index, 1);
+ game.deck.unshift(card);
+ staging.push('Ordered one original undealt Treachery Karama singleton to the front of the first auction pool; it is bought for real spice, not assigned to a hand.');
+}
+/** Original undealt Shai-Hulud singleton to the front of the physical Spice
+ * Deck; the real turn-two occurrence then opens the Nexus. */
+function frontUndealtShaiHulud(game: Game, staging: string[]): void {
+ const index = game.spiceDeck.findIndex(card =>
+ 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
+ assert.ok(index >= 0, 'The original undealt Spice Deck still holds a Shai-Hulud singleton');
+ const [card] = game.spiceDeck.splice(index, 1);
+ game.spiceDeck.unshift(card);
+ staging.push('Ordered one original undealt Shai-Hulud Spice singleton to the front; the real turn-two occurrence opens the Nexus. Not a natural shuffle history.');
+}
+
+/** Genuine Basic/Advanced classic setup with Bene Gesserit absent from the table:
+ * four seated base factions, online audit seam, offline determinism. */
+function initialize(options: NexusBorrowedVoiceFixtureOptions, rotation: number): Game {
+ const advanced = options.advanced ?? false;
+ const ids = options.seatIds ?? ['nexus-atreides', 'nexus-holder', 'nexus-canceler', 'nexus-partner'];
+ assert.equal(new Set(ids).size, 4, 'Four distinct seats are required');
+ const factions: FactionId[] = ['atreides', 'harkonnen', 'guild', 'emperor'];
+ const rotated = [...factions.slice(rotation), ...factions.slice(0, rotation)];
+ let game = createGame('NEXUSBORROWEDVOICE', newPlayer(ids[0], FACTION_NAMES[rotated[0]]!, rotated[0]), advanced);
+ for (let index = 1; index < 4; index++)
+ joinGame(game, newPlayer(ids[index], FACTION_NAMES[rotated[index]]!, rotated[index]));
+ assert.deepEqual(game.expansions, [], 'Only base Treachery and Nexus cards are admitted');
+ assert.ok(game.players.every(player => player.faction !== 'beneGesserit'), 'Absent Bene Gesserit needs no seated Sisterhood');
+ game.nexusCards ??= { cards: null, phase: null };
+ for (const player of game.players) if (!player.ready)
+ game = applyAction(game, player.id, { type: 'ready' });
+ return game;
+}
+
+/** One original chronology with a fixed initial seat order. */
+function buildNativeFixture(options: NexusBorrowedVoiceFixtureOptions, rotation: number): NexusBorrowedVoiceFixture {
+ const actions: NexusBorrowedVoiceStep[] = [], staging: string[] = [];
+ const lobby = initialize(options, rotation);
+ const initial = structuredClone(lobby);
+ let game = initializeNexusGameForAudit(lobby);
+ const advanced = game.advanced;
+ const atreides = game.players.find(player => player.faction === 'atreides')!.id;
+ const canceler = game.players.find(player => player.faction !== 'atreides')!.id;
+ const driver: Driver = { atreides, canceler, holder: null, territory: 'arrakeen', boughtKarama: false };
+
+ // ---- Real setup: traitors, starting Treachery Cards and force placement.
+ game = advanceNexusBorrowedVoice(game, state => state.status === 'playing', actions, driver);
+ const afterSetup = structuredClone(game);
+ assert.ok(game.players.every(player => player.traitors.length > 0 && player.hand.length > 0),
+ 'The real setup deals traitors and starting Treachery Cards');
+ assert.equal(game.players.length, 4);
+ nexusBorrowedVoiceInventory(game);
+
+ // ---- Turn one, real Bidding: the canceler wins one real Karama for real spice.
+ game = advanceToPhase(game, 2, driver, actions);
+ frontUndealtKarama(game, staging);
+ frontUndealtShaiHulud(game, staging);
+ game = advanceNexusBorrowedVoice(game, state => nexusBorrowedVoiceClean(state) &&
+ nexusBorrowedVoicePlayer(state, canceler).hand.some(card => card.effect === 'karama'), actions, driver);
+ driver.boughtKarama = true;
+ const cancelerCard = nexusBorrowedVoicePlayer(game, canceler).hand
+ .find(card => card.effect === 'karama')!.id;
+ staging.push('The real first auction sold that Karama to the allied seat for real spice; no hand, wallet or lot was written.');
+
+ // ---- Turn one stays otherwise quiet, then the real turn-two Shai-Hulud Nexus.
+ game = advanceToPhase(game, 8, driver, actions);
+ game = advanceNexusBorrowedVoice(game,
+ state => state.turn === 2 && state.nexus === true && !state.spiceWindow && nexusBorrowedVoiceClean(state),
+ actions, driver);
+ assert.equal(game.nexus, true, 'The real Shai-Hulud appearance opened the Nexus');
+
+ // The turn-two physical order already includes the phase-zero storm move.
+ const order = game.order;
+ const later = order.filter(id => order.indexOf(id) > order.indexOf(atreides) && id !== canceler);
+ assert.ok(later.length, 'The initial seat order must leave a later opposing combatant');
+ const holder = later[0];
+ const partner = game.players.map(player => player.id)
+ .find(id => id !== atreides && id !== holder && id !== canceler)!;
+ driver.holder = holder;
+ assert.ok(!game.players.some(player => player.faction === 'beneGesserit'), 'The Sisterhood stays absent');
+ const alliance: NexusBorrowedVoiceStep[] = [
+ { actor: canceler, action: { type: 'alliance', target: partner } },
+ { actor: partner, action: { type: 'alliance', target: canceler } },
+ ];
+ for (const entry of alliance) {
+ actions.push(structuredClone(entry));
+ game = applyAction(game, entry.actor, entry.action);
+ }
+ assert.equal(nexusBorrowedVoicePlayer(game, canceler).ally, partner);
+ assert.equal(nexusBorrowedVoicePlayer(game, partner).ally, canceler);
+ assert.equal(nexusBorrowedVoicePlayer(game, atreides).ally, null,
+ 'The native Atreides aggressor must stay unallied to draw');
+ assert.equal(nexusBorrowedVoicePlayer(game, holder).ally, null,
+ 'The borrowed-Voice holder must stay unallied');
+ staging.push('The two non-combatant seats formed the real reciprocal Nexus alliance; the two combatants stay unallied and the Sisterhood stays absent.');
+
+ // ---- Real closing Nexus draw: both unallied combatants draw one physical card.
+ game = advanceNexusBorrowedVoice(game,
+ state => state.nexusCards?.phase?.stage === 'drawing' && nexusBorrowedVoiceClean(state),
+ actions, driver);
+ assert.equal(game.turn, 2);
+ assert.equal(game.phase, 1);
+ const phase = game.nexusCards!.phase!;
+ assert.deepEqual([...phase.eligible].sort(), [atreides, holder].sort(),
+ 'Only the two unallied combatants may draw');
+ assert.deepEqual(phase.done, []);
+ const cards = game.nexusCards!.cards!;
+ assert.equal(cards.hands[atreides], null, 'No Nexus hand is granted before the real draw');
+ assert.equal(cards.hands[holder], null, 'No Nexus hand is granted before the real draw');
+ const wanted: Record = { [atreides]: 'atreides', [holder]: 'beneGesserit' };
+ const front = phase.eligible.map(id => wanted[id]);
+ assert.equal(new Set(front).size, front.length);
+ cards.deck = [...front, ...cards.deck.filter(card => !front.includes(card))];
+ staging.push('Ordered the original undealt Nexus deck so the seated Atreides seat draws its own faction and the unallied combatant draws the physical Bene Gesserit card; conserved cards, no hand is granted.');
+ game = advanceNexusBorrowedVoice(game,
+ state => state.nexusCards?.phase?.stage === 'complete', actions, driver);
+ assert.equal(game.nexusCards!.cards!.hands[atreides], 'atreides');
+ assert.equal(game.nexusCards!.cards!.hands[holder], 'beneGesserit',
+ 'The real closing draw put the physical Bene Gesserit card in the unallied combatant hand');
+
+ // ---- Turn-two Movement: both combatants pay for a real reserve shipment into Arrakeen.
+ game = advanceNexusBorrowedVoice(game,
+ state => state.turn === 2 && state.phase === 6 && !state.battle &&
+ state.active === atreides && nexusBorrowedVoiceClean(state), actions, driver);
+ const beforeBattle = structuredClone(game);
+ const chooserAction: Action = { type: 'chooseBattle', territory: driver.territory, target: holder };
+ actions.push({ actor: atreides, action: structuredClone(chooserAction) });
+ game = applyAction(game, atreides, chooserAction);
+ game = advanceNexusBorrowedVoice(game, nexusBorrowedVoiceClean, actions, driver);
+ const battle = game.battle!;
+ assert.equal(battle.attacker, atreides, 'The neutral Atreides seat stays the aggressor');
+ assert.equal(battle.defender, holder);
+ assert.equal(battle.territory, driver.territory);
+ assert.ok(battle.prepared && !battle.revealed);
+ assert.equal(battle.plans[atreides], undefined);
+ assert.equal(battle.plans[holder], undefined);
+ assert.equal(battle.voice, undefined, 'No Voice exists before the queue settles');
+
+ const frame = game.battle?.nexusBorrowedVoice;
+ assert.ok(frame, 'The battle must carry the absent-Bene-Gesserit borrowed-Voice queue');
+ const target = nexusBorrowedVoicePlayer(game, atreides);
+ const targetCard = target.hand.find(card => VOICE_KINDS.includes(card.kind) &&
+ defaultVoiceMatch(card, card.kind));
+ assert.ok(targetCard, 'The real Atreides deal must hold one canonical commandable card');
+ const absentKind = VOICE_KINDS.find(kind => !['stoneBurner', 'mirrorWeapon'].includes(kind) &&
+ !target.hand.some(card => defaultVoiceMatch(card, kind)));
+ assert.ok(absentKind, 'A canonical category outside the target hand must remain declarable');
+
+ const scenario = {
+ code: game.code,
+ advanced,
+ turn: game.turn,
+ phase: game.phase,
+ event: battle.event,
+ territory: battle.territory,
+ attacker: battle.attacker,
+ defender: battle.defender,
+ queueEvent: frame.event,
+ stage: frame.stage,
+ order: frame.order,
+ heldNexusCard: cards.hands[holder],
+ nexusCard: 'beneGesserit',
+ nexusDiscard: [...cards.discard],
+ holder, atreides, canceler, partner,
+ holderFaction: nexusBorrowedVoicePlayer(game, holder).faction,
+ atreidesFaction: target.faction,
+ cancelerCard,
+ cancelerAllies: nexusBorrowedVoicePlayer(game, canceler).ally,
+ command: { kind: targetCard.kind, must: true },
+ commandCard: targetCard.id,
+ impossibleKind: absentKind,
+ forceCensus: game.players.map(player => ({ id: player.id, faction: player.faction,
+ spice: player.spice, reserves: player.reserves, tanks: player.tanks,
+ forces: player.forces, hand: player.hand.map(card => card.id) })),
+ staging: [...staging],
+ };
+ assert.equal(game.nexusCards!.cards!.hands[holder], 'beneGesserit',
+ 'The Nexus card stays in hand until a real declaration spends it');
+ nexusBorrowedVoiceInventory(game);
+ return {
+ initial, afterSetup, beforeBattle, game, advanced,
+ attackOrder: [battle.attacker, battle.defender],
+ atreides, holder, canceler, partner, cancelerCard,
+ nexusCard: 'beneGesserit', target: atreides, absentKind,
+ targetCard: targetCard.id, targetKind: targetCard.kind,
+ actions, staging, scenario,
+ };
+}
+
+/** Genuine Basic/Advanced absent-Bene-Gesserit chronology: four real base
+ * seats, a real first-auction Karama purchase, the real turn-two Shai-Hulud
+ * Nexus with its real reciprocal alliance and closing draws, two real paid
+ * reserve shipments, and the real battle chooser. No hand, wallet, board
+ * counter, phase, plan, frame or death is written by the fixture. */
+export function createNexusBorrowedVoiceFixture(
+ options: NexusBorrowedVoiceFixtureOptions = {},
+): NexusBorrowedVoiceFixture {
+ return identityRandom(() => buildNativeFixture(options, 2));
+}
diff --git a/tests/nexus-borrowed-voice-runtime.test.ts b/tests/nexus-borrowed-voice-runtime.test.ts
new file mode 100644
index 00000000..592059fe
--- /dev/null
+++ b/tests/nexus-borrowed-voice-runtime.test.ts
@@ -0,0 +1,330 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
+import { defaultVoiceMatch } from '../game/battle-cards';
+import { validateNexusBorrowedVoice, type NexusBorrowedVoiceFrame } from '../game/nexus-borrowed-voice';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+import {
+ createNexusBorrowedVoiceFixture, nexusBorrowedVoiceInventory,
+ nexusBorrowedVoicePlayer as player,
+ type NexusBorrowedVoiceFixture,
+} from './fixture-nexus-borrowed-voice';
+
+const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
+/** The engine's own frame bound to this real battle, never a stand-in. */
+function frame(game: Game): NexusBorrowedVoiceFrame {
+ const record = game.battle?.nexusBorrowedVoice;
+ assert.ok(record, 'The battle must carry the absent-Bene-Gesserit borrowed-Voice frame');
+ return record;
+}
+function context(game: Game) {
+ const battle = game.battle!;
+ assert.ok(battle.event);
+ return { turn: game.turn,
+ battle: { event: battle.event, attacker: battle.attacker, defender: battle.defender } };
+}
+function offer(game: Game, actor: string) {
+ const view = viewGame(game, actor).nexusBorrowedVoice;
+ assert.ok(view, `The current queue owner must see the borrowed-Voice offer for ${actor}`);
+ return view;
+}
+function preparation(game: Game, actor?: string) {
+ const battle = actor ? viewGame(game, actor).battle : game.battle;
+ return battle?.preparation;
+}
+function reject(game: Game, actor: string, action: Action, error?: RegExp): void {
+ const before = structuredClone(game);
+ if (error) assert.throws(() => applyAction(game, actor, action), error);
+ else assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before, 'A refused queue action cannot spend, deal, command or move anything');
+}
+/** The single opportunistic physical auction purchase this fixture makes. */
+function declareStep(game: Game, fixture: NexusBorrowedVoiceFixture, must: boolean,
+ kind = fixture.targetKind): Game {
+ const neutral = applyAction(game, fixture.atreides,
+ { type: 'nexusBorrowedVoice', event: offer(game, fixture.atreides).event, use: false });
+ return applyAction(neutral, fixture.holder,
+ { type: 'nexusBorrowedVoice', event: offer(neutral, fixture.holder).event,
+ use: true, kind, must });
+}
+/** Settle the original Voice response exactly as the engine's own window does. */
+function settledWindow(game: Game): Game {
+ let state = game;
+ for (let index = 0; state.response && index < 24; index++) {
+ const actor = state.players.find(seat => !state.response!.passed.includes(seat.id))!.id;
+ state = applyAction(state, actor, { type: 'passResponse' });
+ }
+ assert.equal(state.response, null);
+ return state;
+}
+function policy(game: Game, actor: string, difficulty: Difficulty): Action[] {
+ const saved = reload(game);
+ player(saved, actor).bot = difficulty;
+ return botActions(viewGame(saved, actor));
+}
+function plan(game: Game, actor: string, weapon: string | null): Action {
+ const leader = player(game, actor).leaders.filter(candidate => !candidate.dead)
+ .sort((a, b) => b.strength - a.strength)[0];
+ assert.ok(leader);
+ return { type: 'battlePlan', leader: leader.id, dial: 0, support: 0, weapon, defense: null };
+}
+/** Decline the native preparation exactly as the original flow allows, including
+ * the Advanced Atreides full-plan offer that follows it. */
+function declinePreparation(game: Game, actor: string): Game {
+ const declined = applyAction(game, actor, { type: 'declineBattlePower' });
+ return declined.decision?.kind === 'fullPlanOffer'
+ ? applyAction(declined, declined.decision.player, { type: 'decision', decline: true })
+ : declined;
+}
+const built: Partial> = {};
+function fixture(advanced: boolean): NexusBorrowedVoiceFixture {
+ const key = advanced ? 'advanced' : 'basic';
+ built[key] ??= createNexusBorrowedVoiceFixture({ advanced });
+ return built[key]!;
+}
+
+for (const advanced of [false, true]) {
+ const band = advanced ? 'Advanced' : 'Basic';
+
+ void test(`${band} absent-Bene-Gesserit Nexus offers both real combatants in attacker order before native Prescience`, () => {
+ const f = fixture(advanced);
+ const game = reload(f.game);
+ const record = frame(game);
+ assert.equal(record.stage, 'choose');
+ assert.deepEqual(record.order, [f.atreides, f.holder]);
+ assert.deepEqual(record.passed, []);
+ assert.equal(record.owner, null);
+ assert.equal(record.voice, null);
+ assert.equal(record.turn, game.turn);
+ assert.equal(record.battle, game.battle!.event);
+ validateNexusBorrowedVoice(context(game), record);
+ assert.equal(preparation(game, f.atreides)!.kind, 'nexusVoice');
+ assert.equal(preparation(game, f.atreides)!.owner, f.atreides);
+ const first = offer(game, f.atreides);
+ assert.equal(first.event, record.event);
+ assert.equal(first.owner, f.atreides);
+ assert.equal(first.target, f.holder);
+ assert.equal(first.canUse, false, 'The neutral aggressor owns no physical Nexus card');
+ assert.equal(viewGame(game, f.holder).nexusBorrowedVoice, null, 'Only the current queue owner sees the offer');
+ assert.equal(viewGame(game, f.canceler).nexusBorrowedVoice, null);
+ assert.equal(viewGame(game, f.partner).nexusBorrowedVoice, null);
+ assert.equal(preparation(game, f.canceler)!.kind, 'nexusVoice',
+ 'The public preparation names who must acknowledge without exposing a card');
+ assert.equal(preparation(game, f.canceler)!.owner, f.atreides);
+ // A cardless or foreign seat may never command, and the queue event is bound.
+ reject(game, f.atreides, { type: 'nexusBorrowedVoice', event: first.event, use: true,
+ kind: f.targetKind, must: true });
+ reject(game, f.holder, { type: 'nexusBorrowedVoice', event: first.event, use: false });
+ reject(game, f.canceler, { type: 'nexusBorrowedVoice', event: first.event, use: false });
+ reject(game, f.partner, { type: 'nexusBorrowedVoice', event: first.event, use: false });
+ reject(game, f.atreides, { type: 'nexusBorrowedVoice', event: `${first.event}:stale`, use: false });
+ reject(game, f.atreides, { type: 'nexusBorrowedVoice', event: game.battle!.event, use: false });
+ nexusBorrowedVoiceInventory(game);
+
+ // The neutral aggressor acknowledges first; nothing is spent or dealt.
+ const hands = player(game, f.holder).hand.map(card => card.id);
+ const passed = applyAction(game, f.atreides, { type: 'nexusBorrowedVoice', event: first.event, use: false });
+ const afterFirst = frame(passed);
+ assert.equal(afterFirst.stage, 'choose');
+ assert.deepEqual(afterFirst.passed, [f.atreides]);
+ assert.equal(passed.battle!.voice, undefined);
+ assert.deepEqual(player(passed, f.holder).hand.map(card => card.id), hands);
+ assert.equal(passed.nexusCards!.cards!.hands[f.holder], f.nexusCard);
+ assert.equal(viewGame(passed, f.atreides).nexusBorrowedVoice, null);
+ const second = offer(passed, f.holder);
+ assert.equal(second.owner, f.holder);
+ assert.equal(second.target, f.atreides);
+ assert.equal(second.canUse, true, 'The actual holder may use the physical card once');
+ assert.ok(!second.blocked, 'The eligible holder has no queue blockage');
+ assert.equal(preparation(passed, f.holder)!.owner, f.holder);
+ // Only one ordered opportunity each: the answered seat cannot speak again.
+ reject(passed, f.atreides, { type: 'nexusBorrowedVoice', event: second.event, use: false });
+ reject(passed, f.holder, { type: 'nexusBorrowedVoice', event: second.event, use: true,
+ kind: f.targetKind });
+ assert.equal(frame(passed).passed.length, 1, 'A malformed declaration never consumes the opportunity');
+ nexusBorrowedVoiceInventory(passed);
+ });
+
+ void test(`${band} absent-Bene-Gesserit Nexus accepts only the eligible holder declaration and spends the physical card`, () => {
+ const f = fixture(advanced);
+ const game = reload(f.game);
+ const nexusDiscard = [...game.nexusCards!.cards!.discard];
+ const hands = Object.fromEntries(game.players.map(seat => [seat.id, seat.hand.map(card => card.id)]));
+ const declared = declareStep(game, f, true);
+ const record = frame(declared);
+ assert.equal(record.stage, 'declared');
+ assert.equal(record.owner, f.holder);
+ assert.deepEqual(record.voice, { target: f.atreides, kind: f.targetKind, must: true });
+ assert.deepEqual(declared.battle!.voice, { target: f.atreides, kind: f.targetKind, must: true });
+ assert.equal(declared.battle!.revealed, false);
+ assert.equal(declared.battle!.plans[f.atreides], undefined);
+ assert.equal(declared.nexusCards!.cards!.hands[f.holder], null,
+ 'The declaration spends the physical Bene Gesserit Nexus card');
+ assert.deepEqual(declared.nexusCards!.cards!.discard, [...nexusDiscard, f.nexusCard]);
+ assert.equal(declared.response?.kind, 'voice');
+ assert.equal(declared.response?.owner, f.holder, 'The original Voice response opens for the declaring seat');
+ assert.equal(viewGame(declared, f.holder).nexusBorrowedVoice, null, 'The queue closes for good');
+ assert.equal(viewGame(declared, f.atreides).nexusBorrowedVoice, null);
+ // No second declaration, no re-deal and no extra spend.
+ const handMap = (state: Game) => Object.fromEntries(
+ state.players.map(seat => [seat.id, seat.hand.map(card => card.id)]));
+ const before = handMap(declared);
+ reject(declared, f.holder, { type: 'nexusBorrowedVoice', event: record.event, use: true, kind: f.targetKind, must: true });
+ reject(declared, f.holder, { type: 'nexusBorrowedVoice', event: record.event, use: false });
+ reject(declared, f.atreides, { type: 'nexusBorrowedVoice', event: record.event, use: true, kind: f.targetKind, must: true });
+ assert.deepEqual(handMap(declared), before);
+ assert.deepEqual(before, hands, 'The declaration spends the Nexus card, not a Treachery card');
+ // The original Voice window settles normally and the command survives into Prescience.
+ const settled = settledWindow(declared);
+ assert.equal(frame(settled).stage, 'active');
+ assert.deepEqual(settled.battle!.voice, { target: f.atreides, kind: f.targetKind, must: true });
+ assert.equal(settled.response, null);
+ const preparationAfter = preparation(settled, f.atreides)!;
+ assert.equal(preparationAfter.kind, 'prescience', 'The original preparation continues under the command');
+ assert.equal(preparationAfter.owner, f.atreides, 'The native first inspection belongs to the seated Atreides seat');
+ const next = nextStrongholdFactionsNativeStep(settled);
+ assert.ok(next);
+ assert.equal(next.actor, f.atreides);
+ nexusBorrowedVoiceInventory(settled);
+ });
+
+ void test(`${band} absent-Bene-Gesserit Nexus: all-pass leaves the hidden card unspent and continues the original preparation`, () => {
+ const f = fixture(advanced);
+ const game = reload(f.game);
+ const record = frame(game);
+ const first = applyAction(game, f.atreides,
+ { type: 'nexusBorrowedVoice', event: record.event, use: false });
+ const answered = applyAction(first, f.holder,
+ { type: 'nexusBorrowedVoice', event: frame(first).event, use: false });
+ const after = frame(answered);
+ assert.equal(after.stage, 'passed');
+ assert.deepEqual(after.passed, [f.atreides, f.holder]);
+ assert.equal(after.owner, null);
+ assert.equal(after.voice, null);
+ assert.equal(answered.battle!.voice, undefined, 'All-pass never declares a command');
+ assert.equal(answered.response, null, 'All-pass opens no Voice response');
+ assert.equal(answered.nexusCards!.cards!.hands[f.holder], f.nexusCard,
+ 'All-pass keeps the physical Nexus card in hand');
+ assert.deepEqual(answered.nexusCards!.cards!.discard, game.nexusCards!.cards!.discard);
+ const preparationAfter = preparation(answered, f.atreides)!;
+ assert.equal(preparationAfter.kind, 'prescience', 'All-pass resumes the original preparation');
+ assert.equal(preparationAfter.owner, f.atreides);
+ assert.equal(frame(answered).order[0], f.atreides);
+ const next = nextStrongholdFactionsNativeStep(answered);
+ assert.ok(next, 'The original continuation must not stall on a passed queue');
+ assert.equal(next.actor, f.atreides);
+ assert.equal(next.action.type, 'declineBattlePower');
+ // The answered queue stays settled: no reopen, no late command, no spend.
+ reject(answered, f.holder, { type: 'nexusBorrowedVoice', event: after.event, use: false });
+ reject(answered, f.holder, { type: 'nexusBorrowedVoice', event: after.event, use: true,
+ kind: f.targetKind, must: true });
+ assert.equal(answered.nexusCards!.cards!.hands[f.holder], f.nexusCard);
+ nexusBorrowedVoiceInventory(answered);
+ });
+
+ void test(`${band} absent-Bene-Gesserit Nexus: a real auctioned Karama cancels only the Voice and leaves the Nexus card spent`, () => {
+ const f = fixture(advanced);
+ const declared = declareStep(reload(f.game), f, true);
+ const nexusBefore = structuredClone(declared.nexusCards!.cards!);
+ const voice = structuredClone(declared.battle!.voice);
+ const preparationBefore = structuredClone(preparation(declared, f.atreides));
+ const karama = player(declared, f.canceler).hand.find(card => card.id === f.cancelerCard);
+ assert.ok(karama && karama.effect === 'karama',
+ 'The real first-auction Karama must still be in the allied seat hand');
+ assert.notEqual(f.canceler, declared.response!.owner);
+ const canceled = applyAction(declared, f.canceler,
+ { type: 'card', mode: 'cancel', card: karama.id });
+ assert.equal(canceled.battle!.voice, undefined, 'Cancellation removes only the Voice');
+ assert.deepEqual(preparation(canceled, f.atreides), preparationBefore,
+ 'The original preparation survives cancellation');
+ assert.equal(canceled.nexusCards!.cards!.hands[f.holder], null,
+ 'The spent Nexus card is not returned to the holder');
+ assert.deepEqual(canceled.nexusCards!.cards!.discard, nexusBefore.discard,
+ 'The Nexus card stays spent through cancellation');
+ assert.equal(frame(canceled).stage, 'canceled');
+ assert.deepEqual(frame(canceled).voice, voice, 'A canceled command remains a receipt');
+ assert.equal(canceled.discard.some(card => card.id === karama.id), true, 'The Karama is spent');
+ assert.equal(canceled.response, null);
+ assert.equal(viewGame(canceled, f.holder).nexusBorrowedVoice, null);
+ assert.equal(viewGame(canceled, f.atreides).nexusBorrowedVoice, null);
+ const next = nextStrongholdFactionsNativeStep(canceled);
+ assert.ok(next, 'The canceled declaration must not stall the original continuation');
+ assert.equal(next.actor, f.atreides);
+ assert.equal(preparation(canceled, f.atreides)!.kind, 'prescience');
+ nexusBorrowedVoiceInventory(canceled);
+ });
+
+ void test(`${band} absent-Bene-Gesserit Nexus: the command binds the target's original plan and never the declarer's`, () => {
+ const f = fixture(advanced);
+ const must = settledWindow(declareStep(reload(f.game), f, true));
+ const state = declinePreparation(must, f.atreides);
+ assert.equal(state.battle!.preparation, undefined);
+ assert.deepEqual(state.battle!.voice, { target: f.atreides, kind: f.targetKind, must: true });
+ reject(state, f.atreides, plan(state, f.atreides, null), /Voice/);
+ const compliant = applyAction(state, f.atreides, plan(state, f.atreides, f.targetCard));
+ assert.equal(compliant.battle!.plans[f.atreides].weapon, f.targetCard);
+ // The declarer carries no obligation of its own Voice.
+ const declared = applyAction(compliant, f.holder, plan(compliant, f.holder, null));
+ assert.equal(declared.battle!.plans[f.holder].weapon, null);
+
+ const forbid = settledWindow(declareStep(reload(f.game), f, false));
+ const forbidding = declinePreparation(forbid, f.atreides);
+ reject(forbidding, f.atreides, plan(forbidding, f.atreides, f.targetCard), /Voice/);
+ const free = applyAction(forbidding, f.atreides, plan(forbidding, f.atreides, null));
+ assert.equal(free.battle!.plans[f.atreides].weapon, null);
+
+ // A public category the target cannot supply is not an impossible command.
+ const absent = settledWindow(declareStep(reload(f.game), f, true, f.absentKind));
+ assert.deepEqual(absent.battle!.voice,
+ { target: f.atreides, kind: f.absentKind, must: true });
+ const unstoppable = applyAction(declinePreparation(absent, f.atreides),
+ f.atreides, plan(absent, f.atreides, null));
+ assert.equal(unstoppable.battle!.plans[f.atreides].weapon, null);
+ assert.equal(player(absent, f.atreides).hand.some(card => defaultVoiceMatch(card, f.absentKind)), false);
+ });
+
+ void test(`${band} absent-Bene-Gesserit Nexus: native Prescience and the extra Nexus inspection wait for the queue`, () => {
+ const f = fixture(advanced);
+ const game = reload(f.game);
+ reject(game, f.atreides, { type: 'prescience', field: 'weapon' });
+ reject(game, f.atreides, { type: 'declineBattlePower' });
+ reject(game, f.atreides, { type: 'battlePlan', leader: player(game, f.atreides).leaders[0].id,
+ dial: 0, support: 0, weapon: null, defense: null });
+ const inspection = viewGame(game, f.atreides).nexusAtreides;
+ assert.ok(inspection, 'The seated Atreides seat also holds the physical Atreides Nexus card');
+ assert.ok(inspection.blocked, 'The extra inspection stays blocked before the first native inspection');
+ reject(game, f.atreides, { type: 'nexusAtreides', event: inspection.event,
+ mode: inspection.mode, field: 'weapon' });
+ assert.equal(preparation(game, f.atreides)!.kind, 'nexusVoice');
+ nexusBorrowedVoiceInventory(game);
+ });
+
+ void test(`${band} absent-Bene-Gesserit Nexus: all four native policies answer the queue legally`, () => {
+ const f = fixture(advanced);
+ for (const difficulty of DIFFICULTIES) {
+ const neutralStage = reload(f.game);
+ const neutralPolicy = policy(neutralStage, f.atreides, difficulty);
+ const pass = neutralPolicy.find(action => action.type === 'nexusBorrowedVoice');
+ assert.ok(pass, `${difficulty} must answer the borrowed-Voice queue`);
+ assert.equal(pass.use, false, 'The cardless combatant can only continue');
+ const passed = applyAction(neutralStage, f.atreides, pass);
+ assert.deepEqual(frame(passed).passed, [f.atreides]);
+ const holderPolicy = policy(passed, f.holder, difficulty);
+ const answer = holderPolicy.find(action => action.type === 'nexusBorrowedVoice');
+ assert.ok(answer, `${difficulty} must answer the holder opportunity`);
+ const done = applyAction(passed, f.holder, answer);
+ if (answer.use === true) {
+ assert.equal(frame(done).stage, 'declared');
+ assert.equal(done.nexusCards!.cards!.hands[f.holder], null,
+ 'A policy use spends the same physical card');
+ } else {
+ assert.equal(frame(done).stage, 'passed');
+ assert.equal(done.nexusCards!.cards!.hands[f.holder], f.nexusCard);
+ }
+ nexusBorrowedVoiceInventory(done);
+ }
+ });
+}
+
diff --git a/tests/nexus-borrowed-voice.test.ts b/tests/nexus-borrowed-voice.test.ts
new file mode 100644
index 00000000..af76b3ab
--- /dev/null
+++ b/tests/nexus-borrowed-voice.test.ts
@@ -0,0 +1,271 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { VOICE_KINDS } from '../game/battle-cards';
+import {
+ createNexusBorrowedVoice,
+ currentNexusBorrowedVoiceOwner,
+ declareNexusBorrowedVoice,
+ finishNexusBorrowedVoice,
+ passNexusBorrowedVoice,
+ validateNexusBorrowedVoice,
+ type NexusBorrowedVoiceContext,
+ type NexusBorrowedVoiceFrame,
+} from '../game/nexus-borrowed-voice';
+
+const context: NexusBorrowedVoiceContext = {
+ turn: 2,
+ battle: { event: 'original-battle-7', attacker: 'a', defender: 'd' },
+};
+const command: NonNullable = {
+ target: 'd', kind: 'poison', must: true,
+};
+
+function freeze(value: unknown): void {
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return;
+ for (const child of Object.values(value)) freeze(child);
+ Object.freeze(value);
+}
+
+void test('creation binds a deterministic original turn/battle and attacker-first queue', () => {
+ const frame = createNexusBorrowedVoice(context);
+ assert.deepEqual(frame, {
+ event: JSON.stringify(['nexusBorrowedVoice', 2, 'original-battle-7']),
+ turn: 2, battle: 'original-battle-7', order: ['a', 'd'], passed: [],
+ stage: 'choose', owner: null, voice: null,
+ });
+ assert.deepEqual(createNexusBorrowedVoice(structuredClone(context)), frame);
+ assert.equal(currentNexusBorrowedVoiceOwner(frame), 'a');
+ validateNexusBorrowedVoice(context, frame);
+ assert.notEqual(createNexusBorrowedVoice({ ...context, turn: 3 }).event, frame.event);
+ assert.notEqual(createNexusBorrowedVoice({ ...context,
+ battle: { ...context.battle, event: 'original-battle-8' } }).event, frame.event);
+});
+
+void test('all-pass closes the opportunity without a declaration or response', () => {
+ const original = createNexusBorrowedVoice(context);
+ const first = passNexusBorrowedVoice(original, 'a');
+ assert.deepEqual(first.passed, ['a']);
+ assert.equal(first.stage, 'choose');
+ assert.equal(currentNexusBorrowedVoiceOwner(first), 'd');
+ validateNexusBorrowedVoice(context, first);
+ const closed = passNexusBorrowedVoice(first, 'd');
+ assert.deepEqual(closed.passed, ['a', 'd']);
+ assert.equal(closed.stage, 'passed');
+ assert.equal(closed.owner, null);
+ assert.equal(closed.voice, null);
+ assert.equal(currentNexusBorrowedVoiceOwner(closed), null);
+ assert.equal(closed.event, original.event);
+ validateNexusBorrowedVoice(context, closed);
+ for (const actor of ['a', 'd']) {
+ assert.throws(() => passNexusBorrowedVoice(closed, actor));
+ assert.throws(() => declareNexusBorrowedVoice(closed, actor,
+ { ...command, target: actor === 'a' ? 'd' : 'a' }));
+ }
+ assert.throws(() => finishNexusBorrowedVoice(closed, false));
+});
+
+for (const actor of ['a', 'd']) {
+ void test(`${actor === 'a' ? 'first' : 'second'} combatant may declare once and close the queue`, () => {
+ const original = createNexusBorrowedVoice(context);
+ const ready = actor === 'a' ? original : passNexusBorrowedVoice(original, 'a');
+ const voice = { ...command, target: actor === 'a' ? 'd' : 'a', must: actor === 'a' };
+ const declared = declareNexusBorrowedVoice(ready, actor, voice);
+ assert.equal(declared.stage, 'declared');
+ assert.equal(declared.owner, actor);
+ assert.deepEqual(declared.voice, voice);
+ assert.deepEqual(declared.passed, actor === 'a' ? [] : ['a']);
+ assert.equal(declared.event, original.event);
+ assert.equal(currentNexusBorrowedVoiceOwner(declared), null);
+ validateNexusBorrowedVoice(context, declared);
+ for (const second of ['a', 'd']) {
+ assert.throws(() => passNexusBorrowedVoice(declared, second));
+ assert.throws(() => declareNexusBorrowedVoice(declared, second,
+ { ...command, target: second === 'a' ? 'd' : 'a' }));
+ }
+ for (const canceled of [false, true]) {
+ const settled = finishNexusBorrowedVoice(declared, canceled);
+ assert.equal(settled.stage, canceled ? 'canceled' : 'active');
+ assert.equal(settled.owner, actor);
+ assert.deepEqual(settled.voice, voice);
+ assert.deepEqual(settled.passed, declared.passed);
+ assert.equal(settled.event, declared.event);
+ assert.equal(currentNexusBorrowedVoiceOwner(settled), null);
+ validateNexusBorrowedVoice(context, settled);
+ for (const repeat of [false, true]) assert.throws(() => finishNexusBorrowedVoice(settled, repeat));
+ for (const second of ['a', 'd']) {
+ assert.throws(() => passNexusBorrowedVoice(settled, second));
+ assert.throws(() => declareNexusBorrowedVoice(settled, second,
+ { ...command, target: second === 'a' ? 'd' : 'a' }));
+ }
+ }
+ });
+}
+
+void test('wrong, foreign, passed and stale actors cannot advance the prefix queue', () => {
+ const first = createNexusBorrowedVoice(context);
+ for (const actor of ['d', 'foreign', '', null, undefined]) {
+ assert.throws(() => passNexusBorrowedVoice(first, actor as string));
+ assert.throws(() => declareNexusBorrowedVoice(first, actor as string, command));
+ }
+ const second = passNexusBorrowedVoice(first, 'a');
+ for (const actor of ['a', 'foreign', '', null, undefined]) {
+ assert.throws(() => passNexusBorrowedVoice(second, actor as string));
+ assert.throws(() => declareNexusBorrowedVoice(second, actor as string, { ...command, target: 'a' }));
+ }
+ assert.throws(() => finishNexusBorrowedVoice(first, false));
+ assert.throws(() => finishNexusBorrowedVoice(second, true));
+});
+
+void test('context validation rejects stale turn/event and changed original combatants at every stage', () => {
+ const first = createNexusBorrowedVoice(context);
+ const second = passNexusBorrowedVoice(first, 'a');
+ const declared = declareNexusBorrowedVoice(second, 'd', { ...command, target: 'a' });
+ const frames = [first, second, passNexusBorrowedVoice(second, 'd'), declared,
+ finishNexusBorrowedVoice(declared, false), finishNexusBorrowedVoice(declared, true)];
+ const changed: NexusBorrowedVoiceContext[] = [
+ { ...context, turn: 3 },
+ { ...context, battle: { ...context.battle, event: 'different-battle' } },
+ { ...context, battle: { ...context.battle, attacker: 'foreign' } },
+ { ...context, battle: { ...context.battle, defender: 'foreign' } },
+ { ...context, battle: { ...context.battle, attacker: 'd', defender: 'a' } },
+ ];
+ for (const frame of frames) {
+ validateNexusBorrowedVoice(context, frame);
+ for (const stale of changed) assert.throws(() => validateNexusBorrowedVoice(stale, frame));
+ }
+});
+
+void test('invalid contexts cannot manufacture a queue', () => {
+ const invalid: unknown[] = [
+ null, {}, { ...context, turn: 0 }, { ...context, turn: -1 },
+ { ...context, turn: 1.5 }, { ...context, turn: NaN },
+ { ...context, turn: Number.MAX_SAFE_INTEGER + 1 },
+ { ...context, turn: '2' }, { ...context, battle: null },
+ { ...context, battle: { ...context.battle, event: ' ' } },
+ { ...context, battle: { ...context.battle, attacker: '' } },
+ { ...context, battle: { ...context.battle, defender: ' ' } },
+ { ...context, battle: { ...context.battle, defender: 'a' } },
+ ];
+ const frame = createNexusBorrowedVoice(context);
+ for (const bad of invalid) {
+ assert.throws(() => createNexusBorrowedVoice(bad as NexusBorrowedVoiceContext));
+ assert.throws(() => validateNexusBorrowedVoice(bad as NexusBorrowedVoiceContext, frame));
+ }
+});
+
+void test('saved frames require the bound event, exact shape and a genuine pass prefix', () => {
+ const frame = createNexusBorrowedVoice(context);
+ const sparseOrder: string[] = [];
+ sparseOrder.length = 2;
+ const sparsePassed: string[] = [];
+ sparsePassed.length = 1;
+ sparseOrder[1] = 'd';
+ const invalid: unknown[] = [
+ null, {}, { ...frame, event: '' }, { ...frame, event: 'stale-event' },
+ { ...frame, turn: 3 }, { ...frame, battle: 'other-battle' },
+ { ...frame, source: 'private-card' }, { ...frame, stage: 'unknown' },
+ { ...frame, order: ['a'] }, { ...frame, order: ['a', 'a'] },
+ { ...frame, order: ['a', 'd', 'foreign'] }, { ...frame, order: sparseOrder },
+ { ...frame, passed: null }, { ...frame, passed: ['d'] },
+ { ...frame, passed: ['a', 'a'] }, { ...frame, passed: ['foreign'] },
+ { ...frame, passed: ['a', 'd', 'foreign'] }, { ...frame, passed: sparsePassed },
+ { ...frame, passed: ['a', 'd'] }, { ...frame, stage: 'passed' },
+ { ...frame, owner: 'a' }, { ...frame, voice: command },
+ ];
+ for (const bad of invalid) {
+ const malformed = bad as NexusBorrowedVoiceFrame;
+ assert.throws(() => validateNexusBorrowedVoice(context, malformed));
+ assert.throws(() => currentNexusBorrowedVoiceOwner(malformed));
+ assert.throws(() => passNexusBorrowedVoice(malformed, 'a'));
+ assert.throws(() => declareNexusBorrowedVoice(malformed, 'a', command));
+ assert.throws(() => finishNexusBorrowedVoice(malformed, false));
+ }
+});
+
+void test('every native Voice category and both require/forbid commands are canonical', () => {
+ for (const kind of VOICE_KINDS) {
+ for (const must of [false, true]) {
+ const voice = { ...command, kind: kind as typeof command.kind, must };
+ const declared = declareNexusBorrowedVoice(createNexusBorrowedVoice(context), 'a', voice);
+ assert.deepEqual(declared.voice, voice);
+ validateNexusBorrowedVoice(context, declared);
+ validateNexusBorrowedVoice(context, finishNexusBorrowedVoice(declared, false));
+ validateNexusBorrowedVoice(context, finishNexusBorrowedVoice(declared, true));
+ }
+ }
+});
+
+void test('declarations reject missing/noncanonical commands, nonbooleans and wrong targets', () => {
+ const frame = createNexusBorrowedVoice(context);
+ const invalid: unknown[] = [
+ null, undefined, {}, { ...command, target: 'a' }, { ...command, target: 'foreign' },
+ { ...command, kind: 'karama' }, { ...command, kind: 'portableSnooper' },
+ { ...command, kind: 'Poison' }, { ...command, kind: null },
+ { target: 'd', must: true }, { target: 'd', kind: 'poison' },
+ { ...command, must: 'false' }, { ...command, must: 0 },
+ { ...command, must: null }, { ...command, source: 'nexus' },
+ ];
+ for (const bad of invalid) {
+ assert.throws(() => declareNexusBorrowedVoice(frame, 'a', bad as typeof command));
+ }
+ assert.deepEqual(frame, createNexusBorrowedVoice(context));
+});
+
+void test('declared, active and canceled receipts require exactly one ordered owner and command', () => {
+ const declared = declareNexusBorrowedVoice(createNexusBorrowedVoice(context), 'a', command);
+ for (const stage of ['declared', 'active', 'canceled'] as const) {
+ const base = { ...declared, stage };
+ validateNexusBorrowedVoice(context, base);
+ const invalid: unknown[] = [
+ { ...base, owner: null }, { ...base, owner: 'd' }, { ...base, owner: 'foreign' },
+ { ...base, voice: null }, { ...base, voice: { ...command, target: 'a' } },
+ { ...base, voice: { ...command, must: 'true' } },
+ { ...base, voice: { ...command, kind: 'karama' } },
+ { ...base, passed: ['a'] }, { ...base, passed: ['a', 'd'] },
+ ];
+ for (const bad of invalid) assert.throws(() => validateNexusBorrowedVoice(context, bad as NexusBorrowedVoiceFrame));
+ }
+ for (const bad of ['false', 0, null, undefined]) {
+ assert.throws(() => finishNexusBorrowedVoice(declared, bad as unknown as boolean));
+ }
+});
+
+void test('successful and rejected transitions leave inputs untouched and return independent receipts', () => {
+ const ctx = structuredClone(context);
+ const voice = structuredClone(command);
+ freeze(ctx);
+ freeze(voice);
+ const first = createNexusBorrowedVoice(ctx);
+ const original = structuredClone(first);
+ freeze(first);
+ const second = passNexusBorrowedVoice(first, 'a');
+ freeze(second);
+ const allPassed = passNexusBorrowedVoice(second, 'd');
+ const firstDeclared = declareNexusBorrowedVoice(first, 'a', voice);
+ freeze(firstDeclared);
+ const canceled = finishNexusBorrowedVoice(firstDeclared, true);
+ const active = finishNexusBorrowedVoice(firstDeclared, false);
+ const secondDeclared = declareNexusBorrowedVoice(second, 'd', { ...voice, target: 'a' });
+ assert.throws(() => passNexusBorrowedVoice(first, 'd'));
+ assert.throws(() => declareNexusBorrowedVoice(first, 'a', { ...voice, target: 'a' }));
+ assert.deepEqual(first, original);
+ assert.deepEqual(ctx, context);
+ assert.deepEqual(voice, command);
+ assert.deepEqual(second.passed, ['a']);
+ assert.equal(firstDeclared.stage, 'declared');
+ for (const result of [second, allPassed, firstDeclared, secondDeclared, canceled, active]) {
+ assert.notEqual(result, first);
+ assert.notEqual(result.order, first.order);
+ assert.notEqual(result.passed, first.passed);
+ }
+ assert.notEqual(firstDeclared.voice, voice);
+ assert.notEqual(canceled.voice, firstDeclared.voice);
+ assert.notEqual(active.voice, firstDeclared.voice);
+ canceled.voice!.must = false;
+ canceled.order[0] = 'changed';
+ canceled.passed.push('changed');
+ active.voice!.kind = 'shield';
+ assert.deepEqual(firstDeclared.voice, command);
+ assert.deepEqual(firstDeclared.order, ['a', 'd']);
+ assert.deepEqual(firstDeclared.passed, []);
+});