From 7ef3377218cfa50c141c92dd37ddd2e20951b18a Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:24:11 +1100 Subject: [PATCH] Connect native Homeworld Ecaz Arrakis coalition battles --- docs/ADVANCED_PREVIEW.md | 5 +- docs/CURRENT_STATUS.md | 16 ++- docs/ECAZ_OCCUPY_RULES.md | 30 ++++++ docs/IMPLEMENTATION_STATUS.md | 25 +++++ docs/RULE_DECISIONS.md | 8 +- game/engine.ts | 14 ++- game/reference.ts | 2 +- tests/advanced-preview.test.ts | 13 --- tests/fixture-homeworld-ecaz-occupy.ts | 108 +++++++++++++++++++ tests/homeworld-ecaz-occupy-runtime.test.ts | 111 ++++++++++++++++++++ 10 files changed, 310 insertions(+), 22 deletions(-) create mode 100644 tests/fixture-homeworld-ecaz-occupy.ts create mode 100644 tests/homeworld-ecaz-occupy-runtime.test.ts diff --git a/docs/ADVANCED_PREVIEW.md b/docs/ADVANCED_PREVIEW.md index cd4c405e..a885e320 100644 --- a/docs/ADVANCED_PREVIEW.md +++ b/docs/ADVANCED_PREVIEW.md @@ -48,8 +48,9 @@ The 17 October lobby-access pass opens expansion factions and supported optional modules as previews; it does not remove their existing composition guards. Skills and the independent Ecaz cards use lobby-only selections consumed during start; the other modules retain their existing undealt lobby shape. Every -selected module must be honoured or the start rejected. Ecaz with Homeworlds -alone remains unavailable because its combined Occupy battle is not supported. +selected module must be honoured or the start rejected. Ecaz selections must +also support their mandatory allied Arrakis Occupy battle; fresh Ecaz/classic +Homeworlds now compose that path, with or without the supported Skills entry. Special development profiles remain separate. No pending rules ruling or complete-mode/publication gate is resolved by opening these controls; see the [decision index](RULE_DECISIONS.md). diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ff34574e..9257d082 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,13 +29,25 @@ optional skill bands and combinations remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Native World Ecaz Arrakis Occupy — 17 October.** Fresh Ecaz/classic +Homeworld games no longer need Nexus Cards for their ordinary Arrakis +coalition, including the supported native Skills entry. Basic setup now marks +Ecaz participation before shared movement/battle enumeration. Actual World +shipments, four Ecaz/four Imperial counters, selected lead, fixed losses, +Sardaukar/support and Karama use the existing physical rule paths. World +locations themselves do not gain Occupy or Intrusion. Seven new cases and the +affected91/91 union, types/lint and build pass; four native-action smokes reach +Collection. Actual390px Ecaz World+Skills lobby entry has no browser errors or +overflow; coalition arithmetic is engine evidence, not a browser-played battle. +[Source and limits](ECAZ_OCCUPY_RULES.md#native-world-reserves-with-arrakis-occupy--17-october-2026). + **Optional modules from the normal lobby — 17 October (user-directed speed pass).** The host can now tick Leader Skills, Ecaz Treachery Cards, Nexus Cards, Discoveries and Homeworlds as labelled previews alongside Tech Tokens and Stronghold Cards. A normal start routes to the existing genuine setup for that selection; each module's own admission still decides what combinations are -supported, and an unsupported choice is rejected at start with its reason. Ecaz -with Homeworlds alone stays unavailable (no combined Occupy composition yet). +supported, and an unsupported choice is rejected at start with its reason. +Ecaz's native World Arrakis coalition is connected in the follow-up above. Lobby choices for Skills and the independent cards live in `lobbyOptions` and are consumed at start. A 240-start sweep (Basic/Advanced × classic/Ix/CHOAM/Ecaz × ten selections, repeated three times) exercised 183 all-AI games to turn 3 or diff --git a/docs/ECAZ_OCCUPY_RULES.md b/docs/ECAZ_OCCUPY_RULES.md index c06be8cc..b9064841 100644 --- a/docs/ECAZ_OCCUPY_RULES.md +++ b/docs/ECAZ_OCCUPY_RULES.md @@ -2,6 +2,36 @@ Historical primary-source audit, 2026-09-07, corrected after independent verification. The publisher/designer odd-force conflict remains unresolved as a publisher correction. Under the 8 October Basic-first prototype instruction, Basic now provisionally uses the main p8 paragraph: ceiling contribution/loss and floor survivors, with a visible control warning. This is not a numerical ruling by the user or designer. Advanced retains its separately authorized source. The earlier false attribution to Jack Reda remains withdrawn. +## Native World reserves with Arrakis Occupy — 17 October 2026 + +Fresh Ecaz/classic Homeworld games now retain their ordinary Arrakis coalition +without needing Nexus Cards. The original Homeworld occupation entry marks +Ecaz participation in Basic as well as Advanced; the supported native +Homeworld/Leader Skills entry keeps the same lead, skill posture and +owner-labelled army path. This composes the supplied revision's physical +pages21–23 optional-module permission with the existing printed/authorized +Occupy arithmetic, not a new combat formula. + +Homeworlds change the source of shipped reserves and native benefits, not the +identity of an Arrakis army. A real worm alliance and native shipments produce +four Ecaz and four Emperor fighters (two Sardaukar) against two Guild fighters. +Both lead choices preserve fixed Ecaz losses and the allied typed commitment; +Advanced Salusa's unpaid full-strength Sardaukar still compose with paid +ordinary support. Karama keeps the selected lead and spares the excluded +co-side. Arrakis casualties never remove another copy of those forces from a +Homeworld pool. Actual World battles remain ordinary separate-army battles: +this does not add Occupy or Intrusion to Homeworld locations. + +Seven new cases cover both modes/leads, explicit typed losses, cancellation +and four legal Skills policies. The affected eight-file union91/91, types/lint +and build pass; four standalone original-action smokes reach Collection. +Real390px normal-lobby Ecaz + Homeworlds + Skills setup reaches its offered +skill controls with no app errors or horizontal overflow. That is entry/control +proof, not a browser-played coalition battle or complete combination acceptance. +Unopened Spice order is controlled in the fixture; armies, wallets, alliances, +World custody and phases follow native actions. Other E3/mixed module envelopes, +World-location coalition rules and final assurance remain separate. + ## Advanced Ecaz special Karama first version — 8 October 2026 The authorized supplied revision, physical **p30, Ecaz G**, permits Karama diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index deed4be9..b20eb647 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,30 @@ # Dune implementation status +## 17 October 2026 — Native World Ecaz Arrakis coalition + +The existing native World occupation and World/Skills setups could deal Ecaz +but still rejected the mandatory allied Arrakis battle. Fresh Ecaz/classic +World compositions now reuse the ordinary coalition instead of requiring Nexus +Cards; the original World occupation entry also marks Basic participation. +World locations remain outside Occupy/Intrusion. No army merger, new support +rule, World loss adapter, played-game conversion or numerical ruling is added. + +Genuine setup, original worm alliance and native paid shipments put Ecaz4, +Emperor4 (two Sardaukar) and Guild2 into a clear Arrakis stronghold. Both leads +retain fixed Ecaz losses, exact allied normal/starred losses and unchanged +native World custody. Advanced Salusa and paid ordinary support compose through +the original physical quote; native Karama keeps the lead and excludes its +co-side. The existing Skills disclosure/lead controls and four legal policies +reach original Collection. Only unopened Spice order is controlled. + +The affected eight-file union **91/91**, types/lint and build pass; four separate +runtime smokes reach Collection without rejected actions. Actual390px Ecaz +World+Skills normal-lobby start reaches the real skill offers with no app errors +or overflow. Coalition arithmetic is engine proof, not a browser-played battle. +The broader9866-case pass belongs to preceding lobby checkpoint `210aa04`, not +this follow-up. Other roster/module compositions and final assurance remain +open. [Source and detailed boundaries](ECAZ_OCCUPY_RULES.md#native-world-reserves-with-arrakis-occupy--17-october-2026). + ## 17 October 2026 — Normal-lobby module previews and legal continuation Hosts can select Leader Skills, the independent three-card Ecaz variant, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index d269de25..1209bc50 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,6 +1,6 @@ # Rule decision index -Updated 15 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority. +Updated 17 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority. ## Physical Traitor roles — 15 October 2026 @@ -616,13 +616,15 @@ Discovery, Tech and Strongholds together/separately irrespective of faction selection. The bounded fresh native composition therefore reuses supported E1/E2 and standalone Ecaz OR Moritani/classic setup predicates, not a new rule model. Original skill-before-faction ordering applies to typed Suk and -residual Cyborg substitution; Homeworlds remain outside Arrakis Occupy and -Intrusion. Existing CHOAM low-opening-income, Basic odd Ecaz and exhausted +residual Cyborg substitution. The 17 October follow-up connects original +Homeworld reserves to Ecaz's Arrakis Occupy; World locations themselves do not +gain Occupy or Intrusion. Existing CHOAM low-opening-income, Basic odd Ecaz and exhausted Richese-cache guards remain visible in original game captures. The external Imperial Suk split is still an implementation interpretation, not ordinary revival placement or a new user/publisher ruling. Native Face Dance, Diplomat retreat and other pending interpretations remain separate. [Exact scope and evidence](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-optional-module-compositions--6-october-2026). +[Native World Arrakis coalition follow-up](ECAZ_OCCUPY_RULES.md#native-world-reserves-with-arrakis-occupy--17-october-2026). ## Advanced Homeworld occupation source cutover — 5 October 2026 diff --git a/game/engine.ts b/game/engine.ts index b3680ca5..797107fa 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9588,7 +9588,10 @@ export function initializeHomeworldOccupationGameForAudit(state: Game, leaderSki g.homeworldTupileState = createOccupiedTupileState(); g.pendingHomeworldTupileCleanup = null; } - if (byFaction(g, 'ecaz')) g.homeworldEcazVictoryPreview = true; + if (byFaction(g, 'ecaz')) { + g.homeworldEcazVictoryPreview = true; + g.ecazOccupyPreview = true; + } return g; } /** Shared classic/supported-native Nexus setup; existing composition guards apply. */ @@ -10523,6 +10526,15 @@ function ecazOccupyCompositionSupported(g: Game): boolean { g.players.some(p => p.faction === 'ecaz')) return true; if (g.discoveries && nativeDiscoveryLeaderSkillsProfile(g) && g.players.some(p => p.faction === 'ecaz')) return true; + // Native World reserve/threshold rules do not replace the ordinary Arrakis + // coalition. Reuse that battle only within the original Ecaz/classic setups. + if (g.homeworlds && g.expansions.length === 1 && g.expansions[0] === 'ecaz' && + g.players.some(p => p.faction === 'ecaz') && + g.players.every(p => p.faction === 'ecaz' || CLASSIC_FACTIONS[p.faction]) && + ((g.homeworldOccupationPreview === true && !g.leaderSkills && + !g.nexusCards && !g.techTokens && !g.strongholdCards && !g.discoveryEnabled) || + (g.ecazOccupyPreview === true && !!g.leaderSkills && homeworldLeaderSkillsProfile(g)))) + return true; return typeof g.advanced === 'boolean' && Array.isArray(g.expansions) && g.expansions.includes('ecaz') && g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) && diff --git a/game/reference.ts b/game/reference.ts index 838433fe..20d99344 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2176,7 +2176,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Base setup enforces prediction, traitors, forces and automatic dealing in order, with private choices and preserved saved tables. Advanced starting advisors and elite forces share the same setup logic. The normal lobby now starts selected expansions and the Leader Skills, Ecaz Treachery Card, Nexus, Discovery, Homeworld, Tech and Stronghold modules through their existing genuine setups as labelled previews; each module still rejects unsupported combinations at start, and Ecaz with Homeworlds alone remains unavailable.', + 'Base setup enforces prediction, traitors, forces and automatic dealing in order, with private choices and preserved saved tables. Advanced starting advisors and elite forces share the same setup logic. The normal lobby now starts selected expansions and the Leader Skills, Ecaz Treachery Card, Nexus, Discovery, Homeworld, Tech and Stronghold modules through their existing genuine setups as labelled previews; unsupported combinations still reject at start. Fresh native Ecaz/classic World games retain ordinary Arrakis Occupy, with or without supported Skills.', }, { area: 'Player controls', diff --git a/tests/advanced-preview.test.ts b/tests/advanced-preview.test.ts index 5193d590..950beb0f 100644 --- a/tests/advanced-preview.test.ts +++ b/tests/advanced-preview.test.ts @@ -132,19 +132,6 @@ void test('incompatible selected modules reject the whole start instead of silen } }); -void test('an Ecaz Homeworld Skills selection cannot start without supported combined Occupy battles', () => { - let game = createGame('ECAZLOBBYMODULES', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']); - joinGame(game, newPlayer('atreides', 'Atreides', 'atreides')); - joinGame(game, newPlayer('guild', 'Guild', 'guild')); - game = act(game, game.host, { type: 'homeworlds', enabled: true }); - game = act(game, game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true }); - game = ready(game); - reject(game, game.host, preview); - assert.equal(game.status, 'lobby'); - assert.equal(game.homeworlds!.custody, null); - assert.equal(game.leaderSkills, undefined); -}); - void test('Advanced starts require the exact host opt-in and cannot dispatch audit initializers or hidden bypass options', () => { const game = ready(lobby(BASE, true)); reject(game, game.host, { type: 'start' }); diff --git a/tests/fixture-homeworld-ecaz-occupy.ts b/tests/fixture-homeworld-ecaz-occupy.ts new file mode 100644 index 00000000..86bb105b --- /dev/null +++ b/tests/fixture-homeworld-ecaz-occupy.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { territory } from '../game/board'; +import { finishEcazOccupySetup, type EcazOccupyFixture } from './fixture-ecaz-occupy'; + +export type HomeworldEcazStep = { actor: string; action: Action }; +export type HomeworldEcazOccupyFixture = EcazOccupyFixture & { actions: HomeworldEcazStep[] }; +export const homeworldEcazClean = (game: Game) => + !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; + +/** Original choices only: leave optional Ambassadors unplaced and pass auctions. */ +export function nextHomeworldEcazStep(game: Game): HomeworldEcazStep | null { + if (game.response) return { actor: game.players.find(p => !game.response!.passed.includes(p.id))!.id, + action: { type: 'passResponse' } }; + if (game.decision?.kind === 'ecazPlacement') + return { actor: game.decision.player, action: { type: 'decision', decline: true } }; + if (!game.phaseOpening && !game.decision && game.phase === 0 && game.stormPending === null) { + const actor = game.stormDialers.find(id => game.stormDials[id] === undefined); + if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } }; + } + if (!game.phaseOpening && !game.decision && game.phase === 3 && game.auction) + return { actor: game.auction.active, action: { type: 'passBid' } }; + for (const player of game.players) { + const view = viewGame(game, player.id); + view.players.find(p => p.id === player.id)!.bot = 'Easy'; + const actions = botActions(view); + const action = actions.find(a => a.type === 'ready') ?? actions[0]; + if (action) return { actor: player.id, action }; + } + return null; +} + +/** Fresh native World setup, a real worm alliance and paid Arrakis shipments. + * Only the unopened Spice order is staged; no armies, wallets, phases, alliances + * or World custody are assigned. The selected skill offers remain the real deal. */ +export function createHomeworldEcazOccupyFixture(options: { advanced?: boolean; skills?: boolean } = {}): HomeworldEcazOccupyFixture { + const advanced = options.advanced ?? true; + let game = createGame('WORLDECAZOCCUPY', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']); + joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); + joinGame(game, newPlayer('guild', 'Guild', 'guild')); + const actions: HomeworldEcazStep[] = []; + const act = (actor: string, action: Action) => { + actions.push({ actor, action: structuredClone(action) }); + game = applyAction(game, actor, action); + }; + act('ecaz', { type: 'homeworlds', enabled: true }); + if (options.skills) act('ecaz', { type: 'moduleOption', module: 'leaderSkills', enabled: true }); + for (const player of game.players) act(player.id, { type: 'ready' }); + act('ecaz', advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const initial = structuredClone(game); + game = finishEcazOccupySetup(game); + const afterSetup = structuredClone(game); + const step = () => { + const next = nextHomeworldEcazStep(game); + assert.ok(next, `A native World action must exist at ${game.turn}/${game.phase}/${game.decision?.kind}`); + act(next.actor, next.action); + }; + const reach = (until: (state: Game) => boolean) => { + for (let n = 0; n < 800 && !until(game); n++) step(); + assert.ok(until(game), 'The original World programme must reach its next native boundary.'); + }; + const orderBlow = (worm: boolean, position: number) => { + const at = game.spiceDeck.findIndex((card, i) => i >= position && + (worm ? 'worm' in card && !card.greatMaker && !card.suppressed : + 'territory' in card && !['arrakeen', 'sietch_tabr', 'habbanya_ridge_sietch'].includes(card.territory))); + assert.ok(at >= position); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]); + }; + orderBlow(false, 0); + if (advanced) orderBlow(false, 1); + reach(g => g.phase === 5 && homeworldEcazClean(g)); + while (game.phase === 5) { + if (homeworldEcazClean(game)) act(game.active!, { type: 'endMovement' }); else step(); + } + reach(g => g.turn === 2 && g.phase === 0 && !g.phaseOpening); + orderBlow(true, 0); + orderBlow(false, 1); + if (advanced) orderBlow(false, 2); + reach(g => g.phase === 1 && g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldEcazClean(g)); + act('ecaz', { type: 'alliance', target: 'emperor' }); + act('emperor', { type: 'alliance', target: 'ecaz' }); + reach(g => g.phase === 5 && homeworldEcazClean(g)); + const battleTerritory = ['arrakeen', 'sietch_tabr', 'habbanya_ridge_sietch'] + .find(id => territory(id).sectors[0] !== game.storm)!; + const sector = territory(battleTerritory).sectors[0]; + const location = `${battleTerritory}:${sector}`; + const shipped = new Set(); + while (game.phase === 5) { + if (!homeworldEcazClean(game)) { step(); continue; } + const actor = game.active!; + if (!shipped.has(actor)) { + act(actor, { type: 'ship', territory: battleTerritory, sector, + amount: actor === 'guild' ? 2 : 4, elite: actor === 'emperor' ? 2 : 0, allyPayment: 0 }); + shipped.add(actor); + reach(homeworldEcazClean); + } + act(actor, { type: 'endMovement' }); + } + reach(homeworldEcazClean); + const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === battleTerritory); + assert.ok(choice, 'The actually shipped coalition must have a mandatory Arrakis battle.'); + act(choice.chooser, { type: 'chooseBattle', territory: battleTerritory, + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }); + if (!options.skills) assert.equal(game.decision?.kind, 'ecazBattleLead'); + return { game, initial, afterSetup, actions, ecaz: 'ecaz', ally: 'emperor', opponent: 'guild', + territory: battleTerritory, location, ecazForces: 4 }; +} diff --git a/tests/homeworld-ecaz-occupy-runtime.test.ts b/tests/homeworld-ecaz-occupy-runtime.test.ts new file mode 100644 index 00000000..f6d84ab2 --- /dev/null +++ b/tests/homeworld-ecaz-occupy-runtime.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { homeworldGameIntegrity } from '../game/homeworld-game'; +import { chooseEcazOccupyLead, openEcazOccupyPlans, stageEcazOccupyCard } from './fixture-ecaz-occupy'; +import { createHomeworldEcazOccupyFixture, homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy'; + +const player = (game: Game, id: string) => game.players.find(p => p.id === id)!; +const leader = (game: Game, id: string, strongest: boolean) => player(game, id).leaders + .filter(l => !l.dead && !l.usedAt).sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0].id; +function revealAndFinish(state: Game, mixedLoss = false): Game { + let game = state; + while (game.battle) { + const view = viewGame(game, game.battle.attacker).battle!; + const voter = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id)); + assert.ok(voter); + game = applyAction(game, voter, { type: 'traitorCall', call: false }); + } + if (mixedLoss) { + const decision = game.decision; + assert.ok(decision?.kind === 'battleLosses'); + const choice = decision.options.findIndex(loss => loss.normal === 1 && loss.elite === 1); + assert.ok(choice >= 0); + game = applyAction(game, decision.player, { type: 'decision', choice }); + } + for (let n = 0; n < 100 && (game.phase === 6 || game.phase === 7 && !homeworldEcazClean(game)); n++) { + const next = nextHomeworldEcazStep(game); assert.ok(next); + game = applyAction(game, next.actor, next.action); + } + assert.equal(game.phase, 7); + homeworldGameIntegrity(game); + return game; +} + +for (const advanced of [false, true]) for (const lead of ['ecaz', 'ally'] as const) + void test(`${advanced ? 'Advanced' : 'Basic'} native Worlds ${lead} lead keeps separate typed Arrakis Occupy losses and original World custody`, () => { + const f = createHomeworldEcazOccupyFixture({ advanced }); + const actor = lead === 'ecaz' ? f.ecaz : f.ally; + let game = openEcazOccupyPlans(chooseEcazOccupyLead(f, lead)); + const before = structuredClone(game); + const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!; + assert.equal(profile.forceOwner, f.ally); + assert.equal(profile.fixedEcazDial, 2); + // Advanced Salusa retains full-strength unpaid Sardaukar. One paid ordinary + // plus one unpaid starred counter makes variable strength 3, not three pieces. + game = applyAction(game, actor, { type: 'battlePlan', dial: advanced ? 5 : 4, + support: advanced ? 1 : 0, leader: leader(game, actor, true) }); + game = applyAction(game, f.opponent, { type: 'battlePlan', dial: 0, leader: leader(game, f.opponent, false) }); + game = revealAndFinish(game, !advanced); + assert.equal(game.lastBattleContext!.winner, actor); + assert.equal(game.lastBattleContext!.result, 'normal'); + assert.equal(player(game, f.ecaz).forces[f.location], 2); + assert.equal(player(game, f.ecaz).tanks - player(before, f.ecaz).tanks, 2); + assert.equal(player(game, f.ally).forces[f.location], 2); + assert.equal(player(game, f.ally).elites!.forces[f.location], 1); + assert.equal(player(game, f.ally).tanks - player(before, f.ally).tanks, 2); + assert.equal(player(game, f.ally).elites!.tanks - player(before, f.ally).elites!.tanks, 1); + assert.equal(player(game, f.opponent).forces[f.location], undefined); + assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody); + }); + +for (const lead of ['ecaz', 'ally'] as const) + void test(`native World Karama prevention keeps ${lead} as lead and spares the excluded allied army`, () => { + const f = createHomeworldEcazOccupyFixture(); + const actor = lead === 'ecaz' ? f.ecaz : f.ally; + const excluded = actor === f.ecaz ? f.ally : f.ecaz; + const karama = stageEcazOccupyCard(f.game, f.opponent, card => card.effect === 'karama'); + let game = chooseEcazOccupyLead(f, lead); + assert.equal(game.response?.kind, 'ecazOccupy'); + game = applyAction(game, f.opponent, { type: 'card', card: karama, mode: 'cancel' }); + game = openEcazOccupyPlans(game); + const before = structuredClone(game); + const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!; + assert.equal(profile.forceOwner, actor); + assert.equal(profile.fixedEcazDial, 0); + game = applyAction(game, actor, { type: 'battlePlan', dial: actor === f.ecaz ? 2 : 3, + support: actor === f.ecaz ? 2 : 1, leader: leader(game, actor, true) }); + game = applyAction(game, f.opponent, { type: 'battlePlan', dial: 0, leader: leader(game, f.opponent, false) }); + game = revealAndFinish(game); + assert.equal(game.lastBattleContext!.winner, actor); + assert.equal(player(game, actor).forces[f.location], 2); + assert.equal(player(game, actor).tanks - player(before, actor).tanks, 2); + assert.deepEqual(player(game, excluded).forces, player(before, excluded).forces); + assert.equal(player(game, excluded).tanks, player(before, excluded).tanks); + assert.equal(game.discard.filter(card => card.id === karama).length, 1); + assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody); + }); + +void test('all four legal profiles complete the native World Skills coalition and retain physical source integrity', () => { + for (const difficulty of DIFFICULTIES) { + const f = createHomeworldEcazOccupyFixture({ skills: true }); + let game = f.game; + for (let n = 0; n < 150 && game.phase === 6; n++) { + let next; + for (const p of game.players) { + const view = viewGame(game, p.id); + view.players.find(seat => seat.id === p.id)!.bot = difficulty; + const action = botActions(view)[0]; + if (action) { next = { actor: p.id, action }; break; } + } + assert.ok(next, `${difficulty} must retain a legal coalition actor`); + game = applyAction(game, next.actor, next.action); + } + assert.equal(game.phase, 7, difficulty); + assert.ok(game.lastBattleContext!.combatants.includes(f.opponent)); + assert.equal(game.battle, null); + homeworldGameIntegrity(game); + } +});