Compose standalone E3 Nexus Skills and original module mechanics
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
|
||||
Reference in new issue
Block a user