370 lines
21 KiB
TypeScript
370 lines
21 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
|
|
viewGame, type Action, type Game, type Player,
|
|
} from '../game/engine';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
|
import { validateLeaderSkills } from '../game/leader-skills';
|
|
import { classicNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
|
|
import { traitorDeck } from '../game/traitors';
|
|
import { nexusInventory } from './fixture-nexus-cards';
|
|
import { nextClassicSkillsStrongholdStep } from './fixture-classic-skills-stronghold';
|
|
|
|
export type ClassicNexusSkillsPaymentKind = 'emperor-revival' | 'fremen-revival' | 'emperor-purchase' | 'harkonnen-cunning';
|
|
export type ClassicNexusSkillsPaymentStep = { actor: string; action: Action };
|
|
export type ClassicNexusSkillsPaymentsOptions = {
|
|
/** Original authenticated lobby OR actual undealt leader-skills CLI setup. */
|
|
initial?: Game;
|
|
advanced?: boolean;
|
|
seatIds?: string[];
|
|
ownerFaction?: FactionId;
|
|
kind?: ClassicNexusSkillsPaymentKind;
|
|
skill?: LeaderSkillId;
|
|
tanks?: number;
|
|
eliteTanks?: number;
|
|
/** Reach a real revealed battle and commit the original call before Cunning. */
|
|
declaredTraitor?: 'call' | 'decline';
|
|
};
|
|
export type ClassicNexusSkillsPaymentsFixture = {
|
|
initial: Game;
|
|
afterSetup: Game;
|
|
beforeFirstMentat: Game;
|
|
firstMentatStep: ClassicNexusSkillsPaymentStep;
|
|
afterFirstMentat: Game;
|
|
beforeClosingDraw: Game;
|
|
closingDraw: ClassicNexusSkillsPaymentStep;
|
|
afterClosingDraw: Game;
|
|
beforePayment: Game;
|
|
game: Game;
|
|
actor: string;
|
|
opponent: string;
|
|
kind: ClassicNexusSkillsPaymentKind;
|
|
paymentAction: Action;
|
|
revivalAction?: Action;
|
|
actions: ClassicNexusSkillsPaymentStep[];
|
|
planActions: ClassicNexusSkillsPaymentStep[];
|
|
staging: string[];
|
|
declaredIdentity?: string;
|
|
};
|
|
export const classicNexusSkillsPaymentsSnapshot = (game: Game): Game => structuredClone(game);
|
|
export function classicNexusSkillsPaymentsPlayer(game: Game, id: string): Player {
|
|
const player = game.players.find(p => p.id === id);
|
|
assert.ok(player, `Missing original seat ${id}`);
|
|
return player;
|
|
}
|
|
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
|
|
|
/** The first thirteen rolls control only the original all14 shuffle. Other
|
|
* scalar rolls are fixed; generic getRandomValues calls retain native behavior. */
|
|
function originalRandom<T>(run: () => T, selected?: LeaderSkillId[]): T {
|
|
const rolls: number[] = [];
|
|
if (selected) {
|
|
assert.equal(new Set(selected).size, selected.length);
|
|
const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
|
|
const remainder = working.filter(card => !selected.includes(card));
|
|
const desired = selected.flatMap(card => [card, remainder.shift()!]);
|
|
desired.push(...remainder);
|
|
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 (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
|
|
else Reflect.apply(native, crypto, [array]);
|
|
return array;
|
|
};
|
|
try { return run(); }
|
|
finally {
|
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
|
}
|
|
}
|
|
|
|
export function nextClassicNexusSkillsPaymentsNativeStep(game: Game): ClassicNexusSkillsPaymentStep | null {
|
|
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
|
|
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
|
assert.ok(actor);
|
|
const view = viewGame(game, actor).leaderSkills!;
|
|
const skill = view.offer!.cards.find(card => !view.unavailableSkills?.[card]);
|
|
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
|
|
const leader = classicNexusSkillsPaymentsPlayer(game, actor).leaders.filter(leader => eligible.has(leader.id))
|
|
.sort((a, b) => b.strength - a.strength)[0];
|
|
assert.ok(skill && leader, 'An original offer must have a legal skill/disc choice');
|
|
return { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
|
|
}
|
|
if (game.nexusCards?.phase?.stage === 'drawing' && clean(game)) {
|
|
const phase = game.nexusCards.phase;
|
|
const 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 } };
|
|
}
|
|
return nextClassicSkillsStrongholdStep(game);
|
|
}
|
|
export function advanceClassicNexusSkillsPaymentsStep(state: Game, actions?: ClassicNexusSkillsPaymentStep[]): Game {
|
|
const next = nextClassicNexusSkillsPaymentsNativeStep(state);
|
|
assert.ok(next, 'The original continuation needs a human battle plan');
|
|
actions?.push(structuredClone(next));
|
|
return applyAction(state, next.actor, next.action);
|
|
}
|
|
export function advanceClassicNexusSkillsPayments(state: Game, predicate: (game: Game) => boolean,
|
|
actions?: ClassicNexusSkillsPaymentStep[]): Game {
|
|
let game = state;
|
|
for (let n = 0; n < 1800; n++) {
|
|
if (predicate(game)) return game;
|
|
assert.notEqual(game.status, 'finished');
|
|
game = advanceClassicNexusSkillsPaymentsStep(game, actions);
|
|
}
|
|
throw Error('Original skill/Nexus continuation did not reach its boundary');
|
|
}
|
|
/** Checks the boundary BEFORE every action, including auto-skipped Battle. */
|
|
export function advanceClassicNexusSkillsPaymentsToPhase(state: Game, phase: number,
|
|
actions?: ClassicNexusSkillsPaymentStep[]): Game {
|
|
const turn = state.turn;
|
|
const game = advanceClassicNexusSkillsPayments(state, current => {
|
|
assert.equal(current.turn, turn);
|
|
return current.phase >= phase && clean(current);
|
|
}, actions);
|
|
assert.equal(game.turn, turn);
|
|
return game;
|
|
}
|
|
|
|
/** No replacement of a supplied setup's saved first offers or original IDs. */
|
|
export function initializeClassicNexusSkillsPaymentsSetup(options: ClassicNexusSkillsPaymentsOptions = {}): Game {
|
|
const kind = options.kind ?? 'emperor-revival';
|
|
const faction = options.ownerFaction ?? (kind === 'harkonnen-cunning' ? 'harkonnen' : 'atreides');
|
|
let game = options.initial ? structuredClone(options.initial)
|
|
: createGame('CLASSICNEXUSSKILLSPAYMENTS', newPlayer(options.seatIds?.[0] ?? 'payer', faction, faction), options.advanced ?? false);
|
|
if (!options.initial) {
|
|
const forbidden = kind === 'fremen-revival' ? 'fremen' : kind.startsWith('emperor-') ? 'emperor' : undefined;
|
|
const count = options.seatIds?.length ?? 3;
|
|
assert.ok(count >= 3 && count <= 6);
|
|
for (const candidate of ['harkonnen', 'guild', 'beneGesserit', 'atreides', 'fremen', 'emperor'] as const)
|
|
if (game.players.length < count && candidate !== forbidden && candidate !== faction)
|
|
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? candidate, candidate, candidate));
|
|
assert.equal(game.players.length, count, 'The requested absent-faction power needs a compatible classic roster');
|
|
}
|
|
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
|
assert.ok(game.players.some(p => p.faction === faction));
|
|
if (game.status === 'setup') {
|
|
assert.ok(classicNexusLeaderSkillsProfile(game));
|
|
assert.ok(game.setupStage === 'prediction' || game.setupStage === 'leaderSkills');
|
|
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
|
|
assert.ok(game.players.every(p => !p.traitors.length && !p.traitorChoices.length));
|
|
assert.equal(game.leaderSkills!.assignments.length, 0);
|
|
classicNexusSkillsPaymentsInventory(game);
|
|
return game;
|
|
}
|
|
assert.equal(game.status, 'lobby');
|
|
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
|
|
assert.ok(classicNexusLeaderSkillsProfile(game));
|
|
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
|
const skill = options.skill ?? 'sandmaster';
|
|
const passive: LeaderSkillId[] = ['sandmaster', 'smuggler', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins', 'swordmaster-of-ginaz']
|
|
.filter(candidate => candidate !== skill) as LeaderSkillId[];
|
|
const selected = game.players.map(p => p.faction === faction ? skill : passive.shift()!);
|
|
return originalRandom(() => initializeLeaderSkillsGameForAudit(game), selected);
|
|
}
|
|
|
|
function reserveBoard(game: Game, staging: string[]): void {
|
|
for (const player of game.players) {
|
|
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
|
|
player.forces = {};
|
|
if (player.elites) {
|
|
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
|
|
player.elites.forces = {};
|
|
}
|
|
if (player.advisors) player.advisors = {};
|
|
}
|
|
staging.push('Conserved original on-board counters into their own reserves; no wallet, phase, turn or module owner assigned.');
|
|
}
|
|
function orderUndealtSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
|
|
const front = [];
|
|
for (const kind of kinds) {
|
|
const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
|
|
assert.ok(index >= 0, 'Only original still-undealt physical Spice Cards may be ordered');
|
|
front.push(game.spiceDeck.splice(index, 1)[0]);
|
|
}
|
|
game.spiceDeck.unshift(...front);
|
|
staging.push(`Ordered original undealt Spice Card singletons: ${kinds.join(', ')}; not a natural shuffle history.`);
|
|
}
|
|
function holdTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
|
const owner = classicNexusSkillsPaymentsPlayer(game, actor);
|
|
if (owner.traitors.includes(identity)) return;
|
|
const returned = owner.traitors[0];
|
|
const index = game.traitorReserve!.indexOf(identity);
|
|
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
|
assert.ok(returned && (index >= 0 || donor));
|
|
if (index >= 0) game.traitorReserve![index] = returned;
|
|
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
|
owner.traitors[0] = identity;
|
|
staging.push(`Conserved original traitor ${identity}: exchanged ${returned} with ${donor?.id ?? 'reserve'} custody.`);
|
|
}
|
|
|
|
export function createClassicNexusSkillsPaymentsFixture(options: ClassicNexusSkillsPaymentsOptions = {}): ClassicNexusSkillsPaymentsFixture {
|
|
const initial = initializeClassicNexusSkillsPaymentsSetup(options);
|
|
return originalRandom(() => {
|
|
const kind = options.kind ?? 'emperor-revival';
|
|
const faction = options.ownerFaction ?? (kind === 'harkonnen-cunning' ? 'harkonnen' : 'atreides');
|
|
const actions: ClassicNexusSkillsPaymentStep[] = [], staging: string[] = [], planActions: ClassicNexusSkillsPaymentStep[] = [];
|
|
let game = advanceClassicNexusSkillsPayments(initial, state => state.status === 'playing', actions);
|
|
const afterSetup = structuredClone(game);
|
|
const actor = game.players.find(p => p.faction === faction)!.id;
|
|
assert.ok(game.players.length >= 3, 'A real alliance leaves this original seat unallied');
|
|
const opponent = game.players.find(p => p.id !== actor)!.id;
|
|
reserveBoard(game, staging);
|
|
game = advanceClassicNexusSkillsPaymentsToPhase(game, 1, actions);
|
|
orderUndealtSpice(game, ['land', 'land'], staging);
|
|
game = advanceClassicNexusSkillsPaymentsToPhase(game, 8, actions);
|
|
let beforeFirstMentat: Game | undefined, firstMentatStep: ClassicNexusSkillsPaymentStep | undefined;
|
|
while (game.turn === 1) {
|
|
const before = structuredClone(game);
|
|
const next = nextClassicNexusSkillsPaymentsNativeStep(game); assert.ok(next);
|
|
game = advanceClassicNexusSkillsPaymentsStep(game, actions);
|
|
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
|
|
}
|
|
assert.ok(beforeFirstMentat && firstMentatStep);
|
|
const afterFirstMentat = structuredClone(game);
|
|
game = advanceClassicNexusSkillsPaymentsToPhase(game, 1, actions);
|
|
reserveBoard(game, staging);
|
|
orderUndealtSpice(game, ['worm', 'land', 'land'], staging);
|
|
let allied = false;
|
|
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) {
|
|
if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) {
|
|
const others = game.players.filter(p => p.id !== actor);
|
|
for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) {
|
|
const action: Action = { type: 'alliance', target: to };
|
|
actions.push({ actor: from, action }); game = applyAction(game, from, action);
|
|
}
|
|
allied = true;
|
|
}
|
|
game = advanceClassicNexusSkillsPaymentsStep(game, actions);
|
|
}
|
|
assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing');
|
|
const card: FactionId = kind === 'harkonnen-cunning' ? 'harkonnen' : kind === 'fremen-revival' ? 'fremen' : 'emperor';
|
|
const cards = game.nexusCards!.cards!;
|
|
const index = cards.deck.indexOf(card); assert.ok(index >= 0);
|
|
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
|
staging.push(`Ordered original undealt ${card} Nexus singleton before the real closing draw; no hand grant.`);
|
|
const beforeClosingDraw = structuredClone(game);
|
|
const closingDraw: ClassicNexusSkillsPaymentStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
|
card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
|
|
actions.push(closingDraw); game = applyAction(game, actor, closingDraw.action);
|
|
assert.equal(game.nexusCards!.cards!.hands[actor], card);
|
|
const afterClosingDraw = structuredClone(game);
|
|
game = advanceClassicNexusSkillsPayments(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions);
|
|
let paymentAction: Action, revivalAction: Action | undefined, declaredIdentity: string | undefined;
|
|
if (kind === 'emperor-purchase') {
|
|
game = advanceClassicNexusSkillsPaymentsToPhase(game, 3, actions);
|
|
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 160; n++) {
|
|
if (game.auction?.active === actor && !game.auction.bid) {
|
|
const action: Action = { type: 'bid', amount: 2 };
|
|
actions.push({ actor, action }); game = applyAction(game, actor, action);
|
|
} else game = advanceClassicNexusSkillsPaymentsStep(game, actions);
|
|
}
|
|
assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(game.decision!.player, actor);
|
|
assert.equal(game.auction!.bid, 2); assert.equal(game.auction!.bidder, actor);
|
|
paymentAction = { type: 'nexusEmperorPurchase', event: viewGame(game, actor).nexusEmperorSecretAlly!.event };
|
|
} else if (kind === 'harkonnen-cunning') {
|
|
assert.equal(faction, 'harkonnen');
|
|
if (options.declaredTraitor) {
|
|
game = advanceClassicNexusSkillsPaymentsToPhase(game, 5, actions);
|
|
reserveBoard(game, staging);
|
|
for (const id of [actor, opponent]) {
|
|
const player = classicNexusSkillsPaymentsPlayer(game, id);
|
|
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= 5);
|
|
player.reserves -= 5; player.forces['pasty_mesa:5'] = 5;
|
|
}
|
|
assert.notEqual(game.storm, 5, 'The actual retained storm must leave Pasty Mesa available');
|
|
staging.push('Conserved five ordinary reserve counters per combatant to Pasty Mesa sector five before genuine Movement completion.');
|
|
const untrained = (id: string) => classicNexusSkillsPaymentsPlayer(game, id).leaders
|
|
.filter(leader => !leader.dead && !game.leaderSkills!.assignments.some(a => a.leader === leader.id));
|
|
const ownLeader = untrained(actor).sort((a, b) => b.strength - a.strength)[0];
|
|
const targetLeader = untrained(opponent).sort((a, b) => a.strength - b.strength)[0];
|
|
assert.ok(ownLeader && targetLeader); declaredIdentity = targetLeader.id;
|
|
holdTraitor(game, actor, declaredIdentity, staging);
|
|
if (options.declaredTraitor === 'decline') {
|
|
const owner = classicNexusSkillsPaymentsPlayer(game, actor);
|
|
const replacement = game.traitorReserve![0];
|
|
owner.traitors[owner.traitors.indexOf(declaredIdentity)] = replacement;
|
|
game.traitorReserve![0] = declaredIdentity;
|
|
staging.push(`Conserved ${declaredIdentity}: exchanged back to the actual next traitor draw for ${replacement}.`);
|
|
}
|
|
game = advanceClassicNexusSkillsPaymentsToPhase(game, 6, actions);
|
|
assert.equal(game.phase, 6);
|
|
const battleActor = game.active!;
|
|
const battleAction: Action = { type: 'chooseBattle', territory: 'pasty_mesa', target: battleActor === actor ? opponent : actor };
|
|
actions.push({ actor: battleActor, action: battleAction }); game = applyAction(game, battleActor, battleAction);
|
|
game = advanceClassicNexusSkillsPayments(game, state => !nextClassicNexusSkillsPaymentsNativeStep(state), actions);
|
|
for (const [id, leader] of [[actor, ownLeader.id], [opponent, targetLeader.id]]) {
|
|
const plan: ClassicNexusSkillsPaymentStep = { actor: id, action: { type: 'battlePlan', dial: 0, support: 0, leader, weapon: null, defense: null } };
|
|
planActions.push(plan); actions.push(plan); game = applyAction(game, id, plan.action);
|
|
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = advanceClassicNexusSkillsPaymentsStep(game, actions);
|
|
}
|
|
assert.ok(game.battle?.revealed);
|
|
const call: Action = { type: 'traitorCall', call: options.declaredTraitor === 'call' };
|
|
actions.push({ actor, action: call }); game = applyAction(game, actor, call);
|
|
} else game = advanceClassicNexusSkillsPaymentsToPhase(game, 8, actions);
|
|
const offer = viewGame(game, actor).nexusTraitors!.offer!;
|
|
assert.equal(offer.mode, 'cunning'); assert.equal(offer.blocked, null);
|
|
paymentAction = { type: 'nexusTraitorDraw', event: offer.event, mode: offer.mode };
|
|
} else {
|
|
game = advanceClassicNexusSkillsPaymentsToPhase(game, 4, actions);
|
|
const player = classicNexusSkillsPaymentsPlayer(game, actor);
|
|
const tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0;
|
|
assert.ok(Number.isInteger(tanks) && tanks >= 0 && elite >= 0 && elite <= tanks);
|
|
assert.ok(tanks - elite <= player.reserves - (player.elites?.reserves ?? 0) && elite <= (player.elites?.reserves ?? 0));
|
|
player.reserves -= tanks; player.tanks += tanks;
|
|
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
|
|
staging.push(`Conserved ${actor}: ${tanks} reserve counters to Tanks, including ${elite} original elites; no earned-revival receipt staged.`);
|
|
const view = viewGame(game, actor);
|
|
if (kind === 'emperor-revival') paymentAction = { type: 'nexusEmperorRevive',
|
|
event: view.nexusEmperorSecretAlly!.event, elite: elite ? 1 : 0 };
|
|
else {
|
|
paymentAction = { type: 'nexusFremenRevival', event: view.nexusFremenRevival!.event };
|
|
revivalAction = { type: 'revive', amount: 3, elite: elite ? 1 : 0 };
|
|
}
|
|
}
|
|
classicNexusSkillsPaymentsInventory(game);
|
|
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeClosingDraw,
|
|
closingDraw, afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind,
|
|
paymentAction, revivalAction, actions, planActions, staging, declaredIdentity };
|
|
});
|
|
}
|
|
|
|
/** Stops at the real private Cunning return instead of silently choosing it. */
|
|
export function payClassicNexusSkillsPaymentsFixture(fixture: ClassicNexusSkillsPaymentsFixture, state = fixture.game): Game {
|
|
let game = applyAction(state, fixture.actor, fixture.paymentAction);
|
|
if (fixture.revivalAction) game = applyAction(game, fixture.actor, fixture.revivalAction);
|
|
return game;
|
|
}
|
|
export function finishClassicNexusSkillsPaymentsFixture(fixture: ClassicNexusSkillsPaymentsFixture,
|
|
state: Game, returned?: string[]): Game {
|
|
let game = state;
|
|
if (fixture.kind === 'harkonnen-cunning') {
|
|
const pending = viewGame(game, fixture.actor).nexusTraitors!.pending!;
|
|
assert.ok(pending);
|
|
const cards = returned ?? pending.choices.filter(choice => choice.drawn).map(choice => choice.id);
|
|
game = applyAction(game, fixture.actor, { type: 'nexusTraitorReturn', event: pending.event, cards });
|
|
}
|
|
return advanceClassicNexusSkillsPayments(game, current => clean(current) && !current.pendingRevival && !current.nexusTraitorPending);
|
|
}
|
|
export function classicNexusSkillsPaymentsInventory(game: Game): void {
|
|
nexusInventory(game);
|
|
validateLeaderSkills(game.leaderSkills!, game.players);
|
|
assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards),
|
|
...game.leaderSkills!.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
|
|
if (game.status !== 'setup')
|
|
assert.deepEqual([...game.traitorReserve!, ...game.players.flatMap(player => player.traitors)].sort(),
|
|
traitorDeck(game.players).sort(), 'Every original traitor identity remains in reserve or actual hand custody');
|
|
for (const player of game.players) if (player.elites)
|
|
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((a, b) => a + b, 0),
|
|
player.faction === 'emperor' ? 5 : 3, 'Original Advanced elite counters are conserved separately');
|
|
}
|