diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index cba68d5f..a011a742 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,46 @@ open mode gates. ## Current checkpoint and work +**Semuta on Homeworld, battle and Face Dance conversions — 26 October. Prototyped / Partial.** +Existing `karamaAdvisorFlipDiscard` now offers exact printed-role costs after +committed typed Homeworld transport, completed battle losses and completed Face +Dance replacement. Pure native validators retain original parent/queue, physical +results and cleanup. Claim/pass applies only conversion denial, then resumes the +original turn, winner-card choice or aftermath once. Producer-specific signature +additions keep earlier formats. No live battle or unfinished mandatory-cost guard +is relaxed; existing persistent controls/minimal bots serve the new sources. + +Three disjoint source lanes and root integration pass **13 files,151/151**, +types/lint/build and **ten native engine/GameTable SSR/actual Semuta-callback +journeys** on unchanged source. Twenty-two new cases cover five original sources, +claim/pass, native allowance, exact custody/cleanup, source rejection and four +profiles. Homeworld86/81 uses original Advanced R/C/BG/A/G with lobby-selected +Worlds, cache1 and dealt A Karama: G5→Junction costs3; actual high-Junction sponsor +and A10→Caladan cost5. Deposits/fees stay committed before the original shipper +really ends its turn. Battle85/90 uses R/C/BG/A/H, actual A9/H1 movement and an +unsupported last A normal: Shield/dial0.5/support0. Normal and genuine mutual +Traitors preserve actual losses; a winner's held Shield stays outside the fresh +group, then native cleanup keeps it through real controls. Face Dance325 uses +R/C/BG/T/A/E and selected CHOAM+Ix: actual deferred A9/E10 funds T5→24, real T20 +shipment/battle loss exhausts reserves, and the original matching Face Dancer +actually returns A9/kills its leader before conversion. Claim/pass preserves that +spent card/cycle and completes aftermath/Collection once. All45/57 Treachery cards +remain physical; no played hand, army, stance, wallet, custody, clock, card, battle +or parent is installed. + +Narrow feedback moves the World-custody assertion from undealt setup to actual +afterSetup; native initialization stays unchanged. Traitor selection uses the +actual registered pool, including native Auditor handling, rather than a fixed +count. Other matching/relocation/spiritual parents, broader modules and complete +modes/final assurance remain open. + +Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-advisor-aftermath-proof/`; +journeys/HTML: adjacent `20261026-semuta-advisor-aftermath-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#homeworld-battle-and-face-dance-conversion-costs--26-october-2026). + **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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d39415dc..3e5333e6 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,41 @@ # Dune implementation status +## 26 October 2026 — Semuta on World, battle and Face Dance conversion costs + +**Prototyped / Partial.** Existing advisor cost continuation retains committed +typed World transport, completed battle losses and completed Face Dance through +fresh claim/pass. Pure native parent validation keeps payment/custody, casualties, +card obligations and actual return/replacement. Only conversion denial runs before +original turn/cleanup/aftermath. Producer-specific signatures preserve earlier +formats; live-battle and unfinished mandatory-cost guards remain. Existing +persistent controls/minimal bots serve the new sources; mode gates stay intact. +[Source and scope](SEMUTA_SOURCE_UPDATE_20260907.md#homeworld-battle-and-face-dance-conversion-costs--26-october-2026). + +Three disjoint source lanes and root integration pass **13 files,151/151**, +types/lint/build and **ten native engine/GameTable SSR/actual Semuta-callback +journeys** on unchanged source. Twenty-two new cases cover five original sources, +claim/pass, native allowance, source rejection, exact custody/cleanup and four +profiles. Actual cache1 and dealt Karama supply all45/57-card sources. World86/81 +earns the real lobby module, native G5→Junction3 or sponsored A10→Caladan5, +preserves paid typed deposits and closes only the original shipper turn. +Battle85/90 earns actual A9/H1 movement, unsupported last A normal at dial0.5, +original Thufir5/Shield versus Iakin2, and separately real mutual-Traitor votes. +The actual registered pool includes Auditor handling; a winner's held Shield is +never fresh and remains through real optional cleanup. Face Dance325 earns +actual deferred19/T5→24, real20 shipment/battle loss and reserve exhaustion, +then actual zero-source replacement returns A9/kills its leader/reveals the +held dancer before conversion and native Collection. No played state, card, +custody, loss, clock, outcome or parent is installed. + +Narrow feedback asserts World custody after actual setup, keeping undealt setup +null; production initialization and source rules stay intact. +Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-advisor-aftermath-proof/`; +journeys/HTML: adjacent `20261026-semuta-advisor-aftermath-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 committed transport, Gamont and Storm costs **Prototyped / Partial.** Existing advisor-conversion cost continuation now diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 601285f1..0ed639bc 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -2,6 +2,99 @@ 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. +## Homeworld, battle and Face Dance conversion costs — 26 October 2026 + +**Prototyped / Partial.** Existing `karamaAdvisorFlipDiscard` now offers fresh +printed-role cancellation costs for mandatory conversion after typed Homeworld +transport, completed battle losses and completed Face Dance replacement. Existing +persistent neutral controls, private claim/pass and minimal legal profiles serve +these sources. This extends the original +[conversion contracts](ADVISOR_AUTOMATIC_FLIPS.md), not source authority, native +rule calculations or mode admission. Continuously-alone duration retains its +existing provisional policy. + +The original [automatic-conversion validator](../game/advisor-automatic-flip.ts) +and native private-parent validators remain authoritative. Battle conversion +still requires the actual completed battle, all mandatory costs finished and +its original cleanup cursor. Face Dance still requires completed reveal, return/ +replacement and card cycle. Homeworld transport still validates its original paid +source and typed deposit. No live battle, pending return or unfinished card cost +becomes eligible merely because Semuta exists. Preflight executes no producer, +conversion or cleanup. Producer-specific signature additions bind only these new +parents, preserving earlier receipt bytes. + +After the fresh group retires, restore the original response and apply native +denial once: + +- Homeworld transport keeps actual fee, normal/elite sources and typed deposit. + Conversion denial retains the held advisors; native completion consumes the + original private transport parent before its existing arrival/turn suffix. + No payment, custody transfer, approval or transport replays. +- Battle losses keep actual casualties, plans/votes, bounty and previous card + disposal. The original winner-card list stays an obligation, not another fresh + discard. Denial keeps the advisor hold, then the original optional winner-card + reply/aftermath resumes once. Allowance changes only the held group's stance. +- Face Dance keeps the actually returned army, killed leader, revealed spent + dancer and completed physical card cycle. Denial preserves the held advisor + occurrence and resumes native aftermath. It creates neither another Face + Dance nor replacement counters that the source did not supply. + +Three disjoint source lanes, bounded native/diff review and root integration pass +**13 files,151/151**, types/lint/build and **ten native engine/GameTable SSR/ +actual Semuta-callback journeys** on unchanged source. Twenty-two new cases cover +five sources, claim/pass, allowance, exact typed custody/cleanup, source rejection +and four minimal profiles. Every cost uses original setup, real cache1 Semuta and +originally dealt Karama. All45/57 Treachery cards remain physical; no played hand, +army, stance, wallet, custody, clock, card, battle, receipt or parent is installed. + +Homeworld86/81 uses fresh Advanced R/C/BG/A/G with the real lobby World option and +original Semuta World initializer. Actual BG advisors coexist with native G5 at +Tuek's or A10 at Arrakeen. G takes its real early turn and returns5 to Junction +for3; the other source earns actual G Wait/high-Junction sponsorship and returns +A10 to Caladan for5. Original permission/rate/income choices complete before +conversion; actual shipper endMovement closes only that turn. Both paid typed +deposits survive cost claim/pass unchanged. + +Battle85/90 uses original Advanced R/C/BG/A/H. R holds the original Karama; +CHOAM actually buys Semuta. Actual A9 and H1 movements leave one A normal beside +BG's advisor at Arrakeen. A's original Thufir5/Shield, dial0.5/support0 defeats +unarmed Iakin2/dial0 while spending its unsupported last normal. The separate +mutual-Traitor source uses native registered Traitor setup and actual held +opposing identities/votes. Its permutation derives the real pool including +native Auditor handling, without an assumed count. Earlier mandatory disposal +gets only real passes. Normal cleanup keeps the still-held Shield through an +actual discard-empty reply; both sources then reach native Collection7. + +Face Dance325 uses original Advanced R/C/BG/T/A/E, CHOAM+Ix57 and no other modules. +Actual T1 A9/E10 bribes defer19 until real Mentat collection, taking native T5 to24. +T really ships20 to Arrakeen for20. A's real unarmed5/dial1/support1 beats T's weak +leader/dial0; all T20 enter Tanks and reserves are truly empty. The actually held +matching Face Dancer, supplied no replacement source, returns A9, kills the +winning leader and completes its reveal/cycle before the BG counter. Denial +preserves this result through Collection; allowance earns native fighter income. +The exhausted stock and earlier payments/losses are never assigned to simulate +an outcome. + +Narrow feedback fixes only the World fixture's clock: custody is undealt/null +in original setup and asserted after actual setup completes. Production +initialization, source guards and rule calculations remain unchanged. Other +matching, relocation/spiritual parents, broader modules, complete modes and +final assurance remain open. + +Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-advisor-aftermath-proof/`; +journeys/HTML: adjacent `20261026-semuta-advisor-aftermath-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: [World sources](../tests/fixture-semuta-advisor-homeworld.ts), +[World cases](../tests/semuta-advisor-homeworld-engine.test.ts), +[battle sources](../tests/fixture-semuta-advisor-battle.ts), +[battle cases](../tests/semuta-advisor-battle-engine.test.ts), +[Face Dance source](../tests/fixture-semuta-advisor-face-dance.ts) and +[Face Dance cases](../tests/semuta-advisor-face-dance-engine.test.ts). + ## Committed transport, Gamont and Storm conversion costs — 26 October 2026 **Prototyped / Partial.** The existing `karamaAdvisorFlipDiscard` now offers diff --git a/game/engine.ts b/game/engine.ts index 5fd9c3a2..00f9750a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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 => !['groundMovement','guildTransport','gamont','storm'].includes(source.producer))) return false; + g.pendingAutomaticAdvisorFlips?.some(source => !['groundMovement','guildTransport','gamont','storm','homeworldTransport','battleLosses','faceDance'].includes(source.producer))) return false; const context: Game = {...g,pendingTreacheryDiscard:null,response}; try { // Native source quotes validate denial without requiring the spent card, @@ -6354,6 +6354,19 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu dials:g.stormDials, dialers:g.stormDialers, card:g.stormCard, known:g.stormCardKnown, spice:g.spice, wall:g.shieldWallDestroyed, ecological:g.ecologicalStorm, maker:g.greatMaker, + } : advisorProducer === 'homeworldTransport' ? { + homeworld:g.pendingAutomaticAdvisorHomeworldTransport, custody:g.homeworlds, + occupation:g.homeworldOccupationHistory, percentages:g.homeworldOccupiedPercentageLedger, + income:g.pendingHomeworldOccupiedIncome, + } : advisorProducer === 'battleLosses' ? { + battle:g.pendingAutomaticAdvisorBattle, context:g.lastBattleContext, + cards:g.pendingWinnerDiscards, rescue:g.pendingSukRescue, + capture:g.pendingCapture, auditor:g.pendingAuditor, income:g.pendingChoamBattleIncome, + tech:g.pendingTech, faceDance:g.pendingFaceDance, + } : advisorProducer === 'faceDance' ? { + faceDance:g.pendingAutomaticAdvisorFaceDance, context:g.lastBattleContext, + pending:g.pendingFaceDance, custody:g.homeworlds, + players:g.players.map(p => ({id:p.id, faceDancers:p.faceDancers, replaced:p.faceDancerReplacedTurn})), } : undefined, order: g.order, players: g.players.map(p => ({ diff --git a/game/reference.ts b/game/reference.ts index 71599e3a..9fe68dc4 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1299,6 +1299,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', + 'The same fresh cost response also follows committed Homeworld transport, completed battle losses and completed Face Dance replacement. Claim or Continue keeps typed deposits, paid fees, casualties, bounty, returned forces and spent cards. Only conversion denial changes; the original arrival, winner-card choice or battle aftermath then resumes once. No held winner card or Face Dancer becomes a candidate merely because its continuation remains pending.', '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.', @@ -1483,6 +1484,12 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-advisor-gamont-engine.test.ts', 'tests/fixture-semuta-advisor-storm.ts', 'tests/semuta-advisor-storm-engine.test.ts', + 'tests/fixture-semuta-advisor-homeworld.ts', + 'tests/semuta-advisor-homeworld-engine.test.ts', + 'tests/fixture-semuta-advisor-battle.ts', + 'tests/semuta-advisor-battle-engine.test.ts', + 'tests/fixture-semuta-advisor-face-dance.ts', + 'tests/semuta-advisor-face-dance-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-battle.ts b/tests/fixture-semuta-advisor-battle.ts new file mode 100644 index 00000000..40c3faa1 --- /dev/null +++ b/tests/fixture-semuta-advisor-battle.ts @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { isAdvisor } from '../game/advisors'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { traitorDeck } from '../game/traitors'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; +import { advisorBattleFlipTraitors } from './fixture-advisor-battle-flip'; +import { + actSemutaAdvisorMatching, nextSemutaAdvisorMatching, passSemutaAdvisorMatching, claimSemutaAdvisorMatching, + semutaAdvisorMatchingPlayer, semutaAdvisorMatchingInventory, +} from './fixture-semuta-advisor-matching'; + +export type SemutaAdvisorBattleStep = { actor: string; action: Action }; +export const actSemutaAdvisorBattle = actSemutaAdvisorMatching; +export const passSemutaAdvisorBattle = passSemutaAdvisorMatching; +export const claimSemutaAdvisorBattle = claimSemutaAdvisorMatching; +export const semutaAdvisorBattlePlayer = semutaAdvisorMatchingPlayer; +export const semutaAdvisorBattleInventory = semutaAdvisorMatchingInventory; +export const semutaAdvisorBattleTraitors = advisorBattleFlipTraitors; +export const semutaAdvisorBattleClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && + !game.truthtrance && !game.pendingTreacheryDiscard; +/** Collection legitimately changes balances after original cleanup; preserve + * committed physical loss/leader/usage facts separately from those balances. */ +export const semutaAdvisorBattlePhysical = (game: Game) => game.players.map(p => ({ id: p.id, forces: p.forces, + elites: p.elites, reserves: p.reserves, tanks: p.tanks, battleLosses: p.battleLosses, leaders: p.leaders, + shipped: p.shipped, moved: p.moved, revived: p.revived, freeForcesRevived: p.freeForcesRevived })); + +/** Native preparation/readiness only. Private plans/votes, mandatory fresh + * disposal and the new automatic response are explicit caller choices. */ +export function nextSemutaAdvisorBattle(game: Game): SemutaAdvisorBattleStep | null { + if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.automaticAdvisorFlip) return null; + if (!game.response && !game.phaseOpening) { + if (game.decision?.kind === 'battleCards') return null; + if (game.status === 'setup' && game.setupStage === 'traitors') { + const player = game.players.find(p => p.traitorChoices.length); assert.ok(player); + const leader = player.faction === 'atreides' && player.traitorChoices.includes('harkonnen-3') + ? 'harkonnen-3' : player.traitorChoices[0]; + return { actor: player.id, action: { type: 'traitor', leader } }; + } + const outcome = game.battle?.outcomeWindow; + if (!game.decision && outcome && !outcome.closed) { + const actor = [outcome.attacker, outcome.defender].find(id => !outcome.ready.includes(id)); assert.ok(actor); + return { actor, action: { type: 'battleOutcomeReady', event: outcome.event } }; + } + if (game.battle?.revealed && viewGame(game, game.host).battle?.traitorVoters.some(id => game.battle!.traitorCalls[id] === undefined)) return null; + } + return nextSemutaAdvisorMatching(game); +} +export const nextSemutaAdvisorBattleStep = nextSemutaAdvisorBattle; +export function advanceSemutaAdvisorBattle(game: Game, until: (state: Game) => boolean, + actions: SemutaAdvisorBattleStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextSemutaAdvisorBattle(game); + assert.ok(step, `Choose the original battle/fresh control at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actSemutaAdvisorBattle(game, step.actor, step.action, actions); + } + throw Error('The native Semuta/advisor battle did not reach its finite boundary'); +} +export function allowSemutaAdvisorBattle(game: Game, actions: SemutaAdvisorBattleStep[] = []): Game { + const source = game.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'battleLosses'); + 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 = actSemutaAdvisorBattle(game, actor.id, { type: 'passResponse' }, actions); + } + assert.notEqual(game.response?.automaticAdvisorFlip?.event, source.event); return game; +} +/** Keep the genuine optional winner Shield via its actual reply, then finish + * original cleanup to Collection. No fresh group or conversion is bypassed. */ +export function finishSemutaAdvisorBattle(game: Game, actions: SemutaAdvisorBattleStep[] = []): Game { + for (let count = 0; count < 250; count++) { + if (!game.battle && game.phase === 7 && semutaAdvisorBattleClean(game)) return game; + assert.ok(!game.pendingTreacheryDiscard && !game.response?.automaticAdvisorFlip, + 'Resolve the original fresh/conversion choice before native battle cleanup'); + const step = game.decision?.kind === 'battleCards' && !game.response && !game.phaseOpening + ? { actor: game.decision.player, action: { type: 'decision', discard: [] } satisfies Action } + : nextSemutaAdvisorBattle(game); + assert.ok(step); game = actSemutaAdvisorBattle(game, step.actor, step.action, actions); + } + throw Error('The original battle cleanup did not reach Collection'); +} + +/** Only original undealt Fisher-Yates draws, consumed by actual prediction. */ +function originalTraitorScalars(original: string[], desired: string[]) { + assert.deepEqual([...original].sort(), [...desired].sort()); + const current = [...original], scalars: number[] = []; + for (let i = current.length - 1; i > 0; i--) { + const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); + scalars.push(Math.floor((j + 0.5) * 4294967296 / (i + 1))); + [current[i], current[j]] = [current[j], current[i]]; + } + assert.deepEqual(current, desired); return scalars; +} +function predictOriginalTraitors(game: Game, actor: string, action: Action, scalars: number[], actions: SemutaAdvisorBattleStep[]) { + assert.equal(game.setupStage, 'prediction'); assert.ok(game.players.every(p => !p.traitors.length && !p.traitorChoices.length)); + 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); array[0] = scalars[cursor++]; + } else Reflect.apply(native, crypto, [array]); + return array; + }; + try { + const next = applyAction(game, actor, action); assert.equal(cursor, scalars.length); + actions.push({ actor, action: structuredClone(action) }); return next; + } finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** Genuine native Advanced losses before the new cancellation. No played + * force, leader, hand, traitor, battle, stance, clock or pending frame is set. */ +export function createSemutaAdvisorBattleFixture(options: { mutualTraitors?: boolean } = {}) { + const mutualTraitors = options.mutualTraitors ?? false, seller = 'richese', holder = 'choam', owner = 'beneGesserit'; + const atreides = 'atreides', harkonnen = 'harkonnen', spender = seller, territory = 'arrakeen', location = 'arrakeen:10'; + let game = createGame('SEMUTAADVISORBATTLE', newPlayer(seller, 'Richese', 'richese'), true, ['choam']); + for (const faction of ['choam', 'beneGesserit', 'atreides', 'harkonnen'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: SemutaAdvisorBattleStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaAdvisorBattle(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorBattle(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.setupStage, 'prediction'); assert.equal(game.semutaPreview, true); + assert.ok(game.players.every(p => !p.hand.length && !p.traitorChoices.length && !p.traitors.length)); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + const originalInventory = semutaAdvisorBattleInventory(game); assert.equal(originalInventory.length, 45); + const printed = game.deck.find(card => card.effect === 'karama'), shieldCard = game.deck.find(card => card.kind === 'shield'); + const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3), extra = game.deck.find(card => card.kind === 'poison'); + assert.ok(printed && shieldCard && extra); assert.equal(ordinary.length, 3); + const selected = [printed, ordinary[0], ordinary[1], shieldCard, ordinary[2], extra]; + 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 originalTraitorOrder = traitorDeck(game.players, game.expansions.includes('ix')); + const originalTraitors = [...originalTraitorOrder].sort(), traitorOrder = [...originalTraitorOrder]; + if (mutualTraitors) { + for (const [target, identity] of [[game.players.findIndex(p => p.id === atreides) * 4, 'harkonnen-3'], + [game.players.findIndex(p => p.id === harkonnen) * 4, 'atreides-0']] as const) { + const from = traitorOrder.indexOf(identity); assert.ok(from >= 0); + [traitorOrder[target], traitorOrder[from]] = [traitorOrder[from], traitorOrder[target]]; + } + } + const traitorScalars = originalTraitorScalars(originalTraitorOrder, traitorOrder), beforeTraitorDeal = structuredClone(game); + const traitorDeal = transition(() => { game = predictOriginalTraitors(game, owner, + { type: 'predict', faction: atreides, turn: 10 }, traitorScalars, actions); }); + const beforeStartingDeal = structuredClone(game); + let advisorSetup: { before: Game; steps: SemutaAdvisorBattleStep[]; after: Game } | null = null; + for (let count = 0; game.status === 'setup' && count < 100; count++) { + const step = nextSemutaAdvisorBattle(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, shield = shieldCard.id; + assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id)); + assert.equal(semutaAdvisorBattlePlayer(game, owner).forces[location], 1); + assert.equal(isAdvisor(semutaAdvisorBattlePlayer(game, owner), territory), true); + if (mutualTraitors) { + assert.ok(semutaAdvisorBattlePlayer(game, atreides).traitors.includes('harkonnen-3')); + assert.ok(semutaAdvisorBattlePlayer(game, harkonnen).traitors.includes('atreides-0')); + } + assert.deepEqual(semutaAdvisorBattleTraitors(game), originalTraitors); + 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: SemutaAdvisorBattleStep | undefined, bidAt = -1; + for (let count = 0; count < 900 && !semutaAdvisorBattlePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const step = nextSemutaAdvisorBattle(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(semutaAdvisorBattlePlayer(beforeBid, holder).spice - semutaAdvisorBattlePlayer(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 && semutaAdvisorBattleClean(state)); + act(owner, { type: 'endMovement' }); + }); + reach(state => state.phase === 5 && state.active === atreides && semutaAdvisorBattleClean(state)); + const atreidesDeparture = transition(() => { + act(atreides, { type: 'move', from: location, territory: 'imperial_basin', sector: 10, amount: 9, elite: 0 }); + reach(semutaAdvisorBattleClean); act(atreides, { type: 'endMovement' }); + }); + reach(state => state.phase === 5 && state.active === harkonnen && semutaAdvisorBattleClean(state)); + const harkonnenArrival = transition(() => { + act(harkonnen, { type: 'move', from: 'carthag:11', territory, sector: 10, amount: 1, elite: 0 }); + reach(semutaAdvisorBattleClean); act(harkonnen, { type: 'endMovement' }); + }); + reach(state => state.phase === 6 && semutaAdvisorBattleClean(state) && !!state.active); + const beforeBattle = structuredClone(game), choice = game.players.flatMap(p => viewGame(game, p.id).battleChoices) + .find(choice => choice.territory === territory); assert.ok(choice); + const battleAction: Action = { type: 'chooseBattle', territory, + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }; + act(choice.chooser, battleAction); + reach(state => !!state.battle && !state.battle.revealed && semutaAdvisorBattleClean(state) && + !state.battle.preparation && state.battle.preLeader?.closed !== false); + const beforePlans = structuredClone(game), planSteps: SemutaAdvisorBattleStep[] = [ + { actor: atreides, action: { type: 'battlePlan', leader: 'atreides-0', dial: 0.5, support: 0, weapon: null, defense: shield } }, + { actor: harkonnen, action: { type: 'battlePlan', leader: 'harkonnen-3', dial: 0, support: 0, weapon: null, defense: null } }, + ]; + for (const step of planSteps) act(step.actor, step.action); + const reveal = transition(() => { reach(state => state.battle?.revealed === true); }), revealed = structuredClone(game); + const lossAt = actions.length, voteHistory: { before: Game; step: SemutaAdvisorBattleStep; after: Game }[] = []; + const disposals: { before: Game; steps: SemutaAdvisorBattleStep[]; after: Game; event: string }[] = []; + for (let count = 0; game.response?.automaticAdvisorFlip?.producer !== 'battleLosses' && count < 150; count++) { + if (game.pendingTreacheryDiscard) { + const pending: NonNullable = game.pendingTreacheryDiscard; + assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy })), [{ card: shield, by: atreides }]); + disposals.push({ ...transition(() => { game = passSemutaAdvisorBattle(game, actions); }), event: pending.batch.event }); + } else if (game.battle?.revealed) { + const actor = viewGame(game, game.host).battle!.traitorVoters.find(id => game.battle!.traitorCalls[id] === undefined); + if (actor) { + const before = structuredClone(game), step: SemutaAdvisorBattleStep = { actor, action: { type: 'traitorCall', call: mutualTraitors } }; + act(step.actor, step.action); voteHistory.push({ before, step, after: structuredClone(game) }); + } else { + const step = nextSemutaAdvisorBattle(game); assert.ok(step); act(step.actor, step.action); + } + } else { + const step = nextSemutaAdvisorBattle(game); assert.ok(step); act(step.actor, step.action); + } + } + const automatic = game.response?.automaticAdvisorFlip; assert.ok(automatic?.producer === 'battleLosses'); + assert.ok(game.pendingAutomaticAdvisorBattle?.resume && game.pendingAutomaticAdvisorFlips?.length && game.lastBattleContext); + assert.equal(game.battle, null); assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.decision ?? null, null); + assert.equal(semutaAdvisorBattlePlayer(game, atreides).forces[location] ?? 0, 0); + assert.equal(semutaAdvisorBattlePlayer(game, harkonnen).forces[location] ?? 0, 0); + assert.equal(semutaAdvisorBattlePlayer(game, owner).forces[location], 1); + assert.equal(semutaAdvisorBattlePlayer(game, atreides).tanks, 1); assert.equal(semutaAdvisorBattlePlayer(game, harkonnen).tanks, 1); + assert.equal(game.discard.filter(card => card.id === shield).length, Number(mutualTraitors)); + assert.ok(semutaAdvisorBattlePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + assert.ok(semutaAdvisorBattlePlayer(game, spender).hand.some(card => card.id === cost)); + const source = structuredClone(game), originalResponse = structuredClone(game.response!); + const originalAutomaticSource = structuredClone(automatic), originalParent = structuredClone(game.pendingAutomaticAdvisorBattle); + const originalQueue = structuredClone(game.pendingAutomaticAdvisorFlips), originalBattle = structuredClone(game.lastBattleContext); + const originalStance = structuredClone(semutaAdvisorBattlePlayer(game, owner).advisors![territory]); + assert.equal(originalStance.aloneFlipHold, automatic.event); + const action: Action = { type: 'card', card: cost, mode: 'cancel' }; + assert.deepEqual(semutaAdvisorBattleInventory(game), originalInventory); assert.deepEqual(semutaAdvisorBattleTraitors(game), originalTraitors); + return { game, source, beforeCost: source, advanced: true as const, mutualTraitors, owner, bg: owner, holder, spender, + seller, richese: seller, atreides, harkonnen, winner: mutualTraitors ? null : atreides, recipient: owner, + cost, karama: cost, semuta: SEMUTA_DRUG_ID, shield, action, originalResponse, + originalAutomaticSource, originalParent, originalQueue, originalStance, originalBattle, + originalCleanup: structuredClone(originalParent.resume), originalPhysical: structuredClone(semutaAdvisorBattlePhysical(game)), + originalBalances: Object.fromEntries(game.players.map(p => [p.id, p.spice])), + originalPlans: structuredClone(revealed.battle!.plans), voteSteps: voteHistory.map(vote => vote.step), + territory, location, event: game.lastBattleContext.event, initial, lobby, setup, initialization, + beforeTraitorDeal, traitorDeal, traitorOrder, traitorScalars, beforeStartingDeal, afterSetup, advisorSetup, + deal: selected.map(card => card.id), dealOrder, originalInventory, originalTraitors, originalSpice, spiceOrder, + acquisition, acquired, declinedPreparation, atreidesDeparture, harkonnenArrival, beforeBattle, choice, battleAction, + beforePlans, planSteps, plans: planSteps, reveal, revealed, voteHistory, disposals, + lossHistory: { before: revealed, steps: structuredClone(actions.slice(lossAt)), after: source }, + actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length, + staging: [ + 'Original Advanced Semuta initializer on the genuinely ready undealt R/C/BG/A/H lobby, exact CHOAM/main35/cache10 and no modules. Only original unopened starting-card/Spice positions and scalar lotteries are selected. Richese really receives printed Karama outside combat; CHOAM really purchases cache1 Semuta. Harkonnen really receives two native ordinary starting cards; Atreides receives original Shield.', + mutualTraitors ? `Before any Traitor is dealt, the actual prediction action consumes the selected original ${originalTraitors.length}-card permutation from the registered native traitorDeck, including its actual Advanced Auditor handling. Atreides is really offered and chooses Iakin; Harkonnen naturally keeps Thufir in its original four-card packet. Both real revealed-battle votes call those held Traitors.` : `The original ${originalTraitors.length}-card native traitorDeck shuffle keeps identity order, including actual Advanced Auditor handling. Native private choices/kept packets execute; both actual revealed-battle votes decline Traitor calls.`, + 'BG actually places its original advisor beside A10 Arrakeen10 and declines native preparation. Atreides really moves nine to Basin10; Harkonnen really moves one original Carthag counter to Arrakeen, leaving nine at home. Choose the real A1/H1 battle; Thufir5/dial0.5/support0/Shield spends the last unsupported normal against Iakin2/dial0/unarmed.', + 'Actual plan seals, final readiness, revealed votes, casualties and leader effects execute. Only observed earlier mandatory Shield disposal groups receive actual passes, preserving the paid Semuta. Stop at native battleLosses conversion with battle=null and mandatory costs finished, before optional winner cleanup/aftermath.', + 'No played hand, leader, Traitor, force, stance, wallet, battle, clock, receipt or parent is installed. The original normal Shield keeper reply remains real; mutual-Traitor Shield stays mandatorily discarded. Native Collection follows original cleanup once.', + ], + }; +} +export type SemutaAdvisorBattleFixture = ReturnType; diff --git a/tests/fixture-semuta-advisor-face-dance.ts b/tests/fixture-semuta-advisor-face-dance.ts new file mode 100644 index 00000000..001ac0eb --- /dev/null +++ b/tests/fixture-semuta-advisor-face-dance.ts @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { traitorDeck } from '../game/traitors'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; +import { advisorFaceDanceFlipTraitors } from './fixture-advisor-face-dance-flip'; +import { + actSemutaCombatCapabilities, nextSemutaCombatCapabilities, + semutaCombatCapabilitiesPlayer, semutaCombatCapabilitiesInventory, semutaCombatCapabilitiesClean, + passSemutaCombatCapabilities, claimSemutaCombatCapabilities, +} from './fixture-semuta-combat-capabilities'; + +export type SemutaAdvisorFaceDanceStep = { actor: string; action: Action }; +export const actSemutaAdvisorFaceDance = actSemutaCombatCapabilities; +export const semutaAdvisorFaceDancePlayer = semutaCombatCapabilitiesPlayer; +export const semutaAdvisorFaceDanceInventory = semutaCombatCapabilitiesInventory; +export const semutaAdvisorFaceDanceClean = semutaCombatCapabilitiesClean; +export const semutaAdvisorFaceDanceTraitors = advisorFaceDanceFlipTraitors; +export const passSemutaAdvisorFaceDance = passSemutaCombatCapabilities; +export const claimSemutaAdvisorFaceDance = claimSemutaCombatCapabilities; +export const semutaAdvisorFaceDancePhysical = (game: Game) => game.players.map(p => ({ + id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, + leaders: p.leaders, battleLosses: p.battleLosses, faceDancers: p.faceDancers, +})); + +/** Native controls only. Face Dance and a completed replacement's conversion + * remain explicit caller choices; actual earlier Semuta groups receive passes. */ +export function nextSemutaAdvisorFaceDance(game: Game): SemutaAdvisorFaceDanceStep | 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 } }; + if (game.status === 'setup' && game.setupStage === 'traitors') { + const p = game.players.find(p => p.traitorChoices.length); assert.ok(p); + return { actor: p.id, action: { type: 'traitor', leader: p.traitorChoices[0] } }; + } + 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: 'arrakeen', sector: 10 } }; + const d = game.decision; + if (d?.kind === 'faceDance') return null; + if (d?.kind === 'advisorBattle' || d?.kind === 'advisor' || d?.kind === 'intrusion') + return { actor: d.player, action: { type: 'decision', accept: false } }; + if (d?.kind === 'revivalStop' || d?.kind === 'fullPlanOffer') + return { actor: d.player, action: { type: 'decision', decline: true } }; + if (d?.kind === 'fullPlanRead') return { actor: d.player, action: { type: 'decision', continue: true } }; + } + return nextSemutaCombatCapabilities(game); +} +export const nextSemutaAdvisorFaceDanceStep = nextSemutaAdvisorFaceDance; +export function advanceSemutaAdvisorFaceDance(game: Game, until: (state: Game) => boolean, + actions: SemutaAdvisorFaceDanceStep[] = []): Game { + for (let count = 0; count < 3600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const next = nextSemutaAdvisorFaceDance(game); + assert.ok(next, `Supply the real Face Dance/private plan at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actSemutaAdvisorFaceDance(game, next.actor, next.action, actions); + } + throw Error('The native zero-stock Face Dance programme did not reach its finite boundary'); +} +export function allowSemutaAdvisorFaceDance(game: Game, actions: SemutaAdvisorFaceDanceStep[] = []): Game { + assert.ok(game.response?.automaticAdvisorFlip?.producer === 'faceDance'); + for (let count = 0; game.response?.automaticAdvisorFlip?.producer === 'faceDance' && count < game.players.length; count++) { + const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p); + game = actSemutaAdvisorFaceDance(game, p.id, { type: 'passResponse' }, actions); + } + assert.notEqual(game.response?.automaticAdvisorFlip?.producer, 'faceDance'); return game; +} +/** Complete only original aftermath/Collection entry; no new return, Face + * Dance, shipment or battle declaration is made by this driver. */ +export function finishSemutaAdvisorFaceDance(game: Game, actions: SemutaAdvisorFaceDanceStep[] = []): Game { + assert.ok(!game.pendingTreacheryDiscard); + return advanceSemutaAdvisorFaceDance(game, state => !state.battle && state.phase >= 7 && state.phase <= 8 && + semutaAdvisorFaceDanceClean(state), actions); +} + +/** Select only the original finishSetup Face Dancer Fisher-Yates shuffle on + * actual advisorSetup. The real engine deals the conserved physical cards. */ +function dealOriginalFaceDancers(game: Game, step: SemutaAdvisorFaceDanceStep, actions: SemutaAdvisorFaceDanceStep[]) { + assert.equal(game.status, 'setup'); assert.equal(game.setupStage, 'forces'); assert.equal(step.action.type, 'advisorSetup'); + assert.ok(game.players.every(p => !p.traitorChoices.length && !p.faceDancers)); + const held = new Set(game.players.flatMap(p => p.traitors)); + const original = traitorDeck(game.players, true).filter(id => !held.has(id)); + const first = ['tleilaxu-3', 'tleilaxu-4', 'atreides-0']; assert.ok(first.every(id => original.includes(id))); + const order = [...first, ...original.filter(id => !first.includes(id))], 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 first actual Face Dancer 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'); + } +} + +/** Real deferred bribes fund all twenty Tleilaxu reserves. The real losing + * battle then leaves no reserve or board counter available for replacement. */ +export function createSemutaAdvisorFaceDanceFixture() { + const richese = 'richese', holder = 'choam', owner = 'beneGesserit', tleilaxu = 'tleilaxu', winner = 'atreides', spender = 'emperor'; + const territory = 'arrakeen', sector = 10, location = `${territory}:${sector}`; + let game = createGame('SEMUTAADVISORFACEDANCE', newPlayer(richese, 'Richese', 'richese'), true, ['choam', 'ix']); + for (const faction of ['choam', 'beneGesserit', 'tleilaxu', 'atreides', 'emperor'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: SemutaAdvisorFaceDanceStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaAdvisorFaceDance(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorFaceDance(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.advanced, true); assert.equal(game.semutaPreview, true); + assert.ok(game.players.every(p => p.hand.length === 0 && !p.faceDancers)); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + const originalInventory = semutaAdvisorFaceDanceInventory(game); assert.equal(originalInventory.length, 57); + const originalTraitors = traitorDeck(game.players, true).sort(); + const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-')); assert.ok(printed); + const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 5); + assert.equal(ordinary.length, 5); 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; + let faceDancerDeal: ReturnType | null = null; + let advisorSetup: { before: Game; steps: SemutaAdvisorFaceDanceStep[]; after: Game } | null = null; + for (let count = 0; game.status === 'setup' && count < 150; count++) { + const next = nextSemutaAdvisorFaceDance(game); assert.ok(next); + if (next.action.type === 'advisorSetup') { + const before = structuredClone(game), at = actions.length; + faceDancerDeal = dealOriginalFaceDancers(game, next, actions); game = faceDancerDeal.game; + advisorSetup = { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) }; + } else act(next.actor, next.action); + } + assert.equal(game.status, 'playing'); assert.ok(faceDancerDeal && advisorSetup); + const afterSetup = structuredClone(game); + assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id)); + assert.equal(semutaAdvisorFaceDancePlayer(game, owner).forces[location], 1); + assert.ok(semutaAdvisorFaceDancePlayer(game, owner).advisors?.[territory]); + assert.equal(semutaAdvisorFaceDancePlayer(game, winner).forces[location], 10); + const dancers = structuredClone(semutaAdvisorFaceDancePlayer(game, tleilaxu).faceDancers!); + const winningDisc = semutaAdvisorFaceDancePlayer(game, winner).leaders.filter(leader => + dancers.some(card => card.leader === leader.id && !card.revealed)).toSorted((a, b) => b.strength - a.strength)[0]; + const losingDisc = semutaAdvisorFaceDancePlayer(game, tleilaxu).leaders.toSorted((a, b) => a.strength - b.strength)[0]; + assert.ok(winningDisc && losingDisc); const winnerLeader = winningDisc.id, loserLeader = losingDisc.id; + assert.deepEqual(semutaAdvisorFaceDanceTraitors(game), originalTraitors); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder = [0, 1, 2, 3].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); + let beforeBid: Game | undefined, bid: SemutaAdvisorFaceDanceStep | undefined, bidAt = -1; + for (let count = 0; count < 1000 && !semutaAdvisorFaceDancePlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const next = nextSemutaAdvisorFaceDance(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 }; + reach(state => state.turn === 1 && state.phase === 5 && semutaAdvisorFaceDanceClean(state)); + const funding = transition(() => { + act(winner, { type: 'bribe', target: tleilaxu, amount: 9 }); + act(spender, { type: 'bribe', target: tleilaxu, amount: 10 }); + }); + assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).bribes, 19); + reach(state => state.turn === 1 && state.phase === 7 && semutaAdvisorFaceDanceClean(state)); + const fundingCollection = transition(() => { + reach(state => state.turn === 1 && state.phase === 8 && semutaAdvisorFaceDanceClean(state)); + }); + assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).bribes, 0); + assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).spice - semutaAdvisorFaceDancePlayer(fundingCollection.before, tleilaxu).spice, 19); + reach(state => state.turn === 2 && state.phase === 5 && state.active === tleilaxu && semutaAdvisorFaceDanceClean(state)); + assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).reserves, 20); + const shipment = transition(() => { + act(tleilaxu, { type: 'ship', territory, sector, amount: 20, elite: 0, allyPayment: 0 }); + reach(semutaAdvisorFaceDanceClean); + }); + assert.equal(semutaAdvisorFaceDancePlayer(shipment.before, tleilaxu).spice - semutaAdvisorFaceDancePlayer(game, tleilaxu).spice, 20); + assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).reserves, 0); + assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).forces[location], 20); + reach(state => state.turn === 2 && state.phase === 6 && semutaAdvisorFaceDanceClean(state) && !!state.active); + const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === territory); + assert.ok(choice); const battleAction: Action = { type: 'chooseBattle', territory, + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }; + act(choice.chooser, battleAction); + reach(state => !!state.battle && semutaAdvisorFaceDanceClean(state) && !state.battle.preparation && + state.battle.preLeader?.closed !== false); + const beforePlans = structuredClone(game), planSteps: SemutaAdvisorFaceDanceStep[] = [ + { actor: winner, action: { type: 'battlePlan', leader: winnerLeader, dial: 1, support: 1, weapon: null, defense: null } }, + { actor: tleilaxu, action: { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: null, defense: null } }, + ]; + for (const step of planSteps) act(step.actor, step.action); + const revealed = structuredClone(game), cleanupAt = actions.length; + reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'faceDance'); + assert.ok(game.decision?.kind === 'faceDance'); assert.equal(game.decision.player, tleilaxu); + assert.equal(game.decision.winner, winner); assert.equal(game.decision.identity, winnerLeader); + assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).reserves, 0); + assert.equal(Object.values(semutaAdvisorFaceDancePlayer(game, tleilaxu).forces).reduce((a, b) => a + b, 0), 0); + assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).tanks, 20); + assert.equal(semutaAdvisorFaceDancePlayer(game, winner).forces[location], 9); + const cleanup = { before: revealed, steps: structuredClone(actions.slice(cleanupAt)), after: structuredClone(game) }; + const faceDanceAction: Action = { type: 'decision', reveal: true, sources: {}, sector }; + const replacement = transition(() => { act(tleilaxu, faceDanceAction); }); + assert.ok(game.response?.automaticAdvisorFlip?.producer === 'faceDance'); + const automatic = game.response.automaticAdvisorFlip; + assert.equal(automatic.faceDance.amount, 0); assert.deepEqual(automatic.faceDance.sources, {}); + assert.deepEqual(automatic.faceDance.returned, { [winner]: 9 }); + assert.equal(semutaAdvisorFaceDancePlayer(game, winner).forces[location] ?? 0, 0); + assert.equal(semutaAdvisorFaceDancePlayer(game, winner).reserves, semutaAdvisorFaceDancePlayer(replacement.before, winner).reserves + 9); + assert.equal(semutaAdvisorFaceDancePlayer(game, tleilaxu).faceDancers!.find(card => card.leader === winnerLeader)?.revealed, true); + assert.equal(game.battle, null); assert.equal(game.pendingFaceDance, null); + assert.deepEqual(semutaAdvisorFaceDanceInventory(game), originalInventory); + assert.deepEqual(semutaAdvisorFaceDanceTraitors(game), originalTraitors); + return { game, kind: 'advisorFlip' as const, advanced: true as const, zeroReplacement: true as const, + richese, holder, owner, tleilaxu, winner, spender, cost, karama: cost, semuta: SEMUTA_DRUG_ID, + action: { type: 'card', card: cost, mode: 'cancel' } as Action, + originalResponse: structuredClone(game.response), originalAutomaticSource: structuredClone(automatic), + originalParent: structuredClone(game.pendingAutomaticAdvisorFaceDance!), originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!), + originalPhysical: structuredClone(semutaAdvisorFaceDancePhysical(game)), originalStance: structuredClone(semutaAdvisorFaceDancePlayer(game, owner).advisors!), + originalBattle: structuredClone(revealed.battle!), originalBattleContext: structuredClone(game.lastBattleContext!), + originalBalances: game.players.map(p => ({ id: p.id, spice: p.spice })), originalDeck: structuredClone(game.deck), + originalFaceDancers: structuredClone(semutaAdvisorFaceDancePlayer(game, tleilaxu).faceDancers!), + territory, location, sector, winnerLeader, loserLeader, faceDanceAction, battleAction, choice, + initial, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup, + faceDancerDeal: { original: faceDancerDeal.original, order: faceDancerDeal.order, scalars: faceDancerDeal.scalars, + before: advisorSetup.before, steps: advisorSetup.steps, after: advisorSetup.after }, + dancers, dealOrder, deal: selected.map(card => card.id), originalInventory, originalTraitors, originalSpice, spiceOrder, + acquisition, acquired, cache: acquisition, funding, fundingCollection, fuel: { funding, fundingCollection, shipment }, + shipment, beforeBattle, beforePlans, planSteps, revealed, cleanup, replacement, + actions, steps: actions, sourceActionCount: actions.length, + staging: [ + 'Original undealt Advanced R/C/BG/T/A/E Semuta initializer with native CHOAM/Ix decks and no other module. Actual BG setup places one advisor beside original Atreides10. Only unopened main/Spice positions and native scalar lotteries are selected; C buys cache1 Semuta and E is dealt printed Karama.', + 'The actual final setup advisor action consumes a selected conserved permutation of the original Face Dancer shuffle: T3/T4/A0 first. All actual private Traitor choices precede it; the engine physically deals all three unrevealed cards. The winning leader is selected from that actual matching living hand.', + 'At clean first movement A really bribes T9 and E really bribes T10. The nineteen deferred spice becomes spendable only through real Mentat collection; T’s native five then funds the actual turn2 city shipment of all twenty reserves for twenty spice. No stock or wallet is installed.', + 'The actual unarmed Arrakeen battle uses winning A leader5/dial1/support1 and weak T leader/dial0. All twenty T counters really enter Tanks; A retains nine. Native preparation, Traitor declines, final outcome and cleanup remain real actions; earlier Semuta offers are passed so C retains Drug.', + 'T has no reserve or board counter left, then actually reveals its held matching Face Dancer with sources{}. Real replacement returns A9, kills its leader, spends the physical Face Dancer and leaves original BG alone. Stop at the resulting native mandatory conversion before printed Karama cost.', + ] }; +} +export type SemutaAdvisorFaceDanceFixture = ReturnType; diff --git a/tests/fixture-semuta-advisor-homeworld.ts b/tests/fixture-semuta-advisor-homeworld.ts new file mode 100644 index 00000000..ba743130 --- /dev/null +++ b/tests/fixture-semuta-advisor-homeworld.ts @@ -0,0 +1,250 @@ +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 { homeworldContext } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options'; +import { junctionTransportChoice } from '../game/junction-transport-options'; +import type { HomeworldShipmentSources } from '../game/homeworld-shipment-options'; +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 SemutaAdvisorHomeworldKind = 'native' | 'junction'; +export type SemutaAdvisorHomeworldStep = { actor: string; action: Action }; +export const actSemutaAdvisorHomeworld = actSemutaRicheseCacheKarama; +export const passSemutaAdvisorHomeworld = passSemutaFremenGuildMovement; +export const claimSemutaAdvisorHomeworld = claimSemutaFremenGuildMovement; +export const semutaAdvisorHomeworldPlayer = semutaRicheseCacheKaramaPlayer; +export const semutaAdvisorHomeworldInventory = semutaRicheseCacheKaramaInventory; +export const semutaAdvisorHomeworldClean = semutaRicheseCacheKaramaClean; +export const semutaAdvisorHomeworldPhysical = (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, homeworlds: game.homeworlds, + 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 })), +}); + +/** Keep setup, Guild timing and typed Homeworld interception native. Neither + * the automatic conversion nor a fresh discard is silently answered here. */ +export function nextSemutaAdvisorHomeworld(game: Game, + kind: SemutaAdvisorHomeworldKind = 'native'): SemutaAdvisorHomeworldStep | null { + if (game.pendingTreacheryDiscard || game.truthtrance || game.response?.automaticAdvisorFlip) 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: kind === 'native' ? 'tueks_sietch' : 'arrakeen', sector: kind === 'native' ? 5 : 10 } }; + if (game.decision?.kind === 'advisorBattle' || game.decision?.kind === 'advisor') + return { actor: game.decision.player, action: { type: 'decision', accept: false } }; + if (game.decision?.kind === 'intrusion') return null; + if (game.decision?.kind === 'guildTiming') + return { actor: game.decision.player, action: { type: 'decision', take: kind === 'native' } }; + if (game.decision?.kind === 'homeworldShipmentGuild') + return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, allow: true } }; + } + return nextSemutaRicheseCacheKarama(game); +} +export function advanceSemutaAdvisorHomeworld(game: Game, until: (state: Game) => boolean, + actions: SemutaAdvisorHomeworldStep[] = [], kind: SemutaAdvisorHomeworldKind = 'native'): Game { + for (let count = 0; count < 1400; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextSemutaAdvisorHomeworld(game, kind); + assert.ok(step, 'Choose the original conversion or fresh discard before continuing'); + game = actSemutaAdvisorHomeworld(game, step.actor, step.action, actions); + } + throw Error('The original Homeworld-conversion programme did not reach its finite boundary'); +} +export function allowSemutaAdvisorHomeworld(game: Game, + actions: SemutaAdvisorHomeworldStep[] = []): Game { + assert.equal(game.response?.automaticAdvisorFlip?.producer, 'homeworldTransport'); + 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 = actSemutaAdvisorHomeworld(game, actor.id, { type: 'passResponse' }, actions); + } + assert.equal(game.response ?? null, null); return game; +} +/** Both original destinations are native Worlds and have no board-arrival + * child. Close only the already-shipped original actor's actual native turn. */ +export function finishSemutaAdvisorHomeworld(game: Game, + actions: SemutaAdvisorHomeworldStep[] = []): Game { + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null); + assert.equal(game.pendingAutomaticAdvisorHomeworldTransport, undefined); + assert.equal(game.pendingAutomaticAdvisorFlips, undefined); + assert.equal(game.pendingArrivalReaction ?? null, null); assert.equal(game.pendingHomeworldShipment ?? null, null); + assert.ok(semutaAdvisorHomeworldClean(game)); assert.equal(game.phase, 5); assert.ok(game.active); + const actor = game.active, shipper = semutaAdvisorHomeworldPlayer(game, actor); + assert.equal(shipper.shipped, true); assert.equal(shipper.moved, 0); + game = actSemutaAdvisorHomeworld(game, actor, { type: 'endMovement' }, actions); + assert.ok(!game.movementRemaining?.includes(actor)); return game; +} + +/** Original Advanced exact-CHOAM Homeworld initializer, real paid Semuta, + * and a native return or sponsored Junction return. Original typed custody, + * source quotes, payment and public receipts all come from native actions. */ +export function createSemutaAdvisorHomeworldFixture(options: { kind: SemutaAdvisorHomeworldKind }) { + const { kind } = options, richese = 'richese', holder = 'choam', owner = 'beneGesserit', spender = 'atreides', guild = 'guild'; + const territory = kind === 'native' ? 'tueks_sietch' : 'arrakeen', sector = kind === 'native' ? 5 : 10; + let game = createGame('SEMUTAADVISORHOMEWORLD', 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: SemutaAdvisorHomeworldStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaAdvisorHomeworld(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAdvisorHomeworld(game, until, actions, kind); }; + const transition = (run: () => void) => { + const before = structuredClone(game), start = actions.length; run(); + return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) }; + }; + const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); }); + 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-homeworld-preview' }; + assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true); + assert.equal(game.homeworlds?.custody, null); assert.deepEqual(game.expansions, ['choam']); + assert.ok(game.players.every(player => player.hand.length === 0)); + assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards && + !game.discoveryEnabled && !game.ecazTreachery); + const originalInventory = semutaAdvisorHomeworldInventory(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), from = `${territory}:${sector}`; + assert.ok(game.homeworlds?.custody); + assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id)); + assert.equal(semutaAdvisorHomeworldPlayer(game, owner).forces[from], 1); + assert.equal(isAdvisor(semutaAdvisorHomeworldPlayer(game, owner), territory), true); + assert.equal(semutaAdvisorHomeworldPlayer(game, kind === 'native' ? guild : spender).forces[from], kind === 'native' ? 5 : 10); + assert.ok(semutaAdvisorHomeworldPlayer(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: SemutaAdvisorHomeworldStep | undefined, bidAt = -1; + for (let count = 0; count < 900 && !semutaAdvisorHomeworldPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const step = nextSemutaAdvisorHomeworld(game, kind); 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 buys the original cache Semuta'); + const acquired = structuredClone(game), acquisition = { before: beforeBid, bid, + steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 }; + assert.equal(semutaAdvisorHomeworldPlayer(beforeBid, holder).spice - semutaAdvisorHomeworldPlayer(game, holder).spice, 1); + assert.equal(semutaAdvisorHomeworldPlayer(game, richese).spice - semutaAdvisorHomeworldPlayer(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 === (kind === 'native' ? guild : owner) && semutaAdvisorHomeworldClean(state)); + }); + let bgEnd: ReturnType | null = null; + let junctionOffer: ReturnType | null = null; + let offerStep: SemutaAdvisorHomeworldStep | null = null; + if (kind === 'junction') { + bgEnd = transition(() => { + act(owner, { type: 'endMovement' }); + reach(state => state.phase === 5 && state.active === spender && semutaAdvisorHomeworldClean(state)); + }); + const sponsor = viewGame(game, guild).junctionTransport; assert.ok(sponsor?.canOffer); + assert.equal(sponsor.recipient, spender); + offerStep = { actor: guild, action: { type: 'offerJunctionTransport', event: sponsor.offerEvent, rate: 'half' } }; + junctionOffer = transition(() => { act(offerStep!.actor, offerStep!.action); }); + } + const shipper = kind === 'native' ? guild : spender, amount = kind === 'native' ? 5 : 10; + const destination = `homeworld:${shipper}`, sources: HomeworldShipmentSources = { [from]: { normal: amount, elite: 0 } }; + const routeQuote = kind === 'native' + ? guildHomeworldShipmentChoice(viewGame(game, shipper), destination, sources, 0) + : junctionTransportChoice(viewGame(game, shipper), destination, sources, 0); + assert.equal(routeQuote.blocked, null); assert.ok(routeQuote.action); + const nativeAction = routeQuote.action, price = routeQuote.cost; + assert.equal(price, kind === 'native' ? 3 : 5); + const beforeDeclaration = structuredClone(game); + const beforeTypedGroups = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!); + const depositBefore = beforeTypedGroups.find(world => world.id === destination)?.forces[shipper]; assert.ok(depositBefore); + const transport = transition(() => { + act(shipper, nativeAction); + reach(state => state.response?.automaticAdvisorFlip?.producer === 'homeworldTransport'); + }); + const actor = semutaAdvisorHomeworldPlayer(game, shipper); + assert.equal(semutaAdvisorHomeworldPlayer(transport.before, shipper).spice - actor.spice, price); + assert.equal(actor.forces[from], undefined); assert.equal(actor.shipped, true); assert.equal(actor.moved, 0); + const afterTypedGroups = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!); + const depositAfter = afterTypedGroups.find(world => world.id === destination)?.forces[shipper]; assert.ok(depositAfter); + assert.equal(depositAfter.normal, depositBefore.normal + amount); assert.equal(depositAfter.elite, depositBefore.elite); + assert.equal(actor.reserves, semutaAdvisorHomeworldPlayer(transport.before, shipper).reserves + amount); + assert.equal(game.response?.kind, 'advisorFlip'); assert.equal(game.response?.owner, owner); + assert.equal(game.response?.automaticAdvisorFlip?.producer, 'homeworldTransport'); + assert.ok(game.pendingAutomaticAdvisorHomeworldTransport && game.pendingAutomaticAdvisorFlips?.length === 1); + assert.equal(game.pendingAutomaticAdvisorHomeworldTransport.receipt.kind, kind); + assert.equal(game.pendingAutomaticAdvisorHomeworldTransport.arrival, undefined); + assert.equal(isAdvisor(semutaAdvisorHomeworldPlayer(game, owner), territory), true); + assert.ok(semutaAdvisorHomeworldPlayer(game, owner).advisors?.[territory]?.aloneFlipHold); + assert.equal(game.pendingHomeworldShipment ?? 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(semutaAdvisorHomeworldPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + assert.ok(semutaAdvisorHomeworldPlayer(game, spender).hand.some(card => card.id === cost)); + assert.deepEqual(semutaAdvisorHomeworldInventory(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, shipper, actor: shipper, cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, + from, to: destination, destination, territory, sector, location: from, amount, elite: 0, sources, price, routeQuote, + originalResponse: structuredClone(game.response!), + originalAutomaticSource: structuredClone(game.response!.automaticAdvisorFlip!), + originalParent: structuredClone(game.pendingAutomaticAdvisorHomeworldTransport!), + originalQueue: structuredClone(game.pendingAutomaticAdvisorFlips!), + originalArrival: structuredClone(game.pendingArrivalReaction ?? null), + originalHeldStance: structuredClone(semutaAdvisorHomeworldPlayer(game, owner).advisors![territory]), + originalPhysical: structuredClone(semutaAdvisorHomeworldPhysical(game)), originalHistory: structuredClone(game.log), + originalCustody: structuredClone(game.homeworlds!.custody!), beforeTypedGroups, afterTypedGroups, + depositBefore: structuredClone(depositBefore), depositAfter: structuredClone(depositAfter), + initial, enableHomeworlds, lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder, originalSpice, + acquired, acquisition, openingOffer, declinedPreparation, bgEnd, junctionOffer, offerStep, + beforeDeclaration, transport: { ...transport, price }, originalInventory, + actions, sourceActions: actions, sourceActionCount: actions.length, + staging: [ + 'Actual lobby Homeworlds enable action, original Advanced Semuta initializer on the ready undealt R/C/BG/A/G exact-CHOAM roster, and no other optional modules. Original physical native pools remain.', + 'Select original unopened starting-card positions so Atreides really receives printed Karama, plus two original ordinary Spice positions. CHOAM actually purchases original cache Semuta for one spice.', + kind === 'native' + ? 'Actual advisorSetup at Tueks Sietch5 beside Guilds five native board counters. Native Storm, Spice, Charity, Bidding and Revival execute, BG declines preparation, and Guild takes its actual early turn.' + : 'Actual advisorSetup at Arrakeen10 beside Atreides ten native board counters. Native Storm, Spice, Charity, Bidding and Revival execute, BG declines preparation and Guild waits. BG really ends its turn; Guild then offers its real current high-Junction half tariff to Atreides.', + kind === 'native' + ? 'Actual owner-view guildHomeworldShipmentChoice returns G5 from Tueks to Junction for three. Native Homeworld interception, transport permission and Guild rate are allowed; payment and typed custody commit before the automatic conversion.' + : 'Actual owner-view junctionTransportChoice returns A10 from Arrakeen to Caladan for five. The original sponsored offer, payment and typed custody commit before the automatic conversion; any original Guild income resolves through native passes.', + 'Stop at the original homeworldTransport advisorFlip before Atreides spends its original printed Karama. No played hand, counter, stance, wallet, custody, clock, source or response is installed. Finish closes only that original shipped actor turn with actual endMovement.', + ], + }; +} +export type SemutaAdvisorHomeworldFixture = ReturnType; diff --git a/tests/semuta-advisor-battle-engine.test.ts b/tests/semuta-advisor-battle-engine.test.ts new file mode 100644 index 00000000..ae5d3f34 --- /dev/null +++ b/tests/semuta-advisor-battle-engine.test.ts @@ -0,0 +1,199 @@ +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 { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { + createSemutaAdvisorBattleFixture as fixture, actSemutaAdvisorBattle as act, + passSemutaAdvisorBattle as pass, claimSemutaAdvisorBattle as claim, + allowSemutaAdvisorBattle as allow, finishSemutaAdvisorBattle as finish, + semutaAdvisorBattlePlayer as player, semutaAdvisorBattleInventory as inventory, + semutaAdvisorBattleTraitors as traitors, semutaAdvisorBattlePhysical as physical, + type SemutaAdvisorBattleFixture, +} from './fixture-semuta-advisor-battle'; + +const balances = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice])); +const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready, + order: game.order, lastBattle: game.lastBattle, movementRemaining: game.movementRemaining, + storm: game.storm, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard }); +const battleFacts = (game: Game) => { + const b = game.lastBattleContext; assert.ok(b); + return { event: b.event, turn: b.turn, territory: b.territory, combatants: b.combatants, + winner: b.winner, result: b.result }; +}; +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: SemutaAdvisorBattleFixture) { + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true); + assert.ok(f.beforeTraitorDeal.players.every(p => !p.hand.length && !p.traitorChoices.length && !p.traitors.length)); + 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.equal(player(f.afterSetup, f.harkonnen).hand.length, 2); + assert.ok(player(f.afterSetup, f.atreides).hand.some(card => card.id === f.shield)); + assert.equal(player(f.afterSetup, f.owner).forces[f.location], 1); + assert.equal(player(f.afterSetup, f.atreides).forces[f.location], 10); + assert.equal(player(f.afterSetup, f.harkonnen).forces['carthag:11'], 10); + assert.equal(f.traitorScalars.length, f.originalTraitors.length - 1); + assert.deepEqual(traitors(f.afterSetup), f.originalTraitors); + if (f.mutualTraitors) { + assert.ok(player(f.afterSetup, f.atreides).traitors.includes('harkonnen-3')); + assert.ok(player(f.afterSetup, f.harkonnen).traitors.includes('atreides-0')); + } + 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.ok(f.atreidesDeparture.steps.some(step => step.actor === f.atreides && step.action.type === 'move' && step.action.amount === 9)); + assert.ok(f.harkonnenArrival.steps.some(step => step.actor === f.harkonnen && step.action.type === 'move' && step.action.amount === 1)); + assert.equal(player(f.beforeBattle, f.atreides).forces[f.location], 1); + assert.equal(player(f.beforeBattle, f.harkonnen).forces[f.location], 1); + assert.equal(player(f.beforeBattle, f.atreides).forces['imperial_basin:10'], 9); + assert.equal(player(f.beforeBattle, f.harkonnen).forces['carthag:11'], 9); + assert.equal(f.planSteps[0].action.dial, 0.5); assert.equal(f.planSteps[0].action.support, 0); + assert.equal(f.planSteps[0].action.defense, f.shield); assert.equal(f.planSteps[1].action.weapon, null); + assert.equal(f.revealed.battle?.revealed, true); assert.deepEqual(f.revealed.battle?.plans, f.originalPlans); + assert.equal(f.voteSteps.length, 2); assert.deepEqual(new Set(f.voteSteps.map(step => step.actor)), new Set([f.atreides, f.harkonnen])); + assert.ok(f.voteSteps.every(step => step.action.call === f.mutualTraitors)); + for (const disposal of f.disposals) { + assert.deepEqual(disposal.before.pendingTreacheryDiscard?.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy })), + [{ card: f.shield, by: f.atreides }]); + assert.ok(disposal.steps.every(step => step.action.type === 'semutaPass')); + assert.notEqual(disposal.after.pendingTreacheryDiscard?.batch.event, disposal.event); + } + assert.equal(f.disposals.length, Number(f.mutualTraitors)); + assert.equal(f.game.battle, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null); + assert.equal(f.game.pendingWinnerDiscards ?? null, null); assert.equal(f.game.decision ?? null, null); + assert.equal(f.game.phase, 6); assert.equal(f.game.turn, 1); + assert.equal(f.game.response?.kind, 'advisorFlip'); assert.deepEqual(f.game.response, f.originalResponse); + assert.equal(f.originalAutomaticSource.producer, 'battleLosses'); assert.equal(f.originalAutomaticSource.battle.event, f.event); + assert.equal(f.originalAutomaticSource.battle.result, f.mutualTraitors ? 'mutualTraitors' : 'normal'); + assert.equal(f.originalAutomaticSource.battle.winner, f.winner); + assert.deepEqual(f.game.pendingAutomaticAdvisorBattle, f.originalParent); + assert.deepEqual(f.game.pendingAutomaticAdvisorFlips, f.originalQueue); + assert.equal(f.originalParent.resume?.kind, f.mutualTraitors ? 'aftermath' : 'winner'); + assert.equal(f.originalParent.signature, JSON.stringify(f.originalParent.resume)); + assert.equal(f.originalStance.aloneFlipHold, f.originalAutomaticSource.event); + assert.equal(player(f.game, f.atreides).tanks, 1); assert.equal(player(f.game, f.harkonnen).tanks, 1); + assert.equal(player(f.game, f.atreides).forces[f.location] ?? 0, 0); + assert.equal(player(f.game, f.harkonnen).forces[f.location] ?? 0, 0); + assert.equal(player(f.game, f.owner).forces[f.location], 1); assert.equal(isAdvisor(player(f.game, f.owner), f.territory), true); + assert.equal(f.game.discard.filter(card => card.id === f.shield).length, Number(f.mutualTraitors)); + assert.equal(player(f.game, f.atreides).hand.some(card => card.id === f.shield), !f.mutualTraitors); + assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta)); + assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost)); + assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory); + assert.deepEqual(traitors(f.game), f.originalTraitors); assert.equal(f.sourceActionCount, f.actions.length); +} +function open(f: SemutaAdvisorBattleFixture) { + 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 committed battle 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.pendingAutomaticAdvisorBattle, f.originalParent); + assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue); + assert.deepEqual(game.lastBattleContext, f.originalBattle); assert.equal(game.battle, null); + assert.deepEqual(player(game, f.owner).advisors?.[f.territory], f.originalStance); + assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(balances(game), f.originalBalances); + assert.deepEqual(clock(game), clock(f.game)); assert.equal(game.discard.filter(card => card.id === f.shield).length, Number(f.mutualTraitors)); + 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(viewGame(game, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function nativeOutcome(state: Game, f: SemutaAdvisorBattleFixture, canceled: boolean) { + assert.equal(state.pendingAutomaticAdvisorBattle ?? null, null); assert.deepEqual(state.pendingAutomaticAdvisorFlips ?? [], []); + assert.equal(state.response?.automaticAdvisorFlip ?? null, null); assert.equal(state.battle, null); + assert.deepEqual(physical(state), f.originalPhysical); assert.deepEqual(battleFacts(state), battleFacts(f.game)); + assert.equal(isAdvisor(player(state, f.owner), f.territory), canceled); + if (canceled) assert.deepEqual(player(state, f.owner).advisors?.[f.territory], f.originalStance); + if (!f.mutualTraitors) { + assert.deepEqual(state.decision, { kind: 'battleCards', player: f.atreides, territory: f.territory, cards: [f.shield] }); + assert.ok(player(state, f.atreides).hand.some(card => card.id === f.shield)); + } + const cleanupSteps: { actor: string; action: Action }[] = [], game = finish(state, cleanupSteps); + assert.equal(game.phase, 7); assert.equal(game.turn, 1); assert.equal(game.battle, null); + assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null); + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingWinnerDiscards ?? null, null); + assert.equal(game.pendingAutomaticAdvisorBattle ?? null, null); assert.deepEqual(game.pendingAutomaticAdvisorFlips ?? [], []); + assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(battleFacts(game), battleFacts(f.game)); + 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(game.discard.filter(card => card.id === f.shield).length, Number(f.mutualTraitors)); + assert.equal(player(game, f.atreides).hand.some(card => card.id === f.shield), !f.mutualTraitors); + const keepReplies = cleanupSteps.filter(step => step.actor === f.atreides && step.action.type === 'decision' && Array.isArray(step.action.discard)); + assert.equal(keepReplies.length, Number(!f.mutualTraitors)); + if (keepReplies.length) assert.deepEqual(keepReplies[0].action.discard, []); + assert.ok(cleanupSteps.every(step => !['battlePlan', 'traitorCall', 'chooseBattle'].includes(step.action.type))); + // Quote the actual resulting board at the already earned pre-Collection + // balances. This is a read-only expectation, not an installed income source. + const collection = quoteSpiceCollection({ ...game, spice: f.game.spice, + players: game.players.map(p => ({ ...p, spice: f.originalBalances[p.id] })) }); + for (const receipt of collection.receipts) assert.equal(player(game, receipt.player).spice, receipt.balance); + assert.deepEqual(game.spice, collection.spice); + assert.equal(player(game, f.owner).spice, f.originalBalances[f.owner] + (canceled ? 0 : 2)); + assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors); + for (const step of f.planSteps) reject(game, step.actor, step.action); + return game; +} +function denied(state: Game, f: SemutaAdvisorBattleFixture, 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 mutualTraitors of [false, true]) for (const claimed of [false, true]) void test( + `native ${mutualTraitors ? 'mutual Traitors' : 'unsupported winner'} battle conversion cost Semuta ${claimed ? 'claim' : 'pass'} preserves losses and resumes original cleanup once`, () => { + const f = fixture({ mutualTraitors }); nativeSource(f); const offered = open(f); + denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + }, +); + +void test('native committed battle conversion allowance flips the real surviving advisor before original cleanup and Collection', () => { + for (const mutualTraitors of [false, true]) { + const f = fixture({ mutualTraitors }); 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 battle conversion cost rejects stale foreign disposal and underlying winner replies before cleanup', () => { + for (const mutualTraitors of [false, true]) { + const f = fixture({ mutualTraitors }), 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.atreides, { type: 'decision', discard: [] }); reject(game, f.owner, { type: 'passResponse' }); + for (const step of f.planSteps) reject(game, step.actor, step.action); + for (const disposal of f.disposals) 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 normal and mutual-Traitor battle conversion costs', () => { + for (const mutualTraitors of [false, true]) { + const f = fixture({ mutualTraitors }), 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-face-dance-engine.test.ts b/tests/semuta-advisor-face-dance-engine.test.ts new file mode 100644 index 00000000..f4d2d020 --- /dev/null +++ b/tests/semuta-advisor-face-dance-engine.test.ts @@ -0,0 +1,180 @@ +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 { quoteAutomaticAdvisorFlips } from '../game/advisor-automatic-flip'; +import { + createSemutaAdvisorFaceDanceFixture as fixture, actSemutaAdvisorFaceDance as act, + passSemutaAdvisorFaceDance as pass, claimSemutaAdvisorFaceDance as claim, + allowSemutaAdvisorFaceDance as allow, finishSemutaAdvisorFaceDance as finish, + semutaAdvisorFaceDancePlayer as player, semutaAdvisorFaceDanceInventory as inventory, + semutaAdvisorFaceDancePhysical as physical, semutaAdvisorFaceDanceTraitors as traitors, + type SemutaAdvisorFaceDanceFixture, +} from './fixture-semuta-advisor-face-dance'; + +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: SemutaAdvisorFaceDanceFixture) { + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true); + assert.deepEqual(f.setup.expansions, ['choam', 'ix']); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0)); + assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'beneGesserit', 'tleilaxu', '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.equal(f.advisorSetup.steps.length, 1); assert.equal(f.advisorSetup.steps[0].action.type, 'advisorSetup'); + 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.winner).forces[f.location], 10); + assert.equal(f.dancers.length, 3); assert.ok(f.dancers.every(card => !card.revealed)); + assert.ok(f.dancers.some(card => card.leader === f.winnerLeader)); + assert.deepEqual([...f.faceDancerDeal.order].sort(), [...f.faceDancerDeal.original].sort()); + assert.equal(f.faceDancerDeal.scalars.length, f.faceDancerDeal.original.length - 1); + 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.funding.steps, [ + { actor: f.winner, action: { type: 'bribe', target: f.tleilaxu, amount: 9 } }, + { actor: f.spender, action: { type: 'bribe', target: f.tleilaxu, amount: 10 } }, + ]); + assert.equal(player(f.funding.after, f.tleilaxu).spice, player(f.funding.before, f.tleilaxu).spice); + assert.equal(player(f.funding.after, f.tleilaxu).bribes, 19); + assert.equal(player(f.fundingCollection.after, f.tleilaxu).spice - player(f.fundingCollection.before, f.tleilaxu).spice, 19); + assert.equal(player(f.fundingCollection.after, f.tleilaxu).bribes, 0); + assert.equal(f.shipment.before.turn, 2); assert.equal(f.shipment.before.phase, 5); + assert.deepEqual(f.shipment.steps[0], { actor: f.tleilaxu, + action: { type: 'ship', territory: f.territory, sector: f.sector, amount: 20, elite: 0, allyPayment: 0 } }); + assert.equal(player(f.shipment.before, f.tleilaxu).spice - player(f.shipment.after, f.tleilaxu).spice, 20); + assert.equal(player(f.shipment.before, f.tleilaxu).reserves, 20); + assert.equal(player(f.shipment.after, f.tleilaxu).reserves, 0); + assert.equal(player(f.shipment.after, f.tleilaxu).forces[f.location], 20); + assert.equal(f.planSteps.length, 2); + assert.ok(f.planSteps.some(step => step.actor === f.winner && step.action.dial === 1 && step.action.support === 1)); + assert.ok(f.planSteps.some(step => step.actor === f.tleilaxu && step.action.dial === 0)); + assert.equal(f.cleanup.steps.filter(step => step.action.type === 'traitorCall' && step.action.call === false).length, 2); + assert.equal(f.cleanup.steps.filter(step => step.action.type === 'battleOutcomeReady').length, 2); + assert.equal(f.replacement.before.decision?.kind, 'faceDance'); + assert.equal(player(f.replacement.before, f.tleilaxu).reserves, 0); + assert.equal(Object.values(player(f.replacement.before, f.tleilaxu).forces).reduce((sum, n) => sum + n, 0), 0); + assert.equal(player(f.replacement.before, f.tleilaxu).tanks, 20, + 'All replacement stock is actually exhausted by the funded native battle'); + assert.equal(player(f.replacement.before, f.winner).forces[f.location], 9); + assert.deepEqual(f.replacement.steps, [{ actor: f.tleilaxu, action: f.faceDanceAction }]); + assert.equal(f.game.phase, 6); assert.equal(f.game.turn, 2); assert.equal(f.game.battle, null); assert.equal(f.game.pendingFaceDance, null); + assert.equal(f.originalBattle.event, f.originalBattleContext.event); assert.equal(f.originalBattleContext.result, 'normal'); + assert.equal(f.originalBattleContext.winner, f.winner); + assert.equal(f.originalAutomaticSource.producer, 'faceDance'); assert.deepEqual(f.originalAutomaticSource.faceDance, f.originalParent); + assert.equal(f.originalParent.amount, 0); assert.deepEqual(f.originalParent.sources, {}); + assert.deepEqual(f.originalParent.returned, { [f.winner]: 9 }); + assert.deepEqual(f.originalAutomaticSource.forces, { [f.location]: 1 }); + assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]); + assert.equal(player(f.game, f.winner).reserves, player(f.replacement.before, f.winner).reserves + 9); + const dead = player(f.game, f.winner).leaders.find(leader => leader.id === f.winnerLeader)!; + const living = player(f.replacement.before, f.winner).leaders.find(leader => leader.id === f.winnerLeader)!; + assert.equal(living.dead, false); assert.equal(dead.dead, true); assert.equal(dead.deaths, living.deaths + 1); + assert.equal(player(f.game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)?.revealed, true); + assert.equal(player(f.game, f.tleilaxu).faceDancers!.filter(card => card.revealed).length, 1); + assert.deepEqual(f.game.players.map(p => p.spice), f.replacement.before.players.map(p => p.spice), 'Face Dance supplies no invented bounty'); + assert.equal(player(f.game, f.owner).advisors?.[f.territory]?.aloneFlipHold, f.originalAutomaticSource.event); + assert.ok(f.actions.every(step => step.action.type !== 'semutaCommit' && step.action.type !== 'semutaSelect')); + assert.equal(f.originalInventory.length, 57); assert.deepEqual(inventory(f.game), f.originalInventory); + assert.deepEqual(traitors(f.game), f.originalTraitors); assert.equal(f.sourceActionCount, f.actions.length); +} +function open(f: SemutaAdvisorFaceDanceFixture) { + 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 actual completed Face Dance 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.equal(game.battle, null); assert.equal(game.pendingFaceDance, null); + assert.deepEqual(game.lastBattleContext, f.originalBattleContext); + assert.deepEqual(game.pendingAutomaticAdvisorFaceDance, f.originalParent); + assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue); + assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(player(game, f.owner).advisors, f.originalStance); + assert.deepEqual(game.players.map(p => ({ id: p.id, spice: p.spice })), f.originalBalances); + assert.deepEqual(player(game, f.tleilaxu).faceDancers, f.originalFaceDancers); + assert.deepEqual(game.deck, f.originalDeck); 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); assert.deepEqual(traitors(game), f.originalTraitors); return game; +} +function finished(state: Game, f: SemutaAdvisorFaceDanceFixture, prevented: boolean) { + const steps: { actor: string; action: Action }[] = [], game = finish(state, steps); + assert.equal(game.phase, 7); assert.equal(game.turn, 2); assert.equal(game.battle, null); assert.equal(game.pendingFaceDance, null); + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingAutomaticAdvisorFaceDance, undefined); + assert.equal(game.pendingAutomaticAdvisorFlips, undefined); assert.deepEqual(game.lastBattleContext, f.originalBattleContext); + assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(player(game, f.tleilaxu).faceDancers, f.originalFaceDancers); + assert.equal(isAdvisor(player(game, f.owner), f.territory), prevented); + assert.equal(fighterCount(player(game, f.owner), f.territory), prevented ? 0 : 1); + if (prevented) assert.deepEqual(player(game, f.owner).advisors, f.originalStance); + else assert.equal(player(game, f.owner).advisors?.[f.territory], undefined); + assert.equal(player(game, f.owner).spice, f.originalBalances.find(p => p.id === f.owner)!.spice + (prevented ? 0 : 2)); + assert.equal(player(game, f.tleilaxu).tanks, 20); assert.equal(player(game, f.tleilaxu).reserves, 0); + assert.equal(player(game, f.winner).forces[f.location] ?? 0, 0); + assert.deepEqual(quoteAutomaticAdvisorFlips(game), []); assert.deepEqual(game.deck, f.originalDeck); + assert.ok(steps.every(step => !['ship', 'move', 'card', 'battlePlan', 'traitorCall', 'chooseBattle'].includes(step.action.type))); + const later = game.log.slice(f.game.log.length); + assert.equal(later.filter(row => row.text.includes('revealed a Face Dancer and replaced')).length, 0); + assert.equal(later.filter(row => row.text.includes('drew three new Face Dancers')).length, 0); + reject(game, f.tleilaxu, f.faceDanceAction); + assert.deepEqual(inventory(game), f.originalInventory); assert.deepEqual(traitors(game), f.originalTraitors); return game; +} +function canceled(state: Game, f: SemutaAdvisorFaceDanceFixture, 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 claimed of [false, true]) void test( + `Advanced native exhausted-stock Face Dance advisor conversion cost Semuta ${claimed ? 'claim' : 'pass'} preserves the real return and revealed Face Dancer then resumes cleanup once`, () => { + const f = fixture(); 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 exhausted-stock Face Dance allowance converts the surviving BG counter and collects its actual stronghold income once', () => { + const f = fixture(); 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('the genuine zero-stock source rejects a positive replacement and fresh cost rejects stale underlying replay', () => { + const f = fixture(); + reject(f.replacement.before, f.tleilaxu, { ...f.faceDanceAction, sources: { reserves: 1 } }); + const 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.tleilaxu, f.faceDanceAction); reject(game, f.owner, { type: 'passResponse' }); + reject(game, f.winner, f.planSteps[0].action); reject(game, f.tleilaxu, f.planSteps[1].action); + reject(game, f.owner, { type: 'prepareAdvisors', territory: f.territory }); + assert.deepEqual(game.pendingAutomaticAdvisorFaceDance, f.originalParent); + assert.deepEqual(physical(game), f.originalPhysical); canceled(pass(game), f, false); +}); + +void test('all four minimal profiles legally answer the actual exhausted-stock Face Dance conversion used cost', () => { + const f = fixture(), 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-homeworld-engine.test.ts b/tests/semuta-advisor-homeworld-engine.test.ts new file mode 100644 index 00000000..e136acf1 --- /dev/null +++ b/tests/semuta-advisor-homeworld-engine.test.ts @@ -0,0 +1,193 @@ +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 { homeworldContext } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + createSemutaAdvisorHomeworldFixture as fixture, actSemutaAdvisorHomeworld as act, + passSemutaAdvisorHomeworld as pass, claimSemutaAdvisorHomeworld as claim, + allowSemutaAdvisorHomeworld as allow, finishSemutaAdvisorHomeworld as finish, + semutaAdvisorHomeworldPlayer as player, semutaAdvisorHomeworldInventory as inventory, + semutaAdvisorHomeworldPhysical as physical, type SemutaAdvisorHomeworldFixture, +} from './fixture-semuta-advisor-homeworld'; + +const transportLogs = (game: Game, f: SemutaAdvisorHomeworldFixture) => game.log.filter(row => + row.automatic?.name === (f.kind === 'native' ? 'Homeworld shipment' : 'Junction transport')).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: SemutaAdvisorHomeworldFixture) { + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true); + assert.equal(f.setup.homeworlds?.custody, null); assert.ok(f.afterSetup.homeworlds?.custody); + assert.deepEqual(f.setup.expansions, ['choam']); + assert.deepEqual(f.enableHomeworlds.steps, [{ actor: f.initial.host, action: { type: 'homeworlds', enabled: 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.shipper).forces[f.from], f.amount); + 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'); + if (f.kind === 'junction') { + assert.ok(f.bgEnd && f.offerStep && f.junctionOffer); + assert.deepEqual(f.bgEnd.steps[0], { actor: f.owner, action: { type: 'endMovement' } }); + assert.equal(f.offerStep.actor, f.guild); assert.equal(f.offerStep.action.type, 'offerJunctionTransport'); + assert.equal(f.offerStep.action.rate, 'half'); assert.deepEqual(f.junctionOffer.steps, [f.offerStep]); + } else assert.equal(f.junctionOffer, null); + assert.equal(f.routeQuote.blocked, null); assert.deepEqual(f.routeQuote.action, f.nativeAction); + assert.equal(f.transport.before.active, f.shipper); assert.equal(player(f.transport.before, f.shipper).shipped, false); + assert.deepEqual(f.transport.steps[0], { actor: f.shipper, action: f.nativeAction }); + assert.equal(f.price, f.kind === 'native' ? 3 : 5); + assert.equal(player(f.game, f.shipper).spice, player(f.transport.before, f.shipper).spice - f.price); + assert.equal(player(f.game, f.shipper).forces[f.from], undefined); + assert.equal(player(f.game, f.shipper).reserves, player(f.transport.before, f.shipper).reserves + f.amount); + assert.equal(f.depositAfter.normal, f.depositBefore.normal + f.amount); + assert.equal(f.depositAfter.elite, f.depositBefore.elite); + assert.equal(f.depositAfter.normal + f.depositAfter.elite, 20); + assert.equal(player(f.game, f.shipper).shipped, true); assert.equal(player(f.game, f.shipper).moved, 0); + assert.equal(f.game.active, f.shipper); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); + assert.deepEqual(f.game.response, f.originalResponse); + assert.ok(f.originalAutomaticSource.producer === 'homeworldTransport'); + assert.deepEqual(f.originalAutomaticSource.transport, f.originalParent.receipt); + assert.deepEqual(f.originalQueue, [f.originalAutomaticSource]); + assert.equal(f.originalParent.receipt.kind, f.kind); assert.equal(f.originalParent.receipt.player, f.shipper); + assert.equal(f.originalParent.receipt.destination, f.destination); + assert.equal(f.originalParent.receipt.amount, f.amount); assert.equal(f.originalParent.receipt.elite, 0); + assert.deepEqual(f.originalParent.receipt.group, { [f.from]: f.amount }); + assert.ok(f.originalParent.sourceEvent.length > 0); assert.equal(f.originalParent.arrival, undefined); + assert.equal(f.originalHeldStance.aloneFlipHold, f.originalAutomaticSource.event); + assert.equal(transportLogs(f.game, f) - transportLogs(f.transport.before, f), 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: SemutaAdvisorHomeworldFixture) { + 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.pendingAutomaticAdvisorHomeworldTransport, 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: SemutaAdvisorHomeworldFixture, canceled: boolean) { + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null); + assert.equal(game.pendingAutomaticAdvisorHomeworldTransport, undefined); assert.equal(game.pendingAutomaticAdvisorFlips, undefined); + assert.equal(game.pendingArrivalReaction ?? null, null); assert.equal(game.pendingHomeworldShipment ?? 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); + const deposited = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!) + .find(world => world.id === f.destination)?.forces[f.shipper]; + assert.deepEqual(deposited, f.depositAfter); + assert.equal(transportLogs(game, f), transportLogs(f.game, f)); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function prevented(game: Game, f: SemutaAdvisorHomeworldFixture, 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: SemutaAdvisorHomeworldFixture, canceled: boolean) { + const steps: { actor: string; action: Action }[] = [], shipper = structuredClone(player(game, f.shipper)); + const result = finish(game, steps); + assert.deepEqual(steps, [{ actor: f.shipper, action: { type: 'endMovement' } }]); + assert.ok(!result.movementRemaining?.includes(f.shipper)); + assert.equal(player(result, f.shipper).spice, shipper.spice); + assert.deepEqual(player(result, f.shipper).forces, shipper.forces); + assert.equal(player(result, f.shipper).reserves, shipper.reserves); + assert.equal(player(result, f.shipper).moved, 0); + assert.deepEqual(result.homeworlds!.custody, f.originalCustody); + assert.equal(isAdvisor(player(result, f.owner), f.territory), canceled); + assert.equal(result.pendingAutomaticAdvisorHomeworldTransport, undefined); assert.equal(result.pendingAutomaticAdvisorFlips, undefined); + assert.equal(transportLogs(result, f), transportLogs(f.game, f)); + assert.deepEqual(inventory(result), f.originalInventory); return result; +} + +for (const kind of ['native', 'junction'] as const) for (const claimed of [false, true]) void test( + `Advanced ${kind} Homeworld transport advisor conversion used cancellation cost ${claimed ? 'claim' : 'pass'} retains the paid typed return and resumes 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 ['native', 'junction'] as const) void test( + `Advanced ${kind} Homeworld transport conversion allowance flips only the stranded advisor after payment and typed deposit`, () => { + 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 ['native', 'junction'] as const) void test( + `fresh ${kind} Homeworld conversion costs reject stale and underlying controls while typed return evidence 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.shipper, f.nativeAction); reject(game, f.shipper, { type: 'endMovement' }); + assert.deepEqual(game.pendingAutomaticAdvisorHomeworldTransport, f.originalParent); + assert.deepEqual(game.pendingAutomaticAdvisorFlips, f.originalQueue); + assert.deepEqual(physical(game), f.originalPhysical); prevented(pass(game), f, false); + }); + +void test('changed original Homeworld source rejects before cost and before a fresh Semuta answer', () => { + const f = fixture({ kind: 'native' }), beforeCost = structuredClone(f.game); + assert.ok(beforeCost.pendingAutomaticAdvisorHomeworldTransport); + beforeCost.pendingAutomaticAdvisorHomeworldTransport.sourceEvent = 'different-original-source'; + 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.pendingAutomaticAdvisorHomeworldTransport); + afterCost.pendingAutomaticAdvisorHomeworldTransport.sourceEvent = 'different-original-source'; + reject(afterCost, f.holder, { type: 'semutaCommit', event: afterCost.pendingTreacheryDiscard!.batch.event }); +}); + +void test('all four minimal policies answer both actual typed Homeworld transport conversion costs legally', () => { + for (const kind of ['native', 'junction'] 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); + } + } +});