Connect native E3 World Skills and independent cards
This commit is contained in:
1 parent
01985af36b
commit
bc37cd38fe
18 files changed
+553
-92
No files matched your search
@@ -1,11 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { Game } from '../game/engine';
|
||||
import { applyAction, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import {
|
||||
assertEcazIndependentSkillsCustody,
|
||||
createEcazIndependentSkillsFixture, ecazIndependentSkillsPlayer as player,
|
||||
@@ -410,3 +413,117 @@ for (const lead of ['ecaz', 'ally'] as const) {
|
||||
assertEcazIndependentSkillsCustody(end);
|
||||
});
|
||||
}
|
||||
|
||||
void test('native World coalition pays Ecaz’s source-labelled fee before committed-only Suk rescue and settles earned Arrakeen and Tech', () => {
|
||||
const f = createEcazIndependentSkillsFixture({
|
||||
homeworlds: true, paired: true, tech: true, strongholds: true,
|
||||
});
|
||||
const native = (game: Game) => homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find(home => home.native === f.ecaz)!.forces[f.ecaz];
|
||||
const plan = f.plans[0];
|
||||
assert.deepEqual(plan.action.reinforcementsSources,
|
||||
{ 'homeworld:ecaz': { normal: 3, elite: 0 } });
|
||||
const before = structuredClone(f.beforePlans);
|
||||
assert.throws(() => applyAction(f.beforePlans, f.ecaz, {
|
||||
...plan.action, reinforcementsSources: { 'homeworld:moritani': { normal: 3, elite: 0 } },
|
||||
}), 'A coalition partner’s native World cannot fund the Ecaz card user’s fee');
|
||||
assert.deepEqual(f.beforePlans, before);
|
||||
const shipment = f.actions.find(step => step.actor === f.ecaz && step.action.type === 'ship')!;
|
||||
assert.deepEqual(shipment.action.homeworldSources,
|
||||
{ 'homeworld:ecaz': { normal: 4, elite: 0 } });
|
||||
assert.equal(native(f.afterFirstMentat!).normal, native(f.afterSetup).normal - 4,
|
||||
'The four real paid native counters hold Arrakeen through END Mentat');
|
||||
assert.equal(f.afterFirstMentat!.strongholdCards!.owners.arrakeen, f.ecaz);
|
||||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||||
const payment = quote.payments.find(item => item.player === f.ecaz)!;
|
||||
assert.equal(payment.bankSupport, 2);
|
||||
assert.equal(payment.ownPayment, 0, 'The earned Arrakeen card actually supports the selected lead');
|
||||
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
|
||||
const rescue = settleEcazIndependentSkillsBattle(f, { stop: 'rescue' });
|
||||
assert.ok(rescue.decision?.kind === 'sukRescue');
|
||||
assert.equal(rescue.decision.player, f.ecaz);
|
||||
assert.deepEqual(rescue.decision.options.filter(option => option.kept),
|
||||
[{ normal: 1, elite: 0, kept: { key: f.location, kind: 'normal' } },
|
||||
{ normal: 2, elite: 0, kept: { key: f.location, kind: 'normal' } }]);
|
||||
assert.equal(player(rescue, f.ecaz).tanks - player(f.game, f.ecaz).tanks, 3,
|
||||
'The source-labelled fee is already in Tanks and never joins the two committed rescue losses');
|
||||
assert.equal(native(rescue).normal, native(f.game).normal - 1,
|
||||
'Three native fee counters leave before the two undialed counters return to the same World');
|
||||
assert.equal(player(rescue, f.ally).tanks - player(f.game, f.ally).tanks, 2,
|
||||
'Moritani’s variable army losses cannot enter Ecaz’s rescue');
|
||||
assert.equal(player(rescue, f.ecaz).spice, player(f.game, f.ecaz).spice,
|
||||
'Held-card bank support settles before Collection, without charging Ecaz');
|
||||
const end = settleEcazIndependentSkillsBattle(f, { state: rescue });
|
||||
assert.equal(end.phase, 7);
|
||||
assert.equal(end.lastBattleContext?.winner, f.ecaz);
|
||||
assert.equal(end.lastBattleContext?.sukRescue?.completed, true);
|
||||
assert.deepEqual(end.lastBattleContext?.sukRescue?.physical, {
|
||||
player: f.ecaz, territory: f.territory, source: 'arrakis',
|
||||
saved: { normal: 2, elite: 0 }, kept: { key: f.location, kind: 'normal' },
|
||||
destinations: { 'homeworld:ecaz': { normal: 1, elite: 0 } }, tanks: { normal: 0, elite: 0 },
|
||||
});
|
||||
assert.equal(native(end).normal, native(f.game).normal);
|
||||
assert.equal(at(end, f.ecaz, f.location), 1);
|
||||
assert.equal(player(end, f.ecaz).tanks - player(f.game, f.ecaz).tanks, 3);
|
||||
const enemyTokens = ownedTech(f.game.techTokens, f.opponent);
|
||||
assert.equal(enemyTokens.length, 1);
|
||||
assert.equal(end.techTokens![enemyTokens[0]].owner, f.ecaz);
|
||||
disposedLeadCards(f, end);
|
||||
pairedArmies(f, end);
|
||||
physicalSource(end, 36);
|
||||
assertEcazIndependentSkillsCustody(end);
|
||||
});
|
||||
|
||||
void test('paired plain World all14 keeps the native Moritani lead and separate Ecaz fixed army through battle continuation', () => {
|
||||
const f = createEcazIndependentSkillsFixture({
|
||||
homeworlds: true, paired: true, independent: false, cards: [], lead: 'ally',
|
||||
});
|
||||
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
|
||||
assert.equal(f.plans[0].actor, f.ally);
|
||||
assert.equal(player(f.beforeLead, f.ecaz).ally, f.ally);
|
||||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||||
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
|
||||
assert.equal(quote.casualties?.owner, f.ally);
|
||||
assert.equal(quote.sukGraduate, undefined,
|
||||
'Ecaz’s native skill cannot rescue its fixed army while Moritani leads');
|
||||
assert.ok(f.game.leaderSkills!.assignments.every(assignment => assignment.leader !== DUKE_VIDAL_ID));
|
||||
const end = settleEcazIndependentSkillsBattle(f);
|
||||
assert.equal(end.phase, 7);
|
||||
assert.equal(end.lastBattleContext?.winner, f.ally);
|
||||
assert.equal(end.lastBattleContext?.sukRescue, undefined);
|
||||
for (const actor of [f.ecaz, f.ally]) {
|
||||
assert.equal(player(end, actor).tanks - player(f.game, actor).tanks, 2);
|
||||
assert.equal(player(end, actor).reserves, player(f.game, actor).reserves);
|
||||
assert.equal(at(end, actor, f.location), 2);
|
||||
}
|
||||
pairedArmies(f, end);
|
||||
physicalSource(end, 33);
|
||||
assertEcazIndependentSkillsCustody(end);
|
||||
});
|
||||
|
||||
void test('no-Skills World independent cards retain the native Emperor-led coalition and typed Sardaukar withdrawal', () => {
|
||||
const f = createEcazIndependentSkillsFixture({ homeworlds: true, skills: false, lead: 'ally' });
|
||||
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
|
||||
assert.equal(f.plans[0].actor, f.ally);
|
||||
const sources = f.game.battle!.plans[f.cardUser].reinforcementsSources!;
|
||||
assert.ok(Object.keys(sources).every(world => world === 'homeworld:emperor' || world === 'homeworld:emperor:salusa'));
|
||||
assert.equal(Object.values(sources).reduce((sum, forces) => sum + forces.normal, 0), 3);
|
||||
const quote = quoteEcazIndependentSkillsBattle(f);
|
||||
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 0, elite: 2 });
|
||||
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 0, elite: 2 } });
|
||||
assert.equal(quote.sukGraduate, undefined);
|
||||
const end = settleEcazIndependentSkillsBattle(f);
|
||||
assert.equal(end.phase, 7);
|
||||
assert.equal(end.lastBattleContext?.winner, f.ally);
|
||||
assert.equal(end.lastBattleContext?.sukRescue, undefined);
|
||||
assert.equal(player(end, f.ally).reserves - player(f.game, f.ally).reserves, -1);
|
||||
assert.equal(player(end, f.ally).tanks - player(f.game, f.ally).tanks, 5,
|
||||
'Three own native fee counters and two variable commitment losses remain distinct from Ecaz');
|
||||
assert.equal(player(end, f.ally).elites!.reserves - player(f.game, f.ally).elites!.reserves, 2);
|
||||
assert.equal(player(end, f.ally).elites!.tanks, player(f.game, f.ally).elites!.tanks);
|
||||
assert.equal(player(end, f.ecaz).tanks - player(f.game, f.ecaz).tanks, 2);
|
||||
assert.equal(at(end, f.ecaz, f.location), 2);
|
||||
disposedLeadCards(f, end);
|
||||
physicalSource(end, 36);
|
||||
assertEcazIndependentSkillsCustody(end);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import type { SukRescueOption } from '../game/suk-graduate';
|
||||
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { chooseEcazOccupyLead, finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
|
||||
import { createHomeworldEcazOccupyFixture } from './fixture-homeworld-ecaz-occupy';
|
||||
@@ -32,6 +33,8 @@ export type EcazIndependentSkillsOptions = {
|
||||
skills?: boolean;
|
||||
/** The independent physical three-card variant; `false` is the printed no-card control. */
|
||||
independent?: boolean;
|
||||
/** Original native World setup, selected before start; off by default. */
|
||||
homeworlds?: boolean;
|
||||
/** Original optional modules, both off by default; Strongholds require Advanced. */
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
@@ -143,6 +146,7 @@ export function stageEcazIndependentSkillsCard(game: Game, actor: string,
|
||||
/** The admitted original card source (thirty-six with the independent variant),
|
||||
* all14 skills and ordinary counters. */
|
||||
export function assertEcazIndependentSkillsCustody(game: Game): void {
|
||||
if (game.homeworlds) homeworldGameIntegrity(game);
|
||||
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
|
||||
const sum = (values: Record<string, number>) => Object.values(values).reduce((a, b) => a + b, 0);
|
||||
for (const player of game.players) {
|
||||
@@ -263,7 +267,7 @@ export function createEcazIndependentSkillsFixture(
|
||||
...LEADER_SKILL_CARDS.map(card => card.id).filter(id => id !== 'suk-graduate')];
|
||||
const draws = skills ? permutationDraws(LEADER_SKILL_CARDS.map(card => card.id), order) : [];
|
||||
const shared = entropy(() => createHomeworldEcazOccupyFixture({
|
||||
advanced, skills, independent, homeworlds: false,
|
||||
advanced, skills, independent, homeworlds: options.homeworlds ?? false,
|
||||
tech: options.tech ?? false, strongholds: options.strongholds ?? false,
|
||||
...(options.paired ? { allyFaction: 'moritani' as const } : {}),
|
||||
...(skills ? { finishSetup: (initial: Game) => trainEcazSeat(initial, picks) } : {}),
|
||||
@@ -295,7 +299,6 @@ export function createEcazIndependentSkillsFixture(
|
||||
state => !!state.battle && ecazIndependentSkillsClean(state) && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false, actions, policy);
|
||||
const afterLead = structuredClone(game);
|
||||
const beforePlans = structuredClone(game);
|
||||
const cardUser = lead === 'ecaz' ? shared.ecaz : shared.ally;
|
||||
const cardIds = cardKinds.map(kind => stageEcazIndependentSkillsCard(game, cardUser,
|
||||
ECAZ_INDEPENDENT_SKILLS_CARD_IDS[kind], staging));
|
||||
@@ -318,12 +321,18 @@ export function createEcazIndependentSkillsFixture(
|
||||
conserveTraitor(game, shared.opponent, traitor, staging);
|
||||
callers.push(shared.opponent);
|
||||
}
|
||||
const feeSources = cardKinds.includes('reinforcements') && game.homeworlds
|
||||
? viewGame(game, cardUser).battle?.reinforcements?.homeworldSources?.[0] : undefined;
|
||||
if (cardKinds.includes('reinforcements') && game.homeworlds)
|
||||
assert.ok(feeSources, 'Choose an actual offered native World source for the card user’s fee');
|
||||
const plans: EcazIndependentSkillsStep[] = [
|
||||
{ actor: cardUser, action: { type: 'battlePlan', dial: profile.fixedEcazDial + variable,
|
||||
support, allyPayment: 0, leader: planLeader, ...own } },
|
||||
support, allyPayment: 0, leader: planLeader, ...own,
|
||||
...(feeSources ? { reinforcementsSources: structuredClone(feeSources) } : {}) } },
|
||||
{ actor: shared.opponent, action: { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: defender, weapon: null, defense: null } },
|
||||
];
|
||||
const beforePlans = structuredClone(game);
|
||||
for (const plan of plans) {
|
||||
actions.push(structuredClone(plan));
|
||||
game = stepEcazIndependentSkills(game, plan);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, ty
|
||||
import { botActions } from '../game/bots';
|
||||
import { territory } from '../game/board';
|
||||
import { finishEcazOccupySetup, type EcazOccupyFixture } from './fixture-ecaz-occupy';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
|
||||
export type HomeworldEcazStep = { actor: string; action: Action };
|
||||
export type HomeworldEcazOccupyFixture = EcazOccupyFixture & {
|
||||
@@ -58,6 +59,14 @@ export function createHomeworldEcazOccupyFixture(options: {
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
game = applyAction(game, actor, action);
|
||||
};
|
||||
const ship = (actor: string, action: Action) => {
|
||||
if (homeworlds) {
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), Number(action.amount), Number(action.elite ?? 0));
|
||||
assert.ok(sources, 'The original native World pools must fund the typed shipment');
|
||||
action.homeworldSources = sources;
|
||||
}
|
||||
act(actor, action);
|
||||
};
|
||||
if (homeworlds) act('ecaz', { type: 'homeworlds', enabled: true });
|
||||
if (options.tech) act('ecaz', { type: 'techTokens', enabled: true });
|
||||
if (options.strongholds) act('ecaz', { type: 'strongholdCards', enabled: true });
|
||||
@@ -92,7 +101,7 @@ export function createHomeworldEcazOccupyFixture(options: {
|
||||
if (!homeworldEcazClean(game)) { step(); continue; }
|
||||
const actor = game.active!;
|
||||
if (strongholdTerritory && actor === 'ecaz' && !game.players.find(player => player.id === actor)!.shipped) {
|
||||
act(actor, { type: 'ship', territory: strongholdTerritory,
|
||||
ship(actor, { type: 'ship', territory: strongholdTerritory,
|
||||
sector: territory(strongholdTerritory).sectors[0], amount: 4, elite: 0, allyPayment: 0, smuggler: false });
|
||||
reach(homeworldEcazClean);
|
||||
}
|
||||
@@ -118,7 +127,7 @@ export function createHomeworldEcazOccupyFixture(options: {
|
||||
if (!homeworldEcazClean(game)) { step(); continue; }
|
||||
const actor = game.active!;
|
||||
if (!shipped.has(actor) && !(strongholdTerritory && actor === 'ecaz')) {
|
||||
act(actor, { type: 'ship', territory: battleTerritory, sector,
|
||||
ship(actor, { type: 'ship', territory: battleTerritory, sector,
|
||||
amount: actor === 'guild' ? 2 : 4,
|
||||
elite: actor === ally && ally === 'emperor' && (advanced || homeworlds) ? 2 : 0, allyPayment: 0 });
|
||||
shipped.add(actor);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { botActions } from '../game/bots';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { withoutEcazLoyalty } from '../game/ecaz-loyalty';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import {
|
||||
@@ -19,6 +19,9 @@ import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
|
||||
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { skillOfferRolls } from './fixture-independent-card-modules';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
|
||||
export type MoritaniIndependentSkillsStep = { actor: string; action: Action };
|
||||
/** 'guild' keeps the classic Heighliner opponent; 'harkonnen' is the native
|
||||
@@ -31,6 +34,8 @@ export type MoritaniIndependentSkillsOptions = {
|
||||
skills?: boolean;
|
||||
/** The independent three-card Ecaz Treachery variant of printed p. 18. */
|
||||
cards?: boolean;
|
||||
/** Original native World custody, selected before the ordinary lobby start. */
|
||||
homeworlds?: boolean;
|
||||
/** Original optional modules, both off by default; Strongholds require Advanced. */
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
@@ -65,6 +70,8 @@ export type MoritaniIndependentSkillsFixture = {
|
||||
beforeSplit: Game;
|
||||
split: MoritaniIndependentSkillsStep;
|
||||
afterSplit: Game;
|
||||
/** A paid native-pool shipment before the original opposing board move. */
|
||||
shipment: { before: Game; step: MoritaniIndependentSkillsStep; after: Game } | null;
|
||||
entry: MoritaniIndependentSkillsStep;
|
||||
afterEntry: Game;
|
||||
beforeBattle: Game;
|
||||
@@ -122,21 +129,6 @@ function entropy<T>(run: () => T, rolls: readonly number[] = []): T {
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
/** Only the original fourteen-card lottery is solved; later native draws keep
|
||||
* a fixed maximum value, and every roll is restored afterwards. */
|
||||
function skillRolls(chosen: readonly LeaderSkillId[]): number[] {
|
||||
const source = LEADER_SKILL_CARDS.map((card) => card.id);
|
||||
const rest = source.filter((id) => !chosen.includes(id));
|
||||
const desired = chosen.flatMap((id) => [id, rest.shift()!]).concat(rest);
|
||||
const work = [...source], rolls: number[] = [];
|
||||
for (let index = work.length - 1; index > 0; index--) {
|
||||
const from = work.indexOf(desired[index]);
|
||||
assert.ok(from >= 0 && from <= index);
|
||||
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
|
||||
[work[index], work[from]] = [work[from], work[index]];
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
export function stepMoritaniIndependentSkills(game: Game, step: MoritaniIndependentSkillsStep): Game {
|
||||
return entropy(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
@@ -168,6 +160,10 @@ export function nextMoritaniIndependentSkillsStep(
|
||||
event: decision.event, hide: policy.hide?.[decision.player] ?? false } };
|
||||
if (decision?.kind === 'mentatQuestion')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
|
||||
if (decision?.kind === 'homeworldDefense')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } };
|
||||
if (decision?.kind === 'homeworldRevivalDeployment')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
|
||||
if (decision?.kind === 'rihani')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
|
||||
if (decision?.kind === 'battleLosses')
|
||||
@@ -212,6 +208,7 @@ const counterTotal = (forces: Record<string, number>) =>
|
||||
/** Conserved physical custody: counters, the real 33/36-card inventory, all
|
||||
* fourteen skills and every Traitor identity, including Assassinate removal. */
|
||||
export function assertMoritaniIndependentSkillsCustody(game: Game): void {
|
||||
if (game.homeworlds) homeworldGameIntegrity(game);
|
||||
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
|
||||
const removed = (game.moritaniAssassinate?.opportunities ?? [])
|
||||
.filter((receipt) => receipt.stage === 'replaced').map((receipt) => receipt.card!);
|
||||
@@ -266,10 +263,13 @@ function board(opponent: MoritaniIndependentSkillsOpponent | 'ecaz') {
|
||||
export function createMoritaniIndependentSkillsFixture(
|
||||
options: MoritaniIndependentSkillsOptions = {},
|
||||
): MoritaniIndependentSkillsFixture {
|
||||
const advanced = (options.rules ?? 'advanced') === 'advanced';
|
||||
const skills = options.skills ?? false;
|
||||
const cards = options.cards ?? false;
|
||||
const opponentFaction = options.paired ? 'ecaz' : (options.opponent ?? 'guild');
|
||||
const advanced = (options.rules ?? (options.initial?.advanced === false ? 'basic' : 'advanced')) === 'advanced';
|
||||
const skills = options.skills || !!options.initial?.lobbyOptions?.leaderSkills || !!options.initial?.leaderSkills;
|
||||
const cards = options.cards || !!options.initial?.lobbyOptions?.ecazTreachery || !!options.initial?.ecazTreachery;
|
||||
const worlds = options.homeworlds || !!options.initial?.homeworlds;
|
||||
const paired = options.paired || !!options.initial?.players.some((seat) => seat.faction === 'ecaz');
|
||||
const opponentFaction = paired ? 'ecaz' : (options.opponent ??
|
||||
(options.initial?.players.some((seat) => seat.faction === 'harkonnen') ? 'harkonnen' : 'guild'));
|
||||
assert.ok(!skills || opponentFaction !== 'harkonnen',
|
||||
'Leader Skills keep the Harkonnen native roster out of this composition.');
|
||||
const plan = board(opponentFaction);
|
||||
@@ -292,12 +292,13 @@ export function createMoritaniIndependentSkillsFixture(
|
||||
assert.equal(game.advanced, advanced);
|
||||
assert.deepEqual(game.expansions, ['ecaz']);
|
||||
assert.equal(game.status, 'lobby', 'Continue only a fresh lobby of this exact composition.');
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.discoveryEnabled,
|
||||
'Only the original E3/classic composition and optional Tech/Strongholds are accepted.');
|
||||
assert.ok(!game.techTokens || options.tech,
|
||||
'A preselected original Tech module needs tech:true.');
|
||||
assert.ok(!game.strongholdCards || options.strongholds,
|
||||
'A preselected original Stronghold module needs strongholds:true.');
|
||||
assert.ok(!game.nexusCards && !game.discoveryEnabled,
|
||||
'Only the original E3/classic composition and optional World/Tech/Stronghold modules are accepted.');
|
||||
assert.deepEqual(game.players.map((seat) => seat.faction).sort(), ['moritani', opponentFaction, 'emperor'].sort(),
|
||||
'Preserve the original native three-faction roster.');
|
||||
const moritani = game.players.find((seat) => seat.faction === 'moritani')!.id;
|
||||
const opponent = game.players.find((seat) => seat.faction === opponentFaction)!.id;
|
||||
const third = game.players.find((seat) => seat.faction === 'emperor')!.id;
|
||||
const actions: MoritaniIndependentSkillsStep[] = [], staging: string[] = [];
|
||||
const act = (actor: string, action: Action) => {
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
@@ -305,40 +306,44 @@ export function createMoritaniIndependentSkillsFixture(
|
||||
return game;
|
||||
};
|
||||
const initial = structuredClone(game);
|
||||
if (skills) act('moritani', { type: 'moduleOption', module: 'leaderSkills', enabled: true });
|
||||
if (cards) act('moritani', { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
|
||||
if (options.tech && !game.techTokens) act('moritani', { type: 'techTokens', enabled: true });
|
||||
if (options.strongholds && !game.strongholdCards) act('moritani', { type: 'strongholdCards', enabled: true });
|
||||
if (worlds && !game.homeworlds) act(game.host, { type: 'homeworlds', enabled: true });
|
||||
if (skills && !game.lobbyOptions?.leaderSkills && !game.leaderSkills)
|
||||
act(game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
|
||||
if (cards && !game.lobbyOptions?.ecazTreachery && !game.ecazTreachery)
|
||||
act(game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
|
||||
if (options.tech && !game.techTokens) act(game.host, { type: 'techTokens', enabled: true });
|
||||
if (options.strongholds && !game.strongholdCards) act(game.host, { type: 'strongholdCards', enabled: true });
|
||||
for (const player of game.players) if (!player.ready) act(player.id, { type: 'ready' });
|
||||
const start: MoritaniIndependentSkillsStep = { actor: 'moritani',
|
||||
const start: MoritaniIndependentSkillsStep = { actor: game.host,
|
||||
action: { type: 'start', ...(advanced ? { advancedPreview: true as const } : {}) } };
|
||||
actions.push(structuredClone(start));
|
||||
game = entropy(() => applyAction(game, start.actor, start.action),
|
||||
skills ? skillRolls([chosen.moritani, chosen[opponentFaction], chosen.emperor]) : []);
|
||||
skills ? skillOfferRolls(game, chosen) : []);
|
||||
staging.push('Normal lobby start through the real module options; the Advanced opt-in is the ordinary lobby action, never a development profile.');
|
||||
if (skills) staging.push('Solved only the original fourteen-card Leader Skill lottery with real Fisher-Yates draws; offers, deck and later native shuffles are untouched.');
|
||||
const offered = structuredClone(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 200; count++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
||||
const faction = moritaniIndependentSkillsPlayer(game, actor).faction;
|
||||
const offer = game.leaderSkills!.offers[actor];
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
const leader = moritaniIndependentSkillsPlayer(game, actor).leaders
|
||||
.filter((disc) => disc.id === trainedLeader[actor] &&
|
||||
.filter((disc) => disc.id === trainedLeader[faction] &&
|
||||
view.eligibleLeaders.some((eligible) => eligible.id === disc.id))[0];
|
||||
assert.ok(leader, 'The requested original leader must be eligible for this offer');
|
||||
assert.ok(offer.cards.includes(chosen[actor]), 'The requested original skill must be in the real offer');
|
||||
act(actor, { type: 'leaderSkill', event: offer.event, skill: chosen[actor], leader: leader.id });
|
||||
assert.ok(offer.cards.includes(chosen[faction]), 'The requested original skill must be in the real offer');
|
||||
act(actor, { type: 'leaderSkill', event: offer.event, skill: chosen[faction], leader: leader.id });
|
||||
continue;
|
||||
}
|
||||
if (game.setupStage === 'forces' && opponentFaction === 'ecaz' &&
|
||||
viewGame(game, 'ecaz').setupPending.includes('ecaz')) {
|
||||
act('ecaz', { type: 'ecazSetup', placements: { [plan.from]: 6 } });
|
||||
viewGame(game, opponent).setupPending.includes(opponent)) {
|
||||
act(opponent, { type: 'ecazSetup', placements: { [plan.from]: 6 } });
|
||||
continue;
|
||||
}
|
||||
if (game.setupStage === 'traitors' || game.players.some((player) => player.traitorChoices.length)) {
|
||||
const player = game.players.find((seat) => seat.traitorChoices.length)!;
|
||||
const keep = player.id === 'moritani'
|
||||
const keep = player.id === moritani
|
||||
? (player.traitorChoices.find((id) => id.startsWith(`${opponentFaction}-`)) ?? player.traitorChoices[0])
|
||||
: player.traitorChoices[0];
|
||||
act(player.id, { type: 'traitor', leader: keep });
|
||||
@@ -354,9 +359,6 @@ export function createMoritaniIndependentSkillsFixture(
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const moritani = 'moritani';
|
||||
const opponent = game.players.find((seat) => seat.faction === opponentFaction)!.id;
|
||||
const third = game.players.find((seat) => seat.faction === 'emperor')!.id;
|
||||
assert.equal(moritaniIndependentSkillsPlayer(game, moritani).reserves, 14);
|
||||
assert.equal(!!game.moritaniAssassinate, advanced,
|
||||
'The printed Advanced assassination producer follows the real rules mode alone.');
|
||||
@@ -390,6 +392,22 @@ export function createMoritaniIndependentSkillsFixture(
|
||||
const afterSplit = structuredClone(game);
|
||||
game = advanceMoritaniIndependentSkills(game,
|
||||
(state) => state.phase === 5 && state.active === opponent && moritaniIndependentSkillsClean(state), actions);
|
||||
let shipment: MoritaniIndependentSkillsFixture['shipment'] = null;
|
||||
if (worlds) {
|
||||
const before = structuredClone(game);
|
||||
const sources = nativeShipmentSources(viewGame(game, opponent), 1, 0);
|
||||
assert.ok(sources, 'The opponent ships its own real native World normal counter.');
|
||||
const step: MoritaniIndependentSkillsStep = { actor: opponent, action: {
|
||||
type: 'ship', territory: plan.battle, sector: plan.sectors[1], amount: 1, elite: 0,
|
||||
allyPayment: 0, homeworldSources: sources } };
|
||||
game = stepMoritaniIndependentSkills(game, step);
|
||||
actions.push(structuredClone(step));
|
||||
game = advanceMoritaniIndependentSkills(game,
|
||||
(state) => state.phase === 5 && state.active === opponent && !state.pendingShipment &&
|
||||
!state.pendingHomeworldShipment && moritaniIndependentSkillsClean(state), actions);
|
||||
shipment = { before, step, after: structuredClone(game) };
|
||||
staging.push('The opponent paid for a real normal-counter shipment from its original native World before moving its original starting army.');
|
||||
}
|
||||
const entrySector = plan.sectors[1];
|
||||
assert.notEqual(game.storm, entrySector, 'The real opponent entry must not enter the storm sector.');
|
||||
const entry: MoritaniIndependentSkillsStep = { actor: opponent, action: {
|
||||
@@ -417,10 +435,11 @@ export function createMoritaniIndependentSkillsFixture(
|
||||
const winnerWeapon = conserveCard(game, opponent, (card) => card.kind === 'worthless', staging);
|
||||
const plans: MoritaniIndependentSkillsStep[] = [
|
||||
{ actor: moritani, action: { type: 'battlePlan', dial: 2, support: 0, leader: loser,
|
||||
...(reinforcements && harass ? { weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } : {}) } },
|
||||
...(reinforcements && harass ? { weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD,
|
||||
...(worlds ? { reinforcementsSources: { 'homeworld:moritani': { normal: 3, elite: 0 } } } : {}) } : {}) } },
|
||||
{ actor: opponent, action: { type: 'battlePlan',
|
||||
dial: options.paired ? (cards ? 4 : 2) : (cards ? 1 : 0),
|
||||
support: options.paired && advanced ? (cards ? 4 : 2) : 0,
|
||||
dial: paired ? (cards ? 4 : 2) : (cards ? 1 : 0),
|
||||
support: paired && advanced ? (cards ? 4 : 2) : 0,
|
||||
leader: winner, weapon: winnerWeapon.id } },
|
||||
];
|
||||
const window = structuredClone(game);
|
||||
@@ -436,7 +455,7 @@ export function createMoritaniIndependentSkillsFixture(
|
||||
assert.ok(!cards || footer, 'The committed Reinforcements fee plus Harass return must be a real physical choice');
|
||||
const returns = footer ? defaultHarassWithdrawAllocation(footer.context, footer.dial, footer.support) : null;
|
||||
const fixture: MoritaniIndependentSkillsFixture = {
|
||||
initial, offered, afterSetup, beforeSplit, split, afterSplit, entry, afterEntry, beforeBattle,
|
||||
initial, offered, afterSetup, beforeSplit, split, afterSplit, shipment, entry, afterEntry, beforeBattle,
|
||||
battleStep, game: window, plans, revealed, returns, returnsContext: footer?.context ?? null,
|
||||
pending: structuredClone(revealed), moritani, opponent, third, territory: plan.battle,
|
||||
first: `${plan.battle}:${plan.sectors[0]}`, second: `${plan.battle}:${plan.sectors[1]}`,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import {
|
||||
advanceMoritaniIndependentSkills,
|
||||
assertMoritaniIndependentSkillsCustody,
|
||||
@@ -28,6 +30,9 @@ const owned = (fixture: MoritaniIndependentSkillsFixture, game: Game, identity:
|
||||
player(game, fixture.opponent).leaders.find((leader) => leader.id === identity)!;
|
||||
const choiceOf = (game: Game, viewer: string) =>
|
||||
viewGame(game, viewer).moritaniAssassinate!.pending!;
|
||||
const pool = (game: Game, actor: string) =>
|
||||
homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
|
||||
.find((home) => home.native === actor)!.forces[actor];
|
||||
|
||||
void test('Advanced standalone Moritani keeps the printed Assassinate power through the real lobby, the real loss and one held unused Traitor', () => {
|
||||
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true, skills: true });
|
||||
@@ -40,7 +45,6 @@ void test('Advanced standalone Moritani keeps the printed Assassinate power thro
|
||||
assert.deepEqual(f.afterSetup.moritaniAssassinateCallEvents, []);
|
||||
assert.deepEqual(f.afterSetup.expansions, ['ecaz']);
|
||||
assert.equal(f.afterSetup.ecazTreachery, true);
|
||||
assert.equal(f.afterSetup.homeworlds, undefined);
|
||||
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 3);
|
||||
assert.equal(moritani.spice, 12);
|
||||
assert.equal(moritani.reserves, 14);
|
||||
@@ -449,7 +453,6 @@ void test('paired Advanced Skills with original phase modules pays the printed E
|
||||
const f = createMoritaniIndependentSkillsFixture({ paired: true, rules: 'advanced', skills: true,
|
||||
tech: true, strongholds: true });
|
||||
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(f.initial.nexusCards, undefined);
|
||||
assert.equal(f.returns, null);
|
||||
assert.equal(f.cards.reinforcements, null);
|
||||
assert.equal(f.cards.harass, null);
|
||||
@@ -476,3 +479,120 @@ void test('paired Advanced Skills with original phase modules pays the printed E
|
||||
defeatedTokens.includes(id) && token.owner === f.opponent).length, 1);
|
||||
assertMoritaniIndependentSkillsCustody(settled);
|
||||
});
|
||||
|
||||
void test('Advanced native World cards pay their real normal source fee and assassinate a different unused trained disc before one Mentat replacement', () => {
|
||||
let initial = createGame('WORLDASSASSINATE', newPlayer('native-owner', 'Moritani', 'moritani'), true, ['ecaz']);
|
||||
joinGame(initial, newPlayer('native-rival', 'Guild', 'guild'));
|
||||
joinGame(initial, newPlayer('native-court', 'Emperor', 'emperor'));
|
||||
for (const action of [
|
||||
{ type: 'homeworlds', enabled: true },
|
||||
{ type: 'moduleOption', module: 'leaderSkills', enabled: true },
|
||||
{ type: 'moduleOption', module: 'ecazTreachery', enabled: true },
|
||||
{ type: 'techTokens', enabled: true },
|
||||
{ type: 'strongholdCards', enabled: true },
|
||||
] as const) initial = applyAction(initial, initial.host, action);
|
||||
const f = createMoritaniIndependentSkillsFixture({ initial });
|
||||
const shipment = f.shipment!;
|
||||
assert.deepEqual(pool(shipment.after, f.opponent),
|
||||
{ normal: pool(shipment.before, f.opponent).normal - 1, elite: 0 });
|
||||
assert.equal(player(shipment.after, f.opponent).spice, player(shipment.before, f.opponent).spice - 1);
|
||||
assert.equal(player(f.afterEntry, f.opponent).forces[f.second], 6);
|
||||
const plan = f.plans.find((step) => step.actor === f.moritani)!;
|
||||
if (plan.action.type !== 'battlePlan') throw new Error('Expected the real Moritani plan.');
|
||||
reject(f.game, f.moritani, { ...plan.action,
|
||||
reinforcementsSources: { 'homeworld:guild': { normal: 3, elite: 0 } } });
|
||||
reject(f.game, f.moritani, { ...plan.action,
|
||||
reinforcementsSources: { 'homeworld:moritani': { normal: 2, elite: 0 } } });
|
||||
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(f.pending.lastBattleContext!.result, 'normal');
|
||||
assert.deepEqual(pool(f.pending, f.moritani),
|
||||
{ normal: pool(f.revealed, f.moritani).normal - 3 + 2, elite: 0 });
|
||||
assert.equal(player(f.pending, f.moritani).tanks, 7);
|
||||
assert.deepEqual(player(f.pending, f.moritani).forces, {});
|
||||
assert.deepEqual(player(f.pending, f.opponent).forces, { [f.second]: 4 });
|
||||
assert.deepEqual(f.returns, { [f.first]: { normal: 2, elite: 0 } });
|
||||
const assignment = f.pending.leaderSkills!.assignments.find((entry) => entry.leader === f.traitorTarget)!;
|
||||
assert.ok(assignment);
|
||||
assert.notEqual(f.traitorTarget, f.winnerLeader);
|
||||
assert.equal(owned(f, f.pending, f.traitorTarget).usedAt, undefined);
|
||||
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
|
||||
const target = owned(f, spent, f.traitorTarget);
|
||||
assert.equal(target.dead, true);
|
||||
assert.equal(target.deaths, 1);
|
||||
assert.equal(target.usedAt, undefined);
|
||||
assert.equal(owned(f, spent, f.winnerLeader).dead, false);
|
||||
assert.deepEqual(spent.leaderSkills!.deck, [...f.pending.leaderSkills!.deck, assignment.skill]);
|
||||
assert.equal(spent.leaderSkills!.assignments.some((entry) => entry.leader === target.id), false);
|
||||
assert.equal(player(spent, f.moritani).spice, player(f.pending, f.moritani).spice + target.strength);
|
||||
assert.deepEqual(player(spent, f.moritani).traitors, [f.traitorTarget]);
|
||||
const beforeMentat = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' },
|
||||
(game) => game.phase === 7 && moritaniIndependentSkillsClean(game));
|
||||
assert.equal(beforeMentat.moritaniAssassinate!.opportunities[0].stage, 'revealed');
|
||||
const settled = finishMoritaniIndependentSkillsBattle(beforeMentat);
|
||||
const receipt = settled.moritaniAssassinate!.opportunities[0];
|
||||
assert.equal(receipt.stage, 'replaced');
|
||||
assert.equal(receipt.bounty, target.strength);
|
||||
assert.ok(receipt.replacement && receipt.replacement !== f.traitorTarget);
|
||||
assert.deepEqual(player(settled, f.moritani).traitors, [receipt.replacement]);
|
||||
assert.equal(settled.traitorReserve!.length, f.pending.traitorReserve!.length - 1);
|
||||
assert.equal(settled.leaderSkills!.deck.filter((skill) => skill === assignment.skill).length, 1);
|
||||
assert.deepEqual(pool(settled, f.moritani), pool(f.pending, f.moritani));
|
||||
assertMoritaniIndependentSkillsCustody(settled);
|
||||
});
|
||||
|
||||
void test('paired native Worlds keep Ecaz Loyalty and the shared Duke outside an unused trained-target assassination', () => {
|
||||
const f = createMoritaniIndependentSkillsFixture({ paired: true, homeworlds: true,
|
||||
rules: 'advanced', skills: true, cards: true, tech: true, strongholds: true });
|
||||
const loyalty = f.afterSetup.ecazLoyalty!;
|
||||
assert.notEqual(f.traitorTarget, loyalty.card);
|
||||
assert.notEqual(f.traitorTarget, f.winnerLeader);
|
||||
assert.notEqual(f.traitorTarget, 'duke-vidal');
|
||||
assert.equal(f.pending.leaderSkills!.assignments.some((entry) => entry.leader === 'duke-vidal'), false);
|
||||
assert.deepEqual(pool(f.shipment!.after, f.opponent),
|
||||
{ normal: pool(f.shipment!.before, f.opponent).normal - 1, elite: 0 });
|
||||
assert.equal(player(f.shipment!.after, f.opponent).spice,
|
||||
player(f.shipment!.before, f.opponent).spice - 2);
|
||||
assert.equal(player(f.afterEntry, f.opponent).forces[f.second], 7);
|
||||
assert.deepEqual(player(f.pending, f.opponent).forces, { [f.second]: 3 });
|
||||
assert.equal(player(f.pending, f.opponent).spice, player(f.game, f.opponent).spice - 4,
|
||||
'support uses the real prebattle wallet after original optional phase income');
|
||||
const assignment = f.pending.leaderSkills!.assignments.find((entry) => entry.leader === f.traitorTarget)!;
|
||||
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
|
||||
assert.equal(owned(f, spent, f.traitorTarget).usedAt, undefined);
|
||||
assert.equal(owned(f, spent, f.traitorTarget).dead, true);
|
||||
assert.equal(owned(f, spent, f.winnerLeader).dead, false);
|
||||
assert.equal(spent.moritaniAssassinate!.opportunities[0].bounty, 4);
|
||||
assert.equal(player(spent, f.moritani).spice, player(f.pending, f.moritani).spice + 4);
|
||||
assert.deepEqual(spent.leaderSkills!.deck, [...f.pending.leaderSkills!.deck, assignment.skill]);
|
||||
const settled = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
|
||||
const receipt = settled.moritaniAssassinate!.opportunities[0];
|
||||
assert.equal(receipt.stage, 'replaced');
|
||||
assert.notEqual(receipt.replacement, loyalty.card);
|
||||
assert.equal(settled.traitorReserve!.length, f.pending.traitorReserve!.length - 1);
|
||||
assert.equal(settled.leaderSkills!.deck.filter((skill) => skill === assignment.skill).length, 1);
|
||||
assert.deepEqual(settled.ecazLoyalty, loyalty);
|
||||
assert.deepEqual(settled.dukeVidal, f.afterSetup.dukeVidal);
|
||||
assertMoritaniIndependentSkillsCustody(settled);
|
||||
});
|
||||
|
||||
void test('Basic native Worlds return the typed Harass survivors but cannot assassinate the unused trained Ecaz target', () => {
|
||||
const f = createMoritaniIndependentSkillsFixture({ paired: true, homeworlds: true,
|
||||
rules: 'basic', skills: true, cards: true });
|
||||
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(f.pending.lastBattleContext!.result, 'normal');
|
||||
assert.deepEqual(f.returns, { [f.first]: { normal: 4, elite: 0 } });
|
||||
assert.deepEqual(pool(f.pending, f.moritani),
|
||||
{ normal: pool(f.revealed, f.moritani).normal - 3 + 4, elite: 0 });
|
||||
assert.equal(player(f.pending, f.moritani).tanks, 5);
|
||||
assert.deepEqual(player(f.pending, f.moritani).forces, {});
|
||||
assert.deepEqual(player(f.pending, f.opponent).forces, { [f.second]: 3 });
|
||||
assert.equal(f.pending.moritaniAssassinate, undefined);
|
||||
reject(f.pending, f.moritani, { type: 'decision', card: f.traitorTarget });
|
||||
const settled = finishMoritaniIndependentSkillsBattle(f.pending);
|
||||
assert.equal(owned(f, settled, f.traitorTarget).dead, false);
|
||||
assert.equal(owned(f, settled, f.traitorTarget).usedAt, undefined);
|
||||
assert.deepEqual(settled.leaderSkills, f.pending.leaderSkills);
|
||||
assert.deepEqual(player(settled, f.moritani).traitors, [f.traitorTarget]);
|
||||
assert.deepEqual(pool(settled, f.moritani), pool(f.pending, f.moritani));
|
||||
assertMoritaniIndependentSkillsCustody(settled);
|
||||
});
|
||||
Reference in new issue
Block a user