From 27959b46555c41d79a2da5cb7e928e477db847a9 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:27:22 +1100 Subject: [PATCH] Offer Semuta on transport, Gamont and Storm conversion costs --- docs/CURRENT_STATUS.md | 40 +++ docs/IMPLEMENTATION_STATUS.md | 34 +++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 87 +++++++ game/engine.ts | 20 +- game/reference.ts | 7 + tests/fixture-semuta-advisor-gamont.ts | 221 ++++++++++++++++ tests/fixture-semuta-advisor-storm.ts | 243 ++++++++++++++++++ tests/fixture-semuta-advisor-transport.ts | 215 ++++++++++++++++ tests/semuta-advisor-gamont-engine.test.ts | 182 +++++++++++++ tests/semuta-advisor-storm-engine.test.ts | 190 ++++++++++++++ tests/semuta-advisor-transport-engine.test.ts | 176 +++++++++++++ 11 files changed, 1413 insertions(+), 2 deletions(-) create mode 100644 tests/fixture-semuta-advisor-gamont.ts create mode 100644 tests/fixture-semuta-advisor-storm.ts create mode 100644 tests/fixture-semuta-advisor-transport.ts create mode 100644 tests/semuta-advisor-gamont-engine.test.ts create mode 100644 tests/semuta-advisor-storm-engine.test.ts create mode 100644 tests/semuta-advisor-transport-engine.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 209ffaab..cba68d5f 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,46 @@ open mode gates. ## Current checkpoint and work +**Semuta on committed transport, Gamont and Storm conversions — 26 October. Prototyped / Partial.** +`karamaAdvisorFlipDiscard` now offers the exact used cancellation cost for +mandatory conversions after Guild cross/return, Trip to Gamont and current-sector +Storm casualties. Pure native validation retains the committed producer, held +queue and original continuation. Claim/pass applies only conversion denial; +payment, transfer/return, disposed fuel and completed casualties stay committed. +Producer-specific signature additions leave prior preparation/matching/ground +formats unchanged. Existing persistent controls/minimal bots serve these sources. + +Three disjoint source lanes and root integration pass **13 files,148/148**, +types/lint/build and **12 native engine/GameTable SSR/actual Semuta-callback +journeys** on unchanged source. Twenty-four new cases cover six sources, +claim/pass, native allowance, original suffixes, source rejection and four +profiles. Every source uses original Advanced setup, actual cache1 Semuta and +original dealt Karama; all45 cards remain physical. Guild85 transports native5 +for cross5 or return3, retains the paid result, then really ends its original +turn once. Gamont84/89 preserves actual fuel disposal and one returned A force; +ordinary/final Mentat restores its original choice before confirmation. Denial +keeps BG at two strongholds and advances turn2; allowance reaches three and wins +only through original final confirmation. Storm115 uses actual A shipment1 for2, +Intrusion/allowed flip, first dials2+3 to6 and a real six-card shuffle3/4. Its +sector9 casualty stays committed through the cost; native remainder either ends9 +with the held advisor or reaches10 and really kills BG before Spice opens. +No played hand, force, stance, wallet, loss, clock, card or cursor is installed. + +Narrow feedback finishes the initial CHOAM market before first dials, and actual +closing market/final Mentat before the next Storm draw. The scoped shuffle now +belongs to that real final Done. Fixture static feedback fixes the decision +narrowing and numeric Storm-card sort; production timing/calculations stay intact. +Original Guild parent already owns deferred arrival, so its arrival qualifier +needs no change. Other automatic/matching parents, wider modules and complete +modes/final assurance remain open. + +Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-advisor-producers-proof-v4/`; +journeys/HTML: adjacent `20261026-semuta-advisor-producers-runtime/`. +No room/D1/server, reset/restart, deployment or recovery operation occurs. These +are engine, SSR and component-callback results; DOM/hydration, browser/mobile and +deployed/final acceptance remain open. [Source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#committed-transport-gamont-and-storm-conversion-costs--26-october-2026). + **Semuta on native Advanced advisor conversions — 26 October. Prototyped / Partial.** `karamaAdvisorFlipDiscard` now offers the exact printed-role cancellation cost for native preparation, ordinary incoming-group matching and mandatory conversion diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index eb675e10..d39415dc 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,39 @@ # Dune implementation status +## 26 October 2026 — Semuta on committed transport, Gamont and Storm costs + +**Prototyped / Partial.** Existing advisor-conversion cost continuation now +retains native Guild cross/return, Gamont return and current-sector Storm +casualties through fresh claim/pass. Pure validation preserves original paid/ +committed producers; only conversion denial runs before their native suffix. +Producer-specific signatures keep earlier receipt bytes. Existing persistent +controls/minimal bots serve the sources; no new mode or rule interpretation. +[Source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#committed-transport-gamont-and-storm-conversion-costs--26-october-2026). + +Three disjoint source lanes and root integration pass **13 files,148/148**, +types/lint/build and **12 native engine/GameTable SSR/actual Semuta-callback +journeys** on unchanged source. Twenty-four new cases cover six original +Advanced sources, claim/pass, allowance, source rejection, original suffixes +and four profiles. Actual cache1 and dealt Karama supply all45-card sources. +Guild85 physically transports native5 for cross5/return3, keeps payment and +really ends its original turn. Gamont84/89 keeps its disposed card/returned A1, +restores ordinary/final Mentat, then denies BG's third stronghold and starts +turn2 or allows native victory only at final confirmation. Storm115 earns its +advisor through actual paid1/tariff2 Intrusion, first dials2+3 to6, real native +shuffle3/4 and turn2 sector9 casualty. Native remainder ends9 with hold or kills +BG at10, once and before Spice. No played state, loss, order or cursor is installed. + +Narrow feedback completes CHOAM's initial market before dials and actual closing +market/final Mentat before the next Storm draw. Fixture static fixes widen the +current decision and sort numeric Storm cards explicitly. Native timing and +calculations stay intact; no extra Guild arrival qualifier is needed. +Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-advisor-producers-proof-v4/`; +journeys/HTML: adjacent `20261026-semuta-advisor-producers-runtime/`. +No room/D1/server, reset/restart, deployment or recovery operation occurs. +DOM/hydration, browser/mobile, other advisor parents/modules, complete modes and +deployed/final assurance remain open. + ## 26 October 2026 — Semuta on native Advanced advisor conversion costs **Prototyped / Partial.** One typed used-cost continuation connects native diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 7254ff6a..601285f1 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -2,6 +2,93 @@ 7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits. +## Committed transport, Gamont and Storm conversion costs — 26 October 2026 + +**Prototyped / Partial.** The existing `karamaAdvisorFlipDiscard` now offers +printed-role cancellation costs for native mandatory conversion after Guild +cross/return, Trip to Gamont and current-sector Storm casualties. Existing +persistent neutral controls, private claim/pass and minimal legal profiles serve +these sources. This extends physical fresh-discard timing through the original +[native conversion contracts](ADVISOR_AUTOMATIC_FLIPS.md); it adds no publisher +ruling or mode admission. The existing continuously-alone duration remains +provisional under the 14 October recommended policy. + +Pure original automatic-conversion validation binds the public source to its +private [Guild transport](../game/advisor-automatic-flip.ts), Gamont or Storm +parent before the cost is spent and when it resumes. No transfer, return, fee, +fuel disposal, casualty, draw or traversal is simulated. Producer-specific +signature additions bind original parents and their continuation/cursor only +for these newly admitted producers; earlier preparation, matching and ground +signature bytes remain unchanged. + +After fresh claim/pass, retire the group, restore its response and finish native +denial once: + +- Guild cross/return retains original permission/rate/payment, physically moved + five counters, spent shipment and unused ground move. Conversion denial keeps + the held advisor group. The original transport parent is consumed before + original Intrusion/arrival; return has no arrival. Its native parent already + defers entry, so no new arrival qualifier or duplicate entry is needed. Actual + endMovement closes only the original Guild turn. +- Gamont retains its actual returned counter and disposed fuel. Denial preserves + the advisor hold, then restores ordinary Mentat or the original final CHOAM + chooser. Only subsequent native ready/Done controls evaluate victory or start + the next turn. The original card/return is never repeated. Allowance converts + first and can change the real stronghold result before confirmation. +- Storm retains all casualties already applied in its current sector, the exact + surviving group, public occurrence and private ready/traversal cursor. Future + sectors remain untouched through Semuta. Native continuation resumes after + conversion denial/allowance; a held advisor is still killed if a later sector + reaches its actual force location. No second loss, Storm draw, completion log + or premature Spice reveal is introduced. + +Three disjoint source lanes, bounded native/diff review and root integration pass +**13 files,148/148**, types/lint/build and **12 native engine/GameTable SSR/actual +Semuta-callback journeys** on unchanged source. Twenty-four new cases cover six +sources, claim/pass, allowance, original continuation and phase/victory results, +source rejection and four minimal profiles. Every source uses fresh original +Advanced setup, actual cache1 Semuta and original dealt Karama. All45 cards +remain physical; no played hand, force, stance, wallet, loss, clock, card order, +receipt, outcome or cursor is installed. + +Guild85 uses R/C/BG/A/G, native BG advisor beside original G5 at Tuek's5 and actual +early Take. Cross transports5 to empty Basin10 for5; return sends5 to reserves +for3. Both keep their original paid result through denial and really close the +Guild turn once. Gamont84/89 uses R/C/BG/A, original CHOAM Gamont, paid BG2 at +Carthag and real1 movement to Tabr, plus actual A9 departure. Gamont returns the +last A1 beside BG's advisor. Denial keeps two BG strongholds and starts turn2; +allowance grants the third and wins only on original final confirmation. + +Storm115 uses R/C/BG/A/E. Original BG setup at Oh Gap10 starts fighter-side; +actual A reserve1 shipment to Oh Gap9 costs2 and its real Intrusion/allowed flip +earns the advisor stance. First dials2+3 move1→6. A scoped five-scalar permutation +selects only the original native six-card shuffle3/4 on actual final CHOAM Mentat +Done. Real turn2 readiness then kills A1 at9 and opens conversion before10. +Ending9 retains the denied advisor/hold; continuing10 actually kills BG1. Both +finish native traversal once at phase1, before the next Spice reveal. + +Narrow feedback preserves initial CHOAM market priority over first dials and +finishes the real closing market/final Mentat before Storm draw. Static fixture +repairs widen the current decision after native callbacks and supply a numeric +Storm-card sort. No production timing or rule calculation changes. Homeworld, +battle/Face Dance, relocation/spiritual and other matching producers retain +separate Semuta follow-ups; broader modules, complete modes and final assurance +remain open. + +Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-advisor-producers-proof-v4/`; +journeys/HTML: adjacent `20261026-semuta-advisor-producers-runtime/`. +No room/D1/server, reset/restart, deployment or recovery operation occurs. These +are engine, SSR and actual component callbacks; DOM/hydration, browser/mobile +and deployed/final acceptance remain open. + +Evidence: [transport sources](../tests/fixture-semuta-advisor-transport.ts), +[transport cases](../tests/semuta-advisor-transport-engine.test.ts), +[Gamont sources](../tests/fixture-semuta-advisor-gamont.ts), +[Gamont cases](../tests/semuta-advisor-gamont-engine.test.ts), +[Storm sources](../tests/fixture-semuta-advisor-storm.ts) and +[Storm cases](../tests/semuta-advisor-storm-engine.test.ts). + ## Native Advanced advisor conversion costs — 26 October 2026 **Prototyped / Partial.** `karamaAdvisorFlipDiscard` offers the fresh printed-role diff --git a/game/engine.ts b/game/engine.ts index 1113c358..5fd9c3a2 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -6071,7 +6071,7 @@ function semutaMarketGrantKaramaResponse(g: Game, response: ResponseWindow, play } } /** Ordinary Advanced advisor conversion sources retain their native timing: - * preparation, uncommitted matching or an already completed ground movement. */ + * preparation, uncommitted matching or an already completed native producer. */ function semutaAdvisorFlipKaramaResponse(g: Game, response: ResponseWindow, player: string): boolean { if (response.kind !== 'advisorFlip' || !g.advanced || response.owner !== byFaction(g,'beneGesserit')?.id || @@ -6089,7 +6089,7 @@ function semutaAdvisorFlipKaramaResponse(g: Game, response: ResponseWindow, play (!!g.pendingAutomaticAdvisorFlips?.length !== !!response.automaticAdvisorFlip)) return false; if ((g.auction || g.richeseAuction) && !response.advisorPreparationSource) return false; if (response.automaticAdvisorFlip && - g.pendingAutomaticAdvisorFlips?.some(source => source.producer !== 'groundMovement')) return false; + g.pendingAutomaticAdvisorFlips?.some(source => !['groundMovement','guildTransport','gamont','storm'].includes(source.producer))) return false; const context: Game = {...g,pendingTreacheryDiscard:null,response}; try { // Native source quotes validate denial without requiring the spent card, @@ -6125,6 +6125,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu const e3 = c.kind === 'karamaEcazMoritaniDiscard'; const marketGrant = c.kind === 'karamaMarketDiscard' || c.kind === 'karamaFremenGrantDiscard'; const advisor = c.kind === 'karamaAdvisorFlipDiscard'; + const advisorProducer = advisor ? c.use.response.automaticAdvisorFlip?.producer : undefined; return JSON.stringify({ player: c.player, card: c.card, use: c.use, parent: nullentropyParentSignature(g, c.resume, @@ -6339,6 +6340,21 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu noField:p.noField, noFieldEvent:p.noFieldEvent, flipBlocked:p.advisorFlipMovementBlocked, hajr:p.hajrPlays, window:p.hajrWindow, leaders:p.leaders, kwisatz:p.kwisatz})), } : undefined, + advisorParent: advisorProducer === 'guildTransport' ? { + transport:g.pendingAutomaticAdvisorTransport, pending:g.pendingGuildTransport, + rate:g.guildRateBlocked, permission:g.guildTransportBlocked, + price:g.karamaShipping, homeworlds:g.homeworlds, + } : advisorProducer === 'gamont' ? { + gamont:g.pendingAutomaticAdvisorGamont, worthless:g.pendingChoamWorthless, + mentat:g.choamMentatPending, market:g.choamMarket, + cleanup:g.moritaniTerror, order:g.order, spice:g.spice, + } : advisorProducer === 'storm' ? { + storm:g.pendingAutomaticAdvisorStorm, sector:g.storm, pending:g.stormPending, + cursor:g.stormResolution, source:g.stormMovementSource, + dials:g.stormDials, dialers:g.stormDialers, card:g.stormCard, known:g.stormCardKnown, + spice:g.spice, wall:g.shieldWallDestroyed, + ecological:g.ecologicalStorm, maker:g.greatMaker, + } : undefined, order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, diff --git a/game/reference.ts b/game/reference.ts index b3b6ade4..71599e3a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1298,6 +1298,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Karama stopping native Ecaz or Moritani placement, Moritani’s earned Duke acquisition, Ecaz Occupy or Enemy of My Enemy also offers its used cost. Claim or Continue keeps earlier payments, physical tokens, the selected battle lead and paid entry intact, then applies the original denial once. Moritani may still reveal or decline its Terror token. Ecaz’s shared-stronghold Collection bonus is Advanced-only: a restricted fresh development setup offers this cost before ordinary collection settles, withholding only the canceled bonus. Basic collection has no such bonus; public Advanced Semuta starts and wider combinations remain guarded.', 'Karama stopping a native CHOAM sale, Richese Black Market, Ixian ally replacement or Fremen allied free-revival grant also offers its fresh used cost. Claim or Continue applies the original denial once: a canceled sale keeps its unsold card and earns nothing; Black Market returns through its unbid continuation; replacement keeps the original paid card and skips the exchange; the ally retains intrinsic revival rates and completed returns. Allowance still uses the original sale, auction, draw or grant controls. Only the used Treachery cost is fresh at this response.', 'Advanced advisor preparation, ordinary incoming-group matching and mandatory conversion after a ground move also offer the used Karama cost. Claim or Continue preserves the original auction or movement boundary, applies only native conversion denial and resumes once. Matching keeps the uncommitted groups and unused move; a completed departure keeps its transferred forces and queued arrival. The existing continuously-alone hold is provisional. Other advisor producers and broader parents remain unfinished.', + 'Mandatory advisor conversions after committed Guild cross-shipment or reserve return, Trip to Gamont and a Storm sector also offer the exact used Karama cost. Claim or Continue retains the original payment, transferred or returned counters, used fuel and completed casualties. The original parent then resumes its arrival, Mentat choice or remaining Storm traversal once. Denial preserves the existing continuously-alone hold while its physical occurrence survives; it does not reverse the producer or stop later Storm casualties.', 'Printed-role Karama stopping ordinary Basic Spiritual Advisors, Atreides Movement foresight, Advanced Fremen Storm knowledge or native Face Dancer replacement also offers its exact used cost. Claim or Continue finishes that original prevention once. The paid shipment and remaining turn stay intact; the stopped free packet is not placed. Foresight gains no new knowledge or draw. An original Bene Gesserit stance or Guild opening choice waits unchanged until the Atreides cost finishes. A stopped replacement retains the original Face Dancers and reserve, with its attempt still used. Storm opening acknowledgements must finish before cancellation; they are never bypassed or repeated. Complex Spiritual and arrival parents remain separate.', 'Printed-role Karama stopping ordinary native Fremen range, Cyborg range, Advanced Guild timing or HMS movement/collection also offers its exact used cost. Claim or Continue completes the original denial once. Fremen and Ixian forces remain in place with their move unused and current-use restriction intact; choose a legal replacement. Guild follows the original remaining queue under its native timing lock. A canceled HMS route neither relocates nor collects; only after the fresh cost finishes does the original Storm turn begin, including its normal Advanced card handling. No declaration, payment, movement, collection or turn opening repeats. Card-flight and other complex parents retain their separate boundaries.', 'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.', @@ -1476,6 +1477,12 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-advisor-matching-engine.test.ts', 'tests/fixture-semuta-advisor-alone.ts', 'tests/semuta-advisor-alone-engine.test.ts', + 'tests/fixture-semuta-advisor-transport.ts', + 'tests/semuta-advisor-transport-engine.test.ts', + 'tests/fixture-semuta-advisor-gamont.ts', + 'tests/semuta-advisor-gamont-engine.test.ts', + 'tests/fixture-semuta-advisor-storm.ts', + 'tests/semuta-advisor-storm-engine.test.ts', 'tests/fixture-semuta-spiritual-foresight.ts', 'tests/semuta-spiritual-foresight-engine.test.ts', 'tests/fixture-semuta-storm-dancer.ts', diff --git a/tests/fixture-semuta-advisor-gamont.ts b/tests/fixture-semuta-advisor-gamont.ts new file mode 100644 index 00000000..39b53278 --- /dev/null +++ b/tests/fixture-semuta-advisor-gamont.ts @@ -0,0 +1,221 @@ +import assert from 'node:assert/strict'; +import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { isAdvisor } from '../game/advisors'; +import { strongholdProgress } from '../game/victory-progress'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; +import { + actSemutaAdvisorMatching, nextSemutaAdvisorMatching, passSemutaAdvisorMatching, claimSemutaAdvisorMatching, + semutaAdvisorMatchingPlayer, semutaAdvisorMatchingInventory, +} from './fixture-semuta-advisor-matching'; + +export type SemutaAdvisorGamontStep = { actor: string; action: Action }; +export const actSemutaAdvisorGamont = actSemutaAdvisorMatching; +export const passSemutaAdvisorGamont = passSemutaAdvisorMatching; +export const claimSemutaAdvisorGamont = claimSemutaAdvisorMatching; +export const semutaAdvisorGamontPlayer = semutaAdvisorMatchingPlayer; +export const semutaAdvisorGamontInventory = semutaAdvisorMatchingInventory; +export const semutaAdvisorGamontClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && + !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingAutomaticAdvisorGamont && !game.pendingAutomaticAdvisorFlips?.length; +export const semutaAdvisorGamontPhysical = (game: Game) => game.players.map(p => ({ id: p.id, spice: p.spice, + forces: p.forces, elites: p.elites, reserves: p.reserves, tanks: p.tanks, + shipped: p.shipped, moved: p.moved, revived: p.revived, freeForcesRevived: p.freeForcesRevived })); +export const semutaAdvisorGamontStrongholds = (game: Game, id: string) => + strongholdProgress(game).progress.find(p => p.player === id)!.strongholds; + +/** Native markets close without selling the held fuel. Original final CHOAM + * confirmation, conversion and every fresh group remain caller-owned. */ +export function nextSemutaAdvisorGamont(game: Game): SemutaAdvisorGamontStep | null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard && game.decision?.kind === 'choamMentat') return null; + return nextSemutaAdvisorMatching(game); +} +export const nextSemutaAdvisorGamontStep = nextSemutaAdvisorGamont; +export function advanceSemutaAdvisorGamont(game: Game, until: (state: Game) => boolean, + actions: SemutaAdvisorGamontStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextSemutaAdvisorGamont(game); + assert.ok(step, `Choose the original Gamont/conversion/final control at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actSemutaAdvisorGamont(game, step.actor, step.action, actions); + } + throw Error('The native Semuta/Gamont programme did not reach its finite boundary'); +} +export function allowSemutaAdvisorGamont(game: Game, actions: SemutaAdvisorGamontStep[] = []): Game { + const source = game.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'gamont'); + for (let count = 0; game.response?.automaticAdvisorFlip?.event === source.event && count < game.players.length; count++) { + const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor); + game = actSemutaAdvisorGamont(game, actor.id, { type: 'passResponse' }, actions); + } + assert.notEqual(game.response?.automaticAdvisorFlip?.event, source.event); return game; +} +/** Settle this original conversion only. Stop at ordinary Mentat or the + * restored final chooser, before any actual victory confirmation. */ +export function finishSemutaAdvisorGamont(game: Game, actions: SemutaAdvisorGamontStep[] = []): Game { + assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first'); + return advanceSemutaAdvisorGamont(game, state => !state.response && !state.phaseOpening && + !state.pendingTreacheryDiscard && !state.pendingAutomaticAdvisorGamont && !state.pendingAutomaticAdvisorFlips?.length && + (!state.decision || state.decision.kind === 'choamMentat'), actions); +} +/** Explicit original ready/market/final-confirmation actions, after the caller + * has resolved conversion. No synthetic victory or next turn is assigned. */ +export function closeSemutaAdvisorGamont(game: Game, actions: SemutaAdvisorGamontStep[] = []): Game { + const turn = game.turn; + for (let count = 0; count < 160; count++) { + if (game.status === 'finished' || game.turn > turn) return game; + assert.ok(!game.response && !game.pendingTreacheryDiscard && !game.pendingAutomaticAdvisorGamont && + !game.pendingAutomaticAdvisorFlips?.length, 'Resolve the actual conversion/fresh cost before confirming Mentat'); + const step = game.decision?.kind === 'choamMentat' && !game.phaseOpening + ? { actor: game.decision.player, action: { type: 'decision', done: true } satisfies Action } + : nextSemutaAdvisorGamont(game); + assert.ok(step); game = actSemutaAdvisorGamont(game, step.actor, step.action, actions); + } + throw Error('The original Gamont Mentat did not reach victory or its next turn'); +} + +/** Genuine paid Semuta and original Gamont/Karama. A native return and its + * completed fuel disposal earn the automatic conversion source before cost. */ +export function createSemutaAdvisorGamontFixture(options: { final?: boolean } = {}) { + const final = options.final ?? true, seller = 'richese', holder = 'choam', owner = 'beneGesserit', spender = 'atreides'; + const territory = 'arrakeen', location = 'arrakeen:10'; + let game = createGame('SEMUTAADVISORGAMONT', newPlayer(seller, 'Richese', 'richese'), true, ['choam']); + for (const faction of ['choam', 'beneGesserit', 'atreides'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: SemutaAdvisorGamontStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaAdvisorGamont(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorGamont(game, until, actions); }; + const transition = (run: () => void) => { + const before = structuredClone(game), at = actions.length; run(); + return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) }; + }; + for (const p of game.players) act(p.id, { type: 'ready' }); + const lobby = structuredClone(game); + game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); + const setup = structuredClone(game), initialization = { before: lobby, after: setup, + entry: 'original-undealt-advanced-semuta-preview' }; + assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true); + assert.ok(game.players.every(p => p.hand.length === 0)); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + const originalInventory = semutaAdvisorGamontInventory(game); assert.equal(originalInventory.length, 45); + const printed = game.deck.find(card => card.effect === 'karama'), gamontCard = game.deck.find(card => card.name === 'Trip to Gamont'); + const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2); assert.equal(ordinary.length, 2); + assert.ok(printed && gamontCard); + const selected = [ordinary[0], gamontCard, ordinary[1], printed], dealOrder = selected.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: card.id }; + }); + const beforeStartingDeal = structuredClone(game); + let advisorSetup: { before: Game; steps: SemutaAdvisorGamontStep[]; after: Game } | null = null; + for (let count = 0; game.status === 'setup' && count < 100; count++) { + const step = nextSemutaAdvisorGamont(game); assert.ok(step); + const before = step.action.type === 'advisorSetup' ? structuredClone(game) : null; + act(step.actor, step.action); + if (before) advisorSetup = { before, steps: [structuredClone(step)], after: structuredClone(game) }; + } + assert.equal(game.status, 'playing'); assert.ok(advisorSetup); + const afterSetup = structuredClone(game), cost = printed.id, gamont = gamontCard.id; + assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id)); + assert.equal(semutaAdvisorGamontPlayer(game, owner).forces[location], 1); assert.equal(isAdvisor(semutaAdvisorGamontPlayer(game, owner), territory), true); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + for (const [position, face] of ['funeral_plain', 'hagga_basin'].entries()) { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && card.territory === face && !card.discovery); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); + spiceOrder.push({ position, from, card: structuredClone(card) }); + } + let beforeBid: Game | undefined, bid: SemutaAdvisorGamontStep | undefined, bidAt = -1; + for (let count = 0; count < 900 && !semutaAdvisorGamontPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const step = nextSemutaAdvisorGamont(game); assert.ok(step); + if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) { + beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length; + } + act(step.actor, step.action); + } + assert.ok(beforeBid && bid && bidAt >= 0); + const acquired = structuredClone(game), acquisition = { before: beforeBid, bid, + steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 }; + assert.equal(semutaAdvisorGamontPlayer(beforeBid, holder).spice - semutaAdvisorGamontPlayer(game, holder).spice, 1); + reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'advisorBattle'); + const declinedPreparation = transition(() => { + act(owner, { type: 'decision', accept: false }); + reach(state => state.phase === 5 && state.active === owner && semutaAdvisorGamontClean(state)); + }); + const bgShipment = transition(() => { + act(owner, { type: 'ship', territory: 'carthag', sector: 11, amount: 2, elite: 0, allyPayment: 0 }); + reach(semutaAdvisorGamontClean); + }); + assert.equal(semutaAdvisorGamontPlayer(bgShipment.before, owner).spice - semutaAdvisorGamontPlayer(game, owner).spice, 2); + const bgMovement = transition(() => { + act(owner, { type: 'move', from: 'carthag:11', territory: 'sietch_tabr', sector: 14, amount: 1, elite: 0 }); + reach(semutaAdvisorGamontClean); act(owner, { type: 'endMovement' }); + }); + reach(state => state.phase === 5 && state.active === spender && semutaAdvisorGamontClean(state)); + const outsiderDeparture = transition(() => { + act(spender, { type: 'move', from: location, territory: 'imperial_basin', sector: 10, amount: 9, elite: 0 }); + reach(semutaAdvisorGamontClean); + }); + assert.equal(semutaAdvisorGamontPlayer(game, spender).forces[location], 1); + reach(state => state.turn === 1 && state.phase === 8 && semutaAdvisorGamontClean(state)); + const ordinaryMentat = structuredClone(game); + if (final) reach(state => state.phase === 8 && !state.response && !state.phaseOpening && state.decision?.kind === 'choamMentat'); + const beforeGamont = structuredClone(game); + assert.equal(semutaAdvisorGamontStrongholds(game, owner).length, 2); + const gamontAction: Action = { type: 'card', mode: 'choam', card: gamont, target: spender, from: location, elite: 0 }; + act(holder, gamontAction); + const declaredGamont = structuredClone(game), gamontAt = actions.length - 1; + const fuelDisposals: { before: Game; steps: SemutaAdvisorGamontStep[]; after: Game; event: string }[] = []; + for (let count = 0; game.response?.automaticAdvisorFlip?.producer !== 'gamont' && count < 70; count++) { + if (game.pendingTreacheryDiscard) { + const pending: NonNullable = game.pendingTreacheryDiscard; + assert.equal(pending.continuation.kind, 'choamGamontDiscard'); + assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy })), [{ card: gamont, by: holder }]); + assert.equal(viewGame(game, holder).semutaReaction?.canCommit ?? false, false, 'Semuta cannot claim its own holder’s fuel'); + fuelDisposals.push({ ...transition(() => { game = passSemutaAdvisorGamont(game, actions); }), event: pending.batch.event }); + } else { + assert.equal(game.response?.kind, 'choamWorthless'); + const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor); + act(actor.id, { type: 'passResponse' }); + } + } + const automatic = game.response?.automaticAdvisorFlip; assert.ok(automatic?.producer === 'gamont'); + assert.ok(game.pendingAutomaticAdvisorGamont && game.pendingAutomaticAdvisorFlips?.length); + assert.equal(semutaAdvisorGamontPlayer(game, spender).forces[location] ?? 0, 0); + assert.equal(semutaAdvisorGamontPlayer(game, spender).reserves, semutaAdvisorGamontPlayer(beforeGamont, spender).reserves + 1); + assert.equal(game.discard.filter(card => card.id === gamont).length, 1); + assert.ok(semutaAdvisorGamontPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + assert.ok(semutaAdvisorGamontPlayer(game, spender).hand.some(card => card.id === cost)); + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []); + const source = structuredClone(game), originalResponse = structuredClone(game.response!); + const originalAutomaticSource = structuredClone(automatic), originalParent = structuredClone(game.pendingAutomaticAdvisorGamont); + const originalQueue = structuredClone(game.pendingAutomaticAdvisorFlips); + const originalStance = structuredClone(semutaAdvisorGamontPlayer(game, owner).advisors![territory]); + assert.equal(originalStance.aloneFlipHold, automatic.event); + const action: Action = { type: 'card', card: cost, mode: 'cancel' }; + const chooser = final ? { kind: 'choamMentat' as const, player: holder } : null; + const originalContinuation = { chooser, ready: [...game.ready], choamMentatPending: game.choamMentatPending }; + assert.deepEqual(semutaAdvisorGamontInventory(game), originalInventory); + return { game, source, beforeCost: source, final, advanced: true as const, owner, bg: owner, holder, choam: holder, + actor: holder, spender, target: spender, atreides: spender, seller, richese: seller, recipient: owner, + cost, karama: cost, semuta: SEMUTA_DRUG_ID, gamont, card: gamont, action, nativeAction: gamontAction, gamontAction, + originalResponse, originalAutomaticSource, originalParent, originalQueue, originalStance, originalContinuation, + effect: structuredClone(originalParent.receipt), + originalPhysical: structuredClone(semutaAdvisorGamontPhysical(game)), chooser, + confirmationActor: holder, confirmationAction: { type: 'decision', done: true } satisfies Action, + territory, location, from: location, to: 'reserves', initial, lobby, setup, initialization, beforeStartingDeal, + afterSetup, advisorSetup, deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder, + acquisition, acquired, declinedPreparation, bgShipment, bgMovement, outsiderDeparture, ordinaryMentat, + beforeGamont, declaredGamont, fuelDisposals, + gamontHistory: { before: beforeGamont, steps: structuredClone(actions.slice(gamontAt)), after: source }, + nearWin: { fighters: ['carthag', 'sietch_tabr'], advisors: [territory], lastOutsider: spender }, + actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length, + staging: [ + 'Original Advanced Semuta initializer on the genuinely ready undealt R/C/BG/A lobby, exact CHOAM/main35/cache10 and no modules. Only unopened original card/Spice positions and scalar lotteries are selected. Atreides really receives printed Karama; CHOAM really receives Gamont and pays cache1 for Semuta.', + 'BG really predicts and places its original advisor beside A10 Arrakeen10, declines native preparation, pays two for two Carthag fighters and uses actual city range to move one to Tabr14. Atreides really moves nine home counters to Basin10, leaving one beside the advisor. Native Collection earns the two genuine fighter-held strongholds.', + final ? 'Original Mentat readiness and closing market really finish before the native final CHOAM chooser. The held original Gamont is then actually declared and its native Worthless counter allowed.' : 'At actual clean ordinary Mentat, the held original Gamont is really declared and its native Worthless counter allowed.', + 'The actual Gamont fuel leaves CHOAM’s hand once and its disposal completes before the last A counter returns and opens BG’s mandatory conversion. CHOAM’s own Semuta never claims its own fuel; any observed original disposal receives real passes, and no fake reaction window is installed.', + 'Stop before Atreides spends the retained printed Karama. Original returned counter, discarded fuel, automatic queue/parent and alone stance hold are native. No played hand, wallet, force, stance, clock, parent or victory is assigned; final confirmation remains an actual original action.', + ], + }; +} +export type SemutaAdvisorGamontFixture = ReturnType; diff --git a/tests/fixture-semuta-advisor-storm.ts b/tests/fixture-semuta-advisor-storm.ts new file mode 100644 index 00000000..ad141979 --- /dev/null +++ b/tests/fixture-semuta-advisor-storm.ts @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; +import { + actSemutaCombatCapabilities, nextSemutaCombatCapabilities, + semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean, + passSemutaCombatCapabilities, claimSemutaCombatCapabilities, +} from './fixture-semuta-combat-capabilities'; + +export type SemutaAdvisorStormStep = { actor: string; action: Action }; +export const actSemutaAdvisorStorm = actSemutaCombatCapabilities; +export const semutaAdvisorStormPlayer = semutaCombatCapabilitiesPlayer; +export const semutaAdvisorStormInventory = semutaCombatCapabilitiesInventory; +export const semutaAdvisorStormClean = semutaCombatCapabilitiesClean; +export const passSemutaAdvisorStorm = passSemutaCombatCapabilities; +export const claimSemutaAdvisorStorm = claimSemutaCombatCapabilities; +export const semutaAdvisorStormPhysical = (game: Game) => ({ + spice: game.spice, + players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves, + tanks: p.tanks, elites: p.elites, leaders: p.leaders, ally: p.ally, moved: p.moved, shipped: p.shipped })), +}); + +/** Quiet native controls only. Intrusion is source-owned; an actual mandatory + * Storm conversion stops this driver until the caller resolves that source. */ +export function nextSemutaAdvisorStorm(game: Game): SemutaAdvisorStormStep | null { + if (game.response?.automaticAdvisorFlip) return null; + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + if (game.status === 'setup' && game.setupStage === 'prediction') + return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } }; + const bg = game.players.find(p => p.faction === 'beneGesserit'); + if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup) + return { actor: bg.id, action: { type: 'advisorSetup', territory: 'oh_gap', sector: 10 } }; + if (game.status === 'playing' && game.turn === 1 && game.phase === 0 && game.stormPending === null && !game.decision) { + const actor = game.stormDialers.find(id => game.stormDials[id] === undefined); + if (actor) return { actor, action: { type: 'stormDial', amount: game.stormDialers.indexOf(actor) === 0 ? 2 : 3 } }; + } + const d = game.decision; + if (d?.kind === 'intrusion') return null; + if (d?.kind === 'advisorBattle' || d?.kind === 'advisor') + return { actor: d.player, action: { type: 'decision', accept: false } }; + } + return nextSemutaCombatCapabilities(game); +} +export const nextSemutaAdvisorStormStep = nextSemutaAdvisorStorm; +export function advanceSemutaAdvisorStorm(game: Game, until: (state: Game) => boolean, + actions: SemutaAdvisorStormStep[] = []): Game { + for (let count = 0; count < 2600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const next = nextSemutaAdvisorStorm(game); + assert.ok(next, `Supply the native Storm/advisor control at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actSemutaAdvisorStorm(game, next.actor, next.action, actions); + } + throw Error('The native Semuta/Storm advisor programme did not reach its finite boundary'); +} +export function allowSemutaAdvisorStorm(game: Game, actions: SemutaAdvisorStormStep[] = []): Game { + assert.ok(game.response?.automaticAdvisorFlip?.producer === 'storm'); + for (let count = 0; game.response?.automaticAdvisorFlip?.producer === 'storm' && count < game.players.length; count++) { + const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor); + game = actSemutaAdvisorStorm(game, actor.id, { type: 'passResponse' }, actions); + } + assert.notEqual(game.response?.automaticAdvisorFlip?.producer, 'storm'); return game; +} +/** Finish remaining native traversal only. Stop when phase1 opens, before its + * ordinary Spice windows/readiness reveal any new Spice Card. */ +export function finishSemutaAdvisorStorm(game: Game, actions: SemutaAdvisorStormStep[] = []): Game { + assert.ok(!game.pendingTreacheryDiscard); + return advanceSemutaAdvisorStorm(game, state => state.phase === 1, actions); +} + +/** Scope only the real six-card native shuffle, on the actual final CHOAM + * Mentat Done. No game Storm Card, order, forecast or traversal field is assigned. */ +function drawOriginalStorm(game: Game, step: SemutaAdvisorStormStep, distance: 3 | 4, + actions: SemutaAdvisorStormStep[]) { + assert.equal(game.turn, 1); assert.equal(game.phase, 8); + assert.ok(!game.response && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard); + assert.equal(game.decision?.kind, 'choamMentat'); + assert.deepEqual(step, { actor: game.decision.player, action: { type: 'decision', done: true } }); + const original = [1, 2, 3, 4, 5, 6], order = [distance, ...original.filter(n => n !== distance)]; + const current = [...original], scalars: number[] = []; + for (let index = current.length - 1; index > 0; index--) { + const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index); + scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1))); + [current[index], current[from]] = [current[from], current[index]]; + } + assert.deepEqual(current, order); + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) { + assert.ok(cursor < scalars.length, 'Only the original six-card shuffle consumes selected scalar entropy'); + array[0] = scalars[cursor++]; + } else Reflect.apply(native, crypto, [array]); + return array; + }; + try { + const next = applyAction(game, step.actor, step.action); + assert.equal(cursor, scalars.length); actions.push(structuredClone(step)); + return { game: next, original, order, scalars }; + } finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Genuine Advanced setup, cache1, paid reserve and accepted Intrusion earn + * split-sector advisors; actual Storm traversal kills the last outsider first. */ +export function createSemutaAdvisorStormFixture(options: { continues?: boolean } = {}) { + const continues = options.continues ?? false, distance: 3 | 4 = continues ? 4 : 3; + const richese = 'richese', holder = 'choam', owner = 'beneGesserit', outsider = 'atreides', spender = 'emperor'; + const territory = 'oh_gap', location = 'oh_gap:10', outsiderLocation = 'oh_gap:9', from = 6; + let game = createGame('SEMUTAADVISORSTORM', newPlayer(richese, 'Richese', 'richese'), true, ['choam']); + for (const faction of ['choam', 'beneGesserit', 'atreides', 'emperor'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: SemutaAdvisorStormStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaAdvisorStorm(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorStorm(game, until, actions); }; + const transition = (run: () => void) => { + const before = structuredClone(game), at = actions.length; run(); + return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) }; + }; + for (const p of game.players) act(p.id, { type: 'ready' }); + const lobby = structuredClone(game); + game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true); assert.equal(game.advanced, true); + assert.ok(game.players.every(p => p.hand.length === 0)); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + const originalInventory = semutaAdvisorStormInventory(game); assert.equal(originalInventory.length, 45); + const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed); + const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 4); + assert.equal(ordinary.length, 4); const selected = [...ordinary, printed]; + const dealOrder = selected.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { card: card.id, position, from }; + }); + const beforeStartingDeal = structuredClone(game), cost = printed.id; + const setupChoices = transition(() => { reach(state => state.status === 'playing'); }); + const afterSetup = structuredClone(game); + assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id)); + assert.equal(semutaAdvisorStormPlayer(game, owner).forces[location], 1); + assert.equal(semutaAdvisorStormPlayer(game, owner).advisors?.[territory], undefined); + assert.equal(game.storm, 1); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder = [0, 1].map(position => { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); + return { position, from, card: structuredClone(card) }; + }); + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + const firstStorm = transition(() => { reach(state => state.turn === 1 && state.phase === 1 && state.storm === from); }); + const firstStormDials = firstStorm.steps.filter(step => step.action.type === 'stormDial'); + assert.deepEqual(firstStormDials.map(step => step.action.amount), [2, 3]); + let beforeBid: Game | undefined, bid: SemutaAdvisorStormStep | undefined, bidAt = -1; + for (let count = 0; count < 1000 && !semutaAdvisorStormPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const next = nextSemutaAdvisorStorm(game); assert.ok(next); + if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) { + beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length; + } + act(next.actor, next.action); + } + assert.ok(beforeBid && bid && bidAt >= 0); + const acquired = structuredClone(game), acquisition = { before: beforeBid, bid, + steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 }; + assert.equal(semutaAdvisorStormPlayer(beforeBid, holder).spice - semutaAdvisorStormPlayer(game, holder).spice, 1); + reach(state => state.turn === 1 && state.phase === 5 && state.active === outsider && semutaAdvisorStormClean(state)); + const shipment = transition(() => { + act(outsider, { type: 'ship', territory, sector: 9, amount: 1, elite: 0, allyPayment: 0 }); + reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'intrusion'); + }); + const intrusionBefore = structuredClone(game), intrusionAt = actions.length; + act(owner, { type: 'decision', accept: true }); + assert.ok(game.response?.kind === 'advisorFlip'); assert.equal(game.response.advisors, true); + const intrusionResponse = structuredClone(game.response); + reach(semutaAdvisorStormClean); + const intrusion = { before: intrusionBefore, steps: structuredClone(actions.slice(intrusionAt)), after: structuredClone(game) }; + assert.equal(semutaAdvisorStormPlayer(shipment.before, outsider).spice - semutaAdvisorStormPlayer(game, outsider).spice, 2); + assert.equal(semutaAdvisorStormPlayer(game, outsider).forces[outsiderLocation], 1); + assert.ok(semutaAdvisorStormPlayer(game, owner).advisors?.[territory]); + reach(state => state.turn === 1 && state.phase === 8 && semutaAdvisorStormClean(state)); + const mentat = structuredClone(game); + const mentatReadiness = transition(() => { + for (const p of game.players.slice(0, -1)) act(p.id, { type: 'ready' }); + }); + const mentatClose = transition(() => { act(game.players.at(-1)!.id, { type: 'ready' }); }); + assert.equal(game.turn, 1); assert.equal(game.phase, 8); + assert.equal(game.decision?.kind, 'choamMarket'); assert.equal(game.decision.player, holder); + const closingMarket = transition(() => { act(holder, { type: 'decision', done: true }); }); + const finalChoice = (game as Game).decision; + assert.equal(finalChoice?.kind, 'choamMentat'); assert.equal(finalChoice.player, holder); + const drawStep: SemutaAdvisorStormStep = { actor: holder, action: { type: 'decision', done: true } }; + const beforeDraw = structuredClone(game), drawAt = actions.length; + const draw = drawOriginalStorm(game, drawStep, distance, actions); game = draw.game; + const stormDraw = { before: beforeDraw, steps: structuredClone(actions.slice(drawAt)), after: structuredClone(game) }; + assert.equal(game.turn, 2); assert.equal(game.phase, 0); assert.equal(game.storm, from); + assert.equal(game.stormPending, distance); assert.equal(game.stormMovementSource?.kind, 'card'); + assert.ok(semutaAdvisorStormClean(game)); assert.equal(game.ready.length, 0); + const stormSource = structuredClone(game.stormMovementSource); + const stormCardLog = structuredClone(game.log.findLast(row => row.component?.kind === 'stormCard' && row.component.distance === distance)); + assert.ok(stormCardLog); + const stormReadiness = transition(() => { + for (const p of game.players.slice(0, -1)) act(p.id, { type: 'ready' }); + }); + assert.equal(game.ready.length, game.players.length - 1); + const triggerActor = game.players.at(-1)!.id, triggerAction: Action = { type: 'ready' }; + const storm = transition(() => { act(triggerActor, triggerAction); }); + assert.ok(game.response?.automaticAdvisorFlip?.producer === 'storm'); + const automatic = game.response.automaticAdvisorFlip; + assert.equal(automatic.owner, owner); assert.equal(automatic.territory, territory); + assert.deepEqual(automatic.forces, { [location]: 1 }); assert.equal(automatic.storm.sector, 9); + assert.ok(game.stormResolution && game.stormResolution.traversed === 3); assert.deepEqual(game.stormResolution.pending, []); + assert.equal(semutaAdvisorStormPlayer(game, outsider).forces[outsiderLocation] ?? 0, 0); + assert.equal(semutaAdvisorStormPlayer(game, outsider).tanks, semutaAdvisorStormPlayer(storm.before, outsider).tanks + 1); + assert.equal(semutaAdvisorStormPlayer(game, owner).forces[location], 1); + assert.ok(game.pendingAutomaticAdvisorStorm?.ready); assert.deepEqual(semutaAdvisorStormInventory(game), originalInventory); + return { game, kind: 'advisorFlip' as const, advanced: true as const, continues, distance, from, + richese, holder, owner, outsider, atreides: outsider, spender, cost, karama: cost, semuta: SEMUTA_DRUG_ID, + action: { type: 'card', card: cost, mode: 'cancel' } as Action, + territory, location, outsiderLocation, triggerActor, triggerAction, + originalResponse: structuredClone(game.response), originalAutomaticSource: structuredClone(automatic), + originalParent: structuredClone(game.pendingAutomaticAdvisorStorm!), originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!), + originalPhysical: structuredClone(semutaAdvisorStormPhysical(game)), + originalStance: structuredClone(semutaAdvisorStormPlayer(game, owner).advisors!), + originalCursor: structuredClone(game.stormResolution), originalStormMovementSource: structuredClone(game.stormMovementSource), + originalStormDials: structuredClone(game.stormDials), originalDeck: structuredClone(game.deck), + originalReady: structuredClone(game.ready), originalSpiceDeck: structuredClone(game.spiceDeck), + originalSpiceDiscard: structuredClone(game.spiceDiscard), initial, lobby, setup, setupChoices, + beforeStartingDeal, afterSetup, dealOrder, deal: selected.map(card => card.id), originalInventory, originalSpice, spiceOrder, + firstStorm, firstStormDials, acquisition, acquired, cache: acquisition, shipment, intrusion, intrusionResponse, + mentat, mentatReadiness, mentatClose, closingMarket, stormDraw, originalStormCards: draw.original, stormCardOrder: draw.order, + stormCardScalars: draw.scalars, stormSource, stormCardLog, stormReadiness, storm, + actions, steps: actions, sourceActionCount: actions.length, + staging: [ + 'Original undealt Advanced R/C/BG/A/E Semuta initializer with CHOAM only, no Fremen or optional modules. Native BG setup places its real counter at empty Oh Gap10 and normally makes it a fighter. Only original unopened deals/Spice and scalar lotteries are selected; C really buys cache1 Semuta and E receives the printed Karama.', + 'Native first secret dials2/3 move Storm1→6. Atreides actually pays2 to ship one original reserve into Oh Gap9; BG accepts actual Intrusion and its ordinary advisorFlip response is allowed. Separate accompaniment is declined, retaining one actual advisor at10 beside one outsider at9.', + 'Real remaining movement, peaceful Battle/Collection and Mentat readiness execute. Select only the original six-card native shuffle when the actual final CHOAM Mentat Done opens turn2 to reveal distance3/4 with its original source/log. No card, forecast, order, stance, force, clock or cursor is assigned.', + 'Actual turn2 Storm readiness begins traversal from6. Sector9 really kills Atreides’ last outsider and opens mandatory conversion before sector10. Stop before the real cancellation cost; the held surviving BG group and full native cursor are actual engine output.', + ] }; +} +export type SemutaAdvisorStormFixture = ReturnType; diff --git a/tests/fixture-semuta-advisor-transport.ts b/tests/fixture-semuta-advisor-transport.ts new file mode 100644 index 00000000..09f2b9fe --- /dev/null +++ b/tests/fixture-semuta-advisor-transport.ts @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import { + createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game, +} from '../game/engine'; +import { isAdvisor } from '../game/advisors'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; +import { + actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama, + semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory, semutaRicheseCacheKaramaPlayer, +} from './fixture-semuta-richese-cache-karama'; +import { + passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement, +} from './fixture-semuta-fremen-guild-movement'; + +export type SemutaAdvisorTransportStep = { actor: string; action: Action }; +export const actSemutaAdvisorTransport = actSemutaRicheseCacheKarama; +export const passSemutaAdvisorTransport = passSemutaFremenGuildMovement; +export const claimSemutaAdvisorTransport = claimSemutaFremenGuildMovement; +export const semutaAdvisorTransportPlayer = semutaRicheseCacheKaramaPlayer; +export const semutaAdvisorTransportInventory = semutaRicheseCacheKaramaInventory; +export const semutaAdvisorTransportClean = semutaRicheseCacheKaramaClean; +export const semutaAdvisorTransportPhysical = (game: Game) => ({ + turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active, ready: game.ready, + movementRemaining: game.movementRemaining, + guildTimingGranted: game.guildTimingGranted, guildTimingLocked: game.guildTimingLocked, + spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard, + auction: game.auction, richeseAuction: game.richeseAuction, richeseBidding: game.richeseBidding, + basicAlliedShipment: game.basicAlliedShipment, + players: game.players.map(player => ({ id: player.id, spice: player.spice, + reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites, + leaders: player.leaders, shipped: player.shipped, moved: player.moved, revived: player.revived, ally: player.ally })), +}); + +/** Actual native setup and transport continuations. The Guild takes its real + * early opportunity; the original automatic flip and fresh cost remain owned + * by the caller. No scalar selection installs a movement order or grant. */ +export function nextSemutaAdvisorTransport(game: Game): SemutaAdvisorTransportStep | null { + if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.kind === 'advisorFlip') return null; + if (!game.phaseOpening && !game.response) { + const bg = game.players.find(player => player.faction === 'beneGesserit'); assert.ok(bg); + if (game.status === 'setup' && game.setupStage === 'prediction') + return { actor: bg.id, action: { type: 'predict', faction: 'atreides', turn: 10 } }; + if (game.status === 'setup' && game.setupStage === 'forces' && !bg.advisorSetup) + return { actor: bg.id, action: { type: 'advisorSetup', territory: 'tueks_sietch', sector: 5 } }; + if (game.decision?.kind === 'advisorBattle' || game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion') + return { actor: game.decision.player, action: { type: 'decision', accept: false } }; + if (game.decision?.kind === 'guildTiming') + return { actor: game.decision.player, action: { type: 'decision', take: true } }; + } + return nextSemutaRicheseCacheKarama(game); +} +export function advanceSemutaAdvisorTransport(game: Game, until: (state: Game) => boolean, + actions: SemutaAdvisorTransportStep[] = []): Game { + for (let count = 0; count < 1400; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextSemutaAdvisorTransport(game); + assert.ok(step, 'Choose the original automatic flip or fresh cost before continuing'); + game = actSemutaAdvisorTransport(game, step.actor, step.action, actions); + } + throw Error('The original transport-conversion programme did not reach its finite boundary'); +} +/** Allow only the original committed transport's conversion. Permission, rate + * and the physical shipment have already completed; none is replayed. */ +export function allowSemutaAdvisorTransport(game: Game, + actions: SemutaAdvisorTransportStep[] = []): Game { + assert.equal(game.response?.automaticAdvisorFlip?.producer, 'guildTransport'); + for (let count = 0; game.response?.kind === 'advisorFlip' && count < game.players.length; count++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = actSemutaAdvisorTransport(game, actor.id, { type: 'passResponse' }, actions); + } + assert.equal(game.response ?? null, null); return game; +} +/** Resolve only the original transport arrival, then genuinely close this + * already-shipped Guild turn. Do not submit another shipment or ground move. */ +export function finishSemutaAdvisorTransport(game: Game, + actions: SemutaAdvisorTransportStep[] = []): Game { + const guild = game.players.find(player => player.faction === 'guild'); assert.ok(guild); + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null); + assert.equal(game.pendingAutomaticAdvisorTransport, undefined); + assert.equal(game.pendingAutomaticAdvisorFlips, undefined); + assert.equal(game.pendingArrivalReaction ?? null, null); + assert.ok(semutaAdvisorTransportClean(game)); + assert.equal(game.phase, 5); assert.equal(game.active, guild.id); + assert.equal(guild.shipped, true); assert.equal(guild.moved, 0); + game = actSemutaAdvisorTransport(game, guild.id, { type: 'endMovement' }, actions); + assert.ok(!game.movementRemaining?.includes(guild.id)); + return game; +} + +/** Native Advanced R/C/BG/A/G, paid original cache Semuta, and all five native + * Guild counters leaving the original BG advisor at Tuek's Sietch5. Only + * original unopened card/Spice positions and scoped scalar lotteries are + * selected. No played resource, stance, clock, control or receipt is installed. */ +export function createSemutaAdvisorTransportFixture(options: { kind: 'cross' | 'return' }) { + const { kind } = options, richese = 'richese', holder = 'choam', owner = 'beneGesserit', spender = 'atreides', guild = 'guild'; + let game = createGame('SEMUTAADVISORTRANSPORT', newPlayer(richese, 'Richese', 'richese'), true, ['choam']); + for (const faction of [holder, owner, spender, guild] as const) + joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: SemutaAdvisorTransportStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaAdvisorTransport(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorTransport(game, until, actions); }; + const transition = (run: () => void) => { + const before = structuredClone(game), start = actions.length; run(); + return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) }; + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); + const setup = structuredClone(game), initialization = { before: lobby, after: setup, + entry: 'original-undealt-semuta-preview' }; + assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true); + assert.ok(game.players.every(player => player.hand.length === 0)); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + const originalInventory = semutaAdvisorTransportInventory(game); + assert.equal(originalInventory.length, 45); assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10); + const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed); + const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 4); + assert.equal(ordinary.length, 4); + const selected = [ordinary[0], ordinary[1], ordinary[2], printed, ordinary[3]], cost = printed.id; + const dealOrder = selected.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: card.id }; + }); + const beforeStartingDeal = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id)); + assert.equal(semutaAdvisorTransportPlayer(game, owner).forces['tueks_sietch:5'], 1); + assert.equal(isAdvisor(semutaAdvisorTransportPlayer(game, owner), 'tueks_sietch'), true); + assert.equal(semutaAdvisorTransportPlayer(game, guild).forces['tueks_sietch:5'], 5); + assert.ok(semutaAdvisorTransportPlayer(game, spender).hand.some(card => card.id === cost)); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + for (let position = 0; position < 2; position++) { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; + game.spiceDeck.splice(position, 0, card); spiceOrder.push({ position, from, card: structuredClone(card) }); + } + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + let beforeBid: Game | undefined, bid: SemutaAdvisorTransportStep | undefined, bidAt = -1; + for (let count = 0; count < 900 && !semutaAdvisorTransportPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const step = nextSemutaAdvisorTransport(game); assert.ok(step); + if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) { + beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length; + } + act(step.actor, step.action); + } + assert.ok(beforeBid && bid && bidAt >= 0, 'CHOAM really purchases the original Semuta from cache'); + const acquired = structuredClone(game), acquisition = { before: beforeBid, bid, + steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 }; + assert.equal(semutaAdvisorTransportPlayer(beforeBid, holder).spice - semutaAdvisorTransportPlayer(game, holder).spice, 1); + assert.equal(semutaAdvisorTransportPlayer(game, richese).spice - semutaAdvisorTransportPlayer(beforeBid, richese).spice, 1); + reach(state => state.turn === 1 && state.phase === 5 && !state.phaseOpening && !state.response && state.decision?.kind === 'advisorBattle'); + const openingOffer = structuredClone(game); + const declinedPreparation = transition(() => { + act(owner, { type: 'decision', accept: false }); + reach(state => state.phase === 5 && state.active === guild && semutaAdvisorTransportClean(state)); + }); + assert.equal(semutaAdvisorTransportPlayer(game, guild).shipped, false); + assert.equal(semutaAdvisorTransportPlayer(game, guild).moved, 0); + const from = 'tueks_sietch:5', territory = 'tueks_sietch', to = kind === 'return' ? 'reserves' : 'imperial_basin:10'; + const nativeAction: Action = { type: 'guildShip', from, amount: 5, elite: 0, allyPayment: 0, + territory: kind === 'return' ? 'reserves' : 'imperial_basin', ...(kind === 'cross' ? { sector: 10 } : {}) }; + const beforeDeclaration = structuredClone(game); + const transport = transition(() => { + act(guild, nativeAction); + reach(state => state.response?.automaticAdvisorFlip?.producer === 'guildTransport'); + }); + const actor = semutaAdvisorTransportPlayer(game, guild); + const price = semutaAdvisorTransportPlayer(transport.before, guild).spice - actor.spice; + assert.ok(price > 0, 'The native accepted Guild rate is actually paid before the automatic flip'); + assert.equal(actor.forces[from], undefined); assert.equal(actor.shipped, true); assert.equal(actor.moved, 0); + if (kind === 'return') assert.equal(actor.reserves, semutaAdvisorTransportPlayer(transport.before, guild).reserves + 5); + else assert.equal(actor.forces[to], 5); + assert.equal(game.response?.kind, 'advisorFlip'); assert.equal(game.response?.owner, owner); + assert.equal(game.response?.automaticAdvisorFlip?.producer, 'guildTransport'); + assert.ok(game.pendingAutomaticAdvisorTransport && game.pendingAutomaticAdvisorFlips?.length === 1); + assert.equal(isAdvisor(semutaAdvisorTransportPlayer(game, owner), territory), true); + assert.ok(semutaAdvisorTransportPlayer(game, owner).advisors?.[territory]?.aloneFlipHold); + assert.equal(game.pendingGuildTransport ?? null, null); + assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null); + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null); + assert.ok(semutaAdvisorTransportPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + assert.ok(semutaAdvisorTransportPlayer(game, spender).hand.some(card => card.id === cost)); + assert.deepEqual(semutaAdvisorTransportInventory(game), originalInventory); + const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' }; + return { game, source, beforeCost: source, kind, advanced: true as const, richese, holder, owner, bg: owner, + spender, atreides: spender, guild, actor: guild, cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, + from, to, territory, sector: 5, location: from, amount: 5, elite: 0, price, + originalResponse: structuredClone(game.response!), + originalAutomaticSource: structuredClone(game.response!.automaticAdvisorFlip!), + originalParent: structuredClone(game.pendingAutomaticAdvisorTransport!), + originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!), + originalArrival: structuredClone(game.pendingArrivalReaction ?? null), + originalHeldStance: structuredClone(semutaAdvisorTransportPlayer(game, owner).advisors![territory]), + originalPhysical: structuredClone(semutaAdvisorTransportPhysical(game)), originalHistory: structuredClone(game.log), + initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder, originalSpice, + acquired, acquisition, openingOffer, declinedPreparation, beforeDeclaration, transport: { ...transport, price }, + originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length, + staging: [ + 'Original Advanced Semuta initializer acts only on the ready undealt R/C/BG/A/G lobby, with CHOAM expansion and no optional modules. Select original unopened starting-card and ordinary first-Spice positions only.', + 'Actually predict Atreides, choose original private Traitors and use advisorSetup at Tueks Sietch5 beside Guilds five native counters. Atreides is genuinely dealt its original printed Karama; CHOAM actually buys original cache Semuta for one spice.', + 'Complete actual first Storm, both Spice blows, Charity, Bidding and Revival. Decline the genuine initial advisorBattle offer, take the original native Guild timing opportunity and pass its original response. No played order, clock, grant, hand, wallet or counter is installed.', + kind === 'return' + ? 'Submit actual guildShip returning all five original Tueks counters to abstract reserves; allow original transport permission and native rate. The native fee and physical return commit before capturing the alone-advisor response.' + : 'Submit actual guildShip crossing all five original Tueks counters to empty Imperial Basin10; allow original transport permission and native rate. The native fee and board transfer commit before capturing the alone-advisor response.', + 'Stop at the original automatic guildTransport advisorFlip before the Atreides cost is played. Parent/source/queue/held stance stay original. Final native turn closure uses endMovement once, with no new shipment or ground move.', + ], + }; +} +export type SemutaAdvisorTransportFixture = ReturnType; diff --git a/tests/semuta-advisor-gamont-engine.test.ts b/tests/semuta-advisor-gamont-engine.test.ts new file mode 100644 index 00000000..0248e524 --- /dev/null +++ b/tests/semuta-advisor-gamont-engine.test.ts @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { isAdvisor } from '../game/advisors'; +import { + createSemutaAdvisorGamontFixture as fixture, actSemutaAdvisorGamont as act, + passSemutaAdvisorGamont as pass, claimSemutaAdvisorGamont as claim, + allowSemutaAdvisorGamont as allow, finishSemutaAdvisorGamont as finish, + closeSemutaAdvisorGamont as close, semutaAdvisorGamontPlayer as player, + semutaAdvisorGamontInventory as inventory, semutaAdvisorGamontPhysical as physical, + semutaAdvisorGamontStrongholds as strongholds, type SemutaAdvisorGamontFixture, +} from './fixture-semuta-advisor-gamont'; + +const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready, + order: game.order, movementRemaining: game.movementRemaining, storm: game.storm, + spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard, choamMentatPending: game.choamMentatPending }); +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +function nativeSource(f: SemutaAdvisorGamontFixture) { + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true); + assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0)); + assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal); + assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama')); + assert.ok(player(f.afterSetup, f.holder).hand.some(card => card.id === f.gamont && card.name === 'Trip to Gamont')); + assert.deepEqual(f.advisorSetup.steps[0].action, { type: 'advisorSetup', territory: 'arrakeen', sector: 10 }); + assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1); + assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), true); + assert.equal(player(f.afterSetup, f.spender).forces[f.location], 10); + assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1); + assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1); + assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1); + assert.equal(player(f.bgShipment.before, f.owner).spice - player(f.bgShipment.after, f.owner).spice, 2); + assert.ok(f.bgShipment.steps.some(step => step.action.type === 'ship' && step.action.amount === 2)); + assert.ok(f.bgMovement.steps.some(step => step.action.type === 'move' && step.action.from === 'carthag:11' && step.action.amount === 1)); + assert.ok(f.outsiderDeparture.steps.some(step => step.actor === f.spender && step.action.type === 'move' && step.action.amount === 9)); + assert.equal(player(f.beforeGamont, f.spender).forces[f.location], 1); + assert.equal(player(f.beforeGamont, f.spender).forces['imperial_basin:10'], 9); + assert.equal(player(f.beforeGamont, f.owner).forces['carthag:11'], 1); + assert.equal(player(f.beforeGamont, f.owner).forces['sietch_tabr:14'], 1); + assert.equal(isAdvisor(player(f.beforeGamont, f.owner), f.territory), true); + assert.equal(strongholds(f.beforeGamont, f.owner).length, 2); + assert.equal(f.beforeGamont.decision?.kind ?? null, f.final ? 'choamMentat' : null); + assert.equal(f.declaredGamont.response?.kind, 'choamWorthless'); + assert.equal(f.gamontHistory.steps[0].actor, f.holder); assert.deepEqual(f.gamontHistory.steps[0].action, f.gamontAction); + assert.ok(f.gamontHistory.steps.some(step => step.actor === f.spender && step.action.type === 'passResponse')); + for (const disposal of f.fuelDisposals) { + assert.equal(disposal.before.pendingTreacheryDiscard?.continuation.kind, 'choamGamontDiscard'); + assert.equal(viewGame(disposal.before, f.holder).semutaReaction?.canCommit ?? false, false); + assert.ok(disposal.steps.every(step => step.action.type === 'semutaPass')); + assert.notEqual(disposal.after.pendingTreacheryDiscard?.batch.event, disposal.event); + } + assert.equal(f.game.pendingTreacheryDiscard ?? null, null); + assert.equal(player(f.game, f.holder).hand.some(card => card.id === f.gamont), false); + assert.equal(f.game.discard.filter(card => card.id === f.gamont).length, 1); + assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta), 'Own fuel disposal never spends Semuta'); + assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost), 'The original Gamont counter was genuinely declined'); + assert.equal(player(f.game, f.spender).forces[f.location] ?? 0, 0); + assert.equal(player(f.game, f.spender).reserves, player(f.beforeGamont, f.spender).reserves + 1); + assert.equal(player(f.game, f.spender).tanks, player(f.beforeGamont, f.spender).tanks); + assert.equal(f.game.response?.kind, 'advisorFlip'); assert.deepEqual(f.game.response, f.originalResponse); + assert.equal(f.originalAutomaticSource.producer, 'gamont'); assert.equal(f.originalAutomaticSource.gamont.returned, true); + assert.equal(f.originalAutomaticSource.gamont.mentat, f.final); + assert.equal(f.originalAutomaticSource.gamont.player, f.holder); assert.equal(f.originalAutomaticSource.gamont.target, f.spender); + assert.equal(f.originalParent.card, f.gamont); assert.deepEqual(f.originalParent.receipt, f.originalAutomaticSource.gamont); + assert.deepEqual(f.game.pendingAutomaticAdvisorGamont, f.originalParent); + assert.deepEqual(f.game.pendingAutomaticAdvisorFlips, f.originalQueue); + assert.equal(f.originalStance.aloneFlipHold, f.originalAutomaticSource.event); + assert.equal(isAdvisor(player(f.game, f.owner), f.territory), true); + assert.equal(strongholds(f.game, f.owner).length, 2); + assert.equal(f.game.status, 'playing'); assert.deepEqual(f.game.winner, []); + assert.equal(f.game.phase, 8); assert.equal(f.game.turn, 1); assert.equal(f.game.decision ?? null, null); + assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory); + assert.equal(f.sourceActionCount, f.actions.length); +} +function open(f: SemutaAdvisorGamontFixture) { + const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; + assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard'); + if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Expected the original Gamont conversion cost'); + assert.equal(pending.batch.cause, 'karama:advisorFlip'); assert.equal(pending.reaction?.stage, 'offer'); + assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })), + [{ card: f.cost, by: f.spender, face: true }]); + assert.deepEqual(pending.continuation.use.response, f.originalResponse); + assert.deepEqual(pending.continuation.resume.response, f.originalResponse); + assert.deepEqual(game.pendingAutomaticAdvisorGamont, f.originalParent); + assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue); + assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalStance); + assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game)); + assert.equal(game.discard.filter(card => card.id === f.gamont).length, 1); + assert.equal(game.discard.filter(card => card.id === f.cost).length, 1); + assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null); + assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []); + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function nativeOutcome(state: Game, f: SemutaAdvisorGamontFixture, canceled: boolean) { + const game = finish(state); + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAutomaticAdvisorGamont ?? null, null); + assert.deepEqual(game.pendingAutomaticAdvisorFlips ?? [], []); assert.equal(game.response ?? null, null); + assert.deepEqual(game.decision, f.chooser); assert.deepEqual(clock(game), clock(f.game)); + assert.deepEqual(physical(game), f.originalPhysical); + assert.equal(isAdvisor(player(game, f.owner), f.territory), canceled); + if (canceled) assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalStance); + assert.equal(player(game, f.spender).forces[f.location] ?? 0, 0); + assert.equal(player(game, f.spender).reserves, player(f.game, f.spender).reserves); + assert.equal(game.discard.filter(card => card.id === f.gamont).length, 1); + assert.equal(strongholds(game, f.owner).length, canceled ? 2 : 3); + assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []); + reject(game, f.holder, f.gamontAction); + const closeSteps: { actor: string; action: Action }[] = [], completed = close(structuredClone(game), closeSteps); + assert.ok(closeSteps.some(step => step.actor === f.holder && step.action.type === 'decision' && step.action.done === true)); + if (f.final) assert.deepEqual(closeSteps[0], { actor: f.confirmationActor, action: f.confirmationAction }); + else assert.ok(closeSteps.some(step => step.action.type === 'ready')); + if (canceled) { + assert.equal(completed.status, 'playing'); assert.equal(completed.turn, 2); assert.deepEqual(completed.winner, []); + } else { + assert.equal(completed.status, 'finished'); assert.equal(completed.turn, 1); assert.deepEqual(completed.winner, [f.owner]); + } + assert.equal(completed.discard.filter(card => card.id === f.gamont).length, 1); + assert.equal(player(completed, f.spender).reserves, player(f.game, f.spender).reserves); + assert.equal(player(completed, f.spender).tanks, player(f.game, f.spender).tanks); + assert.equal(player(completed, f.spender).forces[f.location] ?? 0, 0); + assert.deepEqual(inventory(completed), f.originalInventory); return game; +} +function denied(state: Game, f: SemutaAdvisorGamontFixture, claimed: boolean) { + const game = nativeOutcome(state, f, true); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed)); + assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed)); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed)); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed)); + reject(game, f.spender, f.action); return game; +} + +for (const final of [false, true]) for (const claimed of [false, true]) void test( + `native ${final ? 'final' : 'ordinary'} Gamont conversion cost Semuta ${claimed ? 'claim' : 'pass'} keeps the real return and resumes only original Mentat`, () => { + const f = fixture({ final }); nativeSource(f); const offered = open(f); + denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + }, +); + +void test('native Gamont conversion allowance earns the real third stronghold but waits for actual final confirmation', () => { + for (const final of [false, true]) { + const f = fixture({ final }); nativeSource(f); const game = nativeOutcome(allow(structuredClone(f.game)), f, false); + assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost)); + assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta)); + } +}); + +void test('native Gamont conversion cost rejects stale foreign fuel and underlying chooser replies before stance or victory', () => { + for (const final of [false, true]) { + const f = fixture({ final }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event; + reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` }); + reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action); + reject(game, f.holder, f.gamontAction); reject(game, f.holder, f.confirmationAction); + reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'ready' }); + for (const disposal of f.fuelDisposals) reject(game, f.holder, { type: 'semutaCommit', event: disposal.event }); + const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event }); + } +}); + +void test('all four minimal profiles legally claim or pass native ordinary and final Gamont conversion costs', () => { + for (const final of [false, true]) { + const f = fixture({ final }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + for (const difficulty of DIFFICULTIES) { + const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty; + const proposed = botActions(own)[0]; assert.ok(proposed, difficulty); + assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event); + let game = act(structuredClone(offered), f.holder, proposed); + const claimed = proposed.type === 'semutaCommit'; + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { + const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty; + const selected = botActions(selecting)[0]; assert.ok(selected); + assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); + game = act(game, f.holder, selected); + } else if (game.pendingTreacheryDiscard) game = pass(game); + denied(game, f, claimed); + } + } +}); diff --git a/tests/semuta-advisor-storm-engine.test.ts b/tests/semuta-advisor-storm-engine.test.ts new file mode 100644 index 00000000..746795fe --- /dev/null +++ b/tests/semuta-advisor-storm-engine.test.ts @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { fighterCount, isAdvisor } from '../game/advisors'; +import { + createSemutaAdvisorStormFixture as fixture, actSemutaAdvisorStorm as act, + passSemutaAdvisorStorm as pass, claimSemutaAdvisorStorm as claim, + allowSemutaAdvisorStorm as allow, finishSemutaAdvisorStorm as finish, + semutaAdvisorStormPlayer as player, semutaAdvisorStormInventory as inventory, + semutaAdvisorStormPhysical as physical, type SemutaAdvisorStormFixture, +} from './fixture-semuta-advisor-storm'; + +const variants = [{ continues: false }, { continues: true }]; +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +function nativeSource(f: SemutaAdvisorStormFixture) { + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true); + assert.deepEqual(f.setup.expansions, ['choam']); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0)); + assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'emperor']); + assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal); + assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama')); + assert.ok(f.setupChoices.steps.some(step => step.actor === f.owner && step.action.type === 'advisorSetup' && + step.action.territory === f.territory && step.action.sector === 10)); + assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1); + assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), false, + 'The empty native setup placement actually starts as a fighter'); + assert.deepEqual(f.firstStormDials.map(step => step.action.amount), [2, 3]); + assert.equal(f.firstStorm.before.storm, 1); assert.equal(f.firstStorm.after.storm, 6); + assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1); + assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1); + assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1); + assert.deepEqual(f.shipment.steps[0], { actor: f.outsider, + action: { type: 'ship', territory: f.territory, sector: 9, amount: 1, elite: 0, allyPayment: 0 } }); + assert.equal(player(f.shipment.before, f.outsider).spice - player(f.shipment.after, f.outsider).spice, 2); + assert.equal(player(f.shipment.before, f.outsider).reserves - player(f.shipment.after, f.outsider).reserves, 1); + assert.equal(f.intrusion.before.decision?.kind, 'intrusion'); + assert.deepEqual(f.intrusion.steps[0], { actor: f.owner, action: { type: 'decision', accept: true } }); + assert.equal(f.intrusionResponse.kind, 'advisorFlip'); assert.equal(f.intrusionResponse.advisors, true); + assert.ok(f.intrusion.steps.some(step => step.action.type === 'passResponse')); + assert.equal(isAdvisor(player(f.intrusion.after, f.owner), f.territory), true); + assert.equal(player(f.intrusion.after, f.owner).forces[f.location], 1); + assert.equal(player(f.intrusion.after, f.outsider).forces[f.outsiderLocation], 1); + assert.equal(f.mentatClose.steps.length, 1); assert.equal(f.mentatClose.steps[0].action.type, 'ready'); + assert.equal(f.mentatClose.after.decision?.kind, 'choamMarket'); + assert.equal(f.closingMarket.after.decision?.kind, 'choamMentat'); + assert.deepEqual(f.stormDraw.steps, [{ actor: f.holder, action: { type: 'decision', done: true } }]); + assert.deepEqual(f.originalStormCards, [1, 2, 3, 4, 5, 6]); + assert.equal(f.stormCardOrder[0], f.distance); assert.deepEqual([...f.stormCardOrder].sort((a, b) => a - b), f.originalStormCards); + assert.equal(f.stormCardScalars.length, 5); assert.equal(f.stormDraw.after.stormPending, f.distance); + assert.equal(f.stormDraw.after.turn, 2); assert.equal(f.stormDraw.after.phase, 0); + assert.equal(f.stormSource?.kind, 'card'); assert.equal(f.stormCardLog.component?.distance, f.distance); + assert.equal(f.stormReadiness.steps.length, f.game.players.length - 1); + assert.deepEqual(f.storm.steps, [{ actor: f.triggerActor, action: f.triggerAction }]); + assert.equal(f.storm.before.stormResolution, null); assert.equal(f.game.storm, f.from); + assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0); + assert.equal(f.originalAutomaticSource.producer, 'storm'); + assert.deepEqual(Object.keys(f.originalAutomaticSource.storm).sort((a, b) => a.localeCompare(b)), ['distance', 'event', 'from', 'sector', 'traversed']); + assert.equal(f.originalAutomaticSource.storm.from, 6); assert.equal(f.originalAutomaticSource.storm.distance, f.distance); + assert.equal(f.originalAutomaticSource.storm.traversed, 3); assert.equal(f.originalAutomaticSource.storm.sector, 9); + assert.deepEqual(f.originalAutomaticSource.forces, { [f.location]: 1 }); + assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]); + assert.equal(f.originalParent.ready, true); assert.deepEqual(f.originalParent.receipt, f.originalAutomaticSource.storm); + assert.equal(f.originalParent.cursor, JSON.stringify([f.originalCursor, f.originalStormMovementSource ?? null])); + assert.equal(player(f.game, f.outsider).forces[f.outsiderLocation] ?? 0, 0); + assert.equal(player(f.game, f.outsider).tanks, player(f.storm.before, f.outsider).tanks + 1); + assert.equal(player(f.game, f.outsider).battleLosses, player(f.storm.before, f.outsider).battleLosses); + assert.equal(player(f.game, f.owner).forces[f.location], 1); + assert.equal(player(f.game, f.owner).tanks, player(f.storm.before, f.owner).tanks); + assert.equal(fighterCount(player(f.game, f.owner), f.territory), 0); + assert.equal(player(f.game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event); + assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory); + assert.equal(f.sourceActionCount, f.actions.length); +} +function open(f: SemutaAdvisorStormFixture) { + const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; + assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard'); + if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the native current-sector conversion cost'); + assert.equal(pending.batch.cause, 'karama:advisorFlip'); assert.equal(pending.reaction?.stage, 'offer'); + assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })), + [{ card: f.cost, by: f.spender, face: true }]); + assert.deepEqual(pending.continuation.use.response, f.originalResponse); + assert.deepEqual(pending.continuation.resume.response, f.originalResponse); + assert.deepEqual(game.pendingAutomaticAdvisorStorm, f.originalParent); + assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue); + assert.deepEqual(game.stormResolution, f.originalCursor); assert.deepEqual(game.stormMovementSource, f.originalStormMovementSource); + assert.deepEqual(game.stormDials, f.originalStormDials); assert.deepEqual(game.ready, f.originalReady); + assert.deepEqual(game.deck, f.originalDeck); assert.deepEqual(game.spiceDeck, f.originalSpiceDeck); + assert.deepEqual(game.spiceDiscard, f.originalSpiceDiscard); assert.deepEqual(physical(game), f.originalPhysical); + assert.deepEqual(player(game, f.owner).advisors, f.originalStance); + assert.equal(game.phase, 0); assert.equal(game.storm, 6); assert.equal(game.stormResolution?.traversed, 3); + assert.equal(player(game, f.owner).forces[f.location], 1, 'Future sector10 casualties wait through the fresh cost'); + assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(game.discard.filter(card => card.id === f.cost).length, 1); + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function finished(state: Game, f: SemutaAdvisorStormFixture, prevented: boolean) { + const steps: { actor: string; action: Action }[] = [], game = finish(state, steps); + assert.equal(game.phase, 1); assert.equal(game.turn, 2); assert.equal(game.storm, f.continues ? 10 : 9); + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAutomaticAdvisorStorm, undefined); + assert.equal(game.pendingAutomaticAdvisorFlips, undefined); assert.equal(game.stormResolution, null); + assert.equal(game.stormMovementSource, undefined); assert.equal(game.stormPending, null); + assert.equal(player(game, f.outsider).forces[f.outsiderLocation] ?? 0, 0); + assert.equal(player(game, f.outsider).tanks, player(f.storm.before, f.outsider).tanks + 1, + 'The completed sector9 casualty is never applied twice'); + assert.equal(player(game, f.outsider).forces['arrakeen:10'], 10); + assert.equal(player(game, f.owner).forces[f.location] ?? 0, f.continues ? 0 : 1); + assert.equal(player(game, f.owner).tanks, player(f.storm.before, f.owner).tanks + Number(f.continues)); + assert.equal(isAdvisor(player(game, f.owner), f.territory), !f.continues && prevented); + assert.equal(fighterCount(player(game, f.owner), f.territory), !f.continues && !prevented ? 1 : 0); + if (prevented && !f.continues) assert.equal(player(game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event); + else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined); + for (const p of game.players.filter(p => ![f.owner, f.outsider].includes(p.id))) + assert.deepEqual(physical(game).players.find(row => row.id === p.id), f.originalPhysical.players.find(row => row.id === p.id)); + assert.deepEqual(game.deck, f.originalDeck); assert.deepEqual(game.spiceDeck, f.originalSpiceDeck); + assert.deepEqual(game.spiceDiscard, f.originalSpiceDiscard, 'No subsequent Spice reveal is consumed by this continuation'); + assert.deepEqual(game.stormDials, {}); assert.deepEqual(game.ready, []); + const log = game.log.slice(f.game.log.length), finalText = `Storm moved ${f.distance} sectors to sector ${f.continues ? 10 : 9}.`; + assert.equal(log.filter(row => row.text === finalText).length, 1); + const conversion = log.findIndex(row => row.text === `${player(game, f.owner).name} changed all forces in Oh Gap to fighters.`); + assert.equal(conversion >= 0, !prevented); + if (!prevented) assert.ok(conversion < log.findIndex(row => row.text === finalText), + 'Allowance converts before the actual remaining traversal completes'); + assert.ok(steps.every(step => !['card', 'ship', 'move', 'stormDial'].includes(step.action.type))); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function canceled(state: Game, f: SemutaAdvisorStormFixture, claimed: boolean) { + const game = finished(state, f, true); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed)); + assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed)); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed)); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed)); + reject(game, f.spender, f.action); return game; +} + +for (const variant of variants) for (const claimed of [false, true]) void test( + `Advanced native ${variant.continues ? 'continuing' : 'ending'} Storm advisor conversion cost Semuta ${claimed ? 'claim' : 'pass'} holds sector9 then resumes only the remaining traversal`, () => { + const f = fixture(variant); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + const game = canceled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event }); + }, +); + +void test('native Storm conversion allowance converts the real peaceful group before ending or continuing into its actual next-sector casualty', () => { + for (const variant of variants) { + const f = fixture(variant); nativeSource(f); const game = finished(allow(structuredClone(f.game)), f, false); + assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost)); + assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta)); + } +}); + +void test('fresh Storm conversion cost rejects stale and underlying controls with the exact current-sector parent and surviving advisor still held', () => { + for (const variant of variants) { + const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event; + reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` }); + reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action); + reject(game, f.owner, { type: 'passResponse' }); reject(game, f.triggerActor, f.triggerAction); + reject(game, f.owner, { type: 'prepareAdvisors', territory: f.territory }); + reject(game, f.outsider, { type: 'move', from: f.outsiderLocation, amount: 1, territory: 'imperial_basin', sector: 9 }); + assert.deepEqual(game.pendingAutomaticAdvisorStorm, f.originalParent); + assert.deepEqual(game.stormResolution, f.originalCursor); assert.deepEqual(physical(game), f.originalPhysical); + assert.equal(player(game, f.owner).forces[f.location], 1); + canceled(pass(game), f, false); + } +}); + +void test('all four minimal profiles legally answer native ending and continuing Storm conversion used costs', () => { + for (const variant of variants) { + const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + for (const difficulty of DIFFICULTIES) { + const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty; + const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty); + assert.deepEqual(own, before); assert.ok(action.type === 'semutaPass' || action.type === 'semutaCommit'); + assert.equal(action.event, event); + let game = act(structuredClone(offered), f.holder, action); + const claimed = action.type === 'semutaCommit'; + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { + const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty; + const selected = botActions(selecting)[0]; assert.ok(selected); + assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); + game = act(game, f.holder, selected); + } else if (game.pendingTreacheryDiscard) game = pass(game); + canceled(game, f, claimed); + } + } +}); diff --git a/tests/semuta-advisor-transport-engine.test.ts b/tests/semuta-advisor-transport-engine.test.ts new file mode 100644 index 00000000..ad581ea7 --- /dev/null +++ b/tests/semuta-advisor-transport-engine.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { isAdvisor } from '../game/advisors'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + createSemutaAdvisorTransportFixture as fixture, actSemutaAdvisorTransport as act, + passSemutaAdvisorTransport as pass, claimSemutaAdvisorTransport as claim, + allowSemutaAdvisorTransport as allow, finishSemutaAdvisorTransport as finish, + semutaAdvisorTransportPlayer as player, semutaAdvisorTransportInventory as inventory, + semutaAdvisorTransportPhysical as physical, type SemutaAdvisorTransportFixture, +} from './fixture-semuta-advisor-transport'; + +const transportLogs = (game: Game) => game.log.filter(row => row.text.includes('used Guild transport for 5 forces.')).length; +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +function nativeSource(f: SemutaAdvisorTransportFixture) { + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true); + assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0)); + assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'beneGesserit', 'atreides', 'guild']); + assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama')); + assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'predict' && step.action.faction === 'atreides')); + assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'advisorSetup' && + step.action.territory === f.territory && step.action.sector === f.sector)); + assert.equal(player(f.afterSetup, f.owner).forces[f.from], 1); + assert.equal(player(f.afterSetup, f.guild).forces[f.from], 5); + assert.equal(isAdvisor(player(f.afterSetup, f.owner), f.territory), true); + assert.equal(f.acquisition.price, 1); + assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1); + assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1); + assert.equal(f.openingOffer.decision?.kind, 'advisorBattle'); + assert.ok(f.declinedPreparation.steps.some(step => step.actor === f.owner && step.action.type === 'decision' && step.action.accept === false)); + assert.equal(f.transport.before.active, f.guild); assert.equal(player(f.transport.before, f.guild).shipped, false); + assert.deepEqual(f.transport.steps[0], { actor: f.guild, action: f.nativeAction }); + assert.ok(f.price > 0); assert.equal(f.transport.price, f.price); + assert.equal(player(f.game, f.guild).spice, player(f.transport.before, f.guild).spice - f.price); + assert.equal(player(f.game, f.guild).forces[f.from], undefined); + if (f.kind === 'return') assert.equal(player(f.game, f.guild).reserves, player(f.transport.before, f.guild).reserves + 5); + else assert.equal(player(f.game, f.guild).forces[f.to], 5); + assert.equal(player(f.game, f.guild).shipped, true); assert.equal(player(f.game, f.guild).moved, 0); + assert.equal(f.game.active, f.guild); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); + assert.deepEqual(f.game.response, f.originalResponse); + assert.ok(f.originalAutomaticSource.producer === 'guildTransport'); + assert.deepEqual(f.originalAutomaticSource.transport, f.originalParent); + assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]); + assert.equal(f.originalParent.player, f.guild); assert.equal(f.originalParent.origin, f.territory); + assert.equal(f.originalParent.to, f.kind === 'return' ? 'reserves' : 'imperial_basin'); + assert.equal(f.originalParent.amount, 5); assert.equal(f.originalParent.elite, 0); + assert.deepEqual(f.originalParent.group, { [f.from]: 5 }); + assert.equal(f.originalHeldStance.aloneFlipHold, f.originalAutomaticSource.event); + assert.equal(transportLogs(f.game) - transportLogs(f.transport.before), 1); + assert.deepEqual(physical(f.game), f.originalPhysical); + assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory); +} +function open(f: SemutaAdvisorTransportFixture) { + const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; + assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaAdvisorFlipDiscard'); + assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:advisorFlip'); + assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })), + [{ card: f.cost, by: f.spender, face: true }]); + if (pending.continuation.kind !== 'karamaAdvisorFlipDiscard') throw Error('Use the original native advisor-flip cost'); + assert.deepEqual(pending.continuation.use.response, f.originalResponse); + assert.deepEqual(pending.continuation.resume.response, f.originalResponse); + assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null); + assert.deepEqual(game.pendingAutomaticAdvisorTransport, f.originalParent); + assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue); + assert.deepEqual(game.pendingArrivalReaction ?? null, f.originalArrival); + assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalHeldStance); + assert.deepEqual(physical(game), f.originalPhysical); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(game.discard.filter(card => card.id === f.cost).length, 1); + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function settled(game: Game, f: SemutaAdvisorTransportFixture, canceled: boolean) { + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null); + assert.equal(game.pendingAutomaticAdvisorTransport, undefined); assert.equal(game.pendingAutomaticAdvisorFlips, undefined); + assert.equal(game.pendingArrivalReaction ?? null, null); assert.equal(game.pendingGuildTransport ?? null, null); + assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null); + assert.equal(game.phaseOpening ?? null, null); + assert.deepEqual(physical(game), f.originalPhysical); + assert.equal(isAdvisor(player(game, f.owner), f.territory), canceled); + if (canceled) assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalHeldStance); + else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined); + assert.equal(transportLogs(game), transportLogs(f.game)); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function prevented(game: Game, f: SemutaAdvisorTransportFixture, claimed: boolean) { + game = settled(game, f, true); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed)); + assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed)); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed)); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed)); + return game; +} +function closeOriginalTurn(game: Game, f: SemutaAdvisorTransportFixture, canceled: boolean) { + const steps: { actor: string; action: Action }[] = [], guild = structuredClone(player(game, f.guild)); + const result = finish(game, steps); + assert.deepEqual(steps, [{ actor: f.guild, action: { type: 'endMovement' } }]); + assert.ok(!result.movementRemaining?.includes(f.guild)); + assert.equal(player(result, f.guild).spice, guild.spice); + assert.deepEqual(player(result, f.guild).forces, guild.forces); + assert.equal(player(result, f.guild).reserves, guild.reserves); + assert.equal(player(result, f.guild).moved, 0); + assert.equal(isAdvisor(player(result, f.owner), f.territory), canceled); + assert.equal(result.pendingAutomaticAdvisorTransport, undefined); assert.equal(result.pendingAutomaticAdvisorFlips, undefined); + assert.equal(transportLogs(result), transportLogs(f.game)); + assert.deepEqual(inventory(result), f.originalInventory); return result; +} + +for (const kind of ['cross', 'return'] as const) for (const claimed of [false, true]) void test( + `Advanced native ${kind} transport advisor conversion used cancellation cost ${claimed ? 'claim' : 'pass'} retains the paid shipment and resumes its arrival once`, () => { + const f = fixture({ kind }); nativeSource(f); + const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event }); + closeOriginalTurn(game, f, true); + }); + +for (const kind of ['cross', 'return'] as const) void test( + `Advanced native ${kind} transport advisor conversion allowance flips only the stranded group after the original payment`, () => { + const f = fixture({ kind }); nativeSource(f); + const game = settled(allow(structuredClone(f.game)), f, false); + assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost)); + assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta)); + closeOriginalTurn(game, f, false); + }); + +for (const kind of ['cross', 'return'] as const) void test( + `fresh ${kind} transport conversion costs reject stale and underlying controls while the committed parent stays parked`, () => { + const f = fixture({ kind }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event; + reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` }); + reject(game, f.spender, { type: 'semutaCommit', event }); + reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' }); + reject(game, f.guild, f.nativeAction); reject(game, f.guild, { type: 'endMovement' }); + assert.deepEqual(game.pendingAutomaticAdvisorTransport, f.originalParent); + assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue); + assert.deepEqual(physical(game), f.originalPhysical); + prevented(pass(game), f, false); + }); + +void test('changed committed transport evidence rejects before cost and before a fresh Semuta answer', () => { + const f = fixture({ kind: 'cross' }), beforeCost = structuredClone(f.game); + assert.ok(beforeCost.pendingAutomaticAdvisorTransport); beforeCost.pendingAutomaticAdvisorTransport.amount--; + reject(beforeCost, f.spender, f.action); + assert.ok(player(beforeCost, f.spender).hand.some(card => card.id === f.cost)); + assert.equal(beforeCost.discard.some(card => card.id === f.cost), false); + const afterCost = open(f); assert.ok(afterCost.pendingAutomaticAdvisorTransport); + afterCost.pendingAutomaticAdvisorTransport.amount--; + reject(afterCost, f.holder, { type: 'semutaCommit', event: afterCost.pendingTreacheryDiscard!.batch.event }); +}); + +void test('all four minimal policies answer both actual committed transport conversion costs legally', () => { + for (const kind of ['cross', 'return'] as const) { + const f = fixture({ kind }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + for (const difficulty of DIFFICULTIES) { + const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty; + const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty); + assert.deepEqual(own, before); + assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event); + let game = act(structuredClone(offered), f.holder, action); + const claimed = action.type === 'semutaCommit'; + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { + const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty; + const selected = botActions(choosing)[0]; assert.ok(selected); + assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); + game = act(game, f.holder, selected); + } else if (game.pendingTreacheryDiscard) game = pass(game); + closeOriginalTurn(prevented(game, f, claimed), f, true); + } + } +});