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

+40 -2
View File
@@ -16,10 +16,48 @@ open mode gates.
## Current checkpoint and work
**Next: remaining Basic card/action coverage and native timing.**
Continue remaining Basic functions before Advanced and wider interactions.
**Next: remaining rule functions and native timing.**
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Native E3 trained capture — 27 October. Prototyped / Partial.**
Fresh bare Advanced E3/all14 setups now connect Harkonnen with actual Ecaz
and/or Moritani and classics. The known trainer/card follows native capture;
only its selected lower effect applies, a survivor returns the same assignment,
and death/execution returns one physical Skill. Canonical Banker/native
Moritani state remains. Other optional-module entry gates stay unchanged.
Frozen **15 affected files, 110/110**, types/lint/build and six fresh native
programmes pass: four later captive battles and two executions, using Ecaz and
Moritani sources. Actual preview initialization, original skill/card deals,
training, first Storm1+0, Guild Wait, paid shipments2/1 and original Carthag
movement earn the first battle/candidate lottery. No played hand, army, purse,
clock, capture or continuation is installed. No public-start claim is made.
Runtime includes 36 GameTable SSR renders, 6 actual Capture callbacks,
4 Retain callbacks and 2 Execute callbacks. 8 later Battle Plan packets
use native actions with leader-selector SSR; stateful select/seal callbacks
were not operated. Recorded arrays supply aggregate source/capture/later-battle
counts 336/30/34, not unique cross-programme actions.
Twelve new native cases cover both trainers, exact Warmaster lower3 without
its normal1, same-assignment survival, original poison death, execution2,
actual dealt Karama cancellation/decline and four legal policies. Five profile
cases cover fresh/live native stamps, two–six distinct seats, the paired native
roster and retained overlay gates; these are not full-game proofs for every
roster. Early feedback fixes the new source's first-Storm/printed Battle order
through real dials. A related older foreign-ghola source now finishes preceding
Advanced Revival turns through native controls before its own request.
The existing local server returned HTTP200 without restart. No HTTP/storage
boundary, saved-game/database or deployed operation changes. Local native/SSR/
callback evidence does not certify browser hydration, full modes, broader
combinations or final rules/recovery/privacy/deployed acceptance.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-e3-harkonnen-capture-proof-v2/`;
journeys/HTML: adjacent `20261027-e3-harkonnen-capture-runtime/run/`.
[Contract](LEADER_SKILLS_CAPTURE.md#native-e3-advanced-capture-entry--27-october-2026).
**Banker native placement/disposal — 27 October. Prototyped / Partial.**
Accepted Ecaz Ambassador fees and high-Kaitain atomic disposal invoices now
supply ordinary Banker income. Declaration/cancellation and self-payments earn
+39
View File
@@ -1,5 +1,44 @@
# Dune implementation status
## 27 October 2026 — Trained native E3 capture in Advanced
Prototyped / Partial. Fresh bare Advanced E3/all14 setups now connect Harkonnen with actual Ecaz
and/or Moritani and classics. The known trainer/card follows native capture;
only its selected lower effect applies, a survivor returns the same assignment,
and death/execution returns one physical Skill. Canonical Banker/native
Moritani state remains. Other optional-module entry gates stay unchanged.
Frozen **15 affected files, 110/110**, types/lint/build and six fresh native
programmes pass: four later captive battles and two executions, using Ecaz and
Moritani sources. Actual preview initialization, original skill/card deals,
training, first Storm1+0, Guild Wait, paid shipments2/1 and original Carthag
movement earn the first battle/candidate lottery. No played hand, army, purse,
clock, capture or continuation is installed. No public-start claim is made.
Runtime includes 36 GameTable SSR renders, 6 actual Capture callbacks,
4 Retain callbacks and 2 Execute callbacks. 8 later Battle Plan packets
use native actions with leader-selector SSR; stateful select/seal callbacks
were not operated. Recorded arrays supply aggregate source/capture/later-battle
counts 336/30/34, not unique cross-programme actions.
Twelve new native cases cover both trainers, exact Warmaster lower3 without
its normal1, same-assignment survival, original poison death, execution2,
actual dealt Karama cancellation/decline and four legal policies. Five profile
cases cover fresh/live native stamps, two–six distinct seats, the paired native
roster and retained overlay gates; these are not full-game proofs for every
roster. Early feedback fixes the new source's first-Storm/printed Battle order
through real dials. A related older foreign-ghola source now finishes preceding
Advanced Revival turns through native controls before its own request.
The existing local server returned HTTP200 without restart. No HTTP/storage
boundary, saved-game/database or deployed operation changes. Local native/SSR/
callback evidence does not certify browser hydration, full modes, broader
combinations or final rules/recovery/privacy/deployed acceptance.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-e3-harkonnen-capture-proof-v2/`;
journeys/HTML: adjacent `20261027-e3-harkonnen-capture-runtime/run/`.
[Contract](LEADER_SKILLS_CAPTURE.md#native-e3-advanced-capture-entry--27-october-2026).
## 27 October 2026 — Banker placement and disposal Bank inputs
Prototyped / Partial. Accepted Ecaz Ambassador fees and high-Kaitain atomic disposal invoices now
+72 -1
View File
@@ -1,9 +1,80 @@
# Leader Skills and Harkonnen capture
Updated 13 September 2026. This note resolves the public-state boundary for a
Original source interpretation, 13 September 2026. This note resolves the public-state boundary for a
skilled leader captured by Harkonnen. It records a source interpretation for
implementation; it does not claim the Leader Skills module is complete.
## Native E3 Advanced capture entry — 27 October 2026
**Prototyped / Partial.** Fresh bare Leader Skills setups may now include
Advanced Harkonnen with Ecaz and/or Moritani and classic factions. Select the
E3 deck alone, two through six distinct registered seats and all14 Skills.
No new capture permission, optional-module flag or rule interpretation is
introduced. The existing photographed card/expansion capture procedure and
13 October assignment ruling below apply equally to these native leaders.
This first entry contains no Homeworlds, Nexus, Discoveries, independent cards,
Tech, Strongholds or Semuta. Their separate module gates remain unchanged.
The ordinary mandatory Banker ledger and actual native Moritani assassination
state are parts of the original Skills/native setup, not additional modules.
Selecting an unused E3 deck never invents a native faction or power.
A successful native capture still opens its ordinary cancellation before the
actual random disc. The known trainer/card then accompanies that one captive
publicly; other captured leaders retain their existing procedure. Keep/execute
remains a real Harkonnen choice. An unavailable normal/top skill is not lent to
the captor or retained by the captive's original faction. Use the actual captive
in one later native battle to receive only its lower effect. A survivor returns
with the same original assignment; a killed or executed trainer returns its
physical skill once through existing death handling. No replacement assignment
is fabricated while it remains alive in captivity.
Native Moritani's existing post-loss assassination opportunity and winner
cleanup retain their actual order before original Harkonnen capture. The Ecaz
Duke remains separate and cannot train. Admission does not authorize hidden
target substitution or grant a power from a selected but unseated faction.
Focused paths: [bare profile](../game/leader-skill-profile.ts),
[profile cases](../tests/e3-harkonnen-skills-profile.test.ts),
[fresh source](../tests/fixture-e3-harkonnen-capture.ts),
[native cases](../tests/e3-harkonnen-capture-runtime.test.ts).
Fresh bare Advanced E3/all14 setups now connect Harkonnen with actual Ecaz
and/or Moritani and classics. The known trainer/card follows native capture;
only its selected lower effect applies, a survivor returns the same assignment,
and death/execution returns one physical Skill. Canonical Banker/native
Moritani state remains. Other optional-module entry gates stay unchanged.
Frozen **15 affected files, 110/110**, types/lint/build and six fresh native
programmes pass: four later captive battles and two executions, using Ecaz and
Moritani sources. Actual preview initialization, original skill/card deals,
training, first Storm1+0, Guild Wait, paid shipments2/1 and original Carthag
movement earn the first battle/candidate lottery. No played hand, army, purse,
clock, capture or continuation is installed. No public-start claim is made.
Runtime includes 36 GameTable SSR renders, 6 actual Capture callbacks,
4 Retain callbacks and 2 Execute callbacks. 8 later Battle Plan packets
use native actions with leader-selector SSR; stateful select/seal callbacks
were not operated. Recorded arrays supply aggregate source/capture/later-battle
counts 336/30/34, not unique cross-programme actions.
Twelve new native cases cover both trainers, exact Warmaster lower3 without
its normal1, same-assignment survival, original poison death, execution2,
actual dealt Karama cancellation/decline and four legal policies. Five profile
cases cover fresh/live native stamps, two–six distinct seats, the paired native
roster and retained overlay gates; these are not full-game proofs for every
roster. Early feedback fixes the new source's first-Storm/printed Battle order
through real dials. A related older foreign-ghola source now finishes preceding
Advanced Revival turns through native controls before its own request.
The existing local server returned HTTP200 without restart. No HTTP/storage
boundary, saved-game/database or deployed operation changes. Local native/SSR/
callback evidence does not certify browser hydration, full modes, broader
combinations or final rules/recovery/privacy/deployed acceptance.
Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-e3-harkonnen-capture-proof-v2/`;
journeys/HTML: adjacent `20261027-e3-harkonnen-capture-runtime/run/`.
## Strongest source-supported answer
A Harkonnen capture is not universally secret in the published base rules.
+12
View File
@@ -1,5 +1,17 @@
# Rule decision index
## Trained native E3 leaders and Advanced capture — 27 October 2026
Fresh bare all14/E3 setups now admit Advanced Harkonnen with actual Ecaz and/or
Moritani and classics. This removes a software entry boundary for the existing
printed Skill-travels/lower-band-only rule and the 13 October assignment ruling;
it is not a new capture interpretation. Real cancellation/random capture,
Keep/Execute, later selected captive use and native survivor/death handling
retain their original controls and pieces. Mandatory Banker and native Moritani
state remain. All unrelated optional-module gates and complete-mode acceptance
stay separate. No played capture is reconstructed or save converted.
[Entry, source and limits](LEADER_SKILLS_CAPTURE.md#native-e3-advanced-capture-entry--27-october-2026).
## Banker on native placement and disposal — 27 October 2026
Apply the printed normal Bank-four threshold to accepted Ecaz Ambassador fees
+11 -1
View File
@@ -383,6 +383,16 @@ export function nativeEcazLeaderSkillsProfile(game: LeaderSkillProfile): boolean
return native;
}
/** Only the bare E3 capture composition gains admission; callers check its native roster. */
function bareAdvancedE3CaptureEnvelope(game: LeaderSkillProfile): boolean {
return game.advanced === true && game.expansions.length === 1 && game.expansions[0] === 'ecaz' &&
noOtherLeaderSkillModules(game) && !game.discoveryStash && !game.greatMaker &&
!game.semutaPreview && !game.advancedPreview && !game.kullPreview && !game.nexusKullPreview &&
!game.guildBetrayalPreview && !game.richeseBetrayalPreview &&
!game.nexusIxianReplacementPreview && !game.nexusIxianBetrayalPreview &&
!game.nexusHarkonnenBetrayalPreview;
}
/** Every registered seat uses its selected ordinary family; no faction or Nexus prerequisite. */
export function selectedDeckLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
@@ -416,7 +426,7 @@ export function selectedDeckLeaderSkillsProfile(game: LeaderSkillProfile): boole
if (player.faction === 'harkonnen') harkonnen = true;
}
// E3's existing Nexus/Discovery/preview envelopes retain their original predicates.
if (nativeE3 && ((game.advanced && harkonnen) || game.nexusCards ||
if (nativeE3 && ((game.advanced && harkonnen && !bareAdvancedE3CaptureEnvelope(game)) || game.nexusCards ||
game.discoveries || game.discoveryEnabled || game.discoveryStash ||
game.greatMaker || game.semutaPreview)) return false;
return !game.moritaniAssassinatePreview || (game.advanced && moritani);
+3 -1
View File
@@ -4551,6 +4551,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'When all native Harkonnen leaders are dead, return every unused captive immediately. A new captive retained in that situation also returns immediately.',
'Only Harkonnen and the original owner know a concealed living captive’s identity. Tleilaxu can inspect a concealed executed leader. Using a captive in a revealed battle makes its identity public.',
'In original standalone Ecaz/classic Nexus without Skills, the separate Duke can enter that capture pool without a sixth native leader or Traitor card. Execute pays the same two spice and returns the dead disc to Ecaz Tanks. The provisional return policy restores only unused valid prior tenure; otherwise Duke is set aside. Actual Ecaz Nexus liberation removes captivity permanently, ending after one battle or the current turn rather than restoring Harkonnen.',
'Fresh bare Advanced E3/all14 Skills admits Harkonnen with Ecaz and/or Moritani and classics, two through six seats. A captured trained leader remains identified by its known attached Skill. Only its lower battle effect is available to the captor when actually used; the original assignment returns with a survivor, or its physical card returns once when the trainer dies. Other optional-module envelopes retain their separate gates.',
'The capture engine and private views have focused tests. The six classic factions can start Advanced preview; remaining Advanced rules and expansion interactions are unfinished.',
],
related: [
@@ -6286,12 +6287,13 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and provisional modifier-inclusive battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. All twenty-eight independent bands have connected first versions; full interaction review, polish and combined-module acceptance remain unfinished.',
steps: [
'Fresh bare Advanced E3/all14 Skills may include Harkonnen with Ecaz and/or Moritani and classics, two through six distinct seats. The actual native trainer/card follows a random capture; its normal band is unavailable during captivity and only its selected lower battle effect applies. Surviving use returns the same assignment, while death returns the physical Skill. Mandatory Banker and native Moritani state remain; World/Nexus/Discovery/independent/Tech/Stronghold/Semuta gates are separate.',
'Sandmaster may collect along physical Ornithopter and carried Discovery routes using their actual range, exact declared path and current piles. Each different card movement has its own one-per-entered-territory opportunity; a concealed No-Field counts as one moving force without materializing its value. Choose one pile or decline per entered territory. Original source cohorts and disposal remain; allocation/reset are provisional.',
'Planetologist may provisionally compose either movement alternative with physical Ornithopter or carried Discovery. Fixed flight stays three, including both gather origins. In two-group card mode, each action chooses boosted normal range or ordinary-range gathering; a gather counts as one card movement. Different original counters must supply the second movement, even after merging. Current trainer, source events, earlier-turn token ownership and one disposal remain.',
'Fresh all14 uses one selected-deck capability for every registered actual seat, Basic/Advanced, including mixed native Ecaz/Moritani with selected E1/E2 families. Neither an expansion faction, independent cards, Nexus nor one exact E3 family is required just to use selected ordinary decks. An unused deck creates no seated faction. Original World/phase envelopes retain components and native power bindings; original Nexus/Discovery/Semuta and Advanced role limits stay separate. Ix drafts before training; Auditor/shared Duke never train. No Advanced power, income, owner or played-save history is invented.',
'Classic Nexus core with base33 or selected36 permits optional all14, original Tech3+ and Advanced Strongholds2+. Offers, starting-card order, mandatory normal Banker and native reactions remain. Actual third-party Bureaucrat sees the real Guild-card recipient. Tech pays only at its matching phase end; Banker waits for Mentat. Earned local support does not become personal money or reserve fees. Selection/printed roles are public capabilities; other native/module envelopes stay separate.',
'Explicit guild-betrayal --leader-skills remains a bounded classic preset of the original all14 setup. It does not require independent cards or another overlay. Native Mentat private questions, skilled-disc posture, Sandmaster collection and normal income retain their shared quotes; exact payer/recipient and printed Bureaucrat third-party conditions remain. Native cards, paid forces, Use/Pass and movement/plan continuation stay physical. Wider combinations and complete-mode acceptance remain separate.',
'Basic Moritani keeps legal Terror but gains no Advanced assassination, including registered mixed native Skills seats. Ecaz/Moritani with selected E1/E2 or unused decks keeps original Worlds/Tech3+/Advanced Strongholds2+ and optional independent cards without requiring Nexus. Advanced E3/Harkonnen remains guarded. Winner Suk and Rihani precede a different unused trained-disc death, one skill return, printed bounty and one original Mentat replacement. Native card/force sources, normal-call forfeiture and shared-Duke exclusion remain; unrelated overlays retain their original envelopes.',
'Basic Moritani keeps legal Terror but gains no Advanced assassination, including registered mixed native Skills seats. Ecaz/Moritani with selected E1/E2 or unused decks keeps original Worlds/Tech3+/Advanced Strongholds2+ and optional independent cards without requiring Nexus. Advanced E3/Harkonnen remains guarded in those module envelopes; the fresh bare E3/all14 capture entry is separate. Winner Suk and Rihani precede a different unused trained-disc death, one skill return, printed bounty and one original Mentat replacement. Native card/force sources, normal-call forfeiture and shared-Duke exclusion remain; unrelated overlays retain their original envelopes.',
'Basic and Advanced selected-deck Skills admits registered base, Ixian/Tleilaxu, CHOAM/Richese and Ecaz/Moritani seats only when their family is selected. Unused decks neither create a seat nor require one. All14/native setup remain. Auditor is Advanced and unskilled; foreign-ghola authorization still explicitly requires Advanced, grants no new skill and retains original Duke/custody limits. Cyborg losses, native funding and capped markers stay physical. Supplied Advanced p34 excludes Richese auctions from Ixian Technology; existing capture/KH/copy/role guards keep their own rules.',
'Independent Recruits, Reinforcements and Harass compose registered selected-family all14 Basic/Advanced, including mixed E3, original Worlds and Tech3+/Advanced Strongholds2+. Bare E3 World cards keep their original roster envelope without requiring Skills. Original36/38/50, cache, native setup/prices and selected-hand capability stay. Own-World fee/dial pools are disjoint; visitor returns retain typed homes and Suk cannot rescue fees. Earned support is not personal money or reserve funding. Original Nexus/Discovery/Semuta, shared Duke and Advanced role/effect guards remain.',
'Fresh registered all14 keeps original Tech3–6 Basic/Advanced, optional independent cards and supported Worlds, including mixed E3 without a Nexus dependency. Starting hands/cache, offers/Traitors and printed/first-Storm owners stay genuine. Native rates use actual stock/population at acceptance; normal Banker records the accepted Bank invoice, not net wallet change after separate income. No token owner, premature spendable grant or victory credit is assigned to simulate earned play. Original Nexus/Discovery/Semuta and effect-specific envelopes remain; played games are not converted.',
+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 };
}