Preserve permanent Traitor markers through legal replacement recycling

This commit is contained in:
Quintine committed 2026-10-15 19:08:16 +11:00
1 parent 1cd7db7d0b
commit e265af3cb3
29 files changed
+1273 -68

No files matched your search

+20 -7
View File
@@ -61,6 +61,10 @@ export type MixedE3MoritaniOptions = {
startingCards?: Partial<Record<FactionId, readonly Card['kind'][]>>;
/** Desired physical opposing-faction card in the original undealt Traitor offer. */
traitorTarget?: string;
/** Original undealt reserve prefix, before any Face Dancer reshuffle. */
initialReserveTraitors?: readonly string[];
/** Actual Moritani closing Nexus draw, conserving the original undealt deck. */
nexusCard?: FactionId;
/** Reveal Moritani's own held Traitor normally during the loss battle. */
normalCall?: boolean;
seatIds?: readonly string[];
@@ -262,7 +266,7 @@ function traitorBlockStart(g: Game, actor: string): number {
}
/** Only the original Traitor shuffle is scoped, including the pre-deal Ecaz
* Loyalty draw when Ecaz itself is seated (five/six-native combined roster). */
function traitorRolls(g: Game, target: string, moritani: string): number[] {
function traitorRolls(g: Game, target: string, moritani: string, reserveCards?: readonly string[]): number[] {
const pool = traitorPool(g);
const ecazSeated = g.players.some(p => p.faction === 'ecaz');
const loyal = ecazSeated && g.advanced ? chooseEcazLoyalty(pool, 0xffffffff / 0x100000000) : null;
@@ -270,6 +274,15 @@ function traitorRolls(g: Game, target: string, moritani: string): number[] {
const desired = [...source];
const [at, to] = [desired.indexOf(target), traitorBlockStart(g, moritani)];
assert.ok(at >= 0 && to >= 0); [desired[at], desired[to]] = [desired[to], desired[at]];
if (reserveCards?.length) {
const reserveAt = g.players.filter(p => p.faction !== 'tleilaxu').length * 4;
assert.equal(new Set(reserveCards).size, reserveCards.length);
for (const [offset, card] of reserveCards.entries()) {
const from = desired.indexOf(card), slot = reserveAt + offset;
assert.ok(from >= 0 && slot < desired.length && card !== target);
[desired[from], desired[slot]] = [desired[slot], desired[from]];
}
}
return [...(loyal ? [0xffffffff] : []), ...permutation(source, desired)];
}
/** One requested skill per faction, each genuinely offered; the rest is the
@@ -414,7 +427,7 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {})
const spiceCount = spiceDeck(g.expansions.includes('ix')).length +
(g.discoveryEnabled ? DISCOVERY_SPICE_CARDS.length + 1 : 0);
rolls.push(...identity(spiceCount - 1));
if (!skills) rolls.push(...traitorRolls(g, target, moritani));
if (!skills) rolls.push(...traitorRolls(g, target, moritani, options.initialReserveTraitors));
g = lottery(rolls, initialize);
}
}
@@ -445,7 +458,7 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {})
if (Object.keys(g.leaderSkills!.offers).length === 1) {
// Original order: the chosen card returns to the physical deck, then the
// real Traitor deal (and pre-deal Ecaz Loyalty when Ecaz is seated).
const rolls = [...identity(g.leaderSkills!.deck.length), ...traitorRolls(g, target, moritani)];
const rolls = [...identity(g.leaderSkills!.deck.length), ...traitorRolls(g, target, moritani, options.initialReserveTraitors)];
actions.push(structuredClone(step));
g = lottery(rolls, () => applyAction(mixedE3MoritaniReload(g), step.actor, step.action));
} else g = stepMixedE3Moritani(g, step, actions);
@@ -518,7 +531,7 @@ export function orderMixedE3MoritaniSpice(g: Game, worm = false): void {
/** Real alliance change by two unallied seats, then Moritani's own genuine
* closing Nexus draw after both Advanced Spice piles have been dealt. */
export function closeMixedE3Moritani(f: MixedE3MoritaniSetup, state: Game):
export function closeMixedE3Moritani(f: MixedE3MoritaniSetup, state: Game, card: FactionId = 'moritani'):
{ alliance: Game; drawing: Game; draw: MixedE3MoritaniTransition; game: Game } | null {
let g = advanceMixedE3Moritani(state, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && mixedE3MoritaniClean(s), f.actions);
const others = g.players.filter(p => p.id !== f.moritani && !p.ally); assert.ok(others.length >= 2);
@@ -527,9 +540,9 @@ export function closeMixedE3Moritani(f: MixedE3MoritaniSetup, state: Game):
const alliance = mixedE3MoritaniReload(g);
g = advanceMixedE3Moritani(g, s => s.nexusCards?.phase?.stage === 'drawing' && mixedE3MoritaniClean(s), f.actions);
if (!g.nexusCards!.phase!.eligible.includes(f.moritani)) return null;
const cards = g.nexusCards!.cards!, at = cards.deck.indexOf('moritani'); assert.ok(at >= 0);
const cards = g.nexusCards!.cards!, at = cards.deck.indexOf(card); assert.ok(at >= 0);
cards.deck.unshift(cards.deck.splice(at, 1)[0]);
f.staging.push('Conserved undealt original Moritani Nexus singleton ordered before its real unallied closing draw.');
f.staging.push(`Conserved undealt original ${card} Nexus singleton ordered before its real unallied closing draw.`);
const drawing = mixedE3MoritaniReload(g), step: MixedE3MoritaniStep = { actor: f.moritani,
action: { type: 'nexusCardChoice', turn: g.turn, card: cards.hands[f.moritani] ?? null, choice: 'draw', ownRedraws: 0 } };
g = stepMixedE3Moritani(g, step, f.actions);
@@ -557,7 +570,7 @@ export function createMixedE3MoritaniProgramme(options: MixedE3MoritaniOptions =
result.firstMentat = firstMentat;
g = advanceMixedE3Moritani(g, s => s.phase === 1 && mixedE3MoritaniClean(s), f.actions);
orderMixedE3MoritaniSpice(g, true);
const closed = closeMixedE3Moritani(f, g);
const closed = closeMixedE3Moritani(f, g, options.nexusCard);
if (closed) { g = closed.game; result.alliance = closed.alliance; result.drawing = closed.drawing;
result.draws = [closed.draw]; }
g = advanceMixedE3Moritani(g, s => s.phase === 5 && mixedE3MoritaniClean(s), f.actions);
+2 -1
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import { CHOAM_AUDITOR_ID } from '../game/choam-auditor';
import { type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import {
advanceMixedE3Moritani, createMixedE3MoritaniProgramme, mixedE3MoritaniClean,
mixedE3MoritaniPlayer, mixedE3MoritaniReload, nextMixedE3MoritaniStep,
@@ -22,7 +23,7 @@ export type MoritaniAuditorWinnerFixture = MixedE3MoritaniProgramme & {
* battle selection are actual actions; no disc, card, wallet or army is staged.
* Auditor2 beats Vando1 at dial0, so its one genuinely shipped force survives. */
export function createMoritaniAuditorWinnerFixture(
options: { seatIds?: readonly string[]; auditorTarget?: boolean } = {},
options: { seatIds?: readonly string[]; auditorTarget?: boolean; nexusCard?: FactionId; initialReserveTraitors?: readonly string[] } = {},
): MoritaniAuditorWinnerFixture {
const f = createMixedE3MoritaniProgramme({
rules: 'advanced', families: ['choam'], skills: false,
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import type { Action, Game } from '../game/engine';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import {
advanceMixedE3Moritani, createMixedE3MoritaniProgramme, mixedE3MoritaniClean,
mixedE3MoritaniReload, nextMixedE3MoritaniStep, settleMixedE3MoritaniBoundary,
stageMixedE3MoritaniLoss, stepMixedE3Moritani,
} from './fixture-mixed-e3-moritani-nexus';
import type {
MixedE3MoritaniBattle, MixedE3MoritaniProgramme, MixedE3MoritaniStep, MixedE3MoritaniTransition,
} from './fixture-mixed-e3-moritani-nexus';
export type MoritaniHeroReplacementFixture = MixedE3MoritaniProgramme & {
battle: MixedE3MoritaniBattle;
pending: Game;
reveal: MixedE3MoritaniTransition;
/** Immediately after the actual native target reveal, before battle cleanup. */
revealed: Game;
/** Original Collection boundary, before the automatic Mentat replacement. */
preMentat: Game;
};
/** Fixed own-seat aftermath choices: retain used cards and choose a legal
* physical casualty option. Never calls a normal Traitor or stages custody. */
export function nextMoritaniHeroReplacementStep(g: Game): MixedE3MoritaniStep | null {
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
const d = g.decision;
if (d?.kind === 'battleLosses') {
const elite = Math.max(...d.options.map(option => option.elite));
return { actor: d.player, action: { type: 'decision',
choice: d.options.findIndex(option => option.elite === elite) } };
}
if (d?.kind === 'battleCards')
return { actor: d.player, action: { type: 'decision', discard: [] } };
}
const next = nextMixedE3MoritaniStep(g);
if (next?.action.type === 'traitorCall') next.action.call = false;
return next;
}
export function moritaniHeroTargetAction(f: Pick<MoritaniHeroReplacementFixture, 'moritani' | 'target'>,
pending: Game): Action {
const d = pending.decision;
assert.ok(d?.kind === 'moritaniAssassinate' && d.player === f.moritani);
return { type: 'decision', event: d.event, card: f.target };
}
/** Original no-Skills Advanced E1/E3 play with native Ixians and classic seats.
* The shared producer selects only original undealt shuffle entropy, placing
* the single physical Ix Hero Traitor first in the original remaining reserve.
* There is no Tleilaxu seat or final Face Dancer reshuffle. Shared programme
* Spice/Nexus ordering conserves unplayed cards; no played Traitor reserve,
* hand, leader, wallet, army, battle or phase is edited here.
*
* Deliberately stops BEFORE the formerly failing Mentat transition, so the
* same builder supports failing-before API proof and actual GameTable play. */
export function createMoritaniHeroReplacementFixture(
options: { seatIds?: readonly string[] } = {},
): MoritaniHeroReplacementFixture {
const f = createMixedE3MoritaniProgramme({
rules: 'advanced', families: ['ix'], natives: { ix: 'ixians' }, skills: false,
homeworlds: false, discovery: false, tech: false, strongholds: false,
initialReserveTraitors: [CHEAP_HERO_TRAITOR], ...options,
});
assert.equal(f.nativeFaction, 'ixians');
assert.equal(f.afterSetup.traitorReserve![0], CHEAP_HERO_TRAITOR);
f.staging.push('Original undealt Traitor shuffle entropy places the canonical Ix Cheap Hero Traitor first in the remaining reserve; no played reserve reorder.');
const battle = stageMixedE3MoritaniLoss(f, f.game);
const boundary = settleMixedE3MoritaniBoundary(battle.revealed, null, f.actions);
assert.equal(boundary.kind, 'assassination');
const pending = mixedE3MoritaniReload(boundary.game);
const step = { actor: f.moritani, action: moritaniHeroTargetAction(f, pending) };
const revealed = stepMixedE3Moritani(mixedE3MoritaniReload(pending), step, f.actions);
const reveal = { before: mixedE3MoritaniReload(pending), step,
after: mixedE3MoritaniReload(revealed) };
let g = mixedE3MoritaniReload(revealed);
for (let n = 0; n < 240; n++) {
if (!g.battle && mixedE3MoritaniClean(g)) break;
const next = nextMoritaniHeroReplacementStep(g);
assert.ok(next, `Choose the original Ixians aftermath ${g.decision?.kind ?? g.phase}.`);
g = stepMixedE3Moritani(g, next, f.actions);
}
assert.ok(!g.battle && mixedE3MoritaniClean(g));
const preMentat = advanceMixedE3Moritani(g,
state => state.phase === 7 && mixedE3MoritaniClean(state), f.actions);
assert.equal(preMentat.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
assert.equal(preMentat.traitorReserve![0], CHEAP_HERO_TRAITOR);
return { ...f, battle, pending, reveal, revealed, preMentat };
}
/** Runs the original phase transition and automatic replacement, stopping at
* the first genuine Mentat Terror-placement control. May also start directly
* after the target reveal for GameTable/API continuation. */
export function finishMoritaniHeroReplacement(f: MoritaniHeroReplacementFixture,
state: Game = f.preMentat): Game {
let g = mixedE3MoritaniReload(state);
for (let n = 0; n < 500; n++) {
if (!g.response && !g.phaseOpening && g.decision?.kind === 'moritaniPlacement') return g;
const next = nextMoritaniHeroReplacementStep(g);
assert.ok(next, `Choose the original Hero-replacement continuation ${g.decision?.kind ?? g.phase}.`);
g = stepMixedE3Moritani(g, next, f.actions);
}
throw Error('The original Ixians battle did not reach Mentat replacement.');
}
@@ -0,0 +1,336 @@
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 };
}
+8 -4
View File
@@ -19,6 +19,7 @@ import {
finishNexusSpice,
orderNexusSpice,
} from './fixture-nexus-cards';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export function nexusTraitorFixture(
options: {
@@ -233,11 +234,14 @@ export function nexusTraitorBattle(g: Game, reveal = true): Game {
type: 'battlePreparationReady',
event: g.battle!.event,
});
// Consume the original absent-BG opportunity before native Prescience.
// When requested, leave that native power for the caller's actual use.
while (g.battle!.preparation && (reveal || g.battle!.preparation.kind === 'nexusVoice')) {
const step = nextStrongholdFactionsNativeStep(g);
assert.ok(step);
g = applyAction(g, step.actor, step.action);
}
if (!reveal) return g;
while (g.battle!.preparation)
g = applyAction(g, g.battle!.preparation.owner, {
type: 'declineBattlePower',
});
for (const id of [owner.id, target.id])
g = applyAction(g, id, {
type: 'battlePlan',
-13
View File
@@ -2,7 +2,6 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {applyAction,viewGame,initializeMoritaniAssassinateGameForAudit,createGame,newPlayer,joinGame,type Game} from '../game/engine';
import {DIFFICULTIES} from '../game/bot-profiles';
import {moritaniAssassinateSignature} from '../game/moritani-assassinate';
import {assassinationGame,stageAssassinationBattle,prepareAssassinationBattle,resolveAssassinationBattle,assassinationChoice,assassinationToMentat,assassinationPhysical,assassinationRejects,assassinationActions} from './moritani-assassinate-fixture';
import {advanceToNextStorm,finishToMovement} from './fixture-advanced-source';
@@ -121,11 +120,6 @@ void test('saved assassination receipts and cleanup obligations fail closed afte
const settled=assassinationToMentat(assassinationChoice(pending,'guild-1'));
const missing=structuredClone(settled);missing.moritaniAssassinate!.opportunities=[];
assert.throws(()=>viewGame(missing,'m'));
const wrongDraw=structuredClone(settled),receipt=wrongDraw.moritaniAssassinate!.opportunities[0];
receipt.replacement=wrongDraw.traitorReserve![0];
receipt.signature=moritaniAssassinateSignature(receipt);
wrongDraw.lastBattleContext!.moritaniAssassinate!.signature=receipt.signature;
assert.throws(()=>viewGame(wrongDraw,'m'),/replacement must stay/);
});
void test('the explicit preview refuses Basic and exceptional-faction lobbies without mutation',()=>{
@@ -166,16 +160,9 @@ void test('a private replacement may fund a later assassination against another
first.players=[first.players[0],first.players[2],first.players[1]];
const second=resolveAssassinationBattle(stageAssassinationBattle(first,'emperor-1'));
const revealed=assassinationChoice(second,'emperor-1');
const sameTurn=structuredClone(revealed),earlier=sameTurn.moritaniAssassinate!.opportunities[0];
earlier.turn=sameTurn.turn;earlier.signature=moritaniAssassinateSignature(earlier);
assert.throws(()=>viewGame(sameTurn,'m'),/replacement must stay/);
const final=assassinationToMentat(revealed);
assert.deepEqual(final.moritaniAssassinate!.opportunities.map(r=>r.stage),['replaced','replaced']);
const physical=assassinationPhysical(final);physical.forces.sort((a,b)=>a.id.localeCompare(b.id));inventory.forces.sort((a,b)=>a.id.localeCompare(b.id));
assert.deepEqual(physical,inventory);
for(const p of final.players)assert.deepEqual(viewGame(JSON.parse(JSON.stringify(final)),p.id),viewGame(final,p.id));
const bad=structuredClone(final),later=bad.moritaniAssassinate!.opportunities[1];
later.turn=bad.moritaniAssassinate!.opportunities[0].turn;later.signature=moritaniAssassinateSignature(later);
bad.lastBattleContext!.moritaniAssassinate!.signature=later.signature;
assert.throws(()=>viewGame(bad,'m'),/replacement must stay/);
});
+1 -1
View File
@@ -153,7 +153,7 @@ void test('Harkonnen reveal retains its physical card until one distinct Mentat
assert.throws(() => validateMoritaniAssassinate(state, {
turn: 2, held: ['moritani-0'], removed: ['harkonnen-1'],
}));
for (const replacement of ['harkonnen-1', DUKE_VIDAL_ID, 'cheap-hero-traitor'])
for (const replacement of ['harkonnen-1', DUKE_VIDAL_ID])
assert.throws(() => validateMoritaniAssassinate({
...state, opportunities: [revised(revealed, { stage: 'replaced', replacement })],
}));
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { leaders } from '../game/cards';
import { applyAction, viewGame } from '../game/engine';
import {
moritaniAssassinateChoices, moritaniAssassinateTrigger, quoteMoritaniAssassinate,
} from '../game/moritani-assassinate';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import {
advanceMixedE3Moritani, assertMixedE3MoritaniPhysical, assertMixedE3MoritaniRejects,
mixedE3MoritaniClean, mixedE3MoritaniPlayer, mixedE3MoritaniTraitorCustody,
quoteMixedE3MoritaniBattle,
} from './fixture-mixed-e3-moritani-nexus';
import {
createMoritaniHeroReplacementFixture, finishMoritaniHeroReplacement, moritaniHeroTargetAction,
} from './fixture-moritani-hero-replacement';
void test('original native Ixians loss pays the held native target bounty and Mentat privately draws the one physical Hero without reviving its retired victim', () => {
const f = createMoritaniHeroReplacementFixture();
const target = leaders('ixians').find(leader => leader.id === f.target)!;
assert.ok(target);
assert.equal(f.afterSetup.traitorReserve![0], CHEAP_HERO_TRAITOR);
assert.equal(mixedE3MoritaniTraitorCustody(f.afterSetup).filter(card => card === CHEAP_HERO_TRAITOR).length, 1);
assertMixedE3MoritaniPhysical(f.afterSetup);
const arrival = f.battle.arrival!;
const beforeShipment = mixedE3MoritaniPlayer(arrival.before, f.native);
const afterShipment = mixedE3MoritaniPlayer(f.battle.arrangements, f.native);
assert.equal(beforeShipment.reserves - afterShipment.reserves, 1);
assert.equal((afterShipment.forces[f.location] ?? 0) - (beforeShipment.forces[f.location] ?? 0), 1);
assert.equal(beforeShipment.spice - afterShipment.spice, 1);
assert.equal(quoteMixedE3MoritaniBattle(f.battle.revealed).winner, f.native);
assert.notEqual(f.battle.enemyLeader, f.target);
assert.equal(f.pending.lastBattleContext!.result, 'normal');
assert.equal(f.pending.lastBattleContext!.winner, f.native);
const pendingReceipt = f.pending.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(pendingReceipt.faction, 'ixians');
assert.equal(pendingReceipt.opposingLeader, f.battle.enemyLeader);
const ownerChoice = viewGame(f.pending, f.moritani).moritaniAssassinate!.pending!;
assert.deepEqual(ownerChoice.cards.map(card => ({ card: card.card, bounty: card.bounty })),
[{ card: f.target, bounty: target.strength }]);
assert.deepEqual(viewGame(f.pending, f.native).moritaniAssassinate!.pending!.cards, []);
assert.equal(mixedE3MoritaniPlayer(f.revealed, f.moritani).spice,
mixedE3MoritaniPlayer(f.pending, f.moritani).spice + target.strength);
assert.equal(mixedE3MoritaniPlayer(f.revealed, f.native).spice,
mixedE3MoritaniPlayer(f.pending, f.native).spice);
const killed = mixedE3MoritaniPlayer(f.revealed, f.native).leaders.find(leader => leader.id === f.target)!;
assert.equal(killed.dead, true);
assert.equal(killed.deaths,
mixedE3MoritaniPlayer(f.pending, f.native).leaders.find(leader => leader.id === f.target)!.deaths + 1);
assert.ok(mixedE3MoritaniPlayer(f.preMentat, f.moritani).traitors.includes(f.target),
'The revealed victim stays physically held and reserved until original Mentat.');
assert.equal(f.preMentat.traitorReserve![0], CHEAP_HERO_TRAITOR);
assertMixedE3MoritaniPhysical(f.preMentat);
const replaced = finishMoritaniHeroReplacement(f);
assert.equal(replaced.phase, 8);
assert.equal(replaced.turn, f.preMentat.turn);
const receipt = replaced.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced');
assert.equal(receipt.event, pendingReceipt.event);
assert.equal(receipt.card, f.target);
assert.equal(receipt.replacement, CHEAP_HERO_TRAITOR);
assert.equal(receipt.bounty, target.strength);
assert.deepEqual(replaced.traitorReserve, f.preMentat.traitorReserve!.slice(1));
const hand = mixedE3MoritaniPlayer(replaced, f.moritani).traitors;
assert.equal(hand.filter(card => card === CHEAP_HERO_TRAITOR).length, 1);
assert.equal(hand.includes(f.target), false);
assert.equal(replaced.traitorReserve!.includes(f.target), false);
assert.equal(replaced.players.some(player => player.traitors.includes(f.target) ||
player.traitorChoices.includes(f.target) || player.faceDancers?.some(card => card.leader === f.target)), false);
assert.deepEqual(mixedE3MoritaniTraitorCustody(replaced), mixedE3MoritaniTraitorCustody(f.afterSetup));
assertMixedE3MoritaniPhysical(replaced);
for (const player of replaced.players) {
const publicHistory = viewGame(replaced, player.id).moritaniAssassinate!.history.at(-1)!;
assert.equal(publicHistory.card, f.target);
assert.equal(publicHistory.stage, 'replaced');
assert.equal(Object.hasOwn(publicHistory, 'replacement'), false);
}
const ownerView = viewGame(replaced, f.moritani);
assert.ok(ownerView.players.find(player => player.id === f.moritani)!.traitors?.includes(CHEAP_HERO_TRAITOR));
const rivalView = viewGame(replaced, f.native);
assert.equal(rivalView.players.find(player => player.id === f.moritani)!.traitors, undefined);
const beforeReplay = structuredClone(replaced);
assert.throws(() => applyAction(replaced, f.moritani, moritaniHeroTargetAction(f, f.pending)));
assert.deepEqual(replaced, beforeReplay);
const nextTurn = advanceMixedE3Moritani(replaced,
state => state.turn === replaced.turn + 1 && state.phase === 1 && mixedE3MoritaniClean(state), f.actions);
assert.deepEqual(nextTurn.moritaniAssassinate!.opportunities, replaced.moritaniAssassinate!.opportunities);
assert.deepEqual(nextTurn.traitorReserve, replaced.traitorReserve);
assert.deepEqual(mixedE3MoritaniPlayer(nextTurn, f.moritani).traitors, hand);
assert.equal(mixedE3MoritaniPlayer(nextTurn, f.native).leaders.find(leader => leader.id === f.target)!.dead, true);
assertMixedE3MoritaniPhysical(nextTurn);
});
void test('the canonical Hero replacement remains a Traitor identity, never a physical battle disc or native-faction assassination target', () => {
const f = createMoritaniHeroReplacementFixture();
for (const actor of [f.moritani, f.native])
assertMixedE3MoritaniRejects(structuredClone(f.battle.arrangements), actor,
{ type: 'battlePlan', leader: CHEAP_HERO_TRAITOR, dial: 0, support: 0 });
const replaced = finishMoritaniHeroReplacement(f);
assert.equal(replaced.players.flatMap(player => player.leaders).some(leader => leader.id === CHEAP_HERO_TRAITOR), false);
assert.equal(viewGame(replaced, f.moritani).allLeaders.some(leader => leader.id === CHEAP_HERO_TRAITOR), false);
// Pure identity guard with the ACTUALLY DRAWN private hand and the original
// choice receipt: no game/hand/phase is staged to manufacture another battle.
const state = f.pending.moritaniAssassinate!;
const receipt = state.opportunities.at(-1)!;
const context = { state, receipt, held: mixedE3MoritaniPlayer(replaced, f.moritani).traitors,
normallyRevealed: [], leaders: mixedE3MoritaniPlayer(f.pending, f.native).leaders };
assert.deepEqual(moritaniAssassinateChoices(context).cards, []);
assert.throws(() => quoteMoritaniAssassinate(context, CHEAP_HERO_TRAITOR));
assert.equal(moritaniAssassinateTrigger({ advanced: true, owner: f.moritani,
ownerFaction: 'moritani', opponent: f.native, faction: 'ixians', event: receipt.event,
turn: receipt.turn, territory: receipt.territory, opposingLeader: CHEAP_HERO_TRAITOR,
opposingLeaderSurvived: true, normalBattle: true, lostBattle: true, traitorCalled: false }), false);
assert.ok(mixedE3MoritaniPlayer(replaced, f.moritani).traitors.includes(CHEAP_HERO_TRAITOR));
assertMixedE3MoritaniPhysical(replaced);
});
+118
View File
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { leaders } from '../game/cards';
import { viewGame, type Action } from '../game/engine';
import {
assertMixedE3MoritaniPhysical, assertMixedE3MoritaniRejects, mixedE3MoritaniPlayer,
quoteMixedE3MoritaniBattle,
} from './fixture-mixed-e3-moritani-nexus';
import {
createMoritaniReplacementExchangeFixture, createMoritaniReplacementRedrawProgramme,
finishMoritaniReplacementExchange, moritaniReplacementDrawStep,
} from './fixture-moritani-replacement-exchange';
void test('an original Harkonnen Secret Ally return gives the historical Mentat replacement back to the physical deck without disturbing its public receipt', () => {
const f = createMoritaniReplacementExchangeFixture();
assertMixedE3MoritaniPhysical(f.ready);
assert.deepEqual(mixedE3MoritaniPlayer(f.ready, f.moritani).traitors, [f.historicalReplacement]);
const own = viewGame(f.ready, f.moritani);
assert.equal(own.nexusCards!.card, 'harkonnen');
assert.deepEqual(own.nexusTraitors!.pending, null);
assert.equal(own.nexusTraitors!.offer!.draw, 2);
assert.equal(own.nexusTraitors!.offer!.blocked, null);
const result = finishMoritaniReplacementExchange(f);
const exchange = result.exchanged.nexusTraitorExchanges!.at(-1)!;
assert.equal(exchange.stage, 'complete');
assert.deepEqual(exchange.drawn, [...f.drawn]);
assert.deepEqual([...exchange.returned].sort(), [...result.returnCards].sort());
assert.ok(result.returnCards.includes(f.historicalReplacement),
'The owner really gave the historical replacement back.');
assert.deepEqual(exchange.before, [f.historicalReplacement]);
assert.equal(result.exchanged.traitorReserve![0], f.historicalReplacement,
'The returned historical replacement is a physical reserve card again.');
assert.deepEqual(mixedE3MoritaniPlayer(result.exchanged, f.moritani).traitors, [f.guildTraitor],
'Only the real newly drawn Traitor Card stays in the owner hand.');
assert.equal(result.exchanged.traitorReserve!.includes(f.retiredVictim), false);
for (const player of result.exchanged.players)
assert.equal(player.traitors.includes(f.retiredVictim), false);
const receipt = result.exchanged.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced');
assert.equal(receipt.replacement, f.historicalReplacement);
assert.deepEqual({ event: receipt.event, card: receipt.card, replacement: receipt.replacement,
signature: receipt.signature }, f.history,
'The recorded public victim, its private replacement and the receipt signature must not be rewritten.');
assert.equal(result.exchanged.turn, f.ready.turn);
assert.equal(result.exchanged.phase, 8);
assert.equal(result.exchanged.nexusTraitorPending ?? null, null);
assert.deepEqual(result.exchanged.decision, f.ready.decision,
'The owner keeps its suspended original Mentat placement after returning physical cards.');
assert.equal(result.exchanged.nexusCards!.cards!.discard.filter(card => card === 'harkonnen').length, 1);
assertMixedE3MoritaniPhysical(result.exchanged);
});
void test('the physical return stays owner-only and cannot be replayed against the real custody', () => {
const f = createMoritaniReplacementExchangeFixture();
const guild = f.seats.guild!;
const rival = viewGame(f.ready, guild).nexusTraitors!;
assert.equal(rival.offer, null);
assert.equal(rival.pending, null);
assertMixedE3MoritaniRejects(f.ready, guild, f.draw);
const drawn = moritaniReplacementDrawStep(f);
const pending = viewGame(drawn.after, f.moritani).nexusTraitors!.pending!;
assert.deepEqual(pending.choices.map(choice => choice.id), [f.historicalReplacement, ...f.drawn]);
assert.deepEqual(mixedE3MoritaniPlayer(drawn.after, f.moritani).traitors,
[f.historicalReplacement, ...f.drawn]);
const rivalPending = viewGame(drawn.after, guild).nexusTraitors!.pending!;
assert.equal(rivalPending.event, pending.event);
assert.deepEqual(rivalPending.choices, []);
const giveBack: Action = { type: 'nexusTraitorReturn', event: pending.event,
cards: [f.historicalReplacement, f.guildTraitor] };
assertMixedE3MoritaniRejects(drawn.after, guild, giveBack);
assertMixedE3MoritaniRejects(drawn.after, f.moritani,
{ type: 'nexusTraitorReturn', event: pending.event,
cards: [f.historicalReplacement, f.historicalReplacement] });
assertMixedE3MoritaniRejects(drawn.after, f.moritani, f.draw);
});
void test('a later original battle loss lets the next Mentat draw the same physical card again while the older Nexus history stays unchanged', () => {
const programme = createMoritaniReplacementRedrawProgramme();
const f = programme.fixture;
const guild = f.seats.guild!;
assertMixedE3MoritaniPhysical(programme.exchanged);
const historical = structuredClone(programme.exchanged.nexusTraitorExchanges);
assert.equal(quoteMixedE3MoritaniBattle(programme.revealed).winner, guild,
'The real second loss must be a surviving Guild win.');
const receipts = programme.redrawn.moritaniAssassinate!.opportunities;
assert.equal(receipts.length, 2);
const [first, second] = receipts;
assert.equal(first.stage, 'replaced');
assert.equal(second.stage, 'replaced');
assert.equal(second.turn, 3);
assert.equal(second.card, f.guildTraitor, 'The second public marker retires the held Guild Traitor.');
assert.equal(second.replacement, first.replacement,
'The later original Mentat replacement may be the very card the older Nexus history returned.');
assert.equal(second.replacement, f.historicalReplacement);
assert.equal(second.bounty, leaders('guild').find(leader => leader.id === f.guildTraitor)!.strength);
assert.equal(mixedE3MoritaniPlayer(programme.redrawn, guild).leaders
.find(leader => leader.id === f.guildTraitor)!.dead, true);
assert.deepEqual(mixedE3MoritaniPlayer(programme.redrawn, f.moritani).traitors, [f.historicalReplacement],
'The same physical card is really held again.');
assert.equal(programme.redrawn.traitorReserve!.includes(f.historicalReplacement), false);
assert.equal(programme.redrawn.traitorReserve!.includes(f.guildTraitor), false);
assert.equal(programme.redrawn.traitorReserve!.includes(f.retiredVictim), false);
for (const player of programme.redrawn.players)
for (const retired of [f.retiredVictim, f.guildTraitor])
assert.equal(player.traitors.includes(retired), false);
assert.deepEqual(programme.redrawn.nexusTraitorExchanges, historical,
'A later public marker must not rewrite or reinterpret the earlier Nexus exchange history.');
assert.deepEqual({ event: first.event, card: first.card, replacement: first.replacement,
signature: first.signature }, f.history);
assertMixedE3MoritaniPhysical(programme.redrawn);
});
+6
View File
@@ -15,6 +15,7 @@ import {
nexusTraitorBattle,
} from './fixture-nexus-traitors';
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
function fixture(
options: {
@@ -121,6 +122,11 @@ function actuallyReveal(): Game {
type: 'battlePreparationReady',
event: g.battle!.event,
});
while (g.battle!.preparation) {
const step = nextStrongholdFactionsNativeStep(g);
assert.ok(step);
g = applyAction(g, step.actor, step.action);
}
for (const [id, disc] of [
['q', leader.id],
['r', opposing.id],
+72
View File
@@ -225,6 +225,78 @@ void test('changed whole-batch history, source statuses, original draw order and
assert.deepEqual(corrupt, before);
}
});
void test('permanently set-aside cards are never drawn, returned or resurrected as Face Dancer replacements', () => {
const state = fixture();
const setAside = state.reserve.shift()!;
state.removed = [setAside];
const before = reload(state);
const result = replaceNexusFaceDancers(state, universe, event, () => 0.999);
assert.deepEqual(state, before);
assert.equal(result.receipt.drawn.includes(setAside), false);
assert.equal(result.state.reserve.includes(setAside), false);
assert.equal(
result.state.players[0].faceDancers!.some((c) => c.leader === setAside),
false,
);
assert.deepEqual(result.state.removed, [setAside]);
validateNexusTraitorSnapshot(result.state, universe);
validateNexusFaceDancerHistory(universe, reload(result.receipt));
});
void test('replacement history validates its recorded permanent zone and rejects a rewritten one', () => {
const state = fixture();
state.removed = [state.reserve.shift()!];
const receipt = replaceNexusFaceDancers(state, universe, event, () => 0.999).receipt;
validateNexusFaceDancerHistory(universe, reload(receipt));
for (const mutate of [
(r: NexusFaceDancerReceipt) => {
r.source.removed = [];
},
(r: NexusFaceDancerReceipt) => {
r.source.removed!.push(r.drawn[0]);
},
(r: NexusFaceDancerReceipt) => {
delete (r.source as Partial<NexusTraitorSnapshot>).removed;
},
(r: NexusFaceDancerReceipt) => {
r.replaced = [...r.replaced, r.source.removed![0]];
},
]) {
const corrupt = reload(receipt);
mutate(corrupt);
const before = reload(corrupt);
assert.throws(() => validateNexusFaceDancerHistory(universe, corrupt));
assert.deepEqual(corrupt, before);
}
});
void test('a permanent zone that duplicates reserve or held Face Dancers or omits physical cards is rejected', () => {
for (const mutate of [
(s: NexusTraitorSnapshot) => {
s.removed = [s.reserve[0]];
},
(s: NexusTraitorSnapshot) => {
s.removed = [s.players[0].faceDancers![0].leader];
},
(s: NexusTraitorSnapshot) => {
s.reserve.pop();
s.removed = ['foreign-identity'];
},
(s: NexusTraitorSnapshot) => {
s.removed = [s.reserve.pop()!];
s.players[1].traitors.pop();
},
]) {
const state = fixture();
mutate(state);
const before = reload(state);
assert.throws(() => validateNexusTraitorSnapshot(state, universe));
assert.throws(() =>
replaceNexusFaceDancers(state, universe, event, () => {
throw Error('Corrupt census must not shuffle');
}),
);
assert.deepEqual(state, before);
}
});
void test('corrupt physical census and invalid event metadata reject without invoking shuffle', () => {
for (const mutate of [
(s: NexusTraitorSnapshot) => {
+38
View File
@@ -379,6 +379,44 @@ void test('bounded prototype rejects simultaneous replacement obligations but re
assert.throws(() => validateHarkonnenBetrayalReplacements(history, second.cursor, [secondDrawn]), HarkonnenBetrayalError);
});
void test('permanent set-aside custody survives Betrayal serialization and can never fund its replacement draw', () => {
const ctx = context();
const setAside = ctx.traitors.reserve.shift()!;
ctx.traitors.removed = [setAside];
const native = authority(ctx);
const source = createHarkonnenBetrayalSource(ctx, native, parent);
validateHarkonnenBetrayalSource(ctx, structuredClone(source), native, parent);
const use = closeHarkonnenBetrayalSource(source, 'use', 'a', initialHarkonnenBetrayalCursor());
const replacement = createHarkonnenBetrayalReplacement(use.receipt);
validateHarkonnenBetrayalReplacement(replacement, [use.receipt], use.cursor, ctx.universe);
assert.throws(() => drawHarkonnenBetrayalReplacement(replacement, [use.receipt], use.cursor,
setAside, ctx.universe), HarkonnenBetrayalError);
const next = later(ctx), nextNative = authority(next);
const nextSource = createHarkonnenBetrayalSource(next, nextNative, 'later-native-set-aside-parent');
const pass = closeHarkonnenBetrayalSource(nextSource, 'pass', null, use.cursor);
const history = [use.receipt, pass.receipt];
validateHarkonnenBetrayalHistory(history, pass.cursor, next.universe);
validateHarkonnenBetrayalReplacements(history, pass.cursor, [replacement], next.universe);
const recycled = next.traitors.reserve[0];
const drawn = drawHarkonnenBetrayalReplacement(replacement, history, pass.cursor, recycled, next.universe);
assert.equal(drawn.status, 'drawn');
assert.equal(replacement.status, 'due');
});
void test('Betrayal live custody keeps the permanent zone inside one complete canonical census', () => {
const ctx = context();
const setAside = ctx.traitors.reserve.shift()!;
ctx.traitors.removed = [setAside];
const native = authority(ctx);
const source = createHarkonnenBetrayalSource(ctx, native, parent);
assert.deepEqual(source.required, ['a', 'e']);
for (const variant of ['duplicate', 'missing', 'foreign'] as const) {
const changed = structuredClone(ctx);
if (variant === 'duplicate') changed.traitors.reserve.push(setAside);
if (variant === 'missing') changed.traitors.removed = [];
if (variant === 'foreign') changed.traitors.removed = ['foreign-identity'];
assert.throws(() => createHarkonnenBetrayalSource(changed, native, parent));
}
});
void test('native attempt history serialization stays linear with nonrecursive current and previous heads', () => {
const ctx = context(), history: HarkonnenBetrayalReceipt[] = [];
let cursor = initialHarkonnenBetrayalCursor(), halfway = 0;
+121
View File
@@ -309,6 +309,127 @@ void test('pending state binds original reserve order, owner augmented hand and
);
}
});
void test('a permanent set-aside zone keeps its cards in the printed universe but out of reserve, hands and returns', () => {
const state = fixture();
state.reserve.splice(state.reserve.indexOf('e'), 1);
state.removed = ['e'];
const before = reload(state);
const begun = beginNexusTraitorExchange(state, universe, input());
assert.deepEqual(state, before);
assert.deepEqual(begun.exchange.drawn, ['f']);
assert.equal(
nexusTraitorReturnChoices(begun.state, universe, begun.exchange).includes('e'),
false,
);
const done = finishNexusTraitorExchange(
begun.state,
universe,
begun.exchange,
['f'],
() => 0.999,
);
assert.deepEqual(done.state.reserve, ['g', 'h', 'f']);
assert.deepEqual(done.state.removed, ['e']);
assert.equal(done.state.reserve.includes('e'), false);
validateNexusTraitorHistory(universe, reload(done.exchange));
for (const mutate of [
(s: NexusTraitorSnapshot) => {
s.reserve.push('e');
},
(s: NexusTraitorSnapshot) => {
s.removed = ['e', 'e'];
},
(s: NexusTraitorSnapshot) => {
s.removed = ['unknown'];
},
(s: NexusTraitorSnapshot) => {
s.removed = [];
},
(s: NexusTraitorSnapshot) => {
s.players[1].traitors = ['d', 'e'];
},
]) {
const broken = reload(state);
mutate(broken);
const unchanged = reload(broken);
assert.throws(() => validateNexusTraitorSnapshot(broken, universe));
assert.deepEqual(broken, unchanged);
}
});
void test('a pending exchange cannot rewrite the permanent card zone even when the current census is conserved', () => {
const paved = fixture();
paved.reserve.splice(paved.reserve.indexOf('e'), 1);
paved.removed = ['e'];
const begun = beginNexusTraitorExchange(paved, universe, input());
for (const mutate of [
(s: NexusTraitorSnapshot) => {
const moved = s.reserve[0];
s.reserve[0] = s.removed![0];
s.removed = [moved];
},
(s: NexusTraitorSnapshot) => {
delete (s as Partial<NexusTraitorSnapshot>).removed;
s.reserve.unshift('e');
},
]) {
const state = reload(begun.state);
mutate(state);
validateNexusTraitorSnapshot(state, universe);
assert.throws(() =>
validateNexusTraitorExchange(state, universe, begun.exchange),
);
}
});
void test('completed exchange history keeps its original zone while later retirement permits another legal exchange', () => {
const state = fixture();
state.reserve.splice(state.reserve.indexOf('e'), 1);
state.removed = ['e'];
const begun = beginNexusTraitorExchange(state, universe, input());
const done = finishNexusTraitorExchange(
begun.state,
universe,
begun.exchange,
['f'],
() => 0.999,
);
const later = reload(done.state);
later.removed!.push(later.reserve.shift()!);
assert.deepEqual(later.removed, ['e', 'g']);
assert.equal(later.reserve.includes('e'), false);
validateNexusTraitorSnapshot(later, universe);
validateNexusTraitorHistory(universe, reload(done.exchange));
assert.deepEqual(done.exchange.source.removed, ['e']);
assert.throws(() =>
validateNexusTraitorExchange(later, universe, done.exchange),
);
const next = beginNexusTraitorExchange(later, universe, { ...input(), event: 'after-second-marker', turn: 3 });
const continued = finishNexusTraitorExchange(next.state, universe, next.exchange, [next.exchange.drawn[0]], () => 0.999);
assert.deepEqual(continued.state.removed, ['e', 'g']);
assert.equal(continued.state.reserve.some(card => later.removed!.includes(card)), false);
validateNexusTraitorHistory(universe, done.exchange);
validateNexusTraitorHistory(universe, continued.exchange);
});
void test('an earlier completed history without a permanent zone is never reinterpreted against later markers', () => {
const begun = beginNexusTraitorExchange(fixture(), universe, input());
const done = finishNexusTraitorExchange(
begun.state,
universe,
begun.exchange,
['a'],
() => 0.999,
);
const later = reload(done.state);
later.removed = [later.reserve.shift()!];
validateNexusTraitorSnapshot(later, universe);
validateNexusTraitorHistory(universe, reload(done.exchange));
const next = beginNexusTraitorExchange(later, universe, { ...input(), event: 'after-first-marker', turn: 3 });
const continued = finishNexusTraitorExchange(next.state, universe, next.exchange, [next.exchange.drawn[0]], () => 0.999);
assert.equal(continued.state.reserve.includes(later.removed![0]), false);
assert.equal(continued.state.players.some(player => player.traitors.includes(later.removed![0]) ||
player.faceDancers?.some(card => card.leader === later.removed![0])), false);
validateNexusTraitorHistory(universe, done.exchange);
validateNexusTraitorHistory(universe, continued.exchange);
});
void test('signed completed history rejects alteration but remains valid after later legitimate card custody changes', () => {
const begun = beginNexusTraitorExchange(fixture(), universe, input());
const done = finishNexusTraitorExchange(
+31 -5
View File
@@ -119,7 +119,7 @@ void test('Tleilaxu Rihani normal-only inspection, lower-only exchange and pre-d
assert.deepEqual(declined.peeked, both.peeked);
assert.deepEqual(declined.drawn, []);
conserved(restore(declined));
assert.throws(() => chooseRihaniDraw(declined, universe, declined.state, true), /already been answered/);
assert.throws(() => chooseRihaniDraw(declined, universe, declined.state, true));
});
void test('Rihani cannot replace a revealed Face Dancer or return both newly drawn cards', () => {
@@ -127,7 +127,7 @@ void test('Rihani cannot replace a revealed Face Dancer or return both newly dra
const drawn = chooseRihaniDraw(offer, universe, offer.state, true);
for (const given of [universe[1], ...drawn.drawn]) {
const before = structuredClone(drawn);
assert.throws(() => finishRihani(drawn, universe, drawn.state, drawn.drawn[0], given, random), /unused Traitor/);
assert.throws(() => finishRihani(drawn, universe, drawn.state, drawn.drawn[0], given, random));
assert.deepEqual(drawn, before);
}
const allRevealed = snapshot();
@@ -152,13 +152,13 @@ void test('Rihani rejects status changes in old, drawn and retained Face Dancers
resigned(complete, (r) => { r.state.players[0].faceDancers![2].revealed = true; }),
resigned(drawn, (r) => { r.eligible.push(universe[1]); }),
];
for (const receipt of invalid) assert.throws(() => validateRihani(receipt, universe), /custody|drawn cards|returned card|unused cards/);
for (const receipt of invalid) assert.throws(() => validateRihani(receipt, universe));
const changedCurrent = structuredClone(drawn.state);
changedCurrent.players[0].faceDancers![1].revealed = false;
assert.throws(() => finishRihani(drawn, universe, changedCurrent, drawn.drawn[0], universe[0], random), /current Traitor custody/);
assert.throws(() => finishRihani(drawn, universe, changedCurrent, drawn.drawn[0], universe[0], random));
const duplicate = resigned(drawn, (r) => { r.state.reserve.push(r.drawn[0]); });
assert.throws(() => validateRihani(duplicate, universe), /census/);
assert.throws(() => validateRihani(duplicate, universe));
});
void test('ordinary Rihani receipts retain the existing used-Traitor exclusion and JSON transition', () => {
@@ -176,3 +176,29 @@ void test('ordinary Rihani receipts retain the existing used-Traitor exclusion a
assert.equal(Object.hasOwn(done.state.players[0], 'faceDancers'), false);
validateRihani(restore(done), universe, done.state);
});
void test('Rihani excludes permanently retired cards throughout peek, draw, return and recorded custody', () => {
const original = snapshot();
const retired = original.reserve.shift()!;
original.removed = [retired];
const offer = begin(true, true, original);
assert.equal(offer.peeked.includes(retired), false);
const pending = chooseRihaniDraw(offer, universe, offer.state, true);
assert.equal(pending.drawn.includes(retired), false);
const done = finishRihani(pending, universe, pending.state, pending.drawn[0], universe[0], random);
assert.deepEqual(done.state.removed, [retired]);
assert.equal(done.state.reserve.includes(retired), false);
assert.equal(done.state.players.some(p => p.traitors.includes(retired) ||
p.faceDancers?.some(card => card.leader === retired)), false);
assert.ok(done.state.reserve.includes(universe[0]));
assert.ok(done.state.players[0].faceDancers!.some(card => card.leader === pending.drawn[0] && !card.revealed));
conserved(done);
const rewritten = resigned(done, r => {
r.state.removed = [universe[0]];
r.state.reserve[r.state.reserve.indexOf(universe[0])] = retired;
});
validateNexusTraitorSnapshot(rewritten.state, universe);
const before = structuredClone(rewritten);
assert.throws(() => validateRihani(rewritten, universe));
assert.deepEqual(rewritten, before);
});