diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e8021932..7bce289e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,19 @@ open mode gates. ## Current checkpoint and work +**Basic Richese movement and worm-ride interruptions — 29 October. Prototyped / +Partial.** Richese can reveal its No-Field while another faction's ordinary +movement waits for supported Fremen, Ixian or CHOAM reactions. Semuta may also +recover the private CHOAM/Ixian Ambassador discard from a native Fremen worm +ride, including a Bene Gesserit copy; the original ride queue resumes once. +Native-action fixtures cover zero and positive markers, allow/cancel, projected +controls and legal bot responses; a separate staged remaining ride tests queue +binding. Thirteen related offline files pass 78 cases. Types/lint and the +production build pass. Other parent interactions and complete faction rules +remain open; deployed acceptance is outstanding. +[Reveal scope](RICHESE_NO_FIELD_RULES.md#basic-foreign-movement-interruptions--29-october-2026); +[Semuta scope](SEMUTA_SOURCE_UPDATE_20260907.md#basic-ambassador-discards-during-a-worm-ride--29-october-2026). + **Basic Richese reveal during additional responses — 29 October. Prototyped / Partial.** Richese may now reveal an existing No-Field during clean Emperor allied gifts, Tleilaxu revival income, selected Atreides Spice forecasts and diff --git a/docs/RICHESE_NO_FIELD_RULES.md b/docs/RICHESE_NO_FIELD_RULES.md index 81504e06..5a9e57a3 100644 --- a/docs/RICHESE_NO_FIELD_RULES.md +++ b/docs/RICHESE_NO_FIELD_RULES.md @@ -2,6 +2,18 @@ Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode. +## Basic foreign-movement interruptions — 29 October 2026 + +In the supported Basic native Ix/CHOAM roster, Richese may also reveal its +existing marker while another faction's ordinary movement awaits a Fremen or +Ixian movement response or a CHOAM movement decision. The original physical +movement, source and reaction remain pending. Reveal places the available +forces or removes a zero marker; it does not count as a movement or replay the +original move. The implementation checks the selected foreign forces and +retains its existing current-turn Truthtrance and commitment boundaries. +Special movement parents and other optional modules remain outside this slice. +This interrupt order is a provisional online interpretation. + ## Basic additional response windows — 29 October 2026 The voluntary before-Battle reveal also works during clean ordinary Emperor diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 89449bc1..8a955a8e 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,14 @@ # Rule decision index +## Basic Richese movement and worm-ride interruptions — 29 October 2026 + +Voluntary No-Field reveal can interrupt supported foreign ordinary movement +responses; Semuta can recover qualifying Ambassador discards on an ordinary +Fremen worm ride. Both retain their original continuations. These online timing +choices are provisional, and other parent interactions remain guarded. +[Reveal scope](RICHESE_NO_FIELD_RULES.md#basic-foreign-movement-interruptions--29-october-2026); +[Semuta scope](SEMUTA_SOURCE_UPDATE_20260907.md#basic-ambassador-discards-during-a-worm-ride--29-october-2026). + ## Richese voluntary reveal during Basic responses — 29 October 2026 The printed before-Battle reveal now composes with clean Emperor gifts, diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index f3a03c78..86f06a85 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -1,5 +1,16 @@ # Semuta Drug source update and implementation boundary +## Basic Ambassador discards during a worm ride — 29 October 2026 + +**Prototyped / Partial.** A native Fremen worm ride into a CHOAM or Ixian +Ambassador, including a Bene Gesserit copy, can now offer the original private +discard group to Semuta. CHOAM's credit remains committed; an Ixian replacement +is drawn once after the reaction. The original ride and remaining Spice Blow +queue resume once, with the queue bound to the saved discard continuation. +This covers the clean Basic profile; overlapping Terror, summoned worms and +other parent interactions remain guarded. The ordering is an online +interpretation of the printed fresh-discard timing, not a new publisher ruling. + ## Public Richese Homeworld entry — 29 October 2026 **Prototyped / Partial.** Fresh public Basic and Advanced games with Richese, diff --git a/game/engine.ts b/game/engine.ts index ce28c45e..15a84b73 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1696,7 +1696,7 @@ export type Game = { | { stage: 'offer'; passed: string[] } | { stage: 'select'; player: string; candidates: string[] }; continuation: - | { kind: 'ambassador'; entry: NonNullable } + | { kind: 'ambassador'; entry: NonNullable; wormCursor?: string } | { kind: 'discoveryStash'; event: string; owner: string; card: string } | { kind: 'nexusChoamTrade'; event: string; owner: string; card: string; spiceAfter: number } | { kind: 'nexusChoamBetrayal'; event: string; owner: string; target: string; card: string; handBefore: number } @@ -9626,6 +9626,11 @@ function supportedRicheseTruthLot(g: Game): boolean { (lot.method === 'onceAround' || lot.method === 'silent') && !lot.outcome && !g.currentAuctionSale && !g.auction && !g.ixAuction; } +/** Bind a parked native ride to its original remaining Spice Blow sequence. */ +function ambassadorWormDiscardCursor(g: Game): string { + return JSON.stringify([g.turn, g.phase, g.wormRides, g.spiceSequence, + g.spiceResolution, g.nexus, g.summonedNexusBeforeRides, g.greatMaker]); +} function semutaOfferSupported( g: Game, continuation: NonNullable['continuation'], @@ -9727,10 +9732,16 @@ function semutaOfferSupported( !!g.pendingArrivalOverlap && bareBasicSemutaProfile(g) && g.pendingArrivalOverlap.ambassadorEvent === continuation.entry.ambassadorEvent && ambassadorTerrorEntryMatches(g, continuation.entry); + const ambassadorWormDiscard = ambassadorDiscard && continuation.entry.resume === 'wormRide' && + continuation.entry.arrivalCause === 'wormRide' && g.phase === 1 && + !!continuation.wormCursor && continuation.wormCursor === ambassadorWormDiscardCursor(g) && + validAmbassadorResume(g, continuation.entry) && !g.pendingArrivalOverlap && + (!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction); if (ambassadorDiscard && - (!(bareBasicSemutaProfile(g) || basicIndependentRecruitsSemutaProfile(g) && !g.pendingArrivalOverlap) || g.phase !== 5 || - continuation.entry.stage !== 'cards' || continuation.entry.resume !== 'none' || - !['shipment', 'movement'].includes(continuation.entry.arrivalCause ?? '') || + (!(bareBasicSemutaProfile(g) || basicIndependentRecruitsSemutaProfile(g) && !g.pendingArrivalOverlap) || + !(ambassadorWormDiscard || g.phase === 5 && continuation.entry.resume === 'none' && + ['shipment', 'movement'].includes(continuation.entry.arrivalCause ?? '')) || + continuation.entry.stage !== 'cards' || !['choam', 'ixians'].includes(continuation.entry.effect ?? '') || continuation.entry.guildAdvisorOrigin || continuation.entry.revivalEvent || continuation.entry.victoryEvent || continuation.entry.relocation || @@ -9739,7 +9750,7 @@ function semutaOfferSupported( (g.pendingArrivalOverlap && !overlapOwnsAmbassadorDiscard(g, continuation.entry)) || g.pendingArrivalReaction?.length || g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove || - g.ornithopter || g.summonedWorm || g.wormRides.length > 0 || + g.ornithopter || g.summonedWorm || !ambassadorWormDiscard && g.wormRides.length > 0 || g.pendingRevival)) return false; if (continuation.kind === 'karamaBgConversionCancelDiscard' && !semutaBgConversionCancelResponse(g, continuation.use.response, continuation.resume.pendingKarama)) return false; @@ -27427,6 +27438,8 @@ function decideAmbassador(g: Game, p: Player, action: Action) { stageTreacheryDiscard(g, `ambassador:${entry.effect}`, entries, { kind: 'ambassador', entry, + ...(entry.resume === 'wormRide' && entry.arrivalCause === 'wormRide' + ? { wormCursor: ambassadorWormDiscardCursor(g) } : {}), }); } } diff --git a/game/reference.ts b/game/reference.ts index 470dd2f8..40f325d3 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1131,6 +1131,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.', 'In supported Basic native games, voluntary revelation is also available during an Ix phase opening or selected auction and market decisions without replacing that decision. Other interruption windows remain unfinished.', 'In supported Basic games, your voluntary reveal can also interrupt a clean Emperor allied spice gift, Tleilaxu revival-income response, Atreides Movement-phase Spice forecast without Bene Gesserit, or Guild’s own ordinary reserve-shipment rate response when the shipment enters a different territory from the marker. The original response and payment continue once; other interruptions remain guarded.', + 'During supported Basic games you can also reveal while another faction’s ordinary movement waits for a Fremen or Ixian response or a CHOAM decision. The original move and its response remain pending; a zero No-Field disappears without creating physical forces.', 'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return. Select the marker separately from physical counters; ordinary movement remains available.', 'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible Homeworlds, alone or with selected physical Richese counters from the marker’s current World; its own Homeworld is an eligible endpoint only in Advanced. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal; available reserves are placed there unless it is already on its own World, where the physical pool stays unchanged. Only one marker may be deployed globally. Fresh placement from the screen to your own Homeworld and optional-module combinations remain unavailable; an Advanced native return uses the provisional physical-reserve interpretation described below. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.', 'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.', @@ -1407,6 +1408,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Reactive Baliset and La La La also offer their used card after the accepted cost. The original uncommitted move or free-revival request is already stopped: claim or Continue installs the original restriction without moving forces, consuming movement, reviving or charging the denied request. Another legal move or paid revival remains available. Movement-card, Ambassador, Nexus and other competing parents remain separate.', 'A clean proactive Trip to Gamont also pauses for a selected elite or concealed No-Field. Claim or Continue returns the exact selected physical counter, or performs the native No-Field reveal and returns at most one actual force after reserve-capped materialization. A zero token reveals without inventing a force; taking the used card does not undo that effect or spend revival usage.', 'In supported bare Basic tables with Richese, one entry may earn both an Ambassador and Terror opportunity. Their existing provisional storm order stays fixed while CHOAM/Ix or Sabotage/Robbery discards offer their separate Semuta responses. Complete each fresh group before its original effect continues; the other earned opportunity waits. The table shows when the discard response is pending. Credit, replacement, gift and physical entry are never repeated.', + 'In supported Basic games, CHOAM and Ixian Ambassador discards from an ordinary Fremen worm ride, including Bene Gesserit copies, also offer Semuta before the original ride continues. CHOAM’s credit and the Ixian replacement are applied once; overlapping Terror and summoned-worm continuations remain guarded.', 'Trip to Gamont in CHOAM’s final Mentat opportunity also offers the used card. Claim or Continue completes the selected return and restores that same CHOAM-owned opportunity. It does not check victory or advance the turn: CHOAM must still choose Finish and check victory, using the changed board.', 'Fresh bare public Basic Richese previews now initialize Semuta before genuine setup with selected native E1/E2/E3 families. Older games are unchanged; Advanced and optional modules retain their existing separate entries. Clean ordinary shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: CHOAM keeps its already-earned credit and Ixians draw one replacement after the reaction. A multiple-card private group requires commitment before exactly one selection. Original entrant actions then resume once. Existing Basic Sabotage/Robbery overflow keeps the original victim/draw, removed token, payment, forces and optional gift. Nested arrivals, completed movement-card provenance, wider module coverage and complete modes remain unfinished. The table labels the preview and its limits.', ] : []), diff --git a/game/richese-reveal-foreign-movement.ts b/game/richese-reveal-foreign-movement.ts new file mode 100644 index 00000000..7cc08e18 --- /dev/null +++ b/game/richese-reveal-foreign-movement.ts @@ -0,0 +1,85 @@ +import { location, splitLocation, validLocation } from './board'; +import type { Game } from './engine'; +import { presenceAt } from './force-presence'; + +type MovementPending = 'pendingFremenMove' | 'pendingIxMove' | 'pendingChoamMove'; +type MovementOrder = NonNullable; + +/** + * Identify a single ordinary foreign movement awaiting its native prevention + * response. Revealing Richese's already-present marker cannot add an occupying + * faction, so another faction's declared route and destination remain legal. + * The caller retains its own common profile and Truthtrance/commitment guards. + */ +export function richeseRevealForeignMovementPending(g: Game): MovementPending | null { + if (g.status !== 'playing' || g.advanced || g.phase !== 5 || + !Number.isSafeInteger(g.turn) || g.turn < 1 || g.phaseOpening || g.truthtrance || + g.ornithopter || g.choamMarket || !Array.isArray(g.players)) return null; + const richese = g.players.find(player => player.faction === 'richese'); + const deployed = richese?.noField?.deployed; + if (!richese || !deployed || deployed.controller !== richese.id || + !deployed.location || !('territory' in deployed.location)) return null; + + let key: MovementPending; + let move: MovementOrder; + const response = g.response; + if (response?.kind === 'fremenMovement' && g.pendingFremenMove && !g.decision) { + key = 'pendingFremenMove'; + move = g.pendingFremenMove.order; + if (g.pendingFremenMove.turn !== g.turn) return null; + } else if (response?.kind === 'ixMovement' && g.pendingIxMove && !g.decision) { + key = 'pendingIxMove'; + move = g.pendingIxMove; + } else if (!response && g.decision?.kind === 'choamMovement' && g.pendingChoamMove) { + key = 'pendingChoamMove'; + move = g.pendingChoamMove; + } else return null; + + if (Object.entries(g).some(([name, value]) => name.startsWith('pending') && name !== key && + (Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return null; + if (!move || move.player === richese.id || move.player !== g.active || + move.source || move.ambassadorEvent || move.noField || move.planetologist || move.sandmaster || + move.ambassadorSandmaster || move.ornithopterEvent || move.ornithopterRange || move.discoveryFlight || + !Array.isArray(move.group) || move.group.length === 0 || !move.eliteGroup || + typeof move.origin !== 'string' || typeof move.to !== 'string' || + !Number.isSafeInteger(move.sector) || !validLocation(move.to, move.sector) || + !Number.isSafeInteger(move.total) || move.total <= 0 || + !Number.isSafeInteger(move.elite) || move.elite < 0 || move.elite > move.total || + (move.origins && (move.origins.length !== 1 || move.origins[0] !== move.origin))) return null; + const mover = g.players.find(player => player.id === move.player); + if (!mover || !Number.isSafeInteger(mover.moved) || mover.moved < 0) return null; + const seen = new Set(); + let total = 0, elite = 0; + for (const [source, amount] of move.group) { + if (typeof source !== 'string' || seen.has(source) || splitLocation(source).territory !== move.origin || + !validLocation(splitLocation(source).territory, splitLocation(source).sector) || + !Number.isSafeInteger(amount) || amount <= 0 || amount > (mover.forces[source] ?? 0)) return null; + seen.add(source); + const selected = move.eliteGroup[source]; + if (!Number.isSafeInteger(selected) || selected < 0 || selected > amount || + selected > (mover.elites?.forces[source] ?? 0) || + amount - selected > (mover.forces[source] ?? 0) - (mover.elites?.forces[source] ?? 0)) return null; + total += amount; + elite += selected; + } + if (Object.keys(move.eliteGroup).some(source => !seen.has(source)) || + total !== move.total || elite !== move.elite) return null; + + if (key === 'pendingChoamMove') { + const decision = g.decision; + const choam = g.players.find(player => player.faction === 'choam'); + return decision?.kind === 'choamMovement' && choam && presenceAt(choam, move.to) > 0 && decision.player === choam.id && + decision.mover === move.player && decision.territory === move.to && + decision.sector === move.sector && decision.amount === move.total ? key : null; + } + if (!response || response.owner !== move.player || + response.location !== location(move.to, move.sector) || response.amount !== move.total || + !Array.isArray(response.passed) || new Set(response.passed).size !== response.passed.length || + response.passed.some(id => !g.players.some(player => player.id === id))) return null; + const keys = Object.keys(response).sort().join(','); + if (key === 'pendingIxMove') + return mover.faction === 'ixians' && keys === 'amount,elite,kind,location,owner,passed' && + response.elite === move.elite && move.elite > 0 ? key : null; + return mover.faction === 'fremen' && keys === 'amount,kind,location,owner,passed' && + g.pendingFremenMove?.move === mover.moved ? key : null; +} diff --git a/game/richese-reveal-interruption.ts b/game/richese-reveal-interruption.ts index 0d271a90..dbca9180 100644 --- a/game/richese-reveal-interruption.ts +++ b/game/richese-reveal-interruption.ts @@ -1,5 +1,6 @@ import type { Game } from './engine'; import { validateFremenRevivalGrant } from './fremen-revival-grant'; +import { richeseRevealForeignMovementPending } from './richese-reveal-foreign-movement'; const factions = new Set([ 'atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen', @@ -51,8 +52,9 @@ export function richeseRevealInterruptSupported(g: Game): boolean { // Each other pending field is an active child/continuation, not a completed // receipt. Empty pending arrays do not represent a live child. + const foreignMovement = richeseRevealForeignMovementPending(g); if (Object.entries(g).some(([key, value]) => key.startsWith('pending') && - !(key === 'pendingShipment' && guildRate) && + !(key === 'pendingShipment' && guildRate) && key !== foreignMovement && (Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return false; const live = (rows: readonly {turn: number; fulfilled?: boolean; released?: boolean}[] | undefined) => @@ -72,6 +74,7 @@ export function richeseRevealInterruptSupported(g: Game): boolean { g.mixedAllianceQuestions, g.mixedMovementQuestions, g.mixedTraitorQuestions, g.mixedBattleQuestions]; if (mixed.some(rows => rows?.some(row => row.turn === g.turn && row.binding))) return false; + if (foreignMovement) return true; if (g.response) { // Only independent, un-nested native responses with a stable original // source are admitted. In particular, shipment and revival transactions diff --git a/tests/basic-semuta-ambassador-worm-runtime.test.ts b/tests/basic-semuta-ambassador-worm-runtime.test.ts new file mode 100644 index 00000000..5e21f760 --- /dev/null +++ b/tests/basic-semuta-ambassador-worm-runtime.test.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { territory } from '../game/board'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { actBasicSemutaAmbassador as act, nextBasicSemutaAmbassadorStep, + basicSemutaAmbassadorPlayer as player, basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; + +type Effect = 'choam' | 'ixians'; +const holder = 'guild', owner = 'ecaz', rider = 'fremen'; +/** Public setup, paid Ambassador placement, real Spice Blow/Nexus and Fremen + * ride. Conserved staging chooses the supplied Ambassador packet, exchanges Semuta + * from the cache for Guild's dealt card in the deck, moves one starting Fremen counter to + * the worm's desert, and selects the first two unopened Spice cards. */ +function source(effect: Effect, copied: boolean) { + let g = createGame('SEMUTAWORM', newPlayer(owner, 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']); + for (const faction of ['richese', rider, holder] as const) joinGame(g, newPlayer(faction, faction, faction)); + for (const p of g.players) g = act(g, p.id, {type:'ready'}); + g = act(g, owner, {type:'start'}); + assert.equal(g.status, 'setup'); assert.equal(g.semutaPreview, true); + const step = () => { + if (g.status === 'setup' && g.setupStage === 'forces' && player(g,rider).reserves === 20) { + g = act(g,rider,{type:'fremenSetup',placements:{'sietch_tabr:14':10}}); + return; + } + const next = g.decision?.kind === 'richeseCache' ? nextRicheseStrongholdsNativeStep(g) : nextBasicSemutaAmbassadorStep(g); + assert.ok(next, `Missing native action at ${g.status}/${g.turn}/${g.phase}/${g.decision?.kind}`); + g = act(g, next.actor, next.action); + }; + const reach = (condition: (g: Game) => boolean) => { + for (let n = 0; n < 2000; n++) { + if (condition(g)) return; + assert.notEqual(g.status, 'finished'); step(); + } + throw Error('Native worm source did not reach the requested phase'); + }; + reach(state => state.status === 'playing'); + const packet = g.ecazAmbassadors!, selected = packet.tokens.find(t => t.effect === (copied ? 'beneGesserit' : effect))!; + const swap = (included: boolean, tokenId: string) => { + if (packet.cohort.includes(tokenId) === included) return; + const other = packet.cohort.find(id => id !== selected.id && id !== packet.tokens.find(t => t.effect === effect)!.id)!; + const replacement = packet.tokens.find(t => t.id === tokenId)!; + const displaced = packet.tokens.find(t => t.id === other)!; + if (included) { + packet.cohort[packet.cohort.indexOf(other)] = tokenId; + displaced.zone = 'pool'; replacement.zone = 'supply'; + } else { + const candidate = packet.tokens.find(t => t.zone === 'pool' && t.id !== selected.id && t.id !== tokenId)!; + packet.cohort[packet.cohort.indexOf(tokenId)] = candidate.id; + replacement.zone = 'pool'; candidate.zone = 'supply'; + } + }; + swap(true, selected.id); + if (copied) swap(false, packet.tokens.find(t => t.effect === effect)!.id); + const guild = player(g, holder), semutaIndex = g.richeseCache!.findIndex(c => c.id === SEMUTA_DRUG_ID); + assert.ok(semutaIndex >= 0 && guild.hand.length); + const semuta = g.richeseCache!.splice(semutaIndex,1)[0]; + g.deck.push(guild.hand[0]); guild.hand[0] = semuta; + const sourceForce = Object.keys(player(g, rider).forces).find(key => player(g, rider).forces[key] > 0)!; + assert.ok(sourceForce); + player(g, rider).forces[sourceForce]--; + if (!player(g, rider).forces[sourceForce]) delete player(g, rider).forces[sourceForce]; + player(g, rider).forces['the_great_flat:15'] = 1; + for (const [position, predicate] of [ + [0, (c: Game['spiceDeck'][number]) => 'territory' in c && c.territory === 'the_great_flat'], + [1, (c: Game['spiceDeck'][number]) => 'worm' in c && !c.greatMaker], + ] as const) { + const index = g.spiceDeck.findIndex((card, i) => i >= position && predicate(card)); + assert.ok(index >= position); [g.spiceDeck[position],g.spiceDeck[index]] = [g.spiceDeck[index],g.spiceDeck[position]]; + } + reach(state => state.phase === 4 && !state.response && state.decision?.kind === 'ecazPlacement'); + const destination = ['carthag','arrakeen','habbanya_ridge_sietch','tueks_sietch'] + .find(id => territory(id).sectors.some(sector => sector !== g.storm))!; + assert.ok(destination); + const sector = territory(destination).sectors.find(value => value !== g.storm)!; + g = act(g, owner, {type:'decision',token:selected.id,territory:destination}); + reach(state => !state.response && state.decision?.kind === 'ecazPlacement'); + g = act(g, owner, {type:'decision',decline:true}); + reach(state => state.turn === 2 && state.decision?.kind === 'wormRide' && state.decision.player === rider); + assert.equal(g.decision?.kind === 'wormRide' && g.decision.territory, 'the_great_flat'); + assert.notEqual(g.storm, 15); + g = act(g, rider, {type:'decision',accept:true,territory:destination,sector, + forces:{'the_great_flat:15':1}}); + assert.equal(g.pendingAmbassador?.arrivalCause, 'wormRide'); + assert.equal(g.pendingAmbassador?.resume, 'wormRide'); + const event = g.pendingAmbassador!.event; + g = act(g, owner, {type:'decision',event,trigger:true,beneficiary:owner,choice:'effect'}); + if (copied) g = act(g, owner, {type:'decision',event,effect}); + assert.equal(g.pendingAmbassador?.stage,'cards'); + const cards = player(g, owner).hand.slice(0, effect === 'choam' ? 2 : 1).map(c => c.id); + assert.ok(cards.length >= 1); + if (effect === 'ixians') assert.equal(cards.length, 1); + return {g,event,cards,destination,sector,original:inventory(g)}; +} + +for (const effect of ['choam','ixians'] as const) for (const copied of [false,true]) + for (const claim of [false,true]) void test( + `Basic worm ride ${copied ? 'BG copy' : 'native'} ${effect} preserves entry after Semuta ${claim ? 'claim' : 'pass'}`, () => { + const f = source(effect,copied), before = structuredClone(f.g); + const spice = player(before,owner).spice, top = before.deck[0]?.id; + let g = act(before,owner,{type:'decision',event:f.event,cards:f.cards}); + const pending = g.pendingTreacheryDiscard!; + assert.equal(pending?.continuation.kind,'ambassador'); + assert.equal(pending?.reaction?.stage,'offer'); + assert.equal(pending.batch.cause,`ambassador:${effect}`); + assert.deepEqual(pending.batch.entries.map(e => e.card.id),f.cards); + assert.ok(pending.batch.entries.every(e => e.discardedBy === owner && !e.publicFace)); + assert.deepEqual(g.deck,before.deck); + assert.equal(player(g,owner).spice,spice + (effect === 'choam' ? f.cards.length * 3 : 0)); + assert.equal(player(g,rider).forces[`${f.destination}:${f.sector}`],1); + for (const p of g.players) { + const projected = viewGame(g,p.id).semutaReaction!; + assert.deepEqual(projected.candidates,[]); assert.equal(projected.canCommit,p.id === holder); + } + const event = pending.batch.event; + if (claim) { + g = act(g,holder,{type:'semutaCommit',event}); + if (g.pendingTreacheryDiscard?.reaction?.stage === 'select') + g = act(g,holder,{type:'semutaSelect',event,card:f.cards[0]}); + } else for (const p of g.players) g = act(g,p.id,{type:'semutaPass',event}); + assert.equal(g.pendingTreacheryDiscard,null); assert.equal(g.pendingAmbassador,null); + assert.equal(player(g,rider).forces[`${f.destination}:${f.sector}`],1); + assert.equal(player(g,owner).spice,spice + (effect === 'choam' ? f.cards.length * 3 : 0)); + assert.equal(player(g,holder).hand.some(c => c.id === f.cards[0]),claim); + if (effect === 'ixians') assert.ok(player(g,owner).hand.some(c => c.id === top)); + assert.deepEqual(inventory(g),f.original); + assert.throws(() => act(g,holder,{type:'semutaCommit',event})); + assert.notEqual(g.decision?.kind,'ecazAmbassador'); + }); + +void test('a conserved staged remaining ride is bound to the discard and continues only once', () => { + const f = source('ixians',false), before = structuredClone(f.g); + // Supplement the real first ride with a conserved queued second ride. + const fremen = player(before,rider); assert.ok(fremen.reserves > 0); + fremen.reserves--; fremen.forces['hagga_basin:12'] = (fremen.forces['hagga_basin:12'] ?? 0) + 1; + before.wormRides.push('hagga_basin'); + let pending = act(before,owner,{type:'decision',event:f.event,cards:f.cards}); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer'); + assert.deepEqual(pending.wormRides,['hagga_basin']); + const tampered = structuredClone(pending); tampered.wormRides.push('hagga_basin'); + const saved = structuredClone(tampered); + assert.throws(() => act(tampered,holder,{type:'semutaPass',event})); + assert.deepEqual(tampered,saved); + for (const p of pending.players) pending = act(pending,p.id,{type:'semutaPass',event}); + assert.equal(pending.pendingTreacheryDiscard,null); + assert.deepEqual(pending.decision,{kind:'wormRide',player:rider,territory:'hagga_basin'}); + assert.deepEqual(pending.wormRides,[]); + pending = act(pending,rider,{type:'decision',accept:false}); + assert.notEqual(pending.decision?.kind,'wormRide'); + assert.deepEqual(pending.wormRides,[]); + assert.equal(player(pending,rider).forces[`${f.destination}:${f.sector}`],1); +}); diff --git a/tests/basic-semuta-ambassador.test.ts b/tests/basic-semuta-ambassador.test.ts index 07bd2415..efd51f09 100644 --- a/tests/basic-semuta-ambassador.test.ts +++ b/tests/basic-semuta-ambassador.test.ts @@ -28,13 +28,16 @@ void test('fresh public bare Basic Richese starts initialize Semuta before the o } }); -void test('automatic Semuta initialization does not spread to Advanced, optional-module or non-Richese starts', () => { +void test('Semuta initialization follows the current Advanced, Skills and non-Richese profiles', () => { const advanced = lobby(['richese', 'guild'], true); const optional = lobby(['richese', 'guild']); optional.lobbyOptions = { leaderSkills: true }; - for (const source of [advanced, optional, lobby(['ecaz', 'choam', 'guild'])]) { + for (const source of [advanced, lobby(['ecaz', 'choam', 'guild'])]) { const started = applyAction(source, source.host, source.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); assert.equal(started.semutaPreview, undefined); } + const skills = applyAction(optional, optional.host, { type: 'start' }); + assert.equal(skills.semutaPreview, true); + assert.ok(skills.leaderSkills); const dirty = lobby(['richese', 'guild']); dirty.semutaPreview = true; assert.throws(() => applyAction(dirty, dirty.host, { type: 'start' })); }); diff --git a/tests/richese-reveal-foreign-movement-quote.test.ts b/tests/richese-reveal-foreign-movement-quote.test.ts new file mode 100644 index 00000000..c815fea0 --- /dev/null +++ b/tests/richese-reveal-foreign-movement-quote.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, newPlayer, type Game } from '../game/engine'; +import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field'; +import { richeseRevealForeignMovementPending } from '../game/richese-reveal-foreign-movement'; + +/** Staged response packets exercise only the classifier. Native actions and + * actual reveal/continuation are covered by the separate runtime programme. */ +function movement(kind: 'fremen' | 'ixians' | 'choam'): Game { + const richese = newPlayer('r', 'Richese', 'richese'); + richese.noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']), { + tokenId: 'zero', controller: 'r', location: { territory: 'polar_sink', sector: 0 }, + }); + const g = createGame('MOVEQUOTE', richese, false, ['choam', 'ix']); + const mover = newPlayer('m', 'Mover', kind === 'choam' ? 'emperor' : kind); + mover.forces = { 'arrakeen:10': 3 }; + if (mover.elites) mover.elites.forces = { 'arrakeen:10': 1 }; + const choam = newPlayer('c', 'CHOAM', 'choam'); + choam.forces = { 'carthag:11': 1 }; + g.players.push(mover, choam); + Object.assign(g, { status: 'playing', phase: 5, turn: 2, active: 'm', decision: null, response: null }); + const order: NonNullable = { + player: 'm', group: [['arrakeen:10', 3]], eliteGroup: { 'arrakeen:10': kind === 'ixians' ? 1 : 0 }, + elite: kind === 'ixians' ? 1 : 0, origin: 'arrakeen', origins: ['arrakeen'], + total: 3, to: 'carthag', sector: 11, advisors: false, wantsFighters: false, + }; + if (kind === 'fremen') { + g.pendingFremenMove = { turn: g.turn, move: 0, order }; + g.response = { kind: 'fremenMovement', owner: 'm', location: 'carthag:11', amount: 3, passed: [] }; + } else if (kind === 'ixians') { + g.pendingIxMove = order; + g.response = { kind: 'ixMovement', owner: 'm', location: 'carthag:11', amount: 3, elite: 1, passed: [] }; + } else { + g.pendingChoamMove = order; + g.decision = { kind: 'choamMovement', player: 'c', mover: 'm', territory: 'carthag', sector: 11, amount: 3 }; + } + return g; +} + +void test('all three ordinary foreign-movement parents are classified without mutation', () => { + for (const [kind, expected] of [ + ['fremen', 'pendingFremenMove'], ['ixians', 'pendingIxMove'], ['choam', 'pendingChoamMove'], + ] as const) { + const g = movement(kind), before = structuredClone(g); + assert.equal(richeseRevealForeignMovementPending(g), expected); + assert.deepEqual(g, before); + } +}); + +void test('foreign movement requires the original actor, source, response and no unrelated child', () => { + for (const kind of ['fremen', 'ixians', 'choam'] as const) { + const g = movement(kind); + const stale = structuredClone(g); stale.active = 'r'; + assert.equal(richeseRevealForeignMovementPending(stale), null); + const child = structuredClone(g); child.pendingShipment = {} as NonNullable; + assert.equal(richeseRevealForeignMovementPending(child), null); + const wrongPhase = structuredClone(g); wrongPhase.phase = 4; + assert.equal(richeseRevealForeignMovementPending(wrongPhase), null); + const noMarker = structuredClone(g); noMarker.players[0].noField!.deployed = null; + assert.equal(richeseRevealForeignMovementPending(noMarker), null); + const lostForces = structuredClone(g); lostForces.players[1].forces = {}; + assert.equal(richeseRevealForeignMovementPending(lostForces), null); + if (kind === 'choam') { + const wrong = structuredClone(g); + if (wrong.decision?.kind === 'choamMovement') wrong.decision.amount++; + assert.equal(richeseRevealForeignMovementPending(wrong), null); + } else { + const wrong = structuredClone(g); wrong.response!.location = 'arrakeen:10'; + assert.equal(richeseRevealForeignMovementPending(wrong), null); + const duplicate = structuredClone(g); duplicate.response!.passed = ['r', 'r']; + assert.equal(richeseRevealForeignMovementPending(duplicate), null); + } + } +}); + +void test('marker identity and denomination are not needed to identify a foreign movement', () => { + const g = movement('fremen'); + g.players[0].noField = deployRicheseNoField(createRicheseNoField(['zero', 'three', 'five']), { + tokenId: 'five', controller: 'r', location: { territory: 'arrakeen', sector: 10 }, + }); + assert.equal(richeseRevealForeignMovementPending(g), 'pendingFremenMove'); + g.players[0].noField!.deployed!.controller = 'm'; + assert.equal(richeseRevealForeignMovementPending(g), null); +}); diff --git a/tests/richese-reveal-foreign-movement-runtime.test.ts b/tests/richese-reveal-foreign-movement-runtime.test.ts new file mode 100644 index 00000000..4a013122 --- /dev/null +++ b/tests/richese-reveal-foreign-movement-runtime.test.ts @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board'; +import { presenceAt } from '../game/force-presence'; +import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; +import { truthMovementInventory } from './fixture-truth-movement'; + +type Kind = 'fremenMovement' | 'ixMovement' | 'choamMovement'; +const seat = (g: Game, id: string) => { const p = g.players.find(p => p.id === id); assert.ok(p); return p; }; +const clean = (g: Game) => !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingTreacheryDiscard; +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +const cards = (g: Game) => truthMovementInventory(g); + +/** Select an existing physical card by a custody-preserving swap after the + * actual starting deal. This controls only who can answer the test window. */ +function swapHeld(g: Game, id: string, predicate: (c: Game['deck'][number]) => boolean): string { + const target = seat(g, id); + const held = target.hand.find(predicate); + if (held) return held.id; + assert.ok(target.hand.length); + const index = g.deck.findIndex(predicate); + if (index >= 0) { + [target.hand[0], g.deck[index]] = [g.deck[index], target.hand[0]]; + return target.hand[0].id; + } + for (const other of g.players) { + if (other.id === id) continue; + const at = other.hand.findIndex(predicate); + if (at >= 0) { + [target.hand[0], other.hand[at]] = [other.hand[at], target.hand[0]]; + return target.hand[0].id; + } + } + throw Error('The original card must exist in the native deal or deck'); +} + +/** All pending movement/decisions come from actual player actions. The only + * controlled inputs are unopened Spice cards, first Storm dials and conserved + * card swaps. Richese's marker is actually shipped on its own turn. */ +function fixture(kind: Kind, value: 0 | 3 = 3, markerAtDestination = false) { + assert.ok(!markerAtDestination || kind === 'fremenMovement'); + const mover = kind === 'fremenMovement' ? 'fremen' : kind === 'ixMovement' ? 'ixians' : 'emperor'; + const expansions = kind === 'ixMovement' ? ['choam', 'ix'] : ['choam']; + let g = createGame(`REVEAL-${kind}-${value}`, newPlayer('richese', 'Richese', 'richese'), false, expansions); + if (kind === 'choamMovement') joinGame(g, newPlayer('choam', 'CHOAM', 'choam')); + else joinGame(g, newPlayer(mover, mover, mover)); + joinGame(g, newPlayer('emperor', 'Emperor', 'emperor')); + for (const p of g.players) g = act(g, p.id, {type:'ready'}); + g = act(g, g.host, {type:'start'}); + assert.equal(g.status, 'setup'); + if (kind === 'ixMovement') { + // The native Ix setup driver chooses a Shield from the inspected packet. + // Move the original unopened card to its first draw position. + const shield = g.deck.findIndex(card => card.kind === 'shield'); + assert.ok(shield >= 0); + [g.deck[0], g.deck[shield]] = [g.deck[shield], g.deck[0]]; + } + const next = (state: Game) => { + const fremen = state.players.find(p => p.faction === 'fremen'); + if (state.status === 'setup' && state.setupStage === 'forces' && fremen?.reserves === 20 && + viewGame(state, fremen.id).setupPending.includes(fremen.id)) + return {actor:fremen.id, action:{type:'fremenSetup', placements:{'sietch_tabr:14':10}} satisfies Action}; + if (state.phase === 5 && state.active === 'richese' && clean(state) && !seat(state,'richese').shipped && + !seat(state,'richese').noField?.deployed) { + const owner = seat(state,'richese'), marker = owner.noField!.tokens.find(t => t.value === value); + assert.ok(marker && owner.noFieldEvent); + const target = markerAtDestination + ? TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s))) + .find(key => splitLocation(key).sector > 7 && state.players.every(p => !(p.forces[key] > 0)) && + gameDistance(state,'sietch_tabr:14',key,k => splitLocation(k).sector === state.storm) === 2 && + gameDistance(state,'sietch_tabr:14',key,k => splitLocation(k).sector === (state.storm % 18) + 1) === 2) + : 'polar_sink:0'; + assert.ok(target, 'The original Fremen position has a safe distance-two marker destination'); + return {actor:'richese', action:{type:'ship', noField:marker.id, event:owner.noFieldEvent, + ...splitLocation(target), allyPayment:0} satisfies Action}; + } + if (kind === 'choamMovement' && state.turn === 1 && state.phase === 5 && state.active === 'choam' && + clean(state) && !seat(state,'choam').shipped) + return {actor:'choam', action:{type:'ship', territory:'arrakeen', sector:10, amount:1, elite:0, allyPayment:0} satisfies Action}; + if (state.phase === 5 && state.active === 'emperor' && clean(state)) + return {actor:'emperor', action:{type:'endMovement'} satisfies Action}; + const step = nextTruthMovementNativeEarlyStep(state); + assert.ok(step, `Missing native continuation ${state.status}/${state.turn}/${state.phase}/${state.decision?.kind}`); + if (step.action.type === 'stormDial' && state.turn === 1) + return {...step, action:{...step.action, amount:0}}; + return step; + }; + const reach = (until: (s: Game) => boolean) => { + for (let n = 0; n < 2400 && !until(g); n++) { + assert.notEqual(g.status, 'finished'); + const step = next(g); g = act(g, step.actor, step.action); + } + assert.ok(until(g), 'The real game reached the requested native boundary'); + }; + reach(state => state.status === 'playing'); + const karama = swapHeld(g, 'emperor', c => c.effect === 'karama'); + const baliset = kind === 'choamMovement' ? swapHeld(g, 'choam', c => c.name === 'Baliset') : undefined; + for (let pos = 0; pos < 2; pos++) { + const at = g.spiceDeck.findIndex((c, index) => index >= pos && 'territory' in c && !c.discovery); + assert.ok(at >= pos); [g.spiceDeck[pos], g.spiceDeck[at]] = [g.spiceDeck[at], g.spiceDeck[pos]]; + } + reach(state => state.turn === 2 && state.phase === 5 && state.active === mover && clean(state) && + !!seat(state,'richese').noField?.deployed); + const marker = seat(g,'richese').noField!.deployed!.tokenId; + const event = seat(g,'richese').noFieldEvent; assert.ok(event); + let origin: string; + if (kind === 'fremenMovement') { + origin = 'sietch_tabr:14'; + assert.ok((seat(g,mover).forces[origin] ?? 0) > 0); + } else if (kind === 'choamMovement') { + origin = 'carthag:11'; + g = act(g,mover,{type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0}); + reach(clean); + assert.ok((seat(g,'choam').forces['arrakeen:10'] ?? 0) > 0); + } else { + origin = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s))) + .find(key => splitLocation(key).sector !== g.storm && g.players.every(p => !(p.forces[key] > 0)) && + TERRITORIES.some(t => t.type === 'sand' && t.sectors.some(s => s !== g.storm && + gameDistance(g,key,location(t.id,s),k => splitLocation(k).sector === g.storm) === 2)))!; + assert.ok(origin); + g = act(g,mover,{type:'ship', ...splitLocation(origin), amount:1, elite:1, allyPayment:0}); + reach(clean); + } + const markerLocation = seat(g,'richese').noField!.deployed!.location; + const destination = markerAtDestination && 'territory' in markerLocation + ? location(markerLocation.territory,markerLocation.sector) + : kind === 'choamMovement' ? 'arrakeen:10' : + TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s))) + .find(key => splitLocation(key).sector !== g.storm && g.players.every(p => !(p.forces[key] > 0)) && + gameDistance(g,origin,key,k => splitLocation(k).sector === g.storm) === 2)!; + assert.ok(destination); + const beforeMove = structuredClone(g); + const move: Action = {type:'move', forces:{[origin]:1}, + ...(kind === 'ixMovement' ? {eliteForces:{[origin]:1}} : {}), ...splitLocation(destination)}; + g = act(g,mover,move); + assert.equal(kind === 'choamMovement' ? g.decision?.kind : g.response?.kind, kind); + assert.deepEqual(seat(g,mover).forces, seat(beforeMove,mover).forces); + return {game:g, mover, marker, event, karama, baliset, value, origin, destination, beforeMove}; +} + +void test('a foreign movement into a zero No-Field destination still commits after the marker vanishes', () => { + const f = fixture('fremenMovement',0,true), original = structuredClone(f.game); + const territory = splitLocation(f.destination).territory; + assert.equal(presenceAt(seat(original,'richese'),territory),1); + assert.equal(original.response?.kind,'fremenMovement'); + assert.equal(viewGame(original,'richese').richeseNoField?.canReveal,true); + const response = structuredClone(original.response), order = structuredClone(original.pendingFremenMove); + const after = act(original,'richese',{type:'revealNoField',token:f.marker,event:f.event}); + assert.equal(presenceAt(seat(after,'richese'),territory),0); + assert.deepEqual(after.response,response); + assert.deepEqual(after.pendingFremenMove,order); + assert.deepEqual(seat(after,f.mover).forces,seat(f.beforeMove,f.mover).forces); + const control = finishPending(reload(original)), done = finishPending(reload(after)); + assert.equal(presenceAt(seat(control,'richese'),territory),1); + assert.equal(presenceAt(seat(done,'richese'),territory),0); + assert.deepEqual(seat(done,f.mover),seat(control,f.mover)); + assert.equal(seat(done,f.mover).forces[f.destination],1); + assert.equal(seat(done,f.mover).moved,seat(f.beforeMove,f.mover).moved+1); +}); +const cached = new Map>(); +function source(kind: Kind, value: 0 | 3 = 3) { + const key = `${kind}:${value}`; + if (!cached.has(key)) cached.set(key,fixture(kind,value)); + return cached.get(key)!; +} +function reveal(kind: Kind, value: 0 | 3 = 3) { + const f = source(kind,value), g = structuredClone(f.game); + const pending = structuredClone(kind === 'choamMovement' ? g.pendingChoamMove : + kind === 'fremenMovement' ? g.pendingFremenMove : g.pendingIxMove); + const control = structuredClone(kind === 'choamMovement' ? g.decision : g.response); + const before = structuredClone(seat(g,'richese')), inventory = cards(g); + assert.equal(viewGame(g,'richese').richeseNoField?.canReveal,true); + const action: Action = {type:'revealNoField', token:f.marker, event:f.event}; + const invalid = structuredClone(g); + assert.throws(() => act(invalid,f.mover,action)); assert.deepEqual(invalid,g); + const after = act(g,'richese',action); + assert.deepEqual(kind === 'choamMovement' ? after.pendingChoamMove : + kind === 'fremenMovement' ? after.pendingFremenMove : after.pendingIxMove,pending); + assert.deepEqual(kind === 'choamMovement' ? after.decision : after.response,control); + assert.deepEqual(seat(after,f.mover).forces,seat(g,f.mover).forces); + assert.equal(seat(after,'richese').noField?.deployed,null); + assert.equal(seat(after,'richese').reserves,before.reserves-value); + assert.equal(seat(after,'richese').forces['polar_sink:0'] ?? 0,(before.forces['polar_sink:0'] ?? 0)+value); + assert.deepEqual(cards(after),inventory); + assert.throws(() => act(after,'richese',action)); + return after; +} +function finishPending(g: Game) { + for (let n = 0; n < 40; n++) { + if (g.pendingKull?.stage === 'offer') { + g = act(g,g.pendingKull.player,{type:'kullDecision',event:g.pendingKull.event,decline:true}); continue; + } + if (g.pendingTreacheryDiscard) { + const reaction = g.pendingTreacheryDiscard.reaction; assert.ok(reaction?.stage === 'offer'); + const actor = g.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor); + g = act(g,actor.id,{type:'semutaPass',event:g.pendingTreacheryDiscard.batch.event}); continue; + } + if (g.response) { + const actor = g.players.find(p => !g.response!.passed.includes(p.id)); assert.ok(actor); + g = act(g,actor.id,{type:'passResponse'}); continue; + } + if (g.decision?.kind === 'choamMovement') { + g = act(g,g.decision.player,{type:'decision',decline:true}); continue; + } + return g; + } + throw Error('The original movement response did not finish'); +} +for (const kind of ['fremenMovement','ixMovement','choamMovement'] as const) { + for (const value of [0,3] as const) void test(`Basic ${kind} retains its original physical move through No-Field ${value} reveal`, () => { + const f = source(kind,value), after = reveal(kind,value); + const control = finishPending(reload(f.game)), done = finishPending(reload(after)); + assert.deepEqual(seat(done,f.mover),seat(control,f.mover)); + assert.equal(seat(done,f.mover).forces[f.destination],(seat(f.beforeMove,f.mover).forces[f.destination] ?? 0)+1); + assert.equal(seat(done,f.mover).forces[f.origin] ?? 0,(seat(f.beforeMove,f.mover).forces[f.origin] ?? 0)-1); + assert.equal(seat(done,f.mover).moved,seat(f.beforeMove,f.mover).moved+1); + assert.equal(seat(done,'richese').forces['polar_sink:0'] ?? 0,value); + }); + void test(`Basic ${kind} cancellation after reveal preserves unspent movement`, () => { + const f = source(kind), after = reveal(kind); + let pending = kind === 'choamMovement' + ? act(after,'choam',{type:'card',card:f.baliset,mode:'choam',target:f.mover,territory:splitLocation(f.destination).territory}) + : act(after,'emperor',{type:'card',card:f.karama,mode:'cancel'}); + pending = finishPending(pending); + assert.deepEqual(seat(pending,f.mover).forces,seat(f.beforeMove,f.mover).forces); + assert.equal(seat(pending,f.mover).moved,seat(f.beforeMove,f.mover).moved); + assert.equal(seat(pending,'richese').forces['polar_sink:0'],3); + }); + void test(`all bot profiles have a legal response to preserved Basic ${kind}`, () => { + const after = reveal(kind), actor = kind === 'choamMovement' ? 'choam' : 'emperor'; + for (const difficulty of DIFFICULTIES) { + const view = viewGame(after,actor); view.players.find(p => p.id === actor)!.bot = difficulty; + const action = botActions(view)[0]; assert.ok(action,difficulty); + assert.doesNotThrow(() => act(reload(after),actor,action)); + } + }); +}