Connect absent Bene Gesserit Nexus Voice before Prescience
This commit is contained in:
1 parent
72e37a8978
commit
8ab827b902
15 files changed
+1417
-50
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
Reference in new issue
Block a user