Compose native Ecaz and Moritani Stronghold rule paths

This commit is contained in:
Quintine committed 2026-10-04 01:31:22 +10:00
1 parent 50808aed5e
commit 39e2954ab4
15 files changed
+1010 -91

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer,
viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { treacheryDeck } from '../game/cards';
import { strongholdControllers } from '../game/stronghold-cards';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { advanceEcazStronghold, ecazStrongholdFixture, ecazStrongholdSeat as seat,
finishEcazStronghold, openEcazStronghold, type EcazStrongholdFixture } from './fixture-ecaz-strongholds';
function freshLobby(advanced = true): Game {
let game = createGame('ECAZSTRONGHOLDGUARD', newPlayer('auth-ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
joinGame(game, newPlayer('auth-guild', 'Guild', 'guild'));
joinGame(game, newPlayer('auth-emperor', 'Emperor', 'emperor'));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
return game;
}
function reveal(fixture: EcazStrongholdFixture, lead: 'ecaz' | 'ally', options: {
variable?: number; opponentDial?: number; tie?: boolean; defense?: string;
} = {}) {
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const defense = options.defense ?? stageEcazOccupyCard(fixture.game, actor, c => c.kind === 'shield');
fixture.staging.push(`Conserved physical Shield ${defense} at selected ${actor} exposes the original winner-card boundary before Collection.`);
let game = openEcazStronghold(fixture, lead);
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
let own = seat(game, actor).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
let other = seat(game, fixture.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
if (options.tie) {
const matching = seat(game, actor).leaders.find(l => !l.dead && !l.usedAt &&
seat(game, fixture.opponent).leaders.some(o => !o.dead && !o.usedAt && o.strength === l.strength));
assert.ok(matching, 'The original faction leaders supply an actual equal-strength pair.');
own = matching;
other = seat(game, fixture.opponent).leaders.find(l => !l.dead && !l.usedAt && l.strength === own.strength)!;
}
const variable = options.variable ?? 3;
const before = structuredClone(game);
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variable,
support: variable, leader: own.id, defense });
const opponentDial = options.opponentDial ?? (options.tie ? profile.fixedEcazDial + variable : 0);
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: opponentDial,
support: opponentDial, leader: other.id });
assert.ok(game.battle?.revealed, 'Both original selected players seal real plans.');
const battle = game.battle!;
const side = (id: string): ResolutionCombatant => {
const player = seat(game, id), view = viewGame(game, id).battle!;
assert.ok(view.ownForces);
return { id, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand,
plan: battle.plans[id], leader: player.leaders.find(l => l.id === battle.plans[id].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[id] };
};
const publicBattle = viewGame(game, actor).battle!;
const quote = quoteBattleResolution({ advanced: true, turn: game.turn, territory: fixture.territory,
ecazOccupy: publicBattle.ecazOccupy!.profile!, aggressor: publicBattle.aggressor,
attacker: side(battle.attacker), defender: side(battle.defender),
voters: publicBattle.traitorVoters.map(id => ({ id, called: false, traitors: seat(game, id).traitors,
beneficiary: [battle.attacker, battle.defender].includes(id) ? id : actor })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
for (const voter of publicBattle.traitorVoters)
game = applyAction(game, voter, { type: 'traitorCall', call: false });
const afterBattle = structuredClone(game);
game = finishEcazStronghold(game);
return { game, afterBattle, before, actor, quote, ownLeader: own.id, opponentLeader: other.id };
}
void test('Advanced E3 Stronghold setup retains authenticated seats, exact deck and actual shared end-Mentat custody', () => {
const admitted = freshLobby();
const fixture = ecazStrongholdFixture({ initial: admitted });
assert.deepEqual(fixture.initial.players.map(p => p.id), admitted.players.map(p => p.id));
assert.equal(fixture.ecaz, 'auth-ecaz'); assert.equal(fixture.ally, 'auth-guild');
assert.deepEqual([...fixture.afterSetup.deck, ...fixture.afterSetup.discard,
...fixture.afterSetup.players.flatMap(p => p.hand)].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
assert.ok(Object.values(fixture.initial.strongholdCards!.owners).every(owner => owner === null));
assert.ok(Object.values(fixture.afterSetup.strongholdCards!.owners).every(owner => owner === null));
assert.equal(fixture.beforeMentat.strongholdCards!.owners.arrakeen, null);
assert.equal(strongholdControllers(fixture.beforeMentat.players, false).arrakeen, fixture.ecaz);
const settled = applyAction(fixture.beforeMentat, fixture.mentatStep.actor, fixture.mentatStep.action);
assert.deepEqual(settled.strongholdCards, fixture.afterMentat.strongholdCards);
assert.equal(settled.strongholdCards!.claimedTurn, 2);
assert.equal(Object.values(settled.strongholdCards!.owners).filter(id => id === fixture.ecaz).length, 1);
assert.ok(!Object.values(settled.strongholdCards!.owners).includes(fixture.ally));
assert.equal(seat(settled, fixture.ecaz).ally, fixture.ally);
assert.equal(seat(settled, fixture.ally).ally, fixture.ecaz);
});
void test('Only an actual pending Ecaz lead choice is JSON-restored and consumed once', () => {
const fixture = ecazStrongholdFixture();
const restored: Game = JSON.parse(JSON.stringify(fixture.game));
const decision = restored.decision;
assert.ok(decision?.kind === 'ecazBattleLead');
const action = { type: 'decision', event: decision.event, lead: fixture.ally };
const continued = applyAction(restored, fixture.ecaz, action);
assert.equal(continued.battle!.ecazOccupy!.lead, fixture.ally);
assert.equal(continued.strongholdCards!.owners.arrakeen, fixture.ecaz);
assert.throws(() => applyAction(continued, fixture.ecaz, action));
});
for (const lead of ['ecaz', 'ally'] as const) for (const variable of [0, 1, 3])
void test(`Arrakeen ${lead} lead: only actual variable support ${variable} receives holder bank funding`, () => {
const fixture = ecazStrongholdFixture();
const result = reveal(fixture, lead, { variable });
const { game, afterBattle, before, actor, quote } = result;
const payment = quote.payments.find(p => p.player === actor)!;
assert.equal(payment.bankSupport, lead === 'ecaz' ? Math.min(2, variable) : 0);
assert.equal(payment.ownPayment, lead === 'ecaz' ? Math.max(0, variable - 2) : variable);
assert.equal(afterBattle.decision?.kind, 'battleCards');
assert.equal(seat(before, actor).spice - seat(afterBattle, actor).spice, payment.ownPayment);
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
assert.equal(seat(afterBattle, nonLead).spice, seat(before, nonLead).spice);
assert.equal(seat(game, actor).spice, seat(afterBattle, actor).spice + 2);
assert.equal(seat(game, nonLead).spice, seat(afterBattle, nonLead).spice + 2);
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(seat(game, fixture.ecaz).forces[fixture.location], 2);
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, 3);
assert.equal(seat(game, fixture.ally).forces[fixture.location], 4 - variable);
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, variable);
assert.equal(seat(game, fixture.opponent).forces[fixture.location], undefined);
assert.ok(!seat(game, actor).leaders.find(l => l.id === result.ownLeader)!.dead);
assert.ok(!seat(game, fixture.opponent).leaders.find(l => l.id === result.opponentLeader)!.dead);
const scores = quote.scores!;
assert.equal(scores[quote.winner === before.battle!.attacker ? 'attacker' : 'defender'],
3 + variable + seat(before, actor).leaders.find(l => l.id === result.ownLeader)!.strength);
assert.equal(game.strongholdCards!.owners.arrakeen, fixture.ecaz);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Habbanya tie advantage belongs only to the actual ${lead} plan owner`, () => {
const fixture = ecazStrongholdFixture({ kind: 'habbanya_ridge_sietch' });
const prepared = openEcazStronghold(fixture, lead);
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const view = viewGame(prepared, actor).battle!;
if (lead === 'ecaz') assert.equal(view.aggressor, fixture.opponent,
'The actual holder must overturn an opposing ordinary tie priority.');
assert.equal(view.strongholdEffects[actor], lead === 'ecaz' ? fixture.territory : null);
assert.equal(view.tieWinner, lead === 'ecaz' ? fixture.ecaz : view.aggressor);
const result = reveal(fixture, lead, { variable: 0, tie: true });
assert.equal(result.quote.scores!.attacker, result.quote.scores!.defender);
assert.equal(result.game.lastBattleContext!.winner, view.tieWinner);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Tabr winning income is paid once to ${lead} only when that selected plan owner holds the card`, () => {
const fixture = ecazStrongholdFixture({ kind: 'sietch_tabr' });
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const shield = stageEcazOccupyCard(fixture.game, actor, c => c.kind === 'shield');
fixture.staging.push(`Conserved physical Shield ${shield} transferred to selected ${actor}; no face or wallet manufactured.`);
const result = reveal(fixture, lead, { variable: 1, opponentDial: 2, defense: shield });
const { game, afterBattle, before, quote } = result;
assert.equal(game.lastBattleContext!.winner, actor);
assert.deepEqual(quote.strongholdIncome, lead === 'ecaz' ? [{ player: actor, amount: 2 }] : []);
assert.equal(seat(afterBattle, actor).spice, seat(before, actor).spice - 1 + (lead === 'ecaz' ? 2 : 0));
assert.ok(seat(game, actor).hand.some(c => c.id === shield));
const afterCollection = advanceEcazStronghold(game, g => g.phase === 8 && !g.phaseOpening && !g.response && !g.decision);
assert.ok(seat(afterCollection, actor).hand.some(c => c.id === shield));
assert.equal(afterCollection.strongholdCards!.owners.sietch_tabr, fixture.ecaz);
assert.equal(afterCollection.strongholdCards!.claimedTurn, 2);
assert.equal(seat(afterCollection, fixture.ecaz).forces[fixture.location], 2);
assert.equal(seat(afterCollection, fixture.ally).forces[fixture.location], 3);
});
void test('All four minimal policies consume actual Ecaz lead, original counter and selected plan slots', () => {
const fixture = ecazStrongholdFixture();
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const policy = (game: Game, actor: string) => {
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
return botActions(view);
};
const lead = policy(fixture.game, fixture.ecaz).find(a => a.type === 'decision');
assert.ok(lead);
let game = applyAction(fixture.game, fixture.ecaz, lead);
assert.equal(game.response?.kind, 'ecazOccupy');
const counter = policy(game, fixture.opponent).find(a => a.type === 'passResponse' ||
a.type === 'card' && a.mode === 'cancel');
assert.ok(counter);
game = applyAction(game, fixture.opponent, counter);
game = advanceEcazStronghold(game, g => !g.response && !g.decision && !g.battle?.preparation && (!g.battle?.preLeader || g.battle.preLeader.closed));
const actor = game.battle!.ecazOccupy!.lead!;
const plan = policy(game, actor).find(a => a.type === 'battlePlan');
assert.ok(plan);
game = applyAction(game, actor, plan);
assert.ok(game.battle!.plans[actor]);
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
assert.ok(game.battle!.plans[actor].dial >= profile.fixedEcazDial);
for (const selected of ['ecaz', 'ally'] as const) {
const prepared = openEcazStronghold(fixture, selected);
const selectedActor = selected === 'ecaz' ? fixture.ecaz : fixture.ally;
const selectedPlan = policy(prepared, selectedActor).find(a => a.type === 'battlePlan');
assert.ok(selectedPlan);
applyAction(prepared, selectedActor, selectedPlan);
}
}
});
void test('Actual Stronghold initializer excludes Basic, E1/E2 mixtures, native pair and all other overlays', () => {
assert.throws(() => initializeStrongholdFactionsGameForAudit(freshLobby(false)));
for (const overlay of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'discoveryEnabled', 'ecazTreachery'] as const) {
const game = freshLobby();
Object.assign(game, { [overlay]: true });
assert.throws(() => initializeStrongholdFactionsGameForAudit(game));
}
for (const expansion of ['ix', 'choam'] as const) {
const mixed = freshLobby(); mixed.expansions.push(expansion);
assert.throws(() => initializeStrongholdFactionsGameForAudit(mixed));
}
let pair = createGame('ECAZMORITANISTRONGHOLDGUARD', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(pair, newPlayer('moritani', 'Moritani', 'moritani'));
joinGame(pair, newPlayer('guild', 'Guild', 'guild'));
for (const player of pair.players) pair = applyAction(pair, player.id, { type: 'ready' });
assert.throws(() => initializeStrongholdFactionsGameForAudit(pair));
});
-63
View File
@@ -14,10 +14,6 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Game } from '../game/engine';
import { isAuditorLeader, treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { STRONGHOLD_CARDS } from '../game/stronghold-cards';
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
import { createTechTokens } from '../game/tech-tokens';
const root = new URL('../', import.meta.url).pathname;
const cli = new URL('../tools/faction-games.ts', import.meta.url).pathname;
@@ -102,65 +98,6 @@ function assertFailedEvidence(
return report;
}
void test('native Stronghold samples admit every two-to-six-seat roster and preserve canonical physical inventories', (t) => {
const area = temporary(t);
const out = join(area, 'native');
assert.equal(run(out, '--profile', 'stronghold-factions',
'--players', 'all', '--seed', '1000', '--max-actions', '1').status, 1);
const results = json<{ results: CliResult[] }>(join(out, 'results.json')).results;
const families = [
{ native: 'ixians-choam', expansions: ['ix', 'choam'],
roster: ['ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 151 },
{ native: 'ixians-tleilaxu', expansions: ['ix'],
roster: ['ixians', 'tleilaxu', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 156 },
{ native: 'choam-richese', expansions: ['choam'],
roster: ['choam', 'richese', 'emperor', 'fremen', 'harkonnen', 'beneGesserit'], counts: [2, 3, 4, 5, 6], ordinal: 161 },
{ native: 'ixians-tleilaxu-choam-richese', expansions: ['ix', 'choam'],
roster: ['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'fremen'], counts: [4, 5, 6], ordinal: 166 },
];
const scenarios = families.flatMap(family => family.counts.map((count, index) => ({
...family, count, seed: 1000 + family.ordinal + index,
name: `stronghold-factions-${family.native}-${count}-advanced`,
})));
assert.deepEqual(results.map(result => result.name), scenarios.map(scenario => scenario.name));
for (const [index, result] of results.entries()) {
const scenario = scenarios[index];
assert.equal(result.seed, scenario.seed);
assert.equal(result.error, 'Error: Action limit');
assert.equal(result.actions, 1);
const game = json<Game>(join(out, `failed-${result.name}.json`));
assert.equal(game.advanced, true);
assert.deepEqual(game.expansions, scenario.expansions);
assert.deepEqual(game.players.map(player => player.faction), scenario.roster.slice(0, scenario.count));
assert.deepEqual(game.players.map(player => player.bot),
['Easy', 'Medium', 'Hard', 'Brutal', 'Easy', 'Medium'].slice(0, scenario.count));
const deck = treacheryDeck(scenario.expansions).map(card => card.id).sort();
assert.equal(deck.length, scenario.expansions.includes('ix') ? 47 : 35);
const cache = scenario.roster.includes('richese') ? richeseCards().map(card => card.id).sort() : [];
const inventory = sampleInventory(game);
assert.deepEqual(inventory.cards, [...deck, ...cache].sort());
assert.deepEqual((game.richeseCache ?? []).map(card => card.id).sort(), cache);
verifySampleCustody(game, inventory);
assert.deepEqual(Object.keys(game.strongholdCards!.owners).sort(),
STRONGHOLD_CARDS.map(card => card.id).sort());
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
assert.equal(game.strongholdCards!.claimedTurn, 0);
const choam = game.players.find(player => player.faction === 'choam');
if (choam) assert.equal(choam.leaders.filter(isAuditorLeader).length, 1);
}
for (const scenario of scenarios.filter(candidate => candidate.count === 6)) {
const snapshot = join(out, `failed-${scenario.name}.json`);
const original = readFileSync(snapshot, 'utf8');
const continued = join(area, `continued-${scenario.native}`);
assert.equal(run(continued, '--resume', snapshot, '--seed', '1000',
'--max-actions', '1').status, 1);
const report = assertFailedEvidence(continued, scenario.name, scenario.seed, true);
assert.equal(report.options.resume?.path, snapshot);
verifySampleCustody(json<Game>(join(continued, `failed-${scenario.name}.json`)),
sampleInventory(json<Game>(snapshot)), json<Game>(snapshot));
assert.equal(readFileSync(snapshot, 'utf8'), original);
}
});
void test('native Stronghold admission and resume reject foreign profiles and corrupted custody before gameplay', (t) => {
const area = temporary(t);
+186
View File
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer,
viewGame, type Game } from '../game/engine';
import { territory } from '../game/board';
import { STRONGHOLD_CARDS, type StrongholdId } from '../game/stronghold-cards';
import { finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type EcazStrongholdOptions = {
/** Ready authenticated lobby, or its original fresh native setup. Seats are never replaced. */
initial?: Game;
kind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
ecazForces?: number;
allyForces?: number;
opponentForces?: number;
};
export type EcazStrongholdFixture = {
initial: Game; afterSetup: Game; beforeMentat: Game; mentatStep: StrongholdFactionsNativeStep;
afterMentat: Game; beforeBattle: Game; game: Game;
ecaz: string; ally: string; opponent: string; territory: EcazStrongholdOptions['kind'] & string;
location: string; ecazForces: number; allyForces: number; staging: string[];
};
export const ecazStrongholdSeat = (game: Game, actor: string) => {
const player = game.players.find(p => p.id === actor);
assert.ok(player, 'Every actor retains its originally admitted identity.');
return player;
};
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision;
export function nextEcazStronghold(game: Game): Game {
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next, 'A native pending plan requires the actual selected plan owner.');
return applyAction(game, next.actor, next.action);
}
export function advanceEcazStronghold(game: Game, predicate: (game: Game) => boolean): Game {
for (let attempt = 0; attempt < 1800; attempt++) {
if (predicate(game)) return game;
assert.equal(game.status, 'playing');
game = nextEcazStronghold(game);
}
throw Error('Original Ecaz/Stronghold chronology did not reach its boundary.');
}
function reserveBoard(game: Game): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
}
function place(game: Game, owner: string, location: string, count: number): void {
const player = ecazStrongholdSeat(game, owner);
assert.ok(Number.isSafeInteger(count) && count >= 0 && count <= player.reserves);
player.reserves -= count;
if (count) player.forces[location] = (player.forces[location] ?? 0) + count;
}
function orderBlow(game: Game, worm: boolean, position: number): void {
const at = game.spiceDeck.findIndex((card, i) => i >= position && (worm
? 'worm' in card && !card.greatMaker && !card.suppressed
: 'territory' in card));
assert.ok(at >= position, 'Only an actual unplayed Spice Card may be ordered.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
/** Determinism is limited to the original native shuffle, never a saved deal. */
function initialize(game: Game): Game {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeStrongholdFactionsGameForAudit(game); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Genuine Advanced E3 setup and reciprocal Nexus alliance. Controlled physical
* board relocations follow setup; no bank grants, phase assignments, ownership
* assignments, replaced seats, manufactured plans or battle receipts. */
export function ecazStrongholdFixture(options: EcazStrongholdOptions = {}): EcazStrongholdFixture {
const kind = options.kind ?? 'arrakeen';
const location = `${kind}:${territory(kind).sectors[0]}`;
const ecazForces = options.ecazForces ?? 5, allyForces = options.allyForces ?? 4;
const staging: string[] = [];
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0);
assert.equal(game.advanced, true);
assert.deepEqual(game.expansions, ['ecaz']);
} else {
game = createGame('ECAZSTRONGHOLDS', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
// Original printed seats put the opposing Emperor first at the later storm.
for (const [actor, position] of [['ecaz', 6], ['guild', 4], ['emperor', 5]] as const)
if (viewGame(game, actor).playerPositions[actor] !== position)
game = applyAction(game, actor, { type: 'seatPosition', position });
}
const ecaz = game.players.find(p => p.faction === 'ecaz')?.id;
const ally = game.players.find(p => p.faction === 'guild')?.id;
const opponent = game.players.find(p => p.faction === 'emperor')?.id;
assert.ok(ecaz && ally && opponent && game.players.length === 3,
'The original native seats must be Ecaz, Guild and Emperor.');
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game = initialize(game);
}
const initial = structuredClone(game);
assert.equal(game.strongholdCards!.claimedTurn, 0);
assert.deepEqual(Object.keys(game.strongholdCards!.owners).sort(), STRONGHOLD_CARDS.map(c => c.id).sort());
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
game = finishEcazOccupySetup(game);
const afterSetup = structuredClone(game);
reserveBoard(game);
staging.push('After genuine setup: return every physical board counter to its own reserves; preserve native wallets, cards, leaders and identities.');
orderBlow(game, false, 0); orderBlow(game, false, 1);
staging.push('Order only remaining physical Spice Cards: no turn-one worm, then a turn-two worm followed by real blows.');
// Native first Storm dials keep both subsequent 1..6 Storm Cards away from
// this printed battle sector, without replacing a storm field or source.
const sector = territory(kind).sectors[0];
const distance = sector <= 11 ? sector + 1 : 0;
const dials = new Map(game.stormDialers.map((id, index) => [id, index === 0 ? Math.min(6, distance) : Math.max(0, distance - 6)]));
for (let attempt = 0; !(game.turn === 2 && game.phase === 0 && clean(game)) && attempt < 1800; attempt++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
if (next.action.type === 'stormDial') next.action.amount = dials.get(next.actor) ?? 0;
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.turn, 2);
orderBlow(game, true, 0); orderBlow(game, false, 1); orderBlow(game, false, 2);
game = advanceEcazStronghold(game, g => g.phase === 1 && !!g.nexus && clean(g) && !g.spiceWindow && !g.spiceResolution);
game = applyAction(game, ecaz, { type: 'alliance', target: ally });
game = applyAction(game, ally, { type: 'alliance', target: ecaz });
assert.equal(ecazStrongholdSeat(game, ecaz).ally, ally);
assert.equal(ecazStrongholdSeat(game, ally).ally, ecaz);
game = advanceEcazStronghold(game, g => g.phase === 8 && clean(g));
reserveBoard(game);
place(game, ecaz, location, 1); place(game, ally, location, 1);
staging.push(`Before real turn-two END Mentat: conserve one Ecaz and one reciprocal allied Guild counter in ${kind}; leave every other stronghold uncontrolled.`);
let beforeMentat: Game | undefined, mentatStep: StrongholdFactionsNativeStep | undefined;
for (let attempt = 0; game.turn === 2 && attempt < 200; attempt++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
const before = structuredClone(game);
game = applyAction(game, next.actor, next.action);
if (game.turn === 3) { beforeMentat = before; mentatStep = next; }
}
assert.ok(beforeMentat && mentatStep);
assert.equal(game.strongholdCards!.owners[kind], ecaz);
assert.equal(game.strongholdCards!.claimedTurn, 2);
const afterMentat = structuredClone(game);
orderBlow(game, false, 0); orderBlow(game, false, 1);
game = advanceEcazStronghold(game, g => g.phase === 5 && clean(g));
reserveBoard(game);
place(game, ecaz, location, ecazForces); place(game, ally, location, allyForces);
place(game, opponent, location, options.opponentForces ?? 8);
staging.push(`Before original movement completion: conserved ${ecazForces} Ecaz, ${allyForces} Guild and ${options.opponentForces ?? 8} Emperor counters at ${kind}; no shipment fee or bank income is fabricated.`);
const beforeCards = new Set([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)].map(c => c.id));
stageEcazOccupyCard(game, opponent, c => c.effect === 'karama');
staging.push('Conserved canonical Karama transfer from existing physical deck/hand custody to Emperor, exposing the actual Occupy counter window.');
assert.deepEqual(new Set([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)].map(c => c.id)), beforeCards);
game = advanceEcazStronghold(game, g => g.phase === 6 && clean(g));
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === kind);
assert.ok(choice, 'Actual combined armies must be admitted by original chooseBattle.');
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: kind,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
assert.equal(game.decision?.kind, 'ecazBattleLead');
return { initial, afterSetup, beforeMentat, mentatStep, afterMentat, beforeBattle, game,
ecaz, ally, opponent, territory: kind, location, ecazForces, allyForces, staging };
}
export function openEcazStronghold(fixture: EcazStrongholdFixture, lead: 'ecaz' | 'ally'): Game {
let game = fixture.game;
const decision = game.decision;
assert.ok(decision?.kind === 'ecazBattleLead');
game = applyAction(game, fixture.ecaz, { type: 'decision', event: decision.event,
lead: lead === 'ecaz' ? fixture.ecaz : fixture.ally });
return advanceEcazStronghold(game, g => !!g.battle && !g.battle.preparation &&
(!g.battle.preLeader || g.battle.preLeader.closed) && clean(g));
}
export function finishEcazStronghold(game: Game): Game {
return advanceEcazStronghold(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard);
}
+205
View File
@@ -0,0 +1,205 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { territory } from '../game/board';
import type { Card } from '../game/cards';
import type { StrongholdId } from '../game/stronghold-cards';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type MoritaniStrongholdsOptions = {
initial?: Game;
kind?: 'tueks_sietch' | 'sietch_tabr' | 'carthag';
holder?: 'moritani' | 'guild';
normalCall?: boolean;
};
export type MoritaniStrongholdsFixture = {
initial: Game; afterSetup: Game; beforePlacement: Game; afterPlacement: Game;
beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; afterFirstMentat: Game;
beforeBattle: Game; game: Game; revealed: Game; pending: Game;
moritani: string; opponent: string; holder: string; kind: StrongholdId;
target: string; moritaniLeader: string; opponentLeader: string;
moritaniCards: string[]; opponentCards: string[];
battleAction: StrongholdFactionsNativeStep; planActions: StrongholdFactionsNativeStep[];
sourceActions: StrongholdFactionsNativeStep[]; staging: string[];
};
export const moritaniStrongholdsPlayer = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id); assert.ok(player, `Missing original seat ${id}`); return player;
};
export function moritaniStrongholdsPolicy(game: Game, actor: string, difficulty: Difficulty = 'Easy') {
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
return botActions(view);
}
/** Use original owner-projected decisions; no invented Terror charge, card claim,
* assassination opportunity or hand/leader-skill overlay. */
export function nextMoritaniStrongholdsStep(game: Game): StrongholdFactionsNativeStep | null {
if (game.decision && ['moritaniSetup', 'moritaniPlacement', 'moritaniTerror', 'moritaniRetention', 'moritaniAssassinate'].includes(game.decision.kind)) {
const actor = game.decision.player, action = moritaniStrongholdsPolicy(game, actor)[0]; assert.ok(action);
return { actor, action };
}
return nextStrongholdFactionsNativeStep(game);
}
function run(game: Game, action: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]) {
actions.push(structuredClone(action)); return applyAction(game, action.actor, action.action);
}
export function advanceMoritaniStrongholds(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextMoritaniStrongholdsStep(game); assert.ok(next, 'A real sealed Battle Plan is required');
game = run(game, next, actions);
}
throw Error('Original native Moritani Stronghold continuation did not reach its boundary');
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
const key = (kind: StrongholdId) => `${kind}:${territory(kind).sectors[0]}`;
function reserveBoard(game: Game) {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0); player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0); player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
}
function place(game: Game, id: string, kind: StrongholdId, count: number) {
const player = moritaniStrongholdsPlayer(game, id); assert.ok(player.reserves >= count);
player.reserves -= count; player.forces[key(kind)] = count;
}
function heldCard(game: Game, id: string, kind: Card['kind'], staging: string[], excluded: string[] = []) {
const player = moritaniStrongholdsPlayer(game, id);
const held = player.hand.find(c => c.kind === kind && !excluded.includes(c.id)); if (held) return held.id;
const index = game.deck.findIndex(c => c.kind === kind && !excluded.includes(c.id)); assert.ok(index >= 0);
assert.ok(player.hand.length < 4);
const card = game.deck.splice(index, 1)[0]; player.hand.push(card);
staging.push(`Conserved ${card.id}: remaining exact native ecaz deck → ${id}'s hand`); return card.id;
}
export function holdMoritaniStrongholdsTraitor(game: Game, owner: string, identity: string, staging: string[] = []) {
const player = moritaniStrongholdsPlayer(game, owner), old = player.traitors[0]; assert.ok(old);
if (old === identity) return;
const donor = game.players.find(p => p.id !== owner && p.traitors.includes(identity));
if (donor) donor.traitors[donor.traitors.indexOf(identity)] = old;
else {
const index = game.traitorReserve!.indexOf(identity); assert.ok(index >= 0);
game.traitorReserve![index] = old;
}
player.traitors[0] = identity;
staging.push(`Conserved physical traitor ${identity}: native ${donor?.id ?? 'reserve'} custody exchanged for ${old}`);
}
/** Deterministic battle positions only AFTER real setup and actual Mentat claim.
* No assignment to phases, turn, storm, owners, plans or native pending state. */
export function stageMoritaniStrongholdsBattle(game: Game, moritani: string, opponent: string, kind: StrongholdId, staging: string[] = []) {
assert.equal(game.phase, 5); assert.ok(clean(game));
reserveBoard(game); place(game, moritani, kind, 6); place(game, opponent, kind, 6);
// Explicitly isolate battle/assassination bank legs from board Collection.
game.spice = {};
staging.push('Before genuine movement completion: conserved six original counters per combatant and empty board deposits; preserve original native wallets and actual prior Mentat card holders.');
return game;
}
function reveal(game: Game, plans: StrongholdFactionsNativeStep[], actions: StrongholdFactionsNativeStep[]) {
for (const plan of plans) {
game = run(game, plan, actions);
while (game.response || game.decision?.kind === 'fullPlanOffer') {
const next = nextMoritaniStrongholdsStep(game); assert.ok(next); game = run(game, next, actions);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function resolveMoritaniStrongholdsBattle(game: Game, moritani: string, normalCall = false, actions: StrongholdFactionsNativeStep[] = []) {
for (let count = 0; count < 160; count++) {
if (game.decision?.kind === 'moritaniAssassinate' || !game.battle && !game.decision && !game.response && !game.pendingTreacheryDiscard) return game;
const next = nextMoritaniStrongholdsStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani;
game = run(game, next, actions);
}
throw Error('Original Moritani battle suffix did not finish');
}
export function createMoritaniStrongholdsFixture(options: MoritaniStrongholdsOptions = {}): MoritaniStrongholdsFixture {
const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
const kind = options.kind ?? 'tueks_sietch';
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0));
assert.equal(game.advanced, true); assert.deepEqual(game.expansions, ['ecaz']);
} else {
game = createGame('MORITANISTRONGHOLDS', newPlayer('moritani', 'Moritani', 'moritani'), true, ['ecaz']);
joinGame(game, newPlayer('guild', 'Guild', 'guild')); joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
}
assert.equal(game.players.filter(p => p.faction === 'moritani').length, 1);
assert.ok(game.players.some(p => p.faction === 'guild'));
assert.ok(game.players.every(p => ['moritani', 'atreides', 'bene_gesserit', 'emperor', 'fremen', 'guild'].includes(p.faction)));
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = run(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = initializeStrongholdFactionsGameForAudit(game);
}
const initial = structuredClone(game);
assert.ok(game.moritaniAssassinate, 'Fresh eligible E3 profile initializes the original assassination state');
assert.equal(game.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
for (let count = 0; game.status === 'setup' && count < 200; count++) {
const next = nextMoritaniStrongholdsStep(game); assert.ok(next); game = run(game, next, actions);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const moritani = game.players.find(p => p.faction === 'moritani')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const holder = options.holder === 'guild' ? opponent : moritani;
for (let count = 0; !(game.phase === 8 && game.decision?.kind === 'moritaniPlacement') && count < 1800; count++) {
const next = nextMoritaniStrongholdsStep(game); assert.ok(next);
// Real first Storm dials keep the printed battle sector outside every
// possible native turn-two storm advance; the storm field is never assigned.
if (next.action.type === 'stormDial' && game.turn === 1) next.action.amount = kind === 'tueks_sietch' ? 4 : 0;
game = run(game, next, actions);
}
assert.equal(game.decision?.kind, 'moritaniPlacement');
const beforePlacement = structuredClone(game);
// Real native placement; choose a different stronghold so subsequent conserved
// battle staging neither fabricates an entry nor invokes an unrelated token.
const token = game.moritaniTerror!.tokens.find(t => t.status === 'available')!; assert.ok(token);
game = run(game, { actor: moritani, action: { type: 'decision', token: token.id, territory: 'arrakeen' } }, actions);
game = advanceMoritaniStrongholds(game, state => state.phase === 8 && clean(state), actions);
const afterPlacement = structuredClone(game);
reserveBoard(game); place(game, holder, kind, 1);
const other = kind === 'carthag' ? 'sietch_tabr' : 'carthag'; place(game, holder === moritani ? opponent : moritani, other, 1);
staging.push('Before actual first END Mentat: conserve original counters through reserves, one controlled Stronghold per combatant, no three-hold victory or assigned custody.');
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
for (let count = 0; game.turn === 1 && count < 200; count++) {
const next = nextMoritaniStrongholdsStep(game); assert.ok(next);
const before = structuredClone(game); game = run(game, next, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep); assert.equal(game.turn, 2);
assert.equal(game.strongholdCards!.owners[kind], holder);
const afterFirstMentat = structuredClone(game);
game = advanceMoritaniStrongholds(game, state => state.phase === 5 && clean(state), actions);
stageMoritaniStrongholdsBattle(game, moritani, opponent, kind, staging);
const m = moritaniStrongholdsPlayer(game, moritani), guild = moritaniStrongholdsPlayer(game, opponent);
const ownLeader = m.leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
const enemyLeader = guild.leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
assert.ok(ownLeader && enemyLeader);
const target = guild.leaders.find(l => !l.dead && l.id !== enemyLeader.id)!; assert.ok(target);
holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemyLeader.id : target.id, staging);
const moritaniCards: string[] = [], opponentCards: string[] = [];
if (kind === 'carthag') {
moritaniCards.push(heldCard(game, moritani, 'shield', staging));
opponentCards.push(heldCard(game, opponent, 'poison', staging));
} else if (kind === 'tueks_sietch') {
moritaniCards.push(heldCard(game, moritani, 'worthless', staging));
moritaniCards.push(heldCard(game, moritani, 'worthless', staging, moritaniCards));
opponentCards.push(heldCard(game, opponent, 'worthless', staging));
}
game = advanceMoritaniStrongholds(game, state => state.phase === 6 && clean(state), actions);
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok([moritani, opponent].includes(actor));
const battleAction: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === moritani ? opponent : moritani } };
game = run(game, battleAction, actions);
game = advanceMoritaniStrongholds(game, state => !!state.battle && !state.battle.revealed && !nextMoritaniStrongholdsStep(state), actions);
const planActions: StrongholdFactionsNativeStep[] = [
{ actor: moritani, action: { type: 'battlePlan', leader: ownLeader.id, dial: 1, support: 1, weapon: kind === 'tueks_sietch' ? moritaniCards[0] : null, defense: kind === 'tueks_sietch' ? moritaniCards[1] : kind === 'carthag' ? moritaniCards[0] : null } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 3, support: 3, weapon: opponentCards[0] ?? null, defense: null } },
];
const prepared = structuredClone(game), revealed = reveal(game, planActions, actions);
const pending = resolveMoritaniStrongholdsBattle(structuredClone(revealed), moritani, options.normalCall, actions);
return { initial, afterSetup, beforePlacement, afterPlacement, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, game: prepared, revealed, pending,
moritani, opponent, holder, kind, target: target.id, moritaniLeader: ownLeader.id, opponentLeader: enemyLeader.id, moritaniCards, opponentCards, battleAction, planActions, sourceActions: actions, staging };
}
+6 -4
View File
@@ -78,13 +78,13 @@ function step(game: Game): Game {
assert.ok(next, 'A native Richese continuation requires an unsealed human plan');
return applyAction(game, next.actor, next.action);
}
function advance(game: Game, predicate: (game: Game) => boolean): Game {
function advance(game: Game, predicate: (game: Game) => boolean, firstStormDial = 4): Game {
for (let count = 0; count < 1600; count++) {
if (predicate(game)) return game;
assert.equal(game.status, 'playing');
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
// Select legal original dials so the next storm band cannot cross Habbanya.
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = 4;
// Use legal original dials that leave the intended controlled battle outside the next Storm band.
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = firstStormDial;
game = applyAction(game, next.actor, next.action);
}
throw Error('Native Richese continuation did not reach its requested boundary');
@@ -180,7 +180,9 @@ export function createRicheseStrongholdsFixture(options: RicheseStrongholdsOptio
const choam = game.players.find(p => p.faction === 'choam')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
// Stone comes from the actual unbid-cache acquisition producer, never a hand injection.
game = advance(game, g => g.decision?.kind === 'richeseCache');
const firstStormDial = Math.min(4, Math.floor((territory(kind).sectors[0] - 7) / 2));
assert.ok(firstStormDial >= 0);
game = advance(game, g => g.decision?.kind === 'richeseCache', firstStormDial);
const acquisitionBefore = structuredClone(game), acquisitionSteps: StrongholdFactionsNativeStep[] = [];
for (let count = 0; !richeseStrongholdsPlayer(game, richese).hand.some(c => c.id === 'richese-stone-burner') && count < 100; count++) {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
+201
View File
@@ -0,0 +1,201 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck } from '../game/cards';
import { STRONGHOLD_CARDS, strongholdControllers } from '../game/stronghold-cards';
import { assassinationPhysical } from './moritani-assassinate-fixture';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import {
advanceMoritaniStrongholds, createMoritaniStrongholdsFixture, holdMoritaniStrongholdsTraitor,
moritaniStrongholdsPlayer as player, moritaniStrongholdsPolicy, nextMoritaniStrongholdsStep,
resolveMoritaniStrongholdsBattle, stageMoritaniStrongholdsBattle,
} from './fixture-moritani-strongholds';
function custody(game: Game) {
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
assert.deepEqual(cards, treacheryDeck(['ecaz']).map(c => c.id).sort());
assert.equal(new Set(cards).size, cards.length);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'emperor' ? 5 : 3);
}
const traitors = assassinationPhysical(game).traitors;
assert.equal(new Set(traitors).size, traitors.length);
}
function revealAssassination(game: Game, card: string) {
const d = game.decision; assert.equal(d?.kind, 'moritaniAssassinate');
if (d?.kind !== 'moritaniAssassinate') throw Error('Original loss decision is required');
return applyAction(game, d.player, { type: 'decision', event: d.event, card });
}
function finishBattle(game: Game) {
return advanceMoritaniStrongholds(game, state => !state.battle && !state.decision && !state.response && !state.pendingTreacheryDiscard);
}
void test('standalone native Advanced Moritani preserves authenticated seats, exact ecaz deck and six real first-Mentat card claims', () => {
let lobby = createGame('AUTHMORITANI', newPlayer('authenticated-m', 'Original Moritani', 'moritani'), true, ['ecaz']);
joinGame(lobby, newPlayer('authenticated-g', 'Original Guild', 'guild'));
joinGame(lobby, newPlayer('authenticated-e', 'Original Emperor', 'emperor'));
for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' });
const original = structuredClone(lobby), f = createMoritaniStrongholdsFixture({ initial: lobby });
assert.deepEqual(lobby, original);
assert.deepEqual(f.afterSetup.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), original.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })));
for (const game of [f.initial, f.afterSetup, f.afterFirstMentat, f.pending]) custody(game);
assert.equal(f.afterSetup.leaderSkills, undefined);
assert.equal(f.afterSetup.players.some(p => p.faction === 'ecaz' || p.faction === 'harkonnen'), false);
assert.equal(f.initial.strongholdCards!.claimedTurn, 0);
assert.deepEqual(Object.keys(f.initial.strongholdCards!.owners), STRONGHOLD_CARDS.map(c => c.id));
assert.ok(Object.values(f.initial.strongholdCards!.owners).every(id => id === null));
assert.equal(f.beforeFirstMentat.phase, 8);
const claimed = applyAction(structuredClone(f.beforeFirstMentat), f.firstMentatStep.actor, f.firstMentatStep.action);
assert.equal(claimed.turn, 2);
assert.deepEqual(claimed.strongholdCards, f.afterFirstMentat.strongholdCards);
assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(f.beforeFirstMentat.players, false));
assert.deepEqual(f.pending.strongholdCards, claimed.strongholdCards, 'A real loss retains recorded cards until the next end-Mentat checkpoint');
assert.equal(f.pending.strongholdCards!.owners.tueks_sietch, f.moritani);
assert.equal(strongholdControllers(f.pending.players, false).tueks_sietch, f.opponent);
});
void test('Tuek pays its actual losing holder for both played Worthless cards, then original assassination pays one printed bounty and one private next-Mentat replacement', () => {
const f = createMoritaniStrongholdsFixture(), pending = f.pending;
assert.equal(pending.decision?.kind, 'moritaniAssassinate');
assert.equal(pending.lastBattleContext?.winner, f.opponent);
const quote = quoteStrongholdFactionsBattle(f.revealed);
assert.deepEqual(quote.strongholdIncome, [{ player: f.moritani, amount: 4 }]);
assert.equal(quote.bounty?.amount ?? 0, 0);
const moritaniBefore = player(f.revealed, f.moritani).spice, opponentBefore = player(f.revealed, f.opponent).spice;
assert.equal(player(pending, f.moritani).spice, moritaniBefore - 1 + 4, 'Native wallet minus one actual support plus two printed two-spice Worthless payouts');
assert.equal(player(pending, f.opponent).spice, opponentBefore - 3, 'The unheld winner Worthless card earns no Tuek payout');
assert.equal(player(pending, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
assert.equal(player(pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, false);
const target = structuredClone(player(pending, f.opponent).leaders.find(l => l.id === f.target)!);
assert.notEqual(target.id, f.opponentLeader);
assert.equal(target.dead, false);
const physical = assassinationPhysical(pending), reserve = [...pending.traitorReserve!], held = [...player(pending, f.moritani).traitors];
const deaths = pending.players.flatMap(p => p.leaders).map(l => ({ id: l.id, deaths: l.deaths }));
const tanks = pending.players.map(p => ({ id: p.id, tanks: p.tanks, elites: p.elites?.tanks }));
let game = revealAssassination(structuredClone(pending), f.target);
assert.equal(player(game, f.moritani).spice, moritaniBefore - 1 + 4 + target.strength);
assert.equal(player(game, f.opponent).spice, opponentBefore - 3);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.bounty, target.strength);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
assert.deepEqual(game.traitorReserve, reserve);
assert.deepEqual(player(game, f.moritani).traitors, held, 'Revealed physical card remains held until original Mentat replacement');
assert.equal(game.decision?.kind, 'battleCards', 'Original saved winner-card cleanup resumes after reveal');
assert.equal(game.decision?.player, f.opponent);
game = applyAction(game, f.opponent, { type: 'decision', discard: f.opponentCards });
game = finishBattle(game);
for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1);
assert.deepEqual(normalizeAutomaticGame(structuredClone(game)), game);
game = advanceMoritaniStrongholds(game, state => state.phase === 8);
const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced');
assert.equal(receipt.replacement, reserve[0]);
assert.deepEqual(game.traitorReserve, reserve.slice(1));
assert.deepEqual(player(game, f.moritani).traitors, [...held.filter(id => id !== f.target), reserve[0]]);
assert.equal(game.players.flatMap(p => p.traitors).includes(f.target), false);
assert.equal(game.traitorReserve!.includes(f.target), false);
assert.equal(player(game, f.moritani).spice, moritaniBefore - 1 + 4 + target.strength, 'Neither support debit, Stronghold income nor printed assassination bounty replays');
assert.deepEqual(game.players.map(p => ({ id: p.id, tanks: p.tanks, elites: p.elites?.tanks })),
tanks.map(entry => entry.id === f.opponent ? { ...entry, tanks: entry.tanks + 3 } : entry),
'Original winning Guild dial3 casualties finish after assassination and winner-card cleanup, once only');
for (const old of deaths) assert.equal(game.players.flatMap(p => p.leaders).find(l => l.id === old.id)!.deaths, old.deaths + (old.id === f.target ? 1 : 0));
assert.deepEqual(assassinationPhysical(game), physical);
const rivalReceipt = viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!;
assert.equal(rivalReceipt.card, f.target);
assert.equal('replacement' in rivalReceipt, false);
custody(game);
const suffix = advanceMoritaniStrongholds(structuredClone(game), state => state.turn === 3);
assert.equal(suffix.traitorReserve!.length, reserve.length - 1, 'End-Mentat card claim does not draw a second replacement');
assert.equal(player(suffix, f.moritani).spice, player(game, f.moritani).spice);
assert.equal(suffix.strongholdCards!.claimedTurn, 2);
assert.equal(suffix.strongholdCards!.owners.tueks_sietch, f.opponent);
assert.deepEqual(Object.keys(suffix.strongholdCards!.owners), STRONGHOLD_CARDS.map(c => c.id));
});
void test('Tuek follows the actual holder, including a winning Guild plan, rather than paying every native participant', () => {
const f = createMoritaniStrongholdsFixture({ holder: 'guild' });
const quote = quoteStrongholdFactionsBattle(f.revealed);
assert.deepEqual(quote.strongholdIncome, [{ player: f.opponent, amount: 2 }]);
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1);
assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3 + 2);
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
assert.equal(player(f.pending, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
custody(f.pending);
});
void test('Carthag gives the native holder-plan Shield its printed Snooper property, preserving an ordinary losing Moritani leader with no invented death bounty', () => {
const f = createMoritaniStrongholdsFixture({ kind: 'carthag' });
assert.equal(f.pending.lastBattleContext?.winner, f.opponent);
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
assert.equal(player(f.pending, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, false);
assert.equal(quoteStrongholdFactionsBattle(f.revealed).bounty?.amount ?? 0, 0);
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1);
assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
const declared = f.pending.decision!;
let declined = applyAction(f.pending, f.moritani, { type: 'decision', event: declared.kind === 'moritaniAssassinate' ? declared.event : '', decline: true });
declined = finishBattle(declined);
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
assert.equal(player(declined, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
custody(declined);
const unheld = createMoritaniStrongholdsFixture({ kind: 'carthag', holder: 'guild' });
const killed = player(unheld.pending, unheld.moritani).leaders.find(l => l.id === unheld.moritaniLeader)!;
assert.equal(killed.dead, true, 'Opponent card custody does not lend Snooper coverage to the losing Shield plan');
assert.equal(player(unheld.pending, unheld.opponent).spice, player(unheld.revealed, unheld.opponent).spice - 3 + killed.strength, 'An actual poison death, unlike mere defeat, pays its printed bounty');
custody(unheld.pending);
});
void test('a native normal Traitor reveal forfeits assassination across the actual next turn and a different classic opposing faction', () => {
const f = createMoritaniStrongholdsFixture({ normalCall: true });
assert.equal(f.pending.lastBattleContext?.winner, f.moritani);
assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(f.pending.moritaniAssassinate!.opportunities.length, 0);
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true);
let game = advanceMoritaniStrongholds(f.pending, state => state.turn === 3 && state.phase === 5 && !state.phaseOpening && !state.response && !state.decision);
const emperor = game.players.find(p => p.faction === 'emperor')!.id;
stageMoritaniStrongholdsBattle(game, f.moritani, emperor, 'tueks_sietch');
const loser = player(game, f.moritani).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
const winner = player(game, emperor).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const target = player(game, emperor).leaders.find(l => !l.dead && l.id !== winner.id)!;
holdMoritaniStrongholdsTraitor(game, f.moritani, target.id);
game = advanceMoritaniStrongholds(game, state => state.phase === 6 && !state.phaseOpening && !state.response && !state.decision);
const actor = game.active!; assert.ok([f.moritani, emperor].includes(actor));
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === f.moritani ? emperor : f.moritani });
game = advanceMoritaniStrongholds(game, state => !!state.battle && !state.battle.revealed && !nextMoritaniStrongholdsStep(state));
game = applyAction(game, f.moritani, { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 });
game = applyAction(game, emperor, { type: 'battlePlan', leader: winner.id, dial: 2, support: 2 });
game = resolveMoritaniStrongholdsBattle(game, f.moritani);
assert.equal(game.lastBattleContext?.winner, emperor);
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(game.moritaniAssassinate!.opportunities.length, 0);
assert.notEqual(game.decision?.kind, 'moritaniAssassinate');
assert.equal(player(game, emperor).leaders.find(l => l.id === target.id)!.dead, false);
assert.equal(player(game, f.moritani).leaders.find(l => l.id === loser.id)!.dead, false);
custody(game);
});
void test('all four minimal native policies perform original Terror choices and loss reveal/decline without a Terror fee or premature card transfer', () => {
const f = createMoritaniStrongholdsFixture();
for (const difficulty of DIFFICULTIES) {
const placement = moritaniStrongholdsPolicy(f.beforePlacement, f.moritani, difficulty).find(a => a.type === 'decision'); assert.ok(placement);
const placed = applyAction(structuredClone(f.beforePlacement), f.moritani, placement);
assert.deepEqual(placed.players.map(p => ({ id: p.id, spice: p.spice })), f.beforePlacement.players.map(p => ({ id: p.id, spice: p.spice })), 'Printed Terror placement has no fee');
assert.deepEqual(placed.strongholdCards, f.beforePlacement.strongholdCards);
const action = moritaniStrongholdsPolicy(f.pending, f.moritani, difficulty)[0]; assert.ok(action);
const revealed = applyAction(structuredClone(f.pending), f.moritani, action);
assert.equal(revealed.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
assert.equal(revealed.moritaniAssassinate!.opportunities.at(-1)!.card, f.target);
// Private eligibility is varied by a conserved swap before actual plan
// submission, never by modifying an original assassination receipt.
let empty = structuredClone(f.game);
holdMoritaniStrongholdsTraitor(empty, f.moritani, player(empty, f.moritani).leaders[0].id);
for (const plan of f.planActions) empty = applyAction(empty, plan.actor, plan.action);
empty = resolveMoritaniStrongholdsBattle(empty, f.moritani);
const decline = moritaniStrongholdsPolicy(empty, f.moritani, difficulty)[0]; assert.ok(decline);
const declined = applyAction(empty, f.moritani, decline);
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
custody(placed); custody(revealed); custody(declined);
}
});
@@ -133,7 +133,6 @@ void test('native marker-only plans use the reserve-limited private pool, reveal
void test('Arrakeen allowance funds real supported marker battle costs without creating spendable spice', () => {
const fixture = createRicheseStrongholdsFixture({ kind: 'arrakeen', noFieldValue: 5, ownerDial: 3, support: 3 });
const revealed = revealRicheseStrongholdsBattle(fixture);
assert.equal(richeseStrongholdsPlayer(revealed, fixture.richese).spice, 20, 'sealing a subsidized plan does not credit its wallet');
const { quote, resolved } = settle(fixture, revealed);
const payment = quote.payments.find(p => p.player === fixture.richese)!;
assert.equal(payment.bankSupport, 2);