From 528b152f7d0d6a0a5a402b64548602af8c653265 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:56:08 +1100 Subject: [PATCH] Connect Semuta in Discovery and Skills games --- docs/CURRENT_STATUS.md | 14 ++- docs/DISCOVERY_PROTOTYPE.md | 7 +- docs/RULE_DECISIONS.md | 7 ++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 17 +++ game/discovery-module-profile.ts | 4 +- game/engine.ts | 22 ++-- game/faction-module-profile.ts | 9 ++ game/leader-skill-profile.ts | 1 + game/reference.ts | 2 +- .../semuta-discovery-skills-ordinary.test.ts | 95 ++++++++++++++++ ...semuta-discovery-skills-overlap-fixture.ts | 64 +++++++++++ tests/semuta-discovery-skills-overlap.test.ts | 58 ++++++++++ tests/semuta-discovery-skills-station.test.ts | 106 ++++++++++++++++++ ...emuta-discovery-skills-truth-order.test.ts | 90 +++++++++++++++ 14 files changed, 481 insertions(+), 15 deletions(-) create mode 100644 tests/semuta-discovery-skills-ordinary.test.ts create mode 100644 tests/semuta-discovery-skills-overlap-fixture.ts create mode 100644 tests/semuta-discovery-skills-overlap.test.ts create mode 100644 tests/semuta-discovery-skills-station.test.ts create mode 100644 tests/semuta-discovery-skills-truth-order.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ef2833d4..54fdb647 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,13 +17,23 @@ open mode gates. ## Current checkpoint and work +**Semuta with Discovery and Leader Skills — 30 October. Prototyped / Partial.** +Fresh selected-deck Basic and Advanced games with Richese can now use supported +Semuta reactions while both modules are active. Ten related files pass 53 +cases. Public-start fixtures exercise Hajr and Sapho discards, claim/pass, +Truthtrance and the Ecaz/Moritani shared arrival; token placement, unopened +Spice and conserved card custody are staged. Other discard sources, Homeworlds, +Nexus, full games and deployed verification remain open. +[Scope](SEMUTA_SOURCE_UPDATE_20260907.md#selected-discovery-and-skills--30-october-2026). + **Selected Discovery with Leader Skills — 30 October. Prototyped / Partial.** Basic and Advanced Discovery/Skills previews admit selected expansion decks, including E3 and unseated families, without Nexus or independent cards. Eight related files pass 48 cases. Public-start fixtures train leaders and exercise a real Discovery blow and shared Ambassador/Terror entry with controlled unopened -Spice order and token placement. Advanced Harkonnen with actual E3 factions, -Semuta, deeper interactions, complete games and deployed verification remain +Spice order and token placement. The later Semuta entry above extends this +profile; Advanced Harkonnen with actual E3 factions, other Semuta sources, +deeper interactions, complete games and deployed verification remain open. [Scope](DISCOVERY_PROTOTYPE.md#selected-decks-with-leader-skills--30-october-2026). **Human Emperor before automated Extortion — 30 October. Prototyped / diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index 3cc82718..6c2a6d0e 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -7,9 +7,10 @@ original Discovery tokens and Leader Skills with selected Ecaz/Moritani, Ixians/Tleilaxu and CHOAM/Richese decks without requiring Nexus or independent Ecaz cards. Selected families need not all be seated. Original Tech requires three seats; Strongholds require Advanced. The native Ecaz/Moritani shared -arrival retains its provisional storm order. Advanced actual E3/Harkonnen and -separate Semuta, nested reactions and other interactions remain guarded or -unfinished. Started games are not converted. +arrival retains its provisional storm order. The later [Semuta extension](SEMUTA_SOURCE_UPDATE_20260907.md#selected-discovery-and-skills--30-october-2026) +connects supported discards when Richese is seated. Advanced actual E3/Harkonnen, +other Semuta sources, nested reactions and other interactions remain guarded +or unfinished. Started games are not converted. Public-start fixtures train leaders, resolve an actual Discovery blow and the shared Ambassador/Terror entry, with controlled unopened Spice order and diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index cd48f5f3..9c37d51c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,12 @@ # Rule decision index +## Semuta with selected Discovery and Skills — 30 October 2026 + +Richese's existing Semuta protocol now joins supported fresh Discovery/Skills +games with selected decks. Source-specific discard and nested-continuation +limits remain; no new publisher timing ruling is implied. +[Scope](SEMUTA_SOURCE_UPDATE_20260907.md#selected-discovery-and-skills--30-october-2026). + ## Selected Discovery with Leader Skills — 30 October 2026 Discovery and Leader Skills can be selected with mixed expansion decks without diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index cbbb3eb4..89916ef6 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -1,5 +1,22 @@ # Semuta Drug source update and implementation boundary +## Selected Discovery and Skills — 30 October 2026 + +**Prototyped / Partial.** Fresh Basic and Advanced selected-deck games with +Richese, Discovery and Leader Skills now initialize the existing Semuta +reaction. Nexus, independent cards and Homeworlds retain separate entry +boundaries; Advanced actual E3/Harkonnen stays guarded. Original Skills, +Discovery and physical cards remain intact. A clean Hajr discard, Station Sapho +and simultaneous Truthtrance-order Sapho can offer the card, then resume their +original continuations after claim or pass. The existing Ecaz/Moritani shared +arrival remains available when Richese is seated. + +Public-start fixtures exercise those paths with original training and real +actions; unopened Spice order, Station/token placement and conserved card +custody are staged. Unsupported Ambassador/Terror and nested discard sources +remain partial; this does not certify all Semuta interactions, complete games +or deployed behavior. Existing games are not converted. + ## Basic Terror discards during a worm ride — 29 October 2026 **Prototyped / Partial.** A native Fremen worm ride into Sabotage or Robbery diff --git a/game/discovery-module-profile.ts b/game/discovery-module-profile.ts index 6cc198be..2bf3e64f 100644 --- a/game/discovery-module-profile.ts +++ b/game/discovery-module-profile.ts @@ -1,5 +1,5 @@ import { FACTIONS } from './catalog'; -import { independentDiscoveryProfile, mixedE3NexusRosterProfile, selectedDiscoveryWithoutCardsProfile, selectedNexusWithoutCardsProfile, selectedPhaseModuleRoster, type FactionModuleProfile } from './faction-module-profile'; +import { independentDiscoveryProfile, mixedE3NexusRosterProfile, selectedDiscoverySkillsSemutaProfile, selectedDiscoveryWithoutCardsProfile, selectedNexusWithoutCardsProfile, selectedPhaseModuleRoster, type FactionModuleProfile } from './faction-module-profile'; import type { LeaderSkillProfile } from './leader-skill-profile'; /** Public unused-lobby admission only. The original initializer owns physical @@ -64,7 +64,7 @@ export function discoveryModeSupported(game: FactionModuleProfile): boolean { * and the separate Ecaz cards. Setup owns physical inventory; this public * roster contract also remains valid during live Discovery frames. */ export function selectedDiscoveryLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - return selectedDiscoveryWithoutCardsProfile(game); + return selectedDiscoveryWithoutCardsProfile(game) || selectedDiscoverySkillsSemutaProfile(game); } /** The original E3 pair can use selected decks with Discovery without Nexus or cards. */ diff --git a/game/engine.ts b/game/engine.ts index a7d00b79..4c2b006a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -99,7 +99,7 @@ import { movementShipmentPreparations } from './movement-shipment-preparation'; import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseModeSupported, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises'; import { findMovementCompletion, type MovementCompletion } from './movement-promise-search'; import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile'; -import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedDiscoveryWithoutCardsProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile, selectedNexusWithoutCardsProfile, selectedPhaseModuleRoster } from './faction-module-profile'; +import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedDiscoverySkillsSemutaProfile, selectedDiscoveryWithoutCardsProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile, selectedNexusWithoutCardsProfile, selectedPhaseModuleRoster } from './faction-module-profile'; import { homeworldOccupationLeaderSkillsProfile, homeworldOccupationModulesProfile, homeworldPhaseModulesProfile } from './homeworld-module-profile'; import { RevivalPromiseError, revivalPromiseModeSupported, liveRevivalPromises, recordPromisedRevival, revivalPromisesSatisfied, validateRevivalPromises, type RevivalClaim, type RevivalPromise } from './revival-promises'; @@ -8091,6 +8091,7 @@ function semutaHomeworldRoster(g: Game): boolean { * Sapho's neutral simultaneous-Truth ordering in their supported parents. */ function semutaTruthOrderProfile(g: Game): boolean { if (bareBasicSemutaProfile(g) || semutaHomeworldRoster(g)) return true; + if (selectedDiscoverySkillsSemutaProfile(g)) return true; return g.semutaPreview === true && g.expansions.length === 1 && g.expansions[0] === 'choam' && !!byFaction(g, 'richese') && g.players.every(p => p.faction === 'richese' || p.faction === 'choam' || CLASSIC_FACTIONS[p.faction] === true) && @@ -11163,6 +11164,10 @@ function lobbyModulesSelected(g: Game): boolean { /** Fresh public Richese + Skills entry, using the already connected Semuta * continuation. Other family/module compositions retain their own entry. */ function publicSemutaSkillsProfile(g: Game): boolean { + if (g.status === 'lobby' && !g.leaderSkills && !g.homeworlds && + !g.twoPlayerControllers && !g.shortAllianceVictory && !g.coalitionPreview && + g.expansions.includes('choam') && !!byFaction(g, 'richese') && + selectedDiscoveryWithoutCardsProfile(g)) return true; return g.status === 'lobby' && typeof g.advanced === 'boolean' && g.players.length >= 2 && g.players.length <= 6 && g.expansions.length === 1 && g.expansions[0] === 'choam' && !!byFaction(g, 'richese') && @@ -13638,21 +13643,24 @@ function bareAdvancedEcazSemutaProfile(g: Game): boolean { export function initializeSemutaGameForAudit(state: Game, skills = false): Game { const basicNative = bareBasicSemutaProfile(state); const advancedEcaz = bareAdvancedEcazSemutaProfile(state); + const selectedSkillsDiscovery = skills && state.discoveryEnabled === true && !state.homeworlds && + selectedDiscoveryWithoutCardsProfile(state); requireRule(!advancedEcaz || !skills, 'Advanced Ecaz Semuta excludes Leader Skills and other optional modules.'); requireRule(!state.semutaPreview && state.expansions.includes('choam') && - (state.expansions.every(id => id === 'choam' || id === 'ix') || basicNative || advancedEcaz) && + (state.expansions.every(id => id === 'choam' || id === 'ix') || basicNative || advancedEcaz || selectedSkillsDiscovery) && state.players.some(p => p.faction === 'richese'), 'Semuta needs a fresh Richese faction game: bare Basic selected native families, bare Advanced Ecaz/Richese, or CHOAM with optional Ix and supported original modules/skills; separate classic or Advanced Tleilax Homeworld occupation has no other modules. Other overlays remain excluded.'); - if (state.discoveryEnabled) requireRule(!basicNative && !skills && !state.homeworlds && - !state.nexusCards && discoveryModuleProfile(state), - 'Semuta Discovery keeps original Richese/CHOAM and optional Ix setup, without Skills, Homeworlds, Nexus or E3 overlays.'); + if (state.discoveryEnabled) requireRule(selectedSkillsDiscovery || (!basicNative && !skills && !state.homeworlds && + !state.nexusCards && discoveryModuleProfile(state)), + 'Semuta Discovery requires a supported fresh Richese roster without Homeworlds or Nexus.'); if (state.nexusCards) requireRule(!basicNative && !state.homeworlds && !state.discoveryEnabled && !state.discoveries, 'Semuta Nexus retains original E1/E2 rosters without Homeworlds, Discovery or E3.'); if (state.homeworlds) requireRule(!skills && semutaHomeworldRoster(state), 'Semuta Homeworlds require original Richese/CHOAM and classic factions with exact CHOAM, or Advanced Tleilax with exact CHOAM+Ix, and no other modules.'); - const g = state.discoveryEnabled ? initializeDiscoveryGameForAudit(state) + const g = selectedSkillsDiscovery ? initializeLeaderSkillsGameForAudit(state) + : state.discoveryEnabled ? initializeDiscoveryGameForAudit(state) : state.homeworlds ? initializeHomeworldOccupationGameForAudit(state) : skills ? initializeLeaderSkillsGameForAudit(state) : state.nexusCards ? state.expansions.length === 1 && !!byFaction(state, 'choam') @@ -25185,7 +25193,7 @@ function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParen if (bareBasicNativeArrivalOverlap(g)) return true; if (bareAdvancedNativeArrivalOverlap(g)) return true; if (e3DiscoveryOnlyOverlapProfile(g)) return true; - if (g.leaderSkills && selectedDiscoveryWithoutCardsProfile(g) && + if (g.leaderSkills && (selectedDiscoveryWithoutCardsProfile(g) || selectedDiscoverySkillsSemutaProfile(g)) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) 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; diff --git a/game/faction-module-profile.ts b/game/faction-module-profile.ts index e56da4be..01dcca3c 100644 --- a/game/faction-module-profile.ts +++ b/game/faction-module-profile.ts @@ -70,6 +70,15 @@ export function selectedDiscoveryWithoutCardsProfile(game: NexusCoreConfiguratio players.some(player => player.faction === 'ecaz' || player.faction === 'moritani')); } +/** The original Richese Semuta protocol can join these selected decks once + * Skills and Discovery have both been initialized. Homeworlds retain their + * separately supported Semuta envelope. */ +export function selectedDiscoverySkillsSemutaProfile(game: NexusCoreConfiguration): boolean { + return game.semutaPreview === true && !!game.leaderSkills && !game.homeworlds && + game.expansions.includes('choam') && !!game.players?.some(player => player.faction === 'richese') && + selectedDiscoveryWithoutCardsProfile({...game, semutaPreview: undefined}); +} + /** Original selected decks do not require their native factions or the * independent three-card variant. Hidden E3/Harkonnen compositions retain * their existing separately scoped paths. */ diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 2e3deead..5f9cb6a9 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -6,6 +6,7 @@ import { classicDiscoveryLeaderSkillsProfile, selectedDiscoveryLeaderSkillsProfi export type LeaderSkillProfile = { expansions: readonly string[]; advanced?: boolean; + leaderSkills?: unknown; players?: readonly { faction: string }[]; homeworlds?: unknown; nexusCards?: unknown; diff --git a/game/reference.ts b/game/reference.ts index f4a0f7d8..fe85fb3b 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -5927,7 +5927,7 @@ export const RULE_TOPICS: RuleTopic[] = [ searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter', steps: [ 'The Discovery-only fresh preview supports classics and selected Ixian/Tleilaxu, CHOAM/Richese and Ecaz/Moritani decks in Basic/Advanced2–6, including unused selected decks. It supplies seven extra Spice Cards/eight tokens; optional Tech requires3+ and Strongholds require Advanced. Nexus and independent cards are not required. Existing Advanced Moritani/Harkonnen and deeper interaction restrictions remain; complete-mode acceptance stays separate.', - 'Fresh selected-deck Discovery previews can also train Leader Skills without Nexus or independent Ecaz cards, including mixed and unseated families. Advanced Harkonnen with an actual Ecaz or Moritani seat, Semuta and deeper interactions keep their existing limitations.', + 'Fresh selected-deck Discovery previews can also train Leader Skills without Nexus or independent Ecaz cards, including mixed and unseated families. With Richese seated, supported Semuta discards can react while those modules are active. Advanced Harkonnen with an actual Ecaz or Moritani seat, other Semuta sources and deeper interactions keep their existing limitations.', '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.', diff --git a/tests/semuta-discovery-skills-ordinary.test.ts b/tests/semuta-discovery-skills-ordinary.test.ts new file mode 100644 index 00000000..4a702bb6 --- /dev/null +++ b/tests/semuta-discovery-skills-ordinary.test.ts @@ -0,0 +1,95 @@ +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 { TERRITORIES } from '../game/board'; +import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador'; +import { advanceSelectedIndependentNexus } from './fixture-selected-independent-nexus-cards'; +import { selectedDiscoverySkillsStart, advanceDiscoverySkillsBlow } from './selected-discovery-skills-fixture'; + +function player(game: Game, id: string) { + const seat = game.players.find(row => row.id === id); assert.ok(seat); return seat; +} +const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && + !g.truthtrance && !g.pendingTreacheryDiscard; + +/** Public mixed selected-deck start with real training, Discovery blow and + * Movement. Only unopened Spice order and conserved card custody are staged. */ +function fixture(advanced: boolean) { + const started = selectedDiscoverySkillsStart({advanced, + factions:['richese', 'ecaz', 'ixians', 'atreides']}); + assert.equal(started.setup.semutaPreview, true); + assert.ok(started.game.leaderSkills && started.game.discoveries); + let game = advanceDiscoverySkillsBlow(started.game, started.actions); + game = advanceSelectedIndependentNexus(game, + state => state.phase === 5 && state.active === 'atreides' && clean(state), started.actions); + const beforeInventory = inventory(game); + const give = (id: string, match: (card: Game['deck'][number]) => boolean) => { + const target = player(game, id); + const zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(row => row.hand)]; + const source = zones.find(zone => zone.some(match)); assert.ok(source); + const [card] = source.splice(source.findIndex(match), 1); + if (target.hand.length >= 4) source.push(target.hand.pop()!); + target.hand.push(card); + return card.id; + }; + give('richese', card => card.id === SEMUTA_DRUG_ID); + const hajr = give('atreides', card => card.effect === 'hajr'); + assert.deepEqual(inventory(game), beforeInventory); + if (advanced) { + // The Advanced face requires an actual shipment followed by movement. + game = applyAction(game, 'atreides', {type:'ship', territory:'polar_sink', sector:0, + amount:1, elite:0, allyPayment:0}); + game = advanceSelectedIndependentNexus(game, + state => state.phase === 5 && state.active === 'atreides' && clean(state), started.actions); + const own = viewGame(game, 'atreides'), seat = own.players.find(row => row.id === 'atreides'); assert.ok(seat); + const from = 'polar_sink:0'; + const destination = TERRITORIES.filter(place => place.type === 'sand' && + !game.players.some(row => Object.keys(row.forces).some(key => key.startsWith(`${place.id}:`))) && + !Object.keys(game.spice).some(key => key.startsWith(`${place.id}:`) && game.spice[key] > 0)) + .flatMap(place => place.sectors.map(sector => ({territory:place.id, sector}))) + .find(place => groundMoveRouteDistance(own, seat, from, `${place.territory}:${place.sector}`) === 1 && + botGroundMoveAllowed(own, seat, from, `${place.territory}:${place.sector}`, 0)); + assert.ok(destination); + game = applyAction(game, 'atreides', {type:'move', from, ...destination, amount:1, elite:0}); + game = advanceSelectedIndependentNexus(game, + state => state.phase === 5 && state.active === 'atreides' && clean(state), started.actions); + assert.equal(player(game, 'atreides').hajrWindow?.turn, game.turn); + } + return {game, hajr, beforeInventory}; +} + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} mixed Discovery Skills supports an original Hajr Semuta claim or pass`, () => { + const f = fixture(advanced); + const prior = structuredClone(f.game); + const pending = applyAction(prior, 'atreides', {type:'card', card:f.hajr}); + assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'ordinary:hajr'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const event = pending.pendingTreacheryDiscard!.batch.event; + const ownerView = viewGame(pending, 'richese'); + assert.equal(ownerView.semutaReaction?.canCommit, true); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(pending, 'richese'); + view.players.find(row => row.id === 'richese')!.bot = difficulty; + const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass'); + assert.ok(action, difficulty); + assert.doesNotThrow(() => applyAction(pending, 'richese', action), difficulty); + } + for (const claim of [false, true]) { + let next = structuredClone(pending); + if (claim) next = applyAction(next, 'richese', {type:'semutaCommit', event}); + else for (const seat of next.players) next = applyAction(next, seat.id, {type:'semutaPass', event}); + if (next.pendingTreacheryDiscard?.reaction?.stage === 'select') + next = applyAction(next, 'richese', {type:'semutaSelect', event, card:f.hajr}); + assert.equal(next.pendingTreacheryDiscard, null); + assert.equal(next.active, 'atreides'); + assert.ok(next.hajr.includes('atreides')); + assert.equal(player(next, 'richese').hand.some(card => card.id === f.hajr), claim); + assert.equal(next.discard.some(card => card.id === SEMUTA_DRUG_ID), claim); + assert.deepEqual(inventory(next), f.beforeInventory); + } + }); diff --git a/tests/semuta-discovery-skills-overlap-fixture.ts b/tests/semuta-discovery-skills-overlap-fixture.ts new file mode 100644 index 00000000..dbbc2a6e --- /dev/null +++ b/tests/semuta-discovery-skills-overlap-fixture.ts @@ -0,0 +1,64 @@ +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 as act, basicSemutaAmbassadorClean as clean, + basicSemutaAmbassadorPlayer as player, nextBasicSemutaAmbassadorStep } from './fixture-basic-semuta-ambassador'; +import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; + +export { act, clean, player }; + +/** Real public mixed-family setup and skills training. Only unopened Spice + * order and original Ambassador/Terror placements are controlled. */ +export function semutaDiscoverySkillsOverlapFixture(advanced: boolean) { + let game = createGame('SEMUTADISCOVERYSKILLSOVERLAP', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, + ['ecaz', 'ix', 'choam']); + for (const faction of ['moritani', 'ixians', 'richese', 'guild'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + game = act(game, 'ecaz', {type:'moduleOption', module:'discoveries', enabled:true}); + game = act(game, 'ecaz', {type:'moduleOption', module:'leaderSkills', 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); + const advance = (initial: Game, until: (state: Game) => boolean) => { + let state = initial; + for (let count = 0; count < 1800; count++) { + if (until(state)) return state; + assert.notEqual(state.status, 'finished'); + const step = state.status === 'setup' && state.setupStage === 'leaderSkills' + ? nextPairedChoamSkillsStep(state) : !state.response && !state.phaseOpening && + state.decision?.kind === 'richeseCache' + ? nextRicheseStrongholdsNativeStep(state) : nextBasicSemutaAmbassadorStep(state); + assert.ok(step, `No original continuation at ${state.status}/${state.phase}/${state.decision?.kind}`); + state = act(state, step.actor, step.action); + } + throw Error('The mixed Discovery/Skills game did not reach its requested boundary'); + }; + game = advance(game, state => state.status === 'playing'); + assert.equal(game.leaderSkills?.assignments.length, game.players.length); + assert.ok(game.discoveries && game.ecazAmbassadors && game.moritaniTerror); + assert.equal(game.semutaPreview, true); + assert.ok(!game.nexusCards && !game.ecazTreachery); + for (const position of advanced ? [0, 1] : [0]) { + const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && + (position === 0 ? card.discovery === 'discovery-hagga-basin' : !card.discovery)); + assert.ok(index >= position); + [game.spiceDeck[position], game.spiceDeck[index]] = [game.spiceDeck[index], game.spiceDeck[position]]; + } + game = advance(game, state => state.turn === 1 && state.phase === 5 && state.active === 'guild' && clean(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, advance}; +} diff --git a/tests/semuta-discovery-skills-overlap.test.ts b/tests/semuta-discovery-skills-overlap.test.ts new file mode 100644 index 00000000..d9059161 --- /dev/null +++ b/tests/semuta-discovery-skills-overlap.test.ts @@ -0,0 +1,58 @@ +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 { act, player, semutaDiscoverySkillsOverlapFixture } from './semuta-discovery-skills-overlap-fixture'; + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} mixed Semuta, Discovery and Skills retain the original Ecaz/Moritani arrival`, () => { + const f = semutaDiscoverySkillsOverlapFixture(advanced); + assert.equal(f.lobby.discoveryEnabled, true); + assert.ok(f.setup.discoveries && f.setup.leaderSkills && f.setup.semutaPreview); + assert.equal(f.game.leaderSkills!.assignments.length, f.game.players.length); + 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.ok(!f.game.nexusCards && !f.game.ecazTreachery); + assert.equal(f.game.semutaPreview, true); + const moritaniSpice = player(f.game, 'moritani').spice; + const duke = structuredClone(f.game.dukeVidal!.leader); + let game = act(f.game, 'guild', f.shipment); + game = f.advance(game, state => !!state.pendingArrivalOverlap); + const afterShipment = player(game, 'guild').spice; + const stolen = Math.ceil(afterShipment / 2); + 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); + } + const 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'}); + }; + const robbery = () => { + assert.equal(game.pendingTerrorEntry?.token, f.terror); + game = act(game, 'moritani', {type:'decision', reveal:true}); + game = act(game, 'moritani', {type:'decision', choice:'spice'}); + }; + if (first === 'ambassador') { ambassador(); robbery(); } + else { robbery(); ambassador(); } + assert.equal(game.pendingArrivalOverlap ?? null, null); + assert.equal(game.pendingAmbassador ?? null, null); + assert.equal(game.pendingTerrorEntry ?? null, null); + assert.equal(player(game, 'guild').spice, afterShipment - stolen); + assert.equal(player(game, 'moritani').spice, moritaniSpice + 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, duke); + assert.equal(game.leaderSkills!.assignments.length, game.players.length); + }, +); diff --git a/tests/semuta-discovery-skills-station.test.ts b/tests/semuta-discovery-skills-station.test.ts new file mode 100644 index 00000000..949ce29c --- /dev/null +++ b/tests/semuta-discovery-skills-station.test.ts @@ -0,0 +1,106 @@ +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 { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries'; +import { discoveryStormActions } from '../game/discovery-storm-options'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { selectedDiscoverySkillsStart } from './selected-discovery-skills-fixture'; +import { advanceTestingStationContest } from './fixture-testing-station-contest'; +import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; +import { reactSemutaSpecial } from './fixture-semuta-special-karama'; + +const SAPHO = 'richese-juice-of-sapho'; +const STATION = 'ecological-testing-station'; +const cards = (game: Game) => [...game.deck, ...game.discard, ...(game.richeseCache ?? []), + ...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); +function player(game: Game, id: string) { + const seat = game.players.find(candidate => candidate.id === id); + assert.ok(seat); return seat; +} + +/** Public lobby, original native setup and all14 training. The rare revealed + * Station, physical occupants and the two singleton cards are staged by + * conserved transfers; the ordinary first Storm opens the real priority. */ +function fixture(advanced: boolean, mixed: boolean) { + const started = selectedDiscoverySkillsStart({advanced, + factions: mixed ? ['richese', 'ixians', 'atreides', 'emperor'] : ['richese', 'atreides', 'emperor'], + expansions: mixed ? ['choam', 'ix', 'ecaz'] : ['choam']}); + let game = started.game; + assert.equal(game.semutaPreview, true, 'The public Discovery/Skills start must enable the connected Semuta reaction'); + assert.equal(game.leaderSkills!.assignments.length, game.players.length); + validateLeaderSkills(game.leaderSkills!, game.players); + assert.ok(!game.nexusCards && !game.ecazTreachery); + const inventory = cards(game); + for (const seat of game.players) game.deck.push(...seat.hand.splice(0)); + const token = game.discoveries!.tokens.find(candidate => candidate.face === STATION); + assert.ok(token && token.status === 'supply'); + const position = Object.values(DISCOVERY_CARD_PLACEMENTS).find(candidate => candidate.type === token.type); + assert.ok(position); + Object.assign(token, {status:'placed', territory:position.territory, sector:position.sector, revealedTurn:game.turn}); + validateDiscoveryState(game.discoveries!); + for (const id of ['atreides', 'emperor']) { + const seat = player(game, id); assert.ok(seat.reserves > 0); + seat.reserves--; seat.forces[`${STATION}:0`] = (seat.forces[`${STATION}:0`] ?? 0) + 1; + } + moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'richese'); + game = advanceTestingStationContest(game, state => !!state.saphoStationPriority && + state.decision?.kind === 'ecologicalStorm'); + const order = game.ecologicalStorm!.queue!.order; + assert.equal(order.length, 2); + const holder = order[1]; + moveSemutaEmperorCard(game, SAPHO, holder); + assert.deepEqual(cards(game), inventory); + return {game, holder, first:order[0], order:[...order], inventory}; +} + +for (const advanced of [false, true]) for (const mixed of [false, true]) + for (const claim of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} ${mixed ? 'mixed' : 'CHOAM'} Discovery/Skills restores Station after Semuta ${claim ? 'claim' : 'pass'}`, () => { + const f = fixture(advanced, mixed); + let game = f.game; + const priority = game.saphoStationPriority!; + game = applyAction(game, f.first, {type:'saphoStationPass', event:priority.event}); + const option = viewGame(game, f.holder).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === 'first'); + assert.ok(option); + game = applyAction(game, f.holder, {type:'card', card:SAPHO, scope:option.scope, event:option.event, mode:option.mode}); + assert.ok(!game.saphoStationPriority); + assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'sapho:station'); + assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'saphoStationDiscard'); + assert.deepEqual(game.ecologicalStorm?.queue?.order, [...f.order].reverse()); + assert.equal(game.ecologicalStorm?.queue?.choices.length, 0); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'richese'); + view.players.find(seat => seat.id === 'richese')!.bot = difficulty; + const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass'); + assert.ok(action, difficulty); + assert.doesNotThrow(() => applyAction(game, 'richese', action)); + } + game = reactSemutaSpecial(game, 'richese', claim); + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(game.decision?.kind, 'ecologicalStorm'); + assert.equal(game.decision?.player, f.holder); + assert.equal(player(game, 'richese').hand.some(card => card.id === SAPHO), claim); + assert.equal(player(game, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID), !claim); + for (let i = 0; i < 2; i++) { + const owner = game.ecologicalStorm!.owner; + const action = discoveryStormActions(viewGame(game, owner)).find(choice => choice.delta === 0); + assert.ok(action); + game = applyAction(game, owner, action); + } + assert.equal(game.ecologicalStorm?.stage, 'complete'); + assert.equal(game.ecologicalStorm.queue?.choices.length, 2); + if (mixed) { + // A real first Storm with Ixians must place the HMS before Spice Blow. + const decision = game.decision; + assert.ok(decision?.kind === 'mobileStronghold' && decision.placement); + game = applyAction(game, decision.player, {type:'decision', location:'polar_sink:0'}); + assert.equal(game.mobileStronghold?.location, 'polar_sink:0'); + } + assert.equal(game.phase, 1); + assert.deepEqual(cards(game), f.inventory); + validateLeaderSkills(game.leaderSkills!, game.players); + validateDiscoveryState(game.discoveries!); + }); diff --git a/tests/semuta-discovery-skills-truth-order.test.ts b/tests/semuta-discovery-skills-truth-order.test.ts new file mode 100644 index 00000000..e9e5bfdf --- /dev/null +++ b/tests/semuta-discovery-skills-truth-order.test.ts @@ -0,0 +1,90 @@ +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 { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { semutaSkillsInventory } from './fixture-semuta-skills'; +import { finishOriginalTruthQuestions, finishTruthOrder } from './fixture-sapho-truth-order'; +import { selectedDiscoverySkillsStart, advanceDiscoverySkillsBlow } from './selected-discovery-skills-fixture'; + +const SAPHO = 'richese-juice-of-sapho'; +const seat = (game: Game, id: string) => { + const player = game.players.find(row => row.id === id); assert.ok(player); return player; +}; + +/** Real public mixed-deck setup, training and Discovery blow. Reorder only + * unopened Spice Cards and conserve every physical Treachery Card while + * staging the two simultaneous question holders and Sapho/Semuta custody. */ +function fixture(advanced: boolean) { + const started = selectedDiscoverySkillsStart({advanced, + factions:['richese', 'ecaz', 'atreides', 'emperor']}); + assert.equal(started.setup.semutaPreview, true); + assert.ok(started.game.leaderSkills && started.game.discoveries); + let game = advanceDiscoverySkillsBlow(started.game, started.actions); + const inventory = semutaSkillsInventory(game); + for (const player of game.players) game.deck.push(...player.hand.splice(0)); + const give = (id: string, identity: string) => { + const zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(row => row.hand)]; + const source = zones.find(zone => zone.some(card => card.id === identity || card.effect === identity)); + assert.ok(source, `Missing original ${identity}`); + const [card] = source.splice(source.findIndex(row => row.id === identity || row.effect === identity), 1); + seat(game, id).hand.push(card); + return card.id; + }; + const ordered = game.order.filter(id => id === 'atreides' || id === 'emperor'); + assert.equal(ordered.length, 2); + const [other, holder] = ordered; + give('richese', SEMUTA_DRUG_ID); + give(holder, SAPHO); + const truth = give(holder, 'truthtrance'), otherTruth = give(other, 'truthtrance'); + assert.deepEqual(semutaSkillsInventory(game), inventory); + const parent = {turn:game.turn, phase:game.phase, active:game.active, + order:[...game.order], movementRemaining:structuredClone(game.movementRemaining)}; + for (const [id, card] of [[holder, truth], [other, otherTruth]]) + game = applyAction(game, id, {type:'card', card}); + while (game.truthtrance?.stage === 'priority') { + const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor); + game = applyAction(game, actor.id, {type:'truthPass'}); + } + assert.equal(game.truthtrance?.stage, 'order'); + return {game, holder, parent, inventory}; +} + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} mixed Discovery Skills preserves Sapho Truth-order through Semuta`, () => { + const f = fixture(advanced), frame = f.game.truthtrance!.order!; + assert.ok(viewGame(f.game, f.holder).saphoTruthOrderChoices.some(choice => choice.card === SAPHO && choice.mode === 'first')); + const pending = applyAction(f.game, f.holder, {type:'saphoTruthOrder', event:frame.event, + revision:frame.revision, card:SAPHO, mode:'first'}); + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoTruthOrderDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(pending.truthtrance, null); + const event = pending.pendingTreacheryDiscard!.batch.event; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(pending, 'richese'); + view.players.find(row => row.id === 'richese')!.bot = difficulty; + const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass'); + assert.ok(action, difficulty); + assert.doesNotThrow(() => applyAction(pending, 'richese', action), difficulty); + } + for (const claim of [false, true]) { + let game = structuredClone(pending); + if (claim) game = applyAction(game, 'richese', {type:'semutaCommit', event}); + else for (const player of game.players) game = applyAction(game, player.id, {type:'semutaPass', event}); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = applyAction(game, 'richese', {type:'semutaSelect', event, card:SAPHO}); + assert.equal(game.pendingTreacheryDiscard, null); + assert.equal(seat(game, 'richese').hand.some(card => card.id === SAPHO), claim); + assert.equal(seat(game, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID), !claim); + game = finishTruthOrder(game); + assert.equal(game.truthtrance!.queue[0].player, f.holder); + assert.deepEqual(game.order, f.parent.order); + assert.equal(game.turn, f.parent.turn); assert.equal(game.phase, f.parent.phase); + assert.equal(game.active, f.parent.active); + assert.deepEqual(game.movementRemaining, f.parent.movementRemaining); + game = finishOriginalTruthQuestions(game); + assert.equal(game.truthtrance, null); + assert.deepEqual(semutaSkillsInventory(game), f.inventory); + } + });