407 lines
25 KiB
TypeScript
407 lines
25 KiB
TypeScript
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 Cunning revives the set-aside Duke from Tanks while invalid custody and timing remain immutable', () => {
|
|
const f = fixture({ tech: false, stronghold: false });
|
|
const g = f.beforeCunning, duke = g.dukeVidal!, event = viewGame(g, f.owner).nexusEcazDuke!.event;
|
|
const tanks = structuredClone(g);
|
|
tanks.dukeVidal!.leader = { ...duke.leader, dead: true, deaths: 2, usedAt: f.territory };
|
|
const beforeRevival = structuredClone(tanks);
|
|
assert.equal(quoteNexusEcazDuke(tanks, f.owner)?.blocked, null);
|
|
const receipt = createNexusEcazDukeReceipt(tanks, f.owner);
|
|
assert.equal(receipt.before.leader.dead, true);
|
|
assert.deepEqual(tanks, beforeRevival);
|
|
const revived = act(tanks, f.owner, { type: 'nexusEcazDuke', event: receipt.event });
|
|
assert.deepEqual(tanks, beforeRevival);
|
|
assert.deepEqual(revived.dukeVidal, {
|
|
...duke, controller: f.owner, source: 'ecazNexus', acquiredTurn: revived.turn,
|
|
leader: { ...duke.leader, dead: false, deaths: 2 },
|
|
});
|
|
assert.equal(revived.dukeVidal!.leader.usedAt, undefined);
|
|
assert.equal(revived.nexusCards!.cards!.hands[f.owner], null);
|
|
assert.equal(revived.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length,
|
|
beforeRevival.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length + 1);
|
|
assert.equal(revived.nexusEcazDukeHistory!.length, (beforeRevival.nexusEcazDukeHistory?.length ?? 0) + 1);
|
|
assert.deepEqual(revived.leaderSkills, g.leaderSkills);
|
|
assert.deepEqual(revived.players.map(p => p.leaders), g.players.map(p => p.leaders));
|
|
reject(revived, f.owner, { type: 'nexusEcazDuke', event: receipt.event });
|
|
custody(revived);
|
|
const alternatives: Game[] = [
|
|
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, controller: f.opponent } },
|
|
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, acquiredTurn: g.turn } },
|
|
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, source: 'ecazNexus' } },
|
|
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, leader: { ...tanks.dukeVidal!.leader, capturedBy: f.opponent } } },
|
|
{ ...tanks, dukeVidal: { ...tanks.dukeVidal!, leader: { ...tanks.dukeVidal!.leader, gholaBy: f.opponent } } },
|
|
{ ...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, battle: f.game.battle },
|
|
];
|
|
for (const input of alternatives) {
|
|
const before = structuredClone(input);
|
|
assert.ok(quoteNexusEcazDuke(input, f.owner)?.blocked);
|
|
assert.throws(() => createNexusEcazDukeReceipt(input, f.owner));
|
|
assert.deepEqual(input, before);
|
|
reject(input, f.owner, { type: 'nexusEcazDuke', event: quoteNexusEcazDuke(input, f.owner)!.event });
|
|
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);
|
|
}
|
|
});
|