From 61689514a02db507b429c0d2620c4198cb2d63f7 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:08:52 +1000 Subject: [PATCH] Implement private Tupile intelligence and Homeworld occupation evidence --- components/game-table.tsx | 2 + components/tupile-intelligence.tsx | 107 ++++ docs/HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md | 2 + docs/HOMEWORLD_OCCUPATION_RULES.md | 2 + docs/HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md | 43 +- docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md | 84 +++ docs/IMPLEMENTATION_STATUS.md | 2 + game/bots.ts | 3 + game/engine.ts | 107 +++- game/homeworld-game.ts | 3 +- game/homeworld-occupation-history.ts | 436 ++++++++++++++ game/reference.ts | 19 +- game/tupile-intelligence-answer.ts | 91 +++ game/tupile-intelligence-options.ts | 47 ++ game/tupile-intelligence-state.ts | 249 ++++++++ tests/bidding-end-engine.test.ts | 22 +- tests/bidding-end-recovery.test.ts | 10 +- tests/fixture-caladan-ambassador.ts | 51 +- tests/fixture-caladan-victory.ts | 33 +- tests/fixture-grumman-collection.ts | 23 +- tests/fixture-homeworld-revival-ambassador.ts | 50 +- tests/fixture-homeworld-revival.ts | 24 +- tests/fixture-tupile-intelligence.ts | 98 ++++ tests/grumman-collection-recovery.test.ts | 6 +- tests/homeworld-basic-counters.test.ts | 11 + tests/homeworld-battle-engine.test.ts | 121 ++++ tests/homeworld-benefits-recovery.test.ts | 7 +- tests/homeworld-charity-engine.test.ts | 15 +- tests/homeworld-collection-engine.test.ts | 10 +- tests/homeworld-collection-recovery.test.ts | 45 +- tests/homeworld-occupation-history.test.ts | 450 ++++++++++++++ tests/homeworld-payment-income-engine.test.ts | 22 +- ...eworld-revival-ambassador-recovery.test.ts | 9 +- .../homeworld-revival-benefits-engine.test.ts | 11 +- ...eworld-revival-deployment-recovery.test.ts | 7 +- tests/homeworld-spiritual-advisors.test.ts | 3 + tests/homeworld-tupile-sales-recovery.test.ts | 7 +- ...rld-victory-reinforcement-recovery.test.ts | 6 +- tests/tupile-intelligence-answer.test.ts | 172 ++++++ tests/tupile-intelligence-controls.test.ts | 158 +++++ tests/tupile-intelligence-engine.test.ts | 180 ++++++ tests/tupile-intelligence-recovery.test.ts | 550 ++++++++++++++++++ tests/tupile-intelligence-state.test.ts | 310 ++++++++++ 43 files changed, 3420 insertions(+), 188 deletions(-) create mode 100644 components/tupile-intelligence.tsx create mode 100644 docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md create mode 100644 game/homeworld-occupation-history.ts create mode 100644 game/tupile-intelligence-answer.ts create mode 100644 game/tupile-intelligence-options.ts create mode 100644 game/tupile-intelligence-state.ts create mode 100644 tests/fixture-tupile-intelligence.ts create mode 100644 tests/homeworld-occupation-history.test.ts create mode 100644 tests/tupile-intelligence-answer.test.ts create mode 100644 tests/tupile-intelligence-controls.test.ts create mode 100644 tests/tupile-intelligence-engine.test.ts create mode 100644 tests/tupile-intelligence-recovery.test.ts create mode 100644 tests/tupile-intelligence-state.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 98cc13d9..be2552df 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -8,6 +8,7 @@ import { HomeworldShipment } from './homeworld-shipment'; import { HomeworldRevivalDeployment } from './homeworld-revival-deployment'; import { CaladanReinforcement } from './caladan-reinforcement'; import { GrummanCollection } from './grumman-collection'; +import { TupileIntelligence } from './tupile-intelligence'; import { TerrorBoardMarkers } from './terror-board-markers'; import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options'; import { GuildHomeworldShipment } from './guild-homeworld-shipment'; @@ -1414,6 +1415,7 @@ export function GameTable({ {g.status === 'finished' ? 'FINAL OUTCOME' : 'YOUR NEXT DECISION'} + {g.biddingEnd && } {g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && ( diff --git a/components/tupile-intelligence.tsx b/components/tupile-intelligence.tsx new file mode 100644 index 00000000..d35e9eb2 --- /dev/null +++ b/components/tupile-intelligence.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { faction, PHASES } from '@/game/catalog'; +import type { TupileIntelligenceCategory } from '@/game/tupile-intelligence'; +import { + tupileIntelligenceCanAct, + tupileIntelligenceChoice, +} from '@/game/tupile-intelligence-options'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; + +export function TupileIntelligence({ game, act, busy }: Props) { + if (!game.tupileIntelligence || game.tupileIntelligence.owner !== game.me) + return null; + return ; +} + +function IntelligencePanel({ game, act, busy }: Props) { + const offer = game.tupileIntelligence!; + const [target, setTarget] = useState(''); + const [category, setCategory] = useState('weapons'); + const currentTarget = target || offer.targets.find((candidate) => !candidate.blocked)?.player || ''; + const canAct = tupileIntelligenceCanAct(game); + const choice = tupileIntelligenceChoice(game, currentTarget, category); + const eligible = offer.targets.some((candidate) => !candidate.blocked); + const disabled = busy || !canAct || !eligible; + return ( +
+

Your Tupile intelligence

+

Once per faction, learn their current spice and one card count.

