Compose standalone E3 Nexus Skills and original module mechanics

This commit is contained in:
Quintine committed 2026-10-05 06:19:40 +11:00
1 parent a8c02b4a85
commit 83f825513a
23 files changed
+1926 -43

No files matched your search

@@ -0,0 +1,381 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { quoteNexusEcazDuke, createNexusEcazDukeReceipt } from '../game/nexus-ecaz-duke';
import { ownedTech } from '../game/tech-tokens';
import {
advanceEcazNexusSkillsModules as advance, assertEcazNexusSkillsModulesCustody as custody,
completeEcazNexusSkillsModulesSetup as complete, createEcazNexusSkillsModulesFixture as fixture,
ecazNexusSkillsModulesAction as act, ecazNexusSkillsModulesCurrentControl as controls,
ecazNexusSkillsModulesPlayer as player, ecazNexusSkillsModulesSourceFacts as facts,
ecazNexusSkillsModulesTrainer as trainer, expireEcazNexusSkillsModulesDuke as expire,
initializeEcazNexusSkillsModules as initialize, reloadEcazNexusSkillsModules as reload,
revealEcazNexusSkillsModulesBattle as reveal, settleEcazNexusSkillsModulesBattle as settle,
stepEcazNexusSkillsModules as step, type EcazNexusSkillsModulesOptions,
} from './fixture-ecaz-nexus-skills-modules';
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected actions must preserve the actual original source.');
}
function policy(game: Game, actor: string, difficulty: typeof DIFFICULTIES[number]): Action {
const view = viewGame(reload(game), actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
return action;
}
const profiles: EcazNexusSkillsModulesOptions[] = [
{ rules: 'basic', tech: false, stronghold: false },
{ rules: 'advanced', tech: false, stronghold: false },
{ rules: 'basic', tech: true, stronghold: false },
{ rules: 'advanced', tech: true, stronghold: false },
{ rules: 'advanced', tech: false, stronghold: true },
{ rules: 'advanced', tech: true, stronghold: true },
];
void test('all six standalone Ecaz bands draw at a genuine closing Nexus and spend living unclaimed Duke once without native Moritani', () => {
for (const options of profiles) {
const f = fixture(options);
assert.equal(f.offered.setupStage, 'leaderSkills');
assert.equal(f.offered.players.flatMap(p => p.traitors).length, 0);
assert.equal(f.offered.players.flatMap(p => p.traitorChoices).length, 0);
assert.equal(f.afterSetup.players.some(p => p.faction === 'moritani'), false);
assert.equal(f.afterFirstStorm.techTokens ? ownedTech(f.afterFirstStorm.techTokens, f.owner).length : 0, options.tech ? 1 : 0);
if (options.stronghold) {
assert.equal(f.beforeFirstMentat.strongholdCards!.owners[f.territory], null);
assert.equal(f.afterFirstMentat.strongholdCards!.owners[f.territory], f.owner);
}
assert.equal(f.closingDraw.before.nexusCards!.cards!.hands[f.owner], null);
assert.ok(f.closingDraw.before.nexusCards!.phase!.eligible.includes(f.owner));
assert.equal(player(f.closingDraw.before, f.opponent).ally, f.observer);
assert.equal(player(f.closingDraw.before, f.observer).ally, f.opponent);
assert.equal(player(f.closingDraw.before, f.owner).ally, null);
const drawn = step(f.closingDraw.before, f.closingDraw.step);
assert.equal(drawn.nexusCards!.cards!.hands[f.owner], 'ecaz');
assert.equal(drawn.nexusCards!.cards!.deck.includes('ecaz'), false);
custody(drawn);
assert.equal(f.closingDraw.after.nexusCards!.cards!.hands[f.owner], 'ecaz');
assert.equal(f.beforeCunning.dukeVidal!.controller, null);
assert.equal(f.beforeCunning.dukeVidal!.leader.dead, false);
assert.equal(viewGame(f.beforeCunning, f.opponent).nexusEcazDuke, null);
assert.equal(viewGame(f.beforeCunning, f.observer).nexusEcazDuke, null);
reject(f.beforeCunning, f.opponent, f.cunning.step.action);
reject(f.beforeCunning, f.owner, { type: 'nexusEcazDuke', event: `${viewGame(f.beforeCunning, f.owner).nexusEcazDuke!.event}-stale` });
const acquired = step(f.beforeCunning, f.cunning.step);
assert.deepEqual(acquired, f.cunning.after);
assert.deepEqual([acquired.dukeVidal!.controller, acquired.dukeVidal!.source, acquired.dukeVidal!.acquiredTurn], [f.owner, 'ecazNexus', acquired.turn]);
assert.equal(acquired.nexusCards!.cards!.hands[f.owner], null);
assert.equal(acquired.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
assert.equal(acquired.nexusEcazDukeHistory!.length, 1);
assert.equal(acquired.nexusEcazDukeHistory![0].before.controller, null);
assert.deepEqual(acquired.leaderSkills, f.beforeCunning.leaderSkills);
assert.deepEqual(acquired.players.map(p => p.leaders), f.beforeCunning.players.map(p => p.leaders));
assert.equal(player(acquired, f.owner).leaders.length, 5);
assert.ok(acquired.players.every(p => p.leaders.every(l => l.id !== DUKE_VIDAL_ID)));
assert.ok(viewGame(acquired, f.owner).players.find(p => p.id === f.owner)!.leaders.some(l => l.id === DUKE_VIDAL_ID));
assert.ok(viewGame(acquired, f.owner).leaderSkills!.eligibleLeaders.every(l => l.id !== DUKE_VIDAL_ID));
reject(acquired, f.owner, f.cunning.step.action);
custody(acquired);
}
});
void test('normal Warmaster discipline composes real Nexus Duke6, winner cleanup and mandatory original Tech; selected training never transfers to Duke', () => {
for (const options of profiles) for (const band of ['normal', 'skilled'] as const) {
const f = fixture({ ...options, band });
const assigned = structuredClone(trainer(f.game, f.owner)), native = structuredClone(player(f.game, f.owner).leaders);
const revealed = reveal(f.game, f.plans), source = facts(revealed, f.owner, f.opponent);
const winner = band === 'normal' ? f.owner : f.opponent;
let game = settle(revealed, 'cards');
assert.equal(game.lastBattleContext!.winner, winner);
const winnerUsedCard = f.plans.find(plan => plan.actor === winner)!.action.weapon;
if (winnerUsedCard) {
assert.equal(game.decision?.kind, 'battleCards');
assert.equal(game.decision?.player, winner);
}
assert.equal(game.dukeVidal!.leader.dead, false);
assert.equal(game.dukeVidal!.leader.usedAt, f.territory);
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn], [null, null, null]);
assert.deepEqual(player(game, f.owner).leaders, native);
assert.deepEqual(trainer(game, f.owner), assigned);
assert.equal(player(game, f.owner).tanks, winner === f.owner ? 0 : 8);
const enemyDial = f.plans[1].action.dial as number;
assert.equal(player(game, f.opponent).tanks, winner === f.owner ? 8 : enemyDial);
if (winnerUsedCard) {
if (game.techTokens) {
const loser = winner === f.owner ? f.opponent : f.owner;
const token = ownedTech(revealed.techTokens, loser)[0]; assert.ok(token);
reject(game, winner, { type: 'decision', token });
assert.equal(game.techTokens[token].owner, loser);
}
game = settle(act(game, winner, { type: 'decision', discard: [] }));
}
if (game.techTokens) {
const loser = winner === f.owner ? f.opponent : f.owner;
for (const token of ownedTech(revealed.techTokens, loser)) assert.equal(game.techTokens[token].owner, winner);
}
const worthless = source.ownHand.find(c => c.kind === 'worthless')!.id;
assert.equal(player(game, f.owner).hand.some(c => c.id === worthless), winner === f.owner);
assert.equal(game.discard.some(c => c.id === worthless), winner !== f.owner);
assert.equal(game.battle, null);
if (game.strongholdCards) {
assert.equal(game.strongholdCards.owners[f.territory], f.owner, 'Held cards do not transfer at battle resolution.');
assert.equal(controls(game)[f.territory], winner, 'Current sole physical control is independent of retained card custody.');
}
const afterExpiry = expire(game);
assert.equal(afterExpiry.dukeVidal!.leader.usedAt, undefined);
assert.equal(afterExpiry.dukeVidal!.controller, null);
assert.deepEqual(trainer(afterExpiry, f.owner), assigned);
if (game.strongholdCards) assert.equal(afterExpiry.strongholdCards!.owners[f.territory], winner);
custody(afterExpiry);
}
});
void test('normal Suk on the actual Duke rescues one real dial casualty with and without held Arrakeen subsidy', () => {
for (const options of profiles) {
const f = fixture({ ...options, skill: 'suk-graduate', band: 'normal', ownerDial: 5, opponentDial: 0 });
const revealed = reveal(f.game, f.plans), source = facts(revealed, f.owner, f.opponent);
let game = settle(revealed, 'cards');
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(game.pendingSukRescue, null, 'The face-up normal skill is automatic and never borrows the selected-disc three-force rescue.');
assert.equal(player(game, f.owner).forces[f.location], 3);
assert.equal(player(game, f.owner).reserves, source.ownReserves + 1);
assert.equal(player(game, f.owner).tanks, 4);
assert.equal(player(game, f.opponent).tanks, 8);
assert.equal(player(game, f.owner).spice, source.ownWallet - (f.game.advanced ? options.stronghold ? 3 : 5 : 0));
assert.equal(game.dukeVidal!.leader.dead, false);
assert.equal(game.dukeVidal!.leader.usedAt, f.territory);
assert.equal(game.dukeVidal!.controller, null);
assert.deepEqual(trainer(game, f.owner), source.assignment);
game = settle(act(game, f.owner, { type: 'decision', discard: [] }));
for (const token of source.enemyTech) assert.equal(game.techTokens![token].owner, f.owner);
custody(game);
}
});
void test('ordinary trained Ecaz Suk keeps one casualty locally and returns two, while unused Nexus Duke expires only at genuine turn end', () => {
for (const options of profiles) {
const f = fixture({ ...options, kind: 'suk', band: 'skilled' });
const revealed = reveal(f.game, f.plans), source = facts(revealed, f.owner, f.opponent);
let game = settle(revealed, 'suk');
assert.equal(game.decision?.kind, 'sukRescue');
if (game.decision?.kind !== 'sukRescue') throw Error('Expected the original selected-disc rescue.');
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 5, elite: 0, paidNormal: game.advanced ? 5 : 0, paidElite: 0 });
assert.equal(game.dukeVidal!.controller, f.owner);
assert.equal(game.dukeVidal!.source, 'ecazNexus');
assert.equal(game.dukeVidal!.leader.usedAt, undefined);
const event = game.decision.event;
reject(game, f.opponent, { type: 'decision', event, choice: 0 });
reject(game, f.owner, { type: 'decision', event: `${event}-stale`, choice: 0 });
reject(game, f.owner, { type: 'decision', event, choice: game.decision.options.length });
const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === f.location); assert.ok(choice >= 0);
game = settle(act(game, f.owner, { type: 'decision', event, choice }), 'cards');
assert.equal(player(game, f.owner).forces[f.location], 4);
assert.equal(player(game, f.owner).reserves, source.ownReserves + 2);
assert.equal(player(game, f.owner).tanks, 2);
assert.equal(game.pendingSukRescue, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
for (const token of source.enemyTech) assert.equal(game.techTokens![token].owner, f.opponent);
game = settle(act(game, f.owner, { type: 'decision', discard: [] }));
assert.equal(game.dukeVidal!.controller, f.owner, 'Winning with an ordinary disc does not consume the unused shared Duke.');
assert.deepEqual(trainer(game, f.owner), source.assignment);
const afterExpiry = expire(game);
assert.deepEqual([afterExpiry.dukeVidal!.controller, afterExpiry.dukeVidal!.source, afterExpiry.dukeVidal!.acquiredTurn], [null, null, null]);
assert.equal(afterExpiry.dukeVidal!.leader.dead, false);
assert.equal(afterExpiry.nexusEcazDukeHistory!.length, 1);
assert.equal(afterExpiry.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
custody(afterExpiry);
}
});
void test('retained Carthag supplies real Duke Shield-as-Snooper, and losing Tuek income and printed Duke6 bounty precede original winner cleanup', () => {
const safe = fixture({ strongholdKind: 'carthag', poison: true, defense: true });
const shield = player(safe.game, safe.owner).hand.find(c => c.kind === 'shield')!.id;
const defended = settle(reveal(safe.game, safe.plans));
assert.equal(defended.lastBattleContext!.winner, safe.owner);
assert.equal(defended.dukeVidal!.leader.dead, false);
assert.equal(player(defended, safe.owner).hand.some(c => c.id === shield), true);
assert.equal(defended.strongholdCards!.owners.carthag, safe.owner);
custody(defended);
const f = fixture({ strongholdKind: 'tueks_sietch', poison: true, opponentDial: 0 });
const revealed = reveal(f.game, f.plans), source = facts(revealed, f.owner, f.opponent);
let game = settle(revealed, 'cards');
assert.equal(game.lastBattleContext!.winner, f.opponent);
assert.equal(game.dukeVidal!.leader.dead, true);
assert.equal(game.dukeVidal!.leader.deaths, 1);
assert.equal(player(game, f.owner).spice, source.ownWallet + 2);
assert.equal(player(game, f.opponent).spice, source.enemyWallet + 6);
assert.equal(player(game, f.owner).tanks, 8);
assert.deepEqual(player(game, f.owner).leaders, source.nativeLeaders);
assert.deepEqual(trainer(game, f.owner), source.assignment);
assert.equal(game.strongholdCards!.owners.tueks_sietch, f.owner);
const weapon = source.enemyHand.find(c => c.kind === 'poison')!.id;
const worthless = source.ownHand.find(c => c.kind === 'worthless')!.id;
game = settle(act(game, f.opponent, { type: 'decision', discard: [] }));
assert.equal(game.discard.some(c => c.id === worthless), true);
assert.equal(player(game, f.opponent).hand.some(c => c.id === weapon), true);
for (const token of ownedTech(revealed.techTokens, f.owner)) assert.equal(game.techTokens![token].owner, f.opponent);
custody(game);
});
void test('held Habbanya wins the real Duke tie and Tabr pays the actual opposing dial independently of cleanup and current control', () => {
const tie = fixture({ strongholdKind: 'habbanya_ridge_sietch', opponentDial: 2 });
const tied = settle(reveal(tie.game, tie.plans), 'cards');
assert.equal(tied.lastBattleContext!.winner, tie.owner);
assert.equal(player(tied, tie.owner).forces[tie.location], 8);
assert.equal(player(tied, tie.opponent).tanks, 8);
assert.equal(tied.strongholdCards!.owners.habbanya_ridge_sietch, tie.owner);
assert.equal(controls(tied).habbanya_ridge_sietch, tie.owner);
const completeTie = settle(act(tied, tie.owner, { type: 'decision', discard: [] }));
custody(completeTie);
const f = fixture({ strongholdKind: 'sietch_tabr' });
const revealed = reveal(f.game, f.plans), source = facts(revealed, f.owner, f.opponent);
const opposingDial = f.plans[1].action.dial as number;
let game = settle(revealed, 'cards');
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).spice, source.ownWallet + opposingDial);
assert.equal(player(game, f.opponent).spice, source.enemyWallet - opposingDial);
assert.equal(player(game, f.owner).forces[f.location], 8);
assert.equal(player(game, f.opponent).tanks, 8);
assert.equal(game.dukeVidal!.controller, null);
assert.equal(game.dukeVidal!.leader.usedAt, f.territory);
assert.deepEqual(trainer(game, f.owner), source.assignment);
for (const token of source.enemyTech) assert.equal(game.techTokens![token].owner, f.opponent);
game = settle(act(game, f.owner, { type: 'decision', discard: [] }));
for (const token of source.enemyTech) assert.equal(game.techTokens![token].owner, f.owner);
custody(game);
});
void test('all four minimal legal policies execute native Cunning, trained rescue, winner cards and original Tech without inventing a plan', () => {
const f = fixture({ kind: 'suk', band: 'skilled' });
const revealed = reveal(f.game, f.plans), rescue = settle(revealed, 'suk');
for (const difficulty of DIFFICULTIES) {
const cunning = act(f.beforeCunning, f.owner, policy(f.beforeCunning, f.owner, difficulty));
assert.equal(cunning.dukeVidal!.controller, f.owner);
assert.equal(cunning.nexusCards!.cards!.hands[f.owner], null);
let game = settle(act(rescue, f.owner, policy(rescue, f.owner, difficulty)), 'cards');
assert.equal(game.pendingSukRescue, null);
assert.equal(player(game, f.owner).forces[f.location], 4);
assert.equal(player(game, f.owner).tanks, 2);
assert.equal(player(game, f.owner).reserves, player(revealed, f.owner).reserves + 2);
game = settle(act(game, f.owner, policy(game, f.owner, difficulty)));
assert.equal(game.battle, null);
assert.equal(game.lastBattleContext!.winner, f.owner);
for (const token of ownedTech(revealed.techTokens, f.opponent)) assert.equal(game.techTokens![token].owner, f.owner);
assert.deepEqual(trainer(game, f.owner), trainer(revealed, f.owner));
custody(game);
}
});
void test('each of the five genuine native discs may take its actual offered skill, but foreign discs and Duke cannot be assigned', () => {
const offered = initialize({ tech: false, stronghold: false });
const owner = offered.players.find(p => p.faction === 'ecaz')!.id;
const offer = offered.leaderSkills!.offers[owner];
assert.ok(offer.cards.includes('warmaster'));
const native = player(offered, owner).leaders;
assert.equal(native.length, 5);
for (const disc of native) {
const game = complete(offered, { leader: disc.id });
assert.equal(trainer(game, owner).leader, disc.id);
assert.equal(trainer(game, owner).skill, 'warmaster');
custody(game);
}
for (const leader of [DUKE_VIDAL_ID, offered.players.find(p => p.id !== owner)!.leaders[0].id])
reject(offered, owner, { type: 'leaderSkill', event: offer.event, skill: 'warmaster', leader });
const absent = ['warmaster', 'suk-graduate', 'sandmaster', 'planetologist'] as const;
const skill = absent.find(c => !offer.cards.includes(c)); assert.ok(skill);
const before = structuredClone(offered);
assert.throws(() => complete(offered, { skill }));
assert.deepEqual(offered, before, 'Original own-seat offers cannot be redealt to satisfy a fixture request.');
});
void test('quiet-boundary custody guards remain pure and reject receipt creation; exceptional recovery is not staged as played history', () => {
const f = fixture({ tech: false, stronghold: false });
const g = f.beforeCunning, duke = g.dukeVidal!, event = viewGame(g, f.owner).nexusEcazDuke!.event;
// Quote-only hypothetical inputs, NOT accepted games, fabricated acquisitions,
// resurrected leaders or completed receipts. Actual runtime sources above are
// unchanged; each rejected alternative must leave the physical card unspent.
const alternatives: Game[] = [
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, dead: true } } },
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, capturedBy: f.opponent } } },
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, gholaBy: f.opponent } } },
{ ...g, dukeVidal: { ...duke, leader: { ...duke.leader, usedAt: f.territory } } },
{ ...g, dukeVidal: { ...duke, controller: f.owner } },
{ ...g, players: g.players.map(p => p.id === f.owner ? { ...p, ally: f.opponent } : p) },
{ ...g, phase: 5 },
{ ...g, battle: f.game.battle },
];
for (const input of alternatives) {
assert.ok(quoteNexusEcazDuke(input, f.owner)?.blocked);
assert.throws(() => createNexusEcazDukeReceipt(input, f.owner));
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'ecaz');
assert.equal(g.nexusEcazDukeHistory?.length ?? 0, 0);
}
reject(g, f.owner, { type: 'nexusEcazDuke', event: `${event}-stale` });
reject(f.game, f.owner, f.cunning.step.action);
custody(g);
});
void test('original human lobby and setup IDs/offer stock survive continuation; two-seat play never fabricates an unallied closing draw', () => {
const initial = createGame('HUMANECAZNEXUS', newPlayer('human-ecaz', 'Human Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(initial, newPlayer('human-emperor', 'Human Emperor', 'emperor'));
joinGame(initial, newPlayer('human-atreides', 'Human Atreides', 'atreides'));
const before = structuredClone(initial), f = fixture({ initial, tech: true, stronghold: true });
assert.deepEqual(initial, before);
assert.deepEqual(f.afterSetup.players.map(p => p.id), initial.players.map(p => p.id));
assert.equal(f.owner, 'human-ecaz'); assert.equal(f.opponent, 'human-emperor');
assert.deepEqual(initialize({ initial: f.offered }), f.offered);
custody(f.game);
const two = createGame('TWOECAZNEXUS', newPlayer('human-two-ecaz', 'Human Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(two, newPlayer('human-two-emperor', 'Human Emperor', 'emperor'));
const offered = initialize({ initial: two, tech: false, stronghold: true });
const game = complete(offered);
assert.equal(game.players.length, 2);
assert.ok(game.players.every(p => game.nexusCards!.cards!.hands[p.id] === null));
custody(game);
assert.throws(() => fixture({ initial: two, tech: false, stronghold: true }));
assert.throws(() => initialize({ initial: two, tech: true, stronghold: true }));
const basic = createGame('BASICECAZNEXUS', newPlayer('b-ecaz', 'Ecaz', 'ecaz'), false, ['ecaz']);
joinGame(basic, newPlayer('b-emperor', 'Emperor', 'emperor'));
assert.throws(() => initialize({ initial: basic, tech: false, stronghold: true }));
for (const faction of ['harkonnen', 'moritani', 'ixians', 'choam'] as const) {
const expansions = faction === 'ixians' ? ['ecaz', 'ix'] : faction === 'choam' ? ['ecaz', 'choam'] : ['ecaz'];
const unsupported = createGame('BOUNDARYECAZNEXUS', newPlayer('e', 'Ecaz', 'ecaz'), true, expansions);
joinGame(unsupported, newPlayer('x', faction, faction));
assert.throws(() => initialize({ initial: unsupported, tech: false, stronghold: false }));
}
assert.throws(() => initialize({ initial: f.afterSetup }));
const basicHark = createGame('BASICHARKECAZNEXUS', newPlayer('e', 'Ecaz', 'ecaz'), false, ['ecaz']);
joinGame(basicHark, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(basicHark, newPlayer('a', 'Atreides', 'atreides'));
const nativeBasic = complete(initialize({ initial: basicHark, tech: false, stronghold: false }));
assert.equal(nativeBasic.players.find(p => p.faction === 'harkonnen')!.traitors.length, 4);
custody(nativeBasic);
});
void test('original six-seat Basic and Advanced Skills/Nexus retain full native inventories with unused optional module bands', () => {
for (const advanced of [false, true]) {
const initial = createGame('SIXECAZNEXUS', newPlayer('six-ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
for (const faction of ['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit'] as const)
joinGame(initial, newPlayer(`six-${faction}`, faction, faction));
const offered = initialize({ initial, tech: true, stronghold: advanced });
const game = complete(offered);
assert.equal(game.leaderSkills!.assignments.length, 6);
assert.equal(player(game, 'six-ecaz').leaders.length, 5);
if (advanced) {
for (const [actor, total] of [['six-fremen', 3], ['six-emperor', 5]] as const) {
const elites = player(game, actor).elites; assert.ok(elites);
assert.equal(elites.reserves + elites.tanks + Object.values(elites.forces).reduce((sum, n) => sum + n, 0), total);
}
} else assert.ok(game.players.every(p => !p.elites));
const storm = advance(game, g => g.phase === 1 && !g.response && !g.phaseOpening && !g.decision);
assert.equal(storm.techTokens!.production.owner, 'six-fremen');
const owners = Object.values(storm.techTokens!).map(token => token.owner);
assert.ok(owners.every(owner => owner !== null && storm.players.some(p => p.id === owner)));
assert.equal(new Set(owners).size, 3, 'Actual first Storm assigns the remaining tokens to distinct native seats, not to all six players.');
custody(storm);
}
});
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import {
advanceEcazNexusSkillsModules as advance, assertEcazNexusSkillsModulesCustody as custody,
createEcazNexusSkillsModulesFixture as fixture, ecazNexusSkillsModulesClean as clean,
ecazNexusSkillsModulesPlayer as player, holdEcazNexusSkillsModulesCard as hold,
nextEcazNexusSkillsModulesStep as next, placeEcazNexusSkillsModulesForce as place,
revealEcazNexusSkillsModulesBattle as reveal, settleEcazNexusSkillsModulesBattle as settle,
stepEcazNexusSkillsModules as step,
} from './fixture-ecaz-nexus-skills-modules';
function coalition(tech: boolean, stronghold: boolean, trained: boolean,
skill: 'suk-graduate' | 'diplomat' = 'suk-graduate', losing = false) {
const f = fixture({ rules: 'advanced', tech, stronghold, skill, kind: 'suk', band: trained ? 'skilled' : 'normal' });
let game = structuredClone(f.beforeAlliance);
// A real alternate Nexus alliance, not a post-Battle rewritten side.
game = applyAction(game, f.owner, { type: 'alliance', target: f.observer });
game = applyAction(game, f.observer, { type: 'alliance', target: f.owner });
game = advance(game, g => g.phase === 5 && clean(g));
const staging: string[] = [];
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((sum, amount) => sum + amount, 0);
p.forces = {};
if (p.elites) {
p.elites.reserves += Object.values(p.elites.forces).reduce((sum, amount) => sum + amount, 0);
p.elites.forces = {};
}
game.deck.push(...p.hand); p.hand = [];
}
// Conserved original counters/cards only. No phase, wallet, skill or receipt assigned.
const location = 'arrakeen:10';
place(game, f.owner, location, 5, staging);
place(game, f.observer, location, 4, staging);
place(game, f.opponent, location, 8, staging);
const worthless = hold(game, f.owner, c => c.kind === 'worthless', staging);
game = advance(game, g => g.phase === 6 && clean(g));
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === 'arrakeen');
assert.ok(choice);
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: 'arrakeen',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
assert.equal(game.decision?.kind, 'ecazBattleLead');
game = applyAction(game, f.owner, { type: 'decision', event: game.decision!.event, lead: f.owner });
for (let n = 0; n < 100; n++) {
if (game.battle && clean(game) && !game.battle.preparation) break;
const action = next(game); assert.ok(action);
if (action.action.type === 'leaderSkillVisibility' && action.actor === f.owner) action.action.hide = trained;
game = step(game, action);
}
assert.ok(game.battle && clean(game) && !game.battle.preparation);
const own = player(game, f.owner), enemy = player(game, f.opponent);
const leader = trained ? f.trainer : own.leaders.find(l => l.id !== f.trainer && l.strength === 4 && !l.dead)!.id;
const opponentLeader = enemy.leaders.find(l => l.strength === 6 && !l.dead)!.id;
const before = structuredClone(game);
game = reveal(game, [
{ actor: f.owner, action: { type: 'battlePlan', leader, dial: 5, support: 2, weapon: worthless.id } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: opponentLeader, dial: losing ? 8 : 0, support: losing ? 8 : 0 } },
]);
return { f, before, game, location };
}
void test('Ecaz-led Occupy normal Suk redirects its own fixed casualty, never the ally variable pool', () => {
for (const [tech, stronghold] of [[false, false], [true, false], [false, true], [true, true]]) {
const c = coalition(tech, stronghold, false);
const game = settle(c.game, 'cards');
assert.equal(game.lastBattleContext!.winner, c.f.owner);
assert.equal(player(game, c.f.owner).tanks - player(c.before, c.f.owner).tanks, 2);
assert.equal(player(game, c.f.owner).reserves - player(c.before, c.f.owner).reserves, 1);
assert.equal(player(game, c.f.owner).forces[c.location], 2);
assert.equal(player(game, c.f.observer).tanks - player(c.before, c.f.observer).tanks, 2);
assert.equal(player(game, c.f.observer).forces[c.location], 2);
assert.equal(game.pendingSukRescue, null);
custody(game);
}
});
void test('Ecaz-led Occupy trained Suk saves its fixed three after original ally losses and survives JSON continuation', () => {
const c = coalition(true, true, true);
let game = settle(c.game, 'suk');
assert.equal(game.decision?.kind, 'sukRescue');
assert.equal(player(game, c.f.observer).tanks - player(c.before, c.f.observer).tanks, 2);
assert.equal(player(game, c.f.owner).forces[c.location], 5);
const choice = game.decision!;
assert.equal(choice.kind, 'sukRescue');
if (choice.kind !== 'sukRescue') throw Error('Expected real rescue');
const option = choice.options.findIndex(o => o.normal === 3); assert.ok(option >= 0);
game = applyAction(JSON.parse(JSON.stringify(game)) as Game, c.f.owner,
{ type: 'decision', event: choice.event, choice: option });
assert.equal(player(game, c.f.owner).tanks, player(c.before, c.f.owner).tanks);
assert.equal(player(game, c.f.owner).reserves - player(c.before, c.f.owner).reserves, 2);
assert.equal(player(game, c.f.owner).forces[c.location], 3);
assert.equal(player(game, c.f.observer).forces[c.location], 2);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
custody(game);
});
void test('Ecaz-led Occupy Diplomat retreats only its own undialed remainder, not the ally dial', () => {
const c = coalition(true, true, true, 'diplomat', true);
let game = advance(c.game, g => g.decision?.kind === 'diplomatRetreat' || !g.battle && clean(g));
assert.equal(game.decision?.kind, 'diplomatRetreat');
if (game.decision?.kind !== 'diplomatRetreat') throw Error('Expected actual own-force retreat');
const decision = game.decision;
const destination = decision.destinations.find(d => d.choices.some(o => o.normal === 2 && o.elite === 0));
assert.ok(destination);
const before = structuredClone(game);
assert.throws(() => applyAction(game, c.f.owner, { type: 'decision', event: decision.event,
destination: destination.location, normal: 3, elite: 0 }));
assert.deepEqual(game, before);
game = applyAction(JSON.parse(JSON.stringify(game)) as Game, c.f.owner,
{ type: 'decision', event: decision.event, destination: destination.location, normal: 2, elite: 0 });
game = settle(game);
assert.equal(player(game, c.f.owner).forces[destination.location], 2);
assert.equal(player(game, c.f.owner).forces[c.location], undefined);
assert.equal(player(game, c.f.owner).tanks - player(c.before, c.f.owner).tanks, 3);
assert.equal(player(game, c.f.observer).tanks - player(c.before, c.f.observer).tanks, 4);
assert.equal(game.lastBattleContext!.winner, c.f.opponent);
custody(game);
});
+354
View File
@@ -0,0 +1,354 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
import { ownedTech } from '../game/tech-tokens';
import type { Card } from '../game/cards';
import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills';
import {
assertEcazSkillsModulesCustody, ecazSkillsModulesAction, ecazSkillsModulesPlayer,
ecazSkillsModulesTrainer, nextEcazSkillsModulesStep, reloadEcazSkillsModules,
} from './fixture-ecaz-skills-modules';
import type { StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
/** Existing source composition, not an exceptional-Duke or allied-Occupy ruling.
* GF9 E3 pp.3/8/11/16 and publisher Ecaz Nexus face: docs/NEXUS_ECAZ_RULES.md.
* All14 normal/selected-disc effects: docs/LEADER_SKILLS_RULES.md.
* Original held-card effects/lifecycle: docs/STRONGHOLD_CARDS.md.
* The quiet living/unclaimed Duke window is the existing product boundary;
* printed dead/captured/Ghola recovery and shared-Duke training remain pending.
*/
export const ECAZ_NEXUS_SKILLS_MODULES_SOURCES = {
nexus: 'docs/NEXUS_ECAZ_RULES.md#bounded-native-cunning-duke-vidal',
skills: 'docs/LEADER_SKILLS_RULES.md',
modules: 'docs/STRONGHOLD_CARDS.md',
publisher: 'https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf',
face: 'https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani',
} as const;
export type EcazNexusSkillsModulesStep = StrongholdFactionsNativeStep;
export type EcazNexusSkillsModulesOptions = {
/** Only the actual undealt lobby or original unassigned skill-first setup. */
initial?: Game;
rules?: 'basic' | 'advanced';
tech?: boolean;
stronghold?: boolean;
skill?: LeaderSkillId;
/** A genuine eligible native disc; never the separate Duke. */
leader?: string;
kind?: 'duke' | 'suk';
band?: 'normal' | 'skilled';
strongholdKind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
poison?: boolean;
defense?: boolean;
ownerDial?: number;
opponentDial?: number;
};
export type EcazNexusSkillsModulesTransition = {
before: Game; step: EcazNexusSkillsModulesStep; after: Game;
};
export type EcazNexusSkillsModulesFixture = {
initial: Game; offered: Game; afterSetup: Game; afterFirstStorm: Game;
beforeFirstMentat: Game; firstMentatStep: EcazNexusSkillsModulesStep; afterFirstMentat: Game;
beforeAlliance: Game; allianceActions: EcazNexusSkillsModulesStep[];
closingDraw: EcazNexusSkillsModulesTransition;
beforeCunning: Game; cunning: EcazNexusSkillsModulesTransition;
beforeBattle: Game; posture: Game; game: Game;
owner: string; opponent: string; observer: string; trainer: string;
territory: Exclude<StrongholdId, 'hidden_mobile_stronghold'>; location: string;
plans: EcazNexusSkillsModulesStep[]; actions: EcazNexusSkillsModulesStep[]; staging: string[];
};
export {
ecazSkillsModulesAction as ecazNexusSkillsModulesAction,
ecazSkillsModulesPlayer as ecazNexusSkillsModulesPlayer,
ecazSkillsModulesTrainer as ecazNexusSkillsModulesTrainer,
reloadEcazSkillsModules as reloadEcazNexusSkillsModules,
};
export const ecazNexusSkillsModulesClean = (g: Game): boolean => !g.response && !g.phaseOpening && !g.decision;
export function assertEcazNexusSkillsModulesCustody(game: Game): void {
assertEcazSkillsModulesCustody(game);
const skills = game.leaderSkills!;
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards),
...skills.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
validateNexusCards(game.nexusCards!.cards!, game.players);
const cards = game.nexusCards!.cards!;
assert.deepEqual([...cards.deck, ...cards.discard,
...Object.values(cards.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
}
export function nextEcazNexusSkillsModulesStep(game: Game): EcazNexusSkillsModulesStep | null {
if (ecazNexusSkillsModulesClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id));
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
if (!game.response && !game.phaseOpening && game.decision?.kind === 'wormRide')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
return nextEcazSkillsModulesStep(game);
}
export function stepEcazNexusSkillsModules(game: Game, next: EcazNexusSkillsModulesStep,
actions?: EcazNexusSkillsModulesStep[]): Game {
actions?.push(structuredClone(next));
return ecazSkillsModulesAction(game, next.actor, next.action);
}
export function advanceEcazNexusSkillsModules(state: Game, until: (g: Game) => boolean,
actions?: EcazNexusSkillsModulesStep[], firstDial = 0): Game {
let game = reloadEcazSkillsModules(state);
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.equal(game.status, 'playing');
const next = nextEcazNexusSkillsModulesStep(game);
assert.ok(next, 'An actual private battle plan must be supplied by its owner.');
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = firstDial;
game = stepEcazNexusSkillsModules(game, next, actions);
}
throw Error('Original Ecaz Nexus/Skills chronology did not reach its declared boundary.');
}
const CLASSIC_OPPONENTS: Partial<Record<FactionId, true>> = {
atreides: true, emperor: true, guild: true, fremen: true, beneGesserit: true, harkonnen: true,
};
function lobby(options: EcazNexusSkillsModulesOptions): Game {
const rules = options.rules ?? (options.initial?.advanced === false ? 'basic' : 'advanced');
let game = options.initial ? reloadEcazSkillsModules(options.initial)
: createGame('ECAZNEXUSSKILLSMODULES', newPlayer('ecaz', 'Ecaz', 'ecaz'), rules === 'advanced', ['ecaz']);
if (!options.initial) for (const faction of ['emperor', 'atreides'] as const) joinGame(game, newPlayer(faction, faction, faction));
const tech = options.tech ?? (options.initial ? !!game.techTokens : true);
const stronghold = options.stronghold ?? (options.initial ? !!game.strongholdCards : rules === 'advanced');
assert.equal(game.advanced, rules === 'advanced');
assert.deepEqual(game.expansions, ['ecaz']);
assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
assert.ok(!stronghold || game.advanced);
assert.equal(game.players.filter(p => p.faction === 'ecaz').length, 1);
assert.ok(game.players.every(p => p.faction === 'ecaz' ||
CLASSIC_OPPONENTS[p.faction] === true && (!game.advanced || p.faction !== 'harkonnen')));
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
game.setupStage === 'leaderSkills' && game.leaderSkills?.assignments.length === 0,
'Continue only an original undealt lobby or unassigned skill-first setup.');
if (game.status === 'lobby') {
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== stronghold) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: stronghold });
// Same unused lobby-module declaration as the original Nexus/Skills fixtures.
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
} else {
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, stronghold);
assert.ok(game.nexusCards, 'Do not convert a dealt setup into a Nexus game.');
}
return game;
}
export function initializeEcazNexusSkillsModules(options: EcazNexusSkillsModulesOptions = {}): Game {
const game = lobby(options);
return initializeAdvancedNativeSkillsSetup({ family: 'ecaz', initial: game,
rules: game.advanced ? 'advanced' : 'basic', requestedSkill: options.skill ?? (options.kind === 'suk' ? 'suk-graduate' : 'warmaster') });
}
/** Original own-seat offers/IDs are authoritative; a requested missing skill is
* an error, never a redeal, a fallback assignment or a forged selected effect. */
export function completeEcazNexusSkillsModulesSetup(state: Game, options: EcazNexusSkillsModulesOptions = {},
actions?: EcazNexusSkillsModulesStep[]): Game {
let game = reloadEcazSkillsModules(state);
const owner = game.players.find(p => p.faction === 'ecaz')!.id;
const requested = options.skill ?? (options.kind === 'suk' ? 'suk-graduate' : 'warmaster');
for (let count = 0; game.status === 'setup' && count < 200; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const offer = game.leaderSkills!.offers[actor];
const skill = actor === owner ? requested : offer.cards.find(c => !own.unavailableSkills?.[c]);
const eligible = new Set(own.eligibleLeaders.map(l => l.id));
const discs = ecazSkillsModulesPlayer(game, actor).leaders.filter(l => eligible.has(l.id));
const leader = actor === owner && options.leader ? discs.find(l => l.id === options.leader)
: discs.sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader);
game = stepEcazNexusSkillsModules(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions);
} else {
const next = nextEcazNexusSkillsModulesStep(game); assert.ok(next);
game = stepEcazNexusSkillsModules(game, next, actions);
}
}
assert.equal(game.status, 'playing');
assertEcazNexusSkillsModulesCustody(game);
return game;
}
function reserveBoard(game: Game, staging: string[]): void {
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {};
if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); p.elites.forces = {}; }
if (p.advisors) p.advisors = {};
}
staging.push('Conserve all native board counters into their own reserves; no wallet, phase, turn, module owner, skill, receipt or Duke custody is assigned.');
}
export function placeEcazNexusSkillsModulesForce(game: Game, actor: string, location: string, amount: number, staging: string[]): void {
const p = ecazSkillsModulesPlayer(game, actor);
assert.ok(Number.isSafeInteger(amount) && amount >= 0 && p.reserves >= amount);
p.reserves -= amount; p.forces[location] = (p.forces[location] ?? 0) + amount;
staging.push(`Conserved ${amount} ordinary ${actor} counters from their own reserves to ${location}.`);
}
function orderSpice(game: Game, kinds: ('worm' | 'land')[], staging: string[]): void {
const front = kinds.map(kind => {
const index = game.spiceDeck.findIndex(c => kind === 'worm' ? 'worm' in c : 'territory' in c); assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
});
game.spiceDeck.unshift(...front);
staging.push(`Order only existing unplayed original Spice Cards: ${kinds.join(', ')}; played discards remain unchanged.`);
}
export function holdEcazNexusSkillsModulesCard(game: Game, actor: string, predicate: (c: Card) => boolean, staging: string[]): Card {
const p = ecazSkillsModulesPlayer(game, actor), held = p.hand.find(predicate);
if (held) return held;
const index = game.deck.findIndex(predicate); assert.ok(index >= 0 && p.hand.length < handLimit(p));
const card = game.deck.splice(index, 1)[0]; p.hand.push(card);
staging.push(`Conserved original Ecaz33 ${card.id} from the remaining physical deck to ${actor}'s hand.`);
return card;
}
export function ecazNexusSkillsModulesCunningRequest(game: Game, owner: string): EcazNexusSkillsModulesStep {
const offer = viewGame(game, owner).nexusEcazDuke; assert.ok(offer && offer.blocked === null);
return { actor: owner, action: { type: 'nexusEcazDuke', event: offer.event } };
}
/** Stop before the actual human posture choice, never manufacture visibility. */
export function openEcazNexusSkillsModulesPosture(game: Game, owner: string, opponent: string,
stronghold: string, actions?: EcazNexusSkillsModulesStep[]): Game {
assert.ok(game.phase === 6 && ecazNexusSkillsModulesClean(game) && !game.battle);
assert.ok(game.active === owner || game.active === opponent);
const opened = stepEcazNexusSkillsModules(game, { actor: game.active!, action: {
type: 'chooseBattle', territory: stronghold, target: game.active === owner ? opponent : owner } }, actions);
return advanceEcazNexusSkillsModules(opened, g => g.decision?.kind === 'leaderSkillVisibility', actions);
}
export function advanceEcazNexusSkillsModulesToPlans(game: Game, actions?: EcazNexusSkillsModulesStep[]): Game {
return advanceEcazNexusSkillsModules(game, g => !!g.battle && ecazNexusSkillsModulesClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, actions);
}
export function revealEcazNexusSkillsModulesBattle(game: Game, plans: readonly EcazNexusSkillsModulesStep[],
actions?: EcazNexusSkillsModulesStep[]): Game {
let state = reloadEcazSkillsModules(game);
for (const plan of plans) {
state = stepEcazNexusSkillsModules(state, plan, actions);
if (!state.battle?.revealed) state = advanceEcazNexusSkillsModulesToPlans(state, actions);
}
assert.ok(state.battle?.revealed);
return state;
}
export function settleEcazNexusSkillsModulesBattle(game: Game,
stop: 'suk' | 'cards' | 'tech' | 'complete' = 'complete', actions?: EcazNexusSkillsModulesStep[]): Game {
return advanceEcazNexusSkillsModules(game, g =>
stop === 'suk' && g.decision?.kind === 'sukRescue' ||
stop === 'cards' && g.decision?.kind === 'battleCards' ||
stop === 'tech' && g.decision?.kind === 'techToken' ||
!g.battle && ecazNexusSkillsModulesClean(g) && !g.pendingTreacheryDiscard, actions);
}
/** Native immutable source facts: no computed/fabricated matching stock. */
export function ecazNexusSkillsModulesSourceFacts(game: Game, owner: string, opponent: string) {
const p = ecazSkillsModulesPlayer(game, owner), enemy = ecazSkillsModulesPlayer(game, opponent);
return structuredClone({
turn: game.turn, phase: game.phase, advanced: game.advanced,
duke: game.dukeVidal, assignment: ecazSkillsModulesTrainer(game, owner),
nativeLeaders: p.leaders, ownWallet: p.spice, enemyWallet: enemy.spice,
ownForces: p.forces, enemyForces: enemy.forces, ownReserves: p.reserves,
ownTanks: p.tanks, enemyTanks: enemy.tanks, ownHand: p.hand, enemyHand: enemy.hand,
heldCards: game.strongholdCards?.owners, controls: strongholdControllers(game.players, false),
enemyTech: ownedTech(game.techTokens, opponent), plans: game.battle?.plans,
losses: game.pendingSukRescue?.losses, battle: game.lastBattleContext,
});
}
export const ecazNexusSkillsModulesCurrentControl = (game: Game) => strongholdControllers(game.players, false);
/** Genuine turn-end readiness, including ordinary set-aside clearing and unused
* Nexus tenure expiry. No phase/turn or custody jump is permitted. */
export function expireEcazNexusSkillsModulesDuke(game: Game, actions?: EcazNexusSkillsModulesStep[]): Game {
const turn = game.turn;
return advanceEcazNexusSkillsModules(game, g => g.turn > turn, actions);
}
/** Native setup -> first Storm -> actual END Mentat -> actual worm/alliance ->
* closing Ecaz draw -> quiet Battle -> living/unclaimed Cunning. Conserved
* staging is labeled below, not claimed to be natural random play history.
* Three seats are needed for a real alliance plus one unallied draw; two-seat
* original setup is supported by initialize/complete, never by a forged draw. */
export function createEcazNexusSkillsModulesFixture(options: EcazNexusSkillsModulesOptions = {}): EcazNexusSkillsModulesFixture {
const actions: EcazNexusSkillsModulesStep[] = [], staging: string[] = [];
const initial = lobby(options), offered = initializeEcazNexusSkillsModules({ ...options, initial });
let game = completeEcazNexusSkillsModulesSetup(offered, options, actions);
const afterSetup = reloadEcazSkillsModules(game);
const owner = game.players.find(p => p.faction === 'ecaz')!.id;
const opponents = game.players.filter(p => p.id !== owner);
const opponent = (opponents.find(p => p.faction === 'emperor') ?? opponents[0]).id;
const observer = opponents.find(p => p.id !== opponent)?.id;
assert.ok(observer, 'A genuine alliance and unallied Ecaz closing draw require three real seats.');
const trainer = ecazSkillsModulesTrainer(game, owner).leader;
const strongholdKind = options.strongholdKind ?? 'arrakeen';
const location = `${strongholdKind}:${territory(strongholdKind).sectors[0]}`;
const firstDial = ['tueks_sietch', 'habbanya_ridge_sietch'].includes(strongholdKind) ? 4 : 0;
reserveBoard(game, staging);
game = advanceEcazNexusSkillsModules(game, g => g.phase === 1 && ecazNexusSkillsModulesClean(g), actions, firstDial);
const afterFirstStorm = reloadEcazSkillsModules(game);
orderSpice(game, ['land', 'land'], staging);
game = advanceEcazNexusSkillsModules(game, g => g.phase === 8 && ecazNexusSkillsModulesClean(g), actions);
reserveBoard(game, staging); placeEcazNexusSkillsModulesForce(game, owner, location, 1, staging);
let beforeFirstMentat: Game | undefined, firstMentatStep: EcazNexusSkillsModulesStep | undefined;
for (let count = 0; game.turn === 1 && count < 200; count++) {
const before = reloadEcazSkillsModules(game), next = nextEcazNexusSkillsModulesStep(game); assert.ok(next);
game = stepEcazNexusSkillsModules(game, next, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
if (game.strongholdCards) assert.equal(game.strongholdCards.owners[strongholdKind], owner);
const afterFirstMentat = reloadEcazSkillsModules(game);
game = advanceEcazNexusSkillsModules(game, g => g.phase === 1 && ecazNexusSkillsModulesClean(g), actions);
orderSpice(game, ['worm', 'land', 'land'], staging);
game = advanceEcazNexusSkillsModules(game, g => g.nexus === true && !g.spiceWindow && !g.spiceResolution && ecazNexusSkillsModulesClean(g), actions);
const beforeAlliance = reloadEcazSkillsModules(game), allianceActions: EcazNexusSkillsModulesStep[] = [
{ actor: opponent, action: { type: 'alliance', target: observer } },
{ actor: observer, action: { type: 'alliance', target: opponent } },
];
for (const next of allianceActions) game = stepEcazNexusSkillsModules(game, next, actions);
game = advanceEcazNexusSkillsModules(game, g => g.nexusCards?.phase?.stage === 'drawing' && ecazNexusSkillsModulesClean(g), actions);
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('ecaz'); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
staging.push('Order the unused original Ecaz Nexus singleton first immediately before its genuine closing draw; no Nexus hand is assigned.');
const drawBefore = reloadEcazSkillsModules(game), drawStep: EcazNexusSkillsModulesStep = { actor: owner,
action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
game = stepEcazNexusSkillsModules(game, drawStep, actions);
const closingDraw = { before: drawBefore, step: drawStep, after: reloadEcazSkillsModules(game) };
game = advanceEcazNexusSkillsModules(game, g => g.phase === 5 && ecazNexusSkillsModulesClean(g), actions);
reserveBoard(game, staging);
for (const actor of [owner, opponent]) placeEcazNexusSkillsModulesForce(game, actor, location, 8, staging);
// Only controlled battle positions move native cards; fresh CLI deals and
// own-seat offers above remain authoritative and are exported unchanged.
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
staging.push('Before the real Movement closes, return original hands to the same Ecaz33 physical deck, then transfer only the requested native battle cards.');
const worthless = holdEcazNexusSkillsModulesCard(game, owner, c => c.kind === 'worthless', staging);
const shield = options.defense ? holdEcazNexusSkillsModulesCard(game, owner, c => c.kind === 'shield', staging) : undefined;
const poison = options.poison ? holdEcazNexusSkillsModulesCard(game, opponent, c => c.kind === 'poison', staging) : undefined;
game = advanceEcazNexusSkillsModules(game, g => g.phase === 6 && ecazNexusSkillsModulesClean(g), actions);
const beforeCunning = reloadEcazSkillsModules(game), cunningStep = ecazNexusSkillsModulesCunningRequest(game, owner);
assert.equal(game.dukeVidal!.controller, null);
game = stepEcazNexusSkillsModules(game, cunningStep, actions);
const cunning = { before: beforeCunning, step: cunningStep, after: reloadEcazSkillsModules(game) };
const beforeBattle = reloadEcazSkillsModules(game);
game = openEcazNexusSkillsModulesPosture(game, owner, opponent, strongholdKind, actions);
const posture = reloadEcazSkillsModules(game), band = options.band ?? (options.kind === 'suk' ? 'skilled' : 'normal');
for (let count = 0; count < 200; count++) {
if (game.battle && ecazNexusSkillsModulesClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) break;
const next = nextEcazNexusSkillsModulesStep(game); assert.ok(next);
if (next.action.type === 'leaderSkillVisibility' && next.actor === owner) next.action.hide = band === 'skilled';
game = stepEcazNexusSkillsModules(game, next, actions);
}
assert.ok(game.battle && ecazNexusSkillsModulesClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false);
const enemy = ecazSkillsModulesPlayer(game, opponent), enemyTrainer = ecazSkillsModulesTrainer(game, opponent).leader;
const defender = enemy.leaders.filter(l => !l.dead && l.id !== enemyTrainer).sort((a, b) =>
options.kind === 'suk' ? a.strength - b.strength : b.strength - a.strength)[0]; assert.ok(defender);
const ownLeader = options.kind === 'suk' ? band === 'skilled' ? trainer
: ecazSkillsModulesPlayer(game, owner).leaders.find(l => l.strength === 4 && l.id !== trainer)!.id : DUKE_VIDAL_ID;
const ownerDial = options.ownerDial ?? (options.kind === 'suk' ? 5 : 0);
const opponentDial = options.opponentDial ?? (options.kind === 'suk' ? 0 : game.battle.attacker === owner ? 2 : 1);
const plans: EcazNexusSkillsModulesStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: ownLeader, dial: ownerDial,
support: game.advanced ? ownerDial : 0, weapon: worthless.id, defense: shield?.id } },
{ actor: opponent, action: { type: 'battlePlan', leader: defender.id, dial: opponentDial,
support: game.advanced ? opponentDial : 0, weapon: poison?.id } },
];
assertEcazNexusSkillsModulesCustody(game);
return { initial, offered, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat,
beforeAlliance, allianceActions, closingDraw, beforeCunning, cunning, beforeBattle, posture, game,
owner, opponent, observer, trainer, territory: strongholdKind, location, plans, actions, staging };
}
@@ -0,0 +1,341 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { Card } from '../game/cards';
import { leaders, treacheryDeck } from '../game/cards';
import { territory } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { TERROR_KINDS, type TerrorKind } from '../game/moritani-terror';
import { nextNexusSkillsModulesBattleStep } from './fixture-nexus-skills-modules-battles';
import {
moritaniSkillsModulesClean, moritaniSkillsModulesPlayer, moritaniSkillsModulesPolicy,
nextMoritaniSkillsModulesStep, revealMoritaniSkillsModulesPlans, stageMoritaniSkillsModulesBattle,
} from './fixture-moritani-skills-modules';
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
import type { StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type MoritaniNexusSkillsModulesStep = StrongholdFactionsNativeStep;
export type MoritaniNexusSkillsModulesOptions = {
/** Original lobby or unassigned turn-one skill setup, never a converted save. */
initial?: Game;
rules?: 'basic' | 'advanced';
tech?: boolean;
strongholds?: boolean;
factions?: readonly FactionId[];
seatIds?: readonly string[];
/** Supplied offers are authoritative; an explicit request must actually be offered. */
guildSkill?: LeaderSkillId;
moritaniSkill?: LeaderSkillId;
holder?: 'moritani' | 'guild';
normalCall?: boolean;
stack?: boolean;
};
export type MoritaniNexusSkillsModulesFixture = {
initial: Game; afterSetup: Game; afterFirstStorm: Game;
beforeFirstMentat: Game; firstMentatStep: MoritaniNexusSkillsModulesStep; afterFirstMentat: Game;
beforeAlliance: Game; allianceActions: MoritaniNexusSkillsModulesStep[];
beforeClosingDraw: Game; closingDrawStep: MoritaniNexusSkillsModulesStep; afterClosingDraw: Game;
beforeBattle: Game; battleAction: MoritaniNexusSkillsModulesStep;
plans: Game; planActions: MoritaniNexusSkillsModulesStep[]; revealed: Game; pending: Game; game: Game;
moritani: string; opponent: string; partner: string; holder: string;
target: string; moritaniLeader: string; opponentLeader: string;
moritaniCards: string[]; opponentCards: string[]; karama: string;
actions: MoritaniNexusSkillsModulesStep[]; staging: string[];
};
export const moritaniNexusSkillsModulesPlayer = moritaniSkillsModulesPlayer;
export const moritaniNexusSkillsModulesPolicy = moritaniSkillsModulesPolicy;
export const moritaniNexusSkillsModulesClean = moritaniSkillsModulesClean;
const snapshot = (game: Game): Game => structuredClone(game);
/** Only the original physical all14 shuffle is controlled, before Traitor deals.
* The remainder of the native initializer retains its original randomness. */
function originalSkillShuffle<T>(selected: LeaderSkillId[], initialize: () => T): T {
assert.equal(new Set(selected).size, selected.length);
const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(c => c.id);
const remainder = working.filter(c => !selected.includes(c));
const desired = selected.flatMap(c => [c, remainder.shift()!]); desired.push(...remainder);
const rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
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 initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function initializeMoritaniNexusSkillsModulesOffers(options: MoritaniNexusSkillsModulesOptions = {}): Game {
const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true;
const tech = options.tech ?? !!options.initial?.techTokens;
const strongholds = options.strongholds ?? !!options.initial?.strongholdCards;
const factions: readonly FactionId[] = options.factions ?? ['moritani', 'guild', 'emperor'];
let game = options.initial ? snapshot(options.initial)
: createGame('MORITANINEXUSSKILLSMODULES', newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), advanced, ['ecaz']);
if (!options.initial) for (const faction of factions.slice(1))
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']);
assert.ok(!game.homeworlds && game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
assert.equal(game.players.filter(p => p.faction === 'moritani').length, 1);
assert.equal(game.players.filter(p => p.faction === 'guild').length, 1);
const allowed: FactionId[] = ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', ...(advanced ? [] : ['harkonnen'] as FactionId[])];
assert.ok(game.players.every(p => allowed.includes(p.faction))); assert.ok(advanced || !strongholds);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
(game.setupStage === 'prediction' || game.setupStage === 'leaderSkills') && game.leaderSkills?.assignments.length === 0,
'Only an original lobby or undealt skill-first setup may continue');
if (game.status === 'setup') {
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
assertMoritaniNexusSkillsModulesCustody(game);
while (game.setupStage === 'prediction') game = stepMoritaniNexusSkillsModules(game);
return game;
}
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
game.nexusCards ??= { cards: null, phase: null };
assert.deepEqual(game.nexusCards, { cards: null, phase: null }); assert.ok(!game.leaderSkills);
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
const guildSkill = options.guildSkill ?? 'suk-graduate', moritaniSkill = options.moritaniSkill ?? 'warmaster';
const passive: LeaderSkillId[] = ['sandmaster', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'swordmaster-of-ginaz', 'planetologist']
.filter(c => c !== guildSkill && c !== moritaniSkill) as LeaderSkillId[];
const selected = game.players.map(p => p.faction === 'guild' ? guildSkill : p.faction === 'moritani' ? moritaniSkill : passive.shift()!);
game = originalSkillShuffle(selected, () => initializeLeaderSkillsGameForAudit(game));
while (game.setupStage === 'prediction') game = stepMoritaniNexusSkillsModules(game);
return game;
}
export function nextMoritaniNexusSkillsModulesStep(game: Game): MoritaniNexusSkillsModulesStep | null {
if (game.response || game.phaseOpening || game.pendingTreacheryDiscard) return nextMoritaniSkillsModulesStep(game);
const d = game.decision;
if (d?.kind === 'moritaniPlacement' || d?.kind === 'moritaniAssassinate' || d?.kind === 'moritaniSetup' || d?.kind === 'leaderSkillVisibility')
return nextMoritaniSkillsModulesStep(game);
if (d?.kind === 'moritaniTerror' && ['select', 'offer'].includes(game.pendingTerrorEntry!.stage))
return { actor: d.player, action: { type: 'decision', decline: true } };
return nextNexusSkillsModulesBattleStep(game);
}
export function stepMoritaniNexusSkillsModules(game: Game, next = nextMoritaniNexusSkillsModulesStep(game), actions?: MoritaniNexusSkillsModulesStep[]): Game {
assert.ok(next, 'An original human sealed Battle Plan is required'); actions?.push(structuredClone(next));
return applyAction(game, next.actor, next.action);
}
export function advanceMoritaniNexusSkillsModules(game: Game, until: (state: Game) => boolean, actions?: MoritaniNexusSkillsModulesStep[]): Game {
for (let n = 0; n < 2000; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished'); game = stepMoritaniNexusSkillsModules(game, undefined, actions);
}
throw Error('Original Moritani Skills/Nexus continuation did not reach its requested boundary');
}
export function completeMoritaniNexusSkillsModulesSetup(game: Game, options: MoritaniNexusSkillsModulesOptions = {}, actions?: MoritaniNexusSkillsModulesStep[]): Game {
for (let n = 0; game.status === 'setup' && n < 240; n++) {
if (game.setupStage !== 'leaderSkills') { game = stepMoritaniNexusSkillsModules(game, undefined, actions); continue; }
const actor = Object.keys(game.leaderSkills!.offers)[0], p = moritaniSkillsModulesPlayer(game, actor);
const own = viewGame(game, actor).leaderSkills!, offer = own.offer!;
const requested = p.faction === 'guild' ? options.guildSkill : p.faction === 'moritani' ? options.moritaniSkill : undefined;
const skill = requested ?? offer.cards.find(c => !own.unavailableSkills?.[c]);
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill], 'Requested skill must be genuinely offered');
const leader = p.faction === 'guild' && skill === 'suk-graduate'
? own.eligibleLeaders.find(l => l.name === 'Master Bewt') : own.eligibleLeaders[0];
assert.ok(leader);
game = stepMoritaniNexusSkillsModules(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions);
}
assert.equal(game.status, 'playing'); return game;
}
export function assertMoritaniNexusSkillsModulesCustody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players); validateNexusCards(game.nexusCards!.cards!, game.players);
assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
...game.leaderSkills!.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
const nexus = game.nexusCards!.cards!;
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
for (const p of game.players) {
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'emperor' ? 5 : 3);
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
}
}
if (game.moritaniTerror) {
assert.deepEqual(game.moritaniTerror.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort());
assert.equal(new Set(game.moritaniTerror.tokens.map(t => t.id)).size, 6);
}
}
function reserveBoard(game: Game, staging: string[]) {
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0); p.forces = {};
if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0); p.elites.forces = {}; }
if (p.advisors) p.advisors = {};
}
staging.push('Conserved original board counters through their own reserves; wallets, phase, turn, skill effects and earned module custody unchanged.');
}
function place(game: Game, actor: string, site: string, count: number, staging: string[]) {
const p = moritaniSkillsModulesPlayer(game, actor); assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= count);
p.reserves -= count; const key = `${site}:${territory(site).sectors[0]}`; p.forces[key] = (p.forces[key] ?? 0) + count;
staging.push(`Conserved ${count} original ordinary ${actor} counters: own reserves → ${key}.`);
}
export function holdMoritaniNexusSkillsModulesCard(game: Game, actor: string, kind: Card['kind'] | 'karama', staging: string[] = [], excluded: string[] = []): string {
const p = moritaniSkillsModulesPlayer(game, actor);
const matches = (c: Card) => (c.kind === kind || c.effect === kind) && !excluded.includes(c.id);
const held = p.hand.find(matches); if (held) return held.id;
const index = game.deck.findIndex(matches);
const donor = index < 0 ? game.players.find(p => p.id !== actor && p.hand.some(matches)) : undefined;
assert.ok((index >= 0 || donor) && p.hand.length < handLimit(p));
const card = index >= 0 ? game.deck.splice(index, 1)[0] : donor!.hand.splice(donor!.hand.findIndex(matches), 1)[0];
p.hand.push(card);
staging.push(`Conserved original ecaz33 ${card.id}: ${donor?.id ?? 'unused deck'} → ${actor}'s hand; not a natural deal.`); return card.id;
}
function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]) {
const front = kinds.map(kind => {
const index = game.spiceDeck.findIndex(c => kind === 'land' ? 'territory' in c : 'worm' in c); assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
});
game.spiceDeck.unshift(...front); staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; original Discards unchanged.`);
}
export function resolveMoritaniNexusSkillsModulesBattle(game: Game, moritani: string, normalCall = false, actions?: MoritaniNexusSkillsModulesStep[]): Game {
for (let n = 0; n < 160; n++) {
if (game.decision?.kind === 'sukRescue' || game.decision?.kind === 'moritaniAssassinate' ||
!game.battle && moritaniSkillsModulesClean(game) && !game.pendingTreacheryDiscard) return game;
const next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani;
game = stepMoritaniNexusSkillsModules(game, next, actions);
}
throw Error('Original battle did not reach rescue, assassination or settled boundary');
}
export type MoritaniNexusSkillsModulesSettlementBoundary = {
game: Game;
kind: 'losses' | 'rescue' | 'assassination' | 'cards' | 'tech' | 'settled';
};
/** Human physical casualty/rescue/assassination/cleanup/reward choices are never
* skipped to manufacture a later boundary. Apply that actual choice, then resume. */
export function settleMoritaniNexusSkillsModulesBoundary(game: Game, moritani: string, normalCall = false,
actions?: MoritaniNexusSkillsModulesStep[]): MoritaniNexusSkillsModulesSettlementBoundary {
const choices: Partial<Record<NonNullable<Game['decision']>['kind'], MoritaniNexusSkillsModulesSettlementBoundary['kind']>> = {
battleLosses: 'losses', sukRescue: 'rescue', moritaniAssassinate: 'assassination', battleCards: 'cards', techToken: 'tech',
};
for (let n = 0; n < 160; n++) {
const kind = game.decision && choices[game.decision.kind];
if (kind && !game.response && !game.phaseOpening) return { game, kind };
if (!game.battle && moritaniSkillsModulesClean(game) && !game.pendingTreacheryDiscard) return { game, kind: 'settled' };
const next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani;
game = stepMoritaniNexusSkillsModules(game, next, actions);
}
throw Error('Original battle settlement did not reach its next physical human choice');
}
export function finishMoritaniNexusSkillsModulesBattle(game: Game, actions?: MoritaniNexusSkillsModulesStep[]): Game {
return advanceMoritaniNexusSkillsModules(game, s => !s.battle && moritaniSkillsModulesClean(s) && !s.pendingTreacheryDiscard, actions);
}
export const revealMoritaniNexusSkillsModulesPlans = revealMoritaniSkillsModulesPlans;
export const stageMoritaniNexusSkillsModulesBattle = stageMoritaniSkillsModulesBattle;
export function moritaniNexusSkillsModulesToken(game: Game, kind: TerrorKind) {
const token = game.moritaniTerror!.tokens.find(t => t.kind === kind); assert.ok(token); return token;
}
export function moritaniNexusSkillsModulesRequest(game: Game, actor: string, kind: TerrorKind = 'robbery', site = 'red_chasm'): Action {
const offer = viewGame(game, actor).nexusMoritani; assert.ok(offer);
return { type: 'decision', token: moritaniNexusSkillsModulesToken(game, kind).id, territory: site, nexus: offer.event };
}
/** Only pass the persisted native response queue, not a guessed policy actor. */
export function allowMoritaniNexusSkillsModules(game: Game, actions?: MoritaniNexusSkillsModulesStep[]): Game {
for (let n = 0; (game.response || game.phaseOpening || game.pendingKarama) && n < 100; n++)
game = stepMoritaniNexusSkillsModules(game, undefined, actions);
assert.ok(!game.response && !game.phaseOpening && !game.pendingKarama); return game;
}
export function cancelMoritaniNexusSkillsModules(game: Game, actor: string, card: string, actions?: MoritaniNexusSkillsModulesStep[]): Game {
game = stepMoritaniNexusSkillsModules(game, { actor, action: { type: 'card', mode: 'cancel', card } }, actions);
return allowMoritaniNexusSkillsModules(game, actions);
}
export function rejectMoritaniNexusSkillsModulesAction(game: Game, actor: string, action: Action): void {
const before = snapshot(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
/** Source: docs/NEXUS_MORITANI_RULES.md13–32 (publisher face and native rulebook).
* Genuine first Storm/END Mentat → worm/alliance/closing draw → original battle
* → actual second Mentat supply opportunity. Enhanced relocation, HMS and
* Grumman are deliberately NOT interpreted by this continuation. */
export function createMoritaniNexusSkillsModulesFixture(options: MoritaniNexusSkillsModulesOptions = {}): MoritaniNexusSkillsModulesFixture {
const actions: MoritaniNexusSkillsModulesStep[] = [], staging: string[] = [];
let game = initializeMoritaniNexusSkillsModulesOffers(options); const initial = snapshot(game);
game = completeMoritaniNexusSkillsModulesSetup(game, options, actions); const afterSetup = snapshot(game);
const moritani = game.players.find(p => p.faction === 'moritani')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const partner = game.players.find(p => p.id !== moritani && p.id !== opponent)?.id;
assert.ok(partner, 'A real unallied closing Nexus draw requires two other original alliance seats; two-seat setup is supported separately.');
while (game.phase === 0) {
const next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next);
if (next.action.type === 'stormDial') next.action.amount = 4;
game = stepMoritaniNexusSkillsModules(game, next, actions);
}
game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 1 && moritaniSkillsModulesClean(s), actions);
const afterFirstStorm = snapshot(game); orderSpice(game, ['land', 'land'], staging);
game = advanceMoritaniNexusSkillsModules(game, s => s.decision?.kind === 'moritaniPlacement', actions);
if (options.stack) {
game = stepMoritaniNexusSkillsModules(game, { actor: moritani, action: { type: 'decision', token: moritaniNexusSkillsModulesToken(game, 'sabotage').id, territory: 'arrakeen' } }, actions);
game = allowMoritaniNexusSkillsModules(game, actions);
} else game = stepMoritaniNexusSkillsModules(game, undefined, actions);
reserveBoard(game, staging);
const holder = options.holder === 'moritani' ? moritani : opponent;
place(game, holder, 'tueks_sietch', 1, staging); place(game, holder === moritani ? opponent : moritani, 'carthag', 1, staging);
let beforeFirstMentat: Game | undefined, firstMentatStep: MoritaniNexusSkillsModulesStep | undefined;
while (game.turn === 1) {
const before = snapshot(game), next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next);
game = stepMoritaniNexusSkillsModules(game, next, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep); const afterFirstMentat = snapshot(game);
game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 1 && moritaniSkillsModulesClean(s), actions);
orderSpice(game, ['worm', 'land', 'land'], staging);
game = advanceMoritaniNexusSkillsModules(game, s => s.nexus === true && !s.spiceWindow && !s.spiceResolution && moritaniSkillsModulesClean(s), actions);
const beforeAlliance = snapshot(game), allianceActions: MoritaniNexusSkillsModulesStep[] = [
{ actor: opponent, action: { type: 'alliance', target: partner } },
{ actor: partner, action: { type: 'alliance', target: opponent } },
];
for (const next of allianceActions) game = stepMoritaniNexusSkillsModules(game, next, actions);
game = advanceMoritaniNexusSkillsModules(game, s => s.nexusCards?.phase?.stage === 'drawing' && moritaniSkillsModulesClean(s), actions);
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('moritani'); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]); staging.push('Conserved undealt original Moritani Nexus singleton ordered before its real unallied closing draw.');
const beforeClosingDraw = snapshot(game), closingDrawStep: MoritaniNexusSkillsModulesStep = {
actor: moritani, action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[moritani], choice: 'draw', ownRedraws: 0 },
};
game = stepMoritaniNexusSkillsModules(game, closingDrawStep, actions); const afterClosingDraw = snapshot(game);
game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 5 && moritaniSkillsModulesClean(s), actions);
stageMoritaniSkillsModulesBattle(game, moritani, opponent, staging);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const ownLeader = moritaniSkillsModulesPlayer(game, moritani).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
const enemyLeader = moritaniSkillsModulesPlayer(game, opponent).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
const target = game.leaderSkills!.assignments.find(a => a.owner === opponent)!.leader; assert.ok(ownLeader && enemyLeader && enemyLeader.id !== target);
holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemyLeader.id : target, staging);
const moritaniCards = [holdMoritaniNexusSkillsModulesCard(game, moritani, 'worthless', staging)];
moritaniCards.push(holdMoritaniNexusSkillsModulesCard(game, moritani, 'worthless', staging, moritaniCards));
const opponentCards = [holdMoritaniNexusSkillsModulesCard(game, opponent, 'worthless', staging)];
game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 6 && moritaniSkillsModulesClean(s), actions);
const beforeBattle = snapshot(game), actor = game.active!; assert.ok([moritani, opponent].includes(actor));
const battleAction: MoritaniNexusSkillsModulesStep = { actor, action: { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === moritani ? opponent : moritani } };
game = stepMoritaniNexusSkillsModules(game, battleAction, actions);
game = advanceMoritaniNexusSkillsModules(game, s => !!s.battle && !s.battle.revealed && !nextMoritaniNexusSkillsModulesStep(s), actions);
const planActions: MoritaniNexusSkillsModulesStep[] = [
{ actor: moritani, action: { type: 'battlePlan', leader: ownLeader.id, dial: 1, ...(game.advanced ? { support: 1 } : {}), weapon: moritaniCards[0], defense: moritaniCards[1] } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 3, ...(game.advanced ? { support: 3 } : {}), weapon: opponentCards[0] } },
];
const plans = snapshot(game), revealed = revealMoritaniSkillsModulesPlans(game, planActions, actions);
const pending = resolveMoritaniNexusSkillsModulesBattle(snapshot(revealed), moritani, options.normalCall, actions);
game = finishMoritaniNexusSkillsModulesBattle(snapshot(pending), actions);
game = advanceMoritaniNexusSkillsModules(game, s => s.decision?.kind === 'moritaniPlacement', actions);
const karama = holdMoritaniNexusSkillsModulesCard(game, opponent, 'karama', staging);
assertMoritaniNexusSkillsModulesCustody(game);
return { initial, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat,
beforeAlliance, allianceActions, beforeClosingDraw, closingDrawStep, afterClosingDraw, beforeBattle,
battleAction, plans, planActions, revealed, pending, game, moritani, opponent, partner, holder,
target, moritaniLeader: ownLeader.id, opponentLeader: enemyLeader.id, moritaniCards, opponentCards,
karama, actions, staging };
}
@@ -0,0 +1,360 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { DIFFICULTIES } from '../game/bot-profiles';
import { ownedTech } from '../game/tech-tokens';
import { strongholdControllers } from '../game/stronghold-cards';
import { territory } from '../game/board';
import { quoteNexusSkillsModulesBattle } from './fixture-nexus-skills-modules-battles';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import { assassinationPhysical } from './moritani-assassinate-fixture';
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
import {
advanceMoritaniNexusSkillsModules as advance, allowMoritaniNexusSkillsModules as allow,
assertMoritaniNexusSkillsModulesCustody as custody, cancelMoritaniNexusSkillsModules as cancel,
completeMoritaniNexusSkillsModulesSetup, createMoritaniNexusSkillsModulesFixture as fixture,
finishMoritaniNexusSkillsModulesBattle as finishBattle, initializeMoritaniNexusSkillsModulesOffers,
moritaniNexusSkillsModulesClean as clean, moritaniNexusSkillsModulesPlayer as player,
moritaniNexusSkillsModulesPolicy as policy, moritaniNexusSkillsModulesRequest as request,
moritaniNexusSkillsModulesToken as token, nextMoritaniNexusSkillsModulesStep,
rejectMoritaniNexusSkillsModulesAction as reject, resolveMoritaniNexusSkillsModulesBattle as resolve,
revealMoritaniNexusSkillsModulesPlans as reveal, stageMoritaniNexusSkillsModulesBattle as stage,
settleMoritaniNexusSkillsModulesBoundary,
type MoritaniNexusSkillsModulesOptions,
} from './fixture-moritani-nexus-skills-modules';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
function physicalPlayers(game: Game) {
return game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites }));
}
function assassinate(game: Game, card: string | null): Game {
const d = game.decision;
if (d?.kind !== 'moritaniAssassinate') throw Error('An original post-loss assassination choice is required');
return applyAction(game, d.player, { type: 'decision', event: d.event, ...(card === null ? { decline: true } : { card }) });
}
const bands: MoritaniNexusSkillsModulesOptions[] = [
{ rules: 'basic', tech: false, strongholds: false },
{ rules: 'advanced', tech: false, strongholds: false },
{ rules: 'basic', tech: true, strongholds: false },
{ rules: 'advanced', tech: true, strongholds: false },
{ rules: 'advanced', tech: false, strongholds: true },
{ rules: 'advanced', tech: true, strongholds: true },
];
void test('original standalone E3 skill-first admission conserves ecaz33/all14/all12 across no-module and selected-module two-to-six-seat bands', () => {
const factions: FactionId[] = ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit'];
for (const options of bands) for (let count = options.tech ? 3 : 2; count <= 6; count++) {
const lobby = createGame(`MORINEXUSAUTH${count}`, newPlayer('authenticated-m', 'Original Moritani', 'moritani'), options.rules === 'advanced', ['ecaz']);
for (let i = 1; i < count; i++) joinGame(lobby, newPlayer(`authenticated-${i}`, `Original ${factions[i]}`, factions[i]));
const before = structuredClone(lobby), offered = initializeMoritaniNexusSkillsModulesOffers({ ...options, initial: lobby });
assert.deepEqual(lobby, before);
assert.deepEqual(offered.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), before.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })));
assert.equal(offered.setupStage, 'leaderSkills'); assert.ok(offered.players.every(p => p.traitors.length === 0));
for (const p of offered.players) assert.equal(viewGame(offered, p.id).leaderSkills!.offer!.cards.length, 2);
assert.equal(!!offered.moritaniAssassinate, options.rules === 'advanced');
assert.deepEqual(initializeMoritaniNexusSkillsModulesOffers({ ...options, initial: reload(offered) }), offered, 'Undealt original CLI continuation must not replace actual offers');
const guild = offered.players.find(p => p.faction === 'guild')!.id;
const unavailable = viewGame(offered, guild).leaderSkills!.offer!.cards.includes('sandmaster') ? 'swordmaster-of-ginaz' : 'sandmaster';
assert.ok(!viewGame(offered, guild).leaderSkills!.offer!.cards.includes(unavailable));
assert.throws(() => completeMoritaniNexusSkillsModulesSetup(reload(offered), { guildSkill: unavailable }));
const setup = completeMoritaniNexusSkillsModulesSetup(offered), moritani = player(setup, 'authenticated-m');
assert.equal(moritani.reserves, 14); assert.equal(Object.values(moritani.forces).reduce((a, b) => a + b, 0), 6);
const startingSite = Object.keys(moritani.forces)[0].split(':')[0];
assert.ok(setup.players.filter(p => p.id !== moritani.id).every(p => !Object.entries(p.forces).some(([key, n]) => n > 0 && key.startsWith(`${startingSite}:`))));
assert.equal(setup.leaderSkills!.assignments.find(a => a.owner === guild)!.skill, 'suk-graduate');
assert.equal(!!setup.techTokens, !!options.tech); assert.equal(!!setup.strongholdCards, !!options.strongholds);
if (setup.strongholdCards) assert.ok(Object.values(setup.strongholdCards.owners).every(owner => owner === null));
custody(setup);
if (count === 2) assert.throws(() => fixture({ ...options, initial: reload(lobby) }), /two other original alliance seats/);
}
});
void test('Basic original Harkonnen remains native while Advanced excludes capture bounds and paired/foreign E3 rosters', () => {
const basic = initializeMoritaniNexusSkillsModulesOffers({ rules: 'basic', factions: ['moritani', 'guild', 'harkonnen'] });
const setup = completeMoritaniNexusSkillsModulesSetup(basic);
assert.equal(player(setup, 'harkonnen').traitors.length, 4); assert.equal(setup.moritaniAssassinate, undefined); custody(setup);
for (const faction of ['harkonnen', 'ecaz', 'ixians', 'richese', 'tleilaxu', 'choam'] as FactionId[])
assert.throws(() => initializeMoritaniNexusSkillsModulesOffers({ rules: 'advanced', factions: ['moritani', 'guild', faction] }));
assert.throws(() => initializeMoritaniNexusSkillsModulesOffers({ rules: 'basic', strongholds: true }));
assert.throws(() => initializeMoritaniNexusSkillsModulesOffers({ tech: true, factions: ['moritani', 'guild'] }));
assert.throws(() => initializeMoritaniNexusSkillsModulesOffers({ initial: setup }), /original lobby or undealt/);
});
void test('Tech custody comes from the first real Storm, Strongholds from END Mentat, and Cunning from the genuine worm/alliance closing draw', () => {
const f = fixture({ tech: true, strongholds: true });
assert.ok(Object.values(f.initial.techTokens!).every(t => t.owner === null));
for (const p of f.afterFirstStorm.players) assert.equal(ownedTech(f.afterFirstStorm.techTokens, p.id).length, 1);
assert.ok(Object.values(f.initial.strongholdCards!.owners).every(owner => owner === null));
const claimed = applyAction(reload(f.beforeFirstMentat), f.firstMentatStep.actor, f.firstMentatStep.action);
assert.equal(claimed.turn, 2);
assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(f.beforeFirstMentat.players, false));
assert.deepEqual(claimed.strongholdCards, f.afterFirstMentat.strongholdCards);
let allied = reload(f.beforeAlliance);
for (const next of f.allianceActions) allied = applyAction(allied, next.actor, next.action);
assert.equal(player(allied, f.opponent).ally, f.partner); assert.equal(player(allied, f.partner).ally, f.opponent);
assert.equal(player(allied, f.moritani).ally, null);
assert.equal(f.beforeClosingDraw.nexusCards!.phase!.stage, 'drawing');
assert.ok(f.beforeClosingDraw.nexusCards!.phase!.eligible.includes(f.moritani));
assert.equal(f.beforeClosingDraw.nexusCards!.cards!.hands[f.moritani], null);
const drawn = applyAction(reload(f.beforeClosingDraw), f.closingDrawStep.actor, f.closingDrawStep.action);
assert.equal(drawn.nexusCards!.cards!.hands[f.moritani], 'moritani');
assert.deepEqual(drawn.nexusCards, f.afterClosingDraw.nexusCards);
reject(drawn, f.moritani, f.closingDrawStep.action);
for (const g of [f.initial, f.afterSetup, f.afterFirstStorm, f.afterFirstMentat, drawn, f.pending, f.game]) custody(g);
});
void test('genuine Cunning declares only supply into static Arrakis or an existing stack; allowance commits once and cancellation spends the card and attempt without moving a token', () => {
for (const rules of ['basic', 'advanced'] as const) for (const site of ['red_chasm', 'sihaya_ridge', 'polar_sink', 'arrakeen']) {
const f = fixture({ rules, stack: true }), original = reload(f.game), action = request(original, f.moritani, 'robbery', site);
reject(original, f.opponent, action); reject(original, f.moritani, { ...action, nexus: 'stale' });
reject(original, f.moritani, { ...action, token: token(original, 'sabotage').id });
for (const bad of ['grumman', 'hidden_mobile_stronghold', 'unknown']) reject(original, f.moritani, { ...action, territory: bad });
const nativeAttempt = { type: 'decision', token: token(original, 'robbery').id, territory: site };
reject(original, f.moritani, nativeAttempt);
const declared = applyAction(reload(original), f.moritani, action);
assert.equal(declared.response?.kind, 'moritaniPlacement'); assert.deepEqual(declared.moritaniTerror, original.moritaniTerror);
assert.equal(declared.nexusCards!.cards!.hands[f.moritani], null);
assert.equal(declared.nexusCards!.cards!.discard.filter(c => c === 'moritani').length, 1);
const allowed = allow(reload(declared));
assert.equal(token(allowed, 'robbery').status, 'placed'); assert.equal(token(allowed, 'robbery').location, site);
assert.equal(allowed.moritaniTerror!.placementTurn, 2); assert.equal(allowed.nexusMoritaniLast!.stage, 'complete');
assert.deepEqual(allowed.moritaniTerror!.tokens.filter(t => t.kind !== 'robbery'), original.moritaniTerror!.tokens.filter(t => t.kind !== 'robbery'));
assert.deepEqual(physicalPlayers(allowed), physicalPlayers(original));
assert.deepEqual(allowed.leaderSkills, original.leaderSkills); assert.deepEqual(allowed.techTokens, original.techTokens); assert.deepEqual(allowed.strongholdCards, original.strongholdCards);
if (site === 'arrakeen') assert.equal(allowed.moritaniTerror!.tokens.filter(t => t.location === site).length, 2);
const canceled = cancel(reload(declared), f.opponent, f.karama);
assert.equal(canceled.nexusMoritaniLast!.stage, 'canceled'); assert.equal(canceled.moritaniTerror!.placementTurn, 2);
assert.deepEqual(canceled.moritaniTerror!.tokens, original.moritaniTerror!.tokens);
assert.equal(canceled.nexusCards!.cards!.hands[f.moritani], null);
assert.equal(canceled.discard.filter(c => c.id === f.karama).length, 1);
assert.deepEqual(physicalPlayers(canceled), physicalPlayers(original));
for (const settled of [allowed, canceled]) {
reject(settled, f.moritani, action); reject(settled, f.moritani, { type: 'decision', token: token(settled, 'extortion').id, territory: 'carthag' });
assert.deepEqual(normalizeAutomaticGame(reload(settled)), settled); custody(settled);
for (const p of settled.players.filter(p => p.id !== f.moritani)) {
assert.equal(viewGame(settled, p.id).nexusMoritani, null);
assert.equal('nexusMoritaniHistory' in viewGame(settled, p.id), false);
assert.ok(viewGame(settled, p.id).moritaniTerror!.tokens.filter(t => t.status === 'placed').every(t => !('kind' in t)));
}
}
}
});
void test('ordinary later relocation remains native-only and does not reuse the historical expanded Cunning placement', () => {
const f = fixture({ stack: true });
let game = allow(applyAction(reload(f.game), f.moritani, request(f.game, f.moritani)));
const placedId = token(game, 'robbery').id;
game = advance(game, s => s.turn === 3 && s.decision?.kind === 'moritaniPlacement');
assert.equal(token(game, 'robbery').location, 'red_chasm');
assert.equal(game.nexusCards!.cards!.hands[f.moritani], null);
reject(game, f.moritani, { type: 'decision', token: placedId, territory: 'sihaya_ridge' });
reject(game, f.moritani, { type: 'decision', token: placedId, territory: 'arrakeen' });
reject(game, f.moritani, { type: 'decision', token: placedId, territory: 'hidden_mobile_stronghold' });
const before = reload(game);
game = allow(applyAction(game, f.moritani, { type: 'decision', token: placedId, territory: 'carthag' }));
assert.equal(token(game, 'robbery').id, placedId); assert.equal(token(game, 'robbery').location, 'carthag');
assert.equal(game.moritaniTerror!.placementTurn, 3); assert.equal(game.nexusMoritaniLocations?.[placedId], undefined);
assert.deepEqual(game.nexusMoritaniHistory, before.nexusMoritaniHistory);
assert.deepEqual(physicalPlayers(game), physicalPlayers(before)); custody(game);
});
void test('normal held Suk physical rescue precedes unused-disc assassination, cleanup and original winner Tech; printed bounty and one private actual Mentat replacement do not replay settlement', () => {
const f = fixture({ tech: true, strongholds: true, holder: 'moritani' }), pending = f.pending;
assert.equal(pending.lastBattleContext!.result, 'normal'); assert.equal(pending.lastBattleContext!.winner, f.opponent);
assert.equal(pending.lastBattleContext!.sukRescue!.completed, true); assert.equal(pending.decision!.kind, 'moritaniAssassinate');
assert.equal(player(pending, f.opponent).tanks, player(f.revealed, f.opponent).tanks + 2);
assert.equal(player(pending, f.opponent).reserves, player(f.revealed, f.opponent).reserves + 1);
assert.equal(player(pending, f.moritani).tanks, player(f.revealed, f.moritani).tanks + 6);
const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!;
assert.equal(target.name, 'Master Bewt'); assert.equal(target.strength, 3); assert.equal(target.dead, false); assert.notEqual(target.id, f.opponentLeader);
const quoted = quoteStrongholdFactionsBattle(f.revealed);
assert.deepEqual(quoted.strongholdIncome, [{ player: f.moritani, amount: 4 }]);
const skilledQuote = quoteNexusSkillsModulesBattle(f.revealed);
const heldBonus = f.revealed.battle!.attacker === f.moritani ? skilledQuote.leaderSkillBonuses.attacker : skilledQuote.leaderSkillBonuses.defender;
assert.equal(heldBonus.bonus, 1, 'The unused face-up Warmaster still adds its printed +1 to the other disc using Worthless.');
assert.ok(heldBonus.applied.some(b => b.skill === 'warmaster' && b.mode === 'normal' && b.amount === 1));
assert.equal(player(pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1 + 4);
assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
const d = pending.decision;
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected loss choice');
assert.equal(viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!.bounty, 3);
const action = { type: 'decision', event: d.event, card: target.id };
reject(pending, f.opponent, action); reject(pending, f.moritani, { ...action, event: 'stale' });
reject(pending, f.moritani, { ...action, card: f.opponentLeader });
const inventory = assassinationPhysical(pending), reserve = [...pending.traitorReserve!], traitors = [...player(pending, f.moritani).traitors];
const retained = pending.leaderSkills!.assignments.filter(a => a.leader !== target.id);
let game = assassinate(reload(pending), target.id);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === target.id)!.dead, true);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
assert.deepEqual(game.leaderSkills!.assignments, retained); assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
assert.equal(player(game, f.moritani).spice, player(pending, f.moritani).spice + 3);
assert.equal(player(game, f.opponent).spice, player(pending, f.opponent).spice);
assert.deepEqual(game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks })), pending.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks })));
assert.deepEqual(game.strongholdCards, pending.strongholdCards); assert.deepEqual(game.techTokens, pending.techTokens);
assert.equal(game.nexusCards!.cards!.hands[f.moritani], 'moritani');
assert.equal(game.decision?.kind, 'battleCards'); assert.equal(game.decision?.player, f.opponent);
const cleanupBoundary = settleMoritaniNexusSkillsModulesBoundary(game, f.moritani);
assert.equal(cleanupBoundary.kind, 'cards');
assert.deepEqual(cleanupBoundary.game.techTokens, pending.techTokens, 'A human card decision must precede winner Tech custody');
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);
const captured = ownedTech(pending.techTokens, f.moritani)[0]; assert.ok(captured);
assert.equal(game.techTokens![captured].owner, f.opponent);
for (const held of ownedTech(pending.techTokens, f.opponent)) assert.equal(game.techTokens![held].owner, f.opponent);
assert.equal(game.strongholdCards!.owners.tueks_sietch, f.moritani, 'Battle control cannot preempt END Mentat card custody');
game = advance(game, s => s.decision?.kind === 'moritaniPlacement');
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, [...traitors.filter(c => c !== target.id), reserve[0]]);
assert.deepEqual(assassinationPhysical(game), inventory);
assert.equal('replacement' in viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
assert.equal('traitors' in viewGame(game, f.opponent).players.find(p => p.id === f.moritani)!, false);
assert.equal(game.leaderSkills!.offers[f.opponent], undefined, 'Original replacement Traitor is not foreign replacement training');
reject(game, f.moritani, action); custody(game);
const placed = allow(applyAction(game, f.moritani, request(game, f.moritani)));
game = advance(placed, s => s.turn === 3 && s.phase === 4 && clean(s));
assert.equal(game.traitorReserve!.length, reserve.length - 1);
assert.equal(game.moritaniAssassinate!.opportunities.length, 1);
reject(game, f.opponent, { type: 'reviveLeader', leader: target.id }); custody(game);
});
void test('selected-disc-only empty menu can decline without removing Suk or drawing a replacement, while every component band preserves the original loss aftermath', () => {
for (const options of bands) {
const f = fixture(options), pending = f.pending;
assert.equal(pending.lastBattleContext!.winner, f.opponent); assert.equal(pending.lastBattleContext!.result, 'normal');
assert.equal(pending.lastBattleContext!.sukRescue!.completed, true);
assert.equal(player(pending, f.opponent).tanks, 2); assert.equal(player(pending, f.moritani).tanks, 6);
assert.equal(!!pending.techTokens, !!options.tech); assert.equal(!!pending.strongholdCards, !!options.strongholds);
if (options.rules === 'basic') {
assert.equal(pending.moritaniAssassinate, undefined);
assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice);
assert.equal(player(pending, f.moritani).spice, player(f.revealed, f.moritani).spice);
reject(pending, f.moritani, { type: 'decision', event: 'not-earned', card: f.target });
assert.ok(pending.leaderSkills!.assignments.some(a => a.leader === f.target && a.skill === 'suk-graduate'));
if (options.tech) {
const lost = ownedTech(f.revealed.techTokens, f.moritani)[0]; assert.ok(lost); assert.equal(pending.techTokens![lost].owner, f.opponent);
}
} else {
assert.equal(pending.decision!.kind, 'moritaniAssassinate');
let game = assassinate(reload(pending), f.target); game = finishBattle(game);
game = advance(game, s => s.decision?.kind === 'moritaniPlacement');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
if (options.tech) {
const lost = ownedTech(pending.techTokens, f.moritani)[0]; assert.ok(lost); assert.equal(game.techTokens![lost].owner, f.opponent);
}
custody(game);
}
custody(pending);
}
const f = fixture(); let empty = reload(f.plans);
holdMoritaniStrongholdsTraitor(empty, f.moritani, f.opponentLeader);
empty = resolve(reveal(empty, f.planActions), f.moritani);
assert.deepEqual(viewGame(empty, f.moritani).moritaniAssassinate!.pending!.cards, []);
const reserve = [...empty.traitorReserve!], skills = structuredClone(empty.leaderSkills);
const d = empty.decision;
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected empty unused-leader menu');
reject(empty, f.moritani, { type: 'decision', event: d.event, card: f.opponentLeader });
const declined = advance(finishBattle(assassinate(empty, null)), s => s.decision?.kind === 'moritaniPlacement');
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
assert.deepEqual(declined.traitorReserve, reserve); assert.deepEqual(declined.leaderSkills, skills); custody(declined);
});
void test('normal Traitor declaration forfeits Advanced assassination for the rest of the original game, including a later real normal loss', () => {
const f = fixture({ tech: true, strongholds: true, normalCall: true });
assert.equal(f.pending.lastBattleContext!.result, 'traitor'); assert.equal(f.pending.lastBattleContext!.winner, f.moritani);
assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(f.pending.moritaniAssassinateCallEvents!.length, 1); assert.deepEqual(f.pending.moritaniAssassinate!.opportunities, []);
let game = advance(reload(f.game), s => s.turn === 3 && s.phase === 5 && clean(s));
stage(game, f.moritani, f.opponent); holdMoritaniStrongholdsTraitor(game, f.moritani, f.target);
const assigned = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const winner = player(game, f.opponent).leaders.filter(l => !l.dead && !assigned.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
const loser = player(game, f.moritani).leaders.filter(l => !l.dead && !assigned.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(winner && loser);
game = advance(game, s => s.phase === 6 && clean(s));
const actor = game.active!;
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === f.moritani ? f.opponent : f.moritani });
game = advance(game, s => !!s.battle && !s.battle.revealed && !nextMoritaniNexusSkillsModulesStep(s));
game = reveal(game, [
{ actor: f.moritani, action: { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: winner.id, dial: 3, support: 3 } },
]);
game = finishBattle(resolve(game, f.moritani));
assert.equal(game.lastBattleContext!.result, 'normal'); assert.equal(game.lastBattleContext!.winner, f.opponent);
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true); assert.equal(game.moritaniAssassinateCallEvents!.length, 1);
assert.deepEqual(game.moritaniAssassinate!.opportunities, []);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, false); custody(game);
});
void test('Basic Cunning placement leads to genuine later legal Terror on a real shipment without granting Advanced assassination', () => {
const f = fixture({ rules: 'basic' });
let game = allow(applyAction(reload(f.game), f.moritani, request(f.game, f.moritani, 'robbery', 'red_chasm')));
game = advance(game, s => s.turn === 3 && s.phase === 5 && s.active === f.opponent && clean(s));
const sector = territorySector(game, 'red_chasm');
game = applyAction(game, f.opponent, { type: 'ship', territory: 'red_chasm', sector, amount: 3 });
game = allow(game);
assert.equal(game.pendingTerrorEntry!.stage, 'offer'); assert.equal(viewGame(game, f.moritani).terrorEntry!.kind, 'robbery');
assert.equal(viewGame(game, f.opponent).terrorEntry!.kind, undefined);
const before = reload(game);
game = applyAction(game, f.moritani, { type: 'decision', reveal: true });
game = applyAction(game, f.moritani, { type: 'decision', choice: 'spice' });
const taken = Math.ceil(player(before, f.opponent).spice / 2);
assert.equal(player(game, f.moritani).spice, player(before, f.moritani).spice + taken);
assert.equal(player(game, f.opponent).spice, player(before, f.opponent).spice - taken);
assert.equal(token(game, 'robbery').status, 'removed'); assert.equal(game.pendingTerrorEntry, null);
assert.equal(player(game, f.opponent).forces[`red_chasm:${sector}`], 3);
assert.equal(game.moritaniAssassinate, undefined); reject(game, f.moritani, { type: 'decision', reveal: true }); custody(game);
});
void test('all four minimal legal policies commit actual Cunning and post-loss choices without duplicating custody, rescue, bounty or replacement', () => {
const f = fixture({ tech: true, strongholds: true, stack: true });
const basic = fixture({ rules: 'basic', stack: true });
for (const source of [f, basic]) {
const enemy = player(source.game, source.opponent), key = `red_chasm:${territorySector(source.game, 'red_chasm')}`;
assert.ok(enemy.reserves > 0);
enemy.reserves--;
enemy.forces[key] = (enemy.forces[key] ?? 0) + 1;
source.staging.push('Conserved one original Guild reserve counter at Red Chasm as a useful non-stronghold Cunning policy target; not played movement.');
}
let empty = reload(f.plans);
holdMoritaniStrongholdsTraitor(empty, f.moritani, f.opponentLeader);
empty = resolve(reveal(empty, f.planActions), f.moritani);
for (const difficulty of DIFFICULTIES) {
const action = policy(reload(f.game), f.moritani, difficulty)[0]; assert.ok(action, difficulty);
const placed = allow(applyAction(reload(f.game), f.moritani, action));
assert.equal(placed.nexusCards!.cards!.hands[f.moritani], null);
assert.equal(placed.moritaniTerror!.placementTurn, 2); assert.equal(placed.nexusMoritaniLast!.stage, 'complete');
assert.equal(placed.moritaniTerror!.tokens.filter(t => t.status === 'placed').length, 2);
assert.deepEqual(physicalPlayers(placed), physicalPlayers(f.game)); custody(placed);
const basicAction = policy(reload(basic.game), basic.moritani, difficulty)[0]; assert.ok(basicAction, difficulty);
const basicPlaced = allow(applyAction(reload(basic.game), basic.moritani, basicAction));
assert.equal(basicPlaced.nexusCards!.cards!.hands[basic.moritani], null);
assert.equal(basicPlaced.moritaniTerror!.placementTurn, 2); assert.equal(basicPlaced.moritaniAssassinate, undefined);
assert.deepEqual(physicalPlayers(basicPlaced), physicalPlayers(basic.game)); custody(basicPlaced);
const lossChoice = policy(reload(f.pending), f.moritani, difficulty)[0]; assert.ok(lossChoice, difficulty);
let game = applyAction(reload(f.pending), f.moritani, lossChoice);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.card, f.target);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
assert.equal(player(game, f.moritani).spice, player(f.pending, f.moritani).spice + 3);
game = finishBattle(game); game = advance(game, s => s.decision?.kind === 'moritaniPlacement');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(player(game, f.opponent).tanks, 2); assert.equal(player(game, f.moritani).tanks, 6);
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1); custody(game);
const decline = policy(reload(empty), f.moritani, difficulty)[0]; assert.ok(decline, difficulty);
const declined = applyAction(reload(empty), f.moritani, decline);
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
assert.deepEqual(declined.leaderSkills, empty.leaderSkills);
assert.deepEqual(declined.traitorReserve, empty.traitorReserve);
assert.equal(player(declined, f.moritani).spice, player(empty, f.moritani).spice); custody(finishBattle(declined));
}
});
function territorySector(game: Game, id: string): number {
// Only physical board sectors are chosen; the original Storm is never edited.
const site = territory(id);
const sector = site.sectors.find(s => s !== game.storm); assert.ok(sector !== undefined); return sector;
}
-9
View File
@@ -102,13 +102,6 @@ void test('native owner sees explicit blocks; no card identity leaks to other se
assert.match(quoteNexusEcazDuke({ ...g, battle: {} as Game['battle'] }, 'ecaz-seat')!.blocked!,
/current interaction/);
assert.match(quoteNexusEcazDuke(g, 'ecaz-seat', true)!.blocked!, /current interaction/);
assert.match(quoteNexusEcazDuke({ ...g, homeworlds: { custody: null } }, 'ecaz-seat')!.blocked!,
/Nexus alone/);
assert.match(quoteNexusEcazDuke({ ...g, players: g.players.slice(0, 2) }, 'ecaz-seat')!.blocked!,
/paired Ecaz and Moritani/);
assert.match(quoteNexusEcazDuke({
...g, players: [...g.players, { ...g.players[2], id: 'ixians-seat', faction: 'ixians' }],
}, 'ecaz-seat')!.blocked!, /Nexus alone/);
const harkonnen = {
...g, advanced: true,
players: [...g.players.slice(0, 2),
@@ -118,8 +111,6 @@ void test('native owner sees explicit blocks; no card identity leaks to other se
/Advanced Harkonnen/);
assert.equal(quoteNexusEcazDuke({ ...harkonnen, advanced: false }, 'ecaz-seat')!.blocked,
null);
assert.match(quoteNexusEcazDuke({ ...g, sandtrout: true }, 'ecaz-seat')!.blocked!,
/Nexus alone/);
const allied = structuredClone(g);
allied.players[0].ally = allied.players[1].id;
assert.match(quoteNexusEcazDuke(allied, 'ecaz-seat')!.blocked!, /unallied/);