diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6f473336..e5de027c 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index ab4653fb..14be336d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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 diff --git a/docs/LEADER_SKILLS_CAPTURE.md b/docs/LEADER_SKILLS_CAPTURE.md index dc9e08a4..62041ca3 100644 --- a/docs/LEADER_SKILLS_CAPTURE.md +++ b/docs/LEADER_SKILLS_CAPTURE.md @@ -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. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 8c243f9a..08a747ad 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index b266a2e4..75250345 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -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); diff --git a/game/reference.ts b/game/reference.ts index b36ebfad..60de9d86 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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.', diff --git a/tests/e3-harkonnen-capture-runtime.test.ts b/tests/e3-harkonnen-capture-runtime.test.ts new file mode 100644 index 00000000..aa9471bc --- /dev/null +++ b/tests/e3-harkonnen-capture-runtime.test.ts @@ -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); + } + } +}); diff --git a/tests/e3-harkonnen-skills-profile.test.ts b/tests/e3-harkonnen-skills-profile.test.ts new file mode 100644 index 00000000..49f7153c --- /dev/null +++ b/tests/e3-harkonnen-skills-profile.test.ts @@ -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 { + 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); + } +}); diff --git a/tests/fixture-e3-harkonnen-capture.ts b/tests/fixture-e3-harkonnen-capture.ts new file mode 100644 index 00000000..c1aee0df --- /dev/null +++ b/tests/fixture-e3-harkonnen-capture.ts @@ -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(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(rolls: readonly number[], run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (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 = { 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; + +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 }; +} diff --git a/tests/fixture-selected-deck-skills.ts b/tests/fixture-selected-deck-skills.ts index d5084b35..e32e065b 100644 --- a/tests/fixture-selected-deck-skills.ts +++ b/tests/fixture-selected-deck-skills.ts @@ -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 }; }