+ + + {offer.blocked &&

{offer.blocked}

} + {!eligible && ( +
+

No faction is currently available for a request.

+ {!!offer.targets.length && ( +
    + {offer.targets.map((candidate) => ( +
  • + {faction(candidate.faction).name}: {candidate.blocked} +
  • + ))} +
+ )} +
+ )} + {eligible && !offer.blocked && choice.blocked && ( +

{choice.blocked}

+ )} + + {!!offer.receipts.length && ( +
+

Your private observations

+

These are past observations. They do not update when a hand or balance changes.

+
    + {[...offer.receipts].reverse().map((receipt) => ( +
  • + {faction(receipt.faction).name}: {receipt.spice} spice, {receipt.count}{' '} + {receipt.category === 'weapons' + ? receipt.count === 1 ? 'weapon' : 'weapons' + : receipt.count === 1 ? 'defense' : 'defenses'}. + Turn {receipt.turn} · {PHASES[receipt.phase]} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/docs/HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md b/docs/HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md index 4cadb2ea..7b1bbe73 100644 --- a/docs/HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md +++ b/docs/HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md @@ -1,5 +1,7 @@ # Homeworld occupation lifecycle integration audit +Runtime follow-up, 10 September 2026: [the implemented observation hooks and Tupile limits](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupation-observation-boundaries) record the subsequent integration, including why helpers may observe complete Homeworld groups before separate Arrakis/Tanks bookkeeping. This original read-only audit is retained without rewriting its recommendations as completed work. + Read-only code audit, 9 September 2026. This document maps implementation boundaries; it does not settle the occupation interpretations still identified in [the invasion source audit](HOMEWORLD_INVASION_RULES.md#verification-handoff). Root source work must define retention after departure, contested qualification, native high/low coexistence and unique rewards before those consumers are implemented. No runtime, database or deployment changes were made by this audit. ## Observe semantic force changes, not just actions diff --git a/docs/HOMEWORLD_OCCUPATION_RULES.md b/docs/HOMEWORLD_OCCUPATION_RULES.md index 39c20f14..3d7b2be2 100644 --- a/docs/HOMEWORLD_OCCUPATION_RULES.md +++ b/docs/HOMEWORLD_OCCUPATION_RULES.md @@ -1,5 +1,7 @@ # Homeworld occupation: qualification and unresolved entitlement +Runtime follow-up, 10 September 2026: [Tupile intelligence and qualification history](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupation-observation-boundaries) implement confirmed observations while retaining the unresolved entitlement and expiry questions below. The source audit remains historical evidence. + Source audit, 9 September 2026. This supplements [Homeworld rules](HOMEWORLD_RULES.md#occupier-lifecycle-and-benefits) and the [invasion audit](HOMEWORLD_INVASION_RULES.md#occupation-evidence-and-unresolved-lifecycle). It defines the evidence that can safely be recorded; it does **not** settle every occupied benefit or authorize a complete occupation release. The [integration audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) maps the physical mutation and recovery boundaries. ## Confirmed publisher contract diff --git a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md index 941b9412..99a1063c 100644 --- a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md +++ b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md @@ -1,6 +1,8 @@ # Tupile low-population intelligence source contract -Audited 10 September 2026. This checkpoint implements a public eligibility quote only. It does not activate an intelligence action, disclose private information, establish occupation lifecycle rules, or certify Homeworld play. Existing release gates remain. +Runtime follow-up, 10 September 2026: [private disclosure, durable occupation evidence and verification](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md) now document the integrated implementation. The source checkpoint below is retained as historical evidence; its future-integration statements describe that earlier inspection. + +Audited 10 September 2026; category source follow-up added during the next runtime checkpoint. The original checkpoint implemented a public eligibility quote only. This document supplies the counting contract; it does not certify a disclosure handler, occupation lifecycle or complete Homeworld play. Existing release gates remain. ## Primary evidence @@ -10,6 +12,8 @@ The [official Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content The [official November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf), printed p8, permits a Truthtrance question asking whether someone holds a weapon and a defense. It does not provide a held-card category-count algorithm. Its p3 Weirding Way/Chemistry answers describe their conditional roles in a committed battle plan. Those battle roles should not be mistaken for evidence that every card playable in a slot counts in that held-card category. +The fresh category follow-up retrieved **FAQ p5**, which explicitly establishes Weirding Way's default as a projectile weapon and Chemistry's default as a poison defense. Its Voice discussion distinguishes those defaults from optional alternate uses. The original E1 faces were also freshly inspected in [physical component photograph 5495302](https://boardgamegeek.com/image/5495302/dune-ixians-and-tleilaxu), locally `/tmp/dune-ix-cards.jpg`. Weirding Way's printed category lists weapon, defense and special; Chemistry lists defense, weapon and special. Their instructions make the alternate role conditional on playing a complementary card. Therefore the table below is supported by an express publisher default, not merely card color or the order of words in the header. + ## Executable public eligibility - The native CHOAM player has 0–10 native reserves at the request. Foreign visitors do not contribute to this threshold. @@ -31,7 +35,42 @@ The appropriate classification composition is physical held cards by their print Canonical printed identity matters for Richese cards: Stone Burner and Mirror Weapon are printed weapons, Portable Snooper a defense, and Residual Poison a Special card. A transport value such as `kind: special` is insufficient to determine this count. Physical component evidence is recorded in [RICHESE_COMPONENTS.md](RICHESE_COMPONENTS.md). -For Weirding Way and Chemistry, the existing canonical presentation/default battle-role convention is respectively weapon and defense. The official FAQ supports distinguishing the default role from a conditional alternate battle role, but does not expressly answer Tupile held-card counting. This is a documented source composition, not a claimed Tupile-specific FAQ ruling. No category-count function is introduced by this checkpoint. Before a disclosure handler is activated, its canonical category table must be checked against the printed component inventory, including expansion cards; reusing the permissive `isWeaponCard`/`isDefenseCard` slot predicates would incorrectly include Worthless cards and alternate roles. The existing [Truthtrance card-count implementation](TRUTHTRANCE_CARD_COUNT.md) counts named physical identities rather than these categories and is not a substitute. +The following exact table applies to unplayed cards currently held in hand. `1 / 0` means one weapon and zero defenses **per physical card**, not one per distinct printed name. The canonical identity must match its registered ID, name, kind and effect; a similarly named or effect-tagged fabricated card is not an additional canonical identity. + +| Physical cards / canonical classification | Weapon / defense count | Inventory coverage | +| --- | --- | --- | +| Crysknife, Maula Pistol, Slip Tip, Stunner; Ix Hunter Seeker (`projectile`) | 1 / 0 | Four base identities; one Ix identity | +| Chaumas, Chaumurky, Gom Jabbar, Ellaca Drug; Ix Basilia Weapon (`poison`) | 1 / 0 | Four base identities; one Ix identity | +| Lasgun (`lasgun`) | 1 / 0 | One base identity | +| Poison Blade (`poisonBlade`) | 1 / 0 | One Ix identity; two attack types still count as one card | +| Poison Tooth and Artillery Strike (`poisonTooth`, `artillery`) | 1 / 0 | One canonical identity each; E2 replacement faces do not add another physical copy | +| Weirding Way (`weirdingWay`) | 1 / 0 | One Ix identity; publisher default role | +| Shield (`shield`) | 0 / 1 | Four base copies and one Ix copy; count every held copy | +| Snooper (`snooper`) | 0 / 1 | Four base copies and one Ix copy; count every held copy | +| Shield Snooper (`shieldSnooper`) | 0 / 1 | One Ix identity; two defense types still count as one card | +| Chemistry (`chemistry`) | 0 / 1 | One Ix identity; publisher default role | +| Canonical Richese Stone Burner and Mirror Weapon | 1 / 0 | One each; printed weapon category despite saved `kind: special` | +| Canonical Richese Portable Snooper | 0 / 1 | One; printed defense category despite saved `kind: special` | +| All Worthless cards, Cheap Hero and Cheap Heroine | 0 / 0 | Five base Worthless, Ix Kull Wahad, two base leader replacements | +| Other base/Ix Special cards | 0 / 0 | Family Atomics, Weather Control, Hajr, Tleilaxu Ghola, Harvester, Karama, Truthtrance, Thumper and Amal; all their canonical copies | +| Other Richese Special cards | 0 / 0 | Ornithopter, Residual Poison, Semuta Drug, Distrans, Karama, Juice of Sapho, Nullentropy Box | +| All three E3 Treachery cards | 0 / 0 | Recruits, Reinforcements, Harass & Withdraw | + +Implementation inventory cross-check: the current factories contain 33 base, 14 Ix, ten Richese and three E3 identities. The table accounts for 17 weapon, 13 defense and 30 other identities across that 60-card catalog. These are software catalog totals, **not** a certified combined draw-deck recipe: Richese begins in a separate cache, replacements replace existing cards, and inactive expansion setup remains gated. Sandtrout, Nexus, skill, traitor and other non-Treachery cards are outside this catalog and do not enter the count. The existing [component inventory](COMPONENT_INVENTORY.md) preserves replacement/setup limitations. + +The Richese classifications rely on the previously inspected original physical faces documented in [RICHESE_COMPONENTS.md](RICHESE_COMPONENTS.md); this follow-up could not re-fetch that photograph and does not claim a new inspection. Fresh E3 publisher p11 again explicitly excludes Reinforcements and Harass & Withdraw from both categories. Neither category changes because a faction can use a Worthless card as Karama, a skill modifies battle, a hand lacks a complementary card, or an effect is currently disabled in development. + +Applying the publisher defaults to Tupile is a source composition, not a Tupile-specific FAQ answer. If a request is admitted after a hybrid has actually been played in a battle, its conditional role is a separate timing context. This audit does not establish whether Tupile then asks for its active battle role or its ordinary held identity. A bounded runtime can exclude the unresolved battle interval through a **public timing rule**, independent of any target's hidden cards. It must not inspect a sealed plan, double-count both possible roles, or disable the action only when a hidden hybrid is present. A retained card returns to its ordinary held default after its battle role ends. + +Do not reuse permissive `isWeaponCard`/`isDefenseCard` slot predicates: they admit Worthless cards and alternate roles. The existing [Truthtrance card-count implementation](TRUTHTRANCE_CARD_COUNT.md) counts named physical identities rather than these categories and is not a substitute. Count only the target's actual current hand, excluding caches, draws awaiting assignment, discarded cards and other escrow zones; no future opponent response or subsequent balance change may update a recorded snapshot. + +## Establishing unoccupied Tupile without inventing expiry + +At this follow-up's code inspection, `Game.homeworlds` is validated as a custody-only object. `homeworldForceGroups` supplies current physical armies; neither it nor `homeworldPopulations` stores prior occupation qualification. There is no current occupation-history record that certifies the absence of an earlier occupier. Logs and an empty current visitor map are not a substitute for that missing lifecycle evidence. + +A sole foreign army with no natives is positive current qualification evidence. Current natives beside a visitor do not prove absence of an earlier same-turn qualification; current emptiness likewise cannot settle whether a departed occupier's specific effect persists. The unresolved cases and existing user question remain in [HOMEWORLD_OCCUPATION_RULES.md](HOMEWORLD_OCCUPATION_RULES.md). No duplicate question is introduced here. + +A trusted new-setup baseline can establish that no foreign occupation has occurred yet. Future runtime evidence can preserve a conservative no-qualification invariant while all relevant semantic transfers and turn boundaries are observed, then mark it unknown when that invariant can no longer be proved. This is implementation evidence, not a new occupation expiry rule. Any such tracker needs the mutation coverage documented in [HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md), including intermediate ordered battle losses. Legacy custody lacking the baseline/history must remain unknown; do not initialize it as unoccupied merely because the current board looks clear. The existing pure helper already accepts explicit `unoccupied`, `occupied` or `unknown` from its caller. ## Current code boundary and verification diff --git a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md new file mode 100644 index 00000000..6fa3cbdc --- /dev/null +++ b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md @@ -0,0 +1,84 @@ +# Tupile private intelligence and occupation evidence + +Runtime checkpoint, 10 September 2026. Native CHOAM can now request and retain a private Tupile intelligence observation in a game with proven setup history. This implements the bounded disclosure described in the [source contract](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md); occupation entitlement, expiry and occupied income remain unresolved. Advanced and unfinished expansion starts, publication and complete-rules release retain the gates in [README](../README.md). + +## Request and historical answer + +The engine accepts `{ type: 'tupileIntelligence', target, category }`, where `target` identifies a seated opposing faction and `category` is `weapons` or `defenses`. The server validates current eligibility before reading the answer or consuming use: + +- CHOAM has at most ten native reserves on Tupile and occupation evidence proves that Tupile has never qualified an occupier since setup. +- CHOAM has positive physical forces on a Homeworld native to the target, or that target has positive physical forces on Tupile. Contact can be contested. Sharing a third faction's world does not qualify either visitor as the other's target. +- The faction has not already answered a successful Tupile request during this game. Another category, turn, contact direction, departure or reentry does not renew its use. Either Emperor world supplies contact with the same single faction allowance. +- The game is playing and the current response or committed action permits the request. Pending decisions, responses, Truthtrance, phase openings, supported nested card operations and revealed battles block it. The request does not require CHOAM's ordinary movement turn and creates no opponent confirmation or Karama response. + +One accepted action records the target's current spice and the selected held-card count, then consumes that faction's allowance. It does not spend spice, discard a card or move a force. Rejected actions reveal no answer and consume no use. The server's current target projection supplies availability to both the component and bot policy. + +Only CHOAM receives the observation. The public chronicle identifies use against a faction without exposing the chosen category, answer, hand identities or private receipt. Other sessions receive no Tupile panel data. The stored answer is historical: a later expenditure or hand change does not update it. A blocked new request, including one blocked by occupation uncertainty, does not hide an already obtained answer. + +The [answer helper](../game/tupile-intelligence-answer.ts) counts canonical physical cards in the current hand by printed primary category. Multiple held copies count separately; duplicate physical IDs or malformed identities reject. Caches, discard piles, escrowed cards, leader skills and cards merely compatible with a battle slot are excluded. Richese cards use their verified printed categories despite their transport representation as Special cards. Weirding Way counts as a weapon and Chemistry as a defense using the publisher's default-role FAQ. Applying those defaults to this held-card question is a documented composition, rather than a Tupile-specific FAQ ruling. The unresolved revealed-battle interval is blocked using public timing, independently of anyone's hidden hand. The complete category table and source limitations remain in the [source contract](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md#private-answer-and-held-card-classification). + +## Durable evidence and legacy saves + +Genuine completed Homeworld setup creates a paired initialization record: + +| Saved field | Purpose | +| --- | --- | +| `homeworlds.historyVersion` | Marks the initialized occupation-history format. | +| `homeworldOccupationHistory` | Binds the setup roster, physical source snapshots, accepted source events and confirmed qualifications. | +| `tupileIntelligence` when CHOAM is seated | Binds lifetime faction usage and private historical observations. | + +The engine requires the marker and history together, and requires the CHOAM ledger for an initialized CHOAM game. Deleting an individual initialized field, removing used receipts, changing an answer or altering the bound occupation evidence rejects views, normalization and actions. Original physical contact, turn, phase, faction and event remain part of each intelligence receipt. Historical validation does not compare an old answer with today's hand or spice. + +The signatures are canonical consistency data, **not cryptographic authentication**. Independent source receipts let the history validator recompute its qualification records; the outer signature binds their membership and order. They detect the tested inconsistent edits, not an attacker rewriting every field and every matching signature. A wholly absent legacy marker/history/intelligence set remains distinguishable from a partially deleted initialized record only to the extent that those saved fields provide evidence. + +Legacy saves without these initialization fields remain readable, with occupation unknown and new Tupile requests blocked. Reads and automatic normalization never adopt the current board as a proven setup, reconstruct departed occupiers, restore a lifetime use or invent an earlier answer. Existing D1 rooms retain their original state; no database reset or bulk history migration installs this feature. Accepted actions use the existing room-version compare-and-swap write, so simultaneous weapon/defense requests against one faction can persist only one answer. + +## Occupation observation boundaries + +The [occupation-history module](../game/homeworld-occupation-history.ts) records facts rather than choosing a current controller. A sole positive foreign army with no native or other foreign army qualifies immediately. At the end and start of the full game turn, every positive foreign presence gets its own boundary evidence, including contested worlds. Kaitain and Salusa are separate physical locations. Qualification at another Homeworld does not itself remove Tupile's low advantage. + +The pure reducer interns public typed-force snapshots, binds each recorded source to its event, turn and cause, and retains the first qualification for each world/player/turn/cause. Replaying a known event with different source facts rejects. A same-turn change call with unchanged physical facts returns unchanged data without appending another empty observation. Returned ledgers are detached from their inputs. + +Engine change-event identities derive deterministically from the original setup event and current source-record position. Resolving an action directly or restoring the same committed inner continuation therefore produces the same occupation evidence. Turn-start/end identities derive from their turn and boundary cause. Concurrent branches may propose the same next identity, but the room-version write persists only the winning branch; the reducer still rejects reuse of a recorded identity with different physical facts. Generating a new random change identity during recovery would incorrectly make otherwise identical saved continuations diverge. + +The integrated hooks preserve the semantic order of physical changes: + +| Boundary | Observation order | +| --- | --- | +| Genuine setup | Seed the visitor-free baseline before the first phase opens. | +| Normal or single-traitor battle | Observe loser elimination before losing-card disposal and separately observe winner casualties. A sole foreign interval remains recorded even if the winner subsequently loses its last force. | +| Mutual traitor or explosion | Observe the completed simultaneous army-destruction group, avoiding array-order qualifications. A later owned native explosion allocation has its own committed boundary. | +| Winner aftermath | Observe complete Ix substitution and Face Dancer replacement before later cleanup. | +| Native departure and revival | Observe after the helper installs all native totals, elite totals and Homeworld custody. The ledger reads only those world groups, so later Arrakis placement or Tanks bookkeeping cannot create an artificial Homeworld interval. | +| Other transfers | Observe complete Homeworld shipment, Junction transport, Emperor inter-world movement, Ambassador shipment, Sneak Attack, Guild transport, revival deployment and Caladan reinforcement before dependent arrival effects. | +| No-Field and Trip to Gamont | Observe the real reveal/departure and the later return separately; a single outer-action scan would miss a transient sole occupation between them. | +| Full-turn rollover | Record the old turn's end before incrementing the turn; record the new turn's start before opening Storm opportunities. | + +Action/continuation observation also provides a final check of physical changes. It does not replace the internal battle and transfer hooks. The earlier [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md) remains the historical integration analysis; this checkpoint refines its placement recommendation for helpers that read only complete Homeworld groups. + +No history observation grants occupied income, shares spice, awards a unique power or implements expiry. A history complete from setup with no Tupile qualification supplies `unoccupied`; any recorded Tupile qualification supplies `unknown` until the [pending entitlement ruling](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle) resolves departure, replacement and contested benefits. Departure and native revival preserve that uncertainty rather than declaring a permanent current occupier or silently resetting the advantage. Mere invasion alongside natives does not qualify immediately and is not a blanket block on intelligence before a qualifying boundary. + +## Client, bot and verification scope + +[Tupile controls](../components/tupile-intelligence.tsx) provide explicit opponent and category selection, one request button, the current blocking reason and private historical observations. Busy and ineligible controls cannot submit. The empty-target explanation distinguishes lack of contact from already-used factions. No opponent hand or balance is read to determine eligibility. + +[Shared options](../game/tupile-intelligence-options.ts) supply all four bot profiles with the first currently eligible weapon-count request and stop requesting a used faction. This demonstrates a legal common policy. It does not claim that profiles strategically exploit intelligence differently or add AI strength calibration. + +The focused evidence is split across [public eligibility](../tests/tupile-intelligence.test.ts), [canonical counts](../tests/tupile-intelligence-answer.test.ts), [private durable state](../tests/tupile-intelligence-state.test.ts), [occupation history](../tests/homeworld-occupation-history.test.ts), [controls](../tests/tupile-intelligence-controls.test.ts), [actual engine flows](../tests/tupile-intelligence-engine.test.ts) and [production SQLite recovery](../tests/tupile-intelligence-recovery.test.ts). The recovery suite uses real session authentication, production room code and all migrations in disposable in-memory databases. It covers concurrent categories, lost-response rereads, private serialized views and logs, unchanged unrelated rooms, corrupt-record rejection without writes, legacy uncertainty and actual occupation/departure. Conserved staged positions and later private-hand fixtures are documented in the tests; they do not fabricate successful disclosures or qualification receipts. + +Commands for the integrated verification are: + +```sh +npm test -- tupile-intelligence homeworld-occupation-history +npm run check +npm run build +npm run test:integration +``` + +Final verification: **`npm run check` passes types, lint and all 3,755 offline tests** (98.8 seconds for the tests). The change adds 41 cases across new test files and two real-battle cases in [Homeworld battle tests](../tests/homeworld-battle-engine.test.ts), with an existing typed-explosion case also strengthened. **The production build and all 40 HTTP/session integration checks pass.** Existing fixture families now bind their final factions and authenticated seat IDs before genuine setup, preserving roster and history validation. Explicitly staged Basic counter positions record their completed public position before testing normalization purity. The existing Ghola direct-action/inner-continuation comparison verifies the new deterministic observation identity. + +The coordinating browser pass used three human sessions in room `ZMA84DY8`, through version 7. Keyboard controls shipped one CHOAM force to Caladan, changing native reserves from ten to nine, then disclosed the target's **17 spice and one weapon only to CHOAM**. A later Atreides shipment reduced its current spice to 16 while the saved observation remained 17. Refreshing all three sessions preserved privacy with no page errors. Desktop 1440-pixel and phone 390-pixel layouts were inspected. After improving the no-eligible-target explanation, the final read-only three-session restoration and phone review passed: Atreides is clearly marked used, Harkonnen lacks contact, and the historical 17-spice/one-weapon answer remains visible to CHOAM. The coordinating inspection artifact is `/tmp/dune-tupile-final-phone.png`; this temporary image is not a committed dependency. + +The coordinated 22:42 development-server restart preserved all **3,112** baseline rooms. After the browser work and final HTTP checks, every one of those room versions and state hashes still matches. The database now contains 3,173 rooms: the baseline, this isolated browser room and 60 rooms created by the two integration runs. Read-only restoration also passed for earlier Grumman `HU6YUC7Z` (version 10), Caladan `LV2TNQ88` (version 5), and revival `463GUCY3` (version 7), with all three private seats in each. No saved game was reset or rewritten to install the feature. + +Final logs: `/tmp/dune-tupile-verified-check.log`, `/tmp/dune-tupile-verified-build.log`, `/tmp/dune-tupile-verified-http.log`. The browser screenshots and private session files remain outside version control. No goal completion or expansion release is claimed by this checkpoint. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 1b0b65b9..e7ff9a0e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,7 @@ # Dune implementation status +**Tupile private intelligence and occupation evidence checkpoint, 10 September 2026:** [HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md) records current low-population contact checks, one historical spice/category answer per opposing faction, private controls and shared legal bot requests. Genuine setup binds qualification history and lifetime usage; legacy history stays unknown. Ordered battle observations retain brief sole occupation while simultaneous destruction does not invent it. Deterministic source identities make direct actions and restored continuations agree. **3,755 offline tests, types, lint, production build and 40 HTTP/session tests pass.** Three-seat desktop/mobile keyboard shipment, private disclosure, later balance change and refresh passed. The hourly restart and final comparison preserved all 3,112 preexisting room versions/hashes. Occupation entitlement/expiry/income, complete module games and the full expansion goal remain unfinished; no release gate opened. The removed automation stays removed. + **Grumman Collection and stacked Terror checkpoint, 10 September 2026:** [GRUMMAN_STACKED_TERROR_RUNTIME.md](GRUMMAN_STACKED_TERROR_RUNTIME.md) records one hidden-token addition with four bank spice, separate Mentat placement, private stacked-token selection, shared controls/AI and durable discard/alliance continuations. Review fixed selected-choice rewinds and restoration of a consumed Sabotage token during its genuine discard child. **3,712 offline cases, types, lint, production build and 40 HTTP/session cases pass.** Desktop/phone keyboard Collection, shipment, token selection, Robbery payment, public stack count and three-seat private refresh passed. All 3,081 preexisting room versions/hashes remained unchanged. Removal custody, occupation, unfinished effects and complete expansion games remain unresolved; no release gate opened. The existing server was reused and the removed automation stays removed. **Caladan victory reinforcement checkpoint, 10 September 2026:** [HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md](HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md) records the actual high-Caladan post-victory one-force transfer, current population and survivors, original battle location, exact native/visitor custody, owned controls and all-profile AI. Shared Ambassador arrivals preserve the original reinforcement and its nested children. Independent review reproduced and fixed arrival-to-choice rewinds and a deleted preceding cleanup choice; new revival returns also carry version-bound independent progress, while legacy completed QA saves remain readable. Final types, lint, **3,682 offline cases** (89.9 seconds), production build and **40 HTTP cases** pass. Eight Caladan production SQLite cases cover competing writes, reloads, private projections, nested arrivals and corruption. Two older Face Dance fixtures now explicitly decline the preceding guarded Caladan option, without choosing the unresolved placement order. Desktop and phone keyboard placement and three-seat refresh passed; final restart restoration passed for both Caladan and revival QA games. All **3,051** pre-existing room versions/hashes remain unchanged after restart and additive HTTP work. Low-Tupile public contact/once-per-faction quoting has seven cases but no playable disclosure action. Face Dancer ordering, guarded entry combinations, occupation, other missing effects and complete expansion games remain unfinished; no release gate opened. User workflow optimizations remain in use and automation stays removed. diff --git a/game/bots.ts b/game/bots.ts index d2fc3e24..ba0da07e 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1,3 +1,4 @@ +import { tupileIntelligenceActions } from './tupile-intelligence-options'; import { biddingEndActions, choamMarketPolicy } from './bidding-end-options'; import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options'; import { caladanReinforcementActions } from './caladan-reinforcement-options'; @@ -3295,6 +3296,8 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null { /** Obligations apply across policy branches, including choosing to move first. */ export function botActions(g: GameView): Action[] { if (g.automaticContinuationPending) return []; + const intelligence = tupileIntelligenceActions(g); + if (intelligence.length) return intelligence; const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => { const sourced = withNativeShipmentSources(g, action); return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) ? [sourced] : []; diff --git a/game/engine.ts b/game/engine.ts index 938b6de1..29620580 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,7 @@ +import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateHomeworldOccupationHistory, tupileOccupationStatus, type HomeworldOccupationHistory } from './homeworld-occupation-history'; +import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence'; +import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer'; +import { createTupileIntelligenceState, appendTupileIntelligenceObservation, validateTupileIntelligenceState, type TupileIntelligenceState } from './tupile-intelligence-state'; import { quoteHomeworldCustody } from './homeworld-custody'; import { quoteHomeworldShipment, type HomeworldShipmentIntent } from './homeworld-shipment'; import { quoteGuildHomeworldShipment } from './guild-homeworld-shipment'; @@ -1241,7 +1245,9 @@ export type Game = { techTokens?: TechState | null; strongholdCards?: StrongholdState | null; /** Null/absent disables the module; custody is installed at force placement. */ - homeworlds?: { custody: HomeworldCustody | null } | null; + homeworlds?: { custody: HomeworldCustody | null; historyVersion?: 1 } | null; + homeworldOccupationHistory?: HomeworldOccupationHistory; + tupileIntelligence?: TupileIntelligenceState; pendingTech?: { player: string; loser: string; choices: TechId[] } | null; summonedBeforeBlow?: boolean; summonedNexusBeforeRides?: boolean; @@ -1489,6 +1495,7 @@ function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) { p.reserves -= result.forces; if (result.forces) place(p, result.location.territory, result.location.sector, result.forces); + observeOccupation(g); log( g, `${p.name} revealed the ${result.value} No-Field in ${territory(result.location.territory).name} (${cause}) and placed ${result.forces} physical forces from reserves.${result.forces < result.value ? ' Only the remaining reserves could be placed.' : ''}`, @@ -3574,6 +3581,8 @@ function withdrawNativeReserves( p.reserves = changed.reserves; if (p.elites) p.elites.reserves = changed.eliteReserves; g.homeworlds.custody = result.state; + // All Homeworld changes are committed here; the destination is on Arrakis. + observeOccupation(g); return result.receipts; } function addRevivedReserves(g: Game, p: Player, amount: number, elite: number) { @@ -3594,6 +3603,7 @@ function addRevivedReserves(g: Game, p: Player, amount: number, elite: number) { p.reserves = changed.reserves; if (p.elites) p.elites.reserves = changed.eliteReserves; g.homeworlds.custody = result.state; + observeOccupation(g); } function kill( g: Game, @@ -3796,6 +3806,7 @@ function settleHomeworldExplosion(g: Game, choice: HomeworldForces, automatic: b requireRule(pending.kind === 'explosion' && pending.options.some((p) => p.normal === choice.normal && p.elite === choice.elite), 'Choose a native explosion casualty allocation.'); commitHomeworldLoss(g, pending.player, pending.territory, choice); + observeOccupation(g); const player = getPlayer(g, pending.player); log(g, `${player.name} lost ${choice.normal} normal and ${choice.elite} special forces on ${combatLocationName(g, pending.territory)}. The remaining native forces survived the Lasgun–shield explosion.${automatic ? ' The only physical allocation was applied automatically.' : ''}`, automatic ? { faction: player.faction, name: 'Native explosion casualties' } : undefined); @@ -4195,6 +4206,12 @@ function finishSetup(g: Game) { .map((leader) => ({ leader, revealed: false })); } g.status = 'playing'; + if (g.homeworlds?.custody) { + g.homeworldOccupationHistory = homeworldRule(() => createHomeworldOccupationHistory(homeworldContext(g), g.homeworlds!.custody!, g.turn, crypto.randomUUID())); + g.homeworlds.historyVersion = 1; + const choam = byFaction(g, 'choam'); + if (choam) g.tupileIntelligence = createTupileIntelligenceState(choam.id); + } log(g, 'Setup complete. The first storm awaits two secret dials.'); openPhase(g, false); } @@ -7693,6 +7710,7 @@ function commitAmbassadorShipment(g: Game, shipment: PendingShipment) { p.reserves -= order.amount; if (p.elites) p.elites.reserves -= order.elite; place(p, order.territory, order.sector, order.amount, order.elite); + observeOccupation(g); if (order.advisors) (p.advisors ??= {})[order.territory] ??= {}; if (p.faction !== 'fremen') techIncome(g, 'heighliners', p); entry.stage = 'arrival'; @@ -9119,6 +9137,7 @@ function decideTerror(g: Game, p: Player, action: Action) { ); p.reserves -= amount; place(p, entry.territory, entry.sector, amount); + observeOccupation(g); log( g, `${p.name} sent ${amount} reserves into ${territory(entry.territory).name} through Sneak Attack.`, @@ -9913,8 +9932,10 @@ function completePhase(g: Game) { g.phase++; if (g.phase === 9) { if (g.dukeVidal) g.dukeVidal = expireDuke(g.dukeVidal, g.turn); + observeOccupation(g, 'turnEnd'); g.turn++; g.phase = 0; + observeOccupation(g, 'turnStart'); } openPhase(g); } @@ -10280,6 +10301,65 @@ function creditGiediCollection(g: Game, player: string, desert: number) { log(g, 'Harkonnen received 2 spice from the bank: high-population Giedi Prime rewards positive desert collection once this phase.', { faction: 'harkonnen', name: 'Giedi Prime collection' }); } +/** Qualification history records facts only; disputed expiry and benefits remain separate. */ +function observeOccupation(g: Game, cause: 'change' | 'turnStart' | 'turnEnd' = 'change') { + if (g.homeworlds?.historyVersion !== 1 || !g.homeworldOccupationHistory) return; + g.homeworldOccupationHistory = homeworldRule(() => observeHomeworldOccupation( + g.homeworldOccupationHistory!, homeworldContext(g), g.homeworlds!.custody!, g.turn, cause, + cause === 'change' + ? `homeworld-change-${g.homeworldOccupationHistory!.sources[0].event}-${g.homeworldOccupationHistory!.sources.length}` + : `homeworld-turn-${g.turn}-${cause}`)); +} +function homeworldHistoryIntegrity(g: Game) { + const initialized = g.homeworlds?.historyVersion === 1; + requireRule(initialized === (g.homeworldOccupationHistory !== undefined), + 'The Homeworld qualification history has lost its initialized record.'); + if (initialized) { + homeworldRule(() => validateHomeworldOccupationHistory(g.homeworldOccupationHistory!, homeworldContext(g), g.turn)); + const choam = byFaction(g, 'choam'); + requireRule(!!choam === (g.tupileIntelligence !== undefined), 'The original Tupile intelligence ledger is missing.'); + if (g.tupileIntelligence) homeworldRule(() => validateTupileIntelligenceState(g.tupileIntelligence!, g.players, g.turn)); + } else requireRule(g.tupileIntelligence === undefined, 'Tupile intelligence requires its original Homeworld history.'); +} +function tupileIntelligenceBlock(g: Game): string | null { + if (!g.homeworlds?.custody || !g.tupileIntelligence || !g.homeworldOccupationHistory) + return 'This saved game lacks the original occupation and intelligence history required for Tupile.'; + if (g.status !== 'playing') return 'Tupile intelligence is available during play.'; + if (g.phaseOpening || g.response || g.decision || g.truthtrance || g.pendingKarama || + g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || + g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.battle?.revealed) + return 'Finish the current response or committed action before requesting Tupile intelligence.'; + return null; +} +function projectedTupileIntelligence(g: Game, id: string) { + const owner = byFaction(g, 'choam'); + if (!owner || owner.id !== id || !g.homeworlds?.custody) return null; + const state = g.tupileIntelligence; + const targets = homeworldRule(() => tupileIntelligenceTargets(homeworldContext(g), g.homeworlds!.custody!, id, + state?.receipts.map((receipt) => receipt.faction) ?? [], + tupileOccupationStatus(g.homeworldOccupationHistory, homeworldContext(g)))); + return {owner: id, blocked: tupileIntelligenceBlock(g), targets, + receipts: (state?.receipts ?? []).map(({event, target, faction, category, spice, count, turn, phase}) => + ({event, target, faction, category, spice, count, turn, phase}))}; +} +function requestTupileIntelligence(g: Game, p: Player, action: Action) { + const blocked = tupileIntelligenceBlock(g); + requireRule(!blocked, blocked ?? 'Tupile intelligence is unavailable.'); + requireRule(p.faction === 'choam' && g.tupileIntelligence!.owner === p.id, 'Only CHOAM may request its private Tupile intelligence.'); + requireRule(Object.keys(action).every((key) => ['type', 'target', 'category'].includes(key)) && + typeof action.target === 'string' && (action.category === 'weapons' || action.category === 'defenses'), + 'Choose one opposing faction and either weapons or defenses.'); + const request = homeworldRule(() => quoteTupileIntelligenceRequest(homeworldContext(g), g.homeworlds!.custody!, p.id, + g.tupileIntelligence!.receipts.map((receipt) => receipt.faction), + tupileOccupationStatus(g.homeworldOccupationHistory, homeworldContext(g)), action.target as string, + action.category as TupileIntelligenceCategory)); + const target = getPlayer(g, request.target); + const answer = homeworldRule(() => quoteTupileIntelligenceAnswer(target.hand, target.spice, request.category)); + g.tupileIntelligence = homeworldRule(() => appendTupileIntelligenceObservation(g.tupileIntelligence!, { + event: crypto.randomUUID(), ...request, ...answer, turn: g.turn, phase: g.phase})); + log(g, `${p.name} used Tupile intelligence against ${faction(target.faction).name}. The spice balance and chosen card count were recorded privately; this faction cannot be questioned again.`, + {faction: 'choam', name: 'Tupile intelligence'}); +} function grummanCollectionIntegrity(g: Game) { homeworldRule(() => validateGrummanCollection(g)); const frame = g.grummanCollection; @@ -11145,6 +11225,7 @@ function gholaOptions(g: Game, p: Player) { }; } function marketGholaIntegrity(g: Game) { + homeworldHistoryIntegrity(g); grummanCollectionIntegrity(g); homeworldVictoryReturnIntegrity(g); homeworldRevivalReturnIntegrity(g); @@ -12214,6 +12295,7 @@ function resolveBattle(g: Game) { dead(dl); killTerritory(g, a, b.territory, Infinity, true); killTerritory(g, d, b.territory, Infinity, true); + observeOccupation(g); log(g, 'Both leaders were traitors. Both armies were destroyed.'); } else if (ac || dc) { const loser = ac ? d : a; @@ -12221,6 +12303,7 @@ function resolveBattle(g: Game) { dead(l); winner!.spice += quote.bounty!.amount; killTerritory(g, loser, b.territory, Infinity, true); + observeOccupation(g); log( g, `${faction(winner!.faction).name} revealed a traitor and won without losses.`, @@ -12232,6 +12315,7 @@ function resolveBattle(g: Game) { 'a Lasgun–shield explosion destroyed it', ); for (const player of quote.destroyedArmies) killTerritory(g, getPlayer(g, player), b.territory, Infinity, true); + observeOccupation(g); dead(al); dead(dl); for (const [p, plan] of [ @@ -12261,8 +12345,10 @@ function resolveBattle(g: Game) { const loser = winner === a ? d : a; if (quote.bounty) winner!.spice += quote.bounty.amount; killTerritory(g, loser, b.territory, Infinity, true); + observeOccupation(g); if (quote.basicWinnerLosses !== null) killTerritory(g, winner!, b.territory, quote.basicWinnerLosses, true); + observeOccupation(g); log( g, stoneResult @@ -12456,6 +12542,7 @@ function settleWinnerCasualties( ) { if (to.startsWith('homeworld:')) homeworldBattleLossIntegrity(g); const losses = takeBattleLosses(g, p, to, choice); + observeOccupation(g); if (to.startsWith('homeworld:')) g.homeworldBattleLoss = null; log( g, @@ -12705,6 +12792,7 @@ function decideHomeworldVictoryReturn(g: Game, p: Player, action: Action) { const seat = quote.transfer.players.find((seat) => seat.id === p.id)!; p.reserves = seat.reserves; g.homeworlds!.custody = quote.transfer.state; + observeOccupation(g); frame.destination = quote.selected.id; frame.stage = 'arrival'; frame.ambassadors = []; frame.arrivalSignature = homeworldArrivalSignature(frame); stampHomeworldVictoryStage(g); @@ -13147,6 +13235,7 @@ function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) { p.reserves = seat.reserves; if (p.elites) p.elites.reserves = seat.eliteReserves; g.homeworlds!.custody = quote.transfer.state; + observeOccupation(g); frame.destination = quote.selected.id; frame.stage = 'arrival'; stampHomeworldRevivalProgress(g); @@ -13852,6 +13941,7 @@ function finishResponse(g: Game, canceled: boolean) { target.reserves++; if (target.elites) target.elites.reserves += pending.elite!; } + observeOccupation(g); settleAdvisors(g); log( g, @@ -14061,6 +14151,7 @@ function finishResponse(g: Game, canceled: boolean) { `${player.name} substituted surviving suboids for cyborgs lost in this battle.`, ); } + observeOccupation(g); g.pendingIxSubstitution = null; if (quote) { log( @@ -14614,6 +14705,7 @@ function performJunctionTransport(g: Game, p: Player, action: Action) { owner.reserves = seat.reserves; if (owner.elites) owner.elites.reserves = seat.eliteReserves; } + observeOccupation(g); if (arrival) { place(p, arrival.territory, arrival.sector, quote.amount, quote.elite); if (advisors) (p.advisors ??= {})[arrival.territory] = { @@ -14711,6 +14803,7 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) { owner.reserves = seat.reserves; if (owner.elites) owner.elites.reserves = seat.eliteReserves; } + observeOccupation(g); p.shipped = true; g.pendingHomeworldShipment = null; // The E3 FAQ explicitly includes Homeworld shipment, including Fremen. @@ -16026,6 +16119,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { return normalizeAutomaticGame(state); } const g = applyActionInner(state, id, action); + observeOccupation(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); homeworldSubstitutionIntegrity(g); @@ -16117,6 +16211,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { reconcileBattlePromises(g, { actor: id, action }); reconcileShipmentPromises(g, { actor: id, action }); settleAutomaticContinuations(g); + observeOccupation(g); marketGholaIntegrity(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); @@ -16131,6 +16226,7 @@ function finishActionContinuations(g: Game) { // discard. Retire that next physical batch before exposing optional choices. for (let i = 0; g.pendingTreacheryDiscard && i < 16; i++) finishTreacheryDiscard(g); requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.'); + observeOccupation(g); resumeHomeworldRevivalReturn(g); resumeHomeworldVictoryReturn(g); resumeMarketGhola(g); @@ -16234,6 +16330,7 @@ export function normalizeAutomaticGame(state: Game): Game { reconcileBattlePromises(g); reconcileShipmentPromises(g); settleAutomaticContinuations(g); + observeOccupation(g); marketGholaIntegrity(g); homeworldRule(() => homeworldGameIntegrity(g)); homeworldBattleLossIntegrity(g); @@ -16343,6 +16440,10 @@ function applyActionInner( playSapho(g, p, action); return g; } + if (t === 'tupileIntelligence') { + requestTupileIntelligence(g, p, action); + return g; + } if (t === 'portableSnooper') { requireRule( Object.keys(action).every((key) => @@ -17198,6 +17299,7 @@ function applyActionInner( delete leader.concealed; } if (total && !home) place(p, decision.territory, sector, total); + observeOccupation(g); dancer.revealed = true; log( g, @@ -18830,6 +18932,7 @@ function applyActionInner( }; else if (p.advisors) delete p.advisors[to]; } + observeOccupation(g); finishShipmentPromises( g, p, @@ -18879,6 +18982,7 @@ function applyActionInner( ), ); g.homeworlds!.custody = quote.state; + observeOccupation(g); finishShipmentPromises(g, p, null); p.shipped = true; p.moved++; @@ -19555,6 +19659,7 @@ export function viewGame(state: Game, id: string) { moritaniPendingPlacement: me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null, grummanCollection: projectedGrummanCollection(g, id), + tupileIntelligence: projectedTupileIntelligence(g, id), terrorEntry: g.pendingTerrorEntry ? (() => { const entry = g.pendingTerrorEntry!; diff --git a/game/homeworld-game.ts b/game/homeworld-game.ts index 5d7d4b76..01bf932f 100644 --- a/game/homeworld-game.ts +++ b/game/homeworld-game.ts @@ -28,7 +28,8 @@ export function homeworldGameIntegrity(g: Game): void { if ( typeof state !== 'object' || Array.isArray(state) || - Object.keys(state).length !== 1 || + Object.keys(state).some((key) => !['custody', 'historyVersion'].includes(key)) || + (state.historyVersion !== undefined && state.historyVersion !== 1) || !Object.hasOwn(state, 'custody') ) throw new HomeworldCustodyError( diff --git a/game/homeworld-occupation-history.ts b/game/homeworld-occupation-history.ts new file mode 100644 index 00000000..bb208459 --- /dev/null +++ b/game/homeworld-occupation-history.ts @@ -0,0 +1,436 @@ +import type { FactionId } from './catalog'; +import { + HomeworldCustodyError, + homeworldForceGroups, + homeworldLocations, + type HomeworldCustody, + type HomeworldCustodyContext, +} from './homeworld-custody'; + +type Cause = 'change' | 'turnStart' | 'turnEnd'; +type Source = { + event: string; + turn: number; + cause: Cause | 'setup'; + snapshot: number; + signature: string; +}; +export type HomeworldOccupationQualification = { + event: string; + world: string; + player: string; + faction: FactionId; + turn: number; + cause: 'sole' | 'turnStart' | 'turnEnd'; +}; +export type HomeworldOccupationHistory = { + version: 1; + coverage: 'setup'; + advanced: boolean; + seats: [string, FactionId][]; + /** Interned canonical public typed force groups, shared across event receipts. */ + snapshots: string[]; + sources: Source[]; + qualifications: HomeworldOccupationQualification[]; + signature: string; +}; +type Snapshot = [string, [string, number, number][]][]; +const validText = (s: unknown): s is string => + typeof s === 'string' && s.trim().length > 0; +const whole = (n: unknown): n is number => + Number.isSafeInteger(n) && (n as number) >= 0; +function requireHistory( + condition: unknown, + message: string, +): asserts condition { + if (!condition) + throw new HomeworldCustodyError(`Homeworld occupation history: ${message}`); +} +function keys(value: unknown, expected: string[]): boolean { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === expected.length && + expected.every((key) => Object.hasOwn(value, key)) + ); +} +function seats(context: HomeworldCustodyContext): [string, FactionId][] { + homeworldLocations(context); + return context.players + .map((p): [string, FactionId] => [p.id, p.faction]) + .sort((a, b) => a[0].localeCompare(b[0])); +} +function sourceSnapshot( + context: HomeworldCustodyContext, + custody: HomeworldCustody, +): string { + const snapshot: Snapshot = homeworldForceGroups(context, custody).map( + (home) => [ + home.id, + Object.entries(home.forces) + .filter( + ([player, forces]) => + player === home.native || forces.normal + forces.elite > 0, + ) + .map(([player, forces]): [string, number, number] => [ + player, + forces.normal, + forces.elite, + ]) + .sort((a, b) => a[0].localeCompare(b[0])), + ], + ); + snapshot.sort((a, b) => a[0].localeCompare(b[0])); + return JSON.stringify(snapshot); +} +function readSnapshot( + value: string, + context: HomeworldCustodyContext, +): Snapshot { + requireHistory(typeof value === 'string', 'a source snapshot is missing.'); + let raw: unknown; + try { + raw = JSON.parse(value); + } catch { + requireHistory(false, 'a source snapshot is malformed.'); + } + const homes = homeworldLocations(context); + requireHistory( + Array.isArray(raw) && raw.length === homes.length, + 'a source snapshot needs every seated world.', + ); + const visited = new Set(); + const reserves = new Map( + context.players.map((p) => [p.id, { normal: 0, elite: 0 }]), + ); + const custody: HomeworldCustody = { visitors: {}, salusa: null }; + for (const row of raw) { + requireHistory( + Array.isArray(row) && + row.length === 2 && + validText(row[0]) && + Array.isArray(row[1]), + 'invalid world source row.', + ); + const home = homes.find((h) => h.id === row[0]); + requireHistory( + home && !visited.has(home.id), + 'a source world is unknown or repeated.', + ); + visited.add(home.id); + const players = new Set(); + for (const group of row[1]) { + requireHistory( + Array.isArray(group) && + group.length === 3 && + validText(group[0]) && + reserves.has(group[0]) && + !players.has(group[0]) && + whole(group[1]) && + whole(group[2]), + 'invalid typed source army.', + ); + const [player, normal, elite] = group as [string, number, number]; + players.add(player); + if (player === home.native) { + const total = reserves.get(player)!; + total.normal += normal; + total.elite += elite; + if (home.secondary) custody.salusa = { normal, elite }; + } else (custody.visitors[home.id] ??= {})[player] = { normal, elite }; + } + requireHistory( + players.has(home.native), + 'the native physical pool is missing.', + ); + } + const historical: HomeworldCustodyContext = { + advanced: context.advanced, + players: context.players.map((p) => { + const total = reserves.get(p.id)!; + return { + id: p.id, + faction: p.faction, + reserves: total.normal + total.elite, + eliteReserves: total.elite, + }; + }), + }; + requireHistory( + sourceSnapshot(historical, custody) === value, + 'source armies are not canonical or physically valid.', + ); + return raw as Snapshot; +} +function sourceSignature( + source: Omit, + snapshot: string, +): string { + return JSON.stringify([ + 'homeworldOccupationSource', + source.event, + source.turn, + source.cause, + snapshot, + ]); +} +function historySignature(history: HomeworldOccupationHistory): string { + return JSON.stringify([ + 'homeworldOccupationHistory', + history.version, + history.coverage, + history.advanced, + history.seats, + history.snapshots, + history.sources, + history.qualifications, + ]); +} +function facts( + source: Source, + snapshot: Snapshot, + context: HomeworldCustodyContext, +): HomeworldOccupationQualification[] { + if (source.cause === 'setup') return []; + const homes = homeworldLocations(context); + const result: HomeworldOccupationQualification[] = []; + for (const [world, groups] of snapshot) { + const native = homes.find((home) => home.id === world)!.native; + const present = groups.filter(([, normal, elite]) => normal + elite > 0); + const foreign = + source.cause === 'change' + ? present.length === 1 && present[0][0] !== native + ? present + : [] + : present.filter(([player]) => player !== native); + for (const [player] of foreign) + result.push({ + event: source.event, + world, + player, + faction: context.players.find((p) => p.id === player)!.faction, + turn: source.turn, + cause: source.cause === 'change' ? 'sole' : source.cause, + }); + } + return result; +} +const factKey = (fact: HomeworldOccupationQualification) => + JSON.stringify([fact.world, fact.player, fact.turn, fact.cause]); + +/** Validate stored evidence independently of today's physical garrisons. This + * proves recorded qualification, never current entitlement or a spice award. */ +export function validateHomeworldOccupationHistory( + history: HomeworldOccupationHistory, + context: HomeworldCustodyContext, + currentTurn?: number, +): void { + requireHistory( + keys(history, [ + 'version', + 'coverage', + 'advanced', + 'seats', + 'snapshots', + 'sources', + 'qualifications', + 'signature', + ]) && + history.version === 1 && + history.coverage === 'setup' && + history.advanced === context.advanced && + JSON.stringify(history.seats) === JSON.stringify(seats(context)) && + Array.isArray(history.snapshots) && + history.snapshots.length > 0 && + Array.isArray(history.sources) && + history.sources.length > 0 && + Array.isArray(history.qualifications), + 'the initialized record or seated world roster is invalid.', + ); + if (currentTurn !== undefined) + requireHistory( + whole(currentTurn) && currentTurn > 0, + 'invalid current turn.', + ); + requireHistory( + new Set(history.snapshots).size === history.snapshots.length, + 'source snapshots must be interned.', + ); + const snapshots = history.snapshots.map((value) => + readSnapshot(value, context), + ); + const events = new Set(), + used = new Set(), + seenFacts = new Set(); + const expected: HomeworldOccupationQualification[] = []; + let lastTurn = 0; + for (const [index, source] of history.sources.entries()) { + requireHistory( + keys(source, ['event', 'turn', 'cause', 'snapshot', 'signature']) && + validText(source.event) && + !events.has(source.event) && + whole(source.turn) && + source.turn > 0 && + source.turn >= lastTurn && + (currentTurn === undefined || source.turn <= currentTurn) && + (index === 0 + ? source.cause === 'setup' + : ['change', 'turnStart', 'turnEnd'].includes(source.cause)) && + whole(source.snapshot) && + source.snapshot < snapshots.length, + 'a source event is duplicated, stale, future or malformed.', + ); + events.add(source.event); + used.add(source.snapshot); + lastTurn = source.turn; + requireHistory( + source.signature === + sourceSignature(source, history.snapshots[source.snapshot]), + 'source event physical facts changed.', + ); + if (index === 0) { + const homes = homeworldLocations(context); + requireHistory( + snapshots[source.snapshot].every(([world, groups]) => + groups.every( + ([player]) => + player === homes.find((home) => home.id === world)!.native, + ), + ), + 'setup cannot prove history while foreign armies are present.', + ); + } + for (const fact of facts(source, snapshots[source.snapshot], context)) { + const key = factKey(fact); + if (!seenFacts.has(key)) { + expected.push(fact); + seenFacts.add(key); + } + } + } + requireHistory( + used.size === snapshots.length, + 'unreferenced source snapshots are invalid.', + ); + requireHistory( + history.qualifications.every((fact) => + keys(fact, ['event', 'world', 'player', 'faction', 'turn', 'cause']), + ) && JSON.stringify(history.qualifications) === JSON.stringify(expected), + 'qualification evidence no longer matches the independent source facts.', + ); + requireHistory( + history.signature === historySignature(history), + 'the signed history was changed.', + ); +} + +export function createHomeworldOccupationHistory( + context: HomeworldCustodyContext, + custody: HomeworldCustody, + turn: number, + event: string, +): HomeworldOccupationHistory { + requireHistory( + Object.keys(custody.visitors).length === 0, + 'setup requires empty foreign custody.', + ); + const snapshot = sourceSnapshot(context, custody); + const source: Source = { + event, + turn, + cause: 'setup', + snapshot: 0, + signature: '', + }; + source.signature = sourceSignature(source, snapshot); + const history: HomeworldOccupationHistory = { + version: 1, + coverage: 'setup', + advanced: context.advanced, + seats: seats(context), + snapshots: [snapshot], + sources: [source], + qualifications: [], + signature: '', + }; + history.signature = historySignature(history); + validateHomeworldOccupationHistory(history, context, turn); + return history; +} + +/** Observe only committed semantic changes. Simultaneous destruction belongs to + * one caller event; separately ordered loser/winner losses need separate events. */ +export function observeHomeworldOccupation( + history: HomeworldOccupationHistory, + context: HomeworldCustodyContext, + custody: HomeworldCustody, + turn: number, + cause: Cause, + event: string, +): HomeworldOccupationHistory { + validateHomeworldOccupationHistory(history, context, turn); + requireHistory( + whole(turn) && + turn > 0 && + validText(event) && + ['change', 'turnStart', 'turnEnd'].includes(cause), + 'invalid observation event.', + ); + const snapshot = sourceSnapshot(context, custody); + const prior = history.sources.find((source) => source.event === event); + if (prior) { + requireHistory( + prior.turn === turn && + prior.cause === cause && + history.snapshots[prior.snapshot] === snapshot, + 'a repeated source event has different physical facts.', + ); + return structuredClone(history); + } + const latest = history.sources.at(-1)!; + requireHistory( + turn >= latest.turn, + 'observations cannot move backwards in time.', + ); + if ( + cause === 'change' && + latest.turn === turn && + history.snapshots[latest.snapshot] === snapshot + ) + return structuredClone(history); + const result = structuredClone(history); + let index = result.snapshots.indexOf(snapshot); + if (index < 0) { + index = result.snapshots.length; + result.snapshots.push(snapshot); + } + const source: Source = { event, turn, cause, snapshot: index, signature: '' }; + source.signature = sourceSignature(source, snapshot); + result.sources.push(source); + const existing = new Set(result.qualifications.map(factKey)); + for (const fact of facts(source, readSnapshot(snapshot, context), context)) { + if (!existing.has(factKey(fact))) { + result.qualifications.push(fact); + existing.add(factKey(fact)); + } + } + result.signature = historySignature(result); + return result; +} + +/** No qualification since proven setup is affirmative evidence. A past qualifier + * or absent legacy history needs the pending entitlement/expiry ruling. */ +export function tupileOccupationStatus( + history: HomeworldOccupationHistory | undefined, + context: HomeworldCustodyContext, + currentTurn?: number, +): 'unoccupied' | 'unknown' { + if (history === undefined) return 'unknown'; + validateHomeworldOccupationHistory(history, context, currentTurn); + const native = context.players.find((p) => p.faction === 'choam'); + if (!native) return 'unknown'; + return history.qualifications.some((fact) => fact.world === 'homeworld:choam') + ? 'unknown' + : 'unoccupied'; +} diff --git a/game/reference.ts b/game/reference.ts index 83e13aa1..1acdb6c2 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3126,14 +3126,16 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In supported payment cases, low Kaitain halves Emperor’s Treachery payment income and low Junction halves eligible Guild shipping income, rounded up. The payer still pays the full cost. For example, a five-spice auction payment gives low-population Emperor three spice and leaves two in the bank. The income opportunity shows the original payment, current receipt and bank remainder. A population change before collection updates the split; ordinary Karama can still prevent the separate faction income.', 'Guild’s own shipment contribution goes to the bank before any low-Junction reduction. Splits with two odd contributions from other factions remain unavailable while their rounding rule is unresolved; choose another funding split. Single-contributor payments and splits where both rounding interpretations agree remain supported. Occupier income is still unfinished.', 'With at least seven native forces on Giedi Prime, Harkonnen automatically gains two bank spice after collecting positive desert spice during Spice Collection, once for the whole phase. Several deposits still earn only two. Shared Ecaz spice qualifies when Harkonnen actually receives a positive allocation; a zero share, stronghold income or technology income does not qualify. Refreshing preserves the completed bonus.', - 'With fewer than eight native forces on Grumman, Moritani can reveal Terror only when at least three forces enter together. Count each incoming physical counter once, including advisors, and a concealed No-Field as one regardless of its hidden value. Forces already at the destination do not count. Grumman’s high-population Collection token action remains unfinished.', + 'With fewer than eight native forces on Grumman, Moritani can reveal Terror only when at least three forces enter together. Count each incoming physical counter once, including advisors, and a concealed No-Field as one regardless of its hidden value. Forces already at the destination do not count. High Grumman’s separate Collection choice can add a token to an existing stronghold stack and collect four bank spice; removal still requires a custody ruling.', 'While Ecaz has at least seven native reserves, it automatically receives three bank spice for each discarded poison weapon, including its own. Poison Blade and Poison Tooth qualify; Chemistry qualifies only when used as a weapon. Poison defenses and Residual Poison do not qualify. Ecaz sees its income in a private Homeworld record; private discarded faces stay private.', 'Battle cleanup checks population when each card is actually discarded. Losing-card disposal comes before winner casualties. Mandatory winning cards, including a used Poison Tooth, are discarded after winner casualties and any Ixian substitution, before optional winning-card cleanup. If Ecaz falls below seven during its winning losses, its later card discards earn no poison income.', 'If Tleilax starts Revival at low population, it receives no bank income for other factions’ ordinary free revivals throughout that phase, even if its population rises. Paid revival receipts and the separately named Ghola reward remain available.', 'Low Caladan prevents the phase-opening Spice Blow peek. Low Wallach IX prevents free Spiritual Advisors, while high Wallach IX allows one or two to Polar Sink after another faction’s qualifying shipment. Accompanying elsewhere still uses one. Low Ix prevents moving the mobile stronghold; low Richese prevents moving a No-Field token. Physical-force movement and ordinary paid shipments retain their separate rules.', 'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.', 'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Placement involving Face Dancer ordering, storm, the mobile stronghold or unresolved entry reactions remains guarded. These guards do not certify those combined rules.', - 'Low Tupile’s intelligence advantage requires contact with another faction’s Homeworld or that faction’s presence on Tupile, and is usable once per faction. Its public eligibility checks are implemented, but the private disclosure action and occupation-dependent availability remain unfinished.', + 'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.', + 'Choose weapons or defenses in the private Tupile panel at a settled action boundary. The server answers immediately without an opponent confirmation, and does not expose individual cards. Count physical held Treachery cards by their default category: Weirding Way is a weapon, Chemistry is a defense, while Worthless cards and leader substitutes count as neither. Selecting the other category later cannot renew a faction’s used entitlement.', + 'Occupation removes Tupile’s low advantage. New Homeworld games record qualifying foreign presence from setup, including brief sole occupation during ordered battle losses and presence at turn boundaries. Earlier saves without that history, or histories with a Tupile qualifier whose continuing entitlement is unresolved, cannot make a new intelligence request. Already learned observations remain readable. Recorded qualification does not itself award occupied income or settle its expiry.', 'During Spice Collection, Grumman with at least eight native reserves may add one available Terror token to an ordinary stronghold that already has Terror, then collect four bank spice. This opportunity is separate from Mentat placement and follows any pending shared spice allocation. Declining collects nothing. Removing a token remains unavailable while its destination after removal awaits a ruling.', 'When several Terror tokens share a stronghold, Moritani privately selects one for an eligible entry, then chooses whether to reveal it or offer an alliance. The other tokens remain hidden. The board shows the public stack count; refresh preserves the selected token and original arrival.', 'While Junction has high population, Guild may offer another faction transport at half or full price during that faction’s shipment. The recipient can choose exact forces from one territory or Homeworld, including a return to their own world. An allied Homeworld remains forbidden. Half-price totals round up; five forces to a Homeworld cost three spice. The Junction route and offered rate resist Karama.', @@ -3183,6 +3185,11 @@ export const RULE_TOPICS: RuleTopic[] = [ 'game/homeworld-card-economy.ts', 'game/homeworld-payment-income.ts', 'game/homeworld-collection.ts', + 'game/homeworld-occupation-history.ts', + 'game/tupile-intelligence.ts', + 'game/tupile-intelligence-answer.ts', + 'game/tupile-intelligence-state.ts', + 'components/tupile-intelligence.tsx', 'game/giedi-collection.ts', 'game/terror-entry-receipt.ts', 'game/bidding-end.ts', @@ -3212,7 +3219,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'AI', status: 'Partial', detail: - 'Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect unresolved special-Karama and mid-battle Ghola timing boundaries. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Concealed transport, occupation and remaining card-effect strategies are unfinished.', + 'Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect unresolved special-Karama and mid-battle Ghola timing boundaries. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use eligible unused low-Tupile requests without reading opponents’ private hands or balances. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Concealed transport, occupation and remaining card-effect strategies are unfinished.', evidence: ['game/homeworld-options.ts', 'game/homeworld-shipment-options.ts', 'game/guild-homeworld-shipment-options.ts', 'game/junction-transport-options.ts'], }, { @@ -3310,6 +3317,12 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/caladan-reinforcement-controls.test.ts', 'tests/homeworld-victory-reinforcement-recovery.test.ts', 'tests/tupile-intelligence.test.ts', + 'tests/tupile-intelligence-answer.test.ts', + 'tests/tupile-intelligence-state.test.ts', + 'tests/tupile-intelligence-controls.test.ts', + 'tests/tupile-intelligence-engine.test.ts', + 'tests/tupile-intelligence-recovery.test.ts', + 'tests/homeworld-occupation-history.test.ts', 'tests/grumman-collection.test.ts', 'tests/grumman-collection-engine.test.ts', 'tests/grumman-collection-controls.test.ts', diff --git a/game/tupile-intelligence-answer.ts b/game/tupile-intelligence-answer.ts new file mode 100644 index 00000000..1afb5197 --- /dev/null +++ b/game/tupile-intelligence-answer.ts @@ -0,0 +1,91 @@ +import { baseDeck, ixDeck, type Card } from './cards'; +import { ecazTreacheryCards } from './ecaz-cards'; +import { HomeworldCustodyError } from './homeworld-custody'; +import { richeseCards, richeseCardDefinition } from './richese-cards'; +import type { TupileIntelligenceCategory } from './tupile-intelligence'; + +const inventory = new Map( + [...baseDeck(), ...ixDeck(), ...richeseCards(), ...ecazTreacheryCards()].map( + (card) => [card.id, card] as const, + ), +); + +/** Printed primary categories, never permissive battle-slot eligibility. + * Weirding Way and Chemistry follow the documented default-role composition; + * no dedicated Tupile FAQ ruling is claimed. */ +function primaryCategory(card: Card): TupileIntelligenceCategory | null { + const richese = richeseCardDefinition(card); + if (richese) + return richese.printedType === 'Weapon - Special' + ? 'weapons' + : richese.printedType === 'Defense - Poison' + ? 'defenses' + : null; + switch (card.kind) { + case 'projectile': + case 'poison': + case 'lasgun': + case 'poisonBlade': + case 'poisonTooth': + case 'artillery': + case 'weirdingWay': + return 'weapons'; + case 'shield': + case 'snooper': + case 'shieldSnooper': + case 'chemistry': + return 'defenses'; + case 'worthless': + case 'hero': + case 'special': + return null; + } +} + +function fail(): never { + // Never include a private identity, balance or partially computed count. + throw new HomeworldCustodyError( + 'Tupile intelligence needs a valid private hand, spice balance and selected category.', + ); +} + +/** The caller authorizes and records this disclosure atomically. This returns + * only a detached historical answer; it does not check contact, reveal cards, + * consume usage or expose a second category. No expansion is activated here. */ +export function quoteTupileIntelligenceAnswer( + hand: readonly Card[], + spice: number, + category: TupileIntelligenceCategory, +): { spice: number; count: number } { + if ( + !Array.isArray(hand) || + !Number.isSafeInteger(spice) || + spice < 0 || + (category !== 'weapons' && category !== 'defenses') + ) + fail(); + const seen = new Set(); + let count = 0; + for (const card of hand) { + if ( + !card || + typeof card !== 'object' || + Array.isArray(card) || + typeof card.id !== 'string' || + seen.has(card.id) + ) + fail(); + const canonical = inventory.get(card.id); + if ( + !canonical || + canonical.kind !== card.kind || + canonical.effect !== card.effect || + (canonical.name !== card.name && + !(card.id === 'treachery-7' && card.name === 'Basilia Weapon')) + ) + fail(); + seen.add(card.id); + if (primaryCategory(canonical) === category) count++; + } + return { spice, count }; +} diff --git a/game/tupile-intelligence-options.ts b/game/tupile-intelligence-options.ts new file mode 100644 index 00000000..5960538b --- /dev/null +++ b/game/tupile-intelligence-options.ts @@ -0,0 +1,47 @@ +import type { Action, GameView } from './engine'; +import type { TupileIntelligenceCategory } from './tupile-intelligence'; + +/** Eligibility is projected by the server. Never inspect hands or reconstruct + * occupation history, card categories or balances in a client or bot. */ +export function tupileIntelligenceCanAct(g: GameView): boolean { + return !!( + g.status === 'playing' && + g.tupileIntelligence?.owner === g.me && + !g.tupileIntelligence.blocked + ); +} + +export function tupileIntelligenceChoice( + g: GameView, + target: string, + category: TupileIntelligenceCategory, +): { blocked: string | null; action: Action | null } { + const offer = g.tupileIntelligence; + let blocked: string | null = null; + if (!offer || offer.owner !== g.me || g.status !== 'playing') + blocked = 'Tupile intelligence is not available to this seat.'; + else if (offer.blocked) blocked = offer.blocked; + else if (category !== 'weapons' && category !== 'defenses') + blocked = 'Choose the weapon count or defense count.'; + else { + const targets = offer.targets.filter((candidate) => candidate.player === target); + if (targets.length !== 1 || target === g.me) + blocked = 'Choose a current opposing faction.'; + else blocked = targets[0].blocked; + } + return { + blocked, + action: blocked ? null : { type: 'tupileIntelligence', target, category }, + }; +} + +/** A deterministic legal request shared by every bot profile. Historical + * answers never determine whether another faction is currently eligible. */ +export function tupileIntelligenceActions(g: GameView): Action[] { + if (!tupileIntelligenceCanAct(g)) return []; + for (const target of g.tupileIntelligence!.targets) { + const choice = tupileIntelligenceChoice(g, target.player, 'weapons'); + if (choice.action) return [choice.action]; + } + return []; +} diff --git a/game/tupile-intelligence-state.ts b/game/tupile-intelligence-state.ts new file mode 100644 index 00000000..ec5ad3ce --- /dev/null +++ b/game/tupile-intelligence-state.ts @@ -0,0 +1,249 @@ +import { FACTIONS, type FactionId } from './catalog'; +import { HomeworldCustodyError } from './homeworld-custody'; +import type { TupileIntelligenceCategory } from './tupile-intelligence'; + +export type TupileIntelligenceObservation = { + event: string; + owner: string; + target: string; + faction: FactionId; + category: TupileIntelligenceCategory; + spice: number; + count: number; + turn: number; + phase: number; + contact: string[]; + signature: string; +}; +export type TupileIntelligenceState = { + version: 1; + owner: string; + receipts: TupileIntelligenceObservation[]; + signature: string; +}; +type ObservationInput = Omit & { + signature?: string; +}; +type PublicSeat = { id: string; faction: FactionId }; + +function fail(): never { + throw new HomeworldCustodyError( + 'The saved Tupile intelligence ledger does not match its original private observations and seated factions.', + ); +} +const text = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; +const whole = (value: unknown): value is number => + Number.isSafeInteger(value) && (value as number) >= 0; +const faction = (value: unknown): value is FactionId => + FACTIONS.some((candidate) => candidate.id === value); +const keys = (value: unknown, allowed: readonly string[]): boolean => + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).every((key) => allowed.includes(key)); + +/** Consistency signatures bind canonical ordered facts, not cryptographic authority. */ +export function tupileIntelligenceObservationSignature( + observation: Omit, +): string { + return JSON.stringify([ + 'tupileIntelligenceObservation', + observation.event, + observation.owner, + observation.target, + observation.faction, + observation.category, + observation.spice, + observation.count, + observation.turn, + observation.phase, + observation.contact, + ]); +} +export function tupileIntelligenceStateSignature( + state: Omit, +): string { + return JSON.stringify([ + 'tupileIntelligenceState', + state.version, + state.owner, + state.receipts.map((receipt) => [ + tupileIntelligenceObservationSignature(receipt), + receipt.signature, + ]), + ]); +} + +function validateObservation( + observation: ObservationInput, + owner: string, +): void { + if ( + !keys(observation, [ + 'event', + 'owner', + 'target', + 'faction', + 'category', + 'spice', + 'count', + 'turn', + 'phase', + 'contact', + 'signature', + ]) || + !text(observation.event) || + observation.owner !== owner || + !text(observation.target) || + observation.target === owner || + !faction(observation.faction) || + observation.faction === 'choam' || + !['weapons', 'defenses'].includes(observation.category) || + !whole(observation.spice) || + !whole(observation.count) || + !whole(observation.turn) || + observation.turn < 1 || + !whole(observation.phase) || + observation.phase > 8 || + !Array.isArray(observation.contact) || + observation.contact.length === 0 || + new Set(observation.contact).size !== observation.contact.length + ) + fail(); + const contacts = [ + 'homeworld:choam', + `homeworld:${observation.faction}`, + ...(observation.faction === 'emperor' ? ['homeworld:emperor:salusa'] : []), + ]; + if ( + observation.contact.some( + (location) => + typeof location !== 'string' || !contacts.includes(location), + ) || + (observation.signature !== undefined && + observation.signature !== + tupileIntelligenceObservationSignature(observation)) + ) + fail(); +} + +function validateLedger(state: TupileIntelligenceState): void { + if ( + !keys(state, ['version', 'owner', 'receipts', 'signature']) || + state.version !== 1 || + !text(state.owner) || + !Array.isArray(state.receipts) || + typeof state.signature !== 'string' + ) + fail(); + const events = new Set(), + factions = new Set(), + targets = new Set(); + for (const receipt of state.receipts) { + validateObservation(receipt, state.owner); + if ( + typeof receipt.signature !== 'string' || + events.has(receipt.event) || + factions.has(receipt.faction) || + targets.has(receipt.target) + ) + fail(); + events.add(receipt.event); + factions.add(receipt.faction); + targets.add(receipt.target); + } + if (state.signature !== tupileIntelligenceStateSignature(state)) fail(); +} + +/** Only genuine new CHOAM Homeworld setup initializes this ledger. Readers and + * normalization must not use this constructor to replace a missing saved one. */ +export function createTupileIntelligenceState( + owner: string, +): TupileIntelligenceState { + if (!text(owner)) fail(); + const state: TupileIntelligenceState = { + version: 1, + owner, + receipts: [], + signature: '', + }; + state.signature = tupileIntelligenceStateSignature(state); + return state; +} + +/** Appending consumes a faction's lifetime use and snapshots its authorized + * answer atomically. The caller proves live eligibility and seated identity. */ +export function appendTupileIntelligenceObservation( + state: TupileIntelligenceState, + observation: ObservationInput, +): TupileIntelligenceState { + validateLedger(state); + validateObservation(observation, state.owner); + if ( + state.receipts.some( + (receipt) => + receipt.event === observation.event || + receipt.faction === observation.faction || + receipt.target === observation.target, + ) + ) + fail(); + const receipt: TupileIntelligenceObservation = { + ...structuredClone(observation), + signature: tupileIntelligenceObservationSignature(observation), + }; + const result = { + ...state, + receipts: [...structuredClone(state.receipts), receipt], + }; + result.signature = tupileIntelligenceStateSignature(result); + return result; +} + +/** Historical answers do not follow current balances, hands or later contact. + * Only public seated identity and the current turn are needed to validate them. */ +export function validateTupileIntelligenceState( + state: TupileIntelligenceState, + players: readonly PublicSeat[], + currentTurn: number, +): void { + validateLedger(state); + if (!Array.isArray(players) || !whole(currentTurn) || currentTurn < 1) fail(); + const ids = new Set(), + factions = new Set(); + for (const seat of players) { + if ( + !seat || + typeof seat !== 'object' || + !text(seat.id) || + !faction(seat.faction) || + ids.has(seat.id) || + factions.has(seat.faction) + ) + fail(); + ids.add(seat.id); + factions.add(seat.faction); + } + if ( + players.filter( + (seat) => seat.id === state.owner && seat.faction === 'choam', + ).length !== 1 || + state.receipts.some( + (receipt) => + receipt.turn > currentTurn || + !players.some( + (seat) => + seat.id === receipt.target && seat.faction === receipt.faction, + ), + ) + ) + fail(); +} + +export function tupileIntelligenceUsedFactions( + state: TupileIntelligenceState, +): FactionId[] { + validateLedger(state); + return state.receipts.map((receipt) => receipt.faction); +} diff --git a/tests/bidding-end-engine.test.ts b/tests/bidding-end-engine.test.ts index 01ae2791..2851687a 100644 --- a/tests/bidding-end-engine.test.ts +++ b/tests/bidding-end-engine.test.ts @@ -32,14 +32,17 @@ function hold(g: Game, id: string, match: (card: Card) => boolean) { /** Emperor-only cases use the genuine audited setup pipeline. CHOAM's complete * expansion deck remains gated, so its shared-window cases use an explicit * faction seam with real base-deck IDs and conserved Homeworld counters. */ -function fixture(choam = false, advanced = true) { +function fixture(choam = false, advanced = true, richese = false) { let g = createGame( 'BIDDINGEND', newPlayer('e', 'Emperor', 'emperor'), advanced, - choam ? ['choam'] : [], + choam || richese ? ['choam'] : [], ); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); + if (richese) joinGame(g, newPlayer('r', 'Richese', 'richese')); + // The Richese audit uses its separate cache plus the implemented base deck. + if (richese) g.expansions = []; if (choam) { joinGame(g, newPlayer('c', 'CHOAM', 'choam')); Object.assign(g, { @@ -440,16 +443,11 @@ void test('ordinary CHOAM market retains its existing end-of-Bidding decision wh }); void test('a scheduled post-normal Richese cache auction completes before the shared Kaitain window opens', () => { - let g = fixture(false, false); - // Add the explicitly staged Richese faction and complete physical cache to - // the audited core game; the full Richese expansion setup remains gated. - const r = newPlayer('r', 'Richese', 'richese'); - r.spice = 20; - g.players.push(r); - g.order = ['e', 'a', 'r']; - g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) }; - g.richeseCache = richeseCards(); - g.richeseRemoved = []; + let g = fixture(false, false, true); + // Richese and its separate physical cache originate in genuine setup before + // occupation-history initialization. Public expansion release remains gated. + assert.equal(g.richeseCache?.length, 10); + assert.deepEqual(g.richeseRemoved, []); const disposal = hold(g, 'e', (card) => card.kind === 'worthless'); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); assert.equal(g.decision?.kind, 'richeseDeclaration'); diff --git a/tests/bidding-end-recovery.test.ts b/tests/bidding-end-recovery.test.ts index e4610eb5..8f00fdcb 100644 --- a/tests/bidding-end-recovery.test.ts +++ b/tests/bidding-end-recovery.test.ts @@ -116,6 +116,10 @@ async function fixture(ecaz = false) { tokens.map((token) => store.rooms.authenticate(code, token)), ); let g = await store.rooms.readRoom(code); + // Offline lobby faction seam: public expansion joins remain gated, but final + // identities precede every genuine setup allocation and signed history. + g.players[2] = engine.newPlayer(g.players[2].id, 'CHOAM', 'choam'); + if (ecaz) g.players[3] = engine.newPlayer(g.players[3].id, 'Ecaz', 'ecaz'); g = engine.applyAction(g, g.host, { type: 'homeworlds', enabled: true }); for (const player of g.players) g = engine.applyAction(g, player.id, { type: 'ready' }); @@ -137,13 +141,11 @@ async function fixture(ecaz = false) { assert.equal(g.status, 'playing'); for (const player of g.players) g.deck.push(...player.hand.splice(0)); const ids = g.players.map((p) => p.id); - // Explicit expansion-faction seams after genuine base Homeworld setup. - // Retain every physical card/counter; no full CHOAM/Ecaz setup is certified. - g.players[2].faction = 'choam'; + // The final faction roster is established before genuine Homeworld setup. + // These later positions preserve its signed identities and physical counters. g.players[2].name = 'CHOAM'; if (ecaz) { Object.assign(g.players[3], { - faction: 'ecaz', name: 'Ecaz', reserves: 20, forces: {}, diff --git a/tests/fixture-caladan-ambassador.ts b/tests/fixture-caladan-ambassador.ts index c3e2d6ac..e4d10c3a 100644 --- a/tests/fixture-caladan-ambassador.ts +++ b/tests/fixture-caladan-ambassador.ts @@ -1,4 +1,3 @@ -import { newPlayer } from '../game/engine'; import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; import { caladanVictoryFixture, @@ -7,39 +6,31 @@ import { victoryPlayer as p, } from './fixture-caladan-victory'; -/** Real native setup and battle dispatch, with an explicit Ecaz/Harkonnen/BG - * scenario seam to exercise entry effects without fabricating victory receipts. */ -export function caladanAmbassadorFixture() { - const g = caladanVictoryFixture({ advanced: true }); - for (const [id, faction] of [ - ['ec', 'ecaz'], - ['h', 'harkonnen'], - ['bg', 'beneGesserit'], - ] as const) { - const player = newPlayer(id, faction, faction); - Object.assign(player, { - reserves: 20, - tanks: 0, - forces: {}, - hand: [], - traitors: [], - traitorChoices: [], - spice: 20, - }); - g.players.push(player); - } +/** Real final native/Ecaz/Harkonnen/BG setup and battle dispatch, with + * staged positions and Ambassador tokens to exercise entry effects without fabricating victory receipts. */ +export function caladanAmbassadorFixture(seatIds: Record = {}) { + const id = (key: string) => seatIds[key] ?? key; + const g = caladanVictoryFixture({ + advanced: true, + seatIds, + extraSeats: [ + { id: 'ec', faction: 'ecaz' }, + { id: 'h', faction: 'harkonnen' }, + { id: 'bg', faction: 'beneGesserit' }, + ], + }); g.expansions.push('ecaz'); g.order = g.players.map((player) => player.id); - for (const player of [p(g, 'a'), p(g, 'g')]) { + for (const player of [p(g, id('a')), p(g, id('g'))]) { player.forces['arrakeen:10'] = player.forces['hagga_basin:12']; delete player.forces['hagga_basin:12']; } - p(g, 'ec').ally = 'h'; - p(g, 'h').ally = 'ec'; - p(g, 'ec').forces = { 'red_chasm:7': 1 }; - p(g, 'ec').reserves = 19; - p(g, 'bg').forces = { 'sietch_tabr:14': 1 }; - p(g, 'bg').reserves = 19; + p(g, id('ec')).ally = id('h'); + p(g, id('h')).ally = id('ec'); + p(g, id('ec')).forces = { 'red_chasm:7': 1 }; + p(g, id('ec')).reserves = 19; + p(g, id('bg')).forces = { 'sietch_tabr:14': 1 }; + p(g, id('bg')).reserves = 19; const state = createAmbassadors(() => 0); const guild = state.tokens.find((token) => token.effect === 'guild')!; const fremen = state.tokens.find((token) => token.effect === 'fremen')!; @@ -78,7 +69,7 @@ export function caladanAmbassadorFixture() { allowed: true, }, }).state; - holdVictoryCard(g, 'ec', 'karama'); + holdVictoryCard(g, id('ec'), 'karama'); inventory(g); return g; } diff --git a/tests/fixture-caladan-victory.ts b/tests/fixture-caladan-victory.ts index 800bd8f7..1389ba64 100644 --- a/tests/fixture-caladan-victory.ts +++ b/tests/fixture-caladan-victory.ts @@ -10,6 +10,7 @@ import { type Game, } from '../game/engine'; import { botActions } from '../game/bots'; +import type { FactionId } from '../game/catalog'; import { baseDeck, ixDeck } from '../game/cards'; import { homeworldContext, @@ -24,17 +25,27 @@ export const victoryReload = (g: Game): Game => JSON.parse(JSON.stringify(g)); /** Genuine setup and physical cards; only the later phase and force positions * are staged. Optional Tleilaxu uses its actual Ix setup, not a faction swap. */ export function caladanVictoryFixture( - options: { advanced?: boolean; tleilaxu?: boolean; native?: number } = {}, + options: { + advanced?: boolean; + tleilaxu?: boolean; + native?: number; + seatIds?: Record; + extraSeats?: readonly { id: string; faction: FactionId }[]; + } = {}, ): Game { + const id = (key: string) => options.seatIds?.[key] ?? key; let g = createGame( 'CALADANVICTORY', - newPlayer('a', 'Atreides', 'atreides'), + newPlayer(id('a'), 'Atreides', 'atreides'), options.advanced ?? false, options.tleilaxu ? ['ix'] : [], ); - joinGame(g, newPlayer('g', 'Guild', 'guild')); - if (options.tleilaxu) joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu')); - g = applyAction(g, 'a', { type: 'homeworlds', enabled: true }); + joinGame(g, newPlayer(id('g'), 'Guild', 'guild')); + if (options.tleilaxu) joinGame(g, newPlayer(id('t'), 'Tleilaxu', 'tleilaxu')); + // Audit-only final roster is fixed before setup signs public history. + for (const extra of options.extraSeats ?? []) + g.players.push(newPlayer(id(extra.id), extra.faction, extra.faction)); + g = applyAction(g, id('a'), { type: 'homeworlds', enabled: true }); for (const player of g.players) g = applyAction(g, player.id, { type: 'ready' }); g = initializeHomeworldGameForAudit(g); @@ -64,13 +75,17 @@ export function caladanVictoryFixture( phaseOpening: null, response: null, decision: null, - active: 'a', + active: id('a'), ready: [], order: g.players.map((player) => player.id), }); - positionVictoryArmy(g, 'a', options.native ?? 6, 3); - positionVictoryArmy(g, 'g', 17, 3); - if (options.tleilaxu) positionVictoryArmy(g, 't', 20, 0); + positionVictoryArmy(g, id('a'), options.native ?? 6, 3); + positionVictoryArmy(g, id('g'), 17, 3); + if (options.tleilaxu) positionVictoryArmy(g, id('t'), 20, 0); + for (const extra of options.extraSeats ?? []) { + const player = victoryPlayer(g, id(extra.id)); + Object.assign(player, { reserves: 20, tanks: 0, forces: {} }); + } victoryInventory(g); return g; } diff --git a/tests/fixture-grumman-collection.ts b/tests/fixture-grumman-collection.ts index 00a5a921..7fa15eed 100644 --- a/tests/fixture-grumman-collection.ts +++ b/tests/fixture-grumman-collection.ts @@ -22,17 +22,22 @@ export const grummanReload = (g: Game): Game => JSON.parse(JSON.stringify(g)); * pre-Collection board position is staged; no signed phase/arrival is fabricated. * Runtime Moritani support here does not lift its public expansion-start gate. */ export function grummanCollectionFixture( - options: { native?: number; advanced?: boolean } = {}, + options: { + native?: number; + advanced?: boolean; + seatIds?: [string, string, string]; + } = {}, ): Game { + const [m, a, h] = options.seatIds ?? ['m', 'a', 'g']; let g = createGame( 'GRUMMANCOLLECTION', - newPlayer('m', 'Moritani', 'moritani'), + newPlayer(m, 'Moritani', 'moritani'), options.advanced ?? false, [], ); - joinGame(g, newPlayer('a', 'Atreides', 'atreides')); - joinGame(g, newPlayer('g', 'Harkonnen', 'harkonnen')); - g = applyAction(g, 'm', { type: 'homeworlds', enabled: true }); + joinGame(g, newPlayer(a, 'Atreides', 'atreides')); + joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen')); + g = applyAction(g, m, { type: 'homeworlds', enabled: true }); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); g = initializeHomeworldGameForAudit(g); for (let n = 0; g.status === 'setup' && n < 60; n++) { @@ -61,7 +66,7 @@ export function grummanCollectionFixture( moved: 0, }); } - const owner = grummanPlayer(g, 'm'); + const owner = grummanPlayer(g, m); owner.reserves = options.native ?? 8; owner.forces = { 'polar_sink:0': 20 - owner.reserves }; assert.ok(g.moritaniTerror); @@ -72,9 +77,9 @@ export function grummanCollectionFixture( Object.assign(g, { phase: 5, turn: 2, - active: 'a', - movementRemaining: ['a'], - order: ['a', 'm', 'g'], + active: a, + movementRemaining: [a], + order: [a, m, h], ready: [], phaseOpening: null, response: null, diff --git a/tests/fixture-homeworld-revival-ambassador.ts b/tests/fixture-homeworld-revival-ambassador.ts index ff69e9bf..17df6cf9 100644 --- a/tests/fixture-homeworld-revival-ambassador.ts +++ b/tests/fixture-homeworld-revival-ambassador.ts @@ -1,4 +1,3 @@ -import { newPlayer } from '../game/engine'; import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; import { homeworldRevivalFixture, @@ -8,35 +7,30 @@ import { revivalInventory as inventory, } from './fixture-homeworld-revival'; -/** Genuine native revival fixture plus an explicit Ecaz/BG faction seam: no +/** Genuine final native/Ecaz/BG revival roster with staged positions: no * fabricated revival, Ambassador, response or continuation receipt. */ -export function homeworldRevivalAmbassadorFixture() { - const g = homeworldRevivalFixture({ advanced: true, tleilaxu: true }); - for (const [id, faction] of [ - ['ec', 'ecaz'], - ['bg', 'beneGesserit'], - ['a', 'atreides'], - ] as const) { - const player = newPlayer(id, faction, faction); - Object.assign(player, { - reserves: 20, - forces: {}, - tanks: 0, - spice: 20, - hand: [], - traitors: [], - traitorChoices: [], - }); - g.players.push(player); - } +export function homeworldRevivalAmbassadorFixture( + seatIds: Record = {}, +) { + const id = (key: string) => seatIds[key] ?? key; + const g = homeworldRevivalFixture({ + advanced: true, + tleilaxu: true, + seatIds, + extraSeats: [ + { id: 'ec', faction: 'ecaz' }, + { id: 'bg', faction: 'beneGesserit' }, + { id: 'a', faction: 'atreides' }, + ], + }); g.expansions.push('ecaz'); g.order = g.players.map((player) => player.id); - p(g, 'ec').ally = 'a'; - p(g, 'a').ally = 'ec'; - p(g, 'bg').forces = { 'sietch_tabr:14': 1 }; - p(g, 'bg').reserves = 19; - p(g, 'ec').forces = { 'red_chasm:7': 1 }; - p(g, 'ec').reserves = 19; + p(g, id('ec')).ally = id('a'); + p(g, id('a')).ally = id('ec'); + p(g, id('bg')).forces = { 'sietch_tabr:14': 1 }; + p(g, id('bg')).reserves = 19; + p(g, id('ec')).forces = { 'red_chasm:7': 1 }; + p(g, id('ec')).reserves = 19; const state = createAmbassadors(() => 0); const first = state.tokens.find((token) => token.effect === 'guild')!; const second = state.tokens.find((token) => token.effect === 'fremen')!; @@ -77,6 +71,6 @@ export function homeworldRevivalAmbassadorFixture() { }).state; inventory(g); const entered = enterHomeworldRevival(g); - holdRevivalCard(entered, 'ec', 'karama'); + holdRevivalCard(entered, id('ec'), 'karama'); return entered; } diff --git a/tests/fixture-homeworld-revival.ts b/tests/fixture-homeworld-revival.ts index a6db3785..0c19c67b 100644 --- a/tests/fixture-homeworld-revival.ts +++ b/tests/fixture-homeworld-revival.ts @@ -10,6 +10,7 @@ import { type Game, } from '../game/engine'; import { botActions } from '../game/bots'; +import type { FactionId } from '../game/catalog'; import { baseDeck, ixDeck } from '../game/cards'; import { homeworldGameIntegrity } from '../game/homeworld-game'; @@ -24,17 +25,23 @@ export function homeworldRevivalFixture( options: { advanced?: boolean; tleilaxu?: boolean; + seatIds?: Record; + extraSeats?: readonly { id: string; faction: FactionId }[]; } = {}, ): Game { + const id = (key: string) => options.seatIds?.[key] ?? key; let g = createGame( 'HOMEREVIVAL', - newPlayer('f', 'Fremen', 'fremen'), + newPlayer(id('f'), 'Fremen', 'fremen'), options.advanced ?? false, options.tleilaxu ? ['ix'] : [], ); - joinGame(g, newPlayer('e', 'Emperor', 'emperor')); - if (options.tleilaxu) joinGame(g, newPlayer('t', 'Tleilaxu', 'tleilaxu')); - g = applyAction(g, 'f', { type: 'homeworlds', enabled: true }); + joinGame(g, newPlayer(id('e'), 'Emperor', 'emperor')); + if (options.tleilaxu) joinGame(g, newPlayer(id('t'), 'Tleilaxu', 'tleilaxu')); + // Audit-only final roster is fixed before setup signs public history. + for (const extra of options.extraSeats ?? []) + g.players.push(newPlayer(id(extra.id), extra.faction, extra.faction)); + g = applyAction(g, id('f'), { type: 'homeworlds', enabled: true }); for (const player of g.players) g = applyAction(g, player.id, { type: 'ready' }); g = initializeHomeworldGameForAudit(g); @@ -68,8 +75,13 @@ export function homeworldRevivalFixture( response: null, decision: null, }); - positionRevivalForces(g, 'f', { native: 3, tanks: 5, eliteTanks: 2 }); - if (options.tleilaxu) positionRevivalForces(g, 't', { native: 9, tanks: 5 }); + positionRevivalForces(g, id('f'), { native: 3, tanks: 5, eliteTanks: 2 }); + if (options.tleilaxu) + positionRevivalForces(g, id('t'), { native: 9, tanks: 5 }); + for (const extra of options.extraSeats ?? []) { + const player = revivalPlayer(g, id(extra.id)); + Object.assign(player, { reserves: 20, tanks: 0, forces: {} }); + } revivalInventory(g); return g; } diff --git a/tests/fixture-tupile-intelligence.ts b/tests/fixture-tupile-intelligence.ts new file mode 100644 index 00000000..e75a36ca --- /dev/null +++ b/tests/fixture-tupile-intelligence.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeHomeworldGameForAudit, joinGame, + newPlayer, viewGame, type Action, type Game, +} from '../game/engine'; +import { baseDeck, type Card } from '../game/cards'; +import { botActions } from '../game/bots'; +import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; +import { quoteHomeworldCustody } from '../game/homeworld-custody'; + +export const tupilePlayer = (g: Game, id: string) => g.players.find((p) => p.id === id)!; +export const tupileReload = (g: Game): Game => JSON.parse(JSON.stringify(g)); + +/** Real faction setup creates both signed ledgers using the final authenticated + * seat IDs. Only conserved armies and a settled movement opportunity are staged; + * the default contact then comes from an actual paid Homeworld shipment. */ +export function tupileIntelligenceFixture(options: { + seatIds?: [string, string, string]; contact?: boolean; advanced?: boolean; +} = {}): Game { + const [c, a, h] = options.seatIds ?? ['c', 'a', 'h']; + let g = createGame('TUPILEINTELLIGENCE', newPlayer(c, 'CHOAM', 'choam'), options.advanced ?? false, []); + joinGame(g, newPlayer(a, 'Atreides', 'atreides')); + joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen')); + g = applyAction(g, c, { type: 'homeworlds', enabled: true }); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + g = initializeHomeworldGameForAudit(g); + for (let step = 0; g.status === 'setup' && step < 60; step++) { + let next: Game | undefined; + for (const p of g.players) { + const view = viewGame(g, p.id); + view.players.find((seat) => seat.id === p.id)!.bot = 'Easy'; + const action = botActions(view)[0]; + if (action) { next = applyAction(g, p.id, action); break; } + } + assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`); + g = next; + } + assert.equal(g.status, 'playing'); + assert.equal(g.homeworlds?.historyVersion, 1); + assert.equal(g.tupileIntelligence?.owner, c); + assert.deepEqual(g.homeworldOccupationHistory?.qualifications, []); + for (const p of g.players) { + g.deck.push(...p.hand.splice(0)); + Object.assign(p, { spice: 20, forces: {}, reserves: 20, tanks: 0, shipped: false, moved: 0 }); + } + Object.assign(tupilePlayer(g, c), { reserves: 10, forces: { 'polar_sink:0': 10 } }); + Object.assign(g, { + phase: 5, turn: 2, active: c, order: [c, a, h], movementRemaining: [c, a, h], + ready: [], phaseOpening: null, response: null, decision: null, storm: 18, spice: {}, + }); + if (options.contact !== false) g = enterTupileContact(g); + tupileInventory(g); + return g; +} + +export function enterTupileContact(g: Game): Game { + const owner = g.players.find((p) => p.faction === 'choam')!; + return applyAction(g, owner.id, { + type: 'homeworldShip', event: viewGame(g, owner.id).homeworldShipment!.event, + destination: 'homeworld:atreides', sources: { 'homeworld:choam': { normal: 1, elite: 0 } }, + }); +} + +/** Conserved leave/reentry scenarios exercise current contact without forging + * occupation or intelligence receipts. Both worlds retain native garrisons. */ +export function positionTupileContact(g: Game, present: boolean): void { + const owner = g.players.find((p) => p.faction === 'choam')!; + const one = { normal: 1, elite: 0 }, zero = { normal: 0, elite: 0 }; + const quote = quoteHomeworldCustody(homeworldContext(g), g.homeworlds!.custody!, [ + { homeworld: 'homeworld:choam', player: owner.id, withdraw: present ? one : zero, deposit: present ? zero : one }, + { homeworld: 'homeworld:atreides', player: owner.id, withdraw: present ? zero : one, deposit: present ? one : zero }, + ]); + g.homeworlds!.custody = quote.state; + for (const p of quote.players) tupilePlayer(g, p.id).reserves = p.reserves; + tupileInventory(g); +} + +export function holdTupileCard(g: Game, id: string, kind: string): Card { + const index = g.deck.findIndex((card) => card.id === kind || card.kind === kind || card.effect === kind); + assert.ok(index >= 0, `Missing canonical card ${kind}`); + const card = g.deck.splice(index, 1)[0]; + tupilePlayer(g, id).hand.push(card); + return card; +} +export function tupileReject(g: Game, id: string, action: Action, match: RegExp = /./): void { + const before = tupileReload(g); + assert.throws(() => applyAction(g, id, action), match); + assert.deepEqual(g, before); +} +export function tupileInventory(g: Game): void { + homeworldGameIntegrity(g); + for (const p of g.players) { + const visitors = Object.values(g.homeworlds!.custody!.visitors) + .reduce((sum, world) => sum + (world[p.id]?.normal ?? 0) + (world[p.id]?.elite ?? 0), 0); + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0) + visitors, 20); + } + assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)].map((c) => c.id).sort(), baseDeck().map((c) => c.id).sort()); +} diff --git a/tests/grumman-collection-recovery.test.ts b/tests/grumman-collection-recovery.test.ts index 1e607535..7362c3e3 100644 --- a/tests/grumman-collection-recovery.test.ts +++ b/tests/grumman-collection-recovery.test.ts @@ -109,10 +109,8 @@ async function persisted() { } const auths = await Promise.all(credentials.map((value) => store.rooms.authenticate(code, value))); const initial = await store.rooms.readRoom(code); - let encoded = JSON.stringify(grummanCollectionFixture()); - for (const [index, id] of ['m', 'a', 'g'].entries()) - encoded = encoded.replaceAll(JSON.stringify(id), JSON.stringify(auths[index].playerId)); - const g: Game = JSON.parse(encoded); + // Setup signs the final authenticated identities, including occupation history. + const g = grummanCollectionFixture({ seatIds: auths.map((auth) => auth.playerId) as [string, string, string] }); g.code = code; g.host = auths[0].playerId; g.version = initial.version; diff --git a/tests/homeworld-basic-counters.test.ts b/tests/homeworld-basic-counters.test.ts index 194af59c..caea6c01 100644 --- a/tests/homeworld-basic-counters.test.ts +++ b/tests/homeworld-basic-counters.test.ts @@ -1,3 +1,5 @@ +import { observeHomeworldOccupation } from '../game/homeworld-occupation-history'; +import { homeworldContext } from '../game/homeworld-game'; import test from 'node:test'; import assert from 'node:assert/strict'; import { @@ -70,6 +72,13 @@ function setup(faction: 'emperor' | 'fremen', homeworlds = true) { }); return g; } +/** Record the explicit conserved fixture position before checking read/reload purity. */ +function recordPosition(g: Game) { + if (g.homeworldOccupationHistory) + g.homeworldOccupationHistory = observeHomeworldOccupation(g.homeworldOccupationHistory, + homeworldContext(g), g.homeworlds!.custody!, g.turn, 'change', + `fixture-position-${g.homeworldOccupationHistory.sources.length}`); +} function inventory(g: Game) { for (const p of g.players) { assert.equal(p.reserves + p.tanks + sum(p.forces), 20); @@ -106,6 +115,7 @@ function battle(faction: 'emperor' | 'fremen', homeworlds = true) { player(g).elites!.forces['arrakeen:10'] = 1; } Object.assign(player(g, 'q'), { reserves: 19, forces: { 'arrakeen:10': 1 } }); + recordPosition(g); inventory(g); g = applyAction(g, 'p', { type: 'chooseBattle', @@ -155,6 +165,7 @@ function tanks(faction: 'emperor' | 'fremen', homeworlds = true) { p.elites.reserves -= 3; p.elites.tanks = 3; } + recordPosition(g); inventory(g); return g; } diff --git a/tests/homeworld-battle-engine.test.ts b/tests/homeworld-battle-engine.test.ts index 83268271..6e6bedb3 100644 --- a/tests/homeworld-battle-engine.test.ts +++ b/tests/homeworld-battle-engine.test.ts @@ -297,6 +297,105 @@ void test('two foreign armies at an empty native home resolve automatically afte assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g)); }); +void test('ordered loser elimination records a sole foreign qualifier before the Basic winner loses its entire dialed army', () => { + let g = setup(['atreides', 'guild', 'harkonnen']); + assert.equal(g.homeworlds!.historyVersion, 1); + assert.ok(g.homeworldOccupationHistory); + const world = 'homeworld:harkonnen'; + invade(g, 'atreides', world, 3); + invade(g, 'guild', world, 3); + seat(g, 'harkonnen').tanks += seat(g, 'harkonnen').reserves; + seat(g, 'harkonnen').reserves = 0; + g = prepared(g, 'atreides', 'guild', world); + assert.equal( + g.homeworldOccupationHistory!.qualifications.filter( + (fact) => fact.world === world, + ).length, + 0, + ); + g = commit(g, 'atreides', 3); + g = commit(reload(g), 'guild'); + assert.equal(g.lastBattleContext!.winner, 'atreides'); + assert.equal(seat(g, 'atreides').tanks, 3); + assert.equal(seat(g, 'guild').tanks, 3); + assert.equal(g.homeworlds!.custody!.visitors[world], undefined); + const history = g.homeworldOccupationHistory!; + const qualifications = history.qualifications.filter( + (fact) => fact.world === world, + ); + assert.equal(qualifications.length, 1); + assert.equal(qualifications[0].player, 'atreides'); + assert.equal(qualifications[0].cause, 'sole'); + const source = history.sources.find( + (entry) => entry.event === qualifications[0].event, + )!; + const snapshot = JSON.parse(history.snapshots[source.snapshot]) as [ + string, + [string, number, number][], + ][]; + const armies = snapshot.find(([location]) => location === world)![1]; + assert.deepEqual( + armies.filter(([, normal, elite]) => normal + elite > 0), + [['atreides', 3, 0]], + 'The original qualification observes the winner before its own dialed losses.', + ); + inventory(g); + const saved = reload(g); + for (let attempt = 0; attempt < 3; attempt++) + g = normalizeAutomaticGame(reload(g)); + assert.deepEqual(reload(g), saved); + assert.deepEqual( + g.homeworldOccupationHistory, + saved.homeworldOccupationHistory, + ); +}); + +void test('simultaneous Lasgun Shield destruction of two invaders at an empty native Homeworld never invents a sole qualifier', () => { + let g = setup(['atreides', 'guild', 'harkonnen']); + const world = 'homeworld:harkonnen'; + invade(g, 'atreides', world, 3); + invade(g, 'guild', world, 3); + seat(g, 'harkonnen').tanks += seat(g, 'harkonnen').reserves; + seat(g, 'harkonnen').reserves = 0; + const laser = hold(g, 'atreides', 'lasgun'); + const shield = hold(g, 'guild', 'shield'); + g = prepared(g, 'atreides', 'guild', world); + assert.deepEqual(viewGame(g, 'atreides').battle!.traitorVoters, []); + assert.equal( + g.homeworldOccupationHistory!.qualifications.filter( + (fact) => fact.world === world, + ).length, + 0, + ); + g = commit(g, 'atreides', 0, { weapon: laser }); + g = commit(reload(g), 'guild', 0, { defense: shield }); + assert.equal(g.lastBattleContext!.winner, null); + assert.equal(g.battle, null); + assert.equal(seat(g, 'atreides').tanks, 3); + assert.equal(seat(g, 'guild').tanks, 3); + assert.equal(g.homeworlds!.custody!.visitors[world], undefined); + assert.equal( + g.homeworldOccupationHistory!.qualifications.filter( + (fact) => fact.world === world, + ).length, + 0, + ); + for (const raw of g.homeworldOccupationHistory!.snapshots) { + const snapshot = JSON.parse(raw) as [string, [string, number, number][]][]; + const present = snapshot + .find(([location]) => location === world)![1] + .filter(([, normal, elite]) => normal + elite > 0); + assert.ok( + present.length !== 1 || present[0][0] === 'harkonnen', + 'No sequential half-explosion snapshot may show an invented sole invader.', + ); + } + for (const id of [laser, shield]) + assert.equal(g.discard.filter((card) => card.id === id).length, 1); + inventory(g); + assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g)); +}); + void test('real Lasgun Shield explosion preserves mixed native reserves and offers the printed-strength typed loss choice after mandatory discard', () => { let g = setup(['atreides', 'emperor']); invade(g, 'atreides', 'homeworld:emperor', 3); @@ -355,6 +454,28 @@ void test('real Lasgun Shield explosion preserves mixed native reserves and offe for (const card of [laser, shield]) assert.equal(done.discard.filter((held) => held.id === card).length, 1); inventory(done); + const history = done.homeworldOccupationHistory!; + assert.ok(history); + assert.equal( + history.qualifications.filter( + (fact) => fact.world === 'homeworld:emperor', + ).length, + 0, + ); + const source = history.sources.at(-1)!; + const snapshot = JSON.parse(history.snapshots[source.snapshot]) as [ + string, + [string, number, number][], + ][]; + const native = snapshot + .find(([world]) => world === 'homeworld:emperor')![1] + .find(([owner]) => owner === 'emperor')!; + assert.equal(native[1] + native[2], originalNative - 2); + assert.equal(native[2], seat(done, 'emperor').elites!.reserves); + assert.deepEqual( + reload(normalizeAutomaticGame(reload(done))), + reload(done), + ); reject(done, 'emperor', action); } }); diff --git a/tests/homeworld-benefits-recovery.test.ts b/tests/homeworld-benefits-recovery.test.ts index 308362c3..cbc9228d 100644 --- a/tests/homeworld-benefits-recovery.test.ts +++ b/tests/homeworld-benefits-recovery.test.ts @@ -112,6 +112,9 @@ async function fixture(revival = false, advanced = !revival) { tokens.map((token) => store.rooms.authenticate(code, token)), ); let g = await store.rooms.readRoom(code); + // Public expansion joins stay gated; establish the offline payer faction in + // the untouched lobby before genuine setup creates its signed history. + if (!revival) g.players[2] = engine.newPlayer(g.players[2].id, 'CHOAM', 'choam'); g = engine.applyAction(g, g.host, { type: 'homeworlds', enabled: true }); for (const player of g.players) g = engine.applyAction(g, player.id, { type: 'ready' }); @@ -183,9 +186,7 @@ function position(g: engine.Game, index: number, reserves: number, tanks = 0) { function charity(f: Fixture) { let g = f.initial; position(g, 1, 10); - // Explicit payer seam after genuine base setup; this is not CHOAM setup or - // deck certification. All original physical forces and cards are retained. - g.players[2].faction = 'choam'; + // CHOAM's identity was fixed before setup; only its current balance is staged. g.players[2].spice = 12; g.players[1].spice = 9; g.choamCharity = { turn: g.turn, canceled: false }; diff --git a/tests/homeworld-charity-engine.test.ts b/tests/homeworld-charity-engine.test.ts index 69013944..9c6577e5 100644 --- a/tests/homeworld-charity-engine.test.ts +++ b/tests/homeworld-charity-engine.test.ts @@ -22,10 +22,13 @@ function setup(advanced = true, ix = false) { 'HOMECHARITYENGINE', newPlayer('a', 'Atreides', 'atreides'), advanced, - ix ? ['ix'] : [], + ix ? ['ix', 'choam'] : ['choam'], ); joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit')); - joinGame(g, newPlayer('c', 'Guild', 'guild')); + joinGame(g, newPlayer('c', 'CHOAM', 'choam')); + // Audit CHOAM's faction with only the implemented physical base/Ix deck. + // The lobby roster is final before any setup pieces or history are created. + g.expansions = ix ? ['ix'] : []; g = applyAction(g, 'a', { type: 'homeworlds', enabled: true }); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); g = initializeHomeworldGameForAudit(g); @@ -64,12 +67,10 @@ function low(g: Game, id: string, reserves: number) { homeworldGameIntegrity(g); } function choam(g: Game) { - // Explicit unsupported-faction component seam AFTER genuine base/ix setup. - // No CHOAM deck/setup completeness is claimed; preserve all original counters - // and card custody, change only the payer faction needed by this contract. + // Faction identity and both histories originate in genuine setup. Stage only + // the phase's existing payer balance/settlement, preserving physical custody. const p = seat(g, 'c'); - p.faction = 'choam'; - p.name = 'CHOAM'; + assert.equal(p.faction, 'choam'); p.spice = 12; g.choamCharity = { turn: g.turn, canceled: false }; homeworldGameIntegrity(g); diff --git a/tests/homeworld-collection-engine.test.ts b/tests/homeworld-collection-engine.test.ts index 4e228681..f57cb867 100644 --- a/tests/homeworld-collection-engine.test.ts +++ b/tests/homeworld-collection-engine.test.ts @@ -12,7 +12,7 @@ import { type Action, } from '../game/engine'; import { botActions } from '../game/bots'; -import { baseDeck, leaders } from '../game/cards'; +import { baseDeck } from '../game/cards'; import { homeworldGameIntegrity } from '../game/homeworld-game'; import { createTechTokens } from '../game/tech-tokens'; @@ -36,6 +36,8 @@ function fixture(options: Options = {}) { ); joinGame(g, newPlayer('g', 'Guild', 'guild')); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); + // Select the audit-only final faction before real setup signs its roster. + if (options.shared) g.players[1] = newPlayer('g', 'Ecaz', 'ecaz'); g = applyAction(g, 'h', { type: 'homeworlds', enabled: true }); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); g = initializeHomeworldGameForAudit(g); @@ -75,11 +77,9 @@ function fixture(options: Options = {}) { hark.forces['imperial_basin:10'] = 20 - hark.reserves - Object.values(hark.forces).reduce((a, b) => a + b, 0); if (options.shared) { - // Explicit Ecaz faction seam after genuine base Homeworld setup; the shared - // collection and all negotiated allocations still use production actions. + // The final Ecaz roster passed through genuine Homeworld setup; only + // the conserved collection positions and alliance are staged here. const ecaz = own(g, 'g'); - ecaz.faction = 'ecaz'; - ecaz.leaders = leaders('ecaz'); ecaz.forces = { 'wind_pass:14': 2, ...(options.multiple ? { 'hagga_basin:12': 2 } : {}), diff --git a/tests/homeworld-collection-recovery.test.ts b/tests/homeworld-collection-recovery.test.ts index 83d6bae9..8bb045ef 100644 --- a/tests/homeworld-collection-recovery.test.ts +++ b/tests/homeworld-collection-recovery.test.ts @@ -89,7 +89,7 @@ function unitStore(runBots = bots.runBots) { return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes }; } -import { baseDeck, leaders } from '../game/cards'; +import { baseDeck } from '../game/cards'; import { createTechTokens } from '../game/tech-tokens'; const { applyAction, @@ -112,17 +112,22 @@ type Options = { stronghold?: boolean; empty?: boolean; technology?: boolean; + seatIds?: [string, string, string]; }; function fixture(options: Options = {}) { + const [h, partner, a] = options.seatIds ?? ['h', 'g', 'a']; let g = createGame( 'GIEDICOLLECTION', - newPlayer('h', 'Harkonnen', 'harkonnen'), + newPlayer(h, 'Harkonnen', 'harkonnen'), options.advanced ?? true, [], ); - joinGame(g, newPlayer('g', 'Guild', 'guild')); - joinGame(g, newPlayer('a', 'Atreides', 'atreides')); - g = applyAction(g, 'h', { type: 'homeworlds', enabled: true }); + joinGame(g, newPlayer(partner, 'Guild', 'guild')); + joinGame(g, newPlayer(a, 'Atreides', 'atreides')); + // Only the offline lobby faction is selected here; no initialized history is + // altered and public expansion joins retain their existing gates. + if (options.shared) g.players[1] = newPlayer(partner, 'Ecaz', 'ecaz'); + g = applyAction(g, h, { type: 'homeworlds', enabled: true }); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); g = initializeHomeworldGameForAudit(g); for (let n = 0; g.status === 'setup' && n < 40; n++) { @@ -151,7 +156,7 @@ function fixture(options: Options = {}) { moved: 0, }); } - const hark = own(g, 'h'); + const hark = own(g, h); hark.forces = { 'wind_pass:14': 2, ...(options.multiple ? { 'hagga_basin:12': 2 } : {}), @@ -161,11 +166,9 @@ function fixture(options: Options = {}) { hark.forces['imperial_basin:10'] = 20 - hark.reserves - Object.values(hark.forces).reduce((a, b) => a + b, 0); if (options.shared) { - // Explicit Ecaz faction seam after genuine base Homeworld setup; the shared - // collection and all negotiated allocations still use production actions. - const ecaz = own(g, 'g'); - ecaz.faction = 'ecaz'; - ecaz.leaders = leaders('ecaz'); + // Ecaz was seated before setup. The later shared position and allocations + // preserve that original signed faction identity. + const ecaz = own(g, partner); ecaz.forces = { 'wind_pass:14': 2, ...(options.multiple ? { 'hagga_basin:12': 2 } : {}), @@ -179,9 +182,9 @@ function fixture(options: Options = {}) { Object.assign(g, { phase: 5, turn: 2, - active: 'a', - movementRemaining: ['a'], - order: ['g', 'h', 'a'], + active: a, + movementRemaining: [a], + order: [partner, h, a], ready: [], phaseOpening: null, response: null, @@ -196,7 +199,7 @@ function fixture(options: Options = {}) { }); if (options.technology) { g.techTokens = createTechTokens(g.players); - g.techTokens.heighliners = { owner: 'h', spice: 2 }; + g.techTokens.heighliners = { owner: h, spice: 2 }; } inventory(g); return g; @@ -239,16 +242,8 @@ async function persisted(options: Options = {}, collection = false) { tokens.map((token) => store.rooms.authenticate(code, token)), ); const initial = await store.rooms.readRoom(code); - const prepared = fixture(options); - // Preserve the production room credentials while relocating the audited - // fixture's three identities consistently through its JSON keys and values. - let encoded = JSON.stringify(prepared); - for (const [index, id] of ['h', 'g', 'a'].entries()) - encoded = encoded.replaceAll( - JSON.stringify(id), - JSON.stringify(auths[index].playerId), - ); - let g: Game = JSON.parse(encoded); + // Bind session identities before genuine setup produces signed history. + let g = fixture({ ...options, seatIds: auths.map((auth) => auth.playerId) as [string, string, string] }); g.code = code; g.host = auths[0].playerId; g.version = initial.version; diff --git a/tests/homeworld-occupation-history.test.ts b/tests/homeworld-occupation-history.test.ts new file mode 100644 index 00000000..0c09c2be --- /dev/null +++ b/tests/homeworld-occupation-history.test.ts @@ -0,0 +1,450 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createHomeworldCustody, + type HomeworldCustodyContext, +} from '../game/homeworld-custody'; +import { + createHomeworldOccupationHistory, + observeHomeworldOccupation, + validateHomeworldOccupationHistory, + tupileOccupationStatus, + type HomeworldOccupationHistory, +} from '../game/homeworld-occupation-history'; + +function fixture(advanced = false) { + const context: HomeworldCustodyContext = { + advanced, + players: [ + { id: 'c', faction: 'choam', reserves: 10, eliteReserves: 0 }, + { id: 'a', faction: 'atreides', reserves: 10, eliteReserves: 0 }, + { id: 'h', faction: 'harkonnen', reserves: 10, eliteReserves: 0 }, + { id: 'e', faction: 'emperor', reserves: 10, eliteReserves: 5 }, + ], + }; + const custody = createHomeworldCustody(context); + const history = createHomeworldOccupationHistory( + context, + custody, + 1, + 'real-setup', + ); + return { context, custody, history }; +} +const reload = ( + history: HomeworldOccupationHistory, +): HomeworldOccupationHistory => JSON.parse(JSON.stringify(history)); +function unchangedThrow(state: T, work: () => unknown) { + const before = structuredClone(state); + assert.throws(work, /occupation|Homeworld|forces|custody|reserves/i); + assert.deepEqual(state, before); +} + +void test('only proven visitor-free setup establishes unoccupied Tupile; legacy unknown is never upgraded from current armies', () => { + const { context, custody, history } = fixture(); + const before = structuredClone({ context, custody, history }); + assert.equal(tupileOccupationStatus(undefined, context, 1), 'unknown'); + assert.equal(tupileOccupationStatus(history, context, 1), 'unoccupied'); + assert.deepEqual(history.qualifications, []); + let current = history; + for (let n = 0; n < 30; n++) + current = observeHomeworldOccupation( + current, + context, + custody, + 1, + 'change', + `idle-wrapper-${n}`, + ); + assert.deepEqual(current, history); + assert.notEqual(current, history); + current.sources[0].event = 'mutated-return-value'; + assert.deepEqual({ context, custody, history }, before); + custody.visitors['homeworld:choam'] = { a: { normal: 1, elite: 0 } }; + unchangedThrow(custody, () => + createHomeworldOccupationHistory(context, custody, 1, 'forged-setup'), + ); + custody.visitors['homeworld:choam'] = {}; + unchangedThrow(custody, () => + createHomeworldOccupationHistory( + context, + custody, + 1, + 'empty-visitor-record', + ), + ); + unchangedThrow(history, () => + tupileOccupationStatus( + null as unknown as HomeworldOccupationHistory, + context, + 1, + ), + ); +}); + +void test('mere invasion alongside natives does not qualify, but turn boundaries record every foreign faction without choosing a controller', () => { + const { context, custody, history } = fixture(); + custody.visitors['homeworld:choam'] = { + a: { normal: 2, elite: 0 }, + h: { normal: 1, elite: 0 }, + }; + const invaded = observeHomeworldOccupation( + history, + context, + custody, + 1, + 'change', + 'shared-arrival', + ); + assert.deepEqual(invaded.qualifications, []); + assert.equal(tupileOccupationStatus(invaded, context, 1), 'unoccupied'); + const end = observeHomeworldOccupation( + invaded, + context, + custody, + 1, + 'turnEnd', + 'turn-1-end', + ); + assert.deepEqual( + end.qualifications.map(({ world, player, turn, cause }) => ({ + world, + player, + turn, + cause, + })), + [ + { world: 'homeworld:choam', player: 'a', turn: 1, cause: 'turnEnd' }, + { world: 'homeworld:choam', player: 'h', turn: 1, cause: 'turnEnd' }, + ], + ); + const start = observeHomeworldOccupation( + end, + context, + custody, + 2, + 'turnStart', + 'turn-2-start', + ); + assert.equal(start.qualifications.length, 4); + assert.deepEqual( + start.qualifications + .slice(2) + .map((fact) => [fact.player, fact.turn, fact.cause]), + [ + ['a', 2, 'turnStart'], + ['h', 2, 'turnStart'], + ], + ); + assert.equal(tupileOccupationStatus(start, context, 2), 'unknown'); + assert.equal( + start.snapshots.length, + 2, + 'unchanged boundary physical snapshot is shared', + ); + assert.equal(start.sources.length, 4); + assert.equal(Object.hasOwn(start, 'controller'), false); + assert.equal(Object.hasOwn(start, 'income'), false); + validateHomeworldOccupationHistory(reload(start), context, 2); +}); + +void test('separately committed loser elimination records the transient sole foreign winner even when winner casualties immediately empty Tupile', () => { + const { context, custody, history } = fixture(); + custody.visitors['homeworld:choam'] = { a: { normal: 1, elite: 0 } }; + const invaded = observeHomeworldOccupation( + history, + context, + custody, + 1, + 'change', + 'invasion', + ); + const native = context.players.find((p) => p.id === 'c')!; + native.reserves = 0; + const winnerAlone = observeHomeworldOccupation( + invaded, + context, + custody, + 1, + 'change', + 'battle-loser-removed', + ); + assert.deepEqual(winnerAlone.qualifications, [ + { + event: 'battle-loser-removed', + world: 'homeworld:choam', + player: 'a', + faction: 'atreides', + turn: 1, + cause: 'sole', + }, + ]); + delete custody.visitors['homeworld:choam']; + const winnerLost = observeHomeworldOccupation( + reload(winnerAlone), + context, + custody, + 1, + 'change', + 'battle-winner-casualties', + ); + assert.deepEqual(winnerLost.qualifications, winnerAlone.qualifications); + assert.equal(tupileOccupationStatus(winnerLost, context, 1), 'unknown'); + native.reserves = 10; + const restoredNative = observeHomeworldOccupation( + winnerLost, + context, + custody, + 1, + 'change', + 'native-revival', + ); + const nextTurn = observeHomeworldOccupation( + restoredNative, + context, + custody, + 2, + 'turnStart', + 'next-turn', + ); + assert.equal( + tupileOccupationStatus(nextTurn, context, 2), + 'unknown', + 'no inferred occupation expiry', + ); + assert.equal(nextTurn.qualifications.length, 1); + validateHomeworldOccupationHistory(nextTurn, context, 2); +}); + +void test('one simultaneous casualty group creates no intermediate qualifier; typed armies at Kaitain and Salusa are separate worlds', () => { + const { context, custody, history } = fixture(true); + custody.visitors['homeworld:choam'] = { a: { normal: 1, elite: 0 } }; + const invaded = observeHomeworldOccupation( + history, + context, + custody, + 1, + 'change', + 'arrive', + ); + context.players.find((p) => p.id === 'c')!.reserves = 0; + delete custody.visitors['homeworld:choam']; + const destroyed = observeHomeworldOccupation( + invaded, + context, + custody, + 1, + 'change', + 'simultaneous-explosion', + ); + assert.deepEqual(destroyed.qualifications, []); + assert.equal(tupileOccupationStatus(destroyed, context, 1), 'unoccupied'); + context.players.find((p) => p.id === 'e')!.reserves = 5; + custody.visitors['homeworld:emperor'] = { a: { normal: 1, elite: 0 } }; + custody.visitors['homeworld:emperor:salusa'] = { h: { normal: 2, elite: 0 } }; + const kaitain = observeHomeworldOccupation( + destroyed, + context, + custody, + 1, + 'change', + 'separate-imperial-worlds', + ); + assert.deepEqual( + kaitain.qualifications.map((q) => [q.world, q.player, q.cause]), + [['homeworld:emperor', 'a', 'sole']], + ); + const boundary = observeHomeworldOccupation( + kaitain, + context, + custody, + 1, + 'turnEnd', + 'imperial-end', + ); + assert.deepEqual( + boundary.qualifications.slice(1).map((q) => [q.world, q.player, q.cause]), + [ + ['homeworld:emperor', 'a', 'turnEnd'], + ['homeworld:emperor:salusa', 'h', 'turnEnd'], + ], + ); + assert.equal( + tupileOccupationStatus(boundary, context, 1), + 'unoccupied', + 'qualification elsewhere cannot remove CHOAM low advantage', + ); + validateHomeworldOccupationHistory(boundary, context, 1); +}); + +void test('source events bind original turn, cause and exact public physical counts before replay or unchanged-snapshot suppression', () => { + const { context, custody, history } = fixture(); + context.players.find((p) => p.id === 'c')!.reserves = 0; + custody.visitors['homeworld:choam'] = { a: { normal: 1, elite: 0 } }; + const first = observeHomeworldOccupation( + history, + context, + custody, + 1, + 'change', + 'sole-entry', + ); + const replay = observeHomeworldOccupation( + reload(first), + context, + custody, + 1, + 'change', + 'sole-entry', + ); + assert.deepEqual(replay, first); + assert.notEqual(replay, first); + unchangedThrow(first, () => + observeHomeworldOccupation( + first, + context, + custody, + 1, + 'turnEnd', + 'sole-entry', + ), + ); + unchangedThrow(first, () => + observeHomeworldOccupation( + first, + context, + custody, + 2, + 'change', + 'sole-entry', + ), + ); + custody.visitors['homeworld:choam'].a.normal = 2; + const second = observeHomeworldOccupation( + first, + context, + custody, + 1, + 'change', + 'more-forces', + ); + assert.equal( + second.qualifications.length, + 1, + 'same world/player/turn/cause retains first qualification evidence', + ); + unchangedThrow(second, () => + observeHomeworldOccupation( + second, + context, + custody, + 1, + 'change', + 'sole-entry', + ), + ); + const unchanged = observeHomeworldOccupation( + second, + context, + custody, + 1, + 'change', + 'new-wrapper-event', + ); + assert.deepEqual(unchanged, second); + const end = observeHomeworldOccupation( + second, + context, + custody, + 2, + 'turnEnd', + 'later-boundary', + ); + unchangedThrow(end, () => + observeHomeworldOccupation(end, context, custody, 1, 'change', 'backwards'), + ); + unchangedThrow(end, () => + validateHomeworldOccupationHistory(end, context, 1), + ); +}); + +void test('saved qualifications, roster, event receipts and interned source snapshots reject corruption independently without mutation', () => { + const { context, custody, history } = fixture(); + context.players.find((p) => p.id === 'c')!.reserves = 0; + custody.visitors['homeworld:choam'] = { a: { normal: 1, elite: 0 } }; + const initial = observeHomeworldOccupation( + history, + context, + custody, + 1, + 'change', + 'qualified', + ); + const corruptions = [ + (h: HomeworldOccupationHistory) => { + h.qualifications = []; + }, + (h: HomeworldOccupationHistory) => { + h.qualifications[0].player = 'h'; + }, + (h: HomeworldOccupationHistory) => { + h.qualifications[0].world = 'homeworld:atreides'; + }, + (h: HomeworldOccupationHistory) => { + h.sources[1].turn = 0; + }, + (h: HomeworldOccupationHistory) => { + h.sources[1].cause = 'setup'; + }, + (h: HomeworldOccupationHistory) => { + h.sources[1].snapshot = 0; + }, + (h: HomeworldOccupationHistory) => { + h.sources[1].signature = ''; + }, + (h: HomeworldOccupationHistory) => { + h.sources.push({ ...h.sources[1] }); + }, + (h: HomeworldOccupationHistory) => { + h.snapshots.push(h.snapshots[0]); + }, + (h: HomeworldOccupationHistory) => { + h.snapshots[1] = '[]'; + }, + (h: HomeworldOccupationHistory) => { + h.seats[0][0] = 'unseated'; + }, + (h: HomeworldOccupationHistory) => { + h.signature = ''; + }, + (h: HomeworldOccupationHistory) => { + Object.assign(h, { controller: 'a' }); + }, + ]; + for (const corrupt of corruptions) { + const changed = reload(initial); + corrupt(changed); + unchangedThrow(changed, () => + validateHomeworldOccupationHistory(changed, context, 1), + ); + unchangedThrow(changed, () => + observeHomeworldOccupation( + changed, + context, + custody, + 1, + 'change', + 'must-not-adopt', + ), + ); + unchangedThrow(changed, () => tupileOccupationStatus(changed, context, 1)); + } + const swapped = structuredClone(context); + swapped.players.find((p) => p.id === 'a')!.faction = 'guild'; + unchangedThrow(initial, () => + validateHomeworldOccupationHistory(initial, swapped, 1), + ); + const reordered = { ...context, players: [...context.players].reverse() }; + validateHomeworldOccupationHistory(initial, reordered, 1); + assert.equal(tupileOccupationStatus(initial, reordered, 1), 'unknown'); +}); diff --git a/tests/homeworld-payment-income-engine.test.ts b/tests/homeworld-payment-income-engine.test.ts index 22c91f2a..72bd5c6b 100644 --- a/tests/homeworld-payment-income-engine.test.ts +++ b/tests/homeworld-payment-income-engine.test.ts @@ -11,14 +11,14 @@ import { type Game, } from '../game/engine'; import { botActions } from '../game/bots'; -import { baseDeck, leaders } from '../game/cards'; +import { baseDeck } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; import { homeworldGameIntegrity } from '../game/homeworld-game'; import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; const own = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); -function fixture() { +function fixture(third: 'atreides' | 'richese' | 'ecaz' = 'atreides') { let g = createGame( 'PAYMENTINCOME', newPlayer('e', 'Emperor', 'emperor'), @@ -27,6 +27,8 @@ function fixture() { ); joinGame(g, newPlayer('g', 'Guild', 'guild')); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); + // Final audit roster is fixed before any setup or occupation history exists. + if (third !== 'atreides') g.players[2] = newPlayer('a', third, third); g = applyAction(g, 'e', { type: 'homeworlds', enabled: true }); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); g = initializeHomeworldGameForAudit(g); @@ -372,13 +374,9 @@ void test('native Guild shipment crosses from five to low before collecting its void test('Richese special cache purchase keeps its three-spice cost while low Kaitain income allows or cancels after reload', () => { for (const cancel of [false, true]) { - let g = fixture(); + // Genuine final Richese roster also initializes its physical cache. + let g = fixture('richese'); population(g, 'e', 4); - // Explicit Richese/cache seam after genuine base Homeworld setup; no full expansion setup claim. - own(g, 'a').faction = 'richese'; - own(g, 'a').leaders = leaders('richese'); - g.richeseCache = richeseCards(); - g.richeseRemoved = []; const payment = hold(g, 'a', 'karama'); const cancellation = hold(g, 'g', 'karama'); g = applyAction(g, 'a', { @@ -401,12 +399,10 @@ void test('Richese special cache purchase keeps its three-spice cost while low K void test('real Richese Ambassador purchase applies low Kaitain income after reload and rejects corrupted owner or gross before mutation', () => { for (const cancel of [false, true]) { - let g = movement(fixture(), 'g'); + let g = movement(fixture('ecaz'), 'g'); population(g, 'e', 4); - // Explicit Ecaz faction/token seam after genuine Homeworld setup; arrival, - // token trigger, paid card draw and income use their ordinary action paths. - own(g, 'a').faction = 'ecaz'; - own(g, 'a').leaders = leaders('ecaz'); + // Genuine final Ecaz roster; only this known Ambassador layout is staged. + // Arrival, trigger, paid draw and income still use production actions. const karama = hold(g, 'a', 'karama'); let ambassadors = createAmbassadors(() => 0.2); const token = ambassadors.tokens.find((t) => t.effect === 'richese')!; diff --git a/tests/homeworld-revival-ambassador-recovery.test.ts b/tests/homeworld-revival-ambassador-recovery.test.ts index 04ac155d..2ff880e3 100644 --- a/tests/homeworld-revival-ambassador-recovery.test.ts +++ b/tests/homeworld-revival-ambassador-recovery.test.ts @@ -110,13 +110,8 @@ async function persisted() { } const auths = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token))); const initial = await store.rooms.readRoom(code); - const ids: Record = {}; - let encoded = JSON.stringify(homeworldRevivalAmbassadorFixture()); - for (const [index, [id]] of roster.entries()) { - ids[id] = auths[index].playerId; - encoded = encoded.replaceAll(JSON.stringify(id), JSON.stringify(ids[id])); - } - const g: Game = JSON.parse(encoded); + const ids = Object.fromEntries(roster.map(([id],index) => [id,auths[index].playerId])); + const g: Game = homeworldRevivalAmbassadorFixture(ids); g.code = code; g.host = ids.f; g.version = initial.version; diff --git a/tests/homeworld-revival-benefits-engine.test.ts b/tests/homeworld-revival-benefits-engine.test.ts index 432e93af..2ba576f6 100644 --- a/tests/homeworld-revival-benefits-engine.test.ts +++ b/tests/homeworld-revival-benefits-engine.test.ts @@ -22,11 +22,11 @@ const p = (g: Game, id: string) => g.players.find((player) => player.id === id)!; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); -function setup(roster: FactionId[] = ['fremen', 'emperor']) { +function setup(roster: FactionId[] = ['fremen', 'emperor'], advanced = false) { let g = createGame( 'HOMEREVIVALBENEFITS', newPlayer(roster[0], roster[0], roster[0]), - false, + advanced, roster.some((id) => id === 'tleilaxu' || id === 'ixians') ? ['ix'] : [], ); for (const id of roster.slice(1)) joinGame(g, newPlayer(id, id, id)); @@ -359,12 +359,11 @@ void test('actual high Tleilax phase opening permits one ordinary free-income pa }); void test('every AI respects the projected low-Homeworld special-Karama boundary and can allow the saved revival', () => { - let g = setup(['fremen', 'tleilaxu', 'atreides']); + let g = setup(['fremen', 'tleilaxu', 'atreides'], true); position(g, 'fremen', 2, 12); position(g, 'tleilaxu', 12, 0); - // This interaction uses the same real physical components under Advanced - // timing; public Advanced/Homeworld starts remain independently gated. - g.advanced = true; + // Advanced identity is established before genuine setup history is signed; + // public Advanced/Homeworld starts remain independently gated. g = enterRevival(g); const card = hold(g, 'tleilaxu', 'karama'); g = applyAction(g, 'fremen', { type: 'revive', amount: 1, elite: 0 }); diff --git a/tests/homeworld-revival-deployment-recovery.test.ts b/tests/homeworld-revival-deployment-recovery.test.ts index 048dbec3..d6f567c4 100644 --- a/tests/homeworld-revival-deployment-recovery.test.ts +++ b/tests/homeworld-revival-deployment-recovery.test.ts @@ -113,11 +113,8 @@ async function persisted(mode: Mode = 'fremen') { const tokens = [made.token, emperor.token!, ...(tleilaxu ? [tleilaxu.token!] : [])]; const auths = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token))); const initial = await store.rooms.readRoom(code); - const prepared = homeworldRevivalFixture({ advanced, tleilaxu: expansion }); - let encoded = JSON.stringify(prepared); - for (const [index, id] of ['f', 'e', ...(expansion ? ['t'] : [])].entries()) - encoded = encoded.replaceAll(JSON.stringify(id), JSON.stringify(auths[index].playerId)); - let g: Game = JSON.parse(encoded); + const seatIds = Object.fromEntries(['f','e',...(expansion ? ['t'] : [])].map((id,index) => [id,auths[index].playerId])); + let g: Game = homeworldRevivalFixture({advanced,tleilaxu:expansion,seatIds}); g.code = code; g.host = auths[0].playerId; g.version = initial.version; diff --git a/tests/homeworld-spiritual-advisors.test.ts b/tests/homeworld-spiritual-advisors.test.ts index e40a8c23..4d4bf428 100644 --- a/tests/homeworld-spiritual-advisors.test.ts +++ b/tests/homeworld-spiritual-advisors.test.ts @@ -209,7 +209,10 @@ void test('advisor group size rejects malformed requests before cards, reserves void test('absent Homeworld module preserves ordinary one-force accompaniment and rejects two', () => { const g = setup(11).g; + // This parity fixture deliberately removes the whole optional module, + // including its setup evidence; it does not represent a legal live action. g.homeworlds = null; + delete g.homeworldOccupationHistory; const offered = ship(g); reject(offered, 'beneGesserit', { type: 'decision', diff --git a/tests/homeworld-tupile-sales-recovery.test.ts b/tests/homeworld-tupile-sales-recovery.test.ts index 001b36be..25be30e5 100644 --- a/tests/homeworld-tupile-sales-recovery.test.ts +++ b/tests/homeworld-tupile-sales-recovery.test.ts @@ -94,7 +94,7 @@ async function fixture(population = 10, withTleilaxu = false) { const store = unitStore(); const made = await store.rooms.createRoom( 'Tupile recovery QA', - 'guild', + 'choam', true, ['ix'], ); @@ -130,10 +130,9 @@ async function fixture(population = 10, withTleilaxu = false) { assert.equal(g.status, 'playing'); for (const player of g.players) g.deck.push(...player.hand.splice(0)); const ids = g.players.map((p) => p.id); - // Explicit unsupported-faction seam after genuine Homeworld/Ix setup. - // This audits production persistence for Tupile sales, not CHOAM setup or its deck. + // The real CHOAM identity precedes Homeworld/Ix setup and its signed history. + // Stage only the conserved population and balance needed for this sale. Object.assign(g.players[0], { - faction: 'choam', name: 'CHOAM', spice: 12, reserves: population, diff --git a/tests/homeworld-victory-reinforcement-recovery.test.ts b/tests/homeworld-victory-reinforcement-recovery.test.ts index e39cf45f..de507dd6 100644 --- a/tests/homeworld-victory-reinforcement-recovery.test.ts +++ b/tests/homeworld-victory-reinforcement-recovery.test.ts @@ -117,10 +117,8 @@ async function persisted(ambassador = false, native = 6) { } const auths = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token))); const initial = await store.rooms.readRoom(code); - let encoded = JSON.stringify(ambassador ? caladanAmbassadorFixture() : caladanVictoryFixture()); - for (const [index, id] of (ambassador ? ['a', 'g', 'ec', 'h', 'bg'] : ['a', 'g']).entries()) - encoded = encoded.replaceAll(JSON.stringify(id), JSON.stringify(auths[index].playerId)); - let g: Game = JSON.parse(encoded); + const seatIds = Object.fromEntries((ambassador ? ['a','g','ec','h','bg'] : ['a','g']).map((id,index) => [id,auths[index].playerId])); + let g: Game = ambassador ? caladanAmbassadorFixture(seatIds) : caladanVictoryFixture({seatIds}); g.code = code; g.host = auths[0].playerId; g.version = initial.version; diff --git a/tests/tupile-intelligence-answer.test.ts b/tests/tupile-intelligence-answer.test.ts new file mode 100644 index 00000000..d90289a2 --- /dev/null +++ b/tests/tupile-intelligence-answer.test.ts @@ -0,0 +1,172 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { baseDeck, ixDeck, type Card } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import { ecazTreacheryCards } from '../game/ecaz-cards'; +import { quoteTupileIntelligenceAnswer as answer } from '../game/tupile-intelligence-answer'; + +const inventory = () => [ + ...baseDeck(), + ...ixDeck(), + ...richeseCards(), + ...ecazTreacheryCards(), +]; +const card = (id: string) => inventory().find((c) => c.id === id)!; + +void test('every registered physical Treachery card has its printed primary category, independently of activation', () => { + const weapons = new Set([ + ...Array.from({ length: 9 }, (_, n) => `treachery-${n}`), + 'ix-poison-tooth', + 'ix-artillery', + 'ix-poison-blade', + 'ix-weirding-way', + 'ix-hunter-seeker', + 'ix-basilia-weapon', + 'richese-stone-burner', + 'richese-mirror-weapon', + ]); + const defenses = new Set([ + ...Array.from({ length: 8 }, (_, n) => `treachery-${n + 9}`), + 'ix-shield-snooper', + 'ix-chemistry', + 'ix-shield', + 'ix-snooper', + 'richese-portable-snooper', + ]); + const cards = inventory(); + assert.equal(cards.length, 60); + assert.equal(new Set(cards.map((c) => c.id)).size, 60); + for (const c of cards) { + assert.deepEqual( + answer([c], 7, 'weapons'), + { spice: 7, count: Number(weapons.has(c.id)) }, + c.id, + ); + assert.deepEqual( + answer([c], 7, 'defenses'), + { spice: 7, count: Number(defenses.has(c.id)) }, + c.id, + ); + } + assert.deepEqual(answer(cards, 0, 'weapons'), { spice: 0, count: 17 }); + assert.deepEqual(answer(cards, 0, 'defenses'), { spice: 0, count: 13 }); +}); + +void test('hybrids count once in their default printed category, while slot-compatible noncombat cards count neither', () => { + const hybrids = [ + 'ix-poison-blade', + 'ix-shield-snooper', + 'ix-weirding-way', + 'ix-chemistry', + ].map(card); + assert.deepEqual(answer(hybrids, 2, 'weapons'), { spice: 2, count: 2 }); + assert.deepEqual(answer(hybrids, 2, 'defenses'), { spice: 2, count: 2 }); + const neither = inventory().filter( + (c) => + c.kind === 'worthless' || c.kind === 'hero' || c.id.startsWith('ecaz-'), + ); + assert.equal(neither.length, 11); + assert.equal(answer(neither, 4, 'weapons').count, 0); + assert.equal(answer(neither, 4, 'defenses').count, 0); + for (const c of hybrids) + Object.defineProperty(c, 'battleRole', { + get() { + throw new Error('Held count must not inspect a committed battle role'); + }, + }); + assert.equal(answer(hybrids, 2, 'weapons').count, 2); +}); + +void test('Richese transport specials use canonical physical printed categories, not name or effect guesses', () => { + const cards = richeseCards(); + assert.ok(cards.every((c) => c.kind === 'special')); + assert.equal(answer(cards, 3, 'weapons').count, 2); + assert.equal(answer(cards, 3, 'defenses').count, 1); + assert.equal( + answer([card('richese-residual-poison')], 3, 'weapons').count, + 0, + ); + for (const forged of [ + { ...card('richese-residual-poison'), effect: 'stoneBurner' }, + { ...card('richese-stone-burner'), id: 'forged-weapon' }, + { ...card('richese-mirror-weapon'), kind: 'poison' }, + { ...card('richese-portable-snooper'), name: 'Snooper' }, + ]) + assert.throws(() => answer([forged as Card], 3, 'weapons')); +}); + +void test('same-name physical copies count separately; duplicate IDs and invented leader-skill cards reject', () => { + const shields = inventory().filter((c) => c.name === 'Shield'); + const sparse: Card[] = []; + sparse.length = 2; + assert.equal(answer(shields, 0, 'defenses').count, 5); + for (const invalid of [ + [shields[0], shields[0]], + [shields[0], { ...shields[0] }], + [ + { + id: 'leader-skill-master-of-assassins', + name: 'Master of Assassins', + kind: 'poison', + }, + ], + [{ ...card('treachery-0'), id: 'treachery-999' }], + [null], + sparse, + ]) + assert.throws(() => answer(invalid as Card[], 0, 'weapons')); +}); + +void test('zero and safe balances are truthful, malformed requests reject without partial private answers or mutation', () => { + const hand = [card('treachery-0')]; + assert.deepEqual(answer([], 0, 'defenses'), { spice: 0, count: 0 }); + assert.deepEqual(answer(hand, Number.MAX_SAFE_INTEGER, 'weapons'), { + spice: Number.MAX_SAFE_INTEGER, + count: 1, + }); + for (const spice of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) + assert.throws(() => answer(hand, spice, 'weapons')); + for (const category of ['both', 'weapon', null, 1]) + assert.throws(() => answer(hand, 0, category as 'weapons')); + const corrupt = [ + hand[0], + { ...card('richese-stone-burner'), name: 'Private corrupt face' }, + ]; + const before = JSON.stringify(corrupt); + assert.throws( + () => answer(corrupt, 109, 'weapons'), + (error: Error) => { + assert.doesNotMatch( + error.message, + /treachery|richese|109|Private corrupt face/, + ); + return true; + }, + ); + assert.equal(JSON.stringify(corrupt), before); +}); + +void test('the answer is a detached two-number snapshot across hand changes, JSON restore and frozen inputs', () => { + const hand = [card('treachery-0'), card('treachery-9')]; + const receipt = answer(hand, 12, 'weapons'); + assert.deepEqual(Object.keys(receipt).sort(), ['count', 'spice']); + assert.deepEqual(JSON.parse(JSON.stringify(receipt)), { + spice: 12, + count: 1, + }); + hand.splice(0, 1); + assert.equal(answer(hand, 1, 'weapons').count, 0); + assert.deepEqual(receipt, { spice: 12, count: 1 }); + Object.freeze(hand[0]); + Object.freeze(hand); + assert.deepEqual(answer(hand, 1, 'defenses'), { spice: 1, count: 1 }); +}); + +void test('the documented legacy Ellaca Drug name is recognized without changing its saved identity or the distinct Ix weapon', () => { + const legacy = { ...card('treachery-7'), name: 'Basilia Weapon' }; + const hand = [legacy, card('ix-basilia-weapon')]; + const before = JSON.stringify(hand); + assert.equal(answer(hand, 6, 'weapons').count, 2); + assert.equal(JSON.stringify(hand), before); + assert.throws(() => answer([{ ...legacy, kind: 'shield' }], 6, 'defenses')); +}); diff --git a/tests/tupile-intelligence-controls.test.ts b/tests/tupile-intelligence-controls.test.ts new file mode 100644 index 00000000..3ad4595a --- /dev/null +++ b/tests/tupile-intelligence-controls.test.ts @@ -0,0 +1,158 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { TupileIntelligence } from '../components/tupile-intelligence'; +import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine'; +import { + tupileIntelligenceActions, + tupileIntelligenceCanAct, + tupileIntelligenceChoice, +} from '../game/tupile-intelligence-options'; + +/** Projected facts isolate client/policy behavior. The engine supplies contact, + * lifetime use, timing and private answer custody; the client cannot infer them. */ +function fixture(): GameView { + const g = createGame('TUPILECONTROLS', newPlayer('c', 'CHOAM', 'choam')); + joinGame(g, newPlayer('a', 'Atreides', 'atreides')); + joinGame(g, newPlayer('e', 'Emperor', 'emperor')); + Object.assign(g, { status: 'playing', phase: 2, turn: 3, active: 'a', order: ['a', 'e', 'c'], storm: 18 }); + const view = viewGame(g, 'c'); + view.tupileIntelligence = { + owner: 'c', blocked: null, + targets: [ + { player: 'a', faction: 'atreides', contact: ['homeworld:atreides'], blocked: null }, + { player: 'e', faction: 'emperor', contact: ['homeworld:emperor:salusa'], blocked: null }, + ], + receipts: [], + }; + return view; +} +function markup(game: GameView, busy = false) { + return renderToStaticMarkup(createElement(TupileIntelligence, { game, busy, act() {} })); +} + +void test('Tupile choice emits exactly one selected category for a current eligible target without mutation', () => { + const view = fixture(); + const before = structuredClone(view); + for (const category of ['weapons', 'defenses'] as const) + assert.deepEqual(tupileIntelligenceChoice(view, 'e', category), { + blocked: null, action: { type: 'tupileIntelligence', target: 'e', category }, + }); + for (const target of ['c', 'missing']) + assert.equal(tupileIntelligenceChoice(view, target, 'weapons').action, null); + assert.equal(tupileIntelligenceChoice(view, 'a', 'both' as never).action, null); + assert.deepEqual(view, before); + view.tupileIntelligence!.targets.push({ ...view.tupileIntelligence!.targets[0] }); + assert.equal(tupileIntelligenceChoice(view, 'a', 'weapons').action, null); +}); + +void test('all policy profiles skip spent or ineligible factions and obey the server timing block', () => { + for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = fixture(); + view.players.find((player) => player.id === view.me)!.bot = profile; + view.tupileIntelligence!.targets[0].blocked = 'Already used against Atreides in this game.'; + assert.deepEqual(tupileIntelligenceActions(view), [ + { type: 'tupileIntelligence', target: 'e', category: 'weapons' }, + ]); + view.tupileIntelligence!.blocked = 'Finish the current response before requesting intelligence.'; + assert.equal(tupileIntelligenceCanAct(view), false); + assert.deepEqual(tupileIntelligenceActions(view), []); + assert.equal(tupileIntelligenceChoice(view, 'e', 'defenses').blocked, view.tupileIntelligence!.blocked); + view.tupileIntelligence!.blocked = null; + view.tupileIntelligence!.targets[1].blocked = 'No current Homeworld contact.'; + assert.deepEqual(tupileIntelligenceActions(view), []); + } +}); + +void test('owned controls expose accessible opponent/category selection and one request button', () => { + const html = markup(fixture()); + assert.match(html, /aria-label="Opposing faction"/); + assert.match(html, /aria-label="Card count"/); + assert.match(html, /value="weapons" selected=""/); + assert.match(html, /value="defenses"/); + assert.match(html, />Atreides<\/option>/); + assert.match(html, />Emperor<\/option>/); + assert.equal((html.match(/Request intelligence<\/button>/); + assert.doesNotMatch(html, /confirm|Reveal hand|Your private observations/i); +}); + +void test('private historical observations remain readable during a block and after JSON restoration', () => { + const view = fixture(); + view.tupileIntelligence!.blocked = 'Occupation must be resolved before another request.'; + view.tupileIntelligence!.receipts = [ + { event: 'old-a', target: 'a', faction: 'atreides', category: 'weapons', spice: 13, count: 2, turn: 1, phase: 3 }, + { event: 'old-e', target: 'e', faction: 'emperor', category: 'defenses', spice: 0, count: 1, turn: 2, phase: 7 }, + ]; + const before = structuredClone(view); + const html = markup(JSON.parse(JSON.stringify(view))); + assert.match(html, /Your private observations/); + assert.match(html, /13 spice, 2 weapons/); + assert.match(html, /0 spice, 1 defense/); + assert.match(html, /Turn 1 · Bidding/); + assert.match(html, /Turn 2 · Spice collection/); + assert.ok(html.indexOf('0 spice, 1 defense') < html.indexOf('13 spice, 2 weapons')); + assert.match(html, /do not update/); + assert.match(html, /Occupation must be resolved/); + assert.match(html, /]* disabled=""[^>]*>Request intelligence/); + assert.deepEqual(view, before); +}); + +void test('busy, empty and spent-target controls cannot submit a new request', () => { + const busy = markup(fixture(), true); + assert.equal((busy.match(/]* disabled=""/g) ?? []).length, 2); + assert.match(busy, /]* disabled=""[^>]*>Request intelligence/); + const view = fixture(); + view.tupileIntelligence!.targets = []; + assert.match(markup(view), /No eligible faction/); + assert.match(markup(view), /No faction is currently available for a request/); + assert.doesNotMatch(markup(view), /Choose a current opposing faction/); + assert.match(markup(view), /]* disabled=""[^>]*>Request intelligence/); + assert.deepEqual(tupileIntelligenceActions(view), []); + const spent = fixture(); + spent.tupileIntelligence!.targets[0].blocked = 'Already used against this faction in this game.'; + spent.tupileIntelligence!.targets[1].blocked = 'CHOAM must be on that faction’s Homeworld, or that faction must be on Tupile.'; + spent.tupileIntelligence!.blocked = 'Finish the current response before requesting intelligence.'; + spent.tupileIntelligence!.receipts = [ + { event: 'spent-a', target: 'a', faction: 'atreides', category: 'weapons', spice: 7, count: 1, turn: 1, phase: 5 }, + ]; + const unavailable = markup(spent); + assert.match(unavailable, /No eligible faction/); + assert.match(unavailable, /Atreides<\/strong>: Already used against this faction in this game/); + assert.match(unavailable, /Emperor<\/strong>: CHOAM must be on that faction/); + assert.match(unavailable, /Finish the current response before requesting intelligence/); + assert.match(unavailable, /7 spice, 1 weapon/); + assert.doesNotMatch(unavailable, /Choose a current opposing faction/); + assert.deepEqual(tupileIntelligenceActions(spent), []); +}); + +void test('other seats cannot render or consult private offers, even if a malformed projection includes one', () => { + const view = fixture(); + view.me = 'a'; + for (const field of ['targets', 'receipts'] as const) + Object.defineProperty(view.tupileIntelligence!, field, { get() { throw new Error(`Private ${field} accessed`); } }); + assert.equal(markup(view), ''); + assert.equal(tupileIntelligenceCanAct(view), false); + assert.deepEqual(tupileIntelligenceActions(view), []); + assert.equal(tupileIntelligenceChoice(view, 'e', 'weapons').action, null); + const missing = fixture(); + missing.tupileIntelligence = null; + assert.equal(markup(missing), ''); + assert.deepEqual(tupileIntelligenceActions(missing), []); +}); + +void test('policy and panel never inspect opponent hands, balances, plans or private source state', () => { + const view = fixture(); + const expected = markup(view); + Object.defineProperty(view, 'players', { get() { throw new Error('Player source inspected'); } }); + for (const field of ['hand', 'spice', 'battle', 'homeworlds', 'noField', 'leaders']) + Object.defineProperty(view, field, { get() { throw new Error(`Hidden ${field} inspected`); } }); + assert.equal(markup(view), expected); + Object.defineProperty(view.tupileIntelligence!, 'receipts', { get() { throw new Error('History used for eligibility'); } }); + assert.deepEqual(tupileIntelligenceActions(view), [{ type: 'tupileIntelligence', target: 'a', category: 'weapons' }]); + assert.ok(tupileIntelligenceChoice(view, 'e', 'defenses').action); + view.status = 'finished'; + assert.deepEqual(tupileIntelligenceActions(view), []); + assert.equal(tupileIntelligenceChoice(view, 'e', 'defenses').action, null); +}); diff --git a/tests/tupile-intelligence-engine.test.ts b/tests/tupile-intelligence-engine.test.ts new file mode 100644 index 00000000..98790c88 --- /dev/null +++ b/tests/tupile-intelligence-engine.test.ts @@ -0,0 +1,180 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { + holdTupileCard, positionTupileContact, tupileIntelligenceFixture, + tupileInventory, tupilePlayer, tupileReject, tupileReload, +} from './fixture-tupile-intelligence'; + +const request = (category = 'weapons', target = 'a') => ({ type: 'tupileIntelligence', target, category }); +function heldAnswer(g: Game): void { + for (const kind of ['projectile', 'shield', 'worthless', 'ghola']) holdTupileCard(g, 'a', kind); + tupilePlayer(g, 'a').spice = 13; +} + +void test('actual Tupile contact grants one private snapshot off turn, without a response or material cost', () => { + for (const advanced of [false, true]) { + const g = tupileIntelligenceFixture({ advanced }); + heldAnswer(g); + g.active = 'h'; + const before = tupileReload(g); + const next = applyAction(g, 'c', request()); + assert.deepEqual(g, before); + const receipt = viewGame(next, 'c').tupileIntelligence!.receipts[0]; + assert.deepEqual({ ...receipt, event: 'event' }, { + event: 'event', target: 'a', faction: 'atreides', category: 'weapons', spice: 13, count: 1, turn: 2, phase: 5, + }); + assert.deepEqual(next.players, before.players); + assert.deepEqual(next.homeworlds, before.homeworlds); + assert.equal(next.active, 'h'); + assert.equal(next.response, null); + assert.equal(next.decision, null); + assert.equal(next.tupileIntelligence!.receipts.length, 1); + assert.deepEqual(next.homeworldOccupationHistory!.qualifications, []); + tupileInventory(next); + } +}); + +void test('all seat views keep the unchosen count and private receipt out of opponents and public logs', () => { + const g = tupileIntelligenceFixture(); + heldAnswer(g); + const next = applyAction(g, 'c', request('defenses')); + const receipt = next.tupileIntelligence!.receipts[0]; + for (const id of ['c', 'a', 'h']) { + const view = viewGame(next, id); + assert.deepEqual(Object.keys(view.tupileIntelligence?.receipts[0] ?? {}).sort(), id === 'c' + ? ['category', 'count', 'event', 'faction', 'phase', 'spice', 'target', 'turn'] : []); + if (id !== 'c') { + assert.equal(view.tupileIntelligence, null); + assert.equal(JSON.stringify(view).includes(receipt.event), false); + assert.equal(JSON.stringify(view).includes(receipt.signature), false); + } + for (const other of view.players.filter((p) => p.id !== id)) assert.equal(other.hand, undefined); + } + const publicLog = JSON.stringify(next.log.slice(g.log.length)); + assert.match(publicLog, /Tupile intelligence/); + assert.doesNotMatch(publicLog, /13|defenses|weapons|"count"|"spice"/); +}); + +void test('a foreign arrival on garrisoned Tupile grants contact independently for each opposing faction', () => { + let g = tupileIntelligenceFixture({ contact: false }); + for (const [id, faction] of [['a', 'atreides'], ['h', 'harkonnen']]) { + g.active = id; + g = applyAction(g, id, { + type: 'homeworldShip', event: viewGame(g, id).homeworldShipment!.event, + destination: 'homeworld:choam', sources: { [`homeworld:${faction}`]: { normal: 1, elite: 0 } }, + }); + assert.deepEqual(g.homeworldOccupationHistory!.qualifications, []); + const target = viewGame(g, 'c').tupileIntelligence!.targets.find((seat) => seat.player === id)!; + assert.deepEqual(target.contact, ['homeworld:choam']); + assert.equal(target.blocked, null); + g = applyAction(g, 'c', request('defenses', id)); + } + assert.deepEqual(g.tupileIntelligence!.receipts.map((receipt) => receipt.faction), ['atreides', 'harkonnen']); + assert.equal(tupilePlayer(g, 'c').reserves, 10); + tupileInventory(g); +}); + +void test('the lifetime faction use survives category changes, later phases, departure and reentry', () => { + let g = applyAction(tupileIntelligenceFixture(), 'c', request()); + for (const category of ['weapons', 'defenses']) tupileReject(g, 'c', request(category), /already been used/); + positionTupileContact(g, false); + g.turn = 3; + g.phase = 2; + positionTupileContact(g, true); + g = tupileReload(g); + assert.equal(viewGame(g, 'c').tupileIntelligence!.receipts.length, 1); + tupileReject(g, 'c', request('defenses'), /already been used/); + assert.deepEqual(normalizeAutomaticGame(g).tupileIntelligence, g.tupileIntelligence); + tupileInventory(g); +}); + +void test('JSON reload retains the historical answer when the target spends spice and exchanges held cards', () => { + let g = tupileIntelligenceFixture(); + heldAnswer(g); + g = applyAction(g, 'c', request()); + const answer = structuredClone(viewGame(g, 'c').tupileIntelligence!.receipts); + const target = tupilePlayer(g, 'a'); + target.spice = 0; + g.deck.push(...target.hand.splice(0)); + holdTupileCard(g, 'a', 'shield'); + positionTupileContact(g, false); + g = normalizeAutomaticGame(tupileReload(g)); + assert.deepEqual(viewGame(g, 'c').tupileIntelligence!.receipts, answer); + assert.equal(target.hand.length, 1); + tupileInventory(g); +}); + +void test('current population, contact, pending timing and malformed requests reject without changing the game', () => { + const g = tupileIntelligenceFixture(); + const high = tupileReload(g); + tupilePlayer(high, 'c').reserves += 2; + tupilePlayer(high, 'c').forces['polar_sink:0'] -= 2; + tupileReject(high, 'c', request(), /at most ten/); + const absent = tupileReload(g); + positionTupileContact(absent, false); + tupileReject(absent, 'c', request(), /must be on/); + const opening = tupileReload(g); + opening.phaseOpening = { passed: [], initialize: false }; + assert.match(viewGame(opening, 'c').tupileIntelligence!.blocked!, /Finish/); + tupileReject(opening, 'c', request(), /Finish/); + tupileReject(g, 'a', request(), /Only CHOAM/); + for (const action of [request('both'), request('weapons', 'c'), request('weapons', 'missing'), { ...request(), count: 0 }, { ...request(), spice: 0 }]) + tupileReject(g, 'c', action); + assert.deepEqual(g.tupileIntelligence!.receipts, []); +}); + +void test('deleted receipts or mismatched initialized history reject projection, normalization and actions immutably', () => { + const original = applyAction(tupileIntelligenceFixture(), 'c', request()); + const corruptions: ((g: Game) => void)[] = [ + (g) => { g.tupileIntelligence!.receipts = []; }, + (g) => { g.tupileIntelligence!.receipts[0].count++; }, + (g) => { g.tupileIntelligence!.receipts[0].category = 'defenses'; }, + (g) => { delete g.tupileIntelligence; }, + (g) => { delete g.homeworldOccupationHistory; }, + (g) => { delete g.homeworlds!.historyVersion; }, + ]; + for (const corrupt of corruptions) { + const broken = tupileReload(original); + corrupt(broken); + const before = tupileReload(broken); + for (const id of ['c', 'a', 'h']) assert.throws(() => viewGame(broken, id), /ledger|history|observation/i); + assert.throws(() => normalizeAutomaticGame(broken), /ledger|history|observation/i); + tupileReject(broken, 'c', request('defenses'), /ledger|history|observation/i); + assert.deepEqual(broken, before); + } +}); + +void test('legacy saves with no initialized history remain readable but cannot invent a fresh intelligence allowance', () => { + const g = tupileIntelligenceFixture(); + delete g.homeworlds!.historyVersion; + delete g.homeworldOccupationHistory; + delete g.tupileIntelligence; + const before = tupileReload(g); + for (const id of ['c', 'a', 'h']) { + const view = viewGame(g, id); + if (id === 'c') { + assert.match(view.tupileIntelligence!.blocked!, /lacks the original/); + assert.deepEqual(view.tupileIntelligence!.receipts, []); + } else assert.equal(view.tupileIntelligence, null); + } + tupileReject(g, 'c', request(), /lacks the original/); + const normalized = normalizeAutomaticGame(g); + assert.equal(normalized.tupileIntelligence, undefined); + assert.equal(normalized.homeworldOccupationHistory, undefined); + assert.deepEqual(g, before); +}); + +void test('every real bot profile emits a legal owned request and stops after using that faction', () => { + for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const g = tupileIntelligenceFixture(); + g.active = 'a'; + tupilePlayer(g, 'c').bot = profile; + const actions = botActions(viewGame(g, 'c')); + assert.deepEqual(actions, [request()]); + const next = applyAction(g, 'c', actions[0]); + assert.equal(next.tupileIntelligence!.receipts.length, 1); + assert.equal(botActions(viewGame(next, 'c')).some((action) => action.type === 'tupileIntelligence'), false); + } +}); diff --git a/tests/tupile-intelligence-recovery.test.ts b/tests/tupile-intelligence-recovery.test.ts new file mode 100644 index 00000000..8775273b --- /dev/null +++ b/tests/tupile-intelligence-recovery.test.ts @@ -0,0 +1,550 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash, webcrypto } from 'node:crypto'; +import { readFileSync, readdirSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { runInNewContext } from 'node:vm'; +import ts from 'typescript'; +import * as engine from '../game/engine'; +import * as bots from '../game/bots'; +import type * as Rooms from '../db/rooms'; +import { homeworldGameIntegrity } from '../game/homeworld-game'; +import { homeworldContext } from '../game/homeworld-game'; +import { tupileOccupationStatus } from '../game/homeworld-occupation-history'; +import { + tupileIntelligenceFixture, + tupileInventory, + tupilePlayer as player, + tupileReload as reload, + holdTupileCard, +} from './fixture-tupile-intelligence'; + +/** Execute the production room module and SQL, with a hook immediately before its CAS. */ +function unitStore(runBots = bots.runBots) { + const sqlite = new DatabaseSync(':memory:'); + const hooks: { beforeWrite?: () => Promise } = {}; + const writes: { expected: number; changes: number }[] = []; + for (const file of readdirSync(new URL('../drizzle/', import.meta.url)) + .filter((f) => f.endsWith('.sql')) + .sort()) + sqlite.exec( + readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'), + ); + class Statement { + values: (string | number | null)[] = []; + constructor(readonly sql: string) {} + bind(...values: (string | number | null)[]) { + this.values = values; + return this; + } + async first() { + return sqlite.prepare(this.sql).get(...this.values) ?? null; + } + async run() { + const continuation = this.sql.startsWith('UPDATE rooms SET state'); + if (continuation) await hooks.beforeWrite?.(); + const changes = Number( + sqlite.prepare(this.sql).run(...this.values).changes, + ); + if (continuation) + writes.push({ expected: Number(this.values[3]), changes }); + return { meta: { changes } }; + } + } + const database = { + prepare: (sql: string) => new Statement(sql), + batch: async (statements: Statement[]) => { + sqlite.exec('BEGIN'); + try { + const results = []; + for (const statement of statements) results.push(await statement.run()); + sqlite.exec('COMMIT'); + return results; + } catch (error) { + sqlite.exec('ROLLBACK'); + throw error; + } + }, + }; + function loadRooms() { + const exports = {}; + runInNewContext( + ts.transpileModule( + readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }, + ).outputText, + { + exports, + crypto: webcrypto, + TextEncoder, + structuredClone, + // Keep persisted JSON values in the test realm for strict structural assertions. + JSON, + require: (name: string) => { + if (name === 'cloudflare:workers') return { env: { DB: database } }; + if (name === '@/game/engine') return engine; + if (name === '@/game/bots') return { ...bots, runBots }; + throw new Error('Unexpected module ' + name); + }, + }, + ); + return exports as typeof Rooms; + } + return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes }; +} + +type Game = engine.Game; +const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} }; +async function persisted() { + const store = unitStore(); + const made = await store.rooms.createRoom( + 'Tupile intelligence SQL QA', + 'choam', + false, + [], + ); + const code = made.view.code; + const credentials = [made.token]; + for (const faction of ['atreides', 'harkonnen'] as const) { + const joined = await store.rooms.joinRoom(code, faction, faction); + credentials.push(joined.token!); + } + const auths = await Promise.all( + credentials.map((token) => store.rooms.authenticate(code, token)), + ); + const ids = auths.map((auth) => auth.playerId) as [string, string, string]; + const initial = tupileIntelligenceFixture({ seatIds: ids, contact: false }); + initial.code = code; + initial.host = ids[0]; + initial.version = (await store.rooms.readRoom(code)).version; + holdTupileCard(initial, ids[1], 'projectile'); + holdTupileCard(initial, ids[1], 'poison'); + holdTupileCard(initial, ids[1], 'shield'); + player(initial, ids[1]).spice = 997; + const save = (state: Game) => + store.sqlite + .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?') + .run(JSON.stringify(state), state.version, code); + save(initial); + const other = await store.rooms.createRoom( + 'Untouched Tupile room', + 'fremen', + false, + [], + ); + const otherBefore = await store.rooms.readRoom(other.view.code); + store.writes.length = 0; + return { + ...store, + code, + credentials, + auths, + ids, + initial, + save, + otherCode: other.view.code, + otherBefore, + }; +} +type Fixture = Awaited>; +function snapshot(f: Fixture) { + const row = f.sqlite + .prepare('SELECT state, version FROM rooms WHERE code = ?') + .get(f.code)!; + return { + version: row.version, + hash: createHash('sha256').update(String(row.state)).digest('hex'), + }; +} +async function act(f: Fixture, g: Game, actor: string, action: engine.Action) { + await f.restart().act( + f.code, + f.auths.find((auth) => auth.playerId === actor)!, + g.version, + action, + clock, + ); + return f.rooms.readRoom(f.code); +} +function request( + f: Fixture, + category: 'weapons' | 'defenses' = 'weapons', +): engine.Action { + return { type: 'tupileIntelligence', target: f.ids[1], category }; +} +async function contact(f: Fixture) { + const owner = await f.rooms.readSeatView(f.code, f.auths[0]); + const g = await act(f, f.initial, f.ids[0], { + type: 'homeworldShip', + event: owner.homeworldShipment!.event, + destination: 'homeworld:atreides', + sources: { 'homeworld:choam': { normal: 1, elite: 0 } }, + }); + assert.equal( + g.homeworlds!.custody!.visitors['homeworld:atreides'][f.ids[0]].normal, + 1, + ); + assert.equal(g.homeworldOccupationHistory!.qualifications.length, 0); + assert.equal( + ( + await f.rooms.readSeatView(f.code, f.auths[0]) + ).tupileIntelligence!.targets.find((t) => t.player === f.ids[1])!.blocked, + null, + ); + await restored(f, g); + f.writes.length = 0; + return g; +} +async function restored(f: Fixture, g: Game) { + const rooms = f.restart(); + const before = snapshot(f); + for (const [index, token] of f.credentials.entries()) { + const auth = await rooms.authenticate(f.code, token); + const view = await rooms.readSeatView(f.code, auth); + assert.deepEqual(view, engine.viewGame(g, f.ids[index])); + for (const opponent of view.players.filter((p) => p.id !== auth.playerId)) { + assert.equal(opponent.hand, undefined); + assert.equal(opponent.spice, undefined); + } + if (index !== 0) { + assert.equal(view.tupileIntelligence, null); + // JSON export of another session's public/private view contains no CHOAM + // observation identity or private consistency payload. + const exported = JSON.stringify(view); + for (const receipt of g.tupileIntelligence?.receipts ?? []) { + assert.equal(exported.includes(receipt.event), false); + assert.equal(exported.includes(receipt.signature), false); + } + assert.equal(exported.includes('tupileIntelligenceObservation'), false); + } + for (const entry of view.log.filter((entry) => + entry.text.includes('used Tupile intelligence'), + )) { + assert.doesNotMatch( + entry.text, + /997|weapons|defenses|count[=:]|balance[=:]/, + ); + } + } + homeworldGameIntegrity(g); + tupileInventory(g); + assert.deepEqual(snapshot(f), before); + assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore); + return rooms; +} +function barrier(f: Fixture) { + let arrivals = 0; + let release!: () => void; + const waiting = new Promise((resolve) => { + release = resolve; + }); + f.hooks.beforeWrite = async () => { + if (++arrivals === 2) release(); + await waiting; + }; +} + +void test('production SQL competing weapon and defense requests reveal one historical answer to CHOAM and consume the target once', async () => { + const f = await persisted(); + try { + const g = await contact(f); + barrier(f); + const outcomes = await Promise.allSettled([ + f.rooms.act(f.code, f.auths[0], g.version, request(f, 'weapons'), clock), + f + .restart() + .act(f.code, f.auths[0], g.version, request(f, 'defenses'), clock), + ]); + delete f.hooks.beforeWrite; + assert.equal( + outcomes.filter((result) => result.status === 'fulfilled').length, + 1, + ); + assert.deepEqual( + f.writes.map((write) => write.changes).sort((a, b) => a - b), + [0, 1], + ); + const done = await f.rooms.readRoom(f.code); + assert.equal(done.version, g.version + 1); + assert.equal(done.tupileIntelligence!.receipts.length, 1); + const receipt = done.tupileIntelligence!.receipts[0]; + assert.equal(receipt.spice, 997); + assert.equal(receipt.count, receipt.category === 'weapons' ? 2 : 1); + assert.equal(receipt.target, f.ids[1]); + assert.deepEqual(done.players, g.players); + assert.deepEqual(done.homeworlds, g.homeworlds); + const before = snapshot(f); + for (const category of ['weapons', 'defenses'] as const) { + await assert.rejects( + f.rooms.act(f.code, f.auths[0], g.version, request(f, category), clock), + ); + await assert.rejects( + f.rooms.act( + f.code, + f.auths[0], + done.version, + request(f, category), + clock, + ), + /already been used/, + ); + } + assert.deepEqual(snapshot(f), before); + await restored(f, done); + } finally { + f.sqlite.close(); + } +}); + +void test('production SQL a lost answer response is recovered privately and remains the original snapshot after target balance and hand change', async () => { + const f = await persisted(); + try { + const g = await contact(f); + // Deliberately discard the successful response to model a lost network reply. + await f.rooms.act(f.code, f.auths[0], g.version, request(f), clock); + const done = await f.restart().readRoom(f.code); + const expected = structuredClone(done.tupileIntelligence!.receipts[0]); + assert.equal(expected.count, 2); + const changed = reload(done); + // A conserved later hand fixture proves stored observations do not follow + // current private data; it does not invent a second request or receipt. + changed.deck.push(...player(changed, f.ids[1]).hand.splice(0)); + player(changed, f.ids[1]).spice = 13; + f.save(changed); + const rooms = await restored(f, changed); + const owner = await rooms.readSeatView(f.code, f.auths[0]); + assert.deepEqual(owner.tupileIntelligence!.receipts, [ + { + event: expected.event, + target: expected.target, + faction: expected.faction, + category: 'weapons', + spice: 997, + count: 2, + turn: expected.turn, + phase: expected.phase, + }, + ]); + assert.deepEqual(changed.tupileIntelligence!.receipts[0], expected); + const before = snapshot(f); + f.writes.length = 0; + await assert.rejects( + rooms.act( + f.code, + f.auths[0], + changed.version, + request(f, 'defenses'), + clock, + ), + /already been used/, + ); + await rooms.continueRoomAutomatic(f.code, clock); + assert.deepEqual(snapshot(f), before); + assert.equal(f.writes.length, 0); + } finally { + f.sqlite.close(); + } +}); + +void test('production SQL corrupt answer, lifetime usage, setup marker or occupation evidence rejects every session without database writes', async () => { + const f = await persisted(); + try { + const g = await contact(f); + const answered = await act(f, g, f.ids[0], request(f)); + const cases: ((state: Game) => void)[] = [ + (state) => { + state.tupileIntelligence!.receipts[0].count++; + }, + (state) => { + state.tupileIntelligence!.receipts[0].spice++; + }, + (state) => { + state.tupileIntelligence!.receipts[0].category = 'defenses'; + }, + (state) => { + state.tupileIntelligence!.receipts = []; + }, + (state) => { + delete state.tupileIntelligence; + }, + (state) => { + delete state.homeworlds!.historyVersion; + }, + (state) => { + delete state.homeworldOccupationHistory; + }, + (state) => { + state.homeworldOccupationHistory!.sources[0].event = 'another-setup'; + }, + (state) => { + state.homeworldOccupationHistory!.signature = 'changed-history'; + }, + ]; + for (const mutate of cases) { + const corrupt = reload(answered); + mutate(corrupt); + f.save(corrupt); + f.writes.length = 0; + const before = snapshot(f); + const rooms = f.restart(); + for (const auth of f.auths) + await assert.rejects( + rooms.readSeatView(f.code, auth), + /Tupile|Homeworld|occupation/i, + ); + await assert.rejects( + rooms.act(f.code, f.auths[0], corrupt.version, request(f), clock), + /Tupile|Homeworld|occupation/i, + ); + const stateBefore = reload(corrupt); + assert.throws( + () => engine.normalizeAutomaticGame(corrupt), + /Tupile|Homeworld|occupation/i, + ); + assert.deepEqual(corrupt, stateBefore); + assert.deepEqual(snapshot(f), before); + assert.equal(f.writes.length, 0); + assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore); + } + } finally { + f.sqlite.close(); + } +}); + +void test('production SQL legacy history absence stays unknown on reads and normalization with no reconstructed intelligence', async () => { + const f = await persisted(); + try { + const g = await contact(f); + // Explicit legacy save seam: this game predates all three initialization + // fields. Removing only one from an initialized game is tested above. + delete g.homeworlds!.historyVersion; + delete g.homeworldOccupationHistory; + delete g.tupileIntelligence; + f.save(g); + f.writes.length = 0; + const before = snapshot(f); + const rooms = await restored(f, g); + const view = await rooms.readSeatView(f.code, f.auths[0]); + assert.match(view.tupileIntelligence!.blocked!, /lacks.*history/); + assert.deepEqual(view.tupileIntelligence!.receipts, []); + assert.equal( + tupileOccupationStatus( + g.homeworldOccupationHistory, + homeworldContext(g), + g.turn, + ), + 'unknown', + ); + const normalized = engine.normalizeAutomaticGame(reload(g)); + assert.deepEqual(normalized, g); + await assert.rejects( + rooms.act(f.code, f.auths[0], g.version, request(f), clock), + /lacks.*history/, + ); + await rooms.continueRoomAutomatic(f.code, clock); + assert.deepEqual(snapshot(f), before); + assert.equal(f.writes.length, 0); + } finally { + f.sqlite.close(); + } +}); + +void test('production SQL actual sole occupation suppresses Tupile after the qualifier departs without inventing a current controller', async () => { + const f = await persisted(); + try { + let g = await contact(f); + assert.equal( + tupileOccupationStatus( + g.homeworldOccupationHistory, + homeworldContext(g), + g.turn, + ), + 'unoccupied', + ); + // Conserved battle-position seam empties natives before a genuine invading + // Homeworld shipment. CHOAM keeps its real contact on Atreides throughout. + player(g, f.ids[0]).forces['polar_sink:0'] += player(g, f.ids[0]).reserves; + player(g, f.ids[0]).reserves = 0; + g.active = f.ids[2]; + g.movementRemaining = [f.ids[2]]; + player(g, f.ids[2]).shipped = false; + f.save(g); + const invasionView = await f.rooms.readSeatView(f.code, f.auths[2]); + g = await act(f, g, f.ids[2], { + type: 'homeworldShip', + event: invasionView.homeworldShipment!.event, + destination: 'homeworld:choam', + sources: { 'homeworld:harkonnen': { normal: 1, elite: 0 } }, + }); + const evidence = structuredClone( + g.homeworldOccupationHistory!.qualifications.filter( + (q) => q.world === 'homeworld:choam', + ), + ); + assert.deepEqual( + evidence.map((q) => [q.player, q.cause]), + [[f.ids[2], 'sole']], + ); + assert.equal( + tupileOccupationStatus( + g.homeworldOccupationHistory, + homeworldContext(g), + g.turn, + ), + 'unknown', + ); + const before = snapshot(f); + await assert.rejects( + f.rooms.act(f.code, f.auths[0], g.version, request(f), clock), + /occupation/, + ); + assert.deepEqual(snapshot(f), before); + // A later unused shipment is staged in the same turn so departure does not + // imply an invented turn-expiry policy. Departure to another foreign world + // is a real action; ordinary return to Harkonnen's own world stays gated. + player(g, f.ids[2]).shipped = false; + f.save(g); + const returnView = await f.rooms.readSeatView(f.code, f.auths[2]); + g = await act(f, g, f.ids[2], { + type: 'homeworldShip', + event: returnView.homeworldShipment!.event, + destination: 'homeworld:atreides', + sources: { 'homeworld:choam': { normal: 1, elite: 0 } }, + }); + assert.equal( + g.homeworlds!.custody!.visitors['homeworld:choam']?.[f.ids[2]], + undefined, + ); + assert.deepEqual( + g.homeworldOccupationHistory!.qualifications.filter( + (q) => q.world === 'homeworld:choam', + ), + evidence, + ); + assert.equal( + tupileOccupationStatus( + g.homeworldOccupationHistory, + homeworldContext(g), + g.turn, + ), + 'unknown', + ); + const departed = snapshot(f); + f.writes.length = 0; + await assert.rejects( + f.restart().act(f.code, f.auths[0], g.version, request(f), clock), + /occupation/, + ); + await restored(f, g); + assert.deepEqual(snapshot(f), departed); + assert.equal(f.writes.length, 0); + assert.deepEqual(g.tupileIntelligence!.receipts, []); + } finally { + f.sqlite.close(); + } +}); diff --git a/tests/tupile-intelligence-state.test.ts b/tests/tupile-intelligence-state.test.ts new file mode 100644 index 00000000..892b361a --- /dev/null +++ b/tests/tupile-intelligence-state.test.ts @@ -0,0 +1,310 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + appendTupileIntelligenceObservation as append, + createTupileIntelligenceState as create, + validateTupileIntelligenceState as validate, + tupileIntelligenceUsedFactions as used, + tupileIntelligenceObservationSignature, + tupileIntelligenceStateSignature, + type TupileIntelligenceObservation, + type TupileIntelligenceState, +} from '../game/tupile-intelligence-state'; +import type { FactionId } from '../game/catalog'; + +const players = [ + { id: 'c', faction: 'choam' as const }, + { id: 'e', faction: 'emperor' as const }, + { id: 'a', faction: 'atreides' as const }, +]; +function observation(): Omit { + return { + event: 'first-query', + owner: 'c', + target: 'e', + faction: 'emperor', + category: 'weapons', + spice: 17, + count: 2, + turn: 2, + phase: 6, + contact: [ + 'homeworld:emperor', + 'homeworld:emperor:salusa', + 'homeworld:choam', + ], + }; +} +function two() { + return append(append(create('c'), observation()), { + ...observation(), + event: 'second-query', + target: 'a', + faction: 'atreides', + category: 'defenses', + turn: 3, + phase: 0, + spice: 0, + count: 0, + contact: ['homeworld:atreides'], + }); +} + +void test('new owner ledger and detached observations preserve a historical private answer through JSON and later turns', () => { + const empty = create('c'); + validate(empty, players, 1); + assert.deepEqual(used(empty), []); + const input = observation(); + const state = append(empty, input); + assert.equal(empty.receipts.length, 0); + assert.deepEqual(used(state), ['emperor']); + validate( + JSON.parse(JSON.stringify(state)) as TupileIntelligenceState, + players, + 10, + ); + input.spice = 0; + input.count = 0; + input.contact.pop(); + assert.equal(state.receipts[0].spice, 17); + assert.equal(state.receipts[0].count, 2); + assert.equal(state.receipts[0].contact.length, 3); + assert.deepEqual(Object.keys(state).sort(), [ + 'owner', + 'receipts', + 'signature', + 'version', + ]); +}); + +void test('faction lifetime use never resets with another turn, phase, category, contact direction or target ID', () => { + const state = append(create('c'), observation()); + const before = JSON.stringify(state); + for (const change of [ + { event: 'new-query' }, + { event: 'new-query', turn: 7, phase: 0 }, + { event: 'new-query', category: 'defenses' as const }, + { event: 'new-query', contact: ['homeworld:choam'] }, + { event: 'new-query', contact: ['homeworld:emperor:salusa'] }, + { event: 'new-query', target: 'new-emperor-seat' }, + ]) + assert.throws(() => append(state, { ...observation(), ...change })); + assert.throws(() => + append(state, { + ...observation(), + target: 'a', + faction: 'atreides', + contact: ['homeworld:atreides'], + }), + ); + assert.equal(JSON.stringify(state), before); + assert.deepEqual(used(two()), ['emperor', 'atreides']); +}); + +void test('only physical owner/target Homeworld IDs supply historical provenance, with both Emperor worlds allowed', () => { + for (const contact of [ + ['homeworld:choam'], + ['homeworld:emperor'], + ['homeworld:emperor:salusa'], + ]) + validate(append(create('c'), { ...observation(), contact }), players, 2); + for (const contact of [ + [], + ['homeworld:emperor', 'homeworld:emperor'], + ['kaitain'], + ['salusa_secundus'], + ['homeworld:atreides'], + ['arrakeen'], + ['homeworld:choam:salusa'], + ]) + assert.throws(() => append(create('c'), { ...observation(), contact })); + assert.throws(() => + append(create('c'), { + ...observation(), + target: 'a', + faction: 'atreides', + contact: ['homeworld:emperor:salusa'], + }), + ); +}); + +void test('every signed observation field and the ledger order, version and receipt membership resist corruption', () => { + const original = two(); + const mutations: ((state: TupileIntelligenceState) => void)[] = [ + (s) => { + s.owner = 'a'; + }, + (s) => { + s.version = 0 as 1; + }, + (s) => { + s.receipts.pop(); + }, + (s) => { + s.receipts.reverse(); + }, + (s) => { + s.receipts[0].event = 'changed'; + }, + (s) => { + s.receipts[0].owner = 'a'; + }, + (s) => { + s.receipts[0].target = 'a'; + }, + (s) => { + s.receipts[0].faction = 'atreides'; + }, + (s) => { + s.receipts[0].category = 'defenses'; + }, + (s) => { + s.receipts[0].spice++; + }, + (s) => { + s.receipts[0].count++; + }, + (s) => { + s.receipts[0].turn++; + }, + (s) => { + s.receipts[0].phase++; + }, + (s) => { + s.receipts[0].contact.reverse(); + }, + (s) => { + delete (s.receipts[0] as Partial) + .signature; + }, + (s) => { + delete (s as Partial).signature; + }, + ]; + for (const mutate of mutations) { + const state = structuredClone(original); + mutate(state); + const before = JSON.stringify(state); + assert.throws(() => validate(state, players, 5)); + assert.throws(() => used(state)); + assert.equal(JSON.stringify(state), before); + } + // Object key order is not observation order and survives ordinary JSON tooling. + const reordered = JSON.parse( + JSON.stringify(original), + (_key, value: unknown) => + value && typeof value === 'object' && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).reverse()) + : value, + ) as TupileIntelligenceState; + validate(reordered, players, 5); +}); + +void test('valid signatures cannot disguise invalid participants, duplicate faction use or a future observation', () => { + const state = append(create('c'), observation()); + for (const seats of [ + players.filter((p) => p.id !== 'e'), + players.map((p) => + p.id === 'e' ? { ...p, faction: 'guild' as const } : p, + ), + players.map((p) => + p.id === 'c' ? { ...p, faction: 'guild' as const } : p, + ), + [...players, { id: 'replacement', faction: 'emperor' as const }], + [...players, { id: 'a', faction: 'guild' as const }], + ]) + assert.throws(() => validate(state, seats, 2)); + assert.throws(() => validate(state, players, 1)); + for (const turn of [-1, 0, 1.5, Infinity]) + assert.throws(() => validate(state, players, turn)); + const duplicate = structuredClone(state); + const extra = { + ...duplicate.receipts[0], + event: 'new-event', + target: 'new-target', + }; + extra.signature = tupileIntelligenceObservationSignature(extra); + duplicate.receipts.push(extra); + duplicate.signature = tupileIntelligenceStateSignature(duplicate); + assert.throws(() => validate(duplicate, players, 2)); +}); + +void test('malformed observations reject before append and never preserve a hidden hand or partial disclosure', () => { + const state = create('c'); + const before = JSON.stringify(state); + for (const patch of [ + { event: '' }, + { owner: 'other' }, + { target: 'c' }, + { faction: 'choam' }, + { faction: 'invented' }, + { category: 'both' }, + { spice: -1 }, + { spice: NaN }, + { spice: Number.MAX_SAFE_INTEGER + 1 }, + { count: -1 }, + { count: 0.5 }, + { turn: 0 }, + { phase: 9 }, + { phase: -1 }, + { signature: 'wrong' }, + { hand: ['private-card'] }, + ]) { + assert.throws(() => + append(state, { ...observation(), ...patch } as Parameters< + typeof append + >[1]), + ); + assert.equal(JSON.stringify(state), before); + } + const maximum = append(state, { + ...observation(), + spice: Number.MAX_SAFE_INTEGER, + count: Number.MAX_SAFE_INTEGER, + }); + validate(maximum, players, 2); + assert.throws(() => create(' ')); + assert.throws(() => + validate(null as unknown as TupileIntelligenceState, players, 2), + ); +}); + +void test('public validation reads no private fields and immutable inputs or returned use lists cannot alter receipts', () => { + const state = two(); + const seats: { id: string; faction: FactionId }[] = structuredClone(players); + for (const seat of seats) + for (const field of [ + 'spice', + 'hand', + 'forces', + 'reserves', + 'traitors', + 'battlePlan', + ]) + Object.defineProperty(seat, field, { + get() { + throw new Error(`Private ${field}`); + }, + }); + for (const receipt of state.receipts) { + Object.freeze(receipt.contact); + Object.freeze(receipt); + } + Object.freeze(state.receipts); + Object.freeze(state); + validate(state, seats, 4); + const factions = used(state); + factions.pop(); + assert.deepEqual(used(state), ['emperor', 'atreides']); + const next = append(state, { + ...observation(), + event: 'third', + target: 'g', + faction: 'guild', + contact: ['homeworld:guild'], + }); + assert.notEqual(next.receipts[0], state.receipts[0]); + assert.notEqual(next.receipts[0].contact, state.receipts[0].contact); + assert.equal(state.receipts.length, 2); + validate(next, [...seats, { id: 'g', faction: 'guild' }], 4); +});