Enable paired Ecaz and Moritani Discovery preview
Verify and publish checkpoint container / container (push) Successful in 10m37s

This commit is contained in:
Quintine committed 2026-10-29 20:05:34 +11:00
1 parent 2ad7f127ec
commit 9ca68c9ff8
11 files changed
+212 -11

No files matched your search

+10
View File
@@ -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
+17
View File
@@ -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
+7
View File
@@ -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
+9 -1
View File
@@ -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.
+3 -2
View File
@@ -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;
+5 -5
View File
@@ -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',
+30 -2
View File
@@ -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);
@@ -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 {
+4 -1
View File
@@ -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 } }
+52
View File
@@ -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);
}
@@ -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);
});