Compose standalone E3 Leader Skills with Tech and Strongholds
This commit is contained in:
1 parent
a8f5a3c953
commit
2b72021611
17 files changed
+1303
-23
No files matched your search
@@ -0,0 +1,252 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, 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 { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import {
|
||||
advanceEcazSkillsModules as advance, assertEcazSkillsModulesCustody as custody,
|
||||
createEcazSkillsModulesFixture as fixture, ecazSkillsModulesAction as act,
|
||||
ecazSkillsModulesPlayer as player, ecazSkillsModulesTrainer as trainer,
|
||||
reloadEcazSkillsModules as reload, revealEcazSkillsModulesBattle as reveal,
|
||||
settleEcazSkillsModulesBattle as settle,
|
||||
} from './fixture-ecaz-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, 'A rejected combined-module action must preserve the real 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;
|
||||
}
|
||||
function collection(game: Game, actor: string): number {
|
||||
if (game.phase !== 7 || game.phaseOpening) return 0;
|
||||
return quoteSpiceCollection({ ...game, players: game.players.map(p => ({ ...p, spice: 0 })) }).receipts
|
||||
.filter(r => r.player === actor).reduce((sum, r) => sum + r.strongholds + r.collected, 0);
|
||||
}
|
||||
|
||||
void test('original Ecaz normal and selected Suk rescue physical ordinary losses before winner cards and earned Tech', () => {
|
||||
for (const rules of ['basic', 'advanced'] as const) {
|
||||
for (const stronghold of rules === 'advanced' ? [false, true] : [false]) {
|
||||
for (const band of ['normal', 'skilled'] as const) {
|
||||
const f = fixture({ rules, tech: true, stronghold, band });
|
||||
const assigned = structuredClone(trainer(f.game, f.owner));
|
||||
const before = reveal(f), wallet = player(before, f.owner).spice, reserves = player(before, f.owner).reserves;
|
||||
const defeatedTokens = ownedTech(before.techTokens, f.opponent);
|
||||
assert.equal(defeatedTokens.length, 1, 'Actual three-seat first Storm assigns one physical token to each original seat.');
|
||||
const supportCost = rules === 'advanced' ? stronghold ? 3 : 5 : 0;
|
||||
let game = settle(before, band === 'skilled' ? 'suk' : 'cards');
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(game, f.owner).spice, wallet - supportCost);
|
||||
assert.equal(game.techTokens![defeatedTokens[0]].owner, f.opponent, 'Neither committed rescue nor optional card retention steals technology early.');
|
||||
if (band === 'skilled') {
|
||||
assert.equal(game.decision?.kind, 'sukRescue');
|
||||
if (game.decision?.kind !== 'sukRescue') throw Error('Expected physical trained-disc Suk rescue.');
|
||||
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 5, elite: 0, paidNormal: rules === 'advanced' ? 5 : 0, paidElite: 0 });
|
||||
reject(game, f.opponent, { type: 'decision', event: game.decision.event, choice: 0 });
|
||||
reject(game, f.owner, { type: 'decision', event: `${game.decision.event}-stale`, choice: 0 });
|
||||
reject(game, f.owner, { type: 'decision', event: game.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: game.decision.event, choice }), 'cards');
|
||||
assert.equal(player(game, f.owner).forces[f.location], 4);
|
||||
assert.equal(player(game, f.owner).reserves, reserves + 2);
|
||||
assert.equal(player(game, f.owner).tanks, 2);
|
||||
assert.equal(game.pendingSukRescue, null);
|
||||
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
|
||||
} else {
|
||||
assert.equal(player(game, f.owner).forces[f.location], 3);
|
||||
assert.equal(player(game, f.owner).reserves, reserves + 1);
|
||||
assert.equal(player(game, f.owner).tanks, 4);
|
||||
assert.equal(game.pendingSukRescue, null);
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
assert.equal(game.decision?.player, f.owner);
|
||||
reject(game, f.owner, { type: 'decision', token: defeatedTokens[0] });
|
||||
assert.equal(game.techTokens![defeatedTokens[0]].owner, f.opponent);
|
||||
game = settle(act(game, f.owner, { type: 'decision', discard: [] }));
|
||||
assert.equal(game.techTokens![defeatedTokens[0]].owner, f.owner);
|
||||
assert.equal(player(game, f.opponent).tanks, 8);
|
||||
assert.deepEqual(trainer(game, f.owner), assigned);
|
||||
custody(game);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four real legal policies rescue trained native Ecaz counters and resolve its physical card/Tech continuation', () => {
|
||||
const f = fixture({ tech: true, stronghold: true, band: 'skilled' });
|
||||
const revealed = reveal(f), source = settle(revealed, 'suk');
|
||||
assert.equal(source.decision?.kind, 'sukRescue');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const rescued = settle(act(source, f.owner, policy(source, f.owner, difficulty)), 'cards');
|
||||
assert.equal(rescued.pendingSukRescue, null);
|
||||
assert.equal(player(rescued, f.owner).tanks, 2);
|
||||
assert.equal(player(rescued, f.owner).reserves, player(revealed, f.owner).reserves + 2);
|
||||
assert.equal(player(rescued, f.owner).forces[f.location], 4);
|
||||
assert.equal(player(rescued, f.owner).spice, player(revealed, f.owner).spice - 3);
|
||||
assert.equal(rescued.decision?.kind, 'battleCards');
|
||||
const cardAction = policy(rescued, f.owner, difficulty);
|
||||
const finished = settle(act(rescued, f.owner, cardAction));
|
||||
assert.equal(finished.battle, null);
|
||||
assert.equal(finished.lastBattleContext!.winner, f.owner);
|
||||
for (const token of ownedTech(revealed.techTokens, f.opponent)) assert.equal(finished.techTokens![token].owner, f.owner);
|
||||
assert.equal(player(finished, f.owner).tanks, 2);
|
||||
custody(finished);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real Arrakeen subsidy and separately opted-in normal Banker count only the opposing paid Bank leg', () => {
|
||||
const f = fixture({ kind: 'income', band: 'normal', bankerIncome: true, tech: true, stronghold: true, opponentDial: 4, defense: true });
|
||||
const before = reveal(f), sources = before.spiceBankerIncome!.sources.length;
|
||||
const game = settle(before, 'cards');
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 3);
|
||||
assert.equal(player(game, f.opponent).spice, player(before, f.opponent).spice - 4);
|
||||
const payments = game.spiceBankerIncome!.sources.slice(sources).filter(r => r.source.kind === 'battle-support');
|
||||
assert.deepEqual(payments.map(r => r.source.bankLegs), [
|
||||
[{ payer: before.battle!.attacker, amount: before.battle!.attacker === f.owner ? 3 : 4 }],
|
||||
[{ payer: before.battle!.defender, amount: before.battle!.defender === f.owner ? 3 : 4 }],
|
||||
]);
|
||||
assert.deepEqual(payments.filter(r => r.grant).map(r => ({ owner: r.grant!.owner, amount: r.grant!.amount })), [{ owner: f.owner, amount: 1 }]);
|
||||
const finished = settle(game);
|
||||
const beforeMentat = advance(finished, g => g.phase === 7 && !g.response && !g.phaseOpening && !g.decision);
|
||||
const wallet = player(beforeMentat, f.owner).spice;
|
||||
const mentat = advance(beforeMentat, g => g.phase === 8 && !g.response && !g.phaseOpening && !g.decision);
|
||||
assert.equal(player(mentat, f.owner).spice, wallet + 1, 'The one front-of-shield grant becomes wallet spice only at actual Mentat.');
|
||||
custody(mentat);
|
||||
});
|
||||
|
||||
void test('original Ambassador Duke6 receives normal Warmaster1, never the separate trained-disc3, and survives release with native assignment intact', () => {
|
||||
for (const rules of ['basic', 'advanced'] as const) {
|
||||
for (const band of ['normal', 'skilled'] as const) {
|
||||
const f = fixture({ rules, kind: 'duke', tech: true, stronghold: rules === 'advanced', band });
|
||||
assert.ok(f.acquisition);
|
||||
assert.equal(player(f.acquisition.after, f.owner).spice, player(f.acquisition.before, f.owner).spice - 1);
|
||||
assert.equal(player(f.acquisition.after, f.opponent).spice, player(f.acquisition.before, f.opponent).spice - 1);
|
||||
assert.equal(f.acquisition.after.dukeVidal!.source, 'ecaz');
|
||||
const assigned = structuredClone(trainer(f.game, f.owner)), native = structuredClone(player(f.game, f.owner).leaders);
|
||||
const before = reveal(f), game = settle(before);
|
||||
assert.equal(game.lastBattleContext!.winner, band === 'normal' ? f.owner : f.opponent);
|
||||
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(trainer(game, f.owner), assigned);
|
||||
assert.deepEqual(player(game, f.owner).leaders, native);
|
||||
assert.ok(viewGame(game, f.owner).leaderSkills!.eligibleLeaders.every(l => l.id !== DUKE_VIDAL_ID));
|
||||
custody(game);
|
||||
}
|
||||
const f = fixture({ rules, kind: 'duke', tech: true, stronghold: rules === 'advanced', opponentDial: 3 });
|
||||
const game = settle(reveal(f));
|
||||
assert.equal(game.lastBattleContext!.winner, f.opponent, 'Duke6+normal1 loses to printed5+3; borrowing the selected trainer bonus would incorrectly win.');
|
||||
assert.equal(game.dukeVidal!.leader.dead, false);
|
||||
custody(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual retained Carthag adds Snooper to real Duke Shield, while Tuek pays for played Worthless even on printed6 death and paid5 revival', () => {
|
||||
const defended = fixture({ kind: 'duke', strongholdKind: 'carthag', poison: true, defense: true });
|
||||
const safe = settle(reveal(defended));
|
||||
assert.equal(safe.lastBattleContext!.winner, defended.owner);
|
||||
assert.equal(safe.dukeVidal!.leader.dead, false, 'Retained Carthag adds the printed Snooper property to the original physical Shield.');
|
||||
custody(safe);
|
||||
const f = fixture({ kind: 'duke', strongholdKind: 'tueks_sietch', poison: true, opponentDial: 0 });
|
||||
const assigned = structuredClone(trainer(f.game, f.owner)), native = structuredClone(player(f.game, f.owner).leaders);
|
||||
const before = reveal(f), dead = settle(before);
|
||||
assert.equal(dead.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(dead.dukeVidal!.leader.dead, true);
|
||||
assert.equal(dead.dukeVidal!.leader.deaths, 1);
|
||||
assert.equal(player(dead, f.owner).spice, player(before, f.owner).spice + 2 + collection(dead, f.owner), 'Tuek counts the real Worthless even though Duke dies and Ecaz loses.');
|
||||
assert.equal(player(dead, f.opponent).spice, player(before, f.opponent).spice + 6 + collection(dead, f.opponent), 'Only the printed shared-disc6 is paid, not normal7 or trained9.');
|
||||
assert.deepEqual(player(dead, f.owner).leaders, native);
|
||||
assert.deepEqual(trainer(dead, f.owner), assigned);
|
||||
const skillInventory = structuredClone(dead.leaderSkills);
|
||||
const revival = advance(dead, g => g.turn > dead.turn && g.phase === 4 && !g.response && !g.phaseOpening && !g.decision);
|
||||
const option = viewGame(revival, f.owner).revival.leaders.find(l => l.id === DUKE_VIDAL_ID)!;
|
||||
assert.equal(option.cost, 5); assert.equal(option.normalCost, 5); assert.equal(option.affordable, true);
|
||||
reject(revival, f.opponent, { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
const funds = player(revival, f.owner).spice;
|
||||
let revived = act(revival, f.owner, { type: 'reviveLeader', leader: DUKE_VIDAL_ID });
|
||||
revived = advance(revived, g => !g.response && !g.pendingRevival);
|
||||
assert.equal(player(revived, f.owner).spice, funds - 5);
|
||||
assert.equal(player(revived, f.owner).leaderRevived, true);
|
||||
assert.equal(revived.dukeVidal!.leader.dead, false);
|
||||
assert.equal(revived.dukeVidal!.leader.deaths, 1);
|
||||
assert.deepEqual([revived.dukeVidal!.controller, revived.dukeVidal!.source, revived.dukeVidal!.acquiredTurn], [null, null, null]);
|
||||
assert.deepEqual(revived.leaderSkills, skillInventory);
|
||||
assert.deepEqual(trainer(revived, f.owner), assigned);
|
||||
assert.equal(revived.leaderSkills!.offers[f.owner], undefined);
|
||||
custody(revived);
|
||||
});
|
||||
|
||||
void test('fresh human IDs retain original module acquisition chronology; insufficient Tech seats and Advanced Hark reject without conversion', () => {
|
||||
const initial = createGame('HUMANECAZMODULES', 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 snapshot = structuredClone(initial), f = fixture({ initial, kind: 'suk' });
|
||||
assert.deepEqual(initial, snapshot);
|
||||
assert.deepEqual(f.game.players.map(p => p.id), snapshot.players.map(p => p.id));
|
||||
assert.equal(f.owner, 'human-ecaz'); assert.equal(f.opponent, 'human-emperor');
|
||||
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
|
||||
const claimed = act(f.beforeFirstMentat, f.firstMentatStep.actor, f.firstMentatStep.action);
|
||||
assert.equal(claimed.turn, 2); assert.equal(claimed.strongholdCards!.claimedTurn, 1);
|
||||
assert.equal(claimed.strongholdCards!.owners.arrakeen, f.owner);
|
||||
const physicalSkills = [...f.offered.leaderSkills!.deck, ...Object.values(f.offered.leaderSkills!.offers).flatMap(o => o.cards)];
|
||||
assert.deepEqual(physicalSkills.sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
|
||||
assert.equal(f.offered.players.find(p => p.id === f.owner)!.reserves, 20);
|
||||
const two = createGame('ECAZTWOTECH', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
joinGame(two, newPlayer('p', 'Emperor', 'emperor'));
|
||||
let unsupported = applyAction(two, two.host, { type: 'techTokens', enabled: true });
|
||||
for (const p of unsupported.players) unsupported = applyAction(unsupported, p.id, { type: 'ready' });
|
||||
const before = structuredClone(unsupported);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported)); assert.deepEqual(unsupported, before);
|
||||
const hark = createGame('ECAZHARK', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
joinGame(hark, newPlayer('p', 'Emperor', 'emperor')); joinGame(hark, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
let advancedHark = applyAction(hark, hark.host, { type: 'techTokens', enabled: true });
|
||||
for (const p of advancedHark.players) advancedHark = applyAction(advancedHark, p.id, { type: 'ready' });
|
||||
const harkBefore = structuredClone(advancedHark);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(advancedHark)); assert.deepEqual(advancedHark, harkBefore);
|
||||
custody(settle(reveal(f)));
|
||||
});
|
||||
|
||||
void test('real two-seat Stronghold and six-seat Basic/Advanced Tech boundaries preserve native Emperor5 and Fremen3 inventories', () => {
|
||||
const two = createGame('ECAZTWOSTRONG', newPlayer('two-ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
joinGame(two, newPlayer('two-emperor', 'Emperor', 'emperor'));
|
||||
const twoCase = fixture({ initial: two, tech: false, stronghold: true });
|
||||
const twoDone = settle(reveal(twoCase));
|
||||
assert.equal(twoDone.lastBattleContext!.winner, twoCase.owner);
|
||||
assert.equal(player(twoDone, twoCase.owner).tanks, 2);
|
||||
assert.equal(twoCase.afterFirstMentat.strongholdCards!.owners.arrakeen, twoCase.owner);
|
||||
custody(twoDone);
|
||||
for (const rules of ['basic', 'advanced'] as const) {
|
||||
const six = createGame(`ECAZSIX${rules}`, newPlayer('six-ecaz', 'Ecaz', 'ecaz'), rules === 'advanced', ['ecaz']);
|
||||
for (const faction of ['emperor', 'atreides', 'guild', 'fremen', 'beneGesserit'] as const)
|
||||
joinGame(six, newPlayer(`six-${faction}`, faction, faction));
|
||||
const f = fixture({ initial: six, rules, tech: true, stronghold: rules === 'advanced' });
|
||||
const game = settle(reveal(f));
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(game, f.owner).forces[f.location], 4);
|
||||
assert.equal(player(game, f.owner).tanks, 2);
|
||||
if (rules === 'advanced') {
|
||||
assert.equal(game.players.find(p => p.faction === 'fremen')!.elites!.reserves, 3);
|
||||
assert.equal(game.players.find(p => p.faction === 'emperor')!.elites!.reserves, 5);
|
||||
} else assert.ok(game.players.every(p => !p.elites));
|
||||
assert.deepEqual(game.players.map(p => p.id), six.players.map(p => p.id));
|
||||
custody(game);
|
||||
}
|
||||
const basicHark = createGame('ECAZBASICHARK', newPlayer('basic-ecaz', 'Ecaz', 'ecaz'), false, ['ecaz']);
|
||||
joinGame(basicHark, newPlayer('basic-emperor', 'Emperor', 'emperor'));
|
||||
joinGame(basicHark, newPlayer('basic-hark', 'Harkonnen', 'harkonnen'));
|
||||
const allowed = fixture({ initial: basicHark, rules: 'basic', tech: true, stronghold: false });
|
||||
const done = settle(reveal(allowed));
|
||||
assert.equal(done.lastBattleContext!.winner, allowed.owner);
|
||||
assert.equal(player(done, allowed.owner).tanks, 2);
|
||||
assert.equal(player(done, 'basic-hark').traitors.length, 4);
|
||||
custody(done);
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { leaders, treacheryDeck, type Card } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import type { StrongholdId } from '../game/stronghold-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills';
|
||||
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type EcazSkillsModulesOptions = {
|
||||
/** Only an original undealt authenticated lobby; preserve its IDs and order. */
|
||||
initial?: Game;
|
||||
rules?: 'basic' | 'advanced';
|
||||
tech?: boolean;
|
||||
stronghold?: boolean;
|
||||
kind?: 'suk' | 'duke' | 'income';
|
||||
skill?: LeaderSkillId;
|
||||
band?: 'normal' | 'skilled';
|
||||
strongholdKind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
|
||||
/** Separate existing native Banker income opt-in, not a skill-module default. */
|
||||
bankerIncome?: boolean;
|
||||
poison?: boolean;
|
||||
defense?: boolean;
|
||||
opponentDial?: number;
|
||||
};
|
||||
export type EcazSkillsModulesFixture = {
|
||||
initial: Game; offered: Game; afterSetup: Game;
|
||||
beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; afterFirstMentat: Game;
|
||||
acquisition: { before: Game; after: Game; actions: StrongholdFactionsNativeStep[] } | null;
|
||||
beforeBattle: Game; game: Game;
|
||||
owner: string; opponent: string; trainer: string;
|
||||
territory: Exclude<StrongholdId, 'hidden_mobile_stronghold'>; location: string;
|
||||
plans: StrongholdFactionsNativeStep[]; actions: StrongholdFactionsNativeStep[]; staging: string[];
|
||||
};
|
||||
export const reloadEcazSkillsModules = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
export const ecazSkillsModulesAction = (g: Game, actor: string, action: Action): Game =>
|
||||
applyAction(reloadEcazSkillsModules(g), actor, action);
|
||||
export function ecazSkillsModulesPlayer(game: Game, actor: string): Player {
|
||||
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
|
||||
}
|
||||
export const ecazSkillsModulesTrainer = (game: Game, owner: string) => {
|
||||
const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner); assert.ok(assignment); return assignment;
|
||||
};
|
||||
export function assertEcazSkillsModulesCustody(game: Game): void {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'fremen' ? 3 : 5, p.faction);
|
||||
for (const [location, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[location] ?? 0));
|
||||
}
|
||||
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
|
||||
}
|
||||
const physical = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])];
|
||||
assert.deepEqual(physical.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
|
||||
const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices]),
|
||||
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])];
|
||||
assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort());
|
||||
assert.equal(game.players.find(p => p.faction === 'ecaz')!.leaders.length, 5);
|
||||
assert.equal(game.dukeVidal!.leader.id, DUKE_VIDAL_ID);
|
||||
assert.equal(game.dukeVidal!.leader.strength, 6);
|
||||
assert.ok(game.leaderSkills!.assignments.every(a => a.leader !== DUKE_VIDAL_ID));
|
||||
validateAmbassadors(game.ecazAmbassadors!);
|
||||
}
|
||||
const clean = (g: Game) => !g.response && !g.phaseOpening && !g.decision;
|
||||
/** Responses always precede decisions: consume the original persisted pass queue. */
|
||||
export function nextEcazSkillsModulesStep(game: Game, hide = true): StrongholdFactionsNativeStep | null {
|
||||
if (game.response || game.phaseOpening || game.pendingTreacheryDiscard)
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'ecazPlacement' || d?.kind === 'ecazAmbassador')
|
||||
return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player,
|
||||
action: { type: 'leaderSkillVisibility', event: d.event, hide } };
|
||||
if (d?.kind === 'mentatQuestion') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
||||
if (d?.kind === 'rihani') return { actor: d.player, action: { type: 'decision', event: d.event, draw: false } };
|
||||
if (d?.kind === 'sukRescue') {
|
||||
const maximum = Math.max(...d.options.map(o => o.normal + o.elite));
|
||||
const choice = d.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal');
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event,
|
||||
choice: choice >= 0 ? choice : d.options.findIndex(o => o.normal + o.elite === maximum) } };
|
||||
}
|
||||
if (d?.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
function step(game: Game, next: StrongholdFactionsNativeStep, actions?: StrongholdFactionsNativeStep[]): Game {
|
||||
actions?.push(structuredClone(next));
|
||||
return ecazSkillsModulesAction(game, next.actor, next.action);
|
||||
}
|
||||
export function advanceEcazSkillsModules(state: Game, until: (g: Game) => boolean,
|
||||
actions?: StrongholdFactionsNativeStep[], hide = true, firstDial = 0): Game {
|
||||
let game = state;
|
||||
for (let i = 0; i < 1800; i++) {
|
||||
if (until(game)) return game;
|
||||
assert.equal(game.status, 'playing');
|
||||
const next = nextEcazSkillsModulesStep(game, hide); assert.ok(next, 'An original private plan needs its actual owner.');
|
||||
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = firstDial;
|
||||
game = step(game, next, actions);
|
||||
}
|
||||
throw Error('Original Ecaz skill/module chronology did not reach its boundary.');
|
||||
}
|
||||
function reserveBoard(game: Game): void {
|
||||
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 = {};
|
||||
}
|
||||
}
|
||||
function place(game: Game, actor: string, location: string, count: number): void {
|
||||
const p = ecazSkillsModulesPlayer(game, actor); assert.ok(p.reserves >= count);
|
||||
p.reserves -= count; p.forces[location] = (p.forces[location] ?? 0) + count;
|
||||
}
|
||||
function take(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 ${card.id}: original remaining Ecaz33 deck to ${actor}'s hand.`); return card;
|
||||
}
|
||||
/** Original Ecaz33/all14 setup and first Storm assignment, then actual first END
|
||||
* Mentat. Only physical offer order, conserved native cards/board and unplayed
|
||||
* non-worm Spice Cards are staged. No phase, turn, storm, wallet, owner or receipt
|
||||
* is assigned, and no allied Occupy, E3 pair or overlay module is admitted. */
|
||||
export function createEcazSkillsModulesFixture(options: EcazSkillsModulesOptions = {}): EcazSkillsModulesFixture {
|
||||
const rules = options.rules ?? (options.initial?.advanced === false ? 'basic' : 'advanced');
|
||||
const tech = options.tech ?? true, stronghold = options.stronghold ?? rules === 'advanced';
|
||||
const kind = options.kind ?? 'suk', band = options.band ?? (kind === 'suk' ? 'skilled' : 'normal');
|
||||
const requested = options.skill ?? (kind === 'duke' ? 'warmaster' : kind === 'income' ? 'spice-banker' : 'suk-graduate');
|
||||
const strongholdKind = options.strongholdKind ?? 'arrakeen';
|
||||
const location = `${strongholdKind}:${territory(strongholdKind).sectors[0]}`;
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('ECAZSKILLSMODULES', newPlayer('ecaz', 'Ecaz', 'ecaz'), rules === 'advanced', ['ecaz']);
|
||||
if (!options.initial) for (const faction of ['emperor', 'atreides'] as FactionId[]) joinGame(game, newPlayer(faction, faction, faction));
|
||||
assert.equal(game.status, 'lobby', 'Only the original undealt authenticated lobby may be continued.');
|
||||
assert.equal(game.advanced, rules === 'advanced'); assert.deepEqual(game.expansions, ['ecaz']);
|
||||
assert.ok(game.players.length >= 2 && game.players.length <= 6 && (!tech || game.players.length >= 3));
|
||||
assert.ok(!stronghold || game.advanced);
|
||||
assert.ok(game.players.every(p => p.faction === 'ecaz' || ['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit', ...(game.advanced ? [] : ['harkonnen'])].includes(p.faction)));
|
||||
const owner = game.players.find(p => p.faction === 'ecaz')?.id, opponent = game.players.find(p => p.faction === 'emperor')?.id;
|
||||
assert.ok(owner && opponent);
|
||||
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 });
|
||||
const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
|
||||
game = initializeAdvancedNativeSkillsSetup({ family: 'ecaz', rules, requestedSkill: requested, bankerIncome: options.bankerIncome, initial: game });
|
||||
const offered = structuredClone(game);
|
||||
for (let i = 0; game.status === 'setup' && i < 200; i++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
|
||||
const own = viewGame(game, actor).leaderSkills!;
|
||||
const skill = actor === owner ? requested : offer.cards.find(c => !own.unavailableSkills?.[c]);
|
||||
const eligible = new Set(own.eligibleLeaders.map(l => l.id));
|
||||
const leader = ecazSkillsModulesPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader);
|
||||
game = step(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions);
|
||||
} else { const next = nextEcazSkillsModulesStep(game); assert.ok(next); game = step(game, next, actions); }
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game), trainer = ecazSkillsModulesTrainer(game, owner).leader;
|
||||
reserveBoard(game);
|
||||
const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 6);
|
||||
for (const [index, card] of blows.entries()) game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
staging.push('After original setup, return every native board counter to its own reserve; order only six existing unplayed non-worm Spice Cards, not a claimed random history.');
|
||||
const firstDial = ['tueks_sietch', 'habbanya_ridge_sietch'].includes(strongholdKind) ? 4 : 0;
|
||||
game = advanceEcazSkillsModules(game, g => g.phase === 8 && clean(g), actions, true, firstDial);
|
||||
reserveBoard(game); place(game, owner, location, 1);
|
||||
staging.push(`Before actual first END Mentat, transfer one Ecaz counter from its reserve to ${location}; no seat controls three holds.`);
|
||||
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
|
||||
for (let i = 0; game.turn === 1 && i < 200; i++) {
|
||||
const next = nextEcazSkillsModulesStep(game); assert.ok(next);
|
||||
const before = structuredClone(game); game = step(game, next, actions);
|
||||
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
|
||||
}
|
||||
assert.ok(beforeFirstMentat && firstMentatStep);
|
||||
if (stronghold) assert.equal(game.strongholdCards!.owners[strongholdKind], owner);
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
let acquisition: EcazSkillsModulesFixture['acquisition'] = null;
|
||||
if (kind === 'duke') {
|
||||
game = advanceEcazSkillsModules(game, g => g.phase === 4 && clean(g), actions);
|
||||
reserveBoard(game);
|
||||
game = advanceEcazSkillsModules(game, g => g.decision?.kind === 'ecazPlacement', actions);
|
||||
const before = structuredClone(game), acquisitionActions: StrongholdFactionsNativeStep[] = [];
|
||||
const token = game.ecazAmbassadors!.tokens.find(t => t.effect === 'ecaz')!;
|
||||
game = step(game, { actor: owner, action: { type: 'decision', token: token.id, territory: strongholdKind } }, acquisitionActions);
|
||||
game = advanceEcazSkillsModules(game, g => !g.response && g.decision?.kind === 'ecazPlacement', acquisitionActions);
|
||||
game = step(game, { actor: owner, action: { type: 'decision', decline: true } }, acquisitionActions);
|
||||
game = advanceEcazSkillsModules(game, g => g.phase === 5 && clean(g) && g.active === opponent, acquisitionActions);
|
||||
game = step(game, { actor: opponent, action: { type: 'ship', territory: strongholdKind, sector: territory(strongholdKind).sectors[0], amount: 1 } }, acquisitionActions);
|
||||
game = advanceEcazSkillsModules(game, g => !g.response && g.decision?.kind === 'ecazAmbassador', acquisitionActions);
|
||||
assert.equal(viewGame(game, owner).ambassadorEntry!.dukeAcquisition!.blocked, null);
|
||||
game = step(game, { actor: owner, action: { type: 'decision', event: game.pendingAmbassador!.event,
|
||||
trigger: true, beneficiary: owner, choice: 'duke' } }, acquisitionActions);
|
||||
assert.equal(game.dukeVidal!.controller, owner);
|
||||
actions.push(...acquisitionActions); acquisition = { before, after: structuredClone(game), actions: acquisitionActions };
|
||||
staging.push('Original paid Ambassador placement and entrant shipment acquire the separate Duke6; no controller assignment or skill reassignment.');
|
||||
} else game = advanceEcazSkillsModules(game, g => g.phase === 5 && clean(g), actions);
|
||||
reserveBoard(game);
|
||||
for (const actor of [owner, opponent]) place(game, actor, location, 8);
|
||||
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
|
||||
staging.push(`Before real turn-two Movement completes, conserve eight ordinary counters per combatant at ${location}; return original hands to the same native deck before physical transfers.`);
|
||||
const worthless = take(game, owner, c => c.kind === 'worthless', staging);
|
||||
const shield = options.defense ? take(game, owner, c => c.kind === 'shield', staging) : undefined;
|
||||
const poison = options.poison ? take(game, opponent, c => c.kind === 'poison', staging) : undefined;
|
||||
game = advanceEcazSkillsModules(game, g => g.phase === 6 && clean(g), actions);
|
||||
const beforeBattle = structuredClone(game);
|
||||
assert.ok(game.active === owner || game.active === opponent);
|
||||
game = step(game, { actor: game.active!, action: { type: 'chooseBattle', territory: strongholdKind,
|
||||
target: game.active === owner ? opponent : owner } }, actions);
|
||||
// The owner alone retains the requested visibility; every enemy skill is hidden.
|
||||
for (let i = 0; !(game.battle && clean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) && i < 200; i++) {
|
||||
const next = nextEcazSkillsModulesStep(game); assert.ok(next);
|
||||
if (next.action.type === 'leaderSkillVisibility' && next.actor === owner) next.action.hide = band === 'skilled';
|
||||
game = step(game, next, actions);
|
||||
}
|
||||
const enemy = ecazSkillsModulesPlayer(game, opponent);
|
||||
const enemyTrainer = ecazSkillsModulesTrainer(game, opponent).leader;
|
||||
const defender = enemy.leaders.filter(l => !l.dead && l.id !== enemyTrainer).sort((a, b) =>
|
||||
kind === 'duke' ? b.strength - a.strength : a.strength - b.strength)[0]; assert.ok(defender);
|
||||
const ownLeader = kind === 'duke' ? DUKE_VIDAL_ID : band === 'skilled' ? trainer
|
||||
: ecazSkillsModulesPlayer(game, owner).leaders.find(l => l.strength === 4 && l.id !== trainer)!.id;
|
||||
const ownerDial = kind === 'duke' ? 0 : 5;
|
||||
const opponentDial = options.opponentDial ?? (kind === 'duke'
|
||||
? game.battle!.attacker === owner ? 2 : 1 : kind === 'income' ? 1 : 0);
|
||||
const plans: StrongholdFactionsNativeStep[] = [
|
||||
{ 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 } },
|
||||
];
|
||||
assertEcazSkillsModulesCustody(game);
|
||||
return { initial, offered, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, acquisition,
|
||||
beforeBattle, game, owner, opponent, trainer, territory: strongholdKind, location, plans, actions, staging };
|
||||
}
|
||||
export function revealEcazSkillsModulesBattle(f: EcazSkillsModulesFixture): Game {
|
||||
let game = reloadEcazSkillsModules(f.game);
|
||||
for (const plan of f.plans) {
|
||||
game = step(game, plan);
|
||||
if (!game.battle?.revealed) game = advanceEcazSkillsModules(game,
|
||||
g => clean(g) && !g.battle?.preparation && g.battle?.preLeader?.closed !== false);
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function settleEcazSkillsModulesBattle(game: Game, stop: 'suk' | 'cards' | 'tech' | 'complete' = 'complete'): Game {
|
||||
return advanceEcazSkillsModules(game, g =>
|
||||
stop === 'suk' && g.decision?.kind === 'sukRescue' ||
|
||||
stop === 'cards' && g.decision?.kind === 'battleCards' ||
|
||||
stop === 'tech' && g.decision?.kind === 'techToken' ||
|
||||
!g.battle && clean(g) && !g.pendingTreacheryDiscard);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { Card } from '../game/cards';
|
||||
import { TERRITORIES, territory } from '../game/board';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills';
|
||||
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
|
||||
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type MoritaniSkillsModulesOptions = {
|
||||
initial?: Game;
|
||||
rules?: 'basic' | 'advanced';
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
holder?: 'moritani' | 'guild';
|
||||
normalCall?: boolean;
|
||||
};
|
||||
export type MoritaniSkillsModulesFixture = {
|
||||
initial: Game; afterSetup: Game; afterFirstStorm: Game;
|
||||
beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; afterFirstMentat: Game;
|
||||
beforeShipment: Game; shipmentStep: StrongholdFactionsNativeStep; afterShipment: Game; afterMovement: Game;
|
||||
beforeBattle: Game; game: Game; revealed: Game; pending: Game;
|
||||
moritani: string; opponent: string; holder: string; target: string;
|
||||
moritaniLeader: string; opponentLeader: string;
|
||||
moritaniCards: string[]; opponentCards: string[];
|
||||
battleAction: StrongholdFactionsNativeStep; planActions: StrongholdFactionsNativeStep[];
|
||||
sourceActions: StrongholdFactionsNativeStep[]; staging: string[];
|
||||
};
|
||||
export function moritaniSkillsModulesPlayer(game: Game, id: string) {
|
||||
const player = game.players.find(p => p.id === id); assert.ok(player, `Missing original seat ${id}`); return player;
|
||||
}
|
||||
export function moritaniSkillsModulesPolicy(game: Game, actor: string, difficulty: Difficulty = 'Easy') {
|
||||
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
|
||||
return botActions(view);
|
||||
}
|
||||
/** A response pass queue takes precedence over every simultaneous native choice.
|
||||
* A null result is the genuine boundary requiring a human sealed Battle Plan. */
|
||||
export function nextMoritaniSkillsModulesStep(game: Game): StrongholdFactionsNativeStep | null {
|
||||
if (game.response || game.phaseOpening || game.pendingTreacheryDiscard)
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
const d = game.decision;
|
||||
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: false } };
|
||||
if (d?.kind === 'moritaniSetup') {
|
||||
const action = moritaniSkillsModulesPolicy(game, d.player)[0]; assert.ok(action);
|
||||
return { actor: d.player, action };
|
||||
}
|
||||
if (d?.kind === 'moritaniPlacement') return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d?.kind === 'moritaniAssassinate') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
||||
if (d?.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]) {
|
||||
actions.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
|
||||
}
|
||||
export function advanceMoritaniSkillsModules(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next, 'A real original sealed Battle Plan is required');
|
||||
game = run(game, next, actions);
|
||||
}
|
||||
throw Error('Original Moritani skills/modules continuation did not reach its requested boundary');
|
||||
}
|
||||
export const moritaniSkillsModulesClean = (game: Game) => !game.response && !game.decision && !game.phaseOpening;
|
||||
|
||||
/** Native offers and starting wallets; never convert a played save or redeal a
|
||||
* supplied original setup. Lobby modules are enabled by their real host actions. */
|
||||
export function initializeMoritaniSkillsModulesOffers(options: MoritaniSkillsModulesOptions = {}): Game {
|
||||
const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true;
|
||||
const tech = options.tech ?? true;
|
||||
const strongholds = options.strongholds ?? advanced;
|
||||
let game: Game;
|
||||
if (options.initial) game = structuredClone(options.initial);
|
||||
else {
|
||||
game = createGame('MORITANISKILLSMODULES', newPlayer('moritani', 'Moritani', 'moritani'), advanced, ['ecaz']);
|
||||
joinGame(game, newPlayer('guild', 'Guild', 'guild')); joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
|
||||
}
|
||||
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']);
|
||||
assert.ok(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', 'atreides', 'emperor', 'fremen', 'guild', 'beneGesserit'];
|
||||
assert.ok(game.players.every(p => allowed.includes(p.faction)));
|
||||
assert.ok(tech || strongholds); assert.ok(!strongholds || advanced);
|
||||
if (game.status === 'lobby') {
|
||||
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 });
|
||||
} else {
|
||||
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
|
||||
}
|
||||
game = initializeAdvancedNativeSkillsSetup({ family: 'moritani', skillOwner: 'guild', requestedSkill: 'suk-graduate',
|
||||
rules: advanced ? 'advanced' : 'basic', initial: game });
|
||||
while (game.setupStage === 'prediction') {
|
||||
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
export function completeMoritaniSkillsModulesSetup(state: Game): Game {
|
||||
let game = state;
|
||||
const guild = game.players.find(p => p.faction === 'guild')!.id;
|
||||
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 === guild ? 'suk-graduate' : offer.cards.find(c => !own.unavailableSkills?.[c]);
|
||||
const leader = actor === guild ? own.eligibleLeaders.find(l => l.name === 'Master Bewt') : own.eligibleLeaders[0];
|
||||
assert.ok(skill && offer.cards.includes(skill) && leader);
|
||||
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: leader.id });
|
||||
} else {
|
||||
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
}
|
||||
assert.equal(game.status, 'playing'); return game;
|
||||
}
|
||||
function reserveBoard(game: Game) {
|
||||
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 = {};
|
||||
}
|
||||
}
|
||||
function place(game: Game, actor: string, location: string, count: number) {
|
||||
const p = moritaniSkillsModulesPlayer(game, actor); assert.ok(p.reserves >= count);
|
||||
p.reserves -= count; p.forces[location] = count;
|
||||
}
|
||||
function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[], excluded: string[] = []) {
|
||||
const p = moritaniSkillsModulesPlayer(game, actor);
|
||||
const held = p.hand.find(c => c.kind === kind && !excluded.includes(c.id)); if (held) return held.id;
|
||||
const index = game.deck.findIndex(c => c.kind === kind && !excluded.includes(c.id)); assert.ok(index >= 0);
|
||||
assert.ok(p.hand.length < 4); const [card] = game.deck.splice(index, 1); p.hand.push(card);
|
||||
staging.push(`Conserved native ecaz33 ${card.id}: remaining deck → ${actor}'s hand`); return card.id;
|
||||
}
|
||||
export function stageMoritaniSkillsModulesBattle(game: Game, moritani: string, opponent: string, staging: string[] = []) {
|
||||
assert.equal(game.phase, 5); assert.ok(moritaniSkillsModulesClean(game));
|
||||
reserveBoard(game);
|
||||
const location = `tueks_sietch:${territory('tueks_sietch').sectors[0]}`;
|
||||
place(game, moritani, location, 6); place(game, opponent, location, 6);
|
||||
staging.push('Before actual movement completion: relocate six original counters per combatant through own reserves; no currency, storm, phase, turn or card-owner edits.');
|
||||
return game;
|
||||
}
|
||||
export function revealMoritaniSkillsModulesPlans(game: Game, plans: StrongholdFactionsNativeStep[], actions: StrongholdFactionsNativeStep[] = []): Game {
|
||||
for (const plan of plans) {
|
||||
game = run(game, plan, actions);
|
||||
while (game.response || game.decision?.kind === 'fullPlanOffer') {
|
||||
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); game = run(game, next, actions);
|
||||
}
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
/** Stop BEFORE original Suk rescue or original assassination; do not erase the
|
||||
* winner's casualties, card decisions or saved continuation to reach a choice. */
|
||||
export function resolveMoritaniSkillsModulesBattle(game: Game, moritani: string, normalCall = false, actions: StrongholdFactionsNativeStep[] = []): Game {
|
||||
for (let count = 0; count < 160; count++) {
|
||||
if (game.decision?.kind === 'sukRescue' || game.decision?.kind === 'moritaniAssassinate' ||
|
||||
!game.battle && moritaniSkillsModulesClean(game) && !game.pendingTreacheryDiscard) return game;
|
||||
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next);
|
||||
if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani;
|
||||
game = run(game, next, actions);
|
||||
}
|
||||
throw Error('Original Moritani skills/modules battle suffix did not settle');
|
||||
}
|
||||
export function createMoritaniSkillsModulesFixture(options: MoritaniSkillsModulesOptions = {}): MoritaniSkillsModulesFixture {
|
||||
const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
|
||||
let game = initializeMoritaniSkillsModulesOffers(options);
|
||||
const initial = structuredClone(game);
|
||||
game = completeMoritaniSkillsModulesSetup(game); const afterSetup = structuredClone(game);
|
||||
const moritani = game.players.find(p => p.faction === 'moritani')!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||
const holder = options.holder === 'moritani' ? moritani : opponent;
|
||||
while (game.phase === 0) {
|
||||
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next);
|
||||
if (next.action.type === 'stormDial') next.action.amount = 4;
|
||||
game = run(game, next, actions);
|
||||
}
|
||||
const afterFirstStorm = structuredClone(game);
|
||||
game = advanceMoritaniSkillsModules(game, s => s.phase === 8 && moritaniSkillsModulesClean(s), actions);
|
||||
reserveBoard(game);
|
||||
place(game, holder, `tueks_sietch:${territory('tueks_sietch').sectors[0]}`, 1);
|
||||
place(game, holder === moritani ? opponent : moritani, `carthag:${territory('carthag').sectors[0]}`, 1);
|
||||
staging.push('Before real first END Mentat: conserved native counters, one controlled stronghold each; card ownership remains entirely engine-derived.');
|
||||
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
|
||||
while (game.turn === 1) {
|
||||
const next = nextMoritaniSkillsModulesStep(game); assert.ok(next);
|
||||
const before = structuredClone(game); game = run(game, next, actions);
|
||||
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
|
||||
}
|
||||
assert.ok(beforeFirstMentat && firstMentatStep);
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && moritaniSkillsModulesClean(s), actions);
|
||||
stageMoritaniSkillsModulesBattle(game, moritani, opponent, staging);
|
||||
const m = moritaniSkillsModulesPlayer(game, moritani), guild = moritaniSkillsModulesPlayer(game, opponent);
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const ownLeader = m.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
|
||||
const enemyLeader = guild.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 = [heldCard(game, moritani, 'worthless', staging)];
|
||||
moritaniCards.push(heldCard(game, moritani, 'worthless', staging, moritaniCards));
|
||||
const opponentCards = [heldCard(game, opponent, 'worthless', staging)];
|
||||
// Empty one selected battle deposit by returning it to the unoccupied Polar
|
||||
// Sink. This is explicit conserved board-spice staging, not minted bank money.
|
||||
const location = `tueks_sietch:${territory('tueks_sietch').sectors[0]}`;
|
||||
const deposit = game.spice[location] ?? 0;
|
||||
if (deposit) { delete game.spice[location]; game.spice['polar_sink:0'] = (game.spice['polar_sink:0'] ?? 0) + deposit; }
|
||||
staging.push(`Conserved board deposit ${deposit}: battle sector → unoccupied Polar Sink; isolate real battle/income wallet legs.`);
|
||||
// One actual non-Guild shipment triggers printed Heighliners accrual. Keep
|
||||
// native active/order/shipped fields intact and reach Moritani by actions.
|
||||
game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && s.active === moritani && moritaniSkillsModulesClean(s), actions);
|
||||
const destination = TERRITORIES.find(site => site.type === 'sand' && !site.sectors.includes(game.storm) &&
|
||||
!Object.entries(game.spice).some(([key, count]) => count > 0 && key.startsWith(`${site.id}:`)) &&
|
||||
game.players.every(p => !Object.entries(p.forces).some(([key, count]) => count > 0 && key.startsWith(`${site.id}:`))));
|
||||
assert.ok(destination, 'The original invoice needs an empty, spice-free desert destination.');
|
||||
const beforeShipment = structuredClone(game);
|
||||
const shipmentStep: StrongholdFactionsNativeStep = { actor: moritani, action: { type: 'ship',
|
||||
territory: destination.id, sector: destination.sectors[0], amount: 1 } };
|
||||
game = run(game, shipmentStep, actions);
|
||||
game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && moritaniSkillsModulesClean(s) && !s.pendingShipment, actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
game = advanceMoritaniSkillsModules(game, s => s.phase === 6 && moritaniSkillsModulesClean(s), actions);
|
||||
const afterMovement = structuredClone(game), beforeBattle = structuredClone(game);
|
||||
const actor = game.active!; assert.ok([moritani, opponent].includes(actor));
|
||||
const battleAction: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === moritani ? opponent : moritani } };
|
||||
game = run(game, battleAction, actions);
|
||||
game = advanceMoritaniSkillsModules(game, s => !!s.battle && !s.battle.revealed && !nextMoritaniSkillsModulesStep(s), actions);
|
||||
const planActions: StrongholdFactionsNativeStep[] = [
|
||||
{ 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 prepared = structuredClone(game), revealed = revealMoritaniSkillsModulesPlans(game, planActions, actions);
|
||||
const pending = resolveMoritaniSkillsModulesBattle(structuredClone(revealed), moritani, options.normalCall, actions);
|
||||
// Three-seat originals get one token per seat; larger originals must not
|
||||
// fabricate missing ownership merely to offer a post-battle Tech choice.
|
||||
if (afterFirstStorm.techTokens && afterFirstStorm.players.length === 3) assert.equal(ownedTech(afterFirstStorm.techTokens, opponent).length, 1);
|
||||
return { initial, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat,
|
||||
beforeShipment, shipmentStep, afterShipment, afterMovement, beforeBattle, game: prepared, revealed, pending,
|
||||
moritani, opponent, holder, target, moritaniLeader: ownLeader.id, opponentLeader: enemyLeader.id,
|
||||
moritaniCards, opponentCards, battleAction, planActions, sourceActions: actions, staging };
|
||||
}
|
||||
export function rejectMoritaniSkillsModulesAction(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
|
||||
if (game.status === 'playing' && !game.decision) {
|
||||
if (game.phase === 0 && game.stormPending === null) {
|
||||
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||
if (actor) return { actor, action: { type: 'stormDial', amount: 0 } };
|
||||
if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } };
|
||||
}
|
||||
if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } };
|
||||
if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { leaders, treacheryDeck } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { STRONGHOLD_CARDS, strongholdControllers } from '../game/stronghold-cards';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { assassinationPhysical } from './moritani-assassinate-fixture';
|
||||
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
||||
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
|
||||
import {
|
||||
advanceMoritaniSkillsModules as advance, completeMoritaniSkillsModulesSetup,
|
||||
createMoritaniSkillsModulesFixture as fixture, initializeMoritaniSkillsModulesOffers,
|
||||
moritaniSkillsModulesClean as clean, moritaniSkillsModulesPlayer as player,
|
||||
moritaniSkillsModulesPolicy as policy, nextMoritaniSkillsModulesStep,
|
||||
rejectMoritaniSkillsModulesAction as reject, resolveMoritaniSkillsModulesBattle,
|
||||
revealMoritaniSkillsModulesPlans, stageMoritaniSkillsModulesBattle,
|
||||
type MoritaniSkillsModulesOptions,
|
||||
} from './fixture-moritani-skills-modules';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
function custody(game: Game) {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id).sort();
|
||||
assert.deepEqual(cards, treacheryDeck(['ecaz']).map(c => c.id).sort());
|
||||
assert.equal(cards.length, 33);
|
||||
const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
|
||||
...game.leaderSkills!.assignments.map(a => a.skill)].sort();
|
||||
assert.deepEqual(skills, LEADER_SKILL_CARDS.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((sum, n) => sum + n, 0), 20);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'emperor' ? 5 : 3);
|
||||
for (const [location, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[location] ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
function assassinate(game: Game, card: string | null) {
|
||||
const d = game.decision; assert.equal(d?.kind, 'moritaniAssassinate');
|
||||
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original loss opportunity');
|
||||
return applyAction(game, d.player, { type: 'decision', event: d.event, ...(card === null ? { decline: true } : { card }) });
|
||||
}
|
||||
function finishBattle(game: Game) {
|
||||
return advance(game, s => !s.battle && clean(s) && !s.pendingTreacheryDiscard);
|
||||
}
|
||||
|
||||
void test('original standalone Moritani retains native offers and authenticated roster across Basic Tech and Advanced module seat boundaries', () => {
|
||||
const cases: { options: MoritaniSkillsModulesOptions; counts: number[] }[] = [
|
||||
{ options: { rules: 'basic', tech: true, strongholds: false }, counts: [3, 4, 5, 6] },
|
||||
{ options: { rules: 'advanced', tech: true, strongholds: false }, counts: [3, 4, 5, 6] },
|
||||
{ options: { rules: 'advanced', tech: false, strongholds: true }, counts: [2, 3, 4, 5, 6] },
|
||||
{ options: { rules: 'advanced', tech: true, strongholds: true }, counts: [3, 4, 5, 6] },
|
||||
];
|
||||
const factions: FactionId[] = ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit'];
|
||||
for (const { options, counts } of cases) for (const count of counts) {
|
||||
const lobby = createGame(`AUTHMORISKILLS${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 = initializeMoritaniSkillsModulesOffers({ ...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.deepEqual(offered.players.map(p => p.traitors), before.players.map(() => []));
|
||||
const guild = offered.players.find(p => p.faction === 'guild')!.id;
|
||||
assert.ok(viewGame(offered, guild).leaderSkills!.offer!.cards.includes('suk-graduate'));
|
||||
for (const p of offered.players) assert.equal(viewGame(offered, p.id).leaderSkills!.offer!.cards.length, 2);
|
||||
if (offered.strongholdCards) assert.ok(Object.values(offered.strongholdCards.owners).every(owner => owner === null));
|
||||
assert.equal(!!offered.moritaniAssassinate, options.rules === 'advanced');
|
||||
const saved = initializeMoritaniSkillsModulesOffers({ ...options, initial: reload(offered) });
|
||||
assert.deepEqual(saved, offered, 'An original skill setup is continued, not redealt');
|
||||
const setup = completeMoritaniSkillsModulesSetup(offered);
|
||||
assert.equal(setup.players.find(p => p.faction === 'moritani')!.reserves, 14);
|
||||
const moritani = setup.players.find(p => p.faction === 'moritani')!;
|
||||
const placement = Object.entries(moritani.forces);
|
||||
assert.equal(placement.length, 1);
|
||||
assert.equal(placement[0][1], 6, 'Native setup places its six original counters together.');
|
||||
const start = placement[0][0].split(':')[0];
|
||||
assert.ok(setup.players.filter(p => p.id !== moritani.id).every(p =>
|
||||
!Object.entries(p.forces).some(([key, count]) => count > 0 && key.startsWith(`${start}:`))),
|
||||
'Original setup must leave other fighters and advisors outside the Moritani starting territory.');
|
||||
const assigned = setup.leaderSkills!.assignments.find(a => a.owner === guild)!;
|
||||
assert.equal(assigned.skill, 'suk-graduate');
|
||||
assert.equal(player(setup, guild).leaders.find(l => l.id === assigned.leader)!.name, 'Master Bewt');
|
||||
custody(setup);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual first Storm assigns Tech, a real native shipment pays phase-end income, and END Mentat alone claims the original Stronghold cards', () => {
|
||||
const f = fixture();
|
||||
assert.ok(Object.values(f.initial.techTokens!).every(token => token.owner === null));
|
||||
assert.ok(Object.values(f.afterFirstStorm.techTokens!).every(token => token.owner !== null));
|
||||
for (const p of f.afterFirstStorm.players) assert.equal(ownedTech(f.afterFirstStorm.techTokens, p.id).length, 1);
|
||||
const shipping = advance(applyAction(reload(f.beforeShipment), f.shipmentStep.actor, f.shipmentStep.action),
|
||||
s => s.phase === 5 && clean(s) && !s.pendingShipment);
|
||||
assert.deepEqual(shipping.techTokens, f.afterShipment.techTokens);
|
||||
const token = f.afterShipment.techTokens!.heighliners;
|
||||
assert.equal(token.triggeredTurn, 2); assert.equal(token.spice, 1);
|
||||
assert.equal(player(shipping, f.moritani).reserves, player(f.beforeShipment, f.moritani).reserves - 1);
|
||||
assert.equal(player(shipping, f.moritani).forces[`${String(f.shipmentStep.action.territory)}:${String(f.shipmentStep.action.sector)}`], 1);
|
||||
assert.equal(f.afterMovement.techTokens!.heighliners.spice, 0);
|
||||
for (const p of shipping.players) assert.equal(player(f.afterMovement, p.id).spice, p.spice + (p.id === token.owner ? 1 : 0), 'Exactly the printed token holder collects at the real phase boundary');
|
||||
assert.equal(f.initial.strongholdCards!.claimedTurn, 0);
|
||||
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);
|
||||
assert.equal(claimed.strongholdCards!.owners.tueks_sietch, f.opponent);
|
||||
assert.deepEqual(Object.keys(claimed.strongholdCards!.owners), STRONGHOLD_CARDS.map(c => c.id));
|
||||
for (const g of [f.afterSetup, f.afterFirstStorm, f.afterFirstMentat, f.afterShipment, f.pending]) custody(g);
|
||||
});
|
||||
|
||||
void test('the normal face-up unused Guild Suk rescues one first; original held Master Bewt assassination returns Suk and printed3 once without replaying winner income or casualties', () => {
|
||||
const f = fixture(), pending = f.pending;
|
||||
assert.equal(pending.lastBattleContext?.result, 'normal'); assert.equal(pending.lastBattleContext?.winner, f.opponent);
|
||||
assert.equal(pending.decision?.kind, 'moritaniAssassinate');
|
||||
assert.equal(pending.lastBattleContext?.sukRescue?.completed, true);
|
||||
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);
|
||||
assert.equal(player(pending, f.opponent).tanks, player(f.revealed, f.opponent).tanks + 2, 'Guild dial3 loses only two counters to Tanks after normal rescue1');
|
||||
assert.equal(player(pending, f.opponent).reserves, player(f.revealed, f.opponent).reserves + 1);
|
||||
assert.equal(Object.values(player(pending, f.opponent).forces).reduce((sum, n) => sum + n, 0), 3);
|
||||
assert.equal(player(pending, f.moritani).tanks, player(f.revealed, f.moritani).tanks + 6);
|
||||
assert.deepEqual(quoteStrongholdFactionsBattle(f.revealed).strongholdIncome, [{ player: f.opponent, amount: 2 }]);
|
||||
const moriBalance = player(f.revealed, f.moritani).spice - 1;
|
||||
const winnerBalance = player(f.revealed, f.opponent).spice - 3 + 2;
|
||||
assert.equal(player(pending, f.moritani).spice, moriBalance);
|
||||
assert.equal(player(pending, f.opponent).spice, winnerBalance);
|
||||
const originalForces = pending.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites }));
|
||||
const retained = structuredClone(pending.leaderSkills!.assignments.filter(a => a.leader !== f.target));
|
||||
const reserve = [...pending.traitorReserve!], traitors = [...player(pending, f.moritani).traitors];
|
||||
const inventory = assassinationPhysical(pending);
|
||||
const d = pending.decision;
|
||||
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original loss choice');
|
||||
const action: Action = { type: 'decision', event: d.event, card: f.target };
|
||||
const privateMenu = viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards;
|
||||
assert.equal(privateMenu.find(c => c.card === f.target)!.bounty, 3);
|
||||
assert.equal(privateMenu.some(c => c.card === f.opponentLeader), false);
|
||||
reject(pending, f.opponent, action); reject(pending, f.moritani, { ...action, event: `${d.event}:stale` });
|
||||
reject(pending, f.moritani, { ...action, card: f.opponentLeader });
|
||||
let game = applyAction(reload(pending), f.moritani, action);
|
||||
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
|
||||
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1);
|
||||
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.bounty, 3);
|
||||
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, moriBalance + 3); assert.equal(player(game, f.opponent).spice, winnerBalance);
|
||||
assert.deepEqual(game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })), originalForces);
|
||||
assert.deepEqual(game.strongholdCards, pending.strongholdCards);
|
||||
assert.deepEqual(game.techTokens, pending.techTokens, 'Assassination does not replay or undo original phase income');
|
||||
assert.equal(game.decision?.kind, 'battleCards'); assert.equal(game.decision?.player, f.opponent);
|
||||
reject(game, f.moritani, action);
|
||||
game = applyAction(reload(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 lostTech = ownedTech(pending.techTokens, f.moritani)[0]; assert.ok(lostTech);
|
||||
assert.equal(game.techTokens![lostTech].owner, f.opponent, 'Winner captures one real defeated-faction token only after original winner cleanup');
|
||||
for (const token of ownedTech(pending.techTokens, f.opponent)) assert.equal(game.techTokens![token].owner, f.opponent, 'Original winning Tech custody is retained alongside the captured token');
|
||||
const afterBattleForces = game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks }));
|
||||
assert.deepEqual(afterBattleForces, pending.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks })));
|
||||
game = advance(reload(game), s => s.phase === 8);
|
||||
const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.replacement, reserve[0]);
|
||||
assert.deepEqual(game.traitorReserve, reserve.slice(1));
|
||||
assert.deepEqual(player(game, f.moritani).traitors, [...traitors.filter(c => c !== f.target), reserve[0]]);
|
||||
const collected = quoteSpiceCollection(pending).receipts.find(r => r.player === f.opponent)!;
|
||||
assert.equal(player(game, f.moritani).spice, moriBalance + 3);
|
||||
assert.equal(player(game, f.opponent).spice, winnerBalance + collected.strongholds + collected.collected,
|
||||
'Ordinary Collection remains separate from original battle income and assassination bounty.');
|
||||
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!.deck.filter(c => c === 'suk-graduate').length, 1);
|
||||
assert.deepEqual(assassinationPhysical(game), inventory);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
reject(game, f.moritani, action); custody(game);
|
||||
game = advance(game, s => s.turn === 3 && s.phase === 4 && clean(s));
|
||||
assert.equal(game.traitorReserve!.length, reserve.length - 1, 'The next real END Mentat does not replace a second time');
|
||||
reject(game, f.opponent, { type: 'reviveLeader', leader: f.target });
|
||||
assert.equal(player(game, f.opponent).leaders.filter(l => l.dead).length, 1);
|
||||
assert.equal(game.leaderSkills!.offers[f.opponent], undefined, 'One original death must not bypass the native all-five-disc revival prerequisite or grant training');
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('losing Moritani retains printed Tuek Worthless income and one real support debit even after Guild rescue, assassination, card cleanup and Tech transfer', () => {
|
||||
const f = fixture({ holder: 'moritani' });
|
||||
assert.deepEqual(quoteStrongholdFactionsBattle(f.revealed).strongholdIncome, [{ player: f.moritani, amount: 4 }]);
|
||||
const own = player(f.revealed, f.moritani).spice - 1 + 4;
|
||||
const enemy = player(f.revealed, f.opponent).spice - 3;
|
||||
assert.equal(player(f.pending, f.moritani).spice, own); assert.equal(player(f.pending, f.opponent).spice, enemy);
|
||||
assert.equal(f.pending.lastBattleContext?.sukRescue?.completed, true);
|
||||
let game = assassinate(reload(f.pending), f.target);
|
||||
game = finishBattle(game);
|
||||
game = advance(game, s => s.phase === 8);
|
||||
const collected = quoteSpiceCollection(f.pending).receipts.find(r => r.player === f.opponent)!;
|
||||
assert.equal(player(game, f.moritani).spice, own + 3);
|
||||
assert.equal(player(game, f.opponent).spice, enemy + collected.strongholds + collected.collected);
|
||||
assert.equal(player(game, f.moritani).tanks, 6); assert.equal(player(game, f.opponent).tanks, 2);
|
||||
assert.equal(player(game, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false);
|
||||
for (const card of f.moritaniCards) assert.equal(game.discard.filter(c => c.id === card).length, 1);
|
||||
assert.equal(game.strongholdCards!.owners.tueks_sietch, f.moritani);
|
||||
assert.equal(strongholdControllers(game.players, false).tueks_sietch, f.opponent);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('selected winning disc is not an assassination target, and declining a real empty native menu neither kills Suk nor schedules replacement', () => {
|
||||
const f = fixture();
|
||||
let game = reload(f.game);
|
||||
holdMoritaniStrongholdsTraitor(game, f.moritani, f.opponentLeader);
|
||||
game = revealMoritaniSkillsModulesPlans(game, f.planActions);
|
||||
game = resolveMoritaniSkillsModulesBattle(game, f.moritani);
|
||||
assert.equal(game.lastBattleContext?.winner, f.opponent);
|
||||
assert.deepEqual(viewGame(game, f.moritani).moritaniAssassinate!.pending!.cards, []);
|
||||
const d = game.decision;
|
||||
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original empty loss choice');
|
||||
reject(game, f.moritani, { type: 'decision', event: d.event, card: f.opponentLeader });
|
||||
const skills = structuredClone(game.leaderSkills), reserve = [...game.traitorReserve!];
|
||||
const balance = player(game, f.moritani).spice;
|
||||
game = finishBattle(assassinate(game, null));
|
||||
game = advance(game, s => s.phase === 8);
|
||||
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
|
||||
assert.deepEqual(game.leaderSkills, skills); assert.deepEqual(game.traitorReserve, reserve);
|
||||
assert.equal(player(game, f.moritani).spice, balance);
|
||||
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, false);
|
||||
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, false);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('a real normal Traitor reveal keeps the original entire-game forfeiture across a native turn, not merely the first modules battle', () => {
|
||||
const f = fixture({ 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, []);
|
||||
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true);
|
||||
let game = advance(f.pending, s => s.turn === 3 && s.phase === 5 && clean(s));
|
||||
stageMoritaniSkillsModulesBattle(game, f.moritani, f.opponent);
|
||||
holdMoritaniStrongholdsTraitor(game, f.moritani, f.target);
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const winner = player(game, f.opponent).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
const loser = player(game, f.moritani).leaders.filter(l => !l.dead && !trained.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 && !nextMoritaniSkillsModulesStep(s));
|
||||
game = revealMoritaniSkillsModulesPlans(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(resolveMoritaniSkillsModulesBattle(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('all four minimal legal policies reach real reveal or empty-menu decline outcomes without changing native skill, income or casualty custody twice', () => {
|
||||
const f = fixture();
|
||||
let empty = reload(f.game);
|
||||
holdMoritaniStrongholdsTraitor(empty, f.moritani, f.opponentLeader);
|
||||
empty = revealMoritaniSkillsModulesPlans(empty, f.planActions);
|
||||
empty = resolveMoritaniSkillsModulesBattle(empty, f.moritani);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const action = policy(reload(f.pending), f.moritani, difficulty)[0]; assert.ok(action, difficulty);
|
||||
let game = applyAction(reload(f.pending), f.moritani, action);
|
||||
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);
|
||||
assert.equal(player(game, f.opponent).spice, player(f.pending, f.opponent).spice);
|
||||
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
|
||||
game = finishBattle(game);
|
||||
assert.equal(player(game, f.opponent).tanks, 2); assert.equal(player(game, f.moritani).tanks, 6);
|
||||
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.equal(player(declined, f.opponent).leaders.find(l => l.id === f.target)!.dead, false);
|
||||
assert.deepEqual(declined.leaderSkills, empty.leaderSkills);
|
||||
assert.equal(player(declined, f.moritani).spice, player(empty, f.moritani).spice);
|
||||
custody(finishBattle(declined));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic Tech keeps original normal Suk and token capture without Advanced assassination, while each single Advanced module preserves the real loss aftermath', () => {
|
||||
const basic = fixture({ rules: 'basic', strongholds: false });
|
||||
assert.equal(basic.pending.lastBattleContext?.result, 'normal');
|
||||
assert.equal(basic.pending.lastBattleContext?.winner, basic.opponent);
|
||||
assert.equal(basic.pending.moritaniAssassinate, undefined);
|
||||
assert.ok(!basic.pending.strongholdCards);
|
||||
assert.equal(basic.pending.lastBattleContext?.sukRescue?.completed, true);
|
||||
assert.equal(player(basic.pending, basic.opponent).tanks, 2);
|
||||
assert.equal(player(basic.pending, basic.moritani).tanks, 6);
|
||||
assert.equal(player(basic.pending, basic.opponent).spice, player(basic.revealed, basic.opponent).spice, 'Basic dials do not acquire an Advanced support debit');
|
||||
assert.equal(player(basic.pending, basic.moritani).spice, player(basic.revealed, basic.moritani).spice);
|
||||
const lost = ownedTech(basic.revealed.techTokens, basic.moritani)[0]; assert.ok(lost);
|
||||
assert.equal(basic.pending.techTokens![lost].owner, basic.opponent);
|
||||
assert.equal(basic.pending.leaderSkills!.assignments.some(a => a.leader === basic.target && a.skill === 'suk-graduate'), true);
|
||||
custody(basic.pending);
|
||||
for (const tech of [false, true]) {
|
||||
const f = fixture({ rules: 'advanced', tech, strongholds: !tech });
|
||||
assert.equal(f.pending.lastBattleContext?.winner, f.opponent);
|
||||
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
|
||||
assert.equal(f.pending.lastBattleContext?.sukRescue?.completed, true);
|
||||
assert.equal(player(f.pending, f.opponent).tanks, 2);
|
||||
const balance = player(f.pending, f.moritani).spice;
|
||||
let game = finishBattle(assassinate(reload(f.pending), f.target));
|
||||
game = advance(game, s => s.phase === 8);
|
||||
assert.equal(player(game, f.moritani).spice, balance + 3);
|
||||
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
|
||||
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
|
||||
if (tech) {
|
||||
const captured = ownedTech(f.pending.techTokens, f.moritani)[0]; assert.ok(captured);
|
||||
assert.equal(game.techTokens![captured].owner, f.opponent);
|
||||
assert.ok(!game.strongholdCards);
|
||||
} else {
|
||||
assert.ok(!game.techTokens);
|
||||
assert.equal(game.strongholdCards!.owners.tueks_sietch, f.opponent);
|
||||
assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3 + 2);
|
||||
}
|
||||
custody(game);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user