371 lines
20 KiB
TypeScript
371 lines
20 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
|
|
type Action, type Game, type Player,
|
|
} from '../game/engine';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { location, territory, validGameLocation } from '../game/board';
|
|
import { treacheryDeck } from '../game/cards';
|
|
import { validateNexusCards } from '../game/nexus-cards';
|
|
import { tleilaxuSecretAllyEvent, tleilaxuSecretAllyOptions } from '../game/nexus-tleilaxu-revival';
|
|
import { shipmentAvailable } from '../game/shipment-opportunity';
|
|
import { traitorDeck } from '../game/traitors';
|
|
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
|
|
|
/** One real native step: the actor is always the seat the engine expects. */
|
|
export type NexusTleilaxuSecretAllyStep = { actor: string; action: Action };
|
|
export type NexusTleilaxuSecretAllyFixtureOptions = {
|
|
/** Only an original undealt classic lobby, or omit for a fresh Atreides/Guild/Emperor table. */
|
|
initial?: Game;
|
|
advanced?: boolean;
|
|
seatIds?: readonly [string, string, string];
|
|
};
|
|
export type NexusTleilaxuSecretAllyDeadLeader = { id: string; name: string; deaths: number };
|
|
export type NexusTleilaxuSecretAllyFixture = {
|
|
initial: Game;
|
|
afterSetup: Game;
|
|
beforeShipment: Game;
|
|
shipment: NexusTleilaxuSecretAllyStep;
|
|
beforeBattle: Game;
|
|
battleActions: NexusTleilaxuSecretAllyStep[];
|
|
afterBattle: Game;
|
|
beforeBlow: Game;
|
|
beforeDraw: Game;
|
|
draw: NexusTleilaxuSecretAllyStep;
|
|
afterDraw: Game;
|
|
/** Quiet turn-two Revival with the physical Tleilaxu card held by an unallied owner. */
|
|
game: Game;
|
|
owner: string;
|
|
opponent: string;
|
|
partner: string;
|
|
ownerFaction: FactionId;
|
|
deadLeader: NexusTleilaxuSecretAllyDeadLeader;
|
|
nexusEvent: string;
|
|
actions: NexusTleilaxuSecretAllyStep[];
|
|
staging: string[];
|
|
/** Raw JSON-safe scenario facts for the parent's throwaway API and phone smoke. */
|
|
scenario: Record<string, unknown>;
|
|
};
|
|
export function nexusTleilaxuSecretAllyPlayer(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 nexusTleilaxuSecretAllyClean = (game: Game): boolean =>
|
|
!game.phaseOpening && !game.response && !game.decision;
|
|
|
|
/** Scoped producer entropy: every original shuffle keeps its printed order, so
|
|
* the real deal, real draws and real battle chronology stay reproducible. Only
|
|
* the initializer and genuine 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');
|
|
}
|
|
}
|
|
|
|
/** The closing Nexus draw is a private table choice, so the shared native
|
|
* continuation stops there instead of drawing a card for a seat. */
|
|
export function nextNexusTleilaxuSecretAllyNativeStep(
|
|
game: Game,
|
|
): NexusTleilaxuSecretAllyStep | null {
|
|
if (game.nexusCards?.phase?.stage === 'drawing' && nexusTleilaxuSecretAllyClean(game)) return null;
|
|
return nextStrongholdFactionsNativeStep(game);
|
|
}
|
|
export function stepNexusTleilaxuSecretAlly(
|
|
game: Game,
|
|
actions?: NexusTleilaxuSecretAllyStep[],
|
|
): Game {
|
|
const next = nextNexusTleilaxuSecretAllyNativeStep(game);
|
|
assert.ok(next, `Original continuation stopped before ${game.status}/${game.turn}/${game.phase}`);
|
|
actions?.push(structuredClone(next));
|
|
return applyAction(game, next.actor, next.action);
|
|
}
|
|
export function advanceNexusTleilaxuSecretAlly(
|
|
game: Game,
|
|
until: (state: Game) => boolean,
|
|
actions?: NexusTleilaxuSecretAllyStep[],
|
|
): Game {
|
|
for (let count = 0; count < 2000; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished');
|
|
game = stepNexusTleilaxuSecretAlly(game, actions);
|
|
}
|
|
throw Error('Original absent-Tleilaxu Nexus chronology did not reach its boundary');
|
|
}
|
|
/** Turn-pinned phase boundary; a turn change is an error, not a success. */
|
|
export function advanceNexusTleilaxuSecretAllyToPhase(
|
|
game: Game,
|
|
phase: number,
|
|
actions?: NexusTleilaxuSecretAllyStep[],
|
|
): Game {
|
|
const turn = game.turn;
|
|
const reached = advanceNexusTleilaxuSecretAlly(game,
|
|
state => state.turn === turn && state.phase >= phase && nexusTleilaxuSecretAllyClean(state),
|
|
actions);
|
|
assert.equal(reached.turn, turn, 'The requested phase belongs to the current turn');
|
|
return reached;
|
|
}
|
|
|
|
/** Original undealt Shai-Hulud singleton to the front of the physical Spice Deck;
|
|
* nothing is added, removed or replaced. */
|
|
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; not a natural shuffle history.');
|
|
}
|
|
|
|
/** Fresh classic Nexus admission only: base seats, no expansions, offline audit seam. */
|
|
export function initializeNexusTleilaxuSecretAllySetup(
|
|
options: NexusTleilaxuSecretAllyFixtureOptions = {},
|
|
): Game {
|
|
const advanced = options.advanced ?? false;
|
|
if (options.initial) {
|
|
let supplied = structuredClone(options.initial);
|
|
assert.equal(supplied.status, 'lobby');
|
|
assert.equal(supplied.advanced, advanced);
|
|
assert.equal(supplied.turn, 1);
|
|
assert.equal(supplied.phase, 0);
|
|
assert.ok(!supplied.setupStage);
|
|
assert.deepEqual([...supplied.expansions], []);
|
|
assert.equal(supplied.players.length, 3);
|
|
assert.ok(supplied.players.every(player => player.reserves === 20 && player.tanks === 0 &&
|
|
player.spice === 0 && Object.keys(player.forces).length === 0 && player.hand.length === 0),
|
|
'Only a fresh undealt classic lobby may be continued');
|
|
if (!supplied.nexusCards) supplied.nexusCards = { cards: null, phase: null };
|
|
for (const player of supplied.players) if (!player.ready) supplied=applyAction(supplied, player.id, { type: 'ready' });
|
|
return identityRandom(() => initializeNexusGameForAudit(supplied));
|
|
}
|
|
const ids = options.seatIds ?? ['atreides', 'guild', 'emperor'];
|
|
assert.equal(new Set(ids).size, 3);
|
|
let game = createGame('NEXUSTLEILAXUSECRETALLY', newPlayer(ids[0], 'Atreides', 'atreides'), advanced);
|
|
joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
|
|
joinGame(game, newPlayer(ids[2], 'Emperor', 'emperor'));
|
|
assert.deepEqual(game.expansions, []);
|
|
assert.ok(game.players.every(player => player.faction === 'atreides' || player.faction === 'guild' || player.faction === 'emperor'));
|
|
game.nexusCards ??= { cards: null, phase: null };
|
|
for (const player of game.players) if (!player.ready) game=applyAction(game, player.id, { type: 'ready' });
|
|
return identityRandom(() => initializeNexusGameForAudit(game));
|
|
}
|
|
|
|
/** Physical census of the original pieces; no module owner is assumed. */
|
|
export function nexusTleilaxuSecretAllyInventory(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());
|
|
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
|
|
for (const player of game.players) {
|
|
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20, player.faction);
|
|
if (player.elites) {
|
|
assert.equal(player.elites.reserves + player.elites.tanks +
|
|
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0),
|
|
player.faction === 'emperor' ? 5 : 3, player.faction);
|
|
assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks);
|
|
}
|
|
}
|
|
assert.deepEqual([...(game.traitorReserve ?? []),
|
|
...game.players.flatMap(player => [...player.traitors, ...player.traitorChoices])].sort(),
|
|
traitorDeck(game.players, false).sort());
|
|
assert.ok(game.nexusCards?.cards, 'The Nexus inventory must exist');
|
|
validateNexusCards(game.nexusCards.cards, game.players);
|
|
}
|
|
|
|
/** Genuine Basic/Advanced absent-Tleilaxu chronology: one real Emperor shipment
|
|
* into the printed Atreides position, one real battle that kills an own leader and
|
|
* eight own counters, the real turn-two Shai-Hulud Nexus occurrence with its real
|
|
* reciprocal alliance, and the real closing Nexus draw. No hand, wallet, board
|
|
* counter, Tanks, alliance, phase, death or pending frame is written. */
|
|
export function createNexusTleilaxuSecretAllyFixture(
|
|
options: NexusTleilaxuSecretAllyFixtureOptions = {},
|
|
): NexusTleilaxuSecretAllyFixture {
|
|
const initial = initializeNexusTleilaxuSecretAllySetup(options);
|
|
return identityRandom(() => {
|
|
const advanced = initial.advanced;
|
|
const actions: NexusTleilaxuSecretAllyStep[] = [], staging: string[] = [];
|
|
let game = advanceNexusTleilaxuSecretAlly(initial, state => state.status === 'playing', actions);
|
|
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');
|
|
const owner = game.players.find(player => player.faction === 'atreides')!.id;
|
|
const opponent = game.players.find(player => player.faction === 'emperor')!.id;
|
|
const partner = game.players.find(player => player.faction === 'guild')!.id;
|
|
assert.equal(game.players.length, 3, 'One absent-faction holder, one aggressor and one alliance partner');
|
|
assert.ok(!game.players.some(player => player.faction === 'tleilaxu'), 'Secret Ally needs Tleilaxu absent');
|
|
assert.ok(game.players.every(player => player.ally === null));
|
|
const weapon = nexusTleilaxuSecretAllyPlayer(game, opponent).hand
|
|
.find(card => card.kind === 'projectile' || card.kind === 'poison');
|
|
assert.ok(weapon, 'The real Emperor deal must hold one printed leader-killing weapon');
|
|
nexusTleilaxuSecretAllyInventory(game);
|
|
|
|
// ---- Turn one, real Movement: the Emperor ships one ordinary counter into Arrakeen.
|
|
game = advanceNexusTleilaxuSecretAllyToPhase(game, 5, actions);
|
|
for (let count = 0; game.active !== opponent; count++) {
|
|
assert.ok(count < 12, 'Every original seat receives one real Movement turn');
|
|
game = stepNexusTleilaxuSecretAlly(game, actions);
|
|
}
|
|
const beforeShipment = structuredClone(game);
|
|
const sector = territory('arrakeen').sectors[0];
|
|
const target = location('arrakeen', sector);
|
|
assert.ok(validGameLocation(game, 'arrakeen', sector));
|
|
assert.ok(shipmentAvailable(game, nexusTleilaxuSecretAllyPlayer(game, opponent)),
|
|
'The Emperor still holds the original turn-one shipment');
|
|
const shipment: NexusTleilaxuSecretAllyStep = { actor: opponent, action: {
|
|
type: 'ship', territory: 'arrakeen', sector, amount: 1, elite: 0, allyPayment: 0 } };
|
|
actions.push(structuredClone(shipment));
|
|
game = applyAction(game, opponent, shipment.action);
|
|
game=advanceNexusTleilaxuSecretAlly(game,nexusTleilaxuSecretAllyClean,actions);
|
|
assert.equal(nexusTleilaxuSecretAllyPlayer(game, opponent).forces[target], 1);
|
|
assert.equal(nexusTleilaxuSecretAllyPlayer(game, owner).forces[target], 10,
|
|
'The printed Atreides position keeps its full real setup army');
|
|
staging.push('The real Emperor seat ships one ordinary reserve counter into the printed Atreides Arrakeen location; no counter, bank or phase is written.');
|
|
|
|
// ---- Turn one, real Battle: the Emperor's real weapon kills the defending disc.
|
|
game = advanceNexusTleilaxuSecretAllyToPhase(game, 6, actions);
|
|
const chooser = game.active!;
|
|
assert.ok([owner, opponent].includes(chooser), 'The real battle chooser must be one of the two co-located seats');
|
|
const battleActions: NexusTleilaxuSecretAllyStep[] = [];
|
|
if (!game.battle) {
|
|
const choose: NexusTleilaxuSecretAllyStep = { actor: chooser, action: {
|
|
type: 'chooseBattle', territory: 'arrakeen',
|
|
target: chooser === owner ? opponent : owner } };
|
|
battleActions.push(structuredClone(choose));
|
|
actions.push(structuredClone(choose));
|
|
game = applyAction(game, choose.actor, choose.action);
|
|
}
|
|
game = advanceNexusTleilaxuSecretAlly(game,
|
|
state => nexusTleilaxuSecretAllyClean(state) && !nextNexusTleilaxuSecretAllyNativeStep(state), actions);
|
|
assert.ok(game.battle && !game.battle.revealed, 'Both combatants still seal a private plan');
|
|
const beforeBattle = structuredClone(game);
|
|
const ownLeader = nexusTleilaxuSecretAllyPlayer(game, owner).leaders
|
|
.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
|
|
const otherLeader = nexusTleilaxuSecretAllyPlayer(game, opponent).leaders
|
|
.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
|
|
assert.ok(ownLeader && otherLeader);
|
|
// Basic dials the lost exchange directly; Advanced expresses the same eight
|
|
// committed ordinary counters on its half-step dial with no support spice.
|
|
const plans: NexusTleilaxuSecretAllyStep[] = [
|
|
{ actor: owner, action: { type: 'battlePlan', dial: advanced ? 4 : 8, support: 0,
|
|
leader: ownLeader.id, weapon: null, defense: null } },
|
|
{ actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0,
|
|
leader: otherLeader.id, weapon: weapon.id, defense: null } },
|
|
];
|
|
for (const plan of plans) {
|
|
battleActions.push(structuredClone(plan));
|
|
actions.push(structuredClone(plan));
|
|
game = applyAction(game, plan.actor, plan.action);
|
|
}
|
|
assert.ok(game.battle?.revealed, 'Both sealed plans are revealed');
|
|
game = advanceNexusTleilaxuSecretAlly(game, state => state.turn === 2 && state.phase === 1 &&
|
|
nexusTleilaxuSecretAllyClean(state) && !state.spiceSequence && !state.spiceResolution &&
|
|
!state.spiceWindow && !state.nexus && state.ready.length === 0, actions);
|
|
const afterBattle = structuredClone(game);
|
|
const deadLeader = nexusTleilaxuSecretAllyPlayer(game, owner).leaders
|
|
.find(leader => leader.id === ownLeader.id)!;
|
|
assert.equal(deadLeader.dead, true, 'The real opposing weapon killed this own leader disc');
|
|
assert.equal(deadLeader.deaths, 1);
|
|
assert.equal(nexusTleilaxuSecretAllyPlayer(game, owner).tanks, 8,
|
|
'Exactly the eight real dialed ordinary counters reached the Tanks');
|
|
staging.push('Real turn-one battle in the printed Atreides stronghold: the opposing printed weapon killed one own disc and the real exchange sent eight ordinary counters to the Tanks.');
|
|
|
|
// ---- Turn two, real Spice Blow and Nexus: one Shai-Hulud occurrence and the real draw.
|
|
const beforeBlow = structuredClone(game);
|
|
frontUndealtShaiHulud(game, staging);
|
|
game = advanceNexusTleilaxuSecretAlly(game,
|
|
state => state.nexus === true && !state.spiceWindow && nexusTleilaxuSecretAllyClean(state), actions);
|
|
assert.equal(game.nexus, true, 'The real Shai-Hulud appearance opened the Nexus');
|
|
const otherSeats = game.players.filter(player => player.id !== owner).map(player => player.id);
|
|
assert.deepEqual([...otherSeats].sort(), [opponent, partner].sort());
|
|
const alliance: NexusTleilaxuSecretAllyStep[] = [
|
|
{ actor: otherSeats[0], action: { type: 'alliance', target: otherSeats[1] } },
|
|
{ actor: otherSeats[1], action: { type: 'alliance', target: otherSeats[0] } },
|
|
];
|
|
for (const entry of alliance) {
|
|
actions.push(structuredClone(entry));
|
|
game = applyAction(game, entry.actor, entry.action);
|
|
}
|
|
assert.equal(nexusTleilaxuSecretAllyPlayer(game, opponent).ally, partner);
|
|
assert.equal(nexusTleilaxuSecretAllyPlayer(game, owner).ally, null, 'The holder must stay unallied');
|
|
staging.push('The two non-holder seats form the real reciprocal Nexus alliance the closing window requires; the holder stays unallied.');
|
|
game = advanceNexusTleilaxuSecretAlly(game,
|
|
state => state.nexusCards?.phase?.stage === 'drawing', actions);
|
|
assert.equal(game.turn, 2);
|
|
assert.equal(game.phase, 1);
|
|
const phase = game.nexusCards!.phase!;
|
|
assert.deepEqual(phase.eligible, [owner], 'Only the unallied holder receives the closing opportunity');
|
|
assert.deepEqual(phase.done, []);
|
|
const cards = game.nexusCards!.cards!;
|
|
assert.equal(cards.hands[owner], null, 'No Nexus hand is granted before the real draw');
|
|
const index = cards.deck.indexOf('tleilaxu');
|
|
assert.ok(index >= 0, 'The twelve-card Nexus inventory still holds the physical Tleilaxu singleton');
|
|
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
|
staging.push('Ordered the original undealt Tleilaxu Nexus singleton before the real closing draw; no hand is granted.');
|
|
const beforeDraw = structuredClone(game);
|
|
const draw: NexusTleilaxuSecretAllyStep = { actor: owner, action: { type: 'nexusCardChoice',
|
|
turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } };
|
|
actions.push(structuredClone(draw));
|
|
game = applyAction(game, owner, draw.action);
|
|
assert.equal(game.nexusCards!.cards!.hands[owner], 'tleilaxu',
|
|
'The real closing draw puts the physical Tleilaxu card in the holder hand');
|
|
assert.equal(game.nexusCards!.phase?.stage, 'complete');
|
|
const afterDraw = structuredClone(game);
|
|
nexusTleilaxuSecretAllyInventory(game);
|
|
|
|
// ---- Quiet turn-two Revival with the real held card.
|
|
game = advanceNexusTleilaxuSecretAllyToPhase(game, 4, actions);
|
|
assert.equal(game.turn, 2);
|
|
assert.equal(game.phase, 4);
|
|
assert.ok(nexusTleilaxuSecretAllyClean(game), 'The card needs the quiet Revival window');
|
|
assert.equal(game.nexusCards!.phase?.stage, 'complete');
|
|
const holder = nexusTleilaxuSecretAllyPlayer(game, owner);
|
|
const dead = holder.leaders.find(leader => leader.dead);
|
|
assert.equal(dead?.id, deadLeader.id);
|
|
assert.equal(holder.tanks, 8, 'The real Tanks custody survives Movement, Battle and Collection');
|
|
const offer = tleilaxuSecretAllyOptions(game, holder);
|
|
assert.equal(offer.blocked, null, 'The physical state must support the owner-only offer');
|
|
assert.deepEqual(offer.leaders.map(leader => leader.id), [deadLeader.id]);
|
|
assert.equal(offer.maxForces, 5);
|
|
assert.equal(offer.maxElite, 0, 'This faction owns no typed counters');
|
|
const nexusEvent = tleilaxuSecretAllyEvent(game, holder);
|
|
assert.equal(offer.event, nexusEvent);
|
|
nexusTleilaxuSecretAllyInventory(game);
|
|
|
|
const deadLeaderFact: NexusTleilaxuSecretAllyDeadLeader = {
|
|
id: deadLeader.id, name: deadLeader.name, deaths: deadLeader.deaths,
|
|
};
|
|
const scenario = {
|
|
code: game.code,
|
|
advanced: game.advanced,
|
|
turn: game.turn,
|
|
phase: game.phase,
|
|
owner, opponent, partner,
|
|
ownerFaction: holder.faction,
|
|
nexusCard: game.nexusCards!.cards!.hands[owner],
|
|
nexusDeckCount: game.nexusCards!.cards!.deck.length,
|
|
event: nexusEvent,
|
|
deadLeader: { ...deadLeaderFact },
|
|
deadForces: { normal: holder.tanks, elite: holder.elites?.tanks ?? 0 },
|
|
seats: game.players.map(player => ({ id: player.id, faction: player.faction, ally: player.ally,
|
|
spice: player.spice, tanks: player.tanks, reserves: player.reserves, revived: player.revived })),
|
|
offer: { blocked: offer.blocked, maxForces: offer.maxForces, maxElite: offer.maxElite,
|
|
leaders: offer.leaders.map(leader => leader.id) },
|
|
action: { type: 'nexusTleilaxuRevival', event: nexusEvent,
|
|
leader: deadLeader.id, amount: offer.maxForces, elite: 0 },
|
|
staging: [...staging],
|
|
};
|
|
assert.ok(game.spiceDeck.length > 0, 'The real spice deck keeps its remaining undealt cards');
|
|
return { initial, afterSetup, beforeShipment, shipment, beforeBattle, battleActions,
|
|
afterBattle, beforeBlow, beforeDraw, draw, afterDraw, game, owner, opponent, partner,
|
|
ownerFaction: holder.faction, deadLeader: deadLeaderFact, nexusEvent, actions, staging, scenario };
|
|
});
|
|
}
|