Files
Dune/tests/fixture-moritani-replacement-exchange.ts

337 lines
19 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { nexusTraitorDrawAction, nexusTraitorReturnAction } from '../game/nexus-traitor-options';
import {
advanceMixedE3Moritani, mixedE3MoritaniClean,
mixedE3MoritaniPlayer, mixedE3MoritaniReload,
orderMixedE3MoritaniSpice, quoteMixedE3MoritaniBattle, revealMixedE3MoritaniPlans,
settleMixedE3MoritaniBoundary, shipMixedE3Moritani, stepMixedE3Moritani,
type MixedE3MoritaniStep, type MixedE3MoritaniTransition,
} from './fixture-mixed-e3-moritani-nexus';
import {
createMoritaniAuditorWinnerFixture, finishMoritaniAuditorReplacement,
nextMoritaniAuditorWinnerStep, pendingMoritaniAuditorAssassination,
revealMoritaniAuditorTarget, type MoritaniAuditorWinnerFixture,
} from './fixture-moritani-auditor-winner';
/** Original undealt Traitor reserve prefix requested from the real initial
* shuffle: the card the original Mentat actually replaces the retired native
* CHOAM victim with, the real Guild Traitor retained by the Secret Ally draw,
* and the second card returned beside that replacement. Positions 16..18 of
* the original deal remain the undealt head after setup appends every unchosen
* offer, so the reserve head here is exactly this prefix without the drawn
* replacement. */
const RESERVE_PREFIX = ['emperor-0', 'guild-2', 'emperor-2'] as const;
/** Printed territories a genuine paid shipment may legally occupy. */
const EXCHANGE_SITES = [
'red_chasm', 'habbanya_ridge_sietch', 'sietch_tabr', 'hagga_basin', 'imperial_basin',
'arrakeen', 'carthag', 'false_wall_south', 'broken_land', 'south_mesa',
] as const;
export type MoritaniReplacementExchangeFixture = MoritaniAuditorWinnerFixture & {
/** The actual public face-up card the original Mentat retired from play. */
retiredVictim: string;
/** The private card that original Mentat actually drew and still holds. */
historicalReplacement: string;
/** A real Traitor Card of the loaded reserve head, drawn by Secret Ally. */
guildTraitor: string;
reservePrefix: readonly string[];
/** Original phase-8 window, retaining Moritani's optional placement while Nexus pauses it. */
ready: Game;
/** The genuine owner-only Secret Ally draw for that offer. */
draw: Action;
/** The two physical cards the original draw takes from the reserve head. */
drawn: readonly string[];
history: { event: string; card: string; replacement: string; signature: string };
};
/** Inverse of the engine's real Fisher-Yates reserve shuffle: the exact
* original randomness that lands one physical reserve in the requested
* layout. Directed only onto a shuffle this fixture itself just caused. */
function fisherRolls(source: readonly string[], desired: readonly string[]): number[] {
assert.deepEqual([...source].sort(), [...desired].sort());
const working = [...source], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i, 'The requested reserve layout is not a permutation of the shuffle.');
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
return rolls;
}
/** Runs one real action with the supplied original shuffle entropy, and proves
* that entropy was the only randomness the action consumed. */
function directedRolls<T>(rolls: readonly number[], operation: () => T): { value: T; consumed: number } {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (cursor < rolls.length && array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++];
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return { value: operation(), consumed: cursor }; }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Highest- or lowest-strength legal commitment for one combatant, chosen by
* trying the engine's own plan validator. Never assumes a fixed dial or budget. */
function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min'): Action {
const spice = mixedE3MoritaniPlayer(g, actor).spice;
const supports = prefer === 'max' ? Array.from({ length: spice + 1 }, (_, index) => spice - index) : [0];
const dials = prefer === 'max' ? Array.from({ length: 13 }, (_, index) => 12 - index) : [0];
const reasons = new Set<string>();
for (const support of supports) for (const dial of dials) {
const action: Action = { type: 'battlePlan', leader, dial, support };
try { applyAction(mixedE3MoritaniReload(g), actor, action); return action; }
catch (error) { reasons.add(error instanceof Error ? error.message : String(error)); }
}
throw Error(`No legal battle plan for ${actor} with ${leader} (spice ${spice}): ${[...reasons].join(' | ')}`);
}
/** First printed territory no original force occupies and whose storm-free
* sector can legally receive a real shipment. */
function freeMixedE3MoritaniLocation(g: Game): string {
for (const id of EXCHANGE_SITES) {
const occupied = g.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${id}:`)));
if (occupied) continue;
const sector = territory(id).sectors.find(candidate => candidate !== 0 && candidate !== g.storm);
if (sector === undefined) continue;
return `${id}:${sector}`;
}
throw Error('The original programme left no free printed territory for the second real shipment.');
}
function exchangeOffer(g: Game, owner: string) {
const offer = viewGame(g, owner).nexusTraitors?.offer;
assert.ok(offer, 'The owner must receive the real Harkonnen Nexus offer.');
assert.equal(offer.mode, 'secretAlly');
assert.equal(offer.blocked, null);
assert.equal(offer.draw, 2);
return offer;
}
/** Original Advanced CHOAM victory, reveal, Mentat retirement and private
* replacement. Secret Ally pauses the still-owned Moritani placement control;
* only original undealt reserve positions and the conserved Nexus draw are directed. */
export function createMoritaniReplacementExchangeFixture(
options: { seatIds?: readonly string[] } = {},
): MoritaniReplacementExchangeFixture {
const f = createMoritaniAuditorWinnerFixture({
nexusCard: 'harkonnen', initialReserveTraitors: RESERVE_PREFIX, seatIds: options.seatIds,
});
f.staging.push(
`Conserved undealt original Traitor reserve ordered as ${RESERVE_PREFIX.join(', ')} `
+ 'before the real deal; the Moritani closing draw remains the undealt harkonnen Nexus singleton.',
);
const pending = pendingMoritaniAuditorAssassination(f);
const reveal = revealMoritaniAuditorTarget(f, pending);
const replaced = finishMoritaniAuditorReplacement(f, reveal.after);
const receipt = replaced.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced', 'The original Mentat must retire the revealed card.');
assert.equal(receipt.card, f.target);
assert.equal(receipt.replacement, RESERVE_PREFIX[0],
'The original Mentat replacement must draw the reserve head, never the retired card.');
assert.deepEqual(mixedE3MoritaniPlayer(replaced, f.moritani).traitors, [RESERVE_PREFIX[0]]);
assert.equal(replaced.traitorReserve!.includes(f.target), false,
'A retired victim never re-enters the physical reserve.');
const ready = replaced;
assert.equal(ready.decision?.kind, 'moritaniPlacement');
assert.equal(ready.nexusTraitorPending ?? null, null);
const view = viewGame(ready, f.moritani);
const offer = exchangeOffer(ready, f.moritani);
assert.deepEqual(ready.traitorReserve!.slice(0, 2), RESERVE_PREFIX.slice(1),
'The original reserve head must hold the retained Guild Traitor and the second return.');
const draw = nexusTraitorDrawAction(view, offer.event, 'secretAlly');
assert.ok(draw, 'The actual owner must be able to play the real Secret Ally draw.');
return {
...f, retiredVictim: f.target, historicalReplacement: RESERVE_PREFIX[0],
guildTraitor: RESERVE_PREFIX[1], reservePrefix: RESERVE_PREFIX,
ready: mixedE3MoritaniReload(ready), draw, drawn: ready.traitorReserve!.slice(0, 2),
history: { event: receipt.event, card: receipt.card!, replacement: receipt.replacement!, signature: receipt.signature },
};
}
/** Owner-only draw at the real boundary; the physical draw is untouched by any
* shuffle, so only the two fixed reserve-head cards leave the deck. */
export function moritaniReplacementDrawStep(
f: MoritaniReplacementExchangeFixture, state: Game = f.ready,
): MixedE3MoritaniTransition {
const before = mixedE3MoritaniReload(state);
const offer = exchangeOffer(before, f.moritani);
const action = nexusTraitorDrawAction(viewGame(before, f.moritani), offer.event, 'secretAlly');
assert.ok(action, 'The real owner-only Secret Ally draw must stay legal.');
const step: MixedE3MoritaniStep = { actor: f.moritani, action };
return { before, step, after: stepMixedE3Moritani(before, step, f.actions) };
}
/** Owner-only return including the historical replacement, with the original
* return shuffle directed onto the fixture's own disclosed layout: the
* historical card returns first, the freshly drawn Guild Traitor is kept. */
export function moritaniReplacementReturnStep(
f: MoritaniReplacementExchangeFixture, drawn: Game, cards: readonly string[],
): { before: Game; step: MixedE3MoritaniStep; rolls: number[]; after: Game } {
const before = mixedE3MoritaniReload(drawn);
const view = viewGame(before, f.moritani);
const pendingReturn = view.nexusTraitors?.pending;
assert.ok(pendingReturn, 'The owner must hold the real pending Secret Ally return.');
const action = nexusTraitorReturnAction(view, pendingReturn.event, cards);
assert.ok(action, 'The exact owner-only physical return must stay legal.');
const base = [...before.traitorReserve!, ...cards];
const desired = [f.historicalReplacement, ...base.filter(card => card !== f.historicalReplacement)];
const rolls = fisherRolls(base, desired);
f.staging.push('Directed the fixture-caused original return shuffle so the returned historical replacement leads the reserve again.');
const step: MixedE3MoritaniStep = { actor: f.moritani, action };
const result = directedRolls(rolls, () => stepMixedE3Moritani(before, step, f.actions));
assert.equal(result.consumed, rolls.length,
'The original return shuffle must consume exactly the directed Fisher-Yates rolls.');
assert.deepEqual(result.value.traitorReserve, desired,
'The real return shuffle must land on the directed physical reserve.');
return { before, step, rolls, after: result.value };
}
/** The exact owner-only return action for API/UI smoke, keeping the freshly
* drawn Guild Traitor and giving the historical replacement back. The caller
* applies it with its own live randomness. */
export function moritaniReplacementReturnAction(
f: MoritaniReplacementExchangeFixture, drawn: Game,
): Action {
const view = viewGame(mixedE3MoritaniReload(drawn), f.moritani);
const pendingReturn = view.nexusTraitors?.pending;
assert.ok(pendingReturn, 'The owner must hold the real pending Secret Ally return.');
const cards = pendingReturn.choices.map(choice => choice.id).filter(id => id !== f.guildTraitor);
assert.equal(cards.length, 2);
assert.ok(cards.includes(f.historicalReplacement),
'The owner must be able to return the historical replacement.');
const action = nexusTraitorReturnAction(view, pendingReturn.event, cards);
assert.ok(action, 'The exact owner-only physical return must stay legal.');
return action;
}
/** Completes the real Secret Ally draw and return, keeping the freshly drawn
* Guild Traitor and giving the historical replacement back to the deck. */
export function finishMoritaniReplacementExchange(
f: MoritaniReplacementExchangeFixture, state: Game = f.ready,
): {
drawn: Game; exchanged: Game; returnCards: string[];
exchange: { before: Game; step: MixedE3MoritaniStep; after: Game };
} {
const draw = moritaniReplacementDrawStep(f, state);
assert.deepEqual(draw.after.traitorReserve, draw.before.traitorReserve!.slice(2),
'The draw removes exactly its two physical reserve-head cards.');
const action = moritaniReplacementReturnAction(f, draw.after);
const returnCards = action.cards as string[];
const back = moritaniReplacementReturnStep(f, draw.after, returnCards);
return { drawn: draw.after, exchanged: back.after, returnCards,
exchange: { before: back.before, step: back.step, after: back.after } };
}
export type MoritaniReplacementRedrawProgramme = {
fixture: MoritaniReplacementExchangeFixture;
exchanged: Game;
/** Which of the two real shipments arrived first in the live original order. */
arrivalOrder: 'moritani' | 'guild';
location: string;
arrival: MixedE3MoritaniTransition;
assault: MixedE3MoritaniTransition;
plans: MixedE3MoritaniStep[];
revealed: Game;
/** The real second native-equivalent assassination boundary. */
pending: Game;
reveal: MixedE3MoritaniTransition;
/** After the second original Mentat drew the same physical card again. */
redrawn: Game;
};
/** Stops as soon as one original Mentat replacement is on record, never at an
* intermediate owner window. */
function finishReceiptReplacement(f: MoritaniAuditorWinnerFixture, state: Game, event: string): Game {
let g = mixedE3MoritaniReload(state);
for (let n = 0; n < 600; n++) {
const receipt = g.moritaniAssassinate?.opportunities.find(r => r.event === event);
if (receipt?.stage === 'replaced') return g;
const next = nextMoritaniAuditorWinnerStep(g);
assert.ok(next, `Choose the original replacement aftermath ${g.decision?.kind ?? g.phase}.`);
g = stepMixedE3Moritani(g, next, f.actions);
}
throw Error('The original battle did not reach its Mentat replacement.');
}
/** Same source-clear original programme continued through the real turn-three
* storm, conserved unplayed Spice, two real paid shipments and a second
* original battle loss, so the next Mentat can draw the physical card the
* earlier Nexus return gave back. */
export function createMoritaniReplacementRedrawProgramme(
options: { seatIds?: readonly string[] } = {},
): MoritaniReplacementRedrawProgramme {
const fixture = createMoritaniReplacementExchangeFixture(options);
const exchanged = finishMoritaniReplacementExchange(fixture).exchanged;
const guildSeat = fixture.seats.guild;
assert.ok(guildSeat, 'The original classic roster must seat the Guild.');
// The turn-three deal is drawn from the unplayed Spice deck; fronting two
// real land cards is the same disclosed conserved ordering used at turn one.
orderMixedE3MoritaniSpice(exchanged);
let g = advanceMixedE3Moritani(exchanged,
s => s.turn === 3 && s.phase === 1 && mixedE3MoritaniClean(s), fixture.actions);
g = advanceMixedE3Moritani(g,
s => s.phase === 5 && (s.active === fixture.moritani || s.active === guildSeat) && mixedE3MoritaniClean(s),
fixture.actions);
const location = freeMixedE3MoritaniLocation(g);
const mover = g.active;
assert.ok(mover === fixture.moritani || mover === guildSeat,
'The live original movement order must open with one of the two combatants.');
const first: string = mover === guildSeat ? guildSeat : fixture.moritani;
const arrival = shipMixedE3Moritani(g, first, location, 1, 0, fixture.actions);
const other = mover === guildSeat ? fixture.moritani : guildSeat;
const assault = shipMixedE3Moritani(arrival.settled, other, location, 1, 0, fixture.actions);
fixture.staging.push(`Real paid turn-three shipments into the free printed location ${location}: `
+ `${first} first, then ${other}; no force count, card or wallet was staged.`);
const choices = (state: Game) => {
const quote = viewGame(state, state.host).battleChoices;
assert.equal(quote.length, 1, 'The two real shipments must create exactly one original battle pair.');
assert.deepEqual([quote[0].attacker, quote[0].defender].sort(),
[fixture.moritani, guildSeat].sort());
return quote[0];
};
const armed = advanceMixedE3Moritani(assault.settled, s => s.phase === 6 && mixedE3MoritaniClean(s), fixture.actions);
const choice = choices(armed);
const fought = stepMixedE3Moritani(armed, { actor: choice.chooser, action: {
type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, fixture.actions);
const entered = advanceMixedE3Moritani(fought, s => !!s.battle && mixedE3MoritaniClean(s) &&
!s.battle.preparation && s.battle.preLeader?.closed !== false, fixture.actions);
const own = mixedE3MoritaniPlayer(entered, fixture.moritani).leaders
.filter(leader => !leader.dead)
.sort((one, two) => one.strength - two.strength)[0];
const opposing = mixedE3MoritaniPlayer(entered, guildSeat).leaders
.filter(leader => !leader.dead && leader.id !== fixture.guildTraitor)
.sort((one, two) => two.strength - one.strength)[0];
assert.ok(own && opposing, 'The original deal must still supply the second real battle discs.');
const plans: MixedE3MoritaniStep[] = [
{ actor: fixture.moritani, action: legalPlan(entered, fixture.moritani, own.id, 'min') },
{ actor: guildSeat, action: legalPlan(entered, guildSeat, opposing.id, 'max') },
];
const revealed = revealMixedE3MoritaniPlans(entered, plans, fixture.actions);
assert.equal(quoteMixedE3MoritaniBattle(revealed).winner, guildSeat,
'The real Guild disc must survive the genuine second loss.');
const boundary = settleMixedE3MoritaniBoundary(revealed, null, fixture.actions);
assert.equal(boundary.kind, 'assassination');
const decision = boundary.game.decision;
assert.ok(decision?.kind === 'moritaniAssassinate' && decision.player === fixture.moritani);
const ownChoices = viewGame(boundary.game, fixture.moritani).moritaniAssassinate!.pending!;
assert.deepEqual(ownChoices.cards.map(entry => entry.card), [fixture.guildTraitor]);
const step: MixedE3MoritaniStep = { actor: fixture.moritani,
action: { type: 'decision', event: decision.event, card: fixture.guildTraitor } };
const reveal: MixedE3MoritaniTransition = { before: mixedE3MoritaniReload(boundary.game), step,
after: stepMixedE3Moritani(boundary.game, step, fixture.actions) };
const redrawn = finishReceiptReplacement(fixture, reveal.after,
reveal.after.moritaniAssassinate!.opportunities.at(-1)!.event);
return { fixture, exchanged, arrivalOrder: first === fixture.moritani ? 'moritani' : 'guild',
location, arrival, assault, plans, revealed, pending: boundary.game, reveal, redrawn };
}