Connect trained E3 leaders to Advanced Harkonnen capture

This commit is contained in:
Quintine committed 2026-10-27 09:59:28 +11:00
1 parent c56d9c85ef
commit e530d267a3
10 files changed
+700 -7

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { leaderSkillBattleBonus } from '../game/leader-skill-combat';
import {
acceptE3HarkonnenCapture as accept, actE3Capture as act, advanceE3Capture as advance,
cancelE3HarkonnenCapture as cancel, continueE3HarkonnenCaptiveBattle as useCaptive,
createE3HarkonnenCaptureFixture as fixture, declineE3HarkonnenCapture as decline,
e3CaptureClean as clean, e3CaptureInventory as inventory, e3CapturePlayer as p,
e3CaptureReload as reload, type E3CaptureStep,
} from './fixture-e3-harkonnen-capture';
function reject(game: Game, actor: string, action: Action) {
const before = reload(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function skillCount(game: Game, skill: string) {
return game.leaderSkills!.deck.filter(card => card === skill).length +
game.leaderSkills!.assignments.filter(row => row.skill === skill).length +
Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards).filter(card => card === skill).length;
}
for (const native of ['ecaz', 'moritani'] as const) {
void test(`native Advanced ${native} all14 source earns two real battles and the original Harkonnen capture offer`, () => {
const f = fixture(native);
assert.equal(f.afterSetup.advanced, true);
assert.deepEqual(f.afterSetup.players.map(player => player.faction), ['guild', 'harkonnen', native]);
assert.deepEqual(f.afterSetup.expansions, ['ecaz']);
assert.equal(f.sourceCounts.setup, f.setupActions.length);
assert.equal(f.sourceCounts.firstBattle, f.firstBattleActions.length);
assert.equal(f.sourceCounts.source, f.actions.length);
assert.ok(f.setupActions.some(step => step.actor === native && step.action.type === 'leaderSkill' &&
step.action.skill === 'warmaster' && step.action.leader === f.captive));
assert.deepEqual(f.shipments.map(row => ({ actor: row.actor, amount: row.action.amount, cost: row.cost })),
[{ actor: 'harkonnen', amount: 1, cost: 2 }, { actor: 'guild', amount: 1, cost: 1 }]);
assert.equal(f.movement.action.from, 'carthag:11');
assert.equal(f.movement.action.territory, 'tsimpo');
assert.equal(p(f.beforeFirstBattle, 'harkonnen').forces['imperial_basin:9'], 1);
assert.equal(p(f.beforeFirstBattle, 'harkonnen').forces['tsimpo:11'], 1);
assert.equal(p(f.beforeFirstBattle, 'guild').forces['tsimpo:11'], 1);
assert.equal(f.game.decision?.kind, 'captureOffer');
assert.equal(p(f.game, native).tanks, 6);
assert.equal(p(f.game, native).leaders.find(leader => leader.id === f.captive)?.dead, false);
assert.deepEqual(f.game.leaderSkills!.assignments.find(row => row.owner === native), f.assignment);
assert.deepEqual(inventory(f.game), f.original);
assert.equal(!!f.game.moritaniAssassinate, native === 'moritani');
});
void test(`actual trained ${native} capture keeps its original assignment and exposes only the captive lower band`, () => {
const f = fixture(native), actions: E3CaptureStep[] = [];
reject(f.game, 'guild', { type: 'decision', accept: true });
const game = accept(f, 'keep', f.game, actions);
const leader = p(game, native).leaders.find(leader => leader.id === f.captive)!;
assert.equal(leader.capturedBy, 'harkonnen'); assert.equal(leader.concealed, undefined);
assert.equal(leader.dead, false);
assert.deepEqual(game.leaderSkills!.assignments.find(row => row.owner === native), f.assignment);
assert.equal(skillCount(game, 'warmaster'), 1);
assert.equal(game.leaderSkills!.offers[native], undefined);
for (const actor of [native, 'harkonnen']) {
const row = viewGame(game, actor).leaderSkills!.assignments.find(row => row.leader === f.captive)!;
assert.ok(row); assert.equal(row.owner, native); assert.equal(row.controller, 'harkonnen');
assert.equal(row.captured, true); assert.equal(row.faceUp, false);
const otherLeader = p(game, actor).leaders.find(own => own.id !== f.captive && !own.dead)!;
assert.equal(leaderSkillBattleBonus({ assignments: [row],
selectedLeader: { id: otherLeader.id, kind: 'disc' }, weapon: p(game, 'harkonnen').hand.find(card => card.id === f.worthless),
defense: undefined, skilledLeaderSurvives: true }).bonus, 0,
'The captive upper band cannot strengthen either faction’s other leader');
}
assert.ok(actions.some(step => step.actor === 'harkonnen' && step.action.accept === true));
assert.ok(actions.some(step => step.actor === 'harkonnen' && step.action.mode === 'keep'));
if (native === 'moritani') assert.ok([...f.actions, ...actions].some(step =>
step.actor === native && step.action.type === 'decision' && step.action.decline === true),
'Original Moritani optional aftermath is declined through its native control');
assert.deepEqual(inventory(game), f.original);
});
void test(`captured ${native} Warmaster supplies exactly lower three then returns alive with its same assignment`, () => {
const f = fixture(native), captureActions: E3CaptureStep[] = [], battleActions: E3CaptureStep[] = [];
const kept = accept(f, 'keep', f.game, captureActions);
const result = useCaptive(f, kept, 'alive', battleActions), game = result.game;
assert.equal(result.plans[0].action.leader, f.captive);
assert.equal(result.plans[0].action.weapon, f.worthless);
assert.equal(result.plans[0].action.defense, null, 'The held Harkonnen Shield supplies no native Prana bonus');
const bonuses = game.log.filter(entry => entry.automatic?.name === 'Leader Skill' && entry.text.includes('Warmaster'));
assert.ok(bonuses.some(entry => entry.text.includes('gained 3 battle strength')));
assert.equal(bonuses.some(entry => entry.text.includes('gained 1 battle strength')), false);
const leader = p(game, native).leaders.find(leader => leader.id === f.captive)!;
assert.equal(leader.dead, false); assert.equal(leader.capturedBy, undefined); assert.equal(leader.usedAt, 'tsimpo');
assert.deepEqual(game.leaderSkills!.assignments.find(row => row.owner === native), f.assignment);
assert.equal(game.leaderSkills!.deck.includes('warmaster'), false);
assert.equal(skillCount(game, 'warmaster'), 1);
assert.equal(viewGame(game, native).leaderSkills!.assignments.find(row => row.leader === f.captive)?.controller, native);
assert.ok(p(game, 'harkonnen').hand.some(card => card.id === f.shield));
assert.ok(p(game, 'harkonnen').hand.some(card => card.id === f.worthless));
assert.ok(captureActions.length > 0 && battleActions.length > 0);
assert.deepEqual(inventory(game), f.original);
});
void test(`actual Guild poison kills the captive ${native} trainer and returns its one physical skill`, () => {
const f = fixture(native), kept = accept(f);
const result = useCaptive(f, kept, 'death'), game = result.game;
assert.equal(result.plans[1].action.weapon, f.poison);
assert.equal(result.plans[0].action.defense, null);
const leader = p(game, native).leaders.find(leader => leader.id === f.captive)!;
assert.equal(leader.dead, true); assert.equal(leader.deaths, 1); assert.equal(leader.capturedBy, undefined);
assert.equal(game.leaderSkills!.assignments.some(row => row.leader === f.captive), false);
assert.equal(game.leaderSkills!.deck.filter(skill => skill === 'warmaster').length, 1);
assert.equal(skillCount(game, 'warmaster'), 1);
assert.equal(game.discard.filter(card => card.id === f.worthless).length, 1);
assert.ok(p(game, 'harkonnen').hand.some(card => card.id === f.shield));
assert.equal(game.log.filter(entry => entry.automatic?.name === 'Leader Skill lost' && entry.text.includes('Warmaster')).length, 1);
assert.deepEqual(inventory(game), f.original);
});
void test(`native execution of the known trained ${native} captive pays two and returns the skill once`, () => {
const f = fixture(native), before = p(f.game, 'harkonnen').spice;
const game = accept(f, 'execute');
const leader = p(game, native).leaders.find(leader => leader.id === f.captive)!;
assert.equal(leader.dead, true); assert.ok(leader.deaths >= 2); assert.equal(leader.capturedBy, undefined);
assert.equal(p(game, 'harkonnen').spice, before + 2);
assert.equal(game.leaderSkills!.assignments.some(row => row.leader === f.captive), false);
assert.equal(game.leaderSkills!.deck.filter(skill => skill === 'warmaster').length, 1);
assert.equal(skillCount(game, 'warmaster'), 1);
assert.deepEqual(inventory(game), f.original);
});
}
void test('both actual native capture offers may be declined or canceled with the original dealt Karama', () => {
for (const native of ['ecaz', 'moritani'] as const) {
const f = fixture(native);
for (const mode of ['decline', 'cancel'] as const) {
const game = mode === 'decline' ? decline(f) : cancel(f);
const leader = p(game, native).leaders.find(leader => leader.id === f.captive)!;
assert.equal(leader.dead, false); assert.equal(leader.capturedBy, undefined);
assert.deepEqual(game.leaderSkills!.assignments.find(row => row.owner === native), f.assignment);
assert.equal(skillCount(game, 'warmaster'), 1);
assert.equal(game.discard.filter(card => card.id === f.karama).length, mode === 'cancel' ? 1 : 0);
assert.equal(p(game, 'harkonnen').spice, p(f.game, 'harkonnen').spice);
assert.deepEqual(inventory(game), f.original);
}
}
});
void test('four minimal profiles make legal original capture and captive-disposition choices for both native trainers', () => {
for (const native of ['ecaz', 'moritani'] as const) {
const f = fixture(native);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
let game = reload(f.game);
const own = viewGame(game, 'harkonnen'); own.players.find(row => row.id === 'harkonnen')!.bot = difficulty;
const proposal = botActions(own).find(action => action.type === 'decision' && typeof action.accept === 'boolean');
assert.ok(proposal, `${native}/${difficulty} supplies its original capture choice`);
game = act(game, 'harkonnen', proposal, [], f.captive);
if (proposal.accept) {
game = advance(game, state => state.decision?.kind === 'capturedLeader', [], f.captive);
const current = viewGame(game, 'harkonnen'); current.players.find(row => row.id === 'harkonnen')!.bot = difficulty;
const disposition = botActions(current).find(action => action.type === 'decision' && ['keep', 'execute'].includes(String(action.mode)));
assert.ok(disposition, `${native}/${difficulty} disposes of the actual emitted captive`);
game = act(game, 'harkonnen', disposition);
}
game = advance(game, state => !state.battle && clean(state));
assert.equal(skillCount(game, 'warmaster'), 1);
assert.deepEqual(inventory(game), f.original);
}
}
});
+99
View File
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
advancedMoritaniLeaderSkillsProfile,
homeworldLeaderSkillsProfile,
independentTreacheryLeaderSkillsProfile,
nativeEcazLeaderSkillsProfile,
nativeExpansionLeaderSkillsProfile,
nativeTechLeaderSkillsProfile,
ordinaryLeaderSkillModeSupported,
selectedDeckLeaderSkillsProfile,
type LeaderSkillProfile,
} from '../game/leader-skill-profile';
function profile(factions: readonly string[], extra: Partial<LeaderSkillProfile> = {}): LeaderSkillProfile {
return { advanced: true, expansions: ['ecaz'], players: factions.map(faction => ({ faction })), ...extra };
}
const classics = ['atreides', 'emperor', 'guild', 'fremen'] as const;
void test('bare Advanced Harkonnen and either native E3 faction admit ordinary all14 at two through six seats', () => {
for (const native of ['ecaz', 'moritani'] as const) {
for (let count = 2; count <= 6; count++) {
for (const spiceBankerIncomePreview of [undefined, true]) {
const game = profile(['harkonnen', native, ...classics.slice(0, count - 2)], { spiceBankerIncomePreview });
const before = structuredClone(game);
Object.freeze(game.players);
Object.freeze(game);
assert.equal(selectedDeckLeaderSkillsProfile(game), true, `${native}/${count}`);
assert.equal(nativeExpansionLeaderSkillsProfile(game), true);
assert.equal(ordinaryLeaderSkillModeSupported(game), true);
assert.equal(native === 'ecaz' ? nativeEcazLeaderSkillsProfile(game) : advancedMoritaniLeaderSkillsProfile(game), true);
assert.deepEqual(game, before);
}
}
}
});
void test('both E3 natives share the same bare Harkonnen admission and native Moritani state', () => {
for (let count = 3; count <= 6; count++) {
for (const moritaniAssassinatePreview of [undefined, true]) {
const game = profile(['harkonnen', 'ecaz', 'moritani', ...classics.slice(0, count - 3)], {
moritaniAssassinatePreview, spiceBankerIncomePreview: true,
});
assert.equal(selectedDeckLeaderSkillsProfile(game), true);
assert.equal(nativeEcazLeaderSkillsProfile(game), true);
assert.equal(advancedMoritaniLeaderSkillsProfile(game), true);
assert.equal(ordinaryLeaderSkillModeSupported(game), true);
}
}
assert.equal(selectedDeckLeaderSkillsProfile(profile(['harkonnen', 'ecaz'], { moritaniAssassinatePreview: true })), false);
assert.equal(selectedDeckLeaderSkillsProfile(profile(['harkonnen', 'moritani'], { moritaniAssassinatePreview: true })), true);
});
void test('the new capture envelope does not admit optional modules or unrelated effect previews', () => {
const base = profile(['harkonnen', 'ecaz', 'moritani']);
for (const field of [
'homeworlds', 'nexusCards', 'discoveries', 'discoveryEnabled', 'discoveryStash', 'greatMaker',
'ecazTreachery', 'techTokens', 'strongholdCards', 'semutaPreview', 'advancedPreview',
'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview', 'richeseBetrayalPreview',
'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview',
] as const) {
const game = { ...base, [field]: true };
assert.equal(selectedDeckLeaderSkillsProfile(game), false, field);
assert.equal(nativeExpansionLeaderSkillsProfile(game), false, field);
assert.equal(ordinaryLeaderSkillModeSupported(game), false, field);
}
assert.equal(homeworldLeaderSkillsProfile({ ...base, homeworlds: true }), false);
assert.equal(nativeTechLeaderSkillsProfile({ ...base, techTokens: true }), false);
assert.equal(independentTreacheryLeaderSkillsProfile({ ...base, ecazTreachery: true }), false);
});
void test('capture admission keeps the sole selected E3 deck and registered distinct two-to-six seat boundary', () => {
const base = profile(['harkonnen', 'ecaz']);
for (const expansions of [[], ['ix'], ['choam'], ['ecaz', 'ix'], ['ecaz', 'choam'], ['ecaz', 'ecaz'], ['unknown']]) {
assert.equal(selectedDeckLeaderSkillsProfile({ ...base, expansions }), false, JSON.stringify(expansions));
}
for (const factions of [
['harkonnen'],
['harkonnen', 'ecaz', 'moritani', ...classics],
['harkonnen', 'ecaz', 'ecaz'],
['harkonnen', 'ecaz', 'ixians'],
['harkonnen', 'moritani', 'richese'],
['harkonnen', 'ecaz', 'unknown'],
]) assert.equal(selectedDeckLeaderSkillsProfile(profile(factions)), false, factions.join('/'));
assert.equal(selectedDeckLeaderSkillsProfile({ ...base, players: undefined }), false);
assert.equal(selectedDeckLeaderSkillsProfile({ ...base, advanced: undefined }), false);
});
void test('Basic native Harkonnen Skills and existing module admission remain unchanged', () => {
for (const natives of [['ecaz'], ['moritani'], ['ecaz', 'moritani']]) {
const game = profile(['harkonnen', ...natives, 'atreides'], { advanced: false });
assert.equal(selectedDeckLeaderSkillsProfile(game), true);
assert.equal(nativeExpansionLeaderSkillsProfile(game), true);
assert.equal(ordinaryLeaderSkillModeSupported(game), true);
assert.equal(selectedDeckLeaderSkillsProfile({ ...game, homeworlds: true }), true);
assert.equal(selectedDeckLeaderSkillsProfile({ ...game, techTokens: true }), true);
assert.equal(selectedDeckLeaderSkillsProfile({ ...game, moritaniAssassinatePreview: true }), false);
}
});
+253
View File
@@ -0,0 +1,253 @@
import assert from 'node:assert/strict';
import { applyAction, captureCandidates, createGame, initializeLeaderSkillsGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { skillOfferRolls } from './fixture-independent-card-modules';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type E3CaptureNative = 'ecaz' | 'moritani';
export type E3CaptureStep = { actor: string; action: Action };
export const e3CapturePlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const e3CaptureReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const e3CaptureClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard;
function permutation<T>(source: readonly T[], desired: readonly T[]): number[] {
const working = [...source], rolls: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const from = working.indexOf(desired[index]); assert.ok(from >= 0 && from <= index);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
[working[index], working[from]] = [working[from], working[index]];
}
assert.deepEqual(working, desired); return rolls;
}
/** Select real shuffle draws only; no live card, capture or declaration is installed. */
function entropy<T>(rolls: readonly number[], run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actE3Capture(game: Game, actor: string, action: Action,
actions: E3CaptureStep[] = [], captive?: string): Game {
const pending = game.pendingCapture;
const pool = captive && pending && (game.response?.kind === 'capture' ||
game.decision?.kind === 'captureOffer' && action.accept === true)
? captureCandidates(game, pending.loser, pending.territory) : [];
const selected = pool.find(leader => leader.id === captive);
if (pool.length) assert.ok(selected, 'Select only a leader in the actual emitted native capture pool');
const rolls = selected ? permutation(pool.map(leader => leader.id),
[selected.id, ...pool.filter(leader => leader.id !== selected.id).map(leader => leader.id)]) : [];
const next = entropy(rolls, () => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
export function e3CaptureInventory(game: Game) {
validateLeaderSkills(game.leaderSkills!, game.players);
const skills = [...game.leaderSkills!.deck, ...game.leaderSkills!.assignments.map(row => row.skill),
...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards)].sort();
assert.deepEqual(skills, LEADER_SKILL_CARDS.map(card => card.id).sort());
for (const player of game.players)
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]
.map(card => card.id).sort();
const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
[...player.traitors, ...player.traitorChoices]), ...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])].sort();
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
assert.equal(new Set(cards).size, cards.length); assert.equal(new Set(traitors).size, traitors.length);
return { cards, traitors, skills, spice };
}
export function nextE3CaptureStep(game: Game): E3CaptureStep | null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const d = game.decision;
if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } };
if (d?.kind === 'moritaniSetup') return { actor: d.player,
action: { type: 'decision', territory: 'imperial_basin', sector: 9 } };
if (d?.kind === 'moritaniAssassinate') return { actor: d.player,
action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'ecazPlacement' || d?.kind === 'moritaniPlacement')
return { actor: d.player, action: { type: 'decision', decline: true } };
if (d?.kind === 'captureOffer') return { actor: d.player, action: { type: 'decision', accept: false } };
if (d?.kind === 'capturedLeader') return null;
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player,
action: { type: 'leaderSkillVisibility', event: d.event, hide: false } };
if (game.status === 'playing' && game.turn === 1 && game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(actor => game.stormDials[actor] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: game.stormDialers.indexOf(actor) === 0 ? 1 : 0 } };
}
if (game.status === 'setup' && game.setupStage === 'forces') {
const ecaz = game.players.find(player => player.faction === 'ecaz' && player.reserves === 20);
if (ecaz) return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
}
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceE3Capture(game: Game, until: (state: Game) => boolean,
actions: E3CaptureStep[] = [], captive?: string): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine capture chronology ended before its next requested control');
const next = nextE3CaptureStep(game); assert.ok(next, 'Seal the original human plans or choose the actual captive disposition');
game = actE3Capture(game, next.actor, next.action, actions, captive);
}
throw Error('The finite native E3 capture chronology did not reach its requested boundary');
}
function prepared(game: Game) {
return !!game.battle && !game.battle.preparation && game.battle.preLeader?.closed !== false && e3CaptureClean(game);
}
function openBattle(game: Game, territory: string, actions: E3CaptureStep[]) {
const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === territory); assert.ok(choice);
game = actE3Capture(game, choice.chooser, { type: 'chooseBattle', territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, actions);
return advanceE3Capture(game, prepared, actions);
}
/** Fresh bare Advanced all14 preview initializer, original selected E3 deck,
* genuine setup/training, a paid Harkonnen invasion, one real board move and
* paid Guild shipment earn both first-turn battles. No played state is assigned.
*/
export function createE3HarkonnenCaptureFixture(native: E3CaptureNative) {
let game = createGame('E3HARKONNENCAPTURE', newPlayer('guild', 'Guild', 'guild'), true, ['ecaz']);
joinGame(game, newPlayer('harkonnen', 'Harkonnen', 'harkonnen'));
joinGame(game, newPlayer(native, native, native));
const initial = structuredClone(game), actions: E3CaptureStep[] = [];
for (const player of game.players) game = actE3Capture(game, player.id, { type: 'ready' }, actions);
const lobby = structuredClone(game);
const training: Record<string, LeaderSkillId> = { guild: 'killer-medic', harkonnen: 'prana-bindu-adept', [native]: 'warmaster' };
const cards = baseDeck(), prefix = [cards.find(card => card.kind === 'poison')!,
cards.find(card => card.kind === 'worthless')!, cards.find(card => card.kind === 'shield')!,
cards.find(card => card.effect === 'karama')!];
const deckOrder = [...prefix.map(card => card.id), ...cards.filter(card => !prefix.some(first => first.id === card.id)).map(card => card.id)];
const rolls = [...skillOfferRolls(game, training), ...permutation(cards.map(card => card.id), deckOrder)];
game = entropy(rolls, () => initializeLeaderSkillsGameForAudit(game));
const offered = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 150; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const skill = training[actor]; assert.ok(own.offer?.cards.includes(skill));
const leaders = e3CapturePlayer(game, actor).leaders.filter(leader => own.eligibleLeaders.some(row => row.id === leader.id));
const leader = actor === native ? leaders.find(leader => leader.id === `${native}-0`)
: leaders.sort((a, b) => actor === 'harkonnen' ? a.strength - b.strength : b.strength - a.strength)[0];
assert.ok(leader);
game = actE3Capture(game, actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id }, actions);
} else {
const next = nextE3CaptureStep(game); assert.ok(next);
game = actE3Capture(game, next.actor, next.action, actions);
}
}
assert.equal(game.status, 'playing'); assert.equal(game.advanced, true);
assert.ok(!game.homeworlds && !game.nexusCards && !game.ecazTreachery && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.semutaPreview);
const afterSetup = structuredClone(game), setupActions = structuredClone(actions), original = e3CaptureInventory(game);
assert.deepEqual(e3CapturePlayer(game, native).forces, { 'imperial_basin:9': 6 });
assert.deepEqual(e3CapturePlayer(game, 'harkonnen').hand.map(card => card.id), [prefix[1].id, prefix[2].id]);
assert.deepEqual(e3CapturePlayer(game, 'guild').hand.map(card => card.id), [prefix[0].id]);
assert.deepEqual(e3CapturePlayer(game, native).hand.map(card => card.id), [prefix[3].id]);
const assignment = game.leaderSkills!.assignments.find(row => row.owner === native)!;
const captive = assignment.leader;
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card); assert.ok(from >= position);
const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
const reach = (until: (state: Game) => boolean) => { game = advanceE3Capture(game, until, actions); };
reach(state => state.phase === 5 && state.active === 'harkonnen' && e3CaptureClean(state));
assert.equal(game.storm, 2, 'Actual first Storm1+0 reaches the first Guild circle');
assert.equal(game.order[0], 'harkonnen');
assert.ok(actions.some(step => step.actor === 'guild' && step.action.type === 'decision' && step.action.take === false));
const shipments: { actor: string; before: Game; action: Action; after: Game; cost: number }[] = [];
const ship = (actor: string, territory: string, sector: number) => {
const before = structuredClone(game), action: Action = { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 };
game = actE3Capture(game, actor, action, actions); reach(e3CaptureClean);
shipments.push({ actor, before, action, after: structuredClone(game),
cost: e3CapturePlayer(before, actor).spice - e3CapturePlayer(game, actor).spice });
};
ship('harkonnen', 'imperial_basin', 9);
assert.equal(shipments[0].cost, 2);
const movement: E3CaptureStep = { actor: 'harkonnen', action: {
type: 'move', from: 'carthag:11', territory: 'tsimpo', sector: 11, amount: 1, elite: 0 } };
game = actE3Capture(game, movement.actor, movement.action, actions); reach(e3CaptureClean);
reach(state => state.phase === 5 && state.active === 'guild' && e3CaptureClean(state));
ship('guild', 'tsimpo', 11); assert.equal(shipments[1].cost, 1);
reach(state => state.phase === 6 && !state.battle && e3CaptureClean(state));
const beforeFirstBattle = structuredClone(game), firstStart = actions.length;
assert.equal(game.active, 'harkonnen');
game = openBattle(game, 'imperial_basin', actions);
const loser = e3CapturePlayer(game, native).leaders.filter(leader => leader.id !== captive && !leader.dead)
.sort((a, b) => a.strength - b.strength)[0];
const winner = e3CapturePlayer(game, 'harkonnen').leaders.filter(leader => !leader.dead &&
!game.leaderSkills!.assignments.some(row => row.leader === leader.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(loser && winner && winner.strength > loser.strength);
const firstPlans: E3CaptureStep[] = [
{ actor: 'harkonnen', action: { type: 'battlePlan', dial: 0, support: 0, leader: winner.id } },
{ actor: native, action: { type: 'battlePlan', dial: 0, support: 0, leader: loser.id } },
];
for (const step of firstPlans) game = actE3Capture(game, step.actor, step.action, actions);
reach(state => state.decision?.kind === 'captureOffer');
assert.equal(game.decision?.player, 'harkonnen');
assert.ok(captureCandidates(game, native, 'imperial_basin').some(leader => leader.id === captive));
assert.deepEqual(e3CaptureInventory(game), original);
const firstBattleActions = structuredClone(actions.slice(firstStart));
return { native, initial, lobby, offered, afterSetup, beforeFirstBattle, game,
assignment: structuredClone(assignment), captive, winner: winner.id, loser: loser.id,
poison: prefix[0].id, worthless: prefix[1].id, shield: prefix[2].id, karama: prefix[3].id,
original, shipments, movement, spiceOrder, firstPlans, actions, setupActions, firstBattleActions,
sourceCounts: { setup: setupActions.length, firstBattle: firstBattleActions.length, source: actions.length },
sourceDisclosure: 'Fresh bare Advanced all14 initializeLeaderSkillsGameForAudit preview, not public-start acceptance. Original skill and starting-card shuffle entropy selected; native training/deals/Traitors/force choices retained. Two unopened land Spice positions selected. Actual first Storm dials1+0 reach the Guild circle, putting Harkonnen first in printed Storm/Battle order; actual Guild Wait preserves its later shipment. Actual Harkonnen ship1 costs2, original Carthag move1 and Guild ship1 costs1 earn two first-turn battles. Actual capture selection uses only a permutation of the emitted native candidate pool. No played hand, army, purse, phase, leader death, capture or receipt is installed.' };
}
export type E3HarkonnenCaptureFixture = ReturnType<typeof createE3HarkonnenCaptureFixture>;
export function acceptE3HarkonnenCapture(f: E3HarkonnenCaptureFixture, mode: 'keep' | 'execute' = 'keep',
state = f.game, actions: E3CaptureStep[] = []): Game {
let game = actE3Capture(e3CaptureReload(state), 'harkonnen', { type: 'decision', accept: true }, actions, f.captive);
game = advanceE3Capture(game, next => next.decision?.kind === 'capturedLeader', actions, f.captive);
assert.equal(game.decision?.kind === 'capturedLeader' && game.decision.leader, f.captive);
game = actE3Capture(game, 'harkonnen', { type: 'decision', mode }, actions);
return advanceE3Capture(game, next => !next.battle && e3CaptureClean(next), actions);
}
export function declineE3HarkonnenCapture(f: E3HarkonnenCaptureFixture, actions: E3CaptureStep[] = []): Game {
const game = actE3Capture(e3CaptureReload(f.game), 'harkonnen', { type: 'decision', accept: false }, actions);
return advanceE3Capture(game, next => !next.battle && e3CaptureClean(next), actions);
}
export function cancelE3HarkonnenCapture(f: E3HarkonnenCaptureFixture, actions: E3CaptureStep[] = []): Game {
let game = actE3Capture(e3CaptureReload(f.game), 'harkonnen', { type: 'decision', accept: true }, actions);
assert.equal(game.response?.kind, 'capture');
game = actE3Capture(game, f.native, { type: 'card', card: f.karama, mode: 'cancel' }, actions);
return advanceE3Capture(game, next => !next.battle && e3CaptureClean(next), actions);
}
export function openE3HarkonnenCaptiveBattle(f: E3HarkonnenCaptureFixture, state: Game,
actions: E3CaptureStep[] = []): Game {
assert.equal(e3CapturePlayer(state, f.native).leaders.find(leader => leader.id === f.captive)?.capturedBy, 'harkonnen');
return openBattle(e3CaptureReload(state), 'tsimpo', actions);
}
export function continueE3HarkonnenCaptiveBattle(f: E3HarkonnenCaptureFixture, state: Game,
outcome: 'alive' | 'death' = 'alive', actions: E3CaptureStep[] = []) {
const beforeBattle = e3CaptureReload(state), preparedGame = openE3HarkonnenCaptiveBattle(f, state, actions);
const opponent = e3CapturePlayer(preparedGame, 'guild').leaders.filter(leader => !leader.dead &&
!preparedGame.leaderSkills!.assignments.some(row => row.leader === leader.id)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(opponent);
const plans: E3CaptureStep[] = [
{ actor: 'harkonnen', action: { type: 'battlePlan', dial: 0, support: 0, leader: f.captive,
weapon: f.worthless, defense: null } },
{ actor: 'guild', action: { type: 'battlePlan', dial: 0, support: 0, leader: opponent.id,
weapon: outcome === 'death' ? f.poison : null, defense: null } },
];
let game = preparedGame;
for (const step of plans) game = actE3Capture(game, step.actor, step.action, actions);
const revealed = structuredClone(game); assert.equal(game.battle?.revealed, true);
game = advanceE3Capture(game, next => !next.battle && e3CaptureClean(next), actions);
assert.deepEqual(e3CaptureInventory(game), f.original);
return { game, beforeBattle, prepared: preparedGame, plans, revealed, actions };
}
+3 -2
View File
@@ -195,7 +195,7 @@ export function createSelectedDeckSkillsProgramme(options: SelectedDeckSkillsOpt
}
/** Original poison weapons kill both committed discs for real, then the same
* bare table continues into the actual Revival phase of the next turn. */
* bare table continues into its actual native Revival opportunity next turn. */
export function createSelectedDeckSkillsRevivalCase(options: { advanced?: boolean } = {}) {
const programme = createSelectedDeckSkillsProgramme({ advanced: options.advanced,
factions: ['tleilaxu', 'guild', 'ixians'], skill: 'mentat', poison: 'both' });
@@ -212,7 +212,8 @@ export function createSelectedDeckSkillsRevivalCase(options: { advanced?: boolea
assert.equal(trainer.dead, true, 'The defender’s conserved poison kills the committed Tleilaxu disc');
assert.ok(guild.leaders.filter(disc => !disc.dead).length > 0);
game = advanceSelectedDeckSkills(game,
g => g.turn === 2 && g.phase === 4 && g.status === 'playing' && selectedDeckSkillsClean(g), actions);
g => g.turn === 2 && g.phase === 4 && g.status === 'playing' && selectedDeckSkillsClean(g) &&
(!g.revivalOrder || g.revivalOrder.remaining[0] === tleilaxu), actions);
return { ...programme, tleilaxu, actions, game, dead: dead.id, trainer: trainer.id,
advanced: programme.afterSetup.advanced };
}