Connect trained E3 leaders to Advanced Harkonnen capture
This commit is contained in:
1 parent
c56d9c85ef
commit
e530d267a3
10 files changed
+700
-7
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user