From 903d861170fb3604ce6f491e191bd0ea4bcecbb9 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:15:00 +1000 Subject: [PATCH] Prevent saved CHOAM movement arrival deadlocks Preflight ordinary movement through the existing arrival quote before creating new Ornithopter escrow or opening Ixian, Fremen and CHOAM prevention windows. An explicit legacy CHOAM decline can return an uncommitted unsupported arrival for another choice without consuming movement or touching forces, spice, cards or tokens. Preserve an existing Ornithopter and retire it exactly once on a legal replacement move. Keep dedicated Ambassador relocation handling and Karama cancellation validation before cost; unrelated exceptions remain atomic. Simultaneous Ambassador/Terror priority still awaits the recorded material interpretation, with no invented ordering. Add reusable offline faction-game samples with genuine Basic/Advanced setup, all four AI profiles, physical custody checks, JSON restorations, private traces and failed snapshots. Source-bound reports and input snapshot hashes make fresh and resumed samples repeatable; document that a resumed continuation starts a new seeded stream and does not prove input setup provenance. Validation: independently reviewed fresh declaration, native cancellation, supported single arrivals, legacy retry and card custody tests. Two production SQLite tests cover authenticated private restoration, stale/duplicate/wrong-owner rejection, resource conservation and concurrent version races. Typecheck, lint, 4583 offline tests, production build and 43 HTTP tests passed. Retain the initial failed check log for the corrected numeric-sort lint issue. All six fresh samples complete. CHOAM Basic 1054 actions/28 JSON restores, CHOAM Advanced 225/6, Ecaz Basic 91/2, Ecaz Advanced 523/14, all-three Basic 476/12, all-three Advanced 1303/35 with a turn-six CHOAM win. Combined Advanced has 46 guarded arrival candidates with legal alternatives. The exact prior 614-action failed snapshot separately finishes after 691 additional actions/18 restores and 39 guarded candidates. These are bounded paths, not complete faction or combination certification. Gameplay source hashes match the final checkpoint; later evidence-only documentation and recovery-test lint edits are explicit. After the reported outage, all 632 opening games and original seat/recovery records were preserved (665 after HTTP tests); integrity and foreign keys passed. Existing Ecaz and Ixian browser seats restored their same private hands, resources and pending choices after reload. This is browser restoration evidence, not a staged arrival-return claim. The healthy server was reused; no restart, database reset, recurring automation or publication. Verified source tree: 550221850f4802c41155f7bbc35d623965bc7db3330825636d50f26bb42868f8 --- docs/CURRENT_STATUS.md | 19 +- docs/DEFERRED_MOVEMENT_ARRIVAL.md | 84 +++ docs/EXPANSION_FACTIONS_PROTOTYPE.md | 4 + docs/IMPLEMENTATION_STATUS.md | 35 ++ docs/RULE_DECISIONS.md | 1 + docs/VERIFICATION_WORKFLOW.md | 28 + game/engine.ts | 36 +- game/reference.ts | 3 +- tests/deferred-movement-arrival-fixture.ts | 183 ++++++ ...deferred-movement-arrival-recovery.test.ts | 234 ++++++++ tests/deferred-movement-arrival.test.ts | 212 +++++++ tests/karama-movement-preflight.test.ts | 7 +- tools/faction-games.ts | 555 ++++++++++++++++++ 13 files changed, 1390 insertions(+), 11 deletions(-) create mode 100644 docs/DEFERRED_MOVEMENT_ARRIVAL.md create mode 100644 tests/deferred-movement-arrival-fixture.ts create mode 100644 tests/deferred-movement-arrival-recovery.test.ts create mode 100644 tests/deferred-movement-arrival.test.ts create mode 100644 tools/faction-games.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 3f06aca6..70ce0823 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -4,6 +4,20 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid ## Evidence and source of truth +- [Deferred movement arrival safety](DEFERRED_MOVEMENT_ARRIVAL.md) repairs the + combined Advanced sample's saved CHOAM decline deadlock. New unsupported moves + reject before prevention windows or card commitment; an old saved decline + returns the uncommitted move while preserving any existing Ornithopter escrow. + Simultaneous Ambassador/Terror priority remains a pending source question. + Focused rules and saved concurrency checks retain supported arrivals and + Karama's validation before payment. The reusable offline faction harness records + private traces, incomplete snapshots and source-bound reports. All six fresh + samples now finish; combined Advanced uses 1,303 actions and 35 JSON restores. + The exact earlier failed save also finishes after its recovered decline. + Guarded candidates still occur; broad verification is recorded in the checkpoint. Both existing + browser QA seats restored their hands and decisions after refresh. The server + was healthy and all 632 opening games and original seat records were preserved. + - [Expansion-faction development games](EXPANSION_FACTIONS_PROTOTYPE.md) connect genuine setup for all seven expansion selections, using existing faction controls, all four AI profiles and saved continuation. Ecaz now places its six @@ -12,8 +26,9 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid an explicit saved per-lot decline for Richese offers; the actual exchange still awaits its replacement-custody ruling. Complete-game samples finish five of six games; combined Advanced reaches turn three, then stops - on the existing CHOAM movement/Ambassador arrival guard. That interaction is - the next integration gap. Setup evidence does not open public starts or + on the then-existing CHOAM movement/Ambassador arrival guard. The deferred + movement follow-up above repairs that saved deadlock while retaining the + unresolved simultaneous-arrival gate. Setup evidence does not open public starts or establish full faction compliance. Saved-game checks passed after a necessary restart for a stale server module cache. diff --git a/docs/DEFERRED_MOVEMENT_ARRIVAL.md b/docs/DEFERRED_MOVEMENT_ARRIVAL.md new file mode 100644 index 00000000..54557ece --- /dev/null +++ b/docs/DEFERRED_MOVEMENT_ARRIVAL.md @@ -0,0 +1,84 @@ +# Deferred movement and arrival validation + +13 September 2026. This work repairs a development-game deadlock without +assigning an unverified order to simultaneous expansion reactions. + +## Reproduced failure and source boundary + +The all-three-expansion Advanced sample from checkpoint `cab2f1c` stopped on +turn three after 614 accepted actions. Tleilaxu declared three forces moving +to Carthag, where CHOAM had one force. That opened CHOAM's Baliset opportunity. +Carthag also held Ecaz's Fremen Ambassador and a concealed Moritani Terror +token. After CHOAM declined, the unchanged arrival guard rejected the +Ambassador/Terror overlap, trapping the already accepted declaration. + +CHOAM's Baliset prevents movement into an occupied territory; it does not +prevent shipment. Therefore its prevention window precedes actual entry. +Successful prevention creates no arrival. Decline or cancellation allows the +original move to be revalidated before any entry occurs. The latter is a +composition of the printed effects, not a separate combined FAQ. +[GF9 CHOAM & Richese, p. 7](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7) + +Ambassadors and Terror both offer optional effects after qualifying entry. +The Ambassador FAQ interrupts the entrant's remaining work, but neither it +nor the Terror rules establishes priority between the two. A targeted +publisher/designer search found no additional priority ruling. Their order +is material: a Fremen Ambassador can relocate forces, while Atomics kills +forces in the marked territory. The current gate remains; no storm-order +policy or lost-opportunity rule is inferred from tests. +[GF9 Ecaz & Moritani, pp. 5, 7 and 15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=5) + +The user has been asked whether this overlap should stay gated pending an +official ruling or use clearly labeled provisional storm order. No answer +has been applied. The broader [Ambassador](ECAZ_AMBASSADORS_RULES.md) and +[Terror](MORITANI_TERROR_RULES.md) contracts retain other arrival-order gaps, +including Bene Gesserit reactions and child arrivals. An unavailable reaction +combination is unfinished implementation, not a rule forbidding that move in +the physical game. + +## Continuation contract + +Fresh ordinary movement checks its arrival through the existing shared quote +before opening native Ixian, Fremen or CHOAM prevention windows. An unsupported +destination rejects the original action without saving a pending response or +committing forces, costs or movement use. Supported single-reaction arrivals +keep their existing controls and outcomes. Ambassador-generated relocation +continues to use its own validated source and return path. + +For an already saved CHOAM movement declaration, an explicit decline can return +an uncommitted move to its original mover if the typed arrival validation +rejects it. The chronicle explains the reason; the mover can choose another +action without spending its movement. This recovery does not trigger an +Ambassador or Terror effect, remove their tokens, or invent an arrival order. +Any previously reserved Ornithopter stays in escrow for a replacement move; +that exact card retires once when its use ends. No additional resources are +spent by the return itself. Unrelated failures are not swallowed. Karama cancellation retains its existing +preflight before any cancellation cost is spent. + +Focused regressions and saved-state review cover both new declarations and +legacy pending choices. The checkpoint report records final verification and +sample results. This repairs continuation safety; complete simultaneous +arrival handling, expansion acceptance and public starts remain unfinished. + +## Integrated sample evidence + +The reusable harness repeated the six genuine-setup scenarios with base seed +20260926. All completed; the first five retain their previous action/restore +counts. Combined Advanced now finishes on turn six with CHOAM winning, after +1,303 accepted actions and 35 JSON restorations. Its 46 rejected candidates +(eight moves and 38 shipments) still encounter the explicit Ambassador overlap +guard before using a legal alternative. This is not full combination acceptance. + +Separately, the exact prior failed snapshot resumes through the saved CHOAM +decline and finishes after 691 additional actions and 18 restorations, also on +turn six. Its continuation uses a new seeded stream, not reconstructed earlier +random state. It records 39 guarded candidates (seven moves, 32 shipments). +The input snapshot SHA-256 and private traces are retained in the report. +Both source-bound runs passed; subsequent result documentation changes do not +change their gameplay source. The checkpoint verifies that source correspondence. + +Existing browser rooms restored the same Ecaz Storm-opening choice and Ixian +Richese auction, private cards and spice after reload. This checks outage recovery; +the new movement-return path is covered by engine and SQLite tests, not a staged +browser claim. All 632 opening saved games and original seat records matched the +backup, and the healthy development server was reused. diff --git a/docs/EXPANSION_FACTIONS_PROTOTYPE.md b/docs/EXPANSION_FACTIONS_PROTOTYPE.md index bb03b7ac..3fd6fa26 100644 --- a/docs/EXPANSION_FACTIONS_PROTOTYPE.md +++ b/docs/EXPANSION_FACTIONS_PROTOTYPE.md @@ -176,6 +176,10 @@ complete games with every printed effect or public mode acceptance. ## Integrated sample games and browser evidence +The following historical `cab2f1c` samples predate the +[deferred movement repair](DEFERRED_MOVEMENT_ARRIVAL.md). New source-bound +samples use [the reusable harness](VERIFICATION_WORKFLOW.md#reusable-faction-sample-games). + Six deterministic samples used genuine faction setup, all four AI levels, unchanged physical inventories and JSON continuations every 37 accepted actions. No pieces were staged or phases skipped in these samples. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 85ceae16..fd362eda 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,40 @@ # Dune implementation status +## 13 September 2026 — Deferred movement arrival safety and reusable samples + +[Deferred movement](DEFERRED_MOVEMENT_ARRIVAL.md) fixes the combined Advanced +sample's accepted CHOAM decline deadlock. The actual conflict was a qualifying +Ambassador and Terror at the same destination. Fresh movement checks existing +arrival guards before native prevention windows or new Ornithopter commitment. +A saved explicit CHOAM decline can return an uncommitted unsupported move for +another choice; its existing reserved card remains available and retires once +when the replacement use finishes. Unrelated exceptions still reject atomically, +and Karama cancellation still validates arrival before spending its cost. +No simultaneous-arrival order is invented; that material question remains pending. + +Independent review covers rules, custody and private continuation. Focused +regressions exercise native Ixian/Fremen declarations, ordinary single-token +arrivals, saved legacy choices and Ornithopter escrow. In-memory production-room +checks cover authenticated decline, restoration, stale/duplicate ownership and +concurrent version races. The final checkpoint records actual broad results. + +`tools/faction-games.ts` replaces repeated private simulation copies with six +fixed genuine-setup scenarios, all four profiles, physical custody checks and +JSON restoration. It records private traces, failed snapshots, source fingerprints +and explicit nonzero failure. Snapshot resume records provenance and starts a new +seeded continuation stream. Full-game samples remain evidence of their particular +paths; rejection counts and any later guards are retained in the checkpoint. +All six fresh samples completed. Combined Advanced finished at turn six with +1,303 actions, 35 JSON restores and 46 guarded arrival candidates. The exact +prior failed save separately finished after 691 additional actions, 18 restores +and 39 guarded candidates. The detailed feature document records the limitation. + +After the reported outage the server was healthy, all 632 opening games and their +original seat/recovery records matched the private backup, and existing Ecaz and +Ixian QA seats restored their private hands and pending choices in the browser. +No restart or reset was needed. Public starts and publication remain gated. + + ## 13 September 2026 — Expansion faction setup and saved Ixian auction decline The [faction development profile](EXPANSION_FACTIONS_PROTOTYPE.md) connects diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 909bc22c..0360e8aa 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -35,6 +35,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Boundary | Still unresolved | Existing record | | --- | --- | --- | +| Ambassador/Terror arrival priority | Both optionally react to the same entry, but no publisher/designer priority was found. The order can change Fremen relocation versus Atomics losses. A user question has been sent: keep the overlap gated pending an official ruling, or use explicitly provisional storm order. No answer is assumed; Baliset remains a separate pre-entry prevention effect. | [Reproduction and source boundary](DEFERRED_MOVEMENT_ARRIVAL.md) | | Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and remaining battle timing stay guarded. Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) | | Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered; arithmetic helpers do not admit the full battle card. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) | | Semuta Drug | Full-hand take-before-discard and the privacy-neutral immediate reaction protocol remain unresolved. Typed discard continuations already exist; they are not activation. | [Source update](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) | diff --git a/docs/VERIFICATION_WORKFLOW.md b/docs/VERIFICATION_WORKFLOW.md index 28c51c77..456da915 100644 --- a/docs/VERIFICATION_WORKFLOW.md +++ b/docs/VERIFICATION_WORKFLOW.md @@ -28,6 +28,34 @@ An interrupted or incomplete report is never evidence of success. ## Saved games and online backups +### Reusable faction sample games + +```sh +node --import tsx tools/faction-games.ts --out /tmp/dune-new-faction-games +node --import tsx tools/faction-games.ts --out /tmp/dune-new-combined --profile combined --rules advanced --seed 20260926 +node --import tsx tools/faction-games.ts --out /tmp/dune-new-resume --resume /private/failed-combined-advanced.json +``` + +This offline tool runs six fixed CHOAM/Richese, Ecaz/Moritani and combined +Basic/Advanced samples from genuine setup, using all four AI profiles. Filters +retain each scenario's seed offset. `--seed` defaults to 20260926; +`--max-actions` defaults to 3500 accepted actions per game. Every action checks +physical card and force custody; every 37 actions checks all private views after +JSON restoration. Rejected candidates remain visible in private traces even when +a later candidate succeeds. Samples are development evidence, not calibration or +complete rules certification. + +The output is a new private directory outside the checkout. It contains private +traces, incomplete-game snapshots, `results.json` and a source-bound `report.json`. +An incomplete game or changed source exits nonzero. Resume requires a matching +fixed-scenario snapshot with saved AI profiles and records its SHA-256; it cannot +be combined with profile/rules filters. Its action budget starts at the snapshot, +and its random stream restarts from the supplied seed plus scenario offset. +Check setup provenance against the original report: accepting a supplied snapshot +does not prove it came from genuine setup. No live room or database is accessed. + +### Preservation commands + ```sh node --import tsx tools/saved-games.ts snapshot --db /absolute/games.sqlite --out /private/new-baseline --backup node --import tsx tools/saved-games.ts compare --db /absolute/games.sqlite --baseline /private/new-baseline/snapshot.json diff --git a/game/engine.ts b/game/engine.ts index fee3f1ba..58232af2 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -8657,6 +8657,8 @@ function balisetPrevents(g: Game, player: string, origin: string, to: string) { ); } function offerChoamMovement(g: Game, move: MovementOrder) { + if (move.source === 'ambassador') validateAmbassadorRelocationArrival(g, move); + else validateMovementArrival(g, move); const choam = byFaction(g, 'choam'); if ( g.phase === 5 && @@ -8785,7 +8787,7 @@ function pendingFremenMovement(g: Game, response: ResponseWindow) { validateMovementOrder(g, p, pending.order); return pending; } -function resumeChoamMovement(g: Game) { +function resumeChoamMovement(g: Game, retryUnsupportedArrival = false) { const move = g.pendingChoamMove!; if (move.source === 'ambassador') currentFremenAmbassador(g, move.ambassadorEvent); @@ -8802,16 +8804,28 @@ function resumeChoamMovement(g: Game) { if (move.source === 'ambassador') offerAmbassadorRelocation(g); return; } + if (retryUnsupportedArrival) { + try { + if (move.source === 'ambassador') { + const blocked = ambassadorRelocationArrivalBlock(g, move.player, move.origin, move.to, move.total); + if (blocked) throw new MovementArrivalError(blocked); + } else quoteMovementArrival(g, move); + } catch (error) { + if (!(error instanceof MovementArrivalError)) throw error; + log(g, `${p.name}'s uncommitted movement returned for another choice. ${error.message} No additional forces, spice, cards or movement allowance were spent.`); + if (move.source === 'ambassador') offerAmbassadorRelocation(g); + return; + } + } if (move.source === 'ambassador') commitAmbassadorRelocation(g, move); else completeMove(g, move); } -function validateMovementArrival( +function quoteMovementArrival( g: Game, move: MovementOrder, cancelingChoam = false, ) { - try { - return quoteCompletedMovementArrival({ + return quoteCompletedMovementArrival({ advanced: g.advanced, players: g.players, order: move, @@ -8826,7 +8840,11 @@ function validateMovementArrival( pendingAmbassador: !!g.pendingAmbassador, paidBox: !!g.pendingNullentropy, }, - }); + }); +} +function validateMovementArrival(g: Game, move: MovementOrder, cancelingChoam = false) { + try { + return quoteMovementArrival(g, move, cancelingChoam); } catch (error) { if (error instanceof MovementArrivalError) throw new RuleError(error.message); @@ -20185,7 +20203,7 @@ function applyActionInner( action.decline === true, 'Allow the movement or declare Baliset.', ); - resumeChoamMovement(g); + resumeChoamMovement(g, true); return g; } if ( @@ -22419,6 +22437,12 @@ function applyActionInner( sourceKeys.every((key) => key !== location(to, s)), 'Choose a different destination.', ); + // Reject known unsupported arrivals before any movement-card event or + // native movement-prevention window can commit this declaration. + validateMovementArrival(g, { + player: id, group, eliteGroup, elite, origin, origins, total: n, + to, sector: s, advisors, wantsFighters, lockedTurn, + }); let flight = g.ornithopter?.player === id ? g.ornithopter : null; if (action.movementCard !== undefined) { const mode = action.ornithopter; diff --git a/game/reference.ts b/game/reference.ts index 427be491..cfbf3ba5 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3604,6 +3604,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.', 'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: Recruits, Reinforcements and Harass & Withdraw form a separate unfinished optional variant.', 'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.', + 'Fresh unsupported movement arrivals reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. Simultaneous Ambassador/Terror order remains unresolved.', 'Remaining abilities and combined timing can still encounter explicit development guards. Setup and saved-continuation evidence does not certify complete expansion play.', ], related: ['setup', 'ix-modules', 'choam-modules', 'ecaz-modules', 'implementation-checklist'], @@ -3612,7 +3613,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'Existing faction choices and private hands are reused. Ecaz has an owned sector-allocation control and setup waiting messages; Ixians have an explicit per-lot decline control. Public starts and full acceptance remain gated.', evidence: ['components/ecaz-setup.tsx', 'components/ix-richese-technology.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All four profiles use genuine setup choices and the shared Ecaz allocation quote, and can decline this Richese lot without using Technology. Combined gameplay strategy, guarded interactions and full-game reliability remain unfinished.', evidence: ['game/bots.ts', 'game/ecaz-setup.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'The faction profile records ordinary deck composition, independent variants, Ecaz territory allocation and deterministic setup-order interpretations, with unfinished play explicit.', evidence: ['docs/EXPANSION_FACTIONS_PROTOTYPE.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Setup, inventory, private views, JSON/SQLite continuation, concurrent start and dirty-lobby rejection have focused checks. Ixian per-lot decline has ownership, stale/corrupt decision, private card custody and concurrent recovery checks. Sample gameplay and browser evidence are recorded separately, with remaining guards retained.', evidence: ['tests/expansion-factions-prototype.test.ts', 'tests/expansion-factions-recovery.test.ts', 'tests/ecaz-setup-controls.test.tsx', 'tests/ecaz-cards.test.ts', 'tests/ix-richese-technology.test.ts', 'tests/ix-richese-technology-controls.test.tsx', 'tests/ix-richese-technology-recovery.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Setup, inventory, private views, JSON/SQLite continuation, concurrent start and dirty-lobby rejection have focused checks. Ixian per-lot decline has ownership, stale/corrupt decision, private card custody and concurrent recovery checks. Sample gameplay and browser evidence are recorded separately, with remaining guards retained. Deferred movement checks cover preflight, reserved cards and saved concurrent recovery.', evidence: ['tests/deferred-movement-arrival.test.ts', 'tests/deferred-movement-arrival-recovery.test.ts', 'tools/faction-games.ts', 'tests/expansion-factions-prototype.test.ts', 'tests/expansion-factions-recovery.test.ts', 'tests/ecaz-setup-controls.test.tsx', 'tests/ecaz-cards.test.ts', 'tests/ix-richese-technology.test.ts', 'tests/ix-richese-technology-controls.test.tsx', 'tests/ix-richese-technology-recovery.test.ts'] }, ], }, ...[ diff --git a/tests/deferred-movement-arrival-fixture.ts b/tests/deferred-movement-arrival-fixture.ts new file mode 100644 index 00000000..e1ad878a --- /dev/null +++ b/tests/deferred-movement-arrival-fixture.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + initializeFactionExpansionsGameForAudit, + joinGame, + newPlayer, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { TERRITORIES, distance, location, splitLocation } from '../game/board'; +import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; +import { createTerrorState, placeTerror } from '../game/moritani-terror'; +import type { FactionId } from '../game/catalog'; + +export const arrivalPlayer = (g: Game, id: string) => + g.players.find((p) => p.id === id)!; +export const arrivalReload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +export function arrivalArmy(g: Game, id: string, key: string, amount: number) { + const p = arrivalPlayer(g, id); + p.forces = { [key]: amount }; + p.reserves = 20 - amount; +} +export function addArrivalAmbassador( + g: Game, + effect: 'fremen' | 'atreides' = 'fremen', +) { + const state = createAmbassadors(() => 0.2); + const token = state.tokens.find((t) => t.effect === effect)!; + state.cohort = [ + token.id, + ...state.tokens + .filter((t) => t.effect !== 'ecaz' && t.id !== token.id) + .slice(0, 4) + .map((t) => t.id), + ]; + for (const t of state.tokens) { + t.zone = + t.effect === 'ecaz' || state.cohort.includes(t.id) ? 'supply' : 'pool'; + t.location = null; + } + g.ecazAmbassadors = placeAmbassador(state, token.id, { + turn: 2, + availableSpice: 20, + destination: { + id: 'carthag', + stronghold: true, + inStorm: false, + allowed: true, + }, + }).state; +} +export function addArrivalTerror(g: Game) { + const state = createTerrorState(() => 0.2); + g.moritaniTerror = placeTerror( + state, + state.tokens.find((t) => t.kind === 'atomics')!.id, + 'carthag', + 2, + ); +} +export function holdArrivalCard(g: Game, id: string, name: string) { + const pile = name === 'Ornithopter' ? g.richeseCache! : g.deck; + const index = pile.findIndex((c) => c.name === name); + assert.ok(index >= 0, name); + const card = pile.splice(index, 1)[0]; + arrivalPlayer(g, id).hand.push(card); + return card.id; +} +/** Real expansion setup inventories, then the saved failure's focused movement topology. */ +export function movementArrivalGame( + mover: FactionId = 'tleilaxu', + tokens: 'both' | 'ambassador' | 'terror' | 'none' = 'both', +) { + let g = createGame('DEFERRED', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, [ + 'ix', + 'choam', + 'ecaz', + ]); + for (const faction of [mover, 'choam', 'moritani', 'richese'] as FactionId[]) + joinGame(g, newPlayer(faction, faction, faction)); + if (mover !== 'ixians') joinGame(g, newPlayer('ixians', 'Ixians', 'ixians')); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + g = initializeFactionExpansionsGameForAudit(g); + for (let step = 0; g.status === 'setup' && step < 30; step++) { + let next: Game | undefined; + for (const p of g.players) { + const view = viewGame(g, p.id); + view.players.find((p) => p.id === view.me)!.bot = 'Medium'; + const action = botActions(view)[0]; + if (action) { + next = applyAction(g, p.id, action); + break; + } + } + assert.ok(next); + g = next; + } + assert.equal(g.status, 'playing'); + Object.assign(g, { + turn: 3, + phase: 5, + active: mover, + storm: 18, + ready: [], + decision: null, + response: null, + phaseOpening: null, + stormPending: null, + movementRemaining: g.players.map((p) => p.id), + }); + for (const p of g.players) { + g.deck.push(...p.hand); + p.hand = []; + p.forces = {}; + p.reserves = 20; + p.tanks = 0; + p.spice = 20; + p.shipped = true; + p.moved = 0; + if (p.elites) { + p.elites.forces = {}; + p.elites.reserves = p.faction === 'ixians' ? 7 : 3; + p.elites.tanks = 0; + } + } + arrivalArmy(g, mover, 'arrakeen:10', 3); + arrivalArmy(g, 'choam', 'carthag:11', 1); + g.ecazAmbassadors = createAmbassadors(() => 0.2); + g.moritaniTerror = createTerrorState(() => 0.2); + if (tokens === 'both' || tokens === 'ambassador') addArrivalAmbassador(g); + if (tokens === 'both' || tokens === 'terror') addArrivalTerror(g); + return g; +} +export function arrivalMove(g: Game): Action { + const p = arrivalPlayer(g, g.active!); + return { + type: 'move', + forces: { ...p.forces }, + territory: 'carthag', + sector: 11, + ...(p.faction === 'ixians' ? { eliteForces: { ...p.elites!.forces } } : {}), + }; +} +export function nativeArrivalSource(g: Game) { + const source = TERRITORIES.filter((t) => t.type === 'sand') + .flatMap((t) => t.sectors.map((s) => location(t.id, s))) + .find( + (key) => + splitLocation(key).sector !== 18 && + distance( + key, + 'carthag:11', + (key) => splitLocation(key).sector === 18, + ) === 2, + )!; + assert.ok(source); + arrivalArmy(g, g.active!, source, 3); + if (g.active === 'ixians') { + const p = arrivalPlayer(g, 'ixians'); + p.elites!.forces = { [source]: 1 }; + p.elites!.reserves = 6; + } + return source; +} +export function arrivalResources(g: Game) { + return { + players: g.players, + deck: g.deck, + discard: g.discard, + spice: g.spice, + cache: g.richeseCache, + ambassadors: g.ecazAmbassadors, + terror: g.moritaniTerror, + ornithopter: g.ornithopter, + movementRemaining: g.movementRemaining, + phase: g.phase, + turn: g.turn, + active: g.active, + }; +} diff --git a/tests/deferred-movement-arrival-recovery.test.ts b/tests/deferred-movement-arrival-recovery.test.ts new file mode 100644 index 00000000..75ffdd48 --- /dev/null +++ b/tests/deferred-movement-arrival-recovery.test.ts @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import type { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import type * as Rooms from '../db/rooms'; +import { unitStore } from './fixture-nexus-room-store'; +import { + addArrivalTerror, + arrivalMove, + arrivalPlayer, + arrivalReload, + arrivalResources, + holdArrivalCard, + movementArrivalGame, +} from './deferred-movement-arrival-fixture'; + +const clock: Rooms.RoomsClock = { now: () => 48_000, sleep: async () => {} }; +const decline = { type: 'decision', decline: true }; +const hash = (value: string) => + createHash('sha256').update(value).digest('hex'); +function snapshot(db: DatabaseSync) { + return { + rooms: db.prepare('SELECT * FROM rooms ORDER BY code').all(), + seats: db.prepare('SELECT * FROM seats ORDER BY player_id').all(), + }; +} +async function fixture(t: test.TestContext) { + const store = unitStore(); + t.after(() => store.sqlite.close()); + let game = movementArrivalGame('tleilaxu', 'ambassador'); + for (const player of game.players) player.hand.push(game.deck.shift()!); + const card = holdArrivalCard(game, 'tleilaxu', 'Ornithopter'); + game = applyAction(game, 'tleilaxu', { + ...arrivalMove(game), + movementCard: card, + ornithopter: 'range3', + }); + assert.equal(game.decision?.kind, 'choamMovement'); + // Restore the checkpoint's legacy topology after its genuine unspent declaration. + addArrivalTerror(game); + game.version = 31; + const pending = arrivalReload(game), + code = game.code; + store.sqlite + .prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)') + .run(code, JSON.stringify(pending), pending.version, 47_000); + const tokens = Object.fromEntries( + pending.players.map((player) => [ + player.id, + hash(`deferred-movement-test:${player.id}`), + ]), + ); + for (const player of pending.players) + store.sqlite + .prepare( + 'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)', + ) + .run(hash(tokens[player.id]), code, player.id); + const auths: Record = {}; + for (const [id, token] of Object.entries(tokens)) { + auths[id] = await store.rooms.authenticate(code, token); + assert.equal(auths[id].playerId, id); + } + return { ...store, code, tokens, auths, pending, card }; +} +async function privateViews( + f: Awaited>, + game: Game, +) { + const restarted = f.restart(), + before = snapshot(f.sqlite); + for (const [id, token] of Object.entries(f.tokens)) { + const auth = await restarted.authenticate(f.code, token); + const view = await restarted.readSeatView(f.code, auth); + assert.deepEqual(view, viewGame(game, id)); + for (const other of view.players.filter((player) => player.id !== id)) { + assert.equal(other.hand, undefined); + assert.equal(other.traitors, undefined); + assert.equal(other.faceDancers, undefined); + } + if (id !== 'moritani') + assert.ok( + view.moritaniTerror!.tokens.every((token) => !('kind' in token)), + ); + assert.equal('pendingChoamMove' in view, false); + } + assert.deepEqual(snapshot(f.sqlite), before); +} + +void test('authenticated saved CHOAM decline persists an unspent return and restores every private seat and reserved movement card', async (t) => { + const f = await fixture(t), + before = snapshot(f.sqlite), + resources = structuredClone(arrivalResources(f.pending)); + await privateViews(f, f.pending); + await assert.rejects( + f.rooms.act(f.code, f.auths.tleilaxu, f.pending.version, decline, clock), + /pending decision/, + ); + await assert.rejects( + f.rooms.act(f.code, f.auths.choam, f.pending.version - 1, decline, clock), + /table changed/, + ); + await assert.rejects( + f.rooms.act( + f.code, + f.auths.choam, + f.pending.version, + { type: 'decision', decline: false }, + clock, + ), + /Allow the movement/, + ); + assert.deepEqual(snapshot(f.sqlite), before); + assert.equal(f.writes.length, 0); + + const restarted = f.restart(); + const auth = await restarted.authenticate(f.code, f.tokens.choam); + await restarted.act(f.code, auth, f.pending.version, decline, clock); + const returned = await f.restart().readRoom(f.code); + assert.equal(returned.version, f.pending.version + 1); + assert.deepEqual(arrivalResources(returned), resources); + assert.equal(returned.pendingChoamMove, null); + assert.equal(returned.decision, null); + assert.equal(returned.active, 'tleilaxu'); + assert.equal(arrivalPlayer(returned, 'tleilaxu').moved, 0); + assert.deepEqual(returned.ornithopter, f.pending.ornithopter); + assert.equal(returned.ornithopter!.card.id, f.card); + assert.equal( + returned.discard.some((card) => card.id === f.card), + false, + ); + assert.equal( + arrivalPlayer(returned, 'tleilaxu').hand.some((card) => card.id === f.card), + false, + ); + assert.match( + returned.log.at(-1)!.text, + /Ambassadors combined with another arrival reaction/, + ); + assert.deepEqual( + f.writes.map((write) => write.changes), + [1], + ); + await privateViews(f, returned); + + const saved = snapshot(f.sqlite); + await assert.rejects( + f.rooms.act(f.code, f.auths.choam, f.pending.version, decline, clock), + /table changed/, + ); + await assert.rejects( + f.rooms.act(f.code, f.auths.choam, returned.version, decline, clock), + ); + assert.deepEqual(snapshot(f.sqlite), saved); + assert.equal(f.writes.length, 1); + + await f.restart().act( + f.code, + f.auths.tleilaxu, + returned.version, + { + type: 'move', + forces: { 'arrakeen:10': 3 }, + territory: 'imperial_basin', + sector: 10, + ornithopterEvent: returned.ornithopter!.event, + }, + clock, + ); + const moved = await f.restart().readRoom(f.code); + assert.equal(moved.version, returned.version + 1); + assert.equal(arrivalPlayer(moved, 'tleilaxu').moved, 1); + assert.deepEqual(arrivalPlayer(moved, 'tleilaxu').forces, { + 'imperial_basin:10': 3, + }); + assert.equal(moved.ornithopter, null); + assert.equal(moved.discard.filter((card) => card.id === f.card).length, 1); + await privateViews(f, moved); +}); + +void test('concurrent authenticated declines of the same saved version commit the unspent return exactly once', async (t) => { + const f = await fixture(t), + resources = structuredClone(arrivalResources(f.pending)); + let release!: () => void; + const wait = new Promise((resolve) => { + release = resolve; + }); + let arrivals = 0; + f.hooks.beforeWrite = async () => { + if (++arrivals === 2) release(); + await wait; + }; + const restarted = f.restart(), + otherAuth = await restarted.authenticate(f.code, f.tokens.choam); + const attempts = await Promise.allSettled([ + f.rooms.act(f.code, f.auths.choam, f.pending.version, decline, clock), + restarted.act(f.code, otherAuth, f.pending.version, decline, clock), + ]); + delete f.hooks.beforeWrite; + assert.equal( + attempts.filter((attempt) => attempt.status === 'fulfilled').length, + 1, + ); + assert.equal( + attempts.filter((attempt) => attempt.status === 'rejected').length, + 1, + ); + assert.deepEqual(f.writes.map((write) => write.changes).sort((a, b) => a - b), [0, 1]); + assert.ok(f.writes.every((write) => write.expected === f.pending.version)); + const returned = await f.restart().readRoom(f.code); + assert.equal(returned.version, f.pending.version + 1); + assert.deepEqual(arrivalResources(returned), resources); + assert.equal(returned.pendingChoamMove, null); + assert.equal(returned.decision, null); + assert.equal( + returned.log.filter((entry) => + entry.text.includes('uncommitted movement returned for another choice'), + ).length, + 1, + ); + assert.deepEqual(returned.ornithopter, f.pending.ornithopter); + assert.equal(returned.discard.filter((card) => card.id === f.card).length, 0); + await privateViews(f, returned); + const saved = snapshot(f.sqlite); + await assert.rejects( + f.rooms.act(f.code, f.auths.choam, f.pending.version, decline, clock), + /table changed/, + ); + await assert.rejects( + f.rooms.act(f.code, f.auths.choam, returned.version, decline, clock), + ); + assert.deepEqual(snapshot(f.sqlite), saved); +}); diff --git a/tests/deferred-movement-arrival.test.ts b/tests/deferred-movement-arrival.test.ts new file mode 100644 index 00000000..2edd9930 --- /dev/null +++ b/tests/deferred-movement-arrival.test.ts @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyAction, + normalizeAutomaticGame, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { + addArrivalAmbassador, + addArrivalTerror, + arrivalArmy, + arrivalMove, + arrivalPlayer, + arrivalReload, + arrivalResources, + holdArrivalCard, + movementArrivalGame, + nativeArrivalSource, +} from './deferred-movement-arrival-fixture'; + +function reject(g: Game, player: string, action: Action, pattern?: RegExp) { + const before = structuredClone(g); + if (pattern) assert.throws(() => applyAction(g, player, action), pattern); + else assert.throws(() => applyAction(g, player, action)); + assert.deepEqual(g, before); +} +const overlap = /Ambassadors combined with another arrival reaction/; + +void test('the reproduced Tleilaxu arrival and native Ix/Fremen movement declarations reject known unsupported overlap before prevention windows or RNG', (t) => { + const games = ['tleilaxu', 'ixians', 'fremen'].map((faction) => { + const g = movementArrivalGame(faction as 'tleilaxu' | 'ixians' | 'fremen'); + if (faction !== 'tleilaxu') nativeArrivalSource(g); + if (faction === 'fremen') addArrivalAmbassador(g, 'atreides'); + holdArrivalCard(g, 'choam', 'Karama'); + return g; + }); + const random = t.mock.method(crypto, 'getRandomValues', () => { + throw Error('Unsupported arrival sampled RNG'); + }); + const uuid = t.mock.method(crypto, 'randomUUID', () => { + throw Error('Unsupported arrival allocated an event'); + }); + for (const g of games) { + reject(g, g.active!, arrivalMove(g), overlap); + assert.equal(g.pendingChoamMove, undefined); + assert.equal(g.pendingIxMove, undefined); + assert.equal(g.pendingFremenMove, undefined); + assert.equal(g.response, null); + assert.equal(g.decision, null); + } + assert.equal(random.mock.callCount(), 0); + assert.equal(uuid.mock.callCount(), 0); +}); + +void test('known unsupported entry rejects before putting a newly played Ornithopter into escrow or creating its event', (t) => { + const g = movementArrivalGame(), + card = holdArrivalCard(g, 'tleilaxu', 'Ornithopter'); + const uuid = t.mock.method(crypto, 'randomUUID', () => { + throw Error('Movement card allocated an event'); + }); + reject( + g, + 'tleilaxu', + { ...arrivalMove(g), movementCard: card, ornithopter: 'range3' }, + overlap, + ); + assert.ok(arrivalPlayer(g, 'tleilaxu').hand.some((c) => c.id === card)); + assert.equal(g.ornithopter, undefined); + assert.equal(uuid.mock.callCount(), 0); +}); + +for (const tokens of ['ambassador', 'terror'] as const) + for (const cancellation of [false, true]) + void test(`${tokens}-only entry stays legal through CHOAM ${cancellation ? 'Baliset cancellation' : 'decline'} and commits once`, () => { + let g = movementArrivalGame('tleilaxu', tokens); + const baliset = holdArrivalCard(g, 'choam', 'Baliset'), + karama = holdArrivalCard(g, 'tleilaxu', 'Karama'); + g = applyAction(g, 'tleilaxu', arrivalMove(g)); + assert.equal(g.decision?.kind, 'choamMovement'); + assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 0); + if (cancellation) { + g = applyAction(g, 'choam', { + type: 'card', + card: baliset, + mode: 'choam', + target: 'tleilaxu', + territory: 'carthag', + }); + assert.equal(g.response?.kind, 'choamWorthless'); + g = applyAction(arrivalReload(g), 'tleilaxu', { + type: 'card', + card: karama, + mode: 'cancel', + }); + assert.equal(g.discard.filter((c) => c.id === karama).length, 1); + assert.ok(arrivalPlayer(g, 'choam').hand.some((c) => c.id === baliset)); + } else + g = applyAction(arrivalReload(g), 'choam', { + type: 'decision', + decline: true, + }); + assert.equal(g.pendingChoamMove, null); + assert.equal( + g.decision?.kind, + tokens === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror', + ); + assert.deepEqual(arrivalPlayer(g, 'tleilaxu').forces, { + 'carthag:11': 3, + }); + assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 1); + const event = + tokens === 'ambassador' ? g.pendingAmbassador!.event : undefined; + g = applyAction( + arrivalReload(g), + tokens === 'ambassador' ? 'ecaz' : 'moritani', + { type: 'decision', event, decline: true }, + ); + assert.equal(g.decision, null); + assert.equal(g.active, 'tleilaxu'); + assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 1); + reject(g, 'choam', { type: 'decision', decline: true }); + }); + +void test('a saved CHOAM decline releases an uncommitted unsupported arrival with its precise reason and allows another legal destination', () => { + let g = movementArrivalGame('tleilaxu', 'ambassador'); + const card = holdArrivalCard(g, 'tleilaxu', 'Ornithopter'); + g = applyAction(g, 'tleilaxu', { + ...arrivalMove(g), + movementCard: card, + ornithopter: 'range3', + }); + const flight = structuredClone(g.ornithopter); + // Model the persisted checkpoint: the unsupported second token was already + // present in a legacy saved CHOAM window, whose declaration spent nothing. + addArrivalTerror(g); + const before = structuredClone(g), + resources = structuredClone(arrivalResources(g)); + for (const p of g.players) + assert.deepEqual(viewGame(arrivalReload(g), p.id), viewGame(g, p.id)); + assert.deepEqual(normalizeAutomaticGame(arrivalReload(g)), arrivalReload(g)); + reject(g, 'tleilaxu', { type: 'decision', decline: true }); + reject(g, 'choam', { type: 'decision', decline: false }); + const done = applyAction(arrivalReload(g), 'choam', { + type: 'decision', + decline: true, + }); + assert.deepEqual(g, before); + assert.deepEqual(arrivalResources(done), resources); + assert.equal(done.pendingChoamMove, null); + assert.equal(done.decision, null); + assert.deepEqual(done.ornithopter, flight); + assert.equal( + arrivalPlayer(done, 'tleilaxu').hand.some((c) => c.id === card), + false, + ); + assert.equal( + done.discard.some((c) => c.id === card), + false, + ); + assert.match(done.log.at(-1)!.text, overlap); + assert.match( + done.log.at(-1)!.text, + /No additional forces, spice, cards or movement allowance were spent/, + ); + assert.deepEqual( + normalizeAutomaticGame(arrivalReload(done)), + arrivalReload(done), + ); + reject(done, 'choam', { type: 'decision', decline: true }); + reject(done, 'tleilaxu', arrivalMove(done), overlap); + const retry = applyAction(done, 'tleilaxu', { + type: 'move', + forces: { 'arrakeen:10': 3 }, + territory: 'imperial_basin', + sector: 10, + ornithopterEvent: done.ornithopter!.event, + }); + assert.equal(arrivalPlayer(retry, 'tleilaxu').moved, 1); + assert.deepEqual(arrivalPlayer(retry, 'tleilaxu').forces, { + 'imperial_basin:10': 3, + }); + assert.equal(retry.ornithopter, null); + assert.equal(retry.discard.filter((c) => c.id === card).length, 1); +}); + +void test('legacy invalid-order retry stays unspent while unexpected malformed arrival faults remain rejected', () => { + let g = movementArrivalGame('tleilaxu', 'ambassador'); + g = applyAction(g, 'tleilaxu', arrivalMove(g)); + const invalid = arrivalReload(g); + arrivalArmy(invalid, 'tleilaxu', 'arrakeen:10', 2); + const retried = applyAction(invalid, 'choam', { + type: 'decision', + decline: true, + }); + assert.equal(retried.pendingChoamMove, null); + assert.equal(arrivalPlayer(retried, 'tleilaxu').moved, 0); + assert.deepEqual(arrivalPlayer(retried, 'tleilaxu').forces, { + 'arrakeen:10': 2, + }); + const malformed = arrivalReload(g); + malformed.ecazAmbassadors!.tokens = {} as NonNullable< + Game['ecazAmbassadors'] + >['tokens']; + const before = structuredClone(malformed); + assert.throws( + () => applyAction(malformed, 'choam', { type: 'decision', decline: true }), + TypeError, + ); + assert.deepEqual(malformed, before); +}); diff --git a/tests/karama-movement-preflight.test.ts b/tests/karama-movement-preflight.test.ts index 98722e24..e9094251 100644 --- a/tests/karama-movement-preflight.test.ts +++ b/tests/karama-movement-preflight.test.ts @@ -471,7 +471,7 @@ function balisetFlight(mode: 'range3' | 'twoGroups', conflict = false) { const baliset = holdCard(g, 'c', 'Baliset'); const printed = holdCard(g, 'p', 'Karama'); const worthless = holdCard(g, 'b', 'Jubba Cloak'); - if (conflict) { + const addLegacyConflict = () => { g.ecazAmbassadors = createAmbassadors(() => 0); g.ecazAmbassadors = placeAmbassador( g.ecazAmbassadors, @@ -494,7 +494,7 @@ function balisetFlight(mode: 'range3' | 'twoGroups', conflict = false) { territory, 1, ); - } + }; const inventory = physical(g); if (mode === 'twoGroups') { g = applyAction(g, 'p', { @@ -535,6 +535,9 @@ function balisetFlight(mode: 'range3' | 'twoGroups', conflict = false) { }); assert.equal(g.response?.kind, 'choamWorthless'); assert.equal(g.pendingChoamWorthless?.movement, true); + // Fresh declarations now reject this topology before opening CHOAM. Model + // an already-persisted window (or changed topology) to retain cost preflight coverage. + if (conflict) addLegacyConflict(); assert.deepEqual(physical(g), inventory); return { g, orni, baliset, printed, worthless, destination }; } diff --git a/tools/faction-games.ts b/tools/faction-games.ts new file mode 100644 index 00000000..50768690 --- /dev/null +++ b/tools/faction-games.ts @@ -0,0 +1,555 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { botActions } from '../game/bots'; +import { treacheryDeck } from '../game/cards'; +import type { FactionId } from '../game/catalog'; +import { + applyAction, + createGame, + initializeFactionExpansionsGameForAudit, + joinGame, + newPlayer, + viewGame, + RuleError, + type Action, + type Game, +} from '../game/engine'; +import { richeseCards } from '../game/richese-cards'; +import { privateOutputDirectory, sourceSnapshot } from './verification'; + +const DEFAULT_SEED = 20_260_926; +const DEFAULT_MAX_ACTIONS = 3_500; +const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; +type Profile = 'choam' | 'ecaz' | 'combined'; +type Rules = 'basic' | 'advanced'; + +type Scenario = { + ordinal: number; + profile: Profile; + rules: Rules; + expansions: string[]; + roster: FactionId[]; +}; + +const SCENARIOS: readonly Scenario[] = [ + { + ordinal: 0, + profile: 'choam', + rules: 'basic', + expansions: ['choam'], + roster: ['richese', 'choam', 'emperor', 'guild'], + }, + { + ordinal: 1, + profile: 'choam', + rules: 'advanced', + expansions: ['choam'], + roster: ['richese', 'choam', 'emperor', 'guild'], + }, + { + ordinal: 2, + profile: 'ecaz', + rules: 'basic', + expansions: ['ecaz'], + roster: ['ecaz', 'moritani', 'atreides', 'beneGesserit'], + }, + { + ordinal: 3, + profile: 'ecaz', + rules: 'advanced', + expansions: ['ecaz'], + roster: ['ecaz', 'moritani', 'atreides', 'beneGesserit'], + }, + { + ordinal: 4, + profile: 'combined', + rules: 'basic', + expansions: ['ix', 'choam', 'ecaz'], + roster: ['ecaz', 'ixians', 'tleilaxu', 'choam', 'richese', 'moritani'], + }, + { + ordinal: 5, + profile: 'combined', + rules: 'advanced', + expansions: ['ix', 'choam', 'ecaz'], + roster: ['ecaz', 'ixians', 'tleilaxu', 'choam', 'richese', 'moritani'], + }, +]; + +type TraceEntry = { + attempt: number; + accepted: number; + player: string; + turn: number; + phase: number; + setup: string | null; + decision: string | null; + response: string | null; + action: Action; + outcome: 'accepted' | 'rejected'; + error?: string; +}; + +type Result = { + name: string; + profile: Profile; + rules: Rules; + advanced: boolean; + seed: number; + resumed: boolean; + actions: number; + attempts: number; + restores: number; + turn: number; + phase: number; + winner: string[]; + used: Record; + rejected: Record; + outcome: 'complete' | 'failure'; + error?: string; +}; + +function usage() { + return ( + 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + + '[--seed UINT32] [--profile all|choam|ecaz|combined] ' + + '[--rules both|basic|advanced] [--max-actions POSITIVE] ' + + '[--resume FAILED_GAME.json]\n' + + 'Runs genuine faction-expansion setup and gameplay offline. Output must be a new private directory outside the checkout.' + ); +} + +function supplied(name: string) { + return process.argv + .slice(2) + .some((value) => value === `--${name}` || value.startsWith(`--${name}=`)); +} + +function unsigned32(value: string | undefined) { + if (value === undefined) return DEFAULT_SEED; + if (!/^\d+$/.test(value)) + throw new Error('--seed must be an unsigned 32-bit integer.'); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 0xffff_ffff) + throw new Error('--seed must be an unsigned 32-bit integer.'); + return parsed; +} + +function positive(value: string | undefined) { + if (value === undefined) return DEFAULT_MAX_ACTIONS; + if (!/^\d+$/.test(value)) + throw new Error('--max-actions must be a positive integer.'); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) + throw new Error('--max-actions must be a positive integer.'); + return parsed; +} + +function scenarioName(scenario: Scenario) { + return `${scenario.profile}-${scenario.rules}`; +} + +function parseProfile(value: string | undefined) { + const profile = value ?? 'all'; + if (!['all', 'choam', 'ecaz', 'combined'].includes(profile)) + throw new Error('--profile must be all, choam, ecaz or combined.'); + return profile as Profile | 'all'; +} + +function parseRules(value: string | undefined) { + const rules = value ?? 'both'; + if (!['both', 'basic', 'advanced'].includes(rules)) + throw new Error('--rules must be both, basic or advanced.'); + return rules as Rules | 'both'; +} + +function privateWrite(directory: string, name: string, value: unknown) { + writeFileSync( + resolve(directory, name), + JSON.stringify(value, null, 2) + '\n', + { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }, + ); +} + +function gameCards(g: Game) { + return [ + ...g.players.flatMap((player) => player.hand), + ...g.deck, + ...g.discard, + ...(g.richeseCache ?? []), + ...(g.richeseRemoved ?? []), + ...(g.ixSetupCards ?? []), + ...(g.ixAuction?.cards ?? []), + ...(g.ornithopter ? [g.ornithopter.card] : []), + ...(g.auction?.cards.slice( + g.auction.index + Number(g.currentAuctionSale?.origin === 'normal'), + ) ?? []), + ]; +} + +function expectedCards(g: Game, roster: readonly FactionId[]) { + return [ + ...treacheryDeck(g.expansions), + ...(roster.includes('richese') ? richeseCards() : []), + ] + .map((card) => card.id) + .sort(); +} + +function verifyCustody(g: Game, expected: readonly string[]) { + assert.deepEqual( + gameCards(g) + .map((card) => card.id) + .sort(), + expected, + 'physical card custody', + ); + for (const player of g.players) + assert.equal( + player.reserves + + player.tanks + + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), + 20, + `force custody ${player.faction}`, + ); +} + +function freshGame(scenario: Scenario) { + const [first, ...rest] = scenario.roster; + const game = createGame( + 'FACTIONR', + newPlayer(first, first, first), + scenario.rules === 'advanced', + scenario.expansions, + ); + for (const faction of rest) + joinGame(game, newPlayer(faction, faction, faction)); + for (const [index, player] of game.players.entries()) { + player.bot = DIFFICULTIES[index % DIFFICULTIES.length]; + player.ready = true; + } + return initializeFactionExpansionsGameForAudit(game); +} + +function resumedGame(path: string) { + if (!statSync(path).isFile()) + throw new Error('--resume must name one regular JSON Game snapshot.'); + const input = readFileSync(path); + const hash = createHash('sha256').update(input).digest('hex'); + let value: unknown; + try { + value = JSON.parse(input.toString('utf8')); + } catch { + throw new Error('--resume must contain one JSON Game snapshot.'); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('--resume must contain one JSON Game snapshot.'); + const game = value as Game; + if ( + !['setup', 'playing'].includes(game.status) || + typeof game.advanced !== 'boolean' || + !Array.isArray(game.expansions) || + !Array.isArray(game.players) || + !game.players.length || + game.players.some( + (player) => + !player || + typeof player.id !== 'string' || + typeof player.faction !== 'string' || + !DIFFICULTIES.includes(player.bot as (typeof DIFFICULTIES)[number]), + ) + ) + throw new Error( + '--resume is not an incomplete faction-games snapshot with saved AI profiles.', + ); + const scenario = SCENARIOS.find( + (candidate) => + candidate.rules === (game.advanced ? 'advanced' : 'basic') && + JSON.stringify(candidate.expansions) === + JSON.stringify(game.expansions) && + JSON.stringify(candidate.roster) === + JSON.stringify(game.players.map((player) => player.faction)), + ); + if (!scenario) + throw new Error( + '--resume does not match one of the six fixed faction-games scenarios.', + ); + // Projection validates the engine-facing shape and every private seat boundary. + for (const player of game.players) viewGame(game, player.id); + return { game, scenario, hash, path: resolve(path) }; +} + +function simulate( + initial: Game, + scenario: Scenario, + seed: number, + maxActions: number, + resumed: boolean, +) { + let game = initial; + const expected = expectedCards(game, scenario.roster); + const trace: TraceEntry[] = []; + const used: Record = {}; + const rejected: Record = {}; + let actions = 0; + let restores = 0; + try { + verifyCustody(game, expected); + while (actions < maxActions && game.status !== 'finished') { + let next: Game | undefined; + for (const player of game.players) { + for (const action of botActions(viewGame(game, player.id))) { + const context = { + attempt: trace.length, + accepted: actions, + player: player.id, + turn: game.turn, + phase: game.phase, + setup: game.setupStage ?? null, + decision: game.decision?.kind ?? null, + response: game.response?.kind ?? null, + action, + }; + try { + next = applyAction(game, player.id, action); + const label = + action.type + + (action.type === 'decision' + ? `:${game.decision?.kind ?? 'none'}` + : ''); + used[label] = (used[label] ?? 0) + 1; + trace.push({ ...context, outcome: 'accepted' }); + actions++; + break; + } catch (error) { + if (!(error instanceof RuleError)) throw error; + const message = error.message; + const label = `${action.type}: ${message}`; + rejected[label] = (rejected[label] ?? 0) + 1; + trace.push({ ...context, outcome: 'rejected', error: message }); + } + } + if (next) break; + } + if (!next) + throw new Error( + `No legal candidate: ${JSON.stringify({ + status: game.status, + setup: game.setupStage, + phase: game.phase, + decision: game.decision?.kind, + response: game.response?.kind, + preparation: game.battle?.preparation, + })}`, + ); + game = next; + verifyCustody(game, expected); + if (actions % 37 === 0) { + const restored = JSON.parse(JSON.stringify(game)) as Game; + for (const player of game.players) + assert.deepEqual( + viewGame(restored, player.id), + viewGame(game, player.id), + 'restored view', + ); + game = restored; + restores++; + } + } + if (game.status !== 'finished') throw new Error('Action limit'); + return { + game, + trace, + result: { + name: scenarioName(scenario), + profile: scenario.profile, + rules: scenario.rules, + advanced: scenario.rules === 'advanced', + seed, + resumed, + actions, + attempts: trace.length, + restores, + turn: game.turn, + phase: game.phase, + winner: game.winner, + used, + rejected, + outcome: 'complete', + } satisfies Result, + }; + } catch (error) { + return { + game, + trace, + result: { + name: scenarioName(scenario), + profile: scenario.profile, + rules: scenario.rules, + advanced: scenario.rules === 'advanced', + seed, + resumed, + actions, + attempts: trace.length, + restores, + turn: game.turn, + phase: game.phase, + winner: game.winner, + used, + rejected, + outcome: 'failure', + error: String(error), + } satisfies Result, + }; + } +} + +async function main() { + const { values, positionals } = parseArgs({ + options: { + out: { type: 'string' }, + seed: { type: 'string' }, + profile: { type: 'string' }, + rules: { type: 'string' }, + 'max-actions': { type: 'string' }, + resume: { type: 'string' }, + help: { type: 'boolean', short: 'h' }, + }, + allowPositionals: true, + strict: true, + }); + if (values.help) { + console.log(usage()); + return; + } + if (positionals.length) + throw new Error(`Unexpected argument: ${positionals[0]}`); + if (!values.out) throw new Error('--out is required.\n' + usage()); + if (values.resume && (supplied('profile') || supplied('rules'))) + throw new Error('--resume cannot be combined with --profile or --rules.'); + const seed = unsigned32(values.seed); + const maxActions = positive(values['max-actions']); + const profile = parseProfile(values.profile); + const rules = parseRules(values.rules); + const resume = values.resume ? resumedGame(values.resume) : null; + const selected = resume + ? [resume.scenario] + : SCENARIOS.filter( + (scenario) => + (profile === 'all' || scenario.profile === profile) && + (rules === 'both' || scenario.rules === rules), + ); + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const directory = privateOutputDirectory(root, values.out); + const before = sourceSnapshot(root); + const originalRandom = Object.getOwnPropertyDescriptor( + globalThis.crypto, + 'getRandomValues', + ); + let randomState = seed; + Object.defineProperty(globalThis.crypto, 'getRandomValues', { + configurable: true, + value: (array: T): T => { + if (!(array instanceof Uint32Array)) + throw new Error( + 'Faction game simulation expects Uint32Array randomness.', + ); + for (let index = 0; index < array.length; index++) { + randomState = (randomState + 0x6d2b79f5) >>> 0; + let word = Math.imul( + randomState ^ (randomState >>> 15), + randomState | 1, + ); + word ^= word + Math.imul(word ^ (word >>> 7), word | 61); + array[index] = (word ^ (word >>> 14)) >>> 0; + } + return array; + }, + }); + + const results: Result[] = []; + try { + for (const scenario of selected) { + const scenarioSeed = (seed + scenario.ordinal) >>> 0; + randomState = scenarioSeed; + const outcome = simulate( + resume ? structuredClone(resume.game) : freshGame(scenario), + scenario, + scenarioSeed, + maxActions, + !!resume, + ); + privateWrite( + directory, + `trace-${outcome.result.name}.json`, + outcome.trace, + ); + if (outcome.result.outcome === 'failure') + privateWrite( + directory, + `failed-${outcome.result.name}.json`, + outcome.game, + ); + results.push(outcome.result); + console.log(JSON.stringify(outcome.result)); + } + } finally { + if (originalRandom) + Object.defineProperty( + globalThis.crypto, + 'getRandomValues', + originalRandom, + ); + else Reflect.deleteProperty(globalThis.crypto, 'getRandomValues'); + } + + const after = sourceSnapshot(root); + const sourceUnchanged = + before.commit === after.commit && before.tree === after.tree; + const status = !sourceUnchanged + ? 'source-changed' + : results.every((result) => result.outcome === 'complete') + ? 'passed' + : 'failed'; + privateWrite(directory, 'results.json', { format: 1, results }); + privateWrite(directory, 'report.json', { + format: 1, + before, + after, + sourceUnchanged, + status, + setup: resume + ? 'Resume supplied private snapshot; its setup provenance must be checked against the original report. Saved AI profiles choose every continuation action.' + : 'Genuine faction-only setup; no cards, forces, factions, phases or statistics staged. Real saved AI profiles choose every setup and gameplay action.', + randomness: resume + ? 'Continuation restarts the random stream at seed plus scenario ordinal; it does not reconstruct the pre-snapshot random stream.' + : 'Each scenario starts its random stream at seed plus scenario ordinal.', + options: { + seed, + profile: resume ? resume.scenario.profile : profile, + rules: resume ? resume.scenario.rules : rules, + maxActions, + ...(resume ? { resume: { path: resume.path, sha256: resume.hash } } : {}), + }, + results: results.map( + ({ used: _used, rejected: _rejected, ...result }) => result, + ), + }); + console.log( + JSON.stringify({ status, report: resolve(directory, 'report.json') }), + ); + if (status !== 'passed') process.exitCode = 1; +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +});