diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c109487d..3036a5d9 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Ecaz and Moritani with Discovery alone — 29 October. Prototyped / Partial.** +Basic and Advanced previews can use both factions with Discovery without an +unrelated Nexus or independent card selection. Public-start fixtures exercise +an actual Discovery blow, Duke acquisition and Robbery in the shared entry; +Spice order and original token placement are controlled. Twelve related files +pass 77 cases, including existing legal bot choices. Advanced Harkonnen and +other existing profile restrictions remain; broader interactions, complete +games and deployed verification remain open. +[Scope](DISCOVERY_PROTOTYPE.md#ecaz-and-moritani-with-discovery-alone--29-october-2026). + **Sneak during a Great Maker arrival — 29 October. Prototyped / Partial.** Great Maker now retains its original reserve ride while a positive Moritani Sneak group resolves its own reactions, then completes once. Its encounter and diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index e653e636..11f484ae 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -1,5 +1,22 @@ # Discovery functional prototype +## Ecaz and Moritani with Discovery alone — 29 October 2026 + +**Prototyped / Partial.** A fresh Basic or Advanced preview can select both +Ecaz and Moritani with Discovery without adding Nexus or the independent Ecaz +cards. The existing setup supplies the original factions and Discovery +components. A paired Ambassador/Terror entry uses the existing provisional +storm-order queue; the original move and payment occur once. Existing faction, +Discovery, human and minimal bot controls remain in use. Advanced Moritani +with Harkonnen and unrelated overlays retain their existing boundaries. + +Twelve related files pass 77 cases. The focused public-start fixtures exercise +an actual Discovery Spice Blow and the shared arrival with Duke acquisition and +Robbery, using controlled unopened Spice order and original token placement. +Original Tech/Stronghold seat and rules limits have initializer coverage; full +games, further optional-module and nested-arrival interactions and deployed +verification remain open. + ## Great Maker and Sneak Attack — 29 October 2026 **Prototyped / Partial.** Great Maker's signed reserve arrival can wait for a diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 9e20047e..654f528b 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,12 @@ # Rule decision index +## Ecaz and Moritani with Discovery alone — 29 October 2026 + +The selected E3 pair can use original Discovery without a Nexus or independent +card prerequisite. The existing paired-arrival storm order remains provisional; +other module and full-mode limits remain open. +[Scope](DISCOVERY_PROTOTYPE.md#ecaz-and-moritani-with-discovery-alone--29-october-2026). + ## Great Maker Sneak arrival — 29 October 2026 Great Maker retains its signed reserve arrival while the separately committed diff --git a/game/discovery-module-profile.ts b/game/discovery-module-profile.ts index 21a9c25b..bbef67c3 100644 --- a/game/discovery-module-profile.ts +++ b/game/discovery-module-profile.ts @@ -52,7 +52,15 @@ export function discoveryModeSupported(game: FactionModuleProfile): boolean { if (player.faction === 'moritani') moritani = true; if (player.faction === 'harkonnen') harkonnen = true; } - return (!e3 || native === 1) && !(game.advanced && moritani && harkonnen); + return (!e3 || native >= 1 && native <= 2) && !(game.advanced && moritani && harkonnen); +} + +/** The original E3 pair can use Discovery without choosing a Nexus or card variant. */ +export function pairedE3DiscoveryOnlyProfile(game: FactionModuleProfile): boolean { + return !game.nexusCards && !game.ecazTreachery && !game.leaderSkills && + game.expansions.length === 1 && game.expansions[0] === 'ecaz' && + game.players.some(player => player.faction === 'ecaz') && + game.players.some(player => player.faction === 'moritani') && discoveryModeSupported(game); } /** Fresh or live classic all-fourteen Skills/Discovery, optionally Homeworlds/Tech/Advanced Strongholds. diff --git a/game/engine.ts b/game/engine.ts index 2b3f15fa..af820cbc 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -109,7 +109,7 @@ import { PhaseBidPromiseError, phaseBidPromiseModeSupported, livePhaseBidPromise phaseBidCompletionRange, recordPhaseBid, reconcilePhaseBidPromises, finishPhaseBidPhase, validatePhaseBidPromises, type PhaseBidClaim, type PhaseBidOpportunity, type PhaseBidPromise } from './turn-bid-promises'; -import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; +import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, pairedE3DiscoveryOnlyProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeReceiptTiming, bribeTimingBlock, bribesCloseBeforeNextMentat, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteAtreidesFullPlan } from './atreides-full-plan'; @@ -13695,7 +13695,7 @@ export function initializeNexusKullGameForAudit(state: Game): Game { } /** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */ export function initializeDiscoveryGameForAudit(state: Game): Game { - requireRule(discoveryModuleProfile(state), 'Discovery requires a fresh supported classic, E1/E2 or standalone E3 lobby without unrelated overlays.'); + requireRule(discoveryModuleProfile(state), 'Discovery requires a fresh supported classic, E1/E2 or E3 lobby without unrelated overlays.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); const homeworlds = homeworldDiscoveryProfile(state); @@ -25166,6 +25166,7 @@ function bareAdvancedNativeArrivalOverlap(g: Game): boolean { function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParent): boolean { if (bareBasicNativeArrivalOverlap(g)) return true; if (bareAdvancedNativeArrivalOverlap(g)) return true; + if (pairedE3DiscoveryOnlyProfile(g)) return true; if (homeworldPhaseModulesProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true; if (revivalArrival && g.homeworlds && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true; if (bareBasicSemutaProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true; diff --git a/game/reference.ts b/game/reference.ts index 9977a2f6..5c345db4 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -5920,11 +5920,11 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Advanced & expansions', coverage: 'Partial', developmentStage: 'Prototyped', - summary: 'A development prototype connects Great Maker, six Discovery spice blows, private token inspection, stash rewards, revealed locations, next-turn entry, Cistern income, bounded Orgiz transfers, Ornithopter movement, Jacurutu income, Testing Station and Shrine. Remaining interpretations and normal public starts stay gated.', + summary: 'A development preview connects Great Maker, six Discovery spice blows, private token inspection, stash rewards, revealed locations, next-turn entry, Cistern income, bounded Orgiz transfers, Ornithopter movement, Jacurutu income, Testing Station and Shrine. Supported public starts remain previews; remaining interpretations and complete-mode acceptance stay open.', searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter', steps: [ - 'The Discovery-only fresh entry supports classics, selected Ixian/Tleilaxu/CHOAM/Richese families or standalone Ecaz OR Moritani with classics, Basic/Advanced2–6, seven extra Spice Cards/eight tokens, optional unused Tech3+ and Advanced Strongholds. Its separate Nexus/Skills entries below admit their additional exact compositions; Discovery-only paired or mixed E3 and public starts remain gated.', - 'Fresh Discovery retains original Homeworlds and native reserves in classic, supported E1/E2 or standalone E3 tables, Basic/Advanced2–6, optional Tech3+/Advanced Strongholds. Original Nexus entries additionally admit natives from BOTH Ix+CHOAM families/union47 or BOTH Ecaz+Moritani/ecaz33 with classics, with or without all14 Skills. Maker, actual parent/nested entry, source-local payments/rescue, phase-end industry and original-winner cleanup/Tech remain. Held benefits do not migrate into nested locations. Original capture/roster and unresolved-effect guards remain.', + 'The Discovery-only fresh preview supports classics, selected Ixian/Tleilaxu/CHOAM/Richese families, or Ecaz and/or Moritani with classics, Basic/Advanced2–6, seven extra Spice Cards/eight tokens, optional unused Tech3+ and Advanced Strongholds. The native E3 pair no longer requires Nexus or independent cards. Mixed E3 families and complete-mode acceptance remain separate.', + 'Fresh Discovery retains original Homeworlds and native reserves in classic and supported E1/E2 or E3 tables, Basic/Advanced2–6, optional Tech3+/Advanced Strongholds. Original Nexus entries additionally admit natives from BOTH Ix+CHOAM families/union47 or BOTH Ecaz+Moritani/ecaz33 with classics, with or without all14 Skills. Maker, actual parent/nested entry, source-local payments/rescue, phase-end industry and original-winner cleanup/Tech remain. Held benefits do not migrate into nested locations. Original capture/roster and unresolved-effect guards remain.', 'The original leader-skills --nexus-cards --discoveries entry composes all14 offered training/all12 Nexus/Discovery7/8 for classics/base33, single or paired one-family E1/E2, natives from BOTH selected Ix+CHOAM families/union47, or standalone OR paired Ecaz/Moritani/ecaz33 with classics. Original Homeworlds may be retained; Tech requires3+ and Strongholds Advanced. Hands/offers, native components and actual Maker/both-pile/alliance-change closing draws remain authoritative. Shared Duke never trains; Advanced paired E3/Moritani excludes Harkonnen. Mixed E3/three-family and pending effects/public starts/save conversion remain separate.', 'Connected native outcomes use original sources: Suboid Cunning and winning trained Suk settle typed fixed casualties before one equal actual Cyborg Tank substitution; original cleanup and mandatory winner Tech precede matching Face Dance, retaining its foreign-Homeworld suppression. Signed Richese two-marker one-invoice physical shipment requires a currently revealed nested site. Ecaz-selected coalition armies and Moritani skill-first assassination remain distinct from shared-Duke training. These programmes do not certify every skill or card band.', 'The original nexus --discoveries entry retains NO Skills with classics/base33, single or paired one-family E1/E2, both selected Ix+CHOAM native families/union47, or standalone OR paired Ecaz/Moritani/ecaz33 with classics. Original Homeworlds/Tech3+/Advanced Strongholds remain optional. Real native Traitors, Arrakis Occupy and Advanced assassination marks remain; no starting Nexus hand or training is added. Maker losses/votes/typed reserve rides, both Advanced piles and actual settled alliance change precede the closing card deal. Source guards and public/played-save boundaries remain.', @@ -5970,8 +5970,8 @@ export const RULE_TOPICS: RuleTopic[] = [ }, { area: 'Verification', status: 'Partial', - detail: 'Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Current Collection batch passes80/80 in6 related files, types/lint and build. Genuine Basic/Advanced cases cover shared equal/full/zero splits, acceptance timing, wrong actors and stale events; helper boundaries cover multiple deposits, exhausted recipients, self/zero-share exclusion and cumulative overflow. Actual390px Basic Red Chasm lot4 changes Ecaz7/Guild8 to8/11 with one transfer and Ready restored, no overflow or errors. Four legal policies complete that Basic allocation via actual actions. Earlier canonical-source and Jacurutu/Station evidence remains source-bound; combinations, assurance, strategy and deployed acceptance remain open.', - evidence: ['tests/fixture-discovery-no-field-entry.ts', 'tests/discovery-no-field-entry-engine.test.ts', 'tests/discovery-no-field-entry-quote.test.ts', 'tests/discovery-no-field-entry-controls.test.tsx', 'tests/fixture-discovery-no-field-blow.ts', 'tests/discovery-no-field-blow-engine.test.ts', 'tests/discovery-no-field-blow-preflight.test.ts', 'tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts', + detail: 'Paired E3 Discovery without Nexus or cards passes twelve related files and 77 cases, including public Basic/Advanced starts, Discovery blows and native Duke/Robbery entry with controlled physical tokens and Spice order. Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Current Collection batch passes80/80 in6 related files, types/lint and build. Genuine Basic/Advanced cases cover shared equal/full/zero splits, acceptance timing, wrong actors and stale events; helper boundaries cover multiple deposits, exhausted recipients, self/zero-share exclusion and cumulative overflow. Actual390px Basic Red Chasm lot4 changes Ecaz7/Guild8 to8/11 with one transfer and Ready restored, no overflow or errors. Four legal policies complete that Basic allocation via actual actions. Earlier canonical-source and Jacurutu/Station evidence remains source-bound; combinations, assurance, strategy and deployed acceptance remain open.', + evidence: ['tests/paired-e3-discovery-only-runtime.test.ts', 'tests/fixture-discovery-no-field-entry.ts', 'tests/discovery-no-field-entry-engine.test.ts', 'tests/discovery-no-field-entry-quote.test.ts', 'tests/discovery-no-field-entry-controls.test.tsx', 'tests/fixture-discovery-no-field-blow.ts', 'tests/discovery-no-field-blow-engine.test.ts', 'tests/discovery-no-field-blow-preflight.test.ts', 'tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts', 'tests/fixture-jacurutu-mixed-dial.ts', 'tests/jacurutu-mixed-dial-runtime.test.ts', 'tests/fixture-testing-station-contest.ts', 'tests/testing-station-contest-runtime.test.ts', 'tests/fixture-discovery-occupied-collection.ts', 'tests/discovery-occupied-collection-runtime.test.ts', 'tests/fixture-discovery-shared-orgiz.ts', 'tests/discovery-shared-orgiz-runtime.test.ts', 'tests/board-resolution-quote.test.ts', 'tests/ecaz-collection-quote.test.ts', diff --git a/tests/discovery-module-profile.test.ts b/tests/discovery-module-profile.test.ts index e041fe8e..77daf992 100644 --- a/tests/discovery-module-profile.test.ts +++ b/tests/discovery-module-profile.test.ts @@ -13,15 +13,22 @@ function lobby(roster: FactionId[], expansions: string[], advanced = true): Game return game; } -void test('Discovery requires the selected native family and does not admit paired or mixed E3', () => { +void test('Discovery requires the selected native family and does not admit unsupported mixed E3', () => { const unsupported = [ lobby(['ixians', 'guild'], []), lobby(['richese', 'guild'], ['ix']), - lobby(['ecaz', 'moritani', 'guild'], ['ecaz']), lobby(['ecaz', 'ixians', 'guild'], ['ecaz', 'ix']), lobby(['moritani', 'harkonnen', 'guild'], ['ecaz']), ]; for (const game of unsupported) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError); + for (const advanced of [false, true]) { + const paired = initializeDiscoveryGameForAudit(lobby(['ecaz', 'moritani', 'guild'], ['ecaz'], advanced)); + assert.equal(paired.discoveries?.tokens.length, 8); + assert.equal(paired.status, 'setup'); + assert.deepEqual(paired.players.map(player => player.faction), ['ecaz', 'moritani', 'guild']); + assert.equal(paired.nexusCards, undefined); + assert.equal(paired.ecazTreachery, undefined); + } // The Advanced Moritani/Harkonnen restriction is not a new Basic restriction. const basic = initializeDiscoveryGameForAudit(lobby(['moritani', 'harkonnen', 'guild'], ['ecaz'], false)); assert.equal(basic.moritaniAssassinatePreview, undefined); @@ -41,6 +48,27 @@ void test('Discovery Tech requires three seats and untouched physical tokens', ( assert.equal(initialized.discoveryEnabled, true); }); +void test('paired E3 Discovery keeps the original optional-module seat and rules limits', () => { + const factions: FactionId[] = ['ecaz', 'moritani', 'guild', 'atreides', 'emperor', 'beneGesserit']; + for (const advanced of [false, true]) for (const count of [2, 3, 6]) { + const game = lobby(factions.slice(0, count), ['ecaz'], advanced); + if (count >= 3) game.techTokens = createTechTokens(); + if (advanced) game.strongholdCards = createStrongholdCards(); + const started = initializeDiscoveryGameForAudit(game); + assert.equal(started.discoveries?.tokens.length, 8); + assert.equal(!!started.techTokens, count >= 3); + assert.equal(!!started.strongholdCards, advanced); + } + const twoWithTech = lobby(factions.slice(0, 2), ['ecaz']); + twoWithTech.techTokens = createTechTokens(); + assert.throws(() => initializeDiscoveryGameForAudit(twoWithTech), RuleError); + const basicStrongholds = lobby(factions.slice(0, 3), ['ecaz'], false); + basicStrongholds.strongholdCards = createStrongholdCards(); + assert.throws(() => initializeDiscoveryGameForAudit(basicStrongholds), RuleError); + assert.throws(() => initializeDiscoveryGameForAudit(lobby(['ecaz','moritani','harkonnen'], ['ecaz'], true)), RuleError); + assert.ok(initializeDiscoveryGameForAudit(lobby(['ecaz','moritani','harkonnen'], ['ecaz'], false)).discoveries); +}); + void test('Discovery cannot redeal started components or silently remove another module', () => { const initial = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']); const started = initializeDiscoveryGameForAudit(initial); diff --git a/tests/fixture-discovery-native-e1-skills.ts b/tests/fixture-discovery-native-e1-skills.ts index f552668e..2fed3193 100644 --- a/tests/fixture-discovery-native-e1-skills.ts +++ b/tests/fixture-discovery-native-e1-skills.ts @@ -72,6 +72,9 @@ export function nextDiscoveryNativeE1SkillsStep(game: Game): DiscoveryNativeE1Sk } if (d?.kind === 'battleLosses') return { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === Math.max(...d.options.map(c => c.elite))) } }; + if (game.status === 'playing' && game.phase === 2 && game.charityOrder?.remaining[0] && + !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard) + return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } }; return nextSkillsTechBattleStep(game); } export function stepDiscoveryNativeE1Skills(game: Game, step = nextDiscoveryNativeE1SkillsStep(game)): Game { diff --git a/tests/fixture-discovery-native-typed.ts b/tests/fixture-discovery-native-typed.ts index 1366a6b0..f662c182 100644 --- a/tests/fixture-discovery-native-typed.ts +++ b/tests/fixture-discovery-native-typed.ts @@ -19,7 +19,10 @@ export function nativeTypedPlayer(game: Game, id: string): Player { return player; } export function nativeTypedStep(game: Game): Game { - const next = game.decision?.kind === 'discoveryEntry' + const next = game.status === 'playing' && game.phase === 2 && game.charityOrder?.remaining[0] && + !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard + ? { actor: game.charityOrder.remaining[0], action: { type: 'ready' } } + : game.decision?.kind === 'discoveryEntry' ? { actor: game.decision.player, action: { type: 'decision', accept: false, event: game.decision.event } } : game.decision?.kind === 'ixSubstitution' ? { actor: game.decision.player, action: { type: 'decision', decline: true } } diff --git a/tests/fixture-paired-e3-discovery-only.ts b/tests/fixture-paired-e3-discovery-only.ts new file mode 100644 index 00000000..72c2cec2 --- /dev/null +++ b/tests/fixture-paired-e3-discovery-only.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { placeAmbassador } from '../game/ecaz-ambassadors'; +import { placeTerror } from '../game/moritani-terror'; +import { actBasicSemutaAmbassador, advanceBasicSemutaAmbassador, + basicSemutaAmbassadorClean, basicSemutaAmbassadorPlayer } from './fixture-basic-semuta-ambassador'; + +export const act = actBasicSemutaAmbassador; +export const player = basicSemutaAmbassadorPlayer; + +/** Fresh public lobby/setup and real turn-one Guild shipment. Existing Ecaz and + * Moritani tokens are selected and placed at the clean movement boundary by + * their native helpers. The first unopened ordinary or Discovery Spice cards are selected; + * no player force, purse, order, pending reaction or receipt is staged. */ +export function pairedE3DiscoveryOnlyFixture(advanced: boolean, discoveryBlow = false) { + let game = createGame('PAIREDE3DISCOVERY', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']); + for (const faction of ['moritani', 'atreides', 'guild'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + game = act(game, 'ecaz', {type:'moduleOption', module:'discoveries', enabled:true}); + for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); + const lobby = structuredClone(game); + game = act(game, 'ecaz', {type:'start', ...(advanced ? {advancedPreview:true} : {})}); + const setup = structuredClone(game); + game = advanceBasicSemutaAmbassador(game, state => state.status === 'playing'); + assert.equal(game.advanced, advanced); + assert.ok(game.discoveries && game.ecazAmbassadors && game.moritaniTerror); + assert.ok(!game.nexusCards && !game.ecazTreachery && !game.leaderSkills); + for (const position of advanced ? [0,1] : [0]) { + const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && + (position === 0 && discoveryBlow ? card.discovery === 'discovery-hagga-basin' : !card.discovery)); + assert.ok(index >= position); + [game.spiceDeck[position], game.spiceDeck[index]] = [game.spiceDeck[index], game.spiceDeck[position]]; + } + game = advanceBasicSemutaAmbassador(game, state => state.turn === 1 && state.phase === 5 && + state.active === 'guild' && basicSemutaAmbassadorClean(state)); + const beforeMarkers = structuredClone(game); + const token = game.ecazAmbassadors!.tokens.find(row => row.effect === 'ecaz' && row.zone === 'supply'); + const terror = game.moritaniTerror!.tokens.find(row => row.kind === 'robbery' && row.status === 'available'); + assert.ok(token && terror); + const destination = 'habbanya_ridge_sietch', sector = 17; + game.ecazAmbassadors = placeAmbassador(game.ecazAmbassadors!, token.id, { + turn:game.turn, availableSpice:player(game,'ecaz').spice, + destination:{id:destination,stronghold:true,inStorm:false,allowed:true}, + }).state; + game.moritaniTerror = placeTerror(game.moritaniTerror!, terror.id, destination, game.turn); + const shipment: Action = {type:'ship', territory:destination, sector, amount:1, elite:0, allyPayment:0}; + return {game, lobby, setup, beforeMarkers, destination, sector, token:token.id, terror:terror.id, shipment}; +} + +export function reachPairedE3Entry(state: Game, until: (game: Game) => boolean): Game { + return advanceBasicSemutaAmbassador(state, until); +} diff --git a/tests/paired-e3-discovery-only-runtime.test.ts b/tests/paired-e3-discovery-only-runtime.test.ts new file mode 100644 index 00000000..e6a444c1 --- /dev/null +++ b/tests/paired-e3-discovery-only-runtime.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { reserveShipmentCost } from '../game/shipment-price'; +import { act, pairedE3DiscoveryOnlyFixture, player, reachPairedE3Entry } from './fixture-paired-e3-discovery-only'; + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} paired Ecaz/Moritani Discovery-only public setup preserves the original entry`, () => { + const f = pairedE3DiscoveryOnlyFixture(advanced); + assert.equal(f.lobby.discoveryEnabled, true); + assert.equal(f.setup.discoveryEnabled, true); + assert.ok(f.setup.discoveries); + assert.equal(f.setup.discoveries!.tokens.length, 8); + assert.equal(f.setup.spiceDeck.filter(card => 'discovery' in card && card.discovery).length, 6); + assert.ok(!f.setup.nexusCards && !f.setup.ecazTreachery); + const price = reserveShipmentCost({faction:'guild', halfRate:true}, 'stronghold', 1); + const originalSpice = player(f.game, 'guild').spice; + const originalMoritaniSpice = player(f.game, 'moritani').spice; + const stolen = Math.ceil((originalSpice - price) / 2); + const originalDuke = structuredClone(f.game.dukeVidal!.leader); + let game = act(f.game, 'guild', f.shipment); + game = reachPairedE3Entry(game, state => !!state.pendingArrivalOverlap); + assert.equal(player(game, 'guild').spice, originalSpice - price); + assert.equal(player(game, 'guild').forces[`${f.destination}:${f.sector}`], 1); + const first = game.pendingArrivalOverlap!.first; + const responder = first === 'ambassador' ? 'ecaz' : 'moritani'; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, responder); + view.players.find(row => row.id === responder)!.bot = difficulty; + const choice = botActions(view)[0]; assert.ok(choice, difficulty); + assert.doesNotThrow(() => act(game, responder, choice), difficulty); + } + if (first === 'ambassador') { + const entry = game.pendingAmbassador; assert.ok(entry); + assert.equal(entry.token, f.token); + game = act(game, 'ecaz', {type:'decision', event:entry.event, trigger:true, beneficiary:'ecaz', choice:'duke'}); + assert.equal(game.pendingTerrorEntry?.token, f.terror); + game = act(game, 'moritani', {type:'decision', reveal:true}); + assert.equal(game.pendingTerrorEntry?.stage, 'robbery'); + game = act(game, 'moritani', {type:'decision', choice:'spice'}); + } else { + assert.equal(game.pendingTerrorEntry?.token, f.terror); + game = act(game, 'moritani', {type:'decision', reveal:true}); + assert.equal(game.pendingTerrorEntry?.stage, 'robbery'); + game = act(game, 'moritani', {type:'decision', choice:'spice'}); + const entry = game.pendingAmbassador; assert.ok(entry); + assert.equal(entry.token, f.token); + game = act(game, 'ecaz', {type:'decision', event:entry.event, trigger:true, beneficiary:'ecaz', choice:'duke'}); + } + assert.equal(game.pendingArrivalOverlap ?? null, null); + assert.equal(game.pendingAmbassador ?? null, null); + assert.equal(game.pendingTerrorEntry ?? null, null); + assert.equal(player(game, 'guild').spice, originalSpice - price - stolen); + assert.equal(player(game, 'moritani').spice, originalMoritaniSpice + stolen); + assert.equal(player(game, 'guild').forces[`${f.destination}:${f.sector}`], 1); + assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.terror)?.status, 'removed'); + assert.equal(game.ecazAmbassadors!.tokens.find(row => row.id === f.token)?.zone, 'supply'); + assert.deepEqual([game.dukeVidal?.controller, game.dukeVidal?.source], ['ecaz', 'ecaz']); + assert.deepEqual(game.dukeVidal?.leader, originalDuke); + }); + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} paired Discovery-only table resolves a real Discovery Spice Blow`, () => { + const f = pairedE3DiscoveryOnlyFixture(advanced, true); + assert.ok(f.beforeMarkers.spiceDiscard.flat().some(card => + 'territory' in card && card.discovery === 'discovery-hagga-basin')); + assert.equal(f.beforeMarkers.discoveries!.tokens.filter(token => token.status === 'placed').length, 1); + assert.equal(f.beforeMarkers.phase, 5); + assert.ok(!f.beforeMarkers.nexusCards && !f.beforeMarkers.ecazTreachery); + });