Connect absent Bene Gesserit Nexus Voice before Prescience

This commit is contained in:
Quintine committed 2026-10-15 00:51:43 +11:00
1 parent 72e37a8978
commit 8ab827b902
15 files changed
+1417 -50

No files matched your search

+372
View File
@@ -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<string, unknown>;
};
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<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(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<Record<FactionId, string>> = {
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<string, FactionId> = { [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));
}
+330
View File
@@ -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<Record<'basic' | 'advanced', NexusBorrowedVoiceFixture>> = {};
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);
}
});
}
+271
View File
@@ -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<NexusBorrowedVoiceFrame['voice']> = {
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, []);
});