+ {blocked &&
{blocked}
}
{placement
? 'Point your stronghold at a sector in any non-stronghold territory. Placement does not collect spice.'
diff --git a/components/moritani-terror.tsx b/components/moritani-terror.tsx
index 5ad80f17..a684b969 100644
--- a/components/moritani-terror.tsx
+++ b/components/moritani-terror.tsx
@@ -481,6 +481,8 @@ export function MoritaniEntry({
advisor: 'as an accompanying advisor',
wormRide: 'by worm ride',
ambassador: 'through the Fremen Ambassador',
+ homeworldRevival: 'through Homeworld revival',
+ caladanReinforcement: 'through Caladan reinforcement',
}[entry.cause];
return (
diff --git a/components/revealed-battle.tsx b/components/revealed-battle.tsx
index c8d4ab1e..21390fff 100644
--- a/components/revealed-battle.tsx
+++ b/components/revealed-battle.tsx
@@ -51,6 +51,12 @@ export function RevealedBattle({ game }: { game: GameView }) {
{game.advanced ? ` · ${plan.support} spice support` : ''}
{!!plan.allyPayment && ` (${plan.allyPayment} from CHOAM)`}
+ {battle.native === id && !battle.cards.some(isStoneBurner) && (
+
+ Native Homeworld bonus: +{battle.nativeBattleStrength} to the
+ battle score, separately from the dial
+
+ )}
Leader: {name(plan.leader)}
{plan.kwisatz && (
diff --git a/components/rules-reference.tsx b/components/rules-reference.tsx
index 3ce29dc6..5605824d 100644
--- a/components/rules-reference.tsx
+++ b/components/rules-reference.tsx
@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import { StrongholdCardGallery } from './stronghold-cards';
+import { HomeworldCardGallery } from './homeworld-cards';
import { createStrongholdCards } from '@/game/stronghold-cards';
import { useSearchParams } from 'next/navigation';
import { Suspense, useState, useEffect, useRef } from 'react';
@@ -69,6 +70,7 @@ function RulesReferenceContent() {
[
t.title,
t.summary,
+ t.searchText ?? '',
...t.steps,
t.example ?? '',
...(t.checklist ?? []).flatMap((item) => [
@@ -184,6 +186,7 @@ function RulesReferenceContent() {
players={[]}
/>
)}
+ {topic.id === 'homeworlds' && }
{topic.id === 'richese-cards' && (
& {
richeseAuction?: unknown;
+ battle?: { revealed: boolean; territory: string } | null;
automaticContinuationPending?: boolean;
},
) {
@@ -409,10 +410,12 @@ export function needsAutomaticRoomRecovery(
!!state.pendingTreacheryDiscard ||
!!state.response ||
!!state.richeseAuction ||
+ (!state.decision && !state.response && state.battle?.revealed === true && state.battle.territory.startsWith('homeworld:')) ||
state.decision?.kind === 'choamMarket' ||
state.decision?.kind === 'choamAudit' ||
state.decision?.kind === 'choamAuditPayment' ||
state.decision?.kind === 'ecazAmbassador' ||
+ state.decision?.kind === 'homeworldShipmentGuild' ||
state.decision?.kind === 'fullPlanRead' ||
state.decision?.kind === 'auctionPayment'
);
diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md
index 5156dea3..69311885 100644
--- a/docs/COMPONENT_INVENTORY.md
+++ b/docs/COMPONENT_INVENTORY.md
@@ -1,5 +1,7 @@
# Component inventory and verification ledger
+**Homeworld update, 9 September 2026:** all 13 cards and 26 physical faces are verified, including native population/battle values and occupied income symbols. The internal gallery supplies both faces, complete original gameplay prose, enlarged inspection and thirteen original bundled illustrations. Separate typed custody and population helpers are tested; actual Homeworld gameplay, occupation and effect integration remain unfinished and gated. See [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md) and [HOMEWORLD_COMPONENT_AUDIT.md](HOMEWORLD_COMPONENT_AUDIT.md). Earlier missing-Homeworld statements below are historical.
+
**Auditor update, 7 September 2026:** the separate strength-2 Advanced CHOAM disc and matching traitor are now represented, with one original portrait alongside Duke Prad Vidal in the special registry. Ordinary leader inventory remains 60. Root inspected Auditor at normal and enlarged sizes on desktop and phone; its inspector supplies complete original gameplay prose. Private audited-card faces, payment controls, setup identity, capture/foreign-ghola exclusions and first native revival have focused tests. Repeat/sixth-disc revival cycles and full module combinations remain unresolved. See [CHOAM_AUDITOR.md](CHOAM_AUDITOR.md); earlier missing-Auditor artwork/runtime statements below are historical.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
new file mode 100644
index 00000000..f5c7b3df
--- /dev/null
+++ b/docs/DEVELOPMENT.md
@@ -0,0 +1,102 @@
+# Development workflow
+
+Start with [README](../README.md) for installation and saved-game behavior and
+[AGENTS.md](../AGENTS.md) for project invariants. This page is a navigation and
+verification guide, not a second implementation-status log.
+
+## Architecture map
+
+| Concern | Entry points |
+| ---------------------------------- | -------------------------------------------------------------------------------------------------- |
+| Authoritative state and actions | `game/engine.ts`: `Game`, `applyAction`, `viewGame`, `normalizeAutomaticGame` |
+| Rule validation and calculations | Feature modules in `game/`; `*-quote.ts` modules compute validated outcomes before mutation |
+| AI | `game/bots.ts`: `botActions`, `runBots`; `game/bot-*.ts` and shared rule modules |
+| Persistence, concurrency, recovery | `db/rooms.ts`; D1 schema in `db/schema.ts`, additive SQL in `drizzle/` |
+| HTTP and session boundary | `app/api/rooms/route.ts`, `app/api/rooms/[code]/route.ts`, `app/api/rooms/[code]/control/route.ts` |
+| Table UI | `components/game-table.tsx` orchestrates feature components; `app/page.tsx` is the route entry |
+| Client request/retry state | `lib/client-request.ts`, `lib/room-entry.ts`, `lib/seat-recovery.ts` |
+| Player reference and evidence | `game/reference.ts`, `game/faction-reference.ts`, `app/rules/`; feature docs in `docs/` |
+| Tooling | `package.json`, `tools/test.ts`, `tools/test-discovery.ts`, `vite.config.ts` |
+
+For a rule change, locate its helper, engine action/continuation, projected player
+view, bot policy, UI and existing tests before editing. Keep server legality
+authoritative and reuse helpers across those consumers. Test rejection without
+mutation, physical resource conservation, interrupted decisions and saved JSON
+restoration where relevant. See `tests/fixture-hand.ts` for physical card fixtures.
+
+## Fast feedback
+
+```sh
+# Inspect selection without loading the engine or starting a server.
+npm test -- ecaz-collection ecaz-spice --list
+
+# Run related files, optionally narrowing test names.
+npm test -- ecaz-collection ecaz-spice
+npm test -- ecaz-spice --name 'fallback|conserves'
+
+# Broad offline validation once the change is coherent.
+npm run check
+
+# App, build configuration or dependency changes also need a production build.
+npm run build
+```
+
+| Command | Scope | Live server? |
+| -------------------------- | --------------------------------------------------------------------------------- | ------------ |
+| `npm test` | All offline tests: rules, bots, client/components, tooling and in-memory recovery | No |
+| `npm run test:unit` | Rules, bots, client/components and tooling | No |
+| `npm run test:recovery` | In-memory SQLite persistence and recovery | No |
+| `npm run test:integration` | Explicitly marked HTTP/session tests | Yes |
+| `npm run test:multiplayer` | Recovery plus HTTP tests; preserves the previous command's scope | Yes |
+| `npm run test:all` | Every discovered test | Yes |
+| `npm run check` | Typecheck, lint, then all offline tests | No |
+
+All test commands accept filename fragments, `--list`, `--name REGEX`, and
+`--concurrency NUMBER` after npm's `--`. Filename filters are literal substring
+matches, combined with OR; every supplied filter must match its selected suite.
+Name patterns are Node test-runner regular expressions and may skip every case,
+so check the reported results when using them.
+
+The runner discovers `tests/**/*.test.{ts,tsx,mts,js,mjs}` recursively and sorts
+the paths. New offline tests require no manifest entry. HTTP files carry the
+first-line annotation `// @dune-suite integration`; in-memory recovery tests
+are identified by their single-line `node:sqlite` import. A file referencing
+`process.env.DUNE_TEST_URL` without the HTTP annotation fails discovery. Tests
+that introduce a different network helper must also carry the HTTP annotation.
+The default offline suite includes every test without that annotation regardless
+of its unit/recovery classification.
+
+Offline test files run in isolated processes with at most four workers, avoiding
+Node's machine-sized default on large hosts. Suites containing HTTP tests default
+to one worker to avoid competing for the same development Worker. The launcher
+uses `node --import tsx`, which avoids the tsx CLI's extra IPC listener. The exact
+tsx version is declared directly and locked for clean installs.
+
+## HTTP testing and stored games
+
+Start the existing development environment with `npm run db:local`, then
+`npm run dev`. Reuse a server already serving this checkout. Tests default to
+`http://localhost:3000`; `DUNE_TEST_URL` can select a dedicated test server:
+
+```sh
+DUNE_TEST_URL=http://localhost:3000 npm run test:integration
+```
+
+The runner checks server availability before starting any selected HTTP suite.
+HTTP tests create their own rooms in that server's database; use a development
+or dedicated test instance. In-memory recovery tests execute production room
+code and migrations against disposable SQLite databases and do not touch
+`.wrangler/state`. Preserve that directory and existing human games.
+
+## Keeping future changes small
+
+TypeScript keeps incremental metadata under `node_modules/.cache/dune/`; build
+outputs and Wrangler state are excluded from typechecking. `*.tsbuildinfo` and
+patch backups are ignored. Git history preserves prior source versions.
+
+Use feature docs for rule decisions and evidence, and keep new evidence concise.
+The large [implementation status](IMPLEMENTATION_STATUS.md) log contains historical
+checkpoints; a previous green count is not proof that the current source passes.
+Full AI studies and browser acceptance belong to changes in their respective
+behavior, not every edit. Runtime optimizations should follow a measured bottleneck
+and retain legality, secrecy and saved-game invariants.
diff --git a/docs/ECAZ_ALLIANCE_IMPLEMENTATION.md b/docs/ECAZ_ALLIANCE_IMPLEMENTATION.md
new file mode 100644
index 00000000..73a5ebfd
--- /dev/null
+++ b/docs/ECAZ_ALLIANCE_IMPLEMENTATION.md
@@ -0,0 +1,107 @@
+# Ecaz Ambassador alliance formation
+
+Development checkpoint, 9 September 2026. The reusable Ecaz Ambassador now has
+an alliance offer and consent flow alongside direct Duke acquisition. Full Ecaz
+and expansion starts remain gated. The optional Duke loan remains unavailable
+pending a ruling on its origin, tenure and return destination.
+
+[Publisher evidence and unresolved loan questions](ECAZ_ALLIANCE_RULES.md) and
+[the runtime review](ECAZ_ALLIANCE_RUNTIME_READINESS.md) define this boundary.
+
+## Runtime and controls
+
+An actual eligible arrival opens the existing Ecaz-owned Ambassador decision.
+When Ecaz and the entrant are both unallied, Ecaz can offer an alliance. Choosing
+the offer returns the reusable token to supply immediately without changing the
+five random tokens. The entrant alone then accepts or refuses. Acceptance forms
+the reciprocal pair, records the current turn, clears obsolete proposals to or
+from either participant, and resets Ready. Refusal forms no alliance and grants
+no replacement Duke effect. Both outcomes resume the original entrant’s remaining
+actions; a worm rider continues its original remaining-ride sequence.
+
+Alliance eligibility uses only public seated identities and alliance links. It
+is independent of Duke availability, including the separate Advanced Harkonnen
+acquisition gate. The engine does not alter Duke, native leaders, private cards,
+spice or committed shipment counters when resolving this alliance. The ordinary
+Nexus action retains its phase restriction; the new permission belongs only to
+the current Ambassador event.
+
+A pure quote in `game/ecaz-alliance.ts` validates the public roster and produces
+the reciprocal links and cleaned offer record. Engine integration binds the
+entry’s owner, entrant, turn, phase, returned physical token and reply owner.
+The existing arrival-continuation validator also protects worm provenance.
+Action, view and automatic-recovery entry points reject orphaned or corrupted
+controls. Supported Nullentropy Box and other card suspensions preserve the
+reply until their own decisions finish. Room version checks allow only one
+competing acceptance/refusal to commit.
+
+The owner sees separate Duke and alliance choices, each with its own availability
+reason. The entrant sees explicit accept/refuse controls. Other seats see who
+must act. All four AI profiles prefer an available alliance offer, otherwise use
+the existing Duke/decline path, and accept valid owned replies. The chronicle
+explains token return, consent and continuation; successful formation emits the
+existing configurable house-colored automatic notice.
+
+## Coverage checklist
+
+| Area | Evidence | Remaining |
+| --- | --- | --- |
+| Implementation | Public quote, actual arrival, token commitment, consent, reciprocal links, continuation and saved-control integrity | Optional Duke loan; full faction and module combinations |
+| Player controls | Independent blocked reasons, owner offer, entrant accept/refuse, public decision ownership | Full multi-human mobile faction journey |
+| AI | All four levels propose/accept via their own projected state; existing Duke fallback tested | Relative negotiation strength and complete expansion games |
+| Documentation | Internal Ambassador guide and five-area checklist, source contract, runtime and implementation reports | Loan ruling and complete expansion guidance |
+| Verification | 8 pure, 18 engine and 4 production-room SQLite tests; mobile keyboard proposal, real AI acceptance and refresh | Loan lifecycle, full Advanced/expansion acceptance |
+
+The engine tests cover Basic/Advanced actual shipment, either already-allied
+participant, unavailable Duke, valid Moritani and BG fighter entrants, BG advisor
+exclusion, refusal, private prediction independence, worm continuation, all four
+AI levels, genuine paid Box suspension and invalid live/suspended receipts.
+SQLite tests use production migrations, authentication and room code. Duplicate
+and opposing accept/refuse requests race at the actual compare-and-swap; exactly
+one wins. Reload preserves seats and private data, while stale, duplicate and
+corrupted commands reject before a write. Unrelated rooms are preserved.
+
+## Browser evidence
+
+Isolated QA room `8S3MRDEK` was backed up at version 126. A staged Advanced
+position retained the three authenticated QA seats and used an actual engine
+shipment to create version 127’s Ambassador offer. Duke was deliberately in the
+Tanks and an Advanced Harkonnen seat was present, so his acquisition remained
+unavailable while the alliance action remained enabled.
+
+At 390×844, the browser displayed the distinct actions and availability text,
+with visible keyboard focus and no clipped action text. Keyboard Enter submitted
+the offer. The Medium AI entrant accepted and made its remaining movement.
+Version 132 retained reciprocal Ecaz/Emperor links, the same dead Duke, unchanged
+spice, an unchanged random Ambassador cohort and the reusable token in supply.
+Refresh restored the alliance. The chronicle contains one offer, one acceptance
+and subsequent movement. The normal viewport was restored. This verifies the
+owner’s browser flow and AI reply; human reply behavior is covered by engine and
+SQL tests, not claimed as a separate authenticated browser session.
+
+All **2,586** pre-existing non-QA room versions and state hashes remained unchanged
+before the new HTTP test fixtures. QA snapshots and evidence are local under
+`/tmp/dune-ecaz-alliance-qa-*.json` and
+`/tmp/dune-ecaz-alliance-browser-evidence.json`.
+
+## Validation status
+
+`npm run check` passed typecheck, lint and **3,058 offline tests**, including all
+30 new alliance cases. `npm run build` passed. `npm run test:integration` passed
+**40 tests** against the local development server. The independent review found
+no remaining concrete defect in the implemented consent flow.
+
+Twenty public-start Basic AI games across two through six players and all four
+levels completed **12,186 accepted actions** and **545 JSON round trips** with
+zero rejected candidates, stalls or invariant failures. Seed: `20261025`.
+Game-source fingerprints stayed unchanged throughout. These homogeneous-level
+Basic games check shared-engine regressions; they neither exercise gated Ecaz
+starts nor measure relative AI strength.
+
+Logs: `/tmp/dune-ecaz-alliance-check-final.log`,
+`/tmp/dune-ecaz-alliance-build-final.log`,
+`/tmp/dune-ecaz-alliance-http-final.log`, and
+`/tmp/dune-ecaz-alliance-fullgames.json`.
+
+The full goal remains active. No Advanced, expansion or optional-module start
+gate was opened. The removed maintenance automation was not recreated.
diff --git a/docs/ECAZ_ALLIANCE_RULES.md b/docs/ECAZ_ALLIANCE_RULES.md
new file mode 100644
index 00000000..31d783b4
--- /dev/null
+++ b/docs/ECAZ_ALLIANCE_RULES.md
@@ -0,0 +1,68 @@
+# Ecaz Ambassador alliance and Duke control
+
+Primary-source audit, 9 September 2026. Scope: the reusable Ecaz Ambassador’s alliance alternative and subsequent optional Duke transfer. No runtime changes. [Direct acquisition](ECAZ_DUKE_ACQUISITION.md) is already implemented; [Duke lifecycle](DUKE_VIDAL_RULES.md) records separate unresolved custody and revival combinations.
+
+## Source contract
+
+The publisher’s [E3 rulebook pp.7–9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=7), checked against `/tmp/dune-rules/ecaz-audit.txt`, establishes:
+
+- Ecaz chooses whether to trigger an eligible entry. Advisors, its existing ally and the matching faction cannot trigger the token.
+- Direct Duke acquisition and alliance formation are alternatives. Alliance requires “if neither of you are allied and they agree to form an alliance”.
+- After that alliance, Ecaz may give the new partner Duke control “for this turn”. This is optional and is not permission for arbitrary standing allied transfers.
+- The reusable Ecaz token returns to available supply. It is not one of the five random tokens whose exhaustion replenishes the cohort.
+- Ecaz’s acquired Duke lasts until battle use or Moritani takeover. Moritani has one-battle tenure and can qualify again on a later turn.
+
+E3 p.6’s Moritani alliance power is different: it expressly breaks existing alliances. Do not import that permission into the Ecaz token. The Moritani power’s exclusion of Ecaz is also not a prohibition on an unallied Moritani accepting Ecaz’s offer.
+
+## Entry, consent and token commitment
+
+Recommended direct composition of the printed trigger and consent rules:
+
+| Point | Authoritative result |
+| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Ecaz declines to trigger | Leave the placed token and Duke unchanged; resume entry. |
+| Ecaz chooses direct acquisition | Apply that separately validated effect; no alliance offer follows automatically. |
+| Ecaz chooses alliance | Commit this trigger and return the reusable token to supply; record the entrant-owned consent decision. Do not acquire Duke merely because the offer was made. |
+| Entrant refuses | Neither alliance nor Duke control changes. Resume the original entry. |
+| Entrant accepts | Establish the reciprocal alliance, then resolve Ecaz’s optional Duke-control choice if available, then resume entry. |
+
+The token-return sentence is not conditioned on successful consent. There is no retrieved refusal exception restoring the token to its former stronghold, reopening the direct-acquisition choice or refunding placement. Returning the token when the offer is declared follows the existing trigger/commit model; it must remain returned through a saved pending reply. This is a composition of the general trigger and specific return instructions, not a separate publisher worked example.
+
+Consent is needed from the actual entering faction; Ecaz cannot answer for it. Both parties must still be unallied at commitment. This branch grants no new Nexus, phase transition or extra movement. Preserve the triggering movement, worm ride or nested arrival’s continuation. The alliance is immediate rather than deferred to the next Nexus; subsequent effects must consult the new relation without replaying the completed arrival.
+
+**Duke availability is not an alliance prerequisite.** The alternative can form an alliance while Duke is dead, elsewhere, concealed or otherwise unavailable. Such states may remove the optional transfer choice, but must not expose private custody through the availability of the alliance itself. A successful alliance with no loan leaves the shared disc exactly where it was.
+
+## Existing alliance rules that still apply
+
+[Base p.12](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=12) requires public alliances, limits each player to one partner and grants only the stated shared advantages. Acceptance should announce both factions, update both links together and invalidate superseded offers. It does not transfer hands, expose private spice or change other players’ alliances. Ordinary breakup remains a Nexus action unless another specific power permits it. Ecaz Occupy subsequently handles legal co-occupation; formation does not merge physical force owners.
+
+**Bene Gesserit:** a fighter can be the eligible entrant; an advisor cannot. An alliance does not flip its other advisors, unlock a current-turn stance restriction, reveal/reset prediction or transfer prediction ownership. Base p.18 keeps prediction secret and permits it to win against an allied faction too. Shared occupation and the new three-stronghold win must retain that existing prediction precedence. [Base p.18](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=18)
+
+**Fremen:** an eligible worm-riding entrant can consent through the same interruption. No alliance-specific territorial exclusion is printed for this choice. Formation does not itself replay a worm, refund losses or create another ride. Keep established Fremen alliance protection and victory predicates separate; this task does not settle the previously identified Fremen victory combinations.
+
+**No-Field and optional modules:** an unrevealed marker remains a force under [E2 p.10](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=10), so do not inspect its hidden value to determine faction consent. If the Nexus-card module is enabled, E3 p.10 requires a held Nexus card to be discarded before entering an alliance; that is a real future integration obligation, not permission to turn on the unfinished module. Homeworlds are not ordinary Ambassador destinations.
+
+## Duke transfer: established limits and genuine gaps
+
+The transfer must use the existing unique Ecaz-identity disc, preserving death history and `usedAt`. It does not manufacture a native sixth leader or a Traitor card. Dead Duke cannot be made playable through an alliance; revival remains separate. The printed grant is a time-limited control transfer, not a change of native faction.
+
+Moritani’s p.6 acquisition can take Duke from a current controller when its own end-of-movement conditions are met. A normal living loan therefore cannot silently immunize Duke against that later power. A takeover must supersede the earlier loan receipt so its later expiry cannot steal Duke back from the new controller. Captured/concealed and ghola combinations retain the existing material custody boundary.
+
+| Unresolved point | Why it changes actual play |
+| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Origin of the optional transfer | The alliance sentence does not explicitly require prior Ecaz control, nor does it say the alliance alternative first acquires an available set-aside or Moritani-held Duke. Requiring prior control and permitting direct assignment produce different available choices. |
+| Unused loan expiry | The turn limit ends the ally’s permission, but the paragraph names neither Ecaz nor the set-aside pool as the destination. Returning to a previous controller is a third, distinct policy. |
+| Battle use during the loan | Ecaz and Moritani have explicit battle-use tenure. The ally clause states a turn duration; it does not expressly settle whether loan use consumes Duke’s temporary tenure or only follows ordinary same-territory leader-use restrictions. |
+| Alliance broken during the loan | Its permission is created by acceptance, but the text does not explicitly say whether a subsequent legal breakup terminates that permission immediately. |
+
+These are the existing loan/custody questions, not newly asserted prohibitions. They should be resolved together as a coherent loan lifecycle before activating transfer. Preserve the previous unresolved revival and Harkonnen capture rulings; nothing about a consensual alliance decides them.
+
+## Cancellation and implementation boundary
+
+The E3 p.16 Ambassador row prevents placement for the turn; it does not list cancellation of an already triggered alliance effect. Its separate Moritani Enemy of My Enemy row is not an Ecaz response. Do not add a generic cancellation window merely because the new decision forms an alliance. Existing printed/BG conversion machinery remains relevant only to an actually specified cancelable opportunity.
+
+The substantial implementable branch is the complete reusable-token consent lifecycle across genuine arrivals: owner choice, token return, entrant acceptance/refusal, immediate public reciprocal alliance, private-safe descriptors, saved continuation and all AI profiles. Preserve pending consent through disconnects and reject duplicate/stale replies before any token or alliance mutation. Tests should cover either participant already allied, a valid Moritani partner, BG fighter versus advisor, Fremen worm entry, unavailable Duke with successful no-loan alliance, refusal, and subsequent Occupy movement/collection/victory. Optional Duke control needs the explicit lifecycle ruling above; exposing it with an invented expiry would not complete the feature.
+
+## Fresh clarification search and provenance limits
+
+Official GF9 indexed p.9 was retrieved again and agrees with the local Duke paragraph. [Future Pastimes’ expansion page](https://futurepastimes.com/dune-ecaz-moritani) corroborates the faction’s alliance purpose but supplies no loan endpoint. Public metadata verifies Jack Reda – Future Pastimes Games as the author of [Alliance antics with Ecaz and Moritani](https://www.youtube.com/watch?v=jSiGgD2k3sI); the caption request returned no transcript, so this audit attributes no spoken ruling to it. The indexed [Duke Vidal recap](https://boardgamegeek.com/thread/3626139/duke-vidal-recap) returned only a title/loading shell. No retrievable, author-verified designer answer resolved the loan cases. Community summaries, search snippets without author boundaries and the known misattributed Occupy-rounding comment were not used as errata.
diff --git a/docs/ECAZ_ALLIANCE_RUNTIME_READINESS.md b/docs/ECAZ_ALLIANCE_RUNTIME_READINESS.md
new file mode 100644
index 00000000..9354dc71
--- /dev/null
+++ b/docs/ECAZ_ALLIANCE_RUNTIME_READINESS.md
@@ -0,0 +1,45 @@
+# Reusable Ecaz Ambassador alliance readiness
+
+Read-only architecture audit, 9 September 2026. This records the contract before the alliance branch is integrated. It does not claim optional Duke loans, full Occupy combat or complete Ecaz mode support. Rules authority is being independently reviewed; existing provenance is in [Ambassadors](ECAZ_AMBASSADORS_RULES.md) and [Duke Vidal](DUKE_VIDAL_RULES.md).
+
+## Source-confirmed branch and existing entry
+
+The source reviewer verified E3 p.7: both Ecaz and the entrant must be unallied, and the entrant must consent. Direct Duke acquisition and alliance formation are different alternatives. Duke availability is not a prerequisite for forming an alliance without a loan. BG prediction remains private and unchanged; advisor entry cannot trigger the token. The reusable Ecaz token returns to supply when triggered and never joins or completes the five-token random cohort.
+
+The existing producer near `engine.ts:6534` already establishes the owner, entrant, physical placed token, territory/sector, turn/phase and event, and pauses remaining actions. `finishAmbassador` near 6602 clears that receipt and resumes a saved worm ride exactly once. A Guild Ambassador's final BG accompaniment can transfer its outer continuation to a new Ambassador entry near 7280; preserve its `wormRider` and `guildAdvisorOrigin`, rather than replaying the completed Guild shipment.
+
+Current `decideAmbassador` near 7708 only accepts `choice:'duke'` for the Ecaz token. More importantly, `ambassadorEffectBlock('ecaz')` near 6501 applies the direct Duke gate before choice dispatch. That must be separated: a dead/captured/ghola Duke, same-controller direct-acquisition boundary, or Advanced Harkonnen must not disable an otherwise supported no-loan alliance.
+
+## Reuse alliance mechanics without importing Moritani's rule
+
+`formTerrorAlliance` near `engine.ts:8060` breaks old alliances, refunds touched aid, removes obsolete alliance offers, links the pair, stamps `allySinceTurn` and clears Ready. Its consent stage is useful precedent, but its permission to break existing pairs and refusal-triggered Terror effect are specific to Moritani. The Ecaz branch requires two currently unallied players and must reject before spending the token if either is already allied.
+
+The ordinary Nexus action near 15960 also links reciprocal IDs, stamps the current turn and clears Ready. Its phase restriction must remain intact; the Ambassador is a separate authorized source, not a general relaxation of Nexus timing. A small shared reciprocal-link transaction can serve both special sources after each source validates its own prerequisites. Clear offers involving either new member so an older offer cannot later create a conflicting pair. Validate rather than silently redirect stale escrow. Any actual refund must use the existing safe resource quote and pay its original donor once.
+
+Allied movement, shared collection, victory and faction benefits mostly derive from current reciprocal IDs. Preserve those dynamic consumers. Stamp both `allySinceTurn` fields so existing departure deadlines remain consistent; Ecaz Occupy then permits supported territorial coexistence. Do not clear private hands, BG prediction, paid shipments, movement counters, battle history, traitors or source-bound card reservations as an alliance side effect. Existing promise reconciliation should run after the accepted action; do not manufacture new answers or expose a partner's private commitments.
+
+## Agreed finite API
+
+At the existing owned offer, `{type:'decision', event, trigger:true, choice:'alliance', beneficiary:owner}` validates the actual entry and both unallied identities before calling `triggerAmbassador`. It returns the token to supply immediately and records `effect:'ecaz'`, `stage:'allianceReply'`, `beneficiary:entrant`; the new `ecazAmbassador` decision belongs to the entrant.
+
+The entrant sends `{type:'decision', event, accept:boolean}`. Require the exact boolean and original event. Acceptance revalidates the two unallied identities, forms the pair, clears obsolete offers/Ready, then finishes the Ambassador continuation. Refusal finishes without an alliance, Duke acquisition, fallback effect or token restoration. The latter is the coordinator's composition of committed trigger plus refusal; it is not a retrieved publisher rollback sentence. Declining the original **trigger** still leaves the placed token intact.
+
+The existing generic decision-owner check must explicitly handle `allianceReply`; do not accidentally authorize every later Ecaz stage by the direct-acquisition beneficiary rule. On a saved reply the physical token is now in supply, so validation must distinguish the committed token from the still-placed offer. Bind owner/faction, entrant, effect, stage, source event, turn/phase and continuation. Reject orphaned live or suspended controls as well as mismatched events. No new generic Ambassador Karama response is introduced by this contract; Moritani's alliance cancellation is not authority to add one here.
+
+Nullentropy Box and other supported decision suspensions should retain the complete reply and restore it after their own event finishes. Use the existing saved-control pattern and normalizer/CAS, not another entry event or rollback copy of the game. Test the ordinary phase-five continuation and actual phase-one worm suffix separately.
+
+## Duke loan remains a separate material boundary
+
+`game/duke-vidal.ts` has `source:'ally'`, but that tag is not an implemented loan lifecycle. `expireDuke` releases only unused Moritani tenure; its existing test explicitly preserves unresolved allied tenure. `consumeDuke` always clears controller/source, and actual battle resolution near `engine.ts:11063` calls it when Duke appeared in either plan. Residual Poison also releases his control after death near 9451.
+
+`controlledLeaders` near 9510 and the player projection near 17816 already make the same physical disc available to its controller, subject to exceptional capture/ghola/Advanced-Harkonnen gates. Reusing those consumers is appropriate only after loan expiry, unused return destination and battle-use return are source-resolved. Preserve disc identity, death history and `usedAt`; neither a loan nor a return should grant a second battle use automatically. Ordinary Ecaz tenure persists, Moritani can supersede it, and revival/capture rules retain their separate gates.
+
+The current agreed alliance branch changes **no Duke state**. Optional loan controls remain explicitly unfinished until authoritative return behavior is settled. This still delivers a complete consent-based alliance alternative, including its entry, token, aftermath, UI, AI and recovery behavior.
+
+## Public controls, AI and evidence
+
+Project owner-offer `allianceOffer:{blocked:string|null}` independently from `dukeAcquisition`. Eligibility depends on public identities and entry facts, not hidden Duke custody. Show a distinct alliance action, the existing direct-acquisition action and leave-in-place action. The entrant sees accept/refuse with the actual inviter and territory; everyone else sees who must respond. No confirmation is needed after an accepted automatic link or token return.
+
+All four AI profiles can propose when the authoritative descriptor permits it, otherwise retain direct-Duke/decline behavior, and accept a valid owned reply. They receive no rival prediction, hand or balance through this path. Later negotiation strategy can differ without changing legality or inventing a Duke loan.
+
+Focused evidence should use actual shipment and worm entry; assert both decision owners, immutable rejection of stale/malformed/foreign actions, both existing-alliance exclusions, exact token/cohort conservation, no Duke changes even when Duke is unavailable, accepted/refused continuation, private prediction invariance, actual Box suspension and JSON recovery, and all four AI policies. Production room CAS races belong to the coordinator's separate recovery tests. Keep unrelated expansion release gates in place.
diff --git a/docs/ECAZ_COLLECTION_IMPLEMENTATION.md b/docs/ECAZ_COLLECTION_IMPLEMENTATION.md
new file mode 100644
index 00000000..016bb6af
--- /dev/null
+++ b/docs/ECAZ_COLLECTION_IMPLEMENTATION.md
@@ -0,0 +1,95 @@
+# Ecaz shared spice collection
+
+Verified development checkpoint, 9 September 2026. This completes the bounded
+shared-collection flow; complete Ecaz, Advanced and expansion starts remain gated.
+The [source contract](ECAZ_COLLECTION_RULES.md) records publisher precedence and
+sector-capacity interpretation. The earlier
+[runtime readiness review](ECAZ_COLLECTION_RUNTIME_READINESS.md) is a design
+proposal; the implemented commitment order below supersedes its deferred-payment
+proposal.
+
+## Implemented behavior
+
+The engine computes each collecting faction’s eligible sector capacity before
+extracting spice. When both Ecaz and its reciprocal ally collect from the same
+desert territory, their combined extraction becomes one shared pool. The board
+loses that spice once. Ordinary collection and bank income settle automatically;
+shared spice is held outside both players’ balances until allocated.
+
+Ecaz proposes first as an interface convention. The other ally can accept or
+counter. Either current decision owner can use the equal split, with odd spice
+going to the ally. Each settled territory has one persisted receipt. Unaccepted
+proposals are visible only to the pair; the chronicle records final allocations
+and their reason. Empty pools create no confirmation.
+
+Advanced co-occupied Arrakeen, Carthag and Tuek’s Sietch pay both allies their
+normal bank income. A meaningful Karama window precedes that commitment.
+Cancellation removes only Ecaz’s income from those shared strongholds. Printed
+Karama and Bene Gesserit Worthless conversion preserve the same source and effect.
+No available blocker means automatic continuation.
+
+Pending events bind their turn, public collecting forces, original deposits,
+allocation index and settled receipts. Actions, player views and automatic
+recovery reject missing or falsely completed collection parents, including
+controls suspended by Nullentropy Box and Karama conversion. Legitimate card
+income during allocation is retained: settlement adds to the current balance.
+Room version checks prevent simultaneous or stale acceptance from paying twice.
+
+## Coverage checklist
+
+| Area | Verified boundary | Evidence |
+| --- | --- | --- |
+| Implementation | Sector capacities, one pool per desert, ordinary/bank commitment, bilateral allocation, scoped cancellation, recovery integrity | `game/board-resolution-quote.ts`, `game/ecaz-spice-allocation.ts`, `game/engine.ts` |
+| Player controls | Private proposal/counteroffer/acceptance, equal fallback, decision ownership, mobile controls, keyboard completion, refresh | `components/ecaz-spice.tsx`, `components/game-table.tsx` |
+| AI | All four profiles use the equal fallback or accept a fair offer without consulting rival balances | `game/bots.ts`, `tests/ecaz-collection-engine.test.ts` |
+| Documentation | Internal phase and faction guidance, source precedence, commitment order and explicit release limits | `game/reference.ts`, this document and the linked source contract |
+| Verification | Pure, engine, interruption, privacy and SQLite concurrency/recovery cases; real browser allocation | Four collection test files listed below |
+
+## Verification
+
+`npm run check` passed typecheck, lint and **3,028 offline tests**, including the
+user’s test-discovery and failure-propagation self-tests. `npm run build` passed.
+`npm run test:integration` passed **40 tests** against the local development
+server. These suites have different scopes; historical counts used the previous
+manual test registration and are not directly comparable.
+
+The focused collection coverage is **60 tests**:
+
+- `tests/ecaz-collection-quote.test.ts`: 20 capacity and income cases.
+- `tests/ecaz-spice-allocation.test.ts`: 11 protocol cases.
+- `tests/ecaz-collection-engine.test.ts`: 26 actual transition, privacy, AI,
+ cancellation and suspended-parent recovery cases.
+- `tests/ecaz-collection-recovery.test.ts`: 3 SQLite scenarios, including racing
+ proposals/acceptance, printed and converted cancellation, reconnect and secrecy.
+
+The browser resumed the isolated three-seat QA room `8S3MRDEK`. Actual last
+movement entered Advanced collection, paid bank income automatically and opened
+the five-spice Hagga Basin allocation. The Medium AI ally accepted the 2/3 split.
+Refresh restored the six-spice Wind Pass choice; keyboard navigation and Enter
+selected its 3/3 equal split. At a 390×844 viewport, controls were readable,
+44 pixels tall, visibly focused and free of horizontal overflow. The normal
+viewport was restored afterward. Room version 126 retained two receipts, one
+spice on Wind Pass and total balance changes of +7 Ecaz, +8 ally and zero rival.
+All **2,556** pre-existing non-QA room versions and state hashes were unchanged.
+This staged position verifies the interaction, not a complete expansion game.
+
+Twenty ordinary production-AI Basic games, spanning two through six players and
+all four levels, completed **10,206 accepted actions** and **465 JSON round trips**
+with zero rejected candidates, stalls or invariant failures. Seed: `20261024`.
+Source fingerprints stayed unchanged during the run. These games cannot exercise
+Ecaz because expansion starts remain gated, and homogeneous-level games do not
+measure the relative strength of the levels.
+
+Local logs: `/tmp/dune-collection-sep09-check-verified.log`,
+`/tmp/dune-collection-sep09-build-final.log`,
+`/tmp/dune-collection-sep09-http.log`,
+`/tmp/dune-collection-sep09-fullgames.json`, and
+`/tmp/dune-collection-sep09-browser-evidence.json`.
+
+## Remaining release gates
+
+Combined Occupy combat and its source discrepancy, Fremen’s Ecaz endgame
+exception, Tleilaxu Ambassador runtime, remaining Duke interactions, complete
+Advanced/expansion/module games and broader presentation acceptance remain
+unfinished. This checkpoint enables none of those modes. The maintenance
+automation remains removed.
diff --git a/docs/ECAZ_POISON_INCOME_RULES.md b/docs/ECAZ_POISON_INCOME_RULES.md
new file mode 100644
index 00000000..ab0363ba
--- /dev/null
+++ b/docs/ECAZ_POISON_INCOME_RULES.md
@@ -0,0 +1,169 @@
+# Ecaz poison-discard income
+
+Audit: 9 September 2026. This covers high Ecaz's card-discard income, not its
+Homeworld victory condition or a complete Ecaz release.
+
+## Source contract
+
+The original Ecaz high face was visually rechecked in
+[BGG component photograph 7767034](https://boardgamegeek.com/image/7767034/dune-ecaz-and-moritani)
+(`/tmp/dune-card-economy-homeworlds.jpg`). At native population 7–20, it awards
+three bank spice for each discarded poison weapon. It does not restrict the
+discarding faction, phase, location, reason or number of awards. Consequently,
+Ecaz's own discards qualify too; a two-card qualifying batch pays six. The
+wording makes collection automatic, with no separate optional claim.
+
+[GF9 E3, pp.9–10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9)
+makes high eligibility depend on current native reserves and protects Homeworld
+advantages and penalties from Karama. Check Ecaz's own Homeworld at the actual
+discard event. An ally's reserves or foreign visitors do not supply its native
+threshold. Neither merely playing Ecaz nor selecting Advanced rules activates
+the Homeworld module. Occupation lifecycle questions remain documented in
+[Homeworld rules](HOMEWORLD_RULES.md).
+
+The [November GF9 FAQ, pp.3–5,8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
+clarifies generic weapon categories for special cards, Chemistry's conditional
+role, and unused Poison Tooth disposal. The
+[E2 rulebook, p.9](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=9)
+independently requires Chemistry to be played as a weapon for the poison-weapon
+Leader Skill bonus. These indexed publisher passages were freshly retrieved;
+direct PDF endpoints remain unavailable. No specific Ecaz/Chemistry worked
+example was retrieved, so composing the actual played role with the discard
+trigger is an explicit interpretation from those category rules.
+
+## Classification
+
+| Physical card / runtime kind | Qualifies when actually discarded? |
+| --- | --- |
+| Chaumas, Chaumurky, Gom Jabbar, Ellaca Drug; Basilia Weapon (`poison`) | Yes, including unused hand disposal or a cost. |
+| Poison Blade (`poisonBlade`) | Yes: its generic attack categories include poison and projectile. One card still produces one award. |
+| Poison Tooth (`poisonTooth`) | Yes: poison weapon, including an unused Tooth that must nevertheless be discarded. Activation changes its attack, not its category. A retained Tooth produces no discard award. |
+| Chemistry (`chemistry`) | Yes when discarded from its actual weapon slot, with the already validated complementary defense. No when used as defense or discarded directly from hand. Potential weapon use is insufficient. |
+| Shield Snooper, Snooper, Portable Snooper | No: poison defenses are not poison weapons. |
+| Artillery Strike, Lasgun, Stone Burner, projectile weapons, Weirding Way | No: killing a leader does not make a card a poison weapon. |
+| Residual Poison | No: its verified physical card is Special, with a pre-leader effect, not a poison battle weapon. The name alone supplies no category. See [Richese component audit](RICHESE_COMPONENTS.md). |
+| Worthless, Cheap Hero, ordinary special cards | No, even if occupying a weapon slot. |
+| Mirror Weapon | Direct hand disposal: no. A played copy uses its retained actual copied weapon category, including poison, Blade, Tooth or weapon Chemistry. No opposing weapon gives no poison category. Existing Mirror activation/retention work remains separate. |
+
+The Chemistry distinction must use the plan slot at disposal, before battle
+cleanup loses that information. In particular, winner optional disposal and
+Moritani retention occur after the engine clears `g.battle`; they need an
+event-bound retained role receipt rather than consulting a missing plan. Do not call `weaponTypes(card)` on every card:
+that helper assumes a weapon role and would incorrectly classify hand or
+defense Chemistry. Do not rewrite a physical card's stored kind to its battle
+role. Generic printed poison/Blade/Tooth discards do not require a battle.
+
+These classifications apply equally to mandatory cleanup, voluntary winning
+card disposal, hand reduction, exchange costs, CHOAM cash-in or Ambassador
+effects, and paid Kaitain disposal. Merely returning an unsold lot to the deck,
+transferring a card between hands, recovering a discard, reshuffling the old
+discard pile or removing a card from the game is not a new discard.
+
+## Battle cleanup timing
+
+The [GF9 base rulebook, p.11](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=11)
+describes losing cleanup (all losing forces and played cards) before winning
+cleanup (dialed force losses, then card retention/disposal). The
+[official Quick Start, p.11](https://www.gf9games.com/dunegame/wp-content/uploads/Quick-start-Guide.pdf#page=11)
+uses the same order in its worked battle. These publisher-indexed passages
+support the existing losing-discard-before-winner-casualties sequence; they do
+not number smaller simultaneous timing steps. Accordingly, native Ecaz winning
+with seven reserves can receive losing-poison income while still high, then
+fall to six when its own dialed losses are removed. Its subsequent winning-card
+discards use that new low population. E3's continuous threshold rule supplies
+the immediate change; no contrary E3 cleanup rule was retrieved.
+
+The original E1 Poison Tooth face was freshly inspected in
+[component photograph 5495302](https://boardgamegeek.com/image/5495302/dune-ixians-and-tleilaxu)
+(`/tmp/dune-ix-cards.jpg`). It permits declining the weapon after seeing battle
+results and contains no physical-discard instruction. The November FAQ, p.8,
+requires a used winning Tooth to be discarded, through its winner/loser
+retention answer; it does not instruct discarding upon activation. E2, p.4,
+replaces this original face. Retrieval of the revised physical face remains
+incomplete, so it cannot be cited as granting an earlier disposal event.
+
+The source-based implementation recommendation is therefore to retain losing
+card disposal in losing cleanup, and apply a winning used Tooth's mandatory
+disposal after winner casualties, alongside winning-card cleanup. This is a
+composition of the published cleanup order and the FAQ retention exception,
+not an explicit Ecaz/Tooth worked ruling. Selecting Tooth activation alone is
+not a physical discard: a subsequent successful traitor declaration still
+overrides ordinary weapon resolution. Do not manufacture a new activation-time
+discard or snapshot the battle's starting population for later disposal.
+
+## Pure API and engine responsibilities
+
+`game/ecaz-poison-income.ts` exports:
+
+```ts
+type EcazPoisonDiscard = {
+ card: Card;
+ battleSlot?: 'weapon' | 'defense' | 'leader';
+ effectiveWeapon?: EffectiveWeapon; // retained validated Mirror copy
+};
+quoteEcazPoisonIncome(context, discarded)
+// => { player, amount, count } | null
+```
+
+The context is the Game subset `advanced`, `players`, `homeworlds`. The quote
+uses public native custody and discarded physical descriptors; it does not read
+hands, spice balances, opponents' plans or discarded-by identity. It validates
+unique physical IDs and supplied slot values. For a played Mirror, the caller supplies its retained
+`resolveBattleWeapons` descriptor. A missing or conflicting descriptor raises
+`EcazPoisonIncomeError` as an integrity failure. The
+[Mirror source audit](MIRROR_WEAPON_ENGINE_AUDIT.md) establishes actual-role
+copying; applying that role to this discard trigger is source composition,
+not a separate new ruling. The earlier unanswered question about mandatory
+physical disposal after copying Tooth/Artillery concerns whether a discard
+occurs, not what category its already established copy has. A recovered poison may later be
+discarded again and earn again: deduplication belongs to the particular event,
+not a permanent set of rewarded card IDs.
+
+The engine must prove the cards genuinely cross into discard custody, obtain
+the actual prior battle slots, pay exactly once, and preserve that payment
+through JSON/CAS and any fresh-discard continuation. Quote before mutation;
+commit the physical transition and its income in the same transaction. Do not
+pay again when merely resuming `pendingTreacheryDiscard`. The quote's current
+population must correspond to that actual event; neither a phase-start snapshot
+nor a later reconstruction after unrelated force changes is equivalent.
+
+Use the existing visibility of each source discard. A private discard remains
+private; do not expose card IDs/names or change `publicFace: false` to publish the
+income. Current game logs are globally projected. Any added public income
+message must be reviewed for revealing a private discarded card's category;
+private account evidence is preferable where the source keeps its face secret.
+
+## Physical mutation audit
+
+At this audit, `rg` found these direct engine mutations:
+
+| Location | Meaning / integration requirement |
+| --- | --- |
+| `discard(g,p,id)` | Central hand-to-pile transition; covers battle cleanup, gifts' disposal costs, sales, cash-in, Ambassador/terror discards and ordinary card play. Preserve plan slot before cleanup. |
+| `finishNullentropy`: `g.discard = result.discard` | Removes the recovered target, shuffles old cards and appends the used Box. Only the Box is freshly discarded; recovering a poison or moving old poison within the pile earns nothing. |
+| `finishOrnithopter`: `g.discard.push(flight.card)` | Played-card-to-pile transition; Ornithopter itself does not qualify. |
+| Distrans action: `g.discard.push(result.discarded)` | Only played Distrans is discarded; the transferred card is not. |
+| `draw`: `g.discard = []` | Reshuffle into the deck, not fresh disposal. |
+| `newGame`: `discard: []` | Initialization, not disposal. |
+| Guild cancellation: `g.discard.splice(...)` | Retrieves a previously spent rate card; no new discard or refund of prior Ecaz income. |
+
+`stageTreacheryDiscard` and `finishTreacheryDiscard` preserve receipts and resume
+effects; they are not additional physical transfers. Existing `discard()` had
+no Ecaz income hook when this audit began. No other direct `.discard.push` or
+`.discard =` mutation was found in `game/`. The battle-resolution quote returns
+card-ID disposal instructions; its engine adapter executes them through
+`discard()`. Winner cleanup and Moritani retention also use that helper.
+`resolveNullentropyBox` and inactive `resolveSemuta` construct detached discard
+arrays; Box is accounted for above, while future Semuta integration must not
+re-award a recovered poison. Generic hand-transfer helpers move cards without
+discarding the transferred identity.
+
+## Verification
+
+Eight focused tests in `tests/ecaz-poison-income.test.ts` pass. They cover all
+seven qualifying physical cards in the currently assembled base+Ix inventory,
+both game modes, conditional Chemistry, all remaining current card kinds,
+exact threshold and module-off behavior, foreign visitors, private-access
+traps, JSON stability, malformed/duplicate receipts and actual Mirror-copy descriptors.
+Run `npm test -- ecaz-poison-income`. These are pure-quote tests; engine timing,
+income visibility and multiplayer recovery need separate integration evidence.
diff --git a/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md b/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md
new file mode 100644
index 00000000..106068ab
--- /dev/null
+++ b/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md
@@ -0,0 +1,27 @@
+# Guild transport from Arrakis to Homeworlds
+
+9 September 2026. Fresh primary-source audit for the next transport integration. This supplements [the transport source update](HOMEWORLD_TRANSPORT_SOURCE_UPDATE.md); it does not resolve the already recorded Junction payment, foreign-garrison departure, or ordinary Karama timing questions.
+
+## Route and custody contract
+
+E3 permits Guild shipment from Arrakis to a Homeworld and classifies it as cross-shipment. Guild can return to its own Junction or invade another native faction's world; an allied destination is forbidden. Guild's ordinary alliance grant does not supply this new route to its ally. Junction's separate high-face permission has its own provenance. [GF9 E3, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10), [component audit](HOMEWORLD_COMPONENT_AUDIT.md#verified-high-side-effects).
+
+The implementable tariff is `ceil(physicalCounters / 2)` for Guild. This composes E3's one-spice-per-force Homeworld tariff with its Guild half-price clause; the paragraph does not give a separate Arrakis-origin worked example. For return to Junction, the base reserve-return price independently produces the same result. Do not use the two-spice non-stronghold territory tariff for a Homeworld. [GF9 E3, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10), [GF9 base, pp.19,23](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=19).
+
+The base Guild alternatives use the faction's single shipment and start in one territory. Its Q&A expressly allows counters distributed among sectors of that territory to ship together. Preserve exact typed source groups; reject combining different territories. The Storm Obstruction rule prevents departure of counters under storm. It supplies no exception for Guild cross-shipping; do not reject an entire partly covered territory when the selected counters are outside storm. [GF9 base, pp.7,19,22](https://www.qugs.org/rules/r283355.pdf#page=7).
+
+The Hidden Mobile Stronghold is independently protected from storms. Its interior is the source when shipping out; its pointer locates its ordinary-board entrance. Do not substitute the pointer's storm-covered sector for the protected interior. This follows the printed protection, rather than a dedicated Guild-departure FAQ example. [GF9 E1, p.9](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=9).
+
+## Distinct card interactions
+
+Guild's special Karama names an off-planet shipment of any player. It does not say that the origin must be off Arrakis, or prohibit choosing Guild itself. A physical Arrakis-to-another-world shipment fits the interplanetary predicate used in the preceding audit; this is source composition, not a new explicit FAQ example. Preserve the existing optional owned response when Guild holds the card, including its own shipment. Never use an Arrakis-origin flag as blanket special-interception immunity. [GF9 base, p.14](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=14).
+
+Ordinary Karama has separate targets for Guild transport permission, its half-price benefit, and payment income. E1's reference prohibits cross/return transport when the Three Types advantage is canceled; it separately removes the discount. E3's immunity protects Homeworld face effects, including Junction's granted permission, without turning Guild's ordinary transport advantage into a face effect. Keep those sources separate. This audit does not choose a new announcement, repricing, or affordability policy for [the pending timing questions](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing). [GF9 E1, p.12](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=12), [GF9 E3, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10).
+
+## Technology and payment
+
+E3's FAQ confirms Heighliner activation for shipment **to** a Homeworld, without restricting the origin or requiring a foreign destination. Preserve E1's exception when Guild is the only qualifying shipper. Thus Guild's own Junction return or invasion alone earns no Heighliner income; a future non-Guild Junction-sponsored shipment to a Homeworld qualifies. Settlement activates the token once for the phase, rather than awarding spice per transported counter. [GF9 E3, p.15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=15), [GF9 E1, p.5](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=5).
+
+Retain the [contributor-specific payment contract](GUILD_SHIPMENT_PAYMENTS.md#publisher-contract-and-precedence). The shipper's personal fee for this route goes to the bank; any non-Guild ally contribution follows the independently recorded November funding rule. Funds, custody, typed totals, shipment usage, and technology remain unchanged until a valid settlement. No sector, territorial entry effect, or stronghold occupancy cap applies to the Homeworld destination.
+
+Validation: freshly retrieved publisher-indexed E3 pp.10/15 and E1 pp.5/9/12, plus base pp.14/19/22. Direct publisher PDF requests failed; the accessible base mirror contains the publisher-authored 24-page rulebook. Fan compilations and tournament rules were excluded. Local document links reviewed; no runtime, saved game, or automation changed by this audit.
diff --git a/docs/GUILD_HOMEWORLD_TRANSPORT_RUNTIME.md b/docs/GUILD_HOMEWORLD_TRANSPORT_RUNTIME.md
new file mode 100644
index 00000000..9f314922
--- /dev/null
+++ b/docs/GUILD_HOMEWORLD_TRANSPORT_RUNTIME.md
@@ -0,0 +1,25 @@
+# Guild transport from Arrakis to Homeworlds
+
+9 September 2026. [Source contract](GUILD_HOMEWORLD_TRANSPORT_RULES.md). This extends the existing [world-to-world shipment runtime](HOMEWORLD_SHIPMENT_RUNTIME.md); Homeworld starts remain disabled pending the complete module.
+
+## Implemented behavior
+
+Guild can ship physical forces from one Arrakis territory to a foreign Homeworld or return them to Junction. Several sectors of that same territory may contribute. The quote rejects mixed territories, invented sector spellings, unavailable counters, storm-covered source sectors, non-Guild users and allied Homeworld destinations. The Hidden Mobile Stronghold has its existing protected interior; its ordinary board pointer does not turn its forces into an exposed storm sector.
+
+The tariff is one spice per two physical counters, rounded up. Native returns credit Junction's reserves; invasions create an explicit foreign pool. Both remove exactly the selected Arrakis counters and preserve the other groups, Tanks and physical special-force inventory. No Homeworld receives a fake sector, board geometry or territorial arrival effect.
+
+The route consumes shipment and retains movement. Guild's printed Heighliner exception remains in force. A non-Guild ally's pledged contribution follows the existing November funding rule, separately from the Guild shipper's bank payment; no receipt can fund its own shipment.
+
+The same event-bound declaration and Guild special-interception machinery serves both interplanetary routes. The new route explicitly freezes the shipper's board pools, storm and active mobile pointer as well as world custody, alliances, turn, cost and funding. The optional self-interception follows the source's unrestricted player predicate. With no legal stop it continues automatically. A real choice remains actionable, including after a paid Box interruption. The previously documented provisional stopped-shipment settlement and ordinary cancellation questions remain unresolved.
+
+The existing `guildShip` return-to-reserves API and its Homeworld destination form now pass through this same declaration when Homeworlds are active. They cannot bypass the new custody, funding, interception or source validation. Other modes retain their prior route handling.
+
+## Controls, AI and verification
+
+The Guild's table has a separate Arrakis-source transport panel with explicit accessible names, sector quantities, storm guidance, Junction/foreign destinations and a shared payment quote. All four AI profiles use that quote through their own public view, consider native defense and existing invasions, retain useful defenders/collectors and preserve normal movement alternatives. Rival private resources and concealed counters do not enter this adapter.
+
+Pure, actual-engine, all-profile, rendering and production SQLite tests cover route legality, costs, source custody, genuine setup, old API parity, interruptions, competing requests, corrupt saved receipts and private projection. Source staging used by special tests is identified in the tests; it does not substitute for complete module games.
+
+Final `npm run check` passes types, lint and all **3,340 offline cases**, including 36 new Guild transport regressions; the production build and **40 HTTP cases** also pass. Independent review found no further defects. An isolated browser QA room used genuine Advanced setup with a staged shipment opportunity: desktop controls rejected six forces from a five-force source, quoted five forces at three spice, and submitted the declaration by keyboard. At 390×844, both owned Guild interception choices remained actionable after refresh; keyboard allowance and another refresh preserved five Guild invaders on Caladan, fifteen native reserves, two spice, zero Tanks, the unspent Karama and the remaining movement. No page errors or horizontal page overflow occurred. The completed phone table was visually inspected. All **2,771** pre-existing rooms retain their exact versions and state hashes, with none missing; the existing server was reused.
+
+Junction-sponsored transport, other factions' Arrakis permissions, unsponsored foreign departures to Arrakis, concealed No-Field transport, ordinary Guild rate/permission cancellation timing, the generic purchased Karama destination scope, occupation, remaining faces and complete Homeworld games remain unfinished. No game-start or publication gate is opened, saved games are preserved, and maintenance automation remains removed.
diff --git a/docs/HOMEWORLD_ART_PROMPTS.json b/docs/HOMEWORLD_ART_PROMPTS.json
new file mode 100644
index 00000000..60472a13
--- /dev/null
+++ b/docs/HOMEWORLD_ART_PROMPTS.json
@@ -0,0 +1,137 @@
+{
+ "generationMode": "built-in image_gen",
+ "assetType": "Original artistic interpretation; standalone Homeworld planet illustration",
+ "generatedDate": "2026-09-09",
+ "assets": [
+ {
+ "id": "caladan",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-c303d3d4-6ed8-4a86-87fa-dbb944a438e2.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Caladan, an ocean world: immense deep cobalt seas, scattered lush island chains and sweeping white weather systems; blue and jade.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/caladan-v1.png",
+ "sha256": "ff8a7c967856fb56e73f7e1e03cf6e90828f47f7c7e63c56cd9f4186e69b583b",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "ix",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-30201195-892d-465b-88b5-e0a8f192b9ed.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Ix, a shadowed technological world: graphite terrain with dense subtle geometric machinery and a few amber lights glowing through atmospheric haze; charcoal and subdued bronze.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/ix-v1.png",
+ "sha256": "1af35b06cd9bc27c5fee54a8319059c3b0e46fd55a4613291f2d498d0bbc85b0",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "ecaz",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-051e0958-886c-4623-bf6f-51c4aa0158fc.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Ecaz, a lush forest world: vast emerald forests, curving green continents, delicate rivers and white cloud banks; rich green and turquoise.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/ecaz-v1.png",
+ "sha256": "ce4bedf884da51b6945cf5258a2afd00bd5805ad7274e2d22756865e8e203014",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "southern_hemisphere",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-d43b00db-c8f2-49d7-8a56-9df9515e7fca.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Southern Hemisphere of Arrakis, an entirely amber desert planet: enormous golden dune fields, burnt umber rock basins, tawny dust storms and dry ochre ridges; no oceans or vegetation.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/southern_hemisphere-v1.png",
+ "sha256": "3cbad2cf8dbdd364a81867e80ddf5cd3d1700c6dd2dd7729d4f5df4b9b9cec47",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "richese",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-6c35cad1-8960-416f-9046-0b14f6bf672d.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Richese, a silvery industrial world: pale silver-grey terrain and dense refined industrial lattices, metallic valleys, cool white haze; soft silver, steel blue and graphite.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/richese-v1.png",
+ "sha256": "4cb685455459a00351f0b971e630a2a382f3a20e50a15bf140887e6447a9be06",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "tupile",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-61923313-474f-4af2-93d3-e521831211ff.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Tupile, a hidden green sanctuary world: muted green forested land and secluded mountain basins partly veiled by cloud cover; atmospheric and mysterious, moss greens and pale jade.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/tupile-v1.png",
+ "sha256": "ad221709d732e8d7d14456fdaeb8621e215e04979dbe3d89b414d715c2641d7d",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "wallach_ix",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-eaa552d7-44bf-498a-8103-f8796d343d20.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Wallach IX, a misty rocky world: slate-grey mountain ranges, long pale valleys and dense pearly fog layers, hints of muted lavender in the thin atmosphere; cool stone and silver.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/wallach_ix-v1.png",
+ "sha256": "4b5870238e9b710a5a7e9fdcf566d3ea7bb69394e64028085b17996d30bcd1cd",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "junction",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-e5459584-6807-4d7a-800f-135f375da491.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Junction, the navigators' orbital domain: a large muted blue-grey planetary limb with a majestic delicate orbital ring and several tiny distant immense cylindrical navigational stations silhouetted above it. Emphasize the planet and sparse orbital infrastructure, no close spacecraft. Blue-black space, silver and faint amber.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/junction-v1.png",
+ "sha256": "3a103dc55d45fbc06cf79689ed33ae1668efacc66b7bd66d9bcc79a0d17a26e2",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "kaitain",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-c87699f9-808a-4518-886a-2fe471242e75.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Kaitain, a verdant imperial world: rich cultivated green continents with orderly gardenlike patterns, gentle rivers, luminous oceans and a subtle small golden city network; elegant serene green and warm gold.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/kaitain-v1.png",
+ "sha256": "16a8f30bd1557fbdf630232d8c013ec102e45aa9a4690ecc57f8ae8958735088",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "salusa_secundus",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-e0c7e1ee-97af-4541-8a8e-e7439cc16d00.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Salusa Secundus, a barren storm world: scarred rust-grey land, jagged rocky highlands and enormous violent slate storm bands with a few faint lightning glows; desolate slate, umber and pale storm light.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/salusa_secundus-v1.png",
+ "sha256": "f8a08fbb9648860c800ce2fc42d103e95b311eb79cdabb610210c5b524bdfb82",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "giedi_prime",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-b37c0d4b-5d9b-4f3d-bba1-ffb238c1d80a.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Giedi Prime, a dark industrial world: polluted near-black continents scored with vast brutal industrial complexes, ember-orange furnace lights and smoky ochre atmospheric bands, oppressive charcoal and rust.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/giedi_prime-v1.png",
+ "sha256": "209a4014aa153a67d4a7357b2f6c8aa7e62188e17bfa25738c6a70a69c571b56",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "grumman",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-e4831381-8b4e-4e0a-a73f-3ecc180fca9d.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Grumman, a severe fortress world: austere iron-grey mountains, broad dry stone plateaus and small angular fortress grids under thin clouds; martial, cold stone with restrained rusty accents.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/grumman-v1.png",
+ "sha256": "d671f929938ca2134c30a0af7f40f87ade2eea6e114e00410a8b750658ee2a9e",
+ "width": 1774,
+ "height": 887
+ },
+ {
+ "id": "tleilax",
+ "source": "/home/quintine/.codex/generated_images/01a0851a-ce33-7341-852b-02100539fded/exec-2d75af27-6693-4d74-88b4-179fd02a33cd.png",
+ "prompt": "Use case: stylized-concept. Asset type: original standalone planet illustration for a board-game Homeworld card. Style: sophisticated original hand-painted science fiction concept art, tangible fine brush texture, restrained cinematic color, richly textured curved planetary limb and luminous thin atmosphere against deep near-black space with very sparse stars. Composition: wide horizontal 2:1 image, one large planet spans most of the frame, fully atmospheric planet from orbit, strongest identifying surface features through the central horizontal region so this works as a 2:1 card-art crop. No typography, labels, logos, card frames, UI, people, watermark, or reproduction of official Dune card art. Subject: Tleilax, a biological-industrial world: strange muted olive land patterns, organic-looking industrial districts with branching conduits and clustered domes, misty green-grey haze, unsettling but non-graphic; moss green, antique brass and charcoal.",
+ "inspection": "Visually inspected: correct thematic subject, wide planetary limb composition, no text, frame, logo or people.",
+ "workspacePath": "public/art/homeworlds/tleilax-v1.png",
+ "sha256": "0cc5e2c9b065b775931ba37656618637d56630ffe439802d8ea7f10622df2f79",
+ "width": 1774,
+ "height": 887
+ }
+ ]
+}
diff --git a/docs/HOMEWORLD_BATTLE_RUNTIME.md b/docs/HOMEWORLD_BATTLE_RUNTIME.md
new file mode 100644
index 00000000..2d8c1cbe
--- /dev/null
+++ b/docs/HOMEWORLD_BATTLE_RUNTIME.md
@@ -0,0 +1,31 @@
+# Homeworld battle runtime
+
+9 September 2026. This connects the [combat foundation](HOMEWORLD_COMBAT_IMPLEMENTATION.md) to real engine actions and recovery. Homeworld starts remain disabled: invasion transport, occupation, remaining face effects and complete module games are unfinished.
+
+## Connected behavior
+
+One combat-location adapter supplies public names, exact native/visiting armies and the combined ordered battle frontier. Arrakis geometry remains unchanged. Phase continuations see remaining Homeworld conflicts before advancing to Collection. Native reserves and foreign garrisons retain separate physical custody; no fake sector keys enter the board.
+
+Live battle preparation, sealed plans, resolution, mandatory card disposal and winner cleanup now use that location. Printed native strength affects the score, not dial losses. Only a native combatant can call a Traitor. Battles between visitors have no traitor voters and continue automatically, preserving Poison Tooth, Stone Burner and eligible late Portable Snooper decisions. An invading owner with a legal late defense gets an event-bound use/decline choice rather than a fictional traitor vote.
+
+Lasgun/Shield destroys visiting armies but removes only the printed number of native physical counters, capped by actual population. Multiple typed allocations belong to the native player, even when two visitors fought and the native was not a combatant. A sole allocation resolves automatically. Native leaders outside the two plans remain unaffected.
+
+Pending typed casualties bind the exact native/visitor pool, resolved event and committed arithmetic. Mandatory disposal, JSON restoration and later owned choices do not replay payment, leader death or prior losses. Same-total reallocations between Kaitain and Salusa cannot redirect a saved choice.
+
+Ixian winners may exchange surviving Suboids for Cyborgs lost at that same Homeworld. The saved declaration binds survivors, typed Tanks and battle-loss history; Karama cancellation leaves the original losses unchanged. Native Tleilaxu Face Dance returns the foreign winner’s army and relocates selected own Arrakis forces into native custody. Existing native reserves remain physically in place, including a redundant same-source selection; zero external replacements are allowed. All four AI profiles and controls use the public location and exact quantities.
+
+## Explicit unfinished interactions
+
+The [replacement source audit](HOMEWORLD_REPLACEMENT_RULES.md) does not resolve the Advanced Emperor’s returned-army allocation between Kaitain and Salusa. That Face Dance return is gated with the same server, AI and interface reason; declining remains available. The existing Salusa Ghola support-timing question also remains gated. These restrictions are development boundaries, not official prohibitions. Other Homeworld face effects, occupation rights and full interaction coverage are still required.
+
+## Evidence
+
+Focused engine cases cover native and visitor combat, zero-voter restoration, native and noncombatant-native explosions, late defense, mandatory disposal, physical conservation and all four AI profiles. Replacement regressions cover Basic/Advanced native/visitor Ixian wins, declared Karama cancellation, corrupt receipts, native noncombatant Face Dance and unchanged private projections.
+
+Production SQLite tests verify competing native casualty submissions, duplicate rejection, exact-pool corruption without writes and concurrent automatic recovery. They exposed a missing room recovery scheduling hint; the engine’s actual continuation remains authoritative. Static rendering tests check world labels, sector-free replacement controls and accessible restricted choices.
+
+Final `npm run check` passes typechecking, lint and **3,264 offline cases** across 314 files (84.55 seconds for the tests); production build passes. The HTTP suite passed **40 cases** before the final saved-decision validation and board-selection fixes. Four additional recovery regressions cover live and Box-suspended late defense, stale event ownership, missing casualty receipts and altered loss options; rejected reads, actions and normalization preserve state.
+
+Actual desktop and 390×844 browser checks in an isolated QA room exercised sealed native plans, the native-only traitor decision, Lasgun/Shield disposal and the three legal native casualty allocations. The pending choice survived refresh. Keyboard selection of one normal counter and one Sardaukar advanced to Collection; refresh restored 18 native reserves (14 normal, four Sardaukar), two native Tanks (one Sardaukar), five invader Tanks and exactly one discarded Lasgun and Shield. No page errors or horizontal page overflow were observed. Visual inspection covered the phone casualty controls and settled desktop result. This used genuine audit setup followed by conserved staged invaders; it does not demonstrate invasion transport or a complete Homeworld game. Browser testing exposed and fixed a world ID incorrectly passed to the Arrakis territory selector.
+
+The controlled 10:30 UTC restart preserved all 2,709 saved room versions and state hashes; the authenticated earlier QA room restored through its invitation. A final read-only comparison after browser settlement found all 2,709 baseline rooms unchanged, with 2,740 rooms now stored including new test rooms. The recurring automation remains removed and user workflow optimizations remain in use.
diff --git a/docs/HOMEWORLD_BENEFITS_IMPLEMENTATION.md b/docs/HOMEWORLD_BENEFITS_IMPLEMENTATION.md
new file mode 100644
index 00000000..0fcfb0ab
--- /dev/null
+++ b/docs/HOMEWORLD_BENEFITS_IMPLEMENTATION.md
@@ -0,0 +1,32 @@
+# Homeworld charity and revival benefits
+
+9 September 2026. This checkpoint extends the native-reserve runtime; it does not certify a complete Homeworld game or enable any unfinished start mode. The [source contract](HOMEWORLD_BENEFITS_RULES.md) distinguishes publisher rules, implementation inferences and unresolved combinations.
+
+## Implemented behavior
+
+- Primary native low population adds one to a qualifying charity claim and to the current free-revival rate. Salusa contributes neither addition. The shared quote supplies engine validation, player amounts and AI quantities.
+- Charity keeps ordinary payment and the extra bank spice separate. CHOAM funds only the ordinary portion. Inflation doubles both portions or prevents collection. Canceling a Bene Gesserit claim pays neither portion. The production token triggers once on successful payment.
+- A declared revival group uses the rate before that group returns. Later groups use the resulting population. The normal capacity is the greater of the ordinary limit and the current free rate, making the FAQ's four-free Fremen entitlement usable without granting a general fourth paid return. This formula and grouped timing are documented source compositions, not quoted errata.
+- Fremen's alliance grant supplies three; the recipient's own low population adds its separate fourth. La La La suppresses the whole free rate. Existing paid discounts, typed allocation, special-counter limits and physical native return destinations remain separate.
+- The actual entry to Revival records whether Tleilax starts low, before phase-opening controls. Later population changes preserve that turn-bound observation. The penalty suppresses other factions' ordinary free-revival bank income, while retaining paid receipts, its own free-revival income and separately named Ghola income.
+- Table controls explain payment sources, current low benefits and the phase-start income condition. The reference contains the same timing guidance. All four AI policies use the shared current allowance; they withhold the special-Karama action that awaits the unresolved overlap ruling.
+
+## Interrupted decisions and saved games
+
+Charity response totals and bank portions are validated in live controls and explicit saved parents, including Nullentropy Box, Richese gift, exchange and discard continuations. Payment independently checks the eligible quote. This closes a reproduced corrupt-save path in which a Box-suspended claim could pay nine instead of three spice after automatic response allowance.
+
+Normal Homeworld revival settlement validates its saved free allocation against the shared allowance before forces return. Current legal interruptions cannot change native population or ordinary usage while that request is pending; La La La success abandons the request. This rejects invented free counters even when a forged paid price is arithmetically consistent. A future force-changing interrupt will require an explicit timing/provenance extension, rather than blindly recomputing an earlier accepted quote.
+
+Missing, stale or malformed Tleilax opening receipts reject; recovery never invents phase-start population from current reserves. The receipt is included in ordinary-card discard signatures. In-memory production D1 tests cover competing allowances/cancellations, restarts, private projections, rejected writes and preservation of another room.
+
+## Evidence and limits
+
+The complete offline check passed **3,190 tests**, including typecheck and lint. The final focused Homeworld/reference run passed **144 cases**. The production build and **40 live HTTP tests** passed. Seven new Homeworld benefit recovery cases cover production SQL and optimistic concurrency. The charity interruption regression uses genuine base setup with an explicitly labeled canonical Richese component seam; CHOAM payer cases similarly identify their unsupported-faction seam. They do not establish complete expansion setup.
+
+Independent review found and integration fixed the AI/server mismatch, invented-free allocation and Box-suspended payout defects. A separate review fixed a shuffle-dependent Hajr fixture by locating genuine cards in either deck or dealt hands; a deterministic opponent-held-card regression preserves inventory.
+
+An isolated Chrome QA room, `4K3HXJ6D`, passed desktop (1440×1000) and phone (390×844) inspection. A keyboard charity claim paid three spice, refresh retained it, an invalid four-force selection was disabled, and the accepted three-force return cost zero and raised Caladan from five to eight reserves. Refresh preserved the result and the opponent’s hand remained private. The inspected controls had no horizontal overflow or page errors. This is bounded feature acceptance, not complete module gameplay. Screenshots are `/tmp/dune-benefits-charity-desktop.png`, `/tmp/dune-benefits-charity-phone.png`, `/tmp/dune-benefits-revival-desktop.png` and `/tmp/dune-benefits-revival-phone.png`.
+
+The prior development server was absent. A fresh read-only snapshot and SQLite backup preserved **2,648 existing rooms** before localhost startup; all versions and state hashes were unchanged afterward. The server returned HTTP 200. No database reset or recurring automation was used.
+
+Pending material interpretations remain CHOAM's own low-population opening income, the Tleilaxu special-Karama/Homeworld overlap, and Ix high-population Suboid bonus quota/shortage behavior. Invasions, Homeworld battles, occupation, remaining card effects, full module combinations and complete games are unfinished. Keep public Homeworld/Advanced/unfinished expansion starts disabled.
diff --git a/docs/HOMEWORLD_BENEFITS_INTEGRATION_AUDIT.md b/docs/HOMEWORLD_BENEFITS_INTEGRATION_AUDIT.md
new file mode 100644
index 00000000..2f69cab5
--- /dev/null
+++ b/docs/HOMEWORLD_BENEFITS_INTEGRATION_AUDIT.md
@@ -0,0 +1,69 @@
+# Homeworld charity and revival integration audit
+
+Read-only inspection, 9 September 2026. This document describes the source at inspection, not a completed implementation or a full Homeworld rules claim. Parent integration may supersede individual line numbers. No runtime or test files were changed for this audit.
+
+## Verified scope and existing foundation
+
+[HOMEWORLD_RULES.md](HOMEWORLD_RULES.md), based on the GF9 Ecaz/Moritani rulebook pp.9–10 and FAQ p.16, records a low native Homeworld's +1 free-revival rate and +1 **bank-paid** spice when collecting CHOAM Charity. Salusa supplies neither bonus. Karama cannot cancel Homeworld advantages or penalties. Tleilax's low face specifically conditions its loss of **other factions' free-revival income** on starting Revival at low population. These are different timing contracts.
+
+`homeworld-population.ts:23–59` already derives public native population and potential bonuses from validated physical custody. It excludes visitors and Salusa's bonus, counts Salusa Sardaukar separately, and performs no payment. `homeworld-game.ts` validates physical inventory before public action, normalization and projection. Native reserve deposits now route revived Emperor normal forces to Kaitain and Sardaukar to Salusa. This physical foundation does not yet apply the economic benefits inspected below.
+
+Population currently reflects physical counters, not the future occupation rule that can retain a low penalty. Reuse it for the released native population domain; do not claim that this completes occupied Homeworld effects.
+
+## Charity: split the actual receipt
+
+| Current producer/consumer | Required integration |
+| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `charity.ts:17 charityAmount` | Currently returns one ordinary amount, including BG's advanced allowance and Inflation. Keep ordinary eligibility separate from a module bonus: adding one before the wealth threshold would create charity for ordinarily ineligible wealthy players. |
+| `charity.ts:29 charityPayer` | Determines the ordinary CHOAM payer only. Low-population bonus is from the bank even when CHOAM funds ordinary charity. |
+| `engine.ts:16345` charity action | Claims once via `charityTurn`, then either pays directly or opens wealthy BG's `bgCharity`. Produce explicit ordinary and bank portions; commit each exactly once. Do not open a new Karama response for the Homeworld portion. |
+| `engine.ts:9251 payCharity` | Currently charges its entire argument to CHOAM. Passing a combined total would overcharge CHOAM and incorrectly require it to fund the bank bonus. Check CHOAM solvency against only its ordinary portion, and check recipient total safely before mutation. Preserve one Production-token trigger per actual collection. |
+| `engine.ts:12782` BG response completion | Currently canceled means no payment. Retain the ordinary response and an explicit pending funding split. E1's BG cancellation says no Charity: the conditional bank bonus then has no qualifying claim. Pay both portions only on the supported completion, with no replay on cancellation, counter-canceled BG conversion, refresh or nested restoration. This is not a separately cancelable Homeworld power. |
+| `terminal-cancellation.ts:172`, `karama-context.ts:150` | Existing BG terminal quote validates current Advanced claim, owner and safe amount; the source signature contains ordinary response data but no standalone charity receipt. If a new receipt is retained, validate its owner/turn/source and source-bound amount before both initial card cost and paid conversion allowance. Avoid freezing unrelated balances or response-pass lists. |
+| `engine.ts:18144` view; `game-table.tsx:3083–3107`; `bots.ts:2466` | Project ordinary/bank/total amounts from the same quote. Current UI says all charity comes from one payer; it must describe the bank portion. Bots already use projected positive amount/claim status, so keep that shared surface authoritative. |
+
+The existing CHOAM phase-opening collection (`engine.ts:9162`) is `2 × seated factions × Inflation multiplier`. Do not increase this allocation merely because a claimant's bank bonus exists.
+
+The independent source reviewer confirmed E1's BG Karama row says no Charity, so its conditional Homeworld addition has no qualifying claim. E2 p.8 changes CHOAM Charity itself: the supported composition applies Inflation to the eligible total, including the bank addition, while retaining separate funding sources. No explicit Homeworld/Inflation cross-example was located; label this composition accordingly. Preserve the once-per-turn claim semantics and encode this ordering explicitly rather than letting a single-amount representation decide it.
+
+## Normal free revival: shared rate, frozen request, ordinary limits
+
+`revival.ts:42–91` provides the shared free remaining, force price and maximum request functions. Their present contexts do not include Homeworld custody. Introduce a public native-rate input or widen their declared context to include validated Homeworld state and public typed reserves. Preserve source provenance: native base/Fremen-alliance free rate, Homeworld addition, ordinary usage and any named restriction.
+
+A low Homeworld increases the free **rate**, not a general paid-revival limit, global elite limit or available Tanks. The subsequent [source audit](HOMEWORLD_BENEFITS_RULES.md) confirmed E3's explicit four-free Fremen entitlement; the implementation uses the greater of ordinary capacity and current free rate to make that entitlement usable. `forceRevivalRemaining` also uses free remaining to enforce Fremen's no-paid-revival rule when no Tleilaxu is seated; it must receive the same new rate. Keep Emperor-funded extra returns, Ghola and leader revival separate.
+
+**Named prohibition:** `revivalRules.freeBlocked` is CHOAM's **La La La**, set at `engine.ts:12476`, not a Karama cancellation flag. The independent source reviewer confirmed E2 p.7 prevents taking Free Revival; direct composition blocks the whole free rate, including the Homeworld addition. Homeworld Karama immunity provides no exception. Likewise, prevention of the entire normal revival is distinct from cancellation of an ordinary price advantage.
+
+| Consumer | Exact concern |
+| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `engine.ts:16704` revive declaration | Use the revised free quote for affordability, chosen free cyborg allocation and frozen `PendingRevival.free/normalCost/cost`. Sample one batch before depositing forces: that batch can itself raise the Homeworld to high population. |
+| `revival-resume.ts:17` context and `:105–136` frozen-price validation | Add the public state needed for current allowance checks, while preserving a valid frozen request's free/paid split. Existing arithmetic verifies paid physical counts and Ix free cyborg attribution. A new provenance field, if used, requires runtime validation; arbitrary extra free units must not pass by supplying a self-consistent lower price. |
+| `revival-cancellation.ts:22`, `:280–307` | CHOAM benefit denial and discount cancellation reprice through `forceRevivalQuote`. Pass the Homeworld context to these calls, and preserve the noncancelable module source while removing only the named faction price benefit. Do not add module cancellation to their check queue. |
+| `engine.ts:11942 beginRevival` and reactive La La La | A request with any applicable free component still enters the existing CHOAM decision. Denial of La La La resumes the unchanged request through `quoteRevivalResume`; successful La La La retains its named prohibition. |
+| `engine.ts:11879 finishRevival` | Retain existing native typed deposit, Tanks reduction, ordinary/free usage, elite-per-turn usage and Axlotl quote. Avoid applying the bonus again at settlement or after a frame drain. |
+| `engine.ts:18205` view; `game-table.tsx:3230–3378`; `bots.ts:2644–2665` | All consume free remaining and pricing. Use the shared quote, explain the module addition, and keep `freeBlocked` text accurate. No UI-only discount. |
+
+The current usage calculation subtracts total ordinary `p.revived`, while `freeForcesRevived` separately records known actual free usage. Do not change this legacy accounting incidentally. Test repeated partial revivals around a population threshold and record the selected timing contract. Recruits' verified doubling of the current rate must use the same source when that card's broader integration is addressed; this audit does not activate it.
+
+## Tleilax: persist the phase-start condition
+
+`beginPhase` at `engine.ts:9176` initializes Revival rules and usage. Add one public, turn-bound phase-start receipt identifying the Tleilaxu seat and whether its native Tleilax was low then. A later ordinary revival or Ghola can increase population; neither refresh nor subsequent income may recompute the original starting condition. Reject malformed/stale new receipts and define a deliberate legacy policy rather than inventing historical population from the current board.
+
+`openPhase` creates an Ix/Amal opening before `beginPhase`; the current ordinary opening permits Amal/pass, plus explicitly earlier card-transfer overlays. Those do not directly change reserves. Still bind the latch to the actual phase initialization once, preserve it through nested opening parents, and do not overwrite it during JSON recovery. Add it to any saved continuation signature whose next income depends on it.
+
+There are **two income producers**, both needing one shared eligibility predicate:
+
+- `revival-resume.ts:338–365` quotes normal settlement's paid transfer plus the first free-revival reward, and emits a per-payer `freeIncome` receipt.
+- `engine.ts:11821 collectRevivalIncome` handles card/special routes including Ghola, with separate `free` and `ghola` flags.
+
+Existing `revivalFreeIncome[player] = turn` only tracks whether a reward opportunity was consumed. It cannot encode Tleilax's starting population. Suppress only the source-supported other-faction free-revival reward. Preserve paid fees; do not blanket-disable `revivalIncome`, the Tleilaxu seat, or Ghola rewards because the current population is low. The source reviewer confirmed paid fees and the separate Ghola reward remain distinct from the specifically named other-faction Free Revival penalty; retain those routes rather than infer suppression from a boolean's name.
+
+`terminal-cancellation.ts:163` deliberately permits Ghola's revival-income response outside phase4. Keep that domain. `viewGame` hides income amounts from seats other than owner/recipient (`engine.ts:18170`); the new public phase-start condition does not authorize exposing private payment totals. The `ordinaryCardDiscard` signature already includes Homeworld custody; any additional phase-start receipt used by its detached income suffix must also be bound.
+
+## Bounded verification before release
+
+Test base/ix genuine setup with conserved low/high custody; Basic and Advanced Emperor with Salusa excluded; ordinary wealthy/poor eligibility; CHOAM-funded versus bank portions; BG printed/converted cancellation with duplicate final allowance; Inflation boundary selected from source; and JSON recovery preserving one claim and one token trigger.
+
+For revival, test a free batch crossing high threshold, later partial request, paid/free mixed Ix typing, Fremen cap, La La La allowed/canceled, discount cancellation, physical deposit, and no duplicate usage/fee/income. Test Tleilax low-at-start then raised versus high-at-start then lowered, paid fees preserved, other-faction reward suppressed exactly as sourced, Ghola distinguished, nested saved parents, and private amount projection.
+
+No broad test suite was run for this read-only document. These are required integration checks, not reported passing coverage.
diff --git a/docs/HOMEWORLD_BENEFITS_RULES.md b/docs/HOMEWORLD_BENEFITS_RULES.md
new file mode 100644
index 00000000..d274466a
--- /dev/null
+++ b/docs/HOMEWORLD_BENEFITS_RULES.md
@@ -0,0 +1,45 @@
+# Homeworld charity and revival benefits: source contract
+
+Source audit, 9 September 2026. This separates publisher wording, rule composition, and remaining decisions. It does not enable Homeworld games or replace the outstanding Ambassador/Duke revival rulings.
+
+## Publisher facts and provenance
+
+| Source | Verified material |
+| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [GF9 E3, pp.9–10,16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf) | Thresholds follow current native reserves. Low adds one free revival and one bank spice **when collecting** charity; Salusa supplies neither. Karama cannot affect Homeworld advantages or penalties. The FAQ explicitly gives low Fremen four free revivals; Recruits doubles the current rate, capped at seven. Local publisher extract: `/tmp/dune-rules/ecaz-audit.txt`, lines375–445 and745–750. |
+| [GF9 base, pp.8–9,16–17](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf) | Ordinary charity raises zero/one spice to two. Normal force revival has a three-counter limit. Fremen normally get three free, cannot buy additional normal revivals, and may grant their ally three free. Advanced BG always gets two charity. Local `base.txt`, lines472–504,929 and1068. |
+| [GF9 E1, pp.6–9,12](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf) | Tleilaxu may raise another faction's limit to five; its own normal limit is unrestricted. Revival payments and bank income for other factions' free revival/Ghola are separate. Cyborgs cost three to revive; ordinary free revival may select either type. Special Tleilaxu Karama prevents revival. The Karama table denies BG charity entirely. Local `ix-official-mirror.txt`, lines197–198,250–255,335 and535–537. |
+| [GF9 November FAQ, p.3](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) | The explicit Advanced Karama timing table restricts Tleilaxu's special effect to Revival. This is not inferred from Guild timing. |
+| [GF9 E2, pp.7–8,14](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf) | La La La prevents Free Revival during Revival. Inflation doubles or cancels charity, expressly including BG and CHOAM's income. Cancellation of CHOAM's income changes ordinary charity's payer to the bank. Local `choam-lelekan-mirror.txt`, lines279–318 and545 onward. |
+
+Actual Ix HIGH and Tleilax LOW cards were visually rechecked in the [four-sheet component photographs](https://www.reddit.com/r/DuneBoardGame/comments/16w27x0/every_homeworld_cards/), locally `homeworld-reddit-face2.png` and `homeworld-reddit-face3.png`. These are photographs of publisher components, not authority derived from commenters. Ix grants two free Suboids when paying to revive a Cyborg. Tleilax specifically tests whether it **starts** Revival low and then denies income from other factions' Free Revival. The card catalog and broader source inventory are in [Homeworld component audit](HOMEWORLD_COMPONENT_AUDIT.md).
+
+## Supported composition for implementation
+
+- **Current eligibility:** use the native population before the claimed event. Do not globally freeze low benefits at phase start. Tleilax's specifically worded income penalty is the exception and requires a durable phase-start observation.
+- **A grouped revival:** quote the current rate before moving that group, preserve the accepted quote through interruptions, then derive the new population for the next independent request. Neither a mid-group per-counter repricing loop nor a whole-phase entitlement lock appears in the sources. This transaction boundary is an implementation inference consistent with the current-rate language, not a separately printed timing rule.
+- **Free rate versus capacity:** adding one free revival does not generally add one paid revival. A useful formula is `max(ordinaryLimit, currentFreeRate)` for total normal capacity, followed by physical Tanks and already-used allowance checks. This makes the FAQ's four-free Fremen entitlement usable without granting everyone a fourth paid revival. The exact formula is an inference; the FAQ does not separately state the no-Recruits total-limit calculation. Do not describe it as a quoted erratum.
+- **Fremen alliance:** apply the granted rate of three, then the recipient's own low bonus. The donor's low population does not turn its printed alliance grant into four. Canceling the alliance benefit removes that source, not the recipient's independent Homeworld increment. This follows the distinct grant and native modifier; it does not add an ordinary Karama cancellation of every faction's printed free rate.
+- **Charity:** establish a valid collection first. Multiply ordinary charity and the conditional low bonus by Inflation, while keeping the bonus bank-paid. At zero spice, ordinary low charity is two from its ordinary payer plus one bank; Double makes four plus two. At one spice it is one plus one, or two plus two. Cancel produces no collection and no bonus. Advanced BG's ordinary amount is two regardless of wealth; a canceled BG charity claim similarly has no qualifying collection. These are cross-source compositions, not located worked examples.
+
+**CHOAM's own income needs an explicit integration decision.** Its initial two-per-faction payment is named the Charity advantage, and E2 expressly includes it in Inflation. This supports treating it as a qualifying collection for Tupile's low bank increment, but that parenthetical does not explicitly adjudicate other modifiers. Conversely, limiting the Homeworld bonus to ordinary poverty claims would give low CHOAM no increment on its usual initial income. No retrieved primary passage resolves that distinction. Preserve the ordinary claim and faction-income sources rather than accidentally awarding twice or silently excluding CHOAM.
+
+## Keep cancellation sources distinct
+
+`revivalRules.freeBlocked` currently represents **La La La**, not Karama. Its prohibition covers taking the current Free Revival, including the Homeworld increment. Homeworld Karama immunity does not provide immunity to this independent Worthless power. Likewise, Inflation is a charity modifier, not a direct Karama against a Homeworld. There should be no response aimed solely at turning off the low increment.
+
+The Tleilaxu special Karama has a different unresolved overlap: it prevents revival itself, while E3 protects Homeworld advantages. The natural event-based reading prevents the ordinary request without erasing its rate, just as denying a qualifying charity event prevents its conditional bonus. However, no retrieved primary text specifically settles whether the low extra counter survives that special prohibition as an independently executable return. Do not silently turn this into La La La's known non-Karama rule, or claim that only the bonus survives without an explicit interpretation.
+
+## Tleilax and Ix finite effect boundaries
+
+**Tleilax:** snapshot native low status on entry to Revival; crossing high later does not erase the printed start-condition. Suppress only the bank income for another faction's Free Revival. Do not suppress paid receipts, its own revival, or Ghola income merely because they share a helper. E1 names Ghola separately; the low card does not name it. Preserve existing once-per-faction ordinary-free-income accounting independently of this condition. High Tleilax's optional shipment of the returned group is a separate destination continuation, not an automatic consequence of this low-income work.
+
+**Ix:** the trigger requires an actually paid Cyborg return while the relevant high advantage applies. A free Cyborg, Ghola, or a paid Suboid supplies no such trigger. The grant remains two physical Suboids per qualifying Cyborg, never two newly created pieces or a Cyborg substitution. A discounted but positive Cyborg payment is still payment. Keep paid/free typed allocations in the quote so total transaction cost is not mistaken for proof that every selected Cyborg was paid.
+
+The retrieved card and publisher FAQ do **not** settle whether those Suboids consume normal revival allowance or are additional to it; this changes legal quantities after one or more earlier revivals. Nor do they explicitly choose between partial fulfillment from an insufficient Suboid Tanks pool and requiring both. Both issues materially affect a complete automatic purchase/bonus implementation. Do not add an arbitrary optional decline to hide that uncertainty. Recruits explicitly doubles free **rates**, so it does not by itself establish four bonus Suboids per paid Cyborg. Its interaction with any exceptional total must follow the resolved quota policy.
+
+## Release boundary
+
+Implementable together: current low benefit projection, source-separated ordinary charity payments, current normal revival quotation, retained accepted-request pricing, and the explicit Tleilax phase-start income condition. Add tests at the exact threshold, during a suspended request, after crossing it, with a Fremen ally, and with Inflation/La La La/BG cancellation. Keep CHOAM's own-income classification, special-Karama overlap and Ix bonus quota/shortage behavior visible until interpreted. These are the remaining material combinations from this audit, not reasons to postpone the independently established mechanics.
+
+Fresh searches of publisher rulebooks/November FAQ and targeted designer clarification queries found no additional primary ruling for the unresolved cases. Tournament compilations, wishlists, and movie-game rules were not used as authority. In particular, a third-party compilation's added “breaking current revival limits” language is not a GF9 quotation. No runtime, tests, saved rooms, or automation were changed.
diff --git a/docs/HOMEWORLD_CARD_ECONOMY_RULES.md b/docs/HOMEWORLD_CARD_ECONOMY_RULES.md
new file mode 100644
index 00000000..588c1112
--- /dev/null
+++ b/docs/HOMEWORLD_CARD_ECONOMY_RULES.md
@@ -0,0 +1,123 @@
+# Homeworld card economy: source contract
+
+Audit: 9 September 2026. Scope: high Kaitain's paid disposal and high Tupile's
+Worthless-sale restriction. This document does not certify runtime integration.
+
+## Authority and access
+
+- The original high faces were freshly retrieved and visually inspected in
+ [BGG photograph 7767034](https://boardgamegeek.com/image/7767034/dune-ecaz-and-moritani).
+ Public metadata identifies a 3024×4032 photograph posted on 2 October 2023;
+ the local inspection copy is `/tmp/dune-card-economy-homeworlds.jpg`.
+ Its Kaitain and Tupile text agrees with the prior
+ [component audit](HOMEWORLD_COMPONENT_AUDIT.md). The photograph depicts
+ publisher components; uploader commentary supplies no rules authority.
+- [GF9 E3, pp.9–10,15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf)
+ was rechecked through publisher-indexed text. Population changes continuously;
+ Homeworld advantages and penalties are immune to Karama. The book expressly
+ identifies Tupile's high side as a penalty and its low side as an advantage.
+- [GF9 E2, pp.4,7–8](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf)
+ was likewise checked through publisher-indexed text. Ordinary CHOAM sales
+ occur at phase end: exact duplicate surplus pays three spice, Worthless cards
+ two. Its reciprocal allied card trade is once per game turn at phase end.
+ Special CHOAM Karama pays three per selected Treachery card at any time,
+ including Worthless cards. Special Worthless effects retain their own timing;
+ Gamont belongs to Mentat Pause. The discard pile is not freely searchable.
+
+Direct GF9 PDF requests currently redirect or return 403; the former
+`/tmp/dune-rules` cache is absent. This audit does not claim a fresh full-PDF
+download. No retrieved official amendment changes either inspected high face.
+Tournament compilations surfaced in search but were excluded.
+
+## Kaitain implementation contract
+
+At Bidding's end, an Emperor with high Kaitain may choose held Treachery cards
+and pay two spice per card to discard them. The face gives neither a one-card
+limit nor a forced discard. Declining is legal. Weapons, defenses, Worthless
+cards and Karama are all Treachery cards. Paying to discard a Karama does not
+activate its printed effect or spend the once-per-game special power.
+
+Treat the selected group atomically: unique currently held physical IDs, enough
+available spice for the whole group, exact cost, and exactly one discard per
+selected card. A stale or invalid selection must spend nothing. The batch is an
+implementation transaction, not a new printed once-only restriction. No extra
+auction begins because disposal opened a hand slot after auctions ended.
+
+The payment is disposal expenditure, not a card purchase, shipment or battle
+support payment. No retrieved rule names a faction recipient: bank payment with
+no commercial-income response is the implementation inference. Separate
+discard-triggered effects still apply: the inspected high Ecaz face rewards
+poison-weapon discards. Reuse physical discard handling and preserve private
+selection information until the normal discard visibility rules permit it.
+
+Do not offer a Karama response against Kaitain itself. Recheck current native
+population at execution, including after a legally played Ghola; do not freeze
+high eligibility at the beginning of Bidding. Foreign visitors do not supply
+native population.
+
+## Shared end-of-Bidding timing
+
+Kaitain and CHOAM's market share a phase-end boundary. No retrieved primary rule
+orders one before the other. The
+[November FAQ, p.8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
+uses storm order for simultaneous Truthtrance plays; it does not explicitly
+establish a universal order for these later expansion abilities.
+
+Consequently, do not describe a fixed CHOAM-before-Kaitain sequence, its reverse,
+or a storm-order queue as a verified official requirement. Keep both legal
+opportunities available until the shared boundary closes. In particular, do not
+discard an Emperor's opportunity merely because its original hand was empty
+before a legal allied trade or another permitted card acquisition. New cards
+and current funds must be considered when quoting a later disposal. Preserve
+any pending trade, response or post-auction continuation; disposal does not
+authorize resolving another player's decision.
+
+## Tupile implementation contract
+
+While Tupile is high, CHOAM cannot discard Worthless cards for spice except
+through Advanced Karama. The prohibition names the card and outcome, so both a
+two-spice Worthless sale and a three-spice duplicate-Worthless sale are blocked.
+An exact-name witness does not change the sold card's kind. Non-Worthless
+duplicate sales, reciprocal trades, battle discards and special Worthless
+effects are outside this prohibition. This is direct application of the face's
+scope, not a new restriction on all CHOAM card handling.
+
+The same block applies when native CHOAM benefits from the CHOAM Ambassador:
+that effect exchanges selected cards for bank spice, and Ecaz may designate
+its ally as beneficiary. Neither changes the card into Advanced Karama. Check
+the actual beneficiary's Tupile, not the token name or Ecaz's population.
+The Ixian Ambassador's discard-and-draw exchange remains outside this block.
+[E3, p.8](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=8)
+This composition follows the named outcome and sole printed exception; no
+dedicated worked Ambassador/Tupile example was retrieved.
+
+The Advanced Karama exception is specific to CHOAM's cash-in power. It neither
+removes the high penalty generally nor creates a Basic-game exception. Preserve
+the existing separate activating-Karama cost, once-per-game use, eligible
+uncommitted card selection and three-spice return. Ordinary Karama cannot
+cancel Tupile's restriction. At low population, this particular sale block is
+absent; do not accidentally suppress the low-side information ability or
+redefine its occupied-face interaction here.
+
+Revalidate high/low status when an interrupted sale actually settles. Test a
+real Ghola transition from low to high while a Worthless sale is pending:
+stale permission must not remove the card or pay income. Keep the market and
+its parent continuation recoverable. This requirement is independent of the
+older unresolved Kull and Worthless-cancellation interpretation questions.
+
+## Deferred payment splitting
+
+E3 p.15 explicitly splits a five-spice Treachery payment into three for Emperor
+and two for Kaitain's occupier, even if that occupier bought the card. It does
+not give an allied-contribution example. The inspected reverse-face record
+assigns rounded-up half of shipment receipts to low Junction. Neither evidence
+establishes separate rounding per contributor versus once per purchase or
+shipment. Preserve payment identity, actual payer contributions and income
+source separately before integrating those effects; do not infer an answer
+from existing integer arithmetic. Occupation lifecycle questions remain in
+[Homeworld rules](HOMEWORLD_RULES.md).
+
+Runtime verification should cover Basic/Advanced and module-off parity,
+threshold transitions, exact card custody, private view/AI boundaries,
+rejected-action immutability and saved parent recovery. Those are engineering
+requirements for these rules, not evidence that the entire module is complete.
diff --git a/docs/HOMEWORLD_CARD_ECONOMY_RUNTIME.md b/docs/HOMEWORLD_CARD_ECONOMY_RUNTIME.md
new file mode 100644
index 00000000..ee62ab62
--- /dev/null
+++ b/docs/HOMEWORLD_CARD_ECONOMY_RUNTIME.md
@@ -0,0 +1,133 @@
+# Homeworld card economy runtime
+
+9 September 2026. This records the integrated Tupile sale restriction, shared
+Kaitain closing opportunity and Ecaz poison income. Source authority is in
+[the card economy contract](HOMEWORLD_CARD_ECONOMY_RULES.md) and
+[the Ecaz poison audit](ECAZ_POISON_INCOME_RULES.md). Complete Homeworld and expansion starts remain disabled; verification is
+scoped to the integrated paths below.
+
+## Implemented paths
+
+- After the last auction and its income/bonus continuations finish, Homeworld
+ tables with Emperor open one shared end-of-Bidding opportunity. Emperor can
+ pay two spice per selected held Treachery Card while Kaitain is high. Payment
+ precedes disposal; it creates no auction income and does not activate card
+ effects. Any number of eligible cards can be selected, subject to current
+ hand custody and funds.
+- CHOAM's closing sales and allied trade share that opportunity. Neither faction
+ has an imposed priority, so a traded card can subsequently be discarded through
+ Kaitain. Ghola and incoming transfers use current population and hands. The
+ saved event binds both owners, the turn and completed-auction state.
+- Owners finish independently; another accepted card action clears readiness.
+ Automatic closure uses only public hand counts and possible incoming
+ exchanges, never private spice or card identities. Nested choices complete
+ before another closing action. The table advances when all owners finish.
+- `game/bidding-end-options.ts` supplies private controls and all four AI profiles
+ with the same current opportunity. The bots preserve a spending reserve and
+ prefer disposing of unwanted Worthless cards. Existing CHOAM market policy
+ supplies their sale/trade choices.
+- At eleven native forces, Tupile blocks Worthless cards from ordinary CHOAM
+ two-spice sales and three-spice duplicate sales. The authoritative action,
+ current private sale choices and final settlement use the same population
+ predicate. Foreign visitors do not raise native population.
+- The same restriction applies when native CHOAM receives the CHOAM Ambassador's
+ three-spice-per-card effect. Each selected card is validated before any
+ discard or payment. A choice with only unavailable cards completes
+ automatically. Other beneficiaries and the Ixian discard-and-draw effect
+ retain their separate rules.
+- Non-Worthless duplicates, reciprocal allied trades and CHOAM's Advanced
+ special Karama cash-in remain available under their existing prerequisites.
+ The market explains the high-Tupile restriction and shows eligible sales.
+ Other seats receive no private sale list or held-card information.
+- Ghola can interrupt a declared CHOAM sale. The ordinary revival and physical
+ discard complete before the original sale resumes. A Tleilaxu income response
+ has its own allowance or cancellation, including saved-state restoration.
+ If revival raises Tupile from ten to eleven, the original Worthless sale
+ subsequently keeps its card and pays no spice. Canceling that original sale
+ is still a valid separate action.
+- The suspended sale stores its exact market, response, original actor/card,
+ discard sequence and event. Explicit discard, income and completion stages
+ bind the receipt to the actual live or suspended income response. Incoming
+ state is checked before actions, normalization and private views. Completion
+ restores the original response without replaying the declaration or revival.
+- All four AI profiles use the filtered market choices. Their existing Ghola
+ recovery policies can also use this sale interruption through the shared
+ timing quote. Other ordinary-card response windows remain under their
+ existing coverage limits.
+
+## Ecaz income and battle cleanup
+
+The central physical hand-to-discard operation quotes `game/ecaz-poison-income.ts`
+and pays three bank spice per poison weapon while native Ecaz is at seven or
+more reserves. It applies to any owner's qualifying discard, including Ecaz's
+own cards, paid Kaitain disposal, battle cleanup and legal hand cash-in. Current
+population is checked at disposal. Neither returning cards to the deck nor
+resuming an already committed discard pays again.
+
+Printed poison weapons, Poison Blade and Poison Tooth qualify. Chemistry
+qualifies only from its actual weapon slot. Residual Poison and poison defenses
+do not qualify. A conditional battle role is retained in a signature-bound
+resolved-battle receipt, preserving delayed cleanup through JSON restoration.
+The pure quote understands validated Mirror copy descriptors; this does not
+enable currently guarded Mirror battle plans or settle its separate retention
+question.
+
+Losing cards are discarded before winner casualties. Mandatory winning cards,
+including an activated winning Tooth, remain reserved until winner casualties
+and any Ixian substitution finish. `pendingWinnerDiscards` binds the original
+battle, owner, physical cards and optional cleanup suffix. An independent
+resolved-battle obligation requires that queue until its physical disposal is
+complete; missing queues and missing obligations reject on restoration. Both
+mandatory and optional played cards remain reserved through the casualty pause.
+`finishWinner` then
+performs the actual compulsory disposal and stages its discard continuation
+before offering optional winning-card cleanup. Thus a native Ecaz winner that
+falls from seven reserves to six can receive losing-card income while still
+high, but receives no income for its later winning-card disposals. The source
+composition and revised-Tooth-face retrieval limit are recorded in the Ecaz audit.
+
+Income is committed with the physical discard. An Ecaz-only ledger projects
+amount, count, turn and phase to the owner's Homeworld panel; no public log or
+event-sequence entry discloses a private discard's poison category. Other seats
+receive no ledger or private spice balance.
+
+## Verification scope
+
+Final `npm run check` passes types, lint and **3,515 offline cases**. The
+production build and **40 HTTP integration cases** also pass. Desktop and
+390×844 browser seats completed paid poison disposal, private Ecaz income, an
+allied CHOAM trade, a Worthless sale with a human Karama allowance, and independent
+closing readiness. All three seats restored Revival and their exact private
+balances after refresh. Screenshots were visually inspected; no page overflow or
+browser errors occurred. QA setup uses the documented expansion faction seam.
+
+All **2,894** pre-existing room versions and state hashes remain unchanged.
+Three isolated browser rooms and thirty HTTP test rooms were added; no game was
+reset. The earlier controlled server restart restored the existing private seat.
+
+Focused entry points are `npm test -- bidding-end ecaz-poison-income`
+and `npm test -- battle-discard`. The five new suites add 47 cases: twelve
+Bidding engine cases, ten controls/AI cases, five production SQLite cases, eight
+pure Ecaz quotes and twelve Ecaz engine cases. Existing battle-discard and SQL
+recovery regressions now verify separate losing/winning batches, exactly-once
+casualties, played-card reservations and missing-queue rejection.
+
+The new pure, engine and in-memory production-room suites cover exact thresholds,
+typed custody, opponent-secret getter traps, malformed selections, unchanged
+rejections, both sale prices, separate legal exceptions, Ambassador selection,
+Ghola revival, nested income cancellation, all four AI profiles, private JSON
+restoration, corrupt receipts and competing duplicate settlements.
+
+Fixtures explicitly isolate CHOAM's unavailable complete expansion setup.
+Production recovery uses real supported setup before the documented CHOAM
+faction seam. The currently implemented decks contain no duplicate Worthless
+printed names, so duplicate-Worthless validation uses two real physical IDs with
+a documented same-name fixture seam. Neither seam certifies the expansion deck.
+No live saved game is modified by the in-memory suites.
+
+## Remaining work
+
+Occupation retention, Tupile's low/occupied powers, remaining Homeworld economy,
+broader Ghola interruptions, complete module games and expansion setup remain
+unfinished. The outstanding Mirror retention and other previously recorded
+source questions are unchanged. No start or publication gate changes.
diff --git a/docs/HOMEWORLD_COLLECTION_INTEGRATION.md b/docs/HOMEWORLD_COLLECTION_INTEGRATION.md
new file mode 100644
index 00000000..4b27adcd
--- /dev/null
+++ b/docs/HOMEWORLD_COLLECTION_INTEGRATION.md
@@ -0,0 +1,186 @@
+# Homeworld Collection and Terror integration audit
+
+This is a read-only code audit and proposed integration boundary. The current
+source audit determines the precise Grumman and Giedi Prime wording, action
+ordering and unresolved combinations. Existing source contracts are in
+[Homeworld rules](HOMEWORLD_RULES.md),
+[Moritani Terror rules](MORITANI_TERROR_RULES.md) and
+[Ecaz Collection implementation](ECAZ_COLLECTION_IMPLEMENTATION.md).
+No faction or expansion completion is implied.
+
+## Concrete hazards in the current implementation
+
+1. `engine.ts::openTerrorEntry` uses `.find` for the first placed token at the
+ destination and saves that single identity. Permitting Grumman stacks without
+ changing entry selection silently chooses a secret effect by array order.
+ The present view, `decideTerror`, bots and Terror reaction component all
+ operate on one token. Source verification must settle whether one or several
+ tokens may resolve for an entry and who chooses their order.
+2. `moritani-terror.ts::placeTerror` rejects a destination already containing a
+ token and consumes `placementTurn`. That is the ordinary Mentat opportunity,
+ not a reusable Grumman Collection action. Reusing it either prevents stacking
+ or consumes the wrong phase's allowance. Its engine action also opens an
+ ordinary Karama response; Homeworld effects must not inherit that response.
+3. A removed token's destination matters. `revealTerror` exposes its face and
+ removes it from play; `returnTerror` returns it to supply and rotates available
+ identities using private randomness. Neither is an automatic interpretation
+ of Grumman's removal wording. Reusing the wrong function can reveal a hidden
+ face, create a replacement token or permanently lose a recoverable token.
+4. `CollectionQuote.receipts[].collected` omits Ecaz shared desert escrow until
+ allocation. Harkonnen may be the ally. Awarding Giedi only from the initial
+ receipt misses later qualifying income; awarding it just because a shared
+ lot exists may pay even when Harkonnen eventually receives zero.
+5. `collect` only gets an `ecazCollection` completion marker when an Ecaz bonus,
+ shared allocation or same-turn frame exists. A Homeworld once-per-Collection
+ award needs its own durable receipt even in a game with no Ecaz. Reads and
+ normalization must not infer a fresh award from current board presence.
+6. Low Grumman must feed both actual entry/reveal legality and earlier competing
+ reaction checks. Adding only a guard to `openTerrorEntry` leaves less-than-three
+ entries incorrectly rejected by preflight checks that see any placed Terror.
+7. Existing Terror guidance permits even zero No-Field placement to trigger.
+ The subsequent source audit confirms the public marker counts as one force:
+ low Grumman therefore suppresses a marker-only entry, independently of its
+ hidden value; a marker plus two admitted physical forces reaches three.
+ A public window must never depend on the concealed denomination.
+
+## Arrival contract and consumers
+
+`openTerrorEntry` is called only after entry is committed. It stores entrant,
+destination, sector, `amount`, `elite`, cause, turn, phase and a continuation
+for worm rides or Ambassadors. Existing callers pass total physical arrivals as
+`amount`; `elite` is a subset, not an additional count. Existing forces already
+at the destination must not contribute to the entering threshold. BG advisors
+are physical arrivals and already qualify under ordinary Terror guidance;
+ordinary one-advisor and Wallach two-advisor batches remain below three.
+
+The smallest shared public eligibility quote should consume native Grumman
+effect state, owner/entrant/ally identities, original entry cause and a verified
+entering count. It should return an explicit trigger/reveal block without
+reading token kinds, hands or private resources. Keep physical population and
+retained occupation effects separated as described in the
+[occupation integration audit](HOMEWORLD_OCCUPATION_INTEGRATION.md).
+
+Use the same quote in these adapters:
+
+- `openTerritoryEntry`, `openTerrorEntry` and `terrorRevealBlocked`.
+- The competing-reaction checks in `guildAdvisorAmbassador` and the Ambassador
+ relocation path, plus the engine's movement board supplied Terror list.
+- Guild Ambassador shipment and advisor continuations, Fremen Ambassador
+ relocation, normal/Guild shipment, ordinary movement and worm-ride entry.
+- Saved Terror alliance and discard continuations, including `terrorAllianceBlocked`:
+ refusal requires revelation, so an unavailable revelation cannot be bypassed
+ by offering Enemy of My Enemy first.
+
+A quote for stacked entries must return owner-selectable physical token IDs;
+public observers should learn only the same entry/location/stage information
+they already receive. Preserve a chosen token's identity through alliance reply,
+discard continuation and random resolution. Never reroll or reselect on reload.
+Keep unresolved token effects gated even when another token in the stack is
+implemented. Exact multiple-token sequencing awaits source confirmation.
+
+## Collection pipeline and source provenance
+
+`board-resolution-quote.ts::quoteSpiceCollection` settles advisor stances,
+calculates collection capacity, removes map deposits and returns independent
+stronghold income, ordinary collected amounts and Ecaz shared lots.
+`engine.ts::collect` may first open the Ecaz ordinary-advantage Karama window;
+`commitCollection` then applies receipts and starts shared allocation.
+`decideSharedSpice` credits each resolved lot and marks that frame complete.
+`nextPhase` invokes collection on entering phase 7 and opens ordinary Terror
+placement separately on entering Mentat phase 8.
+
+Keep Homeworld additions out of `quoteSpiceCollection`'s resource mutation until
+its existing result has been committed. Otherwise `commitCollection` assigning
+each `receipt.balance` can overwrite a Homeworld credit made earlier. A helper
+that awards during view or re-quote also duplicates payment when Ecaz's canceled
+quote is recomputed. Existing `collectionBoardSignature` intentionally excludes
+private balances and native reserve counts; it is not an original Homeworld
+eligibility receipt.
+
+Giedi needs qualifying collection provenance, not a balance difference. Ordinary
+stronghold bank income, poison/technology payments, bribe returns and unrelated
+Collection effects must not qualify by accident. Current `collected` merges all
+map deposits without a terrain tag. A minimal extension is committed source
+receipts of the form `{player, source, amount}`, with `source` distinguishing
+desert collection, Homeworld collection and other income. Shared allocation
+must add receipts for its actual recipients, including zero where needed for
+auditing, rather than redistributing the original bank or ordinary income.
+Homeworld occupation Collection itself remains separate unfinished work.
+
+A pure Giedi quote can take public native-effect context plus a validated
+qualifying amount and an existing event receipt, and return either zero or the
+printed bank bonus. A general resource quote must not inspect another player's
+private balance to decide eligibility. Current-versus-captured native eligibility
+and whether qualifying shared participation requires a positive allocation must
+follow the source audit rather than an incidental hook position.
+
+## Smallest coherent Grumman contract
+
+Use a separate `HomeworldCollection` event with turn, owner, stage and completed
+receipt; do not overload `TerrorState.placementTurn` or `ecazCollection`. It must
+coordinate with the existing Ecaz decision so neither overwrites the other.
+The source audit determines the permitted phase ordering; a continuation can
+open the next authorized opportunity only after the active one settles.
+
+A focused pure quote should validate a discriminated action: decline, add an
+owned eligible physical token to a qualifying already-marked stronghold, or
+remove a selected placed token. It should return the precise custody change and
+bank payment without mutating state. Validate native high eligibility, original
+event, legal destination, unique token identity and prior completion. The
+source must determine whether payment requires completing one operation and
+where a removed token goes; the contract must represent those decisions
+explicitly. Preserve Mentat placement independently.
+
+Project own choices from the same quote for a new Collection component and bot
+decision branch. The existing `MoritaniPlacementDecision` component and
+`bots.ts` placement branch intentionally seek empty strongholds, so they are
+inappropriate for stack destinations. All four bot levels need a legal action
+or decline path. Observers see public stack count/location and completed bank
+income, while available hidden token faces stay owner-only. If a hidden token
+returns to supply, preserve the identity-rotation protection before reuse.
+
+## Focused verification before enabling the paths
+
+- Entry batches of two and three, elites counted once, existing destination
+ forces excluded, advisors included, module off and native high/low crossings.
+- Low eligibility in both preflight and actual continuation; immutable rejected
+ actions and no paid entry replay through Ambassador, alliance or discard.
+- Stack selection with two different physical token IDs; concealed observer
+ equivalence; chosen identity retained through JSON and SQL recovery.
+- Collection add/remove/decline preserves six-token custody, independently of
+ the same turn's Mentat placement; no duplicate bank payment on retry/read.
+- Giedi: zero collection, stronghold-only income, positive desert income, several
+ qualifying locations, and shared Ecaz allocation of zero versus positive
+ spice under the verified qualification rule; award at most once per event.
+- Ecaz ordinary Collection cancellation does not cancel a Homeworld effect;
+ pending allocation and Grumman choice preserve their own original receipts.
+- Source-confirmed No-Field and occupation cases, with explicit release gates
+ where those compositions remain unresolved.
+
+The audit itself makes no engine changes. A subsequent isolated implementation
+adds `game/homeworld-collection.ts::quoteGiediCollectionBonus` and
+`lowGrummanRevealBlock`, with eight passing pure regressions in
+`tests/homeworld-collection.test.ts`. They use current physical population and
+validated actual source/count inputs; event timing, No-Field composition,
+occupation and high Grumman token operations remain outside those helpers.
+Focused lint, code references and local documentation links were checked.
+
+The low Grumman engine integration now shares that block across movement and
+Ambassador preflight, entry opening, reveal and alliance availability. Narrow
+Ambassador destinations where one or two forces avoid a currently unsupported
+simultaneous Intrusion/Terror reaction expose a maximum of two, used by the UI
+and all four bot levels. High and module-off paths retain their prior behavior.
+
+New entries carry `entrySignature`, a consistency receipt binding the original
+public count, token, entrant, location, cause, turn and continuation identity.
+View, action and normalization validate both live entries and suspended Terror
+discard entries. Alliance cancellation preserves the receipt. Older saved
+entries without a signature remain an explicit compatibility boundary; nothing
+reconstructs their original count from present forces. Current Grumman population
+is still rechecked independently of the immutable arrival.
+
+Twelve new engine regressions cover typed counts, advisor arrivals, identical
+observer views for concealed zero/three/five markers, marker-plus-two movement,
+count-dependent Ambassador legality, all four bots, actual Karama cancellation,
+and corrupt live/suspended receipts. The related 19-file run passed 281 tests.
+These results do not implement high Grumman stacking/removal or occupation.
diff --git a/docs/HOMEWORLD_COLLECTION_RULES.md b/docs/HOMEWORLD_COLLECTION_RULES.md
new file mode 100644
index 00000000..c84581af
--- /dev/null
+++ b/docs/HOMEWORLD_COLLECTION_RULES.md
@@ -0,0 +1,63 @@
+# Grumman and Giedi Prime: collection and Terror restrictions
+
+Primary-source audit, 9 September 2026. This document supplies a bounded implementation contract, not a runtime checkpoint. Occupation lifecycle and the separate low-Guild contributor-rounding questions remain pending; do not duplicate them.
+
+## Evidence inspected
+
+The original high faces were visually read again from `/tmp/dune-card-economy-homeworlds.jpg`, the 3024×4032 original photograph associated with [BGG image 7767034](https://boardgamegeek.com/image/7767034/dune-ecaz-and-moritani), posted by Jaakko/zaksalo on 2 October 2023. Grumman and Giedi Prime are both legible in its bottom row. The photograph is primary component evidence; the photographer's opinions are not rules authority. The [component audit](HOMEWORLD_COMPONENT_AUDIT.md) records all face provenance.
+
+The low-Grumman face wording comes from the earlier complete reverse-face inspection recorded in that audit. The old reverse-image cache is absent. A fresh attempt to retrieve the original gallery's public RSS returned 403, so no new reverse-image inspection is claimed. Fresh publisher-indexed E3 pages 5, 15 and 16 and E2 page 6 were retrieved successfully; direct opening of the E3 PDF failed. No additional authenticated designer ruling was located in bounded searches.
+
+## Giedi Prime high collection income
+
+The high face requires at least seven native Harkonnen reserves and awards two bank spice when Harkonnen collects spice from desert territories or Homeworlds during that turn's Spice Collection. The E3 FAQ explicitly limits this to **two total**, even when several qualifying collections occur. Ordinary stronghold income is not one of its listed reasons. Homeworld effects resist Karama. [GF9 E3, pp.10,15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=15).
+
+Executable contract:
+
+- Preserve each actual collection receipt's player, turn, phase, source kind and positive amount. A positive desert receipt qualifies; presence in an empty desert or an uncredited collection quote does not.
+- For Ecaz/Harkonnen shared desert collection, use Harkonnen's **settled allocation**. A reserved joint pool is not yet its income; a zero share does not qualify. A positive agreed or fallback share retains its desert origin. This composes the card trigger with [Ecaz's allocation rules](ECAZ_COLLECTION_RULES.md), not a dedicated Giedi/Ecaz FAQ example.
+- Once the condition is met while the high advantage is available, credit two and retain a once-only turn receipt. Later desert or Homeworld receipts, repeated phase normalization and restored allocation continuations do not pay again.
+- Exclude ordinary stronghold bank income, the extra Ecaz stronghold award, technology receipts, sales, charity, revival income, payment income, Grumman's four-spice award, unrelated ally gifts and collection outside the named phase. Having all Tech Tokens count toward victory does not turn their income into desert or Homeworld collection.
+- Future occupied-Homeworld bank income needs its actual authorized recipient and original source. Immediate sharing of another occupier's income is not explicitly classified by this card or the retrieved FAQ; preserve provenance and resolve that interaction with occupation rather than silently counting every ally transfer.
+
+Population at an actual qualifying receipt is the continuous-threshold composition used elsewhere in this module. The printed text does not freeze the population at phase start. An eventual low-to-high change after an earlier collection but before the phase ends needs deliberate timing composition; this document does not claim a dedicated retroactive-trigger ruling. Ordinary phase-end settlement can retain qualifying-source evidence without paying twice.
+
+The existing `quoteSpiceCollection` totals map deposits into `receipt.collected`; it does not guarantee every deposited location is a desert. A Giedi consumer must retain/filter each source territory's actual `sand` type, rather than equating the aggregate field with desert income. Commit normal and shared receipts separately, preserving current balances and their existing durable continuations.
+
+## Grumman high Collection opportunity
+
+The inspected high face requires at least eight native Moritani reserves. It offers adding a Terror token to a stronghold already containing one, **or** removing a Terror token from the board during Spice Collection, followed by four bank spice. The payment is tied to the completed token change: declining or having no legal token change supplies no independent four-spice claim.
+
+The singular phase-scoped opportunity supports one selected operation and one award per Collection, using the same ordinary phase grammar as Terrorize's place-or-move opportunity. The card does not contain an explicit repeat count or the word “once”; this is a grammar composition, not a Grumman-specific FAQ quotation. It does not authorize an add/remove loop generating repeated income. Retain a turn/phase completion receipt independent of the ordinary Mentat `placementTurn`.
+
+For addition, select one available physical Terror token and a permitted stronghold already holding a placed token. The card supplies an exception to ordinary Terrorize's empty-destination requirement; it does not authorize a new token, a second ordinary Mentat placement, relocation from another stronghold, Homeworld placement or use of the Hidden Mobile Stronghold. Storm alone does not prevent ordinary Terror placement. [GF9 E3, pp.5,16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=5).
+
+For removal, select one actual placed token. A spent token, the separate Atomics Aftermath marker, an available supply token or a revealed Extortion record is not a Terror token on the board. The face says removal from the board; it does not expressly name its next custody zone. Returning it to hidden supply is a plausible composition, while ordinary **revelation** expressly removes tokens from the game. Do not claim the latter as authority to permanently spend an unrevealed removal, or call `revealTerror` merely to clear its location. The removal destination remains a source boundary for the coordinator; no new question was sent by this audit.
+
+Token change and four-spice credit must be one accepted transaction after checking current high population, valid custody, phase and unused opportunity. Reject before mutation or randomness if any check fails. Preserve the six physical identities and every other placed token. If returning a token to hidden supply is adopted from sufficient authority, use the established identity-rotation contract so a previously known face cannot be followed through a persistent public ID.
+
+Stacking is a genuine new registry condition. Remove only the global one-token-per-location assumption; retain unique physical identity, exactly one location/status per token and valid stronghold locations. Ordinary Mentat placement still requires an empty destination. Never overwrite an existing token merely because both share a location.
+
+## Grumman low entry restriction
+
+The recorded low face, zero through seven native forces, permits Terror revelation only when at least three forces enter its territory. The base Terror rule includes entering advisors and excludes Moritani and its ally. Existing presence is not an arrival. The threshold does not force revelation. [GF9 E3, p.5](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=5).
+
+Count the current completed entry group, not the territory's population after arrival. Regular and special counters each count one physical force; do not double Sardaukar, Fedaykin or Cyborg battle strength. Count entering advisors as the ordinary Terror rule explicitly includes them. A legal move combining several sectors into one entering group uses that combined entry count. Separate shipment, movement and accompanying-advisor events do not accumulate toward three; stationary allied forces do not enlarge another faction's incoming group.
+
+A concealed No-Field counts as one force regardless of its private 0/3/5 value. The ordinary Terror FAQ identifies its placement as an entry, including zero; it does not waive Grumman's additional low restriction. Thus a lone concealed marker fails three, while a legally admitted single move of two physical newcomers plus that marker reaches three. Do not read its secret value or force a reveal to compute eligibility. Revealing an already-present No-Field and Face Dancer replacement are not new Terror entries. This combines the ordinary marker-presence rule with the Homeworld restriction. [GF9 E2, p.6](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=6), [GF9 E3, p.14](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=14).
+
+Keep the original public entry count through a pending decision; later group changes must not rewrite which event happened. Current native population controls whether the low penalty currently applies. A mid-response population change and Enemy of My Enemy's compulsory-refusal revelation need their existing continuation checks; a disallowed low entry must not be used to manufacture an alliance offer whose refused outcome cannot legally reveal. Retained occupied penalties remain subject to the existing occupation boundary.
+
+## Multiple placed tokens and private choices
+
+The ordinary rule permits Moritani to choose a Terror token to reveal. With a stack, a first-array-element lookup cannot stand in for the owner's choice. Present the owner the actual eligible placed identities, preserve their secret faces for every other seat and keep decline available. The singular entry opportunity supports selecting one token; no retrieved Grumman FAQ expressly discusses activating several stacked tokens from the same entry. Treat one-choice handling as ordinary trigger composition, not as an invented automatic all-token resolution or a claim of a dedicated stacking clarification.
+
+Bind the selected token and immutable original entry to the saved continuation. Validate all current candidates remain at that location, and ensure relocation/removal cannot consume a reserved token during its pending trigger. Place/remove operations do not themselves activate Terror or reveal a hidden face. A successful Grumman Collection operation should not expose the removed token's type through logs, errors, public IDs or arbitrary supply ordering.
+
+## Verification handoff
+
+Test Giedi's first positive desert receipt, multiple pools still totaling two, desert-versus-stronghold deposits, shared zero/positive allocations, high/low boundaries, unrelated Collection-phase income and no replay across JSON restoration. Homeworld income and indirect sharing require the separate occupation source decisions.
+
+Test Grumman addition and removal with exact custody and four-spice conservation, decline and unavailable operations, separate Collection/Mentat usage, storm and location rules, stacked owner selection, three physical elites versus two, advisors, combined and separate entry groups, stationary forces, concealed marker getter traps, original entry binding and low-state rejection without token disclosure. Extend SQLite continuation tests once runtime producers exist.
+
+Only this document was written. Local links and whitespace were reviewed; no runtime, database, reference or implementation-status change and no gameplay verification claim follows from this audit.
diff --git a/docs/HOMEWORLD_COLLECTION_RUNTIME.md b/docs/HOMEWORLD_COLLECTION_RUNTIME.md
new file mode 100644
index 00000000..7e4382a4
--- /dev/null
+++ b/docs/HOMEWORLD_COLLECTION_RUNTIME.md
@@ -0,0 +1,96 @@
+# Giedi collection and low Grumman entry
+
+This checkpoint connects two population effects. The complete Homeworld module,
+Moritani faction and expansion starts remain gated. Printed component evidence,
+the No-Field composition and Grumman's unresolved removal destination are in
+[the source audit](HOMEWORLD_COLLECTION_RULES.md). The broader integration map is
+in [the collection audit](HOMEWORLD_COLLECTION_INTEGRATION.md).
+
+## Automatic Giedi Prime income
+
+`board-resolution-quote.ts` separates actual desert collection from other map
+deposits and stronghold income. The engine first commits ordinary balances,
+then quotes Giedi's two bank spice using current physical native population.
+Seven native reserves qualify in both Basic and Advanced. Multiple deposits
+earn two total; technology and stronghold income do not qualify.
+
+Ecaz joint desert lots remain escrow until their allocation settles. A positive
+Harkonnen allocation can earn the first Giedi bonus; a zero allocation cannot.
+An earlier ordinary desert bonus prevents another payment after shared income.
+Canceling Ecaz's ordinary stronghold Collection advantage does not cancel Giedi.
+Homeworld occupation income remains unfinished and has no new producer here.
+
+`giedi-collection.ts` records turn, owner, cumulative actual qualifying income
+and whether the bank bonus was paid. A canonical signature detects inconsistent
+saved receipt fields at action, view and normalization boundaries. The receipt
+is completed evidence, never a command to pay on read. Older saves without the
+whole receipt remain compatible: they receive no reconstructed or retrospective
+award. A newly settled positive share can still qualify. Whole-receipt absence
+cannot be distinguished from legacy state and is not a claimed corruption check.
+
+The public chronicle explains the automatic two-spice payment and supplies the
+existing optional faction animation event. There is no new confirmation or AI
+decision. Private balance and hand visibility remain unchanged.
+
+## Low Grumman
+
+The shared entry restriction checks fewer than eight native reserves and an
+original entering group smaller than three. Physical special counters count
+once; entering advisors count; existing destination forces do not. A concealed
+No-Field counts as one independently of its private value. Two physical forces
+and that marker in the same admitted movement form a group of three.
+
+The restriction applies before competing reactions are declared and again at
+actual entry, revelation and an alliance offer whose refusal requires Terror.
+Ordinary movement, shipments, advisor arrivals and Ambassador continuations use
+the same predicate. Guild and Fremen Ambassador choices project a maximum of
+two where larger groups would require unresolved simultaneous Intrusion/Terror
+ordering. Their controls and all four bot policies honor that limit.
+
+The original public entry facts are bound to a canonical saved signature;
+mutable resolution stages do not change the original count. The binding also
+covers suspended Terror discard continuations. Current population can change
+the low restriction, while the saved entering group cannot be rewritten.
+Missing signatures remain legacy-compatible; present inconsistent signatures
+must reject rather than repair the entry from current board forces.
+
+High Grumman's optional Collection token change and four-spice payment remain
+unfinished. No stacked-token or removal-destination interpretation is enabled
+by these changes. The question about unrevealed removal custody is pending,
+alongside the earlier occupation and low-Junction rounding questions.
+
+## Verification scope
+
+Final `npm run check` passes types, lint and **3,575 offline cases**. The final
+production build and **40 HTTP cases** pass. There are 34 new cases: eight pure
+quotes, seven Giedi engine cases, four Giedi SQLite cases, twelve Grumman engine
+cases and three Grumman SQLite cases. The existing stale-Terror regression now
+checks both signed receipts and legacy unsigned saves. Final check evidence is
+in `/tmp/dune-collection-verified-check.log`, with build and HTTP evidence in
+`/tmp/dune-collection-final-build.log` and `/tmp/dune-collection-final-http.log`.
+
+The pure, engine and production SQLite suites cover population thresholds,
+actual receipt provenance, shared allocations, rejection immutability,
+simultaneous submissions, private projection and JSON/database restoration.
+Grumman regression cases cover original entry counts, concealed marker equality,
+advisors, elite counters, competing reactions and all four Ambassador bot levels.
+These subsystem checks do not certify complete games with the module.
+
+A three-seat browser table collected seven desert spice and automatically added
+two bank spice, reaching Harkonnen's balance of 29 from 20. Keyboard completion,
+three-seat refresh, private balances and desktop/390-pixel phone layouts passed.
+Both chronicle screenshots were visually inspected. The initial capture waited
+for chronicle text while the hand tab was selected; opening the chronicle fixed
+the test harness, with no repeated gameplay action or application change.
+
+The controlled development-server restart preserved all 2,958 existing room
+versions and state hashes. Three saved payment-test seats reconnected at their
+original version and balances without browser errors. The new collection QA
+room and HTTP test rooms are additive. No saved game was reset, and no recurring
+automation was installed.
+
+At the end of verification, the database contains 3,019 rooms: the 2,958 unchanged
+baseline rooms, one new collection browser table and 60 rooms from the two HTTP
+runs. The collection table remains at version four with one completed bonus
+record. Private saved-seat files and the database backup remain local temporary
+artifacts and are not included in version control.
diff --git a/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md b/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md
new file mode 100644
index 00000000..1b34547a
--- /dev/null
+++ b/docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md
@@ -0,0 +1,23 @@
+# Homeworld combat foundation and Salusa support
+
+9 September 2026. This extends the native-reserve runtime. Homeworld invasion and live off-planet battle actions remain unavailable; no start gate is opened.
+
+## Connected Salusa behavior
+
+In Advanced Homeworld development games, maintaining at least two Sardaukar on Salusa grants free support to the Emperor’s Sardaukar wherever they fight. Normal forces retain their ordinary support costs. The shared combat calculation uses a separate optional elite-support flag, so ordinary Sardaukar Karama can reduce their strength without canceling the Homeworld benefit. Shipping a fourth Sardaukar away from the initial five leaves Salusa low and removes free elite support. Normal reserves transferred there do not replace the named population threshold.
+
+Server plans, typed casualty options, Stone Burner comparisons, the private player view, all four AI policies and the table’s support maximum share this calculation. Saved casualty decisions preserve the relevant force snapshot. Legacy snapshots without the optional flag retain their existing behavior. Salusa’s low free-revival prohibition and occupation penalty remain unfinished.
+
+## Off-planet foundation
+
+`game/homeworld-combat.ts` reads exact native and visiting typed custody into detached public armies and non-allied battle pairs. It validates player order and reciprocal alliances without adding fictional sectors or strongholds to Arrakis.
+
+The pure battle-resolution quote accepts an explicit native card, face and physical army. It adds the printed native strength without increasing dial casualties, restricts traitor callers to the native combatant, and quotes native Lasgun/Shield losses separately from invading army destruction. Two visitors can fight with no traitor voters; a noncombatant native still retains the explosion protection. Basic Homeworld outcomes preserve typed physical losses.
+
+These quotes are not yet wired into live off-planet battle discovery, aftermath, force removal, Face Dancers or occupation. The engine must consume the separate native explosion choice before transport opens. See the [integration audit](HOMEWORLD_COMBAT_INTEGRATION_AUDIT.md) and [invasion source audit](HOMEWORLD_INVASION_RULES.md). Current invasion/occupation interpretation questions remain pending.
+
+## Verification
+
+Focused checks pass 62 combat/Stone Burner cases, 29 battle-resolution cases, nine Homeworld army/discovery cases and eight Salusa engine cases. These groups overlap existing regression coverage and are not a combined full-suite count. The Salusa cases use genuine Advanced setup, typed shipments and actual battle preparation, sealed plans, traitor actions and casualty settlement. They cover high/low transitions, normal support costs, ordinary Karama, JSON restoration, opponent privacy, physical conservation and legal decisions from every AI level.
+
+The first integrated pass passed **3,218 offline cases**, typecheck, lint, production build and **40 HTTP cases**. Independent review then reproduced a Ghola deadlock: raising Salusa from one to two Sardaukar during combat could invalidate an already sealed paid-support plan. A temporary explicit release boundary now blocks only that population-changing Sardaukar Ghola return during the Emperor’s battle, before any card or force moves. Regressions cover both a sealed plan and a binding prescience dial before either full plan, JSON rejection without mutation, subsequent battle completion and the later legal Sardaukar return. The player view, AI allowance and controls expose the same restriction; ordinary force and leader targets remain available. This is an unfinished interaction, not an official prohibition. The final check after the guard passes **3,221 offline cases**, typecheck and lint; the final production build passes. The 40 HTTP cases passed before the guard; the guard’s new coverage executes production engine actions with JSON restoration. All **2,648** earlier baseline rooms retain their versions and state hashes. No new browser acceptance was performed for this slice. No live Homeworld invasion, complete Homeworld game, full module combination or browser acceptance is claimed by this checkpoint. The recurring automation remains removed.
diff --git a/docs/HOMEWORLD_COMBAT_INTEGRATION_AUDIT.md b/docs/HOMEWORLD_COMBAT_INTEGRATION_AUDIT.md
new file mode 100644
index 00000000..71cb0b60
--- /dev/null
+++ b/docs/HOMEWORLD_COMBAT_INTEGRATION_AUDIT.md
@@ -0,0 +1,81 @@
+# Homeworld combat integration audit
+
+Read-only architecture audit, 9 September 2026. The contract is [Homeworld rules](HOMEWORLD_RULES.md), with transport/occupation boundaries in [invasion rules](HOMEWORLD_INVASION_RULES.md). This document identifies adapters required for actual combat; it does not enable Homeworlds or certify complete games. Line numbers below identify the inspected worktree and may shift during concurrent implementation; function names are the durable anchors.
+
+## Current boundary
+
+Native and visitor inventory already has an independent representation: `homeworldForceGroups` and `quoteHomeworldCustody` in [homeworld-custody.ts](../game/homeworld-custody.ts) return exact `{normal, elite}` groups and detached transactions. Native primary counts derive from player reserve totals; Salusa is an allocation inside Emperor's total. Visitor groups are separate and never belong in `Player.forces`. `homeworldPopulations` supplies the printed card, native owner, current side and bonus, but deliberately does not resolve occupation entitlements.
+
+The concurrently added [homeworld-battle-rules.ts](../game/homeworld-battle-rules.ts) and Homeworld branch of [battle-resolution-quote.ts](../game/battle-resolution-quote.ts) already accept an explicit native/card/side/army contract. At inspection, the pure result can add native strength, restrict traitor callers and return typed native explosion losses. **The live engine does not yet supply this contract or consume the explosion result.** A calculator result is not evidence of live battle support.
+
+Keep `gameTerritories`, `validLocation`, `splitLocation`, storm geometry, territory occupancy and stronghold control restricted to their existing Arrakis/HMS domain. Do not add a fabricated `homeworld:…:0` force key or append Homeworld cards to `TERRITORIES` to make existing validators pass.
+
+## Minimum adapter contracts
+
+Introduce one explicit combat-location resolver used by discovery, plans, resolution, aftermath, bots and controls. A discriminated location `{kind:'territory', id}` or `{kind:'homeworld', id}` can coexist with legacy saved `Battle.territory` strings through a strict decoder. The resolver must reject unseated worlds and Basic Salusa. It should supply:
+
+- A stable canonical identity and display name; territory geometry only for the territory variant.
+- Current physical armies by seated player, with native versus visitor provenance on Homeworlds. Public presence must not inspect concealed No-Field values.
+- For a Homeworld, its native owner, card, applicable side and printed native bonus; use this only for that native faction's score. Preserve occupation as a separate input once its lifecycle is settled.
+- Legal combatant pairs, current attacker order, entitled traitor callers, and eligible Face Dancer owner.
+- A detached casualty transaction and receipt that binds exact source pool, normal/elite losses, Tanks deltas and resulting custody. This must also serve replacement/return effects without conflating them with deaths.
+
+Do not require the native to be one of the two combatants: multiple visitors can fight each other. There is no Homeworld stronghold-capacity rule. Use the ordinary opposing-faction and turn-order rules for discovery without inventing native-first ordering. Allied noncombatants remain relevant to an explosion even when they do not fight each other.
+
+## Live function map
+
+| Boundary | Inspected code | Required adaptation |
+| --- | --- | --- |
+| Battle frontier | `quoteBattleBoard`, [board-resolution-quote.ts](../game/board-resolution-quote.ts):93; `battles`, [engine.ts](../game/engine.ts):8650 | Retain the planet quote, then compose a separate Homeworld conflict quote from custody and current order. No `gameDistance`, storm sector, advisor merge, Polar Sink exclusion or stronghold capacity applies to a world. Preserve the original advisor-release operations for planet battles. |
+| Phase progress | `quoteBattleBoardContinuation`:364; `currentBattleAftermathQuote`, `finishBattle`; phase-6 initialization and automatic response normalization | Every test for “no remaining battles” must use the same combined frontier. Updating only `chooseBattle` would allow automatic advance to Collection while a Homeworld conflict remains. Preserve aid refunds and the existing CHOAM/Ix opening boundaries. |
+| Choice and opening | `chooseBattle` action, `engine.ts`:17398; `Battle` type:480; `beginStrongholdBattle`, `beginBattlePowers` | Validate the selected canonical location/pair against that frontier; persist its identity in the battle event. Use normal sealed preparation. Stronghold copy/effects remain unavailable on Homeworlds; ordinary Voice, Prescience and other faction powers retain their independent scope. Do not add Homeworld bonus cancellation windows. |
+| Force commitment | `combatForces`:3453; `validatePlan`:9972; `feasiblePrescience`:10912; `prescienceAnswer`; plan completion/truth helpers | Replace the board-force reduction with the exact location army. Continue to reuse `CombatForces`, `casualtyOptions`, leader/card legality and aid pricing. Basic dial parsing in `prescienceAnswer` currently caps against `at(p,b.territory)` and also needs this adapter. Audit plan-completion simulations, not just final plan submission. |
+| Final pure input | `currentBattleResolutionQuote`:11160 | Supply `homeworld` from authoritative custody and current applicable face. Keep stronghold benefits null. Bind the actual native army, including when it is a noncombatant; do not infer it from a visitor's counters. Keep private hands and traitor identities server-only. |
+| Removal preflight | [force-loss-preflight.ts](../game/force-loss-preflight.ts) `validateBattleForceLoss`; `currentBattleResolutionQuote` | The existing validator checks canonical sector keys only. Add Homeworld pool preflight through the custody quote, including aggregate reserve and Tanks consistency. Do not relax board-key checks. |
+| Removal commit | `kill`:3402; `killTerritory`:3431; `takeBattleLosses`:3490; `resolveBattle`:11228; `settleWinnerCasualties`:11460 | Dispatch by location. Native deaths withdraw from the exact primary/Salusa pool and increment Tanks; visitor deaths withdraw from visitors and increment that faction's Tanks. Update elite Tanks and battle-loss counts exactly once. Ordinary deaths must not become reserve returns. |
+| Delayed choices | `continueResolvedBattle`, `battleLosses`, `settleWinnerCasualties`, `pendingIxSubstitution`, `ixSubstitution` decision/response | Reuse owned casualty choices and automatic singleton handling, but bind them to a typed Homeworld pool. Ix substitution currently stores sector-key loss/source/recovery maps and directly reads board forces: it needs location-aware records and a real survivor/cyborg exchange. |
+| Aftermath | `currentBattleAftermathQuote`:11500; [battle-aftermath-quote.ts](../game/battle-aftermath-quote.ts); `battleCleanupInput`:11762; [karama-battle-preflight.ts](../game/karama-battle-preflight.ts) | Replace the `territoryIds = gameTerritories(...)` validation boundary with explicit valid combat locations. Carry identical location identity through capture, Auditor, card retention, tech transfer and Face Dance. Keep their established sequencing and ownership; accepting a Homeworld identity must not grant territory-specific effects. |
+
+`offerMoritaniDuke` is another consumer of `battles(g)`: it currently calls `territory(battle.territory).type`. Once the combined frontier is returned, filter the territory variant before the stronghold predicate. Homeworld combat does not qualify as a stronghold battle for this power.
+
+## Strength and casualty details that need explicit handling
+
+`CombatForces.freeSupport` is currently one boolean for the entire army. The Salusa high face gives **Sardaukar** free support, including when normal forces have been transferred there. Setting the existing boolean would accidentally make normal forces free too. Extend the shared arithmetic to represent per-type free support, then use it in `casualtyOptions`, plan validation, pure support payment, saved casualty validation, bots and the plan wheel. Preserve Basic strength-one starred counters, Ix fixed-half Suboids and the independent Emperor-versus-Fremen exception. Salusa occupation's loss of Sardaukar advantage is a separate printed modifier; it cannot be implemented as ordinary Karama cancellation.
+
+The native printed bonus is a score addition, not additional physical counters, a larger dial, a spice-support charge or ordinary winner casualties. The source's “current” bonus should be supplied by the authoritative resolution adapter. Do not silently lock a high face for the whole battle merely because its opening was saved; conversely, once a resolution is committed, its receipt must retain the accepted effective facts while delayed casualties change population.
+
+For Lasgun/Shield, `resolveBattle` currently loops through all players and calls `killTerritory(...,Infinity,true)`; it ignores the new pure `homeworldExplosion` result. The Homeworld branch must destroy actual foreign armies and commit only the native printed casualty count, capped by available natives. When multiple normal/elite allocations exist, create a native-owned casualty choice with a saved event; when only one exists, apply it automatically. Both combatants still lose and leaders die according to the explosion result. Preserve surviving natives and recalculate population after actual losses. Do not carry this named exception into Stone Burner, mutual Traitors, ordinary losses or arbitrary board destruction.
+
+The exploding native may be a noncombatant. Test this explicitly: using the winner's ordinary casualty continuation alone cannot represent the choice because an explosion has no winner. An empty native army must not create a meaningless confirmation. Resolve the native choice before any successor consumes the final custody/occupation state.
+
+## Traitors and Face Dancers
+
+`traitorVoters` currently returns both combatants and sometimes a remote Harkonnen ally. On a Homeworld return only the native if it is a combatant. A native who is not fighting cannot become a remote traitor beneficiary; two visitors fighting therefore have no traitor voters. The same helper feeds `traitorCall`, automatic decline/resolution, cancellation preflight and projected `traitorVoters`; change them together to avoid an invisible waiting decision. Preserve revealed-card reactions, including Stone Burner and Poison Tooth, before automatic zero-voter resolution.
+
+`resolveBattle` currently creates `pendingFaceDance` for any Tleilaxu seat when somebody else wins. On a Homeworld, only native Tleilaxu may receive that opportunity. A remote Tleilaxu cannot call on another faction's world, even for an ally. The occupied Tleilax immunity for an occupier/ally is a separate condition to compose when the occupation contract is available.
+
+The `faceDance` decision (`engine.ts`:15526) currently requires a sector and uses `at(winner, territory)`, board source keys, direct reserve decrements, direct winner reserve returns and `place`. The Homeworld branch must instead quote the remaining winning foreign army, selected permitted Tleilaxu replacement sources, and a destination deposit into native Tleilax custody. Return the actual winner's normal/elite pieces through the native-return adapter, preserving Emperor's distinct pools and any unresolved return-routing interpretation. Preserve leader death, shared Cheap Hero identity, revealed-dancer custody and deck refresh. Source scope of replacement sources must be audited explicitly; the old `reserves`/sector parser does not authorize arbitrary foreign garrisons.
+
+## Recovery boundaries
+
+The mandatory battle-discard validator (`engine.ts`:3080–3252) both requires `gameTerritories(...).some(...)` and rechecks winner casualties by reducing `winner.forces`. It will reject an otherwise valid Homeworld battle even after the live resolver is adapted. Carry explicit combat identity and exact pool snapshots into `battleResolved`, `battleCleanup`, `lastBattleContext`, casualty choices and any new explosion continuation. Revalidate nested saved parents before read/action/normalization effects, including Nullentropy or other interruptions of those decisions.
+
+Receipts must distinguish “resolution committed, typed losses pending” from “losses already committed, cleanup pending.” Do not rederive a completed native bonus after casualties flip its card or rerun support payment, bounty, leader death, card discard, tech transfer or capture on refresh. Same-total Salusa/native/visitor reallocations must invalidate a receipt bound to a different pool. Existing territory-only saves should continue through their current decoder and checks.
+
+Atreides high reinforcement belongs after the appropriate completed winner/casualty sequence and requires a surviving own force at that combat location. It needs an owned optional reserve-transfer action, not a new ordinary shipment. Do not fold occupation rewards into generic battle victory: presence changes and qualification events need their own event-bound settlement, and the unresolved occupation cases remain documented in the source contract.
+
+## Bots and controls
+
+`viewGame` already projects owner-only `battle.ownForces`, sealed own plans, revealed plans and entitled prescience separately. Reuse that privacy boundary and add public combat location/name, native bonus and public opponent army counts. Do not expose a server-side `ResolutionCombatant` object; it contains private cards.
+
+[bot-battle-choices.ts](../game/bot-battle-choices.ts) independently reconstructs only planetary battles. Replace that duplication with shared legal combat choices. In [bots.ts](../game/bots.ts), `plans` already prefers `b.ownForces`, but its opponent estimate uses `presenceAt(other,b.territory)` and would see zero defenders on a Homeworld. Update both legal candidates and strength estimates with the public adapter; add native bonus to evaluation, not dial. Face Dance strategy currently selects board sectors; Ix substitution and explosion choices also need exact legal descriptors for all four difficulty levels.
+
+[game-table.tsx](../components/game-table.tsx) prefers `ownForces` but still uses `territory(g.battle.territory).name` in the battle heading and multiple cleanup/replacement labels. Use the combat display adapter; no sector selector should appear for a Homeworld-only target. Show the native bonus separately from the dial, per-type support costs, exact casualty pools and clear decision ownership. Mandatory single-option effects remain automatic with existing animation notices; optional replacement/casualty choices stay actionable after reload.
+
+## Evidence required before enabling
+
+Use actual dispatcher paths from a conserved invasion fixture through battle choice, sealed plans, reveal, owned casualties, card disposal and aftermath. Cover native/visitor and visitor/visitor pairs, ordinary turn order, allied third-party explosion losses, zero-voter automatic progress, remote Harkonnen/Tleilaxu rejection, Basic starred identity, mixed Salusa forces, population crossings, ordinary traitor losses and the explosion exception. Check typed Ix substitution and native Tleilax Face Dance without creating counters.
+
+Test JSON and in-memory D1 recovery before/after each committed stage, concurrent final plans, duplicate casualty submissions and corrupted exact-pool receipts. Demonstrate all four bots can finish these paths without secret information. Browser-check Homeworld battle selection, sealed plan controls, native bonus, typed support/casualty choices, Face Dance, labels and refresh on desktop/mobile. Broader setup-to-endgame and every-faction/module evidence remains necessary for the full release gate.
+
+This audit changed only this document and ran no game, test suite, server or saved-room operation. Current pure-calculator edits were observed, not independently certified. Occupation lifecycle and other existing source ambiguities remain unresolved here.
diff --git a/docs/HOMEWORLD_COMPONENT_AUDIT.md b/docs/HOMEWORLD_COMPONENT_AUDIT.md
new file mode 100644
index 00000000..1a974c8e
--- /dev/null
+++ b/docs/HOMEWORLD_COMPONENT_AUDIT.md
@@ -0,0 +1,116 @@
+# Homeworld component audit
+
+Verified 9 September 2026. All 13 physical cards, both faces, have now been visually inspected. The tables below preserve the gameplay facts in original wording; they are not a transcription or a claim that the Homeworld module is implemented. Global module rules and unresolved compositions belong to [HOMEWORLD_RULES.md](HOMEWORLD_RULES.md).
+
+## Sources and completeness
+
+- **Publisher inventory:** [GF9 E3 rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), printed p.4, lists **13 Homeworld cards and 13 matching tokens**. There are 12 factions: Emperor has Kaitain and the Advanced-only Salusa Secundus. Local page `/tmp/dune-rules/ecaz-p4.png` was visually checked; the count is not on printed p.3.
+- **Independent physical photograph:** [Jaakko/zaksalo, BGG image 7767034](https://boardgamegeek.com/image/7767034/dune-ecaz-and-moritani), posted 2 October 2023, shows all 13 high faces and matching tokens. Public metadata API `/api/images/7767034` supplied the original 3024×4032 photograph. Local inspection file: `/tmp/dune-rules/homeworld-bgg-high.jpg`.
+- **Complete front/back face sheets:** [C4ESIUM, “Every Homeworld cards”](https://www.reddit.com/r/DuneBoardGame/comments/16w27x0/every_homeworld_cards/), 30 September 2023. Its public RSS feed supplied four direct physical-face images; all were visually read. These are third-party-hosted depictions of the original components, not authority from comment replies. Their production provenance cannot be established from the post alone. The independent BGG photograph corroborates every high face. No fan interpretations are adopted.
+- **Designer corroboration:** [Jack Reda, Homeworlds Revealed](https://www.youtube.com/watch?v=4KZKz13wf9c), linked by [Future Pastimes](https://futurepastimes.com/dune-ecaz-moritani). Public preview sheets `/tmp/dune-rules/homeworld-story-0.jpg` through `-14.jpg` show selected enlarged original faces. Particularly useful: Caladan high around 8:55; Kaitain low around 1:50 and 3:00; Tupile low around 2:15; Junction occupied around 4:00; Tupile occupied around 4:35. Only visible text was used; the captions request returned empty and no unverified spoken statement is credited.
+
+The complete sheets are `/tmp/dune-rules/homeworld-reddit-face0.png` (seven base high faces), `face1.png` (their reverses), `face2.png` (six expansion high faces), and `face3.png` (their reverses). These research images are not application assets or redistribution permission.
+
+## Canonical physical manifest
+
+Each row is **one two-sided card plus one matching world token**. High and low ranges are printed inclusive ranges, not an inferred universal flip algorithm. Battle numbers are native faction additions to its dial in battles on that world. The card prints the same number as its native losses from a Lasgun/Shield explosion. Occupied icons are counted separately from the occupied text.
+
+| Proposed card ID | Printed name | Runtime faction | High reserves | Low reserves | High battle / explosion losses | Low battle / explosion losses | Occupied spice icons |
+|---|---|---|---|---|---|---|---|
+| `caladan` | Caladan | `atreides` | 6–20 | 0–5 | 2 | 2 | 2 |
+| `giedi_prime` | Giedi Prime | `harkonnen` | 7–20 | 0–6 | 2 | 2 | 2 |
+| `southern_hemisphere` | Southern Hemisphere | `fremen` | 3–20 | 0–2 | 2 | 2 | 2 |
+| `junction` | Junction | `guild` | 5–20 | 0–4 | 2 | 2 | 2 |
+| `wallach_ix` | Wallach IX | `beneGesserit` | 11–20 | 0–10 | 3 | 2 | 1 |
+| `kaitain` | Kaitain | `emperor` | 5–20 | 0–4 | 2 | 3 | 2 |
+| `salusa_secundus` | Salusa Secundus | `emperor` | **2–5 Sardaukar** | **0–2 Sardaukar** | 3 | 2 | 0 |
+| `ix` | Ix | `ixians` | 5–20 | 0–4 | 2 | 2 | 2 |
+| `tleilax` | Tleilax | `tleilaxu` | 9–20 | 0–8 | 2 | 2 | 2 |
+| `tupile` | Tupile | `choam` | 11–20 | 0–10 | 2 | 2 | 2 |
+| `richese` | Richese | `richese` | 10–20 | 0–9 | 2 | 2 | 1 |
+| `ecaz` | Ecaz | `ecaz` | 7–20 | 0–6 | 2 | 2 | 2 |
+| `grumman` | Grumman | `moritani` | 8–20 | 0–7 | 2 | 2 | 2 |
+
+All ranges except Salusa name the faction's reserves, not a special force type. Do not substitute Arrakis for the Fremen card's printed name, Junction for CHOAM, or the Tleilaxu faction name for the world Tleilax. Card IDs above are implementation proposals, not printed identifiers; engine custody zones can have distinct stable IDs.
+
+## Verified high-side effects
+
+| Card | Gameplay facts |
+|---|---|
+| Caladan | After winning a battle, Atreides may add one reserve force to its battle territory or homeworld, provided Atreides already has a force there. |
+| Giedi Prime | Harkonnen receives 2 bank spice when it collects spice from any desert territories or homeworlds during that turn's Spice Collection. |
+| Southern Hemisphere | A revived starred Fedaykin force may immediately be placed in any territory already containing Fremen forces. |
+| Junction | Guild may offer another faction cross-planet shipment, at half or full price, as part of that faction's Shipping action. This includes travel to or from homeworlds, including the shipper's own. |
+| Wallach IX | When accompaniment to another faction's shipment sends spiritual advisors to Polar Sink, BG may send two. |
+| Kaitain | At the end of Bidding, Emperor may pay 2 spice for each Treachery Card it chooses to discard from its hand. |
+| Salusa Secundus | Sardaukar do not require spice support to count at full strength. |
+| Ix | Paying to revive a Cyborg also grants two free Suboid revivals. |
+| Tleilax | On taking Free Revival, Tleilaxu may send those forces from reserves, without shipping cost, to one territory or homeworld; storm and occupancy restrictions still apply. |
+| Tupile | CHOAM cannot exchange Worthless Cards for spice, except through its Advanced Karama. This high side is a penalty. |
+| Richese | Reserve forces may accompany a No-Field token shipment. |
+| Ecaz | Each poison-weapon discard yields 3 bank spice. Its alliance also has a victory route: jointly occupy at least one stronghold and occupy two other factions' homeworlds. |
+| Grumman | During Spice Collection, Moritani may either add a Terror token to a stronghold already holding one, or remove a Terror token from the board; it then receives 4 bank spice. |
+
+These are source scopes, not completed adapters. For example, Giedi Prime's collection award is not automatically repeated for every pool; Kaitain's discard is a paid choice, and Tupile's exception does not erase its high-side restriction.
+
+## Verified low-side effects
+
+Every low face **except Salusa Secundus** also explicitly grants **one extra Free Revival and one extra CHOAM Charity spice from the bank**. The extra Charity is not a payment from the CHOAM player. Salusa prints neither bonus, corroborated by E3 p.10.
+
+| Card | Additional low-side gameplay |
+|---|---|
+| Caladan | Atreides cannot use its Movement advantage to inspect the next Spice Blow. |
+| Giedi Prime | No additional penalty. |
+| Southern Hemisphere | No additional penalty. |
+| Junction | Guild receives only the rounded-up half of shipping payments. |
+| Wallach IX | BG cannot send spiritual advisors. |
+| Kaitain | Emperor receives only the rounded-up half of Treachery Card payments. |
+| Salusa Secundus | Sardaukar cannot be revived for free. |
+| Ix | The Hidden Mobile Stronghold cannot be moved. |
+| Tleilax | If Tleilaxu starts the Revival phase low, it receives no spice for other factions' Free Revival that phase. The start-of-phase condition is printed and requires a timing snapshot. |
+| Tupile | Once per faction, when CHOAM is on another homeworld or another faction is on Tupile, CHOAM can require that faction to disclose its spice and the number of weapons or defenses it holds. |
+| Richese | A No-Field token cannot be moved. |
+| Ecaz | When Ecaz and its ally are both present for a battle against another faction, the opponent chooses which allied faction fights. |
+| Grumman | A Terror token can be revealed only if at least three forces enter its territory. |
+
+## Verified occupied-side effects
+
+The occupied block is on the reverse alongside the low block; it does not replace the still-active low penalty. Printed yellow spice symbols are distinct from the income/payment-sharing instructions below. E3 p.10 assigns occupied Homeworld income to Spice Collection and the bank, and the source reviewer independently confirms each symbol represents one additional bank spice, separate from the text-dependent payment splits.
+
+| Card | Occupied text gameplay |
+|---|---|
+| Caladan | The occupier shares Atreides' Bidding advantage. |
+| Giedi Prime | When Harkonnen buys Treachery Cards, the occupier or its ally receives Harkonnen's extra free card if the recipient has hand room. |
+| Southern Hemisphere | The occupier receives the rounded-down half of all spice collected by Fremen during Spice Collection. |
+| Junction | The occupier receives the rounded-down other half of shipping payments made by other players. |
+| Wallach IX | The occupier and its ally are immune to Voice. |
+| Kaitain | The occupier receives the rounded-down other half of Treachery Card payments received by Emperor. |
+| Salusa Secundus | Sardaukar lose their Sardaukar advantage. |
+| Ix | The occupier controls the Ixian Bidding advantage. |
+| Tleilax | Tleilaxu cannot reveal Face Dancers against the occupier or the occupier's ally. |
+| Tupile | The occupier and its ally each gain one hand slot; they discard to their ordinary limits once they cease occupying Tupile. CHOAM loses its low-side advantage. |
+| Richese | The occupier chooses the Richese Treachery Card offered during bidding and receives the rounded-down half of the payment received by Richese. |
+| Ecaz | The occupier or its ally gains Duke Vidal and may revive him from the Tanks; this overrides the Ecaz/Moritani Duke advantage. |
+| Grumman | Terror cannot be revealed when the occupier or its ally enters a territory holding a Terror token. |
+
+## Source discrepancies and catalog boundary
+
+1. **Caladan:** both independent physical-image sets and the designer's visible close-up print battle bonus 2 and explosion losses 2. The E3 p.10 example says high Caladan loses 3, while the actual rule delegates the value to the card. The coordinator/source reviewer therefore adopts the explicit face value **2** and records the inconsistent example; no missing card fact remains here.
+2. **Salusa ranges overlap:** high 2–5 and low 0–2 are verified on the actual faces. Do not encode one threshold number or silently change low to 0–1. A stateful return-to-high interpretation is discussed by third-party commenters but was not independently verified from designer speech in this audit. The coordinator adopts the rulebook’s operative minimum-high rule: **2 is high**, treating the overlapping low endpoint as inconsistent printed data. Both ranges remain in the catalog; it does not implement this transition itself.
+3. **Salusa counted reserves:** both faces name Sardaukar. The rules also permit Emperor movement between its two worlds. A future threshold evaluator must resolve what happens to normal forces transferred to Salusa without pretending the printed label is generic.
+4. **Tupile information scope:** the designer close-up confirms CHOAM must be **on another homeworld**, or another faction must be on Tupile. An initial audit reading omitted the small word “on”; this was corrected through independent visual review. No planet-moving effect is printed.
+5. **Occupier timing/custody:** a catalog cannot decide when a persistent occupier's power ends, competing occupiers, Duke return, private answers, paid-card redirection, or interrupted discard/refill timing. These remain runtime work and any independently identified rulings in the global audit.
+
+Proposed catalog contract: one immutable definition per row; faction and name; `reserveType: 'faction' | 'sardaukar'`; `high` and `low` each containing their own inclusive reserve range, native battle strength and readable gameplay; `occupied` containing readable gameplay and the verified icon count. The catalog is inspectable reference data, not permission to enable module starts or an executable payout/threshold engine.
+
+## Research file checksums
+
+| File | SHA-256 |
+|---|---|
+| BGG high photograph | `1200b1f3a12c963dc5e58d06fc19de879f5f9d2768557c178ce37237c8046295` |
+| Base high sheet | `d3fbf506c2bb697d0f6b4c497c731d5e3138bb3be60d2363607b54ebab7c2c54` |
+| Base reverse sheet | `10a2e53c5d6ee8e7ae67f9761a0c64d0f6d36abfec40d863b6b1119af0ad048d` |
+| Expansion high sheet | `dfbbb9bb3776899633617a351c7921ed3d64bf25b6780d0722484afca9d1ebaf` |
+| Expansion reverse sheet | `950e39c320b66b12749d1fc971489482b0053766358b9ac558c93333977e9523` |
+
+No production files, game states, application artwork or databases were changed by this audit. No individual face remains missing.
diff --git a/docs/HOMEWORLD_IMPLEMENTATION.md b/docs/HOMEWORLD_IMPLEMENTATION.md
new file mode 100644
index 00000000..ecf25235
--- /dev/null
+++ b/docs/HOMEWORLD_IMPLEMENTATION.md
@@ -0,0 +1,108 @@
+# Homeworld component and custody foundation
+
+Historical component checkpoint. Subsequent setup, native shipment/revival,
+Emperor movement, controls, AI and recovery work is recorded in
+[HOMEWORLD_RUNTIME_IMPLEMENTATION.md](HOMEWORLD_RUNTIME_IMPLEMENTATION.md).
+
+9 September 2026. This checkpoint adds the complete public Homeworld card
+collection and isolated physical-state helpers. **Homeworld gameplay is not
+enabled.** It does not certify the complete module, any expansion or Advanced
+play.
+
+## Implemented boundary
+
+- `game/homeworld-cards.ts` contains 13 immutable two-sided definitions, one
+ per physical card/token pair, covering all twelve factions. Both faces include
+ original gameplay prose, population ranges, native battle/explosion values
+ and occupied Collection symbols. No source photographs are bundled.
+- `components/homeworld-cards.tsx` renders original local art with selectable
+ text, high/reverse tabs and a scrollable enlarged inspector. The reverse
+ correctly contains both low and occupied panels. Salusa is marked Advanced
+ only. The internal reference searches the complete card text as well as names.
+- `game/homeworld-custody.ts` keeps native primary reserves authoritative in
+ each player's existing typed totals. It stores only foreign visitors and
+ Salusa's allocation within Emperor totals. Explicit withdrawals/deposits
+ return detached before/after receipts; a transfer cannot spend forces newly
+ deposited during the same transaction. Native and foreign physical totals,
+ special-counter caps, active worlds and seated identities are validated.
+- `game/homeworld-population.ts` joins the card manifest to those separate
+ world locations. It counts native forces only, uses Sardaukar alone on
+ Salusa, selects the face at the verified minimum-high boundary, and exposes
+ printed native battle values and conditional low-population bonuses.
+ Printed occupied income is explicitly potential, never a payment.
+
+The helpers never read private hands or leaders. Homeworld IDs do not enter
+the Arrakis territory/sector registry. Both Emperor force types can be
+transferred between Kaitain and Salusa without duplicating reserves or
+mistaking normal Salusa defenders for Sardaukar population. Starred Fremen
+identity can be retained in Basic without granting Advanced strength.
+
+## Source decisions
+
+See [component audit](HOMEWORLD_COMPONENT_AUDIT.md) and
+[global rules contract](HOMEWORLD_RULES.md). All 26 physical faces were inspected
+and independently reviewed. Caladan's printed native value is 2; the rulebook
+delegates to the card despite an inconsistent example saying 3. Salusa's
+printed high 2–5 and low 0–2 ranges are both retained; the global minimum-high
+instruction selects high at 2. Tupile's reverse says CHOAM on another
+Homeworld **or** another faction on Tupile; an initial missing-word reading was
+corrected before release.
+
+## Verification
+
+Focused tests cover all 13 card boundaries, inventory and immutable data;
+57 base rosters in both modes and every expansion faction; native/foreign
+custody and both-type Emperor transfers; death/revival custody deltas; duplicate
+change aggregation; malformed state; prototype-like player IDs; private getter
+guards; and JSON round trips. The focused Homeworld/reference run passes 39
+cases. The runner's five-case subprocess self-check also passes.
+
+Full typecheck, lint and **3,085 offline tests** pass; the final production
+build also passes. The final text-size-only adjustment was followed by named
+lint, a rebuild and browser verification. Logs:
+`/tmp/dune-homeworld-check-final.log`,
+`/tmp/dune-homeworld-build-final.log`, and
+`/tmp/dune-homeworld-focused-second.log`.
+
+Browser checks exercised both enlarged faces of all 13 cards on desktop and
+at 390×844, all normal reverse controls, loaded original artwork, full-text
+search, refresh, keyboard face selection/activation and Escape dismissal.
+No inspector or visible text overflows horizontally. Normal gameplay text is
+16px; enlarged text is 20px with 32px line height. Screenshot review covered
+Caladan high/reverse on desktop, Ecaz high on desktop, Tupile high/reverse on
+phone, Salusa reverse on phone and the normal Caladan phone card. This is
+bounded visual sampling, not a claim that screenshots of every possible face
+and viewport have been inspected.
+
+All thirteen 1774×887 original illustrations were individually inspected by
+the asset agent. Final files are `public/art/homeworlds/-v1.png`;
+[the exact prompt manifest](HOMEWORLD_ART_PROMPTS.json) records the built-in
+image-generation mode, selected sources, workspace paths and SHA-256 hashes.
+
+The controlled 07:53 UTC development-server restart preserved all **2,617**
+saved room versions and state hashes. The human setup room stayed at version
+14 and the QA room at version 132. No HTTP/persistence implementation changed,
+so unrelated complete-game simulations and HTTP suites were not repeated.
+
+## Remaining runtime work
+
+The custody quote is physical bookkeeping, not action authorization. Callers
+must validate the matching Arrakis/Tanks change, turn ownership, destination,
+cost, ally restrictions and special permissions before accepting a transfer.
+No saved `Game` schema or existing game's reserves were migrated here.
+
+The module still needs setup/migration and complete typed arrival routing;
+ordinary and Guild-sponsored shipments; Emperor movement; revival and
+post-battle arrivals; occupation entitlements and turnover; battles and native
+explosion casualties; all high/low/occupied card effects at their real timing
+windows; income sharing, private knowledge, Duke and hand-limit custody;
+alliance restrictions and Ecaz victory; authoritative player views, controls,
+all four AI profiles, persistence races and complete-game verification.
+
+Population, current foreign forces and a turn-qualified Occupier are separate
+concepts. Do not replace them with one high/low/occupied enum or let this
+projection decide occupation duration. Homeworld advantage/penalty immunity
+must also remain distinct from ordinary cancelable faction powers.
+
+No release gate or maintenance automation was changed. User development
+workflow optimizations and all existing saved games are preserved.
diff --git a/docs/HOMEWORLD_INVASION_RULES.md b/docs/HOMEWORLD_INVASION_RULES.md
new file mode 100644
index 00000000..7079e125
--- /dev/null
+++ b/docs/HOMEWORLD_INVASION_RULES.md
@@ -0,0 +1,62 @@
+# Homeworld invasion transport and occupation audit
+
+Source audit, 9 September 2026. This document refines the existing [Homeworld contract](HOMEWORLD_RULES.md#transport-and-arrival); it does not activate transport, occupation, battles, or faction effects. Implementation evidence remains separate.
+
+## Authority and fresh verification
+
+The [GF9 E3 rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf) remains the module authority. Its indexed printed pages 9, 10 and 15 were freshly retrieved. Direct PDF access returned HTTP 403; the earlier `/tmp/dune-rules/ecaz-audit.txt` is no longer present. No claim here relies on that missing file. The [designer expansion page](https://futurepastimes.com/dune-ecaz-moritani) was accessible and links the rules and Homeworld explanation; the video could not be retrieved, so no new spoken ruling is attributed to Jack Reda.
+
+**Fresh E3 checkpoint:** another Homeworld can be invaded; its native reserves defend it. Homeworld-to-Homeworld shipment costs one spice per counter, halved for Guild. Arrakis-to-Homeworld travel requires Guild cross-shipment or the explicit Junction permission. Allied destinations and alliances with an occupier of one's own world are forbidden. Emperor can combine both native origins and has a separate inter-world movement. Homeworlds are not territories. BG advisors cannot arrive there or replace invading fighters. Homeworld effects resist Karama. The FAQ confirms Heighliner activation. Occupier qualification occurs with foreign presence at turn start/end, or immediately upon being alone there; the latter qualification names the current turn. Occupied income is collected during Collection and can immediately be shared with an ally. Native low penalties remain active while occupied. [E3, pp.9–10,15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9)
+
+The detailed route and component mappings already in [transport and arrival](HOMEWORLD_RULES.md#transport-and-arrival), [occupation](HOMEWORLD_RULES.md#occupier-lifecycle-and-benefits), and the [physical component audit](HOMEWORLD_COMPONENT_AUDIT.md#verified-high-side-effects) remain the applicable local contract. In particular, the actual Junction, Wallach IX and Tupile faces must be used rather than replacing their text with generic transport or control rules.
+
+## Base-rule composition
+
+The [base rulebook, pp.17,19,23](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=17) freshly confirms that both ordinary BG accompaniment and its Advanced destination alternative require another faction's off-planet shipment **to Dune**. Therefore a shipment ending on a Homeworld does not, by itself, create a Polar Sink accompaniment opportunity. The Wallach high face changes the size of an eligible accompaniment, not its trigger. This is source composition, not a separate Homeworld FAQ answer.
+
+Guild's base transport alternatives are one shipment per turn; its cross-board or reserve-return alternatives start in one territory. Its printed alliance grant names reserve-to-Dune and territory-to-territory travel. That alliance grant does not independently authorize the new Arrakis-to-Homeworld route. Fractional shipment costs round up: five counters at the E3 half rate cost three spice, and one costs one. Do not round individual counters first or halve a rounded total again. [Base, pp.19,23](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=19)
+
+The [November 2020 FAQ, p.7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7) allows Karama purchase for another player at Guild rates, paid to the bank. Its cancellation list distinguishes Guild income, half-price shipment, ally rates, cross-shipment/reserve return, and Advanced timing. These are distinct sources. E3's immunity does not justify dropping every ordinary Karama response merely because a Homeworld is involved; equally, a response cannot erase a transport permission supplied by Junction's Homeworld face. The November wording and [E1 cancellation table, p.12](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=12) supersede the older April FAQ's contrary statement that Guild's transport types cannot be canceled.
+
+## Route boundaries for implementation
+
+Use an explicit source-world/source-sector and destination-world/destination-sector pair. A foreign garrison must remain physically separate from native reserves. Existing native-to-Arrakis shipment and Emperor transfer are already documented; the new interface must not turn every off-board force into an interchangeable reserve.
+
+| Route | Required distinction |
+| --- | --- |
+| Native Homeworld → foreign Homeworld | New invasion route; retain ordinary shipment usage, typed counters and destination ownership. Fremen's free Southern Hemisphere reinforcement onto Arrakis is not a free invasion. |
+| Foreign Homeworld → another foreign Homeworld | Covered by the general world-to-world permission; use the actual foreign source pool. |
+| Any foreign source → one's native Homeworld | Apply the specific own-world restriction and its explicit exceptions, not merely the general world-to-world sentence. |
+| Arrakis → Homeworld | Ordinary Guild privilege and Junction-sponsored permission need different provenance and cancellation handling. The Guild ally flag alone is insufficient. |
+| Foreign Homeworld → Arrakis | Do not silently label a foreign garrison as native reserves. Junction explicitly supports transport from Homeworlds; a general unsponsored foreign-garrison departure is not expressly described in the retrieved transport paragraph. Record the selected interpretation before exposing this edge. |
+| Any route → ally's Homeworld | Reject before payment or arrival; Ecaz's territorial co-occupation is not an exception. |
+
+There is no retrieved numeric invasion-force cap or Homeworld two-faction capacity rule. Preserve the twenty physical counters, actual available group, affordability and named permissions; do not reuse a stronghold's two-faction occupancy check for a non-territory world. Combining unrelated source worlds in one shipment has no general permission. Emperor's named two-origin exception must remain explicit. These are composition boundaries, not newly printed caps.
+
+**Payment is source composition, not a new question merely because E3 omits a recipient.** The ordinary rule pays the bank; a qualifying faction or funding rule overrides that default. This default is explicit in the [publisher quick-start payment rule, p.8](https://www.gf9games.com/dunegame/wp-content/uploads/Quick-start-Guide.pdf#page=8). A shipper's own invasion fee therefore goes to the bank unless an applicable effect supplies another recipient. Guild's base onto-Dune trigger must not be broadened solely by reusing `guildShipmentIncome`.
+
+Preserve the already documented [November allied-payment adjudication](GUILD_SHIPMENT_PAYMENTS.md#publisher-contract-and-precedence): non-Guild ally contributions go to Guild when present, Guild contributions go to the bank, and an independent Karama rate sends the payment to the bank. This is contributor-specific even when Guild is the shipper. The [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2) was freshly rechecked. A route quote must separate the shipper's eligible income from that contribution rule. `shipment-price.ts` currently has no route parameter; its arithmetic is reusable, its current caller assumptions are not a new Homeworld authority. No Homeworld-specific user ruling was located in the existing payment documents.
+
+Generic Karama purchase must retain its actual destination scope and never grant a missing transport route. Its FAQ establishes payment/rate but supplies no Homeworld worked example; do not turn the absence of an example into a blanket blocker. An exact card/rule-scope review is still needed before extending the existing reserve-shipment purchase to a new off-board route. Existing unresolved ordinary rate-cancellation timing remains tracked in [Guild rate Karama rules](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing), without asking those questions again.
+
+## Occupation evidence and unresolved lifecycle
+
+Keep three independent facts: present native/foreign forces, current native population, and a recorded qualification event with its turn. A sole-controller enum loses evidence that the published timing rule explicitly creates. Do not reconstruct historical qualifications from a reloaded current board.
+
+The following cases still require a lifecycle decision; the cited material does not supply an exclusive-controller or automatic handoff algorithm:
+
+- A qualifies alone, then B enters before Collection. Is A's current-turn benefit retained while contested?
+- A qualifies, leaves entirely, and B becomes sole occupant later that turn. Which continuing advantages and income entitlements survive?
+- Two foreigners remain at a turn boundary, including allies jointly visiting a third faction's world. Does each receive the named qualification, and how are unique rewards resolved?
+- Native revival restores a high population while a foreign qualifier remains. Which native high/low effects coexist with the occupied effect, and until when?
+- A remembered current-turn qualification remains after departure: does it prohibit a later alliance, or does the alliance restriction use present forces? Do not resolve this merely by sharing one occupation predicate across all consumers.
+
+Tupile's component-specific loss-of-occupation discard must be preserved even if another benefit ultimately uses a turn receipt. A unique physical reward such as Duke ownership cannot be duplicated to mask simultaneous entitlements. Occupied bank income, percentage receipts, hand limits, immunity and victory conditions each need their own consumer timing once the lifecycle is resolved.
+
+Fresh targeted GF9, designer-name and occupation searches produced no new official resolution. A community compilation replaces the publisher's start/end-turn wording with an unopposed-only definition; it is not an erratum and was excluded. Search absence is not proof that no clarification exists.
+
+## Verification handoff
+
+Transport tests should cover odd/even Guild costs, funding and income separation, the ordinary shipment limit, native and foreign custody, mixed Emperor origins, own/ally rejection, Fremen invasion payment, BG non-trigger on Homeworld arrival, and one Heighliner receipt. Capture the exact permission source before each response; restore it through JSON/CAS without spending twice.
+
+Occupation tests must use the five cases above plus loss of the last invader, surviving native defenders after an explosion, Collection exactly once, alliance formation, and unique-reward custody. A transport-only pass cannot certify occupation or a complete Homeworld game. No production, server, saved-room, Sites or automation state was changed by this audit. Existing CHOAM charity, Tleilaxu special-Karama and Ix revival questions are neither repeated nor resolved here.
diff --git a/docs/HOMEWORLD_MOBILITY_RULES.md b/docs/HOMEWORLD_MOBILITY_RULES.md
new file mode 100644
index 00000000..dd5484ef
--- /dev/null
+++ b/docs/HOMEWORLD_MOBILITY_RULES.md
@@ -0,0 +1,51 @@
+# Homeworld mobility effects
+
+Primary-source audit, 9 September 2026. This contract covers Caladan low, Wallach IX high/low, Ix low and Richese low. It is not an implementation checkpoint or a release certification. The [component audit](HOMEWORLD_COMPONENT_AUDIT.md) records the inspected physical faces and their provenance; its former `/tmp/dune-rules/` images are absent in this session, so this audit does not claim a new visual inspection. Fresh searches retrieved publisher-indexed rulebook and November FAQ passages; direct publisher PDF requests returned HTTP 403. No community comment or other edition supplies a rule below.
+
+## Population and effect identity
+
+Evaluate the native world's current population. Caladan is low at 0–5; Wallach IX at 0–10; Ix at 0–4; Richese at 0–9. Foreign visitors do not increase native population. E3's minimum-high rule is continuous, not a generic phase-start snapshot. The low penalty remains applicable while the world is occupied. Karama cannot remove a Homeworld advantage or penalty. These rules do not protect a separately sourced faction advantage merely because its faction has a Homeworld. [E3, pp.9–10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9)
+
+Implementation consequence: recheck current eligibility before an effect commits, including restored pending frames. A selected two-counter transfer is one batch: starting at native 11 permits both counters and leaves 9. Do not re-evaluate between the first and second counter. An interrupted declaration must retain its selected amount; never silently increase, truncate or replace it. Population-derived public controls need no opponent hand access.
+
+## Caladan: the named Movement advantage
+
+Low Caladan disables the Atreides Spice Deck inspection. The ordinary advantage occurs at the beginning of Movement, before anyone moves; it does **not** spend an ordinary force movement. Earlier Homeworld developer prose calling it a “movement-spent” peek is inaccurate. The penalty does not cancel battle prescience, auction inspection or a separately printed card effect. [Base, p.17](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=17)
+
+The November FAQ limits the inspection to one card even with Double Spice Blow. Ordinary Karama can prevent that inspection when otherwise available. Low Caladan supplies a direct prohibition, not an extra Karama target or an acknowledgement decision. Hajr cannot buy a second inspection or bypass the penalty: its additional movement follows normal movement rules. [November FAQ, pp.3,7–8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=3)
+
+Do not grant delayed inspection merely because Caladan becomes high after its printed opening opportunity has passed. Likewise, restoring high does not erase information already legally learned. Availability, historical knowledge and a continuously exposed live deck preview are separate implementation concerns.
+
+## Wallach IX: accompaniment, amount and stance
+
+Spiritual Advisors is the named free accompaniment power. Another faction's completed off-planet shipment **to Dune** creates the opportunity; Basic sends one reserve force to Polar Sink, and Advanced offers the arriving territory instead. BG does not accompany itself. An on-planet relocation, Fremen's ordinary reserve entry or a shipment ending at a Homeworld does not meet that trigger. [Base, p.18](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=18), [Homeworld invasion audit](HOMEWORLD_INVASION_RULES.md)
+
+Wallach low suppresses both accompaniment destinations. Wallach high permits an optional two-counter Polar Sink accompaniment. One remains available; passing remains available. The same-territory Advanced alternative stays one, and a two-counter action cannot split its destinations. With the module enabled, a native reserve pool of one is necessarily low and permits no accompaniment. With the module absent, the ordinary one-counter option remains valid. The source of these modifiers is the [physical component audit](HOMEWORLD_COMPONENT_AUDIT.md#verified-high-side-effects).
+
+This restriction does not prohibit ordinary paid BG shipment, moving existing advisors, Intrusion or the normal stance transitions. In particular, the November FAQ permits directly shipped forces to join existing advisors and requires arrivals to match the territory's existing BG type. This supersedes the conflicting April FAQ. [November FAQ, p.4](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=4)
+
+The Guild Ambassador's extra shipment also qualifies when it is another faction's off-planet arrival. Its special parent continuation does not remove the Wallach conditions. E3 confirms both that Ambassador shipments interrupt the triggering player's action and that allied BG may accompany Ecaz. Preserve that original entrant, independent beneficiary and BG as separate actors. [E3, p.15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=15), [Guild Ambassador audit](GUILD_AMBASSADOR_RULES.md)
+
+Check Wallach at every accompaniment producer and at placement. Do not freeze high merely when the original shipper starts its turn. Suppressing an unavailable accompaniment must resume the correct original action without a fake “allow” decision. The parent shipment cannot be charged or transferred again.
+
+### Ordinary Karama and the high modifier
+
+E1 explicitly lists Spiritual Advisors and Advanced Advisors as cancelable free shipments. E3 independently protects Homeworld effects. Neither retrieved source directly answers a two-counter Wallach accompaniment cancellation. [E1, p.12](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=12), [E3, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
+
+The adopted composition is that the high face changes the quantity of a qualifying accompaniment; canceling the underlying Spiritual Advisors use leaves no independent bonus shipment. A second Karama aimed only at removing the extra counter is forbidden. This follows the conditional modifier and its underlying action, rather than a specific two-counter FAQ answer. No retrieved primary text contradicts that composition. A Guild Ambassador parent's resistance to ordinary cancellation does not automatically transfer to BG's distinct accompaniment advantage.
+
+## Ix and Richese: prohibit relocation, preserve other actions
+
+Low Ix prohibits moving the Hidden Mobile Stronghold itself. Apply it both to the ordinary pre-storm relocation and the special Ixian Karama relocation during Shipment and Movement. A Karama cannot override the protected low penalty. The special power otherwise adds a two-territory HMS move while preserving normal troop movement. Ordinary HMS movement and its travel-related collection are described separately from initial placement, protected interior and troop entry. [E1, pp.6,9](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=6)
+
+Low Ix does not immobilize Cyborgs or Suboids, forbid troop entry/exit through a stationary HMS, erase its shelter, or forbid unrelated collection by troops. Initial setup placement is not a later relocation. Do not spend the special Karama or mark it used on a rejected low-population attempt.
+
+Low Richese prohibits relocating a deployed No-Field marker, including a marker-only move, a mixed group carrying it, Hajr movement or an otherwise legal Fremen Ambassador relocation. It does not prohibit initial shipment, voluntary reveal, mandatory reveal, or moving ordinary forces while leaving the marker in place. The token moves like a force, but deployment and revelation have distinct lifecycle rules. [E2, p.6](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=6), [No-Field audit](RICHESE_NO_FIELD_RULES.md)
+
+Hajr supplies no immunity from the prohibition. CHOAM's Baliset remains a separate movement restriction and expressly permits shipment; do not apply it to free BG accompaniment. A stopped marker move preserves its hidden denomination, physical location and last-use history. Ordinary forces materialized by a separately legal reveal can subsequently move normally. [November FAQ, p.8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8), [E2, p.7](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7)
+
+These movement conclusions do not resolve the prior No-Field return/capture or off-planet marker questions. A generic relocation adapter must not fabricate a reveal or change concealed custody to bypass those existing boundaries.
+
+## Verification obligations
+
+Exercise exact thresholds, Basic and Advanced accompaniment, one versus two versus pass, same-territory versus Polar Sink, native 11→9 as one batch, module-off reserve shortage, normal and Ambassador continuations, paid BG arrival joining advisors, low-population restored frames, stationary HMS troop movement, both HMS relocation sources, marker-only/mixed/Hajr moves, independent physical-force movement and legal reveal. Verify rejection immutability, no private information leakage, all four AI profiles and SQLite recovery. No tests were run or code changed by this source-only audit.
diff --git a/docs/HOMEWORLD_MOBILITY_RUNTIME.md b/docs/HOMEWORLD_MOBILITY_RUNTIME.md
new file mode 100644
index 00000000..f6b29915
--- /dev/null
+++ b/docs/HOMEWORLD_MOBILITY_RUNTIME.md
@@ -0,0 +1,25 @@
+# Homeworld mobility and Spiritual Advisors
+
+9 September 2026. [Source contract](HOMEWORLD_MOBILITY_RULES.md). This integrates native population effects into existing movement, knowledge and accompaniment paths. Homeworld starts remain disabled pending the whole module.
+
+## Implemented behavior
+
+Low Caladan suppresses the Atreides foresight opportunity at the start of Shipment and Movement, including a refill that would only serve that inspection. This advantage never spends movement. Knowledge already obtained legally remains available; raising population later does not manufacture a missed phase-opening peek.
+
+Low Wallach IX suppresses the optional free Spiritual Advisor follow-up after another faction's qualifying shipment, including Guild Ambassador and Junction transport. It does not prevent paid Bene Gesserit shipment, joining existing advisors, or moving existing forces. High Wallach IX offers one or two normal reserve counters to Polar Sink; accompanying another destination still permits only one. A declared two-counter batch uses the high population before departure, then removes and places both counters together. Lower population afterward governs later opportunities.
+
+The declared amount survives the existing ordinary advisor response and saved interruptions. Canceling Spiritual Advisors prevents the whole conditional arrival, including its added counter; it does not create a separate Homeworld cancellation. This source composition is identified in the rules audit. Guild Ambassador settlement preserves its already-completed primary shipment and records an exact one- or two-counter accompaniment receipt. Historical two-counter receipts require Homeworlds and Polar Sink but do not incorrectly recheck high population after that legal departure.
+
+Low Ix prevents movement of the Hidden Mobile Stronghold, including the special Karama relocation. Its setup placement and movements by physical forces remain distinct. A low native population skips the ordinary unavailable stronghold movement opportunity. Low Richese prevents movement of the No-Field token, including Hajr, Ornithopter movement selections and Fremen Ambassador relocation; physical forces can move while leaving the token where it is. Shipment and voluntary revelation retain their own rules.
+
+Shared public helpers supply the authoritative limits, player controls and all four AI profiles. Controls explain blocked movement, leave physical-force alternatives available, and expose both permitted Polar Sink amounts. Native counters determine these effects; visitors and another faction's Homeworld do not supply a false threshold. Saved advisor, foresight and mobile-stronghold responses are checked in the current frame and supported explicit saved parents before costs or projection.
+
+## Verification and remaining scope
+
+Fifty-three new regressions cover the public helper (11), mobility engine paths (14), mobility SQLite recovery (4), ordinary advisor engine paths (10), advisor SQLite recovery (6) and Guild Ambassador integration (8). They verify both native thresholds and typed custody, legitimate learned knowledge, no late foresight, ordinary and special movement, free versus paid advisor sources, pair settlement, cancellation, saved Box parents, concurrent requests, rejected writes, private projection and unchanged unrelated rooms. Richese and Ecaz audit fixtures explicitly retain supported physical decks and constructors; they do not establish full expansion setup. Independent reviews and existing related tests passed. Final broad results are recorded in the newest [implementation checkpoint](IMPLEMENTATION_STATUS.md).
+
+In an isolated browser room using genuine Advanced Homeworld setup with a staged shipment opportunity, Emperor's actual one-force shipment opened the BG follow-up. Desktop controls exposed one/two Polar Sink choices and the separate one-force accompaniment alternative. On a 390×844 viewport, keyboard selection declared two forces; the saved response retained that amount after refresh with all nineteen reserves still present. The other human seat allowed the power by keyboard. Another refresh preserved seventeen reserves, two forces in Polar Sink, the original one elsewhere, zero Tanks and BG's unused ordinary shipment. Emperor retained its Karama, nineteen native reserves and nine spice. No page errors or horizontal phone overflow occurred. Phone choice/settlement screenshots were visually inspected. The reaction display subsequently gained explicit declared quantity and destination text; full checks/build were rerun after that display edit.
+
+All **2,833** pre-existing room versions and state hashes remained unchanged after the isolated browser and HTTP tests, with none missing. The existing development server was reused; no maintenance restart was due during this checkpoint.
+
+Occupation may retain low penalties independently of current native population; its existing lifecycle interpretations remain unresolved. Remaining high and occupied faces, concealed transport, full expansion setup and complete Homeworld games remain unfinished. No start or publication gate opens, saved games are preserved, and the maintenance automation stays removed.
diff --git a/docs/HOMEWORLD_OCCUPATION_INTEGRATION.md b/docs/HOMEWORLD_OCCUPATION_INTEGRATION.md
new file mode 100644
index 00000000..ba6c01d9
--- /dev/null
+++ b/docs/HOMEWORLD_OCCUPATION_INTEGRATION.md
@@ -0,0 +1,149 @@
+# Homeworld occupation integration audit
+
+This is a code integration map, not an occupation ruling or a claim of runtime
+completion. It records the current native effect consumers and the boundaries a
+future occupation implementation must preserve. Authority and unresolved cases
+remain in [Homeworld rules](HOMEWORLD_RULES.md),
+[invasion rules](HOMEWORLD_INVASION_RULES.md), and the current source audit.
+
+## Separate physical population from active effects
+
+`game/homeworld-population.ts::homeworldPopulations` is explicitly a public,
+physical projection. Its `side` follows native typed counters and the printed
+minimum. Visitors never raise native population; Kaitain excludes Salusa's
+separate allocation, and Salusa counts Sardaukar only. Keep these calculations,
+reserve custody, shipment sources, and before/after population quotes physical.
+
+Occupation qualification is event based; present foreign forces are not a
+sufficient replacement for saved qualification. Conversely, a saved reward
+entitlement need not answer every present-control question. The existing source
+contract leaves repopulation and some competing-occupier cases unresolved. Do
+not silently impose an exclusive controller or globally replace physical
+`side` with `low` when a foreign force appears.
+
+Three distinct inputs are needed: physical native population, the native
+effects currently active under the verified occupation rule, and each qualified
+occupier's particular benefits. Low penalties, generic low bonuses, high effects
+and native battle strength are separate consumers. Tupile makes this especially
+visible: its Worthless-sale restriction is a **high** effect, while its low
+disclosure ability is explicitly lost under occupation.
+
+## Existing consumers
+
+| Consumer | Current physical predicate | Occupation integration boundary |
+| --- | --- | --- |
+| `homeworld-benefits.ts::homeworldLowBonus` | Native primary world low; Salusa excluded | Generic charity/revival bonus eligibility must be resolved independently of retaining a named low penalty. Do not award bonuses merely because an occupation penalty remains. |
+| `charity.ts::charityQuote`, `revival.ts::freeRevivalRate` | Shared low bonus | Preserve ordinary eligibility, bank funding, inflation, Recruits and resource caps. Changing the shared helper reaches engine/view quotes. |
+| `homeworld-benefits.ts::snapshotHomeworldRevival` | Tleilax physically low at Revival opening | Snapshot the verified low-penalty condition at the original phase-opening event. `tleilaxuHomeworldFreeIncomeBlocked` must continue reading that saved receipt, not current population or occupation. |
+| `homeworld-benefits.ts::homeworldSardaukarFreeSupport` | Advanced Salusa high | A native high capability. Occupied Salusa's loss of Sardaukar advantage needs its own rule composition, not a physical force conversion. |
+| `homeworld-benefits.ts::homeworldSardaukarGholaBlock` | Salusa population exactly one during an Emperor battle | Existing unresolved support-transition guard. If occupation changes effective support, compare the relevant before/after capability; do not remove the guard solely because occupation exists. |
+| `homeworld-mobility.ts::homeworldMovementForesightBlock` | Caladan low | Retained low penalty blocks a new Movement peek; it does not erase already acquired knowledge. |
+| `homeworld-mobility.ts::homeworldMobileStrongholdMovementBlock` | Ix low | Retained low penalty blocks stronghold relocation, including its special Karama route; ordinary troop movement remains separate. |
+| `homeworld-mobility.ts::homeworldNoFieldMovementBlock` | Richese low | Retained low penalty restricts existing marker movement. Shipment and reveal rules remain separate. |
+| `homeworld-mobility.ts::homeworldSpiritualAdvisorAllowance` | Wallach low gives zero; high permits two at Polar Sink | Resolve low suppression and the high two-advisor benefit explicitly. Preserve physical reserve limits and accepted reaction counts. |
+| `homeworld-card-economy.ts::highKaitainDiscardsAvailable` | Kaitain high | Native high capability; use the shared effective condition at quote/action time. Keep the shared end-of-Bidding event public and independent of private affordability. |
+| `homeworld-card-economy.ts::homeworldWorthlessSaleBlock` | Tupile high | High-face restriction, not a retained low penalty. Both ordinary and duplicate Worthless sales and CHOAM Ambassador bank payouts consume this helper. Trades, Ixian discard/draw and special Karama retain their own rules. |
+| `ecaz-poison-income.ts::quoteEcazPoisonIncome` | Ecaz high | Evaluate active high income at actual physical discard, including delayed winner cleanup. Battle roles remain captured at battle resolution; they must not freeze future population or occupation eligibility. |
+| `junction-offer.ts::junctionSponsor` and `junction-transport.ts::junctionSponsorEligible` | Independently test Junction high | Replace duplicated eligibility with one shared capability predicate. Preserve public offer-event invalidation and ordinary Guild transport rights. |
+| `homeworld-payment-income.ts::quoteHomeworldPaymentIncome` | Kaitain/Junction physically low | Physical low native income reduction only. Future retained low must be supplied through the common effect layer; occupier shares need separate payment provenance and settlement. |
+| `homeworld-combat.ts::locations`, `homeworldBattleRules` | Physical face selects native battle strength | Resolve the applicable combat face explicitly. Native dial bonus and Lasgun/shield native casualties use this strength; neither can be inferred from a generic retained-penalty boolean. |
+
+The payment quote is an independently scoped addition being implemented with
+this audit; it does not implement occupation or create a payment event.
+
+The engine also uses `homeworldLowBonus` when deciding whether CHOAM's opening
+charity income may proceed, and when projecting the owner's revival bonus.
+Keep that existing opening-income release boundary explicit: deciding retained
+low penalties alone does not resolve the separately documented classification
+of CHOAM's opening income. `homeworldRevivalKaramaBlock` also derives from the
+generic bonus; its unresolved special-Karama overlap must remain visible.
+
+## Engine, interruptions and projection
+
+`homeworld-game.ts::homeworldGameIntegrity` currently accepts exactly the
+`custody` key inside the module record. Any new persisted occupation field needs
+an explicit schema/integrity change, a compatible initialization rule and JSON
+recovery coverage. Do not reconstruct historical qualification on view/read.
+`homeworldContext` deliberately strips players to public identity, faction and
+typed reserve counts. Extend public context intentionally rather than passing
+hands, secrets or held spice to occupation calculations.
+
+The mobility helpers already feed `movement-phase-quote.ts`,
+`terminal-cancellation.ts`, `karama-movement-cancellation.ts`,
+`guild-ambassador.ts`, `fremen-ambassador.ts` and engine action/view guards.
+Update their narrowed context types consistently. Accepted declarations and
+durable continuations require a source-specific choice between current
+eligibility and captured eligibility; never rewrite their counts on reload.
+
+Card income and sale settlement already have live rechecks. Preserve the CHOAM
+sale parent through Ghola/Tleilaxu income and the battle winner discard queue
+through casualties/Ixian substitution. Ecaz income must observe the effect
+state at each disposal, so losing-card income before winner casualties can
+differ from mandatory and optional winner disposal afterward. Occupied Ecaz's
+Duke authority belongs in the unique Duke custody/alliance lifecycle, not in
+the poison quote.
+
+`combat-location.ts::homeworldBattleLocation` is the common engine adapter for
+Homeworld combat; use it to carry an explicit effective battle projection into
+the existing pure combat rules. Physical battle presence, reciprocal-alliance
+restrictions, typed loss pools and substitution remain independent.
+
+`components/homeworld-table.tsx` labels `world.side` as population. Keep that
+truthful when adding occupied status and active effects. The shipment option
+modules and bots also use physical side and native strength for scores; expose
+separate values if their strategic evaluation needs effective effects. Bot
+heuristics must not become authority for occupation qualification or legality.
+
+## Suggested shared interface
+
+Keep `homeworldPopulations` unchanged. Add one public projection module around
+validated occupation receipts, with a Game adapter providing only public
+physical context, current turn and explicit occupation state. A suitable shape
+is:
+
+```ts
+type HomeworldEffectState = {
+ physical: HomeworldPopulation;
+ occupation: { qualified: readonly string[]; active: boolean };
+ highEffectsActive: boolean;
+ lowPenaltiesActive: boolean;
+ lowBonusActive: boolean;
+ battleSide: 'high' | 'low';
+};
+```
+
+This is a proposed interface, not a decision that the four effect fields follow
+one rule. Their construction must follow the completed source audit. Card
+exceptions such as occupied Tupile's disclosure loss and occupied Salusa's
+Sardaukar restriction should have named quotes consuming this projection; do
+not represent every native faction ability with a blanket disabled flag.
+Beneficiary selection, allied sharing and expiry obligations require their own
+quotes over the receipts; `qualified` alone is not permission to pay every
+listed faction.
+
+Migrate existing capability helpers to this projection rather than inserting
+occupation conditionals independently into engine, view and bots. The same
+projection can supply public explanatory status. Keep actual payment splits,
+card selection and physical mutation in their existing focused quotes and
+settlement functions.
+
+## Verification targets
+
+- Native low to high while a qualified invader remains, with independent
+ assertions for retained low penalties, generic bonuses, high effects and
+ battle strength under the finalized ruling.
+- Present visitors versus qualified occupiers; departure, return, competing
+ qualifiers, turn boundaries and no duplicate awards after JSON restoration.
+- Revival phase-start receipt retained across later population/occupation
+ changes; ordinary paid income and Ghola remain distinct.
+- Sale/Ghola, winner casualties/Ixian substitution, advisor and canceled
+ shipment continuations preserve exact original events and resource custody.
+- Junction/Kaitain rounding per verified payment unit; retain eligible source
+ contributions and explicitly guard cases where transaction and contributor
+ rounding disagree until that rule is resolved. No native/occupier income is
+ minted on read.
+- Opponent private getters are never read; observed public state does not
+ disclose hidden discarded card kinds or private balances.
+
+This documentation audit changes no runtime rules. Links and code symbols were
+reviewed; it does not certify occupation, any full faction or expansion.
diff --git a/docs/HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md b/docs/HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md
new file mode 100644
index 00000000..4cadb2ea
--- /dev/null
+++ b/docs/HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md
@@ -0,0 +1,71 @@
+# Homeworld occupation lifecycle integration audit
+
+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
+
+The engine can perform several ordered physical changes inside one accepted action or one automatic recovery. An observation only at the end of `applyAction` or `finishActionContinuations` misses a foreign winner becoming alone after native loser removal, then losing its final force to its own dial before the wrapper returns. Losing-card disposal can also occur during that interval, so occupation-dependent card effects must see the correctly observed state before the discard is processed.
+
+Conversely, observing every counter assignment or every `killTerritory` call can invent intermediate occupation. `resolveBattle` implements double-traitor destruction with two sequential army removals, and explosions iterate `quote.destroyedArmies`. Those loops are implementation details of one destruction result. Subject to the source ruling about simultaneous losses, observe the completed destruction group, rather than granting the second army an entitlement merely because its array entry was processed last.
+
+Use a focused, pure occupation reducer over public typed force groups, with an explicit observation cause and event identity. The engine should call it at **completed semantic mutation boundaries**. Preserve already recorded qualification separately from the current occupants; the eventual rules may give these different consumers. Source-dependent rewards should run only after this observation and through their own durable continuation if a player choice is required. A final wrapper check is useful as an invariant, but does not replace the intermediate observations.
+
+## Mutation sites
+
+All engine names below refer to [game/engine.ts](../game/engine.ts). Homeworld groups come from [homeworld-custody.ts](../game/homeworld-custody.ts), where primary native counts derive from player reserve totals, foreign visitors are explicit, and Salusa is a separate allocation within the Emperor's totals. Consequently, changing native reserves can change occupation without changing `custody.visitors`.
+
+| Boundary | Existing code path | Observation placement and constraint |
+| --- | --- | --- |
+| Ordinary reserve departure | `withdrawNativeReserves`, called by ordinary shipment and Spiritual Advisor arrivals | Observe after the matching board placement and complete typed reserve update, before arrival/income consequences that consume occupation. Do not invoke full force-conservation validation halfway between withdrawal and placement. |
+| Revival or Ghola return | `addRevivedReserves`, called by normal revival, Ghola and revival effects | Observe after reserve deposit and the caller's Tanks/elite updates. Native revival can end present sole foreign occupancy and change population while a remembered entitlement remains a separate question. |
+| Homeworld shipment | `commitHomeworldShipment` | Observe after `quote.state`, every quoted player reserve total, and any Arrakis source map are installed; before shipment income or subsequent powers. A declaration or a Guild stop does not move forces and must not qualify anyone. |
+| Junction transport | `performJunctionTransport` | Observe once after all source withdrawals, destination deposits and any Arrakis placement. Include every affected origin and destination, including both Emperor native origins. The pure quote's internal withdrawal/deposit order is not a sequence of independent events. |
+| Emperor inter-world movement | `emperorHomeworldMove` action branch | The quote replaces the Salusa allocation while aggregate reserves stay unchanged. Observe both native worlds after the complete quote is installed. A reserve-total-only watcher misses this path. |
+| Homeworld combat loss | `commitHomeworldLoss` → `commitHomeworldResources`, reached by `killTerritory` and `takeBattleLosses` | The resource helper applies custody and all reserve/Tanks counts atomically. The caller must distinguish an independently ordered loser or winner loss from one member of a simultaneous destruction group. |
+| Native explosion allocation | `settleHomeworldExplosion` | Observe after the exact selected native casualty group is committed, before `finishBattle`. Earlier invading-army destruction and later native allocation have distinct saved stages; preserve the source-defined ordering. |
+| Homeworld Ix substitution | `finishResponse` branch `ixSubstitution` → `commitHomeworldResources` | Observe after the whole substitution quote, before `finishWinner` processes mandatory winner discards. Exchanging Suboids and Cyborgs is one operation; do not manufacture a temporary empty army between its component writes. A canceled substitution has no physical transition. |
+| Homeworld Face Dancer replacement | `decision.kind === 'faceDance'` replacement code using `quoteHomeworldCustody` | Observe after winner return, selected external-source removal and replacement deposit are all committed. Include the returned winner's native world as well as the battle world. Do not observe the pure quote's component changes separately. |
+| Setup | `initializeStartingForces`, later faction placements and `finishSetup` | Initialize the new-game tracking baseline after physical custody exists, with setup provenance. Setup allocation is not a retrospective invasion history. |
+
+Several active paths still change reserves directly. A hook only inside the two native-reserve helpers will miss them: `revealPlayerNoField`, `commitAmbassadorShipment`, Moritani Sneak Attack, Trip to Gamont, board Face Dancer replacement, and the legacy `guildShip` branch. Some combinations have their own gates or are redirected to existing Homeworld transport, but each admitted path needs either an explicit observation after its completed transfer or a proved no-Homeworld precondition. Audit source guards before claiming complete coverage. Initial Moritani, Bene Gesserit and Fremen placement also use direct reserve writes and belong to the setup baseline, not occupation rewards.
+
+## Battle order that must survive recovery
+
+The current normal Homeworld battle flow is:
+
+1. `resolveBattle` removes the loser army. Record any source-authorized new sole-foreign qualification here before processing the losing cards.
+2. Losing cards enter the `battle:mandatory` discard batch; `battleResolved` retains winner casualty commitments. Winner mandatory cards remain physically held in `pendingWinnerDiscards`, independently backed by `lastBattleContext.winnerDiscards`.
+3. `continueResolvedBattle` commits the winner's automatic casualties or offers the owned allocation. Observe the completed group even if it removes the final winning force.
+4. Ix substitution, when applicable, runs before `finishWinner`. Its allowed physical change receives its own observation; cancellation does not replay casualties.
+5. `finishWinner` commits the separate `battle:winnerMandatory` card batch. Optional winner card disposal and later cleanup follow.
+
+`finishActionContinuations` can drain multiple discard batches in a loop, and `settleAutomaticContinuations` can allow responses without a new human action. Every one of these paths must use the same physical observation hooks. Recording only the final board cannot reconstruct stage 1 after stage 3 removed the last foreign force. An occupation receipt associated with the battle must remain independent of nullable pending queues, following the existing mandatory-winner obligation safeguard.
+
+## Turn and income boundaries
+
+`completePhase` is the explicit turn rollover: after phase 8 work, it increments phase, expires Duke state, increments `g.turn`, assigns phase 0 and calls `openPhase`. Place the end-turn occupation observation before changing the turn number; then apply the defined expiry and start-turn observation with the new turn, before `openPhase` exposes any new phase powers. Use separate identities for the old turn's end and the new turn's start, even if the physical board is unchanged.
+
+Do not put turn-boundary logic in `beginStormTurn` alone: Ix phase opening and mobile-stronghold opportunities can intervene before that function. `beginPhase` invokes `collect` in phase 7; occupation income needs a once-per-applicable-turn/recipient receipt at its authoritative payment point. Do not infer that ordinary occupation observation itself pays Collection income. The source audit must define which start/end qualifiers and retained receipts are eligible for each payment or unique advantage.
+
+## Durable state and legacy saves
+
+[homeworldGameIntegrity](../game/homeworld-game.ts) currently accepts exactly `{ custody }` for the Homeworld module. Adding a sibling ledger inside that object requires updating the explicit shape contract and its malformed-save tests; silently adding a field currently rejects existing code paths. A separately versioned top-level ledger is another implementation option. In either design, distinguish an absent legacy field from an explicitly malformed, deleted or inconsistent initialized record.
+
+[db/rooms.ts](../db/rooms.ts) parses saved JSON in `readRoom` and `readSeatView`; it does not migrate or persist reads. Accepted actions and `continueRoomAutomatic` persist complete JSON with SQL version comparison. A ledger stored in the JSON does not inherently require a D1 table migration, but its initialization and every qualification/reward must use this existing compare-and-swap boundary. Reconnects, duplicate workers and repeated normalization must not create extra awards or change source state during a private view.
+
+For new games, initialize a versioned ledger from the genuine setup baseline and observe all subsequent semantic transitions. For old games, present force locations do **not** prove earlier sole occupation, a completed boundary check, Collection payment, discarded cards or prior unique-reward ownership. Do not reconstruct these from aggregate reserves, the public chronicle or a default empty receipt list interpreted as complete history.
+
+A compatible adoption design should explicitly mark the first supported observation and incomplete earlier coverage, persist that adoption once, and record only facts actually established from then onward. Present sole occupancy can be observed at adoption if the approved rule grants an immediate current qualification; that still does not prove a past payment or departed occupier. An older save already inside a battle/discard continuation especially cannot recover a sole-presence interval that ended before the saved snapshot. Leave that limitation explicit instead of inventing entitlement. Whether adoption can grant current benefits or must wait for a specified boundary is a rules/product decision, not a database default.
+
+If adoption is an automatic continuation, extend the scheduler hint deliberately: `needsAutomaticRoomRecovery` currently wakes known pending interactions, not an otherwise idle game merely missing a new ledger. Preserve original phase, pending event IDs, credentials, force/card custody and all other saved rooms. Do not reset or bulk rewrite the live database to install this feature.
+
+## Required regressions
+
+- Loser elimination creates an observed sole foreign interval; winner dial then empties it within one public action and within one resumed SQL continuation.
+- Losing poison disposal and later mandatory winner disposal consume the occupation state appropriate to their actual stage; Ix allowance/cancellation and casualty choices restore without repeating qualification.
+- Simultaneous double-traitor/explosion groups do not gain array-order-dependent qualifiers; native explosion survivors and multi-invader cases follow the approved source rule.
+- Multi-origin transport, Salusa movement, native revival and Face Dancing observe complete transfers without temporary invalid custody or artificial empty intervals.
+- End/start boundaries and Collection receipts remain exactly once under two SQL workers, with private views unchanged and malformed/deleted initialized obligations rejected.
+- A legacy mid-turn and a legacy mid-battle save preserve all physical state and explicitly incomplete history; adoption never fabricates earlier rewards or departed occupiers.
+
+This is an integration map, not evidence that occupation or any occupied-card benefit is implemented. The retained-low, contested entitlement and unique-reward source questions remain prerequisites for their respective consumers.
diff --git a/docs/HOMEWORLD_OCCUPATION_RULES.md b/docs/HOMEWORLD_OCCUPATION_RULES.md
new file mode 100644
index 00000000..39c20f14
--- /dev/null
+++ b/docs/HOMEWORLD_OCCUPATION_RULES.md
@@ -0,0 +1,56 @@
+# Homeworld occupation: qualification and unresolved entitlement
+
+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
+
+The E3 rulebook establishes these distinct rules:
+
+- Foreign forces at the start or end of a game turn qualify their faction as an Occupier. Sole foreign occupation at any instant qualifies immediately for that turn.
+- High advantages follow the native reserve threshold. Occupation retains the native low penalty; no global sentence explicitly suppresses a restored high advantage or replaces its battle value.
+- The printed occupied bank award occurs during Spice Collection, including Basic. Occupation income may immediately be shared with the occupier's ally.
+- Homeworld effects cannot be canceled with Karama.
+
+These are separate conditions, rather than three mutually exclusive card states. [GF9 E3, pp.9–10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9).
+
+The Kaitain FAQ divides a five-spice purchase into Emperor three and occupier two, including an occupier's own purchase. It does not demonstrate multiple contributors or resolve competing recipients. [GF9 E3, p.15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=15).
+
+Here a game turn is the complete nine-phase sequence, Storm through Mentat Pause; an individual player's shipment/movement opportunity is not this boundary. [GF9 base rules, p.7](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=7).
+
+## Executable evidence contract
+
+The following is an implementation design derived from those timing requirements, not an additional rule about who receives a disputed reward:
+
+1. Keep native population, present foreign armies and historical qualification evidence separate. Include the exact Homeworld ID: Emperor's two worlds are distinct locations.
+2. After a completed semantic force change, record a sole-occupation event when exactly one foreign faction has forces and no other faction has forces there. A single foreign faction sharing the world with natives is not alone. Do not require an Arrakis stronghold or treat Homeworlds as territories.
+3. Observe the old turn's end before incrementing the turn; observe the new turn's start before opening Storm opportunities. At a boundary, preserve every foreign presence relevant to the printed qualification sentence. If multiple factions satisfy the sentence, storing their evidence does not authorize duplicate rewards.
+4. Bind each observation to its accepted event, turn, world, qualifying faction and cause (`sole`, `turnStart`, `turnEnd`). Make observation replay idempotent. Keep historical facts after departure; a future entitlement policy can expire a benefit without erasing that the qualification happened.
+5. Observe semantic casualty groups rather than intermediate array writes. A separately ordered loser removal can make a foreign winner alone before its own casualties; simultaneous destruction must not manufacture a survivor from implementation order. See the integration audit for the existing battle continuation boundaries.
+6. Use a separate once-only Collection receipt for any awarded bank income and its immediate ally allocation. Qualification, refresh, view projection and JSON normalization must not themselves mint spice. A later qualification is not an instruction to replay an earlier Collection.
+
+A public qualification record needs no hand, Traitor, Face Dancer or sealed-plan data. Malformed initialized evidence must fail validation; a legacy save lacking history cannot be claimed to prove that no earlier qualifier existed. Resolving entitlement by scanning current armies alone would discard the distinction the publisher created.
+
+## Questions the retrieved sources do not settle
+
+| Concrete situation | Missing decision; do not silently substitute a policy |
+| --- | --- |
+| A qualifies alone, then departs completely | Whether all continuing benefits expire immediately or some survive for the stated game turn. |
+| A departs and B becomes alone in the same turn | Whether A retains any entitlement, and which faction controls a unique occupied power or receives the next receipt. |
+| B enters while qualified A remains | Whether A's remembered qualification continues to supply each benefit while contested. |
+| Multiple foreigners remain at a turn boundary | How the qualification wording composes with a unique card choice, Duke custody or a single income stream. Storm order is not an established automatic tie-break here. |
+| Native revival restores the high threshold after qualification | How retained low penalties and occupied benefits expire; occupation alone supplies no explicit global high-benefit suppression. Do not infer low charity/revival bonuses or a low native battle value merely from a remembered qualifier. |
+| A leaves and returns during the same turn | Whether departure interrupts a specific benefit and triggers its cleanup. Recording a second arrival must not itself create a second Collection award. |
+
+Tupile's verified physical reverse explicitly requires hand-limit cleanup when occupation ceases and removes native CHOAM's low advantage. That specific endpoint must be implemented once its triggering occupation condition is determined; it does not establish a universal expiry policy for every card. Original component provenance and the separate occupied income symbols are recorded in the [component audit](HOMEWORLD_COMPONENT_AUDIT.md). This audit did not re-fetch the old reverse-image cache.
+
+The sharing sentence covers occupation income broadly. A bank-only implementation should not silently exclude later occupied percentage receipts; their funding identity and legal beneficiary must first be resolved. An alliance prohibition arising from physically invading a prospective ally is separately grounded in actual presence, rather than in an invented single controller shared by every consumer.
+
+## Fresh research and implementation boundary
+
+The current publisher-indexed E3 Homeworld text was retrieved again. The previously examined E3 FAQ supplies the Kaitain arithmetic but no complete departure/replacement algorithm. Bounded searches of GF9, Future Pastimes and designer-name results found no authenticated further ruling. The [designer's expansion page](https://futurepastimes.com/dune-ecaz-moritani) links [Homeworlds Revealed](https://www.youtube.com/watch?v=4KZKz13wf9c); no usable spoken transcript was retrieved. Earlier component-frame inspection is not evidence for an unheard spoken clarification. A community compilation's replacement of the publisher's boundary clause with an unopposed-only definition is not an official erratum. Search absence does not prove that no clarification exists.
+
+The existing source documents already record these unresolved cases. During this audit the coordinating agent sent the user this concrete clarification, and the answer is pending: **If Guild occupies an empty Homeworld, then withdraws its last force and another faction occupies it during the same game turn, who keeps that Homeworld's occupied ability and income—the first faction, the current occupier, or both?** None of these alternatives is presented here as the official answer. Do not send a duplicate question or treat elapsed time as an answer.
+
+Progress can continue independently on population-only low Kaitain/Junction income reductions, retaining the [contributor routing contract](GUILD_SHIPMENT_PAYMENTS.md#publisher-contract-and-precedence). Rounding once over an eligible transaction total and evaluating a continuous threshold when income actually settles are reasoned compositions, not explicit worked publisher examples for allied funding or Ghola-interrupted receipts. Preserve those distinctions and the already documented [payment-splitting source gap](HOMEWORLD_CARD_ECONOMY_RULES.md#deferred-payment-splitting). A unique current garrison is not sufficient evidence to discard an earlier faction's same-turn qualification.
+
+This documentation-only audit changes no runtime, stored games, mode gates or publication status. Verification is limited to source/link review and the patch whitespace check.
diff --git a/docs/HOMEWORLD_PAYMENT_INCOME_RULES.md b/docs/HOMEWORLD_PAYMENT_INCOME_RULES.md
new file mode 100644
index 00000000..d1b1ca21
--- /dev/null
+++ b/docs/HOMEWORLD_PAYMENT_INCOME_RULES.md
@@ -0,0 +1,66 @@
+# Low Kaitain and Junction: faction payment income
+
+Source-composition contract, 9 September 2026. This covers population-based reductions to existing Emperor and Guild payment receipts. It does not implement occupied income, decide competing Occupiers or certify a complete Homeworld module. [Occupation source boundary](HOMEWORLD_OCCUPATION_RULES.md).
+
+## Printed effects and source precedence
+
+The verified low faces say that native Emperor receives the rounded-up half of Treachery Card payments and native Guild receives the rounded-up half of shipping payments. Both are low at zero through four native forces. Kaitain population is its own physical native pool, excluding forces allocated to Salusa Secundus. Component provenance is recorded in the [Homeworld component audit](HOMEWORLD_COMPONENT_AUDIT.md); this pass did not re-download its historical reverse-image cache.
+
+The E3 Kaitain FAQ's five-spice purchase produces Emperor three and occupier two, even if the occupier bought the card. This supports a division of the existing payment, not an additional buyer charge. It supplies no allied-contribution example. E3 also makes Homeworld penalties Karama-immune and describes threshold effects as continuously dependent on native population. [GF9 E3, pp.9–10,15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9).
+
+Preserve the November FAQ's contribution routing before applying Junction's cut: non-Guild contributions eligible for shipment income go to Guild; Guild's own contribution goes to the bank. Preserve the existing independent-Karama bank-only route. The later funding answer replaces the earlier transfer-to-shipper instruction. This source adjudication and its Guild-as-shipper application are already documented in [Guild shipment payments](GUILD_SHIPMENT_PAYMENTS.md#publisher-contract-and-precedence), with the [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2). No new contributor-rounding ruling was retrieved in this audit.
+
+## Supported receipts and pending contributor-rounding question
+
+The coordinating implementation keeps these operations separate:
+
+1. Validate the transaction's actual authorized contributions. Before a low-Junction shipment declaration or cost commits, reject a split whose two positive eligible contributions are both odd: the two plausible rounding interpretations differ. This is an unsupported-case guard, not a printed prohibition. Complete admitted purchases or shipments and debit each contribution once under the transaction's own source rule.
+2. Determine the amount that the existing ordinary income rule would deliver to the eligible faction. This `gross` is the faction's eligible receipt, not necessarily the total cost.
+3. If ordinary faction-income cancellation succeeds, credit zero and leave that eligible amount in the bank. Homeworld immunity does not immunize the separate ordinary income advantage.
+4. Otherwise evaluate the native world's current low condition at income settlement. For supported low receipts, credit `ceil(gross / 2)` and leave the remainder in the bank. This aggregate arithmetic agrees with per-contributor rounding only where the contributor guard admits the transaction; it does not establish a rule for blocked cases. Without the low condition, credit `gross`.
+5. Keep the original gross transaction receipt and the original positive eligible contribution amounts immutable through responses and restore. These source amounts remain server-private. Recompute the current low-state preview; do not overwrite the original amount with an earlier preview, merge away payer evidence or halve an already reduced amount. Threshold changes must not let a previously high receipt bypass the unresolved-case check when it settles low.
+
+**Contributor rounding remains unanswered.** The coordinating agent has sent a second user question: for low Guild income on a shipment funded by two non-Guild contributions of one spice each, is the income one spice (round the total) or two spice (round each contribution)? Neither interpretation is selected for cases where they differ. Do not duplicate the question, treat elapsed time as an answer, or present the pending aggregate hypothesis as a verified rule. Single positive eligible contributions are unaffected by this distinction; with two contributors, the alternatives coincide unless both are odd. Do not pool unrelated purchases or shipments either. The [payment source gap](HOMEWORLD_CARD_ECONOMY_RULES.md#deferred-payment-splitting) remains open. The separate occupation-lifecycle question is also pending.
+
+**Current population at actual income settlement is also a source composition.** Continuous threshold wording supports applying the condition when the faction receives spice. The publisher supplies no worked example of Ghola changing the native population during an already-open payment response. The implementation must label this composition honestly and test it; it must not reprice the buyer's completed purchase or shipment when the recipient's population changes.
+
+| Supported or deferred example | Low native receipt | Bank portion of the total payment |
+| --- | --- | --- |
+| Five-spice auction payment eligible for Emperor | 3 | 2 |
+| One shipment funded by two non-Guild payers, 1 + 1 | **Pending ruling: 1 or 2; blocked before commitment** | **Pending: 1 or 0** |
+| One shipment funded by two non-Guild payers, 2 + 1 | 2 under either interpretation | 1 |
+| Shipment cost 5, with Guild contributing 2 | 2 from eligible gross 3 | 3: the Guild-funded 2 plus the cut 1 |
+| Guild's own shipment, personal 2 plus non-Guild ally 3 | 2 from eligible gross 3 | 3 |
+| Independent Karama bank-only payment | 0 | Entire actual payment |
+| Zero-price/free acquisition or shipment | 0 | 0 |
+
+The supported multi-payer arithmetic examples deliberately use splits on which both interpretations agree; the five-spice Kaitain split has the cited E3 worked example. The blocked 1 + 1 case is recorded to make the unanswered choice concrete. A paid tariff discount and a reduced income receipt are separate calculations. A recipient cannot spend its future income to fund the same transaction.
+
+## Income-source adapters
+
+| Source | Existing destination/classification to preserve before applying the cut |
+| --- | --- |
+| Normal paid Treachery auction | Emperor income from another faction's payment; the Emperor's own purchase gives no self-income. Free Karama acquisition supplies no paid receipt. |
+| Richese compulsory cache auction bought by Richese | Emperor or bank normally. This destination is explicit in E2 p.5; a positive Emperor receipt uses Kaitain's cut. |
+| Richese cache auction bought by another faction | Richese receives its sale proceeds. Do not send them to Emperor merely because the physical item is a Treachery Card. |
+| Richese Black Market hand-card sale | Preserve its separately specified seller-income destination and existing self-sale handling. This document does not resolve the older self-sale gap or redirect the seller's receipt. |
+| Richese advanced special Karama three-spice cache purchase | Existing composition treats the purchase payment as Emperor income. Apply Kaitain's cut to that receipt; do not cancel or replay the protected special purchase when ordinary income is canceled. |
+| Richese Ambassador three-spice paid draw | Existing composition treats a successful paid draw as a purchase. Another purchasing faction supplies Emperor income; Emperor as buyer supplies none. A failed conditional draw supplies no payment. Preserve its Ambassador continuation and subsequent Harkonnen bonus. |
+| Ordinary reserve, Guild cross/return and admitted Homeworld shipment | Preserve each route's price, payer routing and actual eligible Guild income, then apply Junction's cut. Being a Homeworld route alone does not redefine the receipt. |
+| Junction-sponsored transport | Preserve the explicit route quote and donor contributions. The high transport permission and later low income condition are different effects; do not replay the completed route if population changes before income. |
+
+The Emperor trigger and the two special-purchase compositions are sourced in [Richese acquisition rules](RICHESE_ACQUISITION_RULES.md#1-advanced-special-karama-purchase) and [Richese Ambassador rules](RICHESE_AMBASSADOR_RULES.md). The publisher directly confirms Richese cache self-purchase routing in [GF9 E2, p.5](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=5). Black Market has its own [source contract](RICHESE_BLACK_MARKET_RULES.md#official-expansion-anchors). The special purchase and Ambassador destinations are general-rule compositions, not claims of dedicated GF9 FAQ answers.
+
+Do not reduce unrelated Emperor/Guild bank awards, charity, revival income, ordinary Collection, sales to CHOAM, or Kaitain's paid card disposal. In particular, the Emperor Ambassador's five bank spice is not a Treachery payment; free Guild Ambassador transport does not manufacture shipping income.
+
+## Occupied branch remains separate
+
+The unoccupied calculation leaves the missing half in the bank. An occupied face can assign that portion to a qualifying occupier and may independently award printed bank spice during Collection. Do not convert the bank remainder into an occupier award until the lifecycle and actual recipient are resolved. Likewise, high native population alone cannot prove that a retained occupation penalty has expired. Any runtime scope guard must acknowledge this limitation rather than describing the population-only quote as complete occupation support.
+
+Future integration must preserve the original eligible receipt, present force state and qualification history separately. Ordinary income cancellation, retained low effects, occupied percentage income and immediate ally sharing must compose from their own rules; the current unoccupied branch is not authority for a future occupied Karama outcome.
+
+## Required verification before a runtime claim
+
+Meaningful regressions cover odd/even and zero receipts, rejection of two eligible odd contributions before declaration or payment, coincident 2 + 1 rounding, both contributor identities, protected original contribution evidence, module-off parity, current 4-to-5 and 5-to-4 changes across legal Ghola interruptions, cancellation without refund, all listed Emperor sources, all admitted shipment adapters and exact parent restoration. Save/recovery tests should prove no repeat debit, card acquisition, force move or faction credit, and reject altered gross/source/owner binding without persistence. Public previews may show the current numeric split but must not expose the purchased card's private face.
+
+This documentation pass reviewed source links, referenced local targets, the limited adapter wiring and whitespace only. The contributor guard and unpublished development code are not a completed runtime checkpoint. Runtime tests, full checks and release evidence belong to the coordinating implementation checkpoint; no such result is claimed here.
diff --git a/docs/HOMEWORLD_PAYMENT_INCOME_RUNTIME.md b/docs/HOMEWORLD_PAYMENT_INCOME_RUNTIME.md
new file mode 100644
index 00000000..ae35eb5b
--- /dev/null
+++ b/docs/HOMEWORLD_PAYMENT_INCOME_RUNTIME.md
@@ -0,0 +1,48 @@
+# Homeworld payment income runtime
+
+Development checkpoint, 9 September 2026. Low Kaitain and low Junction now reduce supported native faction payment receipts while preserving the payer's original cost. Occupation and divergent allied-contributor rounding remain unfinished. The [source contract](HOMEWORLD_PAYMENT_INCOME_RULES.md) distinguishes printed rules from timing composition and records the pending user question. No Homeworld, Advanced or expansion start gate is opened by this checkpoint.
+
+## Calculation and settlement
+
+[homeworld-payment-income.ts](../game/homeworld-payment-income.ts) quotes the original eligible gross amount, current native low condition, faction income and bank remainder. A supported low receipt rounds its half upward. Eligibility and actual contributor routing are established before this calculation; a Guild-funded share already destined for the bank is not included in Guild's gross receipt. Kaitain counts its own native forces, excluding the Emperor's Salusa allocation.
+
+The payer has already paid when the faction income opportunity opens. [The engine](../game/engine.ts) credits the quoted faction amount once when that opportunity settles; it does not charge again, refund the buyer or replay the shipment or acquired card. Current native population controls the reduction at receipt. This checkpoint adds no Ghola interruption window; pending income retains the existing card-timing boundaries. Rechecking population at receipt is the continuous-threshold composition documented in the source contract, not a dedicated publisher Ghola example.
+
+Ordinary Karama can prevent the separate Emperor or Guild income advantage. The Homeworld penalty itself remains immune. Canceling an eligible native income leaves the payment in the bank and preserves the completed purchase or transport. This supported unoccupied branch does not decide future occupied-income cancellation or beneficiary rules.
+
+Emperor adapters include ordinary paid auctions, the existing Richese cache self-purchase destination, Richese's advanced three-spice special purchase and successful Richese Ambassador paid draws. Emperor buying for itself creates no self-income. Richese's own seller receipts retain their destination. Guild adapters preserve ordinary and legacy shipment, native return, Homeworld shipment and Junction-sponsored route classifications. Free transport and unrelated bank rewards create no new payment income.
+
+## Unanswered contributor rounding
+
+For two eligible odd contributions, rounding the combined payment and rounding each contributor separately differ. A one-spice plus one-spice non-Guild payment would produce either one or two spice for low Guild. The user question is pending; neither result has been adopted for that case.
+
+The engine rejects that unsupported split before a low-Junction shipment declaration or cost commits. This is a temporary scope guard, not a printed prohibition. Single positive eligible contributions and two-contributor splits on which both interpretations agree remain supported. The aggregate quote implements their common result; it must not be cited as resolving blocked cases. Original contribution evidence accompanies the receipt for later validation, including population-sensitive settlement. Player shipment controls and all four AI profiles use the same public funding guard; blocked candidates are excluded before declaration.
+
+## Saved receipts and private information
+
+New Homeworld Guild income responses retain positive original eligible contribution amounts and a matching proof string. The proof binds turn, phase, owner, gross and contributions. It is an internal consistency record, not a cryptographic authentication claim. Validation checks the amounts sum to gross and rejects partial deletion or mismatched initialized metadata before the relevant read or continuation can settle it.
+
+Legacy responses missing **all** new contribution metadata remain compatible. Such a response cannot reconstruct historical payer evidence, and total metadata removal is not distinguished from a legacy shape by this compatibility rule. This limitation is explicit; the checkpoint does not claim complete tamper detection or full recovery certification.
+
+Contribution amounts and their proof remain server-private in the response projection. The public income preview shows the already-paid eligible gross, current native receipt and bank remainder to every seat; it exposes no private acquired card or unrelated spice balance. [The table](../components/game-table.tsx) explains the low split and unchanged payer cost at the ordinary income opportunity. No additional player claim is needed for the automatic arithmetic.
+
+## Verification at this handoff
+
+Final `npm run check` passes types, lint and **3,541 offline cases**. The
+production build passes. **40 HTTP integration cases** passed before the final
+UI/AI funding-filter adjustment; that adjustment received the final offline
+check and build. The three new payment suites add 26 cases: ten pure quotes,
+eleven engine/control/AI scenarios and five production SQLite scenarios.
+Independent review verified all four profiles and found no secret-state access
+in the shared funding guard. It also identified the Ambassador preview-binding
+and AI-candidate defects repaired before these final checks.
+
+All **2,894** rooms in the saved maintenance baseline retain their exact versions
+and state hashes. This checkpoint added one isolated browser QA room and thirty
+HTTP test rooms. No saved game was reset.
+
+Desktop and phone browser checks exercised a five-spice bid yielding three to low-population Emperor and two to the bank. Keyboard allowance and refresh of all three seats passed. The coordinating agent inspected the resulting views and reported no page overflow or browser errors. These are bounded payment-flow checks, not complete Homeworld-game acceptance.
+
+Focused entry point: `npm test -- homeworld-payment-income`. The relevant suites are [pure rules](../tests/homeworld-payment-income.test.ts), [engine flows](../tests/homeworld-payment-income-engine.test.ts) and [production SQLite recovery](../tests/homeworld-payment-income-recovery.test.ts). Their coverage includes source routing, unchanged payer cost, native threshold crossings, allowed/canceled receipt recovery, divergence rejection before commitment, proof validation and private projection. The implementation-status checkpoint records the same scoped evidence; complete expansion games and occupation behavior are not certified.
+
+This documentation/reference pass changes no database or automation. The removed automation remains removed. Occupation retention, occupied percentage recipients and Collection income, remaining Homeworld effects and complete expansion acceptance retain their existing release gates.
diff --git a/docs/HOMEWORLD_REPLACEMENT_RULES.md b/docs/HOMEWORLD_REPLACEMENT_RULES.md
new file mode 100644
index 00000000..0d8a017b
--- /dev/null
+++ b/docs/HOMEWORLD_REPLACEMENT_RULES.md
@@ -0,0 +1,79 @@
+# Homeworld replacement: source audit and adapter contract
+
+Audited 9 September 2026. Read alongside [Homeworld rules](HOMEWORLD_RULES.md).
+This document authorizes no runtime activation and adds no occupation or Ghola
+ruling. No production files changed in this audit.
+
+## Sources and explicit rules
+
+The [GF9 Ixians & Tleilaxu rulebook](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf)
+currently returns HTTP 403. Its publisher-authored 12-page
+[retailer mirror](https://lelekan.com.ua/files/rules/2081/pravila-nastilnoyi-gri-dyuna-dim-iksianciv-ta-dim-tlejlaksu-dune-ixians-amp-amp-tleilaxu-dopovnennya-angl-anglijskoyu-movoyu.0.pdf)
+was freshly read, including GF9 2020/Future Pastimes credits, pp.6, 9–10 and 12;
+the official indexed p.9 and Karama table agree. This is original publisher
+content, not the retailer's own rules.
+
+- **Face Dance, p.6:** another faction's victory remains its victory. The
+ revealed leader dies without a new bounty. Surviving winning forces return
+ to their reserves. Tleilaxu replacements may come from its reserves or
+ Arrakis, up to the returning force count. No positive minimum is specified.
+ The p.10 FAQ orders traitor declaration, winner declaration, then Face Dance.
+- **Ixians, pp.9–10:** after calculating casualties, surviving Suboids at that
+ battle's location may replace Cyborgs lost in that battle, one for one. The
+ example sends the substituted Suboids to the Tanks and retains the Cyborgs.
+ The p.12 Karama table permits canceling this ordinary substitution advantage.
+
+The [GF9 E3 rulebook, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
+was freshly checked through its official indexed text; direct retrieval also
+returns 403. Only natives may call Homeworld Face Dancers. It does not require
+the native caller to be one of the two combatants. Homeworlds hold reserves;
+they are not Arrakis territories. Advanced Emperor has two homes: initial
+Sardaukar and revived Sardaukar go to Salusa, revived normal forces to Kaitain.
+An expressly permitted transfer can subsequently mix the types. Those placement
+sentences do not expressly assign forces returned by Face Dance. The
+[designer's expansion page](https://futurepastimes.com/dune-ecaz-moritani)
+supplies no additional replacement ruling. No community compilation was adopted
+as an erratum.
+
+## Physical interpretation and remaining assignment boundary
+
+**Native Tleilaxu:** composition of the ordinary caller rule permits a reveal
+after two visitors fight at Tleilax, subject to the existing occupied-face
+restriction. Eligibility must not require Tleilaxu to have lost that battle.
+This is a composition, not a separately retrieved three-army FAQ.
+
+Native reserve counters are already at the destination. Selecting some as
+replacement sources must produce no net force change. The interface may omit
+that redundant selection while allowing zero external replacements. Existing
+natives remain; returning winners disappear. Only actual external arrivals
+increase the native pool. Never create a Tleilaxu visitor pool on Tleilax or
+withdraw the same reserves twice. The original source scope does not expressly
+include Tleilaxu garrisons on other foreign Homeworlds.
+
+**Returning winner:** preserve every normal/special identity and return only
+that winning army, not third parties. A single native Homeworld has an
+unambiguous destination. Advanced Emperor's allocation remains unverified:
+neither arbitrary player choice, universal Salusa routing for stars, nor
+restoration to a historical shipment origin is expressly prescribed for this
+effect. Keep this assignment boundary visible rather than presenting a revival
+helper's default as a confirmed Face Dance rule.
+
+**Ixian adapter:** treating substitution as ordinary battle cleanup at a
+Homeworld is an application of existing combat mechanics, not a new shipment
+permission. Use the one actual native or visiting Ixian pool at the battle
+location. Record the Cyborg casualties of that resolved battle; surviving
+Suboids elsewhere cannot pay. Exchange exact types between that pool and
+the Tanks. Total physical casualties and total Tanks remain unchanged by the
+exchange; only their type allocation changes. No sectors, reserve shipment,
+revival allowance, revival income or fresh population-arrival event is created.
+Native reserves elsewhere and unrelated old Cyborg deaths are not recovery
+sources. Cancellation leaves the original casualties intact.
+
+## Verification targets
+
+Test native and visitor Ixian pools, exact casualty receipts, cancellation and
+JSON resume. For Face Dance, cover a noncombatant native, no replacement,
+existing native reserves, Arrakis arrivals, untouched third-party armies,
+special-counter returns, and the unresolved Emperor assignment explicitly.
+Reject duplicate or nonexistent sources before changing custody. Preserve the
+original winner, leader/card cleanup and any prior battle rewards.
diff --git a/docs/HOMEWORLD_RESERVE_INTEGRATION_AUDIT.md b/docs/HOMEWORLD_RESERVE_INTEGRATION_AUDIT.md
new file mode 100644
index 00000000..bfa41ce5
--- /dev/null
+++ b/docs/HOMEWORLD_RESERVE_INTEGRATION_AUDIT.md
@@ -0,0 +1,86 @@
+# Homeworld reserve integration audit
+
+Read-only engine audit, 9 September 2026. Line references identify the source examined before the coordinator's Homeworld setup/Emperor-transfer integration; function names are the durable anchors. No runtime edits or new rules are introduced here. The source contract is [HOMEWORLD_RULES.md](HOMEWORLD_RULES.md), with the broader location architecture in [HOMEWORLD_RUNTIME_READINESS.md](HOMEWORLD_RUNTIME_READINESS.md).
+
+## Authoritative custody and an important Ix correction
+
+`game/homeworld-custody.ts` derives native primary forces from `Player.reserves` and `elites.reserves`, subtracting the explicitly stored Salusa normal/elite allocation. Foreign garrisons are separate. `quoteHomeworldCustody` changes aggregate native totals and Salusa together and returns exact before/after receipts; it does not perform the matching board/Tanks transfer or authorize the rule action. Every native withdrawal/deposit must therefore be composed with its board/Tanks delta exactly once.
+
+**Ix setup is already correct.** `initializeStartingForces` (`engine.ts:3473`) sets remaining Cyborg reserves to four and places three Cyborgs among six HMS forces. `place` (`3291`) only credits a location, so the resulting four reserves plus three deployed counters are the seven physical Cyborgs. Do not subtract three from the four again. `newPlayer` (`2076`) correctly has all seven before setup; `tests/ix-forces.test.ts:113–121` checks a seven-counter inventory. The new custody helper initially used an Ix maximum of four; that is the initial remaining reserve count, not the physical cap. The coordinator was notified to change that cap to seven and add a return/revival regression. Later all seven may legally be native reserves.
+
+## Real setup hook
+
+| Hook | Current behavior | Correct integration responsibility |
+| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `newPlayer`, `2072–2077`; lobby faction action, `15846` | Fresh reserves20; only Ix has an initial elite record, with seven. Faction changes replace the elite record. | Store the module option separately. Do not freeze native pools or Salusa against a lobby roster that can still change. |
+| `start`, `3403` | Public Basic/base/no-expansion gate. | Explicitly retain the Homeworld gate while only native setup/transfer is integrated. No implicit activation from possessing a card catalog. |
+| `initializeSetup`, `3420` | Sets decks/order and enters prediction stage; private traitor deal follows later. | Validate the module/configuration here, but do not prematurely install a native snapshot or expose starting cards before the established prediction/traitor order. |
+| `initializeStartingForces`, `3457–3497` | Grants starting spice, creates faction pieces, deletes/rebuilds elite state, and applies fixed board placements. | Install initial custody after these operations. Advanced Emperor's five initial Sardaukar then populate Salusa. Other native groups derive from the final remaining totals. |
+| `advanceSetup`, `3566–3574` | Runs the force initializer once, then waits for Fremen/BG placement before dealing Treachery. | Never re-run `createHomeworldCustody` on every normalization or force choice: that would overwrite later saved allocations. |
+| Fremen setup, `15907–15959` | Places ten with explicit `elitePlacements`, subtracts selected elites, sets reserves10. | Preserve the selected physical starred counters; derived native custody shrinks accordingly. Creation of the identity record must precede this choice. |
+| BG setup, `15870–15892` | Advanced starting advisor removes one native reserve. | Debit its sole native pool; preserve setup order/stance. |
+| Moritani setup, `14715–14736` | Places six after the other setup conditions complete. | The existing setup sentinel is reserves20. Do not alter that count merely to represent the Homeworld. Its native pool then becomes fourteen. |
+| `initializeBaseGameForAudit`, `3579–3638` | Strict cloned fresh base lobby without optional modules. | Add the Homeworld exclusion to this existing contract. A new explicitly named fresh all-faction audit seam can exercise the shared initializer without changing public start authorization. |
+
+An installation after fixed placements is safe while the later choices remain genuine setup actions: native counts are derived, so their subsequent single-world debits need no recreation of custody. Initializing at `finishSetup` instead is too late for a source-correct setup view and risks treating all later reserve mutations as unobserved initialization. Do not retrofit a Salusa allocation into an arbitrary already-playing save by guessing where its Emperor forces were located.
+
+## Every existing engine reserve mutation category
+
+The following are actual mutation sites, not every occurrence of the word reserves. Pure prerequisite helpers and projections must receive the matching source data, but must not commit these deltas themselves.
+
+| Route / exact source anchor | Present mutation | Required native allocation and failure example |
+| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `revealPlayerNoField`, `1329` | Materializes Richese forces from reserves. | Debit Richese's native pool only when physical forces are revealed; deploying the marker alone does not debit its hidden denomination. Never count marker plus still-native physical forces as two inventories. All reveal callers inherit this path. |
+| `commitShipment`, `13107–13109` | Debits selected total and elite reserves, then places physical forces; allied No-Field physical shipment shares this branch. | Advanced Emperor needs exact Kaitain/Salusa source quantities, potentially combined in one shipment. An aggregate subtraction can leave `salusa.elite > eliteReserves`, or silently withdraw regulars from Kaitain when those regulars are actually on Salusa. Preserve source allocation through pending Guild/Karama declarations. |
+| `commitAmbassadorShipment`, `7074–7076` | Free Guild Ambassador selected native forces to Arrakis. | Same physical source requirement even though payment and ordinary counters are absent. Emperor beneficiary may need both native pools; do not infer location from `elite` alone after interworld transfers. |
+| `finishGuildAmbassadorAdvisor`, `7265`; `finishResponse` BG advisor branch, `12872` | Removes one BG reserve for free accompaniment. | Debit BG's single native pool; source-specific destination restrictions remain separate. No new Homeworld advisor permission follows from the custody helper. |
+| `decideTerror` Sneak Attack, `8347` | Moritani reserve reinforcement. | Debit the native pool, preserving its independent arrival continuation and zero-choice behavior. |
+| `applyGholaEffect`, `10123–10128` | Tanks→reserves and elite Tanks→elite reserves; increments elite usage. | Deposit revived Emperor normal forces on Kaitain and Sardaukar on Salusa through the prescribed revival routing, while preserving Ghola's own quota/income semantics. Do not merely increase aggregate elite reserves and thereby derive the new Sardaukar on Kaitain. |
+| `finishRevival`, `11688–11704` | All ordinary force and Emperor-funded-extra revival recipients share this commit. | Same typed return routing; preserve payer versus recipient, frozen price, ordinary/free counters and exact quoted Axlotl/Tleilaxu income. Leader, Auditor, KH and buyback branches do not themselves move force reserves. |
+| `executeSpecialKaramaIntent`, Emperor forces, `13944–13951` | Free Tanks→reserves with typed counter usage. | Must use the same typed native revival deposit. The card's cost/once-per-game flag remains owned by the surrounding effect. |
+| `finishResponse` CHOAM Trip to Gamont, `12269–12273` | One selected board force returns to its owner's reserves, preserving elite identity. | Non-revival native return needs an explicit destination contract for two-world Emperor; do not automatically classify every return as revival or increment revival counters. Keep the unresolved destination policy separate from the otherwise definite physical return. |
+| `applyActionInner`, Face Dancer replacement, `15278–15294` | Tleilaxu may withdraw own reserves as replacement; every defeated winner force returns from the territory to its native reserves, including its elites. | Debit Tleilaxu native reserves. For an Emperor winner, the returning normal/elite group needs a source-appropriate **non-revival** deposit decision/contract. Normal board replacement is not shipment and must not add fees or movement counters. |
+| `applyActionInner`, `guildShip`, `16811–16817` | Paid Fremen ally reserve route debits reserves; Guild board return credits reserves; board cross-shipment uses `removeGroup`. | Current genuine native actors have one native Homeworld, but future Junction sponsorship exposes arbitrary recipients. Retain explicit return destination and typed custody rather than treating every `to:'reserves'` as a universal primary-pool return. |
+| New Emperor internal transfer | Not present in the audited snapshot. | One atomic withdrawal+deposit between Kaitain and Salusa; aggregate reserves and total elite reserves remain unchanged. Validate both source physical types before debit, then update movement consumption once. No shipment fee, no Heighliner grant, and no piece multiplication. |
+
+Setup writes described above are the remaining direct reserve mutations in the audited engine. Outside it, `homeworld-custody.ts` mutates only its detached quote results; the search found no other production module directly changing a player's reserve count. Pure helpers that return transformed custody, disaster calculations or views are not additional live commits.
+
+`game/tleilaxu-ambassador-forces.ts` is a pure prospective quote, not an additional integrated Ambassador commit. Likewise the Ecaz Reinforcements/Harass & Withdraw definitions and the new Homeworld free-revival/reinforcement faces must eventually use the same native transfer boundary, but this inventory does not invent live handlers for them. Tleilaxu Ambassador entitlement questions remain separate from routing its eventual actual returns.
+
+## Basic physical identity is not an Advanced power switch
+
+E3 explicitly uses starred Fremen identity in Basic Homeworlds. Current code normally has no Basic Fremen/Emperor elite record, so several consumers implicitly use record presence as a feature flag. Preserve physical identity without accidentally introducing Advanced benefits or restrictions.
+
+| Consumer | Existing behavior / required separation |
+| ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `combatForces`, `engine.ts:3347–3381`; bot fallback `bots.ts:535` | The descriptor sets elite strength2 without a Basic-Fremen guard. Gate benefit strength by rules mode while preserving physical counts. Ordinary Basic scoring is **not universally doubled today**: the separate plan gates below already keep normal Basic dial limits. The descriptor still feeds public `ownForces`, Stone Burner and direct casualty calculations. |
+| `validatePlan`, `9766`; `findLegalBattlePlan`, `10161`; bot plans, `545`; Prescience answer, `17124` | Already use `advanced | | ixians`to choose strength-valued/half-step dialing. Do not replace this with`!!p.elites`, which would introduce Advanced dialing merely because Basic starred counters are tracked. |
+| Combat power queue, `9953–9959` | Already offers Emperor/Fremen elite and Fremen support responses only in Advanced. Preserve that guard. |
+| `eliteRevivalRemaining`, `revival.ts:182` | Has no game-mode argument: non-Ix tracked elites are capped at one per turn. Mode-aware eligibility is necessary before Basic physical tracking is made playable. Do not silently adopt an Advanced restriction from existence of the counter record. |
+| Its engine callers | `gholaOptions:10054`, `applyGholaEffect:10120`, Ghola/card prerequisite `10240`, special Karama validation `13687`, extra revival `16259`, ordinary revival declaration `16466`, projected revival choices `17890`. All must agree with the actual commit. |
+| Its other callers | `revival-cancellation.ts:101` saved/canceled revival validation; `bots.ts:1250,2616,2626,2636`; `components/game-table.tsx:3262`. Updating only the live declaration leaves restored receipts, bots or UI contradictory. |
+| `battle-resolution-quote.ts:456`; `kill:3296` | Basic normal winners take immediate untyped dial losses, and default `kill` selects normal counters first. Once starred identity has future Homeworld value, this is a separate typed-casualty selection audit: do not claim identity-aware combat complete merely because the counters survive setup. Total losses and ordinary strength must remain Basic. |
+| Fremen setup bot, `bots.ts:1067` | Currently omits `elitePlacements` and therefore leaves all stars in reserves. This is legal, though a meaningful future Homeworld policy should reason from the actual card. The human action already accepts explicit setup allocation. |
+| `place`, `3291`; `kill`, `3296`; `removeGroup` | Preserve typed location/Tanks quantities mechanically. Their physical accounting does not itself require Advanced strength. Retain it rather than discarding Basic special identity after movement or death. |
+
+Basic Emperor uses only Kaitain. Preserving its five printed counter identities is compatible with the location model but has the same mode-aware combat/revival obligations; it is not necessary to create Salusa in Basic. Ix always retains its own printed typed mechanics in both modes.
+
+## Saved commitments and speculative preparation
+
+Pending shipments currently bind aggregate `amount`/`elite`, not a split native source. New source selections must be part of the original intent and revalidated at final allowance. Equal aggregate totals do not prove the same Homeworld still contains a promised group. This applies to ordinary shipment, Guild Ambassador shipment and any future Junction-sponsored shipment.
+
+`ordinaryDiscardSignature` (`engine.ts:2238`) captures completed reserve/elite outcomes for Ghola, but a new Salusa allocation is not represented by old aggregate fields. Extend appropriate completed-effect/signature receipts when Homeworlds are active; a wrong secondary allocation with unchanged totals must not pass recovery. Revival cancellation and terminal preflight must quote the real typed destination without performing effects or payments twice.
+
+`findShipmentCompletion` uses internal `shipmentPreparation` execution to simulate real Ghola preparation while suppressing only the ordinary discard frame. Its cloned native custody must change through the same helper as live execution. Otherwise an apparently legal Ghola→shipment witness can fail after the actual Ghola restores Sardaukar to a different pool. Projections may inspect public native counts and owner-controlled source options, but cannot mutate a live Homeworld or emit income while constructing a witness.
+
+## Required concrete checks for this integration
+
+1. Actual prediction→traitors→fixed forces→Fremen/BG/Moritani choices→starting cards preserves existing order. No custody recreation after JSON reload; no duplicate native pieces. Every seated faction receives exactly its active Homeworld locations.
+2. Ix setup retains three Cyborgs on HMS and four native; later a legal return can yield seven native Cyborgs. Full physical totals include visitors and Tanks, not just the helper's local native-plus-visitor bound.
+3. Advanced Emperor starts Kaitain15normal/Salusa5Sardaukar. Transfer normal forces to Salusa, then reject an attempted withdrawal from empty Kaitain even when aggregate reserves suffice. A reverse transfer restores the original allocation and consumes only the appropriate movement once.
+4. A mixed outbound Emperor shipment can use both locations once; pending/reloaded/canceled declarations preserve the selected source quantities. A source corruption with unchanged total reserves rejects atomically.
+5. Normal and Ghola revival credit the correct typed native pool and preserve unrelated counters/income. Non-revival card returns must not be silently forced through a revival helper with new quota or placement semantics.
+6. Basic Fremen retains all three stars across setup, movement, Tanks and revival without double strength. Check authoritative action, public descriptor, all bot levels and resumed prerequisite against the same mode-aware eligibility.
+7. Existing games with the option disabled retain their behavior and saved state. The new fresh audit seam rejects reused/dirty state; public starts remain gated until complete Homeworld gameplay is implemented.
+
+The audit found the concrete Ix custody-cap error described above and the Basic-record/mode coupling. It does not claim that the Homeworld module is playable, choose an unresolved Emperor non-revival return destination, or infer any other new rules from an implementation convenience.
diff --git a/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_INTEGRATION.md b/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_INTEGRATION.md
new file mode 100644
index 00000000..feb8b80a
--- /dev/null
+++ b/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_INTEGRATION.md
@@ -0,0 +1,239 @@
+# Revival deployment integration audit
+
+Historical integration audit, retained as the pre-implementation hazard map.
+Statements below describing proposed hooks or missing controls reflect that
+earlier audit, not the current runtime. As of 10 September 2026, source-specific
+normal, Emperor-extra and Ghola settlement hooks create `homeworldRevivalReturn`;
+the engine connects its owned placement decision, typed custody transfer,
+Ambassador continuation and held revival-income response. Dedicated destination,
+return-receipt and projected-choice modules now exist, with focused engine and
+production SQLite coverage.
+
+Use [the runtime checkpoint](HOMEWORLD_REVIVAL_DEPLOYMENT_RUNTIME.md) for current
+behavior and verification limits, and [the source contract](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md)
+for the unresolved threshold, quantity and arrival classifications. This
+historical document does not select those interpretations or lift the full
+Homeworld/expansion release gates.
+Related contracts are [Homeworld benefits](HOMEWORLD_BENEFITS_RULES.md),
+[Homeworld replacement](HOMEWORLD_REPLACEMENT_RULES.md),
+[occupation integration](HOMEWORLD_OCCUPATION_INTEGRATION.md), and
+[Collection/Terror integration](HOMEWORLD_COLLECTION_INTEGRATION.md).
+
+## Concrete integration hazards
+
+1. `engine.ts::addRevivedReserves` is a physical destination adapter shared by
+ normal revival, Ghola and Emperor special Karama. Adding the ability there
+ would lose the distinction between ordinary Free Revival, a free card effect
+ and a paid return. The effect needs a producer-supplied source receipt.
+2. `PendingRevival` saves total `amount`, total `elite`, scalar `free`, price and
+ ordered checks. It does not save a generally usable typed free group.
+ `action.freeElite` is currently meaningful only for Ixian pricing; that split
+ is recoverable from the frozen normal price. Do not infer every free elite
+ allocation from cost zero, or deploy all revived forces when only some were
+ Free-Revived. Native Tleilaxu has no special counters, simplifying its own
+ group, but the shared contract still needs explicit types.
+3. Once returned counters merge into reserves, counts alone cannot distinguish
+ the new eligible batch from previously held reserves. A bounded, protected
+ typed group must survive until placement or decline; a second independent
+ revival must not append to the earlier permission or substitute its pieces.
+4. Normal revival and Ghola both create income continuations. Installing a new
+ `decision` before those existing controls are saved can overwrite the income
+ response or let the original parent continue before the placement finishes.
+5. Ghola's `ordinaryCardDiscard` integrity explicitly rejects a suspended
+ decision. Simply opening a placement inside `applyGholaEffect` makes its
+ normal card-disposal continuation invalid. Any supported Ghola trigger needs
+ a deliberately extended continuation, including market-sale Ghola's parent.
+6. Ordinary Homeworld shipment has phase, cost, source and own-world permission
+ rules. Reusing `quoteHomeworldShipment` or its engine dispatcher directly
+ would consume a shipment, apply a price, or reject a separately authorized
+ native destination. Reuse custody and destination validation, not that action.
+7. Arrival may trigger Intrusion, Terror or an Ambassador only if the verified
+ rule classifies this deployment as a qualifying entry. A new phase-four
+ placement is not automatically an ordinary phase-five shipment. Conversely,
+ skipping the existing arrival pipeline without checking that classification
+ could omit a required reaction.
+
+## Actual force-return producers
+
+| Producer | Original source evidence | Current settlement |
+| --- | --- | --- |
+| Normal `revive` action | `forceRevivalQuote`, selected total/elite, scalar free, costs and ordered checks | `beginRevival` → CHOAM free-revival opportunity → Tleilaxu stop/checks → `finishRevival` |
+| Emperor-funded extra ally revival | `PendingRevival.emperorExtra`, distinct payer, free zero and full frozen price | Same `finishRevival`; increments `emperorExtra` rather than normal revival usage |
+| Ghola force branch | Physical owned card, selected total/elite, separate card timing and per-turn special-counter limit | `applyGholaEffect` returns forces, then Axlotl/Tleilaxu income and physical card disposal |
+| Emperor special Karama force branch | Prepared `emperorForces` intent, original turn/phase/card, total/elite | `executeSpecialKaramaIntent` returns forces, marks special usage and consumes the card |
+| Leader/Kwisatz/foreign-ghola/Duke revival | Explicit leader identity and its own cycle/custody authority | Does not produce a force group; must not create a deployment receipt |
+| Ixian substitution or Face Dancer replacement | Their own already-committed battle/replacement receipt | Not a generic revival event merely because counters leave Tanks or reserves |
+
+`addRevivedReserves` calls `quoteNativeRevivalDeposit` when Homeworlds are active.
+This deposits normal Emperor counters on Kaitain and Sardaukar on Salusa;
+other factions deposit on their native primary world. It does not authorize
+revival, price it or inspect Tanks. Retain that separation. Existing withdrawal
+uses `quoteNativeReserveWithdrawal`, which enforces exact normal/elite sources
+and cannot consume visitor forces or guess an ambiguous Emperor allocation.
+
+Do not silently broaden Southern Hemisphere to every listed return producer.
+The source audit must decide normal paid revival, Ghola, Basic starred counters,
+and any exceptional replacement cases from the actual wording. High Tleilax
+specifically requires identifying the qualifying Free-Revived group; paying
+zero for some other effect is not adequate source evidence.
+
+## Normal revival sequencing
+
+The declaration quotes the current free rate and selected group before changing
+population. `beginRevival` allows CHOAM's reactive La La La before offering
+Tleilaxu's special stop and ordinary advantage checks. A successful La La La
+clears the request without payment or force movement; its cancellation resumes
+the same original request. `quoteRevivalCancellation` and `quoteRevivalResume`
+preserve the frozen request rather than silently reprice an accepted batch.
+
+`finishRevival` is the first normal producer hook that proves the force return
+actually succeeded. Unfunded and Fremen-limit outcomes must produce no deployment
+permission. Successful settlement debits the correct payer, deposits the exact
+typed group, removes it from Tanks, updates normal or Emperor-extra allowances,
+and handles Axlotl income. It then clears `pendingRevival`, records ordinary free
+income use and installs the quoted `revivalIncome` response.
+
+Capture a completed return receipt at that settlement boundary. Do not leave
+`pendingRevival` live after counters have moved: its resume validation expects
+the original unreturned group and accepted free allocation. Preserve the native
+eligibility observation required by the source audit separately from the
+resulting population. In particular, a return can cross Southern Hemisphere's
+three-counter or Tleilax's nine-counter minimum, and a later deployment can
+cross back. These are distinct observations, not a reason to recalculate the
+accepted price or grant another bonus.
+
+Keep Tleilax's existing low-income observation at the start of Revival separate
+from its high deployment eligibility. `homeworldRevival` owns that phase-start
+condition; ordinary free-income once-per-player history uses
+`revivalFreeIncome`. Neither is a record of which returned counters can deploy.
+The deployment must not create another Axlotl or Tleilaxu revival payment.
+
+## Ghola and nested controls
+
+`applyGholaEffect` has explicit leader and force branches. Its force branch
+returns at most five physical counters, updates elite usage, and leaves normal
+revival allowance untouched. `collectRevivalIncome(..., ghola=true)` creates the
+separate Ghola bank reward; it is not ordinary Free Revival income.
+
+`stageOrdinaryCardDiscard` captures the current controls, clears them and binds
+the completed effect through `ordinaryDiscardSignature`. That signature includes
+Homeworld custody, revival income history, force/Tanks/resource totals and the
+saved response. The current validator admits only the expected Tleilaxu income
+response after Ghola and no suspended decision. `finishTreacheryDiscard` restores
+those controls after the physical card retires.
+
+The scoped `pendingChoamMarketGhola` additionally preserves the original CHOAM
+sale through discard → income → complete, then `resumeMarketGhola` restores the
+sale. If Southern Hemisphere also applies to a Ghola return, its deployment
+must remain inside this original parent, without replaying the card, revival,
+income or sale. Add an explicit source-linked child stage; never mark the market
+interruption complete merely because no income response exists while a placement
+still waits. Ghola played in battle also needs the original battle controls and
+population-sensitive support limits preserved.
+
+Richese gifts/purchases, Nullentropy, Truthtrance, Worthless-as-Karama and generic
+response suspensions retain their original controls while source state remains
+live. Include a new placement obligation in their integrity fences where it can
+legally coexist. Do not restore a whole old Game snapshot over newly settled
+resources. Existing Duke revival remains leader-only with its own source/custody
+gates; no force-deployment decision should change Duke controller or cycle.
+
+## Proposed shared contract
+
+Use a focused pure module with a public physical context and an explicit
+completed-return receipt. A possible receipt shape is:
+
+```ts
+type RevivalReturnReceipt = {
+ event: string;
+ turn: number;
+ phase: number;
+ owner: string;
+ source: 'normal' | 'emperorExtra' | 'ghola' | 'emperorKarama';
+ returned: { normal: number; elite: number };
+ free: { normal: number; elite: number };
+ paid: { normal: number; elite: number };
+ card?: string;
+ nativeSources: Record;
+ // Exact eligible subset and eligibility observation are fixed by source rules.
+};
+```
+
+This is a proposed shape, not a decision that every source activates either
+Homeworld. Require `free + paid = returned` for normal classified groups; a
+card-effect source must retain its own classification rather than automatically
+calling all zero-cost pieces Free-Revived. If card effects need a third category,
+model it explicitly. Bind the original eligible subset and producing event in
+an immutable consistency receipt, with an independent outstanding obligation so
+deleting the pending choice cannot silently erase or duplicate it.
+
+A pure destination quote should accept that receipt, actor and a discriminated
+choice (`decline` or one explicit destination). Return exact custody transfers
+and arrival facts; do not mutate counters, spend another allowance, mint income
+or select opponent secrets. Source verification decides whether a subset may
+deploy or the entire eligible group must move together. Never offer more than
+the receipt's eligible types, even if reserves contain many other counters.
+
+The optional decision needs an independent event and stage, with the original
+income/card/outer continuation retained. Serialize stages such as awaiting
+selection, committed arrival and completed; exact order follows the source
+audit. Once physical departure and arrival commit, resume only the reaction
+suffix. Decline consumes the optional placement opportunity while keeping the
+already revived counters at their recorded native location.
+
+## Destination validation and entry
+
+For an Arrakis destination, reuse `allowedEntry`'s sector/storm/occupancy checks
+where the printed route requires them, but do not automatically grant Fremen's
+ordinary storm-shipment exception or enforce its Great Flat reinforcement range.
+Those belong to the ordinary shipment source. Southern Hemisphere's destination
+presence prerequisite should read the verified native physical presence, not a
+foreign player's forces or hypothetical future placement. Exact advisor, mobile
+stronghold and Homeworld scope remains a source question.
+
+For a Homeworld, use explicit `homeworld:*` identity and
+`quoteHomeworldCustody` transfers, respecting the verified ally/native destination
+rules. Homeworld locations are not Arrakis sectors and must not pass through
+`place`, `territory` or a fabricated sector zero. Do not turn free deployment
+into a Guild tariff, paid shipment, ordinary shipment promise or Heighliner event
+without a rule establishing that trigger.
+
+If this arrival qualifies for reactions, extend `openTerritoryEntry`, Intrusion,
+Terror/Ambassador continuation identity and `terror-entry-receipt.ts` with the
+new explicit cause and resume contract. Terror's original count must be exactly
+the deployed batch, not all destination occupants. Low Grumman's three-force
+rule and all preflight checks must use that same count. Existing simultaneous
+arrival ordering gates remain until the new composition is verified. Off-planet
+deployment must likewise call the eventual occupation lifecycle at its actual
+physical event, without inventing an Arrakis reaction.
+
+## UI, bots and recovery hooks
+
+`GameView.revival.pending` currently means only `pendingRevival`; it cannot stand
+in for the new optional decision. Add a source-labelled owner-only descriptor
+with event, exact eligible types, currently legal destinations and decline.
+Observers need the public pending owner and committed result, not the owner's
+card or undisclosed choices. Reuse the destination quote for UI and bots.
+
+Normal revival controls in `components/game-table.tsx` select total, elites and
+Ixian free elites. Ghola controls use `GameView.ghola`; neither currently selects
+a later deployment. Add a separate decision component rather than reusing the
+ordinary shipment form. All four bot levels need a legal destination or explicit
+decline. `standaloneGholaAction` already avoids pending decisions; preserve that
+behavior and prevent a new revival from replacing an outstanding group.
+
+Integrate obligation validation with pre-action/view/normalization integrity and
+`finishActionContinuations`. Automatic progression must wait for a real choice;
+`automaticContinuationPending` should represent only an automatic stage. Test
+JSON plus SQL competing workers so an accepted uncertain response cannot repeat
+the revival, deployment, income or optional decline.
+
+Minimum regression matrix: pre/post threshold crossing; mixed free/paid return;
+new versus previously held reserves; exact Fedaykin identity; module off and
+Basic/Advanced physical counters; La La La allowed/canceled and revival denied;
+Tleilax income allowed/canceled; Ghola card disposal and market/battle parents if
+supported; destination storm/occupancy and actual arrival reactions; deleted or
+altered original receipt; all four bots; private getter and observer equivalence.
+
+Only this document changed in the audit. Local links and current code symbols
+were reviewed; no tests or runtime behavior were changed.
diff --git a/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md b/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md
new file mode 100644
index 00000000..794cfe70
--- /dev/null
+++ b/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md
@@ -0,0 +1,63 @@
+# Southern Hemisphere and Tleilax revival deployment
+
+Primary-source audit, 10 September 2026. This is a source and implementation-boundary contract, not a runtime completion claim. The coordinator has already asked about threshold restoration and splitting several revived Fedaykin; those answers remain pending.
+
+## Printed effects and provenance
+
+The high faces were visually reread in the original 3024×4032 component photograph, locally `/tmp/dune-card-economy-homeworlds.jpg`, associated with [BGG image 7767034](https://boardgamegeek.com/image/7767034/dune-ecaz-and-moritani). See the [component audit](HOMEWORLD_COMPONENT_AUDIT.md) for provenance. The photographed publisher components are evidence; community explanations are not substituted for rules.
+
+| Homeworld | Native high threshold | Printed scope, paraphrased |
+| --- | --- | --- |
+| Southern Hemisphere | 3 | Newly revived starred Fedaykin may immediately join a territory already containing Fremen forces. |
+| Tleilax | 9 | On taking Free Revival, its returned forces may go from reserves, without cost, to one territory or Homeworld, observing storm and occupancy restrictions. |
+
+Southern uses plural forces and does not name Free Revival or a phase. Tleilax uses the verb **send**, not **ship**. Earlier local documents calling the latter a shipment use a classification inference; the face alone does not establish that inference.
+
+## Exact returning group
+
+Southern applies to actual starred counters revived by an otherwise supported producer: ordinary revival, Ghola, or an eligible Ambassador effect. It excludes ordinary counters, leaders, preexisting reserves, Face Dancer replacement and returning surviving forces. Preserve the physical starred identity in Basic too. Advanced permits only one Fedaykin revival per turn across producers; Basic does not acquire that Advanced limit. [GF9 E3, p.16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=16), [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2).
+
+Tleilax names the ordinary Free Revival source. A paid counter in the same request is excluded. Modifiers to that ordinary free allowance retain its source. E1 distinguishes free revival from Ghola; zero spice alone therefore cannot classify a Ghola or Ambassador return as Free Revival for deployment. This distinction composes the printed sources; no retrieved FAQ directly answers this Homeworld/Ghola interaction. [GF9 E1, p.7](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7). Keep any unresolved Ambassador quota restrictions from its [existing audit](TLEILAXU_AMBASSADOR_RULES.md).
+
+## Population and immediate timing
+
+The base return goes into reserves. E3 maintains high benefits while the minimum native population is present and inactivates them below it. Its nearby immediate-qualification language concerns occupation, not an explicit revival-trigger ordering. [Base, p.9](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=9), [E3, p.9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9).
+
+Ordinary free-rate pricing remains the pre-return quotation described in [benefit rules](HOMEWORLD_BENEFITS_RULES.md). Eligibility for a newly triggered deployment is a separate question. Depositing the complete revived group and then testing high is a plausible composition, but no inspected source settles the low-to-high boundary. Do not present it as a verified timing rule. Preserve both populations and block the divergent case pending the existing question.
+
+If the player starts high and only the newly returned subset leaves, its native population cannot fall below its pre-return count unless another intervening action changes custody. This supplies an independently usable timing subset. Do not substitute old reserves for the returned group. A pending decision must reserve its exact eligible counters and revalidate intervening custody changes.
+
+Tleilax specifies one destination. Southern does not explicitly settle whether a Basic group containing several stars can split among territories, nor whether repeated placements re-evaluate an entitlement earned by the group. The already-pending question covers that case. Do not resolve it through array iteration order or silently assert that plural wording mandates either one destination or several.
+
+Sending the complete eligible group to one legal destination works under both candidate destination interpretations, so the splitting question does not require declining that common case. Neither face expressly permits an arbitrary strict subset through an up-to quantity. A complete eligible group or decline is the strongest directly supported quantity subset; do not present arbitrary partial deployment as separately verified. For Southern the eligible group means only its newly revived stars; for Tleilax it means only the ordinary free counters, excluding any paid counters returned alongside them. Finish that disposition once instead of repeatedly offering the remainder and thereby silently allowing splitting.
+
+## Destinations and arrival effects
+
+Homeworlds are not territories, and allied Homeworld destinations are forbidden. Southern's territory permission therefore excludes Homeworlds; Tleilax expressly includes them. Neither effect authorizes ignoring other occupancy restrictions. [E3, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10).
+
+Southern's existing-force requirement is territorial, not a Great Flat distance limit. Its face supplies no express storm permission. The Fremen half-loss permission in the November FAQ is explicitly about Shipment; applying it to revival placement would be an additional interpretation. Fremen reserves are on-planet. Strongholds containing two other factions cannot be entered for any reason. [November FAQ, pp.2,5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=5).
+
+Use legal out-of-storm sectors for the independently supported Southern subset. Do not label storm placement definitely forbidden or definitely half-loss solely from that subset. Preserve ordinary alliance and protected-location rules; the word territory is not authority to bypass the Hidden Mobile Stronghold's source-specific restrictions.
+
+| Interaction | Source-grounded treatment or remaining boundary |
+| --- | --- |
+| Guild payment income | The deployment costs zero, so it generates no paid-spice receipt regardless of shipment classification. |
+| Ordinary turn usage | This immediate revival consequence does not use or reset the later ordinary shipment/movement allowance. This is timing composition, not a separate FAQ example. |
+| Heighliner | The Tech Token operates in Shipment & Movement. Ordinary Revival-phase deployment therefore does not pay it. Do not generalize this conclusion to a future out-of-phase producer without inspecting its source. [E1, p.5](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=5). |
+| BG accompaniment | Requires an off-planet shipment onto Dune. Southern's on-planet origin does not satisfy it. A Tleilax destination on another Homeworld also does not satisfy onto-Dune. Tleilax deployment onto Arrakis still needs its send/shipment classification settled. [Base, pp.17–18](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=17). |
+| BG Intrusion | Its condition names shipping or moving into a territory with fighters. Neither face explicitly classifies its arrival using those terms. Do not automatically force or suppress an Intrusion response at an affected destination. [Base, p.18](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=18). |
+| Moritani Terror | The general trigger names movement or shipment. These deployment classifications are not settled by the retrieved sources. No-Field placement's explicit FAQ treatment does not prove all placements trigger Terror. [E3, pp.5,14](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=5). |
+| Ecaz Ambassador | The printed trigger more broadly names entry into a stronghold. Actual deployment can compose with this entry condition and its ordinary faction, ally and advisor exclusions; this is not a specific revival FAQ answer. [E3, p.7](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=7). |
+| Karama | The Homeworld effect itself is immune. Independent ordinary advantages retain their own cancellation rules. Do not duplicate the existing unresolved special-Tleilaxu-Karama versus underlying revival question. [E3, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10). |
+
+Until arrival classification is settled, a legal destination with no relevant unresolved reaction permits useful independent work. At a destination where classification changes a reaction, preserve an explicit gate instead of choosing a reaction policy silently. An off-planet BG accompaniment opportunity can make classification relevant even when BG is absent from the selected territory.
+
+## Implementation handoff and existing questions
+
+Retain a server-owned event receipt containing source kind, player, turn/phase, revived physical normal/starred counts, the actual ordinary free subset, pre/post native counts, remaining eligible counters and the suspended phase continuation. Bind its immutable event fields. A deployment must consume those reserved counters exactly once, and decline must leave them in reserves. Restore cannot recreate an offer from current reserves, current Tanks or a recalculated free rate. Keep projected choices, bots and server validation on the same quote; expose no private card identity merely to identify a Ghola source.
+
+Meaningful regressions include paid/free mixed revival, old reserves excluded, Basic stars versus Advanced shared revival cap, low-to-high refusal pending its ruling, no Homeworld option for Southern, one Tleilax destination, legal storm sectors, current alliance occupancy, reaction-specific gates, unchanged phase usage, decline, JSON continuation and rejected-action immutability. Additional complete-module verification remains required.
+
+Ix's paid-Cyborg bonus still lacks authoritative quota and insufficient-Suboid rulings. Those questions were already asked; see [benefits](HOMEWORLD_BENEFITS_RULES.md) and [invasion audit](HOMEWORLD_INVASION_RULES.md). No new authority found here resolves them. Occupation lifecycle, low-Junction contributor rounding and unrevealed Grumman token removal are separate pending questions and were not repeated.
+
+Only this source document was edited. Fresh publisher-indexed extracts and the physical high faces were inspected; direct E3 PDF opening remained unavailable. No runtime behavior, saved game, reference or implementation checkpoint is certified by this audit.
diff --git a/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RUNTIME.md b/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RUNTIME.md
new file mode 100644
index 00000000..e1004d15
--- /dev/null
+++ b/docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RUNTIME.md
@@ -0,0 +1,157 @@
+# Homeworld revival deployment runtime
+
+Development checkpoint, 10 September 2026. High Southern Hemisphere and high
+Tleilax have bounded runtime support. Full Homeworld, Advanced and unfinished
+expansion starts remain gated. See the [source contract](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md)
+for printed evidence and unresolved interpretations; the [integration audit](HOMEWORLD_REVIVAL_DEPLOYMENT_INTEGRATION.md)
+is the historical implementation hazard map.
+
+Subsequent Caladan integration adds a shared Ambassador arrival validator and a
+separate stage record for newly created revival returns. New original signatures
+include a version field and require matching independent progress through
+waiting, choice, arrival and completion. Removing that record or editing an
+arrival back into a choice rejects even when older reserve forces remain
+available. Removing the version field does not preserve the original signature.
+Unversioned legacy records retain their previous signature format and explicit
+compatibility; this does not retroactively prove their stage history. No stored
+room migration is needed. The subsequent
+[Caladan checkpoint](HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md) and implementation
+status record the combined verification.
+
+## Supported return and placement
+
+Southern selects only the newly revived physical Fedaykin from a successful
+ordinary revival, Emperor-funded extra revival or Ghola force return. Basic
+preserves starred identity; Advanced retains its shared one-star-per-turn
+revival limit. Tleilax selects only the actual ordinary Free Revival portion,
+excluding paid counters returned beside it and separately sourced Ghola returns.
+Leaders, substitutions and surviving forces returned to reserves create no
+deployment permission.
+
+The supported threshold case begins high: at least three native Fremen or nine
+native Tleilaxu forces before the return. The original price remains the normal
+revival quote. A return that first restores high population is blocked before
+cost or force movement while its timing ruling is pending.
+
+The owner may place the whole eligible group in one permitted destination or
+leave it in reserves. Southern requires existing Fremen territorial presence;
+Homeworlds are excluded. Tleilax permits a territory or Homeworld, including its
+own world as a custody-preserving choice, but excludes an allied Homeworld.
+There is no second revival, fee, income trigger or ordinary shipment/movement
+allowance use. Old reserves cannot enlarge the eligible group. Partial
+deployment and splitting among destinations are not enabled.
+
+`homeworld-revival-deployment.ts` validates the source and completed typed
+deposit. `homeworld-revival-destinations.ts` supplies current destination and
+custody quotes. `homeworld-revival-return.ts` binds the original event, group,
+population observations, source/card and retained response. The engine opens an
+owned `homeworldRevivalDeployment` decision only after the original return and
+required card disposal finish. A new revival cannot replace that outstanding
+group.
+
+## Reactions and retained continuations
+
+Ordinary occupancy and sector restrictions remain active. Tleilax cannot enter
+storm. Southern storm placement and Hidden Mobile Stronghold entry remain
+explicit ruling boundaries; the mobile stronghold uses its actual pointer
+sector for storm checks.
+
+An otherwise relevant BG Intrusion or Terror reaction blocks that destination
+pending deployment classification. Terror relevance uses the exact arriving
+physical count, current low Grumman restriction and public token placement;
+it does not read a secret token face. Tleilax arrival onto Arrakis also remains
+blocked if BG has reserves and a currently permitted Spiritual Advisor. That
+gate does not apply to Homeworld destinations or Southern's on-planet origin.
+Concealed No-Field occupancy uses the public marker without its denomination.
+
+Supported Ambassador entry passes through the existing arrival pipeline with a
+bound revival event and recorded secondary-entry ancestry. The revival-income
+response stays inside the frame until placement and its Ambassador children
+finish. The original response then resumes once. Ghola's card disposal and any
+CHOAM-market parent remain separate obligations; an unfinished placement cannot
+be treated as a completed market interruption.
+
+Independent review found two saved-arrival defects: edited offer identities
+could bypass the later effect-specific checks, and deleting a live Ambassador
+could prematurely restore income. Offer validation now checks the original
+arrival, and each recorded child has a signed completion flag set by the actual
+Ambassador settlement. Missing unfinished children reject during reads, actions
+and automatic normalization. An actual phase-four Guild shipment, BG
+accompaniment and secondary Fremen relocation completes across JSON restores
+before the original Tleilax income resumes. The CHOAM-sale regression separately
+proves Ghola disposal, placement, income and original sale restoration in order.
+
+Direct cancellation of the Homeworld deployment is not offered. Independently
+cancelable revival income and underlying ordinary powers keep their own source
+rules. The existing special-Tleilaxu-Karama overlap remains a separate gate.
+
+## Persistence and presentation
+
+Waiting, choice, arrival and complete stages distinguish actual physical work
+from an outstanding decision. Independent signatures bind the original return,
+saved income response and arrival ancestry. View, action and automatic
+normalization reject inconsistent present records. Completion is evidence,
+never a command to revive, discard or pay again. No recovery reconstructs a
+missing original grant from current reserves.
+
+The projected offer exposes its public owner and eligible count; destinations
+belong to the acting seat, and the private Ghola card identity is omitted.
+`homeworld-revival-deployment-options.ts` and the dedicated component consume
+that offer. All four bot profiles use the same destination and source-specific
+threshold guards as the player controls. Group quantities cannot enlarge the
+original permission or select previously held reserves.
+
+Browser QA room `463GUCY3` completed normal revival of two counters, placed its
+single newly revived Fedaykin in Arrakeen, then played Ghola for another Fedaykin
+and left that group in reserves. Both owned choices survived refresh; actual
+keyboard submission, three saved seats and private-hand boundaries passed.
+Desktop 1440×1000 and phone 390×844 screenshots were inspected, with readable
+controls, no horizontal overflow and no page errors. Evidence is retained in
+`/tmp/dune-revival-desktop.png`, `/tmp/dune-revival-phone.png` and the isolated QA
+scripts. This does not certify complete Homeworld games.
+
+The controlled hourly restart at 14:39 UTC preserved all 3,050 saved room
+versions and state hashes. The three QA seats reconnected to version 7 with
+the same forces, Tanks, private hands and completed Ghola disposal. The backup
+and exact baseline are in `/tmp/dune-maintenance-20260909T1439/`. The removed
+recurring automation remains absent.
+
+## Verified scope and remaining gates
+
+Nine destination regressions and seven production SQLite cases pass through the
+standard focused runner. Focused lint passes for those test files. Destination
+coverage includes exact typed withdrawal, own/foreign Homeworld custody, allied
+exclusion, storm and mobile-sector checks, relevant reactions, and throwing
+private-field getters.
+
+SQLite tests run the production room module, authentication and SQL. They cover
+competing placement requests with one successful CAS, uncertain responses,
+module reload and seat refresh, Ghola decline, one-time held income, corrupted
+original/resume/completed-arrival records, unchanged unrelated rooms and private
+hand boundaries. Two additional cases restore the actual live BG Ambassador
+child, race the final Fremen relocation with one successful write, and verify
+that held income settles once. Missing children, changed ancestry/completion
+flags and premature parent completion reject without changing persisted state.
+The shared engine suites cover ordinary and Emperor-extra returns, physical
+Ghola disposal, the suspended CHOAM sale, Advanced limits, typed free subsets,
+all-profile legal choices and public/private projection.
+
+Final `npm run check` passes types, lint and all **3,634 offline cases** with no
+failures or skips (88.4 seconds for the offline suite). Production build, all
+**40 HTTP checks** and the browser acceptance above pass. Logs are
+`/tmp/dune-revival-complete-check.log`, `/tmp/dune-revival-final-build.log` and
+`/tmp/dune-revival-final-http.log`.
+
+The full suite also exposed an intermittent older auction-test assumption:
+buying a Karama creates a second eligible Emperor-income responder. The fixture
+now deliberately buys that card, verifies the continued response, and counts
+the retained current-lot reference separately from physical card custody. The
+production auction behavior was unchanged. Earlier Tleilax income tests now
+exercise a non-crossing return and explicitly stage their later high-population
+position, while verifying that unsupported crossing requests make no writes.
+
+Threshold restoration, arbitrary partial
+groups, multiple Southern destinations, storm permission, reaction/Hidden
+Mobile Stronghold classification, occupation lifecycle and the earlier Ix bonus
+questions remain explicit boundaries. Passing these subsystem tests does not
+certify complete games or release any expansion.
diff --git a/docs/HOMEWORLD_RULES.md b/docs/HOMEWORLD_RULES.md
new file mode 100644
index 00000000..ecaa7bd1
--- /dev/null
+++ b/docs/HOMEWORLD_RULES.md
@@ -0,0 +1,122 @@
+# Homeworlds: publisher rules and implementation contract
+
+Source audit, 9 September 2026. No runtime activation. The primary module source is the [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), especially printed pp.4, 9–10 and the Homeworld FAQ on pp.15–16. The local publisher-text transcription is `/tmp/dune-rules/ecaz-audit.txt`; fresh official indexed pp.9, 10 and 15 were checked against it. Direct PDF retrieval currently returns HTTP 403. Physical card faces are separately audited in [Homeworld component audit](HOMEWORLD_COMPONENT_AUDIT.md); a rulebook statement and a card-face statement are not interchangeable evidence.
+
+## Module scope and component inventory
+
+There are **13 double-sided Homeworld cards and 13 corresponding planet discs for twelve factions**, not twelve of each. Emperor uses Kaitain and, in Advanced, Salusa Secundus. Only the seated factions' components enter the game. E3 permits the module independently of the selected factions and independently of Nexus cards, Discovery tokens and new Treachery cards. It does not require either Ecaz or Moritani to be seated, or all expansion factions to be present. [E3 pp.4, 9–10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9)
+
+Both Basic and Advanced are supported by the printed module. In particular, the FAQ explicitly permits the Fremen Homeworld in Basic, using its physical starred tokens without granting Advanced double battle strength. The module does not grant extra player seats: retain the underlying two-to-six-player game and its low-player-count rules. Twelve selectable factions does not mean twelve simultaneous players. [Base setup, p.6](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=6), [designer expansion page](https://futurepastimes.com/dune-ecaz-moritani)
+
+### Verified high faces
+
+The photographed physical components in [BGG image 7767034](https://boardgamegeek.com/image/7767034) were inspected directly, independently of the rulebook prose (`/tmp/dune-rules/homeworld-bgg-high.jpg`). These are primary component artifacts hosted by a community site, not a publisher errata page. Full image provenance belongs to the component audit. The bonus column is both native battle addition and the printed native Lasgun/Shield casualty count.
+
+| Faction / Homeworld | High population | Bonus | Verified high effect and real integration point |
+| ---------------------------- | -------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Atreides / Caladan | 6–20 reserves | 2 | Optional one-force reserve reinforcement after winning a battle, to that battle's territory or Homeworld, provided an own force survives there. Post-battle transfer, not an extra ordinary shipment. |
+| Bene Gesserit / Wallach IX | 11–20 reserves | 3 | May send two spiritual advisors instead of one when accompanying another faction's shipment **to Polar Sink**. This does not allow advisors on Homeworlds. |
+| Emperor / Kaitain | 5–20 reserves | 2 | At Bidding end, may pay two spice per discarded hand Treachery card. Explicit optional paid discard window. |
+| Emperor / Salusa Secundus | 2–5 **Sardaukar** reserves | 3 | Sardaukar need no spice support for full battle strength. Count the named type, not normal forces transferred to Salusa. |
+| Fremen / Southern Hemisphere | 3–20 reserves | 2 | Revived starred forces may be placed directly in a territory already containing own forces. Preserve actual starred identity in Basic too. |
+| Harkonnen / Giedi Prime | 7–20 reserves | 2 | Two bank spice when qualifying desert or Homeworld spice is collected that Collection phase. FAQ caps this at one two-spice award per phase. |
+| Guild / Junction | 5–20 reserves | 2 | May offer other factions cross-shipping at half or full price during their shipping action, including to/from Homeworlds and to their own. Sponsor consent, payment and source pools must be explicit. |
+| Ixians / Ix | 5–20 reserves | 2 | Paying to revive a Cyborg also grants two free Suboids. This is a typed revival modifier, not two manufactured counters. |
+| Tleilaxu / Tleilax | 9–20 reserves | 2 | Free-revived forces may immediately ship free to one territory or Homeworld, subject to storm and occupancy. The revived group must remain identifiable through its owned destination choice. |
+| CHOAM / Tupile | 11–20 reserves | 2 | Cannot discard Worthless for spice except through Advanced Karama. This is not a prohibition on every Worthless special effect. |
+| Richese / Richese | 10–20 reserves | 2 | May ship physical reserve forces together with a No-Field. This is an explicit mixed-shipment modifier and needs concealed/physical custody handling. |
+| Ecaz / Ecaz | 7–20 reserves | 2 | Three bank spice per discarded poison weapon; also an alliance victory combining at least one jointly occupied stronghold with occupation of two other factions' Homeworlds. |
+| Moritani / Grumman | 8–20 reserves | 2 | During Collection, may add Terror to a stronghold already containing Terror or remove a board Terror token, then gain four bank spice. This is a Collection decision, not an unrestricted placement change. |
+
+**Caladan numeric precedence:** E3 p.10 directs the native battle addition to the value on the Homeworld card, then gives a parenthetical Caladan example using three. The inspected card prints two for both relevant numbers. Use the card's two because the operative rule explicitly delegates that value to the component; annotate the inconsistent example. This is not an invented balance ruling or a reason to block every Homeworld pending clarification. No verified subsequent erratum overriding the face was retrieved.
+
+### Verified low and occupied faces
+
+The reverse galleries were also inspected directly (`/tmp/dune-rules/homeworld-reddit-face1.png` and `homeworld-reddit-face3.png`; from [Every Homeworld cards](https://www.reddit.com/r/DuneBoardGame/comments/16w27x0/every_homeworld_cards/), 30 September 2023; detailed provenance is retained in the component audit). These are photographs of the actual components, not rulings by the uploader. Except Salusa, every reverse prints the global +1 free revival and +1 bank charity. The bank column below counts the gold spice symbols in the Occupied panel: E3 p.10 identifies that panel's indicated income as bank-paid during Collection, even in Basic. It is additional to the panel's separately described conditional payments.
+
+| Homeworld | Low population / native battle bonus | Low effect | Occupied effect / occupier bank income |
+| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| Caladan | 0–5 / 2 | No movement-spent peek at the next Spice Blow. | Occupier shares Atreides' Bidding advantage. **2 bank.** |
+| Wallach IX | 0–10 / 2 | No spiritual-advisor shipment. | Occupier and its ally are immune to Voice. **1 bank.** |
+| Kaitain | 0–4 / 3 | Emperor receives only the rounded-up half of Treachery payments. | Occupier receives the rounded-down remainder, as clarified by the five-spice FAQ example. **2 bank.** |
+| Salusa Secundus | Printed 0–2 / 2 | No free Sardaukar revival. Neither generic low bonus is printed/granted. | Sardaukar lose their Sardaukar advantage. **0 bank.** |
+| Southern Hemisphere | 0–2 / 2 | No additional penalty. | Occupier receives the rounded-down half of all spice the native Fremen collect during Collection. **2 bank.** |
+| Giedi Prime | 0–6 / 2 | No additional penalty. | When Harkonnen buys a Treachery card, occupier or its ally gains the extra free card if its receiving hand has room. **2 bank.** |
+| Junction | 0–4 / 2 | Guild receives only the rounded-up half of shipping payments. | Occupier receives the rounded-down half of other players' shipping payments. **2 bank.** |
+| Ix | 0–4 / 2 | Cannot move the Hidden Mobile Stronghold. | Occupier takes control of Ixian Bidding, rather than merely observing a copy of the private selection. **2 bank.** |
+| Tleilax | 0–8 / 2 | Starting Revival low prevents income when another faction takes free revival. This condition is phase-start-specific. | No Face Dancer reveal against occupier or its ally. **2 bank.** |
+| Tupile | 0–10 / 2 | Once per faction, CHOAM can force disclosure of spice and either weapon or defense count when it is on that faction's Homeworld or that faction is on Tupile. | Occupier and ally gain one hand slot; discard to normal limit when no longer occupying. CHOAM loses its low advantage. **2 bank.** |
+| Richese | 0–9 / 2 | Cannot move a No-Field. | Occupier selects the Richese Treachery card sold during bidding and receives the rounded-down half of the payment Richese receives. **1 bank.** |
+| Ecaz | 0–6 / 2 | With Ecaz and ally both in a battle, their opponent selects the participating allied faction. | Occupier or its ally gains Duke, including permission to revive him, overriding the two native Duke advantages. **2 bank.** |
+| Grumman | 0–7 / 2 | Revealing Terror requires entry by at least three forces. | Terror cannot be revealed against occupier or its ally entering its location. **2 bank.** |
+
+**Salusa endpoint discrepancy:** high prints 2–5 Sardaukar while low prints 0–2. E3's operative rule keeps a card high while its minimum is maintained and flips it only below that minimum. That supports high at two and low below two; annotate the conflicting reverse endpoint instead of inventing a player-chosen side or hysteresis. The population domain remains Sardaukar regardless of regular troops transferred there.
+
+These faces require actual rule integrations, not a generic faction-disabled flag. Tleilax's phase-start condition, Tupile's once-per-faction private question and hand-limit cleanup, Grumman's reveal immunity, split income and displaced Bidding/bonus ownership have different lifecycles. Occupied Ecaz is an explicit additional Duke authority and revival permission; it does not by itself answer every existing loan-expiry, revival-custody or death-cycle question.
+
+## Native custody and population
+
+The planet disc holds that faction's native reserve forces. A Homeworld is a distinct off-planet location, not an Arrakis territory, additional stronghold, private hand zone or a new supply of pieces. Invaders retain their own faction and physical force types; native reserves and foreign garrisons must be distinguishable. Total faction inventory continues to include forces on Arrakis, on every Homeworld, and in the Tanks.
+
+Start with the illustrated high-threshold face. Each card supplies its own population boundary. While native reserves meet that boundary, apply its high effect; falling below it switches to the low effect. Recompute after actual custody changes, not merely at phase boundaries. Restoring sufficient population restores the corresponding high condition. The rulebook names reserves as the population measure; an invader cannot inflate the native population or count as an extra native reserve. These are direct compositions of the continuously conditional threshold rule. [E3 p.9](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9)
+
+Low population adds one to the native faction's free-revival rate and adds one bank-paid spice when that faction collects CHOAM Charity. The extra charity is not charged to CHOAM. Salusa supplies neither bonus. Tupile reverses the usual desirable-high/undesirable-low card effects; do not implement that reversal by globally exchanging all high/low mechanics. Recruits doubles the **current** free-revival rate, subject to its printed limit: the FAQ's low-population Fremen example is four doubled but capped at seven. This does not settle unrelated existing Recruits/unlimited-revival questions. [E3 pp.9–10, 16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
+
+Advanced Emperor starts with Sardaukar on Salusa and normal reserves on Kaitain. Revival returns these types to their respective native locations. During Shipment and Movement, Emperor can spend its movement transferring forces between the two. One outbound shipment can combine forces from both Homeworlds. The initial/revival placement restriction is not a permanent ban on the other type arriving through that expressly permitted transfer. Salusa's printed threshold nevertheless counts Sardaukar, so transferred normal defenders do not substitute for them. Basic uses only Kaitain and treats Sardaukar as ordinary battle forces. [E3 p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
+
+## Transport and arrival
+
+| Route or effect | Source contract |
+| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Native reserves to Arrakis | Existing faction shipment rules apply unless the actual Homeworld face modifies them. Keep free Fremen placement, Guild rates and ordinary restrictions distinct. |
+| Homeworld to another Homeworld | Permitted; printed price is one spice per physical force, half price for Guild. Check owner/ally restrictions and the particular card's transport advantage. |
+| Arrakis to a Homeworld | Guild cross-shipment only, except a permission actually conferred by the Guild Homeworld face. Do not infer permission from ordinary allied discounts. |
+| Adding forces to one's own Homeworld | Revival is the general route; Guild/cross-shipping exceptions are explicit. No generic free board-to-reserve return follows merely from introducing the module. Specific existing card returns require their own composition. |
+| Emperor inter-Homeworld transfer | Uses movement, not another ordinary shipment. Preserve typed quantities and the combined-shipment exception. |
+| Allied Homeworld | Shipping there and occupying it are forbidden, including for the Ecaz pair. |
+| BG | No spiritual advisors may be sent to Homeworlds. BG fighters on a foreign Homeworld cannot flip to advisors. |
+
+Homeworld shipment triggers the Heighliner Tech Token, explicitly confirmed by the E3 FAQ. Price payment, ordinary shipment allowance and token ownership remain separate effects; an incoming shipment is not an Arrakis movement and has no sector, storm path, ornithopter access or sandworm route. No Ambassador or Terror placement follows: Homeworlds are not territories, and the FAQ expressly forbids Terror there. [E3 pp.10, 15–16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=15)
+
+The new route must preserve the actual source location. Do not let a foreign garrison become an indistinguishable part of the owner's native `reserves`, or silently ship pieces out of an occupied native location twice. No-Field transport, concealed population and reveal causes need explicit integration with their physical marker rules; the module text alone does not establish those combinations.
+
+## Occupier lifecycle and benefits
+
+The rulebook defines an **Occupier** by events: foreign forces present at the start or end of a turn qualify; a faction alone on the foreign Homeworld at any instant qualifies immediately for that turn. Thus a simple current sole-controller lookup is insufficient. Save which faction qualified, on which Homeworld and turn, separately from the present native/foreign armies. Native low penalties remain in force while occupied. [E3 pp.9–10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9)
+
+An Occupied face can grant an ability and/or bank income during Spice Collection, including in Basic. The occupier may immediately share that income with its ally; this is a specific immediate-sharing permission, not ordinary deferred bribery or Ecaz desert-allocation negotiation. The face determines the amount and any event-specific restrictions. Do not mint the income when occupation first occurs or again when refreshing a saved state.
+
+Kaitain's FAQ supplies a concrete payment split: a five-spice Treachery payment yields three to Emperor and two to the occupier, even when that occupier bought the card. The payment is not charged again. Giedi Prime's high benefit is a total two-spice award per Collection phase if either listed reason occurs, not two per individual collection. These FAQ facts constrain the corresponding card implementations but are not substitutes for the full faces. [E3 p.15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=15)
+
+Do not form an alliance if either prospective partner occupies the other's Homeworld. Apply this to Nexus, Ecaz Ambassador consent and Moritani alliance formation, not only to ordinary alliance buttons. An alliance cannot legalize an already forbidden allied garrison. This restriction uses actual force occupation; do not substitute a private card condition or an unrelated remembered payment benefit. [E3 p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
+
+## Combat and victory
+
+Homeworld invasion attacks the actual native reserve army, with normal battle sequencing unless a face says otherwise. Add the native Homeworld's current printed battle-strength bonus to its dialed result. Do not transfer that native bonus to an invader. The module gives no permission to merge Ecaz/ally armies there. It also does not define a two-faction stronghold capacity for these non-territory locations. Preserve the ordinary combat rules rather than silently applying every Arrakis stronghold predicate. [E3 p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
+
+Only the native faction may call Traitors or Face Dancers in a Homeworld battle. This restriction is on the caller, so an otherwise valid remote Harkonnen ally is not exempt merely because the beneficiary is defending its native Homeworld. For Lasgun/Shield explosion, native casualties are limited to the printed Homeworld battle strength, regardless of dial; surviving natives stay there. Both sides still lose and leaders die normally. The ordinary explosion removes the invading army. This named exception is not evidence for changing Stone Burner or other destruction effects. Physical type allocation, revived arrivals and threshold changes must be evaluated against the actual battle snapshot, without replaying outcomes on restore.
+
+**There is no blanket Homeworld stronghold or victory point in the global rules.** Any occupied card face that explicitly counts as a stronghold must be represented as its own source-specific victory contribution. It must not become an Arrakis territory, a six-card Stronghold Card control site, a location for Ecaz joint-three occupation, or a location satisfying Moritani's battle-in-strongholds Duke condition by accidental shared IDs. Actual card wording must decide its limited contribution and any occupied-native victory restriction. Ordinary and special wins, alliances and BG prediction retain their existing priority unless a verified face expressly changes them.
+
+Ecaz's verified high face supplies a separate actual win predicate: its alliance jointly occupies at least one stronghold and occupies Homeworlds belonging to two **other factions**. Two Emperor Homeworlds do not supply two different native factions. Implement this as a Homeworld-card condition while Ecaz's high advantage applies; do not add all foreign Homeworlds to ordinary stronghold progress. The sentence's alliance subject supports combining the two members' qualifying foreign occupations, while its explicit co-occupation requirement applies to the stronghold. Exact interaction with a transient turn-bound Occupier receipt must use the finalized occupation semantics rather than silently replacing actual occupation with an income entitlement.
+
+## Karama and other optional systems
+
+Karama cannot cancel Homeworld advantages **or** penalties. This includes a BG Worthless card converted into Karama. Do not generate response windows for threshold flips, Homeworld strength or occupied-face awards. However, this immunity is not a blanket ban on playing Karama during a Homeworld shipment/battle: an independently cancelable ordinary faction advantage still has its own source and timing. Distinguish printed Homeworld transport permission from a faction ability and from the generic paid card effect. [E3 p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
+
+Homeworlds do not enable the other optional modules. Leader Skills, Stronghold Cards, Discovery sites and Nexus cards retain their existing activation boundaries. E3 Harass & Withdraw specifically excludes one's own Homeworld; foreign Homeworld use follows the card's actual return rule. New off-planet locations must be audited at every effect currently phrased as territory, Arrakis, reserves, own Homeworld or any forces; these are different predicates.
+
+## Evidence gaps and implementation sequence
+
+The component audit retains provenance for the thirteen names, thresholds, battle bonuses and high/low/occupied face effects summarized above. All thirteen front and reverse faces have now been visually inspected; there is no remaining absent-face placeholder in this contract. The [designer's expansion page](https://futurepastimes.com/dune-ecaz-moritani) embeds [Homeworlds Revealed](https://www.youtube.com/watch?v=4KZKz13wf9c); its presence is verified, but no spoken ruling is attributed here without an inspected passage. Community-hosted component photographs establish their visible printed text, not any additional explanation by the uploader.
+
+Global text leaves a material occupation-lifecycle edge: it awards status for a turn but does not fully specify simultaneous qualifying foreigners, expiry after leaving, or replacement if multiple factions qualify successively. Native repopulation may also restore a high threshold while a prior invader has a turn-qualified entitlement. The state model should retain population, actual presence and event evidence separately rather than pick an arbitrary exclusive controller or three-state enum; resolve an actual conflicting benefit case against the card face and any verified designer clarification. Tupile's explicit loss-of-occupation discard is a concrete expiry obligation even though not every other reward names the same endpoint. Other potential interactions—No-Field hidden population, card returns to an occupied native location, and threshold-sensitive mid-revival entitlements—require targeted source composition before those paths are enabled. Missing implementation is not itself an ambiguous rule; neither is a missing scan evidence for a house rule.
+
+Recommended full-module implementation order:
+
+1. Install verified immutable face data and an explicit module setting; create native Homeworld locations during real setup with conserved typed forces, including Basic Fremen and Advanced Emperor's second disc.
+2. Introduce a shared location/custody model, dynamic population quote and event-bound occupation state. Make UI, bots, battle eligibility, reserve selection and persistence use those same public or owner-private descriptors.
+3. Implement actual shipment, Emperor transfer and revival routes, including payment/income/Heighliner transitions and alliance restrictions. Every action must reject atomically and survive JSON/CAS continuation.
+4. Extend actual combat and aftermath to off-planet locations, native strength, Traitor/Face Dancer restrictions and explosion casualties; preserve physical force and leader custody.
+5. Integrate every verified face into its real phase/card/faction effect, bank/occupier income and source-specific victory contribution. Add occupation turnover, threshold-crossing and canceled ordinary-advantage tests.
+6. Exercise all twelve faction profiles in complete games with this module, plus real multiplayer recovery and UI inspection. Until those contracts and material source cases are resolved, retaining the module gate is accurate; a pure population helper alone does not constitute Homeworld support.
diff --git a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md
new file mode 100644
index 00000000..dde822af
--- /dev/null
+++ b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md
@@ -0,0 +1,129 @@
+# Homeworld setup and native reserve runtime
+
+9 September 2026. This checkpoint connects the physical Homeworld model to
+the authoritative game, player table and AI. **Public Homeworld starts remain
+disabled.** It does not certify Homeworld battles, occupation, card effects or
+complete Advanced/expansion games. The earlier component checkpoint remains
+in [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md).
+
+## Implemented behavior
+
+`Game.homeworlds` is optional for existing saves. An enabled fresh lobby stores
+`{custody:null}` until the genuine setup pipeline places forces, after prediction
+and traitor choices. Setup then records typed native reserves and Advanced
+Emperor's separate Salusa allocation. The offline-only initializer does not
+replace the public start gates or dispatch through the room API. It supports
+the currently implemented base/Ix deck sets; CHOAM/Richese and Ecaz/Moritani
+deck construction still rejects instead of substituting a different deck.
+
+Native primary counts derive from the player's aggregate reserves; Salusa is
+an allocation within those totals. Foreign garrisons remain separate physical
+pools. Reads, actions and automatic normalization validate custody and all
+twenty physical counters, including special-counter identities across board,
+Tanks, native worlds and foreign worlds. Missing Salusa allocation is rejected,
+not reconstructed. Ixians have seven Cyborgs total, initially four in reserves
+and three in the mobile stronghold. The earlier custody limit of four is fixed.
+
+Basic Homeworld setup retains Emperor's five Sardaukar and Fremen's three
+starred counters. They count as strength one in ordinary Basic combat and do
+not activate Advanced faction responses. Basic winner casualties preserve the
+owner's physical normal/special selection. Basic revival and Ghola preserve
+identity without imposing the Advanced one-special-counter-per-turn limit.
+Ixian special-counter rules retain their separate behavior.
+
+The Advanced Emperor's `emperorHomeworldMove` action transfers an explicit
+normal/Sardaukar group between Kaitain and Salusa. The server checks current
+owner, phase, movement budget, pending controls and a public-state event. It
+spends one movement, charges no spice and conserves aggregate reserves.
+Starting with movement passes an unused shipment. A real Hajr supplies the
+second movement; active Ornithopter composition remains explicitly blocked.
+Homeworld permission is independent of ordinary faction Karama cancellation.
+
+Ordinary native-to-Arrakis shipments now withdraw exact typed Homeworld sources.
+An omitted selection is inferred only when the allocation is unique; ambiguous
+partial draws from both Imperial worlds require explicit choices. The same
+transaction places the matching Arrakis counters once. The chronicle records
+the Homeworld sources and cost. No-Fields retain their separate source contract.
+Normal force revival, Emperor special-Karama force revival and Ghola deposit
+Advanced Emperor's normal forces on Kaitain and Sardaukar on Salusa, even when
+those counters previously moved between worlds. Other factions return to their
+one native reserve pool. Completed Ghola discard receipts bind the full
+Homeworld custody as well as aggregate counters.
+
+The public table displays each native world's physical counts and population,
+with the full local-art card inspector. Emperor controls select a movement's
+origin and force types and the Salusa contribution to a combined shipment.
+Kaitain's complementary contribution is displayed and submitted explicitly.
+Invalid source counts disable submission; stale choices are revalidated by the
+server. Shared public helpers give all four AI levels legal typed shipment
+sources and a bounded transfer policy to restore Imperial population. They
+do not read opponent hands, decks, predictions or spice.
+
+The server's shipment-completion search uses the same source helper. Its
+mixed-Imperial proof is tested as an internal future integration seam: actual
+Advanced Truthtrance shipment questions still remain Basic-gated.
+
+## Verification scope
+
+- Genuine setup: sixteen six-seat setups span the eight supported factions,
+ Basic/Advanced and all four profiles; another eight mixed-Ix setups cover
+ two through five seats. Unsupported deck combinations reject unchanged.
+ Prediction/traitor privacy, physical card uniqueness and JSON recovery pass.
+- Actual actions: transfer, Hajr, wrong-owner/stale/blocked rejections, mixed
+ native shipment and a Sardaukar's shipment → Storm death → revival on Salusa.
+ Ghola's saved completed-effect frame drains once; changing only its Salusa
+ allocation rejects despite unchanged aggregate counters.
+- Basic counters: both Emperor and Fremen retain strength-one stars, legal
+ casualty choices, repeated typed revival and Ghola. Ordinary Basic behavior
+ without the module remains unchanged.
+- AI: every offered movement/shipment candidate is individually accepted by
+ the real dispatcher across all four levels. Explicit sources, unavoidable
+ elite draws, hidden-information independence, Basic exclusions and no
+ oscillating population repair are verified.
+- In-memory production SQL: duplicate and competing transfer requests commit
+ one version; fresh module instances and authentication restore the same
+ physical state/private hand. Corrupt missing Salusa state rejects without
+ writes. Unrelated rooms remain unchanged.
+
+`npm run check` passes typecheck, lint and **3,152 offline tests**. The final
+production build passes; `npm test -- homeworld reference` passes **106**
+focused cases. Logs: `/tmp/dune-homeworld-runtime-check-final.log`,
+`/tmp/dune-homeworld-runtime-build-final.log`,
+`/tmp/dune-homeworld-runtime-focused-final.log`, and
+`/tmp/dune-homeworld-actions-engine-final.log`.
+
+The live browser used a new named QA room, `VQ6ALGRE`. Genuine setup preceded
+a documented conserved dual-source phase position. UI actions shipped three
+forces (one normal from Kaitain; one normal and one Sardaukar from Salusa),
+then moved three normal forces from Kaitain to Salusa. The final position has
+Kaitain 7 normal + 2 Sardaukar; Salusa 6 normal + 2 Sardaukar; Arrakeen three
+forces including one Sardaukar; seventeen reserves and seven spice. Refresh
+preserved it. Invalid source input disabled shipment, enlarged Kaitain opened
+from the table, and 390×844 inspection found no horizontal overflow in the
+world panels or inspector. Desktop card and phone movement screenshots were
+visually reviewed; this is bounded sampling, not full module acceptance.
+
+Forty live HTTP tests pass. Twenty ordinary **two-player Basic** games finish
+3,079 accepted actions with no rejected candidates or stalls, using seed
+20261026 and unchanged source fingerprints. This small regression sample is
+not new strength calibration or Homeworld/full-player-count verification.
+All 2,617 pre-existing room versions and hashes remain unchanged. The separate
+QA room and thirty HTTP-test rooms were added. The development server remains
+running from the 07:53 UTC controlled restart; no hourly restart was yet due
+during this checkpoint. The removed automation stays removed.
+
+## Remaining integration
+
+Homeworld population values currently describe physical state; they do not yet
+apply high/low advantages, charity/free-revival bonuses or occupation penalties.
+Foreign Homeworld shipment permissions, Guild sponsorship, alliance rules,
+occupation qualification/turnover, Homeworld combat and aftermath, explosions,
+all card effects, occupation Collection, Ecaz victory and full AI policies
+remain unfinished. Ordinary special arrival/return routes still need the
+reserve integration audit, including Ambassador, Face Dance, No-Field and
+card-specific destinations. Emperor transfer with Ornithopter is also pending.
+
+Occupation history must remain distinct from population and current foreign
+forces. Some turnover/expiry interpretations still need source resolution.
+Do not infer a complete mode from a valid physical state or successful native
+transfer. No release gate, saved-game migration or hosting access was changed.
diff --git a/docs/HOMEWORLD_RUNTIME_READINESS.md b/docs/HOMEWORLD_RUNTIME_READINESS.md
new file mode 100644
index 00000000..c608ebaa
--- /dev/null
+++ b/docs/HOMEWORLD_RUNTIME_READINESS.md
@@ -0,0 +1,77 @@
+# Homeworld runtime readiness
+
+This is the pre-integration architecture audit. For the later implemented
+boundary and remaining work, see
+[HOMEWORLD_RUNTIME_IMPLEMENTATION.md](HOMEWORLD_RUNTIME_IMPLEMENTATION.md).
+
+Read-only architecture audit, 9 September 2026. No Homeworld rules are enabled by this document. The current public start still rejects Advanced and expansion configurations; there is no Homeworld state, option, card registry, or action handler. Source verification of all thirteen cards and faction interactions is a separate workstream. This is an implementation plan, not evidence of completed gameplay.
+
+## Source boundary
+
+The independent source review has confirmed these architectural requirements from Ecaz & Moritani rulebook pages 9–10: native reserves occupy Homeworlds; cards start on their high side and react to their printed reserve thresholds; occupation has turn-start/end and immediate sole-alien-occupation conditions; occupied natives retain their low penalty. Homeworlds are not territories. Ecaz Occupy does not merge factions there, BG cannot use advisor stance there, and Homeworld advantages/penalties are immune to Karama. Emperor has distinct Kaitain regular-force and Salusa Sardaukar custody in Advanced, with a movement transfer and combined shipment selection. Native low effects include additional free revival and bank charity, with printed exceptions.
+
+These are constraints for the interfaces below. Exact card thresholds, high/low effects, invasion rewards and victory effects must come from the source catalog before implementation. In particular, do not infer that every Homeworld is a stronghold, yields ordinary stronghold income, or contributes to Ecaz's three jointly occupied Arrakis strongholds. The source reviewer is checking those distinctions. Transport permission is directional: the reviewed general text permits Homeworld-to-Homeworld shipment, with the Guild's special Arrakis-to-Homeworld access; it does not create ground movement edges from Arrakis. Preserve the exact final source interpretation for exceptions.
+
+## Existing seams and assumptions
+
+| Surface | Current implementation | Required integration |
+| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Configuration/setup | `engine.ts` `Game` around 1160, `createGame` around 2093, `start`/`initializeSetup` around 3403, `initializeStartingForces` around 3457 | Add an explicit optional module configuration/state and prerequisite validation. Initialize Homeworld custody at the actual force-placement step, without exposing cards before prediction/traitor stages or silently activating other modules. |
+| Physical custody | `Player` around 385 has scalar `reserves`, `tanks`, board `forces`, and parallel elite zones | Homeworld native and invading groups need one authoritative physical inventory. Emperor's two native pools cannot be represented by one unqualified source. Do not count native reserves twice as both reserves and board forces. |
+| Location registry | `board.ts` `territory`, `validLocation`, `gameTerritories`, `splitLocation`, `gameDistance` recognize printed Arrakis and HMS only | Introduce an explicit Homeworld location kind and validation/display functions. Keep Arrakis path geometry and storm sectors separate. |
+| Ordinary transport | `validatePhysicalShipment` around 12902, `offerShipment`, `commitShipment` around 13030; `guildShip` action around 16700 | Quote exact source custody, typed quantities, destination kind, rate, contributor split and income. Preserve the declaration-before-payment Guild window and normal combined-turn ownership. |
+| Entry/movement | `allowedEntry` around 5822, `forceGroup`, `completeMove`, `finishMovementTurn` around 6417; `occupancy.ts` | A Homeworld entry branch must enforce its own alliance/occupancy rules. Current Ecaz shared occupation, storm, territory capacity and end-movement allied separation are explicitly planet rules. Emperor's internal transfer needs its own quoted movement action. |
+| Battle discovery | `board-resolution-quote.ts` `quoteBattleBoard`; engine `battles` around 8460 and phase 6 initialization | Add Homeworld conflicts to the battle frontier without running `gameDistance` or storm connectivity. Source-approved attacker selection/order must be explicit. |
+| Plans/results | `combatForces` around 3347, `battle-resolution-quote.ts`, `resolveBattle` around 10995, `takeBattleLosses`/`kill` | Reuse sealed plans, cards, leaders, traitors, support and casualty arithmetic through a location-aware adapter. Casualties on a native Homeworld must debit its reserve pool; invader losses debit its invading pool. |
+| Battle aftermath | `battle-aftermath-quote.ts`, cleanup context, Face Dancers, captures, tech transfer, Auditor and discard continuations | Carry the same typed battle location into every receipt/validator. Homeworld control/rewards must settle at the correct point once, including traitor, explosion, replacement and retreat outcomes. |
+| Revival/card returns | `revival.ts`, `revival-resume.ts`, `revival-cancellation.ts`; `finishRevival` around 11658 and `applyGholaEffect` around 10081 | Resolve dynamic free allowances and actual return pools through one custody/capability quote. This includes Emperor extras, foreign gholas where relevant, Ghola force returns and existing prepared revival receipts. |
+| Economy/capabilities | `charity.ts`; faction-only `handLimit(p)` around 2055; movement/support helpers; faction-specific checks throughout engine | Derive current Homeworld modifiers centrally and consume them at actual eligibility, payment, view and bot call sites. Keep bank-paid Homeworld charity separate from CHOAM payment receipts. |
+| Collection/victory | `quoteSpiceCollection`, `quoteBattlePhaseAdvance`, `victory-progress.ts`, `victory-quote.ts`, `stronghold-cards.ts` | Keep Arrakis spice extraction/Stronghold Cards separate. Compose only source-defined Homeworld income/victory effects, using a shared public progress quote; BG prediction remains private inside final victory. |
+| Player view/UI | `viewGame` around 17395; `game-table.tsx` source/destination controls around 350–700, forces around 880, map/details around 1129–1290 | Add inspectable Homeworld cards/discs and location-specific owned actions. Numerous direct `territory(id).name` calls need a location label adapter for Homeworld battle/action messages. |
+| AI | `bots.ts` `destinations` around 176, `plans` around 520, revival around 2600, shipment around 2786; `bot-mobility.ts`, `transport-quote.ts` | Consume public Homeworld status and owner-only legal action descriptors. Current map destination lists and scalar reserve policies cannot choose or defend Homeworlds. |
+| Recovery | `applyAction`, `normalizeAutomaticGame`, `viewGame`; `db/rooms.ts` `needsAutomaticRoomRecovery` around 401 | Bind pending invasion/control/reward choices to the current module, location and event. Persist automatic threshold/control transitions through the existing CAS path. No read-side resource effects. |
+
+## Recommended state and shared interfaces
+
+Keep `gameTerritories(g)` as the Arrakis/HMS enumeration. Introduce `GameLocation = {kind:'territory', id, sector} | {kind:'homeworld', id}` at new transport, battle and display boundaries. Existing saved `territory` strings can be decoded as the first variant; new Homeworld actions should be explicit, rather than making an invented `:0` key valid in every old consumer. A separate `combatLocations` frontier can combine both types. The existing `ecaz-occupy.ts` already has a `kind:'homeworld'` identity case which deliberately does not merge allies; `occupancy.ts` currently wraps only territory semantics and cannot itself serve as the Homeworld entry rule.
+
+The coordinator's proposed custody model retains `Player.reserves` as the authoritative total native Homeworld forces and `Player.elites.reserves` as its elite subset. Homeworld state stores only foreign occupants and Emperor's secondary Salusa allocation `{normal, elite}`; Kaitain is derived by subtraction. Both unit types can be transferred between Emperor's Homeworlds, so the secondary allocation must not be an elite-only number. Do not duplicate native tokens in `Player.forces`. Keep occupation history where rules require a turn receipt, separate from derived threshold/capability values.
+
+Every custody quote must validate `secondary.normal <= reserves - eliteReserves` and `secondary.elite <= eliteReserves`, with safe whole-number nonnegative counts. Withdrawals debit explicit per-home typed quantities; never silently clamp Salusa when a generic reserve decrement makes it inconsistent. Mixed shipment sums those debits once. Revival credits its prescribed native pool; battle losses debit the actual home under attack rather than any convenient global reserves. Foreign visitor return debits a foreign pool and credits the appropriate native pool without counting visitors as the host's reserves. Return the before/after home counts for threshold/control processing, but keep the custody helper itself free of reward effects, RNG and occupation-rule invention. Every old direct reserve mutation must be audited when wiring this model.
+
+Suggested focused interfaces, with names illustrative rather than prescribed:
+
+- `homeworldStatus(publicState)` returns native/occupier identity, current threshold side, public force/elite totals, and source-defined public effects. Separate current physical presence from the persisted occupation entitlement for the turn.
+- `reservePools(state, player)` and `quoteForceTransfer(state, intent)` define exact normal/elite debits and credits, source location, destination and conservation. Ordinary planet shipment, Guild return, revival, Ghola, No-Field revelation and Emperor transfer must all use this boundary when the module is active.
+- `homeworldCapabilities(state, player)` returns specific modifiers and eligibility values consumed by existing rule helpers. Avoid a blanket `factionDisabled` flag: low, high, occupation and Karama have distinct scopes, and printed Homeworld effects are not cancellable faction response windows.
+- `quoteHomeworldShipment(state, intent)` returns validated custody/payment/arrival facts and the compulsory successor. Existing shipment prices/contributor bounds and physical elite min/max are reusable components; territory type pricing is not the complete Homeworld rule.
+- `quoteCombatLocation(state, location)` resolves participants and typed force inventories. Feed its result into existing plan/result arithmetic. Return casualties through location-aware transfer receipts, then `quoteHomeworldAftermath` determines control/benefit changes without rerunning the battle.
+- `homeworldActionOptions(view)` contains only public legal locations and the acting seat's entitled quantities/choices. Share these descriptors between bots and controls; keep hidden cards, traitors, predictions and concealed marker values out of the common status object.
+
+Do not derive an automatic public opportunity from a hidden modifier or card holder. Threshold flips, loss of high advantage, unconditional gains and occupation updates are automatic transitions with log/animation receipts, not new confirmation decisions. A genuine source-required allocation, target, transfer or reward choice gets an owned event-bound decision. Automatic follow-on work can pause behind existing responses, Box, Truthtrance, or discard frames without replaying an already committed payment or casualty.
+
+## Consumers that must remain location-specific
+
+1. **Arrakis-only geometry and environment:** storm sectors, worms, sandtrout, spice blows, Harvester, Hajr/Ornithopter routes, Fremen reinforcement radius, HMS pointer adjacency, and desert collection. Keep their enumerations planet-only unless a particular printed rule explicitly extends them.
+2. **Planet strongholds:** occupation capacity, ornithopter access from Arrakeen/Carthag, stronghold income/cards and copy effects, Ecaz shared desert/bank collection, Ecaz joint-three victory, Moritani Duke qualification, Ambassador placement/entry and Terror placement/entry. A Homeworld may have a source-defined victory effect without inheriting this entire bundle.
+3. **Alliances:** formation by Nexus, Moritani Enemy of My Enemy, and the newly implemented Ecaz consent branch must all enforce occupation-based alliance restrictions through shared validation. `finishMovementTurn` currently grants Ecaz permanent cooccupation only over `gameTerritories`; do not extend that benefit to Homeworlds. Ally shipment funding permission is distinct from permission to land on an ally's Homeworld.
+4. **Force returns and removals:** ordinary/extra revival, Ghola, disasters, No-Field materialization, Face Dancer replacement, Tleilaxu and Moritani force effects, Ambassador grants, Guild returns and battles. Existing `reserves +=/-=` operations must be inventoried against the chosen custody model, including non-normal-phase effects that can cross a threshold immediately.
+5. **Sealed commitments:** battle plans, Prescience/Voice, Truthtrance battle promises and ordinary shipment promises currently encode territory-oriented destinations/force pools. Homeworld adapters must preserve their established scope rather than silently treating a new destination as an old legal promise. The current structured shipment promise feature is Basic classic only, so Homeworlds do not automatically join that feature.
+6. **Card-specific geography:** Harass & Withdraw already documents the own-Homeworld prohibition in `ecaz-cards.ts`; implementation must check the battle location's native owner, not a territory name. Other card/leader/faction Homeworld modifications remain source-catalog work. A missing card implementation is not fixed merely by extending the battle location type.
+7. **Validation/receipts:** board settlement rejects all unknown force keys today; victory/advisor validation enumerates `gameTerritories`; multiple cancellation and discard signatures bind current force maps and battle territory. These need the chosen domain explicitly. Never relax canonical board validation globally to admit arbitrary Homeworld-looking strings.
+8. **AI and diagnostics:** force-conservation checks in the three calibration runners currently assert `reserves + tanks + sum(board forces) === 20`. Update them to the canonical physical inventory once, including foreign Homeworld troops and Emperor split pools. Strategic scores need threshold retention, home defense, invasion reward/cost and eligible victory progress rather than applying the Arrakis stronghold bonus indiscriminately.
+
+## Coherent delivery sequence
+
+The next production milestone should be a complete Homeworld lifecycle behind the module gate, not a catalog-only or transport-only release.
+
+1. **Component rules and custody foundation:** verify all thirteen card/disc identities and effects; implement optional module state, setup, source-qualified locations, native/foreign typed inventory, occupation receipts and dynamic capabilities. Cover all seated factions and Emperor's two-card case. Preserve old saves and default games unchanged.
+2. **End-to-end invasion and defense:** wire source-permitted shipment/Emperor transfer, exact payments and counters, Homeworld battle discovery/choice, existing sealed combat adapters, casualties/replacement/retreat, occupation effects and return routes. Deliver player controls and all four bot levels concurrently. This supplies a playable complete turn rather than accepting arrivals that cannot fight or leave.
+3. **Whole-turn and faction/card integration:** route revival/charity/bidding/hand limits and every source-defined high/low reward through capabilities; update thresholds after all custody mutations. Integrate occupation-limited alliances, Karama immunity, conquest rewards and victory. Test interaction families that cross phases and suspended continuations, with location-specific exclusions visible to players.
+4. **Release validation:** actual setup-to-endgame simulations, module toggles/prerequisites, source component inventory, human desktop/mobile inspection and controls, 2–6 players/all four AI levels, and production SQLite CAS/recovery for paid invasion, sealed Homeworld battle and control/reward continuation. Enable only configurations whose complete rules and interactions pass; existing unrelated incomplete expansions do not become enabled by this work.
+
+Concrete acceptance cases include native reserve shipment lowering then revival raising a threshold; foreign invasion and complete battle on a Homeworld without storm geometry; occupation entitlement after an invader leaves where the source preserves it; simultaneous reserve depletion and casualty control changes; Emperor mixed shipment and paid-movement internal transfer; rejected ally arrival/alliance; BG fighter-only Homeworld presence; Karama affecting an ordinary faction power while preserving independent Homeworld modifiers; JSON reload between payment and combat aftermath; and duplicate final submissions applying one casualty/reward/occupation receipt.
+
+## Findings and limits
+
+There is no reproduced live Homeworld regression: all relevant starts remain gated and no Homeworld action is accepted today. The concrete incompatibilities above are verified code assumptions that would become defects if the module were enabled or Homeworld IDs were appended to planet enumeration without adapters. This audit did not modify state, execute hypothetical effects, run a new game, or claim full source coverage. It makes no decision on unresolved card interactions, Homeworld victory text, concealed No-Field custody, or broader Ecaz combined combat. The source catalog and coordinator's agreed contracts must settle those before the relevant paths are activated.
diff --git a/docs/HOMEWORLD_SHIPMENT_RUNTIME.md b/docs/HOMEWORLD_SHIPMENT_RUNTIME.md
new file mode 100644
index 00000000..c39eb3b7
--- /dev/null
+++ b/docs/HOMEWORLD_SHIPMENT_RUNTIME.md
@@ -0,0 +1,35 @@
+# Homeworld shipment runtime
+
+9 September 2026. The [transport source update](HOMEWORLD_TRANSPORT_SOURCE_UPDATE.md) and [invasion audit](HOMEWORLD_INVASION_RULES.md) supply the source contract. Homeworld starts remain disabled pending complete transport, occupation, face effects and full-game acceptance.
+
+## Connected behavior
+
+World-to-world shipment now uses an actual engine action with explicit source pools and destination. Each ordinary faction pays one spice per physical counter, including Fremen; Guild pays half, rounding up. A shipment uses one source world, except Advanced Emperor may combine its native Kaitain and Salusa pools. Foreign garrisons stay separate from native reserves. Guild may return a foreign garrison to its own Homeworld through its explicit exception. Other ordinary own-world returns and allied destinations are rejected.
+
+The action consumes ordinary shipment, preserves movement and checks Heighliners once, retaining the printed Guild exception. Homeworld arrivals have no planet sector, storm traversal, stronghold capacity, advisor accompaniment, Terror or Ambassador entry. Native and visiting armies feed the existing [Homeworld battle runtime](HOMEWORLD_BATTLE_RUNTIME.md), so actual invasion reaches battle discovery without staging foreign forces.
+
+The shipper's invasion payment goes to the bank. November FAQ ally-contribution routing is separate: a non-Guild donor's share goes to Guild when present; Guild contributions go to the bank. Pledged and own funding are reserved during a pending declaration. Income is awarded only after payment and arrival, through the existing income response; it cannot fund the same shipment.
+
+Advanced Guild special Karama can stop this interplanetary declaration, including a Fremen invasion. This is the documented source composition, not a new publisher worked example. No forces, payment or technology income move before resolution. The owned choice survives refresh and Box interruption. When Guild has no legal stop, allowance is automatic. The current stopped-shipment settlement retains the existing provisional no-payment interpretation; the previously recorded cancellation/refund questions remain unresolved.
+
+Pending declarations bind the turn, actor, unused shipment, public custody, alliances, exact typed source receipts, price and pledge. Live and saved decision parents must match the event and destination. Rejected reads, actions and normalization preserve the input. Production room writes retain their existing compare-and-swap fence.
+
+The prohibition on alliances with a faction occupying one's own Homeworld uses present foreign forces, independently of unresolved turn-benefit entitlements. Nexus, Ecaz Ambassador and Moritani alliance paths enforce it, and existing allied garrisons are rejected as invalid saved states. Controls and AI receive the same public restriction.
+
+## Controls and AI
+
+The table has typed native/foreign source selectors, combined Emperor selection, explicit costs, pledged contributions and a destination list. Guild gets actual allow/stop controls. All four AI profiles use the public quote, retain native defense, budget shipment and battle spice, select typed forces and consider reinforcement or native returns. They do not receive opponent hands or spice. These tactical policies do not certify relative AI strength or complete module games.
+
+## Verification and limits
+
+Forty new tests cover pure transport, actual engine actions, all-profile public policy, controls and production SQLite recovery. They include competing declarations, duplicate allowances, allowance versus special Karama, Box-suspended ownership, stale events, typed source corruption, conservation, all donor directions and actual invasion followed by battle discovery. Heighliner testing uses canonical staged token ownership after genuine faction setup because combined module setup remains gated.
+
+Independent review found that a public automatic-continuation flag incorrectly disabled human controls while Guild could stop a shipment. A shared predicate now distinguishes that real choice from automatic allowance; three regressions cover it and detached recovery.
+
+Final `npm run check` passes types, lint and **3,304 offline cases** (79.92 seconds for tests). Final production build passes. **40 HTTP cases** passed after engine/persistence integration and before the final UI label, heading and reference edits. `git diff --check` passes.
+
+An isolated three-seat QA room used genuine Advanced Homeworld setup with a staged Shipment phase and a physically held Guild Karama. Actual desktop controls selected three normal forces from Kaitain plus two Sardaukar from Salusa for Caladan, quoting five spice. Before allowance, all forces and spice stayed at their original sources. An independent authenticated Guild seat on 390×844 phone had enabled Allow and Stop controls, survived refresh and allowed using the keyboard. The Emperor then had 12 normal counters on Kaitain, three Sardaukar on Salusa, the exact five-counter foreign army on Caladan and five spice remaining. Shipment was used, movement remained available, Guild retained its card and received no shipper income. Refresh preserved that result. No page errors or horizontal page overflow were observed. Visual inspection covered desktop source selection, phone Guild controls and the settled phone table; it exposed a generic battle heading on the shipment decision, now corrected. Explicit accessible select names were also added.
+
+Read-only comparison after the browser and HTTP tests found all **2,740** pre-existing room versions/hashes unchanged; 2,771 rooms are stored including the new test rooms. The existing server was reused. This browser check verifies declaration/allowance and restoration, not a complete Homeworld game or every interception branch; those other focused branches have engine/SQLite evidence.
+
+Remaining transport includes Arrakis-to-Homeworld routes, Junction offers and their payment composition, generic purchased Karama destination scope, unsponsored foreign departures to Arrakis, concealed No-Fields and ordinary Guild rate-cancellation timing. Occupation qualification/rewards, remaining Homeworld faces, victory and complete module games remain unfinished. This checkpoint does not open a start gate, publish the site or reinstall maintenance automation.
diff --git a/docs/HOMEWORLD_TRANSPORT_SOURCE_UPDATE.md b/docs/HOMEWORLD_TRANSPORT_SOURCE_UPDATE.md
new file mode 100644
index 00000000..6ea4b453
--- /dev/null
+++ b/docs/HOMEWORLD_TRANSPORT_SOURCE_UPDATE.md
@@ -0,0 +1,49 @@
+# Homeworld transport source update
+
+9 September 2026. Targeted source review for invasion integration. This supplements [the invasion audit](HOMEWORLD_INVASION_RULES.md), preserves its occupation questions, and changes no runtime behavior or release gate.
+
+Subsequent source adjudication: [Junction transport rules](JUNCTION_TRANSPORT_RULES.md) resolves the sponsored-route payment recipient using the bank default, the native off-planet reserve income trigger and the independent contributor rule. It separately documents Junction's Karama immunity. The earlier open Junction recipient item below is retained as this audit's historical evidence boundary.
+
+## Newly checked authority
+
+Fresh indexed publisher passages were retrieved from E3 printed p.10, the base rulebook p.14, and the November 2020 FAQ pp.2 and 7. The publisher base PDF returned HTTP 403 on direct access. An accessible [mirror of the publisher-authored 24-page base rulebook](https://www.qugs.org/rules/r283355.pdf) independently supplied the same special-Karama wording and the payment/shipment paragraphs on pp.9 and 19. Its content is the GF9 rulebook, not the mirror host's interpretation. Search results for fan compilations and tournament rules were excluded, including a compilation that narrows the Guild special power to arrivals on Arrakis.
+
+The [publisher E2 rulebook, p.4](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=4) confirms that its two updated Karama cards replace the base copies. A readable original replacement card face was not retrieved during this review; a search result quoting a community revision is not a substitute for that component.
+
+## Route contracts
+
+The explicit own-world restriction takes precedence over the general world-to-world permission. Ordinary factions cannot ship back to their native world; Guild and Junction-sponsored cross-shipping are named exceptions. Emperor's inter-world movement remains separate. World-to-world shipment costs one spice per physical counter, halved for Guild, and Emperor may combine its two native origins. Arrakis-to-world shipment requires Guild or the named Junction exception. Allied destinations remain prohibited. [GF9 E3, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
+
+The previously inspected Junction high face authorizes offers to other factions, at half or full price, during the recipient's Shipping action. Its scope includes travel to or from Homeworlds, including the shipper's own. Retain the actual offered tariff and permission source. Guild alliance status alone is not a Junction offer. [Physical component audit](HOMEWORLD_COMPONENT_AUDIT.md#verified-high-side-effects)
+
+**Unresolved departure edge:** neither the freshly retrieved E3 transport paragraph nor ordinary reserve shipment expressly authorizes an unsponsored foreign garrison to ship to Arrakis. Junction supplies a supported path. Do not silently reclassify foreign forces as reserves to manufacture the other path. This remains a targeted source gap, not a reason to block explicit world-to-world invasion.
+
+## Karama: distinguish three effects
+
+**Special Guild interception.** The publisher's operative phrase is “stop one off-planet shipment of any one player.” It does not name Arrakis as the destination. [GF9 base, p.14](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=14)
+
+Recommended source composition: an actual interplanetary invasion is an eligible shipment, including a Fremen invasion. Fremen's ordinary Southern Hemisphere reinforcement is on Arrakis; the faction's identity does not turn every future shipment into that reinforcement. The module's generic invasion route is distinct from a Homeworld card advantage. This is a composition of the published predicates, not an explicit Homeworld example in the FAQ. Stamp route provenance in the pending intent instead of testing only `faction !== 'fremen'`. A Fremen garrison leaving Caladan provides a particularly clear regression against a faction-wide exemption.
+
+**Ordinary cancellation.** The November FAQ distinguishes Guild payment, its personal discount, allied rates, and cross/return transport. Canceling one does not cancel the others. Fremen's protected reinforcement is its movement from reserves during shipping. [November FAQ, p.7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7)
+
+E3 immunity covers Homeworld advantages and penalties. Junction's granted route/rate therefore needs a different cancellation source from Guild's ordinary discount. Printing the standard Guild half rate in the module's general tariff paragraph does not convert it into Junction's high-face advantage. [GF9 E3, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
+
+**Generic purchased rate.** November confirms that Karama may benefit another player, at Guild rates, paid to the bank. It provides no Homeworld example. [November FAQ, p.7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7) The replacement physical card's exact destination scope is still an evidence gap. Do not grant a missing route through the rate card. Do not present either universal Homeworld eligibility or a publisher-confirmed prohibition as established by this review. Existing [ordinary rate-cancellation timing questions](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) remain unchanged.
+
+## Contributor routing and Junction
+
+Ordinary shipment payments go to the bank. Guild's base personal income override names other factions shipping from their off-planet reserves onto Dune. That does not independently collect a shipper's world-to-world invasion fee. [GF9 base, pp.9 and 19](https://www.qugs.org/rules/r283355.pdf#page=9)
+
+The November funding rule separately sends a non-Guild ally's contribution directly to Guild when present; Guild's contributions go to the bank. The FAQ sets no destination restriction on this funding paragraph. Preserve the existing [contributor-specific adjudication](GUILD_SHIPMENT_PAYMENTS.md#publisher-contract-and-precedence), including when Guild is the shipper. An independently purchased Karama rate sends the payment to the bank. [November FAQ, pp.2 and 7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
+
+Consequently, `guildShipmentIncome` cannot receive an invasion's full fee unchanged: its shipper contribution currently assumes an eligible ordinary route. Example under this composition: a five-spice invasion funded by shipper three plus a non-Guild ally two routes three to the bank and two to Guild before applicable Junction effects. Preserve the two debits and their provenance; the incoming receipt cannot fund the same shipment.
+
+Junction low retains the rounded-up half of eligible shipping payments. Its occupied face awards the rounded-down other half paid by other players to the occupier. These face effects modify an eligible payment; they do not manufacture another full fee or broaden every bank payment into Guild income. [Physical component audit](HOMEWORLD_COMPONENT_AUDIT.md#verified-low-side-effects) This review does not resolve existing occupation lifecycle questions or a new multiple-beneficiary rounding policy. Store an original payment identity and contributor ledger so a later consumer can apply the finalized split once. The exact payment recipient for a Junction-sponsored route whose shipper contribution misses the base income trigger should be resolved explicitly before that distinct sponsored route is exposed.
+
+## Integration checks
+
+Keep route legality, tariff provenance, funding, income entitlement, and interception separate in a detached quote. Revalidation must use the actual typed source pools and authorized escrow without replacing the original offer after a pause. Validate ownership and ally restrictions before card disposal, payment, force arrival, or technology income. No sector, stronghold-capacity, advisor accompaniment, or territorial arrival effect should be inferred merely from an off-board location ID.
+
+Required focused cases for this contract: own-world rejection despite general invasion permission; explicit Guild/Junction exceptions; Emperor combined origins; odd Guild force count; paid Fremen invasion versus free reinforcement; a Fremen foreign-garrison interception; shipper/donor routing in both Guild directions; independent card-rate provenance; unchanged funds and forces on rejection; and exactly one final settlement after JSON recovery and duplicate requests.
+
+Validation of this document: local links and referenced function were inspected; primary passages were freshly browsed. No executable tests, databases, saved games, server processes, or automation settings were changed.
diff --git a/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md
new file mode 100644
index 00000000..941b9412
--- /dev/null
+++ b/docs/HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md
@@ -0,0 +1,40 @@
+# 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.
+
+## Primary evidence
+
+The [Future Pastimes designer page](https://futurepastimes.com/dune-ecaz-moritani) links [Jack Reda’s component presentation](https://www.youtube.com/watch?v=4KZKz13wf9c). Fresh inspection of its public storyboard images showed the readable Tupile low face around 2:15 and occupied instructions around 4:20. This audit reads the printed component; no spoken clarification was obtained. The low face specifies 0–10 native CHOAM reserves and begins “Once per faction”. It permits CHOAM to obtain that faction’s spice balance and its weapon **or** defense count when CHOAM is on the faction’s Homeworld or that faction is on Tupile. The occupied instructions explicitly remove CHOAM’s low-threshold advantage.
+
+The [official Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), printed pp9–10, supplies continuous population thresholds and the Homeworld occupation framework. Page10 distinguishes the usual low-population penalties from CHOAM’s low-population advantage, establishes the Emperor’s two native Homeworlds in Advanced play, and exempts Homeworld advantages and penalties from Karama cancellation. The general retention of low **penalties** under occupation does not override Tupile’s explicit loss of its low **advantage**.
+
+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.
+
+## Executable public eligibility
+
+- The native CHOAM player has 0–10 native reserves at the request. Foreign visitors do not contribute to this threshold.
+- Tupile’s advantage is available only with an authoritative **unoccupied** status. An unresolved occupation status blocks the request. The helper does not infer an occupation’s qualification or expiry from current visitors; those unresolved lifecycle questions are tracked in [HOMEWORLD_OCCUPATION_RULES.md](HOMEWORLD_OCCUPATION_RULES.md).
+- Positive physical CHOAM forces on any Homeworld native to the target establish outbound contact. Positive physical target forces on Tupile establish inbound contact. Contact does not require sole presence, occupation, an alliance, or CHOAM forces beside an inbound visitor. Two visitors sharing a third faction’s Homeworld do not establish contact with each other.
+- Either Kaitain or Salusa Secundus supplies contact with the Emperor in Advanced play. The entitlement belongs to the faction, so contact with both worlds still supplies only one use against the Emperor.
+- The printed frequency has no turn, phase, visit, world or category reset. Its direct reading is one successful request per opposing faction for the game. Leaving, returning, changing the contact direction or choosing the other category cannot restore that use.
+- The card describes current presence rather than an arrival-only trigger and prints no phase restriction. A future engine action must fit its existing legal action/continuation boundaries without silently limiting the ability to Shipment and Movement. This audit does not establish special priority over an already unresolved action.
+
+Usage is consumed by a successful disclosure, not by opening controls, asking for a quote or rejecting a stale request. The pure helper accepts externally supplied game-long usage and never mutates it.
+
+## Private answer and held-card classification
+
+The answer consists of the target’s current spice and **one** selected count: weapons or defenses. The recipient named by the component is CHOAM. A server can calculate this truthful snapshot without requiring a redundant opponent confirmation. Future integration must atomically validate contact, population, occupation and unused entitlement, calculate the answer, record use and store one private CHOAM receipt. Other players must not receive the balance, category count, hand identities or a private receipt through logs, controls, exports or recovery views. A saved answer is a historical observation, not a live view of subsequent changes.
+
+Eligibility is independent of the answer. An empty hand, zero spice, a hybrid card, a skill or an unfinished card effect must not change the public action’s availability and thereby reveal hidden information.
+
+The appropriate classification composition is physical held cards by their printed primary category, with conditional battle roles applied only when resolving a battle. Each qualifying physical card counts once; an attack effective against two defense types is still one card. Worthless cards and leader-replacement cards are not weapons or defenses merely because battle slots accept them. Leader skills are not additional held Treachery cards. The E3 rulebook, printed p11, explicitly treats Reinforcements and Harass & Withdraw as neither weapons nor defenses despite allowing their use in those slots.
+
+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.
+
+## Current code boundary and verification
+
+[tupile-intelligence.ts](../game/tupile-intelligence.ts) exports `tupileIntelligenceTargets(context, custody, owner, usedFactions, occupation)` and `quoteTupileIntelligenceRequest(context, custody, owner, usedFactions, occupation, target, category)`. Occupation is explicitly `unoccupied`, `occupied` or `unknown`. The first returns public target/contact/blocking information; the second returns a validated request without an answer or a usage mutation. Neither accepts private hand or balance fields as part of its contract.
+
+[tupile-intelligence.test.ts](../tests/tupile-intelligence.test.ts) covers both contact directions, irrelevant co-visitors, zero forces, both Emperor worlds sharing one lifetime use, threshold boundaries, occupation blocks, malformed inputs, immutable queries, saved usage and getter traps proving that private hands, balances, plans and other hidden fields are not read. Engine actions, signed receipts, player projection, bots, UI and production SQLite disclosure recovery remain future integration work.
diff --git a/docs/HOMEWORLD_VICTORY_REINFORCEMENT_INTEGRATION.md b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_INTEGRATION.md
new file mode 100644
index 00000000..9a51092a
--- /dev/null
+++ b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_INTEGRATION.md
@@ -0,0 +1,158 @@
+# Caladan victory reinforcement: integration audit
+
+Historical pre-integration audit. The subsequent
+[runtime checkpoint](HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md) records the
+implemented hook and evidence. Face Dancer overlap remains guarded pending a
+ruling; the structural recommendation below does not establish its rules order.
+
+10 September 2026. Read-only, bounded runtime audit. This identifies integration
+points for the high-Caladan optional reserve force; it does not establish new
+rules, certify the feature, or change existing saves. The source/quote owner must
+settle the timing and destination boundaries called out below.
+
+The current [Caladan component](../game/homeworld-cards.ts) requires a battle win
+and an existing own force at that battle's territory or Homeworld. Its high
+threshold is six native forces. The [Homeworld rule inventory](HOMEWORLD_RULES.md)
+identifies this as a post-battle transfer, separate from ordinary shipment.
+
+## Actual battle completion order
+
+The following functions are in [engine.ts](../game/engine.ts). The ordering is
+observable across saved discard batches, decisions, and responses; the initial
+winner announcement is not the end of cleanup.
+
+| Stage | Existing behavior | Reinforcement implication |
+| --- | --- | --- |
+| `chooseBattle` | Creates a fresh `Battle.event`; validates the selected unresolved board/Homeworld battle. Voice, prescience, Stronghold powers, and later revealed-plan responses belong to this battle. | Bind a new entitlement to this event, not territory plus turn. Multiple battles can occur in the same territory and turn. |
+| `resolveBattle` | Uses the committed resolution quote; pays support/bounties, kills losers, and applies immediate Basic winner losses where the quote supplies them. Traitor wins preserve their winner; mutual traitors/explosions do not declare a winner. | A winner can have no surviving force. Never add a force before final winner casualties to make an otherwise eliminated winner survive. A native explosion survivor is not a battle winner. |
+| Resolved context | Sets `lastBattleContext` with event, turn, territory, combatants, winner and result. Stages winner mandatory-discard commitments separately. Saves Homeworld casualty commitments, clears `g.battle`, and stages the losing/mandatory discard batch. | This is a place to record new-event provenance, but not yet to expose an actionable reinforcement. Keep a pending marker even when cleanup suspends. |
+| `continueResolvedBattle` | Runs after the first discard continuation. Applies the sole casualty option, exposes `battleLosses`, or routes a native explosion through `homeworldExplosion`. | The reserve force cannot enter the committed casualty pool or change the number/type of losses. |
+| `settleWinnerCasualties` | Applies real normal/starred casualties; clears the Homeworld loss record. A surviving Ixian Suboid and a lost Cyborg can produce `ixSubstitution`. | Do not bypass the casualty or substitution continuation. Atreides cannot itself be the Ixian substitution winner, but a generic cleanup hook must retain this ordering. |
+| `finishWinner` | Retires mandatory winner cards through `winnerMandatoryDiscard`, then exposes optional `battleCards`, or calls `finishBattle`. | The new choice must not replace a mandatory discard or reuse its optional-card list. Winning Cheap Heroes/Poison Tooth can have a separate physical retirement batch. |
+| Optional winning discards | The current `battleCards` decision handler stages `battleCleanup`; `finishTreacheryDiscard` resumes `finishBattle` only after retirement. | A zero-card choice and a nonempty discard batch must reach the same reinforcement stage once. |
+| `finishBattle` aftermath | Uses [battle-aftermath-quote.ts](../game/battle-aftermath-quote.ts): Moritani retention, CHOAM battle income, technology transfer, Harkonnen capture, CHOAM Auditor, Face Dance, then board continuation. | `finishBattle` is called repeatedly. An unchecked hook here would grant several forces for one battle. |
+| Board continuation | Once the quote returns `board`, computes remaining battles and activates the next attacker; otherwise calls `nextPhase`. | This is the narrow final interception point before another battle or Collection. Do not advance the phase while an owned choice/arrival remains. |
+
+`g.battle = null` retires the old Voice, prescience, revealed plans and battle
+power checks. Their completed context is historical evidence; reinforcement
+must not recreate `g.battle`, restart `beginBattlePowers`, call
+`finishBattlePreparation`, or revive a spent Voice response. Captured leaders are
+returned/marked during resolution and the capture aftermath; reinforcement does
+not reopen their battle eligibility.
+
+## Recommended hook and the timing boundary
+
+The smallest structural hook is immediately before the final `board` branch of
+`finishBattle`, after `currentBattleAftermathQuote` and its existing deterministic
+steps have committed. It opens an optional owned decision or records a terminal
+skip, then eventually resumes that same board suffix. This preserves every
+existing casualty, discard, capture and resource response.
+
+**This placement is a recommendation about code structure, not a source ruling
+that Caladan follows Face Dance.** Face Dance removes the original winner's
+entire remaining army and returns it to native reserves; the Tleilaxu replacement
+can even be zero. If reinforcement occurs after it, the original winner has no
+survivor and cannot reinforce. If sources require reinforcement before it, add a
+distinct aftermath stage immediately before Face Dance, and let its completion
+resume Face Dance. Do not choose between these outcomes accidentally by placing
+a generic end-action observer. The winner in `lastBattleContext` does not change
+when a Face Dancer replaces it.
+
+The same source decision must identify when to evaluate native high population:
+at the original win, after casualty cleanup, or when the choice is used. Native
+Caladan casualties can change that side. Record the rule-defined eligibility
+facts; recheck current physical availability and destinations before mutation.
+Do not infer a new entitlement simply because a subsequent return raises Caladan.
+
+## Minimal durable contract
+
+Use a feature-specific optional Game record; keep it outside the exact-shape
+`homeworlds.custody` object. A practical record contains:
+
+- Original battle event, turn, player, target combat-location ID, result and
+ rule-defined qualifying facts. Bind these immutable fields in a canonical
+ signature.
+- A stage such as `waiting`, `choice`, `arrival`, `complete`; a fixed grant of one
+ ordinary force; and a signed completion/decline outcome. A completed marker
+ must survive every repeated `finishBattle` call for that event.
+- An owned decision carrying the same event. Accept only the current event and
+ a currently offered sector/destination, or explicit decline. Do not accept a
+ posted winner, battle result, source balance, or additional amount.
+- If a transfer can cause an arrival reaction, a saved parent describing the
+ exact board/aftermath suffix, plus original arrival provenance and completion
+ evidence. Retire the reinforcement transfer before executing a child.
+
+Validate the record and owned decision together in the common integrity path
+used by `viewGame`, `applyAction`, and `normalizeAutomaticGame`. A signed record
+does not by itself detect deletion: a new battle's independently committed
+context needs an obligation/completion marker if deleting the pending record
+must be rejected. The existing `lastBattleContext.winnerDiscards` versus
+`pendingWinnerDiscards` pair illustrates why this matters.
+
+Reject malformed original fields, contradictory stages, missing required owned
+choices, stale battle events, and replayed completion before any custody, log,
+resource, phase or response mutation. A skip because there is no surviving force
+must also become terminal for the event; repeated normalization cannot wait for
+some later action to manufacture a survivor.
+
+## Physical destinations and reactions
+
+Use [combat-location.ts](../game/combat-location.ts) and
+[homeworld-custody.ts](../game/homeworld-custody.ts), rather than an Arrakis-only
+`at()`/`p.forces` test. Homeworld defenders/visitors have physical forces outside
+ordinary board keys. For Atreides' source, withdraw one ordinary counter from the
+actual native pool through [homeworld-native-reserves.ts](../game/homeworld-native-reserves.ts).
+An aggregate reserve count or foreign visitor does not prove native availability.
+
+For an Arrakis target, require the original battle territory and a legal offered
+sector, then withdraw and place the one counter atomically. For a foreign
+Homeworld target, withdraw native custody and deposit explicit visitor custody.
+Retain allied-Homeworld and applicable occupancy restrictions; do not silently
+route to a different territory. The native-Caladan target needs an explicit
+source/quote decision: native reserves are already the native battle army, so
+withdrawing and depositing into the same pool cannot create an additional
+physical counter. Do not increase native population as though a force came from
+outside custody.
+
+Do not reuse `ship` or `guildShip`: those actions consume phase-five usage and
+can assess Guild income/technology and response windows. This grant must not
+change `shipped`, `moved`, spice, revival allowances, Tanks, or the already paid
+battle support merely to add a reserve force.
+
+`openTerritoryEntry` composes Ecaz Ambassador and Moritani Terror and expects a
+supported cause plus a clear decision/response parent. It is not a generic safe
+callback: it can open multiple saved children or reject competing entry effects.
+The new reinforcement's shipment/movement classification, storm permission,
+Spiritual Advisors, Intrusion, and Terror need the source owner's explicit
+contract. Gate any unresolved affected destination before transfer. The recently
+added [revival return](../game/homeworld-revival-return.ts) shows a durable
+Ambassador chain but is a different entitlement and must not be reused by
+pretending victory reinforcement was revival.
+
+## Persistence and verification seams
+
+[rooms.ts](../db/rooms.ts) persists automatic normalization with a version CAS.
+Its `needsAutomaticRoomRecovery` predicate must recognize any new `waiting` or
+`arrival` stage that needs to advance without a user action. A deliberate human
+`choice` should remain pending; bots use its projected legal options. Never mutate
+on read or write an unfenced completion from a projection helper.
+
+Older saves can contain an already completed `lastBattleContext` or a legacy
+cleanup context constructed by `cleanupBattleContext`. Neither proves that a
+new Caladan offer is owed or was previously consumed. Missing new-feature
+provenance must remain legacy-compatible without granting a retrospective force.
+Newly resolved battles can opt into the signed record at their real commit.
+No database migration needs to rewrite old room JSON or fabricate battle history.
+
+Focused regressions should use genuine battle resolution: ordinary and traitor
+wins; zero survivors; mutual traitors/native explosions; losing and winner
+discard retirement; before/after final casualty population; same-location second
+battle; Homeworld visitor custody; chosen-versus-declined single grant; stale
+events/corrupt or deleted queues; Face Dance ordering; and any supported arrival
+child. Production in-memory SQLite should race two identical decisions and two
+automatic workers across reload, asserting one transfer and one suffix, exact
+twenty-counter custody, unchanged battle losses/usage, private observer views,
+and zero writes for rejected corruption.
+
+Only this document was created. No production engine, persistence code, stored
+room, test fixture, or release gate was changed by this audit.
diff --git a/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md
new file mode 100644
index 00000000..79b28494
--- /dev/null
+++ b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md
@@ -0,0 +1,55 @@
+# Caladan victory reinforcement contract
+
+Source review, 10 September 2026. The original high Caladan face grants an
+optional transfer of one reserve force to the location of a victorious battle,
+provided Atreides still has a force there. The printed high range is six through
+twenty native forces. Territory and Homeworld destinations are both named.
+The [component audit](HOMEWORLD_COMPONENT_AUDIT.md) records the original physical
+face; its text was visually checked again using the retained component image.
+The linked [designer presentation](https://www.youtube.com/watch?v=4KZKz13wf9c)
+corroborates the visible component. No unheard narration supplies a ruling.
+
+[E3 pp.9–10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9)
+make native population thresholds continuous and Homeworld effects immune to
+Karama. The implementation checks the current threshold after winner casualties
+and again when the optional transfer is selected. A legal transfer from six
+reserves to five is not retroactively invalidated. Foreign visitors do not raise
+native population. Basic and Advanced use the same optional physical transfer;
+it does not revive a counter, charge spice or spend ordinary shipment/movement.
+
+The original winner is the beneficiary, including a traitor victory. Loss,
+mutual-traitor destruction and explosion do not create a winning Atreides
+beneficiary. No surviving Atreides force means no destination. Fighting on
+native Caladan cannot create an additional counter: those reserve forces are
+already present in the native battle pool. That source/destination coincidence
+has zero net transfer, consistent with the physical-custody treatment in the
+[replacement contract](HOMEWORLD_REPLACEMENT_RULES.md).
+
+The transfer must stay at the original battle location. A foreign Homeworld
+receives one actual ordinary counter withdrawn from Caladan. Arrakis uses an
+explicit sector choice within the battle territory. Occupancy constraints and
+the allied-Homeworld prohibition remain in force. Arrival at an Ambassador
+stronghold uses the existing entry predicate and a source-bound continuation;
+it does not create a second battle victory.
+
+## Unresolved ordering and entry combinations
+
+The E1 Face Dancer sequence preserves the original winner and its ordinary
+battle consequences before replacing surviving forces. No retrieved official
+example orders that replacement against the later Caladan Homeworld card.
+The coordinator asked the user whether reinforcement precedes or follows Face
+Dance; the answer remains pending. The engine displays that boundary when the
+two opportunities overlap. It supports an explicit decline without choosing
+the disputed placement order; this does not certify the combined interaction.
+
+Storm-sector placement, Hidden Mobile Stronghold classification, and otherwise
+relevant Intrusion/Terror reactions remain explicit source boundaries.
+Destination guards use public physical presence and token placement, never a
+secret Terror identity. Low Grumman's entering-force minimum still determines
+whether its Terror reaction would be relevant to the one arriving counter.
+
+The [integration audit](HOMEWORLD_VICTORY_REINFORCEMENT_INTEGRATION.md) maps
+battle cleanup and persistence. The
+[runtime checkpoint](HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md) records the
+implemented flow and verification. No complete Homeworld, Advanced or expansion
+release is claimed.
diff --git a/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md
new file mode 100644
index 00000000..6bbb8e26
--- /dev/null
+++ b/docs/HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md
@@ -0,0 +1,83 @@
+# Caladan victory reinforcement runtime
+
+Development checkpoint, 10 September 2026. This implements the bounded behavior
+in the [source contract](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), not complete
+Homeworld or expansion support. Advanced and unfinished module starts remain
+disabled.
+
+## Actual battle and player flow
+
+An actual normal or traitor victory by Atreides records its original battle
+event. After casualty and card cleanup, high Caladan may transfer exactly one
+ordinary native reserve force to the victorious army's location while an
+Atreides force survives there. Current native population must be at least six;
+transferring from six to five is legal. Foreign visitors do not raise that
+threshold. Loss, mutual destruction and explosion create no entitlement.
+
+The player selects an eligible sector of the original territory or the original
+foreign Homeworld, or leaves the force in reserves. Native Caladan is already
+the source pool, so it cannot create another counter there. The transfer spends
+no spice, revival allowance or shipment/movement use. All four AI profiles use
+the same projected legal choices. Controls show ownership, the fixed amount
+and unavailable-destination reasons.
+
+The unresolved Face Dancer ordering overlap permits decline only. Storm,
+mobile-stronghold and relevant Intrusion/Terror combinations retain explicit
+destination guards. These are incomplete interactions, not optional house rules.
+An admitted Ambassador arrival can interrupt reinforcement and return through
+its saved child sequence before the next battle or phase begins.
+
+## Saved state and custody
+
+[homeworld-victory-return.ts](../game/homeworld-victory-return.ts) binds the
+original event, turn, winner, location and result to the offer. A separate battle
+obligation records the actual waiting, choice, arrival or completed stage. This
+prevents editing an arrival back into a choice while another reserve force is
+still available. Deleted choices, mismatched markers and changed original facts
+reject before resource or persistence changes.
+An unfinished waiting stage also requires its preceding cleanup control or
+interrupt; deleting an optional winning-card decision cannot leave a silent
+waiting record. Production actions settle automatic reinforcement work before
+their database write, while legitimate human choices remain pending.
+
+[homeworld-arrival.ts](../game/homeworld-arrival.ts) shares Ambassador ancestry
+and child completion validation with revival deployment. Each child remains
+bound to its source event, entrant, location and parent. A removed unfinished
+child cannot release the battle suffix. The original victory remains distinct
+from any later Ambassador movement or shipment.
+
+Older completed Caladan development saves without the separate stage field
+remain readable. A stage-less unfinished obligation is rejected. Older battles
+without this feature's records do not receive retrospective reinforcements.
+These canonical records validate consistency; they are not cryptographic
+authentication of arbitrary edited database contents.
+
+## Verification evidence
+
+Focused cases cover pure thresholds and physical transfers, owned controls,
+actual normal/traitor outcomes, mandatory and optional cleanup, all four AI
+profiles, and actual nested Ambassador arrivals. Production in-memory SQLite
+cases exercise authenticated competing actions, lost-response reloads, private
+seat projections, unrelated rooms and corrupt continuations with zero writes.
+The stage-rewind regression starts with seven reserves and leaves six after the
+first placement, proving rejection does not depend on running out of forces.
+
+The isolated browser game completed a real battle, restored the pending choice,
+selected Hagga Basin by keyboard and added one reserve force. Desktop and
+390-pixel phone views were inspected without overflow. After another refresh,
+the army had three forces, Caladan had five and the Tanks had one. All three
+private seats restored with no page errors. Its completed legacy record also
+restored after the subsequent server restart.
+
+Whole-project verification results belong in the dated
+[implementation status](IMPLEMENTATION_STATUS.md) checkpoint. Passing this slice
+does not certify complete Homeworld games, occupation, all arrival reactions,
+Face Dancer ordering or the full requested expansions.
+
+## Related public-only work
+
+[tupile-intelligence.ts](../game/tupile-intelligence.ts) now quotes public contact,
+low population, occupation suppression and lifetime once-per-faction eligibility.
+It reads no opposing hand. It does not yet disclose spice or card counts, consume
+a use, or provide a playable engine action. See the
+[Tupile source contract](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) for that boundary.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index ce97da80..dbcc8875 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,39 @@
# Dune implementation status
+**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.
+
+**Homeworld revival deployment checkpoint, 10 September 2026:** [HOMEWORLD_REVIVAL_DEPLOYMENT_RUNTIME.md](HOMEWORLD_REVIVAL_DEPLOYMENT_RUNTIME.md) records newly revived Fedaykin placement and Tleilax ordinary Free Revival deployment, exact typed groups, normal/Emperor-extra/Ghola source distinctions, owned controls and all-profile AI. Signed return, income and Ambassador completion records preserve nested arrivals and CHOAM sale restoration. Independent review found and fixed altered-offer acceptance and deleted-child premature completion. Final types, lint, **3,634 offline cases**, production build and **40 HTTP cases** pass; seven new production SQLite cases cover competing writes, lost responses, nested Ambassador restoration and corruption. The final suite also made an older auction test deterministic when the purchased Karama creates a second income responder. Desktop and phone keyboard placement, Ghola decline, refresh and three private seats passed without overflow or page errors. The hourly restart preserved all **3,050** saved room versions/hashes and restored the QA seats. First-reaching-high timing, partial/split groups, storm/arrival classification, occupation and complete expansion games remain unfinished; no release gate opened. User workflow optimizations remain in use and the automation stays removed.
+
+**Homeworld collection and Terror checkpoint, 9 September 2026:** [HOMEWORLD_COLLECTION_RUNTIME.md](HOMEWORLD_COLLECTION_RUNTIME.md) records automatic high-Giedi income on actual positive desert receipts, once-per-phase recovery across Ecaz allocations, and low-Grumman original entering-force restrictions. Concealed No-Fields count as one; Ambassador choices and all four bot levels honor count-dependent arrival limits. Independent review found and verified a repair for altered saved arrival counts: original facts are now bound through live Terror, alliance cancellation and forced-discard continuations. Final types, lint, **3,575 offline cases**, production build and **40 HTTP cases** pass. Thirty-four new cases include seven production SQLite recovery scenarios; the older stale-entry regression now covers both signed and legacy saves. Desktop/390-pixel phone keyboard collection and three-seat refresh preserve seven collected spice plus the automatic two-spice bonus, with readable history and no overflow or browser errors. All **2,958** pre-existing room versions/hashes remain unchanged after the controlled restart and additive QA/HTTP work. Missing whole new receipts/signatures retain explicit legacy compatibility. High Grumman removal custody, occupation and low-Junction rounding questions remain pending; high Grumman operations, other faction effects and complete expansion games remain unfinished. No release gate opened, workflow optimizations remain in use, and automation stays removed.
+
+**Homeworld payment income checkpoint, 9 September 2026:** [HOMEWORLD_PAYMENT_INCOME_RUNTIME.md](HOMEWORLD_PAYMENT_INCOME_RUNTIME.md) records low Kaitain and Junction reductions, original payer costs, separate ordinary-income cancellation, public split previews and private Guild contributor receipts. Final types, lint, **3,541 offline cases** and production build pass; **40 HTTP cases** passed before the final UI/AI funding guard. Twenty-six new cases cover actual auctions, shipment funding, native departure thresholds, Richese special/Ambassador purchases, all four profiles and five production SQLite recovery scenarios. Independent review found and verified repairs for mismatched Ambassador previews and invalid AI funding candidates. Desktop/390×844 phone keyboard allowance and three-seat refresh preserve the 5-spice purchase, 3-spice Emperor receipt and 2-spice bank remainder; screenshots were inspected without page overflow. All **2,894** baseline room versions/hashes remain unchanged. [Occupation](HOMEWORLD_OCCUPATION_RULES.md) and [contributor rounding](HOMEWORLD_PAYMENT_INCOME_RULES.md) questions are pending; divergent low-Junction odd/odd splits reject before declaration/payment rather than adopting an unverified rule. Legacy receipts without all new metadata retain the documented evidence limitation. Occupation, remaining card effects and full expansion games remain unfinished; no release gate opened and automation stays removed.
+
+**Kaitain and Ecaz card economy checkpoint, 9 September 2026:** [HOMEWORLD_CARD_ECONOMY_RUNTIME.md](HOMEWORLD_CARD_ECONOMY_RUNTIME.md) records shared end-of-Bidding paid Kaitain discards and CHOAM sales/trades, current-population Ecaz poison income, private receipts and all-profile controls. Winning mandatory cards now discard after winner casualties and Ixian substitution, with reserved physical custody and independent saved battle obligations. Final types, lint, **3,515 offline cases**, production build and **40 HTTP cases** pass. Forty-seven new cases and updated battle/SQL regressions cover timing, privacy, corruption and races. Independent review found and verified fixes for conditional battle-role corruption, played-card diversion and deleted winner-discard queues. Desktop keyboard and 390×844 phone play completed disposal, private income, trade, sale, closing readiness and all-seat refresh; screenshots were inspected without page overflow. All **2,894** pre-existing room versions/hashes remain unchanged. Occupation, remaining Homeworld effects, complete expansion setup and full module games remain unfinished; no release gate opened. Workflow optimizations remain in use and automation stays removed.
+
+**Tupile card economy checkpoint, 9 September 2026:** [HOMEWORLD_CARD_ECONOMY_RUNTIME.md](HOMEWORLD_CARD_ECONOMY_RUNTIME.md) records high-Tupile restrictions on both Worthless sale prices and CHOAM Ambassador payouts, private legal choices, and Ghola interruption with durable sale restoration and nested Tleilaxu income. Types, lint, **3,468 offline cases**, production build and **40 HTTP cases** pass. Thirty-one new regressions include all four AI profiles, five production SQLite scenarios and the repaired receipt-binding issue found by independent review. All **2,864** pre-existing room versions/hashes remain unchanged. Kaitain has source and pure-quote coverage only; its shared phase-end window, Ecaz poison-discard income, occupation and complete expansion games remain unfinished. No release gate opened. The optimized workflow remains in use; automation stays removed.
+
+**Homeworld mobility and advisor checkpoint, 9 September 2026:** [HOMEWORLD_MOBILITY_RUNTIME.md](HOMEWORLD_MOBILITY_RUNTIME.md) records low-Caladan phase-opening foresight restrictions, low-Wallach free-advisor suppression and high-Wallach two-force Polar Sink batches, low-Ix mobile-stronghold restrictions, and low-Richese token movement restrictions. Paid arrivals, physical troop movement and legitimate previously learned knowledge remain available under their separate rules. Final types, lint, **3,437 offline cases** and production build pass; **40 HTTP cases** passed before the final declared-advisor-count display text. Fifty-three new cases cover pure thresholds, actual actions, all-profile choices, Guild Ambassador continuations, and ten production SQLite recovery scenarios including paid Box interruption. Desktop choices, phone keyboard declaration, another human seat's allowance, saved reaction and settled refresh passed; phone screenshots were visually inspected without overflow. All **2,833** pre-existing room versions/hashes remain unchanged. Occupation-retained penalties, remaining high/occupied effects, full expansion setup and complete Homeworld games remain unfinished; no start or publication gate opened. Workflow optimizations remain in use and automation stays removed.
+
+**Junction sponsored transport checkpoint, 9 September 2026:** [JUNCTION_TRANSPORT_RUNTIME.md](JUNCTION_TRANSPORT_RUNTIME.md) records optional Guild half/full offers, replacement and expiry, exact Arrakis/Homeworld source groups, native returns, foreign departures, payment provenance, Karama immunity, arrival continuations and all-profile controls/AI. Final types, lint, **3,384 offline cases** and production build pass; **40 HTTP cases** passed before the final sponsor-panel identity and AI-priority guard adjustments. Forty-four new regressions include production SQLite concurrency/recovery and independent review. Actual desktop keyboard offers/replacements and phone typed shipment/refresh passed; visual inspection found readable layouts without phone overflow. Browser testing prompted preserving the sponsor panel across offer replacement. A controlled restart restored the private QA seat and preserved all **2,802** saved rooms; all **2,802** pre-existing room versions/hashes also remain unchanged after isolated QA/API tests. Occupation, remaining faces, concealed transport, unresolved generic/ordinary Karama interactions and complete module games remain unfinished; no start or publication gate opened. Workflow optimizations remain in use and automation stays removed.
+
+**Guild Arrakis-to-Homeworld transport checkpoint, 9 September 2026:** [GUILD_HOMEWORLD_TRANSPORT_RUNTIME.md](GUILD_HOMEWORLD_TRANSPORT_RUNTIME.md) records actual Guild invasions and Junction returns from exact Arrakis sector pools, protected mobile-stronghold departures, shared interception, legacy API parity, accessible controls and all-profile AI. Final types, lint, **3,340 offline cases**, production build and **40 HTTP cases** pass. Thirty-six new regressions cover legality, physical custody, competing requests, interrupted recovery and private projection; independent review found no further defects. Desktop validation and keyboard declaration, phone interception, refresh and visual inspection passed in an isolated QA room. All **2,771** pre-existing room versions/hashes remain unchanged, with none missing. Junction-sponsored routes, occupation, remaining faces, unresolved Karama interpretations and complete module games remain unfinished; no start or publication gate opened. User workflow optimizations remain in use and automation stays removed.
+
+**Homeworld invasion shipment checkpoint, 9 September 2026:** [HOMEWORLD_SHIPMENT_RUNTIME.md](HOMEWORLD_SHIPMENT_RUNTIME.md) records actual world-to-world invasions, explicit native/foreign pools, mixed Emperor origins, Guild native returns, separate shipper/donor payments, Guild interception and present-force alliance restrictions. Final types, lint, **3,304 offline cases** and production build pass; **40 HTTP cases** passed before final UI/reference edits. Forty new regressions include actual invasion-to-battle discovery, all-profile policies, competing declarations, allowance-versus-stop races and Box-suspended recovery. Independent review fixed a public automatic-work flag that disabled genuine human Guild choices. Desktop mixed-source shipment, phone keyboard allowance and both-seat refresh preserve exact forces/payment; all **2,740** pre-existing room versions/hashes remain unchanged. Arrakis/Junction routes, occupation, remaining faces, unresolved Karama interpretations and complete module games remain unfinished; no start gate opened. User workflow optimizations remain in use and automation stays removed.
+
+**Homeworld battle runtime checkpoint, 9 September 2026:** [HOMEWORLD_BATTLE_RUNTIME.md](HOMEWORLD_BATTLE_RUNTIME.md) records live Homeworld battle discovery, native strength, sealed plans, native-only Traitor calls, typed explosion losses, late Portable Snooper decisions, Ixian replacement and native Tleilaxu Face Dance. Final typecheck, lint, **3,264 offline cases** and production build pass; **40 HTTP cases** passed before the final saved-decision and board-selection fixes. Independent review and browser testing fixed paused-decision validation gaps and an off-planet territory-selector crash. Desktop/390×844 keyboard battle settlement and refresh preserved exact typed forces and card disposal in a conserved staged invasion. All **2,709** baseline room versions/hashes remain unchanged. Actual invasion transport, occupation, remaining face effects, unresolved return/support interpretations and complete module games remain unfinished; no start gate opened. User workflow optimizations remain in use and automation stays removed.
+
+**Homeworld combat foundation and Salusa checkpoint, 9 September 2026:** [HOMEWORLD_COMBAT_IMPLEMENTATION.md](HOMEWORLD_COMBAT_IMPLEMENTATION.md) records public typed off-planet armies, pure native battle/explosion rules and connected high-Salusa free Sardaukar support. Final typecheck, lint, **3,221 offline cases** and production build pass; **40 HTTP cases** passed before the final Ghola guard. Independent review reproduced a population-changing Ghola deadlock after a sealed plan or prescience answer; execution, UI and AI now gate that specific unresolved interaction, with eight Salusa engine regressions covering rejection, privacy, JSON restoration and subsequent completion. All **2,648** baseline room versions/hashes are unchanged. Live Homeworld invasion/battle/occupation, remaining face effects, full module games and new browser acceptance remain unfinished; no start gate opened. User workflow optimizations remain in use and automation stays removed.
+
+**Homeworld charity and revival checkpoint, 9 September 2026:** [HOMEWORLD_BENEFITS_IMPLEMENTATION.md](HOMEWORLD_BENEFITS_IMPLEMENTATION.md) records source-separated charity payments, dynamic low free-revival rates, four-free Fremen capacity, and Tleilax’s durable phase-start income condition. **3,190 offline tests**, typecheck, lint, final production build, **40 HTTP tests** and **144 focused Homeworld/reference cases** pass. Independent review reproduced and integration fixed Box-suspended charity overpayment, invented saved free revivals and an AI/server special-Karama mismatch. Seven production SQLite recovery cases cover races, interrupted choices, privacy and rejected writes. Desktop/390×844 keyboard claims, invalid revival prevention, threshold crossing and refresh pass in an isolated QA room. All **2,648** pre-existing room versions/hashes are preserved. CHOAM opening-income and special-Karama interpretations remain pending; Homeworld battles, invasion, occupation and full module games are unfinished. No start gate opened; automation remains removed.
+
+**Homeworld native-reserve runtime checkpoint, 9 September 2026:** [HOMEWORLD_RUNTIME_IMPLEMENTATION.md](HOMEWORLD_RUNTIME_IMPLEMENTATION.md) records genuine supported-deck setup, saved typed custody, Basic strength-one special counters, exact native shipment sources, Emperor transfers and revival destinations, public controls and all-profile AI. **3,152 offline tests**, typecheck, lint, production build and **40 live HTTP tests** pass; the final focused Homeworld/reference run passes **106** cases. Actual transfer → shipment → Storm death → revival, Hajr, Ghola receipt integrity and competing SQLite requests are covered. Desktop/390×844 controls, card inspection, invalid-source prevention and refresh pass in a new isolated QA room. Twenty **two-player Basic** games complete 3,079 accepted actions without rejects/stalls or changed source fingerprints; this is a bounded regression sample. All **2,617** older saved rooms are unchanged. Invasions, Homeworld battles, occupation, card effects, remaining arrival routes and full module games are unfinished; public starts remain disabled. Workflow optimizations are preserved and the automation stays removed.
+
+**Homeworld component and custody checkpoint, 9 September 2026:** [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md) records all 13 cards and 26 verified faces, original bundled planetary art, searchable high/reverse inspectors, typed native/foreign custody and a separate population projection. **3,085 offline tests**, typecheck, lint and the final production build pass. Both enlarged faces of every card were exercised on desktop and 390×844 phone; normal reverse controls, artwork loading, full-text search, keyboard activation, scrolling and refresh pass. Screenshot sampling and remaining complete visual acceptance are explicitly bounded in the feature document. The 07:53 UTC controlled restart preserved all **2,617** saved room versions/hashes and restored the authenticated QA seat. Actual Homeworld shipment, battles, occupation, card effects, AI and complete games remain unfinished; no mode gate opened. User workflow optimizations are in use and the automation remains removed.
+
+**Ecaz Ambassador alliance checkpoint, 9 September 2026:** [ECAZ_ALLIANCE_IMPLEMENTATION.md](ECAZ_ALLIANCE_IMPLEMENTATION.md) records the reusable token’s owner offer, entrant consent/refusal, immediate reciprocal alliance and original movement/worm continuation. Duke availability is independent; optional loans await a custody ruling. **3,058 offline tests**, typecheck, lint, production build and **40 HTTP-suite tests** pass. Thirty new alliance tests include public-identity quotes, all four AI levels, actual Box suspension, corrupt-save rejection and competing SQLite replies. Browser mobile keyboard proposal, AI acceptance, chronicle and refresh passed; all 2,586 older non-QA room versions/hashes are unchanged. Twenty Basic games completed 12,186 accepted actions and 545 JSON round trips with zero rejects/stalls and unchanged source fingerprints. Full faction/mode/module completion remains gated, user workflow changes are preserved, and the automation stays removed.
+
+**Ecaz shared collection checkpoint, 9 September 2026:** [ECAZ_COLLECTION_IMPLEMENTATION.md](ECAZ_COLLECTION_IMPLEMENTATION.md) records bilateral desert allocation, private proposals/counteroffers, the odd-to-ally default, automatic bank income and scoped Karama cancellation. All **3,028 offline tests**, typecheck, lint, production build and **40 HTTP-suite tests** pass under the new automatic test-discovery workflow. Sixty collection tests include actual engine transitions, all four AI levels, suspended Box/Karama recovery and SQLite races; review fixed orphaned collection controls. Twenty Basic games completed 10,206 accepted actions and 465 JSON round trips without rejects/stalls or changed source fingerprints. Real browser proposal/AI acceptance, phone keyboard completion and refresh passed; all 2,556 older non-QA room versions/hashes remain unchanged. Full Ecaz combat, its Fremen endgame exception, Tleilaxu Ambassador, remaining Duke interactions and complete Advanced/expansion/module acceptance stay unfinished and gated. User workflow optimizations are preserved; the automation stays removed.
+
**Ecaz joint stronghold victory checkpoint, 7 September 2026:** [ECAZ_VICTORY_IMPLEMENTATION.md](ECAZ_VICTORY_IMPLEMENTATION.md) records the three co-occupied stronghold win, shared public server/UI/AI progress, prediction privacy and final Stronghold Card recovery. **2,758 rules/client/component +245 multiplayer tests (3,003 total)** pass with TypeScript, lint and production build clean. Twenty Basic games completed 12,818 accepted actions and 568 JSON round trips with zero rejects/stalls and unchanged source fingerprints. Actual browser Mentat victory, verbose chronicle, phone/keyboard and refresh passed; all 2,526 pre-existing non-QA room versions/hashes remain unchanged. Fremen’s Ecaz endgame exception, combined Occupy combat, shared collection, Tleilaxu Ambassador runtime and full Advanced/expansion/module acceptance remain unfinished. The automation stays removed.
**Ecaz territorial occupancy checkpoint, 7 September 2026:** [ECAZ_OCCUPANCY_IMPLEMENTATION.md](ECAZ_OCCUPANCY_IMPLEMENTATION.md) records shared server/AI entry and path checks, permanent Ecaz allied co-occupation, advisor exceptions, physical/hidden-marker conservation and the automatic turn-end notice. **2,730 rules/client/component +242 multiplayer tests (2,972 total)** pass, with TypeScript, lint and production build clean. Twenty Basic games completed 11,446 accepted actions and 515 JSON round trips with zero rejects/stalls and unchanged source fingerprints. Desktop/phone, keyboard completion, notices and refresh passed; all 2,496 older non-QA room versions/hashes are unchanged. Combined Occupy combat, shared desert collection, special victory, Tleilaxu Ambassador runtime and complete Advanced/expansion/module acceptance remain unfinished. The automation stays removed.
diff --git a/docs/JUNCTION_TRANSPORT_RULES.md b/docs/JUNCTION_TRANSPORT_RULES.md
new file mode 100644
index 00000000..0d6d00d1
--- /dev/null
+++ b/docs/JUNCTION_TRANSPORT_RULES.md
@@ -0,0 +1,49 @@
+# Junction transport offers
+
+9 September 2026. Implementation contract for Junction's high-face transport advantage. This is a source audit, not certification of a completed transport module. It supplements [the component audit](HOMEWORLD_COMPONENT_AUDIT.md), [invasion rules](HOMEWORLD_INVASION_RULES.md), and [transport source update](HOMEWORLD_TRANSPORT_SOURCE_UPDATE.md).
+
+## Evidence and precedence
+
+Fresh publisher-indexed passages were retrieved from the [GF9 E3 rulebook, pp.10 and 15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10), [base rulebook, pp.9 and 19](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=19), [November 2020 FAQ, pp.2 and 5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2), and [E1 cancellation table, p.12](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=12). Direct publisher PDF opens returned HTTP 403. This review does not claim a new binary download or new visual inspection of the physical card: the previously inspected research images under `/tmp/dune-rules` are absent in the current environment.
+
+Junction's physical face was previously checked through the sources recorded in the [component audit](HOMEWORLD_COMPONENT_AUDIT.md#sources-and-completeness). Its operative facts are: while high, Guild may offer other factions cross-planet shipment at half or full price, as part of their Shipping action, including travel to/from Homeworlds and their own world. Its printed high range is 5–20 native reserves. Guild is the offer owner; alliance status does not imply consent. The [designer's E3 page](https://futurepastimes.com/dune-ecaz-moritani) still links its Homeworld explanatory material, but no fresh authenticated explanation of payment or special interception was obtained here. Player discussions and fan compilations were not adopted as rules authority.
+
+## Routes and counter custody
+
+E3 permits own-world return through the Junction exception, retains the ban on allied-world destinations, and allows Emperor to combine both native origins for a shipment. Homeworlds have no territory geometry. Bene Gesserit cannot send advisors there. A paid Fremen invasion remains distinct from ordinary free southern reinforcement. Homeworld arrival activates Heighliner; retain E1's Guild exception. [E3, pp.10 and 15](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10), [E1, p.5](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=5).
+
+The following route matrix composes that exception with the face's express scope and the base meaning of cross-planet shipment:
+
+| Origin | Destination | Contract |
+|---|---|---|
+| One Arrakis territory | Another Arrakis territory | Cross-planet shipment; exact destination sector and ordinary entry rules. |
+| One Arrakis territory | Native or foreign Homeworld | Explicit sponsored route, including a return to the shipper's own world. |
+| One Homeworld | Another Homeworld | Exact current native/foreign custody; own-world return permitted by sponsor. |
+| Foreign Homeworld | Arrakis | Explicit departure permission from Junction; visitors never become native reserves. |
+| Native Homeworld | Arrakis | Ordinary reserve shipping already supplies permission. A sponsored route must keep its Junction provenance separate from ordinary personal/allied rates. |
+
+Do not use a synthetic Homeworld sector. Do not combine unrelated Arrakis territories or arbitrary foreign worlds into one origin. Multiple sectors of one territory remain one source; the Emperor's two-native-world exception is explicit. Board sources and destinations must obey storm and entry restrictions. Protected Hidden Mobile Stronghold interior custody does not inherit the pointer sector's storm exposure. [Guild transport source contract](GUILD_HOMEWORLD_TRANSPORT_RULES.md#route-and-custody-contract).
+
+For BG, source selection must distinguish actual fighters and advisors. An advisor cannot be silently converted into an invading fighter as a side effect of shipment. Any separately legal flip requires its own valid timing. The Fremen native Homeworld is **Southern Hemisphere**, not Sietch Tabr; a sponsored paid route does not become free merely because the owner is Fremen.
+
+## Tariff and payment
+
+Treat the accepted offer as either `half` or `full`, never a general arbitrary discount. Use the ordinary destination tariff before the offer multiplier: Homeworld destination costs one spice per physical counter; Arrakis stronghold costs one, other territory two, with independently applicable special-location prices. Half-price totals round upward once. Five counters to a world therefore cost five at full or three at half. Elite counters are physical counters for shipment price. Do not stack an allied Guild discount onto a half-price Junction offer. [E3, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10), [base, p.9](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=9), [November FAQ, p.5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=5).
+
+**Coordinating source composition:** Junction supplies permission and tariff, but its inspected face supplies no separate payment-recipient instruction. Apply the base bank default. Guild's ordinary income exception covers another faction's off-planet native reserves arriving on Dune. It does not automatically collect fees for sponsored world destinations, foreign-garrison departures, or board-to-board cross-shipment. Sponsorship alone is not an income trigger. This resolves the previously recorded recipient gap by composing the existing general and specific rules; there is no official Junction worked example. [Base, pp.9 and 19](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=19), [quick-start payment rule, p.8](https://www.gf9games.com/dunegame/wp-content/uploads/Quick-start-Guide.pdf#page=8).
+
+The November FAQ independently sends a non-Guild ally's contribution directly to Guild when present and sends Guild-funded contributions to the bank. Preserve this [previously adjudicated contributor contract](GUILD_SHIPMENT_PAYMENTS.md#publisher-contract-and-precedence), rather than routing every spice through one shipper-income rule. Incoming receipts cannot finance the same transaction. A five-spice world shipment funded by shipper three and non-Guild ally two thus sends three to bank and two to Guild before independently applicable Homeworld splits. The offer is not a direct spice gift or a bribe. [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2).
+
+## Karama and offer lifetime
+
+E3 states: “Karama cards have no effect on Homeworld advantages or penalties.” The grant and offered rate therefore resist ordinary cancellation. **Coordinating inference:** Guild's special stop must also not prevent a shipment whose permission/rate comes from Junction; doing so would negate this Homeworld advantage. Do not create a special-stop choice for that declared route. This differs from a shipment using Guild's ordinary faction privilege. It does not make all unrelated faction powers during a sponsored arrival immune. [E3, p.10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10), [ordinary cancellation distinction](GUILD_HOMEWORLD_TRANSPORT_RULES.md#distinct-card-interactions).
+
+The card authorizes use during the recipient's Shipping action; it grants no extra shipment and no extra movement. It prints no once-per-turn cap on Guild offers. An offer's UI storage and replacement policy are implementation mechanics, not additional faction powers. Bind any executable offer to the current recipient and unused shipping opportunity, revalidate Guild's high benefit and selected tariff before declaration, and expire stale grants when that opportunity ends. Do not preserve a reusable permission into later turns merely because a proposal was once accepted. Once shipment settlement has validly committed, a later threshold change must not reverse it.
+
+## Remaining evidence boundaries and verification obligations
+
+No publisher example was found for a threshold change between an earlier conversational offer and its execution. Current-state revalidation avoids inventing a permanent grant; a future binding-deal implementation must preserve the documented ordinary deal rules. The existing broader occupation-lifecycle and ordinary Karama repricing questions remain open and are not resolved by this audit. The exact generic purchased-Karama card scope likewise remains in [the source update](HOMEWORLD_TRANSPORT_SOURCE_UPDATE.md#karama-distinguish-three-effects).
+
+Implementation evidence must cover sponsor ownership and consent; high/low revalidation; half/full odd totals; current-shipping expiry; board/world/native/foreign origins; own/ally destination rules; exact normal/elite/advisor custody; paid Fremen routes; source and destination storms; both donor identities; no circular funding; immunity without a spurious human stop; one arrival/technology receipt; unchanged rejected state; JSON restoration; and duplicate/concurrent acceptance. These are engineering acceptance requirements, not claims that the corresponding code currently passes.
+
+Validation for this document: relevant local references reviewed, primary indexed passages compared against earlier source audits, and no runtime, database, server, or automation modified.
diff --git a/docs/JUNCTION_TRANSPORT_RUNTIME.md b/docs/JUNCTION_TRANSPORT_RUNTIME.md
new file mode 100644
index 00000000..2cac1ae5
--- /dev/null
+++ b/docs/JUNCTION_TRANSPORT_RUNTIME.md
@@ -0,0 +1,27 @@
+# Junction transport runtime
+
+9 September 2026. [Source contract and adjudicated compositions](JUNCTION_TRANSPORT_RULES.md). This extends [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RUNTIME.md) with Junction's distinct high-population grant. Homeworld game starts remain disabled pending completion of the entire module.
+
+## Offers and routes
+
+Guild can offer half or full price to the faction currently taking its unused shipment. Offers are optional: the recipient can use one or continue ordinary actions. A replacement has a new identity; the old proposal cannot authorize a shipment. Current actor, turn, unused shipment, native high population, typed source custody, funding and destination legality are revalidated before acceptance. Expired offers cannot be reused in a later opportunity. The grant gives no extra shipment or movement.
+
+The shared quote supports one Arrakis territory to another territory or Homeworld, and one native or foreign Homeworld to Arrakis or another Homeworld. Advanced Emperor can combine both native worlds. Native returns are allowed; allied-world destinations remain prohibited. Normal and elite counters retain physical identity and exact source custody. Multiple sectors from the same territory can contribute. Protected mobile-stronghold interiors, source/destination storms, advisor stance and locked advisors, native reserves, and foreign garrisons remain distinct. Concealed No-Fields are not silently converted to physical counters.
+
+Homeworld destinations cost one spice per counter; ordinary Arrakis destination tariffs apply before the offered multiplier. Half price rounds upward once and does not stack with an ordinary allied discount. Junction's route and rate resist Karama, so settlement has no special Guild interception choice. Unrelated arrival responses retain their own cancellation rules.
+
+Settlement validates before changing payment or forces, consumes the shipment once, and retains movement. Native off-planet reserve shipments onto Arrakis produce ordinary Guild income; other personal route fees use the bank default. The November contributor-specific ally routing remains independent. Incoming income cannot finance the transaction that creates it. Actual Homeworld arrivals and eligible off-planet arrivals on Arrakis activate Heighliner; native Fremen southern departure retains its on-planet distinction. Eligible off-planet arrivals offer the ordinary Bene Gesserit follow-up, and Arrakis entry resumes territory effects.
+
+## Controls, AI and verification
+
+The table exposes half/full offer buttons to Guild and a typed source/destination/payment panel to the recipient. The same pure quote supplies authoritative calculations, controls and all four AI profiles. AI offers use public state; recipients retain defenders and collecting forces while considering native defense, invasions and Arrakis objectives. Automated Guild receives an opportunity to offer before an earlier-seated automated recipient spends its shipment. No human action is delayed to force an optional offer.
+
+Forty-four new regressions cover pure transport (14), actual engine actions (11), controls and all four AI profiles (9), independent integration review (6), and production SQLite recovery (4). They include duplicate acceptance, offer replacement versus acceptance races, exact typed settlement, no repeat execution on reads, malformed saved offers, private event invariance, advisory locks, and native versus foreign Fremen arrival effects. Independent review found no further core defect. Final broad check evidence is recorded in the newest [implementation checkpoint](IMPLEMENTATION_STATUS.md).
+
+Desktop browser testing exercised real keyboard offer creation and replacement, rejected eleven counters from a ten-counter source, and quoted five counters at three spice. The initial replacement collapsed the sponsor panel; retaining its opportunity identity fixed that, and subsequent keyboard replacements remained visible. At 390×844, the recipient restored the offer after refresh and used keyboard shipment to return five actual starting Atreides counters from Arrakeen to Caladan. Another refresh preserved fifteen native reserves, five counters in Arrakeen, seven spice, zero used movement and the consumed offer. Guild's held Karama remained unspent, with no spurious stop prompt or fee receipt. Desktop selection and phone selection/settlement screenshots were visually inspected; no page errors or horizontal phone overflow occurred. This used genuine setup with only the shipment opportunity staged, not a complete Homeworld game.
+
+The hourly controlled server restart backed up and preserved all **2,802** then-existing room versions/state hashes and restored the prior private QA seat. After the new browser/API checks, all **2,802** pre-existing rooms still matched their baseline with none missing. Four additional in-memory probes verified that every automated difficulty lets a later-seated Guild offer before an earlier-seated recipient spends shipment. The live QA room was never modified by those probes.
+
+## Remaining scope
+
+Occupation lifecycle and its retained low penalties, remaining Homeworld faces, concealed transport, unsponsored foreign departures, generic purchased-Karama scope, ordinary Guild cancellation timing, and complete module games remain unfinished. The high-population predicate does not claim to settle the existing occupation interpretation. Public starts and publication remain gated. Saved games and the removed-automation preference are preserved.
diff --git a/game/advisors.ts b/game/advisors.ts
index dc03d792..45d3a492 100644
--- a/game/advisors.ts
+++ b/game/advisors.ts
@@ -28,8 +28,10 @@ export function settleAdvisors(g: Game) {
/** Direct shipments are fighters unless joining existing advisors. */
export function arrivalAsAdvisor(
- g: Game,
- p: Player,
+ g: Pick & {
+ players: readonly (Pick & ForcePresence)[];
+ },
+ p: Pick & ForcePresence,
to: string,
source?: string,
accompanying = false,
diff --git a/game/ambassador-phase.ts b/game/ambassador-phase.ts
new file mode 100644
index 00000000..d671898f
--- /dev/null
+++ b/game/ambassador-phase.ts
@@ -0,0 +1,78 @@
+import type { Game } from './engine';
+import { location } from './board';
+import { validAmbassadorResume } from './ambassador-resume';
+import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
+import { validateHomeworldVictoryReturn } from './homeworld-victory-return';
+import {
+ validateHomeworldArrival,
+ type HomeworldArrivalFrame,
+ type HomeworldArrivalTag,
+} from './homeworld-arrival';
+
+export type AmbassadorPhaseContext = Pick<
+ Game,
+ | 'turn'
+ | 'phase'
+ | 'players'
+ | 'homeworlds'
+ | 'homeworldRevivalReturn'
+ | 'homeworldRevivalProgress'
+ | 'homeworldVictoryReinforcement'
+ | 'lastBattleContext'
+ | 'pendingAmbassador'
+>;
+
+/** Extra phases belong only to the recorded arrival and its actual BG suffixes. */
+export function ambassadorPhaseAllowed(g: AmbassadorPhaseContext): boolean {
+ const entry = g.pendingAmbassador;
+ const candidates: {
+ tag: HomeworldArrivalTag;
+ frame: HomeworldArrivalFrame | undefined;
+ }[] = [
+ { tag: 'revivalEvent', frame: g.homeworldRevivalReturn },
+ { tag: 'victoryEvent', frame: g.homeworldVictoryReinforcement },
+ ];
+ const matching = candidates.filter(
+ ({ tag, frame }) =>
+ entry &&
+ (entry[tag] !== undefined ||
+ (Array.isArray(frame?.ambassadors) &&
+ frame.ambassadors.some((record) => record?.event === entry.event))),
+ );
+ if (!matching.length) return g.phase === 1 || g.phase === 5;
+ if (matching.length !== 1) return false;
+ const { tag, frame } = matching[0];
+ if (
+ !entry ||
+ !frame ||
+ entry[tag] !== frame.event ||
+ frame.stage !== 'arrival' ||
+ frame.turn !== g.turn ||
+ frame.phase !== g.phase ||
+ entry.turn !== g.turn ||
+ entry.phase !== g.phase ||
+ entry.resume !== 'none' ||
+ !validAmbassadorResume(g, entry)
+ )
+ return false;
+ try {
+ if (tag === 'revivalEvent')
+ validateHomeworldRevivalReturn(g, g.homeworldRevivalReturn);
+ else validateHomeworldVictoryReturn(g);
+ validateHomeworldArrival(g, frame, tag);
+ } catch {
+ return false;
+ }
+ const record = frame.ambassadors!.at(-1);
+ if (
+ !record ||
+ record.completed ||
+ record.event !== entry.event ||
+ record.entrant !== entry.entrant ||
+ record.destination !== location(entry.territory, entry.sector)
+ )
+ return false;
+ return record.parent === undefined
+ ? entry.guildAdvisorOrigin === undefined
+ : entry.guildAdvisorOrigin?.event === record.parent;
+}
diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts
index 25121941..e83ec61b 100644
--- a/game/battle-resolution-quote.ts
+++ b/game/battle-resolution-quote.ts
@@ -23,6 +23,11 @@ import {
} from './stone-burner';
import { matchingTraitor } from './traitors';
import { auditCount } from './choam-auditor';
+import {
+ quoteHomeworldBattleRules,
+ type HomeworldBattleRules,
+ type HomeworldExplosionLosses,
+} from './homeworld-battle-rules';
export class BattleResolutionQuoteError extends Error {
constructor(message: string) {
@@ -63,8 +68,12 @@ export type ResolutionCombatant = ResolutionParticipant & {
};
export type BattleResolutionInput = {
advanced: boolean;
+ /** Preserve physical counter choices in Basic Homeworlds without granting Advanced strength. */
+ typedCasualties?: boolean;
turn: number;
territory: string;
+ /** Off-planet rules are explicit; a Homeworld is never a stronghold terrain. */
+ homeworld?: HomeworldBattleRules;
attacker: ResolutionCombatant;
defender: ResolutionCombatant;
/** Exact traitorVoters order, with the canceled allied call substituted false. */
@@ -107,6 +116,9 @@ export type BattleResolutionQuote = {
strongholdIncome: { player: string; amount: number }[];
/** Includes noncombatants only for an actual non-traitor explosion. */
destroyedArmies: string[];
+ /** Native Lasgun/Shield losses replace destruction, including when the
+ * native is a noncombatant at an invaders' battle. Commit one chosen option. */
+ homeworldExplosion?: HomeworldExplosionLosses;
/** Basic ordinary winners lose their dial immediately; Advanced/Ix defer typed losses. */
basicWinnerLosses: number | null;
casualties: {
@@ -153,6 +165,8 @@ function validateCombatant(
f.normal + f.elite <= 20 &&
(f.eliteStrength === 1 || f.eliteStrength === 2) &&
typeof f.freeSupport === 'boolean' &&
+ (f.eliteFreeSupport === undefined ||
+ typeof f.eliteFreeSupport === 'boolean') &&
(f.normalFixedHalf === undefined ||
typeof f.normalFixedHalf === 'boolean') &&
Number.isFinite(p.dial) &&
@@ -236,6 +250,35 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
attacker: validateCombatant(a, input.physicalCards),
defender: validateCombatant(d, input.physicalCards),
};
+ requireQuote(
+ !!input.homeworld === input.territory.startsWith('homeworld:'),
+ 'A Homeworld battle must carry its explicit native location rules.',
+ );
+ const homeworld = input.homeworld
+ ? quoteHomeworldBattleRules(
+ input.territory,
+ input.participants,
+ input.homeworld,
+ )
+ : null;
+ if (homeworld) {
+ requireQuote(
+ input.advanced || input.homeworld!.card !== 'salusa_secundus',
+ 'Salusa Secundus is used only in Advanced Homeworld games.',
+ );
+ requireQuote(
+ !a.stronghold && !d.stronghold &&
+ !input.participants.some((p) => p.noFieldAtTerritory),
+ 'Homeworld combat does not inherit stronghold effects or unresolved concealed No-Field combat.',
+ );
+ const native = [a, d].find((side) => side.id === homeworld.native);
+ requireQuote(
+ !native ||
+ (native.forces.normal === input.homeworld!.nativeForces.normal &&
+ native.forces.elite === input.homeworld!.nativeForces.elite),
+ 'The native battle army must match its Homeworld custody.',
+ );
+ }
const card = (
side: ResolutionCombatant,
selected: string | null | undefined,
@@ -276,9 +319,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
requireQuote(
Array.isArray(input.voters) &&
new Set(input.voters.map((v) => v.id)).size === input.voters.length &&
- [a, d].every((s) =>
+ [a, d].filter((s) => !homeworld || s.id === homeworld.native).every((s) =>
input.voters.some((v) => v.id === s.id && v.beneficiary === s.id),
- ),
+ ) &&
+ (!homeworld || input.voters.every((v) =>
+ v.id === homeworld.native && v.beneficiary === homeworld.native,
+ )),
'The battle needs its complete distinct traitor voters.',
);
const revelations: BattleResolutionQuote['revelations'] = [];
@@ -413,11 +459,13 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
scores = {
attacker:
a.plan.dial +
+ (a.id === homeworld?.native ? homeworld.strength : 0) +
(deaths.attacker || effects.stunned
? 0
: strengths.attacker + (a.plan.kwisatz ? 2 : 0)),
defender:
d.plan.dial +
+ (d.id === homeworld?.native ? homeworld.strength : 0) +
(deaths.defender || effects.stunned
? 0
: strengths.defender + (d.plan.kwisatz ? 2 : 0)),
@@ -440,7 +488,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
}
const destroyedArmies =
result === 'explosion'
- ? input.participants.map((p) => p.id)
+ ? input.participants.filter((p) => p.id !== homeworld?.native).map((p) => p.id)
: winner
? [winner.id === a.id ? d.id : a.id]
: [a.id, d.id];
@@ -453,7 +501,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
let casualties: BattleResolutionQuote['casualties'] = null;
let basicWinnerLosses: number | null = null;
if (winner && result === 'normal') {
- if (input.advanced || winner.faction === 'ixians') {
+ if (
+ input.advanced ||
+ input.typedCasualties ||
+ homeworld ||
+ winner.faction === 'ixians'
+ ) {
const options = casualtyOptions(
winner.forces,
winner.plan.dial,
@@ -599,6 +652,9 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
bounty,
strongholdIncome,
destroyedArmies,
+ ...(result === 'explosion' && homeworld
+ ? { homeworldExplosion: homeworld.explosion }
+ : {}),
basicWinnerLosses,
casualties,
played,
diff --git a/game/bidding-end-options.ts b/game/bidding-end-options.ts
new file mode 100644
index 00000000..6712d13f
--- /dev/null
+++ b/game/bidding-end-options.ts
@@ -0,0 +1,107 @@
+import type { Card } from './cards';
+import type { Action, GameView } from './engine';
+
+/** These controls consume only the requesting seat's projected opportunity. */
+export function biddingEndCanAct(g: GameView): boolean {
+ return !!(
+ g.status === 'playing' &&
+ g.phase === 3 &&
+ g.biddingEnd?.canAct &&
+ g.biddingEnd.owners.includes(g.me) &&
+ !g.decision &&
+ !g.response &&
+ !g.truthtrance &&
+ !g.phaseOpening &&
+ !g.automaticContinuationPending
+ );
+}
+
+export function biddingEndDiscardChoice(g: GameView, selected: string[]) {
+ const me = g.players.find((p) => p.id === g.me);
+ const end = g.biddingEnd;
+ const cost = selected.length * 2;
+ let blocked: string | null = null;
+ if (!biddingEndCanAct(g)) blocked = 'Wait for the current choice to finish.';
+ else if (end?.kaitain?.owner !== g.me || !end.kaitain.eligible)
+ blocked = 'Paid discards require high-population Kaitain.';
+ else if (!selected.length) blocked = 'Choose at least one card to discard.';
+ else if (
+ new Set(selected).size !== selected.length ||
+ selected.some((id) => !me?.hand?.some((card) => card.id === id))
+ )
+ blocked = 'Choose cards that are still in your hand.';
+ else if (cost > (me?.spice ?? 0))
+ blocked = `You need ${cost} spice to discard these cards.`;
+ return {
+ cost,
+ blocked,
+ action: blocked
+ ? null
+ : ({
+ type: 'biddingEnd',
+ event: end!.event,
+ mode: 'discard',
+ cards: selected,
+ } as Action),
+ };
+}
+
+/** The same CHOAM policy is used by the ordinary and shared market windows. */
+export function choamMarketPolicy(
+ g: GameView,
+ value: (card: Card) => number,
+ preserve?: unknown,
+): Action {
+ const market = g.choamMarket!;
+ const me = g.players.find((p) => p.id === g.me)!;
+ const sales = market.sales?.filter((sale) => sale.card !== preserve);
+ const sale = sales?.find((candidate) => candidate.price === 3) ?? sales?.[0];
+ if (sale)
+ return {
+ type: 'decision',
+ mode: 'sell',
+ card: sale.card,
+ witness: sale.witness,
+ };
+ const offerable = me.hand?.filter((card) => card.id !== preserve) ?? [];
+ if (market.canTrade && !market.tradeAttempted && offerable.length) {
+ const offered = [...offerable].sort((a, b) => value(a) - value(b))[0];
+ return { type: 'decision', mode: 'trade', card: offered.id };
+ }
+ return { type: 'decision', done: true };
+}
+
+export function biddingEndActions(
+ g: GameView,
+ level: number,
+ value: (card: Card) => number,
+ preserve?: unknown,
+): Action[] {
+ if (!biddingEndCanAct(g)) return [];
+ const end = g.biddingEnd!;
+ const me = g.players.find((p) => p.id === g.me)!;
+ if (!(me.bot ?? me.autopilot)) return [];
+ if (end.kaitain?.owner === g.me && end.kaitain.eligible) {
+ // Preserve a shipment/revival budget. Emptying a useful hand merely to pay
+ // for new cards next turn is not beneficial, including for the Easy bot.
+ const budget = Math.max(0, (me.spice ?? 0) - [2, 3, 4, 4][level]);
+ const unwanted = (me.hand ?? [])
+ .filter((card) => card.kind === 'worthless' && card.id !== preserve)
+ .slice(0, Math.floor(budget / 2));
+ if (unwanted.length) {
+ const choice = biddingEndDiscardChoice(
+ g,
+ unwanted.map((card) => card.id),
+ );
+ if (choice.action) return [choice.action];
+ }
+ }
+ if (g.choamMarket?.owner === g.me) {
+ const action = choamMarketPolicy(g, value, preserve);
+ if (!action.done)
+ return [{ ...action, type: 'biddingEnd', event: end.event }];
+ }
+ return end.ready.includes(g.me)
+ ? []
+ : [{ type: 'biddingEnd', event: end.event, mode: 'ready' }];
+}
diff --git a/game/bidding-end.ts b/game/bidding-end.ts
new file mode 100644
index 00000000..d7fde3fa
--- /dev/null
+++ b/game/bidding-end.ts
@@ -0,0 +1,44 @@
+import type { Game } from './engine';
+
+export type BiddingEnd = {
+ event: string;
+ turn: number;
+ owners: string[];
+ ready: string[];
+};
+
+export function biddingEndQuiet(g: Game): boolean {
+ return !!g.biddingEnd && !g.decision && !g.response && !g.truthtrance &&
+ !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingNullentropy &&
+ !g.pendingKarama && !g.pendingRicheseGift && !g.pendingExchange &&
+ !g.pendingRichesePurchaseIncome && !g.pendingChoamMarketGhola &&
+ !g.choamMarket?.sale && !g.choamMarket?.trade;
+}
+
+/** Bidding hand counts are public. Never inspect private card identities or
+ * spice to decide whether to skip an owner's opportunity. */
+export function biddingEndPubliclyEmpty(g: Game, id: string): boolean {
+ const owner = g.players.find((p) => p.id === id)!;
+ return owner.hand.length === 0 && g.players.every((p) => p.id === id ||
+ (p.hand.length < 2 && !(p.faction === 'richese' && p.ally === id &&
+ owner.ally === p.id && p.hand.length > 0)));
+}
+
+export function biddingEndError(g: Game): string | null {
+ const end = g.biddingEnd;
+ if (!end) return null;
+ const emperor = g.players.find((p) => p.faction === 'emperor');
+ const choam = g.players.find((p) => p.faction === 'choam');
+ const owners = [emperor?.id, choam?.id].filter((id): id is string => !!id);
+ if (g.status !== 'playing' || g.phase !== 3 || end.turn !== g.turn ||
+ !g.homeworlds?.custody || !emperor ||
+ typeof end.event !== 'string' || !end.event ||
+ JSON.stringify(end.owners) !== JSON.stringify(owners) ||
+ !Array.isArray(end.ready) || new Set(end.ready).size !== end.ready.length ||
+ end.ready.some((id) => !owners.includes(id)) ||
+ !!g.auction || !!g.richeseAuction || !!g.currentAuctionSale ||
+ (choam ? g.choamMarket?.owner !== choam.id || g.choamMarket.resume !== 'phase'
+ : !!g.choamMarket))
+ return 'The saved end-of-Bidding opportunity no longer matches its owners or completed auctions.';
+ return null;
+}
diff --git a/game/board-resolution-quote.ts b/game/board-resolution-quote.ts
index 050f71f5..f1b1bf5f 100644
--- a/game/board-resolution-quote.ts
+++ b/game/board-resolution-quote.ts
@@ -147,6 +147,8 @@ export type CollectionQuote = {
player: string;
strongholds: number;
collected: number;
+ /** Actual desert deposits, excluding bank income and shared escrow. */
+ desert: number;
balance: number;
}[];
/** Removed from the map, but not credited until the allies resolve allocation. */
@@ -283,6 +285,7 @@ export function quoteSpiceCollection(
if (ecazCollectionCanceled && collectionBonus?.owner === id)
strongholds -= collectionBonus.amount;
let collected = 0;
+ let desert = 0;
for (const [key, capacity] of capacities.get(id)!) {
const t = splitLocation(key).territory;
if (
@@ -314,6 +317,8 @@ export function quoteSpiceCollection(
}
const amount = Math.min(spice[key] ?? 0, capacity);
collected += amount;
+ if (sites.some((site) => site.id === t && site.type === 'sand'))
+ desert += amount;
spice[key] = (spice[key] ?? 0) - amount;
}
const balance = original.spice + strongholds + collected;
@@ -321,7 +326,7 @@ export function quoteSpiceCollection(
whole(collected) && whole(balance),
'Collection would overflow the spice balance.',
);
- receipts.push({ player: id, strongholds, collected, balance });
+ receipts.push({ player: id, strongholds, collected, desert, balance });
}
return { released, spice, receipts, shared, collectionBonus };
}
diff --git a/game/bot-battle-choices.ts b/game/bot-battle-choices.ts
index d867a720..36bba605 100644
--- a/game/bot-battle-choices.ts
+++ b/game/bot-battle-choices.ts
@@ -12,6 +12,15 @@ import {
export function botBattleChoices(g: GameView): Action[] {
const me = g.players.find((p) => p.id === g.me)!;
if (g.active !== me.id) return [];
+ if (Array.isArray(g.battleChoices))
+ return g.battleChoices
+ .filter((battle) => battle.attacker === me.id)
+ .map((battle) => ({
+ type: 'chooseBattle',
+ territory: battle.territory,
+ target: battle.defender,
+ }));
+ // Legacy component fixtures may predate the authoritative combat frontier.
const ownLocations = Object.keys(presenceByLocation(me));
return gameTerritories(g)
.filter((t) => t.type !== 'polar' && fighterCount(me, t.id) > 0)
diff --git a/game/bots.ts b/game/bots.ts
index 7767cb9b..7b5a7dee 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1,3 +1,16 @@
+import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
+import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
+import { caladanReinforcementActions } from './caladan-reinforcement-options';
+import { botHomeworldShipmentPaymentAllowed } from './homeworld-payment-options';
+import { guildHomeworldShipmentActions } from './guild-homeworld-shipment-options';
+import { choamSaleGholaTiming } from './choam-market-ghola';
+import { junctionTransportActions } from './junction-transport-options';
+import { currentJunctionOffer, junctionSponsor } from './junction-offer';
+import { homeworldShipmentActions } from './homeworld-shipment-options';
+import {
+ emperorHomeworldMoveActions,
+ withNativeShipmentSources,
+} from './homeworld-options';
import { matchesPlanClaim, respectsBattlePromises } from './battle-promises';
import { ecazOccupancyRelation } from './ecaz-occupy';
import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
@@ -241,16 +254,10 @@ function guildAmbassadorShipments(g: GameView): Action[] {
const me = g.players.find((p) => p.id === g.me)!;
const descriptor = entry.shipment;
const level = rank(g);
- const amount = Math.min(descriptor.maximum, me.reserves);
+ const available = Math.min(descriptor.maximum, me.reserves);
const destinations = descriptor.destinations.filter((d) => !d.blocked);
- if (amount < 1 || !destinations.length)
+ if (available < 1 || !destinations.length)
return [{ type: 'decision', event: entry.event, amount: 0 }];
- const minimum = Math.max(
- 0,
- amount - (me.reserves - descriptor.eliteReserves),
- );
- const elite =
- level === 0 ? minimum : Math.min(amount, descriptor.eliteReserves);
const score = (d: (typeof destinations)[number]) => {
const key = location(d.territory, d.sector);
if (level === 0) return variation(g, key);
@@ -267,6 +274,9 @@ function guildAmbassadorShipments(g: GameView): Action[] {
};
destinations.sort((a, b) => score(b) - score(a));
const destination = destinations[0];
+ const amount = Math.min(available, destination.maximum ?? available);
+ const minimum = Math.max(0, amount - (me.reserves - descriptor.eliteReserves));
+ const elite = level === 0 ? minimum : Math.min(amount, descriptor.eliteReserves);
return [
{
type: 'decision',
@@ -302,7 +312,10 @@ function fremenAmbassadorMoves(g: GameView): Action[] {
territory(source.territory).type === 'stronghold' &&
physical > 1 &&
presenceAt(me, source.territory) === physical;
- let remaining = physical - (keepsSource ? 1 : 0);
+ let remaining = Math.min(
+ physical - (keepsSource ? 1 : 0),
+ destination.maximum ?? physical,
+ );
const forces: Record = {};
const eliteForces: Record = {};
for (const s of sectors) {
@@ -368,6 +381,7 @@ function ornithopterMoves(g: GameView): Action[] {
: null;
const permittedMarker =
marker &&
+ !g.homeworldMobility?.noFieldMovementBlocked &&
(!cohort ||
(cohort.noField?.tokenId === marker.tokenId &&
cohort.noField.event === noField?.event &&
@@ -533,6 +547,7 @@ function plans(g: GameView): Action[] {
normalFixedHalf: me.faction === 'ixians',
elite,
eliteStrength:
+ (!g.advanced && me.faction !== 'ixians') ||
(b.eliteBlocked.includes(me.id) &&
!(g.advanced && me.faction === 'ixians')) ||
(me.faction === 'emperor' && other.faction === 'fremen')
@@ -593,7 +608,21 @@ function plans(g: GameView): Action[] {
(_, i) => i / 2,
).filter((dial) => (supporting(dial)?.support ?? 0) > 0)
: [];
- const enemies = presenceAt(other, b.territory);
+ const freeEliteDials = forces.eliteFreeSupport
+ ? Array.from(
+ { length: Math.floor(ownStrength * 2) + 1 },
+ (_, i) => i / 2,
+ ).filter((dial) => casualtyOptions(forces, dial, 0).length > 0)
+ : [];
+ const enemies = b.opponentForces
+ ? b.opponentForces.normal + b.opponentForces.elite
+ : presenceAt(other, b.territory);
+ const ownNativeBonus = b.native === me.id ? b.nativeBattleStrength : 0;
+ const enemyNativeBonus = b.native === other.id ? b.nativeBattleStrength : 0;
+ const enemyCapacity = b.opponentForces
+ ? b.opponentForces.normal * (b.opponentForces.normalFixedHalf ? 0.5 : 1) +
+ b.opponentForces.elite * b.opponentForces.eliteStrength
+ : enemies;
const insight = b.insight;
const revealedCard =
insight && ['weapon', 'defense'].includes(insight.field)
@@ -658,7 +687,7 @@ function plans(g: GameView): Action[] {
? inspected.plan.dial
: insight?.field === 'dial' && b.prescience?.player === me.id
? Number(insight.value)
- : Math.ceil(enemies * [0.25, 0.4, 0.6, 0.75][level]);
+ : Math.ceil(enemyCapacity * [0.25, 0.4, 0.6, 0.75][level]);
const options: { action: Action; score: number }[] = [];
for (const leader of leaders)
for (const weapon of weapons)
@@ -722,7 +751,7 @@ function plans(g: GameView): Action[] {
...g.players.filter(
(p) => p.faction === 'harkonnen' && p.ally === other.id,
),
- ];
+ ].filter((holder) => !b.native || holder.id === b.native);
const exposedLeader =
!kwisatz &&
traitorHolders.some((p) =>
@@ -759,12 +788,15 @@ function plans(g: GameView): Action[] {
enemySurvivingStrength +
expectedDial -
ownSurvivingStrength +
+ enemyNativeBonus -
+ ownNativeBonus +
(b.tieWinner === me.id ? 0 : typedForces ? 0.5 : 1),
),
);
for (const dial of new Set([
ideal,
...bankSupportedDials,
+ ...freeEliteDials,
0,
...(typedForces ? [0.5] : []),
Math.max(0, ownStrength - 1),
@@ -1162,6 +1194,7 @@ function policyActions(g: GameView): Action[] {
!g.response &&
!g.decision &&
g.mobileStronghold?.location &&
+ !g.homeworldMobility?.mobileStrongholdBlocked &&
(me.forces[MOBILE_LOCATION] ?? 0) > 0
) {
const capacity = (me.forces[MOBILE_LOCATION] ?? 0) * 2;
@@ -1247,7 +1280,10 @@ function policyActions(g: GameView): Action[] {
leader: dead.id,
},
];
- const elite = Math.min(me.elites?.tanks ?? 0, eliteRevivalRemaining(me));
+ const elite = Math.min(
+ me.elites?.tanks ?? 0,
+ eliteRevivalRemaining(me, g.advanced),
+ );
const amount = Math.min(3, me.tanks - (me.elites?.tanks ?? 0) + elite);
if (amount === 3 || (level === 0 && amount > 0))
return [
@@ -1299,6 +1335,10 @@ function policyActions(g: GameView): Action[] {
? []
: [{ type: 'passResponse' }];
}
+ if (g.biddingEnd && !g.decision)
+ return biddingEndActions(
+ g, level, (card) => technologyCardValue(g, card), gamontAction(g)?.card,
+ );
if (g.decision) {
if (g.decision.player !== me.id) return [];
const d = g.decision;
@@ -1550,17 +1590,25 @@ function policyActions(g: GameView): Action[] {
(me.noField.deployed.location.sector === 0 ||
me.noField.deployed.location.sector !== g.storm)
);
+ if (entry.stage === 'allianceReply') return [{ ...action, accept: true }];
if (entry.stage === 'offer') {
if (entry.effect === 'ecaz')
return [
- entry.dukeAcquisition && !entry.dukeAcquisition.blocked
+ entry.allianceOffer && !entry.allianceOffer.blocked
? {
...action,
trigger: true,
beneficiary: entry.owner,
- choice: 'duke',
+ choice: 'alliance',
}
- : { ...action, decline: true },
+ : entry.dukeAcquisition && !entry.dukeAcquisition.blocked
+ ? {
+ ...action,
+ trigger: true,
+ beneficiary: entry.owner,
+ choice: 'duke',
+ }
+ : { ...action, decline: true },
];
const available = entry.beneficiaries.filter(
(option) =>
@@ -1719,7 +1767,12 @@ function policyActions(g: GameView): Action[] {
}
if (d.kind === 'choamBattleFunding') {
const ally = g.players.find((p) => p.id === me.ally)!;
- const forces = countAt(ally, g.battle!.territory);
+ const pool = g.combatLocations?.find(
+ (place) => place.id === g.battle!.territory,
+ )?.forces?.[ally.id];
+ const forces = pool
+ ? pool.normal + pool.elite
+ : countAt(ally, g.battle!.territory);
const total = (me.spice ?? 0) + g.aid.pledged;
const amount = Math.max(
g.aid.pledged,
@@ -1727,29 +1780,10 @@ function policyActions(g: GameView): Action[] {
);
return [{ type: 'decision', amount }];
}
- if (d.kind === 'choamMarket') {
- const market = g.choamMarket!;
- const preserve = gamontAction(g)?.card;
- const sales = market.sales?.filter((s) => s.card !== preserve);
- const sale = sales?.find((s) => s.price === 3) ?? sales?.[0];
- if (sale)
- return [
- {
- type: 'decision',
- mode: 'sell',
- card: sale.card,
- witness: sale.witness,
- },
- ];
- const offerable = me.hand?.filter((c) => c.id !== preserve) ?? [];
- if (market.canTrade && !market.tradeAttempted && offerable.length) {
- const offered = [...offerable].sort(
- (a, b) => technologyCardValue(g, a) - technologyCardValue(g, b),
- )[0];
- return [{ type: 'decision', mode: 'trade', card: offered.id }];
- }
- return [{ type: 'decision', done: true }];
- }
+ if (d.kind === 'choamMarket')
+ return [choamMarketPolicy(
+ g, (card) => technologyCardValue(g, card), gamontAction(g)?.card,
+ )];
if (d.kind === 'choamTradeReply') {
const returned = [...(me.hand ?? [])].sort(
(a, b) => technologyCardValue(g, a) - technologyCardValue(g, b),
@@ -1807,6 +1841,8 @@ function policyActions(g: GameView): Action[] {
},
];
if (d.kind === 'mobileStronghold') {
+ if (!d.placement && g.homeworldMobility?.mobileStrongholdBlocked)
+ return [{type: 'decision', decline: true}];
if (d.placement)
return TERRITORIES.filter((t) => t.type !== 'stronghold')
.flatMap((t) =>
@@ -1839,9 +1875,16 @@ function policyActions(g: GameView): Action[] {
];
}
if (d.kind === 'ixSubstitution') {
- const sourceEntries = Object.entries(me.forces)
- .filter(([key]) => splitLocation(key).territory === d.territory)
- .map(([key, n]) => [key, n - (me.elites?.forces[key] ?? 0)] as const);
+ const world = g.combatLocations?.find(
+ (place) => place.id === d.territory && place.kind === 'homeworld',
+ );
+ const sourceEntries = world
+ ? [[world.id, world.forces?.[me.id]?.normal ?? 0] as const]
+ : Object.entries(me.forces)
+ .filter(([key]) => splitLocation(key).territory === d.territory)
+ .map(
+ ([key, n]) => [key, n - (me.elites?.forces[key] ?? 0)] as const,
+ );
const max = Math.min(
Object.values(d.losses).reduce((a, b) => a + b, 0),
sourceEntries.reduce((sum, [, n]) => sum + n, 0),
@@ -1877,7 +1920,7 @@ function policyActions(g: GameView): Action[] {
target.id !== me.ally &&
(target.tanks >= [12, 8, 5, 3][level] ||
(level > 0 && d.revival === 'leader' && noLeader));
- if (specialCard && shouldStop)
+ if (specialCard && shouldStop && !g.revival.specialKaramaBlock)
return [
{
type: 'card',
@@ -1890,16 +1933,25 @@ function policyActions(g: GameView): Action[] {
}
if (d.kind === 'faceDance') {
if (
+ d.blocked ||
!me.faceDancers?.some((c) => !c.revealed && c.leader === d.identity) ||
me.ally === d.winner
)
return [{ type: 'decision', reveal: false }];
const winner = g.players.find((p) => p.id === d.winner)!;
- let needed = countAt(winner, d.territory);
- const sources: Record = {
- reserves: Math.min(me.reserves, needed),
- };
- needed -= sources.reserves;
+ const world = g.combatLocations?.find(
+ (place) => place.id === d.territory && place.kind === 'homeworld',
+ );
+ const pool = world?.forces?.[winner.id];
+ let needed = world
+ ? (pool?.normal ?? 0) + (pool?.elite ?? 0)
+ : countAt(winner, d.territory);
+ const sources: Record = world
+ ? {}
+ : {
+ reserves: Math.min(me.reserves, needed),
+ };
+ needed -= sources.reserves ?? 0;
for (const [key, amount] of Object.entries(me.forces).sort(
([a], [b]) =>
(territory(splitLocation(a).territory).type === 'stronghold'
@@ -1925,12 +1977,63 @@ function policyActions(g: GameView): Action[] {
type: 'decision',
reveal: true,
sources,
- sector:
- territory(d.territory).sectors.find((s) => s !== g.storm) ??
- territory(d.territory).sectors[0],
+ ...(world
+ ? {}
+ : {
+ sector:
+ territory(d.territory).sectors.find((s) => s !== g.storm) ??
+ territory(d.territory).sectors[0],
+ }),
},
];
}
+ if (d.kind === 'homeworldDefense') {
+ const b = g.battle!;
+ const own = b.plans[me.id];
+ const opponentId = b.attacker === me.id ? b.defender : b.attacker;
+ const opponent = b.plans[opponentId];
+ const snooper = me.hand?.find(
+ (card) =>
+ richeseCardDefinition(card)?.card.effect === 'portableSnooper',
+ );
+ const card = (id: string | null) =>
+ b.cards.find((held) => held.id === id);
+ const dead = (late: boolean) =>
+ strongholdBattleEffects(
+ card(own.weapon),
+ late ? snooper : card(b.lateDefense[me.id] ?? own.defense),
+ card(opponent.weapon),
+ card(b.lateDefense[opponentId] ?? opponent.defense),
+ b.poisonTooth[me.id] ?? true,
+ b.poisonTooth[opponentId] ?? true,
+ b.strongholdEffects[me.id],
+ b.strongholdEffects[opponentId],
+ ).attackerDead;
+ const hasLeader =
+ g.allLeaders.some((leader) => leader.id === own.leader) ||
+ (!!own.kwisatz && card(own.leader)?.kind === 'hero');
+ return [
+ {
+ type: 'decision',
+ event: d.event,
+ use: !!snooper && hasLeader && dead(false) && !dead(true),
+ },
+ ];
+ }
+ if (d.kind === 'homeworldExplosion') {
+ const choices = d.options
+ .map((loss, choice) => ({
+ choice,
+ cost:
+ level === 0
+ ? variation(g, `homeworld-explosion-${choice}`)
+ : loss.normal + loss.elite * 2.5,
+ }))
+ .sort((a, b) => a.cost - b.cost);
+ return choices.length
+ ? [{ type: 'decision', event: d.event, choice: choices[0].choice }]
+ : [];
+ }
if (d.kind === 'techToken') {
const token = [...d.choices].sort((a, b) =>
level === 0
@@ -1949,6 +2052,7 @@ function policyActions(g: GameView): Action[] {
const ownDisc = g.allLeaders.find((l) => l.id === own.leader);
const otherDisc = g.allLeaders.find((l) => l.id === other.leader);
const alreadyWinsByTraitor =
+ b.traitorVoters.includes(me.id) &&
!other.kwisatz &&
!!matchingTraitor(me.traitors ?? [], other.leader, card(other.leader));
const side = me.id === b.attacker ? 'attacker' : 'defender';
@@ -2041,8 +2145,14 @@ function policyActions(g: GameView): Action[] {
b.plans[id === b.attacker ? b.defender : b.attacker].leader,
),
) + (b.plans[id].kwisatz ? 2 : 0);
- const av = a.dial + strength(b.attacker, effects.attackerDead),
- dv = opponent.dial + strength(b.defender, effects.defenderDead);
+ const av =
+ a.dial +
+ strength(b.attacker, effects.attackerDead) +
+ (b.native === b.attacker ? b.nativeBattleStrength : 0),
+ dv =
+ opponent.dial +
+ strength(b.defender, effects.defenderDead) +
+ (b.native === b.defender ? b.nativeBattleStrength : 0);
const winsA = av > dv || (av === dv && b.tieWinner === b.attacker);
const wins = winsA === (me.id === b.attacker);
const ownDead =
@@ -2088,6 +2198,14 @@ function policyActions(g: GameView): Action[] {
}
// A legacy inspection gate is cleared by authoritative normalization.
if (d.kind === 'fullPlanRead') return [];
+ if (d.kind === 'homeworldShipmentGuild') {
+ const world = g.homeworlds?.worlds?.find((home) => home.id === d.destination);
+ const enemy = d.shipper !== me.id && d.shipper !== me.ally;
+ const threatened = world?.native === me.id || !!world?.forces[me.id];
+ if (specialCard && enemy && (level === 0 || threatened || (level >= 2 && d.amount >= 5)))
+ return [{ type: 'card', mode: 'special', card: specialCard.id }];
+ return [{ type: 'decision', event: d.event, allow: true }];
+ }
if (d.kind === 'guildShipment') {
const enemy = d.shipper !== me.id && d.shipper !== me.ally;
const threatened = fighterCount(me, d.territory) > 0;
@@ -2196,16 +2314,18 @@ function policyActions(g: GameView): Action[] {
accompany: choice.accompany,
territory: choice.territory,
sector: choice.sector,
+ amount: choice.amount,
})),
{ type: 'decision', accept: false },
];
}
if (d.kind === 'advisor')
return [
- ...(g.advanced && d.destination
+ ...(g.advanced && d.destination && (g.homeworldMobility?.advisorAccompanyMaximum ?? 1) > 0
? [{ type: 'decision', accept: true, accompany: true }]
: []),
- { type: 'decision', accept: true },
+ ...((g.homeworldMobility?.advisorSinkMaximum ?? 1) > 0
+ ? [{ type: 'decision', accept: true, amount: level === 0 ? 1 : (g.homeworldMobility?.advisorSinkMaximum ?? 1) }] : []),
{ type: 'decision', accept: false },
];
if (d.kind === 'wormProtection')
@@ -2254,6 +2374,10 @@ function policyActions(g: GameView): Action[] {
{ type: 'decision', karama: !!karama },
];
}
+ if (d.kind === 'homeworldRevivalDeployment')
+ return homeworldRevivalDeploymentActions(g);
+ if (d.kind === 'caladanReinforcement')
+ return caladanReinforcementActions(g);
if (d.kind === 'battleCards')
return [
{
@@ -2441,7 +2565,7 @@ function policyActions(g: GameView): Action[] {
}
if (g.nexus && !g.spiceWindow && !me.ally) {
const offer = Object.entries(g.allianceOffers).find(
- ([id, target]) => id !== me.id && target === me.id,
+ ([id, target]) => id !== me.id && target === me.id && !g.homeworldAllianceBlocks?.[id],
);
if (offer && level > 0) return [{ type: 'alliance', target: offer[0] }];
}
@@ -2605,7 +2729,10 @@ function policyActions(g: GameView): Action[] {
: Math.min(
ally.tanks -
(ally.elites?.tanks ?? 0) +
- Math.min(ally.elites?.tanks ?? 0, eliteRevivalRemaining(ally)),
+ Math.min(
+ ally.elites?.tanks ?? 0,
+ eliteRevivalRemaining(ally, g.advanced),
+ ),
3 - (g.emperorExtra[ally.id] ?? 0),
Math.floor(Math.max(0, (me.spice ?? 0) - 3) / 2),
);
@@ -2615,7 +2742,7 @@ function policyActions(g: GameView): Action[] {
amount - (ally.tanks - (ally.elites?.tanks ?? 0)),
);
for (const elite of new Set([
- Math.min(amount, eliteRevivalRemaining(ally)),
+ Math.min(amount, eliteRevivalRemaining(ally, g.advanced)),
minimumElite,
]))
if (paidForceRevivalCost(ally, amount, elite) <= (me.spice ?? 0) - 3)
@@ -2625,7 +2752,7 @@ function policyActions(g: GameView): Action[] {
const freeRemaining = g.revival.freeRemaining;
const eliteAllowance = Math.min(
me.elites?.tanks ?? 0,
- eliteRevivalRemaining(me),
+ eliteRevivalRemaining(me, g.advanced),
);
const maximum = Math.min(
me.tanks - (me.elites?.tanks ?? 0) + eliteAllowance,
@@ -2685,7 +2812,7 @@ function policyActions(g: GameView): Action[] {
)
return cardMoves;
if (g.ornithopter?.active && me.shipped) return [{ type: 'endMovement' }];
- const actions: Action[] = [];
+ const actions: Action[] = [...guildHomeworldShipmentActions(g, level), ...homeworldShipmentActions(g, level)];
const targets = destinations(g);
const halfRate =
me.faction === 'guild' ||
@@ -2741,7 +2868,7 @@ function policyActions(g: GameView): Action[] {
});
}
}
- if (marker && noField && (me.moved ?? 0) < (me.movesAllowed ?? 1)) {
+ if (marker && noField && !g.homeworldMobility?.noFieldMovementBlocked && (me.moved ?? 0) < (me.movesAllowed ?? 1)) {
const from = location(marker.location.territory, marker.location.sector);
const range =
fighterCount(me, 'arrakeen') || fighterCount(me, 'carthag') ? 3 : 1;
@@ -2899,6 +3026,8 @@ function policyActions(g: GameView): Action[] {
});
}
if (reveal && !actions.length) actions.push(reveal);
+ if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
+ actions.unshift(...emperorHomeworldMoveActions(g));
actions.push({ type: 'endMovement' });
return g.ornithopter?.active
? actions.filter((action) => action.type !== 'move')
@@ -2968,16 +3097,14 @@ function policyActions(g: GameView): Action[] {
}));
}
if (b.revealed) {
- if (
- !b.traitorVoters.includes(me.id) ||
- b.traitorSubmitted.includes(me.id)
- )
- return [];
+ const ownsTraitorDecision =
+ b.traitorVoters.includes(me.id) && !b.traitorSubmitted.includes(me.id);
const beneficiary = [b.attacker, b.defender].includes(me.id)
? me.id
: me.ally;
const enemy = b.attacker === beneficiary ? b.defender : b.attacker;
const call =
+ ownsTraitorDecision &&
!b.plans[enemy].kwisatz &&
!!matchingTraitor(
me.traitors ?? [],
@@ -3029,6 +3156,7 @@ function policyActions(g: GameView): Action[] {
},
];
}
+ if (!ownsTraitorDecision) return [];
return [
{
type: 'traitorCall',
@@ -3083,12 +3211,13 @@ function policyActions(g: GameView): Action[] {
function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
const me = g.players.find((p) => p.id === g.me)!;
const options = g.ghola;
+ const duringSale = choamSaleGholaTiming(g);
if (
!(me.bot ?? me.autopilot) ||
g.status !== 'playing' ||
!options?.available ||
g.battle ||
- g.response ||
+ (g.response && !duringSale) ||
g.decision ||
g.truthtrance ||
g.phaseOpening ||
@@ -3096,14 +3225,17 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
g.auction ||
g.richeseAuction ||
g.phase <= 1 ||
- g.ready.includes(me.id) ||
- ([5, 6].includes(g.phase) && g.active !== me.id) ||
+ (g.ready.includes(me.id) && !duringSale) ||
+ ([5, 6].includes(g.phase) && g.active !== me.id && !duringSale) ||
g.shipmentCompletion?.actions.length ||
- (ordinary[0] &&
+ (!duringSale && ordinary[0] &&
![
'ready',
'ship',
'guildShip',
+ 'homeworldShip',
+ 'guildHomeworldShip',
+ 'junctionShip',
'move',
'endMovement',
'chooseBattle',
@@ -3156,10 +3288,13 @@ 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 actions = policyActions(g);
+ const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
+ const sourced = withNativeShipmentSources(g, action);
+ return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) ? [sourced] : [];
+ });
const me = g.players.find((p) => p.id === g.me)!;
const ghola = standaloneGholaAction(g, actions);
- if (ghola) return [ghola];
+ if (ghola && !homeworldRevivalActionBlock(g, ghola)) return [ghola];
if (
g.phase !== 5 ||
g.active !== me.id ||
@@ -3177,12 +3312,30 @@ export function botActions(g: GameView): Action[] {
);
if (!promises.length) return actions;
const filtered = actions.filter((action) => {
- if (!['ship', 'guildShip', 'move', 'endMovement'].includes(action.type))
+ if (
+ ![
+ 'ship',
+ 'guildShip',
+ 'homeworldShip',
+ 'guildHomeworldShip',
+ 'junctionShip',
+ 'move',
+ 'emperorHomeworldMove',
+ 'endMovement',
+ ].includes(action.type)
+ )
return true;
+ const junctionNative = action.type === 'junctionShip' &&
+ typeof action.destination === 'string' && !action.destination.startsWith('homeworld:') &&
+ !!action.sources && Object.keys(action.sources as object).every((source) =>
+ g.homeworlds?.worlds?.some((world) => world.id === source && world.native === me.id));
const fromReserves =
action.type === 'ship' ||
(action.type === 'guildShip' && action.from === 'reserves');
- const shipment = fromReserves
+ const shipment = junctionNative
+ ? {territory: splitLocation(String(action.destination)).territory,
+ amount: Object.values(action.sources as Record).reduce((n, source) => n + source.normal + source.elite, 0)}
+ : fromReserves
? { territory: String(action.territory), amount: Number(action.amount) }
: null;
return promises.every(
@@ -3221,7 +3374,12 @@ export function runBots(state: Game, limit = 96): Game {
: structuredClone(state);
for (let step = 0; step < limit; step++) {
let next: Game | undefined;
- for (const p of g.players.filter((p) => p.bot ?? p.autopilot)) {
+ const actors = g.players.filter((p) => p.bot ?? p.autopilot);
+ // Give an automated optional supplier a chance before a seated-earlier
+ // recipient spends the shipment. Humans retain their ordinary actions.
+ if (g.phase === 5 && junctionSponsor(g) && !currentJunctionOffer(g))
+ actors.sort((a, b) => Number(b.faction === 'guild') - Number(a.faction === 'guild'));
+ for (const p of actors) {
for (const action of botActions(viewGame(g, p.id))) {
try {
next = applyAction(g, p.id, action);
diff --git a/game/caladan-reinforcement-options.ts b/game/caladan-reinforcement-options.ts
new file mode 100644
index 00000000..da51c9d1
--- /dev/null
+++ b/game/caladan-reinforcement-options.ts
@@ -0,0 +1,70 @@
+import type { Action, GameView } from './engine';
+
+/** The resolved battle's projected opportunity is the sole client authority. */
+export function caladanReinforcementCanAct(g: GameView): boolean {
+ return !!(
+ g.status === 'playing' &&
+ g.caladanReinforcement?.player === g.me &&
+ g.decision?.kind === 'caladanReinforcement' &&
+ g.decision.player === g.me &&
+ g.decision.event === g.caladanReinforcement.event &&
+ !g.response &&
+ !g.truthtrance &&
+ !g.phaseOpening &&
+ !g.automaticContinuationPending
+ );
+}
+
+export function caladanReinforcementChoice(
+ g: GameView,
+ destination: string,
+): { blocked: string | null; action: Action | null } {
+ const offer = g.caladanReinforcement;
+ const target = offer?.destinations.find(
+ (option) => option.id === destination,
+ );
+ let blocked: string | null = null;
+ if (!caladanReinforcementCanAct(g))
+ blocked = 'Wait for the current choice to finish.';
+ else if (offer!.blocked) blocked = offer!.blocked;
+ else if (offer!.amount !== 1)
+ blocked = 'This battle reinforcement is no longer available.';
+ else if (!target) blocked = 'Choose a current reinforcement destination.';
+ else if (target.blocked) blocked = target.blocked;
+ return {
+ blocked,
+ action: blocked
+ ? null
+ : { type: 'decision', event: offer!.event, destination, amount: 1 },
+ };
+}
+
+/** Reinforce an already occupied sector when possible. No opposing private
+ * information or reconstructed battle result is needed for this fallback. */
+export function caladanReinforcementActions(g: GameView): Action[] {
+ if (!caladanReinforcementCanAct(g)) return [];
+ const offer = g.caladanReinforcement!;
+ const own = g.players.find((player) => player.id === g.me);
+ const choices = offer.destinations
+ .map((target) => ({
+ target,
+ action: caladanReinforcementChoice(g, target.id).action,
+ }))
+ .filter((choice) => choice.action);
+ const occupied = choices.find(
+ ({ target }) =>
+ (own?.forces[
+ target.sector === undefined
+ ? target.id
+ : `${offer.territory}:${target.sector}`
+ ] ?? 0) > 0,
+ );
+ return [
+ occupied?.action ??
+ choices[0]?.action ?? {
+ type: 'decision',
+ event: offer.event,
+ decline: true,
+ },
+ ];
+}
diff --git a/game/card-availability.ts b/game/card-availability.ts
index a1c148a7..d8d7d115 100644
--- a/game/card-availability.ts
+++ b/game/card-availability.ts
@@ -2,6 +2,7 @@ import { fighterCount } from './advisors';
import { TERRITORIES } from './board';
import { harvesterAvailable, type Card } from './cards';
import type { FactionId } from './catalog';
+import { choamSaleGholaTiming } from './choam-market-ghola';
export type OrdinaryCardEffect =
| 'weather'
@@ -27,6 +28,7 @@ export type OrdinaryCardState = {
phaseOpening?: { passed: readonly string[] } | null;
response?: object | null;
decision?: object | null;
+ choamMarket?: { owner: string } | null;
shieldWallDestroyed: boolean;
spiceWindow?: {
harvested: boolean;
@@ -83,7 +85,7 @@ export function ordinaryCardAvailability(
'Play Amal or pass the phase opening before taking other actions.',
);
}
- if (state.response)
+ if (state.response && !(card?.effect === 'ghola' && choamSaleGholaTiming(state)))
return blocked('Resolve the Karama response window first.');
if (state.decision)
return blocked('Waiting for the player with the pending decision.');
diff --git a/game/charity.ts b/game/charity.ts
index fcedfd17..c87224a4 100644
--- a/game/charity.ts
+++ b/game/charity.ts
@@ -1,4 +1,5 @@
import type { Game, Player } from './engine';
+import { homeworldLowBonus } from './homeworld-benefits';
export type Inflation = {
side: 'double' | 'cancel';
@@ -13,14 +14,26 @@ export function charityMultiplier(g: Pick) {
return g.inflation.side === 'double' ? 2 : 0;
}
-/** Inflation doubles the ordinary amount received, not the wealth threshold. */
-export function charityAmount(
- g: Pick,
- p: Pick,
-) {
- const amount =
+/** Separate ordinary funding from the Homeworld addition paid by the bank.
+ * Inflation changes both eligible portions, never the ordinary wealth threshold.
+ * This quote does not claim, pay, or resolve a Bene Gesserit power response. */
+export function charityQuote(
+ g: Pick,
+ p: Pick,
+): { ordinary: number; homeworld: number; total: number } {
+ const eligible =
g.advanced && p.faction === 'beneGesserit' ? 2 : Math.max(0, 2 - p.spice);
- return amount * charityMultiplier(g);
+ const multiplier = charityMultiplier(g);
+ const ordinary = eligible * multiplier;
+ const homeworld = eligible > 0 ? homeworldLowBonus(g, p.id) * multiplier : 0;
+ return { ordinary, homeworld, total: ordinary + homeworld };
+}
+
+export function charityAmount(
+ g: Pick,
+ p: Pick,
+) {
+ return charityQuote(g, p).total;
}
export function charityPayer(
diff --git a/game/choam-karama.ts b/game/choam-karama.ts
index e57bd339..75bfeb78 100644
--- a/game/choam-karama.ts
+++ b/game/choam-karama.ts
@@ -2,13 +2,15 @@ import type { Game, Player } from './engine';
import { retentionReservesCard } from './moritani-retention';
/** Only cards not already fixed into a battle may be converted into spice. */
export function cashInCards(
- g: Pick,
+ g: Pick,
p: Pick,
) {
const plan = g.battle?.plans[p.id];
const insight = g.battle?.prescience;
return p.hand.filter(
(c) =>
+ !(g.pendingWinnerDiscards?.player === p.id &&
+ [...g.pendingWinnerDiscards.cards, ...g.pendingWinnerDiscards.optional].includes(c.id)) &&
!retentionReservesCard(g, p.id, c.id) &&
(!plan || ![plan.weapon, plan.defense, plan.leader].includes(c.id)) &&
(!insight || insight.player === p.id || insight.value !== c.id),
diff --git a/game/choam-market-ghola.ts b/game/choam-market-ghola.ts
new file mode 100644
index 00000000..64a55ee2
--- /dev/null
+++ b/game/choam-market-ghola.ts
@@ -0,0 +1,96 @@
+import type { Game, ResponseWindow } from './engine';
+import type { ChoamMarket } from './choam-market';
+import { quoteChoamSaleCancellation } from './choam-sale-cancellation';
+
+/** The sale is suspended, never replayed. Its physical card stays in its hand. */
+export type ChoamMarketGhola = {
+ turn: number;
+ phase: number;
+ player: string;
+ card: string;
+ discardSequence: number;
+ event: string;
+ stage: 'discard' | 'income' | 'complete';
+ response: ResponseWindow;
+ market: ChoamMarket;
+};
+
+export function choamGholaEvent(pending: Pick) {
+ return JSON.stringify(['choamGhola', pending.turn, pending.phase,
+ pending.player, pending.card, pending.discardSequence]);
+}
+
+/** Public timing shared by the server and the hand's ordinary-card controls. */
+export function choamSaleGholaTiming(state: {
+ status: string;
+ response?: object | null;
+ decision?: object | null;
+ choamMarket?: { owner: string } | null;
+}) {
+ const response = state.response as { kind?: string; owner?: string } | null;
+ return state.status === 'playing' &&
+ response?.kind === 'choamSale' &&
+ response.owner === state.choamMarket?.owner && !state.decision;
+}
+
+/** No current hand or population requirement: a nested effect may legitimately
+ * move the sale card or change Tupile before the original declaration resumes. */
+export function choamMarketGholaError(g: Game): string | null {
+ const pending = g.pendingChoamMarketGhola;
+ if (!pending) return null;
+ if (
+ g.status !== 'playing' || pending.turn !== g.turn || pending.phase !== g.phase ||
+ !g.players.some((p) => p.id === pending.player) ||
+ typeof pending.card !== 'string' || !pending.card ||
+ !Number.isSafeInteger(pending.discardSequence) || pending.discardSequence < 1 ||
+ pending.discardSequence > (g.treacheryDiscardSequence ?? 0) ||
+ pending.event !== choamGholaEvent(pending) ||
+ !['discard', 'income', 'complete'].includes(pending.stage) ||
+ pending.response?.kind !== 'choamSale' ||
+ !Array.isArray(pending.response.passed) ||
+ new Set(pending.response.passed).size !== pending.response.passed.length ||
+ pending.response.passed.some((id) => !g.players.some((p) => p.id === id)) ||
+ JSON.stringify(pending.market) !== JSON.stringify(g.choamMarket)
+ ) return 'The saved Ghola interruption no longer matches its CHOAM sale.';
+ const discard = g.pendingTreacheryDiscard;
+ if (
+ (g.resolvedTreacheryDiscardSequence ?? 0) < pending.discardSequence &&
+ !(discard?.sequence === pending.discardSequence &&
+ discard.continuation.kind === 'ordinaryCardDiscard' &&
+ discard.continuation.effect === 'ghola' &&
+ discard.continuation.player === pending.player &&
+ discard.continuation.card === pending.card)
+ ) return 'The saved market interruption has no matching committed Ghola discard.';
+ if (pending.stage === 'discard' && discard?.sequence !== pending.discardSequence)
+ return 'The saved Ghola discard stage has no matching continuation.';
+ if (pending.stage !== 'discard' &&
+ (g.resolvedTreacheryDiscardSequence ?? 0) < pending.discardSequence)
+ return 'The suspended market is waiting for its committed Ghola discard.';
+ const continuation = discard?.continuation;
+ const heldRevival = g.homeworldRevivalReturn;
+ const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
+ g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
+ g.summonedWorm?.resume,
+ continuation && 'resume' in continuation ? continuation.resume : null,
+ heldRevival && ['choice', 'arrival'].includes(heldRevival.stage)
+ ? { response: heldRevival.resumeResponse } : null];
+ const incomes = contexts.flatMap((context) => {
+ if (!context) return [];
+ const karama = 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
+ return [context.response, karama?.use?.kind === 'cancel' ? karama.use.response : null];
+ }).filter((response) => response?.kind === 'revivalIncome' && response.intent === pending.event);
+ if (pending.stage === 'income' && !incomes.length)
+ return 'The saved Ghola income has lost its suspended market receipt.';
+ if (pending.stage === 'complete' && incomes.length)
+ return 'Finish the Ghola income before restoring its sale.';
+ if (incomes.some((income) => income!.recipient !== pending.player || income!.amount !== 1 ||
+ !g.players.some((p) => p.id === income!.owner && p.faction === 'tleilaxu')))
+ return 'The saved Ghola income no longer matches the revived faction.';
+ try {
+ quoteChoamSaleCancellation(g, pending.response);
+ } catch (error) {
+ return error instanceof Error ? error.message : 'Invalid suspended CHOAM sale.';
+ }
+ return null;
+}
diff --git a/game/combat-location.ts b/game/combat-location.ts
new file mode 100644
index 00000000..0b46dfcb
--- /dev/null
+++ b/game/combat-location.ts
@@ -0,0 +1,112 @@
+import type { Game } from './engine';
+import { gameTerritories } from './board';
+import { homeworldContext } from './homeworld-game';
+import {
+ homeworldCombatLocation,
+ quoteHomeworldBattles,
+} from './homeworld-combat';
+import { homeworldLocations, type HomeworldForces } from './homeworld-custody';
+import {
+ quoteBattleBoard,
+ quoteBattleBoardContinuation,
+} from './board-resolution-quote';
+
+export type PublicCombatLocation = {
+ id: string;
+ name: string;
+ kind: 'territory' | 'homeworld';
+ native?: string;
+ nativeBattleStrength?: number;
+ forces?: Record;
+};
+export function homeworldBattleLocation(g: Game, id: string) {
+ if (!id.startsWith('homeworld:')) return null;
+ if (!g.homeworlds?.custody)
+ throw new Error('Homeworld combat requires saved Homeworld custody.');
+ const context = homeworldContext(g);
+ return homeworldCombatLocation(
+ {
+ ...context,
+ order: g.order,
+ players: context.players.map((p) => ({
+ ...p,
+ ally: g.players.find((seat) => seat.id === p.id)!.ally,
+ })),
+ },
+ g.homeworlds.custody,
+ id,
+ );
+}
+export function combatLocations(g: Game): PublicCombatLocation[] {
+ const board: PublicCombatLocation[] = gameTerritories(g).map(
+ ({ id, name }) => ({ id, name, kind: 'territory' }),
+ );
+ if (!g.homeworlds?.custody) return board;
+ return [
+ ...board,
+ ...homeworldLocations(homeworldContext(g)).map(({ id }) => {
+ const home = homeworldBattleLocation(g, id)!;
+ return {
+ id,
+ name: home.name,
+ kind: 'homeworld' as const,
+ native: home.native,
+ nativeBattleStrength: home.nativeBattleStrength,
+ forces: home.forces,
+ };
+ }),
+ ];
+}
+export function combatLocationName(g: Game, id: string): string {
+ const home = homeworldBattleLocation(g, id);
+ const name = home?.name ?? gameTerritories(g).find((t) => t.id === id)?.name;
+ if (!name) throw new Error('Choose a current combat location.');
+ return name;
+}
+export function combatArmy(
+ g: Game,
+ player: string,
+ id: string,
+): HomeworldForces {
+ const home = homeworldBattleLocation(g, id);
+ if (home) return { ...(home.forces[player] ?? { normal: 0, elite: 0 }) };
+ combatLocationName(g, id);
+ const p = g.players.find((p) => p.id === player);
+ if (!p) throw new Error('Choose a seated combatant.');
+ const count = (map: Record) =>
+ Object.entries(map).reduce(
+ (sum, [key, n]) =>
+ sum + (key.slice(0, key.lastIndexOf(':')) === id ? n : 0),
+ 0,
+ );
+ const elite = count(p.elites?.forces ?? {});
+ return { normal: count(p.forces) - elite, elite };
+}
+export function quoteCombatBoard(g: Game) {
+ const board = quoteBattleBoard(g);
+ if (!g.homeworlds?.custody) return board;
+ const context = homeworldContext(g);
+ const worlds = quoteHomeworldBattles(
+ {
+ ...context,
+ order: g.order,
+ players: context.players.map((p) => ({
+ ...p,
+ ally: g.players.find((seat) => seat.id === p.id)!.ally,
+ })),
+ },
+ g.homeworlds.custody,
+ );
+ return {
+ ...board,
+ battles: [...board.battles, ...worlds].sort(
+ (a, b) => g.order.indexOf(a.attacker) - g.order.indexOf(b.attacker),
+ ),
+ };
+}
+export function quoteCombatBoardContinuation(g: Game) {
+ const board = quoteCombatBoard(g);
+ return board.battles.length
+ ? { board, phase: null }
+ : quoteBattleBoardContinuation(g);
+}
diff --git a/game/combat.ts b/game/combat.ts
index efccf8ec..2e2bf5e1 100644
--- a/game/combat.ts
+++ b/game/combat.ts
@@ -5,6 +5,9 @@ export type CombatForces = {
elite: number;
eliteStrength: 1 | 2;
freeSupport: boolean;
+ /** Special counters retain full strength without paying support; normal
+ * counters still follow their ordinary support rules. */
+ eliteFreeSupport?: boolean;
};
export type Casualties = {
normal: number;
@@ -13,6 +16,26 @@ export type Casualties = {
paidElite: number;
};
+/** Current supported factions have at most twenty physical counters. */
+export function validCombatForces(forces: CombatForces): boolean {
+ return (
+ !!forces &&
+ typeof forces === 'object' &&
+ !Array.isArray(forces) &&
+ Number.isSafeInteger(forces.normal) &&
+ forces.normal >= 0 &&
+ Number.isSafeInteger(forces.elite) &&
+ forces.elite >= 0 &&
+ forces.normal + forces.elite <= 20 &&
+ (forces.eliteStrength === 1 || forces.eliteStrength === 2) &&
+ typeof forces.freeSupport === 'boolean' &&
+ (forces.eliteFreeSupport === undefined ||
+ typeof forces.eliteFreeSupport === 'boolean') &&
+ (forces.normalFixedHalf === undefined ||
+ typeof forces.normalFixedHalf === 'boolean')
+ );
+}
+
/** Every loss combination consistent with the revealed dial and spice payment. */
export function casualtyOptions(
forces: CombatForces,
@@ -20,11 +43,13 @@ export function casualtyOptions(
support: number,
): Casualties[] {
if (
+ !validCombatForces(forces) ||
!Number.isFinite(dial) ||
dial < 0 ||
!Number.isInteger(dial * 2) ||
- !Number.isInteger(support) ||
+ !Number.isSafeInteger(support) ||
support < 0 ||
+ support > forces.normal + (forces.eliteFreeSupport ? 0 : forces.elite) ||
(forces.freeSupport && support !== 0)
)
return [];
@@ -42,13 +67,16 @@ export function casualtyOptions(
}
for (
let paidElite = Math.max(0, support - normal);
- paidElite <= Math.min(elite, support);
+ paidElite <= (forces.eliteFreeSupport ? 0 : Math.min(elite, support));
paidElite++
) {
const paidNormal = support - paidElite;
if (forces.normalFixedHalf && paidNormal !== 0) continue;
const doubledStrength =
- normal + paidNormal + (elite + paidElite) * forces.eliteStrength;
+ normal +
+ paidNormal +
+ (forces.eliteFreeSupport ? elite * 2 : elite + paidElite) *
+ forces.eliteStrength;
if (doubledStrength === dial * 2) {
options.push({ normal, elite, paidNormal, paidElite });
break; // Different support allocations with identical token losses are equivalent choices.
diff --git a/game/duke-vidal.ts b/game/duke-vidal.ts
index be9c6c17..de3eb1ef 100644
--- a/game/duke-vidal.ts
+++ b/game/duke-vidal.ts
@@ -114,7 +114,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
capture: 'not-implemented',
revival: 'not-implemented',
ambassadorAcquisition: 'ecaz-self-implemented',
- ambassadorAlliance: 'not-implemented',
+ ambassadorAlliance: 'consent-implemented',
ambassadorAllyAcquisition: 'unresolved',
allyLoanReturn: 'unresolved',
sourceAudit: 'docs/DUKE_VIDAL_RULES.md',
diff --git a/game/ecaz-alliance.ts b/game/ecaz-alliance.ts
new file mode 100644
index 00000000..efd42abe
--- /dev/null
+++ b/game/ecaz-alliance.ts
@@ -0,0 +1,135 @@
+import { FACTIONS, type FactionId } from './catalog';
+import type { Game } from './engine';
+
+export type EcazAllianceSeat = {
+ id: string;
+ faction: FactionId;
+ ally: string | null;
+ allySinceTurn?: number;
+};
+export type EcazAllianceContext = {
+ status: Game['status'];
+ turn: number;
+ players: readonly Readonly[];
+ allianceOffers: Readonly>;
+};
+export class EcazAllianceError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'EcazAllianceError';
+ }
+}
+function requireAlliance(
+ condition: unknown,
+ message: string,
+): asserts condition {
+ if (!condition) throw new EcazAllianceError(message);
+}
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+const text = (value: unknown): value is string =>
+ typeof value === 'string' && value.trim().length > 0;
+
+/** Quotes only public alliance identity. The caller owns Ambassador provenance,
+ * consent, token commitment and continuation; Duke custody is independent. */
+export function quoteEcazAlliance(
+ context: EcazAllianceContext,
+ ownerId: string,
+ entrantId: string,
+): { players: EcazAllianceSeat[]; allianceOffers: Record } {
+ requireAlliance(
+ record(context) &&
+ context.status === 'playing' &&
+ Number.isSafeInteger(context.turn) &&
+ context.turn >= 1,
+ 'An Ecaz alliance requires a playing game and a valid turn.',
+ );
+ const seats = context.players;
+ requireAlliance(
+ Array.isArray(seats) &&
+ seats.length >= 2 &&
+ seats.every(
+ (seat) =>
+ record(seat) &&
+ text(seat.id) &&
+ FACTIONS.some((faction) => faction.id === seat.faction) &&
+ (seat.ally === null || text(seat.ally)) &&
+ (seat.allySinceTurn === undefined ||
+ (typeof seat.allySinceTurn === 'number' &&
+ Number.isSafeInteger(seat.allySinceTurn) &&
+ seat.allySinceTurn >= 0 &&
+ seat.allySinceTurn <= context.turn)),
+ ) &&
+ new Set(seats.map((seat) => seat.id)).size === seats.length &&
+ new Set(seats.map((seat) => seat.faction)).size === seats.length,
+ 'An Ecaz alliance requires unique valid seated identities and factions.',
+ );
+ const byId = new Map(seats.map((seat) => [seat.id, seat]));
+ for (const seat of seats) {
+ if (seat.ally === null) continue;
+ requireAlliance(
+ seat.ally !== seat.id && byId.get(seat.ally)?.ally === seat.id,
+ 'Existing alliances must be seated, distinct and reciprocal.',
+ );
+ }
+ const owner = byId.get(ownerId);
+ const entrant = byId.get(entrantId);
+ requireAlliance(
+ text(ownerId) &&
+ text(entrantId) &&
+ owner &&
+ entrant &&
+ ownerId !== entrantId &&
+ owner.faction === 'ecaz',
+ 'Choose the seated Ecaz owner and a different seated entrant.',
+ );
+ requireAlliance(
+ owner.ally === null && entrant.ally === null,
+ 'Ecaz and the entrant must both be unallied.',
+ );
+ requireAlliance(
+ record(context.allianceOffers),
+ 'Alliance offers must be a valid public record.',
+ );
+ const offers = Object.entries(context.allianceOffers);
+ requireAlliance(
+ offers.every(
+ ([from, to]) => text(to) && from !== to && byId.has(from) && byId.has(to),
+ ),
+ 'Alliance offers must connect distinct seated players.',
+ );
+ const pair = new Set([ownerId, entrantId]);
+ return {
+ players: seats.map((seat) => {
+ const result: EcazAllianceSeat = {
+ id: seat.id,
+ faction: seat.faction,
+ ally: seat.ally,
+ };
+ if (seat.allySinceTurn !== undefined)
+ result.allySinceTurn = seat.allySinceTurn;
+ if (pair.has(seat.id)) {
+ result.ally = seat.id === ownerId ? entrantId : ownerId;
+ result.allySinceTurn = context.turn;
+ }
+ return result;
+ }),
+ allianceOffers: Object.fromEntries(
+ offers.filter(([from, to]) => !pair.has(from) && !pair.has(to)),
+ ),
+ };
+}
+
+export function ecazAllianceBlock(
+ context: EcazAllianceContext,
+ ownerId: string,
+ entrantId: string,
+): string | null {
+ try {
+ quoteEcazAlliance(context, ownerId, entrantId);
+ return null;
+ } catch (error) {
+ if (error instanceof EcazAllianceError) return error.message;
+ throw error;
+ }
+}
diff --git a/game/ecaz-poison-income.ts b/game/ecaz-poison-income.ts
new file mode 100644
index 00000000..fe06a427
--- /dev/null
+++ b/game/ecaz-poison-income.ts
@@ -0,0 +1,97 @@
+import type { Card } from './cards';
+import type { Game } from './engine';
+import type { EffectiveWeapon } from './effective-weapons';
+import { homeworldContext } from './homeworld-game';
+import { homeworldPopulations } from './homeworld-population';
+import { richeseCardDefinition } from './richese-cards';
+
+export type EcazPoisonDiscard = {
+ card: Card;
+ /** The actual validated plan slot at disposal, not a possible card role. */
+ battleSlot?: 'weapon' | 'defense' | 'leader';
+ /** Retained validated copy result for a played Mirror, never a client claim. */
+ effectiveWeapon?: EffectiveWeapon;
+};
+export class EcazPoisonIncomeError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'EcazPoisonIncomeError';
+ }
+}
+
+/** Current native population only. The caller proves the physical discard and
+ * pays the quote once; neither card custody nor balances are changed here.
+ * Source composition and retained-role contract: ECAZ_POISON_INCOME_RULES.md. */
+export function quoteEcazPoisonIncome(
+ context: Pick,
+ discarded: readonly EcazPoisonDiscard[],
+): { player: string; amount: number; count: number } | null {
+ const ids = new Set();
+ for (const entry of discarded) {
+ if (
+ !entry?.card ||
+ typeof entry.card.id !== 'string' ||
+ !entry.card.id ||
+ ids.has(entry.card.id) ||
+ (entry.battleSlot !== undefined &&
+ !['weapon', 'defense', 'leader'].includes(entry.battleSlot))
+ )
+ throw new EcazPoisonIncomeError(
+ 'Poison income needs distinct physical discards and valid original battle slots.',
+ );
+ ids.add(entry.card.id);
+ }
+ if (!context.homeworlds?.custody || !discarded.length) return null;
+ const home = homeworldPopulations(
+ homeworldContext(context),
+ context.homeworlds.custody,
+ ).find((candidate) => candidate.card === 'ecaz' && candidate.side === 'high');
+ if (!home) return null;
+ let count = 0;
+ for (const { card, battleSlot, effectiveWeapon } of discarded) {
+ const richese = richeseCardDefinition(card);
+ if (richese?.card.effect === 'mirrorWeapon' && battleSlot === 'weapon') {
+ if (
+ !effectiveWeapon ||
+ effectiveWeapon.physicalId !== card.id ||
+ (effectiveWeapon.kind === null
+ ? effectiveWeapon.copiedFrom !== null
+ : typeof effectiveWeapon.copiedFrom !== 'string' ||
+ !effectiveWeapon.copiedFrom ||
+ effectiveWeapon.copiedFrom === card.id ||
+ ![
+ 'projectile',
+ 'poison',
+ 'lasgun',
+ 'poisonBlade',
+ 'poisonTooth',
+ 'chemistry',
+ 'weirdingWay',
+ 'artillery',
+ 'stoneBurner',
+ ].includes(effectiveWeapon.kind))
+ )
+ throw new EcazPoisonIncomeError(
+ 'The discarded Mirror needs its retained validated copied weapon role.',
+ );
+ if (
+ ['poison', 'poisonBlade', 'poisonTooth', 'chemistry'].includes(
+ effectiveWeapon.kind ?? '',
+ )
+ )
+ count++;
+ continue;
+ }
+ // Richese physical identities cannot become ordinary poison through a
+ // forged kind or a name containing "Poison".
+ if (richese || card.id.startsWith('richese-')) continue;
+ if (
+ card.kind === 'poison' ||
+ card.kind === 'poisonBlade' ||
+ card.kind === 'poisonTooth' ||
+ (card.kind === 'chemistry' && battleSlot === 'weapon')
+ )
+ count++;
+ }
+ return count ? { player: home.native, amount: 3 * count, count } : null;
+}
diff --git a/game/engine.ts b/game/engine.ts
index 38e5ddd2..30d09aaa 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,3 +1,81 @@
+import { quoteHomeworldCustody } from './homeworld-custody';
+import { quoteHomeworldShipment, type HomeworldShipmentIntent } from './homeworld-shipment';
+import { quoteGuildHomeworldShipment } from './guild-homeworld-shipment';
+import { quoteJunctionTransport } from './junction-transport';
+import { homeworldMovementForesightBlock, homeworldSpiritualAdvisorLimit, homeworldMobileStrongholdMovementBlock, homeworldNoFieldMovementBlock } from './homeworld-mobility';
+import { currentJunctionOffer, junctionOfferEvent, junctionOfferIntegrity, junctionSponsor, type JunctionOffer } from './junction-offer';
+import { homeworldAllianceBlock } from './homeworld-alliance';
+import { quoteHomeworldSubstitution } from './homeworld-substitution';
+import { combatArmy, combatLocations, combatLocationName, homeworldBattleLocation, quoteCombatBoard, quoteCombatBoardContinuation } from './combat-location';
+import { quoteHomeworldCombatLoss } from './homeworld-combat-loss';
+import { quoteHomeworldBattleRules } from './homeworld-battle-rules';
+import type { HomeworldForces } from './homeworld-custody';
+import { nativeReserveSources } from './homeworld-options';
+import { HOMEWORLD_CARDS } from './homeworld-cards';
+import { lowGrummanRevealBlock } from './homeworld-collection';
+import { ambassadorPhaseAllowed } from './ambassador-phase';
+import { makeHomeworldVictoryReturn, validateHomeworldVictoryReturn, homeworldVictoryOfferSignature,
+ homeworldVictoryObligationSignature, type HomeworldVictoryReturn, type HomeworldVictoryObligation } from './homeworld-victory-return';
+import { quoteHomeworldVictoryReinforcement, quoteHomeworldVictoryReinforcementDestination } from './homeworld-victory-reinforcement';
+import { homeworldArrivalSignature, appendHomeworldArrivalAmbassador, completeHomeworldArrivalAmbassador, validateHomeworldArrival } from './homeworld-arrival';
+import {
+ quoteHomeworldRevivalDeployment,
+ type HomeworldRevivalDeploymentSource,
+ type HomeworldRevivalDeploymentGroup,
+} from './homeworld-revival-deployment';
+import {
+ makeHomeworldRevivalReturn,
+ makeHomeworldRevivalProgress,
+ type HomeworldRevivalProgress,
+ validateHomeworldRevivalReturn,
+ homeworldRevivalResumeSignature,
+ appendHomeworldRevivalAmbassador,
+ homeworldRevivalArrivalSignature,
+ type HomeworldRevivalReturn,
+} from './homeworld-revival-return';
+import { homeworldRevivalDestinations, quoteHomeworldRevivalDestination } from './homeworld-revival-destinations';
+import { terrorEntrySignature, validateTerrorEntrySignature } from './terror-entry-receipt';
+import {
+ homeworldLowBonus,
+ homeworldRevivalKaramaBlock,
+ homeworldSardaukarFreeSupport,
+ homeworldSardaukarGholaBlock,
+ snapshotHomeworldRevival,
+ tleilaxuHomeworldFreeIncomeBlocked,
+ HomeworldBenefitError,
+ type HomeworldRevivalOpening,
+} from './homeworld-benefits';
+import {
+ quoteEcazAlliance,
+ ecazAllianceBlock,
+ EcazAllianceError,
+} from './ecaz-alliance';
+import {
+ createHomeworldCustody,
+ HomeworldCustodyError,
+ type HomeworldCustody,
+} from './homeworld-custody';
+import {
+ quoteGiediCollectionReceipt,
+ validateGiediCollection,
+ type GiediCollection,
+} from './giedi-collection';
+import {
+ homeworldContext,
+ homeworldGameIntegrity,
+ homeworldTable,
+} from './homeworld-game';
+import {
+ quoteEmperorHomeworldMove,
+ EmperorHomeworldMoveError,
+ type EmperorHomeworld,
+} from './homeworld-emperor-move';
+import {
+ quoteNativeReserveWithdrawal,
+ quoteNativeRevivalDeposit,
+ NativeReserveError,
+ type NativeReserveSelections,
+} from './homeworld-native-reserves';
import { validAmbassadorResume } from './ambassador-resume';
import { ecazOccupancyRelation } from './ecaz-occupy';
import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
@@ -119,10 +197,8 @@ import {
type AftermathCancellation,
} from './battle-aftermath-quote';
import {
- quoteBattleBoard,
quoteSpiceCollection,
quoteBattlePhaseAdvance,
- quoteBattleBoardContinuation,
BoardResolutionError,
type AdvisorRelease,
} from './board-resolution-quote';
@@ -292,8 +368,15 @@ import {
import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions';
import { cashInCards } from './choam-karama';
import { saleOptions, quoteSale, type ChoamMarket } from './choam-market';
+import { highKaitainDiscardsAvailable, quoteKaitainDiscards, homeworldWorthlessSaleBlock } from './homeworld-card-economy';
+import { biddingEndError, biddingEndQuiet, biddingEndPubliclyEmpty, type BiddingEnd } from './bidding-end';
+import { quoteHomeworldPaymentIncome, quoteGuildPaymentRounding } from './homeworld-payment-income';
+import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard } from './ecaz-poison-income';
+import { resolveBattleWeapons } from './effective-weapons';
+import { choamGholaEvent, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola';
import {
charityAmount,
+ charityQuote,
charityPayer,
charityMultiplier,
type Inflation,
@@ -319,7 +402,8 @@ import {
eliteRevivalRemaining,
paidForceRevivalCost,
revivalPrevented,
- forceRevivalLimit,
+ normalForceRevivalLimit,
+ freeRevivalRate,
revivalDiscount,
forceRevivalQuote,
forceRevivalRemaining,
@@ -440,6 +524,7 @@ export type PlanField = Exclude<
'support' | 'kwisatz' | 'allyPayment'
>;
export type Battle = {
+ homeworldDefensePassed?: string[];
event?: string;
strongholdCopy?: StrongholdId;
preLeader?: { event: string; ready: string[]; closed: boolean };
@@ -491,6 +576,8 @@ export type Auction = {
allyPayment?: number;
};
export type Decision =
+ | { kind: 'homeworldRevivalDeployment'; player: string; event: string }
+ | { kind: 'caladanReinforcement'; player: string; event: string }
| { kind: 'choamAudit'; player: string; event: string }
| { kind: 'choamAuditPayment'; player: string; event: string }
| {
@@ -555,6 +642,7 @@ export type Decision =
}
| {
kind: 'faceDance';
+ blocked?: string;
player: string;
winner: string;
leader: string | null;
@@ -566,6 +654,7 @@ export type Decision =
| { kind: 'stoneBurner'; player: string; event: string }
| { kind: 'fullPlanOffer'; player: string }
| { kind: 'fullPlanRead'; player: string; target: string }
+ | { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string }
| {
kind: 'guildShipment';
player: string;
@@ -583,6 +672,8 @@ export type Decision =
options: Casualties[];
cards: string[];
}
+ | { kind: 'homeworldDefense'; player: string; event: string }
+ | { kind: 'homeworldExplosion'; player: string; territory: string; event: string; options: HomeworldForces[]; pool: HomeworldForces }
| { kind: 'captureOffer'; player: string; loser: string; territory: string }
| {
kind: 'capturedLeader';
@@ -630,6 +721,11 @@ export type Decision =
}
| { kind: 'battleCards'; player: string; territory: string; cards: string[] };
export type ResponseWindow = {
+ /** Original eligible contributor amounts; private routing evidence, not a new payment. */
+ guildContributions?: number[];
+ guildPaymentProof?: string;
+ /** Bank-paid portion of a frozen Homeworld charity claim. */
+ charityHomeworld?: number;
source?: 'ambassador';
intent?: string;
advisors?: boolean;
@@ -708,7 +804,19 @@ type KaramaUse =
| { kind: 'shipment'; recipient: string; card?: string }
| { kind: 'purchase' }
| { kind: 'auctionPayment' };
+type PendingHomeworldShipment = HomeworldShipmentIntent & {
+ /** Absent in older world-to-world declarations. */
+ route?: 'arrakis';
+ event: string;
+ turn: number;
+ amount: number;
+ elite: number;
+ cost: number;
+ allyPayment: number;
+ pools: ReturnType['sources'] | ReturnType['boardSources'];
+};
type PendingShipment = {
+ homeworldSources?: NativeReserveSelections;
source?: 'ambassador';
ambassadorEvent?: string;
/** Absent in legacy rooms; new declarations cannot resume in a later turn. */
@@ -743,6 +851,8 @@ export type Game = {
batch: FreshDiscardBatch;
continuation:
| { kind: 'ambassador'; entry: NonNullable }
+ | { kind: 'kaitainDiscard'; owner: string; event: string; cost: number; spiceAfter: number }
+ | { kind: 'winnerMandatoryDiscard'; event: string; player: string; territory: string; optional: string[]; commitment: NonNullable }
| {
kind: 'nullentropyDiscard';
player: string;
@@ -900,6 +1010,8 @@ export type Game = {
seller: string | null;
} | null;
pendingAmbassador?: {
+ revivalEvent?: string;
+ victoryEvent?: string;
event: string;
owner: string;
entrant: string;
@@ -910,6 +1022,7 @@ export type Game = {
phase: number;
stage:
| 'offer'
+ | 'allianceReply'
| 'copy'
| 'cards'
| 'income'
@@ -928,7 +1041,7 @@ export type Game = {
player: string;
territory: string;
sector: number;
- amount: 1;
+ amount: 1 | 2;
elite: 0;
};
};
@@ -971,6 +1084,8 @@ export type Game = {
dukeVidal?: DukeState;
dukeAcquisitionTurn?: number;
pendingTerrorEntry?: {
+ /** Original public arrival, absent only in legacy saves. */
+ entrySignature?: string;
token: string;
entrant: string;
territory: string;
@@ -983,6 +1098,8 @@ export type Game = {
| 'guildTransport'
| 'advisor'
| 'wormRide'
+ | 'homeworldRevival'
+ | 'caladanReinforcement'
| 'ambassador';
turn: number;
phase: number;
@@ -1041,11 +1158,20 @@ export type Game = {
cards: Card[];
} | null;
choamMarket?: ChoamMarket | null;
+ biddingEnd?: BiddingEnd | null;
+ ecazPoisonIncome?: { player: string; turn: number; phase: number; amount: number; count: number }[];
+ pendingWinnerDiscards?: { event: string; turn: number; territory: string; player: string; cards: string[]; optional: string[]; signature: string } | null;
+ pendingChoamMarketGhola?: ChoamMarketGhola | null;
choamTradeTurn?: number;
inflation?: Inflation | null;
inflationUsed?: boolean;
inflationAttemptTurn?: number;
choamCharity?: { turn: number; canceled: boolean };
+ homeworldRevival?: HomeworldRevivalOpening | null;
+ homeworldRevivalReturn?: HomeworldRevivalReturn;
+ homeworldRevivalProgress?: HomeworldRevivalProgress;
+ homeworldVictoryReinforcement?: HomeworldVictoryReturn;
+ giediCollection?: GiediCollection;
ecazCollection?: {
turn: number;
event: string;
@@ -1085,6 +1211,7 @@ export type Game = {
order: MovementOrder;
} | null;
pendingIxSubstitution?: {
+ homeworld?: { pool: HomeworldForces; cyborgsLost: number; eliteTanks: number; normalTanks: number; battleLosses: number };
player: string;
territory: string;
losses: Record;
@@ -1106,6 +1233,8 @@ export type Game = {
sandtrout?: boolean;
techTokens?: TechState | null;
strongholdCards?: StrongholdState | null;
+ /** Null/absent disables the module; custody is installed at force placement. */
+ homeworlds?: { custody: HomeworldCustody | null } | null;
pendingTech?: { player: string; loser: string; choices: TechId[] } | null;
summonedBeforeBlow?: boolean;
summonedNexusBeforeRides?: boolean;
@@ -1125,6 +1254,8 @@ export type Game = {
>;
} | null;
pendingShipment?: PendingShipment | null;
+ pendingHomeworldShipment?: PendingHomeworldShipment | null;
+ junctionOffer?: JunctionOffer | null;
pendingExchange?: {
response: ResponseWindow | null;
decision: Decision | null;
@@ -1196,6 +1327,15 @@ export type Game = {
karamaShipping: { player: string; owner: string; card?: string } | null;
stormDialers: string[];
lastBattle: string[];
+ homeworldBattleLoss?: {
+ event: string;
+ territory: string;
+ player: string;
+ kind: 'winner' | 'explosion';
+ pool: HomeworldForces;
+ options: HomeworldForces[];
+ commitment?: { forces: CombatForces; dial: number; support: number };
+ } | null;
lastBattleContext?: {
event: string;
turn: number;
@@ -1203,6 +1343,10 @@ export type Game = {
combatants: string[];
winner: string | null;
result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion' | 'legacy';
+ cardRoles?: Record>>;
+ cardRolesSignature?: string;
+ winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
+ caladanReinforcement?: HomeworldVictoryObligation;
} | null;
auction: Auction | null;
battle: Battle | null;
@@ -1232,6 +1376,21 @@ export type Game = {
aid: Record;
};
export class RuleError extends Error {}
+function homeworldRule(quote: () => T): T {
+ try {
+ return quote();
+ } catch (error) {
+ if (
+ error instanceof HomeworldCustodyError ||
+ error instanceof EmperorHomeworldMoveError ||
+ error instanceof NativeReserveError ||
+ error instanceof EcazPoisonIncomeError ||
+ error instanceof HomeworldBenefitError
+ )
+ throw new RuleError(error.message);
+ throw error;
+ }
+}
function stringField(value: unknown) {
requireRule(typeof value === 'string', 'Expected a string.');
return value;
@@ -1733,6 +1892,9 @@ function savedTransferResponses(g: Game) {
];
}
function transferCardBlock(g: Game, owner: Player, card: Card) {
+ if (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id))
+ return 'Resolve the Homeworld late-defense choice before transferring its Portable Snooper.';
+
if (
g.pendingNullentropy?.player === owner.id &&
g.pendingNullentropy.box === card.id
@@ -2181,7 +2343,7 @@ function draw(g: Game) {
}
return g.deck.shift();
}
-function discard(g: Game, p: Player, id: string) {
+function discard(g: Game, p: Player, id: string, role?: Omit) {
requireRule(
!g.pendingTreacheryDiscard,
'Finish the committed discard continuation before another discard.',
@@ -2196,10 +2358,71 @@ function discard(g: Game, p: Player, id: string) {
);
const c = p.hand.find((c) => c.id === id);
requireRule(c, 'Card is not in your hand.');
+ const income = homeworldRule(() => quoteEcazPoisonIncome(g, [{ card: c, ...role }]));
+ if (income) {
+ const ecaz = getPlayer(g, income.player);
+ requireRule(Number.isSafeInteger(ecaz.spice + income.amount), 'Ecaz income would exceed a valid spice balance.');
+ ecaz.spice += income.amount;
+ // Discard category and resulting private spice must not enter the public
+ // chronicle. The native owner receives a private income receipt instead.
+ (g.ecazPoisonIncome ??= []).push({ ...income, turn: g.turn, phase: g.phase });
+ }
g.discard.push(c);
p.hand = p.hand.filter((c) => c.id !== id);
return c;
}
+function battleDiscardRoles(g: Game, battle: Battle) {
+ const players = [getPlayer(g, battle.attacker), getPlayer(g, battle.defender)];
+ const selected = players.map((p) => ({ weapon: cardOf(p, battle.plans[p.id].weapon),
+ defense: cardOf(p, battle.plans[p.id].defense) }));
+ const weapons = resolveBattleWeapons({ attacker: selected[0], defender: selected[1] });
+ requireRule(!weapons.error, weapons.error ?? 'Invalid effective battle weapons.');
+ const roles: NonNullable['cardRoles']> = {};
+ for (const [index, p] of players.entries()) {
+ roles[p.id] = {};
+ for (const slot of ['weapon', 'defense', 'leader'] as const) {
+ const id = battle.plans[p.id][slot];
+ if (!id || !cardOf(p, id)) continue;
+ roles[p.id][id] = { battleSlot: slot,
+ ...(slot === 'weapon' ? { effectiveWeapon: index === 0 ? weapons.attacker : weapons.defender } : {}) };
+ }
+ }
+ return roles;
+}
+function cleanupDiscardRole(g: Game, player: Player, id: string) {
+ battleCardRolesIntegrity(g);
+ const card = cardOf(player, id)!;
+ const context = g.lastBattleContext;
+ const role = context?.cardRoles?.[player.id]?.[id];
+ if (g.homeworlds?.custody && byFaction(g, 'ecaz') &&
+ (card.kind === 'chemistry' || card.effect === 'mirrorWeapon'))
+ requireRule(context?.turn === g.turn && context.combatants.includes(player.id) && role,
+ 'This conditional weapon cleanup needs its original battle role receipt.');
+ return role;
+}
+function battleCardRolesSignature(context: NonNullable) {
+ const { event, turn, territory, combatants, winner, result, cardRoles } = context;
+ return JSON.stringify({ event, turn, territory, combatants, winner, result, cardRoles });
+}
+function battleCardRolesIntegrity(g: Game) {
+ const context = g.lastBattleContext;
+ if (!context?.cardRoles && !context?.cardRolesSignature) return;
+ requireRule(context.cardRoles && context.cardRolesSignature === battleCardRolesSignature(context),
+ 'The saved battle card roles no longer match their resolved battle receipt.');
+ for (const [player, cards] of Object.entries(context.cardRoles)) {
+ requireRule(context.combatants.includes(player) && cards && !Array.isArray(cards),
+ 'The resolved battle has an invalid card-role owner.');
+ const slots = Object.values(cards).map((role) => role?.battleSlot);
+ requireRule(new Set(slots).size === slots.length &&
+ slots.every((slot) => ['weapon', 'defense', 'leader'].includes(slot ?? '')),
+ 'The resolved battle card slots are missing or duplicated.');
+ for (const [card, role] of Object.entries(cards))
+ requireRule(role.battleSlot === 'weapon'
+ ? role.effectiveWeapon?.physicalId === card
+ : role.effectiveWeapon === undefined,
+ 'The resolved battle weapon descriptor does not match its physical slot.');
+ }
+}
/** A receipt of suspended controls and live pending obligations; never restored
* as game state. Player resources, hands, log and seat control are excluded. */
function nullentropyParentSignature(
@@ -2262,6 +2485,11 @@ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) {
battle: g.battle,
shipmentPromises: g.shipmentPromises,
techTokens: g.techTokens,
+ homeworlds: g.homeworlds,
+ homeworldRevival: g.homeworldRevival,
+ homeworldRevivalReturn: g.homeworldRevivalReturn,
+ ...(g.homeworldRevivalProgress === undefined ? {} : { homeworldRevivalProgress: g.homeworldRevivalProgress }),
+ homeworldVictoryReinforcement: g.homeworldVictoryReinforcement,
dukeVidal: g.dukeVidal,
revivalFreeIncome: g.revivalFreeIncome,
players: g.players.map((p) => ({
@@ -3036,7 +3264,7 @@ function treacheryDiscardIntegrity(g: Game) {
new Set(c.combatants).size === 2 &&
JSON.stringify(c.combatants) === JSON.stringify(g.lastBattle) &&
c.combatants.every((id) => g.players.some((p) => p.id === id)) &&
- gameTerritories(g).some((t) => t.id === c.territory) &&
+ combatLocations(g).some((t) => t.id === c.territory) &&
batch.entries.every(
(e) => e.publicFace && c.combatants.includes(e.discardedBy),
),
@@ -3149,6 +3377,7 @@ function treacheryDiscardIntegrity(g: Game) {
!!c.casualties ===
(c.result === 'normal' &&
(g.advanced ||
+ !!g.homeworlds ||
g.players.find((p) => p.id === c.winner)?.faction ===
'ixians')),
'The mandatory battle discard cause is inconsistent.',
@@ -3156,20 +3385,9 @@ function treacheryDiscardIntegrity(g: Game) {
if (c.casualties) {
const { forces, dial, support, options } = c.casualties;
const winner = g.players.find((p) => p.id === c.winner);
- const total = winner
- ? Object.entries(winner.forces).reduce(
- (n, [key, count]) =>
- n + (splitLocation(key).territory === c.territory ? count : 0),
- 0,
- )
- : 0;
- const elites = winner
- ? Object.entries(winner.elites?.forces ?? {}).reduce(
- (n, [key, count]) =>
- n + (splitLocation(key).territory === c.territory ? count : 0),
- 0,
- )
- : 0;
+ const pool = winner ? combatArmy(g, winner.id, c.territory) : { normal: 0, elite: 0 };
+ const total = pool.normal + pool.elite;
+ const elites = pool.elite;
requireRule(
winner &&
forces &&
@@ -3179,6 +3397,8 @@ function treacheryDiscardIntegrity(g: Game) {
forces.elite >= 0 &&
[1, 2].includes(forces.eliteStrength) &&
typeof forces.freeSupport === 'boolean' &&
+ (forces.eliteFreeSupport === undefined ||
+ typeof forces.eliteFreeSupport === 'boolean') &&
(forces.normalFixedHalf === undefined ||
typeof forces.normalFixedHalf === 'boolean') &&
forces.normal === total - elites &&
@@ -3191,6 +3411,29 @@ function treacheryDiscardIntegrity(g: Game) {
);
}
}
+ } else if (continuation?.kind === 'winnerMandatoryDiscard') {
+ const c = continuation;
+ requireRule(g.phase === 6 && g.lastBattleContext?.event === c.event &&
+ g.lastBattleContext.winner === c.player && g.lastBattleContext.territory === c.territory &&
+ batch.cause === 'battle:winnerMandatory' && !g.pendingWinnerDiscards &&
+ c.commitment?.signature === winnerDiscardSignature(c.commitment) &&
+ c.commitment.event === c.event && c.commitment.player === c.player &&
+ c.commitment.turn === g.turn && c.commitment.territory === c.territory &&
+ JSON.stringify(c.commitment.optional) === JSON.stringify(c.optional) &&
+ JSON.stringify(c.commitment.cards) === JSON.stringify(batch.entries.map((entry) => entry.card.id)) &&
+ batch.entries.every((entry) => entry.discardedBy === c.player && entry.publicFace) &&
+ Array.isArray(c.optional) && new Set(c.optional).size === c.optional.length &&
+ c.optional.every((id) => getPlayer(g, c.player).hand.some((card) => card.id === id)),
+ 'The mandatory winning card discard no longer matches its resolved battle.');
+ } else if (continuation?.kind === 'kaitainDiscard') {
+ const c = continuation;
+ requireRule(g.phase === 3 && g.biddingEnd?.event === c.event &&
+ getPlayer(g, c.owner).faction === 'emperor' && batch.cause === 'kaitain' &&
+ batch.entries.length > 0 && c.cost === 2 * batch.entries.length &&
+ Number.isSafeInteger(c.spiceAfter) && c.spiceAfter >= 0 &&
+ getPlayer(g, c.owner).spice === c.spiceAfter &&
+ batch.entries.every((entry) => entry.discardedBy === c.owner && !entry.publicFace),
+ 'The committed Kaitain discard no longer matches its payment and closing opportunity.');
} else throw new RuleError('Unknown saved discard continuation.');
}
function stageTreacheryDiscard(
@@ -3226,6 +3469,11 @@ function finishTreacheryDiscard(g: Game) {
// Retire before any suffix can draw or create the next semantic discard.
g.resolvedTreacheryDiscardSequence = pending.sequence;
g.pendingTreacheryDiscard = null;
+ if (next.kind === 'winnerMandatoryDiscard') {
+ finishWinner(g, getPlayer(g, next.player), next.territory, next.optional);
+ return;
+ }
+ if (next.kind === 'kaitainDiscard') return;
if (next.kind === 'ambassador') {
const p = getPlayer(g, next.entry.beneficiary!);
if (next.entry.effect === 'ixians') {
@@ -3251,6 +3499,8 @@ function finishTreacheryDiscard(g: Game) {
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
+ if (g.pendingChoamMarketGhola?.discardSequence === pending.sequence)
+ g.pendingChoamMarketGhola.stage = g.response ? 'income' : 'complete';
} else if (next.kind === 'truthtranceDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -3287,6 +3537,56 @@ function place(p: Player, t: string, s: number, n: number, elite = 0) {
p.forces[k] = (p.forces[k] ?? 0) + n;
if (p.elites && elite) p.elites.forces[k] = (p.elites.forces[k] ?? 0) + elite;
}
+function withdrawNativeReserves(
+ g: Game,
+ p: Player,
+ amount: number,
+ elite: number,
+ selections?: NativeReserveSelections,
+) {
+ if (!g.homeworlds?.custody) {
+ requireRule(
+ selections === undefined,
+ 'Homeworld source selection requires the Homeworld module.',
+ );
+ p.reserves -= amount;
+ if (p.elites) p.elites.reserves -= elite;
+ return [];
+ }
+ const result = homeworldRule(() =>
+ quoteNativeReserveWithdrawal(
+ homeworldContext(g),
+ g.homeworlds!.custody!,
+ p.id,
+ { normal: amount - elite, elite },
+ selections,
+ ),
+ );
+ const changed = result.players.find((seat) => seat.id === p.id)!;
+ p.reserves = changed.reserves;
+ if (p.elites) p.elites.reserves = changed.eliteReserves;
+ g.homeworlds.custody = result.state;
+ return result.receipts;
+}
+function addRevivedReserves(g: Game, p: Player, amount: number, elite: number) {
+ if (!g.homeworlds?.custody) {
+ p.reserves += amount;
+ if (p.elites) p.elites.reserves += elite;
+ return;
+ }
+ const result = homeworldRule(() =>
+ quoteNativeRevivalDeposit(
+ homeworldContext(g),
+ g.homeworlds!.custody!,
+ p.id,
+ { normal: amount - elite, elite },
+ ),
+ );
+ const changed = result.players.find((seat) => seat.id === p.id)!;
+ p.reserves = changed.reserves;
+ if (p.elites) p.elites.reserves = changed.eliteReserves;
+ g.homeworlds.custody = result.state;
+}
function kill(
g: Game,
p: Player,
@@ -3316,6 +3616,184 @@ function kill(
p.tanks += amount;
if (battle) p.battleLosses += amount;
}
+function homeworldSavedDecisions(g: Game): Decision[] {
+ const continuation = g.pendingTreacheryDiscard?.continuation;
+ return [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
+ g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
+ continuation && 'resume' in continuation ? continuation.resume : null,
+ ].flatMap((context) => context?.decision ? [context.decision] : []);
+}
+function spiritualAdvisorMaximum(g: Game, player: string, destination = 'polar_sink') {
+ return homeworldRule(() => homeworldSpiritualAdvisorLimit(g, player, destination));
+}
+function homeworldMobilityIntegrity(g: Game) {
+ if (!g.homeworlds?.custody) return;
+ const continuation = g.pendingTreacheryDiscard?.continuation;
+ const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
+ g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
+ continuation && 'resume' in continuation ? continuation.resume : null];
+ const responses = contexts.flatMap((context) => {
+ if (!context) return [];
+ const pending = 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
+ return [context.response, pending?.use?.kind === 'cancel' ? pending.use.response : null];
+ });
+ for (const response of responses) {
+ if (response?.kind === 'advisor') {
+ const destination = splitLocation(response.location ?? 'polar_sink:0');
+ integer(response.amount === undefined ? 1 : response.amount, 1, spiritualAdvisorMaximum(g, response.owner, destination.territory), 'Saved Spiritual Advisor forces');
+ }
+ if (response?.kind === 'atreidesSpice') {
+ const blocked = homeworldRule(() => homeworldMovementForesightBlock(g, response.owner));
+ requireRule(!blocked, blocked ?? 'Caladan prevents the saved foresight response.');
+ }
+ if (response?.kind === 'mobileStronghold') {
+ const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, response.owner));
+ requireRule(!blocked, blocked ?? 'Ix prevents the saved mobile stronghold response.');
+ }
+ }
+}
+function homeworldDefenseIntegrity(g: Game) {
+ const b = g.battle;
+ const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldDefense');
+ if (!decisions.length && b?.homeworldDefensePassed === undefined) return;
+ requireRule(g.status === 'playing' && g.phase === 6 && b?.revealed && b.territory.startsWith('homeworld:') && b.event,
+ 'The Homeworld late-defense frame needs its current revealed battle.');
+ const voters = traitorVoters(g, b);
+ const passed = b.homeworldDefensePassed ?? [];
+ requireRule(Array.isArray(passed) && new Set(passed).size === passed.length &&
+ passed.every((id) => [b.attacker, b.defender].includes(id) && !voters.includes(id)),
+ 'Homeworld late-defense passes must belong to distinct invading combatants.');
+ for (const d of decisions) {
+ requireRule(d.event === b.event && [b.attacker, b.defender].includes(d.player) && !voters.includes(d.player) &&
+ !passed.includes(d.player) && voters.every((id) => b.traitorCalls[id] !== undefined),
+ 'The saved Homeworld late-defense choice does not match its battle owner and timing.');
+ const owner = getPlayer(g, d.player);
+ const card = owner.hand.find(isPortableSnooper);
+ requireRule(card && !b.lateDefense?.[owner.id] && !portableSnooperPlanBlock(b.plans[owner.id], owner.hand, card,
+ b.voice?.target === owner.id ? b.voice : undefined),
+ 'The saved late-defense opportunity needs its held legal Portable Snooper.');
+ }
+}
+function faceDanceReturnBlock(g: Game, winner: string): string | null {
+ return g.advanced && g.homeworlds && getPlayer(g, winner).faction === 'emperor'
+ ? 'Face Dance returning the Emperor’s army awaits the Kaitain/Salusa reserve-placement ruling.' : null;
+}
+function homeworldSubstitutionIntegrity(g: Game) {
+ const pending = g.pendingIxSubstitution;
+ if (!pending?.territory.startsWith('homeworld:')) {
+ requireRule(!pending?.homeworld, 'A native substitution receipt requires a Homeworld battle.');
+ return;
+ }
+ const receipt = pending.homeworld;
+ const p = getPlayer(g, pending.player);
+ const context = g.lastBattleContext;
+ requireRule(receipt && p.faction === 'ixians' && g.phase === 6 && !g.battle &&
+ context?.turn === g.turn && context.territory === pending.territory && context.winner === p.id && context.result === 'normal',
+ 'The Homeworld substitution needs its completed Ixian battle receipt.');
+ const pool = combatArmy(g, p.id, pending.territory);
+ requireRule(receipt.pool && pool.normal === receipt.pool.normal && pool.elite === receipt.pool.elite &&
+ p.elites!.tanks === receipt.eliteTanks && p.tanks - p.elites!.tanks === receipt.normalTanks && p.battleLosses === receipt.battleLosses &&
+ Number.isSafeInteger(receipt.cyborgsLost) && receipt.cyborgsLost > 0 &&
+ Object.keys(pending.losses).length === 1 && pending.losses[pending.territory] === receipt.cyborgsLost,
+ 'The Homeworld substitution no longer matches its exact survivors and Tanks.');
+ const maps = [pending.sources, pending.recover];
+ requireRule(maps.every((map) => map === undefined) || maps.every((map) => map && Object.keys(map).length === 1 && Object.hasOwn(map, pending.territory)),
+ 'A Homeworld substitution must use its exact battle location.');
+ if (pending.sources && pending.recover) {
+ const amount = pending.sources[pending.territory];
+ requireRule(amount === pending.recover[pending.territory], 'The substitution must exchange matching physical counts.');
+ quoteHomeworldSubstitution(homeworldLossContext(g), g.homeworlds!.custody!, {location: pending.territory, player: p.id, amount, cyborgsLost: receipt.cyborgsLost});
+ } else quoteHomeworldLoss(g, p.id, pending.territory, {normal: 0, elite: 0});
+}
+function currentHomeworldBattleRules(g: Game, to: string) {
+ const home = homeworldBattleLocation(g, to);
+ return home ? { native: home.native, card: home.card, side: home.side,
+ nativeForces: { ...home.forces[home.native] } } : null;
+}
+function quoteHomeworldLoss(g: Game, player: string, to: string, losses: HomeworldForces) {
+ requireRule(g.homeworlds?.custody, 'Homeworld casualties require saved custody.');
+ return quoteHomeworldCombatLoss(homeworldLossContext(g), g.homeworlds.custody, { location: to, player, losses });
+}
+function homeworldLossContext(g: Game) {
+ return { advanced: g.advanced, players: g.players.map((p) => {
+ const total = Object.values(p.forces).reduce((a, b) => a + b, 0);
+ const elite = Object.values(p.elites?.forces ?? {}).reduce((a, b) => a + b, 0);
+ return { id: p.id, faction: p.faction, reserves: p.reserves, eliteReserves: p.elites?.reserves ?? 0,
+ tanks: p.tanks, eliteTanks: p.elites?.tanks ?? 0, battleLosses: p.battleLosses,
+ boardForces: { normal: total - elite, elite } };
+ }) };
+}
+function commitHomeworldLoss(g: Game, player: string, to: string, losses: HomeworldForces) {
+ const quote = quoteHomeworldLoss(g, player, to, losses);
+ commitHomeworldResources(g, quote);
+}
+function commitHomeworldResources(g: Game, quote: Pick, 'custody' | 'players'>) {
+ g.homeworlds!.custody = quote.custody;
+ for (const update of quote.players) {
+ const p = getPlayer(g, update.id);
+ p.reserves = update.reserves;
+ p.tanks = update.tanks;
+ p.battleLosses = update.battleLosses;
+ if (p.elites) { p.elites.reserves = update.eliteReserves; p.elites.tanks = update.eliteTanks; }
+ }
+}
+/** The location's exact typed pool remains fixed while casualty/card choices
+ * wait. No population, support, death or payment is replayed on restoration. */
+function homeworldBattleLossIntegrity(g: Game) {
+ const pending = g.homeworldBattleLoss;
+ const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldExplosion' ||
+ (d.kind === 'battleLosses' && d.territory.startsWith('homeworld:')));
+ if (!pending) { requireRule(!decisions.length, 'The saved Homeworld casualty receipt is missing.'); return; }
+ const context = g.lastBattleContext;
+ requireRule(g.phase === 6 && !g.battle && context && context.turn === g.turn &&
+ context.event === pending.event && context.territory === pending.territory &&
+ pending.territory.startsWith('homeworld:') &&
+ ((pending.kind === 'winner' && context.result === 'normal' && context.winner === pending.player) ||
+ (pending.kind === 'explosion' && context.result === 'explosion' && context.winner === null)),
+ 'The Homeworld casualty receipt does not match its resolved battle.');
+ const pool = combatArmy(g, pending.player, pending.territory);
+ requireRule(pending.pool && Object.keys(pending.pool).length === 2 &&
+ pool.normal === pending.pool.normal && pool.elite === pending.pool.elite,
+ 'The Homeworld casualty receipt does not match its exact physical pool.');
+ let options: HomeworldForces[];
+ if (pending.kind === 'explosion') {
+ const rules = currentHomeworldBattleRules(g, pending.territory)!;
+ requireRule(rules.native === pending.player && !pending.commitment,
+ 'Native explosion casualties belong to this Homeworld’s native faction.');
+ options = quoteHomeworldBattleRules(pending.territory, g.players, rules).explosion.options;
+ } else {
+ const commitment = pending.commitment;
+ requireRule(commitment && commitment.forces.normal === pool.normal && commitment.forces.elite === pool.elite,
+ 'The Homeworld winner needs its committed physical forces.');
+ options = casualtyOptions(commitment.forces, commitment.dial, commitment.support).map(({normal, elite}) => ({normal, elite}));
+ }
+ requireRule(options.length > 0 && JSON.stringify(options) === JSON.stringify(pending.options),
+ 'The Homeworld casualty choices do not match the committed outcome.');
+ for (const choice of options) quoteHomeworldLoss(g, pending.player, pending.territory, choice);
+ for (const decision of decisions) {
+ if (decision.kind !== 'homeworldExplosion' && decision.kind !== 'battleLosses') continue;
+ requireRule(decision.player === pending.player && decision.territory === pending.territory &&
+ (decision.kind === 'homeworldExplosion' ? pending.kind === 'explosion' && decision.event === pending.event &&
+ JSON.stringify(decision.pool) === JSON.stringify(pending.pool) : pending.kind === 'winner') &&
+ JSON.stringify(decision.options.map(({normal, elite}) => ({normal, elite}))) === JSON.stringify(options),
+ 'The owned Homeworld casualty decision does not match its receipt.');
+ }
+}
+function settleHomeworldExplosion(g: Game, choice: HomeworldForces, automatic: boolean) {
+ homeworldBattleLossIntegrity(g);
+ homeworldSubstitutionIntegrity(g);
+ homeworldDefenseIntegrity(g);
+ homeworldShipmentIntegrity(g);
+ const pending = g.homeworldBattleLoss!;
+ 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);
+ 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);
+ g.homeworldBattleLoss = null;
+ finishBattle(g);
+}
function killTerritory(
g: Game,
p: Player,
@@ -3324,6 +3802,11 @@ function killTerritory(
battle = false,
noFieldCause?: NoFieldRevealCause,
) {
+ if (t.startsWith('homeworld:')) {
+ requireRule(battle && n === Infinity, 'Homeworld force removal requires an exact combat casualty allocation.');
+ commitHomeworldLoss(g, p.id, t, combatArmy(g, p.id, t));
+ return;
+ }
if (p.noField?.deployed?.location.territory === t) {
requireRule(
noFieldCause,
@@ -3352,18 +3835,18 @@ function combatForces(
p.noField!.tokens.find((token) => token.id === marker.tokenId)!.value,
)
: 0;
- const physical = Object.entries(p.forces).reduce(
- (n, [key, count]) => n + (splitLocation(key).territory === t ? count : 0),
- 0,
- );
- const elite = Object.entries(p.elites?.forces ?? {})
- .filter(([key]) => splitLocation(key).territory === t)
- .reduce((sum, [, n]) => sum + n, 0);
+ const army = combatArmy(g, p.id, t);
+ const physical = army.normal + army.elite;
+ const elite = army.elite;
return {
normal: physical + prospective - elite,
+ ...(homeworldSardaukarFreeSupport(g, p.id)
+ ? { eliteFreeSupport: true }
+ : {}),
normalFixedHalf: p.faction === 'ixians',
elite,
eliteStrength:
+ (!g.advanced && p.faction !== 'ixians') ||
(g.battle?.eliteBlocked?.includes(p.id) &&
!(g.advanced && p.faction === 'ixians')) ||
(p.faction === 'emperor' && opponent.faction === 'fremen')
@@ -3375,6 +3858,10 @@ function combatForces(
};
}
function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
+ if (t.startsWith('homeworld:')) {
+ commitHomeworldLoss(g, p.id, t, { normal: losses.normal, elite: losses.elite });
+ return losses.elite ? { [t]: losses.elite } : {};
+ }
const lostCyborgs: Record = {};
let normal = losses.normal,
elite = losses.elite;
@@ -3401,6 +3888,7 @@ function start(g: Game) {
'Every player must be ready.',
);
requireRule(!g.advanced, 'Advanced rules are still being implemented.');
+ requireRule(!g.homeworlds, 'Homeworld gameplay is still being implemented.');
requireRule(
g.players.every((p) => faction(p.faction).expansion === 'base'),
'Expansion factions are still being implemented.',
@@ -3470,7 +3958,10 @@ function initializeStartingForces(g: Game) {
p.reserves -= 6;
g.mobileStronghold = { location: null };
}
- if (g.advanced && ['emperor', 'fremen'].includes(p.faction))
+ if (
+ (g.advanced || g.homeworlds) &&
+ ['emperor', 'fremen'].includes(p.faction)
+ )
p.elites = {
reserves: p.faction === 'emperor' ? 5 : 3,
tanks: 0,
@@ -3489,6 +3980,8 @@ function initializeStartingForces(g: Game) {
p.reserves -= t[2];
}
}
+ if (g.homeworlds)
+ g.homeworlds.custody = createHomeworldCustody(homeworldContext(g));
}
function dealStartingTreachery(g: Game) {
const ixians = byFaction(g, 'ixians');
@@ -3571,6 +4064,22 @@ function advanceSetup(g: Game) {
}
/** Offline-only test seam. No player action or room API dispatches this function. */
export function initializeBaseGameForAudit(state: Game): Game {
+ return initializeSetupGameForAudit(state, false);
+}
+/** Offline-only seam through the real setup pipeline. Public Homeworld starts remain gated. */
+export function initializeHomeworldGameForAudit(state: Game): Game {
+ requireRule(
+ !!state.homeworlds,
+ 'Enable the Homeworld module in the audit lobby first.',
+ );
+ return initializeSetupGameForAudit(state, true);
+}
+function initializeSetupGameForAudit(state: Game, homeworlds: boolean): Game {
+ homeworldRule(() => homeworldGameIntegrity(state));
+ homeworldBattleLossIntegrity(state);
+ homeworldSubstitutionIntegrity(state);
+ homeworldDefenseIntegrity(state);
+ homeworldShipmentIntegrity(state);
const g = structuredClone(state);
requireRule(
g.status === 'lobby' && !g.setupStage && g.turn === 1 && g.phase === 0,
@@ -3586,13 +4095,18 @@ export function initializeBaseGameForAudit(state: Game): Game {
'The audit initializer requires two through six distinct ready players and an existing host.',
);
requireRule(
- g.expansions.length === 0 &&
+ (homeworlds || g.expansions.length === 0) &&
!g.techTokens &&
!g.strongholdCards &&
+ (homeworlds || !g.homeworlds) &&
g.players.every((p) =>
- FACTIONS.some((f) => f.id === p.faction && f.expansion === 'base'),
+ FACTIONS.some(
+ (f) => f.id === p.faction && (homeworlds || f.expansion === 'base'),
+ ),
),
- 'The audit initializer supports base factions without expansions or optional modules.',
+ homeworlds
+ ? 'The Homeworld setup audit supports implemented deck sets without Tech Tokens or Stronghold Cards.'
+ : 'The audit initializer supports base factions without expansions or optional modules.',
);
requireRule(
!g.deck.length &&
@@ -3617,7 +4131,13 @@ export function initializeBaseGameForAudit(state: Game): Game {
!p.shipped &&
!p.prediction &&
!p.advisorSetup &&
- !p.elites,
+ (!p.elites ||
+ (homeworlds &&
+ p.faction === 'ixians' &&
+ p.elites.reserves === 7 &&
+ p.elites.tanks === 0 &&
+ p.elites.revived === 0 &&
+ Object.keys(p.elites.forces).length === 0)),
),
'The audit initializer cannot redeal or replace existing game pieces.',
);
@@ -4725,7 +5245,9 @@ function pledgeAid(g: Game, p: Player, requested: unknown) {
'Ally funding cannot be reduced until the submitted Silent bids are revealed.',
);
const committed =
- g.phase === 6
+ g.pendingHomeworldShipment?.player === p.ally
+ ? g.pendingHomeworldShipment.allyPayment
+ : g.phase === 6
? (g.battle?.plans[p.ally]?.allyPayment ?? 0)
: g.auction?.bidder === p.ally
? (g.auction.allyPayment ?? 0)
@@ -4755,7 +5277,9 @@ function uncommittedSpice(g: Game, p: Player) {
? g.auction.bid - (g.auction.allyPayment ?? 0)
: 0) -
(battleSupportCost(g, p, g.battle?.plans[p.id]?.support ?? 0) -
- (g.battle?.plans[p.id]?.allyPayment ?? 0))
+ (g.battle?.plans[p.id]?.allyPayment ?? 0)) -
+ (g.pendingHomeworldShipment?.player === p.id
+ ? g.pendingHomeworldShipment.cost - g.pendingHomeworldShipment.allyPayment : 0)
);
}
function contribution(g: Game, p: Player, cost: number, requested?: unknown) {
@@ -5225,9 +5749,13 @@ function ixSubstitutionCancellationQuote(
) {
if (response.kind !== 'ixSubstitution') return null;
try {
+ homeworldSubstitutionIntegrity(g);
+ homeworldDefenseIntegrity(g);
+ homeworldShipmentIntegrity(g);
const quote = quoteIxSubstitutionCancellation(
{
...g,
+ combatLocations: combatLocations(g),
territories: gameTerritories(g),
physicalCards: physicalTreacheryCards(g),
},
@@ -5563,7 +6091,15 @@ function recordFullPlanInspection(g: Game, owner: string) {
);
}
function finishAutomaticDecision(g: Game): boolean {
+ if (g.biddingEnd && biddingEndQuiet(g)) return finishBiddingEnd(g);
+ if (advanceHomeworldReveal(g)) return true;
const decision = g.decision;
+ if (homeworldShipmentAutomatic(g)) {
+ const shipment = g.pendingHomeworldShipment!;
+ g.decision = null;
+ commitHomeworldShipment(g, shipment);
+ return true;
+ }
const market = g.choamMarket;
if (
decision?.kind === 'ecazAmbassador' &&
@@ -5933,6 +6469,60 @@ function movesAllowed(g: Game, p: Player) {
? 2
: 1;
}
+function emperorHomeworldMoveBlock(g: Game, p: Player): string | null {
+ if (!g.homeworlds?.custody || !g.advanced || p.faction !== 'emperor')
+ return 'Moving between Kaitain and Salusa requires the Advanced Emperor Homeworlds.';
+ if (
+ g.status !== 'playing' ||
+ g.phase !== 5 ||
+ g.active !== p.id ||
+ p.moved >= movesAllowed(g, p)
+ )
+ return 'Use an available movement during your own Shipment and Movement turn.';
+ if (
+ g.truthtrance ||
+ g.response ||
+ g.decision ||
+ g.phaseOpening ||
+ g.pendingNullentropy ||
+ g.pendingTreacheryDiscard ||
+ g.pendingShipment ||
+ g.battle
+ )
+ return 'Finish the current interaction before moving between Homeworlds.';
+ if (g.ornithopter)
+ return 'Homeworld transfers during an active Ornithopter card await their movement-group integration.';
+ const richese = byFaction(g, 'richese');
+ if (
+ richese &&
+ !noFieldAllyOfferBlock(g, richese) &&
+ !(
+ g.richeseAllyOpportunity?.turn === g.turn &&
+ g.richeseAllyOpportunity.recipient === p.id
+ )
+ )
+ return 'Wait for Richese to offer or pass its allied shipment opportunity.';
+ if (!p.shipped) {
+ try {
+ checkShipmentPromises(g, p, null);
+ } catch (error) {
+ if (error instanceof RuleError) return error.message;
+ throw error;
+ }
+ }
+ return null;
+}
+function emperorHomeworldMoveEvent(g: Game, p: Player) {
+ return JSON.stringify([
+ g.turn,
+ g.phase,
+ g.active,
+ p.moved,
+ p.shipped,
+ homeworldContext(g),
+ g.homeworlds?.custody,
+ ]);
+}
function ornithopterBlock(
g: Game,
p: Player,
@@ -6158,6 +6748,10 @@ function offerChoamMovement(g: Game, move: MovementOrder) {
else completeMove(g, move);
}
function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
+ if (move.noField) {
+ const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, p.id));
+ requireRule(!blocked, blocked ?? 'The No-Field cannot move.');
+ }
if (move.source === 'ambassador') {
const { beneficiary } = currentFremenAmbassador(g, move.ambassadorEvent);
requireRule(
@@ -6284,7 +6878,8 @@ function validateMovementArrival(
players: g.players,
order: move,
ambassadors: g.ecazAmbassadors?.tokens ?? [],
- terror: g.moritaniTerror?.tokens ?? [],
+ terror: homeworldTerrorEntryBlock(g, move.total)
+ ? [] : (g.moritaniTerror?.tokens ?? []),
flight: g.ornithopter,
controls: {
response: !cancelingChoam && !!g.response,
@@ -6301,6 +6896,10 @@ function validateMovementArrival(
}
}
function completeMove(g: Game, move: MovementOrder) {
+ if (move.noField) {
+ const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, move.player));
+ requireRule(!blocked, blocked ?? 'The No-Field cannot move.');
+ }
const {
group,
eliteGroup,
@@ -6469,7 +7068,10 @@ function returnAmbassadorsIn(g: Game, to: string, reason: string) {
);
}
}
-function ambassadorDiscardBlock(g: Game, p: Player, card: Card) {
+function ambassadorDiscardBlock(g: Game, p: Player, card: Card, effect?: AmbassadorEffect) {
+ if (g.pendingWinnerDiscards?.player === p.id &&
+ [...g.pendingWinnerDiscards.cards, ...g.pendingWinnerDiscards.optional].includes(card.id))
+ return 'This played card is reserved until the winner’s casualties and card cleanup.';
if (g.battle?.lateDefense?.[p.id] === card.id)
return 'This Portable Snooper is already played and reserved for battle cleanup.';
if (giftReserved(g, p.id, card.id))
@@ -6488,6 +7090,8 @@ function ambassadorDiscardBlock(g: Game, p: Player, card: Card) {
const insight = g.battle?.prescience;
if (insight && insight.player !== p.id && insight.value === card.id)
return 'This card is committed to prescience.';
+ if (effect === 'choam')
+ return homeworldRule(() => homeworldWorthlessSaleBlock(g, p.id, card));
return null;
}
function ambassadorEffectBlock(
@@ -6564,6 +7168,7 @@ function openTerritoryEntry(
moritani &&
entrant.id !== moritani.id &&
entrant.id !== moritani.ally &&
+ !homeworldTerrorEntryBlock(g, amount) &&
g.moritaniTerror?.tokens.some(
(t) => t.status === 'placed' && t.location === to,
);
@@ -6601,6 +7206,12 @@ function openTerritoryEntry(
}
function finishAmbassador(g: Game, deferResume = false) {
const pending = g.pendingAmbassador!;
+ if (pending.revivalEvent !== undefined || pending.victoryEvent !== undefined) {
+ const arrival = pending.revivalEvent !== undefined ? g.homeworldRevivalReturn : g.homeworldVictoryReinforcement;
+ requireRule(arrival && ambassadorPhaseAllowed(g),
+ 'This Ambassador has lost its original Homeworld arrival completion.');
+ completeHomeworldArrivalAmbassador(arrival, pending.event);
+ }
if (
pending.beneficiary &&
pending.effect !== 'ecaz' &&
@@ -6632,7 +7243,7 @@ function currentFremenAmbassador(g: Game, event?: string, completed = false) {
entry.effect === 'fremen' &&
entry.turn === g.turn &&
entry.phase === g.phase &&
- (g.phase === 1 || g.phase === 5) &&
+ ambassadorPhaseAllowed(g) &&
(event === undefined || entry.event === event) &&
(completed
? entry.stage === 'arrival' && entry.relocation
@@ -6652,6 +7263,11 @@ function currentFremenAmbassador(g: Game, event?: string, completed = false) {
/** Check historical arrival receipts without replaying movement or inspecting concealed values. */
function ambassadorRelocationIntegrity(g: Game) {
const entry = g.pendingAmbassador;
+ if (entry && (entry.revivalEvent !== undefined || entry.victoryEvent !== undefined ||
+ g.homeworldRevivalReturn?.ambassadors?.some((record) => record.event === entry.event) ||
+ g.homeworldVictoryReinforcement?.ambassadors?.some((record) => record.event === entry.event)))
+ requireRule(ambassadorPhaseAllowed(g),
+ 'This Ambassador has lost its original Homeworld revival arrival.');
if (entry)
requireRule(
validAmbassadorResume(g, entry),
@@ -6966,7 +7582,7 @@ function currentGuildAmbassador(g: Game, event?: string) {
entry.stage === 'ship' &&
entry.turn === g.turn &&
entry.phase === g.phase &&
- (g.phase === 1 || g.phase === 5) &&
+ ambassadorPhaseAllowed(g) &&
(event === undefined || event === entry.event) &&
(beneficiary.id === owner.id ||
(owner.ally === beneficiary.id && beneficiary.ally === owner.id)) &&
@@ -7025,6 +7641,7 @@ function validateAmbassadorShipmentArrival(
order.player,
'reserves',
order.territory,
+ order.amount,
);
requireRule(!blocked, blocked ?? 'This shipment arrival is unavailable.');
}
@@ -7122,7 +7739,7 @@ function continueGuildAmbassadorShipment(g: Game, event: string) {
if (receipt.next === 'accompany') {
receipt.next = 'advisor';
const bg = byFaction(g, 'beneGesserit');
- if (p.faction !== 'fremen' && bg && bg.id !== p.id && bg.reserves > 0) {
+ if (p.faction !== 'fremen' && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0) {
g.decision = {
kind: 'advisor',
player: bg.id,
@@ -7168,6 +7785,7 @@ function validateGuildAdvisorEntry(
moritani &&
moritani.id !== bg.id &&
moritani.ally !== bg.id &&
+ !homeworldTerrorEntryBlock(g, quote.amount) &&
g.moritaniTerror?.tokens.some(
(t) => t.status === 'placed' && t.location === quote.territory,
);
@@ -7195,9 +7813,11 @@ function guildAdvisorChoices(g: Game, player: string) {
accompany: true,
territory: order.territory,
sector,
+ amount: 1,
}))
: []),
- { accompany: false, territory: 'polar_sink', sector: 0 },
+ ...Array.from({length: Math.max(1, spiritualAdvisorMaximum(g, player))}, (_, index) =>
+ ({accompany: false, territory: 'polar_sink', sector: 0, amount: index + 1})),
];
return {
choices: candidates.map((choice) => {
@@ -7252,12 +7872,13 @@ function finishGuildAmbassadorAdvisor(
accompany: at.territory !== 'polar_sink',
sector: at.sector,
territory: at.territory,
+ amount: response.amount ?? 1,
}),
);
validateGuildAdvisorEntry(g, quote);
const bg = getPlayer(g, quote.player);
- bg.reserves--;
- place(bg, quote.territory, quote.sector, 1);
+ withdrawNativeReserves(g, bg, quote.amount, 0);
+ place(bg, quote.territory, quote.sector, quote.amount);
techIncome(g, 'heighliners', bg);
if (quote.advisors)
(bg.advisors ??= {})[quote.territory] = { lockedTurn: g.turn };
@@ -7267,16 +7888,23 @@ function finishGuildAmbassadorAdvisor(
player: bg.id,
territory: quote.territory,
sector: quote.sector,
- amount: 1,
+ amount: quote.amount,
elite: 0,
};
log(
g,
- `${bg.name} sent one reserve force to ${territory(quote.territory).name}, sector ${quote.sector}, for free after the Guild Ambassador shipment.`,
+ `${bg.name} sent ${quote.amount} free reserve ${quote.amount === 1 ? 'force' : 'forces'} to ${territory(quote.territory).name}, sector ${quote.sector}, after the Guild Ambassador shipment${quote.amount === 2 ? ' using high-population Wallach IX' : ''}.`,
{ faction: bg.faction, name: 'Spiritual Advisors' },
);
if (guildAdvisorAmbassador(g, quote)) {
const parent = g.pendingAmbassador!;
+ const arrivalTag = parent.revivalEvent !== undefined ? 'revivalEvent' :
+ parent.victoryEvent !== undefined ? 'victoryEvent' : undefined;
+ const arrival = arrivalTag === 'revivalEvent' ? g.homeworldRevivalReturn :
+ arrivalTag === 'victoryEvent' ? g.homeworldVictoryReinforcement : undefined;
+ if (arrivalTag !== undefined)
+ requireRule(arrival && ambassadorPhaseAllowed(g),
+ 'This Ambassador accompaniment has lost its original Homeworld arrival.');
const resume = parent.resume;
const wormRider =
parent.wormRider ?? (resume === 'wormRide' ? parent.entrant : undefined);
@@ -7289,7 +7917,7 @@ function finishGuildAmbassadorAdvisor(
bg,
quote.territory,
quote.sector,
- 1,
+ quote.amount,
0,
'advisor',
resume,
@@ -7303,13 +7931,19 @@ function finishGuildAmbassadorAdvisor(
territory: order.territory,
sector: order.sector,
};
+ if (arrival && arrivalTag) {
+ requireRule(validAmbassadorResume(g, g.pendingAmbassador!),
+ 'The accompanying Ambassador has lost its completed Guild shipment.');
+ g.pendingAmbassador![arrivalTag] = arrival.event;
+ appendHomeworldArrivalAmbassador(arrival, g.pendingAmbassador!, parent.event);
+ }
} else if (
!openTerrorEntry(
g,
bg,
quote.territory,
quote.sector,
- 1,
+ quote.amount,
0,
'ambassador',
'ambassador',
@@ -7347,6 +7981,7 @@ function ambassadorRelocationArrivalBlock(
player: string,
origin: string,
to: string,
+ entering: number,
): string | null {
const mover = getPlayer(g, player);
if (origin === to) return null;
@@ -7358,6 +7993,7 @@ function ambassadorRelocationArrivalBlock(
moritani &&
mover.id !== moritani.id &&
mover.id !== moritani.ally &&
+ !homeworldTerrorEntryBlock(g, entering) &&
g.moritaniTerror?.tokens.some(
(token) => token.status === 'placed' && token.location === to,
);
@@ -7374,16 +8010,22 @@ function validateAmbassadorRelocationArrival(
move.player,
move.origin,
move.to,
+ move.total,
);
requireRule(!blocked, blocked ?? 'This arrival is unavailable.');
}
+function ambassadorArrivalChoices(g: Game, player: string, origin: string, to: string) {
+ const blocked = ambassadorRelocationArrivalBlock(g, player, origin, to, 1);
+ const largerBlocked = ambassadorRelocationArrivalBlock(g, player, origin, to, 3);
+ return { blocked, maximum: !blocked && largerBlocked ? 2 : null };
+}
function ambassadorRelocationMovement(g: Game, player: string) {
return {
sources: fremenAmbassadorMovement(g, player).sources.map((source) => ({
...source,
destinations: source.destinations.map((destination) => ({
...destination,
- blocked: ambassadorRelocationArrivalBlock(
+ ...ambassadorArrivalChoices(
g,
player,
source.territory,
@@ -7500,7 +8142,7 @@ function validateAmbassadorPurchaseResponse(g: Game, response: ResponseWindow) {
receipt &&
entry.turn === g.turn &&
entry.phase === g.phase &&
- (g.phase === 1 || g.phase === 5) &&
+ ambassadorPhaseAllowed(g) &&
response.intent === entry.event &&
entry.effect === 'richese' &&
entry.beneficiary === receipt.buyer &&
@@ -7551,6 +8193,72 @@ function ambassadorPurchaseBonus(g: Game) {
};
} else finishAmbassador(g);
}
+/** The payer has already paid. Only credit the eligible faction share once. */
+function creditFactionPayment(g: Game, ownerId: string, kind: 'shipment' | 'treachery', gross: number) {
+ const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, ownerId, kind, gross));
+ const owner = getPlayer(g, ownerId);
+ requireRule(Number.isSafeInteger(owner.spice + payment.income), 'The faction payment would overflow its spice balance.');
+ owner.spice += payment.income;
+ return payment;
+}
+function currentFactionPayment(g: Game) {
+ const response = g.response;
+ if (!g.homeworlds?.custody || !response) return null;
+ const kind = response.kind === 'guildIncome' ? 'shipment' :
+ ['emperorIncome', 'richesePurchaseIncome'].includes(response.kind) ? 'treachery' : null;
+ if (!kind) return null;
+ if (response.kind === 'emperorIncome' && response.source === 'ambassador')
+ validateAmbassadorPurchaseResponse(g, response);
+ if (response.kind === 'richesePurchaseIncome') {
+ const pending = g.pendingRichesePurchaseIncome;
+ requireRule(pending && pending.owner === response.owner && pending.turn === g.turn &&
+ pending.phase === g.phase && response.amount === 3,
+ 'This Richese purchase income is no longer current.');
+ }
+ const gross = response.kind === 'emperorIncome'
+ ? response.source === 'ambassador' ? response.amount! : g.currentAuctionSale?.amount ?? g.auction?.bid
+ : response.amount;
+ requireRule(gross !== undefined, 'The faction payment is missing its original amount.');
+ if (kind === 'shipment' &&
+ (Object.hasOwn(response, 'guildContributions') || Object.hasOwn(response, 'guildPaymentProof'))) {
+ requireRule(Array.isArray(response.guildContributions) &&
+ response.guildPaymentProof === guildPaymentSignature(g, response.owner, gross, response.guildContributions),
+ 'The saved Guild payment no longer matches its original contributor receipt.');
+ validateGuildPaymentRounding(g, response.owner, gross, response.guildContributions);
+ }
+ return { owner: response.owner, kind,
+ ...homeworldRule(() => quoteHomeworldPaymentIncome(g, response.owner, kind, gross)) };
+}
+function validateGuildPaymentRounding(g: Game, owner: string, gross: number, contributions: number[]) {
+ if (!g.homeworlds?.custody) return;
+ const rounding = homeworldRule(() => quoteGuildPaymentRounding(gross, contributions));
+ const payment = homeworldRule(() => quoteHomeworldPaymentIncome(g, owner, 'shipment', gross));
+ requireRule(!payment.low || rounding.unambiguous,
+ 'Low Junction rounding for two odd allied contributions awaits a ruling. Choose a different payment split.');
+}
+function shipmentIncomeContributions(g: Game, p: Player, cost: number, allyPayment: number) {
+ const guild = byFaction(g, 'guild');
+ if (!guild || g.karamaShipping?.player === p.id) return [];
+ return [p.id === guild.id ? 0 : cost - allyPayment,
+ p.ally === guild.id ? 0 : allyPayment].filter(amount => amount > 0);
+}
+function checkShipmentIncomeRounding(g: Game, p: Player, cost: number, allyPayment: number) {
+ const guild = byFaction(g, 'guild');
+ if (!guild) return;
+ const amounts = shipmentIncomeContributions(g, p, cost, allyPayment);
+ validateGuildPaymentRounding(g, guild.id, amounts.reduce((sum, amount) => sum + amount, 0), amounts);
+}
+function guildPaymentResponse(g: Game, owner: string, amounts: number[]): ResponseWindow {
+ const contributions = amounts.filter(amount => amount > 0);
+ const amount = contributions.reduce((sum, value) => sum + value, 0);
+ validateGuildPaymentRounding(g, owner, amount, contributions);
+ return { kind: 'guildIncome', owner, amount, passed: [],
+ ...(g.homeworlds?.custody ? { guildContributions: contributions,
+ guildPaymentProof: guildPaymentSignature(g, owner, amount, contributions) } : {}) };
+}
+function guildPaymentSignature(g: Game, owner: string, amount: number, contributions: number[]) {
+ return JSON.stringify({ turn: g.turn, phase: g.phase, owner, amount, contributions });
+}
function finishAmbassadorPurchaseResponse(
g: Game,
response: ResponseWindow,
@@ -7558,12 +8266,12 @@ function finishAmbassadorPurchaseResponse(
) {
const { receipt, buyer } = validateAmbassadorPurchaseResponse(g, response);
if (response.kind === 'emperorIncome') {
- if (!canceled) getPlayer(g, response.owner).spice += receipt.amount;
+ const payment = !canceled ? creditFactionPayment(g, response.owner, 'treachery', receipt.amount) : null;
log(
g,
canceled
? 'Karama prevented Emperor income from the Richese Ambassador purchase. The three-spice payment stays in the bank; the buyer keeps the card.'
- : `${getPlayer(g, response.owner).name} collected the three-spice Richese Ambassador purchase payment through Emperor income.`,
+ : `${getPlayer(g, response.owner).name} collected ${payment!.income} spice from the three-spice Richese Ambassador purchase payment.${payment!.bank ? ` Low-population Kaitain leaves ${payment!.bank} spice in the bank.` : ''}`,
{
faction: 'emperor',
name: canceled ? 'Purchase income prevented' : 'Card purchase income',
@@ -7662,7 +8370,7 @@ function resolveAmbassadorEffect(g: Game) {
}
if (
effect === 'ixians' ||
- (effect === 'choam' && p.hand.some((c) => !ambassadorDiscardBlock(g, p, c)))
+ (effect === 'choam' && p.hand.some((c) => !ambassadorDiscardBlock(g, p, c, effect)))
) {
entry.stage = 'cards';
g.decision = { kind: 'ecazAmbassador', player: p.id };
@@ -7705,6 +8413,58 @@ function resolveAmbassadorEffect(g: Game) {
}
finishAmbassador(g);
}
+function ecazAllianceQuote(g: Game, owner: string, entrant: string) {
+ const blocked = homeworldAllianceReason(g, owner, entrant);
+ requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
+ try {
+ return quoteEcazAlliance(g, owner, entrant);
+ } catch (error) {
+ if (error instanceof EcazAllianceError) throw new RuleError(error.message);
+ throw error;
+ }
+}
+function ecazAllianceIntegrity(g: Game) {
+ const entry = g.pendingAmbassador;
+ const continuation = g.pendingTreacheryDiscard?.continuation;
+ const controls = [
+ g,
+ g.pendingExchange,
+ g.pendingRicheseGift?.resume,
+ g.pendingNullentropy?.resume,
+ g.pendingRichesePurchaseIncome?.resume,
+ g.summonedWorm?.resume,
+ continuation && 'resume' in continuation ? continuation.resume : null,
+ ];
+ if (!entry) {
+ requireRule(
+ !controls.some((control) => control?.decision?.kind === 'ecazAmbassador'),
+ 'The Ambassador decision has lost its entry event.',
+ );
+ return;
+ }
+ if (entry.stage !== 'allianceReply') return;
+ const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry.token);
+ requireRule(
+ g.status === 'playing' &&
+ entry.turn === g.turn &&
+ entry.phase === g.phase &&
+ ambassadorPhaseAllowed(g) &&
+ typeof entry.event === 'string' &&
+ entry.event.length > 0 &&
+ entry.effect === 'ecaz' &&
+ entry.beneficiary === entry.entrant &&
+ token?.effect === 'ecaz' &&
+ token.zone === 'supply' &&
+ token.location === null &&
+ controls.some(
+ (control) =>
+ control?.decision?.kind === 'ecazAmbassador' &&
+ control.decision.player === entry.entrant,
+ ),
+ 'The Ecaz alliance reply has lost its original Ambassador or decision owner.',
+ );
+ ecazAllianceQuote(g, entry.owner, entry.entrant);
+}
function decideAmbassador(g: Game, p: Player, action: Action) {
const entry = g.pendingAmbassador;
requireRule(
@@ -7720,6 +8480,34 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
p.id === (entry.stage === 'offer' ? owner.id : entry.beneficiary),
'The Ambassador choice belongs to another player.',
);
+ if (entry.stage === 'allianceReply') {
+ requireRule(
+ typeof action.accept === 'boolean',
+ 'Accept or refuse the Ecaz alliance offer.',
+ );
+ const quote = ecazAllianceQuote(g, owner.id, entrant.id);
+ if (action.accept) {
+ for (const member of quote.players) {
+ if (member.id !== owner.id && member.id !== entrant.id) continue;
+ const target = getPlayer(g, member.id);
+ target.ally = member.ally;
+ target.allySinceTurn = member.allySinceTurn;
+ }
+ g.allianceOffers = quote.allianceOffers;
+ g.ready = [];
+ log(
+ g,
+ `${owner.name} and ${entrant.name} formed an alliance through the Ecaz Ambassador. Both were unallied and ${entrant.name} accepted. Their alliance abilities apply immediately; the entrant’s remaining actions resume.`,
+ { faction: 'ecaz', name: 'Ambassador alliance' },
+ );
+ } else
+ log(
+ g,
+ `${entrant.name} refused the Ecaz Ambassador alliance. No alliance or Duke transfer occurred; the triggered token remains in Ecaz’s supply and the entrant’s remaining actions resume.`,
+ );
+ finishAmbassador(g);
+ return;
+ }
if (entry.stage === 'offer') {
const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry.token);
requireRule(
@@ -7738,6 +8526,34 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
action.trigger === true,
'Trigger the Ambassador or leave it in place.',
);
+ if (token.effect === 'ecaz' && action.choice === 'alliance') {
+ requireRule(
+ action.beneficiary === owner.id,
+ 'Ecaz must offer its own Ambassador alliance.',
+ );
+ ecazAllianceQuote(g, owner.id, entrant.id);
+ requireRule(
+ canTriggerAmbassador({
+ owner: owner.id,
+ ally: owner.ally,
+ entrant: entrant.id,
+ entrantFaction: entrant.faction,
+ advisors: isAdvisor(entrant, entry.territory),
+ effect: token.effect,
+ }),
+ 'This entrant cannot trigger the Ecaz Ambassador.',
+ );
+ entry.effect = 'ecaz';
+ entry.beneficiary = entrant.id;
+ entry.stage = 'allianceReply';
+ g.ecazAmbassadors = triggerAmbassador(g.ecazAmbassadors!, token.id);
+ g.decision = { kind: 'ecazAmbassador', player: entrant.id };
+ log(
+ g,
+ `${owner.name} offered an alliance to ${entrant.name} through the Ecaz Ambassador. The reusable token returned to supply; the five random Ambassadors are unchanged. Only the entrant can accept or refuse. Duke Vidal has not changed hands.`,
+ );
+ return;
+ }
const beneficiary = getPlayer(g, stringField(action.beneficiary));
requireRule(
beneficiary.id === owner.id ||
@@ -7857,7 +8673,7 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
for (const id of ids) {
const card = p.hand.find((c) => c.id === id);
requireRule(card, 'Choose a card in your own hand.');
- const blocked = ambassadorDiscardBlock(g, p, card);
+ const blocked = ambassadorDiscardBlock(g, p, card, entry.effect);
requireRule(!blocked, blocked ?? 'This card cannot be discarded.');
}
const entries = ids.map((id) => ({
@@ -7886,6 +8702,28 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
}
}
+/** Card interruptions keep the live entry; fresh discard owns its one suspended copy. */
+function terrorEntryIntegrity(g: Game) {
+ const continuation = g.pendingTreacheryDiscard?.continuation;
+ const entries = [g.pendingTerrorEntry,
+ continuation?.kind === 'terrorDiscard' ? continuation.entry : null];
+ for (const entry of entries) {
+ if (!entry) continue;
+ homeworldRule(() => validateTerrorEntrySignature(entry));
+ if (entry.entrySignature === undefined) continue;
+ requireRule(entry.turn === g.turn && entry.phase === g.phase &&
+ g.players.some((p) => p.id === entry.entrant && p.faction !== 'moritani') &&
+ g.players.filter((p) => p.faction === 'moritani').length === 1,
+ 'The saved Terror entry has lost its original turn or seated participants.');
+ }
+}
+/** Public original batch size; concealed No-Field markers count as one. */
+function homeworldTerrorEntryBlock(g: Game, entering: number): string | null {
+ const owner = byFaction(g, 'moritani');
+ return owner
+ ? homeworldRule(() => lowGrummanRevealBlock(g, owner.id, entering))
+ : null;
+}
/** Entry is already paid and committed; a reaction must never replay its original action. */
function openTerrorEntry(
g: Game,
@@ -7906,7 +8744,8 @@ function openTerrorEntry(
!moritani ||
!token ||
entrant.id === moritani.id ||
- entrant.id === moritani.ally
+ entrant.id === moritani.ally ||
+ homeworldTerrorEntryBlock(g, amount)
)
return false;
requireRule(!g.pendingTerrorEntry, 'Resolve the pending Terror entry first.');
@@ -7928,6 +8767,7 @@ function openTerrorEntry(
resume,
...(ambassadorEvent ? { ambassadorEvent } : {}),
};
+ g.pendingTerrorEntry.entrySignature = terrorEntrySignature(g.pendingTerrorEntry);
g.decision = {
kind: 'moritaniTerror',
player: moritani.id,
@@ -7974,6 +8814,8 @@ function terrorRevealBlocked(
entry: NonNullable,
kind: TerrorKind,
): string | null {
+ const homeworldBlock = homeworldTerrorEntryBlock(g, entry.amount);
+ if (homeworldBlock) return homeworldBlock;
if (kind === 'robbery' || kind === 'sabotage' || kind === 'sneakAttack')
return null;
if (kind !== 'assassination')
@@ -8048,6 +8890,8 @@ function terrorAllianceBlocked(
return 'Karama prevented this alliance opportunity; you may still reveal the token or leave it hidden.';
const entrant = getPlayer(g, entry.entrant);
const owner = byFaction(g, 'moritani')!;
+ const homeworldBlock = homeworldAllianceReason(g, owner.id, entrant.id);
+ if (homeworldBlock) return homeworldBlock;
if (entrant.faction === 'ecaz')
return 'Enemy of My Enemy cannot be offered to Ecaz.';
if (entrant.id === owner.id || entrant.id === owner.ally)
@@ -8058,6 +8902,8 @@ function terrorAllianceBlocked(
}
/** Return only unspent escrow for the pairs this special alliance breaks. */
function formTerrorAlliance(g: Game, owner: Player, entrant: Player) {
+ const blocked = homeworldAllianceReason(g, owner.id, entrant.id);
+ requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
const changed = new Set([owner.id, entrant.id]);
for (const member of [owner, entrant]) {
if (member.ally) getPlayer(g, member.ally).ally = null;
@@ -8346,7 +9192,7 @@ function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
delete getPlayer(g, entry.player).advisors?.[entry.territory];
}
export function battles(g: Game) {
- const quote = boardResolution(() => quoteBattleBoard(g));
+ const quote = boardResolution(() => quoteCombatBoard(g));
applyAdvisorReleases(g, quote.released);
return quote.battles;
}
@@ -8374,6 +9220,7 @@ function offerMoritaniDuke(g: Game) {
.filter(
(battle) =>
[battle.attacker, battle.defender].includes(owner.id) &&
+ !battle.territory.startsWith('homeworld:') &&
territory(battle.territory).type === 'stronghold' &&
getPlayer(
g,
@@ -8697,7 +9544,7 @@ function finishAdvisorReaction(
},
) {
const bg = byFaction(g, 'beneGesserit')!;
- if (details.advisorFollowup && bg.reserves > 0)
+ if (details.advisorFollowup && spiritualAdvisorMaximum(g, bg.id) > 0)
g.decision = { kind: 'advisor', player: bg.id, ...details.advisorFollowup };
else if (details.advisorResume === 'wormRide') nextWormRide(g);
else if (details.advisorResume === 'declaration')
@@ -8786,6 +9633,99 @@ function transferTech(g: Game, id: TechId, winner: string) {
`${getPlayer(g, winner).name} took ${TECH_TOKENS.find((t) => t.id === id)!.name} after winning the battle.`,
);
}
+function decideChoamMarket(g: Game, decision: Extract, action: Action) {
+ const market = g.choamMarket!;
+ const owner = getPlayer(g, market.owner);
+ if (decision.kind === 'choamMarket') {
+ if (action.done === true) {
+ finishChoamMarket(g);
+ } else if (action.mode === 'sell') {
+ const sale = quoteSale(
+ owner.hand,
+ action.card,
+ action.witness,
+ market.blocked,
+ );
+ requireRule(
+ sale,
+ 'Choose an available Worthless card or surplus exact duplicate.',
+ );
+ const card = owner.hand.find((c) => c.id === sale.card)!;
+ const blocked = homeworldRule(() => homeworldWorthlessSaleBlock(g, owner.id, card));
+ requireRule(!blocked, blocked ?? 'This card sale is unavailable.');
+ market.sale = sale;
+ log(
+ g,
+ `${owner.name} revealed ${sale.witness ? 'two copies of ' : ''}${card.name} and offered one for ${sale.price} spice.`,
+ );
+ g.response = { kind: 'choamSale', owner: owner.id, passed: [] };
+ } else {
+ requireRule(
+ action.mode === 'trade',
+ 'Sell a card, offer a trade or finish the phase.',
+ );
+ requireRule(
+ g.choamTradeTurn !== g.turn,
+ 'CHOAM may exchange one card with its ally only once per turn.',
+ );
+ const ally = g.players.find(
+ (p) => p.id === owner.ally && p.ally === owner.id,
+ );
+ requireRule(ally, 'You need an ally for a two-way trade.');
+ requireRule(
+ owner.hand.some((c) => c.id === action.card),
+ 'Offer a card from your own hand.',
+ );
+ market.trade = { ally: ally.id, offered: stringField(action.card) };
+ market.tradeAttempted = true;
+ g.decision = { kind: 'choamTradeReply', player: ally.id };
+ }
+ } else {
+ const trade = market.trade!;
+ const ally = getPlayer(g, trade.ally);
+ if (action.decline === true) {
+ delete market.trade;
+ resumeChoamMarket(g);
+ } else if (decision.kind === 'choamTradeReply') {
+ requireRule(
+ ally.hand.some((c) => c.id === action.card),
+ 'Choose one of your cards to return, or decline.',
+ );
+ trade.returned = stringField(action.card);
+ g.decision = { kind: 'choamTradeConfirm', player: owner.id };
+ } else {
+ requireRule(
+ action.accept === true,
+ 'Confirm the two-way exchange or decline.',
+ );
+ const offered = owner.hand.find((c) => c.id === trade.offered);
+ const returned = ally.hand.find((c) => c.id === trade.returned);
+ if (
+ offered &&
+ returned &&
+ owner.ally === ally.id &&
+ ally.ally === owner.id &&
+ g.choamTradeTurn !== g.turn
+ ) {
+ owner.hand = owner.hand.filter((c) => c.id !== offered.id);
+ ally.hand = ally.hand.filter((c) => c.id !== returned.id);
+ owner.hand.push(returned);
+ ally.hand.push(offered);
+ g.choamTradeTurn = g.turn;
+ log(
+ g,
+ `${owner.name} and ${ally.name} exchanged one treachery card each.`,
+ );
+ } else
+ log(
+ g,
+ 'The proposed card exchange is no longer possible; both hands remain unchanged.',
+ );
+ delete market.trade;
+ resumeChoamMarket(g);
+ }
+ }
+}
function openChoamMarket(g: Game, resume: ChoamMarket['resume']) {
const choam = byFaction(g, 'choam');
if (g.status !== 'playing' || !choam) return false;
@@ -8794,9 +9734,15 @@ function openChoamMarket(g: Game, resume: ChoamMarket['resume']) {
return true;
}
function resumeChoamMarket(g: Game) {
- g.decision = { kind: 'choamMarket', player: g.choamMarket!.owner };
+ g.decision = g.biddingEnd ? null : { kind: 'choamMarket', player: g.choamMarket!.owner };
}
function finishChoamMarket(g: Game) {
+ if (g.biddingEnd || openBiddingEnd(g)) {
+ const owner = g.choamMarket!.owner;
+ if (!g.biddingEnd!.ready.includes(owner)) g.biddingEnd!.ready.push(owner);
+ g.decision = null;
+ return;
+ }
const resume = g.choamMarket!.resume;
g.choamMarket = null;
g.decision = null;
@@ -8804,8 +9750,70 @@ function finishChoamMarket(g: Game) {
else advancePhase(g);
}
function nextPhase(g: Game) {
+ if (openBiddingEnd(g)) return;
if (!openChoamMarket(g, 'phase')) advancePhase(g);
}
+function openBiddingEnd(g: Game): boolean {
+ const emperor = byFaction(g, 'emperor');
+ if (g.status !== 'playing' || g.phase !== 3 || !g.homeworlds?.custody || !emperor) return false;
+ if (g.biddingEnd) return true;
+ const choam = byFaction(g, 'choam');
+ g.biddingEnd = { event: crypto.randomUUID(), turn: g.turn,
+ owners: [emperor.id, ...(choam ? [choam.id] : [])], ready: [] };
+ if (choam) g.choamMarket ??= { owner: choam.id, resume: 'phase', blocked: [] };
+ g.decision = null;
+ g.active = null;
+ log(g, 'Bidding is complete. Kaitain disposal and any CHOAM market actions share this closing opportunity; the owners may act in either order.');
+ return true;
+}
+function finishBiddingEnd(g: Game): boolean {
+ if (!biddingEndQuiet(g)) return false;
+ let changed = false;
+ const end = g.biddingEnd!;
+ for (const id of end.owners) {
+ if (end.ready.includes(id) || !biddingEndPubliclyEmpty(g, id)) continue;
+ end.ready.push(id);
+ changed = true;
+ log(g, `${getPlayer(g, id).name} has no cards and no incoming exchange is available. Their end-of-Bidding opportunity finishes automatically.`);
+ }
+ if (end.owners.every((id) => end.ready.includes(id))) {
+ g.biddingEnd = null;
+ g.choamMarket = null;
+ advancePhase(g);
+ return true;
+ }
+ return changed;
+}
+function decideBiddingEnd(g: Game, p: Player, action: Action) {
+ const end = g.biddingEnd;
+ requireRule(end && action.event === end.event && end.owners.includes(p.id),
+ 'Choose the current end-of-Bidding opportunity for your faction.');
+ requireRule(biddingEndQuiet(g), 'Finish the pending interaction before an end-of-Bidding action.');
+ if (action.mode === 'ready') {
+ requireRule(!end.ready.includes(p.id), 'You are already ready to finish Bidding.');
+ end.ready.push(p.id);
+ log(g, `${p.name} is ready to finish Bidding. They may still act if another closing opportunity remains.`);
+ } else if (action.mode === 'discard') {
+ const quote = homeworldRule(() => quoteKaitainDiscards(g, p.id, action.cards));
+ requireRule(quote.cards.length > 0, 'Choose cards to discard, or finish your opportunity.');
+ for (const card of quote.cards) {
+ const blocked = ambassadorDiscardBlock(g, p, card);
+ requireRule(!blocked, blocked ?? 'This card is committed to another effect.');
+ }
+ p.spice -= quote.cost;
+ const entries = quote.cards.map((card) => ({ card: discard(g, p, card.id),
+ discardedBy: p.id, publicFace: false }));
+ log(g, `${p.name} paid ${quote.cost} spice to the bank and discarded ${entries.length} Treachery Card${entries.length === 1 ? '' : 's'} through high-population Kaitain. No new auction begins.`,
+ { faction: p.faction, name: 'Kaitain disposal' });
+ stageTreacheryDiscard(g, 'kaitain', entries, { kind: 'kaitainDiscard',
+ owner: p.id, event: end.event, cost: quote.cost, spiceAfter: p.spice });
+ } else {
+ requireRule(p.faction === 'choam' && g.choamMarket?.owner === p.id &&
+ (action.mode === 'sell' || action.mode === 'trade'),
+ 'Choose Kaitain disposal, a CHOAM market action or finish your opportunity.');
+ decideChoamMarket(g, { kind: 'choamMarket', player: p.id }, action);
+ }
+}
function advancePhase(g: Game) {
if (g.phase === 6) boardResolution(() => quoteBattlePhaseAdvance(g));
commitPhaseResources(g, currentPhaseResources(g));
@@ -8868,6 +9876,11 @@ function completePhase(g: Game) {
}
/** Open the same public window for every Ix table, independently of who holds Amal. */
function openPhase(g: Game, initialize = true) {
+ if (initialize && g.phase === 4 && g.homeworlds) {
+ g.homeworldRevival = homeworldRule(() => snapshotHomeworldRevival(g));
+ if (g.homeworldRevival?.tleilaxu.low)
+ log(g, 'Tleilax began Revival at low population. Tleilaxu will receive no bank reward for other factions’ Free Revival this phase, even if Tleilax later returns to high population. Paid revival and Ghola income are unchanged.', { faction: 'tleilaxu', name: 'Tleilax low population' });
+ }
if (g.expansions.includes('ix')) g.phaseOpening = { passed: [], initialize };
else if (initialize) beginPhase(g);
}
@@ -8948,6 +9961,7 @@ function beginPhase(g: Game) {
if (
g.mobileStronghold?.location &&
ixians &&
+ !homeworldMobileStrongholdMovementBlock(g, ixians.id) &&
at(ixians, MOBILE_STRONGHOLD)
) {
g.decision = {
@@ -8961,17 +9975,37 @@ function beginPhase(g: Game) {
}
}
/** Transfer actual charity only after its applicable power response. */
-function payCharity(g: Game, player: Player, amount: number) {
+function payCharity(g: Game, player: Player, amount: number, homeworld = 0) {
+ if (g.homeworlds) {
+ const eligible = charityQuote(g, player);
+ requireRule(
+ eligible.total > 0 &&
+ amount === eligible.total &&
+ homeworld === eligible.homeworld,
+ 'The saved Homeworld charity claim does not match its eligible ordinary and bank amounts.',
+ );
+ }
+ requireRule(
+ Number.isSafeInteger(homeworld) && homeworld >= 0 && homeworld <= amount,
+ 'The Homeworld charity payment is invalid.',
+ );
+ const ordinary = amount - homeworld;
const payer = charityPayer(g);
// The published charity rule does not specify insolvency after intervening
// spending. Fail atomically while this edge case remains under audit; never
// mint a bank subsidy or create a negative spice balance silently.
requireRule(
- !payer || payer.spice >= amount,
+ !payer || payer.spice >= ordinary,
'CHOAM cannot fund this charity claim.',
);
- if (payer) payer.spice -= amount;
+ if (payer) payer.spice -= ordinary;
player.spice += amount;
+ if (homeworld)
+ log(
+ g,
+ `${player.name} received ${ordinary} ordinary charity spice ${payer ? `from ${payer.name}` : 'from the Spice Bank'} and ${homeworld} additional bank spice for low Homeworld population.`,
+ { faction: player.faction, name: 'Homeworld charity' },
+ );
techIncome(g, 'production', player);
}
function beginStormTurn(g: Game) {
@@ -9025,25 +10059,6 @@ function collectionAllocation(work: () => T): T {
}
}
function ecazCollectionIntegrity(g: Game) {
- const pending = g.ecazCollection;
- if (!pending) return;
- requireRule(
- Number.isSafeInteger(pending.turn) &&
- pending.turn >= 1 &&
- pending.turn <= g.turn &&
- typeof pending.event === 'string' &&
- pending.event.length > 0 &&
- typeof pending.canceled === 'boolean' &&
- Array.isArray(pending.settled),
- 'The saved collection event is invalid.',
- );
- if (pending.stage === 'complete') {
- requireRule(
- pending.allocation === null,
- 'Completed collection cannot retain unresolved shared spice.',
- );
- return;
- }
const continuation = g.pendingTreacheryDiscard?.continuation;
const controls: (
| {
@@ -9062,6 +10077,43 @@ function ecazCollectionIntegrity(g: Game) {
g.summonedWorm?.resume,
continuation && 'resume' in continuation ? continuation.resume : null,
].filter(Boolean);
+ const hasCollectionControl = controls.some((control) => {
+ const karama = control!.pendingKarama;
+ const response =
+ control!.response?.kind === 'worthlessKarama' &&
+ karama?.use.kind === 'cancel'
+ ? karama.use.response
+ : control!.response;
+ return (
+ control!.decision?.kind === 'ecazSpice' ||
+ response?.kind === 'ecazCollection'
+ );
+ });
+ const pending = g.ecazCollection;
+ if (!pending) {
+ requireRule(
+ !hasCollectionControl,
+ 'The shared collection decision has lost its collection event.',
+ );
+ return;
+ }
+ requireRule(
+ Number.isSafeInteger(pending.turn) &&
+ pending.turn >= 1 &&
+ pending.turn <= g.turn &&
+ typeof pending.event === 'string' &&
+ pending.event.length > 0 &&
+ typeof pending.canceled === 'boolean' &&
+ Array.isArray(pending.settled),
+ 'The saved collection event is invalid.',
+ );
+ if (pending.stage === 'complete') {
+ requireRule(
+ pending.allocation === null && !hasCollectionControl,
+ 'Completed collection cannot retain unresolved shared spice.',
+ );
+ return;
+ }
requireRule(
g.status === 'playing' &&
g.phase === 7 &&
@@ -9170,6 +10222,20 @@ function currentEcazCollectionQuote(
);
return quote;
}
+function creditGiediCollection(g: Game, player: string, desert: number) {
+ if (!g.homeworlds?.custody || getPlayer(g, player).faction !== 'harkonnen') return;
+ const quote = homeworldRule(() => quoteGiediCollectionReceipt(
+ g, g.turn, player, [{ kind: 'desert', amount: desert }], g.giediCollection,
+ ));
+ const p = getPlayer(g, player);
+ requireRule(Number.isSafeInteger(p.spice + quote.amount),
+ 'Giedi Prime collection would overflow the spice balance.');
+ p.spice += quote.amount;
+ g.giediCollection = quote.receipt;
+ if (quote.amount)
+ 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' });
+}
function commitCollection(
g: Game,
quote: ReturnType,
@@ -9227,12 +10293,18 @@ function commitCollection(
`Ecaz and its ally collected ${quote.shared.reduce((sum, lot) => sum + lot.amount, 0)} shared desert spice. Allocate each territory by agreement, or split it equally with any odd spice going to the ally.`,
);
}
+ for (const receipt of quote.receipts)
+ creditGiediCollection(g, receipt.player, receipt.desert);
}
function collect(g: Game) {
if (g.ecazCollection?.turn === g.turn) {
ecazCollectionIntegrity(g);
return;
}
+ if (g.giediCollection?.turn === g.turn) {
+ homeworldRule(() => validateGiediCollection(g, g.turn, g.giediCollection));
+ return;
+ }
const quote = boardResolution(() => quoteSpiceCollection(g));
if (quote.collectionBonus) {
applyAdvisorReleases(g, quote.released);
@@ -9289,6 +10361,8 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
pending.settled.push(receipt);
getPlayer(g, receipt.ecaz).spice += receipt.ecazAmount;
getPlayer(g, receipt.ally).spice += receipt.allyAmount;
+ creditGiediCollection(g, receipt.ecaz, receipt.ecazAmount);
+ creditGiediCollection(g, receipt.ally, receipt.allyAmount);
log(
g,
`${territory(receipt.territory).name} shared collection: Ecaz received ${receipt.ecazAmount} spice and ${faction(getPlayer(g, receipt.ally).faction).name} received ${receipt.allyAmount}, ${receipt.method === 'agreement' ? 'by agreement' : 'using the equal split with any odd spice going to the ally'}.`,
@@ -9773,7 +10847,7 @@ function currentCombatResponseQuote(
status: g.status,
phase: g.phase,
advanced: g.advanced,
- territoryIds: gameTerritories(g).map((t) => t.id),
+ territoryIds: combatLocations(g).map((t) => t.id),
players: g.players.map((p) => ({
id: p.id,
faction: p.faction,
@@ -9919,9 +10993,10 @@ function gholaOptions(g: Game, p: Player) {
strength,
}));
const kwisatz = !!(g.advanced && p.faction === 'atreides' && p.kwisatz?.dead);
- const eliteRemaining = Math.min(
+ const eliteBlock = homeworldSardaukarGholaBlock(g, p.id);
+ const eliteRemaining = eliteBlock ? 0 : Math.min(
p.elites?.tanks ?? 0,
- eliteRevivalRemaining(p),
+ eliteRevivalRemaining(p, g.advanced),
);
const maxForces = Math.min(
5,
@@ -9944,11 +11019,62 @@ function gholaOptions(g: Game, p: Player) {
kwisatz,
maxForces,
eliteRemaining,
+ eliteBlock,
available: reason === null,
reason,
};
}
-function applyGholaEffect(g: Game, p: Player, action: Action) {
+function marketGholaIntegrity(g: Game) {
+ homeworldVictoryReturnIntegrity(g);
+ homeworldRevivalReturnIntegrity(g);
+ terrorEntryIntegrity(g);
+ homeworldRule(() => validateGiediCollection(g, g.turn, g.giediCollection));
+ currentFactionPayment(g);
+ battleCardRolesIntegrity(g);
+ winnerDiscardsIntegrity(g);
+ const endError = biddingEndError(g);
+ requireRule(!endError, endError ?? 'Invalid end-of-Bidding opportunity.');
+ const error = choamMarketGholaError(g);
+ requireRule(!error, error ?? 'Invalid Ghola market interruption.');
+}
+function resumeMarketGhola(g: Game) {
+ if (g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete') return;
+ const pending = g.pendingChoamMarketGhola;
+ if (!pending || pending.stage !== 'complete' || g.pendingTreacheryDiscard || g.pendingNullentropy ||
+ g.response || g.decision || g.pendingKarama || g.truthtrance ||
+ g.phaseOpening || g.pendingRicheseGift || g.pendingExchange) return;
+ marketGholaIntegrity(g);
+ g.response = structuredClone(pending.response);
+ g.pendingChoamMarketGhola = null;
+}
+function playMarketGhola(g: Game, p: Player, action: Action) {
+ requireRule(choamSaleGholaTiming(g) && !g.pendingChoamMarketGhola,
+ 'Finish the current Ghola interruption first.');
+ const card = p.hand.find((c) => c.id === action.card && c.effect === 'ghola');
+ requireRule(card && !action.mode, 'Choose an owned Ghola card.');
+ const options = gholaOptions(g, p);
+ requireRule(options.available, options.reason ?? 'Ghola is unavailable.');
+ requireRule(!g.pendingKarama && !g.pendingRicheseGift && !g.pendingExchange,
+ 'Finish the nested exchange before playing Ghola.');
+ const reserved = ambassadorDiscardBlock(g, p, card);
+ requireRule(!reserved, reserved ?? 'This Ghola is already committed.');
+ choamSaleCancellationQuote(g, g.response!);
+ g.pendingChoamMarketGhola = {
+ turn: g.turn, phase: g.phase, player: p.id, card: card.id,
+ discardSequence: (g.treacheryDiscardSequence ?? 0) + 1,
+ event: '', stage: 'discard',
+ response: structuredClone(g.response!), market: structuredClone(g.choamMarket!),
+ };
+ g.pendingChoamMarketGhola.event = choamGholaEvent(g.pendingChoamMarketGhola);
+ g.response = null;
+ applyGholaEffect(g, p, action, card.id);
+ if (g.response) (g.response as ResponseWindow).intent = g.pendingChoamMarketGhola.event;
+ const used = discard(g, p, card.id);
+ log(g, `${p.name} played ${card.name}. The declared CHOAM sale waits for this revival and its income to finish.`,
+ { faction: p.faction, name: 'Ghola revival' });
+ stageOrdinaryCardDiscard(g, p, used);
+}
+function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
if (action.leader === 'kwisatz') {
requireRule(
g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
@@ -9986,21 +11112,25 @@ function applyGholaEffect(g: Game, p: Player, action: Action) {
'Forces',
);
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
+ const eliteBlock = elite > 0 ? homeworldSardaukarGholaBlock(g, p.id) : null;
+ requireRule(!eliteBlock, eliteBlock ?? 'Choose an eligible Ghola return.');
requireRule(
- elite <= eliteRevivalRemaining(p),
+ elite <= eliteRevivalRemaining(p, g.advanced),
'Only one elite force may be revived per turn.',
);
+ const group = { amount: n, elite, free: 0 };
+ const grant = requireHomeworldRevivalGrant(g, p, 'ghola', group);
+ addRevivedReserves(g, p, n, elite);
p.tanks -= n;
- p.reserves += n;
if (p.elites) {
p.elites.tanks -= elite;
- p.elites.reserves += elite;
p.elites.revived += elite;
}
log(
g,
`${p.name} revived ${n} forces${elite ? `, including ${elite} elite force${elite === 1 ? '' : 's'}` : ''} with Ghola. They returned to reserves for free without using the normal force-revival allowance.`,
);
+ if (cardId) stageHomeworldRevivalReturn(g, p, 'ghola', group, grant, cardId);
}
techIncome(g, 'axlotl', p);
collectRevivalIncome(g, p, 0, false, true);
@@ -10107,7 +11237,12 @@ function gholaPreparationActions(g: Game, p: Player): Action[] {
for (let amount = 1; amount <= Math.min(5, p.tanks); amount++)
for (
let elite = Math.max(0, amount - (p.tanks - (p.elites?.tanks ?? 0)));
- elite <= Math.min(amount, p.elites?.tanks ?? 0, eliteRevivalRemaining(p));
+ elite <=
+ Math.min(
+ amount,
+ p.elites?.tanks ?? 0,
+ eliteRevivalRemaining(p, g.advanced),
+ );
elite++
)
actions.push({ type: 'card', card: card.id, amount, elite });
@@ -10278,6 +11413,8 @@ function findShipmentCompletion(
p.spice,
p.reserves,
p.tanks,
+ p.elites,
+ g.homeworlds,
p.hand.map((c) => c.id),
g.aid,
g.karamaShipping,
@@ -10313,7 +11450,17 @@ function findShipmentCompletion(
p.elites?.reserves ?? 0,
undefined,
);
+ const sources = g.homeworlds?.custody
+ ? nativeReserveSources(
+ homeworldContext(g),
+ g.homeworlds.custody,
+ p.id,
+ { normal: amount - elite, elite },
+ )
+ : null;
+ const nativeSources = sources ? { homeworldSources: sources } : {};
validatePhysicalShipment(g, {
+ ...nativeSources,
turn: g.turn,
player: p.id,
territory: destination,
@@ -10334,6 +11481,7 @@ function findShipmentCompletion(
amount,
elite,
allyPayment,
+ ...nativeSources,
},
],
};
@@ -10582,7 +11730,7 @@ function feasiblePrescience(
for (const defense of field === 'defense' ? [value] : defenses) {
const maxSupport =
g.advanced && field === 'dial'
- ? Math.min(battleSupportBudget(g, p), at(p, b.territory))
+ ? Math.min(battleSupportBudget(g, p), (combatArmy(g, p.id, b.territory).normal + combatArmy(g, p.id, b.territory).elite))
: 0;
for (let support = 0; support <= maxSupport; support++) {
try {
@@ -10667,7 +11815,7 @@ function validatePortableSnooper(
'Use the current revealed battle event; legacy battles without one are not reopened.',
);
requireRule(
- b.traitorCalls[owner.id] === undefined,
+ b.traitorCalls[owner.id] === undefined && !b.homeworldDefensePassed?.includes(owner.id),
'Your battle decisions are already submitted; the late-defense opportunity has closed.',
);
requireRule(
@@ -10781,7 +11929,30 @@ function nextRevealedDecision(g: Game) {
);
if (player) g.decision = { kind: 'poisonTooth', player };
}
+/** Homeworld invaders have no traitor vote to hold their late-defense window.
+ * Offer only a real held legal defense; empty windows resolve automatically. */
+function homeworldRevealPending(g: Game): boolean {
+ const b = g.battle;
+ if (g.status !== 'playing' || g.phase !== 6 || !b?.revealed || !b.territory.startsWith('homeworld:') || g.decision || g.response ||
+ g.truthtrance || g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard ||
+ g.pendingRicheseGift || g.pendingExchange || g.pendingKarama || g.summonedWorm) return false;
+ return traitorVoters(g, b).every((id) => b.traitorCalls[id] !== undefined);
+}
+function advanceHomeworldReveal(g: Game): boolean {
+ if (!homeworldRevealPending(g)) return false;
+ const b = g.battle!;
+ const voters = traitorVoters(g, b);
+ const owner = g.order.find((id) => [b.attacker, b.defender].includes(id) && !voters.includes(id) &&
+ !b.homeworldDefensePassed?.includes(id) && (() => { const offer = portableSnooperView(g, getPlayer(g, id)); return offer && !offer.blocked; })());
+ if (owner) {
+ requireRule(b.event, 'The Homeworld defense opportunity needs its battle event.');
+ g.decision = {kind: 'homeworldDefense', player: owner, event: b.event};
+ } else resolveBattle(g);
+ return true;
+}
function traitorVoters(g: Game, b: Battle) {
+ const home = homeworldBattleLocation(g, b.territory);
+ if (home) return [b.attacker, b.defender].filter((id) => id === home.native);
const voters = [b.attacker, b.defender];
const harkonnen = byFaction(g, 'harkonnen');
if (
@@ -10822,6 +11993,8 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
try {
const quote = quoteBattleResolution({
advanced: g.advanced,
+ typedCasualties: !!g.homeworlds,
+ ...(homeworldBattleLocation(g, b.territory) ? { homeworld: currentHomeworldBattleRules(g, b.territory)! } : {}),
turn: g.turn,
territory: b.territory,
attacker: combatant(b.attacker, b.defender),
@@ -10844,14 +12017,19 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
pendingRetentionPresent: !!g.moritaniRetention,
});
for (const id of quote.destroyedArmies)
- validateBattleForceLoss(getPlayer(g, id), b.territory, Infinity);
- if (quote.winner && quote.result === 'normal')
+ if (b.territory.startsWith('homeworld:')) quoteHomeworldLoss(g, id, b.territory, combatArmy(g, id, b.territory));
+ else validateBattleForceLoss(getPlayer(g, id), b.territory, Infinity);
+ if (quote.winner && quote.result === 'normal' && b.territory.startsWith('homeworld:'))
+ for (const losses of quote.casualties!.options) quoteHomeworldLoss(g, quote.winner, b.territory, { normal: losses.normal, elite: losses.elite });
+ else if (quote.winner && quote.result === 'normal')
validateBattleForceLoss(
getPlayer(g, quote.winner),
b.territory,
quote.basicWinnerLosses ??
Math.max(...quote.casualties!.options.map((c) => c.normal + c.elite)),
);
+ if (quote.homeworldExplosion)
+ for (const losses of quote.homeworldExplosion.options) quoteHomeworldLoss(g, quote.homeworldExplosion.player, b.territory, losses);
return quote;
} catch (error) {
if (
@@ -10932,7 +12110,7 @@ function resolveBattle(g: Game) {
b.territory,
'a Lasgun–shield explosion destroyed it',
);
- for (const p of g.players) killTerritory(g, p, b.territory, Infinity, true);
+ for (const player of quote.destroyedArmies) killTerritory(g, getPlayer(g, player), b.territory, Infinity, true);
dead(al);
dead(dl);
for (const [p, plan] of [
@@ -10948,7 +12126,9 @@ function resolveBattle(g: Game) {
if (splitLocation(k).territory === b.territory) delete g.spice[k];
log(
g,
- 'Lasgun and shield exploded. All forces and spice in the territory were destroyed.',
+ quote.homeworldExplosion
+ ? `Lasgun and shield exploded. Invading armies were destroyed; the native faction loses ${quote.homeworldExplosion.amount} physical forces, limited by its Homeworld’s printed battle strength.`
+ : 'Lasgun and shield exploded. All forces and spice in the territory were destroyed.',
);
} else {
const ak = quote.leaderDeaths.attacker,
@@ -10965,8 +12145,8 @@ function resolveBattle(g: Game) {
log(
g,
stoneResult
- ? `${faction(winner!.faction).name} won in ${territory(b.territory).name} by Stone Burner’s undialed physical tokens (aggressor ${stoneResult.attacker.join(' or ')}, defender ${stoneResult.defender.join(' or ')}; ${strongholdEffect(g, battleTieWinner(g)) === 'habbanya_ridge_sietch' ? 'Habbanya Stronghold advantage wins ties' : 'aggressor wins ties'}). Leader strength and Kwisatz Haderach’s bonus do not affect this comparison; the winner loses its dialed forces normally.`
- : `${faction(winner!.faction).name} won in ${territory(b.territory).name} (${av}–${dv}${av === dv ? (strongholdEffect(g, winner!.id) === 'habbanya_ridge_sietch' ? ', Habbanya Stronghold advantage wins ties' : ', aggressor wins ties') : ''}).`,
+ ? `${faction(winner!.faction).name} won in ${combatLocationName(g, b.territory)} by Stone Burner’s undialed physical tokens (aggressor ${stoneResult.attacker.join(' or ')}, defender ${stoneResult.defender.join(' or ')}; ${strongholdEffect(g, battleTieWinner(g)) === 'habbanya_ridge_sietch' ? 'Habbanya Stronghold advantage wins ties' : 'aggressor wins ties'}). Leader strength and Kwisatz Haderach’s bonus do not affect this comparison; the winner loses its dialed forces normally.`
+ : `${faction(winner!.faction).name} won in ${combatLocationName(g, b.territory)} (${av}–${dv}${av === dv ? (strongholdEffect(g, winner!.id) === 'habbanya_ridge_sietch' ? ', Habbanya Stronghold advantage wins ties' : ', aggressor wins ties') : ''}).`,
stoneResult
? { faction: (isStoneBurner(aw) ? a : d).faction, name: 'Stone Burner' }
: undefined,
@@ -10982,7 +12162,8 @@ function resolveBattle(g: Game) {
);
}
const tleilaxu = byFaction(g, 'tleilaxu');
- if (winner && tleilaxu && winner.id !== tleilaxu.id) {
+ if (winner && tleilaxu && winner.id !== tleilaxu.id &&
+ (!b.territory.startsWith('homeworld:') || homeworldBattleLocation(g, b.territory)!.native === tleilaxu.id)) {
const winningPlan = winner.id === a.id ? ap : dp;
g.pendingFaceDance = {
player: tleilaxu.id,
@@ -11001,6 +12182,9 @@ function resolveBattle(g: Game) {
event: b.event ?? crypto.randomUUID(),
};
if (quote.retention) g.moritaniRetention = structuredClone(quote.retention);
+ const playedCardRoles = g.homeworlds?.custody && byFaction(g, 'ecaz')
+ ? battleDiscardRoles(g, b) : undefined;
+ const winningDiscards = quote.discarded.filter((entry) => entry.player === winner?.id).map((entry) => entry.card);
const discarded: { card: Card; discardedBy: string; publicFace: boolean }[] =
[];
for (const [p, plan, l] of [
@@ -11017,10 +12201,10 @@ function resolveBattle(g: Game) {
p.kwisatz.usedAt = b.territory;
}
for (const entry of quote.discarded.filter(
- (entry) => entry.player === p.id,
+ (entry) => entry.player === p.id && entry.player !== winner?.id,
))
discarded.push({
- card: discard(g, p, entry.card),
+ card: discard(g, p, entry.card, playedCardRoles?.[p.id]?.[entry.card]),
discardedBy: p.id,
publicFace: true,
});
@@ -11052,7 +12236,43 @@ function resolveBattle(g: Game) {
combatants: [...g.lastBattle],
winner: winner?.id ?? null,
result: quote.result,
+ ...(playedCardRoles ? { cardRoles: playedCardRoles } : {}),
};
+ if (g.homeworlds?.custody && winner?.faction === 'atreides' &&
+ (quote.result === 'normal' || quote.result === 'traitor')) {
+ requireRule(!g.homeworldVictoryReinforcement || g.homeworldVictoryReinforcement.stage === 'complete',
+ 'Finish the previous Caladan reinforcement before resolving another battle.');
+ g.homeworldVictoryReinforcement = makeHomeworldVictoryReturn({
+ event: g.lastBattleContext.event, turn: g.turn, player: winner.id,
+ territory: b.territory, result: quote.result,
+ });
+ g.lastBattleContext.caladanReinforcement = {
+ event: g.lastBattleContext.event, completed: false, stage: 'waiting',
+ signature: homeworldVictoryObligationSignature(g.lastBattleContext, false, 'waiting'),
+ };
+ }
+ if (playedCardRoles)
+ g.lastBattleContext.cardRolesSignature = battleCardRolesSignature(g.lastBattleContext);
+ if (winner && winningDiscards.length)
+ g.pendingWinnerDiscards = { event: g.lastBattleContext.event, turn: g.turn,
+ territory: b.territory, player: winner.id, cards: winningDiscards,
+ optional: [...quote.winnerCards], signature: '' };
+ if (g.pendingWinnerDiscards)
+ g.pendingWinnerDiscards.signature = winnerDiscardSignature(g.pendingWinnerDiscards);
+ if (g.pendingWinnerDiscards) {
+ g.lastBattleContext.winnerDiscards = { cards: [...winningDiscards], completed: false, signature: '' };
+ g.lastBattleContext.winnerDiscards.signature = winnerDiscardObligationSignature(g.lastBattleContext);
+ }
+ if (b.territory.startsWith('homeworld:') && (casualtyCommitment || quote.homeworldExplosion)) {
+ const owner = quote.homeworldExplosion?.player ?? winner!.id;
+ g.homeworldBattleLoss = {
+ event: g.lastBattleContext.event, territory: b.territory, player: owner,
+ kind: quote.homeworldExplosion ? 'explosion' : 'winner',
+ pool: combatArmy(g, owner, b.territory),
+ options: (quote.homeworldExplosion?.options ?? casualtyCommitment!.options).map(({normal, elite}) => ({normal, elite})),
+ ...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support } } : {}),
+ };
+ }
g.battle = null;
const cards = [...quote.winnerCards];
const continuation: Extract<
@@ -11082,6 +12302,16 @@ function continueResolvedBattle(
const { cards, territory: to } = continuation;
const winner = continuation.winner ? getPlayer(g, continuation.winner) : null;
const losses = continuation.casualties?.options;
+ const native = g.homeworldBattleLoss;
+ if (native?.kind === 'explosion') {
+ homeworldBattleLossIntegrity(g);
+ homeworldSubstitutionIntegrity(g);
+ homeworldDefenseIntegrity(g);
+ homeworldShipmentIntegrity(g);
+ if (native.options.length === 1) settleHomeworldExplosion(g, native.options[0], true);
+ else g.decision = { kind: 'homeworldExplosion', event: native.event, player: native.player, territory: native.territory, options: native.options.map((p) => ({...p})), pool: {...native.pool} };
+ return;
+ }
if (winner && losses?.length === 1) {
settleWinnerCasualties(g, winner, to, cards, losses[0], true);
} else if (winner && losses)
@@ -11103,20 +12333,27 @@ function settleWinnerCasualties(
choice: Casualties,
automatic = false,
) {
+ if (to.startsWith('homeworld:')) homeworldBattleLossIntegrity(g);
const losses = takeBattleLosses(g, p, to, choice);
+ if (to.startsWith('homeworld:')) g.homeworldBattleLoss = null;
log(
g,
- `${p.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${territory(to).name} to the Tanks. ${automatic ? 'This was the only legal casualty allocation for the revealed battle plan, so it was applied automatically.' : 'This applies the selected casualty allocation for the revealed battle plan.'}`,
+ `${p.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${combatLocationName(g, to)} to the Tanks. ${automatic ? 'This was the only legal casualty allocation for the revealed battle plan, so it was applied automatically.' : 'This applies the selected casualty allocation for the revealed battle plan.'}`,
automatic ? { faction: p.faction, name: 'Battle casualties' } : undefined,
);
- const survivingSuboids = Object.entries(p.forces)
+ const survivingSuboids = to.startsWith('homeworld:') ? combatArmy(g, p.id, to).normal : Object.entries(p.forces)
.filter(([key]) => splitLocation(key).territory === to)
.reduce(
(sum, [key, count]) => sum + count - (p.elites?.forces[key] ?? 0),
0,
);
if (p.faction === 'ixians' && choice.elite > 0 && survivingSuboids > 0) {
- g.pendingIxSubstitution = { player: p.id, territory: to, losses, cards };
+ g.pendingIxSubstitution = { player: p.id, territory: to, losses, cards,
+ ...(to.startsWith('homeworld:') ? { homeworld: {
+ pool: combatArmy(g, p.id, to), cyborgsLost: choice.elite,
+ eliteTanks: p.elites!.tanks, normalTanks: p.tanks - p.elites!.tanks, battleLosses: p.battleLosses,
+ } } : {}),
+ };
g.decision = {
kind: 'ixSubstitution',
player: p.id,
@@ -11125,7 +12362,59 @@ function settleWinnerCasualties(
};
} else finishWinner(g, p, to, cards);
}
+function winnerDiscardSignature(pending: NonNullable) {
+ const { event, turn, territory, player, cards, optional } = pending;
+ return JSON.stringify({ event, turn, territory, player, cards, optional });
+}
+function winnerDiscardObligationSignature(context: NonNullable) {
+ return JSON.stringify({ event: context.event, turn: context.turn, territory: context.territory,
+ player: context.winner, cards: context.winnerDiscards?.cards,
+ completed: context.winnerDiscards?.completed });
+}
+function winnerDiscardsIntegrity(g: Game) {
+ const pending = g.pendingWinnerDiscards;
+ const context = g.lastBattleContext;
+ const obligation = context?.winnerDiscards;
+ if (obligation) {
+ requireRule(obligation.signature === winnerDiscardObligationSignature(context!) &&
+ Array.isArray(obligation.cards) && obligation.cards.length > 0 &&
+ new Set(obligation.cards).size === obligation.cards.length &&
+ typeof obligation.completed === 'boolean' &&
+ (obligation.completed ? !pending : !!pending &&
+ JSON.stringify(pending.cards) === JSON.stringify(obligation.cards)),
+ 'The saved mandatory winner discard obligation lost its original queue or completion.');
+ }
+ if (!pending) return;
+ const owner = g.players.find((p) => p.id === pending.player);
+ requireRule(g.status === 'playing' && g.phase === 6 && pending.turn === g.turn &&
+ obligation && !obligation.completed &&
+ pending.signature === winnerDiscardSignature(pending) &&
+ context?.event === pending.event && context.turn === g.turn &&
+ context.territory === pending.territory && context.winner === pending.player && owner &&
+ Array.isArray(pending.cards) && pending.cards.length > 0 && Array.isArray(pending.optional) &&
+ new Set([...pending.cards, ...pending.optional]).size === pending.cards.length + pending.optional.length &&
+ [...pending.cards, ...pending.optional].every((id) => typeof id === 'string' && owner.hand.some((card) => card.id === id)),
+ 'The saved mandatory winner discards no longer match their battle or physical cards.');
+}
function finishWinner(g: Game, winner: Player, t: string, cards: string[]) {
+ if (g.pendingWinnerDiscards) {
+ winnerDiscardsIntegrity(g);
+ const pending = g.pendingWinnerDiscards;
+ requireRule(pending.player === winner.id && pending.territory === t &&
+ JSON.stringify(pending.optional) === JSON.stringify(cards),
+ 'The mandatory winner discard must resume its original cleanup.');
+ const entries = pending.cards.map((id) => ({ card: discard(g, winner, id,
+ cleanupDiscardRole(g, winner, id)), discardedBy: winner.id, publicFace: true }));
+ g.pendingWinnerDiscards = null;
+ if (g.lastBattleContext?.winnerDiscards) {
+ g.lastBattleContext.winnerDiscards.completed = true;
+ g.lastBattleContext.winnerDiscards.signature = winnerDiscardObligationSignature(g.lastBattleContext);
+ }
+ stageTreacheryDiscard(g, 'battle:winnerMandatory', entries, {
+ kind: 'winnerMandatoryDiscard', event: pending.event, player: winner.id,
+ territory: t, optional: [...cards], commitment: structuredClone(pending) });
+ return;
+ }
if (cards.length)
g.decision = {
kind: 'battleCards',
@@ -11150,7 +12439,7 @@ function currentBattleAftermathQuote(
advanced: g.advanced,
battlePresent: !!g.battle,
lastBattle: g.lastBattle,
- territoryIds: gameTerritories(g).map((t) => t.id),
+ territoryIds: combatLocations(g).map((t) => t.id),
context: g.lastBattleContext,
players: g.players.map((p) => ({
id: p.id,
@@ -11179,7 +12468,7 @@ function currentBattleAftermathQuote(
spending,
);
if (quote.next.kind === 'board')
- boardResolution(() => quoteBattleBoardContinuation(g));
+ boardResolution(() => quoteCombatBoardContinuation(g));
return quote;
} catch (error) {
if (error instanceof BattleAftermathQuoteError)
@@ -11207,6 +12496,125 @@ function validateAftermathCancellation(
spending,
);
}
+function homeworldVictoryWitness(frame: HomeworldVictoryReturn) {
+ return { event: frame.event, turn: frame.turn, player: frame.player,
+ territory: frame.territory, result: frame.result };
+}
+function homeworldVictoryReturnIntegrity(g: Game) {
+ homeworldRule(() => validateHomeworldVictoryReturn(g));
+ const frame = g.homeworldVictoryReinforcement;
+ if (frame?.stage === 'arrival' || frame?.stage === 'complete')
+ homeworldRule(() => validateHomeworldArrival(g, frame, 'victoryEvent'));
+ const continuation = g.pendingTreacheryDiscard?.continuation;
+ const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
+ g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
+ continuation && 'resume' in continuation ? continuation.resume : null];
+ const decisions = contexts.flatMap((context) => context?.decision?.kind === 'caladanReinforcement' ? [context.decision] : []);
+ requireRule(decisions.every((decision) => frame?.stage === 'choice' && decision.player === frame.player && decision.event === frame.event),
+ 'The Caladan choice has lost its original victory.');
+ if (frame?.stage === 'choice')
+ requireRule(decisions.length > 0, 'The Caladan victory has lost its reinforcement choice.');
+ if (frame?.stage === 'waiting')
+ requireRule(!!g.pendingTreacheryDiscard || !!g.pendingNullentropy ||
+ !!g.truthtrance || !!g.pendingKarama ||
+ contexts.some((context) => !!context?.decision || !!context?.response),
+ 'The Caladan victory has lost its preceding battle cleanup choice.');
+}
+function completeHomeworldVictoryReturn(g: Game) {
+ const frame = g.homeworldVictoryReinforcement!;
+ frame.stage = 'complete';
+ const context = g.lastBattleContext!;
+ requireRule(context.caladanReinforcement?.event === frame.event,
+ 'The Caladan reinforcement has lost its battle completion.');
+ context.caladanReinforcement.completed = true;
+ context.caladanReinforcement.stage = 'complete';
+ context.caladanReinforcement.signature = homeworldVictoryObligationSignature(context, true, 'complete');
+}
+function stampHomeworldVictoryStage(g: Game) {
+ const frame = g.homeworldVictoryReinforcement!;
+ const context = g.lastBattleContext!;
+ requireRule(context.caladanReinforcement?.event === frame.event,
+ 'The Caladan reinforcement has lost its original stage obligation.');
+ context.caladanReinforcement.stage = frame.stage;
+ context.caladanReinforcement.signature = homeworldVictoryObligationSignature(context,
+ context.caladanReinforcement.completed, frame.stage);
+}
+function openHomeworldVictoryReturn(g: Game, faceDancePending: boolean) {
+ const frame = g.homeworldVictoryReinforcement;
+ if (!frame || frame.stage === 'complete' || frame.event !== g.lastBattleContext?.event) return false;
+ requireRule(frame.stage === 'waiting', 'Finish the Caladan reinforcement before resuming battle cleanup.');
+ const quote = homeworldRule(() => quoteHomeworldVictoryReinforcement(g, homeworldVictoryWitness(frame)));
+ if (quote.amount === 0) {
+ frame.destination = 'decline'; frame.ambassadors = [];
+ frame.arrivalSignature = homeworldArrivalSignature(frame);
+ completeHomeworldVictoryReturn(g);
+ if (quote.blocked)
+ log(g, `${getPlayer(g, frame.player).name} received no Caladan victory reinforcement: ${quote.blocked}`);
+ return false;
+ }
+ const blocked = faceDancePending
+ ? 'Caladan reinforcement and Face Dancer replacement await an ordering ruling. You may leave the reserve force at home.'
+ : quote.blocked;
+ const offer = { population: quote.population, survivors: quote.survivors, blocked };
+ frame.offer = { ...offer, signature: homeworldVictoryOfferSignature(frame, offer) };
+ frame.stage = 'choice';
+ stampHomeworldVictoryStage(g);
+ g.decision = { kind: 'caladanReinforcement', player: frame.player, event: frame.event };
+ return true;
+}
+function decideHomeworldVictoryReturn(g: Game, p: Player, action: Action) {
+ const frame = g.homeworldVictoryReinforcement;
+ requireRule(frame?.stage === 'choice' && frame.player === p.id && frame.event === action.event,
+ 'Choose the current Caladan victory reinforcement.');
+ if (action.decline === true) {
+ requireRule(action.amount === undefined && action.destination === undefined,
+ 'Leaving the reserve force at home does not select a quantity or destination.');
+ frame.destination = 'decline'; frame.ambassadors = [];
+ frame.arrivalSignature = homeworldArrivalSignature(frame);
+ completeHomeworldVictoryReturn(g);
+ log(g, `${p.name} left the optional Caladan victory reinforcement in reserves.`);
+ finishBattle(g);
+ return;
+ }
+ requireRule(!frame.offer?.blocked, frame.offer?.blocked ?? 'This reinforcement needs its ordering ruling.');
+ requireRule((action.amount === undefined || action.amount === 1) && typeof action.destination === 'string',
+ 'Caladan adds exactly one reserve force to the battle location.');
+ const quote = homeworldRule(() => quoteHomeworldVictoryReinforcementDestination(g,
+ homeworldVictoryWitness(frame), action.destination as string));
+ const seat = quote.transfer.players.find((seat) => seat.id === p.id)!;
+ p.reserves = seat.reserves;
+ g.homeworlds!.custody = quote.transfer.state;
+ frame.destination = quote.selected.id; frame.stage = 'arrival'; frame.ambassadors = [];
+ frame.arrivalSignature = homeworldArrivalSignature(frame);
+ stampHomeworldVictoryStage(g);
+ if (quote.selected.territory) {
+ place(p, quote.selected.territory, quote.selected.sector!, 1);
+ openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, 1, 0, 'caladanReinforcement');
+ if (g.pendingAmbassador) {
+ g.pendingAmbassador.victoryEvent = frame.event;
+ appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador);
+ }
+ }
+ log(g, `${p.name} added one force from Caladan reserves to ${quote.selected.name} after winning the battle. The force joins an existing surviving army; no spice or shipment allowance was spent.`,
+ { faction: p.faction, name: 'Caladan victory reinforcement' });
+}
+function resumeHomeworldVictoryReturn(g: Game) {
+ const frame = g.homeworldVictoryReinforcement;
+ if (frame?.stage !== 'arrival' || g.pendingTreacheryDiscard || g.pendingNullentropy ||
+ g.truthtrance || g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador ||
+ g.pendingTerrorEntry || g.pendingKarama || g.decision || g.response) return;
+ completeHomeworldVictoryReturn(g);
+ finishBattle(g);
+}
+function projectedHomeworldVictoryReturn(g: Game, player: string) {
+ const frame = g.homeworldVictoryReinforcement;
+ if (frame?.stage !== 'choice') return null;
+ const quote = homeworldRule(() => quoteHomeworldVictoryReinforcement(g, homeworldVictoryWitness(frame)));
+ return { event: frame.event, player: frame.player, territory: frame.territory, amount: 1 as const,
+ blocked: frame.offer?.blocked ?? quote.blocked,
+ destinations: player === frame.player ? quote.destinations : [],
+ };
+}
function finishBattle(g: Game) {
const quote = currentBattleAftermathQuote(g);
for (const step of quote.steps) {
@@ -11223,6 +12631,8 @@ function finishBattle(g: Game) {
}
}
const next = quote.next;
+ if ((next.kind === 'faceDance' || next.kind === 'board') &&
+ openHomeworldVictoryReturn(g, next.kind === 'faceDance')) return;
if (next.kind === 'choamBattleIncome') {
g.response = { ...next, passed: [] };
} else if (next.kind === 'choamAudit') {
@@ -11324,7 +12734,7 @@ function auditorIntegrity(g: Game) {
g.lastBattle.length === 2 &&
g.lastBattle.includes(pending.owner) &&
g.lastBattle.includes(pending.opponent) &&
- gameTerritories(g).some((t) => t.id === pending.territory) &&
+ combatLocations(g).some((t) => t.id === pending.territory) &&
g.players.some(
(p) =>
p.id === pending.owner &&
@@ -11403,7 +12813,7 @@ function battleCleanupInput(g: Game) {
battlePresent: !!g.battle,
lastBattle: g.lastBattle,
playerIds: g.players.map((p) => p.id),
- territoryIds: gameTerritories(g).map((t) => t.id),
+ territoryIds: combatLocations(g).map((t) => t.id),
context: g.lastBattleContext,
};
}
@@ -11463,7 +12873,7 @@ function finishMoritaniRetention(g: Game, keep: string | null) {
const discarded = pending.played
.filter((id) => id !== keep)
.map((id) => ({
- card: discard(g, player, id),
+ card: discard(g, player, id, cleanupDiscardRole(g, player, id)),
discardedBy: player.id,
publicFace: true,
}));
@@ -11495,6 +12905,167 @@ function finishMoritaniRetention(g: Game, keep: string | null) {
});
else finishBattle(g);
}
+function currentHomeworldRevivalGrant(
+ g: Game, p: Player, source: HomeworldRevivalDeploymentSource,
+ group: HomeworldRevivalDeploymentGroup,
+) {
+ if (!g.homeworlds?.custody || !['fremen', 'tleilaxu'].includes(p.faction)) return null;
+ const deposit = homeworldRule(() => quoteNativeRevivalDeposit(
+ homeworldContext(g), g.homeworlds!.custody!, p.id,
+ { normal: group.amount - group.elite, elite: group.elite },
+ ));
+ const seat = deposit.players.find((seat) => seat.id === p.id)!;
+ const after = {
+ advanced: g.advanced,
+ homeworlds: { custody: deposit.state },
+ players: g.players.map((other) => other.id !== p.id ? other : {
+ ...p, reserves: seat.reserves,
+ ...(p.elites ? { elites: { ...p.elites, reserves: seat.eliteReserves } } : {}),
+ }),
+ };
+ return homeworldRule(() => quoteHomeworldRevivalDeployment(g, after, p.id, source, group));
+}
+function requireHomeworldRevivalGrant(
+ g: Game, p: Player, source: HomeworldRevivalDeploymentSource,
+ group: HomeworldRevivalDeploymentGroup,
+) {
+ requireRule(!g.homeworldRevivalReturn || g.homeworldRevivalReturn.stage === 'complete',
+ 'Finish the previous revived group’s placement before another revival.');
+ const grant = currentHomeworldRevivalGrant(g, p, source, group);
+ requireRule(!grant?.blocked, grant?.blocked ?? 'This revival placement needs its timing ruling.');
+ return grant;
+}
+function stageHomeworldRevivalReturn(
+ g: Game, p: Player, source: HomeworldRevivalDeploymentSource,
+ group: HomeworldRevivalDeploymentGroup,
+ grant: ReturnType, card?: string,
+) {
+ if (!grant) return;
+ g.homeworldRevivalReturn = homeworldRule(() => makeHomeworldRevivalReturn({
+ event: crypto.randomUUID(), turn: g.turn, phase: g.phase, player: p.id,
+ source, group, quote: grant, ...(card ? { card } : {}),
+ }));
+ stampHomeworldRevivalProgress(g);
+}
+function stampHomeworldRevivalProgress(g: Game) {
+ const frame = g.homeworldRevivalReturn!;
+ if (frame.progressVersion === 1)
+ g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
+}
+function homeworldRevivalReturnIntegrity(g: Game) {
+ const frame = g.homeworldRevivalReturn;
+ homeworldRule(() => validateHomeworldRevivalReturn(g, frame));
+ const continuation = g.pendingTreacheryDiscard?.continuation;
+ const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
+ g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
+ continuation && 'resume' in continuation ? continuation.resume : null];
+ const decisions = contexts.flatMap((context) => context?.decision?.kind === 'homeworldRevivalDeployment' ? [context.decision] : []);
+ requireRule(decisions.every((decision) => frame?.stage === 'choice' &&
+ decision.player === frame.player && decision.event === frame.event),
+ 'The revival placement decision has lost its original returned group.');
+ if (frame?.stage === 'choice')
+ requireRule(decisions.length > 0, 'The returned group has lost its owned placement choice.');
+}
+function completeHomeworldRevivalReturn(g: Game) {
+ const frame = g.homeworldRevivalReturn!;
+ requireRule(!frame.ambassadors?.some((entry) => !entry.completed),
+ 'Finish the revived group’s Ambassador opportunity before its original revival income.');
+ frame.stage = 'complete';
+ stampHomeworldRevivalProgress(g);
+ g.response = structuredClone(frame.resumeResponse ?? null);
+}
+function resumeHomeworldRevivalReturn(g: Game) {
+ const frame = g.homeworldRevivalReturn;
+ if (!frame || frame.stage === 'complete' || frame.stage === 'choice' ||
+ g.pendingTreacheryDiscard || g.pendingNullentropy || g.truthtrance ||
+ g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador ||
+ g.pendingTerrorEntry || g.pendingKarama || g.decision) return;
+ if (frame.stage === 'arrival') {
+ if (!g.response) completeHomeworldRevivalReturn(g);
+ return;
+ }
+ // The original revival/card has committed. Hold its independent income until
+ // the optional physical placement and any Ambassador children are complete.
+ requireRule(!g.response || g.response.kind === 'revivalIncome',
+ 'Finish the original revival continuation before placement.');
+ frame.resumeResponse = structuredClone(g.response);
+ frame.resumeSignature = homeworldRevivalResumeSignature(frame.resumeResponse);
+ g.response = null;
+ frame.stage = 'choice';
+ stampHomeworldRevivalProgress(g);
+ const destinations = homeworldRule(() => homeworldRevivalDestinations(g, frame.player, frame.quote));
+ if (!destinations.length || destinations.every((d) => d.blocked && !d.blocked.includes('await'))) {
+ frame.destination = 'decline';
+ frame.ambassadors = [];
+ frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
+ completeHomeworldRevivalReturn(g);
+ log(g, `${getPlayer(g, frame.player).name} left the revived group in reserves because no placement destination is available.`);
+ return;
+ }
+ g.decision = { kind: 'homeworldRevivalDeployment', player: frame.player, event: frame.event };
+}
+function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) {
+ const frame = g.homeworldRevivalReturn;
+ requireRule(frame?.stage === 'choice' && frame.player === p.id && action.event === frame.event,
+ 'Choose the current returned group’s placement.');
+ if (action.decline === true) {
+ requireRule(action.destination === undefined && action.amount === undefined,
+ 'Leaving the group in reserves does not select a destination or quantity.');
+ frame.destination = 'decline';
+ frame.ambassadors = [];
+ frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
+ completeHomeworldRevivalReturn(g);
+ log(g, `${p.name} left the newly revived group in reserves.`);
+ return;
+ }
+ const amount = frame.quote.normal + frame.quote.elite;
+ requireRule((action.amount === undefined || action.amount === amount) && typeof action.destination === 'string',
+ 'Place the entire eligible revived group in one destination.');
+ const quote = homeworldRule(() => quoteHomeworldRevivalDestination(g, p.id, frame.quote, action.destination as string));
+ const seat = quote.transfer.players.find((seat) => seat.id === p.id)!;
+ p.reserves = seat.reserves;
+ if (p.elites) p.elites.reserves = seat.eliteReserves;
+ g.homeworlds!.custody = quote.transfer.state;
+ frame.destination = quote.selected.id;
+ frame.stage = 'arrival';
+ stampHomeworldRevivalProgress(g);
+ frame.ambassadors = [];
+ frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
+ if (quote.selected.territory) {
+ place(p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite);
+ openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite, 'homeworldRevival');
+ if (g.pendingAmbassador) {
+ g.pendingAmbassador.revivalEvent = frame.event;
+ appendHomeworldRevivalAmbassador(frame, g.pendingAmbassador);
+ }
+ }
+ log(g, `${p.name} placed ${amount} newly revived ${frame.quote.kind === 'fedaykin' ? 'Fedaykin' : 'free forces'} in ${quote.selected.name}. No additional revival payment or shipment allowance was used.`,
+ { faction: p.faction, name: frame.quote.kind === 'fedaykin' ? 'Southern Hemisphere revival' : 'Tleilax free revival' });
+}
+function projectedHomeworldRevivalReturn(g: Game, player: string) {
+ const frame = g.homeworldRevivalReturn;
+ if (frame?.stage !== 'choice') return null;
+ return { event: frame.event, player: frame.player, kind: frame.quote.kind,
+ normal: frame.quote.normal, elite: frame.quote.elite, blocked: frame.quote.blocked,
+ destinations: player === frame.player
+ ? homeworldRule(() => homeworldRevivalDestinations(g, player, frame.quote)) : [],
+ };
+}
+function homeworldRevivalChoiceBlocks(g: Game, p: Player) {
+ const blocks: { source: 'normal' | 'ghola'; amount: number; elite: number; reason: string }[] = [];
+ if (!g.homeworlds?.custody || !['fremen', 'tleilaxu'].includes(p.faction)) return blocks;
+ for (const source of ['normal', 'ghola'] as const) {
+ const maximum = source === 'normal' ? forceRevivalRemaining(g, p) : Math.min(5, p.tanks);
+ for (let amount = 1; amount <= maximum; amount++)
+ for (let elite = Math.max(0, amount - (p.tanks - (p.elites?.tanks ?? 0)));
+ elite <= Math.min(amount, eliteRevivalRemaining(p, g.advanced)); elite++) {
+ const free = source === 'normal' ? forceRevivalQuote(g, p, amount, elite).free : 0;
+ const grant = currentHomeworldRevivalGrant(g, p, source, { amount, elite, free });
+ if (grant?.blocked) blocks.push({ source, amount, elite, reason: grant.blocked });
+ }
+ }
+ return blocks;
+}
function collectRevivalIncome(
g: Game,
actor: Player,
@@ -11505,7 +13076,14 @@ function collectRevivalIncome(
const tleilaxu = byFaction(g, 'tleilaxu');
if (!tleilaxu) return;
g.revivalFreeIncome ??= {};
- const reward = ghola || (free && g.revivalFreeIncome[actor.id] !== g.turn);
+ const reward =
+ ghola ||
+ (free &&
+ g.revivalFreeIncome[actor.id] !== g.turn &&
+ !(
+ actor.id !== tleilaxu.id &&
+ homeworldRule(() => tleilaxuHomeworldFreeIncomeBlocked(g))
+ ));
if (free && !ghola) g.revivalFreeIncome[actor.id] = g.turn;
const amount = (actor.id === tleilaxu.id ? 0 : paid) + (reward ? 1 : 0);
if (amount)
@@ -11554,12 +13132,19 @@ function finishRevival(
);
return;
}
+ const deploymentGrant = revival.kind === 'forces'
+ ? requireHomeworldRevivalGrant(g, p, revival.emperorExtra ? 'emperorExtra' : 'normal', {
+ amount: revival.amount!, elite: revival.elite ?? 0, free: revival.free,
+ })
+ : null;
payer.spice -= revival.cost;
if (revival.kind === 'forces') {
const n = revival.amount!,
elite = revival.elite ?? 0;
+ const source = revival.emperorExtra ? 'emperorExtra' : 'normal';
+ const group = { amount: n, elite, free: revival.free };
+ addRevivedReserves(g, p, n, elite);
p.tanks -= n;
- p.reserves += n;
if (revival.emperorExtra)
g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n;
else {
@@ -11570,7 +13155,6 @@ function finishRevival(
}
if (p.elites) {
p.elites.tanks -= elite;
- p.elites.reserves += elite;
p.elites.revived += elite;
}
if (quote.techIncome) {
@@ -11584,7 +13168,8 @@ function finishRevival(
`${rule.name} accrued ${token.spice} spice for ${getPlayer(g, income.owner).name}, payable at phase end.`,
);
}
- log(g, `${p.name} revived ${n} forces.`);
+ log(g, `${p.name} revived ${n} forces.${g.homeworlds ? ` ${revival.free} were free; ${n - revival.free} were paid. ${payer.name} paid ${revival.cost} spice. Future revival requests use the resulting Homeworld population.` : ''}`);
+ stageHomeworldRevivalReturn(g, p, source, group, deploymentGrant);
} else {
if (revival.kind === 'kwisatz') {
p.kwisatz!.dead = false;
@@ -11619,6 +13204,9 @@ function beginRevival(g: Game, revival: PendingRevival) {
!revivalPrevented(g, revival.player),
'Tleilaxu prevented this faction’s normal revivals for this turn.',
);
+ if (revival.kind === 'forces')
+ requireHomeworldRevivalGrant(g, getPlayer(g, revival.player), revival.emperorExtra ? 'emperorExtra' : 'normal',
+ { amount: revival.amount!, elite: revival.elite ?? 0, free: revival.free });
g.pendingRevival = revival;
const choam = byFaction(g, 'choam');
if (
@@ -11758,7 +13346,7 @@ function currentPlacementCancellationQuote(g: Game, response: ResponseWindow) {
} else if (!byFaction(g, 'choam')) {
const resources = currentPhaseResources(g);
if (!g.expansions.includes('ix')) {
- const board = boardResolution(() => quoteBattleBoard(g));
+ const board = boardResolution(() => quoteCombatBoard(g));
if (!board.battles.length) {
// No combat: the actual Battle initializer departs again to
// collection. Project only settled public balances and empty aid.
@@ -11795,6 +13383,7 @@ function currentPlacementCancellationQuote(g: Game, response: ResponseWindow) {
}
function finishResponse(g: Game, canceled: boolean) {
karamaConversionIntegrity(g);
+ currentFactionPayment(g);
const response = g.response!;
const ecazCollectionQuote =
response.kind === 'ecazCollection'
@@ -12326,7 +13915,17 @@ function finishResponse(g: Game, canceled: boolean) {
: null;
const pending = g.pendingIxSubstitution!;
const player = getPlayer(g, pending.player);
- if (!canceled) {
+ if (!canceled && pending.homeworld) {
+ homeworldSubstitutionIntegrity(g);
+ homeworldDefenseIntegrity(g);
+ homeworldShipmentIntegrity(g);
+ const amount = Object.values(pending.sources!).reduce((a, b) => a + b, 0);
+ const quote = quoteHomeworldSubstitution(homeworldLossContext(g), g.homeworlds!.custody!, {
+ location: pending.territory, player: player.id, amount, cyborgsLost: pending.homeworld.cyborgsLost,
+ });
+ commitHomeworldResources(g, quote);
+ log(g, `${player.name} exchanged ${amount} surviving Suboids for the same number of Cyborgs lost at ${combatLocationName(g, pending.territory)}.`);
+ } else if (!canceled) {
for (const [key, count] of Object.entries(pending.sources!))
kill(g, player, key, count, true, 0);
for (const [key, count] of Object.entries(pending.recover!)) {
@@ -12381,6 +13980,8 @@ function finishResponse(g: Game, canceled: boolean) {
if (g.pendingRevival) finishRevival(g);
} else if (response.kind === 'revivalIncome') {
if (!canceled) getPlayer(g, response.owner).spice += response.amount!;
+ if (g.pendingChoamMarketGhola && g.pendingChoamMarketGhola.event === response.intent)
+ g.pendingChoamMarketGhola.stage = 'complete';
} else if (response.kind === 'faceDancerReplacement') {
const owner = getPlayer(g, response.owner);
const index = owner.faceDancers!.findIndex(
@@ -12412,20 +14013,27 @@ function finishResponse(g: Game, canceled: boolean) {
if (canceled) {
const quote = choamSaleCancellationQuote(g, response)!;
g.choamMarket = quote.market;
- g.decision = quote.decision;
+ resumeChoamMarket(g);
log(
g,
'The declared CHOAM card sale was canceled; the card stays in hand.',
);
return;
- } else if (quoteSale(owner.hand, sale.card, sale.witness)) {
+ }
+ const saleCard = owner.hand.find((card) => card.id === sale.card);
+ const saleBlock = saleCard
+ ? homeworldRule(() => homeworldWorthlessSaleBlock(g, owner.id, saleCard))
+ : null;
+ if (quoteSale(owner.hand, sale.card, sale.witness) && !saleBlock) {
discard(g, owner, sale.card);
owner.spice += sale.price;
log(g, `${owner.name} received ${sale.price} spice from the card sale.`);
} else
log(
g,
- 'The declared card sale is no longer possible; no spice was paid.',
+ saleBlock
+ ? 'Tupile is now high population, so the declared Worthless sale cannot finish. The card stays in hand and no spice is paid.'
+ : 'The declared card sale is no longer possible; no spice was paid.',
);
delete market.sale;
resumeChoamMarket(g);
@@ -12450,6 +14058,10 @@ function finishResponse(g: Game, canceled: boolean) {
: { turn: g.turn, canceled };
if (!canceled) {
const choam = getPlayer(g, response.owner);
+ requireRule(
+ homeworldLowBonus(g, choam.id) === 0,
+ 'Low-population CHOAM opening income awaits its Homeworld charity ruling.',
+ );
const amount = 2 * g.players.length * charityMultiplier(g);
choam.spice += amount;
log(g, `${choam.name} collected ${amount} spice before charity claims.`);
@@ -12460,7 +14072,12 @@ function finishResponse(g: Game, canceled: boolean) {
);
} else if (response.kind === 'bgCharity') {
if (!canceled)
- payCharity(g, getPlayer(g, response.owner), response.amount ?? 2);
+ payCharity(
+ g,
+ getPlayer(g, response.owner),
+ response.amount ?? 2,
+ response.charityHomeworld ?? 0,
+ );
} else if (response.kind === 'advisorFlip') {
if (canceled) movementCancellationQuote(g, response);
const bg = getPlayer(g, response.owner),
@@ -12572,6 +14189,8 @@ function finishResponse(g: Game, canceled: boolean) {
g.auction!.peekKnown =
terminal?.kind === 'atreidesAuction' ? terminal.known : !canceled;
} else if (response.kind === 'atreidesSpice') {
+ const blocked = homeworldRule(() => homeworldMovementForesightBlock(g, response.owner));
+ requireRule(!blocked, blocked ?? 'Caladan prevents this foresight.');
g.spicePeekKnown =
terminal?.kind === 'atreidesSpice' ? terminal.known : !canceled;
} else if (response.kind === 'wormPlacement') {
@@ -12651,12 +14270,12 @@ function finishResponse(g: Game, canceled: boolean) {
);
}
} else if (response.kind === 'guildIncome') {
- if (!canceled) getPlayer(g, response.owner).spice += response.amount!;
+ const payment = !canceled ? creditFactionPayment(g, response.owner, 'shipment', response.amount!) : null;
log(
g,
canceled
? `Karama sent the ${response.amount} spice of Guild shipment income to the bank. The shipment and its price remain unchanged.`
- : `${getPlayer(g, response.owner).name} collected ${response.amount} spice paid by other factions for shipment. Spice contributed by the Guild itself goes to the bank.`,
+ : `${getPlayer(g, response.owner).name} collected ${payment!.income} spice paid by other factions for shipment.${payment!.bank ? ` Low-population Junction leaves ${payment!.bank} spice in the bank.` : ''} Spice contributed by the Guild itself goes to the bank.`,
{
faction: 'guild',
name: canceled ? 'Shipment income prevented' : 'Shipment income',
@@ -12679,12 +14298,12 @@ function finishResponse(g: Game, canceled: boolean) {
response.amount === 3,
'This Richese purchase income is no longer current.',
);
- if (!canceled) getPlayer(g, response.owner).spice += 3;
+ const payment = !canceled ? creditFactionPayment(g, response.owner, 'treachery', 3) : null;
log(
g,
canceled
? 'Karama prevented the Emperor from collecting the 3-spice Richese purchase payment. The purchase remains completed and the payment goes to the bank.'
- : `${getPlayer(g, response.owner).name} collected 3 spice because the Emperor receives another faction’s Treachery Card purchase payment.`,
+ : `${getPlayer(g, response.owner).name} collected ${payment!.income} spice from another faction’s Treachery Card purchase payment.${payment!.bank ? ` Low-population Kaitain leaves ${payment!.bank} spice in the bank.` : ''}`,
{
faction: 'emperor',
name: canceled ? 'Purchase income prevented' : 'Card purchase income',
@@ -12702,10 +14321,10 @@ function finishResponse(g: Game, canceled: boolean) {
if (!canceled) {
const owner = getPlayer(g, response.owner);
const amount = g.currentAuctionSale?.amount ?? g.auction!.bid;
- owner.spice += amount;
+ const payment = creditFactionPayment(g, owner.id, 'treachery', amount);
log(
g,
- `${owner.name} received ${amount} spice because the Emperor collects another faction’s paid auction bid.`,
+ `${owner.name} received ${payment.income} spice from another faction’s ${amount}-spice paid auction bid.${payment.bank ? ` Low-population Kaitain leaves ${payment.bank} spice in the bank.` : ''}`,
{ faction: owner.faction, name: 'Auction income' },
);
}
@@ -12738,9 +14357,8 @@ function finishResponse(g: Game, canceled: boolean) {
}
if (canceled) return;
const owner = getPlayer(g, response.owner);
- requireRule(owner.reserves > 0, 'No forces remain in reserves.');
- owner.reserves--;
const target = splitLocation(response.location ?? 'polar_sink:0');
+ const amount = integer(response.amount === undefined ? 1 : response.amount, 1, spiritualAdvisorMaximum(g, owner.id, target.territory), 'Spiritual Advisor forces');
const advisors = arrivalAsAdvisor(
g,
owner,
@@ -12748,7 +14366,9 @@ function finishResponse(g: Game, canceled: boolean) {
undefined,
target.territory !== 'polar_sink',
);
- place(owner, target.territory, target.sector, 1);
+ allowedEntry(g, owner, target.territory, target.sector, false, advisors);
+ withdrawNativeReserves(g, owner, amount, 0);
+ place(owner, target.territory, target.sector, amount);
techIncome(g, 'heighliners', owner);
if (advisors) {
owner.advisors ??= {};
@@ -12756,20 +14376,253 @@ function finishResponse(g: Game, canceled: boolean) {
}
log(
g,
- `${owner.name} sent a free force to ${territory(target.territory).name}.`,
+ `${owner.name} sent ${amount} free ${amount === 1 ? 'force' : 'forces'} to ${territory(target.territory).name}${amount === 2 ? ' using high-population Wallach IX' : ''}.`,
+ {faction: owner.faction, name: 'Spiritual Advisors'},
);
openTerritoryEntry(
g,
owner,
target.territory,
target.sector,
- 1,
+ amount,
0,
'advisor',
);
} else throw new RuleError('Unknown faction response.');
}
+function homeworldAllianceReason(g: Game, first: string, second: string) {
+ return g.homeworlds?.custody
+ ? homeworldRule(() => homeworldAllianceBlock(homeworldContext(g), g.homeworlds!.custody!, first, second)) : null;
+}
+function homeworldShipmentAutomatic(g: Game): boolean {
+ const decision = g.decision;
+ if (decision?.kind !== 'homeworldShipmentGuild' || g.response || g.truthtrance ||
+ g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard ||
+ g.pendingKarama || g.pendingExchange || g.pendingRicheseGift || g.summonedWorm)
+ return false;
+ homeworldShipmentIntegrity(g);
+ const guild = getPlayer(g, decision.player);
+ return !guild.hand.some((card) => {
+ if (card.effect !== 'karama') return false;
+ try { prepareSpecialKaramaIntent(g, guild.id, {type: 'card', mode: 'special', card: card.id}); return true; }
+ catch (error) { if (error instanceof RuleError) return false; throw error; }
+ });
+}
+function homeworldShipmentEvent(g: Game, p: Player, route?: 'arrakis'): string {
+ if (route === 'arrakis') return JSON.stringify([homeworldShipmentEvent(g, p), route,
+ g.storm, p.forces, p.elites?.forces ?? {}, g.mobileStronghold?.location ?? null]);
+ return JSON.stringify([g.turn, g.phase, g.active, p.id, p.shipped,
+ homeworldContext(g), g.homeworlds?.custody,
+ g.players.map((seat) => [seat.id, seat.ally])]);
+}
+function homeworldShipmentBlock(g: Game, p: Player, checkPromises = true): string | null {
+ if (!g.homeworlds?.custody) return 'Homeworld shipment requires the Homeworld module.';
+ if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || p.shipped)
+ return 'Use your unused shipment during your own Shipment and Movement turn.';
+ if (g.truthtrance || g.response || g.decision || g.phaseOpening ||
+ g.pendingNullentropy || g.pendingTreacheryDiscard || g.pendingShipment ||
+ g.pendingHomeworldShipment || g.pendingExchange || g.pendingRicheseGift || g.battle)
+ return 'Finish the current interaction before another Homeworld shipment.';
+ if (g.karamaShipping?.player === p.id)
+ return 'Homeworld shipment using a purchased Karama rate awaits its card-scope integration.';
+ const richese = byFaction(g, 'richese');
+ if (richese && !noFieldAllyOfferBlock(g, richese) &&
+ !(g.richeseAllyOpportunity?.turn === g.turn && g.richeseAllyOpportunity.recipient === p.id))
+ return 'Wait for Richese to offer or pass its allied shipment opportunity.';
+ try { if (checkPromises) checkShipmentPromises(g, p, null); }
+ catch (error) { if (error instanceof RuleError) return error.message; throw error; }
+ return null;
+}
+function guildHomeworldShipmentBlock(g: Game, p: Player): string | null {
+ if (p.faction !== 'guild') return 'Only Guild has this Arrakis-to-Homeworld shipment permission.';
+ return homeworldShipmentBlock(g, p);
+}
+function junctionTransportWindow(g: Game) {
+ const owner = homeworldRule(() => junctionSponsor(g));
+ const recipient = g.players.find((seat) => seat.id === g.active);
+ if (!owner || !recipient || owner === recipient.id || g.phase !== 5 ||
+ g.status !== 'playing' || recipient.shipped) return null;
+ const offer = currentJunctionOffer(g);
+ const blocked = homeworldShipmentBlock(g, recipient, false);
+ return {owner, recipient: recipient.id, offer, blocked,
+ canOffer: !blocked, offerEvent: junctionOfferEvent(g),
+ event: JSON.stringify([homeworldShipmentEvent(g, recipient, 'arrakis'), offer])};
+}
+function performJunctionTransport(g: Game, p: Player, action: Action) {
+ const option = junctionTransportWindow(g);
+ requireRule(option && option.recipient === p.id && !option.blocked && option.offer,
+ option?.blocked ?? 'Wait for Guild to offer Junction transport during your unused shipment.');
+ requireRule(action.event === option.event && action.offer === option.offer.event,
+ 'This Junction offer or physical source selection has changed.');
+ requireRule(Object.keys(action).every((key) =>
+ ['type', 'event', 'offer', 'destination', 'sources', 'allyPayment'].includes(key)),
+ 'Use the offered tariff and explicit physical forces without concealed tokens.');
+ const destination = stringField(action.destination);
+ const context = {...homeworldContext(g), storm: g.storm,
+ players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})),
+ mobileStronghold: g.mobileStronghold?.location ?? null,
+ board: {[p.id]: {forces: p.forces, eliteForces: p.elites?.forces ?? {}, advisors: p.advisors}}};
+ const quote = homeworldRule(() => quoteJunctionTransport(context, g.homeworlds!.custody!,
+ {player: p.id, sponsor: option.owner, rate: option.offer!.rate, destination,
+ sources: action.sources as HomeworldShipmentIntent['sources']}));
+ const arrival = quote.destinationKind === 'arrakis' ? splitLocation(destination) : null;
+ const nativeDeparture = quote.originKind === 'homeworld' && quote.sources.every((source) =>
+ source.key === `homeworld:${p.faction}` || (p.faction === 'emperor' && source.key === 'homeworld:emperor:salusa'));
+ const promise = nativeDeparture && arrival ? {territory: arrival.territory, amount: quote.amount} : null;
+ checkShipmentPromises(g, p, promise);
+ const sourceLock = quote.originKind === 'arrakis' ? p.advisors?.[quote.origin]?.lockedTurn : undefined;
+ const advisors = arrival ? arrivalAsAdvisor(g, p, arrival.territory,
+ quote.originKind === 'arrakis' ? quote.origin : undefined) : false;
+ if (arrival) {
+ requireRule(arrival.territory !== MOBILE_STRONGHOLD || p.faction === 'ixians',
+ 'Only Ixians may ship directly into the mobile stronghold.');
+ requireRule(advisors || sourceLock !== g.turn ||
+ !g.players.some((other) => other.id !== p.id && at(other, arrival.territory)),
+ 'New advisors cannot become fighters this turn.');
+ allowedEntry(g, p, arrival.territory, arrival.sector, false, advisors);
+ }
+ const allyPayment = contribution(g, p, quote.cost, action.allyPayment);
+ // Junction permission and tariff are Homeworld effects and resist Karama.
+ // All validation precedes this atomic settlement; there is no special-stop frame.
+ payWithAlly(g, p, quote.cost, allyPayment);
+ g.homeworlds!.custody = quote.state;
+ p.forces = quote.boardForces;
+ if (p.elites) p.elites.forces = quote.boardEliteForces;
+ for (const seat of quote.players) {
+ const owner = getPlayer(g, seat.id);
+ owner.reserves = seat.reserves;
+ if (owner.elites) owner.elites.reserves = seat.eliteReserves;
+ }
+ if (arrival) {
+ place(p, arrival.territory, arrival.sector, quote.amount, quote.elite);
+ if (advisors) (p.advisors ??= {})[arrival.territory] = {
+ lockedTurn: Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0) || undefined};
+ else if (p.advisors) delete p.advisors[arrival.territory];
+ }
+ p.shipped = true;
+ g.junctionOffer = null;
+ finishShipmentPromises(g, p, promise);
+ // Native Fremen reinforcement is on Arrakis; all other world departures
+ // are off-planet. Homeworld destinations independently trigger E3 technology.
+ const offPlanet = quote.originKind === 'homeworld' && !(nativeDeparture && p.faction === 'fremen');
+ if (!arrival || offPlanet) techIncome(g, 'heighliners', p);
+ const ordinaryIncome = !!arrival && nativeDeparture && p.faction !== 'fremen';
+ const income = (ordinaryIncome ? quote.cost - allyPayment : 0) +
+ (p.ally === option.owner ? 0 : allyPayment);
+ if (income > 0) g.response = guildPaymentResponse(g, option.owner,
+ [ordinaryIncome ? quote.cost - allyPayment : 0, p.ally === option.owner ? 0 : allyPayment]);
+ const sourceName = quote.originKind === 'arrakis' ? territory(quote.origin).name :
+ quote.sources.map((s) => combatLocationName(g, s.key)).join(' and ');
+ log(g, `${p.name} accepted ${getPlayer(g, option.owner).name}’s Junction ${option.offer.rate}-price offer and transported ${quote.amount} physical forces (${quote.elite} special) from ${sourceName} to ${arrival ? `${territory(arrival.territory).name}, sector ${arrival.sector}` : combatLocationName(g, destination)} for ${quote.cost} spice (${quote.cost - allyPayment} own, ${allyPayment} pledged). The shipment is used; movement remains available.`,
+ {faction: 'guild', name: 'Junction transport'});
+ if (arrival) {
+ const bg = byFaction(g, 'beneGesserit');
+ const followup = offPlanet && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0
+ ? {shipment: p.id, destination} : undefined;
+ if (!intrusion(g, p, arrival.territory, {followup}) && followup)
+ g.decision = {kind: 'advisor', player: bg!.id, ...followup};
+ openTerritoryEntry(g, p, arrival.territory, arrival.sector, quote.amount, quote.elite, 'shipment');
+ }
+}
+function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis'}) {
+ requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.');
+ const context = {
+ ...homeworldContext(g),
+ players: homeworldContext(g).players.map((seat) => ({...seat, ally: getPlayer(g, seat.id).ally})),
+ };
+ const order = {player: intent.player, destination: intent.destination, sources: intent.sources};
+ if (intent.route === 'arrakis') {
+ const quote = homeworldRule(() => quoteGuildHomeworldShipment({...context, storm: g.storm,
+ mobileStronghold: g.mobileStronghold?.location ?? null,
+ board: Object.fromEntries(g.players.map((p) => [p.id, {forces: p.forces, eliteForces: p.elites?.forces ?? {}}])),
+ }, g.homeworlds!.custody!, order));
+ return {...quote, sources: quote.boardSources, sourceNames: [territory(quote.origin).name]};
+ }
+ const quote = homeworldRule(() => quoteHomeworldShipment(context, g.homeworlds!.custody!, order));
+ return {...quote, boardForces: undefined, boardEliteForces: undefined,
+ sourceNames: quote.sources.map((s) => combatLocationName(g, s.homeworld))};
+}
+function validateHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
+ const p = getPlayer(g, shipment.player);
+ requireRule(g.status === 'playing' && g.phase === 5 && g.active === p.id && !p.shipped &&
+ shipment.turn === g.turn && (shipment.route === undefined || shipment.route === 'arrakis') &&
+ shipment.event === homeworldShipmentEvent(g, p, shipment.route),
+ 'This Homeworld declaration no longer matches its unused shipment and physical custody.');
+ requireRule(g.karamaShipping?.player !== p.id,
+ 'Homeworld shipment using a purchased Karama rate awaits its card-scope integration.');
+ const quote = homeworldShipmentQuote(g, shipment);
+ requireRule(shipment.amount === quote.amount && shipment.elite === quote.elite &&
+ shipment.cost === quote.cost && JSON.stringify(shipment.pools) === JSON.stringify(quote.sources),
+ 'The saved Homeworld shipment price or typed source pools changed.');
+ integer(shipment.allyPayment, 0, quote.cost, 'Shipment ally payment');
+ contribution(g, p, quote.cost, shipment.allyPayment);
+ checkShipmentPromises(g, p, null);
+ return quote;
+}
+function homeworldShipmentIntegrity(g: Game) {
+ homeworldMobilityIntegrity(g);
+ homeworldRule(() => junctionOfferIntegrity(g));
+ const decisions = homeworldSavedDecisions(g).filter((d) => d.kind === 'homeworldShipmentGuild');
+ const shipment = g.pendingHomeworldShipment;
+ if (!shipment && !decisions.length) return;
+ requireRule(shipment && decisions.length > 0 && !g.pendingShipment,
+ 'The saved Homeworld shipment needs its original Guild decision.');
+ validateHomeworldShipment(g, shipment);
+ const guild = byFaction(g, 'guild');
+ requireRule(g.advanced && guild && !guild.specialKaramaUsed,
+ 'This Homeworld shipment has no available Guild interception.');
+ for (const d of decisions) requireRule(d.player === guild.id &&
+ d.shipper === shipment.player && d.destination === shipment.destination &&
+ d.amount === shipment.amount && d.event === shipment.event,
+ 'The saved Guild decision differs from its Homeworld declaration.');
+}
+function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment) {
+ const quote = validateHomeworldShipment(g, shipment);
+ const p = getPlayer(g, shipment.player);
+ payWithAlly(g, p, quote.cost, shipment.allyPayment);
+ g.homeworlds!.custody = quote.state;
+ if (shipment.route === 'arrakis') {
+ p.forces = quote.boardForces!;
+ if (p.elites) p.elites.forces = quote.boardEliteForces!;
+ }
+ for (const seat of quote.players) {
+ const owner = getPlayer(g, seat.id);
+ owner.reserves = seat.reserves;
+ if (owner.elites) owner.elites.reserves = seat.eliteReserves;
+ }
+ p.shipped = true;
+ g.pendingHomeworldShipment = null;
+ // The E3 FAQ explicitly includes Homeworld shipment, including Fremen.
+ techIncome(g, 'heighliners', p);
+ // Off-planet destinations do not create the Guild's ordinary onto-Dune income.
+ // November FAQ contributor routing remains independent of that trigger.
+ const guild = byFaction(g, 'guild');
+ const income = guild && p.ally !== guild.id ? shipment.allyPayment : 0;
+ if (guild && income > 0)
+ g.response = guildPaymentResponse(g, guild.id, [income]);
+ finishShipmentPromises(g, p, null);
+ log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.`,
+ {faction: p.faction, name: 'Homeworld shipment'});
+}
+function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis'}, allyPayment?: unknown) {
+ const quote = homeworldShipmentQuote(g, intent);
+ const shipment: PendingHomeworldShipment = {...intent, sources: structuredClone(intent.sources),
+ event: homeworldShipmentEvent(g, p, intent.route), turn: g.turn, amount: quote.amount, elite: quote.elite,
+ cost: quote.cost, allyPayment: contribution(g, p, quote.cost, allyPayment), pools: quote.sources};
+ const guild = byFaction(g, 'guild');
+ if (g.advanced && guild && !guild.specialKaramaUsed) {
+ g.pendingHomeworldShipment = shipment;
+ g.decision = {kind: 'homeworldShipmentGuild', player: guild.id, shipper: p.id,
+ destination: intent.destination, amount: quote.amount, event: shipment.event};
+ log(g, `${p.name} declared ${quote.amount} physical forces for ${combatLocationName(g, intent.destination)}. Payment and departure await the Guild interception decision.`);
+ } else commitHomeworldShipment(g, shipment);
+}
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
+ requireRule(
+ !(shipment.noField || shipment.alliedNoField) ||
+ shipment.homeworldSources === undefined,
+ 'A No-Field shipment uses its own physical source allocation.',
+ );
if (shipment.source === 'ambassador') {
validateAmbassadorShipmentOrder(g, shipment);
return;
@@ -12788,6 +14641,21 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
const n = integer(shipment.amount, 1, p.reserves, 'Shipment forces');
integer(shipment.sector, 0, 18, 'Shipment sector');
eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, shipment.elite);
+ if (g.homeworlds?.custody)
+ homeworldRule(() =>
+ quoteNativeReserveWithdrawal(
+ homeworldContext(g),
+ g.homeworlds!.custody!,
+ p.id,
+ { normal: n - shipment.elite, elite: shipment.elite },
+ shipment.homeworldSources,
+ ),
+ );
+ else
+ requireRule(
+ shipment.homeworldSources === undefined,
+ 'Homeworld source selection requires the Homeworld module.',
+ );
requireRule(
typeof shipment.elite === 'number' &&
shipment.advisors === arrivalAsAdvisor(g, p, shipment.territory),
@@ -12868,6 +14736,7 @@ function validateGuildShipmentDecision(
}
function offerShipment(g: Game, shipment: PendingShipment) {
validatePhysicalShipment(g, shipment);
+ checkShipmentIncomeRounding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
const p = getPlayer(g, shipment.player),
id = p.id;
const { territory: to, sector: s } = shipment;
@@ -12973,9 +14842,25 @@ function commitShipment(g: Game, shipment: PendingShipment) {
owner.spice -= quote.ownerPayment;
p.spice -= quote.recipientPayment;
} else payWithAlly(g, p, cost, allyPayment);
+ let homeworldOrigins = '';
if (!shipment.noField) {
- p.reserves -= n;
- if (p.elites) p.elites.reserves -= elite;
+ const receipts = withdrawNativeReserves(
+ g,
+ p,
+ n,
+ elite,
+ shipment.homeworldSources,
+ );
+ homeworldOrigins = receipts
+ .map((receipt) => {
+ const card = HOMEWORLD_CARDS.find((c) =>
+ receipt.homeworld === 'homeworld:emperor:salusa'
+ ? c.id === 'salusa_secundus'
+ : c.faction === p.faction && c.id !== 'salusa_secundus',
+ )!;
+ return `${receipt.before.normal - receipt.after.normal} normal and ${receipt.before.elite - receipt.after.elite} special forces from ${card.name}`;
+ })
+ .join('; ');
if (n > 0) place(p, to, s, n, elite);
}
if (advisors && n > 0) (p.advisors ??= {})[to] ??= {};
@@ -12991,12 +14876,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
bankOnly: g.karamaShipping?.player === p.id,
});
if (guild && guildPayment > 0)
- g.response = {
- kind: 'guildIncome',
- owner: guild.id,
- amount: guildPayment,
- passed: [],
- };
+ g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment));
g.karamaShipping = null;
log(
g,
@@ -13004,14 +14884,14 @@ function commitShipment(g: Game, shipment: PendingShipment) {
? `${getPlayer(g, shipment.alliedNoField.owner).name} shipped ${p.name} with No-Field ${quoteAlliedTokenValue(g, shipment.alliedNoField)}, immediately placing ${n} physical forces (${elite} elite) in ${territory(to).name}, sector ${s}. ${shipment.alliedNoField.payer === 'both' ? 'Each ally paid 1 spice' : `${getPlayer(g, shipment.alliedNoField.payer).name} paid ${cost} spice`}.`
: shipment.noField
? `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. It counts as one force; physical reserves remain unchanged until reveal.`
- : `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.`,
+ : `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
shipment.noField || shipment.alliedNoField
? { faction: 'richese', name: 'No-Field shipment' }
: undefined,
);
const bg = byFaction(g, 'beneGesserit');
const followup =
- p.faction !== 'fremen' && bg && bg.id !== p.id && bg.reserves > 0
+ p.faction !== 'fremen' && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0
? { shipment: p.id, destination: location(to, s) }
: undefined;
if (!intrusion(g, p, to, { followup }) && followup)
@@ -13042,6 +14922,8 @@ function quoteAlliedTokenValue(g: Game, offer: RicheseAllyOffer) {
)!.value;
}
function validateMobileMove(g: Game, p: Player, input: unknown, max: number) {
+ const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, p.id));
+ requireRule(!blocked, blocked ?? 'The mobile stronghold cannot move.');
requireRule(
p.faction === 'ixians' &&
g.mobileStronghold?.location &&
@@ -13074,6 +14956,8 @@ function relocateMobileStronghold(
move: { player: string; route: string[]; collect: boolean },
) {
const p = getPlayer(g, move.player);
+ const blocked = homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, p.id));
+ requireRule(!blocked, blocked ?? 'The mobile stronghold cannot move.');
let collected = 0;
if (move.collect)
for (const key of move.route) {
@@ -13327,6 +15211,7 @@ export type SpecialKaramaIntent = {
battle: Pick;
}
| { kind: 'guild'; target: string; shipment: PendingShipment }
+ | { kind: 'guildHomeworld'; target: string; shipment: PendingHomeworldShipment }
| { kind: 'emperorForces'; amount: number; elite: number }
| { kind: 'emperorLeader'; leader: string }
| { kind: 'harkonnen'; target: string; amount: number }
@@ -13460,6 +15345,11 @@ export function prepareSpecialKaramaIntent(
'Wait for a normal revival declaration before preventing it.',
);
const target = g.pendingRevival.player;
+ const homeworldBlock = homeworldRevivalKaramaBlock(g, target);
+ requireRule(
+ !homeworldBlock,
+ homeworldBlock ?? '',
+ );
requireRule(
action.target === undefined || action.target === target,
'This decision applies to the faction currently reviving.',
@@ -13514,6 +15404,15 @@ export function prepareSpecialKaramaIntent(
},
};
} else if (p.faction === 'guild') {
+ if (g.decision?.kind === 'homeworldShipmentGuild') {
+ requireRule(!g.response && g.decision.player === p.id,
+ 'Wait for your Homeworld shipment interception decision.');
+ homeworldShipmentIntegrity(g);
+ const shipment = g.pendingHomeworldShipment!;
+ requireRule(action.target === undefined || action.target === shipment.player,
+ 'Stop the player who declared this Homeworld shipment.');
+ return {...base, kind: 'guildHomeworld', target: shipment.player, shipment: structuredClone(shipment)};
+ }
requireRule(
g.phase === 5 &&
!g.response &&
@@ -13554,7 +15453,7 @@ export function prepareSpecialKaramaIntent(
const n = integer(action.amount, 1, Math.min(3, p.tanks), 'Forces');
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
requireRule(
- elite <= eliteRevivalRemaining(p),
+ elite <= eliteRevivalRemaining(p, g.advanced),
'Only one elite force may be revived per turn.',
);
return { ...base, kind: 'emperorForces', amount: n, elite };
@@ -13600,6 +15499,7 @@ function specialKaramaAction(intent: SpecialKaramaIntent): Action {
case 'tleilaxu':
case 'atreides':
case 'guild':
+ case 'guildHomeworld':
return { ...action, target: intent.target };
case 'fremen':
return { ...action, territory: intent.territory };
@@ -13656,6 +15556,9 @@ export function executeSpecialKaramaIntent(
sameDeclaration(intent.shipment, prepared.shipment),
'The original shipment declaration is no longer pending.',
);
+ if (intent.kind === 'guildHomeworld' && prepared.kind === 'guildHomeworld')
+ requireRule(intent.target === prepared.target && sameDeclaration(intent.shipment, prepared.shipment),
+ 'The original Homeworld shipment declaration is no longer pending.');
if (intent.kind === 'atreides' && prepared.kind === 'atreides')
requireRule(
sameDeclaration(intent.battle, prepared.battle),
@@ -13770,6 +15673,15 @@ export function executeSpecialKaramaIntent(
g,
`${p.name} used special Karama to inspect ${getPlayer(g, target).name}’s entire battle plan.`,
);
+ } else if (intent.kind === 'guildHomeworld') {
+ const shipper = getPlayer(g, intent.target);
+ discard(g, p, card.id);
+ p.specialKaramaUsed = true;
+ shipper.shipped = true;
+ g.pendingHomeworldShipment = null;
+ g.decision = null;
+ finishShipmentPromises(g, shipper, null);
+ log(g, `${p.name} used special Karama to stop ${shipper.name}’s interplanetary shipment. No forces arrive or spice is paid; their shipment is used and movement remains available.`);
} else if (intent.kind === 'guild') {
const shipper = getPlayer(g, intent.target);
discard(g, p, card.id);
@@ -13813,11 +15725,10 @@ export function executeSpecialKaramaIntent(
} else {
const n = intent.amount,
elite = intent.elite;
+ addRevivedReserves(g, p, n, elite);
p.tanks -= n;
- p.reserves += n;
if (p.elites) {
p.elites.tanks -= elite;
- p.elites.reserves += elite;
p.elites.revived += elite;
}
log(g, `${p.name} used special Karama to revive ${n} forces for free.`);
@@ -13937,12 +15848,19 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
+ marketGholaIntegrity(state);
+ homeworldRule(() => homeworldGameIntegrity(state));
+ homeworldBattleLossIntegrity(state);
+ homeworldSubstitutionIntegrity(state);
+ homeworldDefenseIntegrity(state);
+ homeworldShipmentIntegrity(state);
karamaConversionIntegrity(state);
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
ambassadorRelocationIntegrity(state);
ecazCollectionIntegrity(state);
+ ecazAllianceIntegrity(state);
// Seat control is independent of gameplay locks and must preserve their exact continuation.
if (action?.type === 'setAutopilot') {
requireRule(
@@ -13987,6 +15905,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
+ homeworldRule(() => homeworldGameIntegrity(g));
+ homeworldBattleLossIntegrity(g);
+ homeworldSubstitutionIntegrity(g);
+ homeworldDefenseIntegrity(g);
+ homeworldShipmentIntegrity(g);
karamaConversionIntegrity(g);
ornithopterIntegrity(g);
lateDefenseIntegrity(g);
@@ -14073,11 +15996,23 @@ export function applyAction(state: Game, id: string, action: Action): Game {
reconcileBattlePromises(g, { actor: id, action });
reconcileShipmentPromises(g, { actor: id, action });
settleAutomaticContinuations(g);
+ marketGholaIntegrity(g);
+ homeworldRule(() => homeworldGameIntegrity(g));
+ homeworldBattleLossIntegrity(g);
+ homeworldSubstitutionIntegrity(g);
+ homeworldDefenseIntegrity(g);
+ homeworldShipmentIntegrity(g);
return g;
}
function finishActionContinuations(g: Game) {
if (g.pendingNullentropy) return;
- finishTreacheryDiscard(g);
+ // Losing cleanup may finish casualties and produce the winner's mandatory
+ // 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.');
+ resumeHomeworldRevivalReturn(g);
+ resumeHomeworldVictoryReturn(g);
+ resumeMarketGhola(g);
if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g);
if (
!g.truthtrance &&
@@ -14151,12 +16086,19 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
+ marketGholaIntegrity(state);
+ homeworldRule(() => homeworldGameIntegrity(state));
+ homeworldBattleLossIntegrity(state);
+ homeworldSubstitutionIntegrity(state);
+ homeworldDefenseIntegrity(state);
+ homeworldShipmentIntegrity(state);
karamaConversionIntegrity(state);
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
ambassadorRelocationIntegrity(state);
ecazCollectionIntegrity(state);
+ ecazAllianceIntegrity(state);
const g = structuredClone(state);
ornithopterIntegrity(g);
lateDefenseIntegrity(g);
@@ -14170,6 +16112,12 @@ export function normalizeAutomaticGame(state: Game): Game {
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
settleAutomaticContinuations(g);
+ marketGholaIntegrity(g);
+ homeworldRule(() => homeworldGameIntegrity(g));
+ homeworldBattleLossIntegrity(g);
+ homeworldSubstitutionIntegrity(g);
+ homeworldDefenseIntegrity(g);
+ homeworldShipmentIntegrity(g);
return g;
}
@@ -14180,6 +16128,9 @@ function applyActionInner(
execution: 'live' | 'shipmentPreparation' = 'live',
): Game {
const g = structuredClone(state);
+ if (g.biddingEnd && action.type !== 'advanceBots' &&
+ !(action.type === 'biddingEnd' && action.mode === 'ready'))
+ g.biddingEnd.ready = [];
ornithopterIntegrity(g);
lateDefenseIntegrity(g);
stoneBurnerIntegrity(g);
@@ -14406,6 +16357,15 @@ function applyActionInner(
specialKarama(g, p, action);
return g;
}
+ if (t === 'card' && choamSaleGholaTiming(g) &&
+ p.hand.some((card) => card.id === action.card && card.effect === 'ghola')) {
+ playMarketGhola(g, p, action);
+ return g;
+ }
+ if (t === 'biddingEnd') {
+ decideBiddingEnd(g, p, action);
+ return g;
+ }
if (g.phaseOpening) {
requireRule(
g.status === 'playing',
@@ -14495,6 +16455,14 @@ function applyActionInner(
'Waiting for the player with the pending decision.',
);
g.decision = null;
+ if (decision.kind === 'homeworldRevivalDeployment') {
+ decideHomeworldRevivalReturn(g, p, action);
+ return g;
+ }
+ if (decision.kind === 'caladanReinforcement') {
+ decideHomeworldVictoryReturn(g, p, action);
+ return g;
+ }
if (decision.kind === 'ecazSpice') {
g.decision = decision;
decideSharedSpice(g, id, action);
@@ -14833,95 +16801,7 @@ function applyActionInner(
decision.kind === 'choamTradeReply' ||
decision.kind === 'choamTradeConfirm'
) {
- const market = g.choamMarket!;
- const owner = getPlayer(g, market.owner);
- if (decision.kind === 'choamMarket') {
- if (action.done === true) {
- finishChoamMarket(g);
- } else if (action.mode === 'sell') {
- const sale = quoteSale(
- owner.hand,
- action.card,
- action.witness,
- market.blocked,
- );
- requireRule(
- sale,
- 'Choose an available Worthless card or surplus exact duplicate.',
- );
- market.sale = sale;
- const card = owner.hand.find((c) => c.id === sale.card)!;
- log(
- g,
- `${owner.name} revealed ${sale.witness ? 'two copies of ' : ''}${card.name} and offered one for ${sale.price} spice.`,
- );
- g.response = { kind: 'choamSale', owner: owner.id, passed: [] };
- } else {
- requireRule(
- action.mode === 'trade',
- 'Sell a card, offer a trade or finish the phase.',
- );
- requireRule(
- g.choamTradeTurn !== g.turn,
- 'CHOAM may exchange one card with its ally only once per turn.',
- );
- const ally = g.players.find(
- (p) => p.id === owner.ally && p.ally === owner.id,
- );
- requireRule(ally, 'You need an ally for a two-way trade.');
- requireRule(
- owner.hand.some((c) => c.id === action.card),
- 'Offer a card from your own hand.',
- );
- market.trade = { ally: ally.id, offered: stringField(action.card) };
- market.tradeAttempted = true;
- g.decision = { kind: 'choamTradeReply', player: ally.id };
- }
- } else {
- const trade = market.trade!;
- const ally = getPlayer(g, trade.ally);
- if (action.decline === true) {
- delete market.trade;
- resumeChoamMarket(g);
- } else if (decision.kind === 'choamTradeReply') {
- requireRule(
- ally.hand.some((c) => c.id === action.card),
- 'Choose one of your cards to return, or decline.',
- );
- trade.returned = stringField(action.card);
- g.decision = { kind: 'choamTradeConfirm', player: owner.id };
- } else {
- requireRule(
- action.accept === true,
- 'Confirm the two-way exchange or decline.',
- );
- const offered = owner.hand.find((c) => c.id === trade.offered);
- const returned = ally.hand.find((c) => c.id === trade.returned);
- if (
- offered &&
- returned &&
- owner.ally === ally.id &&
- ally.ally === owner.id &&
- g.choamTradeTurn !== g.turn
- ) {
- owner.hand = owner.hand.filter((c) => c.id !== offered.id);
- ally.hand = ally.hand.filter((c) => c.id !== returned.id);
- owner.hand.push(returned);
- ally.hand.push(offered);
- g.choamTradeTurn = g.turn;
- log(
- g,
- `${owner.name} and ${ally.name} exchanged one treachery card each.`,
- );
- } else
- log(
- g,
- 'The proposed card exchange is no longer possible; both hands remain unchanged.',
- );
- delete market.trade;
- resumeChoamMarket(g);
- }
- }
+ decideChoamMarket(g, decision, action);
} else if (decision.kind === 'ixSetup') {
requireRule(
g.status === 'setup' && p.faction === 'ixians' && g.ixSetupCards,
@@ -15074,7 +16954,9 @@ function applyActionInner(
.filter(([, count]) => Number(count) > 0),
) as Record;
};
- const sources = Object.fromEntries(
+ const sources = pending.territory.startsWith('homeworld:')
+ ? { [pending.territory]: combatArmy(g, p.id, pending.territory).normal }
+ : Object.fromEntries(
Object.entries(p.forces)
.filter(
([key]) => splitLocation(key).territory === pending.territory,
@@ -15112,7 +16994,12 @@ function applyActionInner(
'You do not have an unrevealed Face Dancer for that leader.',
);
const winner = getPlayer(g, decision.winner);
- const maximum = at(winner, decision.territory);
+ const blocked = faceDanceReturnBlock(g, winner.id);
+ requireRule(!blocked, blocked ?? 'This Face Dance return is unavailable.');
+ const home = homeworldBattleLocation(g, decision.territory);
+ requireRule(!home || home.native === p.id, 'Only the native faction may call a Face Dancer on a Homeworld.');
+ const army = combatArmy(g, winner.id, decision.territory);
+ const maximum = home ? army.normal + army.elite : at(winner, decision.territory);
const sources = action.sources;
requireRule(
sources && typeof sources === 'object' && !Array.isArray(sources),
@@ -15138,19 +17025,31 @@ function applyActionInner(
total <= maximum,
'You cannot replace more forces than the winner has remaining.',
);
- const sector = integer(action.sector, 0, 18, 'Sector');
- requireRule(
- validLocation(decision.territory, sector),
- 'Choose a sector in the battle territory.',
- );
+ const sector = home ? 0 : integer(action.sector, 0, 18, 'Sector');
+ requireRule(home || validLocation(decision.territory, sector), 'Choose a sector in the battle territory.');
// This is replacement, not shipment or movement: no transport cost or worm/storm transit.
for (const source of selected) {
- if (source.key === 'reserves') p.reserves -= source.count;
+ if (source.key === 'reserves') { if (!home) p.reserves -= source.count; }
else {
p.forces[source.key] -= source.count;
if (!p.forces[source.key]) delete p.forces[source.key];
}
}
+ if (home) {
+ const destination = `homeworld:${winner.faction}`;
+ const external = selected.filter((source) => source.key !== 'reserves').reduce((n, source) => n + source.count, 0);
+ const quote = quoteHomeworldCustody(homeworldContext(g), g.homeworlds!.custody!, [
+ { homeworld: home.id, player: winner.id, withdraw: army, deposit: {normal: 0, elite: 0} },
+ { homeworld: destination, player: winner.id, withdraw: {normal: 0, elite: 0}, deposit: army },
+ { homeworld: home.id, player: p.id, withdraw: {normal: 0, elite: 0}, deposit: {normal: external, elite: 0} },
+ ]);
+ g.homeworlds!.custody = quote.state;
+ for (const update of quote.players) {
+ const owner = getPlayer(g, update.id);
+ owner.reserves = update.reserves;
+ if (owner.elites) owner.elites.reserves = update.eliteReserves;
+ }
+ } else {
for (const key of Object.keys(winner.forces).filter(
(key) => splitLocation(key).territory === decision.territory,
)) {
@@ -15161,6 +17060,7 @@ function applyActionInner(
}
delete winner.forces[key];
}
+ }
const leader = g.players
.flatMap((player) => player.leaders)
.find((l) => l.id === decision.leader);
@@ -15171,11 +17071,11 @@ function applyActionInner(
delete leader.capturedBy;
delete leader.concealed;
}
- if (total) place(p, decision.territory, sector, total);
+ if (total && !home) place(p, decision.territory, sector, total);
dancer.revealed = true;
log(
g,
- `${p.name} revealed a Face Dancer and replaced ${total} forces in ${territory(decision.territory).name}.`,
+ `${p.name} revealed a Face Dancer and replaced ${total} forces in ${combatLocationName(g, decision.territory)}.`,
);
if (p.faceDancers!.every((c) => c.revealed)) {
g.traitorReserve = shuffle([
@@ -15252,6 +17152,15 @@ function applyActionInner(
action.continue === true,
'Confirm that you have inspected the battle plan.',
);
+ } else if (decision.kind === 'homeworldShipmentGuild') {
+ requireRule(action.allow === true && action.event === decision.event,
+ 'Allow this exact Homeworld declaration, or use the Guild special Karama.');
+ const shipment = g.pendingHomeworldShipment;
+ requireRule(shipment && decision.player === byFaction(g, 'guild')?.id &&
+ decision.event === shipment.event && decision.shipper === shipment.player &&
+ decision.destination === shipment.destination && decision.amount === shipment.amount,
+ 'The Guild decision no longer matches this Homeworld shipment.');
+ commitHomeworldShipment(g, shipment);
} else if (decision.kind === 'guildShipment') {
requireRule(
action.allow === true,
@@ -15348,6 +17257,29 @@ function applyActionInner(
);
kill(g, p, decision.key, decision.amount, false, elite);
if (decision.resume === 'storm') continueStorm(g);
+ } else if (decision.kind === 'homeworldDefense') {
+ const b = g.battle;
+ requireRule(b?.revealed && b.territory.startsWith('homeworld:') && b.event === decision.event && action.event === decision.event &&
+ [b.attacker, b.defender].includes(id) && !traitorVoters(g, b).includes(id) && !b.homeworldDefensePassed?.includes(id),
+ 'This Homeworld late-defense choice is no longer current.');
+ requireRule(typeof action.use === 'boolean', 'Use Portable Snooper or decline its late-defense opportunity.');
+ if (action.use) {
+ const card = p.hand.find(isPortableSnooper);
+ requireRule(card, 'The late defense needs your held Portable Snooper.');
+ validatePortableSnooper(g, p, card.id, decision.event);
+ (b.lateDefense ??= {})[id] = card.id;
+ log(g, `${p.name} added Portable Snooper after the Homeworld battle plans were revealed.`, {faction: p.faction, name: 'Portable Snooper'});
+ }
+ (b.homeworldDefensePassed ??= []).push(id);
+ advanceHomeworldReveal(g);
+ } else if (decision.kind === 'homeworldExplosion') {
+ homeworldBattleLossIntegrity(g);
+ homeworldSubstitutionIntegrity(g);
+ homeworldDefenseIntegrity(g);
+ homeworldShipmentIntegrity(g);
+ requireRule(action.event === decision.event, 'This Homeworld casualty choice belongs to a different battle.');
+ const choice = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Casualty choice')];
+ settleHomeworldExplosion(g, choice, false);
} else if (decision.kind === 'battleLosses') {
const choice =
decision.options[
@@ -15443,6 +17375,7 @@ function applyActionInner(
owner: id,
passed: [],
location: location(quote.territory, quote.sector),
+ amount: quote.amount,
advisorResume: 'ambassador',
advisorAmbassadorEvent: decision.ambassadorEvent,
};
@@ -15454,13 +17387,13 @@ function applyActionInner(
finishAmbassador(g);
}
} else if (action.accept) {
- requireRule(p.reserves > 0, 'No forces remain in your reserves.');
const target =
action.accompany === true && g.advanced
? decision.destination
: 'polar_sink:0';
requireRule(target, 'This shipment has no accompanying destination.');
const dest = splitLocation(target);
+ const amount = integer(action.amount === undefined ? 1 : action.amount, 1, spiritualAdvisorMaximum(g, p.id, dest.territory), 'Spiritual Advisor forces');
if (
g.advanced &&
action.accompany === true &&
@@ -15480,6 +17413,7 @@ function applyActionInner(
owner: id,
passed: [],
location: location(dest.territory, dest.sector),
+ amount,
};
} else log(g, `${p.name} declined the free shipment.`);
} else if (decision.kind === 'wormPlacement') {
@@ -15594,7 +17528,7 @@ function applyActionInner(
? cleanupBattleContext(g, decision.territory, p.id)
: null;
const discarded = [...selected].map((id) => ({
- card: discard(g, p, id),
+ card: discard(g, p, id, cleanupDiscardRole(g, p, id)),
discardedBy: p.id,
publicFace: true,
}));
@@ -15651,6 +17585,16 @@ function applyActionInner(
g.players.forEach((p) => (p.ready = !!p.bot));
return g;
}
+ if (t === 'homeworlds') {
+ requireRule(id === g.host, 'Only the host can change optional rules.');
+ requireRule(
+ typeof action.enabled === 'boolean',
+ 'Choose whether to use Homeworlds.',
+ );
+ g.homeworlds = action.enabled ? { custody: null } : null;
+ g.players.forEach((player) => (player.ready = !!player.bot));
+ return g;
+ }
if (t === 'strongholdCards') {
requireRule(id === g.host, 'Only the host can change optional rules.');
requireRule(
@@ -15951,6 +17895,8 @@ function applyActionInner(
} else {
const other = getPlayer(g, stringField(action.target));
requireRule(other.id !== id, 'Choose another player.');
+ const blocked = homeworldAllianceReason(g, id, other.id);
+ requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
requireRule(
!p.ally && !other.ally,
'Break existing alliances before forming a new one.',
@@ -15970,7 +17916,8 @@ function applyActionInner(
}
if (t === 'charity') {
const advancedBG = g.advanced && p.faction === 'beneGesserit';
- const amount = charityAmount(g, p);
+ const quote = charityQuote(g, p);
+ const amount = quote.total;
requireRule(
g.phase === 2 && amount > 0,
'Charity is available here when you have fewer than two spice.',
@@ -15985,8 +17932,14 @@ function applyActionInner(
);
p.charityTurn = g.turn;
if (advancedBG && p.spice >= 2)
- g.response = { kind: 'bgCharity', owner: id, passed: [], amount };
- else payCharity(g, p, amount);
+ g.response = {
+ kind: 'bgCharity',
+ owner: id,
+ passed: [],
+ amount,
+ ...(g.homeworlds ? { charityHomeworld: quote.homeworld } : {}),
+ };
+ else payCharity(g, p, amount, quote.homeworld);
log(g, `${p.name} claimed CHOAM charity.`);
return g;
}
@@ -16124,7 +18077,7 @@ function applyActionInner(
)
: 0;
requireRule(
- elite <= eliteRevivalRemaining(recipient),
+ elite <= eliteRevivalRemaining(recipient, g.advanced),
'Only one elite force may be revived per turn.',
);
if (t === 'emperorRevival')
@@ -16331,7 +18284,7 @@ function applyActionInner(
const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces');
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
requireRule(
- elite <= eliteRevivalRemaining(p),
+ elite <= eliteRevivalRemaining(p, g.advanced),
'Only one elite force may be revived per turn.',
);
const availableFree = forceRevivalQuote(g, p, n).free;
@@ -16348,9 +18301,11 @@ function applyActionInner(
requireRule(quote.cost <= p.spice, 'Not enough spice for revival.');
const checks: PendingRevival['checks'] = [];
const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked;
- if (choamBenefit && (n > quote.free || p.revived + n > 3))
+ const uncancelableLimit = Math.max(3, freeRevivalRate(g, p));
+ if (choamBenefit && (n > quote.free || p.revived + n > uncancelableLimit))
checks.push('choamRevival');
- if (!choamBenefit && p.revived + n > 3) checks.push('revivalLimit');
+ if (!choamBenefit && p.revived + n > uncancelableLimit)
+ checks.push('revivalLimit');
const factionCost = choamBenefit ? n - quote.free : quote.normalCost;
if (quote.cost < factionCost) checks.push('revivalDiscount');
beginRevival(g, {
@@ -16437,7 +18392,18 @@ function applyActionInner(
revealPlayerNoField(g, p, 'voluntary');
return g;
}
- if (['ship', 'move', 'endMovement', 'guildShip'].includes(t)) {
+ if (
+ [
+ 'ship',
+ 'homeworldShip',
+ 'guildHomeworldShip',
+ 'junctionShip',
+ 'move',
+ 'emperorHomeworldMove',
+ 'endMovement',
+ 'guildShip',
+ ].includes(t)
+ ) {
const richese = byFaction(g, 'richese');
requireRule(
!richese ||
@@ -16482,6 +18448,33 @@ function applyActionInner(
);
return g;
}
+ if (t === 'offerJunctionTransport') {
+ const option = junctionTransportWindow(g);
+ requireRule(option && option.owner === id && option.canOffer,
+ option?.blocked ?? 'Only high-population Junction can offer transport during another faction’s unused shipment.');
+ requireRule(action.event === option.offerEvent && (action.rate === 'half' || action.rate === 'full') &&
+ Object.keys(action).every((key) => ['type', 'event', 'rate'].includes(key)),
+ 'Choose half or full price for the current Junction opportunity.');
+ g.junctionOffer = {event: crypto.randomUUID(), turn: g.turn, owner: id,
+ recipient: option.recipient, rate: action.rate};
+ log(g, `${p.name} offered ${getPlayer(g, option.recipient).name} ${action.rate}-price Junction transport for their current shipment. They may use the offer or choose their ordinary actions.`);
+ return g;
+ }
+ if (t === 'junctionShip') {
+ performJunctionTransport(g, p, action);
+ return g;
+ }
+ if (t === 'homeworldShip' || t === 'guildHomeworldShip') {
+ const route = t === 'guildHomeworldShip' ? 'arrakis' as const : undefined;
+ const blocked = route ? guildHomeworldShipmentBlock(g, p) : homeworldShipmentBlock(g, p);
+ requireRule(!blocked, blocked ?? 'Homeworld shipment is unavailable.');
+ requireRule(Object.keys(action).every((key) => ['type', 'event', 'destination', 'sources', 'allyPayment'].includes(key)),
+ 'Choose explicit typed source groups and a Homeworld destination without concealed tokens.');
+ requireRule(action.event === homeworldShipmentEvent(g, p, route), 'This Homeworld source selection is stale.');
+ declareHomeworldShipment(g, p, {player: id, destination: stringField(action.destination),
+ sources: action.sources as HomeworldShipmentIntent['sources'], ...(route ? {route} : {})}, action.allyPayment);
+ return g;
+ }
if (t === 'ship') {
requireRule(
g.phase === 5 && g.active === id && !p.shipped,
@@ -16570,9 +18563,16 @@ function applyActionInner(
cost,
allyPayment,
advisors,
+ ...(action.homeworldSources === undefined
+ ? {}
+ : {
+ homeworldSources:
+ action.homeworldSources as NativeReserveSelections,
+ }),
...(noField ? { noField } : {}),
};
if (shipment.noField) {
+ checkShipmentIncomeRounding(g, p, cost, allyPayment);
g.pendingShipment = shipment;
g.response = { kind: 'richeseNoField', owner: p.id, passed: [] };
log(
@@ -16583,6 +18583,21 @@ function applyActionInner(
return g;
}
if (t === 'guildShip') {
+ const homeworldTarget = action.territory ?? 'reserves';
+ if (g.homeworlds?.custody && p.faction === 'guild' &&
+ (homeworldTarget === 'reserves' || (typeof homeworldTarget === 'string' && homeworldTarget.startsWith('homeworld:')))) {
+ const blocked = guildHomeworldShipmentBlock(g, p);
+ requireRule(!blocked, blocked ?? 'Guild Homeworld shipment is unavailable.');
+ requireRule(action.noField === undefined && (action.sector === undefined || action.sector === 0),
+ 'A Homeworld arrival has no planet sector or concealed token.');
+ const group = forceGroup(p, action);
+ const sources = Object.fromEntries(group.group.map(([key, amount]) => [key,
+ {normal: amount - (group.eliteGroup[key] ?? 0), elite: group.eliteGroup[key] ?? 0}]));
+ declareHomeworldShipment(g, p, {route: 'arrakis', player: id,
+ destination: homeworldTarget === 'reserves' ? 'homeworld:guild' : homeworldTarget as string,
+ sources}, action.allyPayment);
+ return g;
+ }
checkShipmentPromises(
g,
p,
@@ -16659,6 +18674,7 @@ function applyActionInner(
'Not enough spice.',
);
const allyPayment = contribution(g, p, cost, action.allyPayment);
+ checkShipmentIncomeRounding(g, p, cost, allyPayment);
payWithAlly(g, p, cost, allyPayment);
const guild = byFaction(g, 'guild');
const guildPayment = guildShipmentIncome({
@@ -16670,12 +18686,7 @@ function applyActionInner(
bankOnly: g.karamaShipping?.player === id,
});
if (guild && guildPayment > 0)
- g.response = {
- kind: 'guildIncome',
- owner: guild.id,
- amount: guildPayment,
- passed: [],
- };
+ g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment));
if (fromReserves) {
p.reserves -= n;
if (p.elites) p.elites.reserves -= elite;
@@ -16708,7 +18719,63 @@ function applyActionInner(
}
return g;
}
+ if (t === 'emperorHomeworldMove') {
+ const blocked = emperorHomeworldMoveBlock(g, p);
+ requireRule(!blocked, blocked ?? 'Homeworld movement is unavailable.');
+ requireRule(
+ Object.keys(action).every((key) =>
+ ['type', 'event', 'origin', 'normal', 'elite'].includes(key),
+ ),
+ 'Choose only the source Homeworld and physical force types.',
+ );
+ requireRule(
+ action.event === emperorHomeworldMoveEvent(g, p),
+ 'This Homeworld movement selection is stale.',
+ );
+ const quote = homeworldRule(() =>
+ quoteEmperorHomeworldMove(
+ {
+ ...homeworldContext(g),
+ status: g.status,
+ phase: g.phase,
+ currentPlayer: g.active,
+ movesLeft: movesAllowed(g, p) - p.moved,
+ },
+ g.homeworlds!.custody!,
+ id,
+ {
+ origin: stringField(action.origin) as EmperorHomeworld,
+ forces: {
+ normal: integer(action.normal, 0, 20, 'Normal forces'),
+ elite: integer(action.elite, 0, 5, 'Sardaukar'),
+ },
+ },
+ ),
+ );
+ g.homeworlds!.custody = quote.state;
+ finishShipmentPromises(g, p, null);
+ p.shipped = true;
+ p.moved++;
+ const name = (world: string) =>
+ world === 'homeworld:emperor' ? 'Kaitain' : 'Salusa Secundus';
+ const shifts = quote.populations.after
+ .filter((world) => world.native === id)
+ .map(
+ (world) =>
+ `${name(world.location)} now has ${world.population} ${world.card === 'salusa_secundus' ? 'Sardaukar' : 'native reserves'} (${world.side} population)`,
+ )
+ .join('; ');
+ log(
+ g,
+ `${p.name} moved ${quote.forces.normal} normal forces and ${quote.forces.elite} Sardaukar from ${name(quote.origin)} to ${name(quote.destination)}. This spends one movement and no spice; total reserves are unchanged. ${shifts}.`,
+ );
+ return g;
+ }
if (t === 'move') {
+ if (action.noField !== undefined) {
+ const blocked = homeworldRule(() => homeworldNoFieldMovementBlock(g, id));
+ requireRule(!blocked, blocked ?? 'The No-Field cannot move.');
+ }
if (!p.shipped) checkShipmentPromises(g, p, null);
requireRule(
g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p),
@@ -16926,7 +18993,7 @@ function applyActionInner(
beginStrongholdBattle(g);
log(
g,
- `${p.name} attacks ${getPlayer(g, choice.defender).name} in ${territory(choice.territory).name}.`,
+ `${p.name} attacks ${getPlayer(g, choice.defender).name} in ${combatLocationName(g, choice.territory)}.`,
);
return g;
}
@@ -16993,7 +19060,7 @@ function applyActionInner(
? typeof action.value === 'number'
? action.value
: NaN
- : integer(action.value, 0, at(p, b.territory), 'Forces dialed')
+ : integer(action.value, 0, (combatArmy(g, p.id, b.territory).normal + combatArmy(g, p.id, b.territory).elite), 'Forces dialed')
: action.value === null || action.value === ''
? null
: stringField(action.value);
@@ -17116,7 +19183,8 @@ function applyActionInner(
if (
traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined)
)
- resolveBattle(g);
+ if (!b.territory.startsWith('homeworld:')) resolveBattle(g);
+ else advanceHomeworldReveal(g);
return g;
}
if (t === 'card') {
@@ -17204,7 +19272,7 @@ function applyActionInner(
);
g.hajr.push(id);
} else if (c.effect === 'ghola') {
- applyGholaEffect(g, p, action);
+ applyGholaEffect(g, p, action, c.id);
} else if (c.effect === 'harvester') {
const blow = g.spiceWindow!;
if (blow.sector !== g.storm) {
@@ -17261,12 +19329,19 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
+ marketGholaIntegrity(state);
+ homeworldRule(() => homeworldGameIntegrity(state));
+ homeworldBattleLossIntegrity(state);
+ homeworldSubstitutionIntegrity(state);
+ homeworldDefenseIntegrity(state);
+ homeworldShipmentIntegrity(state);
karamaConversionIntegrity(state);
treacheryDiscardIntegrity(state);
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
ambassadorRelocationIntegrity(state);
ecazCollectionIntegrity(state);
+ ecazAllianceIntegrity(state);
const g = structuredClone(state);
normalizeCardNames(g);
settleAdvisors(g);
@@ -17297,6 +19372,14 @@ export function viewGame(state: Game, id: string) {
? findReachableBattlePlan(g, me)
: null;
return {
+ ecazPoisonIncome: (g.ecazPoisonIncome ?? []).filter((income) => income.player === id)
+ .map(({turn, phase, amount, count}) => ({turn, phase, amount, count})),
+ biddingEnd: g.biddingEnd ? { event: g.biddingEnd.event,
+ owners: [...g.biddingEnd.owners], ready: [...g.biddingEnd.ready],
+ canAct: biddingEndQuiet(g) && g.biddingEnd.owners.includes(id), kaitain: { owner: byFaction(g, 'emperor')!.id,
+ eligible: homeworldRule(() => highKaitainDiscardsAvailable(g, byFaction(g, 'emperor')!.id)) } } : null,
+ combatLocations: combatLocations(g),
+ battleChoices: g.status === 'playing' && g.phase === 6 ? quoteCombatBoard(g).battles : [],
guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id),
truthShipmentAnswers:
g.truthtrance?.stage === 'answer' &&
@@ -17328,7 +19411,7 @@ export function viewGame(state: Game, id: string) {
: null,
schema: g.schema,
botsPending: g.botsPending ?? false,
- automaticContinuationPending: !!g.pendingTreacheryDiscard,
+ automaticContinuationPending: !!g.pendingTreacheryDiscard || homeworldRevealPending(g) || homeworldShipmentAutomatic(g),
botNextActionAt: g.botNextActionAt ?? null,
code: g.code,
version: g.version,
@@ -17437,7 +19520,7 @@ export function viewGame(state: Game, id: string) {
...choices,
destinations: choices.destinations.map((destination) => ({
...destination,
- blocked: ambassadorRelocationArrivalBlock(
+ ...ambassadorArrivalChoices(
g,
id,
'reserves',
@@ -17451,6 +19534,12 @@ export function viewGame(state: Game, id: string) {
entry.stage === 'move' && id === entry.beneficiary
? ambassadorRelocationMovement(g, id)
: null,
+ allianceOffer:
+ entry.stage === 'offer' &&
+ token.effect === 'ecaz' &&
+ id === owner.id
+ ? { blocked: homeworldAllianceReason(g, owner.id, entrant.id) ?? ecazAllianceBlock(g, owner.id, entrant.id) }
+ : null,
dukeAcquisition:
entry.stage === 'offer' &&
token.effect === 'ecaz' &&
@@ -17489,7 +19578,7 @@ export function viewGame(state: Game, id: string) {
entry.stage === 'cards' && id === entry.beneficiary
? me.hand.map((card) => ({
card: card.id,
- blocked: ambassadorDiscardBlock(g, me, card),
+ blocked: ambassadorDiscardBlock(g, me, card, entry.effect),
}))
: [],
};
@@ -17520,6 +19609,34 @@ export function viewGame(state: Game, id: string) {
: null,
techTokens: g.techTokens ?? null,
strongholdCards: g.strongholdCards ?? null,
+ homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
+ caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
+ homeworldRevivalBlocks: homeworldRevivalChoiceBlocks(g, me),
+ homeworlds: g.homeworlds
+ ? { worlds: homeworldRule(() => homeworldTable(g)) }
+ : null,
+ homeworldMove:
+ g.homeworlds?.custody && g.advanced && me.faction === 'emperor'
+ ? {
+ event: emperorHomeworldMoveEvent(g, me),
+ blocked: emperorHomeworldMoveBlock(g, me),
+ remaining: Math.max(0, movesAllowed(g, me) - me.moved),
+ }
+ : null,
+ homeworldShipment: g.homeworlds?.custody
+ ? {event: homeworldShipmentEvent(g, me), blocked: homeworldShipmentBlock(g, me)}
+ : null,
+ guildHomeworldShipment: g.homeworlds?.custody && me.faction === 'guild'
+ ? {event: homeworldShipmentEvent(g, me, 'arrakis'), blocked: guildHomeworldShipmentBlock(g, me)}
+ : null,
+ junctionTransport: junctionTransportWindow(g),
+ homeworldMobility: {
+ foresightBlocked: homeworldRule(() => homeworldMovementForesightBlock(g, me.id)),
+ mobileStrongholdBlocked: homeworldRule(() => homeworldMobileStrongholdMovementBlock(g, me.id)),
+ noFieldMovementBlocked: homeworldRule(() => homeworldNoFieldMovementBlock(g, me.id)),
+ advisorSinkMaximum: spiritualAdvisorMaximum(g, me.id),
+ advisorAccompanyMaximum: spiritualAdvisorMaximum(g, me.id, 'arrakeen'),
+ },
expansions: g.expansions,
turn: g.turn,
phase: g.phase,
@@ -17548,6 +19665,8 @@ export function viewGame(state: Game, id: string) {
!g.nexus,
spiceDiscardTop: g.spiceDiscard.map((pile) => pile.at(-1) ?? null),
allianceOffers: g.allianceOffers,
+ homeworldAllianceBlocks: Object.fromEntries(g.players.filter((p) => p.id !== id)
+ .flatMap((p) => { const reason = homeworldAllianceReason(g, id, p.id); return reason ? [[p.id, reason]] : []; })),
ecazSpice:
g.ecazCollection?.stage === 'allocation' && g.ecazCollection.allocation
? {
@@ -17661,7 +19780,14 @@ export function viewGame(state: Game, id: string) {
owner: g.choamMarket.owner,
...(g.choamMarket.owner === id
? {
- sales: saleOptions(me.hand, g.choamMarket.blocked),
+ sales: saleOptions(me.hand, g.choamMarket.blocked).filter((sale) =>
+ !homeworldRule(() => homeworldWorthlessSaleBlock(
+ g, me.id, me.hand.find((card) => card.id === sale.card)!,
+ )),
+ ),
+ worthlessSaleBlocked: homeworldRule(() =>
+ homeworldWorthlessSaleBlock(g, me.id, { kind: 'worthless' }),
+ ),
canTrade: !!me.ally && g.choamTradeTurn !== g.turn,
tradeAttempted: !!g.choamMarket.tradeAttempted,
}
@@ -17683,6 +19809,7 @@ export function viewGame(state: Game, id: string) {
inflationUsed: g.inflationUsed ?? false,
inflationAttempted: g.inflationAttemptTurn === g.turn,
charity: {
+ ...charityQuote(g, me),
multiplier: charityMultiplier(g),
amount: charityAmount(g, me),
payer: charityPayer(g)?.id ?? null,
@@ -17703,10 +19830,14 @@ export function viewGame(state: Game, id: string) {
id,
)
? { ...g.decision, leader: '', owner: '' }
- : (g.decision ?? null),
+ : g.decision?.kind === 'faceDance'
+ ? { ...g.decision, ...(faceDanceReturnBlock(g, g.decision.winner) ? {blocked: faceDanceReturnBlock(g, g.decision.winner)!} : {}) }
+ : (g.decision ?? null),
response: g.response
? {
...g.response,
+ ...(g.response.guildContributions ? { guildContributions: undefined } : {}),
+ ...(g.response.guildPaymentProof ? { guildPaymentProof: undefined } : {}),
passed: g.response.passed.includes(id) ? [id] : [],
...(g.response.kind === 'revivalIncome' &&
![g.response.owner, g.response.recipient].includes(id)
@@ -17722,6 +19853,7 @@ export function viewGame(state: Game, id: string) {
: {}),
}
: null,
+ paymentIncome: currentFactionPayment(g),
responseControls: g.response
? {
cancelCards: responseCancelCards(g, me, g.response),
@@ -17745,15 +19877,23 @@ export function viewGame(state: Game, id: string) {
),
revival: {
pending: !!g.pendingRevival,
+ specialKaramaBlock:
+ g.decision?.kind === 'revivalStop'
+ ? homeworldRevivalKaramaBlock(g, g.decision.recipient)
+ : null,
leaders: leaderRevivals.leaders,
kwisatz: leaderRevivals.kwisatz,
prevented: revivalPrevented(g, me.id),
- eliteRemaining: eliteRevivalRemaining(me),
- limit: forceRevivalLimit(g, me),
+ eliteRemaining: eliteRevivalRemaining(me, g.advanced),
+ limit: normalForceRevivalLimit(g, me),
forcesRemaining: forceRevivalRemaining(g, me),
discount: revivalDiscount(g, me),
choamBlocked: !!g.revivalRules?.choamBlocked,
freeRemaining: freeRevivalRemaining(g, me),
+ homeworldBonus: homeworldLowBonus(g, me.id),
+ tleilaxuHomeworldIncomeBlocked: homeworldRule(() =>
+ tleilaxuHomeworldFreeIncomeBlocked(g),
+ ),
freeBlocked: g.revivalRules?.freeBlocked?.includes(id) ?? false,
},
ghola: gholaOptions(g, me),
@@ -18000,6 +20140,13 @@ export function viewGame(state: Game, id: string) {
: null,
battle: b
? {
+ locationName: combatLocationName(g, b.territory),
+ native: homeworldBattleLocation(g, b.territory)?.native ?? null,
+ nativeBattleStrength: homeworldBattleLocation(g, b.territory)?.nativeBattleStrength ?? 0,
+ opponentForces: [b.attacker, b.defender].includes(id) &&
+ !getPlayer(g, b.attacker === id ? b.defender : b.attacker).noField?.deployed
+ ? combatForces(g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), b.territory, me)
+ : null,
strongholdCopy: b.strongholdCopy ?? null,
strongholdEffects: Object.fromEntries(
[b.attacker, b.defender].map((player) => [
diff --git a/game/engine.ts.orig b/game/engine.ts.orig
deleted file mode 100644
index 7a642791..00000000
--- a/game/engine.ts.orig
+++ /dev/null
@@ -1,16113 +0,0 @@
-import {
- quoteNormalAuctionPeek,
- quoteIxTechnologyCancellation,
- IxTechnologyCancellationError,
-} from './ix-technology-cancellation';
-import {
- quoteIxAuctionDraw,
- IxAuctionDrawError,
-} from './ix-auction-draw-quote';
-import {
- quoteMoritaniAllianceCancellation,
- MoritaniAllianceCancellationError,
-} from './moritani-alliance-cancellation';
-import {
- quoteRicheseCancellation,
- RicheseCancellationError,
-} from './richese-cancellation';
-import {
- quoteIxSubstitutionCancellation,
- IxSubstitutionCancellationError,
-} from './ix-substitution-cancellation';
-import {
- quoteCombatResponse,
- isCombatResponseKind,
- CombatResponseQuoteError,
- type CombatResponseOperation,
- type CombatResponseQuote,
-} from './combat-response-quote';
-import {
- quoteNoFieldCancellation,
- NoFieldCancellationError,
-} from './karama-no-field-cancellation';
-import {
- validateTerminalCancellation,
- TerminalCancellationError,
-} from './terminal-cancellation';
-import {
- quoteBattleAftermath,
- BattleAftermathQuoteError,
- type AftermathCancellation,
-} from './battle-aftermath-quote';
-import {
- quoteBattleBoard,
- quoteSpiceCollection,
- quoteBattlePhaseAdvance,
- quoteBattleBoardContinuation,
- BoardResolutionError,
- type AdvisorRelease,
-} from './board-resolution-quote';
-import {
- quoteMovementCancellation,
- MovementCancellationError,
-} from './karama-movement-cancellation';
-import {
- quoteChoamSaleCancellation,
- ChoamSaleCancellationError,
-} from './choam-sale-cancellation';
-import {
- validateBattleForceLoss,
- ForceLossPreflightError,
-} from './force-loss-preflight';
-import {
- quoteBattleResolution,
- BattleResolutionQuoteError,
- type ResolutionCombatant,
-} from './battle-resolution-quote';
-import {
- quoteRevivalCancellation,
- RevivalCancellationError,
-} from './revival-cancellation';
-import {
- validateStormTraversal,
- validateStormCancellation,
- validateWormCancellation,
- validateWormDevouring,
- DisasterPreflightError,
-} from './disaster-preflight';
-import {
- quoteCompletedMovementArrival,
- MovementArrivalError,
-} from './karama-movement-preflight';
-import {
- validateBattleCleanupContext,
- preflightMoritaniRetentionCancellation,
- KaramaBattlePreflightError,
-} from './karama-battle-preflight';
-import { canceledResponseSource, KaramaContextError } from './karama-context';
-import { validateSavedShipmentQuestion } from './truthtrance';
-import {
- reorderOrderedOpportunity,
- protectOrderedOpportunityLast,
- validateOrderedOpportunity,
- type OrderedOpportunity,
-} from './ordered-opportunity';
-import {
- matchesShipment,
- liveShipmentPromises,
- type ShipmentClaim,
- type ShipmentPromise,
-} from './shipment-promises';
-import { portableSnooperPlanBlock } from './portable-snooper';
-import {
- createAuditorLeader,
- isAuditorLeader,
- auditCount,
- sampleAuditCards,
-} from './choam-auditor';
-import { stoneBurnerPlanBlock } from './stone-burner';
-import {
- STRONGHOLD_CARDS,
- createStrongholdCards,
- strongholdBenefit,
- strongholdControllers,
- settleStrongholdCards,
- strongholdSupportCost,
- type StrongholdId,
- type StrongholdState,
-} from './stronghold-cards';
-import {
- residualPoisonCandidates,
- residualPoisonDeath,
-} from './residual-poison';
-import {
- createRemainingCohort,
- validateCohortSelection,
- type MovementCohort,
- type OrnithopterMode,
-} from './ornithopter';
-import {
- eligibleNullentropyCards,
- resolveNullentropyBox,
-} from './nullentropy-box';
-import { transferDistrans } from './distrans';
-import type { FreshDiscardBatch } from './semuta-drug';
-import {
- prepareRicheseGift,
- transferRicheseGift,
- type RicheseGiftIntent,
-} from './richese-gift';
-import { richeseCards, richeseCardDefinition } from './richese-cards';
-import {
- createRicheseNoField,
- deployRicheseNoField,
- moveRicheseNoField,
- revealRicheseNoField,
- shipAlliedRicheseNoField,
- projectRicheseNoField,
- type RicheseNoField,
- type NoFieldRevealCause,
-} from './richese-no-field';
-import { presenceAt, presenceByLocation } from './force-presence';
-import {
- createRicheseAuction,
- submitRicheseBid,
- projectRicheseAuction,
- reorderRicheseBidder,
- type RicheseAuction,
-} from './richese-auction';
-import {
- richeseOwnCommitment,
- richeseAllyCommitment,
- validateRicheseFunding,
- type RicheseFunding,
-} from './richese-funding';
-import {
- quoteRicheseSettlement,
- requireRicheseDeclarationCache,
- RicheseSettlementError,
-} from './richese-settlement';
-import {
- createAmbassadors,
- placeAmbassador,
- blockAmbassadorPlacement,
- triggerAmbassador,
- destroyAmbassador,
- replenishAmbassadors,
- copiedAmbassadorEffects,
- canTriggerAmbassador,
- type AmbassadorEffect,
- type AmbassadorState,
-} from './ecaz-ambassadors';
-import {
- createDukeVidal,
- acquireDuke,
- consumeDuke,
- expireDuke,
- DUKE_VIDAL_ID,
- type DukeState,
-} from './duke-vidal';
-import {
- retentionReservesCard,
- type MoritaniRetention,
-} from './moritani-retention';
-import { ordinaryCardAvailability } from './card-availability';
-import {
- reserveShipmentCost,
- guildShipmentCost,
- guildShipmentIncome,
- shipmentPaymentBounds,
-} from './shipment-price';
-import {
- TERROR_DEFINITIONS,
- createTerrorState,
- placeTerror,
- projectTerror,
- revealTerror,
- returnTerror,
- type TerrorKind,
- type TerrorState,
-} from './moritani-terror';
-import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions';
-import { cashInCards } from './choam-karama';
-import { saleOptions, quoteSale, type ChoamMarket } from './choam-market';
-import {
- charityAmount,
- charityPayer,
- charityMultiplier,
- type Inflation,
-} from './charity';
-import {
- PlanClaimError,
- respectsBattlePromises,
- type PlanClaim,
- type BattlePromise,
-} from './battle-promises';
-import {
- resolveTruthAction,
- validateTruthQuestionReceipt,
- truthFactAnswer,
- TruthError,
- type TruthWindow,
- type TruthRecord,
- type TruthAnswer,
-} from './truthtrance';
-import { controlsLeader, nativeAvailable } from './leader-control';
-import {
- newRevivalRules,
- eliteRevivalRemaining,
- paidForceRevivalCost,
- revivalPrevented,
- forceRevivalLimit,
- revivalDiscount,
- forceRevivalQuote,
- forceRevivalRemaining,
- freeRevivalRemaining,
- leaderRevivalOptions,
- type RevivalRules,
- type PendingRevival,
-} from './revival';
-import { traitorDeck, matchingTraitor, CHEAP_HERO_TRAITOR } from './traitors';
-import {
- isWeaponCard,
- isStoneBurner,
- isDefenseCard,
- isPortableSnooper,
- VOICE_KINDS,
- validBattleCardPair,
- defaultVoiceMatch,
- playedVoiceMatch,
-} from './battle-cards';
-import {
- TECH_TOKENS,
- createTechTokens,
- ownedTech,
- techStronghold,
- type TechId,
- type TechState,
-} from './tech-tokens';
-import { canUseAsKarama } from './karama';
-import {
- isAdvisor,
- fighterCount,
- settleAdvisors,
- arrivalAsAdvisor,
-} from './advisors';
-import { casualtyOptions, type Casualties, type CombatForces } from './combat';
-import { FACTIONS, faction, type FactionId } from './catalog';
-import {
- treacheryDeck,
- normalizeLegacyCardNames,
- leaders,
- spiceDeck,
- type Card,
- type Leader,
- type SpiceCard,
-} from './cards';
-import {
- TERRITORIES,
- MOBILE_STRONGHOLD,
- gameTerritories,
- gameDistance,
- mobileRouteDistance,
- FREMEN_START,
- territory,
- location,
- splitLocation,
- distance,
- validLocation,
-} from './board';
-export type Player = {
- id: string;
- name: string;
- bot?: 'Easy' | 'Medium' | 'Hard' | 'Brutal';
- autopilot?: 'Easy' | 'Medium' | 'Hard' | 'Brutal';
- faction: FactionId;
- ready: boolean;
- spice: number;
- reserves: number;
- tanks: number;
- forces: Record;
- noField?: RicheseNoField;
- noFieldEvent?: string;
- noFieldBlockedTurn?: number;
- advisors?: Record;
- advisorSetup?: boolean;
- charityTurn?: number;
- specialKaramaUsed?: boolean;
- gholaBlocked?: Record;
- ixMovementBlocked?: { turn: number; move: number };
- fremenMovementBlocked?: { turn: number; move: number };
- elites?: {
- reserves: number;
- tanks: number;
- forces: Record;
- revived: number;
- };
- hand: Card[];
- leaders: Leader[];
- traitors: string[];
- faceDancers?: { leader: string; revealed: boolean }[];
- faceDancerReplacedTurn?: number;
- revealedTraitors?: string[];
- traitorChoices: string[];
- ally: string | null;
- /** Formation turn grants the printed next-turn co-occupation departure deadline. */
- allySinceTurn?: number;
- bribes: number;
- revived: number;
- /** Actual free normal force revivals; absent when an older phase's history is unknown. */
- freeForcesRevived?: number;
- leaderRevived: boolean;
- revivalCycle: number;
- shipped: boolean;
- moved: number;
- battleLosses: number;
- kwisatz?: { dead: boolean; usedAt?: string; revivalCycle?: number };
- prediction?: { faction: FactionId; turn: number };
-};
-export type Plan = {
- dial: number;
- leader: string | null;
- weapon: string | null;
- defense: string | null;
- support: number;
- allyPayment?: number;
- kwisatz?: boolean;
-};
-export type PlanField = Exclude<
- keyof Plan,
- 'support' | 'kwisatz' | 'allyPayment'
->;
-export type Battle = {
- event?: string;
- strongholdCopy?: StrongholdId;
- preLeader?: { event: string; ready: string[]; closed: boolean };
- /** Public marker participation persists after a zero-token reveal. */
- noFieldPlayers?: string[];
- truthPromises?: BattlePromise[];
- poisonTooth?: Record;
- stoneBurner?: Record;
- /** Supplemental revealed defense; never rewrites the original sealed plan. */
- lateDefense?: Record;
- fullPlan?: { owner: string; target: string };
- fullPlanOffered?: boolean;
- eliteBlocked?: string[];
- fremenSupportBlocked?: boolean;
- choamAidBlocked?: boolean;
- powerChecks?: {
- kind: 'kwisatz' | 'eliteStrength' | 'fremenSupport' | 'choamBattleAid';
- owner: string;
- }[];
- kwisatzBlocked?: boolean;
- prepared?: boolean;
- territory: string;
- attacker: string;
- defender: string;
- plans: Record;
- revealed: boolean;
- traitorCalls: Record;
- preparation?: {
- kind: 'voice' | 'prescience' | 'prescienceAnswer';
- owner: string;
- beneficiary: string;
- };
- prescience?: {
- player: string;
- field: PlanField;
- value?: string | number | null;
- };
- voice?: { target: string; kind: Card['kind'] | 'stoneBurner'; must: boolean };
-};
-export type Auction = {
- peekKnown?: boolean;
- cards: Card[];
- index: number;
- bid: number;
- bidder: string | null;
- active: string;
- passed: string[];
- opener: number;
- allyPayment?: number;
-};
-export type Decision =
- | { kind: 'choamAudit'; player: string; event: string }
- | { kind: 'choamAuditPayment'; player: string; event: string }
- | {
- kind: 'strongholdCopy';
- player: string;
- event: string;
- choices: StrongholdId[];
- }
- | { kind: 'richeseBlackMarket'; player: string }
- | { kind: 'richeseDeclaration'; player: string }
- | { kind: 'richeseCache'; player: string }
- | { kind: 'richeseUnbid'; player: string }
- | { kind: 'richeseAllyShipment'; player: string; owner: string }
- | { kind: 'richeseAllyOpportunity'; player: string; recipient: string }
- | { kind: 'ecazAmbassador'; player: string }
- | {
- kind: 'moritaniTerror';
- player: string;
- entrant: string;
- territory: string;
- }
- | { kind: 'moritaniSetup'; player: string }
- | { kind: 'moritaniPlacement'; player: string }
- | { kind: 'ecazPlacement'; player: string }
- | {
- kind: 'choamStorm';
- player: string;
- territories: { territory: string; amount: number; sectors: number[] }[];
- protected: string[];
- }
- | {
- kind: 'choamMovement';
- player: string;
- mover: string;
- territory: string;
- sector: number;
- amount: number;
- }
- | { kind: 'choamMentat'; player: string }
- | { kind: 'choamFreeRevival'; player: string; recipient: string }
- | { kind: 'choamBattleFunding'; player: string }
- | { kind: 'choamMarket'; player: string }
- | { kind: 'choamTradeReply'; player: string }
- | { kind: 'choamTradeConfirm'; player: string }
- | { kind: 'ixSetup'; player: string }
- | { kind: 'ixAuction'; player: string }
- | { kind: 'ixTechnology'; player: string }
- | { kind: 'ixAllyCard'; player: string }
- | { kind: 'mobileStronghold'; player: string; placement: boolean }
- | {
- kind: 'ixSubstitution';
- player: string;
- territory: string;
- losses: Record;
- }
- | {
- kind: 'revivalStop';
- player: string;
- recipient: string;
- revival: 'forces' | 'leader';
- }
- | {
- kind: 'faceDance';
- player: string;
- winner: string;
- leader: string | null;
- identity: string | null;
- territory: string;
- }
- | { kind: 'techToken'; player: string; loser: string; choices: TechId[] }
- | { kind: 'poisonTooth'; player: string }
- | { kind: 'stoneBurner'; player: string; event: string }
- | { kind: 'fullPlanOffer'; player: string }
- | { kind: 'fullPlanRead'; player: string; target: string }
- | {
- kind: 'guildShipment';
- player: string;
- shipper: string;
- territory: string;
- sector: number;
- amount: number;
- }
- | { kind: 'nullentropy'; player: string }
- | { kind: 'handExchange'; player: string; target: string; count: number }
- | {
- kind: 'battleLosses';
- player: string;
- territory: string;
- options: Casualties[];
- cards: string[];
- }
- | { kind: 'captureOffer'; player: string; loser: string; territory: string }
- | {
- kind: 'capturedLeader';
- player: string;
- owner: string;
- leader: string;
- controller?: string;
- }
- | { kind: 'guildTiming'; player: string; next: string; following: string }
- | { kind: 'auctionPayment'; player: string }
- | { kind: 'advisor'; player: string; shipment: string; destination?: string }
- | {
- kind: 'intrusion';
- player: string;
- territory: string;
- wormRide?: boolean;
- followup?: { shipment: string; destination: string };
- }
- | { kind: 'advisorBattle'; player: string; territories: string[] }
- | {
- kind: 'stormLosses';
- player: string;
- key: string;
- amount: number;
- minElite: number;
- maxElite: number;
- resume: 'storm' | 'shipment';
- }
- | { kind: 'wormPlacement'; player: string }
- | { kind: 'wormProtection'; player: string; territory: string; ally: string }
- | { kind: 'wormRide'; player: string; territory: string }
- | {
- kind: 'moritaniRetention';
- owner: string;
- player: string;
- territory: string;
- cards: string[];
- }
- | { kind: 'battleCards'; player: string; territory: string; cards: string[] };
-export type ResponseWindow = {
- intent?: string;
- advisors?: boolean;
- advisorResume?: 'wormRide' | 'declaration';
- advisorRemaining?: string[];
- advisorFollowup?: { shipment: string; destination: string };
- amount?: number;
- elite?: number;
- recipient?: string;
- location?: string;
- resume?: 'storm' | 'shipment';
- take?: boolean;
- kind:
- | 'moritaniPlacement'
- | 'ecazPlacement'
- | 'moritaniAlliance'
- | 'moritaniDuke'
- | 'moritaniRetention'
- | 'voice'
- | 'prescience'
- | 'advisor'
- | 'emperorIncome'
- | 'richesePurchaseIncome'
- | 'richeseGift'
- | 'richeseNoField'
- | 'harkonnenBonus'
- | 'harkonnenTraitor'
- | 'guildIncome'
- | 'emperorGift'
- | 'emperorRevival'
- | 'stormPeek'
- | 'stormProtection'
- | 'wormPlacement'
- | 'wormSurvival'
- | 'wormAllyProtection'
- | 'atreidesAuction'
- | 'atreidesSpice'
- | 'guildTiming'
- | 'kwisatz'
- | 'capture'
- | 'eliteStrength'
- | 'fremenSupport'
- | 'advisorFlip'
- | 'bgCharity'
- | 'choamCharity'
- | 'choamInflation'
- | 'choamSale'
- | 'choamWorthless'
- | 'choamBattleIncome'
- | 'choamAudit'
- | 'choamBattleAid'
- | 'worthlessKarama'
- | 'faceDancerReplacement'
- | 'choamRevival'
- | 'revivalLimit'
- | 'revivalDiscount'
- | 'earlyRevival'
- | 'revivalIncome'
- | 'foreignGhola'
- | 'ixSubstitution'
- | 'ixMovement'
- | 'fremenMovement'
- | 'mobileStronghold'
- | 'richeseAuction'
- | 'richeseBlackMarket'
- | 'ixAuction'
- | 'ixTechnology'
- | 'ixAllyCard';
- owner: string;
- passed: string[];
-};
-type KaramaUse =
- | { kind: 'cancel'; response: ResponseWindow }
- | { kind: 'shipment'; recipient: string; card?: string }
- | { kind: 'purchase' }
- | { kind: 'auctionPayment' };
-type PendingShipment = {
- /** Absent in legacy rooms; new declarations cannot resume in a later turn. */
- turn?: number;
- player: string;
- territory: string;
- sector: number;
- amount: number;
- elite: number;
- cost: number;
- allyPayment: number;
- advisors: boolean;
- noField?: { tokenId: string; event: string };
- alliedNoField?: RicheseAllyOffer;
-};
-type RicheseAllyOffer = {
- event: string;
- owner: string;
- recipient: string;
- tokenId: string;
- tokenEvent: string;
- territory: string;
- sector: number;
- payer: string;
-};
-export type Game = {
- treacheryDiscardSequence?: number;
- resolvedTreacheryDiscardSequence?: number;
- /** Committed discard receipts are evidence, not additional card custody. */
- pendingTreacheryDiscard?: {
- sequence: number;
- batch: FreshDiscardBatch;
- continuation:
- | { kind: 'ambassador'; entry: NonNullable }
- | {
- kind: 'nullentropyDiscard';
- player: string;
- searchEvent: string;
- box: string;
- selected: Card;
- finalDiscardIds: string[];
- finalDiscardSignature: string;
- resume: NonNullable['resume'];
- parentSignature: string;
- }
- | {
- kind: 'ordinaryCardDiscard';
- player: string;
- card: string;
- effect: 'hajr' | 'ghola' | 'harvester' | 'weather' | 'atomics';
- resume: NonNullable['resume'];
- stateSignature: string;
- }
- | {
- kind: 'truthtranceDiscard';
- consumed: { player: string; card: string };
- historyIndex: number;
- record: TruthRecord;
- remaining: TruthWindow | null;
- resume: NonNullable['resume'];
- promise:
- | { kind: 'shipment'; index: number; value: ShipmentPromise }
- | { kind: 'battle'; index: number; value: BattlePromise }
- | null;
- parentSignature: string;
- }
- | {
- kind: 'ornithopterDiscard';
- source: 'move' | 'end';
- flight: NonNullable;
- movement: CompletedMovement | null;
- resume: NonNullable['resume'];
- stateSignature: string;
- }
- | {
- kind: 'terrorDiscard';
- source: 'sabotage' | 'robberyOverflow';
- owner: string;
- entry: NonNullable;
- discardedHandSize: number;
- }
- | {
- kind: 'battleResolved';
- event: string;
- result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion';
- combatants: string[];
- territory: string;
- winner: string | null;
- cards: string[];
- casualties?: {
- forces: CombatForces;
- dial: number;
- support: number;
- options: Casualties[];
- };
- }
- | {
- kind: 'battleCleanup';
- event: string;
- combatants: string[];
- territory: string;
- player: string;
- source: 'winner' | 'moritani';
- kept: string[];
- played: string[];
- retention?: MoritaniRetention;
- }
- | {
- kind: 'ixAllyCard';
- player: string;
- card: string;
- free: boolean;
- sale: NonNullable;
- auctionIndex: number | null;
- auctionEvent: string | null;
- };
- } | null;
- ornithopter?: {
- event: string;
- player: string;
- card: Card;
- turn: number;
- mode: OrnithopterMode;
- startingMove: number;
- completed: number;
- cohort?: MovementCohort;
- } | null;
- pendingNullentropy?: {
- event: string;
- player: string;
- box: string;
- turn: number;
- phase: number;
- discardIds: string[];
- discardSignature: string;
- resume: {
- response: Game['response'];
- decision: Game['decision'];
- pendingKarama: Game['pendingKarama'];
- phaseOpening: Game['phaseOpening'];
- };
- } | null;
- pendingRicheseGift?: {
- event: string;
- intent: RicheseGiftIntent;
- turn: number;
- phase: number;
- resume: {
- response: Game['response'];
- decision: Game['decision'];
- pendingKarama: Game['pendingKarama'];
- phaseOpening: Game['phaseOpening'];
- };
- } | null;
- richeseGiftBlocked?: { turn: number; phase: number; cards: string[] };
- richeseAllyOffer?: RicheseAllyOffer | null;
- richeseAllyOpportunity?: { turn: number; recipient: string };
- richeseAllyBlocked?: { turn: number; recipient: string };
- richeseAllyDeclined?: { turn: number; recipient: string };
- richeseCache?: Card[];
- richeseRemoved?: Card[];
- richeseBidding?: {
- owner: string;
- event: string;
- turn: number;
- stage:
- | 'blackMarketOffer'
- | 'declaration'
- | 'cacheOffer'
- | 'lot'
- | 'normal'
- | 'complete';
- position: 'first' | 'last' | null;
- normalCount: number | null;
- blackMarketSold: boolean;
- cacheCanceled: boolean;
- opener?: number;
- } | null;
- richeseAuction?: RicheseAuction | null;
- richeseFunding?: RicheseFunding;
- richesePeekKnown?: boolean;
- richeseOfferedCard?: Card | null;
- richeseClaim?: string | null;
- currentAuctionSale?: {
- winner: string;
- amount: number;
- free: boolean;
- origin: 'normal' | 'cache' | 'blackMarket';
- seller: string | null;
- } | null;
- pendingAmbassador?: {
- event: string;
- owner: string;
- entrant: string;
- token: string;
- territory: string;
- sector: number;
- turn: number;
- phase: number;
- stage: 'offer' | 'copy' | 'cards';
- beneficiary?: string;
- effect?: AmbassadorEffect;
- copyChoices: AmbassadorEffect[];
- resume: 'none' | 'wormRide';
- } | null;
- ambassadorInsights?: {
- event: string;
- viewer: string;
- target: string;
- turn: number;
- effect: 'atreides' | 'harkonnen';
- cards: Card[];
- traitor: string | null;
- }[];
- ecazAmbassadors?: AmbassadorState;
- ecazPlacementTurn?: number;
- pendingEcazPlacement?: {
- token: string;
- territory: string;
- turn: number;
- cost: number;
- } | null;
- dukeVidal?: DukeState;
- dukeAcquisitionTurn?: number;
- pendingTerrorEntry?: {
- token: string;
- entrant: string;
- territory: string;
- sector: number;
- amount: number;
- elite: number;
- cause: 'shipment' | 'movement' | 'guildTransport' | 'advisor' | 'wormRide';
- turn: number;
- phase: number;
- stage:
- | 'offer'
- | 'robbery'
- | 'discard'
- | 'gift'
- | 'sneakAttack'
- | 'allianceResponse'
- | 'allianceReply';
- allianceBlocked?: boolean;
- resume: 'none' | 'wormRide';
- } | null;
- moritaniTerror?: TerrorState;
- pendingMoritaniPlacement?: {
- token: string;
- territory: string;
- turn: number;
- } | null;
- pendingChoamMove?: MovementOrder | null;
- choamBaliset?: { turn: number; player: string; territory: string }[];
- choamMentatPending?: boolean;
- choamMovement?: { turn: number; bonus: number };
- choamWorthlessBlocked?: { turn: number; phase: number; cards: string[] };
- pendingChoamWorthless?: {
- owner: string;
- card: string;
- effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba';
- storm?: boolean;
- movement?: boolean;
- location?: string;
- elite?: number;
- mentat?: boolean;
- noFieldEvent?: string;
- target?: string;
- revival: boolean;
- } | null;
- pendingChoamBattleIncome?: { owner: string; amount: number } | null;
- pendingAuditor?: {
- event: string;
- owner: string;
- opponent: string;
- territory: string;
- turn: number;
- survived: boolean;
- usedCards: string[];
- stage: 'offer' | 'response' | 'payment';
- } | null;
- auditorInsight?: {
- event: string;
- viewer: string;
- target: string;
- turn: number;
- cards: Card[];
- } | null;
- choamMarket?: ChoamMarket | null;
- choamTradeTurn?: number;
- inflation?: Inflation | null;
- inflationUsed?: boolean;
- inflationAttemptTurn?: number;
- choamCharity?: { turn: number; canceled: boolean };
- truthtrance?: TruthWindow | null;
- truthHistory?: TruthRecord[];
- shipmentPromises?: ShipmentPromise[];
- ixSetupCards?: Card[] | null;
- ixAuction?: { count: number; cards: Card[] } | null;
- ixAuctionKnown?: { turn: number; cards: Card[] } | null;
- ixTechnologyTurn?: number;
- pendingIxTechnology?: { card: string } | null;
- pendingIxAlly?: { player: string; card: string; free: boolean } | null;
- mobileStronghold?: { location: string | null } | null;
- pendingMobileMove?: {
- player: string;
- route: string[];
- collect: boolean;
- } | null;
- revivalRules?: RevivalRules;
- revivalRequests?: Record<
- string,
- { leader: string; price: number | null; declined?: boolean }
- >;
- revivalFreeIncome?: Record;
- pendingRevival?: PendingRevival | null;
- pendingIxMove?: MovementOrder | null;
- pendingFremenMove?: {
- turn: number;
- move: number;
- order: MovementOrder;
- } | null;
- pendingIxSubstitution?: {
- player: string;
- territory: string;
- losses: Record;
- cards: string[];
- sources?: Record;
- recover?: Record;
- } | null;
- revivalPrevention?: { player: string; turn: number };
-
- traitorReserve?: string[];
- pendingFaceDance?: {
- player: string;
- winner: string;
- leader: string | null;
- identity: string | null;
- territory: string;
- } | null;
- phaseOpening?: { passed: string[]; initialize: boolean } | null;
- sandtrout?: boolean;
- techTokens?: TechState | null;
- strongholdCards?: StrongholdState | null;
- pendingTech?: { player: string; loser: string; choices: TechId[] } | null;
- summonedBeforeBlow?: boolean;
- summonedNexusBeforeRides?: boolean;
- summonedWorm?: {
- territory: string;
- resume: Pick<
- Game,
- | 'response'
- | 'decision'
- | 'pendingKarama'
- | 'spiceWindow'
- | 'spiceResolution'
- | 'spiceSequence'
- | 'nexus'
- | 'wormRides'
- | 'ready'
- >;
- } | null;
- pendingShipment?: PendingShipment | null;
- pendingExchange?: {
- response: ResponseWindow | null;
- decision: Decision | null;
- } | null;
- /** Purchase is already committed; only the Emperor's receipt remains pending. */
- pendingRichesePurchaseIncome?: {
- owner: string;
- turn: number;
- phase: number;
- resume: Pick;
- } | null;
- pendingKarama?: {
- owner: string;
- use: KaramaUse;
- /** New conversions bind their original power, shipment or lot. Absent in legacy saves. */
- opportunity?: {
- kind: 'cancel' | 'shipment' | 'auction';
- signature: string;
- };
- } | null;
- schema: 1;
- botsPending?: boolean;
- botNextActionAt?: number;
- code: string;
- version: number;
- host: string;
- status: 'lobby' | 'setup' | 'playing' | 'finished';
- /** Absent on legacy rooms whose starting cards were already dealt. */
- setupStage?: 'prediction' | 'traitors' | 'forces';
- advanced: boolean;
- expansions: string[];
- players: Player[];
- /** Fixed printed player circles; absent only in rooms created before this rule fix. */
- playerPositions?: Record;
- turn: number;
- phase: number;
- storm: number;
- order: string[];
- active: string | null;
- movementRemaining: string[] | null;
- /** Juice of Sapho's last position lasts only through this combined-turn queue. */
- saphoMovementLast?: { event: string; turn: number; player: string } | null;
- guildTimingGranted: boolean;
- guildTimingLocked: boolean;
- ready: string[];
- deck: Card[];
- discard: Card[];
- spiceDeck: SpiceCard[];
- spicePeekKnown: boolean;
- spiceDiscard: SpiceCard[][];
- spice: Record;
- log: {
- seq: number;
- text: string;
- automatic?: { faction: FactionId; name: string };
- }[];
- stormDials: Record;
- stormPending: number | null;
- stormCard: number | null;
- stormCardKnown: boolean;
- stormResolution: {
- from: number;
- distance: number;
- traversed: number;
- pending: string[];
- protected?: boolean;
- choamProtected?: string[];
- } | null;
- karamaShipping: { player: string; owner: string; card?: string } | null;
- stormDialers: string[];
- lastBattle: string[];
- lastBattleContext?: {
- event: string;
- turn: number;
- territory: string;
- combatants: string[];
- winner: string | null;
- result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion' | 'legacy';
- } | null;
- auction: Auction | null;
- battle: Battle | null;
- nexus: boolean;
- allianceOffers: Record;
- winner: string[];
- shieldWallDestroyed: boolean;
- spiceWindow: {
- territory: string;
- sector: number;
- amount: number;
- harvested: boolean;
- harvesters?: number;
- harvesterClosed?: boolean;
- } | null;
- hajr: string[];
- decision: Decision | null;
- pendingCapture: { player: string; loser: string; territory: string } | null;
- moritaniRetention?: MoritaniRetention | null;
- response: ResponseWindow | null;
- wormRides: string[];
- wormPlacementCanceledTurn: number;
- spiceResolution: { skipped: SpiceCard[] } | null;
- spiceSequence: { pile: 0 | 1; skipped: SpiceCard[] } | null;
- freeRevival: string[];
- emperorExtra: Record;
- aid: Record;
-};
-export class RuleError extends Error {}
-function stringField(value: unknown) {
- requireRule(typeof value === 'string', 'Expected a string.');
- return value;
-}
-function requireRule(condition: unknown, message: string): asserts condition {
- if (!condition) throw new RuleError(message);
-}
-export function integer(
- value: unknown,
- min: number,
- max: number,
- label = 'Amount',
-): number {
- requireRule(
- typeof value === 'number' &&
- Number.isSafeInteger(value) &&
- value >= min &&
- value <= max,
- `${label} must be an integer from ${min} to ${max}.`,
- );
- return value;
-}
-const random = () => crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296;
-export function shuffle(items: T[], rng = random): T[] {
- const a = [...items];
- for (let i = a.length - 1; i > 0; i--) {
- const j = Math.floor(rng() * (i + 1));
- [a[i], a[j]] = [a[j], a[i]];
- }
- return a;
-}
-const log = (
- g: Game,
- text: string,
- automatic?: { faction: FactionId; name: string },
-) => {
- g.log.push({
- seq: (g.log.at(-1)?.seq ?? 0) + 1,
- text,
- ...(automatic ? { automatic } : {}),
- });
- if (g.log.length > 250) g.log.shift();
-};
-const getPlayer = (g: Game, id: string) => {
- const p = g.players.find((p) => p.id === id);
- requireRule(p, 'You are not seated at this table.');
- return p;
-};
-const byFaction = (g: Game, f: string) =>
- g.players.find((p) => p.faction === f);
-const at = presenceAt;
-function noFieldRule(operation: () => T): T {
- try {
- return operation();
- } catch (error) {
- throw new RuleError(
- error instanceof Error ? error.message : 'Invalid No-Field operation.',
- );
- }
-}
-function revealPlayerNoField(g: Game, p: Player, cause: NoFieldRevealCause) {
- requireRule(
- p.faction === 'richese' && p.noField?.deployed,
- 'No concealed No-Field is deployed.',
- );
- const result = noFieldRule(() =>
- revealRicheseNoField(p.noField!, {
- tokenId: p.noField!.deployed!.tokenId,
- reserves: p.reserves,
- cause,
- }),
- );
- requireRule(
- result.controller === p.id,
- 'This concealed token has an invalid controller.',
- );
- const cohort =
- g.ornithopter?.player === p.id ? g.ornithopter.cohort : undefined;
- if (
- cohort?.noField?.tokenId === p.noField!.deployed!.tokenId &&
- cohort.noField.event === p.noFieldEvent
- ) {
- const key = location(result.location.territory, result.location.sector);
- cohort.forces[key] = (cohort.forces[key] ?? 0) + result.forces;
- delete cohort.noField;
- }
- p.noField = result.state;
- p.noFieldEvent = crypto.randomUUID();
- p.reserves -= result.forces;
- if (result.forces)
- place(p, result.location.territory, result.location.sector, result.forces);
- 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.' : ''}`,
- { faction: 'richese', name: 'No-Field revealed' },
- );
- return result;
-}
-function noFieldShipBlock(g: Game, p: Player): string | null {
- if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id || p.shipped)
- return 'Use a No-Field during your shipment, before movement.';
- if (g.truthtrance || g.phaseOpening || g.response || g.decision)
- return 'Resolve the pending decision before declaring a No-Field shipment.';
- if (p.noFieldBlockedTurn === g.turn)
- return 'Karama prevented No-Field use for this shipment opportunity. Normal shipment remains available.';
- if (!p.noField || !p.noFieldEvent)
- return 'No-Field inventory is not initialized in this saved table.';
- if (p.noField.deployed)
- return 'Reveal the existing No-Field before shipping another.';
- return null;
-}
-function noFieldRevealBlock(g: Game, p: Player): string | null {
- if (g.status !== 'playing' || g.phase >= 6)
- return 'Voluntary No-Field reveal is available before the Battle phase.';
- if (!p.noField?.deployed) return 'No concealed No-Field is deployed.';
- if (g.truthtrance || g.phaseOpening || g.response || g.decision)
- return 'Resolve the pending interaction before revealing the No-Field.';
- return null;
-}
-function noFieldAllyOfferBlock(
- g: Game,
- owner: Player,
- pending = false,
-): string | null {
- const ally = g.players.find((p) => p.id === owner.ally);
- if (owner.faction !== 'richese' || !ally || ally.ally !== owner.id)
- return 'Richese must have a current mutual ally.';
- if (
- g.status !== 'playing' ||
- g.phase !== 5 ||
- g.active !== ally.id ||
- ally.shipped
- )
- return 'Offer the token during your ally’s unused shipment turn.';
- if (
- !pending &&
- (g.truthtrance ||
- g.phaseOpening ||
- g.response ||
- (g.decision &&
- !(
- g.decision.kind === 'richeseAllyOpportunity' &&
- g.decision.player === owner.id
- )))
- )
- return 'Resolve the pending interaction before offering allied shipment.';
- if (!owner.noField || !owner.noFieldEvent)
- return 'No-Field inventory is not initialized.';
- if (ally.faction === 'fremen')
- return 'Fremen reserves are on the planet; this ability ships off-planet reserves.';
- if (ally.faction === 'guild' || g.karamaShipping?.player === ally.id)
- return 'Allied No-Field pricing with Guild or Karama discounts awaits a ruling.';
- if (
- g.richeseAllyBlocked?.turn === g.turn &&
- g.richeseAllyBlocked.recipient === ally.id
- )
- return 'Karama prevented No-Field use for this allied shipment opportunity.';
- return null;
-}
-function alliedNoFieldQuote(
- g: Game,
- offer: RicheseAllyOffer,
- checkFunds = true,
-) {
- const owner = getPlayer(g, offer.owner),
- recipient = getPlayer(g, offer.recipient);
- const blocked = noFieldAllyOfferBlock(g, owner, true);
- requireRule(!blocked, blocked ?? 'Allied No-Field shipment is unavailable.');
- requireRule(
- owner.ally === recipient.id && owner.noFieldEvent === offer.tokenEvent,
- 'This allied No-Field offer is stale.',
- );
- requireRule(
- offer.payer === owner.id ||
- offer.payer === recipient.id ||
- offer.payer === 'both',
- 'Choose an ally payer or split a two-spice shipment equally.',
- );
- requireRule(
- offer.territory !== MOBILE_STRONGHOLD &&
- validLocation(offer.territory, offer.sector),
- 'Choose a printed planet sector for the allied No-Field shipment.',
- );
- // Test the compulsory old-marker reveal without exposing or mutating it before acceptance.
- const trial = structuredClone(g),
- trialOwner = getPlayer(trial, owner.id),
- trialRecipient = getPlayer(trial, recipient.id);
- if (trialOwner.noField!.deployed)
- revealPlayerNoField(trial, trialOwner, 'beforeAlly');
- const materialized = noFieldRule(() =>
- shipAlliedRicheseNoField(
- trialOwner.noField!,
- {
- tokenId: offer.tokenId,
- controller: recipient.id,
- location: { territory: offer.territory, sector: offer.sector },
- },
- trialRecipient.reserves,
- ),
- );
- const advisors = arrivalAsAdvisor(trial, trialRecipient, offer.territory);
- allowedEntry(
- trial,
- trialRecipient,
- offer.territory,
- offer.sector,
- false,
- advisors,
- );
- const cost = territory(offer.territory).type === 'stronghold' ? 1 : 2;
- requireRule(
- offer.payer !== 'both' || cost === 2,
- 'An equal split requires the two-spice shipment price.',
- );
- const ownerPayment =
- offer.payer === owner.id ? cost : offer.payer === 'both' ? 1 : 0;
- const recipientPayment = cost - ownerPayment;
- // The supplier may authorize only its own spending; recipient funding stays private until consent.
- requireRule(
- uncommittedSpice(g, owner) >= ownerPayment,
- 'Richese needs enough uncommitted spice for its payment.',
- );
- if (checkFunds)
- requireRule(
- uncommittedSpice(g, recipient) >= recipientPayment,
- 'The recipient needs enough uncommitted spice for its payment.',
- );
- const elites = recipient.elites?.reserves ?? 0;
- return {
- cost,
- ownerPayment,
- recipientPayment,
- amount: materialized.forces,
- value: materialized.value,
- advisors,
- eliteMin: Math.max(0, materialized.forces - (recipient.reserves - elites)),
- eliteMax: Math.min(elites, materialized.forces),
- };
-}
-function giftReserved(g: Game, owner: string, card: string) {
- return (
- g.pendingRicheseGift?.intent.owner === owner &&
- g.pendingRicheseGift.intent.cardId === card
- );
-}
-function nullentropyContext(g: Game) {
- const trial = structuredClone(g),
- pending = trial.pendingNullentropy;
- if (pending) {
- trial.response = pending.resume.response;
- trial.decision = pending.resume.decision;
- trial.pendingKarama = pending.resume.pendingKarama;
- trial.phaseOpening = pending.resume.phaseOpening;
- trial.pendingNullentropy = null;
- }
- return trial;
-}
-function nullentropyIntegrity(g: Game): string | null {
- const pending = g.pendingNullentropy;
- if (
- !pending ||
- pending.turn !== g.turn ||
- pending.phase !== g.phase ||
- g.decision?.kind !== 'nullentropy' ||
- g.decision.player !== pending.player ||
- g.response ||
- g.phaseOpening ||
- g.pendingKarama
- )
- return 'The saved private search is inconsistent; restore its saved table state before continuing.';
- const owner = g.players.find((p) => p.id === pending.player);
- if (
- !owner ||
- owner.hand.filter(
- (c) =>
- c.id === pending.box &&
- richeseCardDefinition(c)?.card.effect === 'nullentropyBox',
- ).length !== 1 ||
- JSON.stringify(g.discard) !== pending.discardSignature ||
- JSON.stringify(g.discard.map((c) => c.id)) !==
- JSON.stringify(pending.discardIds)
- )
- return 'The saved search card custody changed; restore its saved table state before continuing.';
- return null;
-}
-function validateNullentropyBegin(g: Game, owner: Player, cardId: string) {
- requireRule(
- g.status === 'playing',
- 'Nullentropy Box is available during an active game.',
- );
- requireRule(
- !g.pendingNullentropy,
- 'Finish the paid Nullentropy Box search first.',
- );
- requireRule(
- !g.truthtrance,
- 'Resolve the active Truthtrance question before searching the discard.',
- );
- const card = owner.hand.find((c) => c.id === cardId);
- requireRule(
- card && richeseCardDefinition(card)?.card.effect === 'nullentropyBox',
- 'Use the canonical Nullentropy Box physically in your hand.',
- );
- const reserved = transferCardBlock(g, owner, card);
- requireRule(!reserved, reserved ?? 'The Box is already committed.');
- requireRule(
- owner.hand.length < handLimit(owner),
- 'Full-hand Nullentropy Box use is unresolved; the current guard requires a pre-existing free hand slot.',
- );
- const guild = byFaction(g, 'guild');
- requireRule(
- !(
- g.karamaShipping?.card &&
- guild &&
- !guild.specialKaramaUsed &&
- g.discard.some((c) => c.id === g.karamaShipping?.card)
- ),
- 'Searching while a shipping Karama has a provisional Guild refund claim awaits resolution.',
- );
- // A completed purchase has already paid its bid; it is no longer a reservation.
- const funding = structuredClone(g);
- if (funding.currentAuctionSale) {
- funding.auction = null;
- funding.richeseAuction = null;
- }
- requireRule(
- uncommittedSpice(funding, getPlayer(funding, owner.id)) >= 2,
- 'You need two uncommitted spice to pay the bank for this search.',
- );
- const paid = structuredClone(g);
- getPlayer(paid, owner.id).spice -= 2;
- reconcileBattlePromises(paid, {
- actor: owner.id,
- action: { type: 'card', card: cardId },
- });
- let candidates: Card[];
- try {
- candidates = eligibleNullentropyCards(g.discard);
- } catch (error) {
- throw new RuleError(
- error instanceof Error ? error.message : 'Invalid discard custody.',
- );
- }
- requireRule(
- candidates.length > 0,
- 'An empty or only-Box discard search is unsupported; no spice has been paid.',
- );
- return { card, candidates };
-}
-function finishNullentropy(
- g: Game,
- player: string,
- event: string,
- cardId: string,
-) {
- const pending = g.pendingNullentropy;
- requireRule(
- pending && pending.player === player,
- 'Only the paid search owner can select its card.',
- );
- requireRule(pending.event === event, 'This private search event is stale.');
- const invalid = nullentropyIntegrity(g);
- requireRule(!invalid, invalid ?? 'The search cannot continue.');
- const owner = getPlayer(g, player),
- context = nullentropyContext(g);
- const remaining = g.discard.filter((c) => c.id !== cardId);
- let preview: ReturnType;
- try {
- preview = resolveNullentropyBox(
- owner.hand,
- pending.box,
- g.discard,
- cardId,
- remaining.map((c) => c.id),
- handLimit(owner),
- );
- } catch (error) {
- throw new RuleError(
- error instanceof Error
- ? error.message
- : 'Invalid private search selection.',
- );
- }
- validateTransferCompletion(
- context,
- getPlayer(context, player),
- getPlayer(context, player),
- preview.ownerHand,
- preview.ownerHand,
- { type: 'card', card: pending.box },
- );
- // All rejection paths precede this single server shuffle. The paid record and
- // final shuffled order are each persisted by the room's normal CAS fence.
- const order = shuffle(remaining).map((c) => c.id);
- const result = resolveNullentropyBox(
- owner.hand,
- pending.box,
- g.discard,
- cardId,
- order,
- handLimit(owner),
- );
- owner.hand = result.ownerHand;
- g.discard = result.discard;
- g.pendingNullentropy = null;
- g.decision = null;
- log(
- g,
- `${owner.name} recovered one private card with Nullentropy Box, shuffled the remaining discard pile, and placed the used Box on top. The bank was paid when the search began.`,
- { faction: owner.faction, name: 'Nullentropy Box' },
- );
- stageTreacheryDiscard(
- g,
- 'nullentropyBox',
- [{ card: result.discard.at(-1)!, discardedBy: player, publicFace: true }],
- {
- kind: 'nullentropyDiscard',
- player,
- searchEvent: event,
- box: pending.box,
- selected: result.selected,
- finalDiscardIds: g.discard.map((c) => c.id),
- finalDiscardSignature: JSON.stringify(g.discard),
- resume: pending.resume,
- parentSignature: nullentropyParentSignature(g, pending.resume, event),
- },
- );
-}
-function beginNullentropy(g: Game, owner: Player, cardId: string) {
- const { candidates } = validateNullentropyBegin(g, owner, cardId);
- owner.spice -= 2;
- const event = crypto.randomUUID();
- g.pendingNullentropy = {
- event,
- player: owner.id,
- box: cardId,
- turn: g.turn,
- phase: g.phase,
- discardIds: g.discard.map((c) => c.id),
- discardSignature: JSON.stringify(g.discard),
- resume: {
- response: g.response,
- decision: g.decision,
- pendingKarama: g.pendingKarama,
- phaseOpening: g.phaseOpening,
- },
- };
- g.response = null;
- g.pendingKarama = null;
- g.phaseOpening = null;
- g.decision = { kind: 'nullentropy', player: owner.id };
- log(
- g,
- `${owner.name} paid two spice to the bank to privately search the discard pile with Nullentropy Box. The interrupted decision waits for this search.`,
- );
- if (candidates.length === 1)
- finishNullentropy(g, owner.id, event, candidates[0].id);
-}
-function nullentropyView(g: Game, owner: Player) {
- const pending = g.pendingNullentropy;
- const card = owner.hand.find(
- (c) => richeseCardDefinition(c)?.card.effect === 'nullentropyBox',
- );
- if (!card) return null;
- if (pending?.player === owner.id) {
- const blocked = nullentropyIntegrity(g);
- return {
- card,
- blocked,
- search: blocked
- ? null
- : {
- event: pending.event,
- cards: eligibleNullentropyCards(g.discard).sort(
- (a, b) =>
- a.name.localeCompare(b.name) || a.id.localeCompare(b.id),
- ),
- },
- };
- }
- let blocked: string | null = null;
- try {
- validateNullentropyBegin(g, owner, card.id);
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- blocked = error.message;
- }
- return { card, blocked, search: null };
-}
-function savedTransferResponses(g: Game) {
- const canceled = (pending: Game['pendingKarama']) =>
- pending?.use.kind === 'cancel' ? pending.use.response : null;
- return [
- g.response,
- canceled(g.pendingKarama),
- g.pendingExchange?.response,
- g.pendingRicheseGift?.resume.response,
- canceled(g.pendingRicheseGift?.resume.pendingKarama),
- ];
-}
-function transferCardBlock(g: Game, owner: Player, card: Card) {
- if (
- g.pendingNullentropy?.player === owner.id &&
- g.pendingNullentropy.box === card.id
- )
- return 'This Box is reserved for the paid private search.';
- const reserved = ambassadorDiscardBlock(g, owner, card);
- if (reserved) return reserved;
- if (
- g.richeseAuction?.source === 'blackMarket' &&
- !g.currentAuctionSale &&
- g.richeseAuction.owner === owner.id &&
- g.richeseAuction.cardId === card.id
- )
- return 'The card offered for Black Market must remain available.';
- if (
- g.pendingIxAlly?.player === owner.id &&
- g.pendingIxAlly.card === card.id &&
- savedTransferResponses(g).some(
- (response) => response?.kind === 'ixAllyCard',
- )
- )
- return 'The card committed to Ixian replacement must remain available.';
- return null;
-}
-function validateTransferCompletion(
- g: Game,
- owner: Player,
- recipient: Player,
- ownerHand: Card[],
- recipientHand: Card[],
- action: Action,
-) {
- const pendingKaramaPurchase = savedKaramaConversions(g).some(
- (pending) =>
- pending.owner === recipient.id &&
- ['purchase', 'auctionPayment'].includes(pending.use.kind),
- );
- const lot = g.richeseAuction;
- const richeseReserved =
- lot &&
- (lot.outcome
- ? lot.outcome.kind === 'sold' && lot.outcome.winner === recipient.id
- : lot.method === 'silent'
- ? Object.hasOwn(lot.sealed, recipient.id)
- : lot.bidder === recipient.id);
- const auctionSlot = Number(
- !g.currentAuctionSale &&
- !!(
- g.auction?.bidder === recipient.id ||
- pendingKaramaPurchase ||
- richeseReserved
- ),
- );
- if (recipientHand.length >= handLimit(recipient) && !g.currentAuctionSale) {
- requireRule(
- g.auction?.bidder !== recipient.id,
- 'Leave room for the recipient’s committed auction purchase.',
- );
- requireRule(
- !pendingKaramaPurchase,
- 'Leave room for the recipient’s pending Karama auction purchase.',
- );
- requireRule(
- !richeseReserved,
- 'Leave room until the recipient’s auction commitment is resolved; sealed submissions reserve the slot without revealing their bid.',
- );
- }
- const trial = structuredClone(g),
- donor = getPlayer(trial, owner.id);
- donor.hand = ownerHand;
- getPlayer(trial, recipient.id).hand = recipientHand;
- const decisions = [
- g.decision,
- g.pendingExchange?.decision,
- g.pendingRicheseGift?.resume.decision,
- ];
- for (const exchange of decisions)
- if (exchange?.kind === 'handExchange') {
- requireRule(
- exchange.player !== owner.id || ownerHand.length >= exchange.count,
- 'Keep enough cards to complete your committed hand exchange.',
- );
- requireRule(
- exchange.target !== recipient.id ||
- recipientHand.length + exchange.count + auctionSlot <=
- handLimit(recipient),
- 'Leave room for the cards the recipient must receive from the committed hand exchange and any pending auction purchase.',
- );
- }
- const pending = trial.pendingAmbassador;
- if (pending?.beneficiary === owner.id) {
- const entrant = getPlayer(trial, pending.entrant);
- if (pending.stage === 'cards' && pending.effect === 'ixians')
- requireRule(
- !ambassadorEffectBlock(trial, 'ixians', donor, entrant),
- 'Keep a discardable card for the committed Ixian Ambassador exchange.',
- );
- if (pending.stage === 'copy')
- requireRule(
- pending.copyChoices.some(
- (effect) => !ambassadorEffectBlock(trial, effect, donor, entrant),
- ),
- 'Keep an available effect for the committed Ambassador copy.',
- );
- }
- reconcileBattlePromises(trial, { actor: owner.id, action });
-}
-function distransBlock(g: Game, owner: Player, card: Card): string | null {
- if (g.pendingNullentropy)
- return 'Finish the paid Nullentropy Box search first.';
- if (g.status !== 'playing')
- return 'Distrans is available during an active game.';
- if (g.truthtrance)
- return 'Resolve the active Truthtrance question before changing hands.';
- const reserved = transferCardBlock(g, owner, card);
- if (reserved) return reserved;
- // The face excludes a bid, not the complete Bidding phase. A live unpaid lot
- // remains guarded pending the requested ruling; already-paid income is separate.
- if ((g.auction || g.richeseAuction) && !g.currentAuctionSale)
- return 'Distrans during an unresolved auction lot awaits a ruling. It remains available outside that bid.';
- return null;
-}
-function validateDistrans(
- g: Game,
- owner: Player,
- cardId: string,
- recipientId: string,
- giveId: string,
-) {
- const card = owner.hand.find((c) => c.id === cardId);
- requireRule(
- card && richeseCardDefinition(card)?.card.effect === 'distrans',
- 'Play the canonical Distrans physically in your hand.',
- );
- const blocked = distransBlock(g, owner, card);
- requireRule(!blocked, blocked ?? 'Distrans is unavailable.');
- const recipient = getPlayer(g, recipientId);
- let result: ReturnType;
- try {
- result = transferDistrans(
- owner,
- recipient,
- cardId,
- giveId,
- handLimit(recipient),
- );
- } catch (error) {
- throw new RuleError(
- error instanceof Error ? error.message : 'Invalid Distrans transfer.',
- );
- }
- const reserved = transferCardBlock(g, owner, result.transferred);
- requireRule(!reserved, reserved ?? 'This card is already committed.');
- validateTransferCompletion(
- g,
- owner,
- recipient,
- result.ownerHand,
- result.recipientHand,
- { type: 'card', card: cardId, target: recipientId, give: giveId },
- );
- return { recipient, result };
-}
-function distransView(g: Game, owner: Player) {
- const card = owner.hand.find(
- (c) => richeseCardDefinition(c)?.card.effect === 'distrans',
- );
- if (!card) return null;
- const blocked = distransBlock(g, owner, card);
- const choices = g.players
- .filter((p) => p.id !== owner.id)
- .map((recipient) => {
- const cards: Card[] = [],
- unavailable: { card: Card; reason: string }[] = [];
- const targetBlock =
- blocked ??
- (recipient.hand.length >= handLimit(recipient)
- ? 'The recipient’s Treachery hand is full.'
- : null);
- for (const given of owner.hand.filter((c) => c.id !== card.id)) {
- if (targetBlock) {
- unavailable.push({ card: given, reason: targetBlock });
- continue;
- }
- try {
- validateDistrans(g, owner, card.id, recipient.id, given.id);
- cards.push(given);
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- unavailable.push({ card: given, reason: error.message });
- }
- }
- return {
- recipient: recipient.id,
- blocked: targetBlock,
- cards,
- unavailable,
- };
- });
- return { card, blocked, choices };
-}
-function richeseGiftBlock(g: Game, owner: Player): string | null {
- if (g.pendingNullentropy)
- return 'Finish the paid Nullentropy Box search first.';
- if (g.status !== 'playing')
- return 'Richese gifts are available during an active game.';
- if (owner.faction !== 'richese')
- return 'Only Richese can give a Richese card to its ally.';
- if (g.truthtrance)
- return 'Resolve the active Truthtrance question before changing hands.';
- if (g.pendingRicheseGift) return 'Resolve the pending Richese gift first.';
- const ally = g.players.find((p) => p.id === owner.ally);
- if (!ally || ally.ally !== owner.id) return 'You need a current mutual ally.';
- if (ally.hand.length >= handLimit(ally))
- return 'Your ally’s Treachery hand is full.';
- return null;
-}
-function richeseGiftContext(g: Game) {
- const context = structuredClone(g),
- pending = context.pendingRicheseGift;
- if (pending) {
- context.response = pending.resume.response;
- context.decision = pending.resume.decision;
- context.pendingKarama = pending.resume.pendingKarama;
- context.phaseOpening = pending.resume.phaseOpening;
- context.pendingRicheseGift = null;
- }
- return context;
-}
-function validateRicheseGift(g: Game, owner: Player, cardId: string) {
- const blocked = richeseGiftBlock(g, owner);
- requireRule(!blocked, blocked ?? 'This gift is unavailable.');
- const recipient = getPlayer(g, owner.ally!);
- let intent: RicheseGiftIntent;
- try {
- intent = prepareRicheseGift(owner, recipient, cardId, handLimit(recipient));
- } catch (error) {
- throw new RuleError(
- error instanceof Error ? error.message : 'Invalid Richese gift.',
- );
- }
- const card = owner.hand.find((c) => c.id === cardId)!;
- const reservation = transferCardBlock(g, owner, card);
- requireRule(!reservation, reservation ?? 'This card is already committed.');
- requireRule(
- !(
- g.richeseGiftBlocked?.turn === g.turn &&
- g.richeseGiftBlocked.phase === g.phase &&
- g.richeseGiftBlocked.cards.includes(cardId)
- ),
- 'Retrying this canceled card’s gift in the same phase awaits a ruling.',
- );
- const resulting = transferRicheseGift(
- owner,
- recipient,
- intent,
- handLimit(recipient),
- );
- validateTransferCompletion(
- g,
- owner,
- recipient,
- resulting.ownerHand,
- resulting.recipientHand,
- { type: 'richeseGift', card: cardId },
- );
- return { intent, resulting };
-}
-function richeseGiftView(g: Game, viewer: Player) {
- const owner = byFaction(g, 'richese');
- if (!owner) return null;
- const pending = g.pendingRicheseGift;
- if (viewer.id !== owner.id && pending?.intent.recipient !== viewer.id)
- return null;
- const mine = viewer.id === owner.id;
- const blocked = richeseGiftBlock(g, owner);
- const cards: Card[] = [],
- unavailable: { card: Card; reason: string }[] = [];
- if (mine)
- for (const card of owner.hand.filter((card) =>
- richeseCardDefinition(card),
- )) {
- if (blocked) {
- unavailable.push({ card, reason: blocked });
- continue;
- }
- try {
- validateRicheseGift(g, owner, card.id);
- cards.push(card);
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- unavailable.push({ card, reason: error.message });
- }
- }
- return {
- owner: owner.id,
- recipient: owner.ally,
- blocked,
- cards,
- unavailable,
- pending: pending
- ? {
- event: pending.event,
- owner: pending.intent.owner,
- recipient: pending.intent.recipient,
- card:
- getPlayer(g, pending.intent.owner).hand.find(
- (c) => c.id === pending.intent.cardId,
- ) ?? null,
- }
- : null,
- };
-}
-export const handLimit = (p: Pick) =>
- p.faction === 'harkonnen' ? 8 : p.faction === 'choam' ? 5 : 4;
-export function newPlayer(id: string, name: string, f: FactionId): Player {
- requireRule(
- name.trim().length > 0 && name.trim().length <= 32,
- 'Enter a name of 1–32 characters.',
- );
- requireRule(
- FACTIONS.some((x) => x.id === f),
- 'Choose a valid faction.',
- );
- return {
- id,
- name: name.trim(),
- faction: f,
- ready: false,
- spice: 0,
- reserves: 20,
- tanks: 0,
- forces: {},
- ...(f === 'ixians'
- ? { elites: { reserves: 7, tanks: 0, forces: {}, revived: 0 } }
- : {}),
- hand: [],
- leaders: leaders(f),
- traitors: [],
- traitorChoices: [],
- ally: null,
- bribes: 0,
- revived: 0,
- freeForcesRevived: 0,
- leaderRevived: false,
- revivalCycle: 0,
- shipped: false,
- moved: 0,
- battleLosses: 0,
- };
-}
-export function createGame(
- code: string,
- host: Player,
- advanced = false,
- expansions: string[] = [],
-): Game {
- requireRule(
- expansions.every((x) => ['ix', 'choam', 'ecaz'].includes(x)),
- 'Unknown expansion.',
- );
- return {
- schema: 1,
- code,
- version: 0,
- host: host.id,
- status: 'lobby',
- advanced,
- expansions: [...new Set(expansions)],
- players: [host],
- playerPositions: { [host.id]: 1 },
- turn: 1,
- phase: 0,
- storm: STORM_START_SECTOR,
- order: [],
- active: null,
- movementRemaining: null,
- guildTimingGranted: false,
- guildTimingLocked: false,
- ready: [],
- deck: [],
- discard: [],
- spiceDeck: [],
- spicePeekKnown: false,
- spiceDiscard: [[], []],
- spice: {},
- log: [{ seq: 1, text: `${host.name} opened the table.` }],
- stormDials: {},
- stormPending: null,
- stormCard: null,
- stormCardKnown: false,
- stormResolution: null,
- karamaShipping: null,
- stormDialers: [],
- lastBattle: [],
- auction: null,
- battle: null,
- nexus: false,
- allianceOffers: {},
- winner: [],
- shieldWallDestroyed: false,
- spiceWindow: null,
- hajr: [],
- decision: null,
- pendingCapture: null,
- response: null,
- wormRides: [],
- wormPlacementCanceledTurn: 0,
- spiceResolution: null,
- spiceSequence: null,
- freeRevival: [],
- emperorExtra: {},
- aid: {},
- };
-}
-export function joinGame(g: Game, p: Player) {
- requireRule(g.status === 'lobby', 'This game has already started.');
- requireRule(g.players.length < 6, 'This table has six players.');
- requireRule(!g.players.some((x) => x.id === p.id), 'Already seated.');
- requireRule(
- !g.players.some((x) => x.faction === p.faction),
- 'That faction is already taken.',
- );
- requireRule(
- faction(p.faction).expansion === 'base' ||
- g.expansions.includes(faction(p.faction).expansion),
- 'The host has not enabled that expansion.',
- );
- g.players.push(p);
- g.playerPositions = normalizedPlayerPositions(g);
- g.players.forEach((x) => (x.ready = false));
- log(g, `${p.name} joined as ${faction(p.faction).name}.`);
-}
-function draw(g: Game) {
- requireRule(
- !g.pendingTreacheryDiscard,
- 'Finish the committed discard continuation before drawing cards.',
- );
- requireRule(
- !g.pendingNullentropy,
- 'Finish the paid Nullentropy Box search before drawing cards.',
- );
- if (!g.deck.length) {
- g.deck = shuffle(g.discard);
- g.discard = [];
- }
- return g.deck.shift();
-}
-function discard(g: Game, p: Player, id: string) {
- requireRule(
- !g.pendingTreacheryDiscard,
- 'Finish the committed discard continuation before another discard.',
- );
- requireRule(
- !g.pendingNullentropy,
- 'Finish the paid Nullentropy Box search before changing the discard pile.',
- );
- requireRule(
- !giftReserved(g, p.id, id),
- 'This card is reserved for the pending Richese gift.',
- );
- const c = p.hand.find((c) => c.id === id);
- requireRule(c, 'Card is not in your hand.');
- g.discard.push(c);
- p.hand = p.hand.filter((c) => c.id !== id);
- return c;
-}
-/** A receipt of suspended controls and live pending obligations; never restored
- * as game state. Player resources, hands, log and seat control are excluded. */
-function nullentropyParentSignature(
- g: Game,
- resume: NonNullable['resume'],
- searchEvent: string,
-) {
- const parents = Object.fromEntries(
- Object.entries(g)
- .filter(
- ([key, value]) =>
- key.startsWith('pending') &&
- ![
- 'pendingTreacheryDiscard',
- 'pendingNullentropy',
- 'pendingKarama',
- ].includes(key) &&
- value != null,
- )
- .sort(([a], [b]) => a.localeCompare(b)),
- );
- return JSON.stringify({ searchEvent, resume, parents });
-}
-type OrdinaryDiscardContinuation = Extract<
- NonNullable['continuation'],
- { kind: 'ordinaryCardDiscard' }
->;
-/** Bind completed effects, not a replacement Game. Seat control and private hands
- * may change independently of these outcomes; neither is restored on recovery. */
-function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) {
- return JSON.stringify({
- player: next.player,
- card: next.card,
- effect: next.effect,
- controls: nullentropyParentSignature(
- g,
- next.resume,
- `ordinary:${next.card}`,
- ),
- phase: g.phase,
- turn: g.turn,
- advanced: g.advanced,
- expansions: g.expansions,
- active: g.active,
- order: g.order,
- ready: g.ready,
- movementRemaining: g.movementRemaining,
- hajr: g.hajr,
- ornithopter: g.ornithopter,
- karamaShipping: g.karamaShipping,
- storm: g.storm,
- stormPending: g.stormPending,
- stormDials: g.stormDials,
- stormResolution: g.stormResolution,
- shieldWallDestroyed: g.shieldWallDestroyed,
- spice: g.spice,
- spiceWindow: g.spiceWindow,
- spiceResolution: g.spiceResolution,
- spiceSequence: g.spiceSequence,
- battle: g.battle,
- shipmentPromises: g.shipmentPromises,
- techTokens: g.techTokens,
- dukeVidal: g.dukeVidal,
- revivalFreeIncome: g.revivalFreeIncome,
- players: g.players.map((p) => ({
- id: p.id,
- faction: p.faction,
- spice: p.spice,
- forces: p.forces,
- reserves: p.reserves,
- tanks: p.tanks,
- elites: p.elites,
- advisors: p.advisors,
- moved: p.moved,
- shipped: p.shipped,
- revived: p.revived,
- revivalCycle: p.revivalCycle,
- leaderRevived: p.leaderRevived,
- leaders: p.leaders,
- kwisatz: p.kwisatz,
- })),
- });
-}
-function stageOrdinaryCardDiscard(g: Game, p: Player, card: Card) {
- const resume = {
- response: g.response,
- decision: g.decision,
- pendingKarama: g.pendingKarama,
- phaseOpening: g.phaseOpening,
- };
- g.response = null;
- g.decision = null;
- g.pendingKarama = null;
- g.phaseOpening = null;
- const next: OrdinaryDiscardContinuation = {
- kind: 'ordinaryCardDiscard',
- player: p.id,
- card: card.id,
- effect: card.effect as OrdinaryDiscardContinuation['effect'],
- resume,
- stateSignature: '',
- };
- next.stateSignature = ordinaryDiscardSignature(g, next);
- stageTreacheryDiscard(
- g,
- `ordinary:${next.effect}`,
- [{ card, discardedBy: p.id, publicFace: true }],
- next,
- );
-}
-type TruthDiscardContinuation = Extract<
- NonNullable['continuation'],
- { kind: 'truthtranceDiscard' }
->;
-function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
- return JSON.stringify({
- consumed: next.consumed,
- historyIndex: next.historyIndex,
- record: next.record,
- remaining: next.remaining,
- promise: next.promise,
- controls: nullentropyParentSignature(
- g,
- next.resume,
- `truth:${next.historyIndex}`,
- ),
- history: g.truthHistory,
- battle: g.battle,
- shipmentPromises: g.shipmentPromises,
- active: g.active,
- order: g.order,
- status: g.status,
- setupStage: g.setupStage,
- auction: g.auction,
- richeseAuction: g.richeseAuction,
- currentAuctionSale: g.currentAuctionSale,
- ornithopter: g.ornithopter,
- spiceWindow: g.spiceWindow,
- spiceResolution: g.spiceResolution,
- wormRides: g.wormRides,
- summonedWorm: g.summonedWorm,
- });
-}
-/** Truth validation, promise binding and the public answer have already happened. */
-function finishTruthtranceAnswer(g: Game) {
- const window = g.truthtrance!;
- const consumed = window.queue[0];
- const historyIndex = g.truthHistory!.length - 1;
- const record = g.truthHistory![historyIndex];
- const card = discard(g, getPlayer(g, consumed.player), consumed.card);
- let promise: TruthDiscardContinuation['promise'] = null;
- if (record.question.kind === 'shipment')
- promise = {
- kind: 'shipment',
- index: g.shipmentPromises!.length - 1,
- value: g.shipmentPromises!.at(-1)!,
- };
- else if (
- record.question.kind === 'battlePlan' &&
- g.battle &&
- !g.battle.revealed &&
- !g.battle.plans[record.question.target]
- )
- promise = {
- kind: 'battle',
- index: g.battle.truthPromises!.length - 1,
- value: g.battle.truthPromises!.at(-1)!,
- };
- const resume = {
- response: g.response,
- decision: g.decision,
- pendingKarama: g.pendingKarama,
- phaseOpening: g.phaseOpening,
- };
- const remaining: TruthWindow | null =
- window.queue.length > 1
- ? {
- stage: 'ask',
- queue: window.queue.slice(1),
- passed: window.passed,
- question: null,
- }
- : null;
- g.truthtrance = null;
- g.response = null;
- g.decision = null;
- g.pendingKarama = null;
- g.phaseOpening = null;
- const continuation: TruthDiscardContinuation = {
- kind: 'truthtranceDiscard',
- consumed,
- historyIndex,
- record,
- remaining,
- resume,
- promise,
- parentSignature: '',
- };
- continuation.parentSignature = truthtranceDiscardSignature(g, continuation);
- stageTreacheryDiscard(
- g,
- 'truthtrance',
- [{ card, discardedBy: consumed.player, publicFace: true }],
- continuation,
- );
-}
-/** Bind the committed movement and its remaining arrival/turn obligations.
- * This receipt is never restored over live state and excludes seat AI controls. */
-function ornithopterDiscardSignature(
- g: Game,
- next: Extract<
- NonNullable['continuation'],
- { kind: 'ornithopterDiscard' }
- >,
-) {
- return JSON.stringify({
- source: next.source,
- flight: next.flight,
- movement: next.movement,
- controls: nullentropyParentSignature(g, next.resume, next.flight.event),
- active: g.active,
- order: g.order,
- movementRemaining: g.movementRemaining,
- guildTimingGranted: g.guildTimingGranted,
- guildTimingLocked: g.guildTimingLocked,
- saphoMovementLast: g.saphoMovementLast,
- karamaShipping: g.karamaShipping,
- ambassadors: g.ecazAmbassadors,
- terror: g.moritaniTerror,
- players: g.players.map((p) => ({
- id: p.id,
- faction: p.faction,
- ally: p.ally,
- allySinceTurn: p.allySinceTurn,
- forces: p.forces,
- elites: p.elites,
- reserves: p.reserves,
- tanks: p.tanks,
- advisors: p.advisors,
- noField: p.noField,
- noFieldEvent: p.noFieldEvent,
- moved: p.moved,
- shipped: p.shipped,
- })),
- });
-}
-function suspendedControlsIntegrity(
- g: Game,
- resume: NonNullable['resume'],
-) {
- const seated = (id: string) => g.players.some((p) => p.id === id);
- const passes = (ids: string[]) =>
- Array.isArray(ids) && new Set(ids).size === ids.length && ids.every(seated);
- const response = (r: ResponseWindow | null | undefined) =>
- !r || (typeof r.kind === 'string' && seated(r.owner) && passes(r.passed));
- requireRule(
- resume &&
- typeof resume === 'object' &&
- !Array.isArray(resume) &&
- Object.keys(resume).every((key) =>
- ['response', 'decision', 'pendingKarama', 'phaseOpening'].includes(key),
- ) &&
- response(resume.response) &&
- (!resume.decision ||
- (resume.decision.kind !== 'nullentropy' &&
- typeof resume.decision.kind === 'string' &&
- seated(resume.decision.player))) &&
- (!resume.phaseOpening ||
- (typeof resume.phaseOpening.initialize === 'boolean' &&
- passes(resume.phaseOpening.passed))) &&
- (!resume.pendingKarama ||
- (seated(resume.pendingKarama.owner) &&
- resume.pendingKarama.use &&
- ['cancel', 'shipment', 'purchase', 'auctionPayment'].includes(
- resume.pendingKarama.use.kind,
- ) &&
- (resume.pendingKarama.use.kind !== 'cancel' ||
- response(resume.pendingKarama.use.response)))) &&
- (resume.response?.kind !== 'worthlessKarama' || !!resume.pendingKarama),
- 'The completed discard does not match its suspended controls and obligations.',
- );
- // Validate restored controls without advancing or replaying their effects.
- const context = structuredClone(g);
- context.pendingTreacheryDiscard = null;
- Object.assign(context, structuredClone(resume));
- karamaConversionIntegrity(context);
- shipmentPromiseIntegrity(context);
- saphoMovementIntegrity(context);
- ornithopterIntegrity(context);
- lateDefenseIntegrity(context);
- stoneBurnerIntegrity(context);
- strongholdIntegrity(context);
- auditorIntegrity(context);
-}
-function treacheryDiscardIntegrity(g: Game) {
- const pending = g.pendingTreacheryDiscard;
- const sequence = g.treacheryDiscardSequence ?? 0;
- const resolved = g.resolvedTreacheryDiscardSequence ?? 0;
- requireRule(
- Number.isSafeInteger(sequence) &&
- sequence >= 0 &&
- Number.isSafeInteger(resolved) &&
- resolved >= 0 &&
- sequence === resolved + (pending ? 1 : 0),
- 'The saved discard sequence is inconsistent or already resolved.',
- );
- if (!pending) return;
- const { batch, continuation } = pending;
- const box = continuation?.kind === 'nullentropyDiscard';
- const truth = continuation?.kind === 'truthtranceDiscard';
- requireRule(
- (g.status === 'playing' || (truth && g.status === 'setup')) &&
- pending.sequence === sequence &&
- batch &&
- batch.event === `discard:${g.turn}:${g.phase}:${sequence}` &&
- batch.turn === g.turn &&
- batch.phase === g.phase &&
- Array.isArray(batch.entries) &&
- batch.entries.length > 0 &&
- !g.response &&
- !g.decision &&
- !g.truthtrance &&
- !g.phaseOpening &&
- (!g.pendingRicheseGift || box || truth) &&
- !g.pendingKarama &&
- !g.pendingNullentropy &&
- (!g.pendingAmbassador || box || truth) &&
- (!g.pendingIxAlly || box || truth),
- 'The saved discard does not match its current continuation.',
- );
- const ids = new Set();
- // Auction prefixes and Ix knowledge are receipts, not additional custody.
- const outside = [
- ...g.players.flatMap((p) => p.hand),
- ...g.deck,
- ...(g.richeseCache ?? []),
- ...(g.richeseRemoved ?? []),
- ...(g.ixSetupCards ?? []),
- ...(g.ixAuction?.cards ?? []),
- ...(g.ornithopter ? [g.ornithopter.card] : []),
- ...(g.auction?.cards.slice(
- g.auction.index + (g.currentAuctionSale ? 1 : 0),
- ) ?? []),
- ];
- for (const entry of Array.from(batch.entries)) {
- const card = entry?.card;
- const actual = card && g.discard.filter((c) => c.id === card.id);
- requireRule(
- entry &&
- card &&
- typeof card.id === 'string' &&
- card.id.length > 0 &&
- !ids.has(card.id) &&
- typeof entry.publicFace === 'boolean' &&
- g.players.some((p) => p.id === entry.discardedBy) &&
- actual?.length === 1 &&
- actual[0].name === card.name &&
- actual[0].kind === card.kind &&
- actual[0].effect === card.effect &&
- !outside.some((c) => c.id === card.id),
- 'The fresh discard receipt has conflicting physical custody.',
- );
- ids.add(card.id);
- }
- if (continuation?.kind === 'nullentropyDiscard') {
- const c = continuation;
- const owner = g.players.find((p) => p.id === c.player);
- const selected =
- c.selected && owner?.hand.filter((card) => card.id === c.selected.id);
- requireRule(
- owner &&
- typeof c.searchEvent === 'string' &&
- c.searchEvent.length > 0 &&
- batch.cause === 'nullentropyBox' &&
- batch.entries.length === 1 &&
- batch.entries[0].discardedBy === c.player &&
- batch.entries[0].publicFace &&
- batch.entries[0].card.id === c.box &&
- richeseCardDefinition(batch.entries[0].card)?.card.effect ===
- 'nullentropyBox' &&
- g.discard.at(-1)?.id === c.box &&
- Array.isArray(c.finalDiscardIds) &&
- new Set(c.finalDiscardIds).size === c.finalDiscardIds.length &&
- JSON.stringify(c.finalDiscardIds) ===
- JSON.stringify(g.discard.map((card) => card.id)) &&
- c.finalDiscardSignature === JSON.stringify(g.discard) &&
- selected?.length === 1 &&
- c.selected.effect !== 'nullentropyBox' &&
- c.selected.name !== 'Nullentropy Box' &&
- (['id', 'name', 'kind', 'effect'] as const).every(
- (key) => selected[0][key] === c.selected[key],
- ) &&
- outside.filter((card) => card.id === c.selected.id).length === 1 &&
- !g.discard.some((card) => card.id === c.selected.id) &&
- g.discard.every(
- (card) => !outside.some((other) => other.id === card.id),
- ),
- 'The completed Box search has inconsistent selected-card or shuffled-pile custody.',
- );
- requireRule(
- c.parentSignature ===
- nullentropyParentSignature(g, c.resume, c.searchEvent),
- 'The completed Box search does not match its suspended controls and obligations.',
- );
- suspendedControlsIntegrity(g, c.resume);
- } else if (continuation?.kind === 'ordinaryCardDiscard') {
- const c = continuation,
- entry = batch.entries[0];
- const owner = g.players.find((p) => p.id === c.player);
- requireRule(
- owner &&
- ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(
- c.effect,
- ) &&
- batch.cause === `ordinary:${c.effect}` &&
- batch.entries.length === 1 &&
- entry.publicFace &&
- entry.discardedBy === c.player &&
- entry.card.id === c.card &&
- entry.card.kind === 'special' &&
- entry.card.effect === c.effect &&
- c.stateSignature === ordinaryDiscardSignature(g, c),
- 'The consumed ordinary card no longer matches its completed effect.',
- );
- suspendedControlsIntegrity(g, c.resume);
- requireRule(
- !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening,
- 'The ordinary card has an unexpected suspended decision.',
- );
- const income = c.resume.response,
- tleilaxu = byFaction(g, 'tleilaxu');
- requireRule(
- c.effect === 'ghola' && tleilaxu
- ? income?.kind === 'revivalIncome' &&
- income.owner === tleilaxu.id &&
- income.recipient === c.player &&
- income.amount === 1 &&
- income.passed.length === 0
- : !income,
- 'The consumed card no longer matches its pending revival income.',
- );
- requireRule(
- c.effect === 'hajr'
- ? g.phase === 5 &&
- g.active === c.player &&
- g.hajr.filter((id) => id === c.player).length === 1
- : c.effect === 'weather'
- ? g.phase === 0 &&
- Number.isSafeInteger(g.stormPending) &&
- g.stormPending! >= 0 &&
- g.stormPending! <= 10 &&
- g.ready.length === 0
- : c.effect === 'atomics'
- ? g.phase === 0 &&
- g.shieldWallDestroyed &&
- g.ready.length === 0 &&
- g.players.every(
- (p) =>
- !Object.entries(p.forces).some(
- ([key, n]) => key.startsWith('shield_wall:') && n > 0,
- ),
- )
- : c.effect === 'harvester'
- ? g.phase === 1 &&
- !!g.spiceWindow?.harvested &&
- Number.isSafeInteger(g.spiceWindow.harvesters) &&
- g.spiceWindow.harvesters! > 0 &&
- g.ready.length === 0
- : true,
- 'The completed ordinary-card outcome has changed.',
- );
- } else if (continuation?.kind === 'truthtranceDiscard') {
- const c = continuation,
- entry = batch.entries[0],
- record = c.record;
- requireRule(
- c.consumed &&
- typeof c.consumed.player === 'string' &&
- batch.cause === 'truthtrance' &&
- batch.entries.length === 1 &&
- entry.publicFace &&
- entry.card.effect === 'truthtrance' &&
- entry.card.id === c.consumed.card &&
- entry.discardedBy === c.consumed.player &&
- record &&
- record.asker === c.consumed.player &&
- record.turn === g.turn &&
- record.phase === g.phase &&
- ['yes', 'no'].includes(record.answer) &&
- Array.isArray(g.truthHistory) &&
- Number.isSafeInteger(c.historyIndex) &&
- c.historyIndex >= 0 &&
- c.historyIndex === g.truthHistory.length - 1 &&
- JSON.stringify(g.truthHistory[c.historyIndex]) ===
- JSON.stringify(record),
- 'The consumed Truthtrance does not match its completed answer history.',
- );
- const remaining = c.remaining;
- requireRule(
- remaining === null ||
- (remaining &&
- remaining.stage === 'ask' &&
- remaining.question === null &&
- Array.isArray(remaining.queue) &&
- remaining.queue.length > 0 &&
- new Set(remaining.queue.map((e) => e?.card)).size ===
- remaining.queue.length &&
- remaining.queue.every(
- (e) =>
- e &&
- e.card !== c.consumed.card &&
- typeof e.card === 'string' &&
- e.card.length > 0 &&
- typeof e.player === 'string' &&
- g.players
- .find((p) => p.id === e.player)
- ?.hand.filter(
- (card) => card.id === e.card && card.effect === 'truthtrance',
- ).length === 1 &&
- outside.filter((card) => card.id === e.card).length === 1 &&
- !g.discard.some((card) => card.id === e.card),
- ) &&
- Array.isArray(remaining.passed) &&
- new Set(remaining.passed).size === remaining.passed.length &&
- remaining.passed.every((id) => g.players.some((p) => p.id === id))),
- 'The remaining Truthtrance queue no longer matches its held cards and priority.',
- );
- requireRule(
- c.parentSignature === truthtranceDiscardSignature(g, c),
- 'The completed Truthtrance no longer matches its recorded promises and parent.',
- );
- suspendedControlsIntegrity(g, c.resume);
- const context = structuredClone(g);
- context.pendingTreacheryDiscard = null;
- Object.assign(context, structuredClone(c.resume));
- try {
- validateTruthQuestionReceipt(context, c.consumed.player, record.question);
- } catch (error) {
- if (error instanceof TruthError || error instanceof PlanClaimError)
- throw new RuleError(error.message);
- throw error;
- }
- const q = record.question,
- promise = c.promise;
- const expectedShipment = q.kind === 'shipment';
- const expectedBattle =
- q.kind === 'battlePlan' &&
- g.battle &&
- !g.battle.revealed &&
- !g.battle.plans[q.target];
- if (expectedShipment || expectedBattle) {
- const actual = expectedShipment
- ? g.shipmentPromises
- : g.battle?.truthPromises;
- requireRule(
- promise &&
- promise.value &&
- typeof promise.value === 'object' &&
- promise.kind === (expectedShipment ? 'shipment' : 'battle') &&
- Array.isArray(actual) &&
- Number.isSafeInteger(promise.index) &&
- promise.index >= 0 &&
- promise.index === (actual?.length ?? 0) - 1 &&
- JSON.stringify(promise.value) ===
- JSON.stringify(actual?.[promise.index]) &&
- promise.value.player === q.target &&
- promise.value.asker === record.asker &&
- promise.value.answer === (record.answer === 'yes') &&
- !promise.value.released,
- 'The completed Truthtrance is missing its newly bound promise.',
- );
- if (promise.kind === 'shipment')
- requireRule(
- q.kind === 'shipment' &&
- promise.value.turn === g.turn &&
- !promise.value.fulfilled &&
- promise.value.territory === q.territory &&
- promise.value.minimum === q.minimum,
- 'The completed Truthtrance shipment promise differs from its answer.',
- );
- else
- requireRule(
- q.kind === 'battlePlan' &&
- JSON.stringify(promise.value.claim) === JSON.stringify(q.claim),
- 'The completed Truthtrance battle promise differs from its answer.',
- );
- } else
- requireRule(
- promise === null,
- 'This Truthtrance answer did not create a new promise.',
- );
- } else if (continuation?.kind === 'ornithopterDiscard') {
- const c = continuation,
- flight = c.flight;
- const owner = flight && g.players.find((p) => p.id === flight.player);
- const total = flight?.mode === 'twoGroups' ? 2 : 1;
- requireRule(
- owner &&
- g.phase === 5 &&
- g.active === owner.id &&
- !g.ornithopter &&
- !g.pendingChoamMove &&
- !g.pendingIxMove &&
- !g.pendingFremenMove &&
- !g.pendingTerrorEntry &&
- !g.pendingExchange &&
- !g.battle &&
- flight.turn === g.turn &&
- typeof flight.event === 'string' &&
- flight.event.length > 0 &&
- ['range3', 'twoGroups'].includes(flight.mode) &&
- Number.isSafeInteger(flight.startingMove) &&
- flight.startingMove >= 0 &&
- Number.isSafeInteger(flight.completed) &&
- flight.completed >= 0 &&
- owner.moved === flight.startingMove + flight.completed &&
- ['move', 'end'].includes(c.source) &&
- (c.source === 'move'
- ? flight.completed === total
- : flight.completed < total) &&
- batch.cause === `ornithopter:${c.source}` &&
- batch.entries.length === 1 &&
- batch.entries[0].publicFace &&
- batch.entries[0].discardedBy === owner.id &&
- flight.card &&
- richeseCardDefinition(flight.card)?.card.effect === 'ornithopter' &&
- (['id', 'name', 'kind', 'effect'] as const).every(
- (key) => flight.card[key] === batch.entries[0].card[key],
- ),
- 'The retired Ornithopter does not match its completed flight and discard.',
- );
- if (c.source === 'move') {
- const m = c.movement;
- requireRule(
- m &&
- m.player === owner.id &&
- owner.shipped &&
- gameTerritories(g).some((t) => t.id === m.origin) &&
- validLocation(m.to, m.sector) &&
- Number.isSafeInteger(m.total) &&
- m.total > 0 &&
- typeof m.noField === 'boolean' &&
- Number.isSafeInteger(m.elite) &&
- m.elite >= 0 &&
- m.elite <= m.total - (m.noField ? 1 : 0) &&
- (owner.forces[location(m.to, m.sector)] ?? 0) >=
- m.total - (m.noField ? 1 : 0) &&
- (owner.elites?.forces[location(m.to, m.sector)] ?? 0) >= m.elite &&
- (!m.noField ||
- (owner.noField?.deployed?.location.territory === m.to &&
- owner.noField.deployed.location.sector === m.sector)),
- 'The retired Ornithopter arrival differs from its committed movement.',
- );
- } else
- requireRule(
- c.movement === null,
- 'An ended Ornithopter has no arrival to replay.',
- );
- requireRule(
- c.stateSignature === ornithopterDiscardSignature(g, c),
- 'The retired Ornithopter no longer matches its saved movement obligations.',
- );
- suspendedControlsIntegrity(g, c.resume);
- } else if (continuation?.kind === 'ambassador') {
- const entry = continuation.entry;
- const owner = g.players.find((p) => p.id === entry?.owner);
- const beneficiary = g.players.find((p) => p.id === entry?.beneficiary);
- requireRule(
- entry &&
- entry.turn === g.turn &&
- entry.phase === g.phase &&
- typeof entry.event === 'string' &&
- entry.event.length > 0 &&
- entry.stage === 'cards' &&
- ['choam', 'ixians'].includes(entry.effect!) &&
- ['none', 'wormRide'].includes(entry.resume) &&
- (entry.resume !== 'wormRide' ||
- (g.phase === 1 && byFaction(g, 'fremen')?.id === entry.entrant)) &&
- TERRITORIES.some(
- (t) =>
- t.id === entry.territory &&
- t.type === 'stronghold' &&
- t.sectors.includes(entry.sector),
- ) &&
- owner?.faction === 'ecaz' &&
- beneficiary &&
- (beneficiary.id === owner.id ||
- (beneficiary.ally === owner.id && owner.ally === beneficiary.id)) &&
- g.players.some((p) => p.id === entry.entrant) &&
- g.ecazAmbassadors?.tokens.some(
- (token) =>
- token.id === entry.token &&
- ((token.effect === entry.effect && token.zone === 'used') ||
- (token.effect === 'beneGesserit' &&
- token.zone === 'removed' &&
- Array.isArray(entry.copyChoices) &&
- entry.copyChoices.includes(entry.effect!))),
- ) &&
- batch.cause === `ambassador:${entry.effect}` &&
- batch.entries.every(
- (c) => c.discardedBy === entry.beneficiary && !c.publicFace,
- ) &&
- (entry.effect !== 'ixians' || batch.entries.length === 1),
- 'The saved Ambassador discard continuation is inconsistent.',
- );
- } else if (continuation?.kind === 'terrorDiscard') {
- const { entry, owner: ownerId, source, discardedHandSize } = continuation;
- const owner = g.players.find((p) => p.id === ownerId);
- const entrant = g.players.find((p) => p.id === entry?.entrant);
- const tokens = g.moritaniTerror?.tokens.filter(
- (t) => t.id === entry?.token,
- );
- const sabotage = source === 'sabotage';
- const discarder = sabotage ? entrant : owner;
- requireRule(
- !g.pendingTerrorEntry &&
- !g.pendingExchange &&
- !g.summonedWorm &&
- entry &&
- entry.turn === g.turn &&
- entry.phase === g.phase &&
- ['sabotage', 'robberyOverflow'].includes(source) &&
- owner?.faction === 'moritani' &&
- entrant &&
- entrant.id !== owner.id &&
- TERRITORIES.some(
- (t) =>
- t.id === entry.territory &&
- t.type === 'stronghold' &&
- t.sectors.includes(entry.sector),
- ) &&
- [
- 'shipment',
- 'movement',
- 'guildTransport',
- 'advisor',
- 'wormRide',
- ].includes(entry.cause) &&
- ['none', 'wormRide'].includes(entry.resume) &&
- (entry.cause === 'wormRide'
- ? entry.resume === 'wormRide' &&
- g.phase === 1 &&
- entrant.faction === 'fremen'
- : entry.resume === 'none') &&
- Number.isSafeInteger(entry.amount) &&
- entry.amount >= 0 &&
- Number.isSafeInteger(entry.elite) &&
- entry.elite >= 0 &&
- entry.elite <= entry.amount &&
- tokens?.length === 1 &&
- tokens[0].kind === (sabotage ? 'sabotage' : 'robbery') &&
- tokens[0].status === 'removed' &&
- tokens[0].location === null &&
- entry.stage === (sabotage ? 'offer' : 'discard') &&
- batch.cause === `terror:${source}` &&
- batch.entries.length === 1 &&
- !batch.entries[0].publicFace &&
- discarder &&
- batch.entries[0].discardedBy === discarder?.id &&
- Number.isSafeInteger(discardedHandSize) &&
- discardedHandSize > 0 &&
- discardedHandSize === discarder.hand.length + 1 &&
- (sabotage || discardedHandSize > handLimit(owner)),
- 'The saved Terror discard does not match its consumed entry and hand.',
- );
- } else if (continuation?.kind === 'ixAllyCard') {
- const sale = g.currentAuctionSale;
- const buyer = g.players.find((p) => p.id === continuation.player);
- const ixians = byFaction(g, 'ixians');
- const lot = g.richeseAuction;
- requireRule(
- g.phase === 3 &&
- sale &&
- continuation.sale &&
- Number.isSafeInteger(sale.amount) &&
- sale.amount >= 0 &&
- typeof sale.free === 'boolean' &&
- ['normal', 'blackMarket'].includes(sale.origin) &&
- buyer &&
- ixians &&
- buyer.ally === ixians.id &&
- ixians.ally === buyer.id &&
- (['winner', 'amount', 'free', 'origin', 'seller'] as const).every(
- (key) => sale[key] === continuation.sale[key],
- ) &&
- continuation.player === sale.winner &&
- continuation.free === sale.free &&
- continuation.auctionIndex === (g.auction?.index ?? null) &&
- continuation.auctionEvent === (g.richeseAuction?.event ?? null) &&
- batch.cause === 'ixAllyCard' &&
- batch.entries.length === 1 &&
- batch.entries[0].card.id === continuation.card &&
- batch.entries[0].discardedBy === continuation.player &&
- batch.entries[0].publicFace === false &&
- (sale.origin === 'normal'
- ? sale.seller === null &&
- g.auction?.bidder === buyer.id &&
- g.auction.bid === sale.amount &&
- g.auction.cards[g.auction.index]?.id === continuation.card
- : !sale.free &&
- lot?.source === 'blackMarket' &&
- lot.owner === sale.seller &&
- g.players.some((p) => p.id === sale.seller && p.id !== buyer.id) &&
- lot.outcome?.kind === 'sold' &&
- lot.outcome.winner === buyer.id &&
- lot.outcome.amount === sale.amount &&
- lot.cardId === continuation.card),
- 'The saved Ixian replacement no longer matches its paid auction.',
- );
- } else if (
- continuation?.kind === 'battleResolved' ||
- continuation?.kind === 'battleCleanup'
- ) {
- const c = continuation;
- const context = g.lastBattleContext;
- requireRule(
- g.phase === 6 &&
- !g.battle &&
- context &&
- context.turn === g.turn &&
- typeof context.event === 'string' &&
- context.event.length > 0 &&
- c.event === context.event &&
- c.territory === context.territory &&
- JSON.stringify(c.combatants) === JSON.stringify(context.combatants) &&
- Array.isArray(c.combatants) &&
- c.combatants.length === 2 &&
- new Set(c.combatants).size === 2 &&
- JSON.stringify(c.combatants) === JSON.stringify(g.lastBattle) &&
- c.combatants.every((id) => g.players.some((p) => p.id === id)) &&
- gameTerritories(g).some((t) => t.id === c.territory) &&
- batch.entries.every(
- (e) => e.publicFace && c.combatants.includes(e.discardedBy),
- ),
- 'The saved battle discard does not match its resolved combatants.',
- );
- // Projection must validate the private Auditor owner as strictly as actions.
- auditorIntegrity(g);
- const loser = c.combatants.find((id) => id !== context.winner);
- requireRule(
- (!g.pendingAuditor ||
- (g.pendingAuditor.stage === 'offer' &&
- g.pendingAuditor.event === context.event &&
- g.pendingAuditor.territory === c.territory)) &&
- (!g.pendingCapture ||
- (g.advanced &&
- g.pendingCapture.player === context.winner &&
- g.pendingCapture.loser === loser &&
- g.pendingCapture.territory === c.territory &&
- g.players.find((p) => p.id === context.winner)?.faction ===
- 'harkonnen')) &&
- (!g.pendingTech ||
- (g.pendingTech.player === context.winner &&
- g.pendingTech.loser === loser &&
- g.pendingTech.choices.length > 0 &&
- new Set(g.pendingTech.choices).size ===
- g.pendingTech.choices.length &&
- g.pendingTech.choices.every((id) =>
- ownedTech(g.techTokens, loser!).includes(id),
- ))) &&
- (!g.pendingFaceDance ||
- (g.pendingFaceDance.winner === context.winner &&
- g.pendingFaceDance.territory === c.territory &&
- g.players.find((p) => p.id === g.pendingFaceDance!.player)
- ?.faction === 'tleilaxu')) &&
- (!g.pendingChoamBattleIncome ||
- (Number.isSafeInteger(g.pendingChoamBattleIncome.amount) &&
- g.pendingChoamBattleIncome.amount > 0 &&
- g.players.find((p) => p.id === g.pendingChoamBattleIncome!.owner)
- ?.faction === 'choam')),
- 'The saved battle discard has inconsistent remaining battle effects.',
- );
- const held = c.kind === 'battleResolved' ? c.cards : c.kept;
- const owner = c.kind === 'battleResolved' ? c.winner : c.player;
- requireRule(
- Array.isArray(held) &&
- new Set(held).size === held.length &&
- (owner === null ? held.length === 0 : c.combatants.includes(owner)) &&
- held.every(
- (id) =>
- typeof id === 'string' &&
- !ids.has(id) &&
- outside.filter((card) => card.id === id).length === 1 &&
- !g.discard.some((card) => card.id === id) &&
- g.players
- .find((p) => p.id === owner)
- ?.hand.some((card) => card.id === id),
- ),
- 'The cards kept for the battle continuation are inconsistent.',
- );
- if (c.kind === 'battleCleanup') {
- requireRule(
- ['winner', 'moritani'].includes(c.source) &&
- batch.cause === `battle:${c.source}` &&
- Array.isArray(c.played) &&
- new Set(c.played).size === c.played.length &&
- c.played.length === c.kept.length + batch.entries.length &&
- c.played.every(
- (id) =>
- typeof id === 'string' && (ids.has(id) || c.kept.includes(id)),
- ) &&
- (c.source === 'winner'
- ? c.player === context.winner
- : c.player !== context.winner) &&
- batch.entries.every((e) => e.discardedBy === c.player) &&
- (c.source !== 'moritani' ||
- (!g.moritaniRetention && c.kept.length <= 1)),
- 'The saved battle card cleanup is inconsistent.',
- );
- if (c.source === 'moritani') {
- const r = c.retention;
- const owner = g.players.find((p) => p.id === r?.owner);
- const loser = g.players.find((p) => p.id === c.player);
- requireRule(
- r &&
- r.turn === g.turn &&
- r.territory === c.territory &&
- r.player === c.player &&
- owner?.faction === 'moritani' &&
- owner.ally === c.player &&
- loser?.ally === owner.id &&
- ['choose', 'response'].includes(r.stage) &&
- JSON.stringify(r.played) === JSON.stringify(c.played) &&
- Array.isArray(r.eligible) &&
- new Set(r.eligible).size === r.eligible.length &&
- r.eligible.every((id) => r.played.includes(id)) &&
- c.kept.every((id) => r.eligible.includes(id)),
- 'The consumed Moritani retention record is inconsistent.',
- );
- }
- } else {
- requireRule(
- batch.cause === 'battle:mandatory' &&
- c.result === context.result &&
- c.winner === context.winner &&
- ['normal', 'traitor', 'mutualTraitors', 'explosion'].includes(
- c.result,
- ) &&
- (c.winner === null) ===
- ['mutualTraitors', 'explosion'].includes(c.result) &&
- !!c.casualties ===
- (c.result === 'normal' &&
- (g.advanced ||
- g.players.find((p) => p.id === c.winner)?.faction ===
- 'ixians')),
- 'The mandatory battle discard cause is inconsistent.',
- );
- if (c.casualties) {
- const { forces, dial, support, options } = c.casualties;
- const winner = g.players.find((p) => p.id === c.winner);
- const total = winner
- ? Object.entries(winner.forces).reduce(
- (n, [key, count]) =>
- n + (splitLocation(key).territory === c.territory ? count : 0),
- 0,
- )
- : 0;
- const elites = winner
- ? Object.entries(winner.elites?.forces ?? {}).reduce(
- (n, [key, count]) =>
- n + (splitLocation(key).territory === c.territory ? count : 0),
- 0,
- )
- : 0;
- requireRule(
- winner &&
- forces &&
- Number.isSafeInteger(forces.normal) &&
- forces.normal >= 0 &&
- Number.isSafeInteger(forces.elite) &&
- forces.elite >= 0 &&
- [1, 2].includes(forces.eliteStrength) &&
- typeof forces.freeSupport === 'boolean' &&
- (forces.normalFixedHalf === undefined ||
- typeof forces.normalFixedHalf === 'boolean') &&
- forces.normal === total - elites &&
- forces.elite === elites &&
- Array.isArray(options) &&
- options.length > 0 &&
- JSON.stringify(options) ===
- JSON.stringify(casualtyOptions(forces, dial, support)),
- 'The saved battle casualties do not match the committed dial, support and effective forces.',
- );
- }
- }
- } else throw new RuleError('Unknown saved discard continuation.');
-}
-function stageTreacheryDiscard(
- g: Game,
- cause: string,
- entries: FreshDiscardBatch['entries'],
- continuation: NonNullable['continuation'],
-) {
- requireRule(!g.pendingTreacheryDiscard, 'Finish the prior discard first.');
- treacheryDiscardIntegrity(g);
- const sequence = (g.treacheryDiscardSequence ?? 0) + 1;
- g.treacheryDiscardSequence = sequence;
- g.pendingTreacheryDiscard = {
- sequence,
- batch: {
- event: `discard:${g.turn}:${g.phase}:${sequence}`,
- turn: g.turn,
- phase: g.phase,
- cause,
- entries: structuredClone(entries),
- },
- continuation: structuredClone(continuation),
- };
- treacheryDiscardIntegrity(g);
-}
-/** No reaction policy is selected here. Semuta activation remains unavailable.
- * The existing exchanges resume automatically from their exact committed stage. */
-function finishTreacheryDiscard(g: Game) {
- treacheryDiscardIntegrity(g);
- const pending = g.pendingTreacheryDiscard;
- if (!pending) return;
- const next = pending.continuation;
- // Retire before any suffix can draw or create the next semantic discard.
- g.resolvedTreacheryDiscardSequence = pending.sequence;
- g.pendingTreacheryDiscard = null;
- if (next.kind === 'ambassador') {
- const p = getPlayer(g, next.entry.beneficiary!);
- if (next.entry.effect === 'ixians') {
- const card = draw(g);
- requireRule(card, 'No replacement card is available.');
- p.hand.push(card);
- log(
- g,
- `${p.name} discarded one Treachery card and drew its replacement through the Ixian Ambassador. The replacement remains private.`,
- { faction: p.faction, name: 'Ixian Ambassador' },
- );
- }
- // CHOAM's selected-count payout was already committed with its discard.
- g.pendingAmbassador = next.entry;
- finishAmbassador(g);
- } else if (next.kind === 'nullentropyDiscard') {
- g.response = next.resume.response;
- g.decision = next.resume.decision;
- g.pendingKarama = next.resume.pendingKarama;
- g.phaseOpening = next.resume.phaseOpening;
- } else if (next.kind === 'ordinaryCardDiscard') {
- g.response = next.resume.response;
- g.decision = next.resume.decision;
- g.pendingKarama = next.resume.pendingKarama;
- g.phaseOpening = next.resume.phaseOpening;
- } else if (next.kind === 'truthtranceDiscard') {
- g.response = next.resume.response;
- g.decision = next.resume.decision;
- g.pendingKarama = next.resume.pendingKarama;
- g.phaseOpening = next.resume.phaseOpening;
- g.truthtrance = next.remaining;
- } else if (next.kind === 'ornithopterDiscard') {
- g.response = next.resume.response;
- g.decision = next.resume.decision;
- g.pendingKarama = next.resume.pendingKarama;
- g.phaseOpening = next.resume.phaseOpening;
- if (next.source === 'move') finishMovedGroup(g, next.movement!);
- else finishMovementTurn(g, next.flight.player);
- } else if (next.kind === 'ixAllyCard') {
- const buyer = getPlayer(g, next.player);
- const card = draw(g);
- if (card) buyer.hand.push(card);
- log(
- g,
- `${buyer.name} discarded the purchased card and drew a replacement using the Ixian alliance.`,
- );
- continueAuctionSale(g, next.free);
- } else if (next.kind === 'battleResolved') {
- continueResolvedBattle(g, next);
- } else if (next.kind === 'terrorDiscard') {
- g.pendingTerrorEntry = next.entry;
- continueTerrorDiscard(g, next.source);
- } else {
- finishBattle(g);
- }
-}
-function place(p: Player, t: string, s: number, n: number, elite = 0) {
- const k = location(t, s);
- p.forces[k] = (p.forces[k] ?? 0) + n;
- if (p.elites && elite) p.elites.forces[k] = (p.elites.forces[k] ?? 0) + elite;
-}
-function kill(
- g: Game,
- p: Player,
- key: string,
- n: number,
- battle = false,
- elite?: number,
-) {
- const amount = Math.min(n, p.forces[key] ?? 0);
- if (p.elites) {
- const available = p.elites.forces[key] ?? 0;
- const lost =
- elite ?? Math.max(0, amount - ((p.forces[key] ?? 0) - available));
- requireRule(
- lost >= 0 &&
- lost <= available &&
- lost <= amount &&
- amount - lost <= (p.forces[key] ?? 0) - available,
- 'Invalid elite casualties.',
- );
- p.elites.forces[key] = available - lost;
- if (!p.elites.forces[key]) delete p.elites.forces[key];
- p.elites.tanks += lost;
- }
- p.forces[key] = (p.forces[key] ?? 0) - amount;
- if (!p.forces[key]) delete p.forces[key];
- p.tanks += amount;
- if (battle) p.battleLosses += amount;
-}
-function killTerritory(
- g: Game,
- p: Player,
- t: string,
- n = Infinity,
- battle = false,
- noFieldCause?: NoFieldRevealCause,
-) {
- if (p.noField?.deployed?.location.territory === t) {
- requireRule(
- noFieldCause,
- 'This removal of a concealed No-Field awaits its specific reveal and casualty rules.',
- );
- revealPlayerNoField(g, p, noFieldCause);
- }
- for (const key of Object.keys(p.forces)) {
- if (splitLocation(key).territory !== t) continue;
- const lost = Math.min(n, p.forces[key]);
- kill(g, p, key, lost, battle);
- n -= lost;
- }
-}
-function combatForces(
- g: Game,
- p: Player,
- t: string,
- opponent: Player,
-): CombatForces {
- const marker = p.noField?.deployed;
- const prospective =
- marker?.location.territory === t
- ? Math.min(
- p.reserves,
- p.noField!.tokens.find((token) => token.id === marker.tokenId)!.value,
- )
- : 0;
- const physical = Object.entries(p.forces).reduce(
- (n, [key, count]) => n + (splitLocation(key).territory === t ? count : 0),
- 0,
- );
- const elite = Object.entries(p.elites?.forces ?? {})
- .filter(([key]) => splitLocation(key).territory === t)
- .reduce((sum, [, n]) => sum + n, 0);
- return {
- normal: physical + prospective - elite,
- normalFixedHalf: p.faction === 'ixians',
- elite,
- eliteStrength:
- (g.battle?.eliteBlocked?.includes(p.id) &&
- !(g.advanced && p.faction === 'ixians')) ||
- (p.faction === 'emperor' && opponent.faction === 'fremen')
- ? 1
- : 2,
- freeSupport:
- !g.advanced ||
- (p.faction === 'fremen' && !g.battle?.fremenSupportBlocked),
- };
-}
-function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
- const lostCyborgs: Record = {};
- let normal = losses.normal,
- elite = losses.elite;
- for (const key of Object.keys(p.forces).filter(
- (key) => splitLocation(key).territory === t,
- )) {
- const e = Math.min(elite, p.elites?.forces[key] ?? 0);
- const n = Math.min(normal, p.forces[key] - (p.elites?.forces[key] ?? 0));
- kill(g, p, key, n + e, true, e);
- if (e) lostCyborgs[key] = e;
- normal -= n;
- elite -= e;
- }
- requireRule(
- normal === 0 && elite === 0,
- 'The selected casualties are not available.',
- );
- return lostCyborgs;
-}
-function start(g: Game) {
- requireRule(g.players.length >= 2, 'At least two players are needed.');
- requireRule(
- g.players.every((p) => p.ready),
- 'Every player must be ready.',
- );
- requireRule(!g.advanced, 'Advanced rules are still being implemented.');
- requireRule(
- g.players.every((p) => faction(p.faction).expansion === 'base'),
- 'Expansion factions are still being implemented.',
- );
- requireRule(
- g.expansions.length === 0,
- 'Expansion rules are still being implemented.',
- );
- initializeSetup(g);
-}
-function initializeSetup(g: Game) {
- const choam = byFaction(g, 'choam');
- if (choam && g.advanced && !choam.leaders.some(isAuditorLeader))
- choam.leaders.push(createAuditorLeader());
- if (g.strongholdCards) {
- requireRule(g.advanced, 'Stronghold Cards require Advanced rules.');
- g.strongholdCards = createStrongholdCards();
- }
- if (g.techTokens) {
- requireRule(
- g.players.length >= 3,
- 'Tech tokens currently require at least three players.',
- );
- g.techTokens = createTechTokens(g.players);
- }
- if (byFaction(g, 'richese')) {
- g.richeseCache = richeseCards();
- g.richeseRemoved = [];
- }
- g.playerPositions = normalizedPlayerPositions(g);
- g.storm = STORM_START_SECTOR;
- stormOrder(g);
- g.stormDialers = [g.order[0], g.order.at(-1)!];
- g.lastBattle = [...g.stormDialers];
- g.deck = shuffle(treacheryDeck(g.expansions));
- g.spiceDeck = shuffle(spiceDeck(g.expansions.includes('ix')));
- g.sandtrout = false;
- g.status = 'setup';
- g.setupStage = 'prediction';
- g.ready = [];
- log(
- g,
- byFaction(g, 'beneGesserit')
- ? 'Player positions are fixed. Bene Gesserit must lock its prediction before private cards are dealt.'
- : 'Player positions are fixed. Private traitor selection comes before force placement and starting Treachery Cards.',
- );
-}
-function initializeStartingForces(g: Game) {
- for (const p of g.players) {
- p.spice = faction(p.faction).spice;
- if (p.faction === 'richese') {
- p.noField = createRicheseNoField([
- crypto.randomUUID(),
- crypto.randomUUID(),
- crypto.randomUUID(),
- ]);
- p.noFieldEvent = crypto.randomUUID();
- }
- if (p.faction === 'moritani') g.moritaniTerror = createTerrorState(random);
- if (p.faction === 'ecaz') g.ecazAmbassadors = createAmbassadors(random);
- if (p.faction === 'moritani' || p.faction === 'ecaz')
- g.dukeVidal ??= createDukeVidal();
- delete p.elites;
- if (p.faction === 'ixians') {
- p.elites = { reserves: 4, tanks: 0, forces: {}, revived: 0 };
- place(p, MOBILE_STRONGHOLD, 0, 6, 3);
- p.reserves -= 6;
- g.mobileStronghold = { location: null };
- }
- if (g.advanced && ['emperor', 'fremen'].includes(p.faction))
- p.elites = {
- reserves: p.faction === 'emperor' ? 5 : 3,
- tanks: 0,
- forces: {},
- revived: 0,
- };
- const pos: Record = {
- atreides: ['arrakeen', 10, 10],
- harkonnen: ['carthag', 11, 10],
- guild: ['tueks_sietch', 5, 5],
- beneGesserit: ['polar_sink', 0, 1],
- };
- const t = pos[p.faction];
- if (t && !(g.advanced && p.faction === 'beneGesserit')) {
- place(p, ...t);
- p.reserves -= t[2];
- }
- }
-}
-function dealStartingTreachery(g: Game) {
- const ixians = byFaction(g, 'ixians');
- if (ixians) {
- g.ixSetupCards = [];
- for (const _player of g.players) {
- const card = draw(g);
- if (card) g.ixSetupCards.push(card);
- }
- g.decision = { kind: 'ixSetup', player: ixians.id };
- } else {
- for (const p of g.players) {
- const quantity = p.faction === 'harkonnen' ? 2 : 1;
- for (let i = 0; i < quantity; i++) {
- const card = draw(g);
- if (card) p.hand.push(card);
- }
- log(
- g,
- `${p.name} received ${quantity} starting Treachery Card${quantity === 1 ? '' : 's'} after force placement.`,
- { faction: p.faction, name: 'Starting cards' },
- );
- }
- }
- if (ixians)
- log(
- g,
- 'Starting force placement is complete. Ixians must choose their starting card before the remaining cards are dealt.',
- );
-}
-function setupPending(g: Game): string[] {
- if (g.status !== 'setup' || !g.setupStage) return [];
- if (g.setupStage === 'prediction') {
- const bg = byFaction(g, 'beneGesserit');
- return bg && !bg.prediction ? [bg.id] : [];
- }
- if (g.setupStage === 'traitors')
- return g.players
- .filter((p) => p.traitorChoices.length > 0)
- .map((p) => p.id);
- const fremen = byFaction(g, 'fremen');
- if (fremen?.reserves === 20) return [fremen.id];
- const bg = byFaction(g, 'beneGesserit');
- return g.advanced && bg && !bg.advisorSetup ? [bg.id] : [];
-}
-function advanceSetup(g: Game) {
- if (!g.setupStage || g.status !== 'setup') return;
- if (g.setupStage === 'prediction') {
- if (setupPending(g).length) return;
- const traitors = shuffle(
- traitorDeck(g.players, g.expansions.includes('ix')),
- );
- for (const p of g.players) {
- p.traitorChoices = p.faction === 'tleilaxu' ? [] : traitors.splice(0, 4);
- if (p.faction === 'harkonnen') {
- p.traitors = p.traitorChoices;
- p.traitorChoices = [];
- }
- }
- g.traitorReserve = traitors;
- g.setupStage = 'traitors';
- log(
- g,
- 'Traitor Cards were dealt privately. Choose traitors before starting force placement; Treachery Cards remain undealt.',
- );
- }
- if (g.setupStage === 'traitors') {
- if (setupPending(g).length) return;
- initializeStartingForces(g);
- g.setupStage = 'forces';
- log(
- g,
- 'Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement before receiving Treachery Cards.',
- );
- }
- if (g.setupStage === 'forces' && !setupPending(g).length) {
- delete g.setupStage;
- dealStartingTreachery(g);
- }
-}
-/** Offline-only test seam. No player action or room API dispatches this function. */
-export function initializeBaseGameForAudit(state: Game): Game {
- const g = structuredClone(state);
- requireRule(
- g.status === 'lobby' && !g.setupStage && g.turn === 1 && g.phase === 0,
- 'The audit initializer requires a fresh lobby.',
- );
- requireRule(
- g.players.length >= 2 &&
- g.players.length <= 6 &&
- new Set(g.players.map((p) => p.id)).size === g.players.length &&
- new Set(g.players.map((p) => p.faction)).size === g.players.length &&
- g.players.some((p) => p.id === g.host) &&
- g.players.every((p) => p.id && p.ready),
- 'The audit initializer requires two through six distinct ready players and an existing host.',
- );
- requireRule(
- g.expansions.length === 0 &&
- !g.techTokens &&
- !g.strongholdCards &&
- g.players.every((p) =>
- FACTIONS.some((f) => f.id === p.faction && f.expansion === 'base'),
- ),
- 'The audit initializer supports base factions without expansions or optional modules.',
- );
- requireRule(
- !g.deck.length &&
- !g.discard.length &&
- !g.spiceDeck.length &&
- !g.spiceDiscard.some((pile) => pile.length) &&
- !g.traitorReserve?.length &&
- !g.decision &&
- !g.response &&
- !g.battle &&
- !g.winner.length &&
- g.players.every(
- (p) =>
- !p.hand.length &&
- !p.traitors.length &&
- !p.traitorChoices.length &&
- Object.keys(p.forces).length === 0 &&
- p.reserves === 20 &&
- p.tanks === 0 &&
- p.spice === 0 &&
- p.moved === 0 &&
- !p.shipped &&
- !p.prediction &&
- !p.advisorSetup &&
- !p.elites,
- ),
- 'The audit initializer cannot redeal or replace existing game pieces.',
- );
- requireRule(
- Object.keys(g.playerPositions ?? {}).every((id) =>
- g.players.some((p) => p.id === id),
- ),
- 'Player circles must belong to seated players.',
- );
- normalizedPlayerPositions(g);
- initializeSetup(g);
- return normalizeAutomaticGame(g);
-}
-function otherSetupComplete(g: Game) {
- return (
- !g.setupStage &&
- !g.ixSetupCards &&
- g.players.every(
- (p) =>
- (p.faction === 'tleilaxu' || p.traitors.length > 0) &&
- (p.faction !== 'fremen' || p.reserves === 10) &&
- (p.faction !== 'beneGesserit' ||
- (p.prediction && (!g.advanced || p.advisorSetup))),
- )
- );
-}
-function setupComplete(g: Game) {
- return (
- otherSetupComplete(g) &&
- !g.players.some((p) => p.faction === 'moritani' && p.reserves === 20)
- );
-}
-function finishMoritaniPlacement(g: Game) {
- if (!byFaction(g, 'choam')) victory(g);
-}
-function finishSetup(g: Game) {
- const tleilaxu = byFaction(g, 'tleilaxu');
- if (tleilaxu) {
- const held = new Set(g.players.flatMap((player) => player.traitors));
- g.traitorReserve = shuffle(
- traitorDeck(g.players, g.expansions.includes('ix')).filter(
- (card) => !held.has(card),
- ),
- );
- tleilaxu.faceDancers = g.traitorReserve
- .splice(0, 3)
- .map((leader) => ({ leader, revealed: false }));
- }
- g.status = 'playing';
- log(g, 'Setup complete. The first storm awaits two secret dials.');
- openPhase(g, false);
-}
-
-/** Pure legacy/default projection. Existing occupied circles never move on join. */
-function normalizedPlayerPositions(g: Game): Record {
- const positions: Record = {};
- const occupied = new Set();
- for (const p of g.players) {
- const position = g.playerPositions?.[p.id];
- if (position === undefined) continue;
- requireRule(
- Number.isInteger(position) &&
- position >= 1 &&
- position <= 6 &&
- !occupied.has(position),
- 'Player circles must be distinct positions from 1 to 6.',
- );
- positions[p.id] = position;
- occupied.add(position);
- }
- for (const p of g.players) {
- if (positions[p.id] !== undefined) continue;
- const position = [1, 2, 3, 4, 5, 6].find(
- (candidate) => !occupied.has(candidate),
- );
- requireRule(position !== undefined, 'There are only six player circles.');
- positions[p.id] = position;
- occupied.add(position);
- }
- return positions;
-}
-function stormOrder(g: Game) {
- g.playerPositions = normalizedPlayerPositions(g);
- // A marker already under the storm has been reached; the next occupied
- // marker ahead counterclockwise goes first. Empty circles are skipped.
- const distance = (id: string) =>
- (PLAYER_CIRCLE_SECTORS[g.playerPositions![id] - 1] - g.storm + 18) % 18 ||
- 18;
- g.order = g.players
- .map((p) => p.id)
- .sort((a, b) => distance(a) - distance(b));
-}
-function stormExposed(g: Game, key: string) {
- const t = territory(splitLocation(key).territory);
- return (
- (t.type === 'sand' && t.id !== 'imperial_basin') ||
- (g.shieldWallDestroyed &&
- ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id))
- );
-}
-function moveStorm(g: Game, n: number) {
- g.stormResolution = { from: g.storm, distance: n, traversed: 0, pending: [] };
- offerChoamStorm(g);
-}
-function choamStormOptions(g: Game) {
- const choam = byFaction(g, 'choam'),
- resolution = g.stormResolution;
- if (!choam || !resolution || resolution.traversed !== 0) return [];
- const crossed = new Set(
- Array.from(
- { length: resolution.distance },
- (_, i) => ((resolution.from + i) % 18) + 1,
- ),
- );
- return gameTerritories(g)
- .filter((t) => !resolution.choamProtected?.includes(t.id))
- .flatMap((t) => {
- const forces = Object.entries(choam.forces).filter(
- ([key, n]) =>
- n > 0 &&
- splitLocation(key).territory === t.id &&
- stormExposed(g, key) &&
- crossed.has(splitLocation(key).sector),
- );
- return forces.length
- ? [
- {
- territory: t.id,
- amount: forces.reduce((sum, [, n]) => sum + n, 0),
- sectors: forces.map(([key]) => splitLocation(key).sector),
- },
- ]
- : [];
- });
-}
-function offerChoamStorm(g: Game) {
- const choam = byFaction(g, 'choam'),
- territories = choamStormOptions(g);
- if (choam && territories.length) {
- g.decision = {
- kind: 'choamStorm',
- player: choam.id,
- territories,
- protected: g.stormResolution!.choamProtected ?? [],
- };
- } else beginStormProtection(g);
-}
-function beginStormProtection(g: Game) {
- const n = g.stormResolution!.distance;
- const fremen = byFaction(g, 'fremen');
- const crossed = new Set(
- Array.from({ length: n }, (_, i) => ((g.storm + i) % 18) + 1),
- );
- if (
- g.advanced &&
- fremen &&
- Object.keys(fremen.forces).some(
- (key) => stormExposed(g, key) && crossed.has(splitLocation(key).sector),
- )
- ) {
- g.response = {
- kind: 'stormProtection',
- owner: fremen.id,
- passed: [],
- resume: 'storm',
- };
- } else continueStorm(g);
-}
-function stormCasualties(
- g: Game,
- p: Player,
- key: string,
- count: number,
- elite: number,
- resume: 'storm' | 'shipment',
-) {
- const amount = Math.ceil(count / 2),
- minElite = Math.max(0, amount - (count - elite)),
- maxElite = Math.min(amount, elite);
- if (minElite < maxElite)
- g.decision = {
- kind: 'stormLosses',
- player: p.id,
- key,
- amount,
- minElite,
- maxElite,
- resume,
- };
- else kill(g, p, key, amount, false, minElite);
-}
-function continueStorm(g: Game) {
- disasterPreflight(() => validateStormTraversal(g));
- normalizedPlayerPositions(g);
- const resolution = g.stormResolution!;
- const fremen = byFaction(g, 'fremen');
- while (true) {
- while (resolution.pending.length) {
- const key = resolution.pending.shift()!;
- if (fremen && (fremen.forces[key] ?? 0) > 0) {
- stormCasualties(
- g,
- fremen,
- key,
- fremen.forces[key],
- fremen.elites?.forces[key] ?? 0,
- 'storm',
- );
- if (g.decision) return;
- }
- }
- if (resolution.traversed === resolution.distance) break;
- resolution.traversed++;
- const s = ((resolution.from - 1 + resolution.traversed) % 18) + 1;
- if (g.shieldWallDestroyed)
- for (const city of ['arrakeen', 'carthag'])
- if (territory(city).sectors.includes(s))
- returnAmbassadorsIn(g, city, 'the storm crossed the exposed city');
- for (const p of g.players) {
- const marker = p.noField?.deployed?.location;
- if (marker && marker.sector === s) {
- const t = territory(marker.territory);
- if (
- (t.type === 'sand' && t.id !== 'imperial_basin') ||
- (g.shieldWallDestroyed &&
- ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id))
- )
- revealPlayerNoField(g, p, 'storm');
- }
- for (const key of Object.keys(p.forces)) {
- const loc = splitLocation(key),
- t = territory(loc.territory);
- const exposed =
- (t.type === 'sand' && t.id !== 'imperial_basin') ||
- (g.shieldWallDestroyed &&
- ['arrakeen', 'carthag', 'imperial_basin'].includes(t.id));
- if (loc.sector === s && exposed) {
- if (
- p.faction === 'choam' &&
- resolution.choamProtected?.includes(t.id)
- )
- continue;
- if (
- g.advanced &&
- p.faction === 'fremen' &&
- resolution.protected !== false
- )
- resolution.pending.push(key);
- else kill(g, p, key, p.forces[key]);
- }
- }
- }
- for (const key of Object.keys(g.spice))
- if (splitLocation(key).sector === s) delete g.spice[key];
- }
- g.storm = ((resolution.from - 1 + resolution.distance) % 18) + 1;
- stormOrder(g);
- if (g.turn === 1) assignRemainingTech(g);
- log(g, `Storm moved ${resolution.distance} sectors to sector ${g.storm}.`);
- g.stormDials = {};
- g.stormPending = null;
- g.stormResolution = null;
- const ixians = byFaction(g, 'ixians');
- if (ixians && g.mobileStronghold && !g.mobileStronghold.location) {
- g.decision = {
- kind: 'mobileStronghold',
- player: ixians.id,
- placement: true,
- };
- return;
- }
- finishStorm(g);
-}
-function finishStorm(g: Game) {
- if (!openChoamMarket(g, 'storm')) advanceAfterStorm(g);
-}
-function advanceAfterStorm(g: Game) {
- const fremen = byFaction(g, 'fremen');
- g.phase = 1;
- g.ready = [];
- if (g.advanced && fremen) {
- g.stormCard = shuffle([1, 2, 3, 4, 5, 6])[0];
- g.stormCardKnown = false;
- g.response = { kind: 'stormPeek', owner: fremen.id, passed: [] };
- }
- openPhase(g, false);
-}
-function refillSpice(g: Game) {
- if (!g.spiceDeck.length) {
- g.spiceDeck = shuffle(
- g.spiceDiscard
- .flat()
- .filter((c) => !('worm' in c && c.thumper))
- .map((c) => ('worm' in c ? { worm: true as const } : c)),
- );
- g.spiceDiscard = [[], []];
- }
-}
-function drawSpice(g: Game) {
- refillSpice(g);
- return g.spiceDeck.shift();
-}
-function devour(
- g: Game,
- t: string,
- protectedAlly?: string,
- protectFremen = true,
-) {
- disasterPreflight(() =>
- validateWormDevouring(g, t, protectedAlly, protectFremen),
- );
- for (const p of g.players)
- if ((!protectFremen || p.faction !== 'fremen') && p.id !== protectedAlly)
- killTerritory(g, p, t, Infinity, false, 'worm');
- for (const k of Object.keys(g.spice))
- if (splitLocation(k).territory === t) delete g.spice[k];
- const fremen = byFaction(g, 'fremen');
- if (fremen && at(fremen, t)) g.wormRides.push(t);
- log(g, `Shai-Hulud appeared in ${territory(t).name}.`);
-}
-function beginWorm(g: Game, t: string) {
- const fremen = byFaction(g, 'fremen');
- if (fremen?.ally && at(getPlayer(g, fremen.ally), t)) {
- g.decision = {
- kind: 'wormProtection',
- player: fremen.id,
- territory: t,
- ally: fremen.ally,
- };
- } else wormSurvival(g, t);
-}
-function wormSurvival(g: Game, t: string, protectedAlly?: string) {
- const fremen = byFaction(g, 'fremen');
- if (fremen && at(fremen, t)) {
- g.response = {
- kind: 'wormSurvival',
- owner: fremen.id,
- location: t,
- recipient: protectedAlly,
- passed: [],
- };
- } else {
- devour(g, t, protectedAlly);
- afterWorm(g);
- }
-}
-function afterWorm(g: Game) {
- const pending = g.summonedWorm;
- if (!pending) {
- continueSpice(g);
- return;
- }
- const rides = g.wormRides;
- Object.assign(g, pending.resume);
- g.summonedWorm = null;
- g.wormRides = [...g.wormRides, ...rides];
- g.ready = [];
- if (g.spiceWindow?.territory === pending.territory) {
- g.spiceWindow.harvested = true;
- g.spiceWindow.harvesterClosed = true;
- }
- const beforeBlow =
- !g.spiceSequence &&
- !g.spiceResolution &&
- !g.spiceWindow &&
- !pending.resume.nexus;
- if (beforeBlow) {
- g.summonedBeforeBlow = true;
- g.nexus = false;
- } else g.nexus = true;
- // A summon during an already active ride returns that unmade ride to the
- // queue and opens negotiations before it. An intrusion already caused by a
- // completed ride resolves before opening the newly triggered Nexus.
- if (g.decision?.kind === 'wormRide') {
- g.wormRides.unshift(g.decision.territory);
- g.decision = null;
- } else if (
- g.pendingTerrorEntry?.resume === 'wormRide' ||
- (g.pendingKarama?.use.kind === 'cancel' &&
- g.pendingKarama.use.response.kind === 'advisorFlip' &&
- g.pendingKarama.use.response.advisorResume === 'wormRide') ||
- (g.decision?.kind === 'intrusion' && g.decision.wormRide) ||
- (g.response?.kind === 'advisorFlip' &&
- g.response.advisorResume === 'wormRide')
- )
- g.summonedNexusBeforeRides = true;
- log(
- g,
- 'The summoned worm has resolved. Its Nexus occurs after the spice blow.',
- );
-}
-function blowSpice(g: Game, thumper = false) {
- g.nexus = !!g.summonedBeforeBlow;
- if (!g.summonedBeforeBlow) g.wormRides = [];
- g.summonedBeforeBlow = false;
- g.spiceResolution = { skipped: [] };
- g.spiceSequence = { pile: 0, skipped: [] };
- continueSpice(g, thumper ? { worm: true, thumper: true } : undefined);
-}
-function continueSpice(g: Game, injected?: SpiceCard) {
- g.spiceSequence ??= {
- pile: 0,
- skipped: [...(g.spiceResolution?.skipped ?? [])],
- };
- const sequence = g.spiceSequence;
- let previous = g.spiceDiscard[sequence.pile].findLast(
- (c) => !('sandtrout' in c) && !('worm' in c && c.suppressed),
- );
- let doubleNext = false;
- let card = injected ?? drawSpice(g);
- while (card && !('territory' in card)) {
- if ('sandtrout' in card) {
- g.sandtrout = true;
- for (const p of g.players) p.ally = null;
- for (const [donor, credit] of Object.entries(g.aid))
- getPlayer(g, donor).spice += credit.amount;
- g.aid = {};
- g.allianceOffers = {};
- log(
- g,
- 'Sandtrout broke all alliances and waits for the next Shai-Hulud.',
- );
- doubleNext = false;
- card = drawSpice(g);
- continue;
- }
- doubleNext = false;
- if (g.turn === 1) {
- if (!card.thumper) sequence.skipped.push(card);
- } else if (g.sandtrout) {
- g.sandtrout = false;
- g.spiceDiscard[sequence.pile].push(
- { sandtrout: true },
- { ...card, suppressed: true },
- );
- doubleNext = true;
- log(
- g,
- 'Sandtrout suppressed Shai-Hulud. Only an immediate territory replacement receives double spice.',
- );
- } else {
- g.nexus = true;
- g.spiceDiscard[sequence.pile].push(card);
- if (previous && 'worm' in previous && g.advanced) {
- const fremen = byFaction(g, 'fremen');
- if (fremen && g.wormPlacementCanceledTurn !== g.turn) {
- g.decision = { kind: 'wormPlacement', player: fremen.id };
- return;
- }
- }
- if (previous && 'territory' in previous) {
- beginWorm(g, previous.territory);
- return;
- }
- previous = card;
- }
- card = drawSpice(g);
- }
- if (card && 'territory' in card) {
- g.spiceDiscard[sequence.pile].push(card);
- const amount = card.amount * (doubleNext ? 2 : 1);
- g.spiceWindow = { ...card, amount, harvested: false };
- if (card.sector !== g.storm) {
- const k = location(card.territory, card.sector);
- g.spice[k] = (g.spice[k] ?? 0) + amount;
- log(g, `${amount} spice appeared in ${territory(card.territory).name}.`);
- } else log(g, 'The spice blow was lost to the storm.');
- }
- g.spiceResolution = null;
- g.ready = [];
- if (g.spiceWindow) return;
- finishSpiceWindow(g);
-}
-function finishSpiceWindow(g: Game) {
- g.spiceWindow = null;
- if (g.nexus) {
- g.ready = [];
- log(g, 'Nexus: alliances may be formed or broken.');
- } else finishSpicePass(g);
-}
-function finishSpicePass(g: Game) {
- if (g.advanced && g.spiceSequence?.pile === 0) {
- g.spiceSequence.pile = 1;
- g.spiceResolution = { skipped: [] };
- g.ready = [];
- continueSpice(g);
- } else {
- if (g.spiceSequence?.skipped.length)
- g.spiceDeck = shuffle([...g.spiceDeck, ...g.spiceSequence.skipped]);
- g.spiceSequence = null;
- nextPhase(g);
- }
-}
-function nextWormRide(g: Game) {
- if (g.summonedNexusBeforeRides) {
- g.summonedNexusBeforeRides = false;
- g.nexus = true;
- g.ready = [];
- return;
- }
- const fremen = byFaction(g, 'fremen');
- while (g.wormRides.length) {
- const t = g.wormRides.shift()!;
- if (
- fremen &&
- Object.entries(fremen.forces).some(
- ([key, count]) =>
- count > 0 &&
- splitLocation(key).territory === t &&
- splitLocation(key).sector !== g.storm,
- )
- ) {
- g.decision = { kind: 'wormRide', player: fremen.id, territory: t };
- return;
- }
- }
- g.nexus = false;
- finishSpicePass(g);
-}
-function auctionEligible(g: Game) {
- return g.order.filter(
- (id) => getPlayer(g, id).hand.length < handLimit(getPlayer(g, id)),
- );
-}
-function richeseDecision(
- g: Game,
- kind:
- | 'richeseBlackMarket'
- | 'richeseDeclaration'
- | 'richeseCache'
- | 'richeseUnbid',
-) {
- const round = g.richeseBidding!;
- round.event = crypto.randomUUID();
- g.decision = { kind, player: round.owner };
- g.active = round.owner;
-}
-function beginRicheseBidding(g: Game) {
- const owner = byFaction(g, 'richese')!;
- requireRule(
- g.richeseCache,
- 'This saved Richese game needs its verified cache inventory.',
- );
- g.richeseBidding = {
- owner: owner.id,
- event: '',
- turn: g.turn,
- stage: g.advanced && owner.hand.length ? 'blackMarketOffer' : 'declaration',
- position: null,
- normalCount: null,
- blackMarketSold: false,
- cacheCanceled: false,
- };
- g.auction = null;
- g.richeseAuction = null;
- g.richeseFunding = {};
- if (g.richeseBidding.stage === 'blackMarketOffer')
- richeseDecision(g, 'richeseBlackMarket');
- else richeseDeclaration(g);
-}
-function richeseDeclaration(g: Game) {
- richeseDeclarationCache(g);
- const round = g.richeseBidding!;
- round.stage = 'declaration';
- round.normalCount = Math.max(
- 0,
- auctionEligible(g).length - 1 - Number(round.blackMarketSold),
- );
- richeseDecision(g, 'richeseDeclaration');
-}
-function prepareRicheseNormal(g: Game) {
- g.richeseBidding!.stage = 'normal';
- setAuction(g);
-}
-function offerRicheseCache(g: Game) {
- g.richeseBidding!.stage = 'cacheOffer';
- richeseDecision(g, 'richeseCache');
-}
-function finishNormalBidding(g: Game) {
- const round = g.richeseBidding;
- if (round?.turn === g.turn && round.stage === 'normal') {
- if (round.position === 'last' && !round.cacheCanceled) {
- offerRicheseCache(g);
- return;
- }
- round.stage = 'complete';
- }
- nextPhase(g);
-}
-function richeseLotCard(g: Game): Card | undefined {
- const lot = g.richeseAuction;
- if (!lot) return undefined;
- const cards =
- lot.source === 'cache' ? g.richeseCache : getPlayer(g, lot.owner).hand;
- return cards?.find((card) => card.id === lot.cardId);
-}
-function richeseDeclarationCache(g: Game) {
- try {
- requireRicheseDeclarationCache(g.richeseCache?.length);
- } catch (error) {
- if (error instanceof RicheseSettlementError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function richeseSettlementQuote(
- g: Game,
- lot = g.richeseAuction!,
- spendingOwner?: string,
-) {
- const owner = getPlayer(g, lot.owner);
- const winner =
- lot.outcome?.kind === 'sold' ? getPlayer(g, lot.outcome.winner) : null;
- const seat = (p: Player) => ({
- id: p.id,
- handCount: p.hand.length - Number(p.id === spendingOwner),
- handLimit: handLimit(p),
- });
- try {
- return quoteRicheseSettlement({
- lot,
- owner: seat(owner),
- winner: winner ? { ...seat(winner), spice: winner.spice } : null,
- card: richeseLotCard(g),
- funding: winner ? g.richeseFunding?.[winner.id] : null,
- allyCredit: winner ? aidFor(g, winner) : null,
- });
- } catch (error) {
- if (error instanceof RicheseSettlementError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function finishRicheseLot(g: Game) {
- const lot = g.richeseAuction!;
- const round = g.richeseBidding!;
- if (lot.source === 'blackMarket') {
- richeseDeclarationCache(g);
- round.blackMarketSold = lot.outcome?.kind === 'sold';
- if (lot.method === 'normal') {
- const opener = lot.order[0];
- round.opener = (g.order.indexOf(opener) + 1) % g.order.length;
- }
- }
- g.richeseAuction = null;
- g.richeseFunding = {};
- g.richesePeekKnown = false;
- g.richeseClaim = null;
- g.richeseOfferedCard = null;
- g.currentAuctionSale = null;
- if (lot.source === 'blackMarket') richeseDeclaration(g);
- else if (round.position === 'first') prepareRicheseNormal(g);
- else {
- round.stage = 'complete';
- nextPhase(g);
- }
-}
-function settleRicheseLot(g: Game) {
- const lot = g.richeseAuction!;
- const quote = richeseSettlementQuote(g);
- if (quote.kind === 'open') return;
- const owner = getPlayer(g, lot.owner);
- if (quote.kind !== 'sold') {
- g.richeseFunding = {};
- if (quote.kind === 'retainBlackMarket') {
- log(
- g,
- `${owner.name} retained the unbid Black Market card. No sale occurred, so it does not reduce the normal auction pool.`,
- );
- finishRicheseLot(g);
- } else if (quote.kind === 'removeCache') {
- const card = quote.card;
- g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id);
- (g.richeseRemoved ??= []).push(card);
- log(
- g,
- `${owner.name} removed ${card.name} from the game after no spice was bid; a full hand prevents keeping it.`,
- { faction: 'richese', name: 'Unbid cache card' },
- );
- finishRicheseLot(g);
- } else richeseDecision(g, 'richeseUnbid');
- return;
- }
- const winner = getPlayer(g, quote.winner),
- card = quote.card;
- payWithAlly(g, winner, quote.amount, quote.allyPayment);
- g.richeseFunding = {};
- if (lot.source === 'cache')
- g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id);
- else owner.hand = owner.hand.filter((c) => c.id !== card.id);
- winner.hand.push(card);
- g.currentAuctionSale = {
- winner: winner.id,
- amount: quote.amount,
- free: false,
- origin: lot.source,
- seller: owner.id,
- };
- log(
- g,
- `${winner.name} bought ${lot.source === 'cache' ? card.name : 'the concealed Black Market card'} for ${quote.amount} spice. The funded bid was paid automatically.`,
- { faction: winner.faction, name: 'Auction payment' },
- );
- const ixians = byFaction(g, 'ixians');
- if (lot.source === 'blackMarket' && ixians && winner.ally === ixians.id) {
- g.pendingIxAlly = { player: winner.id, card: card.id, free: false };
- g.decision = { kind: 'ixAllyCard', player: winner.id };
- } else continueAuctionSale(g, false);
-}
-function richeseOfferBlock(g: Game): string | null {
- const ixians = byFaction(g, 'ixians');
- return g.advanced && ixians?.hand.length && g.ixTechnologyTurn !== g.turn
- ? 'Ixian Technology on Richese lots is awaiting a ruling on replacement-card custody.'
- : null;
-}
-function beginRicheseLot(
- g: Game,
- source: 'cache' | 'blackMarket',
- action: Action,
-) {
- const round = g.richeseBidding!,
- owner = getPlayer(g, round.owner);
- const card = (source === 'cache' ? g.richeseCache : owner.hand)?.find(
- (c) => c.id === action.card,
- );
- requireRule(card, 'Choose an available card from the correct collection.');
- requireRule(
- typeof action.method === 'string' &&
- ['normal', 'onceAround', 'silent'].includes(action.method),
- 'Choose an auction method.',
- );
- requireRule(
- source !== 'cache' || action.method !== 'normal',
- 'A Richese cache lot uses Once Around or Silent.',
- );
- requireRule(
- action.direction === undefined ||
- (typeof action.direction === 'string' &&
- ['clockwise', 'counterclockwise'].includes(action.direction)),
- 'Choose a physical bidding direction.',
- );
- requireRule(
- action.method !== 'onceAround' || action.direction !== undefined,
- 'Choose a physical bidding direction.',
- );
- const blocked = richeseOfferBlock(g);
- requireRule(!blocked, blocked ?? 'This Richese lot is unavailable.');
- const positions = normalizedPlayerPositions(g);
- let order = [...g.order];
- if (action.method === 'onceAround') {
- order.sort((a, b) => positions[a] - positions[b]);
- if (action.direction === 'clockwise') order.reverse();
- const at = order.indexOf(owner.id);
- order = [...order.slice(at + 1), ...order.slice(0, at + 1)];
- }
- const lot = createRicheseAuction({
- event: crypto.randomUUID(),
- cardId: card.id,
- source,
- owner: owner.id,
- method: action.method as RicheseAuction['method'],
- eligible: auctionEligible(g),
- order,
- tieOrder: g.order,
- });
- g.richeseAuction = lot;
- g.richeseOfferedCard = structuredClone(card);
- g.richeseFunding = {};
- g.richesePeekKnown = false;
- g.richeseClaim =
- source === 'blackMarket' && typeof action.claim === 'string'
- ? action.claim.trim().slice(0, 300)
- : null;
- round.stage = 'lot';
- g.active = lot.active;
- log(
- g,
- source === 'cache'
- ? `${owner.name} revealed ${card.name} for a ${lot.method === 'silent' ? 'Silent' : 'Once Around'} cache auction.`
- : `${owner.name} offered a concealed hand card on the Black Market using ${lot.method === 'normal' ? 'normal bidding' : lot.method === 'silent' ? 'Silent bidding' : 'Once Around bidding'}.${g.richeseClaim ? ` Seller’s unverified claim: “${g.richeseClaim}”` : ''}`,
- );
- if (source === 'blackMarket')
- g.response = { kind: 'richeseBlackMarket', owner: owner.id, passed: [] };
- else settleRicheseLot(g);
-}
-function decideRichese(g: Game, p: Player, decision: Decision, action: Action) {
- const round = g.richeseBidding;
- requireRule(
- g.phase === 3 &&
- round &&
- round.owner === p.id &&
- action.event === round.event,
- 'This Richese bidding decision has expired.',
- );
- if (decision.kind === 'richeseBlackMarket') {
- requireRule(
- round.stage === 'blackMarketOffer' && g.advanced,
- 'Black Market is not available now.',
- );
- if (action.decline === true) richeseDeclaration(g);
- else beginRicheseLot(g, 'blackMarket', action);
- } else if (decision.kind === 'richeseDeclaration') {
- requireRule(
- round.stage === 'declaration' &&
- typeof action.position === 'string' &&
- ['first', 'last'].includes(action.position),
- 'Announce whether the cache auction will be first or last.',
- );
- round.position = action.position as 'first' | 'last';
- log(
- g,
- `${p.name} announced the Richese cache auction ${round.position}, before normal pool preparation. ${round.normalCount} normal lots are scheduled after the cache reduction${round.blackMarketSold ? ' and completed Black Market sale' : ''}.`,
- );
- g.response = { kind: 'richeseAuction', owner: p.id, passed: [] };
- } else if (decision.kind === 'richeseCache') {
- requireRule(
- round.stage === 'cacheOffer',
- 'The cache offer is not available now.',
- );
- beginRicheseLot(g, 'cache', action);
- } else if (decision.kind === 'richeseUnbid') {
- requireRule(
- g.richeseAuction?.source === 'cache' &&
- g.richeseAuction.outcome?.kind === 'unbid',
- 'There is no unbid cache card to resolve.',
- );
- requireRule(
- typeof action.keep === 'boolean',
- 'Keep the card or remove it from the game.',
- );
- const card = richeseLotCard(g)!;
- requireRule(
- !action.keep || p.hand.length < handLimit(p),
- 'There is no room in your hand.',
- );
- g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id);
- if (action.keep) p.hand.push(card);
- else (g.richeseRemoved ??= []).push(card);
- log(
- g,
- `${p.name} ${action.keep ? 'kept' : 'permanently removed'} ${card.name} after all bids were zero. No purchase bonus applies.`,
- );
- finishRicheseLot(g);
- }
-}
-function setAuction(g: Game, cards?: Card[]) {
- const richese = byFaction(g, 'richese');
- if (!cards && richese && g.richeseBidding?.turn !== g.turn) {
- beginRicheseBidding(g);
- return;
- }
- const eligible = g.order.filter((id) => {
- const p = getPlayer(g, id);
- return p.hand.length < handLimit(p);
- });
- if (!eligible.length) {
- if (cards?.length) g.deck = [...cards, ...g.deck];
- g.auction = null;
- finishNormalBidding(g);
- return;
- }
- const count =
- g.richeseBidding?.turn === g.turn
- ? (g.richeseBidding.normalCount ?? eligible.length)
- : eligible.length;
- const ixians = byFaction(g, 'ixians');
- if (!cards && ixians && eligible.length && count) {
- g.auction = null;
- g.active = null;
- g.ixAuction = { count, cards: [] };
- g.ixAuctionKnown = null;
- g.response = { kind: 'ixAuction', owner: ixians.id, passed: [] };
- return;
- }
- if (!cards) {
- cards = [];
- for (let i = 0; i < count; i++) {
- const c = draw(g);
- if (c) cards.push(c);
- }
- }
- if (!eligible.length || !cards.length) {
- g.auction = null;
- finishNormalBidding(g);
- return;
- }
- const start = g.richeseBidding?.opener ?? 0;
- const opener = [...g.order.slice(start), ...g.order.slice(0, start)].find(
- (id) => eligible.includes(id),
- )!;
- g.auction = {
- cards,
- index: 0,
- bid: 0,
- bidder: null,
- active: opener,
- passed: [],
- opener: g.order.indexOf(opener),
- };
- g.active = opener;
- offerAuctionTechnology(g);
-}
-function offerAuctionTechnology(g: Game) {
- g.auction!.peekKnown = false;
- const ixians = byFaction(g, 'ixians');
- if (
- g.advanced &&
- ixians &&
- ixians.hand.length &&
- g.ixTechnologyTurn !== g.turn
- )
- g.decision = { kind: 'ixTechnology', player: ixians.id };
- else offerAuctionPeek(g);
-}
-function auctionTechnologyQuote(run: () => T): T {
- try {
- return run();
- } catch (error) {
- if (error instanceof IxTechnologyCancellationError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function offerAuctionPeek(g: Game) {
- const quote = auctionTechnologyQuote(() => quoteNormalAuctionPeek(g));
- g.auction!.peekKnown = quote.peekKnown;
- g.response = quote.response;
-}
-function aidFor(g: Game, p: Player) {
- return p.ally && g.aid[p.ally]?.recipient === p.id
- ? g.aid[p.ally]
- : undefined;
-}
-function battleAidFor(g: Game, p: Player) {
- const donor = p.ally
- ? g.players.find((other) => other.id === p.ally)
- : undefined;
- return g.advanced &&
- g.phase === 6 &&
- donor?.faction === 'choam' &&
- donor.ally === p.id &&
- !g.battle?.choamAidBlocked
- ? aidFor(g, p)
- : undefined;
-}
-function strongholdEffect(g: Game, player: string): StrongholdId | null {
- return g.advanced && g.battle
- ? strongholdBenefit(
- g.strongholdCards,
- player,
- g.battle.territory,
- g.battle.strongholdCopy,
- )
- : null;
-}
-function battleSupportCost(g: Game, p: Player, support: number) {
- return strongholdSupportCost(strongholdEffect(g, p.id), support);
-}
-function battleSupportBudget(g: Game, p: Player) {
- return (
- p.spice +
- (battleAidFor(g, p)?.amount ?? 0) +
- (strongholdEffect(g, p.id) === 'arrakeen' ? 2 : 0)
- );
-}
-function battleTieWinner(g: Game) {
- const b = g.battle!;
- return strongholdEffect(g, b.defender) === 'habbanya_ridge_sietch'
- ? b.defender
- : b.attacker;
-}
-function strongholdCopyChoices(g: Game, player: string): StrongholdId[] {
- const controllers = strongholdControllers(
- g.players,
- !!g.mobileStronghold?.location,
- );
- return STRONGHOLD_CARDS.filter(
- (card) => card.id !== MOBILE_STRONGHOLD && controllers[card.id] === player,
- ).map((card) => card.id);
-}
-function beginBattlePowers(g: Game) {
- const b = g.battle!;
- const choam = byFaction(g, 'choam');
- if (
- g.advanced &&
- choam?.ally &&
- [b.attacker, b.defender].includes(choam.ally) &&
- getPlayer(g, choam.ally).ally === choam.id
- )
- g.decision = { kind: 'choamBattleFunding', player: choam.id };
- else {
- battlePreparation(g, 'voice');
- combatResponses(g);
- }
-}
-function beginStrongholdBattle(g: Game) {
- const b = g.battle!;
- const owner = g.strongholdCards?.owners.hidden_mobile_stronghold;
- if (
- g.advanced &&
- b.territory === MOBILE_STRONGHOLD &&
- owner &&
- [b.attacker, b.defender].includes(owner)
- ) {
- const choices = strongholdCopyChoices(g, owner);
- if (choices.length > 1) {
- g.decision = {
- kind: 'strongholdCopy',
- player: owner,
- event: b.event!,
- choices,
- };
- return;
- }
- if (choices.length === 1) {
- b.strongholdCopy = choices[0];
- log(
- g,
- `${getPlayer(g, owner).name}'s mobile Stronghold Card copies ${STRONGHOLD_CARDS.find((card) => card.id === choices[0])!.name}, their only other controlled stronghold.`,
- { faction: getPlayer(g, owner).faction, name: 'Stronghold advantage' },
- );
- }
- }
- beginBattlePowers(g);
-}
-function strongholdIntegrity(g: Game) {
- if (!g.strongholdCards) {
- requireRule(
- !g.battle?.strongholdCopy && g.decision?.kind !== 'strongholdCopy',
- 'A copied Stronghold advantage requires its module.',
- );
- return;
- }
- const state = g.strongholdCards;
- requireRule(
- g.advanced &&
- Number.isSafeInteger(state.claimedTurn) &&
- state.claimedTurn >= 0 &&
- state.claimedTurn <= g.turn &&
- !!state.owners &&
- Object.keys(state.owners).length === STRONGHOLD_CARDS.length &&
- STRONGHOLD_CARDS.every(
- (card) =>
- state.owners[card.id] === null ||
- g.players.some((p) => p.id === state.owners[card.id]),
- ),
- 'The saved Advanced Stronghold Card ownership is invalid.',
- );
- if (g.decision?.kind === 'strongholdCopy') {
- const decision = g.decision,
- b = g.battle;
- const choices = strongholdCopyChoices(g, decision.player);
- requireRule(
- g.status === 'playing' &&
- g.phase === 6 &&
- !!b &&
- b.territory === MOBILE_STRONGHOLD &&
- decision.event === b.event &&
- state.owners.hidden_mobile_stronghold === decision.player &&
- [b.attacker, b.defender].includes(decision.player) &&
- !b.strongholdCopy &&
- Object.keys(b.plans).length === 0 &&
- Array.isArray(decision.choices) &&
- choices.length > 1 &&
- decision.choices.length === choices.length &&
- new Set(decision.choices).size === choices.length &&
- choices.every((choice) => decision.choices.includes(choice)),
- 'The saved Stronghold choice must match this battle and current control.',
- );
- }
- if (g.battle?.strongholdCopy) {
- const b = g.battle,
- owner = state.owners.hidden_mobile_stronghold;
- requireRule(
- !!b.event &&
- b.territory === MOBILE_STRONGHOLD &&
- !!owner &&
- [b.attacker, b.defender].includes(owner) &&
- b.strongholdCopy !== MOBILE_STRONGHOLD &&
- STRONGHOLD_CARDS.some((card) => card.id === b.strongholdCopy),
- 'The saved copied Stronghold advantage does not belong to this battle.',
- );
- }
-}
-function settleStrongholdOwnership(g: Game) {
- if (!g.strongholdCards || g.strongholdCards.claimedTurn === g.turn) return;
- requireRule(
- g.advanced && g.phase === 8,
- 'Stronghold Cards settle at the end of an Advanced turn.',
- );
- settleAdvisors(g);
- const before = g.strongholdCards;
- g.strongholdCards = settleStrongholdCards(
- before,
- g.turn,
- strongholdControllers(g.players, !!g.mobileStronghold?.location),
- );
- for (const card of STRONGHOLD_CARDS) {
- const owner = g.strongholdCards.owners[card.id];
- if (owner === before.owners[card.id]) continue;
- log(
- g,
- owner
- ? `${getPlayer(g, owner).name} claimed the ${card.name} Stronghold Card for the next turn.`
- : `${card.name} has no controlling faction; its Stronghold Card was set aside.`,
- owner
- ? { faction: getPlayer(g, owner).faction, name: 'Stronghold control' }
- : undefined,
- );
- }
-}
-function pledgeAid(g: Game, p: Player, requested: unknown) {
- requireRule(
- p.ally && getPlayer(g, p.ally).ally === p.id,
- 'Choose a current mutual ally.',
- );
- const current = g.aid[p.id]?.amount ?? 0;
- requireRule(
- !(
- g.richeseAuction?.method === 'silent' &&
- !g.richeseAuction.outcome &&
- Object.hasOwn(g.richeseAuction.sealed, p.ally) &&
- typeof requested === 'number' &&
- requested < current
- ),
- 'Ally funding cannot be reduced until the submitted Silent bids are revealed.',
- );
- const committed =
- g.phase === 6
- ? (g.battle?.plans[p.ally]?.allyPayment ?? 0)
- : g.auction?.bidder === p.ally
- ? (g.auction.allyPayment ?? 0)
- : 0;
- const amount = integer(
- requested,
- g.richeseAuction?.method === 'silent' &&
- !g.richeseAuction.outcome &&
- Object.hasOwn(g.richeseAuction.sealed, p.ally)
- ? current
- : Math.max(
- committed,
- richeseAllyCommitment(g.richeseAuction, g.richeseFunding ?? {}, p.id),
- ),
- uncommittedSpice(g, p) + current,
- 'Ally funding',
- );
- p.spice += current - amount;
- g.aid[p.id] = { recipient: p.ally, amount };
- log(g, `${p.name} updated the spice available to their ally this phase.`);
-}
-function uncommittedSpice(g: Game, p: Player) {
- return (
- p.spice -
- richeseOwnCommitment(g.richeseAuction, g.richeseFunding ?? {}, p.id) -
- (g.auction?.bidder === p.id
- ? g.auction.bid - (g.auction.allyPayment ?? 0)
- : 0) -
- (battleSupportCost(g, p, g.battle?.plans[p.id]?.support ?? 0) -
- (g.battle?.plans[p.id]?.allyPayment ?? 0))
- );
-}
-function contribution(g: Game, p: Player, cost: number, requested?: unknown) {
- const available = aidFor(g, p)?.amount ?? 0;
- const { minimum, maximum } = shipmentPaymentBounds(cost, p.spice, available);
- return integer(requested ?? minimum, minimum, maximum, 'Ally payment');
-}
-function payWithAlly(g: Game, p: Player, cost: number, allyPayment: number) {
- const credit = aidFor(g, p);
- requireRule(
- p.spice >= cost - allyPayment &&
- (!allyPayment || (credit && credit.amount >= allyPayment)),
- 'The payment is no longer funded.',
- );
- p.spice -= cost - allyPayment;
- if (credit) credit.amount -= allyPayment;
-}
-function auctionNext(g: Game) {
- const a = g.auction!;
- const eligible = g.order.filter((id) => {
- const p = getPlayer(g, id);
- return p.hand.length < handLimit(p);
- });
- const others = eligible.filter((id) => id !== a.bidder);
- if (others.every((id) => a.passed.includes(id))) {
- if (!a.bidder) {
- g.deck = [...a.cards.slice(a.index), ...g.deck];
- g.auction = null;
- finishNormalBidding(g);
- return;
- }
- g.decision = { kind: 'auctionPayment', player: a.bidder };
- g.active = a.bidder;
- recoverAuctionPayment(g, getPlayer(g, a.bidder));
- return;
- }
- let i = g.order.indexOf(a.active);
- do {
- i = (i + 1) % g.order.length;
- } while (!eligible.includes(g.order[i]) || g.order[i] === a.bidder);
- a.active = g.order[i];
- g.active = a.active;
-}
-function settleAuction(g: Game, free = false, automatic = false) {
- const a = g.auction!;
- const winner = getPlayer(g, a.bidder!);
- if (!free) payWithAlly(g, winner, a.bid, a.allyPayment ?? 0);
- winner.hand.push(a.cards[a.index]);
- g.currentAuctionSale = {
- winner: winner.id,
- amount: a.bid,
- free,
- origin: 'normal',
- seller: null,
- };
- log(
- g,
- `${faction(winner.faction).name} won a treachery card for ${free ? 'a Karama' : `${a.bid} spice`}.${automatic ? ' The declared spice payment was the only available payment method.' : ''}`,
- automatic
- ? { faction: winner.faction, name: 'Auction payment' }
- : undefined,
- );
- const ixians = byFaction(g, 'ixians');
- if (ixians && winner.ally === ixians.id) {
- g.pendingIxAlly = { player: winner.id, card: a.cards[a.index].id, free };
- g.decision = { kind: 'ixAllyCard', player: winner.id };
- } else continueAuctionSale(g, free);
-}
-function continueAuctionSale(g: Game, free: boolean) {
- const sale = g.currentAuctionSale;
- const winner = getPlayer(g, sale?.winner ?? g.auction!.bidder!);
- if (sale && sale.origin !== 'normal' && sale.seller !== winner.id) {
- const seller = getPlayer(g, sale.seller!);
- seller.spice += sale.amount;
- log(
- g,
- `${seller.name} collected ${sale.amount} spice from the ${sale.origin === 'cache' ? 'Richese cache' : 'Black Market'} sale.`,
- { faction: 'richese', name: 'Auction income' },
- );
- auctionBonus(g);
- return;
- }
- const emperor = byFaction(g, 'emperor');
- if (!free && emperor && emperor.id !== winner.id)
- g.response = { kind: 'emperorIncome', owner: emperor.id, passed: [] };
- else auctionBonus(g);
-}
-function auctionBonus(g: Game) {
- const winner = getPlayer(
- g,
- g.currentAuctionSale?.winner ?? g.auction!.bidder!,
- );
- if (winner.faction === 'harkonnen' && winner.hand.length < 8)
- g.response = { kind: 'harkonnenBonus', owner: winner.id, passed: [] };
- else nextAuction(g);
-}
-function nextAuction(g: Game) {
- if (
- g.currentAuctionSale?.origin !== 'normal' &&
- g.richeseAuction?.outcome?.kind === 'sold'
- ) {
- finishRicheseLot(g);
- return;
- }
- g.currentAuctionSale = null;
- const a = g.auction!;
- a.index++;
- const able = g.order.filter(
- (id) => getPlayer(g, id).hand.length < handLimit(getPlayer(g, id)),
- );
- if (a.index === a.cards.length || !able.length) {
- g.deck = [...a.cards.slice(a.index), ...g.deck];
- g.auction = null;
- finishNormalBidding(g);
- return;
- }
- do {
- a.opener = (a.opener + 1) % g.order.length;
- } while (!able.includes(g.order[a.opener]));
- a.bid = 0;
- a.allyPayment = 0;
- a.bidder = null;
- a.passed = [];
- a.active = g.order[a.opener];
- g.active = a.active;
- offerAuctionTechnology(g);
- return;
-}
-/** Physical custody only: promise feasibility remains conservatively checked at commit. */
-function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null {
- if (g.battle?.lateDefense?.[p.id] === card.id)
- return 'This Portable Snooper is already played and reserved for battle cleanup.';
- if (giftReserved(g, p.id, card.id))
- return 'This card is reserved for the pending Richese gift.';
- if (
- !p.hand.some((held) => held.id === card.id) ||
- !canUseAsKarama(g.advanced, p.faction, card)
- )
- return 'Choose a Karama in your hand.';
- if (retentionReservesCard(g, p.id, card.id))
- return 'A card awaiting Moritani alliance battle cleanup cannot be spent as Karama.';
- const plan = g.battle?.plans[p.id];
- if (plan && [plan.weapon, plan.defense, plan.leader].includes(card.id))
- return 'A sealed battle card cannot be spent as Karama.';
- const insight = g.battle?.prescience;
- if (insight && insight.player !== p.id && insight.value === card.id)
- return 'The card committed to prescience must remain available.';
- if (card.kind === 'worthless' && g.pendingKarama)
- return 'A Worthless conversion is already pending.';
- return null;
-}
-function karamaCard(g: Game, p: Player, id?: unknown) {
- // Explicit selections retain the specific custody error from spendKarama.
- const available = p.hand.filter(
- (c) =>
- canUseAsKarama(g.advanced, p.faction, c) &&
- (id === undefined ? !karamaSpendingBlock(g, p, c) : c.id === id),
- );
- return available.find((c) => c.effect === 'karama') ?? available[0];
-}
-function responseCancelCards(
- g: Game,
- p: Player,
- response: ResponseWindow,
-): string[] {
- if (p.id === response.owner) return [];
- // Do not simulate cancellation here: it can execute RNG and continuations.
- // A physically spendable card whose Truthtrance feasibility is not proven
- // remains a possible choice; the authoritative action still checks promises.
- return p.hand
- .filter((card) => !karamaSpendingBlock(g, p, card))
- .map((card) => card.id);
-}
-/** The same conversion can be suspended by an independent private interaction. */
-function savedKaramaContexts(g: Game) {
- const continuation = g.pendingTreacheryDiscard?.continuation;
- return [
- {
- pendingKarama: g.pendingKarama,
- response: g.pendingExchange?.response ?? g.response,
- },
- g.pendingRicheseGift?.resume,
- g.pendingNullentropy?.resume,
- g.pendingRichesePurchaseIncome?.resume,
- g.summonedWorm?.resume,
- continuation && 'resume' in continuation ? continuation.resume : null,
- ].flatMap((context) =>
- context?.pendingKarama
- ? [
- {
- pending: context.pendingKarama,
- response: context.response ?? g.pendingExchange?.response,
- },
- ]
- : [],
- );
-}
-function savedKaramaConversions(g: Game) {
- return savedKaramaContexts(g).map((context) => context.pending);
-}
-function karamaCanceledSource(g: Game, response: ResponseWindow) {
- try {
- return canceledResponseSource(g, response);
- } catch (error) {
- if (error instanceof KaramaContextError) throw new RuleError(error.message);
- throw error;
- }
-}
-/** Locate a conversion behind the controls saved before a summoned worm.
- * Only explicit parent links count; unrelated live overlays are not parents. */
-function controlsContainKarama(
- g: Game,
- controls:
- | Pick
- | null
- | undefined,
- pending: NonNullable,
- seen = new Set(),
-): boolean {
- if (!controls || seen.has(controls)) return false;
- seen.add(controls);
- if (controls.pendingKarama === pending) return true;
- const parent =
- controls.response?.kind === 'richeseGift'
- ? g.pendingRicheseGift?.resume
- : controls.response?.kind === 'richesePurchaseIncome'
- ? g.pendingRichesePurchaseIncome?.resume
- : controls.decision?.kind === 'nullentropy'
- ? g.pendingNullentropy?.resume
- : controls.decision?.kind === 'handExchange'
- ? g.pendingExchange
- : null;
- return controlsContainKarama(g, parent, pending, seen);
-}
-function karamaSourceGame(
- g: Game,
- pending: NonNullable,
-) {
- const worm = g.summonedWorm;
- return worm && controlsContainKarama(g, worm.resume, pending)
- ? { ...g, ...worm.resume, summonedWorm: null }
- : g;
-}
-/** Bind the original opportunity, not hands, balances, response passes or seat control. */
-function karamaOpportunitySignature(
- g: Game,
- pending: NonNullable,
-) {
- g = karamaSourceGame(g, pending);
- const use = pending.use;
- requireRule(
- use &&
- ['cancel', 'shipment', 'purchase', 'auctionPayment'].includes(use.kind),
- 'The saved Karama conversion does not match its original opportunity.',
- );
- const opportunity =
- use.kind === 'cancel'
- ? karamaCanceledSource(g, use.response)
- : use.kind === 'shipment'
- ? {
- active: g.active,
- recipient: use.recipient,
- shipped: getPlayer(g, use.recipient).shipped,
- rate: g.karamaShipping ?? null,
- movementRemaining: g.movementRemaining ?? null,
- }
- : {
- auction: g.auction,
- order: g.order,
- richeseAuction: g.richeseAuction ?? null,
- sale: g.currentAuctionSale ?? null,
- };
- return JSON.stringify({
- owner: pending.owner,
- use,
- status: g.status,
- advanced: g.advanced,
- turn: g.turn,
- phase: g.phase,
- opportunity,
- });
-}
-function karamaConversionIntegrity(g: Game) {
- for (const { pending, response } of savedKaramaContexts(g)) {
- if (pending.opportunity === undefined) continue;
- requireRule(
- pending.opportunity &&
- pending.opportunity.kind ===
- (pending.use?.kind === 'cancel'
- ? 'cancel'
- : pending.use?.kind === 'shipment'
- ? 'shipment'
- : 'auction') &&
- typeof pending.opportunity.signature === 'string' &&
- response?.kind === 'worthlessKarama' &&
- response.owner === pending.owner &&
- g.advanced &&
- getPlayer(g, pending.owner).faction === 'beneGesserit' &&
- pending.opportunity.signature ===
- karamaOpportunitySignature(g, pending),
- 'The saved Karama conversion no longer matches its original opportunity.',
- );
- if (pending.opportunity.kind === 'auction')
- normalKaramaAuction(
- g,
- getPlayer(g, pending.owner),
- pending.use.kind === 'auctionPayment',
- );
- }
-}
-/** Only live ownership zones; knowledge receipts and old auction prefixes are aliases. */
-function physicalTreacheryCards(g: Game) {
- return [
- ...g.players.flatMap((player) => player.hand),
- ...g.deck,
- ...g.discard,
- ...(g.richeseCache ?? []),
- ...(g.richeseRemoved ?? []),
- ...(g.ixSetupCards ?? []),
- ...(g.ixAuction?.cards ?? []),
- ...(g.ornithopter ? [g.ornithopter.card] : []),
- ...(g.auction?.cards.slice(g.auction.index) ?? []),
- ];
-}
-/** Pure normal-lot prerequisites; free payment deliberately does not require spice. */
-function normalKaramaAuction(
- g: Game,
- p: Player,
- payment: boolean,
- spendingCard = false,
-) {
- const a = g.auction;
- const seated = (id: string) => g.players.some((player) => player.id === id);
- requireRule(
- g.status === 'playing' &&
- g.phase === 3 &&
- a &&
- !g.richeseAuction &&
- !g.currentAuctionSale &&
- Array.isArray(a.cards) &&
- Number.isSafeInteger(a.index) &&
- a.index >= 0 &&
- a.index < a.cards.length &&
- a.cards[a.index]?.id &&
- Number.isSafeInteger(a.bid) &&
- a.bid >= 0 &&
- (a.bidder === null || seated(a.bidder)) &&
- seated(a.active) &&
- g.order.length > 0 &&
- new Set(g.order).size === g.order.length &&
- g.order.every(seated) &&
- Number.isSafeInteger(a.opener) &&
- a.opener >= 0 &&
- a.opener < g.order.length,
- 'This Karama purchase needs its original unresolved normal auction lot.',
- );
- requireRule(
- !payment || a.bidder === p.id,
- 'Only the original winning bidder may choose the auction payment.',
- );
- requireRule(
- p.hand.length - Number(payment && spendingCard) < handLimit(p),
- 'Leave room for the committed Karama auction purchase.',
- );
- const card = a.cards[a.index];
- const zones = physicalTreacheryCards(g);
- requireRule(
- zones.filter((candidate) => candidate.id === card.id).length === 1,
- 'The reserved normal auction card has conflicting physical custody.',
- );
-}
-/** Preflight known pre-effect rejection paths without executing a response or drawing. */
-function revivalCancellationQuote(g: Game, response: ResponseWindow) {
- if (
- ![
- 'choamRevival',
- 'revivalLimit',
- 'revivalDiscount',
- 'earlyRevival',
- 'foreignGhola',
- ].includes(response.kind)
- )
- return null;
- try {
- return quoteRevivalCancellation(g, response);
- } catch (error) {
- if (error instanceof RevivalCancellationError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function movementCancellationQuote(g: Game, response: ResponseWindow) {
- try {
- return quoteMovementCancellation(g, response);
- } catch (error) {
- if (error instanceof MovementCancellationError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function currentIxAuctionDrawQuote(
- g: Game,
- response: ResponseWindow,
- canceled: boolean,
- spendingCard?: Card,
-) {
- try {
- return quoteIxAuctionDraw(
- { ...g, physicalCards: physicalTreacheryCards(g) },
- response,
- canceled,
- spendingCard,
- );
- } catch (error) {
- if (error instanceof IxAuctionDrawError) throw new RuleError(error.message);
- throw error;
- }
-}
-function ixSubstitutionCancellationQuote(
- g: Game,
- response: ResponseWindow,
- spending?: { player: string; card: string },
-) {
- if (response.kind !== 'ixSubstitution') return null;
- try {
- const quote = quoteIxSubstitutionCancellation(
- {
- ...g,
- territories: gameTerritories(g),
- physicalCards: physicalTreacheryCards(g),
- },
- response,
- )!;
- if (!quote.decision) currentBattleAftermathQuote(g, undefined, spending);
- return quote;
- } catch (error) {
- if (error instanceof IxSubstitutionCancellationError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function choamSaleCancellationQuote(g: Game, response: ResponseWindow) {
- try {
- return quoteChoamSaleCancellation(g, response);
- } catch (error) {
- if (error instanceof ChoamSaleCancellationError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function noFieldCancellationQuote(g: Game, response: ResponseWindow) {
- if (response.kind !== 'richeseNoField') return null;
- try {
- return quoteNoFieldCancellation(g, response);
- } catch (error) {
- if (error instanceof NoFieldCancellationError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function terminalCancellationQuote(g: Game, response: ResponseWindow) {
- try {
- return validateTerminalCancellation(g, response);
- } catch (error) {
- if (error instanceof TerminalCancellationError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function disasterPreflight(run: () => void) {
- try {
- run();
- } catch (error) {
- if (error instanceof DisasterPreflightError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function validateDisasterCancellation(g: Game, response: ResponseWindow) {
- if (response.kind === 'stormProtection') {
- disasterPreflight(() => validateStormCancellation(g, response));
- if (response.resume === 'storm') normalizedPlayerPositions(g);
- } else if (
- ['wormPlacement', 'wormAllyProtection', 'wormSurvival'].includes(
- response.kind,
- )
- )
- disasterPreflight(() => validateWormCancellation(g, response));
-}
-function validateKaramaUse(
- g: Game,
- p: Player,
- use: KaramaUse,
- spendingCard?: Card,
-) {
- if (use.kind === 'cancel') {
- requireRule(
- use.response && use.response.owner !== p.id,
- 'Karama cancels another faction’s power.',
- );
- karamaCanceledSource(g, use.response);
- currentRicheseCancellationQuote(g, use.response);
- auctionTechnologyQuote(() =>
- quoteIxTechnologyCancellation(g, use.response),
- );
- if (use.response.kind === 'ixAuction')
- currentIxAuctionDrawQuote(g, use.response, true, spendingCard);
- moritaniAllianceCancellationQuote(g, use.response);
- if (isCombatResponseKind(use.response.kind))
- currentCombatResponseQuote(g, {
- kind: 'response',
- response: { kind: use.response.kind, owner: use.response.owner },
- canceled: true,
- });
- requireRule(
- use.response.kind !== 'richeseAuction',
- 'The canceled Richese auction count is awaiting an official ruling or an explicit table interpretation.',
- );
- if (
- use.response.kind === 'richeseBlackMarket' ||
- (use.response.kind === 'atreidesAuction' && g.richeseAuction)
- ) {
- const lot = g.richeseAuction,
- round = g.richeseBidding;
- requireRule(
- g.phase === 3 &&
- lot &&
- round &&
- round.turn === g.turn &&
- round.stage === 'lot' &&
- round.owner === lot.owner &&
- (use.response.kind === 'richeseBlackMarket'
- ? lot.source === 'blackMarket' && lot.owner === use.response.owner
- : getPlayer(g, use.response.owner).faction === 'atreides'),
- 'This canceled power needs its original Richese auction opportunity.',
- );
- const target =
- use.response.kind === 'richeseBlackMarket'
- ? { ...lot, outcome: { kind: 'unbid' as const } }
- : lot;
- const quote = richeseSettlementQuote(
- g,
- target,
- spendingCard ? p.id : undefined,
- );
- if (lot.source === 'blackMarket' && quote.kind !== 'open')
- richeseDeclarationCache(g);
- }
- validateDisasterCancellation(g, use.response);
- revivalCancellationQuote(g, use.response);
- movementCancellationQuote(g, use.response);
- choamSaleCancellationQuote(g, use.response);
- noFieldCancellationQuote(g, use.response);
- terminalCancellationQuote(g, use.response);
- ixSubstitutionCancellationQuote(
- g,
- use.response,
- spendingCard ? { player: p.id, card: spendingCard.id } : undefined,
- );
- if (use.response.kind === 'harkonnenTraitor') {
- const b = g.battle,
- owner = getPlayer(g, use.response.owner);
- requireRule(
- g.phase === 6 &&
- b?.revealed &&
- owner.faction === 'harkonnen' &&
- ![b.attacker, b.defender].includes(owner.id) &&
- traitorVoters(g, b).includes(owner.id) &&
- b.traitorCalls[owner.id] === true,
- 'The canceled allied traitor call needs its original revealed battle.',
- );
- if (traitorVoters(g, b).every((id) => b.traitorCalls[id] !== undefined))
- currentBattleResolutionQuote(g, owner.id);
- }
- if (use.response.kind === 'moritaniRetention')
- validateMoritaniCancellation(g, use.response.owner);
- validateAftermathCancellation(
- g,
- use.response,
- spendingCard ? { player: p.id, card: spendingCard.id } : undefined,
- );
- if (use.response.kind === 'choamWorthless') {
- const pending = g.pendingChoamWorthless;
- requireRule(
- pending &&
- pending.owner === use.response.owner &&
- getPlayer(g, pending.owner).faction === 'choam',
- 'This CHOAM Worthless response is no longer current.',
- );
- if (pending.movement && g.pendingChoamMove) {
- const move = g.pendingChoamMove;
- let valid = true;
- try {
- validateMovementOrder(g, getPlayer(g, move.player), move);
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- valid = false; // Cancellation still accepts an order that now fizzles.
- }
- if (valid) validateMovementArrival(g, move, true);
- }
- }
- // Canceling a Worthless conversion restores its old use; it does not execute
- // that use. In particular, a legacy unsupported count attempt is cancelable.
- } else if (use.kind === 'shipment') {
- const recipient = getPlayer(g, use.recipient);
- requireRule(
- g.status === 'playing' &&
- g.phase === 5 &&
- g.active === recipient.id &&
- !recipient.shipped &&
- !g.karamaShipping,
- 'Choose the active player before their unused Karama shipment.',
- );
- } else
- normalKaramaAuction(g, p, use.kind === 'auctionPayment', !!spendingCard);
-}
-/** Owner-promise proof only. A false result means this suffix remains outside
- * this bounded preview and must retain its existing atomic action boundary. */
-function assertKaramaPromiseFeasibility(
- g: Game,
- p: Player,
- use: KaramaUse,
- costCard?: Card,
-): boolean {
- const battlePromises =
- g.battle && !g.battle.revealed && !g.battle.plans[p.id]
- ? (g.battle.truthPromises ?? []).filter(
- (promise) => promise.player === p.id && !promise.released,
- )
- : [];
- const shipmentPromises = liveShipmentPromises(
- g.shipmentPromises ?? [],
- p.id,
- g.turn,
- );
- if (!battlePromises.length && !shipmentPromises.length) return true;
- const projected = structuredClone(g),
- owner = getPlayer(projected, p.id);
- if (costCard)
- owner.hand = owner.hand.filter((card) => card.id !== costCard.id);
- if (use.kind === 'shipment') {
- projected.karamaShipping = {
- player: use.recipient,
- owner: p.id,
- card: costCard?.id ?? use.card,
- };
- } else if (use.kind === 'cancel') {
- const b = projected.battle;
- switch (use.response.kind) {
- case 'voice':
- if (!b) return false;
- delete b.voice;
- break;
- case 'prescience':
- if (!b) return false;
- delete b.prescience;
- delete b.preparation;
- break;
- case 'eliteStrength':
- if (!b) return false;
- (b.eliteBlocked ??= []).push(use.response.owner);
- break;
- case 'fremenSupport':
- if (!b) return false;
- b.fremenSupportBlocked = true;
- break;
- case 'kwisatz':
- if (!b) return false;
- b.kwisatzBlocked = true;
- break;
- case 'choamBattleAid':
- if (!b) return false;
- b.choamAidBlocked = true;
- break;
- case 'emperorGift':
- // Cancellation transfers no spice and changes no owner's plan inputs.
- break;
- default:
- return false;
- }
- } else return false;
- // The intended result is after this response, not a replay of its suffix.
- projected.response = null;
- projected.pendingKarama = null;
- if (battlePromises.length)
- requireRule(
- !!findReachableBattlePlan(projected, owner, { promises: battlePromises }),
- 'You cannot voluntarily make your Truthtrance battle promise impossible.',
- );
- if (shipmentPromises.length)
- requireRule(
- !!findShipmentCompletion(projected, owner, shipmentPromises),
- 'You cannot voluntarily make your Truthtrance shipment answer impossible.',
- );
- return true;
-}
-function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) {
- const blocked = karamaSpendingBlock(g, p, card);
- requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.');
- validateKaramaUse(g, p, use, card);
- assertKaramaPromiseFeasibility(g, p, use, card);
- discard(g, p, card.id);
- if (use.kind === 'shipment') use = { ...use, card: card.id };
- if (card.kind === 'worthless') {
- requireRule(!g.pendingKarama, 'A Worthless conversion is already pending.');
- g.pendingKarama = { owner: p.id, use };
- g.pendingKarama.opportunity = {
- kind:
- use.kind === 'cancel'
- ? 'cancel'
- : use.kind === 'shipment'
- ? 'shipment'
- : 'auction',
- signature: karamaOpportunitySignature(g, g.pendingKarama),
- };
- g.response = {
- kind: 'worthlessKarama',
- owner: p.id,
- passed: [],
- intent:
- use.kind === 'cancel'
- ? `Cancel a power used by ${getPlayer(g, use.response.owner).name}.`
- : use.kind === 'shipment'
- ? `Give ${getPlayer(g, use.recipient).name} Guild shipment rates paid to the bank.`
- : use.kind === 'purchase'
- ? 'Take the current auction card without paying spice.'
- : 'Pay the winning auction bid without spending spice.',
- };
- log(
- g,
- `${p.name} used ${card.name} as Karama; the faction power awaits responses.`,
- );
- } else completeKarama(g, p, use);
-}
-function completeKarama(g: Game, p: Player, use: KaramaUse) {
- validateKaramaUse(g, p, use);
- if (use.kind === 'cancel') {
- g.response = use.response;
- finishResponse(g, true);
- log(g, `${p.name} used Karama to cancel ${use.response.kind}.`);
- } else if (use.kind === 'shipment') {
- g.karamaShipping = { player: use.recipient, owner: p.id, card: use.card };
- log(
- g,
- `${p.name} used Karama for ${getPlayer(g, use.recipient).name}’s shipment at Guild rates, paid to the bank.`,
- );
- } else {
- if (use.kind === 'purchase') g.auction!.bidder = p.id;
- settleAuction(g, true);
- }
-}
-function auctionSpicePaymentFunded(g: Game, p: Player) {
- const a = g.auction;
- if (!a || a.bidder !== p.id) return false;
- const allyPayment = a.allyPayment ?? 0;
- return (
- Number.isInteger(a.bid) &&
- a.bid >= 0 &&
- Number.isInteger(allyPayment) &&
- allyPayment >= 0 &&
- allyPayment <= a.bid &&
- p.spice >= a.bid - allyPayment &&
- (!allyPayment || (aidFor(g, p)?.amount ?? 0) >= allyPayment)
- );
-}
-function recordFullPlanInspection(g: Game, owner: string) {
- const p = getPlayer(g, owner);
- log(
- g,
- `${p.name} can now inspect the committed battle plan privately. The plan remains available until both plans are revealed.`,
- { faction: p.faction, name: 'Full plan inspection' },
- );
-}
-function finishAutomaticDecision(g: Game): boolean {
- const decision = g.decision;
- const market = g.choamMarket;
- if (
- decision?.kind === 'choamMarket' &&
- market?.owner === decision.player &&
- g.status === 'playing' &&
- g.phase === 3 &&
- !g.response &&
- !g.truthtrance &&
- !g.phaseOpening &&
- !g.pendingNullentropy &&
- !g.pendingRicheseGift &&
- !g.pendingKarama &&
- !market.sale &&
- !market.trade &&
- getPlayer(g, market.owner).hand.length === 0 &&
- g.players.every(
- (p) =>
- p.id === market.owner ||
- (p.hand.length < 2 &&
- !(
- p.faction === 'richese' &&
- p.ally === market.owner &&
- getPlayer(g, market.owner).ally === p.id &&
- p.hand.length > 0
- )),
- )
- ) {
- // Hand counts are public only in Bidding. Nonempty hands may prepare sales, while another
- // two-card hand could use Distrans or a Richese ally could give one card.
- log(
- g,
- `${getPlayer(g, market.owner).name} has no cards and no incoming card exchange is available. The end-of-phase market finishes automatically.`,
- { faction: 'choam', name: 'Market complete' },
- );
- finishChoamMarket(g);
- return true;
- }
- if (decision?.kind === 'choamAudit' && currentAuditCount(g) === 0) {
- finishAuditor(g, 'empty');
- return true;
- }
- if (
- decision?.kind === 'choamAuditPayment' &&
- (currentAuditCount(g) === 0 ||
- getPlayer(g, decision.player).spice < currentAuditCount(g))
- ) {
- finishAuditor(g, 'inspect');
- return true;
- }
- const lot = g.richeseAuction;
- const richese = byFaction(g, 'richese');
- if (
- !decision &&
- richese &&
- !noFieldAllyOfferBlock(g, richese) &&
- !(
- g.richeseAllyOpportunity?.turn === g.turn &&
- g.richeseAllyOpportunity.recipient === richese.ally
- )
- ) {
- g.richeseAllyOpportunity = { turn: g.turn, recipient: richese.ally! };
- g.decision = {
- kind: 'richeseAllyOpportunity',
- player: richese.id,
- recipient: richese.ally!,
- };
- log(
- g,
- `${richese.name} may offer a No-Field before their ally’s shipment. The ally’s turn is held for this choice.`,
- );
- return true;
- }
- if (g.phase === 3 && !decision && lot && !lot.outcome) {
- const full =
- lot.method === 'silent'
- ? lot.eligible.find(
- (id) =>
- !Object.hasOwn(lot.sealed, id) &&
- getPlayer(g, id).hand.length >= handLimit(getPlayer(g, id)),
- )
- : lot.active &&
- getPlayer(g, lot.active).hand.length >=
- handLimit(getPlayer(g, lot.active))
- ? lot.active
- : null;
- if (full) {
- g.richeseAuction = submitRicheseBid(
- lot,
- {
- event: lot.event,
- actor: full,
- amount: lot.method === 'silent' ? 0 : null,
- },
- 0,
- );
- if (lot.method === 'silent')
- (g.richeseFunding ??= {})[full] = {
- amount: 0,
- allyPayment: 0,
- donor: null,
- };
- g.active = g.richeseAuction.active;
- const player = getPlayer(g, full);
- log(
- g,
- `${player.name} automatically ${lot.method === 'silent' ? 'submitted zero' : 'passed'} because their hand became full before bidding.`,
- { faction: player.faction, name: 'Full hand: no bid' },
- );
- if (lot.method === 'silent' && g.richeseAuction.outcome)
- log(
- g,
- `Silent bids revealed together: ${g.richeseAuction.tieOrder.map((id) => `${getPlayer(g, id).name} ${g.richeseAuction!.sealed[id]}`).join('; ')}. Ties are resolved in storm order.`,
- );
- settleRicheseLot(g);
- return true;
- }
- }
- if (
- g.phase === 3 &&
- decision?.kind === 'richeseUnbid' &&
- lot?.source === 'cache' &&
- lot.outcome?.kind === 'unbid' &&
- getPlayer(g, lot.owner).hand.length >= handLimit(getPlayer(g, lot.owner))
- ) {
- g.decision = null;
- settleRicheseLot(g);
- return true;
- }
- if (decision?.kind === 'fullPlanRead') {
- const b = g.battle;
- requireRule(
- b &&
- !b.revealed &&
- b.fullPlan?.owner === decision.player &&
- b.fullPlan.target === decision.target &&
- b.plans[decision.target],
- 'The saved inspection does not match a committed battle plan.',
- );
- g.decision = null;
- recordFullPlanInspection(g, decision.player);
- return true;
- }
- if (
- decision?.kind === 'auctionPayment' &&
- g.status === 'playing' &&
- g.phase === 3
- ) {
- const p = getPlayer(g, decision.player);
- // The ally split was committed with the bid. Never silently select a new
- // split or consume a card on behalf of a player, including a BG conversion.
- if (
- auctionSpicePaymentFunded(g, p) &&
- !p.hand.some((card) => !karamaSpendingBlock(g, p, card))
- ) {
- g.decision = null;
- settleAuction(g, false, true);
- return true;
- }
- }
- return false;
-}
-function recoverAuctionPayment(g: Game, p: Player) {
- const a = g.auction!;
- if (a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0) || karamaCard(g, p)) {
- g.decision = { kind: 'auctionPayment', player: p.id };
- return;
- }
- // Provisional recovery for an unfunded canceled payment; primary-source
- // confirmation is still required before advanced tables are enabled.
- g.decision = null;
- a.bid = 0;
- a.bidder = null;
- a.allyPayment = 0;
- a.passed = [];
- while (
- getPlayer(g, g.order[a.opener]).hand.length >=
- handLimit(getPlayer(g, g.order[a.opener]))
- )
- a.opener = (a.opener + 1) % g.order.length;
- a.active = g.order[a.opener];
- g.active = a.active;
- log(
- g,
- 'The winning bid is no longer funded. Bidding restarts for the same unrevealed card.',
- );
-}
-function allowedEntry(
- g: Game,
- p: Player,
- t: string,
- s: number,
- stormShipment = false,
- advisors = false,
-) {
- requireRule(
- t !== MOBILE_STRONGHOLD || g.mobileStronghold?.location,
- 'The mobile stronghold has not been placed.',
- );
- requireRule(
- validLocation(t, s),
- 'Choose a sector belonging to this territory.',
- );
- requireRule(
- s === 0 || s !== g.storm || stormShipment,
- 'That sector is in storm.',
- );
- if (t !== 'polar_sink' && p.ally && !advisors)
- requireRule(
- at(getPlayer(g, p.ally), t) === 0,
- 'You cannot enter a territory occupied by your ally.',
- );
- if (territory(t).type === 'stronghold' && !advisors)
- requireRule(
- g.players.filter((x) => x.id !== p.id && fighterCount(x, t) > 0).length <
- 2,
- 'A stronghold cannot contain three factions.',
- );
-}
-function eliteChoice(
- total: number,
- available: number,
- eliteAvailable: number,
- value: unknown,
-) {
- const minimum = Math.max(0, total - (available - eliteAvailable));
- return integer(
- value ?? minimum,
- minimum,
- Math.min(total, eliteAvailable),
- 'Elite forces',
- );
-}
-function forceGroup(p: Player, action: Action) {
- let entries: [string, unknown][];
- if (action.forces !== undefined) {
- requireRule(
- action.forces &&
- typeof action.forces === 'object' &&
- !Array.isArray(action.forces),
- 'Choose a force group from one territory.',
- );
- entries = Object.entries(action.forces);
- } else entries = [[stringField(action.from), action.amount]];
- const group: [string, number][] = [];
- const eliteGroup: Record = {};
- let origin: string | undefined;
- let noField: { tokenId: string; event: string; from: string } | undefined;
- if (action.noField !== undefined) {
- const marker = p.noField?.deployed;
- requireRule(
- p.faction === 'richese' &&
- marker &&
- marker.tokenId === action.noField &&
- p.noFieldEvent === action.event,
- 'This concealed No-Field movement selection is stale.',
- );
- origin = marker.location.territory;
- noField = {
- tokenId: marker.tokenId,
- event: p.noFieldEvent!,
- from: location(marker.location.territory, marker.location.sector),
- };
- }
- for (const [key, value] of entries) {
- const src = splitLocation(key);
- requireRule(
- validLocation(src.territory, src.sector),
- 'Invalid source sector.',
- );
- const n = integer(value, 0, p.forces[key] ?? 0, 'Forces');
- if (!n) continue;
- requireRule(
- !origin || origin === src.territory,
- 'A force group must come from one territory.',
- );
- origin = src.territory;
- group.push([key, n]);
- const selectedElite =
- action.forces !== undefined
- ? (action.eliteForces as Record | undefined)?.[key]
- : action.elite;
- eliteGroup[key] = eliteChoice(
- n,
- p.forces[key],
- p.elites?.forces[key] ?? 0,
- selectedElite,
- );
- }
- requireRule(
- group.length > 0 || noField,
- 'Choose at least one force or the deployed No-Field.',
- );
- return {
- group,
- eliteGroup,
- elite: Object.values(eliteGroup).reduce((a, b) => a + b, 0),
- origin: origin!,
- total: group.reduce((n, [, count]) => n + count, 0) + (noField ? 1 : 0),
- noField,
- sourceKeys: [
- ...group.map(([key]) => key),
- ...(noField ? [noField.from] : []),
- ],
- };
-}
-function movesAllowed(g: Game, p: Player) {
- const flight = g.ornithopter;
- return flight?.player === p.id && flight.turn === g.turn
- ? flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1)
- : g.hajr.includes(p.id)
- ? 2
- : 1;
-}
-function ornithopterBlock(
- g: Game,
- p: Player,
- mode: OrnithopterMode,
-): string | null {
- if (g.status !== 'playing' || g.phase !== 5 || g.active !== p.id)
- return 'Use Ornithopter during your own Shipment and Movement turn.';
- if (g.truthtrance || g.response || g.decision || g.phaseOpening)
- return 'Resolve the current interaction before declaring a movement.';
- if (g.ornithopter)
- return 'Finish the already played Ornithopter movement first.';
- if (p.moved || g.hajr.includes(p.id))
- return 'Ornithopter combined with prior moves or Hajr awaits its timing ruling.';
- if (
- mode === 'range3' &&
- p.faction === 'choam' &&
- g.choamMovement?.turn === g.turn &&
- g.choamMovement.bonus
- )
- return 'Kulon combined with the fixed three-territory card range awaits a ruling.';
- return null;
-}
-function ornithopterIntegrity(g: Game) {
- const flight = g.ornithopter;
- if (!flight) return;
- const owner = getPlayer(g, flight.player);
- requireRule(
- g.status === 'playing' &&
- g.phase === 5 &&
- g.active === owner.id &&
- flight.turn === g.turn &&
- ['range3', 'twoGroups'].includes(flight.mode) &&
- Number.isSafeInteger(flight.completed) &&
- flight.completed >= 0 &&
- flight.completed < (flight.mode === 'twoGroups' ? 2 : 1) &&
- owner.moved === flight.startingMove + flight.completed &&
- richeseCardDefinition(flight.card)?.card.effect === 'ornithopter' &&
- ![
- ...g.players.flatMap((p) => p.hand),
- ...g.deck,
- ...g.discard,
- ...(g.richeseCache ?? []),
- ].some((c) => c.id === flight.card.id),
- 'The played Ornithopter custody or movement event is inconsistent; restore the saved movement before continuing.',
- );
- if (flight.mode === 'twoGroups' && flight.completed === 1) {
- requireRule(
- flight.cohort,
- 'The original unmoved Ornithopter cohort is missing; restore the saved movement before continuing.',
- );
- noFieldRule(() =>
- validateCohortSelection(
- flight.cohort!,
- owner.forces,
- owner.elites?.forces ?? {},
- {},
- {},
- ),
- );
- }
-}
-function finishOrnithopter(
- g: Game,
- source: 'move' | 'end',
- movement: CompletedMovement | null = null,
-) {
- const flight = g.ornithopter;
- if (!flight) return;
- requireRule(
- !g.pendingNullentropy,
- 'Finish the paid search before completing the movement card.',
- );
- g.discard.push(flight.card);
- g.ornithopter = null;
- log(
- g,
- `${getPlayer(g, flight.player).name} finished Ornithopter after ${flight.completed} group move${flight.completed === 1 ? '' : 's'} and discarded the played card.`,
- { faction: getPlayer(g, flight.player).faction, name: 'Ornithopter' },
- );
- // completeMove validated arrival with a pure quote before moving any forces.
- // The completed discard resumes that arrival without speculative effect replay.
- const resume = {
- response: g.response,
- decision: g.decision,
- pendingKarama: g.pendingKarama,
- phaseOpening: g.phaseOpening,
- };
- g.response = null;
- g.decision = null;
- g.pendingKarama = null;
- g.phaseOpening = null;
- const continuation: Extract<
- NonNullable['continuation'],
- { kind: 'ornithopterDiscard' }
- > = {
- kind: 'ornithopterDiscard',
- source,
- flight,
- movement,
- resume,
- stateSignature: '',
- };
- continuation.stateSignature = ornithopterDiscardSignature(g, continuation);
- stageTreacheryDiscard(
- g,
- `ornithopter:${source}`,
- [{ card: flight.card, discardedBy: flight.player, publicFace: true }],
- continuation,
- );
-}
-function ornithopterView(g: Game, p: Player) {
- const flight = g.ornithopter?.player === p.id ? g.ornithopter : null;
- const card =
- flight?.card ??
- p.hand.find((c) => richeseCardDefinition(c)?.card.effect === 'ornithopter');
- if (!card) return null;
- const modes = (['range3', 'twoGroups'] as const).map((mode) => ({
- mode,
- blocked: ornithopterBlock(g, p, mode) ?? transferCardBlock(g, p, card),
- }));
- return {
- card,
- blocked: flight
- ? null
- : modes.every((m) => m.blocked)
- ? modes[0].blocked
- : null,
- modes,
- active: flight
- ? {
- event: flight.event,
- mode: flight.mode,
- completed: flight.completed,
- remaining: (flight.mode === 'twoGroups' ? 2 : 1) - flight.completed,
- cohort: flight.cohort,
- }
- : null,
- };
-}
-function movementOrderRange(g: Game, p: Player, move: MovementOrder) {
- return move.ornithopterRange ? 3 : movementRange(g, p, move.elite);
-}
-function validateFlightSelection(g: Game, p: Player, move: MovementOrder) {
- const flight = g.ornithopter;
- requireRule(
- !flight || flight.player !== p.id || move.ornithopterEvent === flight.event,
- 'Use the current Ornithopter movement event.',
- );
- if (!move.ornithopterEvent) return;
- requireRule(
- flight?.player === p.id &&
- flight.event === move.ornithopterEvent &&
- move.ornithopterRange === (flight.mode === 'range3'),
- 'This Ornithopter movement event is stale.',
- );
- if (flight.completed && flight.cohort)
- noFieldRule(() =>
- validateCohortSelection(
- flight.cohort!,
- p.forces,
- p.elites?.forces ?? {},
- Object.fromEntries(move.group),
- move.eliteGroup,
- move.noField,
- ),
- );
-}
-function movementRange(g: Game, p: Player, elite: number) {
- const base =
- fighterCount(p, 'arrakeen') || fighterCount(p, 'carthag')
- ? 3
- : (p.faction === 'fremen' &&
- !(
- p.fremenMovementBlocked?.turn === g.turn &&
- p.fremenMovementBlocked.move === p.moved
- )) ||
- (p.faction === 'ixians' &&
- elite > 0 &&
- !(
- p.ixMovementBlocked?.turn === g.turn &&
- p.ixMovementBlocked.move === p.moved
- ))
- ? 2
- : 1;
- return (
- base +
- (p.faction === 'choam' && g.choamMovement?.turn === g.turn
- ? g.choamMovement.bonus
- : 0)
- );
-}
-function balisetPrevents(g: Game, player: string, origin: string, to: string) {
- const choam = byFaction(g, 'choam');
- return (
- g.phase === 5 &&
- origin !== to &&
- !!choam &&
- at(choam, to) > 0 &&
- !!g.choamBaliset?.some(
- (b) => b.turn === g.turn && b.player === player && b.territory === to,
- )
- );
-}
-function offerChoamMovement(g: Game, move: MovementOrder) {
- const choam = byFaction(g, 'choam');
- if (
- choam &&
- move.player !== choam.id &&
- move.origin !== move.to &&
- at(choam, move.to) > 0
- ) {
- g.pendingChoamMove = move;
- g.decision = {
- kind: 'choamMovement',
- player: choam.id,
- mover: move.player,
- territory: move.to,
- sector: move.sector,
- amount: move.total,
- };
- } else completeMove(g, move);
-}
-function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
- requireRule(
- g.phase === 5 && g.active === p.id && p.moved < movesAllowed(g, p),
- 'The movement is no longer available.',
- );
- validateFlightSelection(g, p, move);
- const selected = forceGroup(p, {
- type: 'move',
- forces: Object.fromEntries(move.group),
- eliteForces: move.eliteGroup,
- ...(move.noField
- ? { noField: move.noField.tokenId, event: move.noField.event }
- : {}),
- });
- requireRule(
- selected.origin === move.origin &&
- selected.total === move.total &&
- selected.elite === move.elite &&
- selected.group.length === move.group.length,
- 'The declared force group no longer matches its movement.',
- );
- requireRule(
- !balisetPrevents(g, p.id, move.origin, move.to),
- 'Baliset prevents that movement.',
- );
- allowedEntry(
- g,
- p,
- move.to,
- move.sector,
- false,
- move.advisors && !move.wantsFighters,
- );
- requireRule(
- [
- ...move.group.map(([key]) => key),
- ...(move.noField ? [move.noField.from] : []),
- ].every(
- (key) =>
- gameDistance(g, key, location(move.to, move.sector), (k) =>
- pathBlocked(g, p, k, isAdvisor(p, move.origin)),
- ) <= movementOrderRange(g, p, move),
- ),
- 'The movement route is no longer available.',
- );
-}
-function pendingFremenMovement(g: Game, response: ResponseWindow) {
- const pending = g.pendingFremenMove;
- requireRule(
- pending &&
- response.kind === 'fremenMovement' &&
- pending.order.player === response.owner &&
- pending.turn === g.turn,
- 'This Fremen movement response is no longer current.',
- );
- const p = getPlayer(g, pending.order.player);
- requireRule(
- p.faction === 'fremen' &&
- pending.move === p.moved &&
- response.location === location(pending.order.to, pending.order.sector) &&
- response.amount === pending.order.total,
- 'This Fremen movement declaration is no longer current.',
- );
- validateMovementOrder(g, p, pending.order);
- return pending;
-}
-function resumeChoamMovement(g: Game) {
- const move = g.pendingChoamMove!;
- g.pendingChoamMove = null;
- const p = getPlayer(g, move.player);
- try {
- validateMovementOrder(g, p, move);
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- log(
- g,
- `${p.name}'s declared movement is no longer legal. No movement was spent.`,
- );
- return;
- }
- completeMove(g, move);
-}
-function validateMovementArrival(
- g: Game,
- move: MovementOrder,
- cancelingChoam = false,
-) {
- try {
- return quoteCompletedMovementArrival({
- advanced: g.advanced,
- players: g.players,
- order: move,
- ambassadors: g.ecazAmbassadors?.tokens ?? [],
- terror: g.moritaniTerror?.tokens ?? [],
- flight: g.ornithopter,
- controls: {
- response: !cancelingChoam && !!g.response,
- decision: !!g.decision,
- pendingTerror: !!g.pendingTerrorEntry,
- pendingAmbassador: !!g.pendingAmbassador,
- paidBox: !!g.pendingNullentropy,
- },
- });
- } catch (error) {
- if (error instanceof MovementArrivalError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function completeMove(g: Game, move: MovementOrder) {
- const {
- group,
- eliteGroup,
- elite,
- origin,
- total: n,
- to,
- sector: s,
- advisors,
- wantsFighters,
- lockedTurn,
- player: id,
- } = move;
- const p = getPlayer(g, id);
- validateFlightSelection(g, p, move);
- validateMovementArrival(g, move);
- const flight = g.ornithopter;
- if (
- flight?.player === id &&
- flight.mode === 'twoGroups' &&
- flight.completed === 0
- ) {
- const marker = p.noField?.deployed;
- flight.cohort = noFieldRule(() =>
- createRemainingCohort(
- p.forces,
- p.elites?.forces ?? {},
- Object.fromEntries(group),
- eliteGroup,
- marker && p.noFieldEvent
- ? {
- tokenId: marker.tokenId,
- event: p.noFieldEvent,
- from: location(marker.location.territory, marker.location.sector),
- }
- : undefined,
- move.noField,
- ),
- );
- }
- if (move.noField) {
- requireRule(
- p.noField && p.noFieldEvent === move.noField.event,
- 'This No-Field movement is no longer current.',
- );
- p.noField = noFieldRule(() =>
- moveRicheseNoField(p.noField!, move.noField!.tokenId, {
- territory: to,
- sector: s,
- }),
- );
- p.noFieldEvent = crypto.randomUUID();
- }
- removeGroup(p, group, eliteGroup);
- if (n > (move.noField ? 1 : 0))
- place(p, to, s, n - (move.noField ? 1 : 0), elite);
- if (advisors)
- (p.advisors ??= {})[to] = {
- lockedTurn:
- Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) ||
- undefined,
- };
- else if (p.advisors) delete p.advisors[to];
- if (wantsFighters)
- g.response = {
- kind: 'advisorFlip',
- owner: id,
- location: to,
- advisors: false,
- passed: [],
- };
- finishShipmentPromises(g, p, null);
- p.shipped = true;
- p.moved++;
- const completed: CompletedMovement = {
- player: id,
- origin,
- total: n,
- to,
- sector: s,
- elite,
- noField: !!move.noField,
- };
- if (flight?.player === id) {
- flight.completed++;
- if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) {
- finishOrnithopter(g, 'move', completed);
- return;
- }
- }
- finishMovedGroup(g, completed);
-}
-/** Forces and movement counters are already committed; only arrival remains. */
-function finishMovedGroup(g: Game, move: CompletedMovement) {
- const { player: id, origin, total: n, to, sector: s, elite } = move;
- const p = getPlayer(g, id);
- g.karamaShipping = null;
- log(
- g,
- move.noField
- ? `${p.name} moved a concealed No-Field${n > 1 ? ` with ${n - 1} physical forces` : ''} from ${territory(origin).name} to ${territory(to).name}. Its value remains concealed.`
- : `${p.name} moved ${n} forces from ${territory(origin).name} to ${territory(to).name}.`,
- );
- intrusion(g, p, to);
- if (origin !== to) openTerritoryEntry(g, p, to, s, n, elite, 'movement');
-}
-/** A played movement card is already retired before queue advancement. */
-function finishMovementTurn(g: Game, id: string) {
- const p = getPlayer(g, id);
- g.karamaShipping = null;
- g.movementRemaining ??= g.order.slice(g.order.indexOf(id));
- if (p.ally)
- for (const terr of gameTerritories(g))
- if (
- terr.type !== 'polar' &&
- at(p, terr.id) &&
- at(getPlayer(g, p.ally), terr.id) &&
- !g.movementRemaining.includes(p.ally) &&
- !(
- p.allySinceTurn === g.turn &&
- getPlayer(g, p.ally).allySinceTurn === g.turn
- )
- )
- killTerritory(g, p, terr.id);
- g.movementRemaining = g.movementRemaining.filter((other) => other !== id);
- if (g.saphoMovementLast?.player === id) g.saphoMovementLast = null;
- movementTurn(g);
-}
-
-const implementedAmbassadorEffects: readonly AmbassadorEffect[] = [
- 'atreides',
- 'harkonnen',
- 'emperor',
- 'choam',
- 'ixians',
- 'beneGesserit',
-];
-function returnAmbassadorsIn(g: Game, to: string, reason: string) {
- for (const token of g.ecazAmbassadors?.tokens.filter(
- (t) => t.zone === 'placed' && t.location === to,
- ) ?? []) {
- g.ecazAmbassadors = destroyAmbassador(g.ecazAmbassadors!, token.id);
- log(
- g,
- `The ${faction(token.effect).name} Ambassador returned from ${territory(to).name} to Ecaz’s supply because ${reason}. This was destruction, not a triggered effect, and does not advance the five-token cycle.`,
- );
- }
-}
-function ambassadorDiscardBlock(g: Game, p: Player, card: Card) {
- if (g.battle?.lateDefense?.[p.id] === card.id)
- return 'This Portable Snooper is already played and reserved for battle cleanup.';
- if (giftReserved(g, p.id, card.id))
- return 'This card is reserved for the pending Richese gift.';
- if (
- g.truthtrance?.queue.some(
- (entry) => entry.player === p.id && entry.card === card.id,
- )
- )
- return 'This card is already committed to Truthtrance.';
- if (retentionReservesCard(g, p.id, card.id))
- return 'This card is reserved for Moritani battle cleanup.';
- const plan = g.battle?.plans[p.id];
- if (plan && [plan.weapon, plan.defense, plan.leader].includes(card.id))
- return 'This card is committed to a sealed battle plan.';
- const insight = g.battle?.prescience;
- if (insight && insight.player !== p.id && insight.value === card.id)
- return 'This card is committed to prescience.';
- return null;
-}
-function ambassadorEffectBlock(
- g: Game,
- effect: AmbassadorEffect,
- beneficiary: Player,
- entrant: Player,
-): string | null {
- if (!implementedAmbassadorEffects.includes(effect))
- return 'This Ambassador effect is still being implemented.';
- if (
- effect === 'ixians' &&
- !beneficiary.hand.some(
- (card) => !ambassadorDiscardBlock(g, beneficiary, card),
- )
- )
- return 'The beneficiary needs a card they can discard.';
- if (
- effect === 'harkonnen' &&
- !(entrant.faction === 'tleilaxu'
- ? entrant.faceDancers?.length
- : entrant.traitors.length)
- )
- return 'The entrant has no held Traitor Card to inspect.';
- return null;
-}
-/** The original entry is committed once. Competing arrival ordering remains gated. */
-function openTerritoryEntry(
- g: Game,
- entrant: Player,
- to: string,
- sector: number,
- amount: number,
- elite: number,
- cause: NonNullable['cause'],
- resume: 'none' | 'wormRide' = 'none',
-) {
- const owner = byFaction(g, 'ecaz');
- const token = g.ecazAmbassadors?.tokens.find(
- (t) => t.zone === 'placed' && t.location === to,
- );
- if (
- !owner ||
- !token ||
- !canTriggerAmbassador({
- owner: owner.id,
- ally: owner.ally,
- entrant: entrant.id,
- entrantFaction: entrant.faction,
- advisors: isAdvisor(entrant, to),
- effect: token.effect,
- })
- )
- return openTerrorEntry(
- g,
- entrant,
- to,
- sector,
- amount,
- elite,
- cause,
- resume,
- );
- const moritani = byFaction(g, 'moritani');
- const terror =
- moritani &&
- entrant.id !== moritani.id &&
- entrant.id !== moritani.ally &&
- g.moritaniTerror?.tokens.some(
- (t) => t.status === 'placed' && t.location === to,
- );
- requireRule(
- !g.response &&
- !g.decision &&
- !g.pendingTerrorEntry &&
- !g.pendingAmbassador &&
- !terror,
- 'Ambassadors combined with another arrival reaction are still being implemented. This entry has not been committed.',
- );
- g.pendingAmbassador = {
- event: `ambassador:${g.turn}:${g.phase}:${(g.log.at(-1)?.seq ?? 0) + 1}`,
- owner: owner.id,
- entrant: entrant.id,
- token: token.id,
- territory: to,
- sector,
- turn: g.turn,
- phase: g.phase,
- stage: 'offer',
- resume,
- copyChoices:
- token.effect === 'beneGesserit'
- ? copiedAmbassadorEffects(g.ecazAmbassadors!)
- : [],
- };
- g.decision = { kind: 'ecazAmbassador', player: owner.id };
- log(
- g,
- `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`,
- );
- return true;
-}
-function finishAmbassador(g: Game) {
- const pending = g.pendingAmbassador!;
- if (
- pending.beneficiary &&
- g.ecazAmbassadors!.cohort.every((id) =>
- g.ecazAmbassadors!.tokens.some(
- (t) => t.id === id && (t.zone === 'used' || t.zone === 'removed'),
- ),
- )
- ) {
- g.ecazAmbassadors = replenishAmbassadors(g.ecazAmbassadors!, random);
- log(
- g,
- 'All five random Ambassadors have been triggered. Ecaz drew a new supply of five; the removed Bene Gesserit token stays out of the game.',
- );
- }
- g.pendingAmbassador = null;
- if (pending.resume === 'wormRide') nextWormRide(g);
-}
-function resolveAmbassadorEffect(g: Game) {
- const entry = g.pendingAmbassador!;
- const p = getPlayer(g, entry.beneficiary!);
- const entrant = getPlayer(g, entry.entrant);
- const effect = entry.effect!;
- const blocked = ambassadorEffectBlock(g, effect, p, entrant);
- requireRule(!blocked, blocked ?? 'This Ambassador effect is unavailable.');
- if (effect === 'beneGesserit') {
- entry.stage = 'copy';
- g.decision = { kind: 'ecazAmbassador', player: p.id };
- return;
- }
- if (
- effect === 'ixians' ||
- (effect === 'choam' && p.hand.some((c) => !ambassadorDiscardBlock(g, p, c)))
- ) {
- entry.stage = 'cards';
- g.decision = { kind: 'ecazAmbassador', player: p.id };
- return;
- }
- if (effect === 'emperor') {
- p.spice += 5;
- log(
- g,
- `${p.name} gained 5 spice from the Spice Bank through the Emperor Ambassador.`,
- { faction: p.faction, name: 'Emperor Ambassador' },
- );
- } else if (effect === 'atreides' || effect === 'harkonnen') {
- const held =
- entrant.faction === 'tleilaxu'
- ? (entrant.faceDancers ?? []).map((c) => c.leader)
- : entrant.traitors;
- (g.ambassadorInsights ??= []).push({
- event: entry.event,
- viewer: p.id,
- target: entrant.id,
- turn: g.turn,
- effect,
- cards: effect === 'atreides' ? structuredClone(entrant.hand) : [],
- traitor:
- effect === 'harkonnen'
- ? held[Math.floor(random() * held.length)]
- : null,
- });
- log(
- g,
- `${p.name} privately inspected ${effect === 'atreides' ? `${entrant.name}’s current Treachery hand` : `one random Traitor Card held by ${entrant.name}`} through the ${faction(effect).name} Ambassador. This records only what was held at this moment.`,
- { faction: p.faction, name: `${faction(effect).name} Ambassador` },
- );
- } else if (effect === 'choam') {
- log(
- g,
- `${p.name} had no available Treachery cards to discard through the CHOAM Ambassador and gained no spice.`,
- );
- }
- finishAmbassador(g);
-}
-function decideAmbassador(g: Game, p: Player, action: Action) {
- const entry = g.pendingAmbassador;
- requireRule(
- entry &&
- entry.turn === g.turn &&
- entry.phase === g.phase &&
- action.event === entry.event,
- 'This Ambassador opportunity is no longer current.',
- );
- const owner = getPlayer(g, entry.owner),
- entrant = getPlayer(g, entry.entrant);
- requireRule(
- p.id === (entry.stage === 'offer' ? owner.id : entry.beneficiary),
- 'The Ambassador choice belongs to another player.',
- );
- if (entry.stage === 'offer') {
- const token = g.ecazAmbassadors?.tokens.find((t) => t.id === entry.token);
- requireRule(
- token?.zone === 'placed' && token.location === entry.territory,
- 'The Ambassador is no longer in that stronghold.',
- );
- if (action.decline === true) {
- log(
- g,
- `${owner.name} left the ${faction(token.effect).name} Ambassador in ${territory(entry.territory).name} without triggering it.`,
- );
- finishAmbassador(g);
- return;
- }
- requireRule(
- action.trigger === true,
- 'Trigger the Ambassador or leave it in place.',
- );
- const beneficiary = getPlayer(g, stringField(action.beneficiary));
- requireRule(
- beneficiary.id === owner.id ||
- (beneficiary.id === owner.ally && beneficiary.ally === owner.id),
- 'Choose Ecaz or its current ally as beneficiary.',
- );
- const blocked = ambassadorEffectBlock(
- g,
- token.effect,
- beneficiary,
- entrant,
- );
- requireRule(!blocked, blocked ?? 'This effect is unavailable.');
- if (token.effect === 'beneGesserit')
- requireRule(
- entry.copyChoices.some(
- (effect) => !ambassadorEffectBlock(g, effect, beneficiary, entrant),
- ),
- 'No copied effect is currently available.',
- );
- entry.beneficiary = beneficiary.id;
- entry.effect = token.effect;
- g.ecazAmbassadors = triggerAmbassador(g.ecazAmbassadors!, token.id);
- log(
- g,
- `${owner.name} triggered the ${faction(token.effect).name} Ambassador for ${beneficiary.name}. ${token.effect === 'beneGesserit' ? 'The Bene Gesserit token was permanently removed.' : 'The token was set aside until the five random Ambassadors have all been triggered.'}`,
- );
- resolveAmbassadorEffect(g);
- } else if (entry.stage === 'copy') {
- const effect = stringField(action.effect) as AmbassadorEffect;
- requireRule(
- entry.copyChoices.includes(effect),
- 'Copy an Ambassador outside the original supply for this cycle.',
- );
- entry.effect = effect;
- resolveAmbassadorEffect(g);
- } else {
- requireRule(
- entry.effect === 'choam' || entry.effect === 'ixians',
- 'This Ambassador has no card selection.',
- );
- requireRule(
- Array.isArray(action.cards) &&
- action.cards.every((id) => typeof id === 'string') &&
- new Set(action.cards).size === action.cards.length,
- 'Choose distinct owned Treachery cards.',
- );
- const ids = action.cards as string[];
- requireRule(
- entry.effect !== 'ixians' || ids.length === 1,
- 'Discard exactly one card for the Ixian Ambassador.',
- );
- for (const id of ids) {
- const card = p.hand.find((c) => c.id === id);
- requireRule(card, 'Choose a card in your own hand.');
- const blocked = ambassadorDiscardBlock(g, p, card);
- requireRule(!blocked, blocked ?? 'This card cannot be discarded.');
- }
- const entries = ids.map((id) => ({
- card: discard(g, p, id),
- discardedBy: p.id,
- publicFace: false,
- }));
- if (entry.effect === 'choam') {
- p.spice += ids.length * 3;
- log(
- g,
- `${p.name} discarded ${ids.length} Treachery cards and gained ${ids.length * 3} bank spice through the CHOAM Ambassador.`,
- ids.length
- ? { faction: p.faction, name: 'CHOAM Ambassador' }
- : undefined,
- );
- }
- if (!entries.length) finishAmbassador(g);
- else {
- g.pendingAmbassador = null;
- stageTreacheryDiscard(g, `ambassador:${entry.effect}`, entries, {
- kind: 'ambassador',
- entry,
- });
- }
- }
-}
-
-/** Entry is already paid and committed; a reaction must never replay its original action. */
-function openTerrorEntry(
- g: Game,
- entrant: Player,
- to: string,
- sector: number,
- amount: number,
- elite: number,
- cause: NonNullable['cause'],
- resume: 'none' | 'wormRide' = 'none',
-) {
- const moritani = byFaction(g, 'moritani');
- const token = g.moritaniTerror?.tokens.find(
- (t) => t.status === 'placed' && t.location === to,
- );
- if (
- !moritani ||
- !token ||
- entrant.id === moritani.id ||
- entrant.id === moritani.ally
- )
- return false;
- requireRule(!g.pendingTerrorEntry, 'Resolve the pending Terror entry first.');
- requireRule(
- !g.response && !g.decision,
- 'Terror combined with another arrival reaction is still being implemented. This entry has not been committed.',
- );
- g.pendingTerrorEntry = {
- token: token.id,
- entrant: entrant.id,
- territory: to,
- sector,
- amount,
- elite,
- cause,
- turn: g.turn,
- phase: g.phase,
- stage: 'offer',
- resume,
- };
- g.decision = {
- kind: 'moritaniTerror',
- player: moritani.id,
- entrant: entrant.id,
- territory: to,
- };
- log(
- g,
- `${moritani.name} may reveal a Terror token after ${entrant.name} entered ${territory(to).name}.`,
- );
- return true;
-}
-function finishTerrorEntry(g: Game) {
- const pending = g.pendingTerrorEntry!;
- g.pendingTerrorEntry = null;
- if (pending.resume === 'wormRide') nextWormRide(g);
-}
-/** The token, random victim/draw, discard and arrival have already committed. */
-function continueTerrorDiscard(
- g: Game,
- source: 'sabotage' | 'robberyOverflow',
-) {
- const entry = g.pendingTerrorEntry!;
- const owner = byFaction(g, 'moritani')!;
- const needsChoice =
- source === 'sabotage'
- ? owner.hand.length > 0
- : owner.hand.length > handLimit(owner);
- if (needsChoice) {
- entry.stage = source === 'sabotage' ? 'gift' : 'discard';
- g.decision = {
- kind: 'moritaniTerror',
- player: owner.id,
- entrant: entry.entrant,
- territory: entry.territory,
- };
- } else finishTerrorEntry(g);
-}
-/** Keep unsupported custody out of the random draw, without leaking concealed captures. */
-function terrorRevealBlocked(
- g: Game,
- entry: NonNullable,
- kind: TerrorKind,
-): string | null {
- if (kind === 'robbery' || kind === 'sabotage' || kind === 'sneakAttack')
- return null;
- if (kind !== 'assassination')
- return 'This Terror effect is still being implemented.';
- if (g.advanced && byFaction(g, 'harkonnen'))
- return 'Assassination with advanced Harkonnen capture is still being implemented.';
- const entrant = getPlayer(g, entry.entrant);
- if (g.dukeVidal?.controller === entrant.id)
- return 'Assassination with Duke Vidal in the leader pool is still being implemented.';
- const catalog = leaders(entrant.faction);
- if (
- catalog.length !== 5 ||
- entrant.leaders.length !== catalog.length ||
- new Set(entrant.leaders.map((leader) => leader.id)).size !==
- catalog.length ||
- entrant.leaders.some(
- (leader) =>
- !catalog.some(
- (native) =>
- native.id === leader.id &&
- native.faction === leader.faction &&
- native.strength === leader.strength,
- ),
- )
- )
- return 'Assassination requires a verified ordinary leader pool; this pool is still being implemented.';
- if (
- entrant.leaders.some((leader) => leader.capturedBy || leader.gholaBy) ||
- g.players.some(
- (other) =>
- other.id !== entrant.id &&
- other.leaders.some((leader) => controlsLeader(entrant, leader)),
- )
- )
- return 'Assassination with captured or foreign ghola leaders is still being implemented.';
- if (!entrant.leaders.some((leader) => !leader.dead))
- return 'Assassination with an empty living leader pool needs a verified ruling.';
- return null;
-}
-function sneakAttackOptions(
- g: Game,
- entry: NonNullable,
-) {
- const owner = byFaction(g, 'moritani')!;
- const maximum = Math.min(5, owner.reserves);
- let blocked: string | null = null;
- try {
- allowedEntry(g, owner, entry.territory, entry.sector);
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- blocked = error.message;
- }
- const bg = byFaction(g, 'beneGesserit');
- if (
- !blocked &&
- bg &&
- (bg.reserves > 0 || (g.advanced && fighterCount(bg, entry.territory) > 0))
- )
- blocked =
- 'Sneak Attack combined with Bene Gesserit arrival reactions is still being implemented.';
- if (!blocked && g.phase === 5 && g.techTokens)
- blocked =
- 'Sneak Attack combined with Heighliners technology is still being implemented.';
- return { maximum, blocked };
-}
-function terrorAllianceBlocked(
- g: Game,
- entry: NonNullable,
- kind: TerrorKind,
-): string | null {
- if (entry.allianceBlocked)
- return 'Karama prevented this alliance opportunity; you may still reveal the token or leave it hidden.';
- const entrant = getPlayer(g, entry.entrant);
- const owner = byFaction(g, 'moritani')!;
- if (entrant.faction === 'ecaz')
- return 'Enemy of My Enemy cannot be offered to Ecaz.';
- if (entrant.id === owner.id || entrant.id === owner.ally)
- return 'Enemy of My Enemy requires an opposing entrant.';
- if (terrorRevealBlocked(g, entry, kind))
- return 'This offer is not yet available because its mandatory effect on refusal is still being implemented.';
- return null;
-}
-/** Return only unspent escrow for the pairs this special alliance breaks. */
-function formTerrorAlliance(g: Game, owner: Player, entrant: Player) {
- const changed = new Set([owner.id, entrant.id]);
- for (const member of [owner, entrant]) {
- if (member.ally) getPlayer(g, member.ally).ally = null;
- member.ally = null;
- }
- for (const [donor, credit] of Object.entries(g.aid)) {
- if (changed.has(donor) || changed.has(credit.recipient)) {
- getPlayer(g, donor).spice += credit.amount;
- delete g.aid[donor];
- }
- }
- for (const [from, to] of Object.entries(g.allianceOffers))
- if (changed.has(from) || changed.has(to)) delete g.allianceOffers[from];
- owner.ally = entrant.id;
- entrant.ally = owner.id;
- owner.allySinceTurn = entrant.allySinceTurn = g.turn;
- g.ready = [];
-}
-function decideTerror(g: Game, p: Player, action: Action) {
- const entry = g.pendingTerrorEntry;
- requireRule(
- entry &&
- entry.turn === g.turn &&
- entry.phase === g.phase &&
- (entry.stage === 'allianceReply'
- ? p.id === entry.entrant
- : p.faction === 'moritani'),
- 'This Terror entry opportunity is no longer current.',
- );
- const token = g.moritaniTerror?.tokens.find((t) => t.id === entry.token);
- requireRule(token, 'The entry’s Terror token is unavailable.');
- const entrant = getPlayer(g, entry.entrant);
- const owner = byFaction(g, 'moritani')!;
- const reopen = () => {
- g.decision = {
- kind: 'moritaniTerror',
- player: owner.id,
- entrant: entrant.id,
- territory: entry.territory,
- };
- };
- if (entry.stage === 'allianceReply') {
- requireRule(
- token.status === 'placed' && token.location === entry.territory,
- 'The offered alliance no longer has a hidden Terror token.',
- );
- requireRule(
- typeof action.accept === 'boolean',
- 'Accept or refuse the alliance offer.',
- );
- if (action.accept) {
- const blocked = terrorAllianceBlocked(g, entry, token.kind);
- requireRule(!blocked, blocked ?? 'This alliance is unavailable.');
- formTerrorAlliance(g, owner, entrant);
- g.moritaniTerror = returnTerror(g.moritaniTerror!, token.id, random);
- log(
- g,
- `${owner.name} and ${entrant.name} formed an alliance through Enemy of My Enemy; their former alliances ended and the Terror token returned hidden to supply.`,
- );
- finishTerrorEntry(g);
- } else {
- log(
- g,
- `${entrant.name} refused Enemy of My Enemy; the Terror token must be revealed.`,
- );
- entry.stage = 'offer';
- decideTerror(g, owner, { type: 'decision', reveal: true });
- }
- return;
- }
- if (entry.stage === 'offer') {
- requireRule(
- token.status === 'placed' && token.location === entry.territory,
- 'The hidden Terror token is no longer in that stronghold.',
- );
- if (action.alliance === true) {
- const blocked = terrorAllianceBlocked(g, entry, token.kind);
- requireRule(!blocked, blocked ?? 'This alliance offer is unavailable.');
- entry.stage = 'allianceResponse';
- g.response = { kind: 'moritaniAlliance', owner: owner.id, passed: [] };
- log(
- g,
- `${owner.name} declared Enemy of My Enemy toward ${entrant.name}.`,
- );
- return;
- }
- if (action.decline === true) {
- log(g, `${p.name} left the Terror token hidden.`);
- finishTerrorEntry(g);
- return;
- }
- requireRule(
- action.reveal === true,
- 'Reveal the Terror token or leave it hidden.',
- );
- const blocked = terrorRevealBlocked(g, entry, token.kind);
- requireRule(!blocked, blocked ?? 'This Terror effect is unavailable.');
- g.moritaniTerror = revealTerror(g.moritaniTerror!, token.id);
- log(
- g,
- `${p.name} revealed ${TERROR_DEFINITIONS[token.kind].name} in ${territory(entry.territory).name}.`,
- );
- if (token.kind === 'robbery') {
- entry.stage = 'robbery';
- reopen();
- } else if (token.kind === 'assassination') {
- const victim = shuffle(
- entrant.leaders.filter((leader) => !leader.dead),
- )[0];
- victim.dead = true;
- victim.deaths++;
- delete victim.usedAt;
- p.spice += victim.strength;
- log(
- g,
- `${victim.name} was assassinated; ${p.name} received ${victim.strength} spice.`,
- );
- finishTerrorEntry(g);
- } else if (token.kind === 'sneakAttack') {
- entry.stage = 'sneakAttack';
- reopen();
- } else {
- const victimCard = shuffle(entrant.hand)[0];
- if (victimCard) {
- const discardedHandSize = entrant.hand.length;
- const card = discard(g, entrant, victimCard.id);
- log(
- g,
- `${entrant.name} discarded a random Treachery card to Sabotage.`,
- );
- g.pendingTerrorEntry = null;
- stageTreacheryDiscard(
- g,
- 'terror:sabotage',
- [{ card, discardedBy: entrant.id, publicFace: false }],
- {
- kind: 'terrorDiscard',
- source: 'sabotage',
- owner: owner.id,
- entry,
- discardedHandSize,
- },
- );
- } else {
- continueTerrorDiscard(g, 'sabotage');
- }
- }
- return;
- }
- requireRule(
- token.status === 'removed',
- 'Reveal the Terror token before resolving its effect.',
- );
- if (entry.stage === 'sneakAttack') {
- requireRule(
- token.kind === 'sneakAttack',
- 'This is not a Sneak Attack opportunity.',
- );
- const options = sneakAttackOptions(g, entry);
- const amount = integer(
- action.amount,
- 0,
- options.maximum,
- 'Sneak Attack forces',
- );
- requireRule(
- action.sector === undefined || action.sector === entry.sector,
- 'Sneak Attack must enter the recorded sector.',
- );
- if (amount > 0) {
- requireRule(
- !options.blocked,
- options.blocked ?? 'This arrival is unavailable.',
- );
- p.reserves -= amount;
- place(p, entry.territory, entry.sector, amount);
- log(
- g,
- `${p.name} sent ${amount} reserves into ${territory(entry.territory).name} through Sneak Attack.`,
- );
- } else log(g, `${p.name} sent no forces through Sneak Attack.`);
- finishTerrorEntry(g);
- } else if (entry.stage === 'robbery') {
- requireRule(
- token.kind === 'robbery' &&
- (action.choice === 'spice' || action.choice === 'card'),
- 'Choose stolen spice or the top Treachery card.',
- );
- if (action.choice === 'spice') {
- const amount = Math.ceil(entrant.spice / 2);
- entrant.spice -= amount;
- p.spice += amount;
- log(
- g,
- `${p.name} stole half of ${entrant.name}’s available spice, rounded up.`,
- );
- } else {
- const card = draw(g);
- if (card) p.hand.push(card);
- log(
- g,
- card
- ? `${p.name} drew a Treachery card through Robbery.`
- : 'No Treachery card remained for Robbery.',
- );
- if (p.hand.length > handLimit(p)) {
- entry.stage = 'discard';
- reopen();
- return;
- }
- }
- finishTerrorEntry(g);
- } else if (entry.stage === 'discard') {
- requireRule(
- token.kind === 'robbery' && p.hand.length > handLimit(p),
- 'There is no excess Robbery card to discard.',
- );
- const discardedHandSize = p.hand.length;
- const card = discard(g, p, stringField(action.card));
- log(g, `${p.name} discarded a card after the Robbery draw.`);
- g.pendingTerrorEntry = null;
- stageTreacheryDiscard(
- g,
- 'terror:robberyOverflow',
- [{ card, discardedBy: p.id, publicFace: false }],
- {
- kind: 'terrorDiscard',
- source: 'robberyOverflow',
- owner: owner.id,
- entry,
- discardedHandSize,
- },
- );
- } else {
- requireRule(
- token.kind === 'sabotage',
- 'This is not a Sabotage gift opportunity.',
- );
- if (action.decline !== true) {
- const card = p.hand.find((c) => c.id === action.card);
- requireRule(card, 'Choose a card from your own hand to give.');
- requireRule(
- entrant.hand.length < handLimit(entrant),
- 'This recipient’s exceptional hand overflow is still being implemented. You may decline the gift.',
- );
- p.hand = p.hand.filter((c) => c.id !== card.id);
- entrant.hand.push(card);
- log(g, `${p.name} gave ${entrant.name} a Treachery card after Sabotage.`);
- } else log(g, `${p.name} declined the optional Sabotage gift.`);
- finishTerrorEntry(g);
- }
-}
-function removeGroup(
- p: Player,
- group: [string, number][],
- eliteGroup: Record = {},
-) {
- for (const [key, n] of group) {
- if (p.elites) {
- p.elites.forces[key] =
- (p.elites.forces[key] ?? 0) - (eliteGroup[key] ?? 0);
- if (!p.elites.forces[key]) delete p.elites.forces[key];
- }
- p.forces[key] -= n;
- if (!p.forces[key]) delete p.forces[key];
- }
-}
-function pathBlocked(g: Game, p: Player, key: string, advisors = false) {
- const l = splitLocation(key);
- if (l.sector && l.sector === g.storm) return true;
- return (
- !advisors &&
- territory(l.territory).type === 'stronghold' &&
- g.players.filter((x) => x.id !== p.id && fighterCount(x, l.territory) > 0)
- .length >= 2
- );
-}
-function boardResolution(quote: () => T): T {
- try {
- return quote();
- } catch (error) {
- if (error instanceof BoardResolutionError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
- for (const entry of released)
- delete getPlayer(g, entry.player).advisors?.[entry.territory];
-}
-export function battles(g: Game) {
- const quote = boardResolution(() => quoteBattleBoard(g));
- applyAdvisorReleases(g, quote.released);
- return quote.battles;
-}
-/** Evaluate only at the committed end of movement, using the normal battle geometry. */
-function offerMoritaniDuke(g: Game) {
- const owner = byFaction(g, 'moritani');
- if (!owner || g.dukeAcquisitionTurn === g.turn) return false;
- g.dukeAcquisitionTurn = g.turn;
- g.dukeVidal ??= createDukeVidal();
- const duke = g.dukeVidal;
- if (duke.leader.dead || duke.controller === owner.id) return false;
- if (
- duke.leader.capturedBy ||
- duke.leader.gholaBy ||
- (g.advanced && byFaction(g, 'harkonnen'))
- ) {
- log(
- g,
- 'Duke Vidal acquisition with exceptional capture or ghola custody is still being implemented.',
- );
- return false;
- }
- const strongholds = new Set(
- battles(g)
- .filter(
- (battle) =>
- [battle.attacker, battle.defender].includes(owner.id) &&
- territory(battle.territory).type === 'stronghold' &&
- getPlayer(
- g,
- battle.attacker === owner.id ? battle.defender : battle.attacker,
- ).faction !== 'ecaz',
- )
- .map((battle) => battle.territory),
- );
- if (strongholds.size < 2) return false;
- g.response = { kind: 'moritaniDuke', owner: owner.id, passed: [] };
- log(
- g,
- `${owner.name} qualifies to acquire Duke Vidal through battles in ${strongholds.size} strongholds.`,
- );
- return true;
-}
-type SaphoOption = {
- scope: 'onceAround' | 'movement';
- event: string;
- mode: 'first' | 'last';
-};
-function saphoMovementIntegrity(g: Game) {
- const last = g.saphoMovementLast;
- if (last === undefined || last === null) return;
- requireRule(
- typeof last === 'object' &&
- !Array.isArray(last) &&
- g.status === 'playing' &&
- g.phase === 5 &&
- last.turn === g.turn &&
- last.event === `movement:${g.turn}` &&
- typeof last.player === 'string' &&
- g.order.includes(last.player),
- 'The saved Juice of Sapho movement scope is invalid.',
- );
- const remaining = g.movementRemaining;
- requireRule(remaining, 'The saved Juice of Sapho movement queue is missing.');
- validateOrderedOpportunity({
- event: last.event,
- eligible: g.order,
- completed: g.order.filter((id) => !remaining.includes(id)),
- remaining,
- current: remaining[0] ?? null,
- currentStarted: false,
- protectedLast: last.player,
- });
-}
-function saphoCleanWindow(g: Game) {
- return (
- g.status === 'playing' &&
- !g.truthtrance &&
- !g.phaseOpening &&
- !g.response &&
- !g.decision &&
- !g.pendingNullentropy &&
- !g.pendingRicheseGift &&
- !g.pendingExchange &&
- !g.pendingKarama &&
- !g.pendingShipment &&
- !g.pendingAmbassador &&
- !g.pendingTerrorEntry &&
- !g.pendingFremenMove &&
- !g.pendingIxMove &&
- !g.pendingChoamMove &&
- !g.richeseAllyOffer &&
- !g.currentAuctionSale
- );
-}
-function saphoMovementQueue(g: Game): OrderedOpportunity | null {
- let remaining = g.movementRemaining;
- if (
- g.phase !== 5 ||
- !remaining?.length ||
- !g.active ||
- !remaining.includes(g.active)
- )
- return null;
- const active = getPlayer(g, g.active);
- // The Guild can already have claimed the next entire turn without rewriting
- // the physical queue. Preserve that granted current turn in this local scope.
- if (g.active !== remaining[0]) {
- if (!g.advanced || active.faction !== 'guild' || !g.guildTimingGranted)
- return null;
- remaining = [g.active, ...remaining.filter((id) => id !== g.active)];
- }
- // A paid/reserved movement preparation also starts the combined turn.
- const started =
- active.shipped ||
- active.moved > 0 ||
- g.hajr.includes(active.id) ||
- !!g.karamaShipping ||
- !!g.ornithopter ||
- (g.choamMovement?.turn === g.turn && active.faction === 'choam');
- const state: OrderedOpportunity = {
- event: `movement:${g.turn}`,
- eligible: g.order,
- completed: g.order.filter((id) => !remaining.includes(id)),
- remaining,
- current: g.active,
- currentStarted: !!started,
- protectedLast: g.saphoMovementLast?.player ?? null,
- };
- validateOrderedOpportunity(state);
- return state;
-}
-/** Own custody and public finite scopes only; no opponent hand scan. */
-function saphoOptions(g: Game, p: Player): SaphoOption[] {
- if (!saphoCleanWindow(g)) return [];
- const card = p.hand.find((c) => c.id === 'richese-juice-of-sapho');
- if (
- !card ||
- card.effect !== 'juiceOfSapho' ||
- !richeseCardDefinition(card) ||
- p.hand.filter((c) => c.id === card.id).length !== 1 ||
- transferCardBlock(g, p, card) ||
- giftReserved(g, p.id, card.id)
- )
- return [];
- const options: SaphoOption[] = [];
- const lot = g.richeseAuction;
- if (
- g.phase === 3 &&
- lot?.method === 'onceAround' &&
- !lot.outcome &&
- g.order.includes(p.id) &&
- p.hand.length <= handLimit(p) &&
- !lot.acted.includes(p.id)
- ) {
- const remaining = lot.order.filter((id) => !lot.acted.includes(id));
- if (!lot.acted.length && remaining[0] !== p.id)
- options.push({ scope: 'onceAround', event: lot.event, mode: 'first' });
- if (remaining.at(-1) !== p.id)
- options.push({ scope: 'onceAround', event: lot.event, mode: 'last' });
- }
- const queue = saphoMovementQueue(g);
- if (
- queue &&
- !queue.currentStarted &&
- queue.remaining.includes(p.id) &&
- !g.saphoMovementLast
- ) {
- if (
- !queue.completed.length &&
- !g.players.some((other) => other.shipped || other.moved > 0) &&
- !(g.advanced && byFaction(g, 'guild')) &&
- queue.current !== p.id
- )
- options.push({ scope: 'movement', event: queue.event, mode: 'first' });
- if (queue.remaining.at(-1) !== p.id)
- options.push({ scope: 'movement', event: queue.event, mode: 'last' });
- }
- return options;
-}
-function playSapho(g: Game, p: Player, action: Action) {
- requireRule(
- Object.keys(action).every((key) =>
- ['type', 'card', 'scope', 'event', 'mode'].includes(key),
- ),
- 'Choose only Juice of Sapho, one supported scope, its event and first or last.',
- );
- const option = saphoOptions(g, p).find(
- (choice) =>
- choice.event === action.event &&
- choice.scope === action.scope &&
- choice.mode === action.mode,
- );
- requireRule(
- option,
- 'This Juice of Sapho opportunity is unavailable, already completed or expired.',
- );
- if (option.scope === 'onceAround') {
- const lot = g.richeseAuction!;
- // Discarding this card frees a slot even when a full hand excluded its holder
- // at opening. Only this still-open finite lot gains that unacted bidder.
- if (!lot.eligible.includes(p.id)) {
- lot.eligible.push(p.id);
- lot.order.push(p.id);
- lot.tieOrder = g.order.filter((id) => lot.eligible.includes(id));
- }
- g.richeseAuction = reorderRicheseBidder(
- lot,
- option.event,
- p.id,
- option.mode,
- );
- g.active = g.richeseAuction.active;
- discard(g, p, 'richese-juice-of-sapho');
- log(
- g,
- `${p.name} discarded Juice of Sapho to bid ${option.mode} in this Once Around auction. Completed bids and their funding remain committed; no bidder receives another bid.`,
- { faction: p.faction, name: 'Juice of Sapho' },
- );
- settleRicheseLot(g);
- } else {
- const queue = saphoMovementQueue(g)!;
- const next =
- option.mode === 'last'
- ? protectOrderedOpportunityLast(queue, {
- event: option.event,
- player: p.id,
- })
- : reorderOrderedOpportunity(queue, {
- event: option.event,
- player: p.id,
- position: 'first',
- });
- g.movementRemaining = [...next.remaining];
- if (option.mode === 'last')
- g.saphoMovementLast = { event: option.event, turn: g.turn, player: p.id };
- discard(g, p, 'richese-juice-of-sapho');
- log(
- g,
- `${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}. Completed turns and physical storm order are unchanged.`,
- { faction: p.faction, name: 'Juice of Sapho' },
- );
- // Preserve the Guild decision already settled for an unchanged current actor.
- if (g.active !== g.movementRemaining[0]) movementTurn(g);
- }
-}
-
-function movementTurn(g: Game) {
- const remaining = g.movementRemaining!;
- if (!remaining.length) {
- if (offerMoritaniDuke(g)) return;
- nextPhase(g);
- return;
- }
- const guild = byFaction(g, 'guild');
- if (
- g.advanced &&
- guild &&
- !g.guildTimingLocked &&
- remaining.includes(guild.id) &&
- guild.id !== g.saphoMovementLast?.player &&
- remaining.some(
- (id) => id !== guild.id && id !== g.saphoMovementLast?.player,
- )
- ) {
- g.active = null;
- g.decision = {
- kind: 'guildTiming',
- player: guild.id,
- next: remaining[0],
- following: remaining.find((id) => id !== guild.id)!,
- };
- } else g.active = remaining[0];
-}
-function chooseGuildTiming(g: Game, take: boolean) {
- const guild = byFaction(g, 'guild')!;
- if (take) g.active = guild.id;
- else {
- const rest = g.movementRemaining!.filter((id) => id !== guild.id);
- const protectedLast = g.saphoMovementLast?.player;
- rest.splice(
- protectedLast ? rest.indexOf(protectedLast) : rest.length,
- 0,
- guild.id,
- );
- g.movementRemaining = rest;
- g.active = g.movementRemaining[0];
- }
-}
-function advisorBattleOptions(g: Game) {
- settleAdvisors(g);
- const bg = byFaction(g, 'beneGesserit');
- if (!g.advanced || !bg) return [];
- return Object.keys(bg.advisors ?? {}).filter((t) => {
- if (
- bg.advisors![t].lockedTurn === g.turn ||
- (bg.ally && at(getPlayer(g, bg.ally), t))
- )
- return false;
- const probe = structuredClone(g);
- delete getPlayer(probe, bg.id).advisors![t];
- return battles(probe).some(
- (b) => b.territory === t && [b.attacker, b.defender].includes(bg.id),
- );
- });
-}
-function beginAdvisorBattle(g: Game, remaining = advisorBattleOptions(g)) {
- const bg = byFaction(g, 'beneGesserit');
- if (bg && remaining.length)
- g.decision = {
- kind: 'advisorBattle',
- player: bg.id,
- territories: remaining,
- };
- else movementTurn(g);
-}
-function finishAdvisorReaction(
- g: Game,
- details: {
- advisorResume?: 'wormRide' | 'declaration';
- advisorRemaining?: string[];
- advisorFollowup?: { shipment: string; destination: string };
- },
-) {
- const bg = byFaction(g, 'beneGesserit')!;
- if (details.advisorFollowup && bg.reserves > 0)
- g.decision = { kind: 'advisor', player: bg.id, ...details.advisorFollowup };
- else if (details.advisorResume === 'wormRide') nextWormRide(g);
- else if (details.advisorResume === 'declaration')
- beginAdvisorBattle(g, details.advisorRemaining ?? []);
-}
-function intrusion(
- g: Game,
- mover: Player,
- t: string,
- options: {
- wormRide?: boolean;
- followup?: { shipment: string; destination: string };
- } = {},
-) {
- const bg = byFaction(g, 'beneGesserit');
- if (g.advanced && bg && bg.id !== mover.id && fighterCount(bg, t)) {
- g.decision = { kind: 'intrusion', player: bg.id, territory: t, ...options };
- return true;
- }
- return false;
-}
-function assignRemainingTech(g: Game) {
- if (!g.techTokens) return;
- const unassigned = shuffle(
- TECH_TOKENS.filter((t) => !g.techTokens![t.id].owner),
- );
- const recipients = g.order.filter(
- (id) => !ownedTech(g.techTokens, id).length,
- );
- for (const token of unassigned) {
- const id = recipients.shift();
- if (!id) break;
- g.techTokens[token.id].owner = id;
- log(
- g,
- `${getPlayer(g, id).name} received ${token.name} after the first storm.`,
- );
- }
-}
-function techIncome(g: Game, id: TechId, actor: Player) {
- if (!g.techTokens) return;
- const rule = TECH_TOKENS.find((t) => t.id === id)!;
- const token = g.techTokens[id];
- if (
- g.phase !== rule.phase ||
- actor.faction === rule.except ||
- !token.owner ||
- token.triggeredTurn === g.turn
- )
- return;
- token.triggeredTurn = g.turn;
- token.spice = ownedTech(g.techTokens, token.owner).length;
- log(
- g,
- `${rule.name} accrued ${token.spice} spice for ${getPlayer(g, token.owner).name}, payable at phase end.`,
- );
-}
-function settleTechIncome(g: Game) {
- if (!g.techTokens) return;
- for (const rule of TECH_TOKENS) {
- const token = g.techTokens[rule.id];
- if (rule.phase === g.phase && token.owner && token.spice) {
- getPlayer(g, token.owner).spice += token.spice;
- log(
- g,
- `${getPlayer(g, token.owner).name} collected ${token.spice} spice from ${rule.name}.`,
- );
- token.spice = 0;
- }
- }
-}
-function transferTech(g: Game, id: TechId, winner: string) {
- g.techTokens![id].owner = winner;
- log(
- g,
- `${getPlayer(g, winner).name} took ${TECH_TOKENS.find((t) => t.id === id)!.name} after winning the battle.`,
- );
-}
-function openChoamMarket(g: Game, resume: ChoamMarket['resume']) {
- const choam = byFaction(g, 'choam');
- if (g.status !== 'playing' || !choam) return false;
- g.choamMarket = { owner: choam.id, resume, blocked: [] };
- g.decision = { kind: 'choamMarket', player: choam.id };
- return true;
-}
-function resumeChoamMarket(g: Game) {
- g.decision = { kind: 'choamMarket', player: g.choamMarket!.owner };
-}
-function finishChoamMarket(g: Game) {
- const resume = g.choamMarket!.resume;
- g.choamMarket = null;
- g.decision = null;
- if (resume === 'storm') advanceAfterStorm(g);
- else advancePhase(g);
-}
-function nextPhase(g: Game) {
- if (!openChoamMarket(g, 'phase')) advancePhase(g);
-}
-function advancePhase(g: Game) {
- if (g.phase === 6) boardResolution(() => quoteBattlePhaseAdvance(g));
- settleTechIncome(g);
- if ([3, 5, 6].includes(g.phase)) {
- for (const [id, credit] of Object.entries(g.aid))
- getPlayer(g, id).spice += credit.amount;
- g.aid = {};
- }
- g.ready = [];
- g.active = null;
- const choam = byFaction(g, 'choam');
- if (g.phase === 8 && choam) {
- g.choamMentatPending = true;
- g.decision = { kind: 'choamMentat', player: choam.id };
- return;
- }
- const ecaz = byFaction(g, 'ecaz');
- if (g.phase === 4 && ecaz && g.ecazPlacementTurn !== g.turn) {
- g.ecazAmbassadors ??= createAmbassadors(random);
- g.decision = { kind: 'ecazPlacement', player: ecaz.id };
- return;
- }
- completePhase(g);
-}
-function ambassadorPlacementQuote(
- g: Game,
- owner: Player,
- token: string,
- to: string,
-) {
- const destination = TERRITORIES.find((t) => t.id === to);
- requireRule(g.ecazAmbassadors, 'No Ambassador supply is available.');
- try {
- return placeAmbassador(g.ecazAmbassadors, token, {
- turn: g.turn,
- availableSpice: owner.spice,
- destination: {
- id: to,
- stronghold: destination?.type === 'stronghold',
- allowed: !!destination,
- inStorm: !destination || destination.sectors.includes(g.storm),
- },
- });
- } catch (error) {
- throw new RuleError(
- error instanceof Error ? error.message : 'Invalid Ambassador placement.',
- );
- }
-}
-function finishEcazPlacement(g: Game) {
- g.ecazPlacementTurn = g.turn;
- g.pendingEcazPlacement = null;
- g.decision = null;
- completePhase(g);
-}
-function completePhase(g: Game) {
- if (g.phase === 8) settleStrongholdOwnership(g);
- g.phase++;
- if (g.phase === 9) {
- if (g.dukeVidal) g.dukeVidal = expireDuke(g.dukeVidal, g.turn);
- g.turn++;
- g.phase = 0;
- }
- openPhase(g);
-}
-/** Open the same public window for every Ix table, independently of who holds Amal. */
-function openPhase(g: Game, initialize = true) {
- if (g.expansions.includes('ix')) g.phaseOpening = { passed: [], initialize };
- else if (initialize) beginPhase(g);
-}
-function beginPhase(g: Game) {
- if (g.phase === 0) g.auditorInsight = null;
- if (g.phase === 2 && g.choamCharity?.turn !== g.turn) {
- const choam = byFaction(g, 'choam');
- if (choam && charityMultiplier(g) === 0) {
- g.choamCharity = { turn: g.turn, canceled: false };
- log(
- g,
- 'Inflation cancels all charity this turn, including CHOAM income.',
- );
- } else if (choam) {
- g.response = { kind: 'choamCharity', owner: choam.id, passed: [] };
- return;
- }
- }
- if (g.phase === 4) {
- g.revivalRules = newRevivalRules();
- g.revivalRequests = {};
- g.freeRevival = [];
- g.emperorExtra = {};
- for (const p of g.players) {
- p.revived = 0;
- p.freeForcesRevived = 0;
- p.leaderRevived = false;
- }
- }
- if (g.phase === 3) setAuction(g);
- if (g.phase === 5) {
- g.movementRemaining = [...g.order];
- g.saphoMovementLast = null;
- g.guildTimingGranted = false;
- g.guildTimingLocked = false;
- g.spicePeekKnown = false;
- const atreides = byFaction(g, 'atreides');
- if (atreides) {
- refillSpice(g);
- if (g.spiceDeck.length)
- g.response = { kind: 'atreidesSpice', owner: atreides.id, passed: [] };
- }
- for (const p of g.players) {
- p.shipped = false;
- p.moved = 0;
- }
- beginAdvisorBattle(g);
- }
- if (g.phase === 6) {
- const b = battles(g);
- if (b.length) g.active = b[0].attacker;
- else {
- nextPhase(g);
- return;
- }
- }
- if (g.phase === 7) {
- collect(g);
- }
- if (g.phase === 8) {
- if (g.inflation && g.inflation.updatedTurn < g.turn) {
- if (g.inflation.flipped) {
- g.inflation = null;
- log(g, 'The Inflation token was removed from the game.');
- } else {
- g.inflation.side = g.inflation.side === 'double' ? 'cancel' : 'double';
- g.inflation.flipped = true;
- g.inflation.updatedTurn = g.turn;
- log(
- g,
- `Inflation flipped to ${g.inflation.side} for next turn’s charity.`,
- );
- }
- }
- for (const p of g.players) {
- p.spice += p.bribes;
- p.bribes = 0;
- }
- const moritani = byFaction(g, 'moritani');
- if (
- moritani &&
- g.moritaniTerror &&
- g.moritaniTerror.placementTurn !== g.turn
- ) {
- g.decision = { kind: 'moritaniPlacement', player: moritani.id };
- } else if (!byFaction(g, 'choam')) victory(g);
- }
- if (g.phase === 0 && g.turn > 1) {
- const ixians = byFaction(g, 'ixians');
- if (
- g.mobileStronghold?.location &&
- ixians &&
- at(ixians, MOBILE_STRONGHOLD)
- ) {
- g.decision = {
- kind: 'mobileStronghold',
- player: ixians.id,
- placement: false,
- };
- return;
- }
- beginStormTurn(g);
- }
-}
-/** Transfer actual charity only after its applicable power response. */
-function payCharity(g: Game, player: Player, amount: number) {
- const payer = charityPayer(g);
- // The published charity rule does not specify insolvency after intervening
- // spending. Fail atomically while this edge case remains under audit; never
- // mint a bank subsidy or create a negative spice balance silently.
- requireRule(
- !payer || payer.spice >= amount,
- 'CHOAM cannot fund this charity claim.',
- );
- if (payer) payer.spice -= amount;
- player.spice += amount;
- techIncome(g, 'production', player);
-}
-function beginStormTurn(g: Game) {
- if (g.dukeVidal) delete g.dukeVidal.leader.usedAt;
- for (const p of g.players) if (p.elites) p.elites.revived = 0;
- g.stormDialers = g.lastBattle;
- if (g.advanced && byFaction(g, 'fremen')) {
- g.stormDialers = [];
- g.stormPending = g.stormCard ?? shuffle([1, 2, 3, 4, 5, 6])[0];
- g.stormCard = null;
- g.stormCardKnown = false;
- log(g, `Storm card revealed: ${g.stormPending} sectors.`);
- }
- for (const p of g.players) {
- for (const l of p.leaders) {
- delete l.usedAt;
- if (l.concealed) delete l.concealed.usedAt;
- }
- if (p.kwisatz) delete p.kwisatz.usedAt;
- }
-
- g.hajr = [];
- g.nexus = false;
- log(g, `Turn ${g.turn} begins.`);
-}
-function collect(g: Game) {
- const quote = boardResolution(() => quoteSpiceCollection(g));
- applyAdvisorReleases(g, quote.released);
- g.spice = quote.spice;
- for (const receipt of quote.receipts) {
- const p = getPlayer(g, receipt.player);
- p.spice = receipt.balance;
- if (receipt.strongholds)
- log(
- g,
- `${faction(p.faction).name} received ${receipt.strongholds} spice from strongholds.`,
- );
- if (receipt.collected)
- log(
- g,
- `${faction(p.faction).name} collected ${receipt.collected} spice.`,
- );
- }
-}
-function victory(g: Game) {
- settleAdvisors(g);
- const holds = (ids: string[]) =>
- gameTerritories(g).filter(
- (t) =>
- t.type === 'stronghold' &&
- ids.some((id) => fighterCount(getPlayer(g, id), t.id) > 0) &&
- g.players.every(
- (p) => ids.includes(p.id) || fighterCount(p, t.id) === 0,
- ),
- ).length;
- for (const id of g.order) {
- const p = getPlayer(g, id);
- const ids = p.ally ? [id, p.ally] : [id];
- const techHold = ids.some((owner) => techStronghold(g.techTokens, owner))
- ? 1
- : 0;
- if (holds(ids) + techHold >= (p.ally || g.players.length === 2 ? 4 : 3)) {
- g.winner = [...new Set([...g.winner, ...ids])];
- }
- }
- const prediction = byFaction(g, 'beneGesserit');
- if (
- prediction?.prediction?.turn === g.turn &&
- g.winner.some(
- (id) => getPlayer(g, id).faction === prediction.prediction!.faction,
- )
- )
- g.winner = [prediction.id];
- if (!g.winner.length && g.turn === 10) {
- const fremen = byFaction(g, 'fremen'),
- guild = byFaction(g, 'guild');
- if (
- fremen &&
- g.players.every(
- (p) =>
- p.id === fremen.id ||
- (!fighterCount(p, 'sietch_tabr') &&
- !fighterCount(p, 'habbanya_ridge_sietch')),
- ) &&
- g.players
- .filter((p) =>
- [
- 'atreides',
- 'harkonnen',
- 'emperor',
- ...(g.advanced ? ['richese'] : []),
- ].includes(p.faction),
- )
- .every((p) => !at(p, 'tueks_sietch'))
- )
- g.winner = fremen.ally ? [fremen.id, fremen.ally] : [fremen.id];
- else if (guild) g.winner = guild.ally ? [guild.id, guild.ally] : [guild.id];
- else if (fremen)
- g.winner = fremen.ally ? [fremen.id, fremen.ally] : [fremen.id];
- else {
- const scores = g.players.map((p) => ({
- id: p.id,
- holds: holds([p.id]) + techStronghold(g.techTokens, p.id),
- }));
- const most = Math.max(...scores.map((p) => p.holds));
- g.winner = scores.filter((p) => p.holds === most).map((p) => p.id);
- const bg = byFaction(g, 'beneGesserit');
- if (
- bg?.prediction?.turn === g.turn &&
- g.winner.some(
- (id) => getPlayer(g, id).faction === bg.prediction!.faction,
- )
- )
- g.winner = [bg.id];
- }
- g.status = 'finished';
- }
- if (g.winner.length) {
- g.status = 'finished';
- log(
- g,
- `${g.winner.map((id) => faction(getPlayer(g, id).faction).name).join(' and ')} won the game.`,
- );
- } else if (g.status === 'finished')
- log(g, 'The tenth turn ended without a winner.');
- if (g.status === 'finished') settleStrongholdOwnership(g);
-}
-function cardOf(p: Player, id: string | null) {
- return p.hand.find((c) => c.id === id);
-}
-function validateResidualPoison(
- g: Game,
- p: Player,
- cardId: string,
- targetId: string,
- event: string,
-) {
- const b = g.battle;
- requireRule(
- g.status === 'playing' &&
- g.phase === 6 &&
- b &&
- !b.revealed &&
- [b.attacker, b.defender].includes(p.id),
- 'Use Residual Poison against your opponent in the current battle.',
- );
- requireRule(
- b.event && b.event === event,
- 'This battle opportunity is stale or predates the saved card timing.',
- );
- requireRule(
- targetId === (b.attacker === p.id ? b.defender : b.attacker),
- 'Residual Poison targets your actual opposing combatant.',
- );
- requireRule(
- !Object.keys(b.plans).length &&
- !(b.prescience?.field === 'leader' && 'value' in b.prescience),
- 'Residual Poison must be played before either combatant commits a leader.',
- );
- requireRule(
- !g.truthtrance && !g.response && !g.decision && !g.phaseOpening,
- 'Resolve the current interaction before playing Residual Poison.',
- );
- const card = p.hand.find((c) => c.id === cardId);
- requireRule(
- card && richeseCardDefinition(card)?.card.effect === 'residualPoison',
- 'Choose the canonical Residual Poison in your hand.',
- );
- const reserved = transferCardBlock(g, p, card);
- requireRule(!reserved, reserved ?? 'The card is committed.');
- // Guard on public configuration, never on whether a particular hidden captive exists.
- requireRule(
- !(g.advanced && byFaction(g, 'harkonnen')),
- 'Residual Poison with advanced Harkonnen capture awaits the secret-captive disclosure ruling.',
- );
- const target = getPlayer(g, targetId),
- controlled = controlledLeaders(g, target);
- requireRule(
- !controlled.some(
- (l) =>
- l.concealed ||
- (l.capturedBy && l.gholaBy) ||
- (l.id === DUKE_VIDAL_ID && (l.capturedBy || l.gholaBy)),
- ),
- 'This exceptional leader custody is not yet supported for Residual Poison.',
- );
- const candidates = noFieldRule(() =>
- residualPoisonCandidates(controlled, b.territory),
- );
- requireRule(
- candidates.length > 0,
- 'The opposing combatant has no available physical leader for this card.',
- );
- for (const candidate of candidates)
- noFieldRule(() => residualPoisonDeath(candidate));
- const hand = p.hand.filter((c) => c.id !== card.id);
- validateTransferCompletion(g, p, p, hand, hand, {
- type: 'card',
- card: card.id,
- });
- return { b, card, target, candidates };
-}
-function playResidualPoison(
- g: Game,
- p: Player,
- cardId: string,
- targetId: string,
- event: string,
-) {
- const { b, card, target, candidates } = validateResidualPoison(
- g,
- p,
- cardId,
- targetId,
- event,
- );
- const selected = shuffle(candidates)[0];
- const victim = controlledLeaders(g, target).find(
- (l) => l.id === selected.id,
- )!;
- const dead = residualPoisonDeath(victim);
- Object.assign(victim, dead);
- delete victim.usedAt;
- if (victim.capturedBy) {
- delete victim.capturedBy;
- delete victim.concealed;
- }
- if (victim.id === DUKE_VIDAL_ID && g.dukeVidal)
- g.dukeVidal = consumeDuke(g.dukeVidal);
- const harkonnen = byFaction(g, 'harkonnen');
- if (harkonnen) returnCaptives(g, harkonnen);
- discard(g, p, card.id);
- log(
- g,
- `${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`,
- { faction: p.faction, name: 'Residual Poison' },
- );
- // Opposing involuntary death may release an impossible answer; it never rerolls the victim.
- reconcileBattlePromises(g);
- const insight = b.prescience;
- if (insight && 'value' in insight && insight.field !== 'leader') {
- const answering = getPlayer(
- g,
- insight.player === b.attacker ? b.defender : b.attacker,
- );
- if (!feasiblePrescience(g, answering, insight.field, insight.value)) {
- delete insight.value;
- b.preparation = {
- kind: 'prescienceAnswer',
- owner: answering.id,
- beneficiary: insight.player,
- };
- log(
- g,
- 'The prior inspected element no longer permits a legal battle plan after the leader death. Its owner must answer that same element again; the earlier private value is not revealed.',
- );
- }
- }
- if (b.preLeader && !b.preLeader.closed)
- b.preLeader.ready = b.preLeader.ready.filter((id) => id !== p.id);
-}
-function residualPoisonView(g: Game, p: Player) {
- const card = p.hand.find(
- (c) => richeseCardDefinition(c)?.card.effect === 'residualPoison',
- );
- if (!card) return null;
- const b = g.battle,
- target =
- b && [b.attacker, b.defender].includes(p.id)
- ? b.attacker === p.id
- ? b.defender
- : b.attacker
- : null;
- let blocked: string | null = null;
- try {
- validateResidualPoison(g, p, card.id, target ?? '', b?.event ?? '');
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- blocked = error.message;
- }
- return { card, event: b?.event ?? null, target, blocked };
-}
-function controlledLeaders(g: Game, p: Player) {
- const native = g.players.flatMap((owner) =>
- owner.leaders.filter((l) => controlsLeader(p, l)),
- );
- const duke = g.dukeVidal;
- if (
- duke &&
- duke.controller === p.id &&
- !duke.leader.capturedBy &&
- !duke.leader.gholaBy &&
- !(g.advanced && byFaction(g, 'harkonnen'))
- )
- native.push(duke.leader);
- return native;
-}
-function returnCaptives(g: Game, captor: Player) {
- if (captor.leaders.some((l) => !l.dead)) return;
- let returned = 0;
- for (const owner of g.players)
- for (const l of owner.leaders)
- if (l.capturedBy === captor.id) {
- delete l.capturedBy;
- delete l.concealed;
- returned++;
- }
- if (returned)
- log(
- g,
- `${captor.name} returned ${returned} captive leaders because all native leaders are dead.`,
- );
-}
-function captureCandidates(g: Game, loser: string, t: string) {
- return controlledLeaders(g, getPlayer(g, loser)).filter(
- (l) => !isAuditorLeader(l) && !l.dead && (!l.usedAt || l.usedAt === t),
- );
-}
-function projectLeader(g: Game, l: Leader, viewer: string): Leader {
- const { concealed, capturedBy, ...visible } = l;
- const owner = g.players.find((p) => p.faction === l.faction);
- if (
- concealed &&
- ![concealed.controller ?? owner?.id, concealed.captor].includes(viewer) &&
- !(getPlayer(g, viewer).faction === 'tleilaxu' && l.dead && !l.capturedBy)
- ) {
- const result = {
- ...visible,
- dead: concealed.dead,
- deaths: concealed.deaths,
- };
- delete result.usedAt;
- if (concealed.usedAt) result.usedAt = concealed.usedAt;
- return result;
- }
- return { ...visible, ...(capturedBy ? { capturedBy } : {}) };
-}
-/** Public alternatives deliberately never read a foreign No-Field token value. */
-function stonePublicPools(
- g: Game,
- p: Player,
- opponent: Player,
-): CombatForces[] {
- const t = g.battle!.territory;
- const hiddenMarker = p.noField?.deployed?.location.territory === t;
- const pool = combatForces(
- g,
- hiddenMarker ? { ...p, noField: undefined } : p,
- t,
- opponent,
- );
- return hiddenMarker
- ? Array.from({ length: 6 }, (_, n) => ({
- ...pool,
- normal: pool.normal + n,
- }))
- : [pool];
-}
-function stoneTimingBlock(g: Game): string | null {
- return g.expansions.includes('ix')
- ? 'Stone Burner with the Ix expansion is awaiting a ruling on its simultaneous revealed choice with Poison Tooth. This implementation boundary applies before either hidden plan is submitted.'
- : null;
-}
-function stonePlanBlock(
- g: Game,
- p: Player,
- dial: number,
- support: number,
-): string | null {
- const b = g.battle!;
- const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
- return (
- stoneTimingBlock(g) ??
- stonePublicPools(g, opponent, p)
- .map((pool) =>
- stoneBurnerPlanBlock(
- combatForces(g, p, b.territory, opponent),
- dial,
- support,
- pool,
- b.attacker === p.id ? 'attacker' : 'defender',
- battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
- ),
- )
- .find(Boolean) ??
- null
- );
-}
-function stoneCompulsionBlock(g: Game, target: Player): string | null {
- const b = g.battle!;
- const other = getPlayer(
- g,
- b.attacker === target.id ? b.defender : b.attacker,
- );
- const timing = stoneTimingBlock(g);
- if (timing) return timing;
- const opponents = stonePublicPools(g, other, target);
- for (const own of stonePublicPools(g, target, other)) {
- const supported = Array.from(
- { length: (own.normal + own.elite * own.eliteStrength) * 2 + 1 },
- (_, n) => n / 2,
- ).some(
- (dial) =>
- casualtyOptions(own, dial, 0).length &&
- opponents.every(
- (opponent) =>
- !stoneBurnerPlanBlock(
- own,
- dial,
- 0,
- opponent,
- b.attacker === target.id ? 'attacker' : 'defender',
- battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
- ),
- ),
- );
- if (!supported)
- return 'Compelling Stone Burner cannot guarantee a supported zero-spice plan across the public force possibilities; the combined allocation timing is unresolved.';
- }
- return null;
-}
-function validatePlan(
- g: Game,
- p: Player,
- input: Record,
- promises = g.battle?.truthPromises ?? [],
-): Plan {
- const b = g.battle!;
- const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
- const forces = combatForces(g, p, b.territory, opponent);
- const typedForces = g.advanced || p.faction === 'ixians';
- const dial = typedForces
- ? Number(input.dial)
- : integer(input.dial, 0, forces.normal + forces.elite, 'Forces dialed');
- const support = g.advanced
- ? integer(input.support ?? 0, 0, battleSupportBudget(g, p), 'Spice support')
- : 0;
- requireRule(
- !typedForces ||
- (typeof input.dial === 'number' &&
- casualtyOptions(forces, dial, support).length > 0),
- 'Dial and spice must match a legal force commitment.',
- );
- const allyPayment = integer(
- input.allyPayment ??
- Math.max(0, battleSupportCost(g, p, support) - p.spice),
- Math.max(0, battleSupportCost(g, p, support) - p.spice),
- Math.min(battleSupportCost(g, p, support), battleAidFor(g, p)?.amount ?? 0),
- 'Battle ally payment',
- );
- const plan: Plan = {
- ...(allyPayment ? { allyPayment } : {}),
- dial,
- leader:
- typeof input.leader === 'string' && input.leader ? input.leader : null,
- weapon:
- typeof input.weapon === 'string' && input.weapon ? input.weapon : null,
- defense:
- typeof input.defense === 'string' && input.defense ? input.defense : null,
- support,
- };
- requireRule(
- input.kwisatz === undefined || typeof input.kwisatz === 'boolean',
- 'Choose whether to use the Kwisatz Haderach.',
- );
- if (input.kwisatz) {
- requireRule(
- g.advanced &&
- p.faction === 'atreides' &&
- p.battleLosses >= 7 &&
- !p.kwisatz?.dead &&
- (!p.kwisatz?.usedAt || p.kwisatz.usedAt === b.territory) &&
- !b.kwisatzBlocked,
- 'Kwisatz Haderach is not available in this battle.',
- );
- requireRule(
- plan.leader,
- 'Kwisatz Haderach must accompany a leader or Cheap Hero.',
- );
- plan.kwisatz = true;
- }
- const available = controlledLeaders(g, p).filter(
- (l) => !l.dead && (!l.usedAt || l.usedAt === b.territory),
- );
- const l = available.find((l) => l.id === plan.leader);
- const hero = cardOf(p, plan.leader)?.kind === 'hero';
- requireRule(
- !plan.leader || l || hero,
- 'That leader is not available for this battle.',
- );
- requireRule(
- plan.leader ||
- (!available.length &&
- (!p.hand.some((c) => c.kind === 'hero') ||
- (b.voice?.target === p.id &&
- !b.voice.must &&
- b.voice.kind === 'hero'))),
- 'You must use an available leader or cheap hero.',
- );
- const w = cardOf(p, plan.weapon),
- d = cardOf(p, plan.defense);
- requireRule(
- !plan.weapon || (w && isWeaponCard(w)),
- 'Choose a weapon or worthless card.',
- );
- requireRule(
- !plan.defense || (d && isDefenseCard(d)),
- 'Choose a defense or worthless card.',
- );
- requireRule(
- !plan.weapon || plan.weapon !== plan.defense,
- 'One card cannot fill both slots.',
- );
- requireRule(
- plan.leader || (!plan.weapon && !plan.defense),
- 'Without a leader, no battle cards can be played.',
- );
- requireRule(
- validBattleCardPair(w, d),
- 'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.',
- );
- if (isStoneBurner(w)) {
- const blocked = stonePlanBlock(g, p, dial, support);
- requireRule(
- !blocked,
- blocked ?? 'Stone Burner cannot resolve this commitment.',
- );
- }
- if (b.voice?.target === p.id) {
- const v = b.voice;
- const canPlayCards =
- available.length > 0 || p.hand.some((c) => c.kind === 'hero');
- const has =
- canPlayCards && p.hand.some((c) => defaultVoiceMatch(c, v.kind));
- const used =
- playedVoiceMatch(cardOf(p, plan.leader), 'leader', v.kind) ||
- playedVoiceMatch(w, 'weapon', v.kind) ||
- playedVoiceMatch(d, 'defense', v.kind);
- requireRule(
- v.must ? !has || used : !used,
- 'Your battle plan must comply with the Voice.',
- );
- }
- const prescience = b.prescience;
- if (prescience && prescience.player !== p.id && 'value' in prescience)
- requireRule(
- plan[prescience.field] === prescience.value,
- 'The element revealed to Atreides must remain unchanged.',
- );
- requireRule(
- respectsBattlePromises(
- promises,
- p.id,
- { ...plan, kwisatz: !!plan.kwisatz },
- p.hand,
- ),
- 'Your battle plan must honor your Truthtrance answers.',
- );
- return plan;
-}
-function currentCombatResponseQuote(
- g: Game,
- operation: CombatResponseOperation,
-) {
- try {
- return quoteCombatResponse(
- {
- status: g.status,
- phase: g.phase,
- advanced: g.advanced,
- territoryIds: gameTerritories(g).map((t) => t.id),
- players: g.players.map((p) => ({
- id: p.id,
- faction: p.faction,
- ally: p.ally,
- specialKaramaUsed: p.specialKaramaUsed,
- })),
- battle: g.battle,
- },
- operation,
- );
- } catch (error) {
- if (error instanceof CombatResponseQuoteError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function commitCombatResponseQuote(g: Game, quote: CombatResponseQuote) {
- const b = g.battle!;
- if (quote.powerChecks !== undefined) b.powerChecks = quote.powerChecks;
- const patch = quote.patches;
- if (patch.eliteBlocked !== undefined) b.eliteBlocked = patch.eliteBlocked;
- if (patch.fremenSupportBlocked !== undefined)
- b.fremenSupportBlocked = patch.fremenSupportBlocked;
- if (patch.kwisatzBlocked !== undefined)
- b.kwisatzBlocked = patch.kwisatzBlocked;
- if (patch.choamAidBlocked !== undefined)
- b.choamAidBlocked = patch.choamAidBlocked;
- if (patch.deleteVoice) delete b.voice;
- if (patch.deletePrescience) delete b.prescience;
- if (patch.deletePreparation) delete b.preparation;
- if (patch.fullPlanOffered) b.fullPlanOffered = true;
- if (quote.response) g.response = quote.response;
- if (quote.decision) g.decision = quote.decision;
-}
-function nextCombatResponse(g: Game) {
- commitCombatResponseQuote(g, currentCombatResponseQuote(g, { kind: 'next' }));
-}
-function combatResponses(g: Game) {
- const b = g.battle!;
- b.powerChecks = [];
- for (const id of [b.attacker, b.defender]) {
- const p = getPlayer(g, id),
- other = getPlayer(g, id === b.attacker ? b.defender : b.attacker);
- if (g.advanced && p.faction === 'atreides')
- b.powerChecks.push({ kind: 'kwisatz', owner: id });
- if (
- ((g.advanced && ['emperor', 'fremen'].includes(p.faction)) ||
- (!g.advanced && p.faction === 'ixians')) &&
- !(p.faction === 'emperor' && other.faction === 'fremen') &&
- combatForces(g, p, b.territory, other).elite > 0
- )
- b.powerChecks.push({ kind: 'eliteStrength', owner: id });
- if (g.advanced && p.faction === 'fremen')
- b.powerChecks.push({ kind: 'fremenSupport', owner: id });
- }
- const choam = byFaction(g, 'choam');
- if (
- g.advanced &&
- choam?.ally &&
- [b.attacker, b.defender].includes(choam.ally) &&
- getPlayer(g, choam.ally).ally === choam.id
- )
- b.powerChecks.push({ kind: 'choamBattleAid', owner: choam.id });
- nextCombatResponse(g);
-}
-function finishBattlePreparation(g: Game) {
- commitCombatResponseQuote(
- g,
- currentCombatResponseQuote(g, { kind: 'finishPreparation' }),
- );
-}
-function battlePreparation(g: Game, kind: 'voice' | 'prescience') {
- const b = g.battle!;
- const owner = byFaction(g, kind === 'voice' ? 'beneGesserit' : 'atreides');
- const combatants = [b.attacker, b.defender];
- const beneficiary =
- owner &&
- (combatants.includes(owner.id)
- ? owner.id
- : owner.ally && combatants.includes(owner.ally)
- ? owner.ally
- : null);
- if (owner && beneficiary)
- b.preparation = { kind, owner: owner.id, beneficiary };
- else if (kind === 'voice') battlePreparation(g, 'prescience');
- else finishBattlePreparation(g);
-}
-function normalizeBattle(g: Game) {
- const b = g.battle;
- if (!b || b.prepared || b.revealed) return;
- b.prepared = true;
- if (b.prescience && !('value' in b.prescience)) {
- const target = b.prescience.player === b.attacker ? b.defender : b.attacker;
- const priorPlan = b.plans[target];
- if (priorPlan) b.prescience.value = priorPlan[b.prescience.field];
- else
- b.preparation = {
- kind: 'prescienceAnswer',
- owner: target,
- beneficiary: b.prescience.player,
- };
- } else if (!Object.keys(b.plans).length)
- battlePreparation(g, b.voice ? 'prescience' : 'voice');
-}
-function applyGholaEffect(g: Game, p: Player, action: Action) {
- if (action.leader === 'kwisatz') {
- requireRule(
- g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
- 'Kwisatz Haderach is not in the tanks.',
- );
- p.kwisatz.dead = false;
- p.kwisatz.revivalCycle =
- Math.max(p.revivalCycle, p.kwisatz.revivalCycle ?? 1) + 1;
- } else if (action.leader) {
- const l = controlledLeaders(g, p).find((l) => l.id === action.leader);
- requireRule(l?.dead && !l.capturedBy, 'Choose a dead leader in your pool.');
- l.dead = false;
- delete l.concealed;
- } else {
- const n = integer(
- action.amount ?? Math.min(5, p.tanks),
- 1,
- Math.min(5, p.tanks),
- 'Forces',
- );
- const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
- requireRule(
- elite <= eliteRevivalRemaining(p),
- 'Only one elite force may be revived per turn.',
- );
- p.tanks -= n;
- p.reserves += n;
- if (p.elites) {
- p.elites.tanks -= elite;
- p.elites.reserves += elite;
- p.elites.revived += elite;
- }
- }
- techIncome(g, 'axlotl', p);
- collectRevivalIncome(g, p, 0, false, true);
-}
-/** Search complete legal plans without learning or consulting the opponent's sealed choices. */
-function findLegalBattlePlan(
- g: Game,
- p: Player,
- options: {
- promises?: BattlePromise[];
- prescience?: { field: PlanField; value: unknown };
- } = {},
-): Plan | null {
- const b = g.battle!;
- const promises = options.promises ?? b.truthPromises ?? [];
- const fixed = options.prescience;
- const accepts = (plan: Partial) =>
- respectsBattlePromises(promises, p.id, plan, p.hand) &&
- (!fixed ||
- plan[fixed.field] === undefined ||
- plan[fixed.field] === fixed.value);
- if (b.plans[p.id]) {
- const sealed = { ...b.plans[p.id], kwisatz: !!b.plans[p.id].kwisatz };
- return accepts(sealed) ? sealed : null;
- }
- const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
- const forces = combatForces(g, p, b.territory, opponent);
- const typed = g.advanced || p.faction === 'ixians';
- const maxDial = typed
- ? forces.normal * (forces.normalFixedHalf ? 0.5 : 1) +
- forces.elite * forces.eliteStrength
- : forces.normal + forces.elite;
- const numerical: { dial: number; support: number }[] = [];
- for (let dial = 0; dial <= maxDial; dial += typed ? 0.5 : 1)
- for (
- let support = 0;
- support <=
- (g.advanced && !forces.freeSupport
- ? Math.min(battleSupportBudget(g, p), forces.normal + forces.elite)
- : 0);
- support++
- ) {
- const n = { dial, support };
- if (
- accepts(n) &&
- (!typed || casualtyOptions(forces, dial, support).length)
- )
- numerical.push(n);
- }
- if (!numerical.length) return null;
- const leaderIds = [
- null,
- ...controlledLeaders(g, p)
- .filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory))
- .map((l) => l.id),
- ...p.hand.filter((c) => c.kind === 'hero').map((c) => c.id),
- ];
- const weapons = [null, ...p.hand.filter(isWeaponCard).map((c) => c.id)];
- const defenses = [
- null,
- ...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id),
- ];
- for (const leader of leaderIds) {
- if (!accepts({ leader })) continue;
- for (const weapon of weapons) {
- if (!accepts({ leader, weapon })) continue;
- for (const defense of defenses) {
- if (
- !accepts({ leader, weapon, defense }) ||
- !validBattleCardPair(cardOf(p, weapon), cardOf(p, defense))
- )
- continue;
- for (const kwisatz of [false, true]) {
- const partial = { leader, weapon, defense, kwisatz };
- if (!accepts(partial)) continue;
- for (const n of numerical) {
- const candidate = { ...partial, ...n };
- if (!accepts(candidate)) continue;
- try {
- return validatePlan(g, p, candidate, promises);
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- }
- }
- }
- }
- }
- }
- return null;
-}
-type BattleCompletion = {
- plan: Plan;
- actions: Action[];
- waitingForIncome: boolean;
-};
-function gholaPreparationActions(g: Game, p: Player): Action[] {
- const card = p.hand.find((c) => c.effect === 'ghola');
- if (!card) return [];
- const actions: Action[] = controlledLeaders(g, p)
- .filter((l) => l.dead && !l.capturedBy)
- .sort((a, b) => b.strength - a.strength)
- .map((l) => ({ type: 'card', card: card.id, leader: l.id }));
- if (g.advanced && p.faction === 'atreides' && p.kwisatz?.dead)
- actions.push({ type: 'card', card: card.id, leader: 'kwisatz' });
- for (let amount = 1; amount <= Math.min(5, p.tanks); amount++)
- for (
- let elite = Math.max(0, amount - (p.tanks - (p.elites?.tanks ?? 0)));
- elite <= Math.min(amount, p.elites?.tanks ?? 0, eliteRevivalRemaining(p));
- elite++
- )
- actions.push({ type: 'card', card: card.id, amount, elite });
- return actions;
-}
-function cashInPreparationActions(g: Game, p: Player): Action[] {
- if (p.faction !== 'choam' || !g.advanced || p.specialKaramaUsed) return [];
- const available = cashInCards(g, p);
- const actions: Action[] = [];
- for (const card of available.filter((c) => c.effect === 'karama')) {
- const others = available.filter((c) => c.id !== card.id);
- for (let mask = 1; mask < 2 ** others.length; mask++)
- actions.push({
- type: 'card',
- mode: 'special',
- card: card.id,
- cards: others.filter((_, i) => mask & (2 ** i)).map((c) => c.id),
- });
- }
- return actions;
-}
-/** Pending income can still be allowed; it is never counted as already paid. */
-function pendingBattleRevivalIncome(g: Game, p: Player): number {
- const r =
- g.response?.kind === 'revivalIncome'
- ? g.response
- : g.pendingKarama?.use.kind === 'cancel'
- ? g.pendingKarama.use.response
- : null;
- return r?.kind === 'revivalIncome' && r.owner === p.id ? (r.amount ?? 0) : 0;
-}
-function findReachableBattlePlan(
- g: Game,
- p: Player,
- options: Parameters[2] = {},
-): BattleCompletion | null {
- const immediate = findLegalBattlePlan(g, p, options);
- if (immediate)
- return { plan: immediate, actions: [], waitingForIncome: false };
- if (g.battle!.revealed || g.battle!.plans[p.id]) return null;
- const initial = structuredClone(g);
- const player = getPlayer(initial, p.id);
- const pending = pendingBattleRevivalIncome(initial, player);
- if (pending) player.spice += pending;
- // The real table must finish its current window before it can execute a preparation action.
- initial.response = null;
- initial.pendingKarama = null;
- // Search after Truthtrance resolves, retaining every existing or proposed
- // battle promise. This does not release a promise on the real table.
- initial.truthtrance = null;
- const queue: { state: Game; actions: Action[] }[] = [
- { state: initial, actions: [] },
- ];
- for (let index = 0; index < queue.length; index++) {
- const node = queue[index],
- actor = getPlayer(node.state, p.id);
- if (node.actions.length || pending) {
- const plan = findLegalBattlePlan(node.state, actor, options);
- if (plan)
- return { plan, actions: node.actions, waitingForIncome: pending > 0 };
- }
- for (const action of [
- ...gholaPreparationActions(node.state, actor),
- ...cashInPreparationActions(node.state, actor),
- ]) {
- const trial = structuredClone(node.state),
- target = getPlayer(trial, p.id);
- try {
- if (action.mode === 'special') specialKarama(trial, target, action);
- else {
- applyGholaEffect(trial, target, action);
- discard(trial, target, action.card as string);
- }
- // Cancellation remains a real response later; a possible allowed branch proves reachability.
- target.spice += pendingBattleRevivalIncome(trial, target);
- trial.response = null;
- trial.pendingKarama = null;
- queue.push({ state: trial, actions: [...node.actions, action] });
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- }
- }
- }
- return null;
-}
-function shipmentPromiseIntegrity(g: Game) {
- try {
- validateSavedShipmentQuestion(g);
- } catch (error) {
- if (error instanceof TruthError) throw new RuleError(error.message);
- throw error;
- }
- const promises = g.shipmentPromises;
- requireRule(
- promises === undefined || Array.isArray(promises),
- 'The saved shipment promises are invalid.',
- );
- for (const promise of promises ?? []) {
- requireRule(
- promise &&
- typeof promise === 'object' &&
- Number.isSafeInteger(promise.turn) &&
- promise.turn >= 1 &&
- promise.turn <= g.turn &&
- g.players.some((p) => p.id === promise.player) &&
- g.players.some(
- (p) => p.id === promise.asker && p.id !== promise.player,
- ) &&
- TERRITORIES.some((t) => t.id === promise.territory) &&
- Number.isSafeInteger(promise.minimum) &&
- promise.minimum >= 1 &&
- promise.minimum <= 20 &&
- typeof promise.answer === 'boolean' &&
- (promise.released === undefined ||
- typeof promise.released === 'boolean') &&
- (promise.fulfilled === undefined ||
- typeof promise.fulfilled === 'boolean') &&
- !(promise.released && promise.fulfilled),
- 'The saved shipment promise is invalid; restore its table state before continuing.',
- );
- if (promise.turn === g.turn && !promise.released && !promise.fulfilled)
- requireRule(
- !g.advanced &&
- !g.expansions.length &&
- g.status === 'playing' &&
- g.phase === 5 &&
- g.active === promise.player &&
- !getPlayer(g, promise.player).shipped,
- 'The saved shipment promise does not belong to this unused Basic shipment opportunity.',
- );
- }
-}
-/** Search owned preparation and canonical shipment quotes, never the AI's scored shortlist. */
-function findShipmentCompletion(
- state: Game,
- owner: Player,
- promises = liveShipmentPromises(
- state.shipmentPromises ?? [],
- owner.id,
- state.turn,
- ),
-): { actions: Action[] } | null {
- if (
- state.status !== 'playing' ||
- state.phase !== 5 ||
- state.active !== owner.id ||
- owner.shipped
- )
- return null;
- if (!promises.some((p) => p.answer))
- return { actions: [{ type: 'endMovement' }] };
- const initial = structuredClone(state);
- initial.truthtrance = null;
- // Test choices after the interrupt, while leaving the real continuation intact.
- // Every actual action still revalidates its timing, quote and physical custody.
- initial.response = null;
- initial.decision = null;
- initial.phaseOpening = null;
- initial.shipmentPromises = [];
- const queue: { game: Game; actions: Action[] }[] = [
- { game: initial, actions: [] },
- ];
- const visited = new Set();
- for (let index = 0; index < queue.length; index++) {
- const { game: g, actions } = queue[index];
- const p = getPlayer(g, owner.id);
- const signature = JSON.stringify([
- p.spice,
- p.reserves,
- p.tanks,
- p.hand.map((c) => c.id),
- g.aid,
- g.karamaShipping,
- ]);
- if (visited.has(signature)) continue;
- visited.add(signature);
- const destination = promises.find((p) => p.answer)!.territory;
- for (let amount = 1; amount <= p.reserves; amount++) {
- if (
- !promises.every(
- (p) =>
- matchesShipment(p, { territory: destination, amount }) === p.answer,
- )
- )
- continue;
- for (const sector of territory(destination).sectors) {
- const cost = reserveShipmentCost(
- {
- faction: p.faction,
- halfRate:
- p.faction === 'guild' ||
- byFaction(g, 'guild')?.id === p.ally ||
- g.karamaShipping?.player === p.id,
- },
- territory(destination).type,
- amount,
- );
- try {
- const allyPayment = contribution(g, p, cost);
- const elite = eliteChoice(
- amount,
- p.reserves,
- p.elites?.reserves ?? 0,
- undefined,
- );
- validatePhysicalShipment(g, {
- turn: g.turn,
- player: p.id,
- territory: destination,
- sector,
- amount,
- elite,
- cost,
- allyPayment,
- advisors: arrivalAsAdvisor(g, p, destination),
- });
- return {
- actions: [
- ...actions,
- {
- type: 'ship',
- territory: destination,
- sector,
- amount,
- elite,
- allyPayment,
- },
- ],
- };
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- }
- if (p.faction === 'fremen' && byFaction(g, 'guild')?.id === p.ally) {
- const action: Action = {
- type: 'guildShip',
- from: 'reserves',
- territory: destination,
- sector,
- amount,
- };
- try {
- applyActionInner(g, p.id, action);
- return { actions: [...actions, action] };
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- }
- }
- }
- }
- // These Basic preparations are deterministic and use only the respondent's
- // own cards and recoverable escrow. More reserves/lower cost cannot remove
- // a previously available choice of physical shipment count.
- const preparations: Action[] = [];
- if (g.aid[p.id]?.amount > 0)
- preparations.push({ type: 'pledgeAid', amount: 0 });
- const karama = p.hand.find((c) => canUseAsKarama(g.advanced, p.faction, c));
- if (karama && !g.karamaShipping)
- preparations.push({
- type: 'card',
- card: karama.id,
- mode: 'shipment',
- target: p.id,
- });
- const ghola = p.hand.find((c) => c.effect === 'ghola');
- if (ghola && p.tanks > 0)
- preparations.push({
- type: 'card',
- card: ghola.id,
- amount: Math.min(5, p.tanks),
- });
- for (const action of preparations) {
- try {
- const next = applyActionInner(g, p.id, action, 'shipmentPreparation');
- queue.push({ game: next, actions: [...actions, action] });
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- }
- }
- }
- return null;
-}
-function shipmentTruthAnswers(
- g: Game,
- p: Player,
- claim: ShipmentClaim,
-): TruthAnswer[] {
- const prior = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn);
- const answers: TruthAnswer[] = [];
- for (const answer of [true, false]) {
- const promises = [
- ...prior,
- { ...claim, turn: g.turn, player: p.id, asker: '', answer },
- ];
- if (findShipmentCompletion(g, p, promises))
- answers.push(answer ? 'yes' : 'no');
- }
- return answers.length ? answers : ['unknown'];
-}
-function bindShipmentTruth(
- g: Game,
- p: Player,
- claim: ShipmentClaim,
- answer: boolean,
- asker: string,
-) {
- (g.shipmentPromises ??= []).push({
- turn: g.turn,
- player: p.id,
- asker,
- territory: claim.territory,
- minimum: claim.minimum,
- answer,
- });
-}
-function checkShipmentPromises(
- g: Game,
- p: Player,
- shipment: { territory: string; amount: number } | null,
-) {
- requireRule(
- liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).every(
- (promise) => matchesShipment(promise, shipment) === promise.answer,
- ),
- 'Honor your Truthtrance shipment answer before using this shipment opportunity.',
- );
-}
-function finishShipmentPromises(
- g: Game,
- p: Player,
- shipment: { territory: string; amount: number } | null,
-) {
- checkShipmentPromises(g, p, shipment);
- for (const promise of liveShipmentPromises(
- g.shipmentPromises ?? [],
- p.id,
- g.turn,
- ))
- promise.fulfilled = true;
-}
-function reconcileShipmentPromises(
- g: Game,
- voluntary?: { actor: string; action: Action },
-) {
- const kept: ShipmentPromise[] = [];
- for (const promise of g.shipmentPromises ?? []) {
- if (promise.turn !== g.turn || promise.released || promise.fulfilled)
- continue;
- const p = getPlayer(g, promise.player);
- if (
- findShipmentCompletion(g, p, [
- ...kept.filter((old) => old.player === p.id),
- promise,
- ])
- )
- kept.push(promise);
- else {
- requireRule(
- !voluntary ||
- voluntary.actor !== p.id ||
- !['card', 'bribe', 'pledgeAid'].includes(voluntary.action.type),
- 'You cannot voluntarily make your Truthtrance shipment answer impossible.',
- );
- promise.released = true;
- log(
- g,
- `${p.name} can no longer fulfill a Truthtrance shipment answer; that answer is no longer binding.`,
- );
- }
- }
-}
-function battleTruthAnswers(
- g: Game,
- p: Player,
- claim: PlanClaim,
-): TruthAnswer[] {
- const promises = g.battle?.truthPromises ?? [];
- const answers: TruthAnswer[] = [];
- for (const answer of [true, false])
- if (
- findReachableBattlePlan(g, p, {
- promises: [...promises, { player: p.id, asker: '', claim, answer }],
- })
- )
- answers.push(answer ? 'yes' : 'no');
- return answers.length ? answers : ['unknown'];
-}
-function bindBattleTruth(
- g: Game,
- p: Player,
- claim: PlanClaim,
- answer: boolean,
- asker: string,
-) {
- if (g.battle && !g.battle.revealed && !g.battle.plans[p.id])
- (g.battle.truthPromises ??= []).push({
- player: p.id,
- asker,
- claim,
- answer,
- });
-}
-function reconcileBattlePromises(
- g: Game,
- voluntary?: { actor: string; action: Action },
-) {
- const b = g.battle;
- if (
- !b ||
- b.revealed ||
- !b.truthPromises?.length ||
- g.response?.kind === 'voice'
- )
- return;
- const kept: BattlePromise[] = [];
- for (const promise of b.truthPromises) {
- if (promise.released || b.plans[promise.player]) {
- kept.push(promise);
- continue;
- }
- const p = getPlayer(g, promise.player);
- if (findReachableBattlePlan(g, p, { promises: [...kept, promise] }))
- kept.push(promise);
- else {
- requireRule(
- !voluntary ||
- p.id !== voluntary.actor ||
- !['card', 'bribe', 'pledgeAid', 'richeseGift'].includes(
- voluntary.action.type,
- ),
- 'You cannot voluntarily make your Truthtrance battle promise impossible.',
- );
- promise.released = true;
- log(
- g,
- `${p.name} can no longer fulfill a Truthtrance battle answer; that answer is no longer binding.`,
- );
- }
- }
-}
-function feasiblePrescience(
- g: Game,
- p: Player,
- field: PlanField,
- value: unknown,
-) {
- if (
- g.battle?.truthPromises?.some(
- (promise) => !promise.released && promise.player === p.id,
- )
- )
- return !!findReachableBattlePlan(g, p, { prescience: { field, value } });
- // Validate the revealed element without committing any other plan components.
- // A legal completion must exist, including compliance with the preceding Voice.
- const b = g.battle!;
- const leaderIds: (string | null)[] = [
- null,
- ...controlledLeaders(g, p)
- .filter((l) => !l.dead && (!l.usedAt || l.usedAt === b.territory))
- .map((l) => l.id),
- ...p.hand.filter((c) => c.kind === 'hero').map((c) => c.id),
- ];
- const weapons: (string | null)[] = [
- null,
- ...p.hand.filter(isWeaponCard).map((c) => c.id),
- ];
- const defenses: (string | null)[] = [
- null,
- ...p.hand.filter((c) => isDefenseCard(c)).map((c) => c.id),
- ];
- for (const leader of field === 'leader' ? [value] : leaderIds)
- for (const weapon of field === 'weapon' ? [value] : weapons)
- for (const defense of field === 'defense' ? [value] : defenses) {
- const maxSupport =
- g.advanced && field === 'dial'
- ? Math.min(battleSupportBudget(g, p), at(p, b.territory))
- : 0;
- for (let support = 0; support <= maxSupport; support++) {
- try {
- const plan = validatePlan(g, p, {
- leader,
- weapon,
- defense,
- support,
- dial: field === 'dial' ? value : 0,
- });
- if (plan[field] === value) return true;
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- }
- }
- }
- return !!findReachableBattlePlan(g, p, { prescience: { field, value } });
-}
-function lateDefenseIntegrity(g: Game) {
- const b = g.battle;
- if (!b?.lateDefense) return;
- requireRule(
- g.phase === 6 &&
- b.revealed &&
- !!b.event &&
- !Array.isArray(b.lateDefense) &&
- typeof b.lateDefense === 'object',
- 'The saved late-defense battle context is invalid.',
- );
- for (const [id, cardId] of Object.entries(b.lateDefense)) {
- requireRule(
- [b.attacker, b.defender].includes(id) &&
- typeof cardId === 'string' &&
- !!b.plans[id],
- 'The saved late defense has an invalid combatant.',
- );
- const owner = getPlayer(g, id),
- card = owner.hand.find((c) => c.id === cardId);
- requireRule(
- card &&
- isPortableSnooper(card) &&
- owner.hand.filter((c) => c.id === cardId).length === 1,
- 'The played Portable Snooper is missing from its reserved hand.',
- );
- requireRule(
- !g.players.some(
- (p) => p.id !== id && p.hand.some((c) => c.id === cardId),
- ) &&
- ![...g.deck, ...g.discard, ...(g.richeseCache ?? [])].some(
- (c) => c.id === cardId,
- ),
- 'The played Portable Snooper has conflicting physical custody.',
- );
- const blocked = portableSnooperPlanBlock(
- b.plans[id],
- owner.hand,
- card,
- b.voice?.target === id ? b.voice : undefined,
- );
- requireRule(
- !blocked,
- blocked ?? 'The saved late defense does not fit the revealed plan.',
- );
- }
-}
-function validatePortableSnooper(
- g: Game,
- owner: Player,
- cardId: string,
- event: string,
-) {
- const b = g.battle;
- requireRule(
- g.status === 'playing' &&
- g.phase === 6 &&
- b?.revealed &&
- [b.attacker, b.defender].includes(owner.id),
- 'Late Portable Snooper is available after both battle plans are revealed.',
- );
- requireRule(
- !!b.event && b.event === event,
- 'Use the current revealed battle event; legacy battles without one are not reopened.',
- );
- requireRule(
- b.traitorCalls[owner.id] === undefined,
- 'Your battle decisions are already submitted; the late-defense opportunity has closed.',
- );
- requireRule(
- !g.truthtrance &&
- !g.decision &&
- !g.response &&
- !g.phaseOpening &&
- !g.pendingNullentropy,
- 'Finish the current interaction before adding a late defense.',
- );
- requireRule(
- !b.lateDefense?.[owner.id],
- 'You have already added Portable Snooper to this battle.',
- );
- const card = owner.hand.find((c) => c.id === cardId);
- requireRule(
- card && isPortableSnooper(card),
- 'Choose the canonical Portable Snooper in your hand.',
- );
- const reserved = transferCardBlock(g, owner, card);
- requireRule(!reserved, reserved ?? 'The card is already committed.');
- const blocked = portableSnooperPlanBlock(
- b.plans[owner.id],
- owner.hand,
- card,
- b.voice?.target === owner.id ? b.voice : undefined,
- );
- requireRule(
- !blocked,
- blocked ?? 'Portable Snooper cannot be added to this plan.',
- );
- const trial = structuredClone(g);
- (trial.battle!.lateDefense ??= {})[owner.id] = card.id;
- lateDefenseIntegrity(trial);
- return { b, card };
-}
-function portableSnooperView(g: Game, owner: Player) {
- const card = owner.hand.find(isPortableSnooper);
- if (!card) return null;
- let blocked: string | null = null;
- try {
- validatePortableSnooper(g, owner, card.id, g.battle?.event ?? '');
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- blocked = error.message;
- }
- return { card, event: g.battle?.event ?? null, blocked };
-}
-function stoneBurnerIntegrity(g: Game) {
- const b = g.battle;
- if (!b) return;
- if (b.stoneBurner !== undefined) {
- requireRule(
- g.phase === 6 &&
- b.revealed &&
- !!b.event &&
- !!b.stoneBurner &&
- typeof b.stoneBurner === 'object' &&
- !Array.isArray(b.stoneBurner),
- 'The saved Stone Burner choice context is invalid.',
- );
- for (const [id, mode] of Object.entries(b.stoneBurner))
- requireRule(
- [b.attacker, b.defender].includes(id) &&
- ['kill', 'ignore'].includes(mode) &&
- isStoneBurner(cardOf(getPlayer(g, id), b.plans[id]?.weapon)),
- 'The saved Stone Burner choice has an invalid owner, mode, or missing canonical weapon.',
- );
- }
- for (const id of [b.attacker, b.defender]) {
- if (b.plans[id]?.weapon !== 'richese-stone-burner') continue;
- const owner = getPlayer(g, id),
- card = cardOf(owner, b.plans[id].weapon);
- requireRule(
- isStoneBurner(card),
- 'The committed canonical Stone Burner weapon is missing from its reserved hand.',
- );
- requireRule(
- g.players.reduce(
- (n, p) => n + p.hand.filter((c) => c.id === card!.id).length,
- 0,
- ) === 1 &&
- ![
- ...g.deck,
- ...g.discard,
- ...(g.richeseCache ?? []),
- ...(g.auction?.cards ?? []),
- ...(g.ixSetupCards ?? []),
- ].some((c) => c.id === card!.id),
- 'The committed Stone Burner physical card is duplicated.',
- );
- }
-}
-function nextRevealedDecision(g: Game) {
- const b = g.battle!;
- const stoneOwner = [b.attacker, b.defender].find(
- (id) =>
- isStoneBurner(cardOf(getPlayer(g, id), b.plans[id].weapon)) &&
- !b.stoneBurner?.[id],
- );
- if (stoneOwner) {
- requireRule(!!b.event, 'The Stone Burner battle event is missing.');
- g.decision = { kind: 'stoneBurner', player: stoneOwner, event: b.event };
- return;
- }
- const player = g.order.find(
- (id) =>
- [b.attacker, b.defender].includes(id) &&
- cardOf(getPlayer(g, id), b.plans[id].weapon)?.kind === 'poisonTooth' &&
- b.poisonTooth?.[id] === undefined,
- );
- if (player) g.decision = { kind: 'poisonTooth', player };
-}
-function traitorVoters(g: Game, b: Battle) {
- const voters = [b.attacker, b.defender];
- const harkonnen = byFaction(g, 'harkonnen');
- if (
- harkonnen?.ally &&
- voters.includes(harkonnen.ally) &&
- !voters.includes(harkonnen.id)
- )
- voters.push(harkonnen.id);
- return voters;
-}
-function traitorBeneficiary(g: Game, b: Battle, id: string) {
- return [b.attacker, b.defender].includes(id) ? id : getPlayer(g, id).ally;
-}
-/** Prepare current server-only inputs once; the pure module cannot inspect Game or mutate it. */
-function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
- const b = g.battle!;
- const combatant = (id: string, opponentId: string): ResolutionCombatant => {
- const p = getPlayer(g, id),
- opponent = getPlayer(g, opponentId);
- const plan = b.plans[id],
- aid = aidFor(g, p);
- return {
- id: p.id,
- faction: p.faction,
- ally: p.ally,
- spice: p.spice,
- hand: p.hand,
- plan,
- leader: controlledLeaders(g, p).find((l) => l.id === plan.leader),
- forces: combatForces(g, p, b.territory, opponent),
- stronghold: strongholdEffect(g, p.id),
- lateDefense: b.lateDefense?.[p.id],
- poisonTooth: b.poisonTooth?.[p.id],
- stoneMode: b.stoneBurner?.[p.id],
- ...(aid ? { aid: { donor: p.ally!, amount: aid.amount } } : {}),
- };
- };
- try {
- const quote = quoteBattleResolution({
- advanced: g.advanced,
- turn: g.turn,
- territory: b.territory,
- attacker: combatant(b.attacker, b.defender),
- defender: combatant(b.defender, b.attacker),
- voters: traitorVoters(g, b).map((id) => ({
- id,
- beneficiary: traitorBeneficiary(g, b, id)!,
- called: id === canceledVoter ? false : b.traitorCalls[id],
- traitors: getPlayer(g, id).traitors,
- })),
- participants: g.players.map((p) => ({
- id: p.id,
- faction: p.faction,
- ally: p.ally,
- noFieldAtTerritory:
- p.noField?.deployed?.location.territory === b.territory,
- })),
- physicalCards: physicalTreacheryCards(g),
- pendingAuditorPresent: !!g.pendingAuditor,
- pendingRetentionPresent: !!g.moritaniRetention,
- });
- for (const id of quote.destroyedArmies)
- validateBattleForceLoss(getPlayer(g, id), b.territory, Infinity);
- if (quote.winner && quote.result === 'normal')
- validateBattleForceLoss(
- getPlayer(g, quote.winner),
- b.territory,
- quote.basicWinnerLosses ??
- Math.max(...quote.casualties!.options.map((c) => c.normal + c.elite)),
- );
- return quote;
- } catch (error) {
- if (
- error instanceof BattleResolutionQuoteError ||
- error instanceof ForceLossPreflightError
- )
- throw new RuleError(error.message);
- throw error;
- }
-}
-function resolveBattle(g: Game) {
- const b = g.battle!;
- const quote = currentBattleResolutionQuote(g);
- const a = getPlayer(g, b.attacker),
- d = getPlayer(g, b.defender),
- ap = b.plans[a.id],
- dp = b.plans[d.id];
- const al = controlledLeaders(g, a).find((l) => l.id === ap.leader),
- dl = controlledLeaders(g, d).find((l) => l.id === dp.leader);
- const aw = cardOf(a, ap.weapon);
- const stoneResult = quote.stone;
- const ac = quote.attackerTraitor,
- dc = quote.defenderTraitor;
- for (const revelation of quote.revelations) {
- const holder = getPlayer(g, revelation.player),
- identity = revelation.identity;
- holder.revealedTraitors = [
- ...new Set([...(holder.revealedTraitors ?? []), identity]),
- ];
- const name =
- identity === CHEAP_HERO_TRAITOR
- ? 'Cheap Hero / Heroine'
- : (g.players.flatMap((p) => p.leaders).find((l) => l.id === identity)
- ?.name ?? identity);
- log(g, `${holder.name} revealed ${name} as a traitor.`);
- }
- const dead = (l: Leader | undefined) => {
- if (l) {
- l.dead = true;
- l.deaths++;
- delete l.usedAt;
- }
- };
- const winner = quote.winner ? getPlayer(g, quote.winner) : undefined;
- const casualtyCommitment = quote.casualties ?? undefined;
- for (const payment of quote.payments) {
- const player = getPlayer(g, payment.player);
- player.spice -= payment.ownPayment;
- if (payment.allyPayment) aidFor(g, player)!.amount -= payment.allyPayment;
- if (payment.bankSupport)
- log(
- g,
- `${player.name}'s Stronghold Card paid ${payment.bankSupport} spice from the bank toward force support.`,
- { faction: player.faction, name: 'Stronghold support' },
- );
- }
- if (quote.choamIncome) g.pendingChoamBattleIncome = { ...quote.choamIncome };
- const explosion = quote.explosion;
- if (ac && dc) {
- dead(al);
- dead(dl);
- killTerritory(g, a, b.territory, Infinity, true);
- killTerritory(g, d, b.territory, Infinity, true);
- log(g, 'Both leaders were traitors. Both armies were destroyed.');
- } else if (ac || dc) {
- const loser = ac ? d : a;
- const l = ac ? dl : al;
- dead(l);
- winner!.spice += quote.bounty!.amount;
- killTerritory(g, loser, b.territory, Infinity, true);
- log(
- g,
- `${faction(winner!.faction).name} revealed a traitor and won without losses.`,
- );
- } else if (explosion) {
- returnAmbassadorsIn(
- g,
- b.territory,
- 'a Lasgun–shield explosion destroyed it',
- );
- for (const p of g.players) killTerritory(g, p, b.territory, Infinity, true);
- dead(al);
- dead(dl);
- for (const [p, plan] of [
- [a, ap],
- [d, dp],
- ] as [Player, Plan][])
- if (plan.kwisatz) {
- p.kwisatz ??= { dead: false };
- p.kwisatz.dead = true;
- p.kwisatz.revivalCycle ??= 1;
- }
- for (const k of Object.keys(g.spice))
- if (splitLocation(k).territory === b.territory) delete g.spice[k];
- log(
- g,
- 'Lasgun and shield exploded. All forces and spice in the territory were destroyed.',
- );
- } else {
- const ak = quote.leaderDeaths.attacker,
- dk = quote.leaderDeaths.defender;
- if (ak) dead(al);
- if (dk) dead(dl);
- const av = quote.scores!.attacker,
- dv = quote.scores!.defender;
- const loser = winner === a ? d : a;
- if (quote.bounty) winner!.spice += quote.bounty.amount;
- killTerritory(g, loser, b.territory, Infinity, true);
- if (quote.basicWinnerLosses !== null)
- killTerritory(g, winner!, b.territory, quote.basicWinnerLosses, true);
- log(
- g,
- stoneResult
- ? `${faction(winner!.faction).name} won in ${territory(b.territory).name} by Stone Burner’s undialed physical tokens (aggressor ${stoneResult.attacker.join(' or ')}, defender ${stoneResult.defender.join(' or ')}; ${strongholdEffect(g, battleTieWinner(g)) === 'habbanya_ridge_sietch' ? 'Habbanya Stronghold advantage wins ties' : 'aggressor wins ties'}). Leader strength and Kwisatz Haderach’s bonus do not affect this comparison; the winner loses its dialed forces normally.`
- : `${faction(winner!.faction).name} won in ${territory(b.territory).name} (${av}–${dv}${av === dv ? (strongholdEffect(g, winner!.id) === 'habbanya_ridge_sietch' ? ', Habbanya Stronghold advantage wins ties' : ', aggressor wins ties') : ''}).`,
- stoneResult
- ? { faction: (isStoneBurner(aw) ? a : d).faction, name: 'Stone Burner' }
- : undefined,
- );
- }
- for (const income of quote.strongholdIncome) {
- const player = getPlayer(g, income.player);
- player.spice += income.amount;
- log(
- g,
- `${player.name} collected ${income.amount} spice from the bank for their ${strongholdEffect(g, player.id) === 'sietch_tabr' ? 'Sietch Tabr victory' : 'Worthless battle cards at Tuek’s Sietch'}.`,
- { faction: player.faction, name: 'Stronghold income' },
- );
- }
- const tleilaxu = byFaction(g, 'tleilaxu');
- if (winner && tleilaxu && winner.id !== tleilaxu.id) {
- const winningPlan = winner.id === a.id ? ap : dp;
- g.pendingFaceDance = {
- player: tleilaxu.id,
- winner: winner.id,
- leader: winningPlan.leader,
- identity:
- cardOf(winner, winningPlan.leader)?.kind === 'hero'
- ? CHEAP_HERO_TRAITOR
- : winningPlan.leader,
- territory: b.territory,
- };
- }
- if (quote.auditor)
- g.pendingAuditor = {
- ...quote.auditor,
- event: b.event ?? crypto.randomUUID(),
- };
- if (quote.retention) g.moritaniRetention = structuredClone(quote.retention);
- const discarded: { card: Card; discardedBy: string; publicFace: boolean }[] =
- [];
- for (const [p, plan, l] of [
- [a, ap, al],
- [d, dp, dl],
- ] as [Player, Plan, Leader | undefined][]) {
- if (l && !l.dead) l.usedAt = b.territory;
- if (l?.capturedBy === p.id) {
- delete l.capturedBy;
- delete l.concealed;
- }
- if (plan.kwisatz) {
- p.kwisatz ??= { dead: false };
- p.kwisatz.usedAt = b.territory;
- }
- for (const entry of quote.discarded.filter(
- (entry) => entry.player === p.id,
- ))
- discarded.push({
- card: discard(g, p, entry.card),
- discardedBy: p.id,
- publicFace: true,
- });
- }
- const harkonnen = byFaction(g, 'harkonnen');
- if (harkonnen) returnCaptives(g, harkonnen);
- if (g.advanced && winner?.faction === 'harkonnen') {
- const loser = winner.id === a.id ? d : a;
- if (captureCandidates(g, loser.id, b.territory).length)
- g.pendingCapture = {
- player: winner.id,
- loser: loser.id,
- territory: b.territory,
- };
- }
- if (winner) {
- const loser = winner.id === a.id ? d : a;
- const choices = ownedTech(g.techTokens, loser.id);
- if (choices.length)
- g.pendingTech = { player: winner.id, loser: loser.id, choices };
- }
- if (g.dukeVidal && [ap.leader, dp.leader].includes(DUKE_VIDAL_ID))
- g.dukeVidal = consumeDuke(g.dukeVidal);
- g.lastBattle = [a.id, d.id];
- g.lastBattleContext = {
- event: b.event ?? g.pendingAuditor?.event ?? crypto.randomUUID(),
- turn: g.turn,
- territory: b.territory,
- combatants: [...g.lastBattle],
- winner: winner?.id ?? null,
- result: quote.result,
- };
- g.battle = null;
- const cards = [...quote.winnerCards];
- const continuation: Extract<
- NonNullable['continuation'],
- { kind: 'battleResolved' }
- > = {
- kind: 'battleResolved',
- event: g.lastBattleContext.event,
- result: quote.result,
- combatants: [...g.lastBattle],
- territory: b.territory,
- winner: winner?.id ?? null,
- cards,
- ...(casualtyCommitment ? { casualties: casualtyCommitment } : {}),
- };
- if (discarded.length)
- stageTreacheryDiscard(g, 'battle:mandatory', discarded, continuation);
- else continueResolvedBattle(g, continuation);
-}
-function continueResolvedBattle(
- g: Game,
- continuation: Extract<
- NonNullable['continuation'],
- { kind: 'battleResolved' }
- >,
-) {
- const { cards, territory: to } = continuation;
- const winner = continuation.winner ? getPlayer(g, continuation.winner) : null;
- const losses = continuation.casualties?.options;
- if (winner && losses?.length === 1) {
- settleWinnerCasualties(g, winner, to, cards, losses[0], true);
- } else if (winner && losses)
- g.decision = {
- kind: 'battleLosses',
- player: winner.id,
- territory: to,
- options: losses,
- cards,
- };
- else if (winner) finishWinner(g, winner, to, cards);
- else finishBattle(g);
-}
-function settleWinnerCasualties(
- g: Game,
- p: Player,
- to: string,
- cards: string[],
- choice: Casualties,
- automatic = false,
-) {
- const losses = takeBattleLosses(g, p, to, choice);
- log(
- g,
- `${p.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${territory(to).name} to the Tanks. ${automatic ? 'This was the only legal casualty allocation for the revealed battle plan, so it was applied automatically.' : 'This applies the selected casualty allocation for the revealed battle plan.'}`,
- automatic ? { faction: p.faction, name: 'Battle casualties' } : undefined,
- );
- const survivingSuboids = Object.entries(p.forces)
- .filter(([key]) => splitLocation(key).territory === to)
- .reduce(
- (sum, [key, count]) => sum + count - (p.elites?.forces[key] ?? 0),
- 0,
- );
- if (p.faction === 'ixians' && choice.elite > 0 && survivingSuboids > 0) {
- g.pendingIxSubstitution = { player: p.id, territory: to, losses, cards };
- g.decision = {
- kind: 'ixSubstitution',
- player: p.id,
- territory: to,
- losses,
- };
- } else finishWinner(g, p, to, cards);
-}
-function finishWinner(g: Game, winner: Player, t: string, cards: string[]) {
- if (cards.length)
- g.decision = {
- kind: 'battleCards',
- player: winner.id,
- territory: t,
- cards,
- };
- else finishBattle(g);
-}
-/** Pure current compulsory aftermath; no board or phase initialization is executed here. */
-function currentBattleAftermathQuote(
- g: Game,
- cancel?: AftermathCancellation,
- spending?: { player: string; card: string },
-) {
- try {
- const quote = quoteBattleAftermath(
- {
- status: g.status,
- phase: g.phase,
- turn: g.turn,
- advanced: g.advanced,
- battlePresent: !!g.battle,
- lastBattle: g.lastBattle,
- territoryIds: gameTerritories(g).map((t) => t.id),
- context: g.lastBattleContext,
- players: g.players.map((p) => ({
- id: p.id,
- faction: p.faction,
- hand: p.hand,
- leaders: p.leaders,
- })),
- leaderIds: [
- ...new Set([
- ...g.players.flatMap((p) => p.leaders.map((l) => l.id)),
- ...(g.dukeVidal ? [g.dukeVidal.leader.id] : []),
- ]),
- ],
- physicalCards: physicalTreacheryCards(g),
- techTokens: g.techTokens,
- pending: {
- retention: g.moritaniRetention,
- income: g.pendingChoamBattleIncome,
- tech: g.pendingTech,
- capture: g.pendingCapture,
- auditor: g.pendingAuditor,
- faceDance: g.pendingFaceDance,
- },
- cancel,
- },
- spending,
- );
- if (quote.next.kind === 'board')
- boardResolution(() => quoteBattleBoardContinuation(g));
- return quote;
- } catch (error) {
- if (error instanceof BattleAftermathQuoteError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function validateAftermathCancellation(
- g: Game,
- response: ResponseWindow,
- spending?: { player: string; card: string },
-) {
- if (
- response.kind === 'capture' ||
- response.kind === 'choamAudit' ||
- response.kind === 'choamBattleIncome'
- )
- return currentBattleAftermathQuote(
- g,
- {
- kind: response.kind,
- owner: response.owner,
- intent: response.intent,
- },
- spending,
- );
-}
-function finishBattle(g: Game) {
- const quote = currentBattleAftermathQuote(g);
- for (const step of quote.steps) {
- if (step.kind === 'transferTech') {
- transferTech(g, step.id, step.to);
- g.pendingTech = null;
- } else if (step.kind === 'emptyAuditor') {
- log(
- g,
- 'The Auditor has no eligible opposing hand cards to inspect. Cards used in this battle are excluded.',
- { faction: 'choam', name: 'Auditor: no eligible cards' },
- );
- g.pendingAuditor = null;
- }
- }
- const next = quote.next;
- if (next.kind === 'choamBattleIncome') {
- g.response = { ...next, passed: [] };
- } else if (next.kind === 'choamAudit') {
- g.decision = { kind: next.kind, player: next.player, event: next.event };
- } else if (next.kind !== 'board') {
- g.decision = { ...next };
- } else {
- const remaining = battles(g);
- if (remaining.length) g.active = remaining[0].attacker;
- else nextPhase(g);
- }
-}
-function currentAuditCount(g: Game) {
- const pending = g.pendingAuditor;
- return pending
- ? auditCount(
- getPlayer(g, pending.opponent).hand,
- pending.usedCards,
- pending.survived,
- )
- : 0;
-}
-function finishAuditor(
- g: Game,
- outcome: 'inspect' | 'pay' | 'decline' | 'cancel' | 'empty',
-) {
- const pending = g.pendingAuditor;
- requireRule(pending, 'This Auditor opportunity has already ended.');
- const owner = getPlayer(g, pending.owner),
- opponent = getPlayer(g, pending.opponent);
- const count = currentAuditCount(g);
- if (outcome === 'pay') {
- requireRule(
- count > 0 && opponent.spice >= count,
- 'Pay the full current audit cost or allow the inspection.',
- );
- opponent.spice -= count;
- owner.spice += count;
- log(
- g,
- `${opponent.name} paid ${count} spice directly to ${owner.name} to cancel the entire Auditor inspection.`,
- );
- } else if (outcome === 'inspect' && count) {
- const cards = sampleAuditCards(
- opponent.hand,
- pending.usedCards,
- pending.survived,
- random,
- );
- g.auditorInsight = {
- event: pending.event,
- viewer: owner.id,
- target: opponent.id,
- turn: g.turn,
- cards,
- };
- log(
- g,
- `${owner.name}'s ${pending.survived ? 'surviving' : 'killed'} Auditor privately inspected ${cards.length} random opposing hand card${cards.length === 1 ? '' : 's'}. Cards used in this battle were excluded; no cards changed hands.`,
- { faction: 'choam', name: 'Auditor inspection' },
- );
- } else {
- log(
- g,
- outcome === 'decline'
- ? `${owner.name} declined the Auditor inspection.`
- : outcome === 'cancel'
- ? 'Karama canceled the Auditor inspection; no cancellation payment was charged.'
- : 'The Auditor has no eligible opposing hand cards to inspect.',
- {
- faction: 'choam',
- name: outcome === 'cancel' ? 'Auditor canceled' : 'Auditor',
- },
- );
- }
- g.pendingAuditor = null;
- g.decision = null;
- finishBattle(g);
-}
-function auditorIntegrity(g: Game) {
- const pending = g.pendingAuditor;
- if (!pending) {
- requireRule(
- !['choamAudit', 'choamAuditPayment'].includes(g.decision?.kind ?? '') &&
- g.response?.kind !== 'choamAudit',
- 'The Auditor continuation is missing.',
- );
- return;
- }
- requireRule(
- g.advanced &&
- g.status === 'playing' &&
- g.phase === 6 &&
- !g.battle &&
- pending.turn === g.turn &&
- typeof pending.event === 'string' &&
- !!pending.event &&
- pending.owner !== pending.opponent &&
- g.lastBattle.length === 2 &&
- g.lastBattle.includes(pending.owner) &&
- g.lastBattle.includes(pending.opponent) &&
- gameTerritories(g).some((t) => t.id === pending.territory) &&
- g.players.some(
- (p) =>
- p.id === pending.owner &&
- p.faction === 'choam' &&
- p.leaders.some(isAuditorLeader),
- ) &&
- g.players.some((p) => p.id === pending.opponent) &&
- typeof pending.survived === 'boolean' &&
- ['offer', 'response', 'payment'].includes(pending.stage) &&
- Array.isArray(pending.usedCards) &&
- pending.usedCards.every((id) => typeof id === 'string') &&
- new Set(pending.usedCards).size === pending.usedCards.length,
- 'The saved Auditor opportunity does not match this resolved battle.',
- );
- // Modern saves retain an independent receipt of the resolved battle. Legacy
- // opportunities without that receipt still use the controls checked below.
- if (g.lastBattleContext)
- requireRule(
- g.lastBattleContext.event === pending.event &&
- g.lastBattleContext.turn === pending.turn &&
- g.lastBattleContext.territory === pending.territory &&
- JSON.stringify(g.lastBattleContext.combatants) ===
- JSON.stringify(g.lastBattle),
- 'The saved Auditor opportunity differs from its completed battle receipt.',
- );
- // These overlays preserve the exact parent decision/response instead of ending it.
- const contexts = [
- g,
- g.pendingNullentropy?.resume,
- g.pendingTreacheryDiscard?.continuation.kind === 'nullentropyDiscard' ||
- g.pendingTreacheryDiscard?.continuation.kind === 'truthtranceDiscard'
- ? g.pendingTreacheryDiscard.continuation.resume
- : undefined,
- g.pendingRicheseGift?.resume,
- ].filter((c) => !!c);
- const decisions = contexts.flatMap((c) => (c.decision ? [c.decision] : []));
- const responses = contexts.flatMap((c) => [
- ...(c.response ? [c.response] : []),
- ...(c.pendingKarama?.use.kind === 'cancel'
- ? [c.pendingKarama.use.response]
- : []),
- ]);
- for (const decision of decisions.filter(
- (d) => d.kind === 'choamAudit' || d.kind === 'choamAuditPayment',
- )) {
- const offer = decision.kind === 'choamAudit';
- requireRule(
- decision.event === pending.event &&
- decision.player === (offer ? pending.owner : pending.opponent) &&
- pending.stage === (offer ? 'offer' : 'payment'),
- 'This Auditor decision is stale.',
- );
- }
- for (const response of responses.filter((r) => r.kind === 'choamAudit'))
- requireRule(
- response.owner === pending.owner &&
- response.intent === pending.event &&
- pending.stage === 'response',
- 'This Auditor cancellation window is stale.',
- );
- requireRule(
- pending.stage === 'offer' ||
- (pending.stage === 'response' &&
- responses.some((r) => r.kind === 'choamAudit')) ||
- (pending.stage === 'payment' &&
- decisions.some((d) => d.kind === 'choamAuditPayment')),
- 'The saved Auditor response or payment continuation is missing.',
- );
-}
-/** Older saved cleanup decisions predate the resolved battle receipt. Their
- * authenticated parent supplies the territory/winner; no battle is replayed. */
-function battleCleanupInput(g: Game) {
- return {
- phase: g.phase,
- turn: g.turn,
- battlePresent: !!g.battle,
- lastBattle: g.lastBattle,
- playerIds: g.players.map((p) => p.id),
- territoryIds: gameTerritories(g).map((t) => t.id),
- context: g.lastBattleContext,
- };
-}
-function battlePreflight(quote: () => T): T {
- try {
- return quote();
- } catch (error) {
- if (error instanceof KaramaBattlePreflightError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function validateMoritaniCancellation(g: Game, responseOwner: string) {
- const pending = g.moritaniRetention;
- return battlePreflight(() =>
- preflightMoritaniRetentionCancellation({
- ...battleCleanupInput(g),
- responseOwner,
- pending,
- hand: g.players.find((p) => p.id === pending?.player)?.hand ?? [],
- physicalCards: physicalTreacheryCards(g),
- }),
- );
-}
-function cleanupBattleContext(g: Game, to: string, winner: string | null) {
- const quote = battlePreflight(() =>
- validateBattleCleanupContext({
- ...battleCleanupInput(g),
- territory: to,
- winner,
- }),
- );
- if (quote.kind === 'legacy')
- g.lastBattleContext = {
- ...quote.seed,
- event: g.pendingAuditor?.event ?? crypto.randomUUID(),
- };
- return g.lastBattleContext!;
-}
-function finishMoritaniRetention(g: Game, keep: string | null) {
- const pending = g.moritaniRetention;
- if (keep === null && pending?.stage === 'response')
- validateMoritaniCancellation(g, pending.owner);
- requireRule(
- pending && pending.turn === g.turn && g.phase === 6,
- 'This alliance card cleanup is no longer current.',
- );
- const player = getPlayer(g, pending.player);
- requireRule(
- keep === null || pending.eligible.includes(keep),
- 'Choose one eligible card from your played battle cards.',
- );
- requireRule(
- pending.played.every((id) => player.hand.some((c) => c.id === id)),
- 'Cards committed to alliance cleanup must remain available.',
- );
- const discarded = pending.played
- .filter((id) => id !== keep)
- .map((id) => ({
- card: discard(g, player, id),
- discardedBy: player.id,
- publicFace: true,
- }));
- log(
- g,
- keep
- ? `${player.name} retained ${cardOf(player, keep)!.name} through the Moritani alliance.`
- : `${player.name} retained no battle card through the Moritani alliance.`,
- );
- g.moritaniRetention = null;
- const context = discarded.length
- ? cleanupBattleContext(
- g,
- pending.territory,
- g.lastBattle.find((id) => id !== player.id) ?? null,
- )
- : null;
- if (discarded.length)
- stageTreacheryDiscard(g, 'battle:moritani', discarded, {
- kind: 'battleCleanup',
- event: context!.event,
- combatants: [...g.lastBattle],
- territory: pending.territory,
- player: player.id,
- source: 'moritani',
- kept: keep ? [keep] : [],
- played: [...pending.played],
- retention: pending,
- });
- else finishBattle(g);
-}
-function collectRevivalIncome(
- g: Game,
- actor: Player,
- paid: number,
- free = false,
- ghola = false,
-) {
- const tleilaxu = byFaction(g, 'tleilaxu');
- if (!tleilaxu) return;
- g.revivalFreeIncome ??= {};
- const reward = ghola || (free && g.revivalFreeIncome[actor.id] !== g.turn);
- if (free && !ghola) g.revivalFreeIncome[actor.id] = g.turn;
- const amount = (actor.id === tleilaxu.id ? 0 : paid) + (reward ? 1 : 0);
- if (amount)
- g.response = {
- kind: 'revivalIncome',
- owner: tleilaxu.id,
- recipient: actor.id,
- amount,
- passed: [],
- };
-}
-function finishRevival(g: Game) {
- const revival = g.pendingRevival!;
- const p = getPlayer(g, revival.player);
- const check = revival.checks.shift();
- if (check) {
- g.response = {
- kind: check,
- owner: check === 'choamRevival' ? p.id : byFaction(g, 'tleilaxu')!.id,
- recipient: p.id,
- passed: [],
- };
- return;
- }
- const payer = revival.payer ? getPlayer(g, revival.payer) : p;
- if (
- revival.kind === 'forces' &&
- !revival.emperorExtra &&
- p.faction === 'fremen' &&
- revival.amount! > forceRevivalRemaining(g, p)
- ) {
- g.pendingRevival = null;
- log(
- g,
- `${p.name}'s normal revival was not completed: the request exceeds their current force-revival allowance. No forces or spice changed.`,
- );
- return;
- }
- if (payer.spice < revival.cost) {
- g.pendingRevival = null;
- log(
- g,
- `${p.name}'s revival was not completed because its current price cannot be paid.`,
- );
- return;
- }
- payer.spice -= revival.cost;
- if (revival.kind === 'forces') {
- const n = revival.amount!,
- elite = revival.elite ?? 0;
- p.tanks -= n;
- p.reserves += n;
- if (revival.emperorExtra)
- g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n;
- else {
- // Preserve unknown legacy usage instead of guessing how earlier paid returns were priced.
- if (p.freeForcesRevived !== undefined || p.revived === 0)
- p.freeForcesRevived = (p.freeForcesRevived ?? 0) + revival.free;
- p.revived += n;
- }
- if (p.elites) {
- p.elites.tanks -= elite;
- p.elites.reserves += elite;
- p.elites.revived += elite;
- }
- if (revival.free > 0) techIncome(g, 'axlotl', p);
- log(g, `${p.name} revived ${n} forces.`);
- } else {
- if (revival.kind === 'kwisatz') {
- p.kwisatz!.dead = false;
- p.kwisatz!.revivalCycle =
- Math.max(p.revivalCycle, p.kwisatz!.revivalCycle ?? 1) + 1;
- log(g, `${p.name} revived Kwisatz Haderach.`);
- } else {
- const leader = g.players
- .flatMap((owner) => owner.leaders)
- .find((l) => l.id === revival.leader)!;
- if (revival.kind === 'foreignGhola') leader.gholaBy = p.id;
- else delete leader.gholaBy;
- leader.dead = false;
- delete leader.concealed;
- log(
- g,
- `${p.name} revived ${leader.name}${revival.kind === 'foreignGhola' ? ' as a ghola' : ''}.`,
- );
- }
- p.leaderRevived = true;
- if (g.revivalRequests) delete g.revivalRequests[p.id];
- }
- g.pendingRevival = null;
- collectRevivalIncome(g, payer, revival.cost, revival.free > 0);
-}
-function beginRevival(g: Game, revival: PendingRevival) {
- requireRule(
- !revivalPrevented(g, revival.player),
- 'Tleilaxu prevented this faction’s normal revivals for this turn.',
- );
- g.pendingRevival = revival;
- const choam = byFaction(g, 'choam');
- if (
- choam &&
- choam.id !== revival.player &&
- revival.free > 0 &&
- !revival.emperorExtra
- ) {
- g.decision = {
- kind: 'choamFreeRevival',
- player: choam.id,
- recipient: revival.player,
- };
- return;
- }
- offerRevivalStop(g);
-}
-function offerRevivalStop(g: Game) {
- const revival = g.pendingRevival!;
- const tleilaxu = byFaction(g, 'tleilaxu');
- if (
- g.advanced &&
- tleilaxu &&
- !tleilaxu.specialKaramaUsed &&
- tleilaxu.id !== revival.player
- ) {
- g.decision = {
- kind: 'revivalStop',
- player: tleilaxu.id,
- recipient: revival.player,
- revival: revival.kind === 'forces' ? 'forces' : 'leader',
- };
- } else finishRevival(g);
-}
-function moritaniAllianceCancellationQuote(g: Game, response: ResponseWindow) {
- try {
- return quoteMoritaniAllianceCancellation(g, response);
- } catch (error) {
- if (error instanceof MoritaniAllianceCancellationError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function currentRicheseCancellationQuote(g: Game, response: ResponseWindow) {
- try {
- const quote = quoteRicheseCancellation(g, response);
- if (!quote) return null;
- // Only restore the controls saved by this producer; all other pending
- // records remain live. No normalization, response resolution or RNG.
- const shadow: Game = { ...g, ...quote.resume };
- if (quote.kind === 'richeseGift') shadow.pendingRicheseGift = null;
- else shadow.pendingRichesePurchaseIncome = null;
- const controls = {
- response: shadow.response,
- decision: shadow.decision,
- pendingKarama: shadow.pendingKarama,
- phaseOpening: shadow.phaseOpening,
- };
- suspendedControlsIntegrity(shadow, controls);
- if (shadow.response) {
- karamaCanceledSource(shadow, shadow.response);
- // These pure contracts validate the declared parent; their canceled
- // outcomes are not applied and no future response is answered here.
- terminalCancellationQuote(shadow, shadow.response);
- if (isCombatResponseKind(shadow.response.kind))
- currentCombatResponseQuote(shadow, {
- kind: 'response',
- response: {
- kind: shadow.response.kind,
- owner: shadow.response.owner,
- },
- canceled: false,
- });
- }
- return quote;
- } catch (error) {
- if (error instanceof RicheseCancellationError)
- throw new RuleError(error.message);
- throw error;
- }
-}
-function finishResponse(g: Game, canceled: boolean) {
- karamaConversionIntegrity(g);
- const response = g.response!;
- const technologyCancellation = canceled
- ? auctionTechnologyQuote(() => quoteIxTechnologyCancellation(g, response))
- : null;
- const richeseCancellation = canceled
- ? currentRicheseCancellationQuote(g, response)
- : null;
- if (canceled) validateDisasterCancellation(g, response);
- if (canceled) validateAftermathCancellation(g, response);
- const terminal = canceled ? terminalCancellationQuote(g, response) : null;
- const allianceCancellation = canceled
- ? moritaniAllianceCancellationQuote(g, response)
- : null;
- g.response = null;
- if (response.kind === 'richeseGift') {
- const pending = g.pendingRicheseGift;
- requireRule(
- pending &&
- pending.intent.owner === response.owner &&
- pending.turn === g.turn &&
- pending.phase === g.phase,
- 'This Richese gift response is no longer current.',
- );
- let transfer: ReturnType | null = null;
- if (!canceled) {
- const context = richeseGiftContext(g);
- try {
- const candidate = validateRicheseGift(
- context,
- getPlayer(context, pending.intent.owner),
- pending.intent.cardId,
- );
- requireRule(
- candidate.intent.owner === pending.intent.owner &&
- candidate.intent.recipient === pending.intent.recipient &&
- candidate.intent.cardId === pending.intent.cardId,
- 'The original gift recipient is no longer your current ally.',
- );
- transfer = candidate;
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- log(
- g,
- 'The Richese gift became unavailable before transfer. No card moved; the interrupted decision resumes.',
- );
- }
- }
- if (canceled) {
- requireRule(
- richeseCancellation?.kind === 'richeseGift',
- 'Missing canceled Richese gift.',
- );
- g.richeseGiftBlocked = richeseCancellation.blocked;
- log(
- g,
- 'Karama prevented this Richese gift. The card remains in its owner’s hand; the interrupted decision resumes.',
- );
- } else if (transfer) {
- getPlayer(g, pending.intent.owner).hand = transfer.resulting.ownerHand;
- getPlayer(g, pending.intent.recipient).hand =
- transfer.resulting.recipientHand;
- log(
- g,
- `${getPlayer(g, pending.intent.owner).name} gave their ally one Richese Treachery Card. The exact card is private, and this gift creates no auction income, bonus draw or replacement.`,
- { faction: 'richese', name: 'Allied card gift' },
- );
- }
- const resume =
- richeseCancellation?.kind === 'richeseGift'
- ? richeseCancellation.resume
- : pending.resume;
- g.pendingRicheseGift = null;
- g.response = resume.response;
- g.decision = resume.decision;
- g.pendingKarama = resume.pendingKarama;
- g.phaseOpening = resume.phaseOpening;
- } else if (response.kind === 'richeseNoField') {
- const quote = canceled ? noFieldCancellationQuote(g, response) : null;
- const shipment = g.pendingShipment;
- requireRule(
- shipment &&
- ((shipment.noField && shipment.player === response.owner) ||
- shipment.alliedNoField?.owner === response.owner),
- 'No current No-Field shipment is awaiting this response.',
- );
- g.pendingShipment = null;
- if (canceled) {
- requireRule(quote, 'Missing canceled No-Field declaration.');
- if (quote.kind === 'allied') g.richeseAllyBlocked = quote.blocked;
- else getPlayer(g, quote.player).noFieldBlockedTurn = quote.blockedTurn;
- log(
- g,
- 'Karama prevented No-Field use for this shipment opportunity. No spice, reserves or token history changed; normal shipment remains available.',
- );
- } else offerShipment(g, shipment);
- } else if (response.kind === 'moritaniRetention') {
- const pending = g.moritaniRetention;
- requireRule(
- pending?.stage === 'response' &&
- pending.owner === response.owner &&
- pending.keep,
- 'No Moritani alliance retention is awaiting this response.',
- );
- if (canceled)
- log(g, 'Karama prevented Moritani’s ally from retaining a battle card.');
- finishMoritaniRetention(g, canceled ? null : pending.keep);
- } else if (response.kind === 'moritaniDuke') {
- requireRule(
- g.phase === 5 && g.dukeAcquisitionTurn === g.turn && g.dukeVidal,
- 'This Duke Vidal acquisition is no longer current.',
- );
- if (!canceled) {
- g.dukeVidal = acquireDuke(
- g.dukeVidal,
- response.owner,
- g.turn,
- 'moritani',
- );
- log(
- g,
- `${getPlayer(g, response.owner).name} acquired Duke Vidal for one battle this turn.`,
- );
- } else
- log(g, 'Karama prevented Moritani from acquiring Duke Vidal this turn.');
- nextPhase(g);
- } else if (response.kind === 'moritaniAlliance') {
- const entry = g.pendingTerrorEntry;
- requireRule(
- entry &&
- entry.stage === 'allianceResponse' &&
- entry.turn === g.turn &&
- entry.phase === g.phase,
- 'This Enemy of My Enemy opportunity is no longer current.',
- );
- if (canceled) {
- requireRule(allianceCancellation, 'Missing canceled Moritani alliance.');
- g.pendingTerrorEntry = allianceCancellation.entry;
- g.decision = allianceCancellation.decision;
- } else {
- entry.stage = 'allianceReply';
- g.decision = {
- kind: 'moritaniTerror',
- player: entry.entrant,
- entrant: entry.entrant,
- territory: entry.territory,
- };
- }
- log(
- g,
- canceled
- ? 'Karama prevented Enemy of My Enemy; Moritani may still reveal the token.'
- : `${getPlayer(g, entry.entrant).name} may accept or refuse Moritani’s alliance offer.`,
- );
- } else if (response.kind === 'ecazPlacement') {
- const pending = g.pendingEcazPlacement;
- requireRule(
- pending &&
- pending.turn === g.turn &&
- g.phase === 4 &&
- getPlayer(g, response.owner).faction === 'ecaz' &&
- g.ecazPlacementTurn !== g.turn,
- 'No Ambassador placement is pending for this turn.',
- );
- const owner = getPlayer(g, response.owner);
- if (canceled) {
- g.ecazAmbassadors = blockAmbassadorPlacement(g.ecazAmbassadors!, g.turn);
- log(
- g,
- `${owner.name}'s remaining Ambassador placements were canceled for this turn.`,
- );
- finishEcazPlacement(g);
- } else {
- const quote = ambassadorPlacementQuote(
- g,
- owner,
- pending.token,
- pending.territory,
- );
- requireRule(
- quote.cost === pending.cost,
- 'The Ambassador placement price changed.',
- );
- owner.spice -= quote.cost;
- g.ecazAmbassadors = quote.state;
- g.pendingEcazPlacement = null;
- log(
- g,
- `${owner.name} placed the ${faction(quote.state.tokens.find((t) => t.id === pending.token)!.effect).name} Ambassador in ${territory(pending.territory).name} for ${quote.cost} spice. The next Ambassador placed this turn will cost ${quote.cost + 1} spice.`,
- { faction: owner.faction, name: 'Ambassador placement' },
- );
- g.decision = { kind: 'ecazPlacement', player: owner.id };
- }
- } else if (response.kind === 'moritaniPlacement') {
- const pending = g.pendingMoritaniPlacement;
- requireRule(
- g.phase === 8 && pending?.turn === g.turn && g.moritaniTerror,
- 'The Terror placement opportunity is no longer current.',
- );
- if (canceled) {
- g.moritaniTerror.placementTurn = g.turn;
- log(
- g,
- 'Moritani’s Terror placement or relocation was prevented for this turn.',
- );
- } else {
- try {
- g.moritaniTerror = placeTerror(
- g.moritaniTerror,
- pending.token,
- pending.territory,
- g.turn,
- );
- } catch (error) {
- throw new RuleError(
- error instanceof Error ? error.message : 'Invalid Terror placement.',
- );
- }
- log(
- g,
- `Moritani placed a hidden Terror token in ${territory(pending.territory).name}.`,
- );
- }
- g.pendingMoritaniPlacement = null;
- finishMoritaniPlacement(g);
- } else if (response.kind === 'choamWorthless') {
- const pending = g.pendingChoamWorthless!;
- g.pendingChoamWorthless = null;
- const choam = getPlayer(g, pending.owner);
- if (canceled) {
- if (
- g.choamWorthlessBlocked?.turn !== g.turn ||
- g.choamWorthlessBlocked.phase !== g.phase
- )
- g.choamWorthlessBlocked = { turn: g.turn, phase: g.phase, cards: [] };
- g.choamWorthlessBlocked.cards.push(pending.card);
- log(g, 'CHOAM’s Worthless card effect was prevented for this phase.');
- } else if (
- choam.hand.some((c) => c.id === pending.card) &&
- (pending.effect !== 'gamont' ||
- gamontAvailable(
- g,
- pending.target!,
- pending.location!,
- pending.elite!,
- pending.noFieldEvent,
- )) &&
- (pending.effect !== 'baliset' || at(choam, pending.location!) > 0) &&
- (pending.effect !== 'jubba' ||
- choamStormOptions(g).some((t) => t.territory === pending.location))
- ) {
- discard(g, choam, pending.card);
- if (pending.effect === 'kulon') {
- g.choamMovement = {
- turn: g.turn,
- bonus:
- (g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) + 1,
- };
- log(
- g,
- `${choam.name} used Kulon to move one extra territory this turn.`,
- );
- } else if (pending.effect === 'jubba') {
- (g.stormResolution!.choamProtected ??= []).push(pending.location!);
- log(
- g,
- `${choam.name} protected its own forces in ${territory(pending.location!).name} for this storm movement with Jubba Cloak.`,
- );
- } else if (pending.effect === 'baliset') {
- (g.choamBaliset ??= []).push({
- turn: g.turn,
- player: pending.target!,
- territory: pending.location!,
- });
- log(
- g,
- `${getPlayer(g, pending.target!).name} cannot move into CHOAM's ${territory(pending.location!).name} this phase; shipment remains possible.`,
- );
- if (pending.movement) {
- g.pendingChoamMove = null;
- log(
- g,
- 'The declared move was prevented without moving forces or spending a movement.',
- );
- }
- } else if (pending.effect === 'gamont') {
- const target = getPlayer(g, pending.target!);
- if (pending.noFieldEvent) revealPlayerNoField(g, target, 'gamont');
- const returned = (target.forces[pending.location!] ?? 0) > 0;
- if (returned) {
- removeGroup(target, [[pending.location!, 1]], {
- [pending.location!]: pending.elite!,
- });
- target.reserves++;
- if (target.elites) target.elites.reserves += pending.elite!;
- }
- settleAdvisors(g);
- log(
- g,
- returned
- ? `${choam.name} sent one of ${target.name}’s forces to reserves with Trip to Gamont.`
- : `${choam.name} used Trip to Gamont to reveal ${target.name}’s No-Field. No force was present to return; the card is still used.`,
- );
- } else {
- g.revivalRules ??= newRevivalRules();
- (g.revivalRules.freeBlocked ??= []).push(pending.target!);
- log(
- g,
- `${getPlayer(g, pending.target!).name} cannot take free force revival this phase.`,
- );
- if (pending.revival) {
- g.pendingRevival = null;
- log(
- g,
- 'The free revival request was stopped without moving forces or paying spice. Choose a paid revival if desired.',
- );
- }
- }
- } else
- log(
- g,
- 'The declared card or force is no longer available; no Worthless effect occurs.',
- );
- if (pending.storm && g.stormResolution) offerChoamStorm(g);
- if (pending.revival && g.pendingRevival) offerRevivalStop(g);
- if (pending.movement && g.pendingChoamMove) resumeChoamMovement(g);
- if (pending.mentat) g.decision = { kind: 'choamMentat', player: choam.id };
- } else if (response.kind === 'richeseAuction') {
- requireRule(
- !canceled,
- 'The canceled Richese auction count is awaiting an official ruling or an explicit table interpretation.',
- );
- if (g.richeseBidding!.position === 'first') offerRicheseCache(g);
- else prepareRicheseNormal(g);
- } else if (response.kind === 'richeseBlackMarket') {
- if (canceled) {
- g.richeseAuction!.outcome = { kind: 'unbid' };
- settleRicheseLot(g);
- } else {
- const atreides = byFaction(g, 'atreides');
- if (atreides)
- g.response = {
- kind: 'atreidesAuction',
- owner: atreides.id,
- passed: [],
- };
- else settleRicheseLot(g);
- }
- } else if (response.kind === 'ixAuction') {
- const request = currentIxAuctionDrawQuote(g, response, canceled)!;
- const pending = g.ixAuction!;
- const cards: Card[] = [];
- for (let i = 0; i < request.drawCount; i++) {
- const card = draw(g);
- if (card) cards.push(card);
- }
- if (!canceled && cards.length > 1) {
- pending.cards = cards;
- g.decision = { kind: 'ixAuction', player: response.owner };
- } else {
- g.ixAuction = null;
- setAuction(g, cards);
- }
- } else if (response.kind === 'ixTechnology') {
- if (technologyCancellation) {
- g.pendingIxTechnology = technologyCancellation.pendingIxTechnology;
- g.auction!.peekKnown = technologyCancellation.peek.peekKnown;
- g.response = technologyCancellation.peek.response;
- log(g, 'Ixian auction substitution did not occur.');
- return;
- }
- const pending = g.pendingIxTechnology!;
- const ixians = getPlayer(g, response.owner);
- const replacement = ixians.hand.find((c) => c.id === pending.card);
- if (!canceled && replacement) {
- const a = g.auction!,
- taken = a.cards[a.index];
- ixians.hand = ixians.hand.filter((c) => c.id !== replacement.id);
- ixians.hand.push(taken);
- a.cards[a.index] = replacement;
- if (g.ixAuctionKnown?.turn === g.turn)
- g.ixAuctionKnown.cards = [
- ...g.ixAuctionKnown.cards.filter(
- (c) => c.id !== taken.id && c.id !== replacement.id,
- ),
- replacement,
- ];
- log(
- g,
- `${ixians.name} exchanged a hand card for the upcoming auction card.`,
- );
- } else log(g, 'Ixian auction substitution did not occur.');
- g.pendingIxTechnology = null;
- offerAuctionPeek(g);
- } else if (response.kind === 'ixAllyCard') {
- const pending = g.pendingIxAlly!;
- const buyer = getPlayer(g, pending.player);
- if (!canceled && buyer.hand.some((c) => c.id === pending.card)) {
- const sale = g.currentAuctionSale;
- requireRule(
- sale,
- 'The paid auction must remain available for its replacement.',
- );
- const card = discard(g, buyer, pending.card);
- g.pendingIxAlly = null;
- stageTreacheryDiscard(
- g,
- 'ixAllyCard',
- [
- {
- card,
- discardedBy: buyer.id,
- publicFace: sale.origin === 'cache',
- },
- ],
- {
- kind: 'ixAllyCard',
- ...pending,
- sale,
- auctionIndex: g.auction?.index ?? null,
- auctionEvent: g.richeseAuction?.event ?? null,
- },
- );
- return;
- }
- g.pendingIxAlly = null;
- continueAuctionSale(g, pending.free);
- } else if (response.kind === 'mobileStronghold') {
- if (canceled) movementCancellationQuote(g, response);
- const pending = g.pendingMobileMove!;
- g.pendingMobileMove = null;
- if (!canceled) relocateMobileStronghold(g, pending);
- else
- log(g, 'Mobile stronghold movement and spice collection were canceled.');
- beginStormTurn(g);
- } else if (response.kind === 'fremenMovement') {
- const pending = pendingFremenMovement(g, response);
- const quote = canceled ? movementCancellationQuote(g, response) : null;
- const player = getPlayer(g, pending.order.player);
- g.pendingFremenMove = null;
- if (canceled) {
- requireRule(
- quote?.kind === 'fremenMovement',
- 'Missing canceled Fremen movement.',
- );
- player.fremenMovementBlocked = { turn: quote.turn, move: quote.move };
- log(
- g,
- `${player.name}'s two-territory movement advantage was canceled for this move. The forces remain in place and no movement was spent. Choose a one-territory move; available ornithopters still apply.`,
- );
- } else {
- log(
- g,
- `${player.name}'s two-territory movement advantage was allowed. The declared movement continues.`,
- { faction: 'fremen', name: 'Fremen movement' },
- );
- offerChoamMovement(g, pending.order);
- }
- } else if (response.kind === 'ixMovement') {
- const quote = canceled ? movementCancellationQuote(g, response) : null;
- const move = g.pendingIxMove!;
- g.pendingIxMove = null;
- if (canceled) {
- const player = getPlayer(g, move.player);
- requireRule(
- quote?.kind === 'ixMovement',
- 'Missing canceled Ixian movement.',
- );
- player.ixMovementBlocked = { turn: quote.turn, move: quote.move };
- log(
- g,
- `${player.name}'s cyborg movement advantage was canceled. A one-territory move remains available.`,
- );
- } else offerChoamMovement(g, move);
- } else if (response.kind === 'ixSubstitution') {
- const quote = canceled
- ? ixSubstitutionCancellationQuote(g, response)!
- : null;
- const pending = g.pendingIxSubstitution!;
- const player = getPlayer(g, pending.player);
- if (!canceled) {
- for (const [key, count] of Object.entries(pending.sources!))
- kill(g, player, key, count, true, 0);
- for (const [key, count] of Object.entries(pending.recover!)) {
- player.tanks -= count;
- player.elites!.tanks -= count;
- player.battleLosses -= count;
- const loc = splitLocation(key);
- place(player, loc.territory, loc.sector, count, count);
- }
- log(
- g,
- `${player.name} substituted surviving suboids for cyborgs lost in this battle.`,
- );
- }
- g.pendingIxSubstitution = null;
- if (quote) {
- log(
- g,
- 'Karama prevented Ixian substitution. Original cyborg casualties remain in the Tanks; no surviving suboids were exchanged.',
- );
- if (quote.decision) g.decision = quote.decision;
- else finishBattle(g);
- } else finishWinner(g, player, pending.territory, pending.cards);
- } else if (
- [
- 'choamRevival',
- 'revivalLimit',
- 'revivalDiscount',
- 'earlyRevival',
- 'foreignGhola',
- ].includes(response.kind)
- ) {
- if (canceled) {
- const quote = revivalCancellationQuote(g, response)!;
- const player = getPlayer(g, g.pendingRevival!.player);
- g.revivalRules = quote.rules;
- g.pendingRevival = quote.pending;
- if (quote.block?.kind === 'foreignGhola') {
- player.gholaBlocked ??= {};
- player.gholaBlocked[quote.block.leader] = g.turn;
- }
- if (response.kind === 'choamRevival') {
- log(g, 'CHOAM revival advantage was canceled for this phase.');
- if (quote.outcome === 'abandoned')
- log(
- g,
- `${player.name} must choose a revival within the remaining allowance. No forces or spice changed.`,
- );
- } else log(g, `${player.name}'s revival benefit was canceled.`);
- } else if (response.kind !== 'choamRevival')
- g.revivalRules ??= newRevivalRules();
- if (g.pendingRevival) finishRevival(g);
- } else if (response.kind === 'revivalIncome') {
- if (!canceled) getPlayer(g, response.owner).spice += response.amount!;
- } else if (response.kind === 'faceDancerReplacement') {
- const owner = getPlayer(g, response.owner);
- const index = owner.faceDancers!.findIndex(
- (c) => c.leader === response.intent && !c.revealed,
- );
- if (!canceled && index >= 0) {
- g.traitorReserve = shuffle([
- ...(g.traitorReserve ?? []),
- owner.faceDancers![index].leader,
- ]);
- owner.faceDancers![index] = {
- leader: g.traitorReserve.shift()!,
- revealed: false,
- };
- log(g, `${owner.name} replaced one unrevealed Face Dancer.`);
- }
- } else if (response.kind === 'worthlessKarama') {
- const pending = g.pendingKarama!;
- g.pendingKarama = null;
- const owner = getPlayer(g, pending.owner);
- if (!canceled) completeKarama(g, owner, pending.use);
- else if (pending.use.kind === 'cancel') g.response = pending.use.response;
- else if (pending.use.kind === 'auctionPayment')
- recoverAuctionPayment(g, owner);
- } else if (response.kind === 'choamSale') {
- const market = g.choamMarket!;
- const owner = getPlayer(g, market.owner);
- const sale = market.sale!;
- if (canceled) {
- const quote = choamSaleCancellationQuote(g, response)!;
- g.choamMarket = quote.market;
- g.decision = quote.decision;
- log(
- g,
- 'The declared CHOAM card sale was canceled; the card stays in hand.',
- );
- return;
- } else if (quoteSale(owner.hand, sale.card, sale.witness)) {
- discard(g, owner, sale.card);
- owner.spice += sale.price;
- log(g, `${owner.name} received ${sale.price} spice from the card sale.`);
- } else
- log(
- g,
- 'The declared card sale is no longer possible; no spice was paid.',
- );
- delete market.sale;
- resumeChoamMarket(g);
- } else if (response.kind === 'choamInflation') {
- if (!canceled) {
- g.inflation = {
- side: response.intent as Inflation['side'],
- placedTurn: g.turn,
- updatedTurn: g.turn,
- flipped: false,
- };
- g.inflationUsed = true;
- log(
- g,
- `CHOAM placed Inflation: ${g.inflation.side} next turn’s charity.`,
- );
- } else log(g, 'Inflation placement was canceled for this Mentat Pause.');
- } else if (response.kind === 'choamCharity') {
- g.choamCharity =
- terminal?.kind === 'choamCharity'
- ? terminal.receipt
- : { turn: g.turn, canceled };
- if (!canceled) {
- const choam = getPlayer(g, response.owner);
- const amount = 2 * g.players.length * charityMultiplier(g);
- choam.spice += amount;
- log(g, `${choam.name} collected ${amount} spice before charity claims.`);
- } else
- log(
- g,
- 'CHOAM income was canceled. Charity is paid by the Spice Bank this turn.',
- );
- } else if (response.kind === 'bgCharity') {
- if (!canceled)
- payCharity(g, getPlayer(g, response.owner), response.amount ?? 2);
- } else if (response.kind === 'advisorFlip') {
- if (canceled) movementCancellationQuote(g, response);
- const bg = getPlayer(g, response.owner),
- to = response.location!;
- if (!canceled) {
- bg.advisors ??= {};
- if (response.advisors) bg.advisors[to] = {};
- else delete bg.advisors[to];
- log(
- g,
- `${bg.name} changed all forces in ${territory(to).name} to ${response.advisors ? 'advisors' : 'fighters'}.`,
- );
- }
- finishAdvisorReaction(g, response);
- } else if (response.kind === 'capture') {
- const capture = g.pendingCapture!;
- g.pendingCapture = null;
- if (canceled) finishBattle(g);
- else {
- const candidates = captureCandidates(g, capture.loser, capture.territory);
- const l = shuffle(candidates)[0];
- if (!l) {
- finishBattle(g);
- return;
- }
- const owner = g.players.find((p) => p.leaders.includes(l))!;
- l.concealed = {
- captor: capture.player,
- controller: capture.loser,
- dead: l.dead,
- deaths: l.deaths,
- usedAt: l.usedAt,
- };
- l.capturedBy = capture.player;
- g.decision = {
- kind: 'capturedLeader',
- player: capture.player,
- owner: owner.id,
- leader: l.id,
- controller: capture.loser,
- };
- log(
- g,
- `${getPlayer(g, capture.player).name} captured a leader from ${getPlayer(g, capture.loser).name}.`,
- );
- }
- } else if (response.kind === 'choamAudit') {
- const pending = g.pendingAuditor;
- requireRule(
- pending &&
- pending.stage === 'response' &&
- response.intent === pending.event &&
- response.owner === pending.owner,
- 'This Auditor cancellation window is stale.',
- );
- if (canceled) finishAuditor(g, 'cancel');
- else if (
- !currentAuditCount(g) ||
- getPlayer(g, pending.opponent).spice < currentAuditCount(g)
- )
- finishAuditor(g, 'inspect');
- else {
- pending.stage = 'payment';
- g.decision = {
- kind: 'choamAuditPayment',
- player: pending.opponent,
- event: pending.event,
- };
- }
- } else if (response.kind === 'choamBattleIncome') {
- const pending = g.pendingChoamBattleIncome!;
- g.pendingChoamBattleIncome = null;
- if (!canceled) {
- const choam = getPlayer(g, pending.owner);
- choam.spice += pending.amount;
- log(
- g,
- `${choam.name} received ${pending.amount} spice from battle force payments.`,
- );
- } else log(g, 'CHOAM battle force payments went to the bank.');
- finishBattle(g);
- } else if (isCombatResponseKind(response.kind)) {
- commitCombatResponseQuote(
- g,
- currentCombatResponseQuote(g, {
- kind: 'response',
- response: { kind: response.kind, owner: response.owner },
- canceled,
- }),
- );
- } else if (response.kind === 'guildTiming') {
- if (canceled) {
- const quote = movementCancellationQuote(g, response);
- requireRule(
- quote?.kind === 'guildTiming',
- 'Missing canceled Guild timing.',
- );
- g.guildTimingLocked = true;
- g.active = quote.active;
- } else {
- g.guildTimingGranted = true;
- chooseGuildTiming(g, response.take!);
- }
- } else if (response.kind === 'atreidesAuction') {
- if (g.richeseAuction) {
- g.richesePeekKnown = !canceled;
- settleRicheseLot(g);
- } else
- g.auction!.peekKnown =
- terminal?.kind === 'atreidesAuction' ? terminal.known : !canceled;
- } else if (response.kind === 'atreidesSpice') {
- g.spicePeekKnown =
- terminal?.kind === 'atreidesSpice' ? terminal.known : !canceled;
- } else if (response.kind === 'wormPlacement') {
- if (canceled) {
- g.wormPlacementCanceledTurn = g.turn;
- continueSpice(g);
- } else beginWorm(g, response.location!);
- } else if (response.kind === 'wormAllyProtection') {
- wormSurvival(
- g,
- response.location!,
- canceled ? undefined : response.recipient,
- );
- } else if (response.kind === 'wormSurvival') {
- devour(g, response.location!, response.recipient, !canceled);
- afterWorm(g);
- } else if (response.kind === 'stormProtection') {
- if (response.resume === 'storm') {
- g.stormResolution!.protected = !canceled;
- continueStorm(g);
- } else {
- const p = getPlayer(g, response.owner);
- if (canceled)
- kill(
- g,
- p,
- response.location!,
- response.amount!,
- false,
- response.elite ?? 0,
- );
- else
- stormCasualties(
- g,
- p,
- response.location!,
- response.amount!,
- response.elite ?? 0,
- 'shipment',
- );
- }
- } else if (response.kind === 'stormPeek') {
- g.stormCardKnown =
- terminal?.kind === 'stormPeek' ? terminal.known : !canceled;
- } else if (
- response.kind === 'emperorGift' ||
- response.kind === 'emperorRevival'
- ) {
- if (!canceled) {
- const emperor = getPlayer(g, response.owner),
- recipient = getPlayer(g, response.recipient!);
- const amount = response.amount!;
- if (response.kind === 'emperorGift') {
- emperor.spice -= amount;
- recipient.spice += amount;
- } else {
- beginRevival(g, {
- player: recipient.id,
- payer: emperor.id,
- emperorExtra: true,
- kind: 'forces',
- amount,
- elite: response.elite ?? 0,
- cost: paidForceRevivalCost(recipient, amount, response.elite ?? 0),
- normalCost: paidForceRevivalCost(
- recipient,
- amount,
- response.elite ?? 0,
- ),
- free: 0,
- checks: [],
- });
- }
- log(
- g,
- `${emperor.name} ${response.kind === 'emperorGift' ? 'shared spice with' : `offered ${amount} extra force revivals to`} ${recipient.name}.`,
- );
- }
- } else if (response.kind === 'guildIncome') {
- if (!canceled) getPlayer(g, response.owner).spice += response.amount!;
- log(
- g,
- canceled
- ? `Karama sent the ${response.amount} spice of Guild shipment income to the bank. The shipment and its price remain unchanged.`
- : `${getPlayer(g, response.owner).name} collected ${response.amount} spice paid by other factions for shipment. Spice contributed by the Guild itself goes to the bank.`,
- {
- faction: 'guild',
- name: canceled ? 'Shipment income prevented' : 'Shipment income',
- },
- );
- } else if (response.kind === 'harkonnenTraitor') {
- const b = g.battle!;
- if (canceled) b.traitorCalls[response.owner] = false;
- if (
- traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined)
- )
- resolveBattle(g);
- } else if (response.kind === 'richesePurchaseIncome') {
- const pending = g.pendingRichesePurchaseIncome;
- requireRule(
- pending &&
- pending.owner === response.owner &&
- pending.turn === g.turn &&
- pending.phase === g.phase &&
- response.amount === 3,
- 'This Richese purchase income is no longer current.',
- );
- if (!canceled) getPlayer(g, response.owner).spice += 3;
- log(
- g,
- canceled
- ? 'Karama prevented the Emperor from collecting the 3-spice Richese purchase payment. The purchase remains completed and the payment goes to the bank.'
- : `${getPlayer(g, response.owner).name} collected 3 spice because the Emperor receives another faction’s Treachery Card purchase payment.`,
- {
- faction: 'emperor',
- name: canceled ? 'Purchase income prevented' : 'Card purchase income',
- },
- );
- const resume =
- richeseCancellation?.kind === 'richesePurchaseIncome'
- ? richeseCancellation.resume
- : pending.resume;
- g.pendingRichesePurchaseIncome = null;
- g.response = resume.response;
- g.decision = resume.decision;
- g.pendingKarama = resume.pendingKarama;
- } else if (response.kind === 'emperorIncome') {
- if (!canceled) {
- const owner = getPlayer(g, response.owner);
- const amount = g.currentAuctionSale?.amount ?? g.auction!.bid;
- owner.spice += amount;
- log(
- g,
- `${owner.name} received ${amount} spice because the Emperor collects another faction’s paid auction bid.`,
- { faction: owner.faction, name: 'Auction income' },
- );
- }
- auctionBonus(g);
- } else if (response.kind === 'harkonnenBonus') {
- if (!canceled) {
- const owner = getPlayer(g, response.owner);
- const room = owner.hand.length < handLimit(owner);
- const bonus = room ? draw(g) : undefined;
- if (bonus) {
- owner.hand.push(bonus);
- log(
- g,
- `${owner.name} drew one bonus treachery card because Harkonnen receives an extra card after winning an auction while below its eight-card limit. Its identity remains private.`,
- { faction: owner.faction, name: 'Bonus treachery card' },
- );
- } else
- log(
- g,
- room
- ? `${owner.name} received no Harkonnen bonus card because no treachery card was available to draw.`
- : `${owner.name} received no Harkonnen bonus because their hand reached its eight-card limit before the draw.`,
- );
- } else log(g, 'Karama prevented the Harkonnen bonus treachery card.');
- nextAuction(g);
- } else if (response.kind === 'advisor') {
- if (canceled) return;
- const owner = getPlayer(g, response.owner);
- requireRule(owner.reserves > 0, 'No forces remain in reserves.');
- owner.reserves--;
- const target = splitLocation(response.location ?? 'polar_sink:0');
- const advisors = arrivalAsAdvisor(
- g,
- owner,
- target.territory,
- undefined,
- target.territory !== 'polar_sink',
- );
- place(owner, target.territory, target.sector, 1);
- techIncome(g, 'heighliners', owner);
- if (advisors) {
- owner.advisors ??= {};
- owner.advisors[target.territory] = { lockedTurn: g.turn };
- }
- log(
- g,
- `${owner.name} sent a free force to ${territory(target.territory).name}.`,
- );
- openTerritoryEntry(
- g,
- owner,
- target.territory,
- target.sector,
- 1,
- 0,
- 'advisor',
- );
- } else throw new RuleError('Unknown faction response.');
-}
-function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
- // Concealed and allied No-Fields retain their separate custody/quote contracts.
- if (shipment.noField || shipment.alliedNoField) return;
- const p = getPlayer(g, shipment.player);
- requireRule(
- g.status === 'playing' &&
- g.phase === 5 &&
- g.active === p.id &&
- !p.shipped &&
- (shipment.turn === undefined || shipment.turn === g.turn),
- 'This shipment no longer belongs to the current unused shipment opportunity.',
- );
- const n = integer(shipment.amount, 1, p.reserves, 'Shipment forces');
- integer(shipment.sector, 0, 18, 'Shipment sector');
- eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, shipment.elite);
- requireRule(
- typeof shipment.elite === 'number' &&
- shipment.advisors === arrivalAsAdvisor(g, p, shipment.territory),
- 'The declared force allocation or arrival stance is no longer current.',
- );
- requireRule(
- shipment.territory !== MOBILE_STRONGHOLD || p.faction === 'ixians',
- 'Only Ixians may ship directly into the mobile stronghold.',
- );
- allowedEntry(
- g,
- p,
- shipment.territory,
- shipment.sector,
- g.advanced && p.faction === 'fremen',
- shipment.advisors,
- );
- if (p.faction === 'fremen')
- requireRule(
- territory('the_great_flat').sectors.some((from) =>
- territory(shipment.territory).sectors.some(
- (to) =>
- distance(
- location('the_great_flat', from),
- location(shipment.territory, to),
- ) <= 2,
- ),
- ),
- 'Fremen reinforcements must arrive within two territories of the Great Flat.',
- );
- const cost = reserveShipmentCost(
- {
- faction: p.faction,
- halfRate:
- p.faction === 'guild' ||
- byFaction(g, 'guild')?.id === p.ally ||
- g.karamaShipping?.player === p.id,
- },
- territory(shipment.territory).type,
- n,
- );
- requireRule(
- Number.isSafeInteger(shipment.cost) && shipment.cost === cost,
- 'The declared shipment price is no longer current.',
- );
- integer(p.spice, 0, Number.MAX_SAFE_INTEGER, 'Current shipment spice');
- integer(shipment.allyPayment, 0, cost, 'Shipment ally payment');
- const credit = aidFor(g, p);
- if (credit)
- integer(credit.amount, 0, Number.MAX_SAFE_INTEGER, 'Current ally pledge');
- if (shipment.allyPayment > 0)
- requireRule(
- p.ally && getPlayer(g, p.ally).ally === p.id,
- 'The declared shipment contributor is no longer your mutual ally.',
- );
- contribution(g, p, cost, shipment.allyPayment);
-}
-function validateGuildShipmentDecision(
- g: Game,
- decision: Extract,
-) {
- const shipment = g.pendingShipment;
- const guild = byFaction(g, 'guild');
- requireRule(
- shipment &&
- g.advanced &&
- guild?.id === decision.player &&
- !guild.specialKaramaUsed &&
- decision.shipper === shipment.player &&
- getPlayer(g, shipment.player).faction !== 'fremen' &&
- decision.territory === shipment.territory &&
- decision.sector === shipment.sector &&
- decision.amount === (shipment.alliedNoField ? 1 : shipment.amount),
- 'The Guild decision does not match the pending shipment.',
- );
- validatePhysicalShipment(g, shipment);
- return shipment;
-}
-function offerShipment(g: Game, shipment: PendingShipment) {
- validatePhysicalShipment(g, shipment);
- const p = getPlayer(g, shipment.player),
- id = p.id;
- const { territory: to, sector: s } = shipment;
- const n = shipment.alliedNoField ? 1 : shipment.amount;
- const guild = byFaction(g, 'guild');
- if (
- g.advanced &&
- guild &&
- !guild.specialKaramaUsed &&
- p.faction !== 'fremen'
- ) {
- // Offer independently of the Guild's hidden hand. No payment or arrival
- // effects occur until this decision has resolved.
- g.pendingShipment = shipment;
- g.decision = {
- kind: 'guildShipment',
- player: guild.id,
- shipper: id,
- territory: to,
- sector: s,
- amount: n,
- };
- log(
- g,
- `${p.name} declared a shipment of ${n} forces to ${territory(to).name}, sector ${s}.`,
- );
- } else commitShipment(g, shipment);
-}
-function commitShipment(g: Game, shipment: PendingShipment) {
- validatePhysicalShipment(g, shipment);
- finishShipmentPromises(
- g,
- getPlayer(g, shipment.player),
- shipment.noField || shipment.alliedNoField ? null : shipment,
- );
- const {
- territory: to,
- sector: s,
- amount: n,
- elite,
- cost,
- allyPayment,
- advisors,
- } = shipment;
- const p = getPlayer(g, shipment.player);
- if (shipment.noField) {
- requireRule(
- p.faction === 'richese' &&
- p.noField &&
- p.noFieldEvent === shipment.noField.event,
- 'This No-Field shipment is no longer current.',
- );
- p.noField = noFieldRule(() =>
- deployRicheseNoField(p.noField!, {
- tokenId: shipment.noField!.tokenId,
- controller: p.id,
- location: { territory: to, sector: s },
- }),
- );
- p.noFieldEvent = crypto.randomUUID();
- }
- if (shipment.alliedNoField) {
- const offer = shipment.alliedNoField;
- let quote: ReturnType;
- try {
- quote = alliedNoFieldQuote(g, offer);
- requireRule(
- quote.amount === n &&
- quote.cost === cost &&
- elite >= quote.eliteMin &&
- elite <= quote.eliteMax,
- 'The offered reserve allocation changed before shipment.',
- );
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- g.pendingShipment = null;
- log(
- g,
- 'The allied No-Field declaration became unavailable before commitment. No shipment, payment or token reveal occurred; the recipient may ship normally.',
- );
- return;
- }
- const owner = getPlayer(g, offer.owner);
- if (owner.noField!.deployed) revealPlayerNoField(g, owner, 'beforeAlly');
- const result = noFieldRule(() =>
- shipAlliedRicheseNoField(
- owner.noField!,
- {
- tokenId: offer.tokenId,
- controller: p.id,
- location: { territory: to, sector: s },
- },
- p.reserves,
- ),
- );
- owner.noField = result.state;
- owner.noFieldEvent = crypto.randomUUID();
- owner.spice -= quote.ownerPayment;
- p.spice -= quote.recipientPayment;
- } else payWithAlly(g, p, cost, allyPayment);
- if (!shipment.noField) {
- p.reserves -= n;
- if (p.elites) p.elites.reserves -= elite;
- if (n > 0) place(p, to, s, n, elite);
- }
- if (advisors && n > 0) (p.advisors ??= {})[to] ??= {};
- p.shipped = true;
- if (p.faction !== 'fremen') techIncome(g, 'heighliners', p);
- const guild = byFaction(g, 'guild');
- const guildPayment = guildShipmentIncome({
- guild: guild?.id,
- shipper: p.id,
- ally: p.ally,
- cost,
- allyPayment,
- bankOnly: g.karamaShipping?.player === p.id,
- });
- if (guild && guildPayment > 0)
- g.response = {
- kind: 'guildIncome',
- owner: guild.id,
- amount: guildPayment,
- passed: [],
- };
- g.karamaShipping = null;
- log(
- g,
- shipment.alliedNoField
- ? `${getPlayer(g, shipment.alliedNoField.owner).name} shipped ${p.name} with No-Field ${quoteAlliedTokenValue(g, shipment.alliedNoField)}, immediately placing ${n} physical forces (${elite} elite) in ${territory(to).name}, sector ${s}. ${shipment.alliedNoField.payer === 'both' ? 'Each ally paid 1 spice' : `${getPlayer(g, shipment.alliedNoField.payer).name} paid ${cost} spice`}.`
- : shipment.noField
- ? `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. It counts as one force; physical reserves remain unchanged until reveal.`
- : `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.`,
- shipment.noField || shipment.alliedNoField
- ? { faction: 'richese', name: 'No-Field shipment' }
- : undefined,
- );
- const bg = byFaction(g, 'beneGesserit');
- const followup =
- p.faction !== 'fremen' && bg && bg.id !== p.id && bg.reserves > 0
- ? { shipment: p.id, destination: location(to, s) }
- : undefined;
- if (!intrusion(g, p, to, { followup }) && followup)
- g.decision = { kind: 'advisor', player: bg!.id, ...followup };
- if (g.advanced && p.faction === 'fremen' && s === g.storm)
- g.response = {
- kind: 'stormProtection',
- owner: p.id,
- passed: [],
- resume: 'shipment',
- location: location(to, s),
- amount: n,
- elite,
- };
- openTerritoryEntry(
- g,
- p,
- to,
- s,
- shipment.alliedNoField ? Math.max(1, n) : n,
- elite,
- 'shipment',
- );
-}
-function quoteAlliedTokenValue(g: Game, offer: RicheseAllyOffer) {
- return getPlayer(g, offer.owner).noField!.tokens.find(
- (t) => t.id === offer.tokenId,
- )!.value;
-}
-function validateMobileMove(g: Game, p: Player, input: unknown, max: number) {
- requireRule(
- p.faction === 'ixians' &&
- g.mobileStronghold?.location &&
- at(p, MOBILE_STRONGHOLD) > 0,
- 'Ixian forces must occupy the mobile stronghold to move it.',
- );
- requireRule(
- Array.isArray(input) && input.every((key) => typeof key === 'string'),
- 'Declare a route of board sectors.',
- );
- const route = input as string[];
- requireRule(
- route.every((key) => splitLocation(key).sector !== g.storm),
- 'The mobile stronghold cannot move into, out of or through the storm.',
- );
- requireRule(
- route.length > 1 &&
- route[0] === g.mobileStronghold.location &&
- mobileRouteDistance(route) <= max,
- `Choose a connected route of at most ${max} territories, starting at the stronghold.`,
- );
- requireRule(
- territory(splitLocation(route.at(-1)!).territory).type !== 'stronghold',
- 'The mobile stronghold must finish in a non-stronghold territory.',
- );
- return route;
-}
-function relocateMobileStronghold(
- g: Game,
- move: { player: string; route: string[]; collect: boolean },
-) {
- const p = getPlayer(g, move.player);
- let collected = 0;
- if (move.collect)
- for (const key of move.route) {
- const amount = Math.min(g.spice[key] ?? 0, at(p, MOBILE_STRONGHOLD) * 2);
- if (amount) {
- g.spice[key] -= amount;
- p.spice += amount;
- collected += amount;
- }
- }
- g.mobileStronghold!.location = move.route.at(-1)!;
- const loc = splitLocation(g.mobileStronghold!.location);
- log(
- g,
- `${p.name} moved the Hidden Mobile Stronghold to ${territory(loc.territory).name}, sector ${loc.sector}, collecting ${collected} spice.`,
- );
-}
-function choamWorthlessBlocked(g: Game, card: string) {
- return (
- g.choamWorthlessBlocked?.turn === g.turn &&
- g.choamWorthlessBlocked.phase === g.phase &&
- g.choamWorthlessBlocked.cards.includes(card)
- );
-}
-function gamontAvailable(
- g: Game,
- target: string,
- key: string,
- elite: number,
- noFieldEvent?: string,
-) {
- const p = g.players.find((p) => p.id === target);
- if (!p || !key) return false;
- if (noFieldEvent) {
- const marker = p.noField?.deployed;
- return (
- !!marker &&
- p.noFieldEvent === noFieldEvent &&
- location(marker.location.territory, marker.location.sector) === key &&
- elite === 0
- );
- }
- if (
- p.noField?.deployed &&
- location(
- p.noField.deployed.location.territory,
- p.noField.deployed.location.sector,
- ) === key
- )
- return false;
- const n = p.forces[key] ?? 0,
- e = p.elites?.forces[key] ?? 0;
- return elite === 1 ? e > 0 : elite === 0 && n > e;
-}
-function playChoamWorthless(g: Game, p: Player, action: Action) {
- const storming =
- g.decision?.kind === 'choamStorm' && g.decision.player === p.id;
- const reactive =
- g.decision?.kind === 'choamFreeRevival' && g.decision.player === p.id;
- const moving =
- g.decision?.kind === 'choamMovement' && g.decision.player === p.id;
- const mentat =
- g.decision?.kind === 'choamMentat' && g.decision.player === p.id;
- requireRule(
- g.status === 'playing' &&
- p.faction === 'choam' &&
- !g.response &&
- !g.choamMarket &&
- (!g.decision || reactive || mentat || moving || storming) &&
- (!g.pendingRevival || reactive),
- 'CHOAM must finish the current decision before playing this Worthless effect.',
- );
- const card = p.hand.find(
- (c) => c.id === action.card && c.kind === 'worthless',
- );
- requireRule(
- card && !choamWorthlessBlocked(g, card.id),
- 'Choose an owned Worthless card whose effect is not blocked this phase.',
- );
- let effect: 'kulon' | 'laLaLa' | 'gamont' | 'baliset' | 'jubba';
- let target: string | undefined;
- let key: string | undefined;
- let elite: number | undefined;
- let noFieldEvent: string | undefined;
- if (card.name === 'Kulon') {
- requireRule(
- g.ornithopter?.mode !== 'range3' || g.ornithopter.player !== p.id,
- 'Kulon combined with fixed Ornithopter range awaits a ruling.',
- );
- requireRule(
- g.phase === 5 && g.active === p.id && p.moved < movesAllowed(g, p),
- 'Use Kulon on your movement turn before an available move.',
- );
- effect = 'kulon';
- } else if (card.name === 'Jubba Cloak') {
- requireRule(
- g.phase === 0 && storming && g.stormResolution,
- 'Use Jubba Cloak when CHOAM responds to the moving storm.',
- );
- key = stringField(action.territory);
- requireRule(
- choamStormOptions(g).some((t) => t.territory === key),
- 'Choose an unprotected territory where the moving storm threatens CHOAM forces.',
- );
- requireRule(
- action.target === undefined || action.target === p.id,
- 'Jubba Cloak protects only CHOAM forces.',
- );
- target = p.id;
- effect = 'jubba';
- } else if (card.name === 'Baliset') {
- requireRule(
- g.phase === 5,
- 'Baliset is played during Shipment and Movement.',
- );
- target =
- moving && g.decision?.kind === 'choamMovement'
- ? g.decision.mover
- : stringField(action.target);
- key =
- moving && g.decision?.kind === 'choamMovement'
- ? g.decision.territory
- : stringField(action.territory);
- requireRule(
- target !== p.id && g.players.some((other) => other.id === target),
- 'Choose another player.',
- );
- requireRule(
- gameTerritories(g).some((t) => t.id === key) && at(p, key) > 0,
- 'Choose a territory CHOAM occupies.',
- );
- requireRule(
- !g.choamBaliset?.some(
- (b) => b.turn === g.turn && b.player === target && b.territory === key,
- ),
- 'That player and territory are already restricted.',
- );
- effect = 'baliset';
- } else if (card.name === 'Trip to Gamont') {
- requireRule(
- g.phase === 8 && (action.amount === undefined || action.amount === 1),
- 'Trip to Gamont returns exactly one force during Mentat Pause.',
- );
- target = stringField(action.target);
- const other = getPlayer(g, target);
- requireRule(
- other.id !== p.id,
- 'Choose a force belonging to another player.',
- );
- key = stringField(action.from);
- const loc = splitLocation(key);
- const marker = other.noField?.deployed;
- const concealed =
- !!marker &&
- location(marker.location.territory, marker.location.sector) === key;
- requireRule(
- validLocation(loc.territory, loc.sector) &&
- ((other.forces[key] ?? 0) > 0 || concealed),
- 'Choose an occupied board sector.',
- );
- if (concealed) {
- requireRule(
- other.faction === 'richese' &&
- !!other.noFieldEvent &&
- (action.elite === undefined || action.elite === 0),
- 'Choose the Richese No-Field sector and ordinary force type.',
- );
- noFieldEvent = other.noFieldEvent;
- elite = 0;
- } else
- elite = eliteChoice(
- 1,
- other.forces[key],
- other.elites?.forces[key] ?? 0,
- action.elite,
- );
- effect = 'gamont';
- } else {
- requireRule(
- card.name === 'La La La' && g.phase === 4,
- 'This Worthless effect is not available in this phase.',
- );
- effect = 'laLaLa';
- target =
- reactive && g.decision?.kind === 'choamFreeRevival'
- ? g.decision.recipient
- : stringField(action.target);
- getPlayer(g, target);
- requireRule(
- !g.revivalRules?.freeBlocked?.includes(target),
- 'That player is already prevented from free revival.',
- );
- }
- g.pendingChoamWorthless = {
- owner: p.id,
- card: card.id,
- effect,
- target,
- revival: reactive,
- movement: moving,
- storm: storming,
- location: key,
- elite,
- mentat,
- ...(noFieldEvent ? { noFieldEvent } : {}),
- };
- if (reactive || mentat || moving || storming) g.decision = null;
- g.ready = [];
- g.response = {
- kind: 'choamWorthless',
- owner: p.id,
- ...(target ? { recipient: target } : {}),
- ...(key
- ? {
- location:
- effect === 'jubba'
- ? location(
- key,
- choamStormOptions(g).find((t) => t.territory === key)!
- .sectors[0],
- )
- : effect === 'baliset'
- ? location(
- key,
- g.pendingChoamMove?.sector ?? territory(key).sectors[0],
- )
- : key,
- ...(elite !== undefined ? { elite } : {}),
- }
- : {}),
- intent: card.name,
- passed: [],
- };
- log(g, `${p.name} declared ${card.name} for its special effect.`);
-}
-/** Internal normalized command; never accept this object directly from a route. */
-export type SpecialKaramaIntent = {
- owner: string;
- card: string;
- turn: number;
- phase: number;
-} & (
- | { kind: 'choam'; cards: string[] }
- | { kind: 'richese'; acquire: string }
- | { kind: 'ixians'; route: string[]; collect: boolean }
- | { kind: 'tleilaxu'; target: string; revival: PendingRevival }
- | { kind: 'fremen'; territory: string }
- | {
- kind: 'atreides';
- target: string;
- battle: Pick;
- }
- | { kind: 'guild'; target: string; shipment: PendingShipment }
- | { kind: 'emperorForces'; amount: number; elite: number }
- | { kind: 'emperorLeader'; leader: string }
- | { kind: 'harkonnen'; target: string; amount: number }
-);
-
-/** Validates and copies a selection without spending resources, continuing play, or drawing RNG. */
-export function prepareSpecialKaramaIntent(
- g: Game,
- playerId: string,
- action: Action,
-): SpecialKaramaIntent {
- const p = getPlayer(g, playerId);
- // Delayed execution must respect the same enclosing windows as dispatch.
- // CHOAM's early cash-in bypasses the later gates, but never Truthtrance.
- requireRule(!g.truthtrance, 'Resolve the current Truthtrance first.');
- if (p.faction !== 'choam') {
- requireRule(
- !g.phaseOpening,
- 'Play Amal or pass the phase opening before taking other actions.',
- );
- requireRule(
- !g.choamMarket,
- 'Finish the end-of-phase exchange before another special power.',
- );
- requireRule(
- !g.pendingRevival ||
- (p.faction === 'tleilaxu' && g.decision?.kind === 'revivalStop'),
- 'Resolve the pending revival first.',
- );
- }
- requireRule(
- g.status === 'playing' && g.advanced,
- 'Special Karama powers require the advanced game.',
- );
- requireRule(
- !p.specialKaramaUsed,
- 'Your special Karama power has already been used this game.',
- );
- const card = p.hand.find(
- (c) => c.id === action.card && c.effect === 'karama',
- );
- requireRule(card, 'Choose a Karama card in your hand.');
- const base = { owner: p.id, card: card.id, turn: g.turn, phase: g.phase };
- if (p.faction === 'richese') {
- const blocked = karamaSpendingBlock(g, p, card);
- requireRule(!blocked, blocked ?? 'This Karama is committed elsewhere.');
- requireRule(
- !(
- g.richeseAuction?.source === 'blackMarket' &&
- !g.currentAuctionSale &&
- g.richeseAuction.owner === p.id &&
- g.richeseAuction.cardId === card.id
- ),
- 'The Karama reserved for Black Market cannot activate another effect.',
- );
- requireRule(
- p.hand.length < handLimit(p),
- 'Special Richese Karama with a full hand awaits a ruling on acquisition and discard order.',
- );
- requireRule(
- uncommittedSpice(g, p) >= 3,
- 'This purchase needs 3 uncommitted spice.',
- );
- requireRule(
- !g.pendingRichesePurchaseIncome,
- 'Resolve the previous purchase income first.',
- );
- const acquire = stringField(action.acquire);
- const chosen = g.richeseCache?.find((c) => c.id === acquire);
- requireRule(
- chosen && richeseCardDefinition(chosen),
- 'Choose a verified card in your Richese cache.',
- );
- requireRule(
- g.richeseAuction?.source !== 'cache' ||
- g.currentAuctionSale ||
- g.richeseAuction.cardId !== acquire,
- 'The card already offered for auction must remain in that auction.',
- );
- requireRule(
- g.richeseCache!.length > 1,
- 'Buying the final cache card awaits the empty-cache Bidding rule implementation.',
- );
- return { ...base, kind: 'richese', acquire };
- } else if (p.faction === 'choam') {
- requireRule(
- Array.isArray(action.cards) &&
- action.cards.length > 0 &&
- action.cards.every((id) => typeof id === 'string'),
- 'Select cards to discard for spice.',
- );
- const selected = action.cards as string[];
- requireRule(
- new Set(selected).size === selected.length && !selected.includes(card.id),
- 'Choose distinct cards; the activating Karama is spent separately.',
- );
- const available = cashInCards(g, p);
- requireRule(
- [card.id, ...selected].every((id) => available.some((c) => c.id === id)),
- 'Choose your own uncommitted cards. Sealed and prescience cards must remain available.',
- );
- const removed = new Set([card.id, ...selected]);
- const credit = g.auction?.allyPayment ?? 0;
- requireRule(
- !g.auction ||
- g.auction.bidder !== p.id ||
- g.auction.bid <= p.spice + selected.length * 3 + credit ||
- p.hand.some((c) => c.effect === 'karama' && !removed.has(c.id)),
- 'Retain enough spice or Karama to honor your current winning bid.',
- );
- return { ...base, kind: 'choam', cards: [...selected] };
- } else if (p.faction === 'ixians') {
- requireRule(
- g.phase === 5 && g.active === p.id && !g.response && !g.decision,
- 'Move the stronghold with special Karama during your own Shipment and Movement turn.',
- );
- const route = validateMobileMove(g, p, action.route, 2);
- return {
- ...base,
- kind: 'ixians',
- route: [...route],
- collect: action.collect !== false,
- };
- } else if (p.faction === 'tleilaxu') {
- requireRule(
- g.phase === 4 &&
- !g.response &&
- g.decision?.kind === 'revivalStop' &&
- g.decision.player === p.id &&
- g.pendingRevival,
- 'Wait for a normal revival declaration before preventing it.',
- );
- const target = g.pendingRevival.player;
- requireRule(
- action.target === undefined || action.target === target,
- 'This decision applies to the faction currently reviving.',
- );
- return {
- ...base,
- kind: 'tleilaxu',
- target,
- revival: structuredClone(g.pendingRevival),
- };
- } else if (p.faction === 'fremen') {
- requireRule(
- g.phase === 1 && !g.summonedWorm,
- 'Summon Shai-Hulud during Spice Blow and Nexus.',
- );
- const t = stringField(action.territory);
- requireRule(
- TERRITORIES.some((x) => x.id === t && x.type === 'sand'),
- 'Summon Shai-Hulud in a sand territory.',
- );
- return { ...base, kind: 'fremen', territory: t };
- } else if (p.faction === 'atreides') {
- const b = g.battle;
- requireRule(
- g.phase === 6 &&
- !g.response &&
- g.decision?.kind === 'fullPlanOffer' &&
- g.decision.player === p.id &&
- b &&
- !b.revealed &&
- !b.preparation &&
- !Object.keys(b.plans).length,
- 'Choose special prescience after battle preparation and before plans are sealed.',
- );
- const target = stringField(action.target);
- requireRule(
- [b.attacker, b.defender].includes(target),
- 'Choose a player in this battle.',
- );
- requireRule(
- !b.noFieldPlayers?.includes(target),
- 'Whole-plan special prescience against a No-Field awaits its disclosure ruling. Ordinary permitted elements remain available.',
- );
- return {
- ...base,
- kind: 'atreides',
- target,
- battle: {
- territory: b.territory,
- attacker: b.attacker,
- defender: b.defender,
- },
- };
- } else if (p.faction === 'guild') {
- requireRule(
- g.phase === 5 &&
- !g.response &&
- g.decision?.kind === 'guildShipment' &&
- g.decision.player === p.id &&
- g.pendingShipment,
- 'Wait for an off-planet shipment declaration.',
- );
- validateGuildShipmentDecision(g, g.decision);
- const shipper = getPlayer(g, g.pendingShipment.player);
- return {
- ...base,
- kind: 'guild',
- target: shipper.id,
- shipment: { ...g.pendingShipment },
- };
- } else if (p.faction === 'emperor') {
- requireRule(
- action.target === undefined || action.target === p.id,
- 'This revival power applies to the Emperor’s own forces or leaders.',
- );
- requireRule(
- g.phase === 4,
- 'The Emperor special Karama is used during Revival.',
- );
- if (action.leader) {
- const l = p.leaders.find((l) => l.id === action.leader);
- requireRule(
- l?.dead && !l.capturedBy && !l.gholaBy,
- 'Choose one of your dead leaders.',
- );
- requireRule(
- action.amount === undefined && action.elite === undefined,
- 'Choose forces or one leader, not both.',
- );
- return { ...base, kind: 'emperorLeader', leader: l.id };
- }
- const n = integer(action.amount, 1, Math.min(3, p.tanks), 'Forces');
- const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
- requireRule(
- elite <= eliteRevivalRemaining(p),
- 'Only one elite force may be revived per turn.',
- );
- return { ...base, kind: 'emperorForces', amount: n, elite };
- } else if (p.faction === 'harkonnen') {
- requireRule(
- g.phase === 3 && !g.pendingExchange,
- 'The Harkonnen special Karama is used during Bidding.',
- );
- const target = getPlayer(g, stringField(action.target));
- requireRule(target.id !== p.id, 'Choose another player’s hand.');
- requireRule(
- g.pendingRicheseGift?.intent.owner !== target.id,
- 'Resolve the reserved Richese gift before randomly exchanging that hand.',
- );
- const count = integer(
- action.amount,
- 1,
- Math.min(4, target.hand.length),
- 'Cards to take',
- );
- requireRule(
- action.cards === undefined,
- 'Cards are drawn unseen, not selected by identity.',
- );
- return { ...base, kind: 'harkonnen', target: target.id, amount: count };
- } else
- throw new RuleError(
- 'This faction’s special Karama power is still being implemented.',
- );
-}
-
-// Convert only known normalized fields for revalidation. The original client Action
-// is never retained, and omitted defaults cannot choose another card or elite mix.
-function specialKaramaAction(intent: SpecialKaramaIntent): Action {
- const action = { type: 'card', mode: 'special', card: intent.card };
- switch (intent.kind) {
- case 'richese':
- return { ...action, acquire: intent.acquire };
- case 'choam':
- return { ...action, cards: [...intent.cards] };
- case 'ixians':
- return { ...action, route: [...intent.route], collect: intent.collect };
- case 'tleilaxu':
- case 'atreides':
- case 'guild':
- return { ...action, target: intent.target };
- case 'fremen':
- return { ...action, territory: intent.territory };
- case 'emperorLeader':
- return { ...action, leader: intent.leader };
- case 'emperorForces':
- return { ...action, amount: intent.amount, elite: intent.elite };
- case 'harkonnen':
- return { ...action, target: intent.target, amount: intent.amount };
- }
-}
-
-/** Revalidates exact custody and the original window before any mutation.
- * A future paused-intent caller must restore its original decision/response first;
- * this executor does not open, pass, or recreate a reaction window.
- * Like other effect executors, it mutates a disposable working Game. The caller
- * must publish that clone only after success; applyAction provides this boundary.
- */
-export function executeSpecialKaramaIntent(
- g: Game,
- intent: SpecialKaramaIntent,
-): void {
- requireRule(
- g.turn === intent.turn && g.phase === intent.phase,
- 'This special Karama declaration belongs to an earlier turn or phase.',
- );
- const prepared = prepareSpecialKaramaIntent(
- g,
- intent.owner,
- specialKaramaAction(intent),
- );
- requireRule(
- prepared.kind === intent.kind,
- 'This special Karama declaration belongs to a different faction.',
- );
- // Pending declarations contain scalar fields and (for revival) an ordered checks
- // array. Compare field values independently of JSON object property order.
- const sameDeclaration = (before: object, now: object) => {
- const previous = before as Record;
- const current = now as Record;
- return [...new Set([...Object.keys(before), ...Object.keys(now)])].every(
- (key) => JSON.stringify(previous[key]) === JSON.stringify(current[key]),
- );
- };
- if (intent.kind === 'tleilaxu' && prepared.kind === 'tleilaxu')
- requireRule(
- intent.target === prepared.target &&
- sameDeclaration(intent.revival, prepared.revival),
- 'The original revival declaration is no longer pending.',
- );
- if (intent.kind === 'guild' && prepared.kind === 'guild')
- requireRule(
- intent.target === prepared.target &&
- sameDeclaration(intent.shipment, prepared.shipment),
- 'The original shipment declaration is no longer pending.',
- );
- if (intent.kind === 'atreides' && prepared.kind === 'atreides')
- requireRule(
- sameDeclaration(intent.battle, prepared.battle),
- 'The original battle is no longer awaiting special prescience.',
- );
- const p = getPlayer(g, intent.owner);
- const card = { id: intent.card };
- if (intent.kind === 'richese') {
- const selected = g.richeseCache!.findIndex((c) => c.id === intent.acquire);
- discard(g, p, card.id);
- p.spice -= 3;
- p.hand.push(...g.richeseCache!.splice(selected, 1));
- p.specialKaramaUsed = true;
- log(
- g,
- `${p.name} spent Karama and 3 spice to buy one secretly chosen card from the Richese cache. This once-per-game special power cannot be canceled.`,
- { faction: 'richese', name: 'Special Karama purchase' },
- );
- const emperor = byFaction(g, 'emperor');
- if (emperor) {
- g.pendingRichesePurchaseIncome = {
- owner: emperor.id,
- turn: g.turn,
- phase: g.phase,
- resume: {
- response: g.response,
- decision: g.decision,
- pendingKarama: g.pendingKarama ?? null,
- },
- };
- g.decision = null;
- g.pendingKarama = null;
- g.response = {
- kind: 'richesePurchaseIncome',
- owner: emperor.id,
- amount: 3,
- passed: [],
- };
- }
- } else if (intent.kind === 'choam') {
- const selected = intent.cards;
- discard(g, p, card.id);
- for (const id of selected) discard(g, p, id);
- p.spice += selected.length * 3;
- p.specialKaramaUsed = true;
- log(
- g,
- `${p.name} used special Karama to discard ${selected.length} cards and gain ${selected.length * 3} spice.`,
- );
- } else if (intent.kind === 'ixians') {
- const route = intent.route;
- discard(g, p, card.id);
- p.specialKaramaUsed = true;
- relocateMobileStronghold(g, {
- player: p.id,
- route,
- collect: intent.collect,
- });
- } else if (intent.kind === 'tleilaxu') {
- const target = intent.target;
- discard(g, p, card.id);
- p.specialKaramaUsed = true;
- g.revivalPrevention = { player: target, turn: g.turn };
- g.pendingRevival = null;
- g.decision = null;
-
- if (g.revivalRequests) delete g.revivalRequests[target];
- log(
- g,
- `${p.name} used special Karama to prevent ${getPlayer(g, target).name}’s normal force and leader revivals for this turn.`,
- );
- } else if (intent.kind === 'fremen') {
- const t = intent.territory;
- discard(g, p, card.id);
- p.specialKaramaUsed = true;
- g.summonedWorm = {
- territory: t,
- resume: {
- response: g.response,
- decision: g.decision,
- pendingKarama: g.pendingKarama,
- spiceWindow: g.spiceWindow,
- spiceResolution: g.spiceResolution,
- spiceSequence: g.spiceSequence,
- nexus: g.nexus,
- wormRides: g.wormRides,
- ready: g.ready,
- },
- };
- g.response = null;
- g.decision = null;
- g.pendingKarama = null;
- g.spiceWindow = null;
- g.spiceResolution = null;
- g.spiceSequence = null;
- g.wormRides = [];
- g.ready = [];
- g.nexus = true;
- log(
- g,
- `${p.name} used special Karama to summon Shai-Hulud in ${territory(t).name}.`,
- );
- beginWorm(g, t);
- } else if (intent.kind === 'atreides') {
- const b = g.battle!,
- target = intent.target;
- discard(g, p, card.id);
- p.specialKaramaUsed = true;
- b.fullPlan = { owner: p.id, target };
- g.decision = null;
- log(
- g,
- `${p.name} used special Karama to inspect ${getPlayer(g, target).name}’s entire battle plan.`,
- );
- } else if (intent.kind === 'guild') {
- const shipper = getPlayer(g, intent.target);
- discard(g, p, card.id);
- p.specialKaramaUsed = true;
- shipper.shipped = true;
- // Provisional canceled-shipment settlement pending a primary-source
- // clarification: retain the attempted shipment's spice and rate card.
- const benefit = g.karamaShipping;
- if (benefit?.player === shipper.id && benefit.card) {
- const index = g.discard.findIndex((c) => c.id === benefit.card);
- if (index >= 0)
- getPlayer(g, benefit.owner).hand.push(...g.discard.splice(index, 1));
- }
- g.karamaShipping = null;
- g.pendingShipment = null;
- g.decision = null;
- log(
- g,
- `${p.name} used special Karama to stop ${shipper.name}’s shipment. No forces arrive or spice is paid; movement remains available.`,
- );
- } else if (
- intent.kind === 'emperorLeader' ||
- intent.kind === 'emperorForces'
- ) {
- if (intent.kind === 'emperorLeader') {
- const l = p.leaders.find((l) => l.id === intent.leader)!;
- l.dead = false;
- delete l.concealed;
- log(g, `${p.name} used special Karama to revive ${l.name} for free.`);
- } else {
- const n = intent.amount,
- elite = intent.elite;
- p.tanks -= n;
- p.reserves += n;
- if (p.elites) {
- p.elites.tanks -= elite;
- p.elites.reserves += elite;
- p.elites.revived += elite;
- }
- log(g, `${p.name} used special Karama to revive ${n} forces for free.`);
- }
- techIncome(g, 'axlotl', p);
- discard(g, p, card.id);
- p.specialKaramaUsed = true;
- } else if (intent.kind === 'harkonnen') {
- const target = getPlayer(g, intent.target),
- count = intent.amount;
- discard(g, p, card.id);
- p.specialKaramaUsed = true;
- const taken = shuffle(target.hand).slice(0, count);
- target.hand = target.hand.filter((c) => !taken.some((t) => t.id === c.id));
- p.hand.push(...taken);
- g.pendingExchange = { response: g.response, decision: g.decision };
- g.response = null;
- g.decision = {
- kind: 'handExchange',
- player: p.id,
- target: target.id,
- count,
- };
- log(
- g,
- `${p.name} used special Karama to take ${count} unseen cards from ${target.name}; an equal return is pending.`,
- );
- }
-}
-function specialKarama(g: Game, p: Player, action: Action) {
- executeSpecialKaramaIntent(g, prepareSpecialKaramaIntent(g, p.id, action));
-}
-
-/** Private, read-only choices; validation performs no draws or continuations. */
-function richeseSpecialKaramaView(g: Game, p: Player) {
- if (
- g.status !== 'playing' ||
- !g.advanced ||
- p.faction !== 'richese' ||
- p.specialKaramaUsed
- )
- return null;
- const karamas = p.hand.filter(
- (c) =>
- c.effect === 'karama' &&
- !karamaSpendingBlock(g, p, c) &&
- !(
- g.richeseAuction?.source === 'blackMarket' &&
- !g.currentAuctionSale &&
- g.richeseAuction.owner === p.id &&
- g.richeseAuction.cardId === c.id
- ),
- );
- const cache = (g.richeseCache ?? []).filter((c) => richeseCardDefinition(c));
- const cards: Card[] = [];
- let blocked: string | null = !karamas.length
- ? 'A spendable Karama card is required.'
- : !cache.length
- ? 'There are no verified cards in your Richese cache.'
- : null;
- if (karamas.length)
- for (const candidate of cache) {
- try {
- prepareSpecialKaramaIntent(g, p.id, {
- type: 'card',
- mode: 'special',
- card: karamas[0].id,
- acquire: candidate.id,
- });
- cards.push(candidate);
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- blocked ??= error.message;
- }
- }
- return {
- cards,
- karamas,
- blocked: cards.length ? null : blocked,
- payee: byFaction(g, 'emperor')?.id ?? null,
- };
-}
-
-type CompletedMovement = Pick<
- MovementOrder,
- 'player' | 'origin' | 'total' | 'to' | 'sector' | 'elite'
-> & { noField: boolean };
-type MovementOrder = {
- player: string;
- group: [string, number][];
- eliteGroup: Record;
- elite: number;
- origin: string;
- total: number;
- to: string;
- sector: number;
- advisors: boolean;
- wantsFighters: boolean;
- lockedTurn?: number;
- noField?: { tokenId: string; event: string; from: string };
- ornithopterEvent?: string;
- ornithopterRange?: boolean;
-};
-export type Action = { type: string; [key: string]: unknown };
-function normalizeCardNames(g: Game) {
- normalizeLegacyCardNames([
- ...g.deck,
- ...g.discard,
- ...(g.ornithopter ? [g.ornithopter.card] : []),
- ...g.players.flatMap((p) => p.hand),
- ...(g.auction?.cards ?? []),
- ...(g.ixSetupCards ?? []),
- ...(g.ixAuction?.cards ?? []),
- ...(g.ixAuctionKnown?.cards ?? []),
- ]);
-}
-export function applyAction(state: Game, id: string, action: Action): Game {
- karamaConversionIntegrity(state);
- treacheryDiscardIntegrity(state);
- shipmentPromiseIntegrity(state);
- saphoMovementIntegrity(state);
- // Seat control is independent of gameplay locks and must preserve their exact continuation.
- if (action?.type === 'setAutopilot') {
- requireRule(
- state.status === 'setup' || state.status === 'playing',
- 'AI control is available after the game starts and before it ends.',
- );
- const player = getPlayer(state, id);
- requireRule(!player.bot, 'This is a permanent AI seat.');
- requireRule(
- Object.keys(action).every(
- (key) => key === 'type' || key === 'difficulty',
- ),
- 'You may change AI control only for your own seat.',
- );
- requireRule(
- action.difficulty === null ||
- (typeof action.difficulty === 'string' &&
- ['Easy', 'Medium', 'Hard', 'Brutal'].includes(action.difficulty)),
- 'Choose an AI difficulty or take back control.',
- );
- const g = structuredClone(state);
- const owner = getPlayer(g, id);
- if ((owner.autopilot ?? null) === action.difficulty) return g;
- if (action.difficulty === null) {
- delete owner.autopilot;
- log(g, `${owner.name} took back control from AI.`);
- } else {
- owner.autopilot = action.difficulty as NonNullable;
- log(
- g,
- `${owner.name} enabled ${owner.autopilot} AI control for their seat.`,
- );
- }
- return g;
- }
- if (state.pendingTreacheryDiscard) {
- getPlayer(state, id);
- requireRule(
- action?.type === 'advanceBots',
- 'Finish the committed discard continuation before another game action.',
- );
- return normalizeAutomaticGame(state);
- }
- const g = applyActionInner(state, id, action);
- karamaConversionIntegrity(g);
- ornithopterIntegrity(g);
- lateDefenseIntegrity(g);
- stoneBurnerIntegrity(g);
- strongholdIntegrity(g);
- auditorIntegrity(g);
- if (g.pendingNullentropy) {
- const invalid = nullentropyIntegrity(g);
- requireRule(!invalid, invalid ?? 'The search cannot continue.');
- return g;
- }
- if (
- g.pendingRicheseGift &&
- state.pendingRicheseGift?.event === g.pendingRicheseGift.event
- ) {
- const pending = g.pendingRicheseGift;
- requireRule(
- getPlayer(g, pending.intent.owner).hand.filter(
- (c) => c.id === pending.intent.cardId,
- ).length === 1,
- 'The card reserved for a pending Richese gift must remain in its owner’s hand.',
- );
- }
- // An accepted exchange obliges its beneficiary to complete the discard.
- // Interrupting voluntary card plays must leave a legal completion available.
- const pending = g.pendingAmbassador;
- if (
- pending &&
- pending.event === state.pendingAmbassador?.event &&
- pending.beneficiary === id
- ) {
- const beneficiary = getPlayer(g, id),
- entrant = getPlayer(g, pending.entrant);
- if (pending.stage === 'cards' && pending.effect === 'ixians')
- requireRule(
- !ambassadorEffectBlock(g, 'ixians', beneficiary, entrant),
- 'Complete the Ixian Ambassador exchange before spending your last available card.',
- );
- if (pending.stage === 'copy')
- requireRule(
- pending.copyChoices.some(
- (effect) => !ambassadorEffectBlock(g, effect, beneficiary, entrant),
- ),
- 'Leave an available effect for the committed Bene Gesserit Ambassador.',
- );
- }
- const lot = g.richeseAuction;
- if (lot && !g.currentAuctionSale) {
- requireRule(
- lot.source !== 'blackMarket' ||
- !g.truthtrance?.queue.some(
- (entry) => entry.player === lot.owner && entry.card === lot.cardId,
- ),
- 'The card reserved for this Black Market auction cannot also be queued for Truthtrance.',
- );
- requireRule(
- richeseLotCard(g),
- 'The card reserved for this auction must remain available.',
- );
- for (const bidder of g.players) {
- requireRule(
- richeseOwnCommitment(lot, g.richeseFunding ?? {}, bidder.id) <=
- bidder.spice,
- 'Spice committed to an auction cannot be spent again.',
- );
- requireRule(
- richeseAllyCommitment(lot, g.richeseFunding ?? {}, bidder.id) <=
- (g.aid[bidder.id]?.amount ?? 0),
- 'Ally spice committed to an auction cannot be withdrawn.',
- );
- const hasWinningCommitment = lot.outcome
- ? lot.outcome.kind === 'sold' && lot.outcome.winner === bidder.id
- : lot.method === 'silent'
- ? (lot.sealed[bidder.id] ?? 0) > 0
- : lot.bidder === bidder.id;
- if (hasWinningCommitment)
- requireRule(
- bidder.hand.length < handLimit(bidder),
- 'Leave room for the card in your committed auction.',
- );
- }
- }
- finishActionContinuations(g);
- reconcileBattlePromises(g, { actor: id, action });
- reconcileShipmentPromises(g, { actor: id, action });
- settleAutomaticContinuations(g);
- return g;
-}
-function finishActionContinuations(g: Game) {
- if (g.pendingNullentropy) return;
- finishTreacheryDiscard(g);
- if (!g.truthtrance && !g.decision && !g.response) advanceSetup(g);
- if (
- !g.truthtrance &&
- g.pendingTerrorEntry?.stage === 'discard' &&
- g.decision?.kind === 'moritaniTerror'
- ) {
- const moritani = getPlayer(g, g.decision.player);
- if (moritani.hand.length <= handLimit(moritani)) {
- g.decision = null;
- finishTerrorEntry(g);
- }
- }
- if (
- g.status === 'setup' &&
- !g.truthtrance &&
- !g.decision &&
- !g.response &&
- otherSetupComplete(g)
- ) {
- const moritani = byFaction(g, 'moritani');
- if (moritani?.reserves === 20) {
- g.moritaniTerror ??= createTerrorState(random);
- g.decision = { kind: 'moritaniSetup', player: moritani.id };
- }
- }
- if (
- g.status === 'setup' &&
- !g.truthtrance &&
- !g.response &&
- !g.decision &&
- setupComplete(g)
- )
- finishSetup(g);
- settleAdvisors(g);
-}
-function settleAutomaticContinuations(g: Game) {
- if (g.pendingNullentropy) return;
- for (let iteration = 0; iteration < 128; iteration++) {
- if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
- const response = g.response;
- const before = JSON.stringify(g);
- if (response) {
- if (
- !(
- response.kind === 'harkonnenBonus' &&
- getPlayer(g, response.owner).hand.length >=
- handLimit(getPlayer(g, response.owner))
- ) &&
- g.players.some(
- (p) =>
- !response.passed.includes(p.id) &&
- responseCancelCards(g, p, response).length > 0,
- )
- )
- return;
- finishResponse(g, false);
- } else if (!finishAutomaticDecision(g)) return;
- finishActionContinuations(g);
- // Opposing/automatic consequences can release a now-impossible promise;
- // they are not the original actor voluntarily spending a promised resource.
- reconcileBattlePromises(g);
- reconcileShipmentPromises(g);
- requireRule(
- JSON.stringify(g) !== before,
- 'Automatic response continuation made no progress.',
- );
- }
- throw new RuleError(
- 'Automatic response continuation exceeded its bounded limit.',
- );
-}
-/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
-export function normalizeAutomaticGame(state: Game): Game {
- karamaConversionIntegrity(state);
- treacheryDiscardIntegrity(state);
- shipmentPromiseIntegrity(state);
- saphoMovementIntegrity(state);
- const g = structuredClone(state);
- ornithopterIntegrity(g);
- lateDefenseIntegrity(g);
- stoneBurnerIntegrity(g);
- strongholdIntegrity(g);
- auditorIntegrity(g);
- if (g.pendingNullentropy) return g;
- normalizeCardNames(g);
- normalizeBattle(g);
- finishActionContinuations(g);
- reconcileBattlePromises(g);
- reconcileShipmentPromises(g);
- settleAutomaticContinuations(g);
- return g;
-}
-
-function applyActionInner(
- state: Game,
- id: string,
- action: Action,
- execution: 'live' | 'shipmentPreparation' = 'live',
-): Game {
- const g = structuredClone(state);
- ornithopterIntegrity(g);
- lateDefenseIntegrity(g);
- stoneBurnerIntegrity(g);
- strongholdIntegrity(g);
- auditorIntegrity(g);
- if (g.pendingNullentropy) {
- if (action?.type === 'advanceBots') return g;
- requireRule(
- action?.type === 'decision',
- 'Finish the paid Nullentropy Box search before another game action.',
- );
- finishNullentropy(
- g,
- id,
- stringField(action.event),
- stringField(action.card),
- );
- return g;
- }
- if (!g.playerPositions) {
- g.playerPositions = normalizedPlayerPositions(g);
- if (g.status === 'lobby') g.storm = STORM_START_SECTOR;
- if (g.status !== 'lobby')
- log(
- g,
- 'Assigned printed player circles in original seating order. Current turn order and storm choices are unchanged; the next completed storm uses these circles.',
- );
- }
- normalizeCardNames(g);
- // Older persisted rooms predate decision windows.
- g.decision ??= null;
- g.pendingCapture ??= null;
- g.response ??= null;
- g.stormPending ??= null;
- g.stormCard ??= null;
- g.stormCardKnown ??= false;
- g.stormResolution ??= null;
- g.karamaShipping ??= null;
- g.wormRides ??= [];
- g.wormPlacementCanceledTurn ??= 0;
- g.spiceResolution ??= null;
- g.spiceSequence ??= null;
- g.spiceWindow ??= null;
- g.spicePeekKnown ??= false;
- g.freeRevival ??= [];
- g.emperorExtra ??= {};
- g.aid ??= {};
- g.movementRemaining ??= null;
- g.guildTimingGranted ??= false;
- g.guildTimingLocked ??= false;
- normalizeBattle(g);
- const p = getPlayer(g, id);
- requireRule(
- action && typeof action.type === 'string',
- 'Action type is required.',
- );
- requireRule(g.status !== 'finished', 'This game has ended.');
- const t = action.type;
- if (t === 'advanceBots') return g;
- requireRule(
- !(
- t === 'card' &&
- g.richeseAuction?.source === 'blackMarket' &&
- !g.currentAuctionSale &&
- g.richeseAuction.owner === p.id &&
- action.card === g.richeseAuction.cardId
- ),
- 'The card reserved for this Black Market auction cannot be played or queued for another effect.',
- );
- try {
- if (
- resolveTruthAction(g, p, action, {
- completeDefiniteAnswer: finishTruthtranceAnswer,
- log,
- shipmentAnswers: shipmentTruthAnswers,
- bindShipment: bindShipmentTruth,
- battleAnswers: battleTruthAnswers,
- bindBattle: bindBattleTruth,
- })
- )
- return g;
- } catch (error) {
- if (error instanceof TruthError || error instanceof PlanClaimError)
- throw new RuleError(error.message);
- throw error;
- }
- if (t === 'card' && action.card === 'richese-juice-of-sapho') {
- playSapho(g, p, action);
- return g;
- }
- if (t === 'portableSnooper') {
- requireRule(
- Object.keys(action).every((key) =>
- ['type', 'card', 'event'].includes(key),
- ),
- 'Choose only the held Portable Snooper and current battle event.',
- );
- const { b, card } = validatePortableSnooper(
- g,
- p,
- stringField(action.card),
- stringField(action.event),
- );
- (b.lateDefense ??= {})[id] = card.id;
- log(
- g,
- `${p.name} played Portable Snooper after revealing their battle plan. It protects against ordinary poison without changing the original plan. Battle and traitor decisions continue.`,
- { faction: p.faction, name: 'Portable Snooper' },
- );
- return g;
- }
- if (t === 'battlePreparationReady') {
- const b = g.battle,
- opportunity = b?.preLeader;
- requireRule(
- g.phase === 6 &&
- b &&
- opportunity &&
- !opportunity.closed &&
- !b.revealed &&
- [b.attacker, b.defender].includes(id),
- 'No shared pre-leader opportunity is waiting for you.',
- );
- requireRule(
- action.event === opportunity.event && !opportunity.ready.includes(id),
- 'This preparation declaration is stale or already recorded.',
- );
- requireRule(
- !g.truthtrance && !g.response && !g.decision && !g.phaseOpening,
- 'Finish the current interaction before leaving preparation.',
- );
- opportunity.ready.push(id);
- opportunity.closed = [b.attacker, b.defender].every((player) =>
- opportunity.ready.includes(player),
- );
- log(
- g,
- `${p.name} is ready to continue battle preparation${opportunity.closed ? '; both combatants have finished their shared pre-leader opportunity' : ''}.`,
- );
- return g;
- }
- if (
- t === 'card' &&
- p.hand.some((c) => c.id === action.card && c.effect === 'residualPoison')
- ) {
- requireRule(
- Object.keys(action).every((key) =>
- ['type', 'card', 'target', 'event'].includes(key),
- ),
- 'Residual Poison chooses the victim on the server; do not submit a leader or random selection.',
- );
- playResidualPoison(
- g,
- p,
- stringField(action.card),
- stringField(action.target),
- stringField(action.event),
- );
- return g;
- }
- if (
- t === 'card' &&
- p.hand.some((c) => c.id === action.card && c.effect === 'nullentropyBox')
- ) {
- requireRule(
- Object.keys(action).every((key) => key === 'type' || key === 'card'),
- 'Begin the private Box search before selecting any discard card.',
- );
- beginNullentropy(g, p, stringField(action.card));
- return g;
- }
- if (
- t === 'card' &&
- p.hand.some((c) => c.id === action.card && c.effect === 'distrans')
- ) {
- requireRule(!action.mode, 'Use Distrans as its printed card effect.');
- const { recipient, result } = validateDistrans(
- g,
- p,
- stringField(action.card),
- stringField(action.target),
- stringField(action.give),
- );
- p.hand = result.ownerHand;
- recipient.hand = result.recipientHand;
- g.discard.push(result.discarded);
- log(
- g,
- `${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`,
- { faction: p.faction, name: 'Distrans' },
- );
- return g;
- }
- if (t === 'richeseGift') {
- const { intent } = validateRicheseGift(g, p, stringField(action.card));
- g.pendingRicheseGift = {
- event: crypto.randomUUID(),
- intent,
- turn: g.turn,
- phase: g.phase,
- resume: {
- response: g.response,
- decision: g.decision,
- pendingKarama: g.pendingKarama,
- phaseOpening: g.phaseOpening,
- },
- };
- g.response = {
- kind: 'richeseGift',
- owner: id,
- recipient: intent.recipient,
- passed: [],
- };
- g.decision = null;
- g.pendingKarama = null;
- g.phaseOpening = null;
- log(
- g,
- `${p.name} offered their ally one Richese Treachery Card from hand. Its identity stays private; resolve the alliance power before transferring it.`,
- );
- return g;
- }
- if (t === 'card' && action.mode === 'special' && p.faction === 'choam') {
- specialKarama(g, p, action);
- return g;
- }
- if (g.phaseOpening) {
- requireRule(
- g.status === 'playing',
- 'Phase opening requires an active game.',
- );
- requireRule(
- !g.phaseOpening.passed.includes(id),
- 'You have already passed the phase opening.',
- );
- if (t === 'ready') {
- g.phaseOpening.passed.push(id);
- if (g.phaseOpening.passed.length === g.players.length) {
- const initialize = g.phaseOpening.initialize;
- g.phaseOpening = null;
- if (initialize) beginPhase(g);
- }
- } else {
- const card = p.hand.find(
- (c) => c.id === action.card && c.effect === 'amal',
- );
- requireRule(
- t === 'card' && card && !action.mode,
- 'Play Amal or pass the phase opening before taking other actions.',
- );
- discard(g, p, card.id);
- for (const player of g.players)
- player.spice = Math.floor(player.spice / 2);
- g.phaseOpening.passed = [];
- log(
- g,
- `${p.name} played Amal. Each faction returned half its available spice to the bank, rounded up.`,
- );
- }
- return g;
- }
- if (t === 'card' && action.mode === 'choam') {
- playChoamWorthless(g, p, action);
- return g;
- }
- if (t === 'card' && action.mode === 'special') {
- requireRule(
- !g.choamMarket,
- 'Finish the end-of-phase exchange before another special power.',
- );
- requireRule(
- !g.pendingRevival ||
- (p.faction === 'tleilaxu' && g.decision?.kind === 'revivalStop'),
- 'Resolve the pending revival first.',
- );
- specialKarama(g, p, action);
- return g;
- }
- if (g.response) {
- const response = g.response;
- if (response.kind === 'fremenMovement') pendingFremenMovement(g, response);
- if (t === 'passResponse') {
- requireRule(
- !response.passed.includes(id),
- 'You have already passed this response window.',
- );
- response.passed.push(id);
- } else {
- requireRule(
- t === 'card' && action.mode === 'cancel',
- 'Resolve the Karama response window first.',
- );
- requireRule(
- id !== response.owner,
- 'Karama cancels another faction’s power.',
- );
- const card = karamaCard(g, p, action.card);
- requireRule(card, 'Choose a Karama in your hand.');
- spendKarama(g, p, card, { kind: 'cancel', response });
- }
- return g;
- }
- if (
- t === 'offerRicheseNoField' &&
- g.decision?.kind === 'richeseAllyOpportunity' &&
- g.decision.player === id
- )
- g.decision = null;
- if (g.decision) {
- const decision = g.decision;
- requireRule(
- t === 'decision' && decision.player === id,
- 'Waiting for the player with the pending decision.',
- );
- g.decision = null;
- if (
- decision.kind === 'richeseBlackMarket' ||
- decision.kind === 'richeseDeclaration' ||
- decision.kind === 'richeseCache' ||
- decision.kind === 'richeseUnbid'
- ) {
- decideRichese(g, p, decision, action);
- return g;
- }
- if (decision.kind === 'ecazAmbassador') {
- decideAmbassador(g, p, action);
- return g;
- }
- if (decision.kind === 'ecazPlacement') {
- requireRule(
- g.status === 'playing' &&
- g.phase === 4 &&
- p.faction === 'ecaz' &&
- g.ecazAmbassadors &&
- g.ecazPlacementTurn !== g.turn,
- 'Place Ambassadors during your end-of-Revival opportunity.',
- );
- if (action.decline === true) {
- log(g, `${p.name} finished Ambassador placement.`);
- finishEcazPlacement(g);
- } else {
- const token = stringField(action.token),
- to = stringField(action.territory);
- const quote = ambassadorPlacementQuote(g, p, token, to);
- g.pendingEcazPlacement = {
- token,
- territory: to,
- turn: g.turn,
- cost: quote.cost,
- };
- g.response = {
- kind: 'ecazPlacement',
- owner: id,
- passed: [],
- intent: `Place the ${faction(g.ecazAmbassadors.tokens.find((t) => t.id === token)!.effect).name} Ambassador in ${territory(to).name} for ${quote.cost} spice.`,
- };
- log(
- g,
- `${p.name} declared an Ambassador placement. No spice has been paid yet.`,
- );
- }
- return g;
- }
- if (decision.kind === 'moritaniRetention') {
- const pending = g.moritaniRetention;
- requireRule(
- pending?.stage === 'choose' &&
- pending.player === id &&
- pending.turn === g.turn &&
- g.phase === 6,
- 'No alliance battle-card choice is pending for you.',
- );
- requireRule(
- action.keep === null ||
- (typeof action.keep === 'string' &&
- pending.eligible.includes(action.keep) &&
- p.hand.some((c) => c.id === action.keep)),
- 'Keep one eligible played card, or explicitly keep none.',
- );
- if (action.keep === null) finishMoritaniRetention(g, null);
- else {
- pending.keep = action.keep as string;
- pending.stage = 'response';
- g.response = {
- kind: 'moritaniRetention',
- owner: pending.owner,
- passed: [],
- };
- }
- return g;
- }
- if (decision.kind === 'moritaniTerror') {
- decideTerror(g, p, action);
- return g;
- }
- if (decision.kind === 'moritaniSetup') {
- requireRule(
- g.status === 'setup' &&
- p.faction === 'moritani' &&
- p.reserves === 20 &&
- otherSetupComplete(g),
- 'Moritani places its starting forces after the other factions finish setup.',
- );
- const to = stringField(action.territory);
- const sector = integer(action.sector, 0, 18, 'Sector');
- requireRule(
- TERRITORIES.some((t) => t.id === to) &&
- to !== MOBILE_STRONGHOLD &&
- validLocation(to, sector),
- 'Choose a printed territory and sector.',
- );
- requireRule(
- g.players.every((player) => at(player, to) === 0),
- 'Moritani must start in an unoccupied territory, including no advisors.',
- );
- place(p, to, sector, 6);
- p.reserves -= 6;
- log(
- g,
- `${p.name} placed six starting forces in ${territory(to).name}, sector ${sector}.`,
- );
- return g;
- }
- if (decision.kind === 'richeseAllyOpportunity') {
- requireRule(
- action.decline === true,
- 'Offer a No-Field shipment or let your ally ship normally.',
- );
- log(
- g,
- `${p.name} passed the allied No-Field offer opportunity. Their ally may ship normally.`,
- );
- return g;
- }
- if (decision.kind === 'richeseAllyShipment') {
- const offer = g.richeseAllyOffer;
- requireRule(
- offer &&
- offer.recipient === id &&
- offer.owner === decision.owner &&
- action.event === offer.event,
- 'This allied No-Field offer is no longer current.',
- );
- if (action.decline === true) {
- g.richeseAllyOffer = null;
- g.richeseAllyDeclined = { turn: g.turn, recipient: id };
- log(
- g,
- `${p.name} declined the allied No-Field offer. Shipment and token custody remain unchanged.`,
- );
- } else {
- requireRule(action.accept === true, 'Accept this offer or decline it.');
- const quote = alliedNoFieldQuote(g, offer);
- const elite = integer(
- action.elite ?? quote.eliteMin,
- quote.eliteMin,
- quote.eliteMax,
- 'Elite forces',
- );
- g.pendingShipment = {
- turn: g.turn,
- player: id,
- territory: offer.territory,
- sector: offer.sector,
- amount: quote.amount,
- elite,
- cost: quote.cost,
- allyPayment: 0,
- advisors: quote.advisors,
- alliedNoField: offer,
- };
- // Verify the entire arrival continuation before the recipient commits.
- // Unsupported simultaneous reactions leave this offer declineable.
- const trial = structuredClone(g);
- commitShipment(trial, trial.pendingShipment!);
- g.richeseAllyOffer = null;
- g.response = { kind: 'richeseNoField', owner: offer.owner, passed: [] };
- log(
- g,
- `${p.name} accepted an allied No-Field shipment. Resolve the Richese power before payment, token reveal or arrival.`,
- );
- }
- return g;
- }
- if (decision.kind === 'moritaniPlacement') {
- requireRule(
- g.status === 'playing' &&
- g.phase === 8 &&
- p.faction === 'moritani' &&
- g.moritaniTerror &&
- g.moritaniTerror.placementTurn !== g.turn,
- 'Place or relocate one Terror token during your Mentat opportunity.',
- );
- if (action.decline === true) {
- g.moritaniTerror.placementTurn = g.turn;
- log(g, `${p.name} passed the Terror placement opportunity.`);
- finishMoritaniPlacement(g);
- } else {
- const token = stringField(action.token);
- const to = stringField(action.territory);
- try {
- placeTerror(g.moritaniTerror, token, to, g.turn);
- } catch (error) {
- throw new RuleError(
- error instanceof Error
- ? error.message
- : 'Invalid Terror placement.',
- );
- }
- g.pendingMoritaniPlacement = { token, territory: to, turn: g.turn };
- g.response = { kind: 'moritaniPlacement', owner: id, passed: [] };
- log(g, `${p.name} declared a hidden Terror placement or relocation.`);
- }
- return g;
- }
- if (decision.kind === 'choamStorm') {
- requireRule(
- action.decline === true && g.stormResolution,
- 'Continue the storm or declare Jubba Cloak.',
- );
- beginStormProtection(g);
- return g;
- }
- if (decision.kind === 'choamMovement') {
- requireRule(
- action.decline === true,
- 'Allow the movement or declare Baliset.',
- );
- resumeChoamMovement(g);
- return g;
- }
- if (
- decision.kind === 'choamAudit' ||
- decision.kind === 'choamAuditPayment'
- ) {
- const pending = g.pendingAuditor;
- requireRule(
- pending &&
- action.event === pending.event &&
- action.event === decision.event,
- 'This Auditor decision is stale or already completed.',
- );
- requireRule(
- Object.keys(action).every((key) =>
- ['type', 'event', 'audit', 'pay', 'count'].includes(key),
- ),
- 'Choose only whether to audit or pay the current cancellation cost.',
- );
- if (decision.kind === 'choamAudit') {
- requireRule(
- typeof action.audit === 'boolean' &&
- action.pay === undefined &&
- action.count === undefined,
- 'Choose whether to use the Auditor.',
- );
- if (!action.audit) finishAuditor(g, 'decline');
- else {
- pending.stage = 'response';
- g.response = {
- kind: 'choamAudit',
- owner: id,
- intent: pending.event,
- passed: [],
- };
- log(
- g,
- `${p.name} declared an Auditor inspection. Opposing Karama cancellation resolves before any inspection payment.`,
- );
- }
- } else {
- requireRule(
- typeof action.pay === 'boolean' && action.audit === undefined,
- 'Pay the full cancellation cost or allow the audit.',
- );
- if (action.pay)
- requireRule(
- action.count === currentAuditCount(g),
- 'The eligible-card count changed. Review the current full cancellation cost.',
- );
- else
- requireRule(
- action.count === undefined,
- 'Allowing an audit does not submit a payment.',
- );
- finishAuditor(g, action.pay ? 'pay' : 'inspect');
- }
- return g;
- }
- if (decision.kind === 'strongholdCopy') {
- const b = g.battle;
- requireRule(
- b?.event === decision.event &&
- action.event === decision.event &&
- b.territory === MOBILE_STRONGHOLD &&
- !Object.keys(b.plans).length &&
- !b.strongholdCopy &&
- g.strongholdCards?.owners.hidden_mobile_stronghold === id,
- 'Choose a copied Stronghold advantage for this exact battle before plans.',
- );
- const choices = strongholdCopyChoices(g, id);
- requireRule(
- typeof action.stronghold === 'string' &&
- decision.choices.includes(action.stronghold as StrongholdId) &&
- choices.includes(action.stronghold as StrongholdId),
- 'Choose another stronghold you currently control.',
- );
- b.strongholdCopy = action.stronghold as StrongholdId;
- log(
- g,
- `${p.name} declared ${STRONGHOLD_CARDS.find((card) => card.id === b.strongholdCopy)!.name} as their copied mobile Stronghold advantage.`,
- { faction: p.faction, name: 'Stronghold advantage' },
- );
- beginBattlePowers(g);
- return g;
- }
- if (decision.kind === 'choamMentat') {
- requireRule(
- action.done === true && g.choamMentatPending && g.phase === 8,
- 'Finish CHOAM’s Mentat opportunity before checking victory.',
- );
- g.choamMentatPending = false;
- victory(g);
- if (g.status === 'playing') completePhase(g);
- return g;
- }
- if (decision.kind === 'choamFreeRevival') {
- requireRule(
- action.decline === true,
- 'Allow the free revival or play La La La.',
- );
- offerRevivalStop(g);
- return g;
- }
- if (decision.kind === 'choamBattleFunding') {
- requireRule(
- p.faction === 'choam' && g.advanced && g.phase === 6 && g.battle,
- 'CHOAM battle funding is unavailable.',
- );
- pledgeAid(g, p, action.amount);
- battlePreparation(g, 'voice');
- combatResponses(g);
- return g;
- }
- if (
- decision.kind === 'choamMarket' ||
- decision.kind === 'choamTradeReply' ||
- decision.kind === 'choamTradeConfirm'
- ) {
- const market = g.choamMarket!;
- const owner = getPlayer(g, market.owner);
- if (decision.kind === 'choamMarket') {
- if (action.done === true) {
- finishChoamMarket(g);
- } else if (action.mode === 'sell') {
- const sale = quoteSale(
- owner.hand,
- action.card,
- action.witness,
- market.blocked,
- );
- requireRule(
- sale,
- 'Choose an available Worthless card or surplus exact duplicate.',
- );
- market.sale = sale;
- const card = owner.hand.find((c) => c.id === sale.card)!;
- log(
- g,
- `${owner.name} revealed ${sale.witness ? 'two copies of ' : ''}${card.name} and offered one for ${sale.price} spice.`,
- );
- g.response = { kind: 'choamSale', owner: owner.id, passed: [] };
- } else {
- requireRule(
- action.mode === 'trade',
- 'Sell a card, offer a trade or finish the phase.',
- );
- requireRule(
- g.choamTradeTurn !== g.turn,
- 'CHOAM may exchange one card with its ally only once per turn.',
- );
- const ally = g.players.find(
- (p) => p.id === owner.ally && p.ally === owner.id,
- );
- requireRule(ally, 'You need an ally for a two-way trade.');
- requireRule(
- owner.hand.some((c) => c.id === action.card),
- 'Offer a card from your own hand.',
- );
- market.trade = { ally: ally.id, offered: stringField(action.card) };
- market.tradeAttempted = true;
- g.decision = { kind: 'choamTradeReply', player: ally.id };
- }
- } else {
- const trade = market.trade!;
- const ally = getPlayer(g, trade.ally);
- if (action.decline === true) {
- delete market.trade;
- resumeChoamMarket(g);
- } else if (decision.kind === 'choamTradeReply') {
- requireRule(
- ally.hand.some((c) => c.id === action.card),
- 'Choose one of your cards to return, or decline.',
- );
- trade.returned = stringField(action.card);
- g.decision = { kind: 'choamTradeConfirm', player: owner.id };
- } else {
- requireRule(
- action.accept === true,
- 'Confirm the two-way exchange or decline.',
- );
- const offered = owner.hand.find((c) => c.id === trade.offered);
- const returned = ally.hand.find((c) => c.id === trade.returned);
- if (
- offered &&
- returned &&
- owner.ally === ally.id &&
- ally.ally === owner.id &&
- g.choamTradeTurn !== g.turn
- ) {
- owner.hand = owner.hand.filter((c) => c.id !== offered.id);
- ally.hand = ally.hand.filter((c) => c.id !== returned.id);
- owner.hand.push(returned);
- ally.hand.push(offered);
- g.choamTradeTurn = g.turn;
- log(
- g,
- `${owner.name} and ${ally.name} exchanged one treachery card each.`,
- );
- } else
- log(
- g,
- 'The proposed card exchange is no longer possible; both hands remain unchanged.',
- );
- delete market.trade;
- resumeChoamMarket(g);
- }
- }
- } else if (decision.kind === 'ixSetup') {
- requireRule(
- g.status === 'setup' && p.faction === 'ixians' && g.ixSetupCards,
- 'Ixian starting-card selection is not available.',
- );
- const card = g.ixSetupCards.find((c) => c.id === action.card);
- requireRule(card, 'Choose one of the starting cards.');
- p.hand.push(card);
- const remainder = shuffle(g.ixSetupCards.filter((c) => c.id !== card.id));
- for (const other of g.players.filter((other) => other.id !== p.id)) {
- const dealt = remainder.shift();
- if (dealt) other.hand.push(dealt);
- }
- const harkonnen = byFaction(g, 'harkonnen');
- if (harkonnen) {
- const bonus = draw(g);
- if (bonus) harkonnen.hand.push(bonus);
- }
- g.ixSetupCards = null;
- log(
- g,
- `${p.name} selected a starting card. The other starting cards were shuffled and dealt privately.`,
- );
- } else if (decision.kind === 'ixAuction') {
- requireRule(
- g.ixAuction && p.faction === 'ixians',
- 'The auction pool is not available.',
- );
- const card = g.ixAuction.cards.find((c) => c.id === action.card);
- requireRule(card, 'Choose a card from the auction pool.');
- requireRule(
- action.position === 'top' || action.position === 'bottom',
- 'Return the card to the top or bottom of the deck.',
- );
- if (action.position === 'top') g.deck.unshift(card);
- else g.deck.push(card);
- const rest = g.ixAuction.cards.filter((c) => c.id !== card.id);
- g.ixAuctionKnown = { turn: g.turn, cards: [...rest] };
- g.ixAuction = null;
- setAuction(g, shuffle(rest));
- log(
- g,
- `${p.name} returned one card face down to the ${action.position} of the deck and shuffled the auction pool.`,
- );
- } else if (decision.kind === 'ixTechnology') {
- requireRule(
- g.advanced &&
- p.faction === 'ixians' &&
- g.auction &&
- g.ixTechnologyTurn !== g.turn,
- 'Ixian auction substitution is not available.',
- );
- if (action.decline === true) offerAuctionPeek(g);
- else {
- const card = p.hand.find((c) => c.id === action.card);
- requireRule(
- card,
- 'Choose one of your hand cards to offer for exchange.',
- );
- g.ixTechnologyTurn = g.turn;
- g.pendingIxTechnology = { card: card.id };
- g.response = { kind: 'ixTechnology', owner: p.id, passed: [] };
- }
- } else if (decision.kind === 'ixAllyCard') {
- const pending = g.pendingIxAlly;
- requireRule(
- pending && pending.player === p.id,
- 'This purchased-card choice is not available.',
- );
- requireRule(
- typeof action.accept === 'boolean',
- 'Keep or replace the purchased card.',
- );
- if (action.accept) {
- const ixians = byFaction(g, 'ixians');
- requireRule(
- ixians &&
- p.ally === ixians.id &&
- p.hand.some((c) => c.id === pending.card),
- 'The purchased card and Ixian alliance must remain available.',
- );
- g.response = {
- kind: 'ixAllyCard',
- owner: ixians.id,
- recipient: p.id,
- passed: [],
- };
- } else {
- g.pendingIxAlly = null;
- continueAuctionSale(g, pending.free);
- }
- } else if (decision.kind === 'mobileStronghold') {
- requireRule(
- p.faction === 'ixians' && g.mobileStronghold,
- 'Only Ixians place or move their stronghold.',
- );
- if (decision.placement) {
- const dest = stringField(action.location);
- const loc = splitLocation(dest);
- requireRule(
- TERRITORIES.some(
- (t) =>
- t.id === loc.territory &&
- t.sectors.includes(loc.sector) &&
- t.type !== 'stronghold',
- ) && location(loc.territory, loc.sector) === dest,
- 'Point the stronghold at a sector in a non-stronghold territory.',
- );
- g.mobileStronghold.location = dest;
- log(
- g,
- `${p.name} placed the Hidden Mobile Stronghold at ${territory(loc.territory).name}, sector ${loc.sector}.`,
- );
- finishStorm(g);
- } else if (action.decline === true) beginStormTurn(g);
- else {
- const route = validateMobileMove(g, p, action.route, 3);
- g.pendingMobileMove = {
- player: p.id,
- route,
- collect: action.collect !== false,
- };
- g.response = {
- kind: 'mobileStronghold',
- owner: p.id,
- location: route.at(-1),
- passed: [],
- };
- }
- } else if (decision.kind === 'ixSubstitution') {
- const pending = g.pendingIxSubstitution!;
- if (action.decline === true) {
- g.pendingIxSubstitution = null;
- finishWinner(g, p, pending.territory, pending.cards);
- } else {
- const parse = (input: unknown, available: Record) => {
- requireRule(
- input && typeof input === 'object' && !Array.isArray(input),
- 'Choose sector counts for the substitution.',
- );
- return Object.fromEntries(
- Object.entries(input as Record)
- .map(([key, value]) => {
- requireRule(
- Object.hasOwn(available, key),
- 'Choose a sector in this battle.',
- );
- return [key, integer(value, 0, available[key], 'Forces')];
- })
- .filter(([, count]) => Number(count) > 0),
- ) as Record;
- };
- const sources = Object.fromEntries(
- Object.entries(p.forces)
- .filter(
- ([key]) => splitLocation(key).territory === pending.territory,
- )
- .map(([key, count]) => [key, count - (p.elites?.forces[key] ?? 0)]),
- );
- pending.sources = parse(action.sources, sources);
- pending.recover = parse(action.recover, pending.losses);
- const sum = (counts: Record) =>
- Object.values(counts).reduce((a, b) => a + b, 0);
- requireRule(
- sum(pending.sources) > 0 &&
- sum(pending.sources) === sum(pending.recover),
- 'Exchange one surviving suboid for each cyborg lost in this battle.',
- );
- g.response = { kind: 'ixSubstitution', owner: id, passed: [] };
- }
- } else if (decision.kind === 'revivalStop') {
- requireRule(
- action.decline === true,
- 'Allow the revival, or spend special Karama to prevent it.',
- );
- finishRevival(g);
- } else if (decision.kind === 'faceDance') {
- requireRule(
- typeof action.reveal === 'boolean',
- 'Reveal a Face Dancer or decline.',
- );
- if (action.reveal) {
- const dancer = p.faceDancers?.find(
- (c) => !c.revealed && c.leader === decision.identity,
- );
- requireRule(
- dancer,
- 'You do not have an unrevealed Face Dancer for that leader.',
- );
- const winner = getPlayer(g, decision.winner);
- const maximum = at(winner, decision.territory);
- const sources = action.sources;
- requireRule(
- sources && typeof sources === 'object' && !Array.isArray(sources),
- 'Choose reserves or board forces to replace the winner.',
- );
- const selected = Object.entries(sources).map(([key, value]) => {
- requireRule(
- key === 'reserves' || key in p.forces,
- 'Choose your own reserves or occupied sectors.',
- );
- return {
- key,
- count: integer(
- value,
- 0,
- key === 'reserves' ? p.reserves : p.forces[key],
- 'Forces',
- ),
- };
- });
- const total = selected.reduce((sum, source) => sum + source.count, 0);
- requireRule(
- total <= maximum,
- 'You cannot replace more forces than the winner has remaining.',
- );
- const sector = integer(action.sector, 0, 18, 'Sector');
- requireRule(
- validLocation(decision.territory, sector),
- 'Choose a sector in the battle territory.',
- );
- // This is replacement, not shipment or movement: no transport cost or worm/storm transit.
- for (const source of selected) {
- if (source.key === 'reserves') p.reserves -= source.count;
- else {
- p.forces[source.key] -= source.count;
- if (!p.forces[source.key]) delete p.forces[source.key];
- }
- }
- for (const key of Object.keys(winner.forces).filter(
- (key) => splitLocation(key).territory === decision.territory,
- )) {
- winner.reserves += winner.forces[key];
- if (winner.elites) {
- winner.elites.reserves += winner.elites.forces[key] ?? 0;
- delete winner.elites.forces[key];
- }
- delete winner.forces[key];
- }
- const leader = g.players
- .flatMap((player) => player.leaders)
- .find((l) => l.id === decision.leader);
- if (leader && !leader.dead) {
- leader.dead = true;
- leader.deaths++;
- delete leader.usedAt;
- delete leader.capturedBy;
- delete leader.concealed;
- }
- if (total) place(p, decision.territory, sector, total);
- dancer.revealed = true;
- log(
- g,
- `${p.name} revealed a Face Dancer and replaced ${total} forces in ${territory(decision.territory).name}.`,
- );
- if (p.faceDancers!.every((c) => c.revealed)) {
- g.traitorReserve = shuffle([
- ...(g.traitorReserve ?? []),
- ...p.faceDancers!.map((c) => c.leader),
- ]);
- p.faceDancers = g.traitorReserve
- .splice(0, 3)
- .map((leader) => ({ leader, revealed: false }));
- log(g, `${p.name} drew three new Face Dancers.`);
- }
- }
- g.pendingFaceDance = null;
- const harkonnen = byFaction(g, 'harkonnen');
- if (harkonnen) returnCaptives(g, harkonnen);
- settleAdvisors(g);
- finishBattle(g);
- } else if (decision.kind === 'stoneBurner') {
- requireRule(
- Object.keys(action).every((key) =>
- ['type', 'event', 'mode'].includes(key),
- ),
- 'Stone Burner needs only this battle event and a mode.',
- );
- requireRule(
- g.battle?.revealed &&
- action.event === decision.event &&
- action.event === g.battle.event,
- 'Choose Stone Burner’s mode for this exact revealed battle.',
- );
- requireRule(
- action.mode === 'kill' || action.mode === 'ignore',
- 'Choose to kill both leaders or ignore surviving leader strength.',
- );
- requireRule(
- isStoneBurner(cardOf(p, g.battle.plans[id]?.weapon)) &&
- !g.battle.stoneBurner?.[id],
- 'Stone Burner’s mode is already chosen or its weapon is missing.',
- );
- (g.battle.stoneBurner ??= {})[id] = action.mode;
- log(
- g,
- `${p.name} chose Stone Burner: ${action.mode === 'kill' ? 'both participating leaders will be killed' : 'surviving leaders add no strength; other weapon attacks still apply'}. Undialed physical force tokens decide the ordinary battle; traitors and explosions retain precedence.`,
- );
- nextRevealedDecision(g);
- } else if (decision.kind === 'poisonTooth') {
- requireRule(
- g.battle?.revealed && typeof action.activate === 'boolean',
- 'Choose whether to activate Poison Tooth.',
- );
- g.battle.poisonTooth ??= {};
- g.battle.poisonTooth[id] = action.activate;
- log(
- g,
- `${p.name} ${action.activate ? 'activated' : 'declined to activate'} Poison Tooth.`,
- );
- nextRevealedDecision(g);
- } else if (decision.kind === 'techToken') {
- requireRule(
- decision.choices.includes(action.token as TechId) &&
- g.techTokens?.[action.token as TechId]?.owner === decision.loser,
- 'Choose one tech token controlled by the defeated faction.',
- );
- transferTech(g, action.token as TechId, id);
- g.pendingTech = null;
- finishBattle(g);
- } else if (decision.kind === 'fullPlanOffer') {
- requireRule(
- action.decline === true,
- 'Decline the inspection, or spend the Atreides special Karama.',
- );
- } else if (decision.kind === 'fullPlanRead') {
- requireRule(
- action.continue === true,
- 'Confirm that you have inspected the battle plan.',
- );
- } else if (decision.kind === 'guildShipment') {
- requireRule(
- action.allow === true,
- 'Allow the shipment, or play the Guild special Karama.',
- );
- const shipment = validateGuildShipmentDecision(g, decision);
- g.pendingShipment = null;
- commitShipment(g, shipment);
- } else if (decision.kind === 'nullentropy') {
- throw new RuleError(
- 'No matching paid search was saved; restore the saved search before continuing.',
- );
- } else if (decision.kind === 'handExchange') {
- requireRule(
- Array.isArray(action.returnCards) &&
- action.returnCards.length === decision.count &&
- new Set(action.returnCards).size === decision.count &&
- action.returnCards.every(
- (id) => typeof id === 'string' && p.hand.some((c) => c.id === id),
- ),
- 'Return exactly the required number of distinct cards from your hand.',
- );
- const target = getPlayer(g, decision.target);
- const returnIds = action.returnCards as string[];
- const returned = p.hand.filter((c) => returnIds.includes(c.id));
- p.hand = p.hand.filter((c) => !returnIds.includes(c.id));
- target.hand.push(...returned);
- requireRule(
- p.hand.length <= handLimit(p) &&
- target.hand.length <= handLimit(target),
- 'The completed exchange must respect hand limits.',
- );
- const pending = g.pendingExchange!;
- g.pendingExchange = null;
- g.response = pending.response;
- g.decision = pending.decision;
- if (!g.response && g.decision?.kind === 'auctionPayment')
- recoverAuctionPayment(g, getPlayer(g, g.decision.player));
- log(g, `${p.name} returned ${decision.count} cards to ${target.name}.`);
- } else if (decision.kind === 'captureOffer') {
- requireRule(
- typeof action.accept === 'boolean',
- 'Choose whether to capture a leader.',
- );
- if (action.accept)
- g.response = { kind: 'capture', owner: id, passed: [] };
- else {
- g.pendingCapture = null;
- finishBattle(g);
- }
- } else if (decision.kind === 'capturedLeader') {
- requireRule(
- action.mode === 'keep' || action.mode === 'execute',
- 'Choose keep or execute for the captured leader.',
- );
- const owner = getPlayer(g, decision.owner),
- l = owner.leaders.find((l) => l.id === decision.leader)!;
- if (action.mode === 'execute') {
- l.dead = true;
- l.deaths = Math.max(2, l.deaths + 1);
- delete l.usedAt;
- delete l.capturedBy;
- p.spice += 2;
- log(g, `${p.name} executed a captive for 2 spice.`);
- } else {
- returnCaptives(g, p);
- log(
- g,
- `${p.name} ${l.capturedBy === id ? 'retained' : 'released'} the captive.`,
- );
- }
- finishBattle(g);
- } else if (decision.kind === 'guildTiming') {
- requireRule(
- typeof action.take === 'boolean',
- 'Choose whether to ship and move now or wait.',
- );
- const inOrder = decision.next === id;
- if (action.take === inOrder) g.active = action.take ? id : decision.next;
- else if (g.guildTimingGranted) chooseGuildTiming(g, action.take);
- else
- g.response = {
- kind: 'guildTiming',
- owner: id,
- passed: [],
- take: action.take,
- };
- } else if (decision.kind === 'stormLosses') {
- const elite = integer(
- action.elite ?? decision.minElite,
- decision.minElite,
- decision.maxElite,
- 'Elite casualties',
- );
- kill(g, p, decision.key, decision.amount, false, elite);
- if (decision.resume === 'storm') continueStorm(g);
- } else if (decision.kind === 'battleLosses') {
- const choice =
- decision.options[
- integer(
- action.choice,
- 0,
- decision.options.length - 1,
- 'Casualty choice',
- )
- ];
- settleWinnerCasualties(g, p, decision.territory, decision.cards, choice);
- } else if (decision.kind === 'auctionPayment') {
- normalKaramaAuction(g, p, true, action.karama === true);
- requireRule(
- typeof action.karama === 'boolean',
- 'Choose spice payment or Karama.',
- );
- if (action.karama) {
- const card = karamaCard(g, p, action.card);
- requireRule(card, 'You do not hold a Karama card.');
- spendKarama(g, p, card, { kind: 'auctionPayment' });
- } else settleAuction(g, false);
- } else if (decision.kind === 'intrusion') {
- requireRule(
- typeof action.accept === 'boolean',
- 'Choose whether to become advisors.',
- );
- const details = {
- advisorFollowup: decision.followup,
- advisorResume: decision.wormRide ? ('wormRide' as const) : undefined,
- };
- if (action.accept)
- g.response = {
- kind: 'advisorFlip',
- owner: id,
- location: decision.territory,
- advisors: true,
- passed: [],
- ...details,
- };
- else finishAdvisorReaction(g, details);
- } else if (decision.kind === 'advisorBattle') {
- requireRule(
- typeof action.accept === 'boolean',
- 'Choose whether to prepare advisors for battle.',
- );
- if (action.accept) {
- const to = stringField(action.territory);
- requireRule(
- decision.territories.includes(to) &&
- advisorBattleOptions(g).includes(to),
- 'These advisors cannot prepare for battle.',
- );
- g.response = {
- kind: 'advisorFlip',
- owner: id,
- location: to,
- advisors: false,
- passed: [],
- advisorResume: 'declaration',
- advisorRemaining: decision.territories.filter((t) => t !== to),
- };
- } else movementTurn(g);
- } else if (decision.kind === 'advisor') {
- requireRule(
- typeof action.accept === 'boolean',
- 'Choose whether to ship an advisor.',
- );
- if (action.accept) {
- requireRule(p.reserves > 0, 'No forces remain in your reserves.');
- const target =
- action.accompany === true && g.advanced
- ? decision.destination
- : 'polar_sink:0';
- requireRule(target, 'This shipment has no accompanying destination.');
- const dest = splitLocation(target);
- if (
- g.advanced &&
- action.accompany === true &&
- action.sector !== undefined
- )
- dest.sector = integer(action.sector, 0, 18, 'Sector');
- const advisors = arrivalAsAdvisor(
- g,
- p,
- dest.territory,
- undefined,
- action.accompany === true,
- );
- allowedEntry(g, p, dest.territory, dest.sector, false, advisors);
- g.response = {
- kind: 'advisor',
- owner: id,
- passed: [],
- location: location(dest.territory, dest.sector),
- };
- } else log(g, `${p.name} declined the free shipment.`);
- } else if (decision.kind === 'wormPlacement') {
- requireRule(
- typeof action.accept === 'boolean',
- 'Choose whether to place the additional worm.',
- );
- if (action.accept) {
- const t = stringField(action.territory);
- requireRule(
- TERRITORIES.some((x) => x.id === t && x.type === 'sand'),
- 'Additional worms must be placed in sand territory.',
- );
- g.response = {
- kind: 'wormPlacement',
- owner: id,
- location: t,
- passed: [],
- };
- } else continueSpice(g);
- } else if (decision.kind === 'wormProtection') {
- requireRule(
- typeof action.accept === 'boolean',
- 'Choose whether to protect your ally.',
- );
- if (action.accept) {
- g.response = {
- kind: 'wormAllyProtection',
- owner: id,
- location: decision.territory,
- recipient: decision.ally,
- passed: [],
- };
- } else wormSurvival(g, decision.territory);
- } else if (decision.kind === 'wormRide') {
- requireRule(
- typeof action.accept === 'boolean',
- 'Choose whether to ride Shai-Hulud.',
- );
- if (action.accept) {
- const to = stringField(action.territory);
- const sector = integer(action.sector, 0, 18, 'Sector');
- requireRule(
- to !== decision.territory,
- 'Choose another destination territory.',
- );
- allowedEntry(g, p, to, sector);
- requireRule(
- action.forces &&
- typeof action.forces === 'object' &&
- !Array.isArray(action.forces),
- 'Choose forces from the worm territory.',
- );
- let total = 0,
- totalElite = 0;
- for (const [key, value] of Object.entries(action.forces)) {
- const source = splitLocation(key);
- requireRule(
- source.territory === decision.territory &&
- validLocation(source.territory, source.sector),
- 'Ride only from the territory where the worm appeared.',
- );
- const n = integer(value, 0, p.forces[key] ?? 0, 'Forces');
- if (!n) continue;
- requireRule(
- source.sector !== g.storm,
- 'Forces cannot leave a sector in storm.',
- );
- const elite = eliteChoice(
- n,
- p.forces[key],
- p.elites?.forces[key] ?? 0,
- (action.eliteForces as Record | undefined)?.[key],
- );
- removeGroup(p, [[key, n]], { [key]: elite });
- totalElite += elite;
- total += n;
- }
- requireRule(total > 0, 'Choose at least one force to ride.');
- place(p, to, sector, total, totalElite);
- log(
- g,
- `${p.name} rode Shai-Hulud with ${total} forces to ${territory(to).name}.`,
- );
- const intruded = intrusion(g, p, to, { wormRide: true });
- if (
- openTerritoryEntry(
- g,
- p,
- to,
- sector,
- total,
- totalElite,
- 'wormRide',
- 'wormRide',
- ) ||
- intruded
- )
- return g;
- } else log(g, `${p.name} declined the sandworm ride.`);
- nextWormRide(g);
- } else {
- requireRule(
- Array.isArray(action.discard) &&
- action.discard.every(
- (card) => typeof card === 'string' && decision.cards.includes(card),
- ),
- 'You may discard only cards played in this battle.',
- );
- const selected = new Set(action.discard);
- const context = selected.size
- ? cleanupBattleContext(g, decision.territory, p.id)
- : null;
- const discarded = [...selected].map((id) => ({
- card: discard(g, p, id),
- discardedBy: p.id,
- publicFace: true,
- }));
- log(
- g,
- `${p.name} discarded ${selected.size} of their played battle cards.`,
- );
- if (discarded.length)
- stageTreacheryDiscard(g, 'battle:winner', discarded, {
- kind: 'battleCleanup',
- event: context!.event,
- combatants: [...g.lastBattle],
- territory: decision.territory,
- player: p.id,
- source: 'winner',
- kept: decision.cards.filter((id) => !selected.has(id)),
- played: [...decision.cards],
- });
- else finishBattle(g);
- }
- return g;
- }
- if (g.status === 'lobby') {
- if (t === 'seatPosition') {
- requireRule(
- action.target === undefined || action.target === id,
- 'You may choose only your own player circle.',
- );
- const position = integer(action.position, 1, 6, 'Player circle');
- const positions = normalizedPlayerPositions(g);
- requireRule(
- !g.players.some(
- (other) => other.id !== id && positions[other.id] === position,
- ),
- 'That player circle is occupied.',
- );
- requireRule(
- positions[id] !== position,
- 'You already occupy that player circle.',
- );
- positions[id] = position;
- g.playerPositions = positions;
- g.players.forEach((player) => (player.ready = !!player.bot));
- log(g, `${p.name} chose player circle ${position}.`);
- return g;
- }
- if (t === 'techTokens') {
- requireRule(id === g.host, 'Only the host can change optional rules.');
- requireRule(
- typeof action.enabled === 'boolean',
- 'Choose whether to use tech tokens.',
- );
- g.techTokens = action.enabled ? createTechTokens() : null;
- g.players.forEach((p) => (p.ready = !!p.bot));
- return g;
- }
- if (t === 'strongholdCards') {
- requireRule(id === g.host, 'Only the host can change optional rules.');
- requireRule(
- typeof action.enabled === 'boolean' && (!action.enabled || g.advanced),
- 'Stronghold Cards require Advanced rules.',
- );
- g.strongholdCards = action.enabled ? createStrongholdCards() : null;
- g.players.forEach((player) => (player.ready = !!player.bot));
- return g;
- }
- if (t === 'addBot' || t === 'removeBot') {
- requireRule(id === g.host, 'Only the host can manage AI seats.');
- if (t === 'addBot') {
- const difficulty = stringField(action.difficulty);
- requireRule(
- ['Easy', 'Medium', 'Hard', 'Brutal'].includes(difficulty),
- 'Choose an AI difficulty.',
- );
- const f = stringField(action.faction) as FactionId;
- requireRule(
- FACTIONS.some((x) => x.id === f),
- 'Unknown faction.',
- );
- const bot = newPlayer(crypto.randomUUID(), `${faction(f).name} AI`, f);
- bot.bot = difficulty as NonNullable;
- joinGame(g, bot);
- } else {
- const target = getPlayer(g, stringField(action.target));
- requireRule(target.bot, 'Only an AI seat can be removed here.');
- g.players = g.players.filter((p) => p.id !== target.id);
- if (g.playerPositions) delete g.playerPositions[target.id];
- g.players.forEach((p) => (p.ready = false));
- }
- g.players.filter((p) => p.bot).forEach((p) => (p.ready = true));
- return g;
- }
- if (t === 'ready') {
- p.ready = !p.ready;
- return g;
- }
- if (t === 'start') {
- requireRule(id === g.host, 'Only the host can start the game.');
- start(g);
- return g;
- }
- if (t === 'faction') {
- const f = action.faction as FactionId;
- requireRule(
- FACTIONS.some((x) => x.id === f),
- 'Unknown faction.',
- );
- requireRule(
- !g.players.some((x) => x.id !== id && x.faction === f),
- 'That faction is taken.',
- );
- requireRule(
- faction(f).expansion === 'base' ||
- g.expansions.includes(faction(f).expansion),
- 'That expansion is disabled.',
- );
- p.faction = f;
- p.elites =
- f === 'ixians'
- ? { reserves: 7, tanks: 0, forces: {}, revived: 0 }
- : undefined;
- p.leaders = leaders(f);
- g.players.forEach((x) => (x.ready = false));
- return g;
- }
- throw new RuleError('That action is not available in the lobby.');
- }
- if (g.status === 'setup') {
- if (g.setupStage) {
- const expected =
- g.setupStage === 'prediction'
- ? ['predict']
- : g.setupStage === 'traitors'
- ? ['traitor']
- : ['fremenSetup', 'advisorSetup'];
- requireRule(
- expected.includes(t) && setupPending(g).includes(id),
- `Wait for the current setup step: ${g.setupStage}.`,
- );
- }
- if (t === 'advisorSetup') {
- requireRule(
- g.advanced && p.faction === 'beneGesserit' && !p.advisorSetup,
- 'Advisor setup is not available.',
- );
- requireRule(
- !byFaction(g, 'fremen') || byFaction(g, 'fremen')!.reserves === 10,
- 'Wait for Fremen placement.',
- );
- const to = stringField(action.territory);
- const sector = integer(action.sector, 0, 18, 'Sector');
- requireRule(
- to !== MOBILE_STRONGHOLD && validLocation(to, sector),
- 'Choose a printed board sector; the mobile stronghold is not yet placed.',
- );
- requireRule(
- p.reserves === 20,
- 'Advisor setup requires the unplaced starting force.',
- );
- p.reserves--;
- place(p, to, sector, 1);
- p.advisors = { [to]: {} };
- p.advisorSetup = true;
- settleAdvisors(g);
- } else if (t === 'traitor') {
- requireRule(
- p.traitorChoices.includes(String(action.leader)),
- 'Choose one of your dealt traitors.',
- );
- if (g.setupStage === 'traitors')
- (g.traitorReserve ??= []).push(
- ...p.traitorChoices.filter(
- (leader) => leader !== String(action.leader),
- ),
- );
- p.traitors = [String(action.leader)];
- p.traitorChoices = [];
- } else if (t === 'fremenSetup') {
- requireRule(
- p.faction === 'fremen' && p.reserves === 20,
- 'Fremen setup is already complete.',
- );
- requireRule(
- action.placements &&
- typeof action.placements === 'object' &&
- !Array.isArray(action.placements),
- 'Choose starting force placements.',
- );
- const placements = Object.entries(action.placements).map(
- ([key, value]) => {
- const loc = key.includes(':')
- ? splitLocation(key)
- : {
- territory: key,
- sector: FREMEN_START.includes(key)
- ? territory(key).sectors[0]
- : -1,
- };
- requireRule(
- FREMEN_START.includes(loc.territory) &&
- validLocation(loc.territory, loc.sector),
- 'Starting forces belong in sectors of Sietch Tabr, False Wall South or False Wall West.',
- );
- return { ...loc, amount: integer(value, 0, 10, 'Starting forces') };
- },
- );
- requireRule(
- placements.reduce((sum, p) => sum + p.amount, 0) === 10,
- 'Place exactly ten starting forces.',
- );
- let totalElite = 0;
- for (const loc of placements) {
- const key = location(loc.territory, loc.sector);
- const elite = integer(
- (action.elitePlacements as Record | undefined)?.[
- key
- ] ?? 0,
- 0,
- Math.min(loc.amount, p.elites?.reserves ?? 0),
- 'Elite forces',
- );
- totalElite += elite;
- if (loc.amount) place(p, loc.territory, loc.sector, loc.amount, elite);
- }
- requireRule(
- totalElite <= (p.elites?.reserves ?? 0),
- 'Too many elite forces in starting placements.',
- );
- if (p.elites) p.elites.reserves -= totalElite;
- p.reserves = 10;
- } else if (t === 'predict') {
- requireRule(
- p.faction === 'beneGesserit' && !p.prediction,
- 'Prediction is already locked.',
- );
- requireRule(
- g.players.some((x) => x.faction === action.faction && x.id !== id),
- 'Predict another faction in this game.',
- );
- p.prediction = {
- faction: action.faction as FactionId,
- turn: integer(action.turn, 1, 10, 'Turn'),
- };
- } else throw new RuleError('Complete the starting choices.');
- return g;
- }
- if (t === 'replaceFaceDancer') {
- requireRule(
- g.phase === 8 && p.faction === 'tleilaxu',
- 'Replace a Face Dancer during Mentat Pause.',
- );
- requireRule(
- p.faceDancerReplacedTurn !== g.turn,
- 'Only one Face Dancer replacement is permitted per turn.',
- );
- const dancer = p.faceDancers?.find(
- (c) => !c.revealed && c.leader === action.leader,
- );
- requireRule(dancer, 'Choose one of your unrevealed Face Dancers.');
- p.faceDancerReplacedTurn = g.turn;
- g.response = {
- kind: 'faceDancerReplacement',
- owner: id,
- passed: [],
- intent: dancer.leader,
- };
- return g;
- }
- if (t === 'choamInflation') {
- requireRule(
- g.phase === 8 && p.faction === 'choam',
- 'CHOAM places Inflation during Mentat Pause.',
- );
- requireRule(
- !g.inflationUsed && !g.inflation,
- 'The Inflation token is unavailable or already used.',
- );
- requireRule(
- g.inflationAttemptTurn !== g.turn,
- 'Inflation placement was already attempted this Mentat Pause.',
- );
- requireRule(
- action.side === 'double' || action.side === 'cancel',
- 'Choose Double or Cancel for next turn’s charity.',
- );
- g.inflationAttemptTurn = g.turn;
- g.response = {
- kind: 'choamInflation',
- owner: id,
- intent: action.side,
- passed: [],
- };
- g.ready = [];
- return g;
- }
- if (t === 'bribe') {
- requireRule(
- g.inflation?.side !== 'double',
- 'Bribes are prohibited while Inflation shows Double.',
- );
- requireRule(
- g.phase !== 8,
- 'Bribes cannot be made during the Mentat pause.',
- );
- const target = getPlayer(g, stringField(action.target));
- requireRule(
- target.id !== id && target.id !== p.ally,
- 'Bribes are between different, non-allied factions.',
- );
- const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice');
- p.spice -= n;
- target.bribes += n;
- log(
- g,
- `${p.name} promised ${n} spice to ${target.name}; collect it at the Mentat pause.`,
- );
- return g;
- }
- if (t === 'stormDial') {
- requireRule(
- g.phase === 0 && g.stormPending === null && g.stormDialers.includes(id),
- 'You are not dialing the storm.',
- );
- requireRule(
- g.stormDials[id] === undefined,
- 'Your storm dial is already locked.',
- );
- g.stormDials[id] = integer(
- action.amount,
- g.turn === 1 ? 0 : 1,
- g.turn === 1 ? 20 : 3,
- 'Storm dial',
- );
- if (g.stormDialers.every((x) => g.stormDials[x] !== undefined)) {
- g.stormPending = Object.values(g.stormDials).reduce((a, b) => a + b, 0);
- g.ready = [];
- log(
- g,
- `Storm distance revealed: ${g.stormPending} sectors. Resolve storm cards before moving.`,
- );
- }
- return g;
- }
- if (t === 'alliance') {
- requireRule(
- g.phase === 1 && g.nexus && !g.spiceWindow && !g.spiceResolution,
- 'Alliances change only during a Nexus.',
- );
- if (!action.target) {
- if (p.ally) getPlayer(g, p.ally).ally = null;
- p.ally = null;
- delete g.allianceOffers[id];
- log(g, `${p.name} is unallied.`);
- } else {
- const other = getPlayer(g, stringField(action.target));
- requireRule(other.id !== id, 'Choose another player.');
- requireRule(
- !p.ally && !other.ally,
- 'Break existing alliances before forming a new one.',
- );
- g.allianceOffers[id] = other.id;
- if (g.allianceOffers[other.id] === id) {
- p.ally = other.id;
- other.ally = id;
- p.allySinceTurn = other.allySinceTurn = g.turn;
- delete g.allianceOffers[id];
- delete g.allianceOffers[other.id];
- log(g, `${p.name} and ${other.name} formed an alliance.`);
- }
- }
- g.ready = [];
- return g;
- }
- if (t === 'charity') {
- const advancedBG = g.advanced && p.faction === 'beneGesserit';
- const amount = charityAmount(g, p);
- requireRule(
- g.phase === 2 && amount > 0,
- 'Charity is available here when you have fewer than two spice.',
- );
- requireRule(
- !byFaction(g, 'choam') || g.choamCharity?.turn === g.turn,
- 'Resolve CHOAM income before claiming charity.',
- );
- requireRule(
- p.charityTurn !== g.turn,
- 'Charity may be claimed only once per turn.',
- );
- p.charityTurn = g.turn;
- if (advancedBG && p.spice >= 2)
- g.response = { kind: 'bgCharity', owner: id, passed: [], amount };
- else payCharity(g, p, amount);
- log(g, `${p.name} claimed CHOAM charity.`);
- return g;
- }
- if (t === 'pledgeAid') {
- requireRule(
- ([3, 5].includes(g.phase) ||
- (g.phase === 6 && g.advanced && p.faction === 'choam')) &&
- p.ally,
- 'You may fund an ally during bidding/shipment, or as advanced CHOAM during Battle.',
- );
- pledgeAid(g, p, action.amount);
- return g;
- }
- if (t === 'richeseBid') {
- const lot = g.richeseAuction;
- requireRule(
- g.phase === 3 && lot && !lot.outcome,
- 'There is no open Richese auction.',
- );
- requireRule(p.hand.length < handLimit(p), 'Your hand is full.');
- requireRule(
- lot.source !== 'blackMarket' ||
- p.id !== lot.owner ||
- action.amount === null ||
- action.amount === 0,
- 'A positive Black Market self-bid is awaiting a ruling on payment and sale counting.',
- );
- const amount =
- action.amount === null
- ? null
- : integer(action.amount, 0, Number.MAX_SAFE_INTEGER, 'Bid');
- const ownAvailable =
- uncommittedSpice(g, p) +
- richeseOwnCommitment(lot, g.richeseFunding ?? {}, p.id);
- const allyAvailable = aidFor(g, p)?.amount ?? 0;
- const allyPayment =
- amount === null
- ? 0
- : integer(
- action.allyPayment ?? Math.max(0, amount - ownAvailable),
- 0,
- Math.min(amount, allyAvailable),
- 'Ally payment',
- );
- if (amount !== null)
- validateRicheseFunding(amount, allyPayment, ownAvailable, allyAvailable);
- g.richeseAuction = submitRicheseBid(
- lot,
- { event: stringField(action.event), actor: p.id, amount },
- ownAvailable + allyAvailable,
- );
- if (amount !== null)
- (g.richeseFunding ??= {})[p.id] = {
- amount,
- allyPayment,
- donor: allyPayment ? p.ally : null,
- };
- g.active = g.richeseAuction.active;
- log(
- g,
- lot.method === 'silent'
- ? `${p.name} submitted a sealed bid; its amount remains concealed until every eligible bidder submits.`
- : `${p.name} ${amount === null ? 'passed' : `bid ${amount} spice`} in the ${lot.method === 'onceAround' ? 'Once Around' : 'Black Market'} auction.`,
- );
- if (lot.method === 'silent' && g.richeseAuction.outcome)
- log(
- g,
- `Silent bids revealed together: ${g.richeseAuction.tieOrder.map((id) => `${getPlayer(g, id).name} ${g.richeseAuction!.sealed[id]}`).join('; ')}. Ties are resolved in storm order.`,
- );
- settleRicheseLot(g);
- return g;
- }
- if (t === 'bid' || t === 'passBid') {
- requireRule(
- g.phase === 3 && g.auction?.active === id,
- 'Wait for your bid.',
- );
- const a = g.auction;
- if (t === 'bid') {
- a.bid = integer(
- action.amount,
- a.bid + 1,
- karamaCard(g, p)
- ? Number.MAX_SAFE_INTEGER
- : p.spice + (aidFor(g, p)?.amount ?? 0),
- 'Bid',
- );
- a.allyPayment =
- a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0)
- ? contribution(g, p, a.bid, action.allyPayment)
- : 0;
- a.bidder = id;
- a.passed = [];
- } else if (!a.passed.includes(id)) a.passed.push(id);
- auctionNext(g);
- return g;
- }
- if (t === 'emperorGift' || t === 'emperorRevival') {
- requireRule(
- p.faction === 'emperor' && p.ally,
- 'Only the Emperor may use this power for an ally.',
- );
- const recipient = getPlayer(g, p.ally);
- if (t === 'emperorRevival') {
- requireRule(
- !revivalPrevented(g, recipient.id),
- 'Tleilaxu prevented this faction’s normal revivals for this turn.',
- );
- requireRule(
- g.phase === 4,
- 'Extra revivals are available during Revival.',
- );
- }
- const maximum =
- t === 'emperorGift'
- ? uncommittedSpice(g, p)
- : Math.min(
- 3 - (g.emperorExtra[recipient.id] ?? 0),
- recipient.tanks,
- Math.floor(uncommittedSpice(g, p) / 2),
- );
- const amount = integer(
- action.amount,
- 1,
- maximum,
- t === 'emperorGift' ? 'Spice' : 'Forces',
- );
- const elite =
- t === 'emperorRevival'
- ? eliteChoice(
- amount,
- recipient.tanks,
- recipient.elites?.tanks ?? 0,
- action.elite,
- )
- : 0;
- requireRule(
- elite <= eliteRevivalRemaining(recipient),
- 'Only one elite force may be revived per turn.',
- );
- if (t === 'emperorRevival')
- requireRule(
- p.spice >= paidForceRevivalCost(recipient, amount, elite),
- 'Not enough spice for these extra revivals.',
- );
- g.response = {
- kind: t,
- elite,
- owner: id,
- recipient: recipient.id,
- amount,
- passed: [],
- };
- return g;
- }
- if (t === 'grantRevival') {
- requireRule(
- g.phase === 4 && p.faction === 'fremen' && p.ally,
- 'Only Fremen may grant their ally free revival during revival.',
- );
- requireRule(
- !g.freeRevival.includes(p.ally),
- 'Free revival has already been granted.',
- );
- g.freeRevival.push(p.ally);
- log(
- g,
- `${p.name} granted their ally up to three free force revivals this phase.`,
- );
- return g;
- }
- if (t === 'tleilaxuRevivalLimit' || t === 'tleilaxuAllyDiscount') {
- requireRule(
- g.phase === 4 && p.faction === 'tleilaxu',
- 'Only Tleilaxu may grant revival benefits during Revival.',
- );
- g.revivalRules ??= newRevivalRules();
- if (t === 'tleilaxuRevivalLimit') {
- const target = getPlayer(g, stringField(action.target));
- requireRule(
- target.id !== id && !g.revivalRules.limitBlocked,
- 'The expanded revival limit is unavailable.',
- );
- requireRule(
- !g.revivalRules.expanded.includes(target.id),
- 'That faction already has permission to revive five forces.',
- );
- g.revivalRules.expanded.push(target.id);
- log(
- g,
- `${p.name} allowed ${target.name} to revive up to five forces this phase.`,
- );
- } else {
- requireRule(
- p.ally &&
- !g.revivalRules.discountBlocked &&
- g.revivalRules.allyDiscount !== p.ally,
- 'The allied revival discount is unavailable or already granted.',
- );
- g.revivalRules.allyDiscount = p.ally;
- log(g, `${p.name} offered their ally half-price revival this phase.`);
- }
- return g;
- }
- if (
- t === 'requestLeaderRevival' ||
- t === 'quoteLeaderRevival' ||
- t === 'declineLeaderRevival' ||
- t === 'cancelLeaderRequest' ||
- t === 'acceptLeaderRevival'
- ) {
- const tleilaxu = byFaction(g, 'tleilaxu');
- requireRule(
- g.phase === 4 && tleilaxu,
- 'Tleilaxu leader negotiations require the Revival phase.',
- );
- g.revivalRequests ??= {};
- if (t === 'cancelLeaderRequest') {
- delete g.revivalRequests[id];
- return g;
- }
- if (t === 'quoteLeaderRevival' || t === 'declineLeaderRevival') {
- requireRule(
- p.id === tleilaxu.id,
- 'Only Tleilaxu may quote a revival price.',
- );
- const recipient = getPlayer(g, stringField(action.target)).id;
- const request = g.revivalRequests[recipient];
- requireRule(request, 'Wait for that faction to request a leader.');
- if (t === 'declineLeaderRevival') request.declined = true;
- else {
- request.price = integer(action.amount, 0, 1000000, 'Price');
- delete request.declined;
- }
- return g;
- }
- requireRule(
- p.id !== tleilaxu.id && !p.leaderRevived,
- 'Only one negotiated leader revival is permitted per faction per phase.',
- );
- const requested =
- t === 'requestLeaderRevival'
- ? stringField(action.leader)
- : g.revivalRequests[id]?.leader;
- requireRule(requested, 'Request a leader first.');
- requireRule(
- !revivalPrevented(g, p.id),
- 'Tleilaxu prevented this faction’s normal revivals for this turn.',
- );
- const buyback = p.leaders.some(
- (l) => l.id === requested && l.gholaBy === tleilaxu.id,
- );
- requireRule(
- buyback || p.leaders.some(nativeAvailable),
- 'When all native leaders are unavailable, use ordinary revival rules.',
- );
- if (requested === 'kwisatz')
- requireRule(
- g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
- 'Kwisatz Haderach is not in the tanks.',
- );
- else
- requireRule(
- p.leaders.some((l) => l.id === requested && l.dead && !l.capturedBy),
- 'Choose one of your dead native leaders.',
- );
- requireRule(
- !g.revivalRules?.earlyBlocked.includes(`${p.id}:${requested}`),
- 'Early revival of that leader was canceled this phase.',
- );
- if (t === 'requestLeaderRevival')
- g.revivalRequests[id] = { leader: requested, price: null };
- else {
- const price = g.revivalRequests[id]?.price;
- requireRule(
- !g.revivalRequests[id]?.declined,
- 'That request was declined.',
- );
- requireRule(
- typeof price === 'number',
- 'Wait for Tleilaxu to quote a price.',
- );
- requireRule(p.spice >= price, 'Not enough spice for the agreed revival.');
- beginRevival(g, {
- player: id,
- kind: requested === 'kwisatz' ? 'kwisatz' : 'leader',
- leader: requested,
- normalCost: price,
- cost: price,
- free: 0,
- checks: ['earlyRevival'],
- });
- }
- return g;
- }
- if (t === 'reviveForeignGhola') {
- requireRule(
- g.advanced && g.phase === 4 && p.faction === 'tleilaxu',
- 'Foreign gholas require advanced Tleilaxu during Revival.',
- );
- const leader = g.players
- .flatMap((owner) => owner.leaders)
- .find((l) => l.id === action.leader);
- requireRule(
- leader &&
- leader.faction !== p.faction &&
- leader.dead &&
- !leader.capturedBy,
- 'Choose a dead leader of another faction.',
- );
- requireRule(
- !isAuditorLeader(leader),
- 'The Auditor cannot be acquired as a Tleilaxu foreign ghola.',
- );
- requireRule(
- controlledLeaders(g, p).filter((l) => !l.dead).length < 5,
- 'Foreign gholas may fill your active leader pool only up to five.',
- );
- requireRule(
- p.gholaBlocked?.[leader.id] !== g.turn,
- 'Reviving that foreign ghola was canceled this turn.',
- );
- const normalCost = leader.strength;
- const cost = revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost;
- requireRule(p.spice >= cost, 'Not enough spice for this ghola.');
- beginRevival(g, {
- player: id,
- kind: 'foreignGhola',
- leader: leader.id,
- normalCost,
- cost,
- free: 0,
- checks:
- cost < normalCost
- ? ['foreignGhola', 'revivalDiscount']
- : ['foreignGhola'],
- });
- return g;
- }
- if (t === 'revive') {
- requireRule(g.phase === 4, 'Revival is not the current phase.');
- const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces');
- const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
- requireRule(
- elite <= eliteRevivalRemaining(p),
- 'Only one elite force may be revived per turn.',
- );
- const availableFree = forceRevivalQuote(g, p, n).free;
- const freeElite =
- p.faction === 'ixians'
- ? integer(
- action.freeElite ?? Math.min(availableFree, elite),
- Math.max(0, availableFree - (n - elite)),
- Math.min(availableFree, elite),
- 'Free cyborg revivals',
- )
- : 0;
- const quote = forceRevivalQuote(g, p, n, elite, freeElite);
- requireRule(quote.cost <= p.spice, 'Not enough spice for revival.');
- const checks: PendingRevival['checks'] = [];
- const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked;
- if (choamBenefit && (n > quote.free || p.revived + n > 3))
- checks.push('choamRevival');
- if (!choamBenefit && p.revived + n > 3) checks.push('revivalLimit');
- const factionCost = choamBenefit ? n - quote.free : quote.normalCost;
- if (quote.cost < factionCost) checks.push('revivalDiscount');
- beginRevival(g, {
- player: id,
- kind: 'forces',
- amount: n,
- elite,
- ...quote,
- checks,
- });
- return g;
- }
- if (t === 'reviveKwisatz' || t === 'reviveLeader') {
- requireRule(
- g.phase === 4 && (!p.leaderRevived || p.faction === 'tleilaxu'),
- 'Only one leader revival is permitted this phase.',
- );
- requireRule(
- !revivalPrevented(g, p.id),
- 'Tleilaxu prevented this faction’s normal revivals for this turn.',
- );
- const options = leaderRevivalOptions(g, p);
- p.revivalCycle = options.cycle;
- const l =
- t === 'reviveLeader'
- ? p.leaders.find((l) => l.id === action.leader)
- : undefined;
- if (t === 'reviveKwisatz')
- requireRule(
- g.advanced && p.faction === 'atreides' && p.kwisatz?.dead,
- 'Kwisatz Haderach is not in the tanks.',
- );
- else
- requireRule(
- l?.dead && !l.capturedBy && !l.gholaBy,
- 'Choose a dead leader.',
- );
- const option = options.leaders.find((option) => option.id === l?.id);
- const normal =
- t === 'reviveKwisatz' ? !!options.kwisatz : !!option && !option.early;
- requireRule(
- normal || p.faction === 'tleilaxu',
- 'All leaders must die before a new revival cycle can begin.',
- );
- const checks: PendingRevival['checks'] = [];
- if (!normal) {
- requireRule(
- !g.revivalRules?.earlyBlocked.includes(`${id}:${l!.id}`),
- 'Early revival of that leader was canceled this phase.',
- );
- checks.push('earlyRevival');
- }
- requireRule(
- t === 'reviveKwisatz' ? !!options.kwisatz : !!option,
- 'That leader is unavailable for revival this phase.',
- );
- const normalCost = l?.strength ?? 2;
- const cost = revivalDiscount(g, p) ? Math.ceil(normalCost / 2) : normalCost;
- requireRule(p.spice >= cost, 'Not enough spice.');
- if (cost < normalCost) checks.push('revivalDiscount');
- beginRevival(g, {
- player: id,
- kind: t === 'reviveKwisatz' ? 'kwisatz' : 'leader',
- leader: l?.id,
- normalCost,
- cost,
- free: 0,
- checks,
- });
- return g;
- }
- if (t === 'revealNoField') {
- requireRule(
- p.faction === 'richese',
- 'Only Richese controls the concealed token.',
- );
- const blocked = noFieldRevealBlock(g, p);
- requireRule(!blocked, blocked ?? 'Voluntary reveal is unavailable.');
- requireRule(
- action.event === p.noFieldEvent &&
- action.token === p.noField?.deployed?.tokenId,
- 'This No-Field reveal selection is stale.',
- );
- revealPlayerNoField(g, p, 'voluntary');
- return g;
- }
- if (['ship', 'move', 'endMovement', 'guildShip'].includes(t)) {
- const richese = byFaction(g, 'richese');
- requireRule(
- !richese ||
- noFieldAllyOfferBlock(g, richese) ||
- (g.richeseAllyOpportunity?.turn === g.turn &&
- g.richeseAllyOpportunity.recipient === id),
- 'Wait for Richese to offer or pass its allied shipment opportunity.',
- );
- }
- if (t === 'offerRicheseNoField') {
- const blocked = noFieldAllyOfferBlock(g, p);
- requireRule(
- !blocked,
- blocked ?? 'Allied No-Field shipment is unavailable.',
- );
- requireRule(
- action.event === p.noFieldEvent,
- 'This No-Field token selection is stale.',
- );
- const offer: RicheseAllyOffer = {
- event: crypto.randomUUID(),
- owner: id,
- recipient: p.ally!,
- tokenId: stringField(action.token),
- tokenEvent: p.noFieldEvent!,
- territory: stringField(action.territory),
- sector: integer(action.sector, 0, 18, 'Sector'),
- payer: stringField(action.payer),
- };
- alliedNoFieldQuote(g, offer, offer.recipient === id);
- g.richeseAllyOffer = offer;
- g.richeseAllyOpportunity = { turn: g.turn, recipient: offer.recipient };
- g.decision = {
- kind: 'richeseAllyShipment',
- player: offer.recipient,
- owner: id,
- };
- g.ready = [];
- log(
- g,
- `${p.name} offered their ally a No-Field shipment. Its proposed token and destination remain private until shipment is declared.`,
- );
- return g;
- }
- if (t === 'ship') {
- requireRule(
- g.phase === 5 && g.active === id && !p.shipped,
- 'You may ship once, before your movement.',
- );
- checkShipmentPromises(g, p, {
- territory: String(action.territory),
- amount: Number(action.amount),
- });
- const to = String(action.territory),
- s = integer(action.sector, 0, 18, 'Sector');
- const advisors = arrivalAsAdvisor(g, p, to);
- requireRule(
- to !== MOBILE_STRONGHOLD || p.faction === 'ixians',
- 'Only Ixians may ship directly into the mobile stronghold.',
- );
- allowedEntry(g, p, to, s, g.advanced && p.faction === 'fremen', advisors);
- let noField: PendingShipment['noField'];
- if (action.noField !== undefined) {
- requireRule(
- p.faction === 'richese',
- 'Only Richese may declare its own concealed No-Field shipment.',
- );
- const blocked = noFieldShipBlock(g, p);
- requireRule(!blocked, blocked ?? 'No-Field shipment is unavailable.');
- requireRule(
- action.event === p.noFieldEvent,
- 'This No-Field selection is stale.',
- );
- const tokenId = stringField(action.noField);
- noFieldRule(() =>
- deployRicheseNoField(p.noField!, {
- tokenId,
- controller: id,
- location: { territory: to, sector: s },
- }),
- );
- requireRule(
- action.amount === undefined || action.amount === 1,
- 'The shipment price is for one No-Field, not a chosen number of physical forces.',
- );
- requireRule(
- action.elite === undefined || action.elite === 0,
- 'A concealed No-Field is not an elite force.',
- );
- noField = { tokenId, event: p.noFieldEvent! };
- }
- const n = noField ? 1 : integer(action.amount, 1, p.reserves, 'Forces');
- const elite = noField
- ? 0
- : eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, action.elite);
- const cost = reserveShipmentCost(
- {
- faction: p.faction,
- halfRate:
- p.faction === 'guild' ||
- byFaction(g, 'guild')?.id === p.ally ||
- g.karamaShipping?.player === id,
- },
- territory(to).type,
- n,
- );
- if (p.faction === 'fremen') {
- requireRule(
- territory('the_great_flat').sectors.some((fs) =>
- territory(to).sectors.some(
- (ts) =>
- distance(location('the_great_flat', fs), location(to, ts)) <= 2,
- ),
- ),
- 'Fremen reinforcements must arrive within two territories of the Great Flat.',
- );
- }
- requireRule(
- cost <= p.spice + (aidFor(g, p)?.amount ?? 0),
- 'Not enough spice to ship those forces.',
- );
- const allyPayment = contribution(g, p, cost, action.allyPayment);
- const shipment: PendingShipment = {
- turn: g.turn,
- player: id,
- territory: to,
- sector: s,
- amount: n,
- elite,
- cost,
- allyPayment,
- advisors,
- ...(noField ? { noField } : {}),
- };
- if (shipment.noField) {
- g.pendingShipment = shipment;
- g.response = { kind: 'richeseNoField', owner: p.id, passed: [] };
- log(
- g,
- `${p.name} declared a concealed No-Field shipment to ${territory(to).name}, sector ${s}.`,
- );
- } else offerShipment(g, shipment);
- return g;
- }
- if (t === 'guildShip') {
- checkShipmentPromises(
- g,
- p,
- action.from === 'reserves'
- ? { territory: String(action.territory), amount: Number(action.amount) }
- : null,
- );
- requireRule(
- action.noField === undefined,
- 'Guild transport of a concealed No-Field awaits its specific return and custody rules.',
- );
- requireRule(
- g.phase === 5 &&
- g.active === id &&
- !p.shipped &&
- (p.faction === 'guild' || byFaction(g, 'guild')?.id === p.ally),
- 'Guild shipment is not available.',
- );
- const fromReserves = action.from === 'reserves';
- requireRule(
- !fromReserves || p.faction === 'fremen',
- 'Only allied Fremen may cross-ship from their southern reserves.',
- );
- const {
- group,
- eliteGroup,
- elite,
- origin,
- total: n,
- } = fromReserves
- ? {
- origin: 'reserves',
- group: [] as [string, number][],
- eliteGroup: {} as Record,
- elite: eliteChoice(
- Number(action.amount),
- p.reserves,
- p.elites?.reserves ?? 0,
- action.elite,
- ),
- total: integer(action.amount, 1, p.reserves, 'Forces'),
- }
- : forceGroup(p, action);
- requireRule(
- group.every(([key]) => splitLocation(key).sector !== g.storm),
- 'The source is in storm.',
- );
- const to = stringField(action.territory ?? 'reserves');
- const sector = integer(action.sector ?? 0, 0, 18);
- requireRule(
- to !== 'reserves' || p.faction === 'guild',
- 'Only the Guild may ship forces back to reserves.',
- );
- const advisors = to !== 'reserves' && arrivalAsAdvisor(g, p, to);
- const sourceLock = p.advisors?.[origin]?.lockedTurn;
- requireRule(
- to === 'reserves' ||
- advisors ||
- sourceLock !== g.turn ||
- !g.players.some((other) => other.id !== id && at(other, to)),
- 'New advisors cannot become fighters this turn.',
- );
- requireRule(
- to !== MOBILE_STRONGHOLD || p.faction === 'ixians',
- 'Only Ixians may ship directly into the mobile stronghold.',
- );
- if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
- const cost = guildShipmentCost(
- to === 'reserves' ? 'reserves' : territory(to).type,
- n,
- );
- requireRule(
- p.spice + (aidFor(g, p)?.amount ?? 0) >= cost,
- 'Not enough spice.',
- );
- const allyPayment = contribution(g, p, cost, action.allyPayment);
- payWithAlly(g, p, cost, allyPayment);
- const guild = byFaction(g, 'guild');
- const guildPayment = guildShipmentIncome({
- guild: guild?.id,
- shipper: id,
- ally: p.ally,
- cost,
- allyPayment,
- bankOnly: g.karamaShipping?.player === id,
- });
- if (guild && guildPayment > 0)
- g.response = {
- kind: 'guildIncome',
- owner: guild.id,
- amount: guildPayment,
- passed: [],
- };
- if (fromReserves) {
- p.reserves -= n;
- if (p.elites) p.elites.reserves -= elite;
- } else removeGroup(p, group, eliteGroup);
- if (to === 'reserves') {
- p.reserves += n;
- if (p.elites) p.elites.reserves += elite;
- } else {
- place(p, to, sector, n, elite);
- if (advisors)
- (p.advisors ??= {})[to] = {
- lockedTurn:
- Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0) ||
- undefined,
- };
- else if (p.advisors) delete p.advisors[to];
- }
- finishShipmentPromises(
- g,
- p,
- fromReserves ? { territory: to, amount: n } : null,
- );
- p.shipped = true;
- g.karamaShipping = null;
- log(g, `${p.name} used Guild transport for ${n} forces.`);
- if (to !== 'reserves') {
- intrusion(g, p, to);
- if (origin !== to)
- openTerritoryEntry(g, p, to, sector, n, elite, 'guildTransport');
- }
- return g;
- }
- if (t === 'move') {
- if (!p.shipped) checkShipmentPromises(g, p, null);
- requireRule(
- g.phase === 5 && g.active === id && p.moved < movesAllowed(g, p),
- 'Your movement is not available.',
- );
- const {
- group,
- eliteGroup,
- elite,
- origin,
- total: n,
- noField,
- sourceKeys,
- } = forceGroup(p, action);
- const to = stringField(action.territory),
- s = integer(action.sector, 0, 18, 'Sector');
- const advisors = arrivalAsAdvisor(g, p, to, origin);
- const lockedTurn = p.advisors?.[origin]?.lockedTurn;
- requireRule(
- advisors ||
- lockedTurn !== g.turn ||
- !g.players.some((other) => other.id !== id && at(other, to)),
- 'New advisors cannot become fighters this turn.',
- );
- const wantsFighters = advisors && action.fighters === true;
- requireRule(
- !wantsFighters ||
- (lockedTurn !== g.turn &&
- !at(p, to) &&
- !(p.ally && at(getPlayer(g, p.ally), to))),
- 'These advisors cannot flip to fighters on arrival.',
- );
- requireRule(
- !balisetPrevents(g, id, origin, to),
- 'Baliset prevents movement into this CHOAM territory; shipment remains possible.',
- );
- allowedEntry(g, p, to, s, false, advisors && !wantsFighters);
- requireRule(
- sourceKeys.every((key) => key !== location(to, s)),
- 'Choose a different destination.',
- );
- let flight = g.ornithopter?.player === id ? g.ornithopter : null;
- if (action.movementCard !== undefined) {
- const mode = action.ornithopter;
- requireRule(
- mode === 'range3' || mode === 'twoGroups',
- 'Choose an Ornithopter movement mode.',
- );
- const blocked = ornithopterBlock(g, p, mode);
- requireRule(!blocked, blocked ?? 'Ornithopter cannot be played.');
- const card = p.hand.find((c) => c.id === action.movementCard);
- requireRule(
- card && richeseCardDefinition(card)?.card.effect === 'ornithopter',
- 'Choose the canonical Ornithopter in your hand.',
- );
- const reserved = transferCardBlock(g, p, card);
- requireRule(!reserved, reserved ?? 'The card is committed.');
- requireRule(
- action.ornithopterEvent === undefined,
- 'A new card movement cannot name an old event.',
- );
- flight = {
- event: crypto.randomUUID(),
- player: id,
- card,
- turn: g.turn,
- mode,
- startingMove: p.moved,
- completed: 0,
- };
- g.ornithopter = flight;
- p.hand = p.hand.filter((c) => c.id !== card.id);
- log(
- g,
- `${p.name} played Ornithopter: ${mode === 'range3' ? 'one group with a maximum three-territory route' : 'two different groups using normal movement'}. The card remains on the table until its use finishes.`,
- );
- } else if (flight)
- requireRule(
- action.ornithopterEvent === flight.event,
- 'Use the current Ornithopter movement event.',
- );
- else
- requireRule(
- action.ornithopterEvent === undefined &&
- action.ornithopter === undefined,
- 'No matching Ornithopter movement is active.',
- );
- requireRule(
- !flight || !isAdvisor(p, origin),
- 'Advanced advisor use of the Ornithopter card awaits its ruling.',
- );
- const speed = flight?.mode === 'range3' ? 3 : movementRange(g, p, elite);
- requireRule(
- sourceKeys.every(
- (key) =>
- gameDistance(g, key, location(to, s), (k) =>
- pathBlocked(g, p, k, isAdvisor(p, origin)),
- ) <= speed,
- ),
- `That destination is blocked or more than ${speed} territories away.`,
- );
- const move: MovementOrder = {
- player: id,
- group,
- eliteGroup,
- elite,
- origin,
- total: n,
- to,
- sector: s,
- advisors,
- wantsFighters,
- lockedTurn,
- ...(noField ? { noField } : {}),
- ...(flight
- ? {
- ornithopterEvent: flight.event,
- ornithopterRange: flight.mode === 'range3',
- }
- : {}),
- };
- validateFlightSelection(g, p, move);
- if (
- !move.ornithopterRange &&
- p.faction === 'ixians' &&
- elite > 0 &&
- speed === 2 &&
- group.some(
- ([key]) =>
- gameDistance(g, key, location(to, s), (k) =>
- pathBlocked(g, p, k, isAdvisor(p, origin)),
- ) > 1,
- )
- ) {
- g.pendingIxMove = move;
- g.response = {
- kind: 'ixMovement',
- owner: id,
- location: location(to, s),
- amount: n,
- elite,
- passed: [],
- };
- } else if (
- !move.ornithopterRange &&
- p.faction === 'fremen' &&
- speed === 2 &&
- group.some(
- ([key]) =>
- gameDistance(g, key, location(to, s), (k) =>
- pathBlocked(g, p, k, isAdvisor(p, origin)),
- ) > 1,
- )
- ) {
- g.pendingFremenMove = { turn: g.turn, move: p.moved, order: move };
- g.response = {
- kind: 'fremenMovement',
- owner: id,
- location: location(to, s),
- amount: n,
- passed: [],
- };
- } else offerChoamMovement(g, move);
- return g;
- }
- if (t === 'endMovement') {
- finishShipmentPromises(g, p, null);
- requireRule(
- g.phase === 5 && g.active === id,
- 'Wait for your movement turn.',
- );
- if (g.ornithopter?.player === id) finishOrnithopter(g, 'end');
- else finishMovementTurn(g, id);
- return g;
- }
- if (t === 'chooseBattle') {
- requireRule(
- g.phase === 6 && g.active === id && !g.battle,
- 'Wait for your battle choice.',
- );
- const choice = battles(g).find(
- (b) =>
- b.attacker === id &&
- b.territory === action.territory &&
- b.defender === action.target,
- );
- requireRule(choice, 'Choose one of your unresolved battles.');
- g.auditorInsight = null;
- const noFieldPlayers = [choice.attacker, choice.defender].filter(
- (playerId) =>
- getPlayer(g, playerId).noField?.deployed?.location.territory ===
- choice.territory,
- );
- for (const playerId of noFieldPlayers)
- requireRule(
- Object.entries(getPlayer(g, playerId).forces).every(
- ([key, n]) => !n || splitLocation(key).territory !== choice.territory,
- ),
- 'Mixed ordinary-force and No-Field battle dialing awaits a ruling. Reveal voluntarily before Battle while that option is available.',
- );
- const battleEvent = crypto.randomUUID();
- g.battle = {
- ...choice,
- event: battleEvent,
- ...(byFaction(g, 'richese')
- ? { preLeader: { event: battleEvent, ready: [], closed: false } }
- : {}),
- ...(noFieldPlayers.length ? { noFieldPlayers } : {}),
- prepared: true,
- plans: {},
- revealed: false,
- traitorCalls: {},
- };
- beginStrongholdBattle(g);
- log(
- g,
- `${p.name} attacks ${getPlayer(g, choice.defender).name} in ${territory(choice.territory).name}.`,
- );
- return g;
- }
- if (t === 'declineBattlePower') {
- const b = g.battle;
- requireRule(
- b?.preparation?.owner === id && b.preparation.kind !== 'prescienceAnswer',
- 'You do not own this battle power decision.',
- );
- const kind = b.preparation.kind;
- if (kind === 'voice') battlePreparation(g, 'prescience');
- else finishBattlePreparation(g);
- log(
- g,
- `${p.name} declined ${kind === 'voice' ? 'the Voice' : 'battle prescience'}.`,
- );
- return g;
- }
- if (t === 'prescience') {
- const b = g.battle;
- requireRule(
- b?.preparation?.kind === 'prescience' && b.preparation.owner === id,
- 'Prescience is not available. The Voice decision must finish first.',
- );
- requireRule(
- ['leader', 'weapon', 'defense', 'dial'].includes(String(action.field)),
- 'Choose a battle-plan element.',
- );
- const beneficiary = b.preparation.beneficiary;
- const target = beneficiary === b.attacker ? b.defender : b.attacker;
- requireRule(
- action.field !== 'dial' || !b.noFieldPlayers?.includes(target),
- 'Atreides may not inspect the number dialed in a No-Field battle.',
- );
- b.prescience = { player: beneficiary, field: action.field as PlanField };
- b.preparation = {
- kind: 'prescienceAnswer',
- owner: beneficiary === b.attacker ? b.defender : b.attacker,
- beneficiary,
- };
- g.response = { kind: 'prescience', owner: id, passed: [] };
- return g;
- }
- if (t === 'prescienceAnswer') {
- const b = g.battle;
- requireRule(
- b?.preparation?.kind === 'prescienceAnswer' &&
- b.preparation.owner === id &&
- b.prescience,
- 'You are not revealing a prescience element.',
- );
- requireRule(
- b.prescience.field !== 'leader' || !b.preLeader || b.preLeader.closed,
- 'Both combatants must finish their shared preparation before leaders are committed.',
- );
- const field = b.prescience.field;
- requireRule(
- field !== 'dial' || !b.noFieldPlayers?.includes(id),
- 'Atreides may not inspect the number dialed in a No-Field battle.',
- );
- const value =
- field === 'dial'
- ? g.advanced || p.faction === 'ixians'
- ? typeof action.value === 'number'
- ? action.value
- : NaN
- : integer(action.value, 0, at(p, b.territory), 'Forces dialed')
- : action.value === null || action.value === ''
- ? null
- : stringField(action.value);
- requireRule(
- feasiblePrescience(g, p, field, value),
- 'Choose an element that permits a legal battle plan and obeys the Voice.',
- );
- b.prescience.value = value;
- finishBattlePreparation(g);
- return g;
- }
- if (t === 'voice') {
- const b = g.battle;
- requireRule(
- b?.preparation?.kind === 'voice' && b.preparation.owner === id,
- 'Voice must be chosen by Bene Gesserit before prescience and battle plans.',
- );
- requireRule(
- VOICE_KINDS.includes(String(action.kind)),
- 'Choose a card type.',
- );
- requireRule(typeof action.must === 'boolean', 'Choose require or forbid.');
- if (action.kind === 'stoneBurner' && action.must) {
- const target = getPlayer(
- g,
- b.attacker === b.preparation.beneficiary ? b.defender : b.attacker,
- );
- const blocked = stoneCompulsionBlock(g, target);
- requireRule(
- !blocked,
- blocked ?? 'Stone Burner cannot be compelled in this battle.',
- );
- }
- b.voice = {
- target:
- b.attacker === b.preparation.beneficiary ? b.defender : b.attacker,
- kind: action.kind as NonNullable['kind'],
- must: action.must,
- };
- battlePreparation(g, 'prescience');
- g.response = { kind: 'voice', owner: id, passed: [] };
- return g;
- }
- if (t === 'battlePlan') {
- const b = g.battle;
- requireRule(
- !b?.preLeader || b.preLeader.closed,
- 'Both combatants must finish their shared preparation before leaders are committed.',
- );
- requireRule(
- b &&
- !b.preparation &&
- !b.revealed &&
- [b.attacker, b.defender].includes(id) &&
- !b.plans[id],
- 'Your battle plan is not available.',
- );
- requireRule(
- !b.fullPlan || b.plans[b.fullPlan.target] || id === b.fullPlan.target,
- 'Wait for the plan requested by special prescience.',
- );
- b.plans[id] = validatePlan(g, p, action);
- if (b.fullPlan?.target === id) {
- recordFullPlanInspection(g, b.fullPlan.owner);
- return g;
- }
- if (b.plans[b.attacker] && b.plans[b.defender]) {
- for (const playerId of b.noFieldPlayers ?? []) {
- const owner = getPlayer(g, playerId);
- if (owner.noField?.deployed?.location.territory === b.territory)
- revealPlayerNoField(g, owner, 'battle');
- }
- b.revealed = true;
- for (const plan of Object.values(b.plans)) {
- const l = g.players
- .flatMap((p) => p.leaders)
- .find((l) => l.id === plan.leader);
- if (l?.capturedBy) delete l.concealed;
- }
- log(g, 'Both battle plans are revealed.');
- nextRevealedDecision(g);
- }
- return g;
- }
- if (t === 'traitorCall') {
- const b = g.battle;
- requireRule(
- b?.revealed &&
- traitorVoters(g, b).includes(id) &&
- b.traitorCalls[id] === undefined,
- 'Traitor decision is not available.',
- );
- requireRule(typeof action.call === 'boolean', 'Choose reveal or decline.');
- if (action.call) {
- const other =
- b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker;
- requireRule(
- !b.plans[other].kwisatz,
- 'Kwisatz Haderach prevents this leader from turning traitor.',
- );
- requireRule(
- matchingTraitor(
- p.traitors,
- b.plans[other].leader,
- cardOf(getPlayer(g, other), b.plans[other].leader),
- ),
- 'You do not hold that leader as a traitor.',
- );
- }
- b.traitorCalls[id] = action.call;
- if (
- action.call &&
- p.faction === 'harkonnen' &&
- ![b.attacker, b.defender].includes(id)
- ) {
- g.response = { kind: 'harkonnenTraitor', owner: id, passed: [] };
- return g;
- }
-
- if (
- traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined)
- )
- resolveBattle(g);
- return g;
- }
- if (t === 'card') {
- const c = p.hand.find((c) => c.id === action.card);
- requireRule(
- c && (c.kind === 'special' || canUseAsKarama(g.advanced, p.faction, c)),
- 'Select a special card in your hand.',
- );
- if (
- canUseAsKarama(g.advanced, p.faction, c) &&
- action.mode === 'shipment'
- ) {
- const recipient = getPlayer(g, stringField(action.target ?? id));
- requireRule(
- g.phase === 5 && g.active === recipient.id && !recipient.shipped,
- 'Choose the active player before their shipment.',
- );
- requireRule(
- !g.karamaShipping,
- 'A Karama shipment benefit is already available.',
- );
- spendKarama(g, p, c, { kind: 'shipment', recipient: recipient.id });
- return g;
- }
- if (
- canUseAsKarama(g.advanced, p.faction, c) &&
- action.mode === 'purchase'
- ) {
- requireRule(
- g.phase === 3 && g.auction && p.hand.length < handLimit(p),
- 'You may take the current auction card only when eligible to bid.',
- );
- spendKarama(g, p, c, { kind: 'purchase' });
- return g;
- }
- if (c.effect === 'thumper') {
- requireRule(
- g.phase === 1 &&
- !g.spiceSequence &&
- !g.spiceResolution &&
- !g.spiceWindow &&
- !g.nexus,
- 'Use Thumper at the beginning of Spice Blow, before the first draw.',
- );
- discard(g, p, c.id);
- log(
- g,
- `${p.name} played Thumper in place of the next spice-card reveal.`,
- );
- blowSpice(g, true);
- return g;
- }
- const availability = ordinaryCardAvailability(
- {
- status: g.status,
- phase: g.phase,
- active: g.active,
- me: id,
- truthtrance: g.truthtrance,
- phaseOpening: g.phaseOpening,
- response: g.response,
- decision: g.decision,
- shieldWallDestroyed: g.shieldWallDestroyed,
- spiceWindow: g.spiceWindow,
- players: [
- {
- id: p.id,
- faction: p.faction,
- forces: p.forces,
- advisors: p.advisors,
- hand: p.hand,
- movesAllowed: movesAllowed(g, p),
- },
- ],
- },
- c.id,
- { amount: action.amount },
- );
- if (availability && !availability.available)
- throw new RuleError(availability.reason);
- if (c.effect === 'hajr') {
- requireRule(
- g.ornithopter?.player !== id,
- 'Hajr combined with Ornithopter awaits its timing ruling.',
- );
- g.hajr.push(id);
- } else if (c.effect === 'ghola') {
- applyGholaEffect(g, p, action);
- } else if (c.effect === 'harvester') {
- const blow = g.spiceWindow!;
- if (blow.sector !== g.storm) {
- const key = location(blow.territory, blow.sector);
- g.spice[key] = (g.spice[key] ?? 0) + blow.amount;
- }
- blow.amount *= 2;
- blow.harvested = true;
- blow.harvesters = (blow.harvesters ?? 0) + 1;
- g.ready = [];
- } else if (c.effect === 'weather') {
- // The shared availability check validated this exact numeric selection.
- g.stormPending = action.amount as number;
- g.ready = [];
- } else if (c.effect === 'atomics') {
- g.shieldWallDestroyed = true;
- for (const other of g.players) killTerritory(g, other, 'shield_wall');
- g.ready = [];
- } else
- throw new RuleError(
- 'This card’s timing and effect are still being implemented.',
- );
- const used = discard(g, p, c.id);
- log(g, `${p.name} played ${c.name}.`);
- // Shipment feasibility executes deterministic preparations on private clones.
- // Its internal mode never comes from the submitted action or player state.
- if (execution === 'live') stageOrdinaryCardDiscard(g, p, used);
- return g;
- }
- if (t === 'ready') {
- requireRule(
- [1, 2, 4, 7, 8].includes(g.phase) ||
- (g.phase === 0 && g.stormPending !== null),
- 'Complete the current phase action first.',
- );
- requireRule(!g.ready.includes(id), 'You are already ready.');
- g.ready.push(id);
- if (g.ready.length === g.players.length) {
- if (g.phase === 0) moveStorm(g, g.stormPending!);
- else if (g.phase === 1 && g.spiceWindow) finishSpiceWindow(g);
- else if (g.phase === 1 && (!g.nexus || g.summonedBeforeBlow))
- blowSpice(g);
- else if (g.phase === 1) nextWormRide(g);
- else nextPhase(g);
- }
- return g;
- }
- throw new RuleError('That action is not available.');
-}
-export function viewGame(state: Game, id: string) {
- karamaConversionIntegrity(state);
- treacheryDiscardIntegrity(state);
- shipmentPromiseIntegrity(state);
- saphoMovementIntegrity(state);
- const g = structuredClone(state);
- normalizeCardNames(g);
- settleAdvisors(g);
- normalizeBattle(g);
- const me = getPlayer(g, id);
- const leaderRevivals = leaderRevivalOptions(g, me);
- const b = g.battle;
- const completion =
- !g.pendingTreacheryDiscard &&
- b &&
- !b.revealed &&
- !b.plans[id] &&
- [b.attacker, b.defender].includes(id) &&
- (b.truthPromises?.some(
- (promise) => promise.player === id && !promise.released,
- ) ||
- (b.prescience && b.prescience.player !== id && 'value' in b.prescience))
- ? findReachableBattlePlan(g, me)
- : null;
- return {
- truthShipmentAnswers:
- g.truthtrance?.stage === 'answer' &&
- g.truthtrance.question?.target === id &&
- g.truthtrance.question.kind === 'shipment'
- ? shipmentTruthAnswers(g, me, g.truthtrance.question)
- : null,
- shipmentPromises: g.shipmentPromises ?? [],
- shipmentCompletion:
- !g.pendingTreacheryDiscard &&
- liveShipmentPromises(g.shipmentPromises ?? [], id, g.turn).some(
- (p) => p.answer,
- )
- ? findShipmentCompletion(g, me)
- : null,
- truthBattleAnswers:
- g.truthtrance?.stage === 'answer' &&
- g.truthtrance.question?.target === id &&
- g.truthtrance.question.kind === 'battlePlan'
- ? battleTruthAnswers(g, me, g.truthtrance.question.claim)
- : null,
- truthtrance: g.truthtrance ?? null,
- truthHistory: g.truthHistory ?? [],
- truthAnswer:
- g.truthtrance?.stage === 'answer' &&
- g.truthtrance.question?.target === id &&
- g.truthtrance.question.kind === 'fact'
- ? truthFactAnswer(me, g.truthtrance.question.fact)
- : null,
- schema: g.schema,
- botsPending: g.botsPending ?? false,
- automaticContinuationPending: !!g.pendingTreacheryDiscard,
- botNextActionAt: g.botNextActionAt ?? null,
- code: g.code,
- version: g.version,
- host: g.host,
- status: g.status,
- setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null,
- setupPending: setupPending(g),
- advanced: g.advanced,
- dukeVidal: g.dukeVidal
- ? { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) }
- : null,
- moritaniTerror: g.moritaniTerror
- ? projectTerror(g.moritaniTerror, me.faction === 'moritani')
- : null,
- moritaniPendingPlacement:
- me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null,
- terrorEntry: g.pendingTerrorEntry
- ? (() => {
- const entry = g.pendingTerrorEntry!;
- const token = g.moritaniTerror?.tokens.find(
- (t) => t.id === entry.token,
- );
- return {
- entrant: entry.entrant,
- territory: entry.territory,
- sector: entry.sector,
- cause: entry.cause,
- stage: entry.stage,
- ...(me.faction === 'moritani' && token
- ? {
- kind: token.kind as TerrorKind,
- canOfferAlliance: !terrorAllianceBlocked(
- g,
- entry,
- token.kind,
- ),
- allianceBlockedReason:
- terrorAllianceBlocked(g, entry, token.kind) ?? undefined,
- canReveal: !terrorRevealBlocked(g, entry, token.kind),
- revealBlocked:
- terrorRevealBlocked(g, entry, token.kind) ?? undefined,
- ...(token.kind === 'sneakAttack'
- ? { sneakAttack: sneakAttackOptions(g, entry) }
- : {}),
- }
- : {}),
- };
- })()
- : null,
- ixTechnology:
- me.faction === 'ixians'
- ? {
- setup: g.ixSetupCards ?? null,
- pool: g.ixAuction?.cards.length
- ? [...g.ixAuction.cards].sort((a, b) => a.id.localeCompare(b.id))
- : null,
- known:
- g.ixAuctionKnown?.turn === g.turn
- ? [...g.ixAuctionKnown.cards].sort((a, b) =>
- a.id.localeCompare(b.id),
- )
- : [],
- used: g.ixTechnologyTurn === g.turn,
- }
- : null,
- ixPurchased:
- g.pendingIxAlly?.player === id
- ? (me.hand.find((c) => c.id === g.pendingIxAlly!.card) ?? null)
- : null,
- ambassadors: g.ecazAmbassadors
- ? {
- tokens: g.ecazAmbassadors.tokens.map((token) => ({ ...token })),
- nextCost:
- (g.ecazAmbassadors.placement?.turn === g.turn
- ? g.ecazAmbassadors.placement.count
- : 0) + 1,
- blocked:
- g.ecazAmbassadors.placement?.turn === g.turn &&
- g.ecazAmbassadors.placement.blocked,
- completedTurn: g.ecazPlacementTurn ?? null,
- }
- : null,
- ambassadorEntry: g.pendingAmbassador
- ? (() => {
- const entry = g.pendingAmbassador!;
- const owner = getPlayer(g, entry.owner);
- const entrant = getPlayer(g, entry.entrant);
- const token = g.ecazAmbassadors!.tokens.find(
- (t) => t.id === entry.token,
- )!;
- return {
- event: entry.event,
- owner: entry.owner,
- entrant: entry.entrant,
- territory: entry.territory,
- stage: entry.stage,
- effect: entry.effect ?? token.effect,
- beneficiary: entry.beneficiary ?? null,
- beneficiaries:
- entry.stage === 'offer' && id === owner.id
- ? g.players
- .filter(
- (p) =>
- p.id === owner.id ||
- (p.id === owner.ally && p.ally === owner.id),
- )
- .map((p) => ({
- player: p.id,
- blocked:
- ambassadorEffectBlock(g, token.effect, p, entrant) ??
- (token.effect === 'beneGesserit' &&
- !entry.copyChoices.some(
- (effect) =>
- !ambassadorEffectBlock(g, effect, p, entrant),
- )
- ? 'No copied effect is currently available.'
- : null),
- }))
- : [],
- copies:
- entry.stage === 'copy' && id === entry.beneficiary
- ? entry.copyChoices.map((effect) => ({
- effect,
- blocked: ambassadorEffectBlock(g, effect, me, entrant),
- }))
- : [],
- cards:
- entry.stage === 'cards' && id === entry.beneficiary
- ? me.hand.map((card) => ({
- card: card.id,
- blocked: ambassadorDiscardBlock(g, me, card),
- }))
- : [],
- };
- })()
- : null,
- ambassadorInsights: (g.ambassadorInsights ?? []).filter(
- (entry) => entry.viewer === id,
- ),
- auditor: g.pendingAuditor
- ? {
- event: g.pendingAuditor.event,
- owner: g.pendingAuditor.owner,
- opponent: g.pendingAuditor.opponent,
- survived: g.pendingAuditor.survived,
- count: currentAuditCount(g),
- }
- : null,
- auditorInsight:
- g.auditorInsight?.viewer === id
- ? structuredClone(g.auditorInsight)
- : null,
- mobileStronghold: g.mobileStronghold ?? null,
- mobileRoute: g.pendingMobileMove
- ? {
- route: g.pendingMobileMove.route,
- collect: g.pendingMobileMove.collect,
- }
- : null,
- techTokens: g.techTokens ?? null,
- strongholdCards: g.strongholdCards ?? null,
- expansions: g.expansions,
- turn: g.turn,
- phase: g.phase,
- phaseOpening: g.phaseOpening ? { passed: g.phaseOpening.passed } : null,
- storm: g.status === 'lobby' ? STORM_START_SECTOR : g.storm,
- playerPositions: normalizedPlayerPositions(g),
- order: g.order,
- active: g.active,
- movementRemaining: g.movementRemaining ?? null,
- guildTimingLocked: g.guildTimingLocked ?? false,
- ready: g.ready,
- spice: g.spice,
- log: g.log,
- nexus: g.nexus,
- summonedWorm: g.summonedWorm
- ? { territory: g.summonedWorm.territory }
- : null,
- sandtrout: g.sandtrout ?? false,
- spiceWindow: g.spiceWindow ?? null,
- spicePile: g.spiceSequence?.pile ?? 0,
- beforeSpiceDraw:
- g.phase === 1 &&
- !g.spiceSequence &&
- !g.spiceResolution &&
- !g.spiceWindow &&
- !g.nexus,
- spiceDiscardTop: g.spiceDiscard.map((pile) => pile.at(-1) ?? null),
- allianceOffers: g.allianceOffers,
- winner: g.winner,
- shieldWallDestroyed: g.shieldWallDestroyed,
- stormDialers: g.stormDialers,
- stormSubmitted: Object.keys(g.stormDials),
- karamaShipping: g.karamaShipping
- ? { player: g.karamaShipping.player, owner: g.karamaShipping.owner }
- : null,
- stormPending: g.stormPending ?? null,
- stormForecast:
- me.faction === 'fremen' && g.stormCardKnown ? g.stormCard : null,
- stormRevealed:
- g.phase === 0 &&
- g.stormDialers.length > 0 &&
- g.stormDialers.every((id) => g.stormDials[id] !== undefined)
- ? g.stormDials
- : null,
- me: id,
- choamWorthless:
- me.faction === 'choam'
- ? {
- pending:
- g.pendingChoamWorthless?.owner === id
- ? g.pendingChoamWorthless.card
- : null,
- baliset: gameTerritories(g)
- .filter((t) => at(me, t.id) > 0)
- .map((t) => t.id),
- gamont: g.players
- .filter((p) => p.id !== id)
- .flatMap((p) =>
- Object.entries(presenceByLocation(p)).flatMap(
- ([key, count]) => {
- const elites = p.elites?.forces[key] ?? 0;
- const marker = p.noField?.deployed;
- const noField =
- !!marker &&
- location(
- marker.location.territory,
- marker.location.sector,
- ) === key;
- return [
- ...(count > elites
- ? [{ target: p.id, key, elite: 0, noField }]
- : []),
- ...(elites > 0
- ? [{ target: p.id, key, elite: 1, noField: false }]
- : []),
- ];
- },
- ),
- ),
- targets: g.players
- .filter((p) => !g.revivalRules?.freeBlocked?.includes(p.id))
- .map((p) => p.id),
- cards: me.hand.filter(
- (c) =>
- c.kind === 'worthless' &&
- [
- 'Baliset',
- 'Kulon',
- 'La La La',
- 'Trip to Gamont',
- 'Jubba Cloak',
- ].includes(c.name) &&
- !choamWorthlessBlocked(g, c.id),
- ),
- }
- : null,
- balisetRestrictions:
- g.phase === 5
- ? (g.choamBaliset ?? []).filter((b) => b.turn === g.turn)
- : [],
- mentatVictoryPending:
- g.phase === 8 &&
- g.status === 'playing' &&
- (!!byFaction(g, 'choam') ||
- g.decision?.kind === 'moritaniPlacement' ||
- g.response?.kind === 'moritaniPlacement'),
- choamMovementBonus:
- g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0,
- choamCashIn:
- g.status === 'playing' &&
- me.faction === 'choam' &&
- g.advanced &&
- !me.specialKaramaUsed
- ? {
- cards: cashInCards(g, me),
- karamas: cashInCards(g, me).filter((c) => c.effect === 'karama'),
- }
- : null,
- choamMarket: g.choamMarket
- ? {
- owner: g.choamMarket.owner,
- ...(g.choamMarket.owner === id
- ? {
- sales: saleOptions(me.hand, g.choamMarket.blocked),
- canTrade: !!me.ally && g.choamTradeTurn !== g.turn,
- tradeAttempted: !!g.choamMarket.tradeAttempted,
- }
- : {}),
- ...(g.choamMarket.trade &&
- [g.choamMarket.owner, g.choamMarket.trade.ally].includes(id)
- ? {
- offered: getPlayer(g, g.choamMarket.owner).hand.find(
- (c) => c.id === g.choamMarket!.trade!.offered,
- ),
- returned: getPlayer(g, g.choamMarket.trade.ally).hand.find(
- (c) => c.id === g.choamMarket!.trade!.returned,
- ),
- }
- : {}),
- }
- : null,
- inflation: g.inflation ?? null,
- inflationUsed: g.inflationUsed ?? false,
- inflationAttempted: g.inflationAttemptTurn === g.turn,
- charity: {
- multiplier: charityMultiplier(g),
- amount: charityAmount(g, me),
- payer: charityPayer(g)?.id ?? null,
- incomePending: !!byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn,
- incomeCanceled:
- g.choamCharity?.turn === g.turn && g.choamCharity.canceled,
- },
- moritaniRetention: g.moritaniRetention ?? null,
- moritaniRetentionCard: g.moritaniRetention?.keep
- ? (cardOf(
- getPlayer(g, g.moritaniRetention.player),
- g.moritaniRetention.keep,
- ) ?? null)
- : null,
- decision:
- g.decision?.kind === 'capturedLeader' &&
- ![g.decision.player, g.decision.controller ?? g.decision.owner].includes(
- id,
- )
- ? { ...g.decision, leader: '', owner: '' }
- : (g.decision ?? null),
- response: g.response
- ? {
- ...g.response,
- passed: g.response.passed.includes(id) ? [id] : [],
- ...(g.response.kind === 'revivalIncome' &&
- ![g.response.owner, g.response.recipient].includes(id)
- ? { amount: undefined }
- : {}),
- ...(g.response.kind === 'faceDancerReplacement' &&
- g.response.owner !== id
- ? { intent: undefined }
- : {}),
- ...(g.response.kind === 'emperorGift' &&
- ![g.response.owner, g.response.recipient].includes(id)
- ? { amount: undefined }
- : {}),
- }
- : null,
- responseControls: g.response
- ? {
- cancelCards: responseCancelCards(g, me, g.response),
- hasPassed: g.response.passed.includes(id),
- }
- : undefined,
- revivalPrevention:
- g.revivalPrevention?.turn === g.turn ? g.revivalPrevention : null,
- emperorExtra: g.emperorExtra ?? {},
- freeRevival: g.freeRevival ?? [],
- revivalRules: {
- ...(g.revivalRules ?? newRevivalRules()),
- earlyBlocked: (g.revivalRules?.earlyBlocked ?? []).filter(
- (key) => me.faction === 'tleilaxu' || key.startsWith(`${id}:`),
- ),
- },
- revivalRequests: Object.fromEntries(
- Object.entries(g.revivalRequests ?? {}).filter(
- ([player]) => player === id || me.faction === 'tleilaxu',
- ),
- ),
- revival: {
- pending: !!g.pendingRevival,
- leaders: leaderRevivals.leaders,
- kwisatz: leaderRevivals.kwisatz,
- prevented: revivalPrevented(g, me.id),
- eliteRemaining: eliteRevivalRemaining(me),
- limit: forceRevivalLimit(g, me),
- forcesRemaining: forceRevivalRemaining(g, me),
- discount: revivalDiscount(g, me),
- choamBlocked: !!g.revivalRules?.choamBlocked,
- freeRemaining: freeRevivalRemaining(g, me),
- freeBlocked: g.revivalRules?.freeBlocked?.includes(id) ?? false,
- },
- aid: {
- pledged: g.aid?.[id]?.amount ?? 0,
- available:
- g.phase === 6
- ? (battleAidFor(g, me)?.amount ?? 0)
- : me.ally && g.aid?.[me.ally]?.recipient === id
- ? g.aid[me.ally].amount
- : 0,
- },
- players: g.players.map((p) => ({
- ...(p.noField
- ? { noField: projectRicheseNoField(p.noField, false) }
- : {}),
- id: p.id,
- name: p.name,
- bot: p.bot,
- autopilot: p.autopilot,
- faction: p.faction,
- handLimit: handLimit(p),
- ready: p.ready,
- reserves: p.reserves,
- tanks: p.tanks,
- forces: p.forces,
- elites: p.elites,
- advisors: p.advisors,
- advisorSetup: p.advisorSetup,
- specialKaramaUsed: p.specialKaramaUsed ?? false,
- fremenMovementBlocked:
- p.fremenMovementBlocked?.turn === g.turn &&
- p.fremenMovementBlocked.move === p.moved,
- ixMovementBlocked:
- p.ixMovementBlocked?.turn === g.turn &&
- p.ixMovementBlocked.move === p.moved,
- revealedTraitors: p.revealedTraitors ?? [],
- revealedFaceDancers: (p.faceDancers ?? [])
- .filter((c) => c.revealed)
- .map((c) => c.leader),
- leaders: [
- ...(g.dukeVidal?.controller === p.id &&
- !g.dukeVidal.leader.capturedBy &&
- !g.dukeVidal.leader.gholaBy &&
- !(g.advanced && byFaction(g, 'harkonnen'))
- ? [{ ...g.dukeVidal.leader, controller: p.id }]
- : []),
- ...p.leaders.map((l) => projectLeader(g, l, id)),
- ...g.players
- .filter((owner) => owner.id !== p.id)
- .flatMap((owner) =>
- owner.leaders
- .filter(
- (l) =>
- l.gholaBy === p.id || (p.id === id && l.capturedBy === p.id),
- )
- .map((l) => projectLeader(g, l, id)),
- ),
- ],
- ally: p.ally,
- allySinceTurn: p.allySinceTurn,
- ...(p.id === id
- ? {
- spice: p.spice,
- charityClaimed: p.charityTurn === g.turn,
- ...(g.advanced && p.faction === 'atreides'
- ? {
- kwisatz: {
- active: p.battleLosses >= 7,
- dead: p.kwisatz?.dead ?? false,
- usedAt: p.kwisatz?.usedAt,
- losses: p.battleLosses,
- },
- }
- : {}),
- hand: p.hand,
- traitors: p.traitors,
- faceDancers: p.faceDancers,
- faceDancerReplaced: p.faceDancerReplacedTurn === g.turn,
- traitorChoices: p.traitorChoices,
- prediction: p.prediction,
- bribes: p.bribes,
- shipped: p.shipped,
- moved: p.moved,
- movesAllowed: movesAllowed(g, p),
- revived: p.revived,
- leaderRevived: p.leaderRevived,
- gholaBlocked: Object.entries(p.gholaBlocked ?? {})
- .filter(([, turn]) => turn === g.turn)
- .map(([leader]) => leader),
- }
- : {}),
- ...(g.phase === 3 || p.id === id ? { handCount: p.hand.length } : {}),
- })),
- richeseNoField: (() => {
- const owner = byFaction(g, 'richese');
- if (!owner?.noField || !owner.noFieldEvent) return null;
- const mine = owner.id === me.id;
- const shipBlock = mine
- ? noFieldShipBlock(g, owner)
- : 'Only Richese controls this token inventory.';
- const revealBlock = mine
- ? noFieldRevealBlock(g, owner)
- : 'Only Richese controls this concealed token.';
- return {
- owner: owner.id,
- event: owner.noFieldEvent,
- public: projectRicheseNoField(owner.noField, false),
- private: mine
- ? projectRicheseNoField(owner.noField, true).private
- : null,
- canShip: !shipBlock,
- shipBlock,
- canReveal: !revealBlock,
- revealBlock,
- canOfferAlly: mine && !noFieldAllyOfferBlock(g, owner),
- allyOfferBlock: mine
- ? noFieldAllyOfferBlock(g, owner)
- : 'Only Richese may offer these tokens.',
- allyDeclined:
- g.richeseAllyDeclined?.turn === g.turn &&
- g.richeseAllyDeclined.recipient === owner.ally,
- allyOffer: (() => {
- const offer = g.richeseAllyOffer;
- if (!offer || ![offer.owner, offer.recipient].includes(id))
- return null;
- const { event, owner, recipient, territory, sector, payer } = offer;
- try {
- const {
- cost,
- ownerPayment,
- recipientPayment,
- amount,
- value,
- eliteMin,
- eliteMax,
- } = alliedNoFieldQuote(g, offer, offer.recipient === id);
- return {
- event,
- owner,
- recipient,
- territory,
- sector,
- payer,
- cost,
- ownerPayment,
- recipientPayment,
- amount,
- value,
- eliteMin,
- eliteMax,
- blocked: null as string | null,
- };
- } catch (error) {
- if (!(error instanceof RuleError)) throw error;
- return {
- event,
- owner,
- recipient,
- territory,
- sector,
- payer,
- cost: 0,
- ownerPayment: 0,
- recipientPayment: 0,
- amount: 0,
- value: 0 as const,
- eliteMin: 0,
- eliteMax: 0,
- blocked: error.message,
- };
- }
- })(),
- };
- })(),
- richeseGift: richeseGiftView(g, me),
- distrans: distransView(g, me),
- nullentropy: nullentropyView(g, me),
- ornithopter: ornithopterView(g, me),
- residualPoison: residualPoisonView(g, me),
- portableSnooper: portableSnooperView(g, me),
- saphoOptions: saphoOptions(g, me),
- saphoMovementLast: g.saphoMovementLast
- ? {
- event: g.saphoMovementLast.event,
- turn: g.saphoMovementLast.turn,
- player: g.saphoMovementLast.player,
- }
- : null,
- playedOrnithopter: g.ornithopter
- ? {
- player: g.ornithopter.player,
- card: g.ornithopter.card,
- mode: g.ornithopter.mode,
- completed: g.ornithopter.completed,
- }
- : null,
- richeseSpecialKarama: richeseSpecialKaramaView(g, me),
- richeseBidding:
- g.richeseBidding?.turn === g.turn
- ? {
- owner: g.richeseBidding.owner,
- event: g.richeseBidding.event,
- stage: g.richeseBidding.stage,
- position: g.richeseBidding.position,
- normalCount: g.richeseBidding.normalCount,
- offerBlocked: richeseOfferBlock(g),
- cache:
- g.richeseBidding.owner === id ? (g.richeseCache ?? []) : null,
- }
- : null,
- richeseAuction: g.richeseAuction
- ? {
- ...projectRicheseAuction(g.richeseAuction, id),
- card:
- g.richeseAuction.source === 'cache' ||
- g.richeseAuction.owner === id ||
- (me.faction === 'atreides' && g.richesePeekKnown)
- ? (g.richeseOfferedCard ?? richeseLotCard(g) ?? null)
- : null,
- claim: g.richeseClaim ?? null,
- ownAllyPayment: g.richeseFunding?.[id]?.allyPayment ?? 0,
- ownAvailable:
- uncommittedSpice(g, me) +
- richeseOwnCommitment(g.richeseAuction, g.richeseFunding ?? {}, id),
- allyAvailable: aidFor(g, me)?.amount ?? 0,
- peekKnown: !!g.richesePeekKnown,
- }
- : null,
- auction: g.auction
- ? {
- bid: g.auction.bid,
- bidder: g.auction.bidder,
- active: g.auction.active,
- ...(g.auction.bidder === id
- ? { allyPayment: g.auction.allyPayment ?? 0 }
- : {}),
- remaining: g.auction.cards.length - g.auction.index,
- card:
- me.faction === 'atreides' && g.auction.peekKnown
- ? g.auction.cards[g.auction.index]
- : null,
- }
- : null,
- battle: b
- ? {
- strongholdCopy: b.strongholdCopy ?? null,
- strongholdEffects: Object.fromEntries(
- [b.attacker, b.defender].map((player) => [
- player,
- strongholdEffect(g, player),
- ]),
- ) as Record,
- tieWinner: battleTieWinner(g),
- noFieldPlayers: b.noFieldPlayers ?? [],
- ownForces: [b.attacker, b.defender].includes(id)
- ? combatForces(
- g,
- me,
- b.territory,
- getPlayer(g, b.attacker === id ? b.defender : b.attacker),
- )
- : null,
- truthPromises: b.truthPromises ?? [],
- compliantPlan:
- completion &&
- !completion.actions.length &&
- !completion.waitingForIncome
- ? completion.plan
- : null,
- compliantPreparation:
- completion &&
- (completion.actions.length || completion.waitingForIncome)
- ? completion
- : null,
- poisonTooth: b.revealed ? (b.poisonTooth ?? {}) : {},
- stoneBurner: b.revealed ? (b.stoneBurner ?? {}) : {},
- stoneBurnerContext: [b.attacker, b.defender].includes(id)
- ? {
- blocked: stoneTimingBlock(g),
- opponentPools: stonePublicPools(
- g,
- getPlayer(g, b.attacker === id ? b.defender : b.attacker),
- me,
- ),
- }
- : null,
- lateDefense: b.revealed ? (b.lateDefense ?? {}) : {},
- fullPlan: b.fullPlan ?? null,
- fullPlanInsight:
- b.fullPlan?.owner === id &&
- b.plans[b.fullPlan.target] &&
- !b.revealed
- ? {
- target: b.fullPlan.target,
- plan: b.plans[b.fullPlan.target],
- cards: getPlayer(g, b.fullPlan.target).hand.filter((c) => {
- const plan = b.plans[b.fullPlan!.target];
- return [plan.leader, plan.weapon, plan.defense].includes(
- c.id,
- );
- }),
- }
- : null,
- event: b.event ?? null,
- preLeader: b.preLeader ?? null,
- territory: b.territory,
- attacker: b.attacker,
- defender: b.defender,
- revealed: b.revealed,
- kwisatzBlocked: b.kwisatzBlocked ?? false,
- eliteBlocked: b.eliteBlocked ?? [],
- fremenSupportBlocked: b.fremenSupportBlocked ?? false,
- choamAidBlocked: b.choamAidBlocked ?? false,
- cards: b.revealed
- ? [b.attacker, b.defender].flatMap((playerId) => {
- const plan = b.plans[playerId];
- return getPlayer(g, playerId).hand.filter((c) =>
- [
- plan.leader,
- plan.weapon,
- plan.defense,
- b.lateDefense?.[playerId],
- ].includes(c.id),
- );
- })
- : [],
- submitted: Object.keys(b.plans),
- plans: b.revealed
- ? b.plans
- : b.plans[id]
- ? { [id]: b.plans[id] }
- : {},
- traitorSubmitted: Object.keys(b.traitorCalls),
- traitorVoters: traitorVoters(g, b),
- preparation: b.preparation ?? null,
- prescience: b.prescience
- ? {
- player: b.prescience.player,
- field: b.prescience.field,
- answered: 'value' in b.prescience,
- }
- : undefined,
- voice: b.voice,
- insight:
- b.prescience &&
- 'value' in b.prescience &&
- (b.prescience.player === id ||
- [b.attacker, b.defender].includes(id))
- ? {
- field: b.prescience.field,
- value: b.prescience.value,
- label:
- g.players
- .flatMap((p) => [...p.leaders, ...p.hand])
- .concat(g.dukeVidal ? [g.dukeVidal.leader] : [])
- .find((item) => item.id === b.prescience!.value)?.name ??
- String(b.prescience.value ?? 'None'),
- }
- : null,
- }
- : null,
- spicePeek:
- g.phase === 5 && me.faction === 'atreides' && g.spicePeekKnown
- ? (g.spiceDeck[0] ?? null)
- : null,
- allLeaders: [
- ...g.players.flatMap((p) => p.leaders),
- ...(g.dukeVidal ? [g.dukeVidal.leader] : []),
- ].map((l) => ({
- id: l.id,
- name: l.name,
- faction: l.faction,
- strength: l.strength,
- })),
- };
-}
-export type GameView = ReturnType;
diff --git a/game/fremen-ambassador.ts b/game/fremen-ambassador.ts
index 34281c7a..19367ea2 100644
--- a/game/fremen-ambassador.ts
+++ b/game/fremen-ambassador.ts
@@ -10,6 +10,7 @@ import { arrivalAsAdvisor } from './advisors';
import { presenceAt } from './force-presence';
import { territoryEntryBlock } from './occupancy';
import { ecazOccupancyRelation } from './ecaz-occupy';
+import { homeworldNoFieldMovementBlock } from './homeworld-mobility';
export class FremenAmbassadorMoveError extends Error {
constructor(message: string) {
@@ -176,6 +177,8 @@ export function quoteFremenAmbassadorMove(
let origin: string | undefined;
let noField: FremenAmbassadorMove['noField'];
if (action.noField !== undefined) {
+ const blocked = homeworldNoFieldMovementBlock(g, p.id);
+ requireMove(!blocked, blocked ?? 'This No-Field cannot move.');
const marker = ownMarker(p);
requireMove(
record(action.noField) &&
@@ -339,7 +342,7 @@ export function fremenAmbassadorMovement(
beneficiaryId: string,
): FremenAmbassadorMovement {
const p = playerFor(g, beneficiaryId);
- const marker = ownMarker(p);
+ const marker = homeworldNoFieldMovementBlock(g, p.id) ? null : ownMarker(p);
const sources: FremenAmbassadorMovement['sources'] = [];
for (const t of gameTerritories(g)) {
const sectors = Object.entries(p.forces)
diff --git a/game/giedi-collection.ts b/game/giedi-collection.ts
new file mode 100644
index 00000000..288b314b
--- /dev/null
+++ b/game/giedi-collection.ts
@@ -0,0 +1,92 @@
+import {
+ quoteGiediCollectionBonus,
+ type HomeworldCollectionContext,
+ type HomeworldCollectionSource,
+} from './homeworld-collection';
+import { HomeworldCustodyError } from './homeworld-custody';
+
+export type GiediCollection = {
+ turn: number;
+ player: string;
+ qualifying: number;
+ awarded: boolean;
+ signature: string;
+};
+
+function signature(receipt: Omit) {
+ return JSON.stringify([
+ receipt.turn,
+ receipt.player,
+ receipt.qualifying,
+ receipt.awarded,
+ ]);
+}
+
+/** A saved receipt records completed income, never an instruction to pay again.
+ * Missing receipts remain compatible with older saves; do not reconstruct one
+ * from present balances, forces or already-collected deposits. */
+export function validateGiediCollection(
+ context: HomeworldCollectionContext,
+ turn: number,
+ receipt: GiediCollection | undefined,
+) {
+ if (receipt === undefined) return;
+ if (
+ !receipt ||
+ typeof receipt !== 'object' ||
+ Array.isArray(receipt) ||
+ Object.keys(receipt).sort().join(',') !==
+ 'awarded,player,qualifying,signature,turn' ||
+ !context.homeworlds?.custody ||
+ !context.players.some(
+ (p) => p.id === receipt.player && p.faction === 'harkonnen',
+ ) ||
+ !Number.isSafeInteger(receipt.turn) ||
+ receipt.turn < 1 ||
+ receipt.turn > turn ||
+ !Number.isSafeInteger(receipt.qualifying) ||
+ receipt.qualifying < 0 ||
+ typeof receipt.awarded !== 'boolean' ||
+ (receipt.awarded && receipt.qualifying === 0) ||
+ receipt.signature !== signature(receipt)
+ )
+ throw new HomeworldCustodyError(
+ 'The saved Giedi Prime collection receipt is invalid.',
+ );
+}
+
+export function quoteGiediCollectionReceipt(
+ context: HomeworldCollectionContext,
+ turn: number,
+ player: string,
+ sources: HomeworldCollectionSource[],
+ previous?: GiediCollection,
+): { receipt: GiediCollection; amount: 0 | 2 } {
+ validateGiediCollection(context, turn, previous);
+ if (!Number.isSafeInteger(turn) || turn < 1 || !context.homeworlds?.custody)
+ throw new HomeworldCustodyError(
+ 'Giedi Prime collection needs its Homeworld and current turn.',
+ );
+ const current = previous?.turn === turn ? previous : undefined;
+ const quote = quoteGiediCollectionBonus(
+ context,
+ player,
+ sources,
+ current?.awarded ?? false,
+ );
+ const qualifying = (current?.qualifying ?? 0) + quote.qualifying;
+ if (!Number.isSafeInteger(qualifying))
+ throw new HomeworldCustodyError(
+ 'Giedi Prime collection receipts would overflow.',
+ );
+ const receipt = {
+ turn,
+ player,
+ qualifying,
+ awarded: (current?.awarded ?? false) || quote.amount > 0,
+ };
+ return {
+ receipt: { ...receipt, signature: signature(receipt) },
+ amount: quote.amount,
+ };
+}
diff --git a/game/guild-ambassador-continuation.ts b/game/guild-ambassador-continuation.ts
index d20f58e6..568e4c6c 100644
--- a/game/guild-ambassador-continuation.ts
+++ b/game/guild-ambassador-continuation.ts
@@ -1,4 +1,5 @@
import { validAmbassadorResume } from './ambassador-resume';
+import { ambassadorPhaseAllowed } from './ambassador-phase';
import type { Game } from './engine';
import type { GuildAmbassadorShipment } from './guild-ambassador';
import { validLocation, territory } from './board';
@@ -18,6 +19,11 @@ export type GuildAmbassadorArrivalContext = Pick<
| 'players'
| 'pendingAmbassador'
| 'ecazAmbassadors'
+ | 'homeworlds'
+ | 'homeworldRevivalReturn'
+ | 'homeworldRevivalProgress'
+ | 'homeworldVictoryReinforcement'
+ | 'lastBattleContext'
>;
export type GuildAmbassadorArrivalNext =
| 'intrusion'
@@ -61,7 +67,7 @@ export function validateGuildAmbassadorArrivalContext(
g.status === 'playing' &&
whole(g.turn) &&
g.turn > 0 &&
- (g.phase === 1 || g.phase === 5) &&
+ ambassadorPhaseAllowed(g) &&
Array.isArray(g.players) &&
g.players.every((p) => id(p.id)) &&
new Set(g.players.map((p) => p.id)).size === g.players.length &&
@@ -151,7 +157,11 @@ export function validateGuildAmbassadorArrivalContext(
bg?.faction === 'beneGesserit' &&
bg.id !== order.player &&
g.players.filter((p) => p.faction === 'beneGesserit').length === 1 &&
- advisorArrival.amount === 1 &&
+ (advisorArrival.amount === 1 ||
+ (advisorArrival.amount === 2 &&
+ !!g.homeworlds?.custody &&
+ advisorArrival.territory === 'polar_sink' &&
+ advisorArrival.sector === 0)) &&
advisorArrival.elite === 0 &&
boardLocation(advisorArrival.territory, advisorArrival.sector) &&
(advisorArrival.territory === order.territory ||
@@ -159,6 +169,9 @@ export function validateGuildAmbassadorArrivalContext(
advisorArrival.sector === 0)),
'The saved accompanying advisor does not match this shipment destination and owner.',
);
+ // This is a committed arrival: the batch itself or later child effects can
+ // lower Wallach below high. Rechecking today's allowance would invalidate
+ // legal historical receipts. Declaration/commit prove the original limit.
}
return { order, ...(advisorArrival ? { advisorArrival } : {}) };
}
diff --git a/game/guild-ambassador.ts b/game/guild-ambassador.ts
index fb14c2ae..d81fb76e 100644
--- a/game/guild-ambassador.ts
+++ b/game/guild-ambassador.ts
@@ -9,6 +9,7 @@ import {
} from './board';
import { presenceAt } from './force-presence';
import { territoryEntryBlock } from './occupancy';
+import { homeworldSpiritualAdvisorQuote } from './homeworld-mobility';
export class GuildAmbassadorShipmentError extends Error {
constructor(message: string) {
@@ -251,7 +252,7 @@ export type GuildAmbassadorAdvisor = {
player: string;
territory: string;
sector: number;
- amount: 1;
+ amount: 1 | 2;
elite: 0;
advisors: boolean;
};
@@ -306,15 +307,26 @@ export function quoteGuildAmbassadorAdvisor(
? shipment.sector
: (action.sector as number)
: 0;
+ const allowance = homeworldSpiritualAdvisorQuote(g, bg.id, to);
+ requireShipment(
+ !allowance.blocked,
+ allowance.blocked ?? 'Accompaniment is unavailable.',
+ );
+ const amount = action.amount === undefined ? 1 : action.amount;
+ requireShipment(
+ (amount === 1 || amount === 2) &&
+ amount <= allowance.maximum &&
+ amount <= bg.reserves - (bg.elites?.reserves ?? 0),
+ 'Choose an available spiritual-advisor amount; two requires high-population Wallach IX and Polar Sink.',
+ );
requireShipment(
(action.territory === undefined || action.territory === to) &&
- (action.amount === undefined || action.amount === 1) &&
(action.elite === undefined || action.elite === 0) &&
action.noField === undefined &&
action.alliedNoField === undefined &&
usableLocation(g, location(to, sector)) &&
(sector === 0 || sector !== g.storm),
- 'This accompaniment grants one ordinary force only to the shipment territory or Polar Sink, outside storm.',
+ 'Accompaniment sends ordinary forces only to the shipment territory or Polar Sink, outside storm.',
);
// E1 p.10 FAQ explicitly allows BG to accompany Ixians into the HMS. This
// grants no independent BG direct shipment or relocation of the stronghold.
@@ -325,15 +337,15 @@ export function quoteGuildAmbassadorAdvisor(
const advisors = arrivalAsAdvisor(g, bg, to, undefined, accompanying);
validateDestinationOccupancy(g, bg, to, advisors);
requireShipment(
- count((bg.forces[location(to, sector)] ?? 0) + 1) &&
- count(presenceAt(bg, to) + 1),
+ count((bg.forces[location(to, sector)] ?? 0) + amount) &&
+ count(presenceAt(bg, to) + amount),
'The resulting Bene Gesserit force quantity is invalid.',
);
return {
player: bg.id,
territory: to,
sector,
- amount: 1,
+ amount,
elite: 0,
advisors,
};
diff --git a/game/guild-homeworld-shipment-options.ts b/game/guild-homeworld-shipment-options.ts
new file mode 100644
index 00000000..23665dac
--- /dev/null
+++ b/game/guild-homeworld-shipment-options.ts
@@ -0,0 +1,259 @@
+import type { Action, GameView } from './engine';
+import { HOMEWORLD_CARDS } from './homeworld-cards';
+import { gameTerritories, MOBILE_LOCATION, splitLocation } from './board';
+import {
+ HomeworldCustodyError,
+ type HomeworldCustody,
+} from './homeworld-custody';
+import { quoteGuildHomeworldShipment } from './guild-homeworld-shipment';
+import type { HomeworldShipmentSources } from './homeworld-shipment-options';
+
+/** The quote uses public physical counters, never opposing private resources. */
+export function guildHomeworldShipmentChoice(
+ view: GameView,
+ destination: string,
+ sources: HomeworldShipmentSources,
+ allyPayment = 0,
+) {
+ const result: {
+ blocked: string | null;
+ cost: number;
+ amount: number;
+ elite: number;
+ ownPayment: number;
+ allyPayment: number;
+ action: Action | null;
+ } = {
+ blocked: null,
+ cost: 0,
+ amount: 0,
+ elite: 0,
+ ownPayment: 0,
+ allyPayment,
+ action: null,
+ };
+ const me = view.players.find((player) => player.id === view.me);
+ const option = view.guildHomeworldShipment;
+ const worlds = view.homeworlds?.worlds;
+ if (
+ !me ||
+ me.faction !== 'guild' ||
+ !option ||
+ !worlds?.length ||
+ view.status !== 'playing' ||
+ view.phase !== 5 ||
+ view.active !== me.id ||
+ me.shipped
+ ) {
+ result.blocked = 'Use Guild transport during your unused shipment turn.';
+ return result;
+ }
+ if (option.blocked) {
+ result.blocked = option.blocked;
+ return result;
+ }
+ const custody: HomeworldCustody = { salusa: null, visitors: {} };
+ for (const world of worlds) {
+ if (world.secondary) custody.salusa = { ...world.forces[world.native] };
+ const visitors = Object.entries(world.forces).filter(
+ ([owner]) => owner !== world.native,
+ );
+ if (visitors.length)
+ custody.visitors[world.id] = Object.fromEntries(
+ visitors.map(([owner, forces]) => [owner, { ...forces }]),
+ );
+ }
+ try {
+ const quote = quoteGuildHomeworldShipment(
+ {
+ advanced: view.advanced,
+ storm: view.storm,
+ mobileStronghold: view.mobileStronghold?.location ?? null,
+ players: view.players.map((player) => ({
+ id: player.id,
+ faction: player.faction,
+ reserves: player.reserves,
+ eliteReserves: player.elites?.reserves ?? 0,
+ ally: player.ally ?? null,
+ })),
+ // Only the acting Guild map is needed. Concealed rival counters never enter.
+ board: {
+ [me.id]: { forces: me.forces, eliteForces: me.elites?.forces ?? {} },
+ },
+ },
+ custody,
+ { player: me.id, destination, sources },
+ );
+ result.cost = quote.cost;
+ result.amount = quote.amount;
+ result.elite = quote.elite;
+ result.ownPayment = quote.cost - allyPayment;
+ if (
+ !Number.isSafeInteger(allyPayment) ||
+ allyPayment < 0 ||
+ allyPayment > quote.cost ||
+ allyPayment > (me.ally ? view.aid.available : 0)
+ )
+ result.blocked =
+ 'Choose an allied contribution within the current pledge and shipment price.';
+ else if (result.ownPayment > (me.spice ?? 0))
+ result.blocked =
+ 'Your spice and selected allied contribution cannot pay this shipment.';
+ else
+ result.action = {
+ type: 'guildHomeworldShip',
+ event: option.event,
+ destination,
+ sources,
+ allyPayment,
+ };
+ } catch (error) {
+ if (!(error instanceof HomeworldCustodyError)) throw error;
+ result.blocked = error.message;
+ }
+ return result;
+}
+
+/** Gather actual Guild counters by one Arrakis territory, retaining sectors. */
+export function guildHomeworldShipmentOrigins(view: GameView) {
+ const me = view.players.find((player) => player.id === view.me);
+ if (!me || me.faction !== 'guild') return [];
+ return gameTerritories(view).flatMap((territory) => {
+ const pools = Object.entries(me.forces).flatMap(([key, amount]) => {
+ if (splitLocation(key).territory !== territory.id || amount <= 0)
+ return [];
+ const elite = me.elites?.forces[key] ?? 0;
+ return [
+ {
+ key,
+ sector: key === MOBILE_LOCATION ? null : splitLocation(key).sector,
+ normal: amount - elite,
+ elite,
+ },
+ ];
+ });
+ return pools.length ? [{ territory, pools }] : [];
+ });
+}
+
+/** Return exposed desert armies to a depleted Junction or reinforce attainable
+ * invasions. Keep stronghold defenders and spice collectors on Arrakis. */
+export function guildHomeworldShipmentActions(
+ view: GameView,
+ level: number,
+): Action[] {
+ const me = view.players.find((player) => player.id === view.me);
+ if (
+ !me ||
+ me.faction !== 'guild' ||
+ !view.guildHomeworldShipment ||
+ view.guildHomeworldShipment.blocked ||
+ me.shipped ||
+ view.phase !== 5 ||
+ view.active !== me.id
+ )
+ return [];
+ const difficulty = Math.max(0, Math.min(3, level));
+ const budget = Math.max(
+ 0,
+ (me.spice ?? 0) +
+ (me.ally ? view.aid.available : 0) -
+ [2, 3, 3, 2][difficulty],
+ );
+ const origins = guildHomeworldShipmentOrigins(view);
+ const actions: Action[] = [];
+ const targets = (view.homeworlds?.worlds ?? [])
+ .filter((world) => world.native !== me.ally)
+ .map((world) => {
+ const enemy = Object.entries(world.forces)
+ .filter(([id]) => id !== me.id && id !== me.ally)
+ .reduce(
+ (sum, [, pool]) =>
+ sum + pool.normal + pool.elite * (view.advanced ? 2 : 1),
+ 0,
+ );
+ const native = world.forces[world.native];
+ const strength =
+ enemy +
+ (world.native !== me.id && native.normal + native.elite > 0
+ ? world.nativeBattleStrength
+ : 0);
+ const own = world.forces[me.id];
+ const existing = (own?.normal ?? 0) + (own?.elite ?? 0);
+ return { world, strength, existing };
+ })
+ .sort((a, b) =>
+ a.world.native === me.id
+ ? -1
+ : b.world.native === me.id
+ ? 1
+ : a.strength - b.strength,
+ );
+ for (const target of targets) {
+ if (
+ target.world.native === me.id &&
+ target.world.side === 'high' &&
+ target.strength === 0
+ )
+ continue;
+ for (const origin of origins) {
+ const eligible = origin.pools.filter(
+ (pool) => pool.sector !== view.storm,
+ );
+ const total = eligible.reduce(
+ (sum, pool) => sum + pool.normal + pool.elite,
+ 0,
+ );
+ const retain =
+ origin.territory.type === 'stronghold'
+ ? [2, 3, 4, 4][difficulty]
+ : (view.spice[origin.territory.id] ?? 0) > 0
+ ? Math.min(total, Math.ceil(view.spice[origin.territory.id] / 2))
+ : 0;
+ const desired =
+ target.world.native === me.id
+ ? Math.max(
+ 1,
+ (HOMEWORLD_CARDS.find((card) => card.id === target.world.card)
+ ?.high.reserves.min ?? 5) - target.existing,
+ )
+ : Math.max(
+ [1, 3, 4, 5][difficulty],
+ target.strength + [0, 1, 2, 3][difficulty] - target.existing,
+ );
+ const amount = Math.min(
+ total - retain,
+ budget * 2,
+ [3, 5, 7, 9][difficulty],
+ desired,
+ );
+ if (
+ amount <= 0 ||
+ (target.world.native !== me.id &&
+ amount + target.existing < target.strength + (difficulty > 0 ? 1 : 0))
+ )
+ continue;
+ let remaining = amount;
+ const sources: HomeworldShipmentSources = {};
+ for (const pool of eligible) {
+ const count = Math.min(remaining, pool.normal + pool.elite);
+ if (!count) continue;
+ sources[pool.key] = { normal: count, elite: 0 };
+ remaining -= count;
+ }
+ const initial = guildHomeworldShipmentChoice(
+ view,
+ target.world.id,
+ sources,
+ );
+ const quote = guildHomeworldShipmentChoice(
+ view,
+ target.world.id,
+ sources,
+ Math.max(0, initial.cost - (me.spice ?? 0)),
+ );
+ if (quote.action) actions.push(quote.action);
+ }
+ }
+ return actions;
+}
diff --git a/game/guild-homeworld-shipment.ts b/game/guild-homeworld-shipment.ts
new file mode 100644
index 00000000..17eb2252
--- /dev/null
+++ b/game/guild-homeworld-shipment.ts
@@ -0,0 +1,198 @@
+import { MOBILE_LOCATION, splitLocation, validLocation } from './board';
+import {
+ HomeworldCustodyError,
+ homeworldForceGroups,
+ quoteHomeworldCustody,
+ type HomeworldCustody,
+ type HomeworldForces,
+} from './homeworld-custody';
+import type { HomeworldShipmentContext } from './homeworld-shipment';
+
+export type GuildHomeworldShipmentContext = HomeworldShipmentContext & {
+ storm: number;
+ board: Record<
+ string,
+ { forces: Record; eliteForces: Record }
+ >;
+ /** The mobile stronghold's ordinary Arrakis pointer, not its interior. */
+ mobileStronghold?: string | null;
+};
+export type GuildHomeworldShipmentIntent = {
+ player: string;
+ destination: string;
+ sources: Record;
+};
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+const whole = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
+function requireShipment(
+ condition: unknown,
+ message: string,
+): asserts condition {
+ if (!condition) throw new HomeworldCustodyError(message);
+}
+function canonicalSector(key: string) {
+ const { territory, sector } = splitLocation(key);
+ return key === `${territory}:${sector}` && validLocation(territory, sector);
+}
+
+/** Guild's own special shipment from one Arrakis territory to a Homeworld.
+ * The caller owns timing, payment, alliance funding, responses and arrival
+ * effects. No private card, concealed-force value or transport usage is read.
+ * The mobile stronghold's interior remains protected when its pointer is in
+ * storm. A Homeworld destination never becomes a fabricated Arrakis sector. */
+export function quoteGuildHomeworldShipment(
+ context: GuildHomeworldShipmentContext,
+ custody: HomeworldCustody,
+ intent: GuildHomeworldShipmentIntent,
+) {
+ const worlds = homeworldForceGroups(context, custody);
+ requireShipment(
+ whole(context.storm) && context.storm <= 18 && record(context.board),
+ 'Guild shipment needs the current board and storm sector.',
+ );
+ requireShipment(
+ context.players.every(
+ (p) =>
+ p.ally === null ||
+ (typeof p.ally === 'string' &&
+ p.ally !== p.id &&
+ context.players.some((other) => other.id === p.ally)),
+ ),
+ 'Homeworld shipment needs valid seated alliance identities.',
+ );
+ requireShipment(
+ record(intent) &&
+ Object.keys(intent).length === 3 &&
+ typeof intent.player === 'string' &&
+ typeof intent.destination === 'string' &&
+ record(intent.sources),
+ 'Choose the Guild, a destination Homeworld and typed Arrakis source groups.',
+ );
+ const player = context.players.find((p) => p.id === intent.player);
+ const destination = worlds.find((w) => w.id === intent.destination);
+ requireShipment(
+ player?.faction === 'guild' && destination,
+ 'Only the Guild may ship from Arrakis to an active Homeworld.',
+ );
+ const native = context.players.find((p) => p.id === destination.native)!;
+ requireShipment(
+ !(player.ally === native.id && native.ally === player.id),
+ 'You cannot ship to your ally’s Homeworld.',
+ );
+ const board = Object.hasOwn(context.board, player.id)
+ ? context.board[player.id]
+ : undefined;
+ requireShipment(
+ record(board) && record(board.forces) && record(board.eliteForces),
+ 'Guild shipment needs its current physical board counters.',
+ );
+ const pointer = context.mobileStronghold;
+ requireShipment(
+ pointer === undefined ||
+ pointer === null ||
+ (typeof pointer === 'string' &&
+ canonicalSector(pointer) &&
+ pointer !== MOBILE_LOCATION),
+ 'The mobile stronghold needs a canonical Arrakis pointer.',
+ );
+ for (const [key, amount] of Object.entries(board.forces))
+ requireShipment(
+ canonicalSector(key) &&
+ whole(amount) &&
+ amount <= 20 &&
+ (key !== MOBILE_LOCATION || !!pointer),
+ 'Guild shipment needs valid physical force locations and counts.',
+ );
+ for (const [key, amount] of Object.entries(board.eliteForces))
+ requireShipment(
+ canonicalSector(key) && whole(amount) && amount === 0,
+ 'Guild counters cannot acquire special force identities.',
+ );
+ const boardTotal = Object.values(board.forces).reduce((sum, n) => sum + n, 0);
+ const worldTotal = worlds.reduce(
+ (sum, w) =>
+ sum +
+ (w.forces[player.id]?.normal ?? 0) +
+ (w.forces[player.id]?.elite ?? 0),
+ 0,
+ );
+ requireShipment(
+ whole(boardTotal) && boardTotal + worldTotal <= 20,
+ 'Guild board and Homeworld custody exceed its twenty physical counters.',
+ );
+ const entries = Object.entries(intent.sources);
+ requireShipment(
+ entries.length > 0,
+ 'Choose forces from one Arrakis territory.',
+ );
+ let origin: string | undefined;
+ let amount = 0;
+ const boardForces = { ...board.forces };
+ const boardEliteForces = { ...board.eliteForces };
+ const boardSources: {
+ key: string;
+ before: HomeworldForces;
+ after: HomeworldForces;
+ }[] = [];
+ for (const [key, selected] of entries) {
+ requireShipment(
+ canonicalSector(key) && (key !== MOBILE_LOCATION || !!pointer),
+ 'Choose a canonical, active Arrakis source sector.',
+ );
+ const source = splitLocation(key);
+ requireShipment(source.sector !== context.storm, 'The source is in storm.');
+ requireShipment(
+ origin === undefined || origin === source.territory,
+ 'A Guild force group must come from one Arrakis territory.',
+ );
+ origin = source.territory;
+ requireShipment(
+ record(selected) &&
+ Object.keys(selected).length === 2 &&
+ whole(selected.normal) &&
+ whole(selected.elite) &&
+ selected.normal > 0 &&
+ selected.elite === 0 &&
+ selected.normal <= 20,
+ 'Each source needs a positive physical Guild counter group.',
+ );
+ const before = {
+ normal: Object.hasOwn(board.forces, key) ? board.forces[key] : 0,
+ elite: 0,
+ };
+ requireShipment(
+ selected.normal <= before.normal,
+ 'The selected Arrakis sector does not hold those physical counters.',
+ );
+ const after = { normal: before.normal - selected.normal, elite: 0 };
+ amount += selected.normal;
+ if (after.normal) boardForces[key] = after.normal;
+ else delete boardForces[key];
+ delete boardEliteForces[key];
+ boardSources.push({ key, before, after });
+ }
+ requireShipment(
+ amount <= 20,
+ 'A shipment cannot exceed twenty physical counters.',
+ );
+ const transferred = quoteHomeworldCustody(context, custody, [
+ {
+ homeworld: destination.id,
+ player: player.id,
+ withdraw: { normal: 0, elite: 0 },
+ deposit: { normal: amount, elite: 0 },
+ },
+ ]);
+ return {
+ ...transferred,
+ amount,
+ elite: 0,
+ cost: Math.ceil(amount / 2),
+ origin: origin!,
+ boardSources,
+ boardForces,
+ boardEliteForces,
+ };
+}
diff --git a/game/homeworld-alliance.ts b/game/homeworld-alliance.ts
new file mode 100644
index 00000000..638411d7
--- /dev/null
+++ b/game/homeworld-alliance.ts
@@ -0,0 +1,17 @@
+import { homeworldForceGroups, type HomeworldCustody, type HomeworldCustodyContext } from './homeworld-custody';
+
+/** E3 alliance restriction uses present foreign forces, independently of any
+ * saved turn entitlement to an occupied card's benefits. */
+export function homeworldAllianceBlock(
+ context: HomeworldCustodyContext,
+ custody: HomeworldCustody,
+ first: string,
+ second: string,
+): string | null {
+ const occupied = homeworldForceGroups(context, custody).some((world) => {
+ const visitor = world.native === first ? second : world.native === second ? first : null;
+ const forces = visitor ? world.forces[visitor] : undefined;
+ return !!forces && forces.normal + forces.elite > 0;
+ });
+ return occupied ? 'An alliance cannot form while either faction has forces on the other’s Homeworld.' : null;
+}
diff --git a/game/homeworld-arrival.ts b/game/homeworld-arrival.ts
new file mode 100644
index 00000000..f1e70298
--- /dev/null
+++ b/game/homeworld-arrival.ts
@@ -0,0 +1,211 @@
+import type { Game } from './engine';
+import { location, validLocation } from './board';
+import { HomeworldCustodyError } from './homeworld-custody';
+
+export type HomeworldAmbassadorArrival = {
+ event: string;
+ entrant: string;
+ destination: string;
+ parent?: string;
+ completed: boolean;
+};
+export type HomeworldArrivalFrame = {
+ event: string;
+ turn: number;
+ phase: number;
+ player: string;
+ stage: 'waiting' | 'choice' | 'arrival' | 'complete';
+ signature: string;
+ destination?: string;
+ ambassadors?: HomeworldAmbassadorArrival[];
+ arrivalSignature?: string;
+};
+export type HomeworldArrivalTag = 'revivalEvent' | 'victoryEvent';
+export type HomeworldArrivalContext = Pick<
+ Game,
+ 'turn' | 'phase' | 'players' | 'pendingAmbassador'
+>;
+function fail(): never {
+ throw new HomeworldCustodyError(
+ 'The saved Homeworld arrival has lost its original Ambassador chain or completion proof.',
+ );
+}
+function text(value: unknown): value is string {
+ return typeof value === 'string' && value.length > 0;
+}
+function keys(value: unknown, allowed: string[]): boolean {
+ return (
+ !!value &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ Object.keys(value).every((key) => allowed.includes(key))
+ );
+}
+
+/** Arrival provenance is fixed separately from the original revival grant. */
+export function homeworldArrivalSignature(
+ frame: HomeworldArrivalFrame,
+): string {
+ if (!text(frame.destination) || !Array.isArray(frame.ambassadors)) fail();
+ const seen = new Set();
+ for (const [index, entry] of frame.ambassadors.entries()) {
+ if (
+ !keys(entry, [
+ 'event',
+ 'entrant',
+ 'destination',
+ 'parent',
+ 'completed',
+ ]) ||
+ !text(entry.event) ||
+ !text(entry.entrant) ||
+ !text(entry.destination) ||
+ typeof entry.completed !== 'boolean' ||
+ (index < frame.ambassadors.length - 1 && !entry.completed) ||
+ seen.has(entry.event) ||
+ (index === 0
+ ? entry.parent !== undefined ||
+ entry.entrant !== frame.player ||
+ entry.destination !== frame.destination
+ : !text(entry.parent) || !seen.has(entry.parent))
+ )
+ fail();
+ seen.add(entry.event);
+ }
+ if (frame.destination === 'decline' && frame.ambassadors.length !== 0) fail();
+ return JSON.stringify([
+ // Preserve the existing persisted signature format; frame.signature binds its source.
+ 'homeworldRevivalArrival',
+ frame.signature,
+ frame.destination,
+ frame.ambassadors.map((entry) => [
+ entry.event,
+ entry.entrant,
+ entry.destination,
+ entry.parent ?? null,
+ entry.completed,
+ ]),
+ ]);
+}
+
+/** Only an actual Ambassador opening may append to this linear arrival chain. */
+export function appendHomeworldArrivalAmbassador(
+ frame: HomeworldArrivalFrame,
+ entry: {
+ event: string;
+ entrant: string;
+ territory: string;
+ sector: number;
+ turn: number;
+ phase: number;
+ },
+ parent?: string,
+): void {
+ if (
+ frame.stage !== 'arrival' ||
+ frame.turn !== entry.turn ||
+ frame.phase !== entry.phase ||
+ !validLocation(entry.territory, entry.sector) ||
+ frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
+ (parent !== undefined && parent !== frame.ambassadors!.at(-1)?.event)
+ )
+ fail();
+ const ambassadors = [
+ ...frame.ambassadors!,
+ {
+ event: entry.event,
+ entrant: entry.entrant,
+ destination: location(entry.territory, entry.sector),
+ completed: false,
+ ...(parent === undefined ? {} : { parent }),
+ },
+ ];
+ const arrivalSignature = homeworldArrivalSignature({
+ ...frame,
+ ambassadors,
+ });
+ frame.ambassadors = ambassadors;
+ frame.arrivalSignature = arrivalSignature;
+}
+
+/** Record actual completion before removing its live Ambassador control. */
+export function completeHomeworldArrivalAmbassador(
+ frame: HomeworldArrivalFrame,
+ event: string,
+): void {
+ const current = frame.ambassadors?.at(-1);
+ if (
+ frame.stage !== 'arrival' ||
+ frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
+ !current ||
+ current.event !== event ||
+ current.completed
+ )
+ fail();
+ const ambassadors = frame.ambassadors!.map((record) =>
+ record === current ? { ...record, completed: true } : record,
+ );
+ const arrivalSignature = homeworldArrivalSignature({
+ ...frame,
+ ambassadors,
+ });
+ frame.ambassadors = ambassadors;
+ frame.arrivalSignature = arrivalSignature;
+}
+
+/** Validate only the completed transfer's arrival chain. Original entitlement,
+ * winner/revival evidence and their independent obligations stay with callers. */
+export function validateHomeworldArrival(
+ g: HomeworldArrivalContext,
+ frame: HomeworldArrivalFrame,
+ tag: HomeworldArrivalTag,
+): void {
+ if (
+ !['revivalEvent', 'victoryEvent'].includes(tag) ||
+ !frame ||
+ !['arrival', 'complete'].includes(frame.stage) ||
+ !text(frame.event) ||
+ !text(frame.signature) ||
+ !Number.isSafeInteger(frame.turn) ||
+ frame.turn < 1 ||
+ frame.turn > g.turn ||
+ !Number.isSafeInteger(frame.phase) ||
+ frame.phase < 0 ||
+ frame.phase > 8 ||
+ (frame.stage === 'arrival' &&
+ (frame.turn !== g.turn || frame.phase !== g.phase)) ||
+ frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
+ frame.ambassadors!.some(
+ (entry, index) =>
+ !g.players.some(
+ (seat) =>
+ seat.id === entry.entrant &&
+ (index === 0 || seat.faction === 'beneGesserit'),
+ ),
+ )
+ )
+ fail();
+ const unfinished = frame.ambassadors!.find((entry) => !entry.completed);
+ const current = g.pendingAmbassador;
+ const otherTag = tag === 'revivalEvent' ? 'victoryEvent' : 'revivalEvent';
+ if (
+ unfinished &&
+ (frame.stage !== 'arrival' ||
+ !current ||
+ current[tag] !== frame.event ||
+ current[otherTag] !== undefined ||
+ current.event !== unfinished.event ||
+ current.entrant !== unfinished.entrant ||
+ location(current.territory, current.sector) !== unfinished.destination ||
+ current.turn !== frame.turn ||
+ current.phase !== frame.phase)
+ )
+ fail();
+ if (
+ current &&
+ frame.ambassadors!.some(
+ (entry) => entry.event === current.event && entry.completed,
+ )
+ )
+ fail();
+}
diff --git a/game/homeworld-battle-rules.ts b/game/homeworld-battle-rules.ts
new file mode 100644
index 00000000..f07c2665
--- /dev/null
+++ b/game/homeworld-battle-rules.ts
@@ -0,0 +1,65 @@
+import { HOMEWORLD_CARDS, type HomeworldId } from './homeworld-cards';
+import type { FactionId } from './catalog';
+import type { HomeworldForces } from './homeworld-custody';
+
+/** The location adapter supplies the current face and exact native army.
+ * Occupation timing belongs to that adapter, not to the battle calculator. */
+export type HomeworldBattleRules = {
+ native: string;
+ card: HomeworldId;
+ side: 'high' | 'low';
+ nativeForces: HomeworldForces;
+};
+export type HomeworldExplosionLosses = {
+ player: string;
+ amount: number;
+ options: HomeworldForces[];
+};
+
+export function quoteHomeworldBattleRules(
+ location: string,
+ participants: readonly { id: string; faction: FactionId }[],
+ rules: HomeworldBattleRules,
+) {
+ const card = HOMEWORLD_CARDS.find((c) => c.id === rules?.card);
+ const native = participants.find((p) => p.id === rules?.native);
+ if (
+ !card ||
+ !native ||
+ native.faction !== card.faction ||
+ location !==
+ `homeworld:${card.faction}${card.id === 'salusa_secundus' ? ':salusa' : ''}` ||
+ !['high', 'low'].includes(rules.side) ||
+ !rules.nativeForces ||
+ typeof rules.nativeForces !== 'object' ||
+ Array.isArray(rules.nativeForces) ||
+ Object.keys(rules.nativeForces).length !== 2 ||
+ !['normal', 'elite'].every((key) =>
+ Object.hasOwn(rules.nativeForces, key),
+ ) ||
+ !Object.values(rules.nativeForces).every(
+ (n) => Number.isSafeInteger(n) && n >= 0,
+ ) ||
+ rules.nativeForces.normal + rules.nativeForces.elite > 20
+ )
+ throw new Error(
+ 'The Homeworld battle needs its seated native, canonical card face and physical native army.',
+ );
+ const strength = card[rules.side].battleStrength;
+ const amount = Math.min(
+ strength,
+ rules.nativeForces.normal + rules.nativeForces.elite,
+ );
+ const options: HomeworldForces[] = [];
+ for (
+ let elite = Math.max(0, amount - rules.nativeForces.normal);
+ elite <= Math.min(amount, rules.nativeForces.elite);
+ elite++
+ )
+ options.push({ normal: amount - elite, elite });
+ return {
+ native: native.id,
+ strength,
+ explosion: { player: native.id, amount, options },
+ };
+}
diff --git a/game/homeworld-benefits.ts b/game/homeworld-benefits.ts
new file mode 100644
index 00000000..9981d7f2
--- /dev/null
+++ b/game/homeworld-benefits.ts
@@ -0,0 +1,166 @@
+import type { Game } from './engine';
+import { homeworldContext } from './homeworld-game';
+import { homeworldPopulations } from './homeworld-population';
+
+type HomeworldBenefitContext = Pick<
+ Game,
+ 'advanced' | 'players' | 'homeworlds'
+>;
+export type HomeworldRevivalOpening = {
+ turn: number;
+ tleilaxu: { player: string; low: boolean };
+};
+export class HomeworldBenefitError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'HomeworldBenefitError';
+ }
+}
+
+/** Current native population only. The caller owns benefit timing and all
+ * ordinary eligibility; Salusa never supplies another low-population bonus. */
+export function homeworldLowBonus(
+ context: HomeworldBenefitContext,
+ playerId: string,
+): 0 | 1 {
+ if (!context.homeworlds?.custody) return 0;
+ return homeworldPopulations(
+ homeworldContext(context),
+ context.homeworlds.custody,
+ ).some(
+ (home) =>
+ home.native === playerId &&
+ home.card !== 'salusa_secundus' &&
+ home.side === 'low',
+ )
+ ? 1
+ : 0;
+}
+
+/** Keep the unresolved special-Karama overlap consistent in validation and
+ * the public controls. This is a release boundary, not a rules exception. */
+export function homeworldRevivalKaramaBlock(
+ context: HomeworldBenefitContext,
+ target: string,
+): string | null {
+ return homeworldLowBonus(context, target)
+ ? 'Special Karama against a low-population Homeworld awaits the revival-overlap ruling.'
+ : null;
+}
+
+/** Salusa's threshold counts Sardaukar at Salusa, not all Imperial reserves.
+ * Its high advantage affects the faction's Sardaukar wherever they fight. */
+export function homeworldSardaukarFreeSupport(
+ context: HomeworldBenefitContext,
+ playerId: string,
+): boolean {
+ if (!context.advanced || !context.homeworlds?.custody) return false;
+ if (
+ !context.players.some((p) => p.id === playerId && p.faction === 'emperor')
+ )
+ return false;
+ return homeworldPopulations(
+ homeworldContext(context),
+ context.homeworlds.custody,
+ ).some(
+ (home) =>
+ home.native === playerId &&
+ home.card === 'salusa_secundus' &&
+ home.side === 'high',
+ );
+}
+
+/** Temporary release boundary: a population-changing return during combat
+ * must not invalidate sealed dials/support or earlier binding answers. Use
+ * public battle participation, never an opponent's hidden plan contents. */
+export function homeworldSardaukarGholaBlock(
+ context: HomeworldBenefitContext & Pick,
+ playerId: string,
+): string | null {
+ if (
+ !context.advanced ||
+ !context.homeworlds?.custody ||
+ !context.battle ||
+ ![context.battle.attacker, context.battle.defender].includes(playerId) ||
+ !context.players.some((p) => p.id === playerId && p.faction === 'emperor')
+ )
+ return null;
+ return homeworldPopulations(
+ homeworldContext(context),
+ context.homeworlds.custody,
+ ).some(
+ (home) =>
+ home.native === playerId &&
+ home.card === 'salusa_secundus' &&
+ home.population === 1,
+ )
+ ? 'Ghola raising Salusa to high population during the Emperor’s battle awaits its battle-support timing ruling.'
+ : null;
+}
+
+const validTurn = (turn: unknown): turn is number =>
+ typeof turn === 'number' && Number.isSafeInteger(turn) && turn > 0;
+
+/** Capture before any revival-opening effect can change native population.
+ * A saved receipt owns this phase's Tleilaxu free-revival income restriction. */
+export function snapshotHomeworldRevival(
+ context: HomeworldBenefitContext & Pick,
+): HomeworldRevivalOpening | null {
+ if (!context.homeworlds?.custody) return null;
+ const tleilaxu = context.players.find(
+ (player) => player.faction === 'tleilaxu',
+ );
+ if (!tleilaxu) return null;
+ if (!validTurn(context.turn))
+ throw new HomeworldBenefitError(
+ 'The Homeworld revival opening needs a valid current turn.',
+ );
+ return {
+ turn: context.turn,
+ tleilaxu: {
+ player: tleilaxu.id,
+ low: homeworldLowBonus(context, tleilaxu.id) === 1,
+ },
+ };
+}
+
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+
+/** Read only the saved opening decision. Never infer a missing receipt from
+ * current populations: earlier revivals may already have crossed a threshold.
+ * This answers only the free-revival income gate, not Ghola purchase income. */
+export function tleilaxuHomeworldFreeIncomeBlocked(
+ context: HomeworldBenefitContext &
+ Pick & {
+ homeworldRevival?: HomeworldRevivalOpening | null;
+ },
+): boolean {
+ if (context.phase !== 4 || !context.homeworlds) return false;
+ const tleilaxu = context.players.filter(
+ (player) => player.faction === 'tleilaxu',
+ );
+ if (!tleilaxu.length) return false;
+ const receipt: unknown = context.homeworldRevival;
+ if (
+ tleilaxu.length !== 1 ||
+ !validTurn(context.turn) ||
+ !record(receipt) ||
+ Object.keys(receipt).length !== 2 ||
+ !Object.hasOwn(receipt, 'turn') ||
+ !Object.hasOwn(receipt, 'tleilaxu') ||
+ receipt.turn !== context.turn ||
+ !record(receipt.tleilaxu) ||
+ Object.keys(receipt.tleilaxu).length !== 2 ||
+ !Object.hasOwn(receipt.tleilaxu, 'player') ||
+ !Object.hasOwn(receipt.tleilaxu, 'low') ||
+ typeof receipt.tleilaxu.player !== 'string' ||
+ !receipt.tleilaxu.player.trim() ||
+ receipt.tleilaxu.player !== tleilaxu[0].id ||
+ typeof receipt.tleilaxu.low !== 'boolean'
+ )
+ throw new HomeworldBenefitError(
+ 'The current Tleilaxu Homeworld revival-opening receipt is missing, stale or malformed.',
+ );
+ return receipt.tleilaxu.low;
+}
diff --git a/game/homeworld-card-economy.ts b/game/homeworld-card-economy.ts
new file mode 100644
index 00000000..07871068
--- /dev/null
+++ b/game/homeworld-card-economy.ts
@@ -0,0 +1,107 @@
+import type { Card } from './cards';
+import type { Game } from './engine';
+import type { HomeworldId } from './homeworld-cards';
+import { HomeworldCustodyError } from './homeworld-custody';
+import { homeworldContext } from './homeworld-game';
+import {
+ homeworldPopulations,
+ type HomeworldPopulation,
+} from './homeworld-population';
+
+export type HomeworldCardEconomyContext = Pick<
+ Game,
+ 'advanced' | 'players' | 'homeworlds'
+>;
+
+/** Current native physical population. Foreign visitors and the Emperor's
+ * separate Salusa allocation do not increase Kaitain's population. Occupation
+ * retention and entitlement are separate lifecycle rules, not inferred here. */
+export function homeworldCardEconomyPopulation(
+ context: HomeworldCardEconomyContext,
+ ownerId: string,
+ card: HomeworldId,
+): HomeworldPopulation | null {
+ if (!context.homeworlds?.custody) return null;
+ return (
+ homeworldPopulations(
+ homeworldContext(context),
+ context.homeworlds.custody,
+ ).find((home) => home.native === ownerId && home.card === card) ?? null
+ );
+}
+
+/** Population eligibility only; the engine owns the end-of-Bidding window. */
+export function highKaitainDiscardsAvailable(
+ context: HomeworldCardEconomyContext,
+ ownerId: string,
+): boolean {
+ return (
+ homeworldCardEconomyPopulation(context, ownerId, 'kaitain')?.side === 'high'
+ );
+}
+
+export type KaitainDiscardQuote = { cards: Card[]; cost: number };
+
+/** Pay from spice already held, then discard these physical cards without
+ * activating their effects. The caller performs payment and ordinary discard
+ * reactions. An empty selection is the eligible owner's optional decline. */
+export function quoteKaitainDiscards(
+ context: HomeworldCardEconomyContext,
+ ownerId: string,
+ selectedIds: unknown,
+): KaitainDiscardQuote {
+ if (!highKaitainDiscardsAvailable(context, ownerId))
+ throw new HomeworldCustodyError(
+ 'Only the Emperor with high-population Kaitain may pay to discard Treachery Cards.',
+ );
+ if (
+ !Array.isArray(selectedIds) ||
+ !selectedIds.every(
+ (id: unknown) => typeof id === 'string' && id.length > 0,
+ ) ||
+ new Set(selectedIds).size !== selectedIds.length
+ )
+ throw new HomeworldCustodyError(
+ 'Choose a list of unique Treachery Card identities.',
+ );
+ const owner = context.players.find((player) => player.id === ownerId)!;
+ const hand = owner.hand;
+ if (
+ !Array.isArray(hand) ||
+ !hand.every(
+ (card) => card && typeof card.id === 'string' && card.id.length > 0,
+ ) ||
+ new Set(hand.map((card) => card.id)).size !== hand.length
+ )
+ throw new HomeworldCustodyError(
+ 'The Emperor hand must contain unique physical Treachery Cards.',
+ );
+ const cards = selectedIds.map((id: string) => {
+ const card = hand.find((candidate) => candidate.id === id);
+ if (!card)
+ throw new HomeworldCustodyError(
+ 'Each selected Treachery Card must still be in the Emperor hand.',
+ );
+ return { ...card };
+ });
+ const cost = cards.length * 2;
+ if (!Number.isSafeInteger(owner.spice) || owner.spice < cost)
+ throw new HomeworldCustodyError(
+ 'Kaitain costs 2 spice per discarded card, paid from spice already held.',
+ );
+ return { cards, cost };
+}
+
+/** Restricts both ordinary Worthless sales and duplicate-Worthless sales.
+ * Non-Worthless duplicates, card trades and Advanced Karama are separate
+ * actions and are not restricted by this ordinary-sale quote. */
+export function homeworldWorthlessSaleBlock(
+ context: HomeworldCardEconomyContext,
+ ownerId: string,
+ card: Pick,
+): string | null {
+ return card.kind === 'worthless' &&
+ homeworldCardEconomyPopulation(context, ownerId, 'tupile')?.side === 'high'
+ ? 'High-population Tupile prevents discarding Worthless Cards for spice, including duplicate sales; Advanced Karama remains available under its own rules.'
+ : null;
+}
diff --git a/game/homeworld-cards.ts b/game/homeworld-cards.ts
new file mode 100644
index 00000000..5c62984e
--- /dev/null
+++ b/game/homeworld-cards.ts
@@ -0,0 +1,296 @@
+import type { FactionId } from './catalog';
+
+/** Original English E3 component facts. This catalog does not enable Homeworlds
+ * or execute threshold changes, payments, occupation or faction effects.
+ * Physical sources and discrepancies: docs/HOMEWORLD_COMPONENT_AUDIT.md. */
+export type HomeworldId =
+ | 'caladan'
+ | 'giedi_prime'
+ | 'southern_hemisphere'
+ | 'junction'
+ | 'wallach_ix'
+ | 'kaitain'
+ | 'salusa_secundus'
+ | 'ix'
+ | 'tleilax'
+ | 'tupile'
+ | 'richese'
+ | 'ecaz'
+ | 'grumman';
+export type HomeworldSide = Readonly<{
+ /** Inclusive range printed on this face; Salusa's printed ranges overlap. */
+ reserves: Readonly<{ min: number; max: number }>;
+ /** Native dial addition and native force losses in a Lasgun/Shield explosion. */
+ battleStrength: number;
+ gameplay: readonly string[];
+}>;
+export type HomeworldCard = Readonly<{
+ id: HomeworldId;
+ name: string;
+ faction: FactionId;
+ reserveType: 'faction' | 'sardaukar';
+ high: HomeworldSide;
+ low: HomeworldSide;
+ occupied: Readonly<{
+ gameplay: readonly string[];
+ /** One bank spice per printed icon during Spice Collection, additional to
+ * any payment-sharing effect described in the occupied text. */
+ spiceIcons: number;
+ }>;
+}>;
+const lowBonuses = [
+ 'Gain one additional free revival.',
+ 'When collecting CHOAM Charity, gain one extra spice directly from the Spice Bank.',
+] as const;
+function card(
+ id: HomeworldId,
+ name: string,
+ faction: FactionId,
+ minimum: number,
+ highStrength: number,
+ lowStrength: number,
+ high: string[],
+ low: string[],
+ occupied: string[],
+ spiceIcons: number,
+): HomeworldCard {
+ const sardaukar = id === 'salusa_secundus';
+ const side = (
+ min: number,
+ max: number,
+ battleStrength: number,
+ gameplay: readonly string[],
+ ): HomeworldSide =>
+ Object.freeze({
+ reserves: Object.freeze({ min, max }),
+ battleStrength,
+ gameplay: Object.freeze([...gameplay]),
+ });
+ return Object.freeze({
+ id,
+ name,
+ faction,
+ reserveType: sardaukar ? 'sardaukar' : 'faction',
+ high: side(minimum, sardaukar ? 5 : 20, highStrength, high),
+ low: side(
+ 0,
+ sardaukar ? 2 : minimum - 1,
+ lowStrength,
+ sardaukar ? low : [...low, ...lowBonuses],
+ ),
+ occupied: Object.freeze({
+ gameplay: Object.freeze([...occupied]),
+ spiceIcons,
+ }),
+ });
+}
+
+export const HOMEWORLD_CARDS: readonly HomeworldCard[] = Object.freeze([
+ card(
+ 'caladan',
+ 'Caladan',
+ 'atreides',
+ 6,
+ 2,
+ 2,
+ [
+ 'After winning a battle, you may add one reserve force to the territory or homeworld where it occurred, provided you already have a force there.',
+ ],
+ ['You cannot use your Movement advantage to inspect the next Spice Blow.'],
+ ['The occupier shares your Bidding advantage.'],
+ 2,
+ ),
+ card(
+ 'giedi_prime',
+ 'Giedi Prime',
+ 'harkonnen',
+ 7,
+ 2,
+ 2,
+ [
+ 'Collect 2 spice from the Spice Bank if you collect spice from any desert territories or homeworlds during this turn’s Spice Collection.',
+ ],
+ ['No additional penalty.'],
+ [
+ 'When you buy Treachery Cards, the occupier or their ally gains your extra free Treachery Card, provided the recipient’s hand is not full.',
+ ],
+ 2,
+ ),
+ card(
+ 'southern_hemisphere',
+ 'Southern Hemisphere',
+ 'fremen',
+ 3,
+ 2,
+ 2,
+ [
+ 'When a starred Fedaykin force is revived, you may immediately place it in any territory where you already have forces.',
+ ],
+ ['No additional penalty.'],
+ [
+ 'Give the occupier half of all spice you collect during Spice Collection, rounded down.',
+ ],
+ 2,
+ ),
+ card(
+ 'junction',
+ 'Junction',
+ 'guild',
+ 5,
+ 2,
+ 2,
+ [
+ 'You may offer other factions cross-planet shipment at half or full price as part of their Shipping action, including to or from homeworlds—even their own.',
+ ],
+ ['You receive only half of shipping payments, rounded up.'],
+ [
+ 'The occupier receives the other half of shipping payments made by other players, rounded down.',
+ ],
+ 2,
+ ),
+ card(
+ 'wallach_ix',
+ 'Wallach IX',
+ 'beneGesserit',
+ 11,
+ 3,
+ 2,
+ [
+ 'When another faction’s shipment lets you send spiritual advisors to Polar Sink, you may send two.',
+ ],
+ ['You cannot send spiritual advisors.'],
+ ['The occupier and their ally are immune to Voice.'],
+ 1,
+ ),
+ card(
+ 'kaitain',
+ 'Kaitain',
+ 'emperor',
+ 5,
+ 2,
+ 3,
+ [
+ 'At the end of Bidding, you may pay 2 spice per card to discard Treachery Cards from your hand.',
+ ],
+ ['You receive only half of Treachery Card payments, rounded up.'],
+ [
+ 'The occupier receives the other half of Treachery Card payments you receive, rounded down.',
+ ],
+ 2,
+ ),
+ card(
+ 'salusa_secundus',
+ 'Salusa Secundus',
+ 'emperor',
+ 2,
+ 3,
+ 2,
+ ['Your Sardaukar count at full strength without spice support.'],
+ ['Your Sardaukar cannot be revived for free.'],
+ ['Your Sardaukar lose their Sardaukar advantage.'],
+ 0,
+ ),
+ card(
+ 'ix',
+ 'Ix',
+ 'ixians',
+ 5,
+ 2,
+ 2,
+ ['When you pay to revive a Cyborg, you also receive two Suboids for free.'],
+ ['You cannot move the Hidden Mobile Stronghold.'],
+ ['The occupier controls your Bidding advantage.'],
+ 2,
+ ),
+ card(
+ 'tleilax',
+ 'Tleilax',
+ 'tleilaxu',
+ 9,
+ 2,
+ 2,
+ [
+ 'When taking Free Revival, you may send those forces from reserves for free to any one territory or homeworld, subject to storm and occupancy restrictions.',
+ ],
+ [
+ 'If you start the Revival phase at low threshold, you receive no spice when other factions take Free Revival.',
+ ],
+ ['You cannot reveal Face Dancers against the occupier or their ally.'],
+ 2,
+ ),
+ card(
+ 'tupile',
+ 'Tupile',
+ 'choam',
+ 11,
+ 2,
+ 2,
+ [
+ 'You cannot discard Worthless Cards for spice, except through Advanced Karama.',
+ ],
+ [
+ 'Once per faction, when you are on another homeworld or another faction is on Tupile, you may require that faction to disclose its spice and how many weapons or defenses it holds.',
+ ],
+ [
+ 'The occupier and their ally each gain one hand slot. When they no longer occupy Tupile, discard down to the normal hand limit. CHOAM loses its low-threshold advantage.',
+ ],
+ 2,
+ ),
+ card(
+ 'richese',
+ 'Richese',
+ 'richese',
+ 10,
+ 2,
+ 2,
+ ['You may ship reserve forces along with a No-Field token.'],
+ ['You cannot move a No-Field token.'],
+ [
+ 'The occupier chooses which Richese Treachery Card is sold during bidding and receives half of the payment you receive, rounded down.',
+ ],
+ 1,
+ ),
+ card(
+ 'ecaz',
+ 'Ecaz',
+ 'ecaz',
+ 7,
+ 2,
+ 2,
+ [
+ 'Whenever a poison weapon is discarded, collect 3 spice from the Spice Bank.',
+ 'Your alliance can win by jointly occupying at least one stronghold and occupying two other factions’ homeworlds.',
+ ],
+ [
+ 'When you and your ally are both in a battle against another faction, your opponent chooses which of you fights.',
+ ],
+ [
+ 'The occupier or their ally gains Duke Vidal and may revive him from the Tanks. This overrides the Ecaz and Moritani Duke Vidal advantage.',
+ ],
+ 2,
+ ),
+ card(
+ 'grumman',
+ 'Grumman',
+ 'moritani',
+ 8,
+ 2,
+ 2,
+ [
+ 'During Spice Collection, you may add a Terror token to a stronghold that already has one, or remove a Terror token from the board, then gain 4 spice from the Spice Bank.',
+ ],
+ [
+ 'You cannot reveal a Terror token unless at least three forces enter its territory.',
+ ],
+ [
+ 'You cannot reveal a Terror token when the occupier or their ally enters a territory containing one.',
+ ],
+ 2,
+ ),
+]);
+
+/** Unknown identities remain missing rather than silently selecting a faction's
+ * first card: Emperor has two distinct physical worlds. */
+export function homeworldCard(id: string): HomeworldCard | undefined {
+ return HOMEWORLD_CARDS.find((definition) => definition.id === id);
+}
diff --git a/game/homeworld-collection.ts b/game/homeworld-collection.ts
new file mode 100644
index 00000000..7686228c
--- /dev/null
+++ b/game/homeworld-collection.ts
@@ -0,0 +1,97 @@
+import type { Game } from './engine';
+import { HomeworldCustodyError } from './homeworld-custody';
+import { homeworldContext } from './homeworld-game';
+import { homeworldPopulations } from './homeworld-population';
+
+export type HomeworldCollectionContext = Pick<
+ Game,
+ 'advanced' | 'players' | 'homeworlds'
+>;
+
+export type HomeworldCollectionSource = {
+ kind: 'desert' | 'homeworld' | 'stronghold' | 'technology';
+ amount: number;
+};
+
+/** Source amounts are actual settled receipts, including an agreed Ecaz split,
+ * not capacity, presence, expected income or a change in the held balance.
+ * The engine owns event timing and the durable already-awarded receipt. */
+export function quoteGiediCollectionBonus(
+ context: HomeworldCollectionContext,
+ ownerId: string,
+ sources: HomeworldCollectionSource[],
+ alreadyAwarded: boolean,
+): { player: string; amount: 0 | 2; qualifying: number } {
+ const owner = context.players.find((player) => player.id === ownerId);
+ if (!owner || owner.faction !== 'harkonnen')
+ throw new HomeworldCustodyError(
+ 'Only Harkonnen can receive the native Giedi Prime collection bonus.',
+ );
+ if (typeof alreadyAwarded !== 'boolean' || !Array.isArray(sources))
+ throw new HomeworldCustodyError(
+ 'Giedi Prime needs settled collection sources and an award receipt.',
+ );
+ let total = 0;
+ let qualifying = 0;
+ for (const source of sources) {
+ if (
+ !source ||
+ typeof source !== 'object' ||
+ Array.isArray(source) ||
+ !['desert', 'homeworld', 'stronghold', 'technology'].includes(
+ source.kind,
+ ) ||
+ !Number.isSafeInteger(source.amount) ||
+ source.amount < 0
+ )
+ throw new HomeworldCustodyError(
+ 'Collection sources need a known kind and a nonnegative safe amount.',
+ );
+ total += source.amount;
+ if (!Number.isSafeInteger(total))
+ throw new HomeworldCustodyError('Settled collection sources overflow.');
+ if (source.kind === 'desert' || source.kind === 'homeworld')
+ qualifying += source.amount;
+ }
+ const high = context.homeworlds?.custody
+ ? homeworldPopulations(
+ homeworldContext(context),
+ context.homeworlds.custody,
+ ).some(
+ (home) =>
+ home.native === ownerId &&
+ home.card === 'giedi_prime' &&
+ home.side === 'high',
+ )
+ : false;
+ return {
+ player: ownerId,
+ amount: high && qualifying > 0 && !alreadyAwarded ? 2 : 0,
+ qualifying,
+ };
+}
+
+/** The caller supplies the verified original entering force count. This must
+ * not be fed a concealed No-Field size to infer a public reaction window.
+ * Occupation retention and No-Field composition are separate rules. */
+export function lowGrummanRevealBlock(
+ context: HomeworldCollectionContext,
+ ownerId: string,
+ entering: number,
+): string | null {
+ if (!Number.isSafeInteger(entering) || entering < 0)
+ throw new HomeworldCustodyError(
+ 'A Terror entry requires a nonnegative safe entering force count.',
+ );
+ if (!context.homeworlds?.custody) return null;
+ const low = homeworldPopulations(
+ homeworldContext(context),
+ context.homeworlds.custody,
+ ).some(
+ (home) =>
+ home.native === ownerId && home.card === 'grumman' && home.side === 'low',
+ );
+ return low && entering < 3
+ ? 'Low-population Grumman prevents revealing Terror unless at least three forces enter.'
+ : null;
+}
diff --git a/game/homeworld-combat-loss.ts b/game/homeworld-combat-loss.ts
new file mode 100644
index 00000000..ffac6abd
--- /dev/null
+++ b/game/homeworld-combat-loss.ts
@@ -0,0 +1,163 @@
+import {
+ HomeworldCustodyError,
+ homeworldForceGroups,
+ quoteHomeworldCustody,
+ type HomeworldCustody,
+ type HomeworldCustodyContext,
+ type HomeworldForces,
+ type HomeworldReserveSeat,
+} from './homeworld-custody';
+
+export type HomeworldCombatLossPlayer = HomeworldReserveSeat & {
+ tanks: number;
+ eliteTanks: number;
+ battleLosses: number;
+ /** Arrakis counters only; foreign Homeworld armies are held in custody. */
+ boardForces: HomeworldForces;
+};
+export type HomeworldCombatLossContext = Omit<
+ HomeworldCustodyContext,
+ 'players'
+> & {
+ players: readonly HomeworldCombatLossPlayer[];
+};
+export type HomeworldCombatLossRequest = {
+ location: string;
+ player: string;
+ losses: HomeworldForces;
+};
+export class HomeworldCombatLossError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'HomeworldCombatLossError';
+ }
+}
+function requireLoss(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new HomeworldCombatLossError(message);
+}
+const whole = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
+function typed(value: unknown): asserts value is HomeworldForces {
+ requireLoss(
+ !!value &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ Object.keys(value).length === 2 &&
+ Object.hasOwn(value, 'normal') &&
+ Object.hasOwn(value, 'elite') &&
+ whole((value as HomeworldForces).normal) &&
+ whole((value as HomeworldForces).elite) &&
+ (value as HomeworldForces).normal + (value as HomeworldForces).elite <=
+ 20,
+ 'Homeworld combat needs exact physical normal and special counter counts.',
+ );
+}
+function conserved(
+ context: HomeworldCombatLossContext,
+ custody: HomeworldCustody,
+) {
+ const groups = homeworldForceGroups(context, custody);
+ for (const player of context.players) {
+ typed(player.boardForces);
+ requireLoss(
+ whole(player.tanks) &&
+ player.tanks <= 20 &&
+ whole(player.eliteTanks) &&
+ player.eliteTanks <= player.tanks &&
+ whole(player.battleLosses),
+ 'Homeworld combat needs valid Tanks and battle-loss totals.',
+ );
+ const visitors = groups.reduce(
+ (sum, home) => {
+ const pool =
+ home.native !== player.id && Object.hasOwn(home.forces, player.id)
+ ? home.forces[player.id]
+ : { normal: 0, elite: 0 };
+ return {
+ normal: sum.normal + pool.normal,
+ elite: sum.elite + pool.elite,
+ };
+ },
+ { normal: 0, elite: 0 },
+ );
+ const supply =
+ player.faction === 'emperor'
+ ? 5
+ : player.faction === 'fremen'
+ ? 3
+ : player.faction === 'ixians'
+ ? 7
+ : 0;
+ requireLoss(
+ player.reserves +
+ player.tanks +
+ player.boardForces.normal +
+ player.boardForces.elite +
+ visitors.normal +
+ visitors.elite ===
+ 20 &&
+ player.eliteReserves +
+ player.eliteTanks +
+ player.boardForces.elite +
+ visitors.elite ===
+ supply,
+ 'Homeworld combat must conserve all 20 physical counters and their special identities.',
+ );
+ }
+}
+
+/** Detached physical settlement only. The battle resolver authorizes the exact
+ * casualty choice. Zero casualties are valid; printed strength never becomes
+ * a counter, and no Arrakis force keys, occupation effects or payments occur. */
+export function quoteHomeworldCombatLoss(
+ context: HomeworldCombatLossContext,
+ custody: HomeworldCustody,
+ request: HomeworldCombatLossRequest,
+) {
+ try {
+ conserved(context, custody);
+ requireLoss(
+ !!request && typeof request === 'object' && !Array.isArray(request),
+ 'Choose a Homeworld combat loss.',
+ );
+ typed(request.losses);
+ const quote = quoteHomeworldCustody(context, custody, [
+ {
+ homeworld: request.location,
+ player: request.player,
+ withdraw: request.losses,
+ deposit: { normal: 0, elite: 0 },
+ },
+ ]);
+ const total = request.losses.normal + request.losses.elite;
+ const players = context.players.map(
+ (player, index): HomeworldCombatLossPlayer => ({
+ ...quote.players[index],
+ tanks: player.tanks + (player.id === request.player ? total : 0),
+ eliteTanks:
+ player.eliteTanks +
+ (player.id === request.player ? request.losses.elite : 0),
+ battleLosses:
+ player.battleLosses + (player.id === request.player ? total : 0),
+ boardForces: { ...player.boardForces },
+ }),
+ );
+ conserved({ advanced: context.advanced, players }, quote.state);
+ const receipt = quote.receipts[0];
+ return {
+ custody: quote.state,
+ players,
+ receipt: {
+ location: receipt.homeworld,
+ player: receipt.player,
+ before: { ...receipt.before },
+ after: { ...receipt.after },
+ losses: { ...request.losses },
+ },
+ };
+ } catch (error) {
+ if (error instanceof HomeworldCustodyError)
+ throw new HomeworldCombatLossError(error.message);
+ throw error;
+ }
+}
diff --git a/game/homeworld-combat.ts b/game/homeworld-combat.ts
new file mode 100644
index 00000000..45aced77
--- /dev/null
+++ b/game/homeworld-combat.ts
@@ -0,0 +1,161 @@
+import { HOMEWORLD_CARDS, type HomeworldId } from './homeworld-cards';
+import {
+ homeworldForceGroups,
+ type HomeworldCustody,
+ type HomeworldCustodyContext,
+ type HomeworldForces,
+ type HomeworldReserveSeat,
+} from './homeworld-custody';
+import { homeworldPopulations } from './homeworld-population';
+
+export type HomeworldCombatContext = Omit<
+ HomeworldCustodyContext,
+ 'players'
+> & {
+ players: readonly (HomeworldReserveSeat & { ally?: string | null })[];
+ order: readonly string[];
+};
+export type HomeworldCombatLocation = {
+ id: string;
+ name: string;
+ native: string;
+ card: HomeworldId;
+ population: number;
+ side: 'high' | 'low';
+ nativeBattleStrength: number;
+ forces: Record;
+};
+export type HomeworldCombatArmy = HomeworldForces & {
+ player: string;
+ native: boolean;
+ nativeBattleStrength: number;
+};
+export type HomeworldCombatPair = {
+ territory: string;
+ attacker: string;
+ defender: string;
+};
+export class HomeworldCombatError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'HomeworldCombatError';
+ }
+}
+function requireCombat(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new HomeworldCombatError(message);
+}
+
+function locations(context: HomeworldCombatContext, custody: HomeworldCustody) {
+ // Custody validates seated identities and all typed pools before any lookup.
+ const groups = homeworldForceGroups(context, custody);
+ const seats = new Map(context.players.map((player) => [player.id, player]));
+ requireCombat(
+ Array.isArray(context.order) &&
+ context.order.length === seats.size &&
+ new Set(context.order).size === seats.size &&
+ context.order.every((id) => seats.has(id)),
+ 'Homeworld combat needs the current complete distinct player order.',
+ );
+ for (const player of context.players) {
+ const ally = player.ally;
+ requireCombat(
+ ally === undefined ||
+ ally === null ||
+ (typeof ally === 'string' &&
+ ally !== player.id &&
+ seats.has(ally) &&
+ seats.get(ally)!.ally === player.id),
+ 'Homeworld combat needs reciprocal alliances between distinct seated players.',
+ );
+ }
+ const populations = new Map(
+ homeworldPopulations(context, custody).map((home) => [home.location, home]),
+ );
+ return groups.map((home): HomeworldCombatLocation => {
+ const population = populations.get(home.id)!;
+ const card = HOMEWORLD_CARDS.find((card) => card.id === population.card)!;
+ return {
+ id: home.id,
+ name: card.name,
+ native: home.native,
+ card: population.card,
+ population: population.population,
+ side: population.side,
+ nativeBattleStrength: population.nativeBattleStrength,
+ forces: home.forces,
+ };
+ });
+}
+
+/** Canonical public location facts only. Presence does not award occupation,
+ * authorize an action, change a card face, or grant an Arrakis territory rule. */
+export function homeworldCombatLocation(
+ context: HomeworldCombatContext,
+ custody: HomeworldCustody,
+ location: string,
+): HomeworldCombatLocation {
+ const home = locations(context, custody).find((home) => home.id === location);
+ requireCombat(
+ home,
+ 'Choose a canonical Homeworld belonging to a seated faction.',
+ );
+ return home;
+}
+
+/** Exact physical pool; native strength is additional printed battle strength,
+ * not a conversion of special counters. Basic retains the same typed pieces. */
+export function homeworldCombatArmy(
+ context: HomeworldCombatContext,
+ custody: HomeworldCustody,
+ location: string,
+ player: string,
+): HomeworldCombatArmy {
+ const home = homeworldCombatLocation(context, custody, location);
+ requireCombat(
+ context.players.some((seat) => seat.id === player),
+ 'Choose a seated Homeworld combatant.',
+ );
+ const forces = Object.hasOwn(home.forces, player)
+ ? home.forces[player]
+ : { normal: 0, elite: 0 };
+ const native = player === home.native;
+ return {
+ player,
+ normal: forces.normal,
+ elite: forces.elite,
+ native,
+ nativeBattleStrength: native ? home.nativeBattleStrength : 0,
+ };
+}
+
+/** Ordinary attacker-order pairing over positive armies. Homeworlds have no
+ * storm sectors, stronghold capacity or advisor stance. An empty native pool
+ * creates no defender; two foreign armies can still require their own battle. */
+export function quoteHomeworldBattles(
+ context: HomeworldCombatContext,
+ custody: HomeworldCustody,
+): HomeworldCombatPair[] {
+ const homes = locations(context, custody);
+ const seats = new Map(context.players.map((player) => [player.id, player]));
+ const rank = new Map(context.order.map((id, index) => [id, index]));
+ const positive = (home: HomeworldCombatLocation, id: string) =>
+ Object.hasOwn(home.forces, id) &&
+ home.forces[id].normal + home.forces[id].elite > 0;
+ const battles: HomeworldCombatPair[] = [];
+ for (const id of context.order) {
+ const player = seats.get(id)!;
+ for (const home of homes) {
+ if (!positive(home, id)) continue;
+ for (const other of context.players) {
+ if (
+ rank.get(other.id)! <= rank.get(id)! ||
+ other.id === player.ally ||
+ !positive(home, other.id)
+ )
+ continue;
+ battles.push({ territory: home.id, attacker: id, defender: other.id });
+ }
+ }
+ }
+ return battles;
+}
diff --git a/game/homeworld-custody.ts b/game/homeworld-custody.ts
new file mode 100644
index 00000000..89c66adc
--- /dev/null
+++ b/game/homeworld-custody.ts
@@ -0,0 +1,319 @@
+import { FACTIONS, type FactionId } from './catalog';
+
+/** Typed physical counters. Special identity also matters in Basic Homeworlds;
+ * it does not by itself grant Advanced battle strength. */
+export type HomeworldForces = { normal: number; elite: number };
+export type HomeworldReserveSeat = {
+ id: string;
+ faction: FactionId;
+ reserves: number;
+ eliteReserves: number;
+};
+export type HomeworldCustodyContext = {
+ advanced: boolean;
+ players: readonly HomeworldReserveSeat[];
+};
+export type HomeworldLocation = {
+ id: string;
+ native: string;
+ secondary: boolean;
+};
+export type HomeworldCustody = {
+ /** Only foreign forces: native primary counts derive from the reserve totals. */
+ visitors: Record>;
+ /** Salusa allocation within Emperor's totals, not additional reserve forces. */
+ salusa: HomeworldForces | null;
+};
+export type HomeworldCustodyChange = {
+ homeworld: string;
+ player: string;
+ withdraw: HomeworldForces;
+ deposit: HomeworldForces;
+};
+export type HomeworldCustodyReceipt = {
+ homeworld: string;
+ player: string;
+ before: HomeworldForces;
+ after: HomeworldForces;
+};
+export class HomeworldCustodyError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'HomeworldCustodyError';
+ }
+}
+const empty = (): HomeworldForces => ({ normal: 0, elite: 0 });
+const validFaction = (value: unknown): value is FactionId =>
+ FACTIONS.some((f) => f.id === value);
+const eliteLimit = (faction: FactionId) =>
+ faction === 'emperor'
+ ? 5
+ : faction === 'fremen'
+ ? 3
+ : faction === 'ixians'
+ ? 7
+ : 0;
+const whole = (n: unknown): n is number =>
+ Number.isSafeInteger(n) && (n as number) >= 0;
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+const identifier = (value: unknown): value is string =>
+ typeof value === 'string' && value.trim().length > 0;
+function requireCustody(
+ condition: unknown,
+ message: string,
+): asserts condition {
+ if (!condition) throw new HomeworldCustodyError(message);
+}
+function group(value: unknown): asserts value is HomeworldForces {
+ requireCustody(
+ record(value) &&
+ Object.keys(value).length === 2 &&
+ whole(value.normal) &&
+ whole(value.elite) &&
+ value.normal + value.elite <= 20,
+ 'Homeworld forces need valid physical normal and special counter counts.',
+ );
+}
+function reserveSeats(
+ context: HomeworldCustodyContext,
+): HomeworldReserveSeat[] {
+ requireCustody(
+ record(context) &&
+ typeof context.advanced === 'boolean' &&
+ Array.isArray(context.players) &&
+ context.players.length >= 2 &&
+ context.players.length <= 6,
+ 'Homeworld custody needs a valid two-through-six-player configuration.',
+ );
+ const seats = context.players.map((p) => {
+ requireCustody(
+ record(p) &&
+ identifier(p.id) &&
+ validFaction(p.faction) &&
+ whole(p.reserves) &&
+ p.reserves <= 20 &&
+ whole(p.eliteReserves) &&
+ p.eliteReserves <= p.reserves &&
+ p.eliteReserves <= eliteLimit(p.faction) &&
+ (p.eliteReserves === 0 ||
+ ['emperor', 'fremen', 'ixians'].includes(p.faction)),
+ 'Homeworld reserves need valid seated identities and typed totals.',
+ );
+ return {
+ id: p.id,
+ faction: p.faction,
+ reserves: p.reserves,
+ eliteReserves: p.eliteReserves,
+ };
+ });
+ requireCustody(
+ new Set(seats.map((p) => p.id)).size === seats.length &&
+ new Set(seats.map((p) => p.faction)).size === seats.length,
+ 'Homeworld reserve seats and factions must be unique.',
+ );
+ return seats;
+}
+/** Stable software locations, deliberately outside the Arrakis sector registry. */
+export function homeworldLocations(
+ context: HomeworldCustodyContext,
+): HomeworldLocation[] {
+ return reserveSeats(context).flatMap((p) => [
+ { id: `homeworld:${p.faction}`, native: p.id, secondary: false },
+ ...(p.faction === 'emperor' && context.advanced
+ ? [{ id: 'homeworld:emperor:salusa', native: p.id, secondary: true }]
+ : []),
+ ]);
+}
+function nativeForces(
+ seat: HomeworldReserveSeat,
+ home: HomeworldLocation,
+ state: HomeworldCustody,
+): HomeworldForces {
+ if (home.secondary) return { ...state.salusa! };
+ const split = seat.faction === 'emperor' ? state.salusa : null;
+ return {
+ normal: seat.reserves - seat.eliteReserves - (split?.normal ?? 0),
+ elite: seat.eliteReserves - (split?.elite ?? 0),
+ };
+}
+function validate(context: HomeworldCustodyContext, state: HomeworldCustody) {
+ const seats = reserveSeats(context),
+ homes = homeworldLocations(context);
+ requireCustody(
+ record(state) && Object.keys(state).length === 2 && record(state.visitors),
+ 'The saved Homeworld custody record is invalid.',
+ );
+ const emperor = seats.find((p) => p.faction === 'emperor');
+ if (context.advanced && emperor) {
+ group(state.salusa);
+ requireCustody(
+ state.salusa.normal <= emperor.reserves - emperor.eliteReserves &&
+ state.salusa.elite <= emperor.eliteReserves,
+ 'Salusa forces must remain within Emperor’s current typed reserve totals.',
+ );
+ } else
+ requireCustody(
+ state.salusa === null,
+ 'Salusa is available only to an Advanced Emperor.',
+ );
+ const homeById = new Map(homes.map((h) => [h.id, h]));
+ const byId = new Map(seats.map((p) => [p.id, p]));
+ const away = new Map(seats.map((p) => [p.id, 0]));
+ const awayElites = new Map(seats.map((p) => [p.id, 0]));
+ for (const [id, visitors] of Object.entries(state.visitors)) {
+ const home = homeById.get(id);
+ requireCustody(
+ home && record(visitors),
+ 'Foreign forces require an active Homeworld location.',
+ );
+ for (const [player, forces] of Object.entries(visitors)) {
+ requireCustody(
+ byId.has(player) && player !== home.native,
+ 'Native forces belong to their reserve totals, not a foreign-force pool.',
+ );
+ group(forces);
+ away.set(player, away.get(player)! + forces.normal + forces.elite);
+ awayElites.set(player, awayElites.get(player)! + forces.elite);
+ }
+ }
+ for (const p of seats)
+ requireCustody(
+ p.reserves + away.get(p.id)! <= 20 &&
+ p.eliteReserves + awayElites.get(p.id)! <= eliteLimit(p.faction),
+ 'Native reserves and foreign Homeworld forces exceed physical counter custody.',
+ );
+ return { seats, homes, homeById, byId };
+}
+/** Setup boundary only: all initial Sardaukar go to Salusa in Advanced play. */
+export function createHomeworldCustody(
+ context: HomeworldCustodyContext,
+): HomeworldCustody {
+ const seats = reserveSeats(context),
+ emperor = seats.find((p) => p.faction === 'emperor');
+ return {
+ visitors: {},
+ salusa:
+ context.advanced && emperor
+ ? { normal: 0, elite: emperor.eliteReserves }
+ : null,
+ };
+}
+export function homeworldForceGroups(
+ context: HomeworldCustodyContext,
+ state: HomeworldCustody,
+) {
+ const { homes, byId } = validate(context, state);
+ return homes.map((home) => ({
+ ...home,
+ forces: Object.fromEntries([
+ [home.native, nativeForces(byId.get(home.native)!, home, state)],
+ ...Object.entries(state.visitors[home.id] ?? {}).map(([id, forces]) => [
+ id,
+ { ...forces },
+ ]),
+ ]) as Record,
+ }));
+}
+/** Custody transaction only. Callers must separately authorize shipment, revival,
+ * movement or casualties and account for the matching planet/Tanks deltas.
+ * Withdrawals use the source's pre-transaction pool; no silent Salusa clamping,
+ * replacement type, threshold effect, occupation grant, payment or RNG occurs. */
+export function quoteHomeworldCustody(
+ context: HomeworldCustodyContext,
+ state: HomeworldCustody,
+ changes: readonly HomeworldCustodyChange[],
+) {
+ const { seats, homeById, byId } = validate(context, state);
+ requireCustody(
+ Array.isArray(changes),
+ 'Homeworld custody changes must be a list.',
+ );
+ const totals = new Map();
+ for (const change of changes) {
+ requireCustody(
+ record(change) &&
+ identifier(change.homeworld) &&
+ identifier(change.player) &&
+ homeById.has(change.homeworld) &&
+ byId.has(change.player),
+ 'Choose an active Homeworld and a seated faction.',
+ );
+ group(change.withdraw);
+ group(change.deposit);
+ const key = JSON.stringify([change.homeworld, change.player]);
+ const total = totals.get(key) ?? {
+ homeworld: change.homeworld,
+ player: change.player,
+ withdraw: empty(),
+ deposit: empty(),
+ };
+ for (const kind of ['normal', 'elite'] as const) {
+ total.withdraw[kind] += change.withdraw[kind];
+ total.deposit[kind] += change.deposit[kind];
+ }
+ group(total.withdraw);
+ group(total.deposit);
+ totals.set(key, total);
+ }
+ const next: HomeworldCustody = {
+ salusa: state.salusa ? { ...state.salusa } : null,
+ visitors: Object.fromEntries(
+ Object.entries(state.visitors).map(([id, pools]) => [
+ id,
+ Object.fromEntries(
+ Object.entries(pools).map(([player, forces]) => [
+ player,
+ { ...forces },
+ ]),
+ ),
+ ]),
+ ),
+ };
+ const players = seats.map((p) => ({ ...p })),
+ updates = new Map(players.map((p) => [p.id, p]));
+ const receipts: HomeworldCustodyReceipt[] = [];
+ for (const change of totals.values()) {
+ const home = homeById.get(change.homeworld)!;
+ const native = home.native === change.player;
+ const before = native
+ ? nativeForces(byId.get(change.player)!, home, state)
+ : Object.hasOwn(state.visitors[home.id] ?? {}, change.player)
+ ? state.visitors[home.id][change.player]
+ : empty();
+ requireCustody(
+ change.withdraw.normal <= before.normal &&
+ change.withdraw.elite <= before.elite,
+ 'The selected Homeworld does not hold those physical counters.',
+ );
+ const after = {
+ normal: before.normal - change.withdraw.normal + change.deposit.normal,
+ elite: before.elite - change.withdraw.elite + change.deposit.elite,
+ };
+ group(after);
+ if (native) {
+ const player = updates.get(change.player)!;
+ player.reserves +=
+ after.normal + after.elite - before.normal - before.elite;
+ player.eliteReserves += after.elite - before.elite;
+ if (home.secondary) next.salusa = after;
+ } else if (after.normal + after.elite) {
+ next.visitors[home.id] = {
+ ...next.visitors[home.id],
+ [change.player]: after,
+ };
+ } else {
+ delete next.visitors[home.id]?.[change.player];
+ if (next.visitors[home.id] && !Object.keys(next.visitors[home.id]).length)
+ delete next.visitors[home.id];
+ }
+ receipts.push({
+ homeworld: home.id,
+ player: change.player,
+ before: { ...before },
+ after: { ...after },
+ });
+ }
+ validate({ advanced: context.advanced, players }, next);
+ return { players, state: next, receipts };
+}
diff --git a/game/homeworld-emperor-move.ts b/game/homeworld-emperor-move.ts
new file mode 100644
index 00000000..a3b8653d
--- /dev/null
+++ b/game/homeworld-emperor-move.ts
@@ -0,0 +1,137 @@
+import {
+ HomeworldCustodyError,
+ quoteHomeworldCustody,
+ type HomeworldCustody,
+ type HomeworldCustodyContext,
+ type HomeworldForces,
+} from './homeworld-custody';
+import { homeworldPopulations } from './homeworld-population';
+
+export type EmperorHomeworld = 'homeworld:emperor' | 'homeworld:emperor:salusa';
+export type EmperorHomeworldMoveContext = HomeworldCustodyContext & {
+ status: 'lobby' | 'setup' | 'playing' | 'finished';
+ phase: number;
+ currentPlayer: string | null;
+ /** Remaining complete movements, including a legally granted Hajr movement.
+ * The caller owns control/overlay eligibility and the authoritative budget. */
+ movesLeft: number;
+};
+export type EmperorHomeworldMove = {
+ origin: EmperorHomeworld;
+ forces: HomeworldForces;
+};
+export class EmperorHomeworldMoveError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'EmperorHomeworldMoveError';
+ }
+}
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+const whole = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
+function requireMove(condition: unknown, message: string): asserts condition {
+ if (!condition) throw new EmperorHomeworldMoveError(message);
+}
+
+/** E3 p.10 permits this transfer as Emperor's movement. It is Homeworld module
+ * permission, immune to Karama; no ordinary faction-cancellation flag is read.
+ * Hajr obeys own-turn movement timing (November 2020 FAQ p.8) and supplies an
+ * additional movement, not a separate once-per-turn transfer entitlement.
+ *
+ * This quote commits nothing: no shipment, charge, phase/queue change, arrival
+ * effect, occupation entitlement or random event is produced. */
+export function quoteEmperorHomeworldMove(
+ context: EmperorHomeworldMoveContext,
+ custody: HomeworldCustody,
+ actor: string,
+ order: EmperorHomeworldMove,
+) {
+ requireMove(
+ record(context) &&
+ context.status === 'playing' &&
+ context.advanced === true &&
+ context.phase === 5 &&
+ typeof actor === 'string' &&
+ actor.trim().length > 0 &&
+ context.currentPlayer === actor &&
+ whole(context.movesLeft) &&
+ context.movesLeft > 0,
+ 'Emperor Homeworld movement requires an unused movement during the active Advanced Shipment and Movement turn.',
+ );
+ requireMove(
+ record(order) &&
+ Object.keys(order).length === 2 &&
+ (order.origin === 'homeworld:emperor' ||
+ order.origin === 'homeworld:emperor:salusa') &&
+ record(order.forces) &&
+ Object.keys(order.forces).length === 2 &&
+ whole(order.forces.normal) &&
+ whole(order.forces.elite) &&
+ Number.isSafeInteger(order.forces.normal + order.forces.elite) &&
+ order.forces.normal + order.forces.elite > 0 &&
+ order.forces.normal + order.forces.elite <= 20,
+ 'Choose a positive typed force group from Kaitain or Salusa Secundus.',
+ );
+ try {
+ // Shared projection validates the entire public physical roster and custody.
+ const before = homeworldPopulations(context, custody);
+ const emperor = context.players.find((p) => p.id === actor);
+ requireMove(
+ emperor?.faction === 'emperor',
+ 'Only the seated Emperor can move between its Homeworlds.',
+ );
+ const origin: EmperorHomeworld = order.origin;
+ const destination: EmperorHomeworld =
+ origin === 'homeworld:emperor'
+ ? 'homeworld:emperor:salusa'
+ : 'homeworld:emperor';
+ const forces = { normal: order.forces.normal, elite: order.forces.elite };
+ const transferred = quoteHomeworldCustody(context, custody, [
+ {
+ homeworld: origin,
+ player: actor,
+ withdraw: forces,
+ deposit: { normal: 0, elite: 0 },
+ },
+ {
+ homeworld: destination,
+ player: actor,
+ withdraw: { normal: 0, elite: 0 },
+ deposit: forces,
+ },
+ ]);
+ // A move redistributes the existing native reserve total. It cannot create
+ // an additional native pool or consume somebody else's visitor counters.
+ requireMove(
+ transferred.players.every((seat) => {
+ const original = context.players.find((p) => p.id === seat.id)!;
+ return (
+ seat.reserves === original.reserves &&
+ seat.eliteReserves === original.eliteReserves
+ );
+ }),
+ 'Emperor Homeworld movement must preserve every native reserve total.',
+ );
+ const after = homeworldPopulations(
+ { advanced: context.advanced, players: transferred.players },
+ transferred.state,
+ );
+ return {
+ player: actor,
+ origin,
+ destination,
+ forces,
+ movementSpent: 1 as const,
+ movesLeft: context.movesLeft - 1,
+ players: transferred.players,
+ state: transferred.state,
+ receipts: transferred.receipts,
+ populations: { before, after },
+ };
+ } catch (error) {
+ if (error instanceof HomeworldCustodyError)
+ throw new EmperorHomeworldMoveError(error.message);
+ throw error;
+ }
+}
diff --git a/game/homeworld-game.ts b/game/homeworld-game.ts
new file mode 100644
index 00000000..5d7d4b76
--- /dev/null
+++ b/game/homeworld-game.ts
@@ -0,0 +1,159 @@
+import type { Game } from './engine';
+import {
+ homeworldForceGroups,
+ HomeworldCustodyError,
+} from './homeworld-custody';
+import { homeworldAllianceBlock } from './homeworld-alliance';
+import { homeworldPopulations } from './homeworld-population';
+import { tleilaxuHomeworldFreeIncomeBlocked } from './homeworld-benefits';
+import { charityQuote } from './charity';
+
+export function homeworldContext(g: Pick) {
+ return {
+ advanced: g.advanced,
+ players: g.players.map((p) => ({
+ id: p.id,
+ faction: p.faction,
+ reserves: p.reserves,
+ eliteReserves: p.elites?.reserves ?? 0,
+ })),
+ };
+}
+
+/** Native/visitor custody is physical state. Reading or normalizing never
+ * reconstructs a missing Salusa allocation from present aggregate reserves. */
+export function homeworldGameIntegrity(g: Game): void {
+ const state = g.homeworlds;
+ if (state === undefined || state === null) return;
+ if (
+ typeof state !== 'object' ||
+ Array.isArray(state) ||
+ Object.keys(state).length !== 1 ||
+ !Object.hasOwn(state, 'custody')
+ )
+ throw new HomeworldCustodyError(
+ 'The saved Homeworld module record is invalid.',
+ );
+ const beforeForces =
+ g.status === 'lobby' ||
+ (g.status === 'setup' &&
+ (g.setupStage === 'prediction' || g.setupStage === 'traitors'));
+ if (beforeForces) {
+ if (state.custody !== null)
+ throw new HomeworldCustodyError(
+ 'Homeworld custody starts with force placement, after prediction and traitors.',
+ );
+ return;
+ }
+ if (!state.custody)
+ throw new HomeworldCustodyError(
+ 'This game is missing its saved Homeworld force locations.',
+ );
+ if (g.status === 'playing' && g.phase === 4)
+ tleilaxuHomeworldFreeIncomeBlocked(g);
+ const continuation = g.pendingTreacheryDiscard?.continuation;
+ const savedControls: (
+ | { response?: Game['response']; pendingKarama?: Game['pendingKarama'] }
+ | null
+ | undefined
+ )[] = [
+ g,
+ g.pendingExchange,
+ g.pendingNullentropy?.resume,
+ g.pendingRicheseGift?.resume,
+ g.pendingRichesePurchaseIncome?.resume,
+ g.summonedWorm?.resume,
+ continuation && 'resume' in continuation ? continuation.resume : null,
+ ];
+ for (const controls of savedControls) {
+ const conversion = controls?.pendingKarama;
+ const response =
+ controls?.response?.kind === 'worthlessKarama' &&
+ conversion?.use.kind === 'cancel'
+ ? conversion.use.response
+ : controls?.response;
+ if (response?.kind !== 'bgCharity') continue;
+ const owner = g.players.find((p) => p.id === response.owner);
+ const quote = owner ? charityQuote(g, owner) : null;
+ if (
+ !owner ||
+ g.phase !== 2 ||
+ !g.advanced ||
+ owner.faction !== 'beneGesserit' ||
+ owner.charityTurn !== g.turn ||
+ !quote?.total ||
+ response.amount !== quote.total ||
+ response.charityHomeworld !== quote.homeworld
+ )
+ throw new HomeworldCustodyError(
+ 'The saved Homeworld charity claim does not match its eligible ordinary and bank amounts.',
+ );
+ }
+ const groups = homeworldForceGroups(homeworldContext(g), state.custody);
+ for (const p of g.players) {
+ if (p.ally && g.players.some((other) => other.id === p.ally && other.ally === p.id)) {
+ const blocked = homeworldAllianceBlock(homeworldContext(g), state.custody, p.id, p.ally);
+ if (blocked) throw new HomeworldCustodyError(blocked);
+ }
+ let away = 0,
+ awayElite = 0;
+ for (const home of groups) {
+ if (home.native === p.id || !Object.hasOwn(home.forces, p.id)) continue;
+ const force = home.forces[p.id];
+ away += force.normal + force.elite;
+ awayElite += force.elite;
+ }
+ const counts = [p.reserves, p.tanks, ...Object.values(p.forces)];
+ if (
+ counts.some((n) => !Number.isSafeInteger(n) || n < 0) ||
+ counts.reduce((a, b) => a + b, away) !== 20
+ )
+ throw new HomeworldCustodyError(
+ 'Forces on Arrakis, Homeworlds and in the Tanks must conserve twenty physical counters.',
+ );
+ const expected =
+ p.faction === 'ixians'
+ ? 7
+ : p.faction === 'fremen'
+ ? 3
+ : p.faction === 'emperor'
+ ? 5
+ : 0;
+ const special = p.elites;
+ if (expected) {
+ if (
+ !special ||
+ [
+ special.reserves,
+ special.tanks,
+ ...Object.values(special.forces),
+ ].some((n) => !Number.isSafeInteger(n) || n < 0) ||
+ special.reserves +
+ special.tanks +
+ Object.values(special.forces).reduce((a, b) => a + b, awayElite) !==
+ expected ||
+ special.tanks > p.tanks ||
+ Object.entries(special.forces).some(
+ ([key, n]) => n > (p.forces[key] ?? 0),
+ )
+ )
+ throw new HomeworldCustodyError(
+ 'Homeworld games preserve the faction’s complete physical special-counter inventory.',
+ );
+ } else if (special)
+ throw new HomeworldCustodyError(
+ 'This faction has no physical special-force counters.',
+ );
+ }
+}
+
+export function homeworldTable(g: Game) {
+ homeworldGameIntegrity(g);
+ if (!g.homeworlds?.custody) return null;
+ const context = homeworldContext(g);
+ const populations = homeworldPopulations(context, g.homeworlds.custody);
+ return homeworldForceGroups(context, g.homeworlds.custody).map((home) => ({
+ ...home,
+ ...populations.find((p) => p.location === home.id)!,
+ }));
+}
diff --git a/game/homeworld-mobility.ts b/game/homeworld-mobility.ts
new file mode 100644
index 00000000..2000010a
--- /dev/null
+++ b/game/homeworld-mobility.ts
@@ -0,0 +1,133 @@
+import type { Game } from './engine';
+import type { HomeworldId } from './homeworld-cards';
+import { homeworldContext } from './homeworld-game';
+import { homeworldPopulations } from './homeworld-population';
+
+export type HomeworldMobilityContext = Pick<
+ Game,
+ 'advanced' | 'players' | 'homeworlds'
+>;
+
+/** Current physical native population, shared by validation, controls and AI.
+ * Occupation retention is a separate unresolved lifecycle; this projection
+ * does not infer an occupier or award any occupation advantage. */
+function populationSide(
+ context: HomeworldMobilityContext,
+ playerId: string,
+ card: HomeworldId,
+): 'high' | 'low' | null {
+ if (!context.homeworlds?.custody) return null;
+ return (
+ homeworldPopulations(
+ homeworldContext(context),
+ context.homeworlds.custody,
+ ).find((home) => home.native === playerId && home.card === card)?.side ??
+ null
+ );
+}
+
+/** Only the Atreides Movement advantage: other legal card/auction knowledge
+ * is unaffected. Callers still own the ordinary faction and timing checks. */
+export function homeworldMovementForesightBlock(
+ context: HomeworldMobilityContext,
+ playerId: string,
+): string | null {
+ return populationSide(context, playerId, 'caladan') === 'low'
+ ? 'Low-population Caladan prevents the Atreides Movement advantage from inspecting the next Spice Blow.'
+ : null;
+}
+
+/** Relocation of the stronghold, including special Karama relocation. Troops
+ * may still move through a stationary Hidden Mobile Stronghold normally. */
+export function homeworldMobileStrongholdMovementBlock(
+ context: HomeworldMobilityContext,
+ playerId: string,
+): string | null {
+ return populationSide(context, playerId, 'ix') === 'low'
+ ? 'Low-population Ix prevents movement of the Hidden Mobile Stronghold.'
+ : null;
+}
+
+/** An existing No-Field marker's movement, not its shipment or revelation. */
+export function homeworldNoFieldMovementBlock(
+ context: HomeworldMobilityContext,
+ playerId: string,
+): string | null {
+ return populationSide(context, playerId, 'richese') === 'low'
+ ? 'Low-population Richese prevents movement of a No-Field token.'
+ : null;
+}
+
+export type HomeworldSpiritualAdvisorQuote = {
+ maximum: number;
+ blocked: string | null;
+};
+
+/** Printed population allowance without requiring an available placement
+ * resource. Cancellation validates the denied action without deploying forces;
+ * ordinary games can still cancel a saved advisor after reserves disappear. */
+export function homeworldSpiritualAdvisorAllowance(
+ context: HomeworldMobilityContext,
+ playerId: string,
+ destination: string,
+): number {
+ if (
+ !context.players.some(
+ (p) => p.id === playerId && p.faction === 'beneGesserit',
+ )
+ )
+ return 0;
+ const side = populationSide(context, playerId, 'wallach_ix');
+ return side === 'low'
+ ? 0
+ : side === 'high' && destination === 'polar_sink'
+ ? 2
+ : 1;
+}
+
+/** Free accompaniment to another faction's shipment only. Ordinary shipment
+ * eligibility, destination legality and Karama windows remain caller-owned;
+ * this never restricts paid BG shipments, movement or advisor stance changes.
+ * Two advisors is an optional high-Wallach choice only at Polar Sink. */
+export function homeworldSpiritualAdvisorQuote(
+ context: HomeworldMobilityContext,
+ playerId: string,
+ destination: string,
+): HomeworldSpiritualAdvisorQuote {
+ const player = context.players.find(
+ (candidate) =>
+ candidate.id === playerId && candidate.faction === 'beneGesserit',
+ );
+ if (!player)
+ return {
+ maximum: 0,
+ blocked: 'Only Bene Gesserit can send spiritual advisors.',
+ };
+ const allowance = homeworldSpiritualAdvisorAllowance(
+ context,
+ playerId,
+ destination,
+ );
+ if (allowance === 0)
+ return {
+ maximum: 0,
+ blocked: 'Low-population Wallach IX prevents sending spiritual advisors.',
+ };
+ if (player.reserves === 0)
+ return {
+ maximum: 0,
+ blocked: 'No reserve force is available to send as a spiritual advisor.',
+ };
+ return {
+ maximum: Math.min(player.reserves, allowance),
+ blocked: null,
+ };
+}
+
+export function homeworldSpiritualAdvisorLimit(
+ context: HomeworldMobilityContext,
+ playerId: string,
+ destination: string,
+): number {
+ return homeworldSpiritualAdvisorQuote(context, playerId, destination).maximum;
+}
diff --git a/game/homeworld-native-reserves.ts b/game/homeworld-native-reserves.ts
new file mode 100644
index 00000000..d3088a9f
--- /dev/null
+++ b/game/homeworld-native-reserves.ts
@@ -0,0 +1,172 @@
+import {
+ HomeworldCustodyError,
+ homeworldForceGroups,
+ quoteHomeworldCustody,
+ type HomeworldCustody,
+ type HomeworldCustodyContext,
+ type HomeworldCustodyChange,
+ type HomeworldForces,
+} from './homeworld-custody';
+
+export type NativeReserveSelections = Readonly>;
+export class NativeReserveError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'NativeReserveError';
+ }
+}
+const empty = (): HomeworldForces => ({ normal: 0, elite: 0 });
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+function requireReserve(
+ condition: unknown,
+ message: string,
+): asserts condition {
+ if (!condition) throw new NativeReserveError(message);
+}
+function typed(value: unknown): asserts value is HomeworldForces {
+ requireReserve(
+ record(value) &&
+ Object.keys(value).length === 2 &&
+ Object.hasOwn(value, 'normal') &&
+ Object.hasOwn(value, 'elite') &&
+ typeof value.normal === 'number' &&
+ Number.isSafeInteger(value.normal) &&
+ value.normal >= 0 &&
+ typeof value.elite === 'number' &&
+ Number.isSafeInteger(value.elite) &&
+ value.elite >= 0 &&
+ Number.isSafeInteger(value.normal + value.elite) &&
+ value.normal + value.elite <= 20,
+ 'Choose valid physical normal and special reserve counts.',
+ );
+}
+function checked(fn: () => T): T {
+ try {
+ return fn();
+ } catch (error) {
+ if (error instanceof HomeworldCustodyError)
+ throw new NativeReserveError(error.message);
+ throw error;
+ }
+}
+function nativeHomes(
+ context: HomeworldCustodyContext,
+ custody: HomeworldCustody,
+ actor: string,
+) {
+ const homes = homeworldForceGroups(context, custody).filter(
+ (home) => home.native === actor,
+ );
+ requireReserve(
+ typeof actor === 'string' && actor.trim().length > 0 && homes.length > 0,
+ 'Choose a seated native reserve owner.',
+ );
+ return homes;
+}
+
+/** Removes native reserves for a separately authorized external route. Neither
+ * visitor forces nor a guessed Emperor split can fund a withdrawal. Zero is a
+ * physical no-op, useful for zero-force callers; action permission is external. */
+export function quoteNativeReserveWithdrawal(
+ context: HomeworldCustodyContext,
+ custody: HomeworldCustody,
+ actor: string,
+ requested: HomeworldForces,
+ selections?: NativeReserveSelections,
+) {
+ return checked(() => {
+ const homes = nativeHomes(context, custody, actor);
+ typed(requested);
+ const selected = new Map();
+ if (selections !== undefined) {
+ requireReserve(
+ record(selections),
+ 'Choose a valid Homeworld source allocation.',
+ );
+ const sum = empty();
+ for (const [location, forces] of Object.entries(selections)) {
+ requireReserve(
+ homes.some((home) => home.id === location),
+ 'Reserve sources must be the selected faction’s native Homeworlds.',
+ );
+ typed(forces);
+ selected.set(location, { normal: forces.normal, elite: forces.elite });
+ sum.normal += forces.normal;
+ sum.elite += forces.elite;
+ }
+ requireReserve(
+ sum.normal === requested.normal && sum.elite === requested.elite,
+ 'The chosen Homeworld sources must exactly match both requested force types.',
+ );
+ } else {
+ for (const home of homes) selected.set(home.id, empty());
+ for (const kind of ['normal', 'elite'] as const) {
+ const total = homes.reduce(
+ (sum, home) => sum + home.forces[actor][kind],
+ 0,
+ );
+ requireReserve(
+ requested[kind] <= total,
+ 'The native Homeworlds do not hold the requested reserve counters.',
+ );
+ for (const home of homes) {
+ const available = home.forces[actor][kind];
+ const least = Math.max(0, requested[kind] - (total - available));
+ const most = Math.min(requested[kind], available);
+ requireReserve(
+ least === most,
+ `Choose the Homeworld sources for the ${kind === 'elite' ? 'special' : 'normal'} reserves; more than one allocation is possible.`,
+ );
+ selected.get(home.id)![kind] = least;
+ }
+ }
+ }
+ const changes: HomeworldCustodyChange[] = homes.flatMap((home) => {
+ const forces = selected.get(home.id) ?? empty();
+ return forces.normal + forces.elite > 0
+ ? [
+ {
+ homeworld: home.id,
+ player: actor,
+ withdraw: forces,
+ deposit: empty(),
+ },
+ ]
+ : [];
+ });
+ return quoteHomeworldCustody(context, custody, changes);
+ });
+}
+
+/** Revival destination only: E3 p.10 restores Advanced Emperor's normal forces
+ * to Kaitain and Sardaukar to Salusa. Other native reserve returns must use their
+ * own route contract; this does not validate Tanks, cost or revival allowances. */
+export function quoteNativeRevivalDeposit(
+ context: HomeworldCustodyContext,
+ custody: HomeworldCustody,
+ actor: string,
+ forces: HomeworldForces,
+) {
+ return checked(() => {
+ const homes = nativeHomes(context, custody, actor);
+ typed(forces);
+ const primary = homes.find((home) => !home.secondary)!;
+ const secondary = homes.find((home) => home.secondary);
+ const placements: [string, HomeworldForces][] = secondary
+ ? [
+ [primary.id, { normal: forces.normal, elite: 0 }],
+ [secondary.id, { normal: 0, elite: forces.elite }],
+ ]
+ : [[primary.id, { normal: forces.normal, elite: forces.elite }]];
+ return quoteHomeworldCustody(
+ context,
+ custody,
+ placements.flatMap(([homeworld, deposit]) =>
+ deposit.normal + deposit.elite > 0
+ ? [{ homeworld, player: actor, withdraw: empty(), deposit }]
+ : [],
+ ),
+ );
+ });
+}
diff --git a/game/homeworld-options.ts b/game/homeworld-options.ts
new file mode 100644
index 00000000..02f7a5f9
--- /dev/null
+++ b/game/homeworld-options.ts
@@ -0,0 +1,231 @@
+import type { Action, GameView } from './engine';
+import { HOMEWORLD_CARDS } from './homeworld-cards';
+import {
+ type HomeworldCustody,
+ type HomeworldCustodyContext,
+ type HomeworldForces,
+ homeworldForceGroups,
+ HomeworldCustodyError,
+} from './homeworld-custody';
+import {
+ EmperorHomeworldMoveError,
+ quoteEmperorHomeworldMove,
+ type EmperorHomeworldMove,
+} from './homeworld-emperor-move';
+import {
+ NativeReserveError,
+ quoteNativeReserveWithdrawal,
+ type NativeReserveSelections,
+} from './homeworld-native-reserves';
+
+/** Reconstitute only the public physical custody accepted by the shared quotes.
+ * Private hands, plans, reserve decks and random state are never consulted. */
+function publicCustody(view: GameView) {
+ const worlds = view.homeworlds?.worlds;
+ if (!worlds?.length) return null;
+ const context = {
+ advanced: view.advanced,
+ players: view.players.map((p) => ({
+ id: p.id,
+ faction: p.faction,
+ reserves: p.reserves,
+ eliteReserves: p.elites?.reserves ?? 0,
+ })),
+ };
+ const custody: HomeworldCustody = { salusa: null, visitors: {} };
+ for (const home of worlds) {
+ if (home.secondary) custody.salusa = { ...home.forces[home.native] };
+ const visitors = Object.entries(home.forces).filter(
+ ([owner]) => owner !== home.native,
+ );
+ if (visitors.length)
+ custody.visitors[home.id] = Object.fromEntries(
+ visitors.map(([owner, forces]) => [owner, { ...forces }]),
+ );
+ }
+ return { context, custody, worlds };
+}
+
+/** A deterministic explicit source choice for an ordinary native shipment.
+ * Amount includes elites. Null means no applicable/valid allocation; zero is
+ * valid physical custody even when the shipment route has other requirements. */
+export function nativeShipmentSources(
+ view: GameView,
+ amount: number,
+ elite = 0,
+): NativeReserveSelections | null {
+ const physical = publicCustody(view);
+ if (!physical) return null;
+ if (
+ !Number.isSafeInteger(amount) ||
+ amount < 0 ||
+ amount > 20 ||
+ !Number.isSafeInteger(elite) ||
+ elite < 0 ||
+ elite > amount
+ )
+ return null;
+ return nativeReserveSources(physical.context, physical.custody, view.me, {
+ normal: amount - elite,
+ elite,
+ });
+}
+
+/** Shared deterministic custody choice for public clients and server-generated
+ * shipment witnesses. This has no viewGame dependency or action permission. */
+export function nativeReserveSources(
+ context: HomeworldCustodyContext,
+ custody: HomeworldCustody,
+ actor: string,
+ requested: HomeworldForces,
+): NativeReserveSelections | null {
+ try {
+ const remaining = { ...requested };
+ const sources: Record = {};
+ const homes = homeworldForceGroups(context, custody)
+ .filter((home) => home.native === actor)
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
+ for (const home of homes) {
+ const available = home.forces[actor];
+ sources[home.id] = {
+ normal: Math.min(remaining.normal, available.normal),
+ elite: Math.min(remaining.elite, available.elite),
+ };
+ remaining.normal -= sources[home.id].normal;
+ remaining.elite -= sources[home.id].elite;
+ }
+ quoteNativeReserveWithdrawal(context, custody, actor, requested, sources);
+ return sources;
+ } catch (error) {
+ if (
+ error instanceof NativeReserveError ||
+ error instanceof HomeworldCustodyError
+ )
+ return null;
+ throw error;
+ }
+}
+
+/** Preserve human source choices; add a source choice for generated ordinary
+ * shipments. No-Fields and disabled modules retain their own route semantics. */
+export function withNativeShipmentSources(
+ view: GameView,
+ action: Action,
+): Action | null {
+ if (
+ action.type !== 'ship' ||
+ action.noField !== undefined ||
+ !view.homeworlds
+ )
+ return action;
+ if (typeof action.amount !== 'number') return null;
+ const me = view.players.find((p) => p.id === view.me);
+ if (!me) return null;
+ const elite =
+ action.elite ??
+ Math.max(0, action.amount - (me.reserves - (me.elites?.reserves ?? 0)));
+ if (typeof elite !== 'number') return null;
+ const sources =
+ action.homeworldSources !== undefined
+ ? action.homeworldSources
+ : nativeShipmentSources(view, action.amount, elite);
+ const physical = publicCustody(view);
+ if (!physical || !sources) return null;
+ try {
+ quoteNativeReserveWithdrawal(
+ physical.context,
+ physical.custody,
+ view.me,
+ { normal: action.amount - elite, elite },
+ sources as NativeReserveSelections,
+ );
+ return { ...action, homeworldSources: sources };
+ } catch (error) {
+ if (
+ error instanceof NativeReserveError ||
+ error instanceof HomeworldCustodyError
+ )
+ return null;
+ throw error;
+ }
+}
+
+/** Spend at most one movement restoring a printed high threshold. Salusa has
+ * priority; normal-only returns to Kaitain cannot undo its Sardaukar repair. */
+export function emperorHomeworldMoveActions(view: GameView): Action[] {
+ const movement = view.homeworldMove;
+ if (
+ !movement ||
+ movement.blocked ||
+ movement.remaining <= 0 ||
+ !view.advanced ||
+ view.status !== 'playing' ||
+ view.phase !== 5 ||
+ view.active !== view.me ||
+ view.players.find((p) => p.id === view.me)?.faction !== 'emperor'
+ )
+ return [];
+ const physical = publicCustody(view);
+ if (!physical) return [];
+ const kaitain = physical.worlds.find(
+ (home) => home.native === view.me && home.card === 'kaitain',
+ );
+ const salusa = physical.worlds.find(
+ (home) => home.native === view.me && home.card === 'salusa_secundus',
+ );
+ if (!kaitain || !salusa) return [];
+ const kaitainMinimum = HOMEWORLD_CARDS.find((card) => card.id === 'kaitain')!
+ .high.reserves.min;
+ const salusaMinimum = HOMEWORLD_CARDS.find(
+ (card) => card.id === 'salusa_secundus',
+ )!.high.reserves.min;
+ const atKaitain = kaitain.forces[view.me],
+ atSalusa = salusa.forces[view.me];
+ const salusaDeficit = Math.max(0, salusaMinimum - atSalusa.elite);
+ const kaitainDeficit = Math.max(
+ 0,
+ kaitainMinimum - atKaitain.normal - atKaitain.elite,
+ );
+ let order: EmperorHomeworldMove | null = null;
+ if (salusaDeficit > 0 && atKaitain.elite >= salusaDeficit)
+ order = {
+ origin: 'homeworld:emperor',
+ forces: { normal: 0, elite: salusaDeficit },
+ };
+ else if (kaitainDeficit > 0 && atSalusa.normal >= kaitainDeficit)
+ order = {
+ origin: 'homeworld:emperor:salusa',
+ forces: { normal: kaitainDeficit, elite: 0 },
+ };
+ if (!order) return [];
+ try {
+ quoteEmperorHomeworldMove(
+ {
+ ...physical.context,
+ status: view.status,
+ phase: view.phase,
+ currentPlayer: view.active,
+ movesLeft: movement.remaining,
+ },
+ physical.custody,
+ view.me,
+ order,
+ );
+ return [
+ {
+ type: 'emperorHomeworldMove',
+ event: movement.event,
+ origin: order.origin,
+ normal: order.forces.normal,
+ elite: order.forces.elite,
+ },
+ ];
+ } catch (error) {
+ if (
+ error instanceof EmperorHomeworldMoveError ||
+ error instanceof HomeworldCustodyError
+ )
+ return [];
+ throw error;
+ }
+}
diff --git a/game/homeworld-payment-income.ts b/game/homeworld-payment-income.ts
new file mode 100644
index 00000000..91846a20
--- /dev/null
+++ b/game/homeworld-payment-income.ts
@@ -0,0 +1,87 @@
+import type { Game } from './engine';
+import { HomeworldCustodyError } from './homeworld-custody';
+import { homeworldContext } from './homeworld-game';
+import { homeworldPopulations } from './homeworld-population';
+
+export type HomeworldPaymentIncomeContext = Pick<
+ Game,
+ 'advanced' | 'players' | 'homeworlds'
+>;
+
+export type HomeworldPaymentIncomeQuote = {
+ gross: number;
+ income: number;
+ bank: number;
+ low: boolean;
+};
+
+/** Split one already eligible payment. Contributor exclusions, payment-unit
+ * classification and timing belong to the caller. This does not resolve how
+ * multiple contributors define a payment for rounding purposes.
+ * This quotes physical low population only, not occupation entitlements. */
+export function quoteHomeworldPaymentIncome(
+ context: HomeworldPaymentIncomeContext,
+ ownerId: string,
+ kind: 'shipment' | 'treachery',
+ gross: number,
+): HomeworldPaymentIncomeQuote {
+ if (!Number.isSafeInteger(gross) || gross < 0)
+ throw new HomeworldCustodyError(
+ 'Homeworld payment income requires a nonnegative safe integer payment.',
+ );
+ if (kind !== 'shipment' && kind !== 'treachery')
+ throw new HomeworldCustodyError('Unknown Homeworld payment income kind.');
+ const faction = kind === 'shipment' ? 'guild' : 'emperor';
+ const owner = context.players.find((player) => player.id === ownerId);
+ if (!owner || owner.faction !== faction)
+ throw new HomeworldCustodyError(
+ `Only the ${faction === 'guild' ? 'Guild' : 'Emperor'} receives this payment income.`,
+ );
+ const card = kind === 'shipment' ? 'junction' : 'kaitain';
+ const low = context.homeworlds?.custody
+ ? homeworldPopulations(
+ homeworldContext(context),
+ context.homeworlds.custody,
+ ).some(
+ (home) =>
+ home.native === ownerId && home.card === card && home.side === 'low',
+ )
+ : false;
+ const income = low ? Math.ceil(gross / 2) : gross;
+ return { gross, income, bank: gross - income, low };
+}
+
+/** Compare the unresolved transaction and contributor rounding interpretations.
+ * The caller may accept agreement; divergence requires an explicit ruling. */
+export function quoteGuildPaymentRounding(
+ gross: number,
+ contributions: number[],
+): { transaction: number; contribution: number; unambiguous: boolean } {
+ if (
+ !Number.isSafeInteger(gross) ||
+ gross < 0 ||
+ !Array.isArray(contributions) ||
+ contributions.length > 2 ||
+ !Array.from(contributions).every(
+ (amount) => Number.isSafeInteger(amount) && amount >= 0,
+ )
+ )
+ throw new HomeworldCustodyError(
+ 'Guild rounding requires a nonnegative safe payment and at most two nonnegative safe contributions.',
+ );
+ const total = contributions.reduce((sum, amount) => sum + amount, 0);
+ if (!Number.isSafeInteger(total) || total !== gross)
+ throw new HomeworldCustodyError(
+ 'Guild contributions must sum to the original eligible payment.',
+ );
+ const transaction = Math.ceil(gross / 2);
+ const contribution = contributions.reduce(
+ (sum, amount) => sum + Math.ceil(amount / 2),
+ 0,
+ );
+ return {
+ transaction,
+ contribution,
+ unambiguous: transaction === contribution,
+ };
+}
diff --git a/game/homeworld-payment-options.ts b/game/homeworld-payment-options.ts
new file mode 100644
index 00000000..ee68596b
--- /dev/null
+++ b/game/homeworld-payment-options.ts
@@ -0,0 +1,30 @@
+import type { Action, GameView } from './engine';
+import { quoteGuildPaymentRounding } from './homeworld-payment-income';
+import { reserveShipmentCost, guildShipmentCost } from './shipment-price';
+import { territory } from './board';
+
+/** Own funding and public native population only; no donor balance or hidden card access. */
+export function homeworldShipmentPaymentBlock(g: GameView, cost: number, allyPayment: number): string | null {
+ const guild = g.players.find(p => p.faction === 'guild');
+ const me = g.players.find(p => p.id === g.me)!;
+ if (!guild || g.karamaShipping?.player === g.me ||
+ !g.homeworlds?.worlds?.some(world => world.card === 'junction' && world.side === 'low')) return null;
+ const contributions = [me.id === guild.id ? 0 : cost - allyPayment,
+ me.ally === guild.id ? 0 : allyPayment];
+ return quoteGuildPaymentRounding(contributions[0] + contributions[1], contributions).unambiguous
+ ? null : 'Low Junction rounding for two odd allied contributions awaits a ruling. Choose a different payment split.';
+}
+
+export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action): boolean {
+ if (!['ship', 'guildShip'].includes(action.type)) return true;
+ if (!g.homeworlds?.worlds?.some(world => world.card === 'junction' && world.side === 'low')) return true;
+ const me = g.players.find(p => p.id === g.me)!;
+ const to = String(action.territory);
+ const amount = action.noField ? 1 : Number(action.amount);
+ const cost = action.type === 'ship'
+ ? reserveShipmentCost({ faction: me.faction, halfRate: me.faction === 'guild' ||
+ g.players.some(p => p.faction === 'guild' && p.id === me.ally) || g.karamaShipping?.player === me.id }, territory(to).type, amount)
+ : guildShipmentCost(to === 'reserves' ? 'reserves' : territory(to).type, amount);
+ const share = action.allyPayment === undefined ? Math.max(0, cost - (me.spice ?? 0)) : Number(action.allyPayment);
+ return !homeworldShipmentPaymentBlock(g, cost, share);
+}
diff --git a/game/homeworld-population.ts b/game/homeworld-population.ts
new file mode 100644
index 00000000..146d7143
--- /dev/null
+++ b/game/homeworld-population.ts
@@ -0,0 +1,56 @@
+import { HOMEWORLD_CARDS, type HomeworldId } from './homeworld-cards';
+import {
+ homeworldForceGroups,
+ type HomeworldCustody,
+ type HomeworldCustodyContext,
+} from './homeworld-custody';
+
+export type HomeworldPopulation = {
+ location: string;
+ card: HomeworldId;
+ native: string;
+ population: number;
+ side: 'high' | 'low';
+ nativeBattleStrength: number;
+ /** Printed potential, not an award or proof of occupation eligibility. */
+ printedOccupiedSpice: number;
+ /** Added only when the native faction is otherwise taking the named benefit. */
+ extraFreeRevival: number;
+ extraCharity: number;
+};
+
+/** Public physical projection only. Occupier entitlements and effect timing are
+ * separate: occupation can retain a low penalty after native population rises.
+ * No player advantage, income, battle result or game action is applied here. */
+export function homeworldPopulations(
+ context: HomeworldCustodyContext,
+ custody: HomeworldCustody,
+): HomeworldPopulation[] {
+ return homeworldForceGroups(context, custody).map((home) => {
+ const faction = context.players.find((p) => p.id === home.native)!.faction;
+ const card = HOMEWORLD_CARDS.find((candidate) =>
+ home.secondary
+ ? candidate.id === 'salusa_secundus'
+ : candidate.faction === faction && candidate.id !== 'salusa_secundus',
+ )!;
+ const native = home.forces[home.native];
+ const population =
+ card.reserveType === 'sardaukar'
+ ? native.elite
+ : native.normal + native.elite;
+ // Global minimum-high rule resolves Salusa's overlapping printed endpoints.
+ const side = population >= card.high.reserves.min ? 'high' : 'low';
+ const bonus = side === 'low' && card.id !== 'salusa_secundus' ? 1 : 0;
+ return {
+ location: home.id,
+ card: card.id,
+ native: home.native,
+ population,
+ side,
+ nativeBattleStrength: card[side].battleStrength,
+ printedOccupiedSpice: card.occupied.spiceIcons,
+ extraFreeRevival: bonus,
+ extraCharity: bonus,
+ };
+ });
+}
diff --git a/game/homeworld-revival-deployment-options.ts b/game/homeworld-revival-deployment-options.ts
new file mode 100644
index 00000000..95db8e08
--- /dev/null
+++ b/game/homeworld-revival-deployment-options.ts
@@ -0,0 +1,106 @@
+import { gameTerritories, splitLocation } from './board';
+import type { Action, GameView } from './engine';
+
+/** Use the server's source-specific timing boundary for the selected group. */
+export function homeworldRevivalActionBlock(g: GameView, action: Action): string | null {
+ const me = g.players.find((player) => player.id === g.me);
+ if (!me || action.leader) return null;
+ const source = action.type === 'revive'
+ ? 'normal'
+ : action.type === 'card' && me.hand?.some((card) => card.id === action.card && card.effect === 'ghola')
+ ? 'ghola'
+ : null;
+ if (!source) return null;
+ const amount = action.amount === undefined && source === 'ghola'
+ ? Math.min(5, me.tanks)
+ : Number(action.amount);
+ const elite = action.elite === undefined
+ ? Math.max(0, amount - (me.tanks - (me.elites?.tanks ?? 0)))
+ : Number(action.elite);
+ return g.homeworldRevivalBlocks?.find((block) =>
+ block.source === source && block.amount === amount && block.elite === elite)?.reason ?? null;
+}
+
+/** Consume the actor's projected destinations; never reconstruct eligibility
+ * from reserves or another player's private state. */
+export function homeworldRevivalDeploymentCanAct(g: GameView): boolean {
+ return !!(
+ g.status === 'playing' &&
+ g.homeworldRevivalDeployment?.player === g.me &&
+ g.decision?.kind === 'homeworldRevivalDeployment' &&
+ g.decision.player === g.me &&
+ g.decision.event === g.homeworldRevivalDeployment.event &&
+ !g.response &&
+ !g.truthtrance &&
+ !g.phaseOpening &&
+ !g.automaticContinuationPending
+ );
+}
+
+export function homeworldRevivalDeploymentChoice(
+ g: GameView,
+ destination: string,
+): { blocked: string | null; action: Action | null } {
+ const offer = g.homeworldRevivalDeployment;
+ const target = offer?.destinations.find(
+ (option) => option.id === destination,
+ );
+ let blocked: string | null = null;
+ if (!homeworldRevivalDeploymentCanAct(g))
+ blocked = 'Wait for the current choice to finish.';
+ else if (offer!.blocked) blocked = offer!.blocked;
+ else if (!target) blocked = 'Choose a current revival destination.';
+ else if (target.blocked) blocked = target.blocked;
+ else if (
+ !Number.isSafeInteger(offer!.normal) ||
+ !Number.isSafeInteger(offer!.elite) ||
+ offer!.normal < 0 ||
+ offer!.elite < 0 ||
+ offer!.normal + offer!.elite <= 0 ||
+ offer!.normal + offer!.elite > 20
+ )
+ blocked = 'The revived group is no longer available.';
+ return {
+ blocked,
+ action: blocked
+ ? null
+ : {
+ type: 'decision',
+ event: offer!.event,
+ destination,
+ amount: offer!.normal + offer!.elite,
+ },
+ };
+}
+
+/** All profiles share this legal fallback: reinforce an occupied stronghold,
+ * otherwise use the first permitted destination, or leave the group at home. */
+export function homeworldRevivalDeploymentActions(g: GameView): Action[] {
+ if (!homeworldRevivalDeploymentCanAct(g)) return [];
+ const offer = g.homeworldRevivalDeployment!;
+ const me = g.players.find((player) => player.id === g.me);
+ const strongholds = new Set(
+ gameTerritories(g)
+ .filter((territory) => territory.type === 'stronghold')
+ .map((territory) => territory.id),
+ );
+ const occupied = new Set(
+ Object.entries(me?.forces ?? {})
+ .filter(([, count]) => count > 0)
+ .map(([key]) => splitLocation(key).territory),
+ );
+ const choices = offer.destinations
+ .map((target) => ({
+ target,
+ action: homeworldRevivalDeploymentChoice(g, target.id).action,
+ }))
+ .filter((choice) => choice.action);
+ const preferred = choices.find(
+ ({ target }) =>
+ target.territory &&
+ strongholds.has(target.territory) &&
+ occupied.has(target.territory),
+ );
+ const action = preferred?.action ?? choices[0]?.action;
+ return [action ?? { type: 'decision', event: offer.event, decline: true }];
+}
diff --git a/game/homeworld-revival-deployment.ts b/game/homeworld-revival-deployment.ts
new file mode 100644
index 00000000..70343816
--- /dev/null
+++ b/game/homeworld-revival-deployment.ts
@@ -0,0 +1,135 @@
+import type { Game } from './engine';
+import {
+ homeworldForceGroups,
+ HomeworldCustodyError,
+ type HomeworldCustody,
+ type HomeworldCustodyContext,
+} from './homeworld-custody';
+import { homeworldContext } from './homeworld-game';
+import { quoteNativeRevivalDeposit } from './homeworld-native-reserves';
+import { homeworldPopulations } from './homeworld-population';
+
+export type HomeworldRevivalDeploymentContext = Pick<
+ Game,
+ 'advanced' | 'players' | 'homeworlds'
+>;
+export type HomeworldRevivalDeploymentSource =
+ | 'normal'
+ | 'emperorExtra'
+ | 'ghola';
+export type HomeworldRevivalDeploymentGroup = {
+ amount: number;
+ elite: number;
+ free: number;
+};
+export type HomeworldRevivalDeploymentQuote = {
+ kind: 'fedaykin' | 'tleilax';
+ normal: number;
+ elite: number;
+ beforePopulation: number;
+ afterPopulation: number;
+ blocked: string | null;
+};
+
+function physicalWorlds(
+ context: HomeworldCustodyContext,
+ custody: HomeworldCustody,
+) {
+ return homeworldForceGroups(context, custody)
+ .map((home) => ({
+ id: home.id,
+ native: home.native,
+ forces: Object.entries(home.forces)
+ .filter(([, forces]) => forces.normal + forces.elite > 0)
+ .map(([player, forces]) => [player, forces.normal, forces.elite])
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
+ }))
+ .sort((a, b) => a.id.localeCompare(b.id));
+}
+
+/** Quote the eligible subset of one actual typed native revival deposit.
+ * Timing, destinations, splitting and durable completion belong to the caller.
+ * A newly attained high threshold remains an explicit unresolved boundary. */
+export function quoteHomeworldRevivalDeployment(
+ before: HomeworldRevivalDeploymentContext,
+ after: HomeworldRevivalDeploymentContext,
+ player: string,
+ source: HomeworldRevivalDeploymentSource,
+ group: HomeworldRevivalDeploymentGroup,
+): HomeworldRevivalDeploymentQuote | null {
+ if (
+ !['normal', 'emperorExtra', 'ghola'].includes(source) ||
+ !group ||
+ typeof group !== 'object' ||
+ Array.isArray(group) ||
+ !Number.isSafeInteger(group.amount) ||
+ group.amount <= 0 ||
+ group.amount > 20 ||
+ !Number.isSafeInteger(group.elite) ||
+ group.elite < 0 ||
+ group.elite > group.amount ||
+ !Number.isSafeInteger(group.free) ||
+ group.free < 0 ||
+ group.free > group.amount
+ )
+ throw new HomeworldCustodyError(
+ 'Revival deployment needs a valid source and exact newly returned physical group.',
+ );
+ if (!before.homeworlds?.custody && !after.homeworlds?.custody) return null;
+ if (
+ !before.homeworlds?.custody ||
+ !after.homeworlds?.custody ||
+ before.advanced !== after.advanced
+ )
+ throw new HomeworldCustodyError(
+ 'The Homeworld module cannot change during a revival deposit.',
+ );
+ const beforeContext = homeworldContext(before);
+ const afterContext = homeworldContext(after);
+ const expected = quoteNativeRevivalDeposit(
+ beforeContext,
+ before.homeworlds.custody,
+ player,
+ { normal: group.amount - group.elite, elite: group.elite },
+ );
+ if (
+ JSON.stringify(
+ physicalWorlds(
+ { ...beforeContext, players: expected.players },
+ expected.state,
+ ),
+ ) !== JSON.stringify(physicalWorlds(afterContext, after.homeworlds.custody))
+ )
+ throw new HomeworldCustodyError(
+ 'The revival deployment does not match its original typed native reserve deposit.',
+ );
+ const owner = beforeContext.players.find((seat) => seat.id === player)!;
+ const kind =
+ owner.faction === 'fremen' && group.elite > 0
+ ? 'fedaykin'
+ : owner.faction === 'tleilaxu' && source === 'normal' && group.free > 0
+ ? 'tleilax'
+ : null;
+ if (!kind) return null;
+ const card = kind === 'fedaykin' ? 'southern_hemisphere' : 'tleilax';
+ const prior = homeworldPopulations(
+ beforeContext,
+ before.homeworlds.custody,
+ ).find((home) => home.native === player && home.card === card)!;
+ const current = homeworldPopulations(
+ afterContext,
+ after.homeworlds.custody,
+ ).find((home) => home.native === player && home.card === card)!;
+ if (current.side !== 'high') return null;
+ return {
+ kind,
+ normal: kind === 'tleilax' ? group.free : 0,
+ elite: kind === 'fedaykin' ? group.elite : 0,
+ beforePopulation: prior.population,
+ afterPopulation: current.population,
+ blocked:
+ prior.side === 'low'
+ ? 'Revival deployment when this return first reaches the high Homeworld threshold awaits a timing ruling.'
+ : null,
+ };
+}
diff --git a/game/homeworld-revival-destinations.ts b/game/homeworld-revival-destinations.ts
new file mode 100644
index 00000000..89b1e072
--- /dev/null
+++ b/game/homeworld-revival-destinations.ts
@@ -0,0 +1,100 @@
+import type { Game } from './engine';
+import { gameTerritories, location, MOBILE_STRONGHOLD, splitLocation } from './board';
+import { presenceAt } from './force-presence';
+import { fighterCount } from './advisors';
+import { territoryEntryBlock } from './occupancy';
+import { homeworldContext } from './homeworld-game';
+import { HOMEWORLD_CARDS } from './homeworld-cards';
+import { homeworldForceGroups, quoteHomeworldCustody, HomeworldCustodyError } from './homeworld-custody';
+import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
+import { lowGrummanRevealBlock } from './homeworld-collection';
+import { homeworldSpiritualAdvisorLimit } from './homeworld-mobility';
+import type { HomeworldRevivalDeploymentQuote } from './homeworld-revival-deployment';
+
+type Context = Pick;
+export type HomeworldRevivalDestination = {
+ id: string;
+ name: string;
+ territory?: string;
+ sector?: number;
+ homeworld?: string;
+ blocked: string | null;
+};
+
+/** Only the complete earned group to one destination is admitted here.
+ * Shipment-specific reactions remain explicit source boundaries. No token face,
+ * opposing hand, spice balance or concealed denomination influences these lists. */
+export function homeworldRevivalDestinations(
+ g: Context,
+ player: string,
+ grant: HomeworldRevivalDeploymentQuote,
+): HomeworldRevivalDestination[] {
+ const p = g.players.find((seat) => seat.id === player);
+ if (!p || !g.homeworlds?.custody)
+ throw new HomeworldCustodyError('Revival placement needs its seated native Homeworld owner.');
+ const amount = grant.normal + grant.elite;
+ const bg = g.players.find((seat) => seat.faction === 'beneGesserit');
+ const moritani = g.players.find((seat) => seat.faction === 'moritani');
+ const advisors = grant.kind === 'tleilax' && bg && bg.reserves > 0 &&
+ homeworldSpiritualAdvisorLimit(g, bg.id, 'polar_sink') > 0
+ ? 'Spiritual Advisors after Tleilax revival deployment await a shipment-classification ruling.' : null;
+ const destinations: HomeworldRevivalDestination[] = gameTerritories(g).flatMap((t) => {
+ if (grant.kind === 'fedaykin' && presenceAt(p, t.id) === 0) return [];
+ return t.sectors.map((sector) => {
+ const effectiveSector = t.id === MOBILE_STRONGHOLD
+ ? splitLocation(g.mobileStronghold!.location!).sector : sector;
+ let blocked = grant.blocked ?? advisors;
+ if (!blocked && effectiveSector !== 0 && effectiveSector === g.storm)
+ blocked = grant.kind === 'fedaykin'
+ ? 'Fedaykin revival placement into storm awaits its own permission ruling.'
+ : 'Revival deployment cannot enter a sector in storm.';
+ if (!blocked && t.id === MOBILE_STRONGHOLD)
+ blocked = 'Revival placement in the Hidden Mobile Stronghold awaits its shipment-classification ruling.';
+ blocked ??= territoryEntryBlock(g.players, p.id, t.id);
+ if (!blocked && g.advanced && bg && bg.id !== p.id && fighterCount(bg, t.id) > 0)
+ blocked = 'Intrusion after Homeworld revival placement awaits an entry-classification ruling.';
+ if (!blocked && moritani && p.id !== moritani.id && p.id !== moritani.ally &&
+ !lowGrummanRevealBlock(g, moritani.id, amount) &&
+ g.moritaniTerror?.tokens.some((token) => token.status === 'placed' && token.location === t.id))
+ blocked = 'Terror after Homeworld revival placement awaits an entry-classification ruling.';
+ return { id: location(t.id, sector), name: `${t.name}${sector ? ` · sector ${sector}` : ''}`,
+ territory: t.id, sector, blocked };
+ });
+ });
+ if (grant.kind === 'tleilax') {
+ for (const home of homeworldForceGroups(homeworldContext(g), g.homeworlds.custody)) {
+ const native = g.players.find((seat) => seat.id === home.native)!;
+ const card = HOMEWORLD_CARDS.find((card) => card.faction === native.faction && (card.id === 'salusa_secundus') === home.secondary)!;
+ destinations.push({ id: home.id, name: card.name, homeworld: home.id,
+ blocked: grant.blocked ??
+ (p.ally === native.id && native.ally === p.id ? 'You cannot deploy to your ally’s Homeworld.' : null),
+ });
+ }
+ }
+ return destinations;
+}
+
+/** A custody quote only: the engine commits the physical arrival and its
+ * Ambassador continuation. No fee, new revival or shipment allowance is used. */
+export function quoteHomeworldRevivalDestination(
+ g: Context,
+ player: string,
+ grant: HomeworldRevivalDeploymentQuote,
+ destination: string,
+) {
+ const selected = homeworldRevivalDestinations(g, player, grant).find((d) => d.id === destination);
+ if (!selected || selected.blocked)
+ throw new HomeworldCustodyError(selected?.blocked ?? 'Choose a currently eligible revival destination.');
+ const forces = { normal: grant.normal, elite: grant.elite };
+ const context = homeworldContext(g);
+ if (selected.homeworld) {
+ const native = homeworldForceGroups(context, g.homeworlds!.custody!)
+ .find((home) => home.native === player && !home.secondary)!;
+ const transfer = quoteHomeworldCustody(context, g.homeworlds!.custody!, [
+ { homeworld: native.id, player, withdraw: forces, deposit: { normal: 0, elite: 0 } },
+ { homeworld: selected.homeworld, player, withdraw: { normal: 0, elite: 0 }, deposit: forces },
+ ]);
+ return { selected, transfer };
+ }
+ return { selected, transfer: quoteNativeReserveWithdrawal(context, g.homeworlds!.custody!, player, forces) };
+}
diff --git a/game/homeworld-revival-return.ts b/game/homeworld-revival-return.ts
new file mode 100644
index 00000000..ca415e6a
--- /dev/null
+++ b/game/homeworld-revival-return.ts
@@ -0,0 +1,371 @@
+import type { Game } from './engine';
+import {
+ homeworldArrivalSignature,
+ appendHomeworldArrivalAmbassador,
+ completeHomeworldArrivalAmbassador,
+ validateHomeworldArrival,
+ type HomeworldAmbassadorArrival,
+} from './homeworld-arrival';
+import { HomeworldCustodyError } from './homeworld-custody';
+import type {
+ HomeworldRevivalDeploymentGroup,
+ HomeworldRevivalDeploymentQuote,
+ HomeworldRevivalDeploymentSource,
+} from './homeworld-revival-deployment';
+
+export type HomeworldRevivalReturn = {
+ progressVersion?: 1;
+ event: string;
+ turn: number;
+ phase: number;
+ player: string;
+ source: HomeworldRevivalDeploymentSource;
+ card?: string;
+ group: HomeworldRevivalDeploymentGroup;
+ quote: HomeworldRevivalDeploymentQuote;
+ stage: 'waiting' | 'choice' | 'arrival' | 'complete';
+ resumeResponse?: Game['response'];
+ destination?: string;
+ signature: string;
+ resumeSignature?: string;
+ ambassadors?: HomeworldAmbassadorArrival[];
+ arrivalSignature?: string;
+};
+
+export type HomeworldRevivalProgress = {
+ event: string;
+ stage: HomeworldRevivalReturn['stage'];
+ signature: string;
+};
+
+type Original = Pick<
+ HomeworldRevivalReturn,
+ | 'progressVersion'
+ | 'event'
+ | 'turn'
+ | 'phase'
+ | 'player'
+ | 'source'
+ | 'card'
+ | 'group'
+ | 'quote'
+>;
+type Context = Pick<
+ Game,
+ | 'turn'
+ | 'phase'
+ | 'players'
+ | 'homeworlds'
+ | 'pendingAmbassador'
+ | 'homeworldRevivalProgress'
+>;
+
+function fail(): never {
+ throw new HomeworldCustodyError(
+ 'The saved Homeworld revival return does not match its original typed revival and continuation.',
+ );
+}
+function count(value: unknown, min = 0, max = 20): value is number {
+ return (
+ Number.isSafeInteger(value) &&
+ (value as number) >= min &&
+ (value as number) <= max
+ );
+}
+function text(value: unknown): value is string {
+ return typeof value === 'string' && value.length > 0;
+}
+function keys(value: unknown, allowed: string[]): boolean {
+ return (
+ !!value &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ Object.keys(value).every((key) => allowed.includes(key))
+ );
+}
+function originalSignature(frame: Original): string {
+ return JSON.stringify([
+ 'homeworldRevivalReturn',
+ frame.event,
+ frame.turn,
+ frame.phase,
+ frame.player,
+ frame.source,
+ frame.card ?? null,
+ [frame.group.amount, frame.group.elite, frame.group.free],
+ [
+ frame.quote.kind,
+ frame.quote.normal,
+ frame.quote.elite,
+ frame.quote.beforePopulation,
+ frame.quote.afterPopulation,
+ frame.quote.blocked,
+ ],
+ // Preserve the exact signature bytes of legacy unversioned saves.
+ ...(frame.progressVersion === 1 ? ['progressVersion', 1] : []),
+ ]);
+}
+
+/** Compatibility wrappers retain the original revival grant validation. */
+export function homeworldRevivalArrivalSignature(
+ frame: HomeworldRevivalReturn,
+): string {
+ return homeworldArrivalSignature(frame);
+}
+export function appendHomeworldRevivalAmbassador(
+ frame: HomeworldRevivalReturn,
+ entry: Parameters[1],
+ parent?: string,
+): void {
+ validateOriginal(frame);
+ if (frame.signature !== originalSignature(frame)) fail();
+ appendHomeworldArrivalAmbassador(frame, entry, parent);
+}
+export function completeHomeworldRevivalAmbassador(
+ frame: HomeworldRevivalReturn,
+ event: string,
+): void {
+ validateOriginal(frame);
+ if (frame.signature !== originalSignature(frame)) fail();
+ completeHomeworldArrivalAmbassador(frame, event);
+}
+function validateOriginal(frame: Original): void {
+ if (
+ (frame.progressVersion !== undefined && frame.progressVersion !== 1) ||
+ !text(frame.event) ||
+ !text(frame.player) ||
+ !count(frame.turn, 1, Number.MAX_SAFE_INTEGER) ||
+ !count(frame.phase, 0, 8) ||
+ !['normal', 'emperorExtra', 'ghola'].includes(frame.source) ||
+ (frame.source === 'ghola' ? !text(frame.card) : frame.card !== undefined) ||
+ !keys(frame.group, ['amount', 'elite', 'free']) ||
+ !count(frame.group.amount, 1) ||
+ !count(frame.group.elite, 0, frame.group.amount) ||
+ !count(frame.group.free, 0, frame.group.amount) ||
+ (frame.source !== 'normal' && frame.group.free !== 0) ||
+ !keys(frame.quote, [
+ 'kind',
+ 'normal',
+ 'elite',
+ 'beforePopulation',
+ 'afterPopulation',
+ 'blocked',
+ ]) ||
+ frame.quote.blocked !== null ||
+ !count(frame.quote.normal) ||
+ !count(frame.quote.elite) ||
+ !count(frame.quote.beforePopulation) ||
+ !count(frame.quote.afterPopulation) ||
+ frame.quote.afterPopulation - frame.quote.beforePopulation !==
+ frame.group.amount
+ )
+ fail();
+ const quote = frame.quote;
+ if (quote.kind === 'fedaykin') {
+ if (
+ quote.normal !== 0 ||
+ !count(quote.elite, 1, 3) ||
+ quote.elite !== frame.group.elite ||
+ quote.beforePopulation < 3
+ )
+ fail();
+ } else if (quote.kind === 'tleilax') {
+ if (
+ frame.source !== 'normal' ||
+ frame.group.elite !== 0 ||
+ quote.elite !== 0 ||
+ quote.normal !== frame.group.free ||
+ quote.normal < 1 ||
+ quote.beforePopulation < 9
+ )
+ fail();
+ } else fail();
+}
+
+/** Captured response proof survives JSON key reordering, but never omitted values. */
+export function homeworldRevivalResumeSignature(
+ response: Game['response'],
+): string {
+ if (response === null) return 'null';
+ if (
+ !keys(response, [
+ 'kind',
+ 'owner',
+ 'passed',
+ 'recipient',
+ 'amount',
+ 'intent',
+ ])
+ )
+ fail();
+ return JSON.stringify(
+ Object.fromEntries(
+ Object.entries(response).sort(([left], [right]) =>
+ left.localeCompare(right),
+ ),
+ ),
+ );
+}
+
+/** Construct only after the quoted deposit actually returned its exact group. */
+export function makeHomeworldRevivalReturn(
+ original: Omit,
+): HomeworldRevivalReturn {
+ if (
+ !keys(original, [
+ 'event',
+ 'turn',
+ 'phase',
+ 'player',
+ 'source',
+ 'card',
+ 'group',
+ 'quote',
+ ])
+ )
+ fail();
+ validateOriginal(original);
+ const versioned = {
+ ...structuredClone(original),
+ progressVersion: 1 as const,
+ };
+ return {
+ ...versioned,
+ stage: 'waiting',
+ signature: originalSignature(versioned),
+ };
+}
+
+/** Only engine transition boundaries create progress; readers never reconstruct it. */
+export function makeHomeworldRevivalProgress(
+ frame: HomeworldRevivalReturn,
+): HomeworldRevivalProgress {
+ validateOriginal(frame);
+ if (
+ frame.progressVersion !== 1 ||
+ frame.signature !== originalSignature(frame) ||
+ !['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage)
+ )
+ fail();
+ return {
+ event: frame.event,
+ stage: frame.stage,
+ signature: JSON.stringify([
+ 'homeworldRevivalProgress',
+ frame.signature,
+ frame.event,
+ frame.stage,
+ ]),
+ };
+}
+
+/** Entitlement is the original receipt. Current native population never grants it again. */
+export function validateHomeworldRevivalReturn(
+ g: Context,
+ frame: HomeworldRevivalReturn | null | undefined,
+): void {
+ if (frame === null || frame === undefined) {
+ if (g.homeworldRevivalProgress !== undefined) fail();
+ return;
+ }
+ if (
+ !keys(frame, [
+ 'progressVersion',
+ 'event',
+ 'turn',
+ 'phase',
+ 'player',
+ 'source',
+ 'card',
+ 'group',
+ 'quote',
+ 'stage',
+ 'resumeResponse',
+ 'destination',
+ 'signature',
+ 'resumeSignature',
+ 'ambassadors',
+ 'arrivalSignature',
+ ])
+ )
+ fail();
+ validateOriginal(frame);
+ if (
+ frame.signature !== originalSignature(frame) ||
+ !['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage) ||
+ !g.homeworlds?.custody ||
+ (frame.stage !== 'complete' &&
+ (g.turn !== frame.turn || g.phase !== frame.phase)) ||
+ (frame.stage === 'complete' && g.turn < frame.turn)
+ )
+ fail();
+ if (frame.progressVersion === 1) {
+ const expected = makeHomeworldRevivalProgress(frame);
+ const progress = g.homeworldRevivalProgress;
+ if (
+ !keys(progress, ['event', 'stage', 'signature']) ||
+ progress?.event !== expected.event ||
+ progress.stage !== expected.stage ||
+ progress.signature !== expected.signature
+ )
+ fail();
+ } else if (g.homeworldRevivalProgress !== undefined) fail();
+ const owners = g.players.filter((seat) => seat.id === frame.player);
+ const owner = owners[0];
+ if (
+ owners.length !== 1 ||
+ owner.faction !== (frame.quote.kind === 'fedaykin' ? 'fremen' : 'tleilaxu')
+ )
+ fail();
+ if (frame.stage === 'waiting' || frame.stage === 'choice') {
+ if (frame.ambassadors !== undefined || frame.arrivalSignature !== undefined)
+ fail();
+ const elites = owner.elites?.reserves ?? 0;
+ if (
+ !count(owner.reserves) ||
+ !count(elites, 0, owner.reserves) ||
+ elites < frame.quote.elite ||
+ owner.reserves - elites < frame.quote.normal
+ )
+ fail();
+ }
+ if (frame.stage === 'arrival' || frame.stage === 'complete')
+ validateHomeworldArrival(g, frame, 'revivalEvent');
+ if (frame.stage === 'waiting') {
+ if (
+ frame.resumeResponse !== undefined ||
+ frame.resumeSignature !== undefined ||
+ frame.destination !== undefined
+ )
+ fail();
+ return;
+ }
+ if (
+ frame.resumeResponse === undefined ||
+ frame.resumeSignature !==
+ homeworldRevivalResumeSignature(frame.resumeResponse)
+ )
+ fail();
+ if (
+ frame.stage === 'choice'
+ ? frame.destination !== undefined
+ : !text(frame.destination)
+ )
+ fail();
+ const response = frame.resumeResponse;
+ if (
+ response !== null &&
+ (response.kind !== 'revivalIncome' ||
+ response.recipient !== frame.player ||
+ !g.players.some(
+ (seat) => seat.id === response.owner && seat.faction === 'tleilaxu',
+ ) ||
+ !count(response.amount, 1, Number.MAX_SAFE_INTEGER) ||
+ (frame.source === 'ghola' && response.amount !== 1) ||
+ !Array.isArray(response.passed) ||
+ new Set(response.passed).size !== response.passed.length ||
+ response.passed.some((id) => !g.players.some((seat) => seat.id === id)) ||
+ (response.intent !== undefined && !text(response.intent)))
+ )
+ fail();
+}
diff --git a/game/homeworld-shipment-options.ts b/game/homeworld-shipment-options.ts
new file mode 100644
index 00000000..de11ef7e
--- /dev/null
+++ b/game/homeworld-shipment-options.ts
@@ -0,0 +1,264 @@
+import type { Action, GameView } from './engine';
+import { HOMEWORLD_CARDS } from './homeworld-cards';
+import {
+ HomeworldCustodyError,
+ type HomeworldCustody,
+ type HomeworldForces,
+} from './homeworld-custody';
+import { quoteHomeworldShipment } from './homeworld-shipment';
+
+export type HomeworldShipmentSources = Record;
+export function homeworldName(view: GameView, id: string): string {
+ const home = view.homeworlds?.worlds?.find((world) => world.id === id);
+ return (
+ HOMEWORLD_CARDS.find((card) => card.id === home?.card)?.name ?? 'Homeworld'
+ );
+}
+
+/** Only public physical counters and the actor's own available funding enter
+ * the quote. Foreign garrisons are never relabelled as native reserves. */
+export function homeworldShipmentChoice(
+ view: GameView,
+ destination: string,
+ sources: HomeworldShipmentSources,
+ allyPayment = 0,
+) {
+ const result: {
+ blocked: string | null;
+ cost: number;
+ amount: number;
+ elite: number;
+ ownPayment: number;
+ allyPayment: number;
+ action: Action | null;
+ } = {
+ blocked: null,
+ cost: 0,
+ amount: 0,
+ elite: 0,
+ ownPayment: 0,
+ allyPayment,
+ action: null,
+ };
+ const option = view.homeworldShipment;
+ const worlds = view.homeworlds?.worlds;
+ const me = view.players.find((player) => player.id === view.me);
+ if (
+ !option ||
+ !worlds?.length ||
+ !me ||
+ view.status !== 'playing' ||
+ view.phase !== 5 ||
+ view.active !== view.me ||
+ me.shipped
+ ) {
+ result.blocked = 'Use Homeworld shipment during your unused shipment turn.';
+ return result;
+ }
+ if (option.blocked) {
+ result.blocked = option.blocked;
+ return result;
+ }
+ const context = {
+ advanced: view.advanced,
+ players: view.players.map((player) => ({
+ id: player.id,
+ faction: player.faction,
+ reserves: player.reserves,
+ eliteReserves: player.elites?.reserves ?? 0,
+ ally: player.ally ?? null,
+ })),
+ };
+ const custody: HomeworldCustody = { salusa: null, visitors: {} };
+ for (const world of worlds) {
+ if (world.secondary) custody.salusa = { ...world.forces[world.native] };
+ const visitors = Object.entries(world.forces).filter(
+ ([owner]) => owner !== world.native,
+ );
+ if (visitors.length)
+ custody.visitors[world.id] = Object.fromEntries(
+ visitors.map(([owner, forces]) => [owner, { ...forces }]),
+ );
+ }
+ try {
+ const quote = quoteHomeworldShipment(context, custody, {
+ player: me.id,
+ destination,
+ sources,
+ });
+ result.cost = quote.cost;
+ result.amount = quote.amount;
+ result.elite = quote.elite;
+ result.ownPayment = quote.cost - allyPayment;
+ if (
+ !Number.isSafeInteger(allyPayment) ||
+ allyPayment < 0 ||
+ allyPayment > quote.cost ||
+ allyPayment > (me.ally ? view.aid.available : 0)
+ )
+ result.blocked =
+ 'Choose an allied contribution within the current pledge and shipment price.';
+ else if (result.ownPayment > (me.spice ?? 0))
+ result.blocked =
+ 'Your spice and selected allied contribution cannot pay this shipment.';
+ else
+ result.action = {
+ type: 'homeworldShip',
+ event: option.event,
+ destination,
+ sources,
+ allyPayment,
+ };
+ } catch (error) {
+ if (!(error instanceof HomeworldCustodyError)) throw error;
+ result.blocked = error.message;
+ }
+ return result;
+}
+
+/** Public tactical candidates retain a native defense and spice for the battle.
+ * Higher levels reinforce contested invasions and budget for native strength. */
+export function homeworldShipmentActions(
+ view: GameView,
+ level: number,
+): Action[] {
+ const me = view.players.find((player) => player.id === view.me);
+ const worlds = view.homeworlds?.worlds;
+ if (
+ !me ||
+ !worlds?.length ||
+ !view.homeworldShipment ||
+ view.homeworldShipment.blocked ||
+ view.phase !== 5 ||
+ view.active !== me.id ||
+ me.shipped
+ )
+ return [];
+ const difficulty = Math.max(0, Math.min(3, level));
+ const budget = Math.max(
+ 0,
+ (me.spice ?? 0) + view.aid.available - [2, 3, 3, 2][difficulty],
+ );
+ const sources = worlds.filter(
+ (world) =>
+ (world.forces[me.id]?.normal ?? 0) + (world.forces[me.id]?.elite ?? 0) >
+ 0,
+ );
+ const targets = worlds
+ .filter(
+ (world) =>
+ (world.native !== me.id || me.faction === 'guild') &&
+ world.native !== me.ally,
+ )
+ .map((world) => {
+ const enemies = Object.entries(world.forces).filter(
+ ([owner]) => owner !== me.id && owner !== me.ally,
+ );
+ const enemy = enemies.reduce(
+ (sum, [, forces]) =>
+ sum + forces.normal + forces.elite * (view.advanced ? 2 : 1),
+ 0,
+ );
+ const native = world.forces[world.native];
+ const strength =
+ enemy +
+ (world.native !== me.id && native.normal + native.elite > 0
+ ? world.nativeBattleStrength
+ : 0);
+ const own = world.forces[me.id];
+ const existing =
+ (own?.normal ?? 0) + (own?.elite ?? 0) * (view.advanced ? 2 : 1);
+ return {
+ world,
+ strength,
+ existing,
+ score: (existing > 0 && enemy > 0 ? 10 + difficulty * 3 : 0) - strength,
+ };
+ })
+ .sort((a, b) => b.score - a.score || a.world.id.localeCompare(b.world.id));
+ const sourceGroups = sources.map((source) => [source]);
+ const imperial = sources.filter((source) => source.native === me.id);
+ if (view.advanced && me.faction === 'emperor' && imperial.length === 2)
+ sourceGroups.push(imperial);
+ const actions: Action[] = [];
+ for (const target of targets) {
+ if (
+ target.world.native === me.id &&
+ target.world.side === 'high' &&
+ target.strength === 0
+ )
+ continue;
+ const required = Math.max(
+ 1,
+ target.strength + [0, 1, 2, 3][difficulty] - target.existing,
+ );
+ for (const group of sourceGroups) {
+ if (group.some((source) => source.id === target.world.id)) continue;
+ const pools = group.map((source) => {
+ const force = source.forces[me.id];
+ const keep =
+ source.native !== me.id
+ ? 0
+ : source.secondary
+ ? Math.min(2, force.elite)
+ : [3, 3, 4, 3][difficulty];
+ return {
+ source,
+ force,
+ capacity: Math.max(0, force.normal + force.elite - keep),
+ };
+ });
+ const amount = Math.min(
+ pools.reduce((sum, pool) => sum + pool.capacity, 0),
+ [3, 5, 7, 9][difficulty],
+ budget * (me.faction === 'guild' ? 2 : 1),
+ Math.max([1, 3, 4, 5][difficulty], required),
+ );
+ if (amount <= 0) continue;
+ let remaining = amount;
+ const selection: HomeworldShipmentSources = {};
+ for (const pool of [...pools].sort((a, b) =>
+ difficulty > 0
+ ? b.force.elite - a.force.elite
+ : a.source.id.localeCompare(b.source.id),
+ )) {
+ const count = Math.min(remaining, pool.capacity);
+ if (!count) continue;
+ const availableElite = pool.source.secondary
+ ? Math.max(0, pool.force.elite - 2)
+ : pool.force.elite;
+ const elite =
+ difficulty > 0
+ ? Math.min(count, availableElite)
+ : Math.max(0, count - pool.force.normal);
+ selection[pool.source.id] = { normal: count - elite, elite };
+ remaining -= count;
+ }
+ const elite = Object.values(selection).reduce(
+ (sum, forces) => sum + forces.elite,
+ 0,
+ );
+ const strength = amount + (view.advanced ? elite : 0);
+ if (
+ strength + target.existing <
+ target.strength + (difficulty > 0 ? 1 : 0)
+ )
+ continue;
+ const initial = homeworldShipmentChoice(view, target.world.id, selection);
+ const quote = homeworldShipmentChoice(
+ view,
+ target.world.id,
+ selection,
+ Math.max(0, initial.cost - (me.spice ?? 0)),
+ );
+ if (
+ quote.action &&
+ !actions.some(
+ (action) => JSON.stringify(action) === JSON.stringify(quote.action),
+ )
+ )
+ actions.push(quote.action);
+ }
+ }
+ return actions;
+}
diff --git a/game/homeworld-shipment.ts b/game/homeworld-shipment.ts
new file mode 100644
index 00000000..6ddb3f77
--- /dev/null
+++ b/game/homeworld-shipment.ts
@@ -0,0 +1,134 @@
+import {
+ HomeworldCustodyError,
+ homeworldForceGroups,
+ quoteHomeworldCustody,
+ type HomeworldCustody,
+ type HomeworldCustodyContext,
+ type HomeworldForces,
+ type HomeworldReserveSeat,
+} from './homeworld-custody';
+
+export type HomeworldShipmentContext = Omit<
+ HomeworldCustodyContext,
+ 'players'
+> & {
+ players: (HomeworldReserveSeat & { ally: string | null })[];
+};
+export type HomeworldShipmentIntent = {
+ player: string;
+ destination: string;
+ sources: Record;
+};
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+const whole = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
+function requireShipment(
+ condition: unknown,
+ message: string,
+): asserts condition {
+ if (!condition) throw new HomeworldCustodyError(message);
+}
+
+/** E3 world-to-world transport: use the actual native or foreign source pool.
+ * Guild may also return a foreign garrison to its own world; other own-world
+ * exceptions need separately established permission. The caller owns timing, usage,
+ * funding, bank payment, responses and arrival effects. Guild's E3 half rate
+ * applies to its own shipment; an alliance alone does not grant that rate. */
+export function quoteHomeworldShipment(
+ context: HomeworldShipmentContext,
+ custody: HomeworldCustody,
+ intent: HomeworldShipmentIntent,
+) {
+ // Validate the entire public custody before using any selected source.
+ const worlds = homeworldForceGroups(context, custody);
+ requireShipment(
+ context.players.every(
+ (p) =>
+ p.ally === null ||
+ (typeof p.ally === 'string' &&
+ p.ally !== p.id &&
+ context.players.some((other) => other.id === p.ally)),
+ ),
+ 'Homeworld shipment needs valid seated alliance identities.',
+ );
+ requireShipment(
+ record(intent) &&
+ Object.keys(intent).length === 3 &&
+ typeof intent.player === 'string' &&
+ typeof intent.destination === 'string' &&
+ record(intent.sources),
+ 'Choose a seated shipper, a destination Homeworld and typed source groups.',
+ );
+ const player = context.players.find((p) => p.id === intent.player);
+ const destination = worlds.find((w) => w.id === intent.destination);
+ requireShipment(
+ player && destination,
+ 'Choose an active Homeworld and seated shipper.',
+ );
+ const native = context.players.find((p) => p.id === destination.native)!;
+ requireShipment(
+ native.id !== player.id || player.faction === 'guild',
+ 'Shipment to your own Homeworld needs its specific return permission.',
+ );
+ requireShipment(
+ !(player.ally === native.id && native.ally === player.id),
+ 'You cannot ship to your ally’s Homeworld.',
+ );
+ const sources = Object.entries(intent.sources);
+ requireShipment(
+ sources.length === 1 ||
+ (sources.length === 2 &&
+ context.advanced &&
+ player.faction === 'emperor' &&
+ sources.every(([id]) =>
+ ['homeworld:emperor', 'homeworld:emperor:salusa'].includes(id),
+ )),
+ 'Use one source Homeworld, or combine Emperor’s own Kaitain and Salusa forces in Advanced play.',
+ );
+ let normal = 0;
+ let elite = 0;
+ for (const [id, forces] of sources) {
+ requireShipment(
+ id !== destination.id && worlds.some((w) => w.id === id),
+ 'Shipment needs a distinct active source Homeworld.',
+ );
+ requireShipment(
+ record(forces) &&
+ Object.keys(forces).length === 2 &&
+ whole(forces.normal) &&
+ whole(forces.elite) &&
+ forces.normal + forces.elite > 0 &&
+ forces.normal + forces.elite <= 20,
+ 'Each source needs a positive physical normal and special counter group.',
+ );
+ normal += forces.normal;
+ elite += forces.elite;
+ }
+ const amount = normal + elite;
+ requireShipment(
+ amount > 0 && amount <= 20,
+ 'A shipment cannot exceed the faction’s twenty physical counters.',
+ );
+ const transferred = quoteHomeworldCustody(context, custody, [
+ ...sources.map(([homeworld, forces]) => ({
+ homeworld,
+ player: player.id,
+ withdraw: { normal: forces.normal, elite: forces.elite },
+ deposit: { normal: 0, elite: 0 },
+ })),
+ {
+ homeworld: destination.id,
+ player: player.id,
+ withdraw: { normal: 0, elite: 0 },
+ deposit: { normal, elite },
+ },
+ ]);
+ return {
+ ...transferred,
+ amount,
+ elite,
+ cost: player.faction === 'guild' ? Math.ceil(amount / 2) : amount,
+ sources: transferred.receipts.filter((r) => r.homeworld !== destination.id),
+ };
+}
diff --git a/game/homeworld-substitution.ts b/game/homeworld-substitution.ts
new file mode 100644
index 00000000..e6fc6671
--- /dev/null
+++ b/game/homeworld-substitution.ts
@@ -0,0 +1,116 @@
+import {
+ HomeworldCombatLossError,
+ quoteHomeworldCombatLoss,
+ type HomeworldCombatLossContext,
+} from './homeworld-combat-loss';
+import {
+ HomeworldCustodyError,
+ quoteHomeworldCustody,
+ type HomeworldCustody,
+} from './homeworld-custody';
+
+export type HomeworldSubstitutionRequest = {
+ location: string;
+ player: string;
+ amount: number;
+ /** Bound to the resolved battle by the caller, never all historical deaths. */
+ cyborgsLost: number;
+};
+export class HomeworldSubstitutionError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'HomeworldSubstitutionError';
+ }
+}
+function requireSubstitution(
+ condition: unknown,
+ message: string,
+): asserts condition {
+ if (!condition) throw new HomeworldSubstitutionError(message);
+}
+
+/** Exact post-casualty exchange. Event identity, declaration timing and Karama
+ * permission are caller-owned. The lost Cyborgs stay at this battle location;
+ * surviving Suboids take their place in the Tanks, without revival or transport. */
+export function quoteHomeworldSubstitution(
+ context: HomeworldCombatLossContext,
+ custody: HomeworldCustody,
+ request: HomeworldSubstitutionRequest,
+) {
+ try {
+ requireSubstitution(
+ !!request &&
+ typeof request === 'object' &&
+ !Array.isArray(request) &&
+ Number.isSafeInteger(request.amount) &&
+ request.amount > 0 &&
+ Number.isSafeInteger(request.cyborgsLost) &&
+ request.cyborgsLost <= 7 &&
+ request.amount <= request.cyborgsLost,
+ 'Exchange a positive number of Suboids for Cyborgs lost in this battle.',
+ );
+ // A zero loss validates the entire physical inventory and detaches custody
+ // without temporarily increasing an accumulated battle-loss counter.
+ const before = quoteHomeworldCombatLoss(context, custody, {
+ location: request.location,
+ player: request.player,
+ losses: { normal: 0, elite: 0 },
+ });
+ const owner = before.players.find(
+ (player) => player.id === request.player,
+ )!;
+ requireSubstitution(
+ owner.faction === 'ixians' &&
+ owner.eliteTanks >= request.cyborgsLost &&
+ owner.battleLosses >= request.cyborgsLost &&
+ before.receipt.before.normal >= request.amount,
+ 'Use surviving Ixian Suboids here and the Cyborg casualties of this battle.',
+ );
+ const exchange = quoteHomeworldCustody(
+ { advanced: context.advanced, players: before.players },
+ before.custody,
+ [
+ {
+ homeworld: request.location,
+ player: request.player,
+ withdraw: { normal: request.amount, elite: 0 },
+ deposit: { normal: 0, elite: request.amount },
+ },
+ ],
+ );
+ const players = before.players.map((player, index) => ({
+ ...player,
+ ...exchange.players[index],
+ eliteTanks:
+ player.eliteTanks - (player.id === request.player ? request.amount : 0),
+ }));
+ const after = quoteHomeworldCombatLoss(
+ { advanced: context.advanced, players },
+ exchange.state,
+ {
+ location: request.location,
+ player: request.player,
+ losses: { normal: 0, elite: 0 },
+ },
+ );
+ return {
+ custody: after.custody,
+ players: after.players,
+ receipt: {
+ location: request.location,
+ player: request.player,
+ amount: request.amount,
+ cyborgsLost: request.cyborgsLost,
+ before: { ...before.receipt.before },
+ after: { ...after.receipt.after },
+ },
+ };
+ } catch (error) {
+ if (
+ error instanceof HomeworldCustodyError ||
+ error instanceof HomeworldCombatLossError
+ )
+ throw new HomeworldSubstitutionError(error.message);
+ throw error;
+ }
+}
diff --git a/game/homeworld-victory-reinforcement.ts b/game/homeworld-victory-reinforcement.ts
new file mode 100644
index 00000000..affb6137
--- /dev/null
+++ b/game/homeworld-victory-reinforcement.ts
@@ -0,0 +1,247 @@
+import type { Game } from './engine';
+import {
+ gameTerritories,
+ location,
+ MOBILE_STRONGHOLD,
+ splitLocation,
+ validLocation,
+} from './board';
+import { fighterCount } from './advisors';
+import { territoryEntryBlock } from './occupancy';
+import { homeworldContext } from './homeworld-game';
+import {
+ HomeworldCustodyError,
+ homeworldForceGroups,
+ quoteHomeworldCustody,
+} from './homeworld-custody';
+import { homeworldPopulations } from './homeworld-population';
+import { HOMEWORLD_CARDS } from './homeworld-cards';
+import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
+import { lowGrummanRevealBlock } from './homeworld-collection';
+
+export type HomeworldVictoryReinforcementContext = Pick<
+ Game,
+ | 'advanced'
+ | 'players'
+ | 'homeworlds'
+ | 'storm'
+ | 'mobileStronghold'
+ | 'moritaniTerror'
+>;
+export type HomeworldVictoryWitness = {
+ event: string;
+ turn: number;
+ player: string;
+ territory: string;
+ result: 'normal' | 'traitor';
+};
+export type HomeworldVictoryReinforcementDestination = {
+ id: string;
+ name: string;
+ territory?: string;
+ sector?: number;
+ homeworld?: string;
+ blocked: string | null;
+};
+export type HomeworldVictoryReinforcementQuote = HomeworldVictoryWitness & {
+ population: number;
+ survivors: number;
+ amount: 0 | 1;
+ blocked: string | null;
+ destinations: HomeworldVictoryReinforcementDestination[];
+};
+
+function validCount(n: unknown): n is number {
+ return Number.isSafeInteger(n) && (n as number) >= 0 && (n as number) <= 20;
+}
+function invalid(message: string): never {
+ throw new HomeworldCustodyError(message);
+}
+
+/** Printed Caladan high face; E3 p9's continuous native threshold applies at
+ * actual use, after winner casualties. The caller proves this original victory
+ * against its battle receipt and owns ordering, single use and entry reactions.
+ * Face Dance priority is not selected here. */
+export function quoteHomeworldVictoryReinforcement(
+ g: HomeworldVictoryReinforcementContext,
+ victory: HomeworldVictoryWitness,
+): HomeworldVictoryReinforcementQuote {
+ if (
+ !victory ||
+ typeof victory !== 'object' ||
+ Array.isArray(victory) ||
+ Object.keys(victory).sort().join(',') !==
+ 'event,player,result,territory,turn' ||
+ typeof victory.event !== 'string' ||
+ !victory.event ||
+ typeof victory.player !== 'string' ||
+ !victory.player ||
+ typeof victory.territory !== 'string' ||
+ !victory.territory ||
+ !Number.isSafeInteger(victory.turn) ||
+ victory.turn < 1 ||
+ !['normal', 'traitor'].includes(victory.result)
+ )
+ invalid(
+ 'Caladan reinforcement requires one original winning battle receipt.',
+ );
+ const owner = g.players.find((p) => p.id === victory.player);
+ if (!owner || g.players.filter((p) => p.id === victory.player).length !== 1)
+ invalid('The original winning faction must be seated.');
+ const board = gameTerritories(g).find((t) => t.id === victory.territory);
+ const worlds = g.homeworlds?.custody
+ ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody)
+ : [];
+ const home = worlds.find((world) => world.id === victory.territory);
+ if (!board && !home)
+ invalid('Choose the actual battle territory or active Homeworld.');
+ let survivors = 0;
+ if (home) {
+ const forces = home.forces[owner.id];
+ survivors = forces ? forces.normal + forces.elite : 0;
+ } else {
+ for (const [key, amount] of Object.entries(owner.forces)) {
+ const at = splitLocation(key);
+ if (
+ !validLocation(at.territory, at.sector) ||
+ location(at.territory, at.sector) !== key ||
+ !validCount(amount)
+ )
+ invalid(
+ 'Caladan reinforcement requires valid surviving physical forces.',
+ );
+ if (at.territory === victory.territory) survivors += amount;
+ }
+ }
+ if (!validCount(survivors))
+ invalid('The surviving army exceeds its physical force inventory.');
+ const population = g.homeworlds?.custody
+ ? (homeworldPopulations(homeworldContext(g), g.homeworlds.custody).find(
+ (world) => world.native === owner.id && world.card === 'caladan',
+ )?.population ?? 0)
+ : 0;
+ const quote: HomeworldVictoryReinforcementQuote = {
+ ...victory,
+ population,
+ survivors,
+ amount: 0,
+ blocked: null,
+ destinations: [],
+ };
+ if (owner.faction !== 'atreides' || !g.homeworlds?.custody) return quote;
+ if (population < 6)
+ return {
+ ...quote,
+ blocked:
+ 'Caladan requires at least six native reserves for victory reinforcement.',
+ };
+ if (survivors === 0)
+ return {
+ ...quote,
+ blocked: 'An Atreides force must survive at the battle location.',
+ };
+ // All native reserves already occupy Caladan. Moving one within that same
+ // custody pool cannot add a counter or return one from the Tanks.
+ if (home?.native === owner.id)
+ return {
+ ...quote,
+ blocked:
+ 'The native reserve forces are already on Caladan; no additional force can be placed there.',
+ };
+ quote.amount = 1;
+ if (home) {
+ const native = g.players.find((seat) => seat.id === home.native)!;
+ quote.destinations.push({
+ id: home.id,
+ name: HOMEWORLD_CARDS.find(
+ (card) =>
+ card.faction === native.faction &&
+ (card.id === 'salusa_secundus') === home.secondary,
+ )!.name,
+ homeworld: home.id,
+ blocked:
+ owner.ally === native.id && native.ally === owner.id
+ ? 'You cannot reinforce on your ally’s Homeworld.'
+ : null,
+ });
+ return quote;
+ }
+ if (!Number.isSafeInteger(g.storm) || g.storm < 1 || g.storm > 18)
+ invalid('Caladan reinforcement requires the current storm sector.');
+ const bg = g.players.find((seat) => seat.faction === 'beneGesserit');
+ const moritani = g.players.find((seat) => seat.faction === 'moritani');
+ quote.destinations = board!.sectors.map((sector) => {
+ let blocked: string | null = null;
+ if (board!.id === MOBILE_STRONGHOLD)
+ blocked =
+ 'Caladan reinforcement in the Hidden Mobile Stronghold awaits its entry-classification ruling.';
+ if (!blocked && sector === g.storm)
+ blocked =
+ 'Caladan reinforcement into a sector in storm awaits its placement-permission ruling.';
+ blocked ??= territoryEntryBlock(g.players, owner.id, board!.id);
+ if (!blocked && g.advanced && bg && fighterCount(bg, board!.id) > 0)
+ blocked =
+ 'Intrusion after Caladan reinforcement awaits an entry-classification ruling.';
+ if (
+ !blocked &&
+ moritani &&
+ owner.id !== moritani.ally &&
+ !lowGrummanRevealBlock(g, moritani.id, 1) &&
+ g.moritaniTerror?.tokens.some(
+ (token) => token.status === 'placed' && token.location === board!.id,
+ )
+ )
+ blocked =
+ 'Terror after Caladan reinforcement awaits an entry-classification ruling.';
+ return {
+ id: location(board!.id, sector),
+ name: `${board!.name}${sector ? ` · sector ${sector}` : ''}`,
+ territory: board!.id,
+ sector,
+ blocked,
+ };
+ });
+ return quote;
+}
+
+/** One actual native normal counter; never a revival, fee or ordinary shipment. */
+export function quoteHomeworldVictoryReinforcementDestination(
+ g: HomeworldVictoryReinforcementContext,
+ victory: HomeworldVictoryWitness,
+ destination: string,
+) {
+ const quote = quoteHomeworldVictoryReinforcement(g, victory);
+ const selected = quote.destinations.find(
+ (choice) => choice.id === destination,
+ );
+ if (quote.amount !== 1 || !selected || selected.blocked)
+ invalid(
+ quote.blocked ??
+ selected?.blocked ??
+ 'Choose one eligible destination at the won battle location.',
+ );
+ const forces = { normal: 1, elite: 0 };
+ const context = homeworldContext(g);
+ const transfer = selected.homeworld
+ ? quoteHomeworldCustody(context, g.homeworlds!.custody!, [
+ {
+ homeworld: 'homeworld:atreides',
+ player: victory.player,
+ withdraw: forces,
+ deposit: { normal: 0, elite: 0 },
+ },
+ {
+ homeworld: selected.homeworld,
+ player: victory.player,
+ withdraw: { normal: 0, elite: 0 },
+ deposit: forces,
+ },
+ ])
+ : quoteNativeReserveWithdrawal(
+ context,
+ g.homeworlds!.custody!,
+ victory.player,
+ forces,
+ );
+ return { quote, selected, transfer };
+}
diff --git a/game/homeworld-victory-return.ts b/game/homeworld-victory-return.ts
new file mode 100644
index 00000000..ce68dfbc
--- /dev/null
+++ b/game/homeworld-victory-return.ts
@@ -0,0 +1,90 @@
+import type { Game } from './engine';
+import type { HomeworldAmbassadorArrival } from './homeworld-arrival';
+import type { HomeworldVictoryWitness } from './homeworld-victory-reinforcement';
+import { HomeworldCustodyError } from './homeworld-custody';
+
+export type HomeworldVictoryReturn = HomeworldVictoryWitness & {
+ phase: 6;
+ stage: 'waiting' | 'choice' | 'arrival' | 'complete';
+ signature: string;
+ offer?: { population: number; survivors: number; blocked: string | null; signature: string };
+ destination?: string;
+ ambassadors?: HomeworldAmbassadorArrival[];
+ arrivalSignature?: string;
+};
+export type HomeworldVictoryObligation = {
+ event: string;
+ completed: boolean;
+ /** Absent only on completed development saves written before stage receipts. */
+ stage?: HomeworldVictoryReturn['stage'];
+ signature: string;
+};
+
+function fail(): never {
+ throw new HomeworldCustodyError('The saved Caladan reinforcement no longer matches its original victory and completion obligation.');
+}
+function originalSignature(witness: HomeworldVictoryWitness) {
+ return JSON.stringify(['caladanVictory', witness.event, witness.turn, witness.player, witness.territory, witness.result]);
+}
+function witnessValid(witness: HomeworldVictoryWitness) {
+ return typeof witness.event === 'string' && !!witness.event &&
+ Number.isSafeInteger(witness.turn) && witness.turn > 0 &&
+ typeof witness.player === 'string' && !!witness.player &&
+ typeof witness.territory === 'string' && !!witness.territory &&
+ ['normal', 'traitor'].includes(witness.result);
+}
+export function makeHomeworldVictoryReturn(witness: HomeworldVictoryWitness): HomeworldVictoryReturn {
+ if (!witnessValid(witness)) fail();
+ return { ...witness, phase: 6, stage: 'waiting', signature: originalSignature(witness) };
+}
+export function homeworldVictoryObligationSignature(context: NonNullable, completed: boolean,
+ stage?: HomeworldVictoryReturn['stage']) {
+ return JSON.stringify(['caladanObligation', context.event, context.turn, context.territory,
+ context.winner, context.result, completed, ...(stage === undefined ? [] : [stage])]);
+}
+export function homeworldVictoryOfferSignature(frame: HomeworldVictoryReturn,
+ offer: Pick, 'population' | 'survivors' | 'blocked'>) {
+ return JSON.stringify(['caladanOffer', frame.signature, offer.population, offer.survivors, offer.blocked]);
+}
+
+/** Original battle facts and the independent obligation survive nullable choices.
+ * Arrival chain validation is shared with other Homeworld arrivals by the engine. */
+export function validateHomeworldVictoryReturn(g: Pick) {
+ const frame = g.homeworldVictoryReinforcement;
+ const context = g.lastBattleContext;
+ const obligation = context?.caladanReinforcement;
+ if (obligation) {
+ if (!frame || obligation.event !== context!.event || frame.event !== obligation.event ||
+ typeof obligation.completed !== 'boolean' ||
+ obligation.signature !== homeworldVictoryObligationSignature(context!, obligation.completed, obligation.stage) ||
+ (obligation.stage === undefined ? !obligation.completed : obligation.stage !== frame.stage) ||
+ obligation.completed !== (frame.stage === 'complete')) fail();
+ }
+ if (frame === undefined || frame === null) return;
+ if (typeof frame !== 'object' || Array.isArray(frame) ||
+ Object.keys(frame).some((key) => !['event', 'turn', 'phase', 'player', 'territory', 'result', 'stage',
+ 'signature', 'offer', 'destination', 'ambassadors', 'arrivalSignature'].includes(key)) ||
+ !witnessValid(frame) || frame.signature !== originalSignature(frame) || frame.phase !== 6 ||
+ !['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage) ||
+ !g.homeworlds?.custody ||
+ g.players.filter((p) => p.id === frame.player && p.faction === 'atreides').length !== 1 ||
+ frame.turn > g.turn) fail();
+ if (frame.stage !== 'complete' || frame.event === context?.event) {
+ if (!obligation || (frame.stage !== 'complete' && (frame.turn !== g.turn || g.phase !== 6)) ||
+ context!.event !== frame.event || context!.turn !== frame.turn ||
+ context!.territory !== frame.territory || context!.winner !== frame.player ||
+ context!.result !== frame.result) fail();
+ }
+ const offer = frame.offer;
+ if (offer && (typeof offer !== 'object' || Array.isArray(offer) ||
+ Object.keys(offer).some((key) => !['population', 'survivors', 'blocked', 'signature'].includes(key)) ||
+ !Number.isSafeInteger(offer.population) || offer.population < 6 || offer.population > 20 ||
+ !Number.isSafeInteger(offer.survivors) || offer.survivors < 1 || offer.survivors > 20 ||
+ offer.blocked !== null && (typeof offer.blocked !== 'string' || !offer.blocked) ||
+ offer.signature !== homeworldVictoryOfferSignature(frame, offer))) fail();
+ if (frame.stage === 'waiting' || frame.stage === 'choice') {
+ if (frame.destination !== undefined || frame.ambassadors !== undefined || frame.arrivalSignature !== undefined ||
+ (frame.stage === 'waiting' ? offer !== undefined : !offer)) fail();
+ } else if (typeof frame.destination !== 'string' || !frame.destination || !Array.isArray(frame.ambassadors) ||
+ typeof frame.arrivalSignature !== 'string' || (!offer && frame.destination !== 'decline')) fail();
+}
diff --git a/game/ix-substitution-cancellation.ts b/game/ix-substitution-cancellation.ts
index 813db6b2..8f2bb860 100644
--- a/game/ix-substitution-cancellation.ts
+++ b/game/ix-substitution-cancellation.ts
@@ -17,6 +17,7 @@ type Context = Pick<
> & {
players: readonly Pick[];
territories: readonly { id: string; sectors: readonly number[] }[];
+ combatLocations?: readonly { id: string; kind: 'territory' | 'homeworld' }[];
physicalCards: readonly { id: string }[];
};
function requireIx(value: unknown, message: string): asserts value {
@@ -43,6 +44,18 @@ export function quoteIxSubstitutionCancellation(
owner?.faction === 'ixians',
'The canceled substitution needs its Ixian winner.',
);
+ const homeworld = g.combatLocations?.find(
+ (location) => location.id === p.territory && location.kind === 'homeworld',
+ );
+ const territory = g.territories.find(
+ (location) => location.id === p.territory,
+ );
+ requireIx(
+ homeworld
+ ? p.territory.startsWith('homeworld:') && !territory
+ : !!territory && !p.territory.startsWith('homeworld:'),
+ 'The canceled substitution needs a current canonical combat location.',
+ );
let context: BattleCleanupContextQuote;
try {
context = validateBattleCleanupContext({
@@ -51,7 +64,10 @@ export function quoteIxSubstitutionCancellation(
battlePresent: !!g.battle,
lastBattle: g.lastBattle,
playerIds: g.players.map((p) => p.id),
- territoryIds: g.territories.map((t) => t.id),
+ territoryIds: [
+ ...g.territories.map((t) => t.id),
+ ...(homeworld ? [homeworld.id] : []),
+ ],
territory: p.territory,
winner: p.player,
context: g.lastBattleContext,
@@ -63,20 +79,22 @@ export function quoteIxSubstitutionCancellation(
: 'Invalid substitution battle receipt.',
);
}
- const territory = g.territories.find((t) => t.id === p.territory)!;
const counts = (value: unknown): value is Record =>
record(value) &&
+ (!homeworld || Object.keys(value).length === 1) &&
Object.entries(value).every(
([key, count]) =>
- territory.sectors.some(
- (sector) => key === `${territory.id}:${sector}`,
- ) &&
+ (homeworld
+ ? key === homeworld.id
+ : territory!.sectors.some(
+ (sector) => key === `${territory!.id}:${sector}`,
+ )) &&
Number.isSafeInteger(count) &&
Number(count) > 0,
);
requireIx(
counts(p.losses) && counts(p.sources) && counts(p.recover),
- 'The canceled substitution needs its original casualty and exchange sectors.',
+ 'The canceled substitution needs its original casualty and exchange locations.',
);
const total = (value: Record) =>
Object.values(value).reduce((a, b) => a + b, 0);
@@ -104,13 +122,13 @@ export function quoteIxSubstitutionCancellation(
);
return {
player: owner.id,
- territory: territory.id,
+ territory: p.territory,
context,
decision: p.cards.length
? {
kind: 'battleCards' as const,
player: owner.id,
- territory: territory.id,
+ territory: p.territory,
cards: [...p.cards],
}
: null,
diff --git a/game/junction-offer.ts b/game/junction-offer.ts
new file mode 100644
index 00000000..358eefcd
--- /dev/null
+++ b/game/junction-offer.ts
@@ -0,0 +1,49 @@
+import type { Game } from './engine';
+import { homeworldContext } from './homeworld-game';
+import { homeworldPopulations } from './homeworld-population';
+import { HomeworldCustodyError } from './homeworld-custody';
+
+export type JunctionOffer = {
+ event: string;
+ turn: number;
+ owner: string;
+ recipient: string;
+ rate: 'half' | 'full';
+};
+
+/** A public, optional quote for the recipient's current shipment opportunity.
+ * It is not a second action allowance or a durable grant for a later turn. */
+export function currentJunctionOffer(g: Game): JunctionOffer | null {
+ const offer = g.junctionOffer;
+ if (!offer || !junctionSponsor(g) || offer.owner !== junctionSponsor(g) ||
+ offer.turn !== g.turn || offer.recipient !== g.active || g.phase !== 5 ||
+ g.status !== 'playing' || g.players.find((p) => p.id === offer.recipient)?.shipped)
+ return null;
+ return offer;
+}
+
+export function junctionSponsor(g: Game): string | null {
+ if (!g.homeworlds?.custody) return null;
+ return homeworldPopulations(homeworldContext(g), g.homeworlds.custody)
+ .find((world) => world.card === 'junction' && world.side === 'high')?.native ?? null;
+}
+
+/** Only public turn and population data bind the opportunity. Hand changes or
+ * the sponsor's private balance cannot reveal themselves through this token. */
+export function junctionOfferEvent(g: Game): string {
+ return JSON.stringify([g.turn, g.phase, g.active, junctionSponsor(g),
+ g.players.find((p) => p.id === g.active)?.shipped ?? null]);
+}
+
+export function junctionOfferIntegrity(g: Game): void {
+ const offer = g.junctionOffer;
+ if (offer == null) return;
+ if (typeof offer !== 'object' || Array.isArray(offer) ||
+ Object.keys(offer).sort().join(',') !== 'event,owner,rate,recipient,turn' ||
+ typeof offer.event !== 'string' || !offer.event.length ||
+ !Number.isSafeInteger(offer.turn) || offer.turn < 1 || offer.turn > g.turn ||
+ !g.homeworlds?.custody || !g.players.some((p) => p.id === offer.owner && p.faction === 'guild') ||
+ offer.owner === offer.recipient || !g.players.some((p) => p.id === offer.recipient) ||
+ !['half', 'full'].includes(offer.rate))
+ throw new HomeworldCustodyError('The saved Junction offer needs its original Guild, recipient, turn and tariff.');
+}
diff --git a/game/junction-transport-options.ts b/game/junction-transport-options.ts
new file mode 100644
index 00000000..732969e2
--- /dev/null
+++ b/game/junction-transport-options.ts
@@ -0,0 +1,434 @@
+import type { Action, GameView } from './engine';
+import {
+ gameTerritories,
+ MOBILE_LOCATION,
+ MOBILE_STRONGHOLD,
+ splitLocation,
+} from './board';
+import { arrivalAsAdvisor, forceCount } from './advisors';
+import { territoryEntryBlock } from './occupancy';
+import {
+ HomeworldCustodyError,
+ type HomeworldCustody,
+} from './homeworld-custody';
+import { quoteJunctionTransport } from './junction-transport';
+import {
+ homeworldName,
+ type HomeworldShipmentSources,
+} from './homeworld-shipment-options';
+import { liveShipmentPromises, matchesShipment } from './shipment-promises';
+
+export function junctionTransportOrigins(view: GameView) {
+ const me = view.players.find((player) => player.id === view.me);
+ if (!me) return [];
+ const worlds = (view.homeworlds?.worlds ?? []).flatMap((world) => {
+ const pool = world.forces[me.id];
+ return pool && pool.normal + pool.elite > 0
+ ? [
+ {
+ id: world.id,
+ name: homeworldName(view, world.id),
+ native: world.native === me.id,
+ kind: 'homeworld' as const,
+ pools: [
+ {
+ key: world.id,
+ name: homeworldName(view, world.id),
+ sector: null as number | null,
+ ...pool,
+ },
+ ],
+ },
+ ]
+ : [];
+ });
+ const board = gameTerritories(view).flatMap((territory) => {
+ const pools = Object.entries(me.forces).flatMap(([key, amount]) => {
+ if (amount <= 0 || splitLocation(key).territory !== territory.id)
+ return [];
+ const sector = key === MOBILE_LOCATION ? null : splitLocation(key).sector;
+ return [
+ {
+ key,
+ name: `${territory.name}${sector ? ` sector ${sector}` : ''}`,
+ sector,
+ normal: amount - (me.elites?.forces[key] ?? 0),
+ elite: me.elites?.forces[key] ?? 0,
+ },
+ ];
+ });
+ return pools.length
+ ? [
+ {
+ id: territory.id,
+ name: territory.name,
+ native: false,
+ kind: 'arrakis' as const,
+ pools,
+ },
+ ]
+ : [];
+ });
+ const imperial = worlds.filter((world) => world.native);
+ return [
+ ...worlds,
+ ...(me.faction === 'emperor' && view.advanced && imperial.length === 2
+ ? [
+ {
+ id: 'imperial-worlds',
+ name: 'Kaitain and Salusa Secundus',
+ native: true,
+ kind: 'homeworld' as const,
+ pools: imperial.flatMap((world) => world.pools),
+ },
+ ]
+ : []),
+ ...board,
+ ];
+}
+
+export function junctionTransportDestinations(view: GameView) {
+ const me = view.players.find((player) => player.id === view.me);
+ if (!me) return [];
+ return [
+ ...(view.homeworlds?.worlds ?? [])
+ .filter((world) => world.native !== me.ally)
+ .map((world) => ({
+ id: world.id,
+ name: `${homeworldName(view, world.id)}${world.native === me.id ? ' · own Homeworld' : ''}`,
+ })),
+ ...gameTerritories(view)
+ .filter((t) => t.id !== MOBILE_STRONGHOLD || me.faction === 'ixians')
+ .flatMap((t) =>
+ t.sectors.map((sector) => ({
+ id: `${t.id}:${sector}`,
+ name: `${t.name}${sector ? ` · sector ${sector}` : ''}${sector && sector === view.storm ? ' · storm' : ''}`,
+ })),
+ ),
+ ];
+}
+
+/** Reconstruct public world custody and read only the recipient's own balance.
+ * Ordinary arrival/advisor and promise helpers are shared with the engine. */
+export function junctionTransportChoice(
+ view: GameView,
+ destination: string,
+ sources: HomeworldShipmentSources,
+ allyPayment = 0,
+) {
+ const result: {
+ blocked: string | null;
+ cost: number;
+ amount: number;
+ elite: number;
+ ownPayment: number;
+ allyPayment: number;
+ advisors: boolean;
+ action: Action | null;
+ } = {
+ blocked: null,
+ cost: 0,
+ amount: 0,
+ elite: 0,
+ ownPayment: 0,
+ allyPayment,
+ advisors: false,
+ action: null,
+ };
+ const me = view.players.find((player) => player.id === view.me);
+ const option = view.junctionTransport;
+ const offer = option?.offer;
+ if (
+ !me ||
+ !option ||
+ !offer ||
+ option.recipient !== me.id ||
+ offer.recipient !== me.id ||
+ offer.owner !== option.owner ||
+ offer.turn !== view.turn ||
+ view.status !== 'playing' ||
+ view.phase !== 5 ||
+ view.active !== me.id ||
+ me.shipped
+ ) {
+ result.blocked =
+ 'Guild must offer Junction transport during your unused shipment turn.';
+ return result;
+ }
+ if (option.blocked) {
+ result.blocked = option.blocked;
+ return result;
+ }
+ const custody: HomeworldCustody = { salusa: null, visitors: {} };
+ for (const world of view.homeworlds?.worlds ?? []) {
+ if (world.secondary) custody.salusa = { ...world.forces[world.native] };
+ const visitors = Object.entries(world.forces).filter(
+ ([id]) => id !== world.native,
+ );
+ if (visitors.length)
+ custody.visitors[world.id] = Object.fromEntries(
+ visitors.map(([id, forces]) => [id, { ...forces }]),
+ );
+ }
+ try {
+ const quote = quoteJunctionTransport(
+ {
+ advanced: view.advanced,
+ storm: view.storm,
+ mobileStronghold: view.mobileStronghold?.location ?? null,
+ players: view.players.map((p) => ({
+ id: p.id,
+ faction: p.faction,
+ reserves: p.reserves,
+ eliteReserves: p.elites?.reserves ?? 0,
+ ally: p.ally ?? null,
+ })),
+ board: {
+ [me.id]: {
+ forces: me.forces,
+ eliteForces: me.elites?.forces ?? {},
+ advisors: me.advisors,
+ },
+ },
+ },
+ custody,
+ {
+ player: me.id,
+ sponsor: offer.owner,
+ destination,
+ sources,
+ rate: offer.rate,
+ },
+ );
+ Object.assign(result, {
+ cost: quote.cost,
+ amount: quote.amount,
+ elite: quote.elite,
+ ownPayment: quote.cost - allyPayment,
+ });
+ const arrival =
+ quote.destinationKind === 'arrakis' ? splitLocation(destination) : null;
+ if (arrival) {
+ result.advisors = arrivalAsAdvisor(
+ view,
+ me,
+ arrival.territory,
+ quote.originKind === 'arrakis' ? quote.origin : undefined,
+ );
+ const entryBlock = territoryEntryBlock(
+ view.players,
+ me.id,
+ arrival.territory,
+ result.advisors,
+ );
+ if (arrival.territory === MOBILE_STRONGHOLD && me.faction !== 'ixians')
+ result.blocked =
+ 'Only Ixians may ship directly into the mobile stronghold.';
+ else if (arrival.sector !== 0 && arrival.sector === view.storm)
+ result.blocked = 'That sector is in storm.';
+ else if (entryBlock) result.blocked = entryBlock;
+ else if (
+ quote.originKind === 'arrakis' &&
+ !result.advisors &&
+ me.advisors?.[quote.origin]?.lockedTurn === view.turn &&
+ view.players.some(
+ (p) => p.id !== me.id && forceCount(p, arrival.territory),
+ )
+ )
+ result.blocked = 'New advisors cannot become fighters this turn.';
+ }
+ const nativeOrigin =
+ quote.originKind === 'homeworld' &&
+ Object.keys(sources).every((key) =>
+ view.homeworlds?.worlds?.some(
+ (w) => w.id === key && w.native === me.id,
+ ),
+ );
+ const promiseShipment =
+ arrival && nativeOrigin
+ ? { territory: arrival.territory, amount: quote.amount }
+ : null;
+ if (
+ !result.blocked &&
+ liveShipmentPromises(view.shipmentPromises ?? [], me.id, view.turn).some(
+ (promise) =>
+ matchesShipment(promise, promiseShipment) !== promise.answer,
+ )
+ )
+ result.blocked =
+ 'This transport conflicts with your binding shipment answer.';
+ if (
+ !result.blocked &&
+ (!Number.isSafeInteger(allyPayment) ||
+ allyPayment < 0 ||
+ allyPayment > quote.cost ||
+ allyPayment > (me.ally ? view.aid.available : 0))
+ )
+ result.blocked =
+ 'Choose an allied contribution within the pledge and shipment price.';
+ if (!result.blocked && result.ownPayment > (me.spice ?? 0))
+ result.blocked =
+ 'Your spice and selected allied contribution cannot pay this transport.';
+ if (!result.blocked)
+ result.action = {
+ type: 'junctionShip',
+ event: option.event,
+ offer: offer.event,
+ destination,
+ sources,
+ allyPayment,
+ };
+ } catch (error) {
+ if (!(error instanceof HomeworldCustodyError)) throw error;
+ result.blocked = error.message;
+ }
+ return result;
+}
+
+/** Sponsors offer once per opportunity. Recipients compare public targets,
+ * preserve useful armies and increase their battle reserve with difficulty. */
+export function junctionTransportActions(
+ view: GameView,
+ level: number,
+): Action[] {
+ const me = view.players.find((p) => p.id === view.me);
+ const option = view.junctionTransport;
+ if (!me || !option) return [];
+ const difficulty = Math.max(0, Math.min(3, Math.trunc(level)));
+ if (me.id === option.owner) {
+ const rate =
+ me.ally === option.recipient || difficulty === 0 ? 'half' : 'full';
+ return option.canOffer && !option.offer
+ ? [{ type: 'offerJunctionTransport', event: option.offerEvent, rate }]
+ : [];
+ }
+ if (
+ me.id !== option.recipient ||
+ !option.offer ||
+ option.blocked ||
+ me.shipped ||
+ view.active !== me.id ||
+ view.phase !== 5
+ )
+ return [];
+ const budget = Math.max(
+ 0,
+ (me.spice ?? 0) +
+ (me.ally ? view.aid.available : 0) -
+ [1, 2, 3, 3][difficulty],
+ );
+ const spiceAt = (territory: string) =>
+ Object.entries(view.spice).reduce(
+ (sum, [key, amount]) =>
+ sum + (splitLocation(key).territory === territory ? amount : 0),
+ 0,
+ );
+ const targets = junctionTransportDestinations(view)
+ .map((target) => {
+ const world = view.homeworlds?.worlds?.find((w) => w.id === target.id);
+ const location = world ? null : splitLocation(target.id);
+ const t = location
+ ? gameTerritories(view).find((t) => t.id === location.territory)
+ : null;
+ const own = world
+ ? (world.forces[me.id]?.normal ?? 0) + (world.forces[me.id]?.elite ?? 0)
+ : forceCount(me, location!.territory);
+ const enemy = world
+ ? Object.entries(world.forces)
+ .filter(([id]) => id !== me.id && id !== me.ally)
+ .reduce(
+ (n, [, pool]) =>
+ n + pool.normal + pool.elite * (view.advanced ? 2 : 1),
+ 0,
+ ) +
+ (world.native !== me.id &&
+ world.forces[world.native].normal + world.forces[world.native].elite >
+ 0
+ ? world.nativeBattleStrength
+ : 0)
+ : view.players
+ .filter((p) => p.id !== me.id && p.id !== me.ally)
+ .reduce((n, p) => n + forceCount(p, location!.territory), 0);
+ const defend =
+ world?.native === me.id && (world.side === 'low' || enemy > 0);
+ const score =
+ (defend
+ ? 20
+ : world?.native === me.id
+ ? -20
+ : t?.type === 'stronghold'
+ ? 12
+ : world
+ ? 5
+ : spiceAt(location!.territory) > 0
+ ? 7
+ : -8) +
+ (own > 0 && enemy > 0 ? 8 : 0) -
+ enemy;
+ return { ...target, own, enemy, score };
+ })
+ .filter((t) => t.score > 0)
+ .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
+ const actions: Action[] = [];
+ const seen = new Set();
+ for (const target of targets)
+ for (const origin of junctionTransportOrigins(view)) {
+ const pools = origin.pools.filter(
+ (pool) =>
+ pool.sector === null ||
+ pool.sector === 0 ||
+ pool.sector !== view.storm,
+ );
+ const total = pools.reduce((n, pool) => n + pool.normal + pool.elite, 0);
+ const t = gameTerritories(view).find((t) => t.id === origin.id);
+ const retain = origin.native
+ ? [3, 4, 5, 6][difficulty]
+ : t?.type === 'stronghold'
+ ? [2, 3, 4, 4][difficulty]
+ : spiceAt(origin.id) > 0
+ ? Math.min(total, Math.ceil(spiceAt(origin.id) / 2))
+ : 0;
+ const desired = Math.max(
+ [2, 3, 4, 5][difficulty],
+ target.enemy + [0, 1, 2, 3][difficulty] - target.own,
+ );
+ for (
+ let amount = Math.min(
+ total - retain,
+ desired,
+ [4, 6, 8, 10][difficulty],
+ );
+ amount > 0;
+ amount--
+ ) {
+ if (amount + target.own < target.enemy + (difficulty > 0 ? 1 : 0))
+ break;
+ let left = amount;
+ const sources: HomeworldShipmentSources = {};
+ for (const pool of pools) {
+ const normal = Math.min(left, pool.normal);
+ left -= normal;
+ const elite = Math.min(left, pool.elite);
+ left -= elite;
+ if (normal + elite) sources[pool.key] = { normal, elite };
+ }
+ const initial = junctionTransportChoice(view, target.id, sources);
+ if (initial.cost > budget) continue;
+ const quote = junctionTransportChoice(
+ view,
+ target.id,
+ sources,
+ Math.max(0, initial.cost - (me.spice ?? 0)),
+ );
+ if (quote.action) {
+ const key = JSON.stringify(quote.action);
+ if (!seen.has(key)) {
+ actions.push(quote.action);
+ seen.add(key);
+ }
+ break;
+ }
+ }
+ }
+ return actions;
+}
diff --git a/game/junction-transport.ts b/game/junction-transport.ts
new file mode 100644
index 00000000..363de11c
--- /dev/null
+++ b/game/junction-transport.ts
@@ -0,0 +1,348 @@
+import {
+ MOBILE_LOCATION,
+ splitLocation,
+ territory,
+ validLocation,
+} from './board';
+import {
+ HomeworldCustodyError,
+ homeworldForceGroups,
+ quoteHomeworldCustody,
+ type HomeworldCustody,
+ type HomeworldCustodyChange,
+ type HomeworldForces,
+} from './homeworld-custody';
+import { homeworldPopulations } from './homeworld-population';
+import type { HomeworldShipmentContext } from './homeworld-shipment';
+
+export type JunctionTransportContext = HomeworldShipmentContext & {
+ storm: number;
+ board: Record<
+ string,
+ {
+ /** All physical counters, including the elite subset. */
+ forces: Record;
+ eliteForces: Record;
+ advisors?: Record;
+ }
+ >;
+ /** Ordinary Arrakis pointer, never the mobile interior itself. */
+ mobileStronghold?: string | null;
+};
+export type JunctionTransportIntent = {
+ player: string;
+ sponsor: string;
+ destination: string;
+ sources: Record;
+ rate: 'half' | 'full';
+};
+export type JunctionTransportSource = {
+ key: string;
+ before: HomeworldForces;
+ after: HomeworldForces;
+};
+const record = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+const whole = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
+function requireTransport(
+ condition: unknown,
+ message: string,
+): asserts condition {
+ if (!condition) throw new HomeworldCustodyError(message);
+}
+function canonicalSector(key: string) {
+ const { territory: id, sector } = splitLocation(key);
+ return key === `${id}:${sector}` && validLocation(id, sector);
+}
+const eliteLimit = (faction: string) =>
+ faction === 'emperor'
+ ? 5
+ : faction === 'fremen'
+ ? 3
+ : faction === 'ixians'
+ ? 7
+ : 0;
+
+/** Native population gate only; retained occupation effects need engine policy. */
+export function junctionSponsorEligible(
+ context: HomeworldShipmentContext,
+ custody: HomeworldCustody,
+ sponsor: string,
+) {
+ const player = context.players.find((p) => p.id === sponsor);
+ return (
+ player?.faction === 'guild' &&
+ homeworldPopulations(context, custody).some(
+ (p) =>
+ p.location === 'homeworld:guild' &&
+ p.native === sponsor &&
+ p.side === 'high',
+ )
+ );
+}
+
+/** Physical quote for an already authorized Junction transport offer.
+ * Native-high checks use current population only; occupation eligibility,
+ * timing, once-per-turn usage, offer identity and payment remain caller duties.
+ * Arrakis outputs withdraw sources only: the caller must validate capacity,
+ * mobile entry and advisor stance before placing the arrival. Paid transport
+ * rejects ordinary storm sectors; the protected mobile interior is sector zero.
+ * No concealed force, private hand, other player's board or spice is inspected. */
+export function quoteJunctionTransport(
+ context: JunctionTransportContext,
+ custody: HomeworldCustody,
+ intent: JunctionTransportIntent,
+) {
+ const worlds = homeworldForceGroups(context, custody);
+ requireTransport(
+ whole(context.storm) && context.storm <= 18 && record(context.board),
+ 'Junction transport needs the current public board and storm.',
+ );
+ requireTransport(
+ context.players.every(
+ (p) =>
+ p.ally === null ||
+ (typeof p.ally === 'string' &&
+ p.ally !== p.id &&
+ context.players.some((other) => other.id === p.ally)),
+ ),
+ 'Junction transport needs valid seated alliance identities.',
+ );
+ requireTransport(
+ record(intent) &&
+ Object.keys(intent).length === 5 &&
+ typeof intent.player === 'string' &&
+ typeof intent.sponsor === 'string' &&
+ typeof intent.destination === 'string' &&
+ record(intent.sources) &&
+ (intent.rate === 'half' || intent.rate === 'full'),
+ 'Choose a recipient, Guild sponsor, destination, typed sources and transport rate.',
+ );
+ const player = context.players.find((p) => p.id === intent.player);
+ const sponsor = context.players.find((p) => p.id === intent.sponsor);
+ requireTransport(
+ player && sponsor?.faction === 'guild' && player.id !== sponsor.id,
+ 'Junction transport must be granted by the seated Guild to another faction.',
+ );
+ requireTransport(
+ junctionSponsorEligible(context, custody, sponsor.id),
+ 'Junction transport requires its current native high population.',
+ );
+ const pointer = context.mobileStronghold;
+ requireTransport(
+ pointer === undefined ||
+ pointer === null ||
+ (typeof pointer === 'string' &&
+ canonicalSector(pointer) &&
+ pointer !== MOBILE_LOCATION),
+ 'The mobile stronghold needs a canonical Arrakis pointer.',
+ );
+ const destinationWorld = worlds.find((w) => w.id === intent.destination);
+ requireTransport(
+ destinationWorld ||
+ (canonicalSector(intent.destination) &&
+ (intent.destination !== MOBILE_LOCATION || !!pointer)),
+ 'Choose an active Homeworld or canonical Arrakis destination.',
+ );
+ const destinationKind = destinationWorld
+ ? ('homeworld' as const)
+ : ('arrakis' as const);
+ if (!destinationWorld) {
+ const sector = splitLocation(intent.destination).sector;
+ requireTransport(
+ sector === 0 || sector !== context.storm,
+ 'The destination is in storm.',
+ );
+ }
+ if (destinationWorld) {
+ const native = context.players.find(
+ (p) => p.id === destinationWorld.native,
+ )!;
+ requireTransport(
+ !(player.ally === native.id && native.ally === player.id),
+ 'You cannot ship to your ally’s Homeworld.',
+ );
+ }
+ const board = Object.hasOwn(context.board, player.id)
+ ? context.board[player.id]
+ : undefined;
+ requireTransport(
+ record(board) && record(board.forces) && record(board.eliteForces),
+ 'Junction transport needs the recipient’s current physical board counters.',
+ );
+ let boardTotal = 0;
+ let boardElite = 0;
+ for (const [key, count] of Object.entries(board.forces)) {
+ requireTransport(
+ canonicalSector(key) &&
+ whole(count) &&
+ count <= 20 &&
+ (key !== MOBILE_LOCATION || !!pointer),
+ 'Junction transport needs canonical physical board counts.',
+ );
+ boardTotal += count;
+ }
+ for (const [key, count] of Object.entries(board.eliteForces)) {
+ requireTransport(
+ canonicalSector(key) &&
+ whole(count) &&
+ count <= (Object.hasOwn(board.forces, key) ? board.forces[key] : 0),
+ 'Special counters must be a subset of the physical force group.',
+ );
+ boardElite += count;
+ }
+ const worldTotals = worlds.reduce(
+ (sum, w) => {
+ const group = w.forces[player.id];
+ return {
+ total: sum.total + (group?.normal ?? 0) + (group?.elite ?? 0),
+ elite: sum.elite + (group?.elite ?? 0),
+ };
+ },
+ { total: 0, elite: 0 },
+ );
+ requireTransport(
+ boardTotal + worldTotals.total <= 20 &&
+ boardElite + worldTotals.elite <= eliteLimit(player.faction),
+ 'Board and Homeworld custody exceed the faction’s physical counter supply.',
+ );
+ const entries = Object.entries(intent.sources);
+ requireTransport(
+ entries.length > 0,
+ 'Choose a positive physical source group.',
+ );
+ const originWorld = worlds.find((w) => w.id === entries[0][0]);
+ const originKind = originWorld
+ ? ('homeworld' as const)
+ : ('arrakis' as const);
+ if (originWorld)
+ requireTransport(
+ entries.length === 1 ||
+ (entries.length === 2 &&
+ context.advanced &&
+ player.faction === 'emperor' &&
+ entries.every(([key]) =>
+ ['homeworld:emperor', 'homeworld:emperor:salusa'].includes(key),
+ )),
+ 'Use one source Homeworld, or combine Emperor’s native Kaitain and Salusa groups.',
+ );
+ const boardForces = { ...board.forces };
+ const boardEliteForces = { ...board.eliteForces };
+ const changes: HomeworldCustodyChange[] = [];
+ const sources: JunctionTransportSource[] = [];
+ let origin: string | undefined;
+ let normal = 0;
+ let elite = 0;
+ for (const [key, selected] of entries) {
+ requireTransport(
+ record(selected) &&
+ Object.keys(selected).length === 2 &&
+ whole(selected.normal) &&
+ whole(selected.elite) &&
+ selected.normal + selected.elite > 0 &&
+ selected.normal + selected.elite <= 20,
+ 'Each source needs positive typed physical normal and special counters.',
+ );
+ normal += selected.normal;
+ elite += selected.elite;
+ if (originWorld) {
+ requireTransport(
+ worlds.some((w) => w.id === key) && key !== intent.destination,
+ 'Transport needs a distinct active source Homeworld.',
+ );
+ changes.push({
+ homeworld: key,
+ player: player.id,
+ withdraw: { ...selected },
+ deposit: { normal: 0, elite: 0 },
+ });
+ origin ??= key;
+ } else {
+ requireTransport(
+ canonicalSector(key) && (key !== MOBILE_LOCATION || !!pointer),
+ 'Choose a canonical, active Arrakis source sector.',
+ );
+ const source = splitLocation(key);
+ requireTransport(
+ source.sector === 0 || source.sector !== context.storm,
+ 'The source is in storm.',
+ );
+ requireTransport(
+ origin === undefined || origin === source.territory,
+ 'A transport group must come from one Arrakis territory.',
+ );
+ origin = source.territory;
+ requireTransport(
+ !destinationWorld ||
+ player.faction !== 'beneGesserit' ||
+ !board.advisors?.[source.territory],
+ 'Bene Gesserit advisors cannot ship to a Homeworld.',
+ );
+ const total = Object.hasOwn(board.forces, key) ? board.forces[key] : 0;
+ const special = Object.hasOwn(board.eliteForces, key)
+ ? board.eliteForces[key]
+ : 0;
+ const before = { normal: total - special, elite: special };
+ requireTransport(
+ selected.normal <= before.normal && selected.elite <= before.elite,
+ 'The selected Arrakis sector does not hold those typed physical counters.',
+ );
+ const after = {
+ normal: before.normal - selected.normal,
+ elite: before.elite - selected.elite,
+ };
+ if (after.normal + after.elite)
+ boardForces[key] = after.normal + after.elite;
+ else delete boardForces[key];
+ if (after.elite) boardEliteForces[key] = after.elite;
+ else delete boardEliteForces[key];
+ sources.push({ key, before, after });
+ }
+ }
+ const amount = normal + elite;
+ requireTransport(
+ amount <= 20,
+ 'A transport cannot exceed twenty physical counters.',
+ );
+ if (!destinationWorld && !originWorld)
+ requireTransport(
+ splitLocation(intent.destination).territory !== origin,
+ 'Cross transport needs a different destination territory.',
+ );
+ if (destinationWorld)
+ changes.push({
+ homeworld: destinationWorld.id,
+ player: player.id,
+ withdraw: { normal: 0, elite: 0 },
+ deposit: { normal, elite },
+ });
+ const transferred = quoteHomeworldCustody(context, custody, changes);
+ if (originWorld)
+ sources.push(
+ ...transferred.receipts
+ .filter((r) => r.homeworld !== intent.destination)
+ .map((r) => ({
+ key: r.homeworld,
+ before: { ...r.before },
+ after: { ...r.after },
+ })),
+ );
+ const fullCost =
+ destinationWorld ||
+ territory(splitLocation(intent.destination).territory).type === 'stronghold'
+ ? amount
+ : amount * 2;
+ return {
+ ...transferred,
+ boardForces,
+ boardEliteForces,
+ sources,
+ origin: origin!,
+ originKind,
+ destinationKind,
+ amount,
+ elite,
+ cost: intent.rate === 'half' ? Math.ceil(fullCost / 2) : fullCost,
+ };
+}
diff --git a/game/karama-movement-cancellation.ts b/game/karama-movement-cancellation.ts
index 0c38a551..acfcad0f 100644
--- a/game/karama-movement-cancellation.ts
+++ b/game/karama-movement-cancellation.ts
@@ -1,4 +1,5 @@
import { validAmbassadorResume } from './ambassador-resume';
+import { ambassadorPhaseAllowed } from './ambassador-phase';
import {
validateGuildAmbassadorArrivalContext,
GuildAmbassadorContinuationError,
@@ -14,6 +15,10 @@ import {
} from './board';
import { presenceAt } from './force-presence';
import { validateAmbassadors } from './ecaz-ambassadors';
+import {
+ homeworldMobileStrongholdMovementBlock,
+ homeworldSpiritualAdvisorLimit,
+} from './homeworld-mobility';
export class MovementCancellationError extends Error {
constructor(message: string) {
@@ -148,6 +153,11 @@ export type AmbassadorRelocationContext = Pick<
| 'players'
| 'pendingAmbassador'
| 'ecazAmbassadors'
+ | 'homeworlds'
+ | 'homeworldRevivalReturn'
+ | 'homeworldRevivalProgress'
+ | 'homeworldVictoryReinforcement'
+ | 'lastBattleContext'
>;
/** Historical receipt only: a child may already have changed alliances, forces
@@ -163,7 +173,7 @@ export function validateAmbassadorRelocationContext(
g.status === 'playing' &&
integer(g.turn) &&
g.turn > 0 &&
- (g.phase === 1 || g.phase === 5) &&
+ ambassadorPhaseAllowed(g) &&
Array.isArray(g.players) &&
new Set(g.players.map((p) => p.id)).size === g.players.length &&
entry &&
@@ -343,6 +353,8 @@ export function quoteMovementCancellation(
};
}
if (response.kind === 'mobileStronghold') {
+ const blocked = homeworldMobileStrongholdMovementBlock(g, owner.id);
+ requireContext(!blocked, blocked ?? 'This stronghold cannot move.');
const pending = g.pendingMobileMove;
requireContext(
g.phase === 0 &&
@@ -458,7 +470,10 @@ export function quoteMovementCancellation(
);
return {
kind: 'advisorFlip',
- successor: owner.reserves > 0 ? 'advisor' : 'none',
+ successor:
+ homeworldSpiritualAdvisorLimit(g, owner.id, 'polar_sink') > 0
+ ? 'advisor'
+ : 'none',
};
}
if (response.advisorResume === 'declaration') {
diff --git a/game/moritani-alliance-cancellation.ts b/game/moritani-alliance-cancellation.ts
index 6d621d8b..6c999bb7 100644
--- a/game/moritani-alliance-cancellation.ts
+++ b/game/moritani-alliance-cancellation.ts
@@ -9,6 +9,7 @@ import {
MovementCancellationError,
} from './karama-movement-cancellation';
import { TERROR_KINDS, TERROR_STRONGHOLDS } from './moritani-terror';
+import { validateTerrorEntrySignature } from './terror-entry-receipt';
export class MoritaniAllianceCancellationError extends Error {
constructor(message: string) {
@@ -80,6 +81,11 @@ export function quoteMoritaniAllianceCancellation(
'This Enemy of My Enemy opportunity is no longer current.',
);
const entrants = g.players.filter((p) => p.id === entry.entrant);
+ try {
+ validateTerrorEntrySignature(entry);
+ } catch (error) {
+ throw new MoritaniAllianceCancellationError((error as Error).message);
+ }
requireSource(
entrants.length === 1 &&
entrants[0].id !== response.owner &&
diff --git a/game/movement-phase-quote.ts b/game/movement-phase-quote.ts
index e44c0ebb..88b1c334 100644
--- a/game/movement-phase-quote.ts
+++ b/game/movement-phase-quote.ts
@@ -3,6 +3,7 @@ import { SPICE_CARDS, type SpiceCard } from './cards';
import { gameTerritories } from './board';
import { presenceAt } from './force-presence';
import { ecazOccupancyRelation } from './ecaz-occupy';
+import { homeworldMovementForesightBlock } from './homeworld-mobility';
import {
quoteBattleBoard,
type BoardContext,
@@ -26,6 +27,7 @@ type Context = Pick<
| 'mobileStronghold'
| 'spiceDeck'
| 'spiceDiscard'
+ | 'homeworlds'
>;
type AdvisorContext = BoardContext & { turn: number };
const whole = (n: unknown): n is number =>
@@ -164,7 +166,10 @@ export function quoteMovementPhaseStart(
phaseOpening: { passed: [], initialize: true },
};
const advisor = quoteAdvisorBattleOffer(g);
- const atreides = g.players.find((p) => p.faction === 'atreides'),
+ const atreides = g.players.find(
+ (p) =>
+ p.faction === 'atreides' && !homeworldMovementForesightBlock(g, p.id),
+ ),
guild = g.players.find((p) => p.faction === 'guild');
let refill = false,
hasCards = false;
diff --git a/game/reference.ts b/game/reference.ts
index 198ece23..f7a1269f 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -2,6 +2,7 @@ import { STRONGHOLD_CARDS } from './stronghold-cards';
import { FACTION_RULES } from './faction-reference';
import { FACTIONS, PHASES } from './catalog';
import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
+import { HOMEWORLD_CARDS } from './homeworld-cards';
export const RULE_CHECKLIST_AREAS = [
'Implementation',
@@ -28,6 +29,7 @@ export type RuleTopic = {
| 'Advanced & expansions';
coverage: 'Implemented' | 'Partial' | 'Planned';
summary: string;
+ searchText?: string;
steps: string[];
example?: string;
related?: string[];
@@ -121,6 +123,7 @@ const phaseDetails = [
'The highlighted player may raise the bid or pass. A new high bid clears previous passes.',
'Holding Karama permits a bid above your available spice. If outbid, keep the card. A winning bid above your funds requires Karama.',
'After everyone else passes, the winner chooses spice payment or Karama. The table then resolves applicable Emperor-income and Harkonnen-bonus responses.',
+ 'In Homeworld development tables with Emperor, the last completed auction opens End of Bidding. High Kaitain paid discards and CHOAM closing sales or trades may occur in either order. Finish your opportunity when ready; another card action clears readiness before the phase advances.',
],
[
'Choose the number of forces to revive. The server tracks the free allowance and the phase limit across multiple requests.',
@@ -136,7 +139,7 @@ const phaseDetails = [
'A Fremen move using the two-territory advantage has a Karama response. Cancellation leaves the forces in place and limits this move to one territory; city ornithopters and a later extra move keep their normal rules.',
'Guild transport can move a group across the planet or return it to reserves. Ordinary movement is checked against range, storm and stronghold occupancy.',
'You may pass through an ally’s territory on a legal route that ends elsewhere. The printed clarification also permits shipping into allied territory followed by departure; that temporary arrival and its departure obligation are not yet supported by the table.',
- 'Ecaz and its reciprocal ally may share territories permanently and count as one faction toward stronghold capacity. This applies to normal shipment, ground routes and Ambassador arrivals. It does not grant extra movement, bypass storm or mobile-stronghold entry rules, or apply to homeworlds. Their three jointly occupied stronghold victory is supported; combined combat, shared desert collection and the Fremen endgame exception remain unfinished.',
+ 'Ecaz and its reciprocal ally may share territories permanently and count as one faction toward stronghold capacity. This applies to normal shipment, ground routes and Ambassador arrivals. It does not grant extra movement, bypass storm or mobile-stronghold entry rules, or apply to homeworlds. Their three jointly occupied stronghold victory is supported; shared desert allocation is also supported. Combined combat and the Fremen endgame exception remain unfinished.',
'A restored pending physical shipment is checked again before forces or spice are committed. If its saved turn, force allocation, destination, price or authorized contribution no longer matches the table, it is rejected without changing the saved declaration.',
'Finish shipment and movement explicitly. Any Bene Gesserit free-shipment decision and its response must finish first.',
'Advanced Guild may choose when to take its complete shipment-and-movement turn. The table offers a choice before another player starts. Guild cannot interrupt a turn already underway.',
@@ -150,9 +153,11 @@ const phaseDetails = [
'A winner with played cards chooses which to discard before the next battle or collection.',
],
[
- 'The game collects spice automatically when this phase begins.',
+ 'Ordinary collection resolves automatically when this phase begins. If Ecaz and its ally both collect from a desert territory, their shared pool waits for allocation.',
'Only forces in the sector containing spice collect it. Remaining spice stays on the board.',
'In the advanced game, an occupant receives two additional spice for Arrakeen, two for Carthag and one for Tuek’s Sietch. These payments stack and are separate from spice gathered by forces.',
+ 'For each shared desert pool, Ecaz can propose a split. The other ally may accept or counter; either decision owner may instead choose an equal split, with any odd spice going to Ecaz’s ally. Proposals remain private to the pair until settlement. The table cannot advance until every pool is allocated.',
+ 'Advanced Ecaz Collection gives both allies their full bank income in co-occupied Arrakeen, Carthag and Tuek’s Sietch. Karama prevents only Ecaz’s income from those shared strongholds; the ally’s income, Ecaz’s other stronghold income and desert allocation remain unchanged.',
'Review the table chronicle and your spice, then mark yourself ready.',
],
[
@@ -205,6 +210,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'automatic-decisions',
'ai-pacing',
'ecaz-modules',
+ 'homeworlds',
'advanced-combat',
],
},
@@ -1040,7 +1046,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
summary:
- 'End-of-Revival placement, eight random entry effects and direct Ecaz Duke acquisition are supported in development fixtures, with public token guides and private inspection history.',
+ 'End-of-Revival placement, eight random entry effects, direct Ecaz Duke acquisition and consensual alliance formation are supported in development fixtures, with public token guides and private inspection history.',
steps: [
'Ecaz has its reusable Ambassador plus five randomly selected Ambassadors from the other ten identities. Inspect the public supply, placed tokens, triggered group and unused pool to read each effect.',
'Placed Ambassadors are face up by designer clarification. This implementation also shows the unplaced supply openly by default; that supply policy is an interpretation, not an explicit official FAQ ruling.',
@@ -1048,8 +1054,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'The current closing order is CHOAM market, deferred technology income, then Ecaz placement. Income received at that point can fund placement. This combined order is an implementation choice, not a priority established by a combined official FAQ.',
'The first placement costs one spice, the second two, and so on that turn. A declaration opens a Karama response before spice is paid or the token moves. Canceling stops the remaining placement opportunity for that turn; previous placements and payments remain.',
'Finish Ambassador placement advances the phase. On a later turn the price begins at one again and the earlier placement block expires. Pending declarations survive reload without charging twice.',
- 'The custody model returns destroyed tokens to supply and returns Ecaz after its trigger. Supported entry effects set regular tokens aside, permanently remove Bene Gesserit after its trigger, and replenish only after all five random tokens have triggered. Storm crossings return tokens from exposed Arrakeen and Carthag after the Shield Wall is destroyed. A Lasgun–shield explosion returns a token only if the explosion actually resolves; an overriding traitor victory leaves it placed. Destruction does not count toward the five triggers. The reusable Ecaz Ambassador can acquire Duke directly for Ecaz; its alliance and loan alternatives remain unfinished.',
- 'When an eligible entrant reaches the reusable Ecaz token, Ecaz may choose Acquire Duke Vidal for Ecaz if the shared disc is available. The token immediately returns to supply, its five random companions are unchanged, and the entrant resumes. Ecaz keeps an unused Duke across turns until battle use or Moritani acquisition. Already controlling him, assigning this effect to an ally, alliance formation, loans and Advanced Harkonnen custody remain visibly unavailable while their interactions are unfinished.',
+ 'The custody model returns destroyed tokens to supply and returns Ecaz after its trigger. Supported entry effects set regular tokens aside, permanently remove Bene Gesserit after its trigger, and replenish only after all five random tokens have triggered. Storm crossings return tokens from exposed Arrakeen and Carthag after the Shield Wall is destroyed. A Lasgun–shield explosion returns a token only if the explosion actually resolves; an overriding traitor victory leaves it placed. Destruction does not count toward the five triggers. The reusable Ecaz Ambassador can acquire Duke directly for Ecaz; it also offers a consensual alliance, while Duke loans remain unfinished.',
+ 'When an eligible entrant reaches the reusable Ecaz token, Ecaz may choose Acquire Duke Vidal for Ecaz if the shared disc is available. The token immediately returns to supply, its five random companions are unchanged, and the entrant resumes. Ecaz keeps an unused Duke across turns until battle use or Moritani acquisition. Already controlling him, assigning this effect to an ally, loans and Advanced Harkonnen custody remain visibly unavailable while their interactions are unfinished.',
+ 'Instead of acquiring Duke, unallied Ecaz can offer an alliance to an unallied entrant. The entrant accepts or refuses, then resumes its remaining actions. The triggered token returns to supply in either case. Acceptance activates both factions’ alliance abilities immediately and leaves Duke unchanged. An unavailable Duke does not prevent this alliance choice. Duke loans remain unfinished pending their custody ruling.',
'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave the token in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Entries with competing arrival reactions remain blocked until their ordering is implemented; a rejected entry does not spend or move its pieces.',
'Emperor grants five bank spice. Atreides records the entrant’s current Treachery hand privately for the beneficiary; Harkonnen records one randomly selected held Traitor identity, or Face Dancer identity for a Tleilaxu entrant. These snapshots remain in the recipient’s private inspection history, identify when they were taken, and do not become a live view of later changes. Opening, enlarging or closing them needs no game acknowledgement.',
'CHOAM lets the beneficiary choose any available hand cards, including none, for three spice each. Ixians require one available card to discard before drawing its replacement. Card controls respect projected reservations. After committing an Ixian exchange, a voluntary interruption must leave a card available for its required discard; a queued Truthtrance counts as already committed. A Bene Gesserit trigger permanently removes its token, then the beneficiary chooses an available effect outside the original supply for this group; unsupported copies remain visibly blocked.',
@@ -1059,38 +1066,38 @@ export const RULE_TOPICS: RuleTopic[] = [
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
'No-Field substitution into the free four-force grant awaits the user’s interpretation and is unavailable. Simultaneous Intrusion/Terror and Ambassador/Terror combinations remain blocked. Moritani Atomics Aftermath, Homeworlds and Discovery locations require their still-gated producers or modules; this checkpoint does not claim those combinations.',
- 'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition through the reusable Ecaz token. The remaining effects, Occupy, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
+ 'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. The remaining effects, combined Occupy combat, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
],
checklist: [
{
area: 'Implementation',
status: 'Partial',
detail:
- 'Inventory, placement and eight entry effects plus their Bene Gesserit copies and direct Ecaz Duke acquisition are integrated. Storm/explosion token returns are integrated. Competing arrival ordering and remaining effects remain unfinished.',
+ 'Inventory, placement and eight entry effects plus their Bene Gesserit copies and direct Ecaz Duke acquisition or consensual alliance are integrated. Storm/explosion token returns are integrated. Competing arrival ordering and remaining effects remain unfinished.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
- 'Placement and entry controls show valid destinations, beneficiaries, copies and card choices with blocked reasons. Relocation controls select physical and elite forces by source sector, concealed markers and eligible advisor flips. Independent Guild shipment controls select typed reserve counts, destination sector or zero; BG accompaniment controls show source-specific blocked reasons. Public token guides and recipient-only historical card/identity inspectors remain available without acknowledgement.',
+ 'Placement and entry controls show valid destinations, beneficiaries, copies and card choices with blocked reasons. Ecaz can offer an alliance to an eligible entrant, whose separate accept/refuse controls preserve decision ownership. Relocation controls select physical and elite forces by source sector, concealed markers and eligible advisor flips. Independent Guild shipment controls select typed reserve counts, destination sector or zero; BG accompaniment controls show source-specific blocked reasons. Public token guides and recipient-only historical card/identity inspectors remain available without acknowledgement.',
},
{
area: 'AI',
status: 'Partial',
detail:
- 'All four profiles handle placement, supported trigger/beneficiary choices, copied effects, legal CHOAM/Ixian card selection, Fremen relocation, Guild reserve shipment and its BG accompaniment from their own projections. Unsupported effects are declined; remaining effect policies are unfinished.',
+ 'All four profiles handle placement, supported trigger/beneficiary choices, copied effects, legal CHOAM/Ixian card selection, Fremen relocation, Guild reserve shipment and its BG accompaniment from their own projections. They prefer an available Ecaz alliance offer and accept a valid owned reply; direct Duke acquisition remains the fallback when a new alliance is unavailable. Unsupported effects are declined; remaining effect policies are unfinished.',
},
{
area: 'Documentation',
status: 'Partial',
detail:
- 'Placement, supported entry decisions, private historical inspections, inventory lifecycle and all eleven effect descriptions are available. Complete timing and interaction coverage remains open.',
+ 'Placement, supported entry decisions, private historical inspections, inventory lifecycle and all eleven effect descriptions are available. The Ecaz guide distinguishes consent, token commitment and Duke loans awaiting a custody ruling. Complete timing and interaction coverage remains open.',
},
{
area: 'Verification',
status: 'Partial',
detail:
- 'Focused tests cover custody, placement payment, entry continuation, private snapshots, effect choices, destruction and all four bot profiles. Persisted tests cover concurrent triggers, private snapshot restoration, and stale-event rejection; browser checks verified entry and enlarged private cards. Guild-focused tests cover physical quotes, historical continuations, cancellation, resume provenance, actual engine journeys and all four AI profiles. Guild multiplayer and browser verification are pending at this documentation checkpoint. Complete expansion games and competing arrivals remain.',
+ 'Focused tests cover custody, placement payment, entry continuation, private snapshots, effect choices, destruction and all four bot profiles. Persisted tests cover concurrent triggers, private snapshot restoration, and stale-event rejection; browser checks verified entry and enlarged private cards. Guild-focused tests cover physical quotes, historical continuations, cancellation, resume provenance, actual engine journeys and all four AI profiles. Guild multiplayer and browser verification are pending at this documentation checkpoint. Ecaz alliance tests cover both consent outcomes, real arrival and worm continuations, card suspension, private-state preservation and racing replies; browser checks confirm keyboard proposal and AI acceptance. Complete expansion games and competing arrivals remain.',
evidence: [
'tests/ecaz-ambassadors.test.ts',
'tests/ecaz-placement.test.ts',
@@ -1098,6 +1105,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/ecaz-duke-acquisition.test.ts',
'tests/ecaz-duke-bots.test.ts',
'tests/ecaz-duke-recovery.test.ts',
+ 'tests/ecaz-alliance.test.ts',
+ 'tests/ecaz-alliance-engine.test.ts',
+ 'tests/ecaz-alliance-recovery.test.ts',
'tests/ecaz-entry-bots.test.ts',
'tests/ecaz-entry-continuations.test.ts',
'tests/ecaz-entry-recovery.test.ts',
@@ -2202,7 +2212,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Begin with six forces in the Imperial Basin, fourteen in reserves and twelve spice. Two force revivals are free.',
'Your Ambassador supply contains the Ecaz token and five random tokens from the remaining pool. Inspect the linked Ambassador guide for all eleven identities and their custody rules.',
'End-of-Revival placement has owner controls, escalating prices, cancellation responses and bot support in development fixtures. Full faction starts remain disabled.',
- 'Eight entry effects and their Bene Gesserit copies, plus direct Ecaz Duke acquisition, have focused support. Storm/explosion token returns, ordinary territorial co-occupation and the three jointly occupied stronghold victory are also supported. Tleilaxu revival, combined Occupy battles, shared desert collection, the Fremen endgame exception and the complete Duke revival and custody interactions remain unfinished. Consult the feature checklist for the exact tested boundary.',
+ 'Eight entry effects and their Bene Gesserit copies, plus direct Ecaz Duke acquisition, have focused support. Storm/explosion token returns, ordinary territorial co-occupation and the three jointly occupied stronghold victory are also supported. Shared desert allocation and Advanced Collection income, including cancellation, are also supported in development fixtures. Tleilaxu revival, combined Occupy battles, the Fremen endgame exception and the complete Duke revival and custody interactions remain unfinished. Consult the feature checklist for the exact tested boundary.',
]
: f.id === 'moritani'
? [
@@ -2270,7 +2280,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Karama can prevent Moritani from acquiring Vidal. That cancellation does not remove him once Moritani already controls him.',
'Ecaz gains Vidal through its Ecaz Ambassador if he is neither in the Tanks, captured nor a ghola. Ecaz keeps him until battle use or Moritani takes him. The Ambassador can instead form a consensual alliance when both factions are unallied; Ecaz may then lend Vidal to that new ally for the turn.',
'Only Ecaz may revive Vidal, including with Ghola Treachery. Ecaz may revive him for five spice regardless of how many of its leaders are in the Tanks. Five spice is his special revival cost; his printed battle strength is six. Ecaz may begin ordinary leader revival when five leaders are in the Tanks, counting Vidal, even if it still holds another leader.',
- 'The current implementation supports Moritani acquisition, direct Ecaz Ambassador self-acquisition and one-battle use. Ecaz alliance/loan alternatives, reacquisition while already controlled, revival and captured-leader interactions remain unfinished. This shared component does not enable complete Ecaz or Moritani games. Inspection does not commit a battle plan.',
+ 'The current implementation supports Moritani acquisition, direct Ecaz Ambassador self-acquisition, consensual Ambassador alliances and one-battle use. Duke loans, reacquisition while already controlled, revival and captured-leader interactions remain unfinished. This shared component does not enable complete Ecaz or Moritani games. Inspection does not commit a battle plan.',
],
related: [
'faction-ecaz',
@@ -3086,6 +3096,217 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['faction-beneGesserit', 'movement', 'battle', 'victory'],
},
+ {
+ id: 'homeworlds',
+ title: 'Homeworlds: population and occupation',
+ category: 'Advanced & expansions',
+ coverage: 'Partial',
+ summary:
+ 'Inspect all thirteen Homeworld cards, their population faces and occupied effects. Gameplay integration is in development.',
+ searchText: HOMEWORLD_CARDS.map((card) =>
+ [
+ card.name,
+ ...card.high.gameplay,
+ ...card.low.gameplay,
+ ...card.occupied.gameplay,
+ ].join(' '),
+ ).join(' '),
+ steps: [
+ 'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
+ 'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
+ 'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
+ 'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
+ 'A qualifying charity claim separates its ordinary payer from the extra bank spice. Inflation doubles both portions or prevents the whole collection. Canceling Bene Gesserit charity prevents both portions of that claim.',
+ 'Quote a group of normal revivals using population before that group returns. A later group uses the resulting population: a low Fremen group can return four free forces, but returning a smaller group that restores high population can remove the fourth allowance. La La La prevents the full free rate.',
+ 'When Southern Hemisphere begins a supported revival at high population, Fremen may place the newly revived starred forces together in one territory already containing their forces, or leave them in reserves. This includes supported ordinary, Emperor-funded and Ghola revivals. Basic keeps the starred counters; Advanced still limits Fedaykin revival to one per turn.',
+ 'When Tleilax begins ordinary Free Revival at high population, Tleilaxu may send the newly revived free group together to one permitted territory or Homeworld, or leave it in reserves. Paid counters in the same return and Ghola returns do not join this group. An allied Homeworld is forbidden. Placement costs no additional spice and uses no ordinary shipment or movement allowance.',
+ 'The revival placement choice shows unavailable destinations. A return that first restores high population, partial groups, split destinations, Southern storm placement and entry effects requiring a shipment classification remain guarded while their rules are unresolved. Independent revival income waits until placement and supported Ambassador reactions finish; refreshing does not repeat the return or payment.',
+ 'While Tupile has at least eleven native forces, CHOAM cannot discard Worthless cards for spice through either ordinary sale price or a CHOAM Ambassador. Non-Worthless duplicate sales, allied trades and the Advanced CHOAM Karama cash-in retain their own rules. If a Ghola revival raises Tupile before a declared sale settles, that Worthless card stays in hand and earns no spice.',
+ 'At the end of Bidding, high-population Kaitain lets Emperor pay two spice per selected Treachery Card to discard it. This does not play the card or begin another auction. Emperor and CHOAM share the closing opportunity, so sales, allied trades and paid discards can occur in either order. Current hands, spice and native population determine each action. Finish when ready; a new action clears readiness.',
+ '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.',
+ '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.',
+ '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.',
+ 'Homeworld shipment, battle and occupation have special rules. Occupiers receive the printed Collection income and card effects. An alliance can gain a special Homeworld victory through high-population Ecaz; this is separate from ordinary stronghold victory.',
+ 'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.',
+ ],
+ example:
+ 'Atreides with six reserves on Caladan uses the high face. Dropping to five uses the low face and removes the next-Spice-Blow inspection advantage. Either face adds two to native battle strength on Caladan.',
+ related: [
+ 'ecaz-modules',
+ 'setup',
+ 'revival',
+ 'movement',
+ 'battle',
+ 'victory',
+ ],
+ checklist: [
+ {
+ area: 'Implementation',
+ status: 'Partial',
+ detail:
+ 'All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Concealed transport, occupation and remaining card effects are unfinished; game starts stay disabled.',
+ evidence: [
+ 'game/homeworld-cards.ts',
+ 'game/homeworld-game.ts',
+ 'game/homeworld-native-reserves.ts',
+ 'game/homeworld-revival-deployment.ts',
+ 'game/homeworld-revival-destinations.ts',
+ 'game/homeworld-revival-return.ts',
+ 'game/homeworld-victory-reinforcement.ts',
+ 'game/homeworld-victory-return.ts',
+ 'game/homeworld-arrival.ts',
+ 'game/tupile-intelligence.ts',
+ 'game/homeworld-shipment.ts',
+ 'game/guild-homeworld-shipment.ts',
+ 'game/junction-transport.ts',
+ 'game/junction-offer.ts',
+ 'game/homeworld-alliance.ts',
+ 'game/homeworld-benefits.ts',
+ 'game/homeworld-mobility.ts',
+ 'game/homeworld-card-economy.ts',
+ 'game/homeworld-payment-income.ts',
+ 'game/homeworld-collection.ts',
+ 'game/giedi-collection.ts',
+ 'game/terror-entry-receipt.ts',
+ 'game/bidding-end.ts',
+ 'game/ecaz-poison-income.ts',
+ 'game/choam-market-ghola.ts',
+ 'game/charity.ts',
+ ],
+ },
+ {
+ area: 'Player controls',
+ status: 'Partial',
+ detail:
+ 'Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement; starting this module remains unavailable.',
+ evidence: [
+ 'components/homeworld-cards.tsx',
+ 'components/homeworld-table.tsx',
+ 'components/homeworld-revival-deployment.tsx',
+ 'components/caladan-reinforcement.tsx',
+ 'components/homeworld-shipment.tsx',
+ 'components/guild-homeworld-shipment.tsx',
+ 'components/junction-transport.tsx',
+ 'components/bidding-end.tsx',
+ 'components/ecaz-poison-income.tsx',
+ ],
+ },
+ {
+ 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.',
+ evidence: ['game/homeworld-options.ts', 'game/homeworld-shipment-options.ts', 'game/guild-homeworld-shipment-options.ts', 'game/junction-transport-options.ts'],
+ },
+ {
+ area: 'Documentation',
+ status: 'Partial',
+ detail:
+ 'All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. Divergent allied rounding and occupation entitlement remain unresolved; full interaction guidance remains in progress.',
+ evidence: [
+ 'docs/HOMEWORLD_COMPONENT_AUDIT.md',
+ 'docs/HOMEWORLD_RULES.md',
+ 'docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md',
+ 'docs/HOMEWORLD_BENEFITS_RULES.md',
+ 'docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md',
+ 'docs/HOMEWORLD_REVIVAL_DEPLOYMENT_INTEGRATION.md',
+ 'docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RUNTIME.md',
+ 'docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md',
+ 'docs/HOMEWORLD_BATTLE_RUNTIME.md',
+ 'docs/HOMEWORLD_SHIPMENT_RUNTIME.md',
+ 'docs/GUILD_HOMEWORLD_TRANSPORT_RUNTIME.md',
+ 'docs/JUNCTION_TRANSPORT_RULES.md',
+ 'docs/JUNCTION_TRANSPORT_RUNTIME.md',
+ 'docs/HOMEWORLD_MOBILITY_RULES.md',
+ 'docs/HOMEWORLD_MOBILITY_RUNTIME.md',
+ 'docs/HOMEWORLD_CARD_ECONOMY_RULES.md',
+ 'docs/HOMEWORLD_CARD_ECONOMY_RUNTIME.md',
+ 'docs/HOMEWORLD_PAYMENT_INCOME_RULES.md',
+ 'docs/HOMEWORLD_PAYMENT_INCOME_RUNTIME.md',
+ 'docs/HOMEWORLD_COLLECTION_RULES.md',
+ 'docs/HOMEWORLD_COLLECTION_INTEGRATION.md',
+ 'docs/HOMEWORLD_COLLECTION_RUNTIME.md',
+ 'docs/ECAZ_POISON_INCOME_RULES.md',
+ ],
+ },
+ {
+ area: 'Verification',
+ status: 'Partial',
+ detail:
+ 'Tests cover genuine setup with implemented decks, Basic special-counter identity, actual transfer/shipment/revival, saved-state rejection and competing database submissions. Complete Homeworld games and module combinations are not yet verified.',
+ evidence: [
+ 'tests/homeworld-custody.test.ts',
+ 'tests/homeworld-cards.test.ts',
+ 'tests/homeworld-payment-income.test.ts',
+ 'tests/homeworld-payment-income-engine.test.ts',
+ 'tests/homeworld-payment-income-recovery.test.ts',
+ 'tests/homeworld-collection.test.ts',
+ 'tests/homeworld-collection-engine.test.ts',
+ 'tests/homeworld-collection-recovery.test.ts',
+ 'tests/homeworld-grumman-engine.test.ts',
+ 'tests/homeworld-grumman-recovery.test.ts',
+ 'tests/homeworld-setup-engine.test.ts',
+ 'tests/homeworld-actions-engine.test.ts',
+ 'tests/homeworld-basic-counters.test.ts',
+ 'tests/homeworld-salusa-engine.test.ts',
+ 'tests/homeworld-combat.test.ts',
+ 'tests/homeworld-battle-resolution.test.ts',
+ 'tests/homeworld-battle-engine.test.ts',
+ 'tests/homeworld-battle-recovery.test.ts',
+ 'tests/homeworld-shipment-engine.test.ts',
+ 'tests/homeworld-shipment-recovery.test.ts',
+ 'tests/homeworld-shipment-controls.test.ts',
+ 'tests/homeworld-shipment-review.test.ts',
+ 'tests/guild-homeworld-shipment-engine.test.ts',
+ 'tests/guild-homeworld-shipment-recovery.test.ts',
+ 'tests/guild-homeworld-shipment-controls.test.ts',
+ 'tests/junction-transport.test.ts',
+ 'tests/junction-transport-engine.test.ts',
+ 'tests/junction-transport-recovery.test.ts',
+ 'tests/junction-transport-controls.test.ts',
+ 'tests/junction-transport-review.test.ts',
+ 'tests/homeworld-card-economy.test.ts',
+ 'tests/homeworld-tupile-sales-engine.test.ts',
+ 'tests/homeworld-tupile-sales-recovery.test.ts',
+ 'tests/bidding-end-engine.test.ts',
+ 'tests/bidding-end-controls.test.ts',
+ 'tests/bidding-end-recovery.test.ts',
+ 'tests/ecaz-poison-income.test.ts',
+ 'tests/ecaz-poison-income-engine.test.ts',
+ 'tests/homeworld-mobility.test.ts',
+ 'tests/homeworld-mobility-engine.test.ts',
+ 'tests/homeworld-mobility-recovery.test.ts',
+ 'tests/homeworld-spiritual-advisors.test.ts',
+ 'tests/homeworld-spiritual-advisors-recovery.test.ts',
+ 'tests/homeworld-advisor-ambassador.test.ts',
+ 'tests/homeworld-replacement-engine.test.ts',
+ 'tests/homeworld-recovery.test.ts',
+ 'tests/homeworld-charity-engine.test.ts',
+ 'tests/homeworld-revival-benefits-engine.test.ts',
+ 'tests/homeworld-revival-deployment.test.ts',
+ 'tests/homeworld-revival-return.test.ts',
+ 'tests/homeworld-revival-destinations.test.ts',
+ 'tests/homeworld-revival-deployment-engine.test.ts',
+ 'tests/homeworld-revival-deployment-controls.test.ts',
+ 'tests/homeworld-revival-deployment-recovery.test.ts',
+ 'tests/homeworld-victory-reinforcement.test.ts',
+ 'tests/caladan-reinforcement-controls.test.ts',
+ 'tests/homeworld-victory-reinforcement-recovery.test.ts',
+ 'tests/tupile-intelligence.test.ts',
+ 'tests/homeworld-benefits-recovery.test.ts',
+ ],
+ },
+ ],
+ },
...[
[
'ix-modules',
@@ -3138,6 +3359,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'faction-moritani',
'moritani-terror',
'ecaz-ambassadors',
+ 'homeworlds',
'implementation-checklist',
]
: []),
diff --git a/game/revival-cancellation.ts b/game/revival-cancellation.ts
index 44a0dbcf..572a9d44 100644
--- a/game/revival-cancellation.ts
+++ b/game/revival-cancellation.ts
@@ -3,7 +3,8 @@ import { isAuditorLeader } from './choam-auditor';
import { controlsLeader } from './leader-control';
import {
eliteRevivalRemaining,
- forceRevivalLimit,
+ normalForceRevivalLimit,
+ freeRevivalRate,
forceRevivalQuote,
forceRevivalRemaining,
newRevivalRules,
@@ -23,6 +24,7 @@ export type RevivalCancellationContext = Pick<
| 'phase'
| 'turn'
| 'advanced'
+ | 'homeworlds'
| 'players'
| 'revivalRules'
| 'freeRevival'
@@ -98,7 +100,7 @@ export function continuationCustody(
eliteTanks <= p.tanks &&
elite <= eliteTanks &&
n - elite <= p.tanks - eliteTanks &&
- elite <= eliteRevivalRemaining(p),
+ elite <= eliteRevivalRemaining(p, g.advanced),
'The pending ordinary and elite revival groups no longer match their tanks.',
);
return;
@@ -283,11 +285,12 @@ export function quoteRevivalCancellation(
}
if (kind === 'choamRevival') {
rules.choamBlocked = true;
- if (p.revived + pending.amount! > forceRevivalLimit(context, p))
+ if (p.revived + pending.amount! > normalForceRevivalLimit(context, p))
return { rules, pending: null, outcome: 'abandoned' };
Object.assign(pending, forceRevivalQuote(context, p, pending.amount!));
pending.checks = [];
- if (p.revived + pending.amount! > 3) pending.checks.push('revivalLimit');
+ if (p.revived + pending.amount! > Math.max(3, freeRevivalRate(context, p)))
+ pending.checks.push('revivalLimit');
if (pending.cost < pending.normalCost)
pending.checks.push('revivalDiscount');
} else {
diff --git a/game/revival-resume.ts b/game/revival-resume.ts
index debcbc6b..4b94e132 100644
--- a/game/revival-resume.ts
+++ b/game/revival-resume.ts
@@ -1,6 +1,6 @@
import type { Decision, Game, ResponseWindow } from './engine';
import type { PendingRevival } from './revival';
-import { forceRevivalRemaining } from './revival';
+import { forceRevivalRemaining, freeRevivalRemaining } from './revival';
import {
continuationCustody,
RevivalCancellationError,
@@ -8,6 +8,7 @@ import {
import { isAuditorLeader } from './choam-auditor';
import { controlsLeader } from './leader-control';
import { TECH_TOKENS, ownedTech } from './tech-tokens';
+import { tleilaxuHomeworldFreeIncomeBlocked } from './homeworld-benefits';
export type RevivalResumeContext = Pick<
Game,
@@ -15,6 +16,8 @@ export type RevivalResumeContext = Pick<
| 'phase'
| 'turn'
| 'advanced'
+ | 'homeworlds'
+ | 'homeworldRevival'
| 'players'
| 'pendingRevival'
| 'revivalRules'
@@ -112,6 +115,16 @@ export function quoteRevivalResume(
'The pending force revival has invalid physical counts.',
);
const paid = original.amount - original.free;
+ // Supported pending-revival overlays cannot move native forces or consume
+ // ordinary revival usage. Validate the accepted group's free allocation
+ // before settlement crosses a population threshold. A later independent
+ // request gets the rate at its own declaration.
+ if (g.homeworlds && !original.emperorExtra)
+ requireResume(
+ original.free ===
+ Math.min(original.amount, freeRevivalRemaining(g, p)),
+ 'The saved Homeworld revival free allocation does not match its current eligible group.',
+ );
const elite = original.elite ?? 0;
const freeCyborgs = paid * 2 + elite - original.normalCost;
requireResume(
@@ -342,7 +355,10 @@ export function quoteRevivalResume(
'The revival free-income receipt is invalid.',
);
const free = pending.free > 0;
- const reward = free && lastFree !== g.turn;
+ const reward =
+ free &&
+ lastFree !== g.turn &&
+ !(payer.id !== tleilaxu.id && tleilaxuHomeworldFreeIncomeBlocked(g));
if (free) quote.freeIncome = { player: payer.id, turn: g.turn };
const amount =
(payer.id === tleilaxu.id ? 0 : pending.cost) + (reward ? 1 : 0);
diff --git a/game/revival.ts b/game/revival.ts
index 80446454..8cfd111a 100644
--- a/game/revival.ts
+++ b/game/revival.ts
@@ -1,6 +1,11 @@
import type { Game, Player } from './engine';
import { faction, type FactionId } from './catalog';
import { isAuditorLeader } from './choam-auditor';
+import { homeworldLowBonus } from './homeworld-benefits';
+type RevivalRateContext = Pick<
+ Game,
+ 'advanced' | 'players' | 'homeworlds' | 'revivalRules' | 'freeRevival'
+>;
export type RevivalRules = {
expanded: string[];
choamBlocked?: boolean;
@@ -39,20 +44,31 @@ export function revivalDiscount(
g.players.some((t) => t.faction === 'tleilaxu' && t.ally === p.id)
);
}
-export function freeRevivalRemaining(
- g: Pick,
- p: Pick,
+export function freeRevivalRate(
+ g: RevivalRateContext,
+ p: Pick,
) {
return g.revivalRules?.freeBlocked?.includes(p.id)
? 0
- : Math.max(
- 0,
- (g.freeRevival.includes(p.id) ? 3 : faction(p.faction).revival) -
- p.revived,
- );
+ : (g.freeRevival.includes(p.id) ? 3 : faction(p.faction).revival) +
+ homeworldLowBonus(g, p.id);
+}
+/** E3's explicit four-free Fremen rate may exceed the ordinary three-return cap;
+ * the extra free rate never creates an additional paid allowance. */
+export function normalForceRevivalLimit(
+ g: RevivalRateContext,
+ p: Pick,
+) {
+ return Math.max(forceRevivalLimit(g, p), freeRevivalRate(g, p));
+}
+export function freeRevivalRemaining(
+ g: RevivalRateContext,
+ p: Pick,
+) {
+ return Math.max(0, freeRevivalRate(g, p) - p.revived);
}
export function forceRevivalQuote(
- g: Pick,
+ g: RevivalRateContext,
p: Pick,
amount: number,
elite = 0,
@@ -72,14 +88,14 @@ export function forceRevivalQuote(
/** Normal force returns only; Emperor-funded extra revivals and card effects are separate. */
export function forceRevivalRemaining(
- g: Pick,
+ g: RevivalRateContext,
p: Pick,
) {
return Math.max(
0,
Math.min(
p.tanks,
- forceRevivalLimit(g, p) - p.revived,
+ normalForceRevivalLimit(g, p) - p.revived,
// GF9 November 2020 FAQ p. 5 permits paid Fremen revivals with Tleilaxu in play.
// Selecting expansion cards alone does not provide that faction's permission.
p.faction === 'fremen' &&
@@ -179,8 +195,11 @@ export function revivalPrevented(
);
}
-export function eliteRevivalRemaining(p: Pick) {
- return p.faction === 'ixians'
+export function eliteRevivalRemaining(
+ p: Pick,
+ advanced = true,
+) {
+ return !advanced || p.faction === 'ixians'
? (p.elites?.tanks ?? 0)
: Math.min(p.elites?.tanks ?? 0, Math.max(0, 1 - (p.elites?.revived ?? 0)));
}
diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts
index fb577f4b..e311f809 100644
--- a/game/richese-cancellation.ts
+++ b/game/richese-cancellation.ts
@@ -19,6 +19,8 @@ const DECISIONS = {
moritaniPlacement: true,
ecazPlacement: true,
ecazSpice: true,
+ homeworldRevivalDeployment: true,
+ caladanReinforcement: true,
choamStorm: true,
choamMovement: true,
choamMentat: true,
@@ -41,9 +43,12 @@ const DECISIONS = {
fullPlanOffer: true,
fullPlanRead: true,
guildShipment: true,
+ homeworldShipmentGuild: true,
nullentropy: true,
handExchange: true,
battleLosses: true,
+ homeworldExplosion: true,
+ homeworldDefense: true,
captureOffer: true,
capturedLeader: true,
guildTiming: true,
diff --git a/game/stone-burner.ts b/game/stone-burner.ts
index 4702cf61..74e028c4 100644
--- a/game/stone-burner.ts
+++ b/game/stone-burner.ts
@@ -1,4 +1,8 @@
-import { casualtyOptions, type CombatForces } from './combat';
+import {
+ casualtyOptions,
+ validCombatForces,
+ type CombatForces,
+} from './combat';
export type StoneBurnerSide = 'attacker' | 'defender';
export type StoneBurnerComparison = {
@@ -7,28 +11,9 @@ export type StoneBurnerComparison = {
defender: number[];
};
-// Current supported factions each have 20 physical tokens. This is an input/work
-// bound, not a new battle rule; extend it if a supported module changes that pool.
-const MAX_PHYSICAL_FORCES = 20;
const opposingChoices = new Map();
const MAX_CACHED_POOLS = 128;
-function validForces(forces: CombatForces): boolean {
- return (
- !!forces &&
- typeof forces === 'object' &&
- !Array.isArray(forces) &&
- Number.isSafeInteger(forces.normal) &&
- forces.normal >= 0 &&
- Number.isSafeInteger(forces.elite) &&
- forces.elite >= 0 &&
- forces.normal + forces.elite <= MAX_PHYSICAL_FORCES &&
- (forces.eliteStrength === 1 || forces.eliteStrength === 2) &&
- typeof forces.freeSupport === 'boolean' &&
- (forces.normalFixedHalf === undefined ||
- typeof forces.normalFixedHalf === 'boolean')
- );
-}
function validSide(side: StoneBurnerSide): boolean {
return side === 'attacker' || side === 'defender';
}
@@ -37,7 +22,7 @@ function sorted(values: Iterable): number[] {
}
function totals(forces: CombatForces, dial: number, support: number): number[] {
if (
- !validForces(forces) ||
+ !validCombatForces(forces) ||
!Number.isFinite(dial) ||
dial < 0 ||
!Number.isInteger(dial * 2) ||
@@ -105,41 +90,25 @@ function allOpposingTotals(
forces.eliteStrength,
!!forces.normalFixedHalf,
forces.freeSupport,
+ !!forces.eliteFreeSupport,
].join(':');
const cached = opposingChoices.get(key);
if (cached) return cached;
- const groups = new Map>();
- const add = (halfDial: number, support: number, undialed: number) => {
- const planKey = `${halfDial}:${support}`;
- const group = groups.get(planKey) ?? new Set();
- group.add(undialed);
- groups.set(planKey, group);
- };
- for (let normal = 0; normal <= forces.normal; normal++)
- for (let elite = 0; elite <= forces.elite; elite++) {
- const undialed = forces.normal + forces.elite - normal - elite;
- if (forces.freeSupport) {
- add(
- normal * (forces.normalFixedHalf ? 1 : 2) +
- elite * forces.eliteStrength * 2,
- 0,
- undialed,
- );
- continue;
- }
- for (
- let paidNormal = 0;
- paidNormal <= (forces.normalFixedHalf ? 0 : normal);
- paidNormal++
- )
- for (let paidElite = 0; paidElite <= elite; paidElite++)
- add(
- normal + paidNormal + (elite + paidElite) * forces.eliteStrength,
- paidNormal + paidElite,
- undialed,
- );
+ const result: number[][] = [];
+ const maximumHalfDial =
+ forces.normal * (forces.normalFixedHalf ? 1 : 2) +
+ forces.elite * forces.eliteStrength * 2;
+ const maximumSupport = forces.freeSupport
+ ? 0
+ : (forces.normalFixedHalf ? 0 : forces.normal) +
+ (forces.eliteFreeSupport ? 0 : forces.elite);
+ // Use the same legality and typed-support arithmetic as sealed plans. Cache
+ // public pool results so repeated preflights do not repeat this enumeration.
+ for (let halfDial = 0; halfDial <= maximumHalfDial; halfDial++)
+ for (let support = 0; support <= maximumSupport; support++) {
+ const choices = totals(forces, halfDial / 2, support);
+ if (choices.length) result.push(choices);
}
- const result = [...groups.values()].map(sorted);
if (opposingChoices.size >= MAX_CACHED_POOLS)
opposingChoices.delete(opposingChoices.keys().next().value!);
opposingChoices.set(key, result);
@@ -160,7 +129,7 @@ export function stoneBurnerPlanBlock(
): string | null {
if (!validSide(side) || !validSide(aggressor))
return 'Choose valid Stone Burner combatant roles.';
- if (!validForces(own) || !validForces(opponent))
+ if (!validCombatForces(own) || !validCombatForces(opponent))
return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.';
const ownTotals = totals(own, dial, support);
if (!ownTotals.length)
@@ -185,7 +154,7 @@ export function stoneBurnerCompulsionBlock(
): string | null {
if (!validSide(side) || !validSide(aggressor))
return 'Choose valid Stone Burner combatant roles.';
- if (!validForces(own) || !validForces(opponent))
+ if (!validCombatForces(own) || !validCombatForces(opponent))
return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.';
const maximumStrength = own.normal + own.elite * own.eliteStrength;
for (let halfDial = 0; halfDial <= maximumStrength * 2; halfDial++) {
diff --git a/game/table-turn.ts b/game/table-turn.ts
index 08466a9b..fd0e83ce 100644
--- a/game/table-turn.ts
+++ b/game/table-turn.ts
@@ -11,7 +11,8 @@ type TurnView = Pick<
| 'truthtrance'
| 'phaseOpening'
| 'automaticContinuationPending'
->;
+> &
+ Partial>;
/** The single current decision owner for the table banner and seat highlight.
* Shared response windows have no single acting seat. Only public view fields
@@ -28,6 +29,12 @@ export function tableActionOwner(g: TurnView): string | null {
const seated = (id: string | null | undefined) =>
g.players.some((p) => p.id === id) ? id! : null;
if (g.decision) return seated(g.decision.player);
+ if (g.biddingEnd) {
+ const pending = g.biddingEnd.owners.filter(
+ (id) => !g.biddingEnd!.ready.includes(id),
+ );
+ return pending.length === 1 ? seated(pending[0]) : null;
+ }
const b = g.battle;
if (!b) return seated(g.active);
const combatants = [b.attacker, b.defender];
diff --git a/game/terminal-cancellation.ts b/game/terminal-cancellation.ts
index 6d24f83c..3315ac14 100644
--- a/game/terminal-cancellation.ts
+++ b/game/terminal-cancellation.ts
@@ -3,6 +3,10 @@ import {
GuildAmbassadorContinuationError,
} from './guild-ambassador-continuation';
import type { Game, ResponseWindow } from './engine';
+import {
+ homeworldMovementForesightBlock,
+ homeworldSpiritualAdvisorAllowance,
+} from './homeworld-mobility';
import {
gameTerritories,
location,
@@ -64,6 +68,11 @@ export type TerminalCancellationContext = Pick<
| 'choamCharity'
| 'pendingAmbassador'
| 'ecazAmbassadors'
+ | 'homeworlds'
+ | 'homeworldRevivalReturn'
+ | 'homeworldRevivalProgress'
+ | 'homeworldVictoryReinforcement'
+ | 'lastBattleContext'
>;
const positive = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) > 0;
@@ -212,6 +221,10 @@ export function validateTerminalCancellation(
return { kind: response.kind, owner: owner.id, known: false };
case 'atreidesSpice':
faction('atreides');
+ requireTerminal(
+ !homeworldMovementForesightBlock(g, owner.id),
+ 'Low-population Caladan has no Movement foresight opportunity to cancel.',
+ );
requireTerminal(
g.phase === 5 && Array.isArray(g.spiceDeck) && g.spiceDeck.length > 0,
'Canceled spice knowledge needs its existing lookahead opportunity.',
@@ -254,6 +267,16 @@ export function validateTerminalCancellation(
return { kind: response.kind, owner: owner.id };
case 'advisor': {
faction('beneGesserit');
+ const destination = splitLocation(
+ response.location ?? 'polar_sink:0',
+ ).territory;
+ const advisorAmount = response.amount === undefined ? 1 : response.amount;
+ requireTerminal(
+ positive(advisorAmount) &&
+ advisorAmount <=
+ homeworldSpiritualAdvisorAllowance(g, owner.id, destination),
+ 'This spiritual advisor amount is unavailable at the current Homeworld population.',
+ );
if (response.advisorResume === 'ambassador') {
try {
const { order } = validateGuildAmbassadorArrivalContext(
diff --git a/game/terror-entry-receipt.ts b/game/terror-entry-receipt.ts
new file mode 100644
index 00000000..5148bd33
--- /dev/null
+++ b/game/terror-entry-receipt.ts
@@ -0,0 +1,65 @@
+import type { Game } from './engine';
+import { validLocation } from './board';
+import { HomeworldCustodyError } from './homeworld-custody';
+import { TERROR_STRONGHOLDS } from './moritani-terror';
+
+type TerrorEntry = NonNullable;
+
+/** Original public entry facts, independent of subsequent stage or population.
+ * This is a consistency receipt, not an authorization token or secret digest. */
+export function terrorEntrySignature(entry: TerrorEntry): string {
+ return JSON.stringify([
+ entry.token,
+ entry.entrant,
+ entry.territory,
+ entry.sector,
+ entry.amount,
+ entry.elite,
+ entry.cause,
+ entry.turn,
+ entry.phase,
+ entry.resume,
+ entry.ambassadorEvent ?? null,
+ ]);
+}
+
+/** Older saves without a receipt remain explicit legacy state. Never invent
+ * their original entry count from the destination's present occupants. */
+export function validateTerrorEntrySignature(entry: TerrorEntry): void {
+ if (entry.entrySignature === undefined) return;
+ if (
+ typeof entry.entrySignature !== 'string' ||
+ typeof entry.token !== 'string' ||
+ !entry.token ||
+ typeof entry.entrant !== 'string' ||
+ !entry.entrant ||
+ !TERROR_STRONGHOLDS.includes(entry.territory) ||
+ !Number.isSafeInteger(entry.sector) ||
+ !validLocation(entry.territory, entry.sector) ||
+ !Number.isSafeInteger(entry.amount) ||
+ entry.amount < 0 ||
+ !Number.isSafeInteger(entry.elite) ||
+ entry.elite < 0 ||
+ entry.elite > entry.amount ||
+ !Number.isSafeInteger(entry.turn) ||
+ entry.turn < 1 ||
+ !Number.isSafeInteger(entry.phase) ||
+ entry.phase < 0 ||
+ entry.phase > 8 ||
+ ![
+ 'shipment',
+ 'movement',
+ 'guildTransport',
+ 'advisor',
+ 'wormRide',
+ 'ambassador',
+ ].includes(entry.cause) ||
+ !['none', 'wormRide', 'ambassador'].includes(entry.resume) ||
+ (entry.ambassadorEvent !== undefined &&
+ (typeof entry.ambassadorEvent !== 'string' || !entry.ambassadorEvent)) ||
+ entry.entrySignature !== terrorEntrySignature(entry)
+ )
+ throw new HomeworldCustodyError(
+ 'The saved Terror entry no longer matches its original public arrival receipt.',
+ );
+}
diff --git a/game/tupile-intelligence.ts b/game/tupile-intelligence.ts
new file mode 100644
index 00000000..5b5b6bc7
--- /dev/null
+++ b/game/tupile-intelligence.ts
@@ -0,0 +1,81 @@
+import type { FactionId } from './catalog';
+import {
+ homeworldForceGroups,
+ HomeworldCustodyError,
+ type HomeworldCustody,
+ type HomeworldCustodyContext,
+} from './homeworld-custody';
+import { homeworldPopulations } from './homeworld-population';
+
+/** Qualification/expiry belongs to the occupation lifecycle, not this query. */
+export type TupileOccupation = 'unoccupied' | 'occupied' | 'unknown';
+export type TupileIntelligenceCategory = 'weapons' | 'defenses';
+export type TupileIntelligenceTarget = {
+ player: string;
+ faction: FactionId;
+ /** Public physical contact locations, including either Emperor world. */
+ contact: string[];
+ blocked: string | null;
+};
+
+/** Public eligibility only. No hand, spice, battle plan, skills or hidden
+ * No-Field data is accepted or consulted. Used factions persist for the game. */
+export function tupileIntelligenceTargets(
+ context: HomeworldCustodyContext,
+ custody: HomeworldCustody,
+ owner: string,
+ usedFactions: readonly FactionId[],
+ occupation: TupileOccupation,
+): TupileIntelligenceTarget[] {
+ const worlds = homeworldForceGroups(context, custody);
+ const choam = context.players.find((p) => p.id === owner);
+ if (choam?.faction !== 'choam')
+ throw new HomeworldCustodyError('Only the native CHOAM faction may request Tupile intelligence.');
+ if (!['unoccupied', 'occupied', 'unknown'].includes(occupation))
+ throw new HomeworldCustodyError('Tupile intelligence needs an explicit occupation status.');
+ if (!Array.isArray(usedFactions) || new Set(usedFactions).size !== usedFactions.length ||
+ usedFactions.some((faction) => faction === 'choam' || !context.players.some((p) => p.faction === faction)))
+ throw new HomeworldCustodyError('Tupile intelligence usage must name unique seated opposing factions.');
+ const population = homeworldPopulations(context, custody).find((p) => p.native === owner)!;
+ const tupile = worlds.find((home) => home.native === owner)!;
+ const present = (home: typeof tupile, player: string) => {
+ const group = home.forces[player];
+ return !!group && group.normal + group.elite > 0;
+ };
+ return context.players.filter((p) => p.id !== owner).map((target) => {
+ const contact = worlds.filter((home) => home.native === target.id && present(home, owner)).map((home) => home.id);
+ if (present(tupile, target.id)) contact.push(tupile.id);
+ const blocked = occupation === 'unknown'
+ ? 'Tupile occupation must be resolved before its low advantage can be used.'
+ : occupation === 'occupied'
+ ? 'Occupied Tupile removes CHOAM’s low-population intelligence advantage.'
+ : population.side !== 'low'
+ ? 'Tupile intelligence requires at most ten native CHOAM reserves.'
+ : usedFactions.includes(target.faction)
+ ? 'Tupile intelligence has already been used against this faction in this game.'
+ : contact.length === 0
+ ? 'CHOAM must be on that faction’s Homeworld, or that faction must be on Tupile.'
+ : null;
+ return { player: target.id, faction: target.faction, contact, blocked };
+ });
+}
+
+/** Validate a requested category without reading the answer or consuming use.
+ * The eventual engine must atomically record one current private disclosure. */
+export function quoteTupileIntelligenceRequest(
+ context: HomeworldCustodyContext,
+ custody: HomeworldCustody,
+ owner: string,
+ usedFactions: readonly FactionId[],
+ occupation: TupileOccupation,
+ target: string,
+ category: TupileIntelligenceCategory,
+) {
+ if (category !== 'weapons' && category !== 'defenses')
+ throw new HomeworldCustodyError('Choose either the weapon count or the defense count.');
+ const candidate = tupileIntelligenceTargets(context, custody, owner, usedFactions, occupation)
+ .find((p) => p.player === target);
+ if (!candidate || candidate.blocked)
+ throw new HomeworldCustodyError(candidate?.blocked ?? 'Choose a seated opposing faction for Tupile intelligence.');
+ return { owner, target: candidate.player, faction: candidate.faction, category, contact: [...candidate.contact] };
+}
diff --git a/package-lock.json b/package-lock.json
index 42684f18..8689e6ec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,6 +44,7 @@
"oxlint": "1.76.0",
"oxlint-tsgolint": "7.0.2001",
"tailwindcss": "4.2.1",
+ "tsx": "4.23.13",
"typescript": "5.9.3",
"vite": "8.0.13",
"wrangler": "4.92.0"
diff --git a/package.json b/package.json
index 4110df4b..879c6929 100644
--- a/package.json
+++ b/package.json
@@ -12,10 +12,15 @@
"lint": "oxlint",
"format": "oxfmt",
"db:generate": "drizzle-kit generate",
- "test": "tsx --test tests/engine.test.ts tests/bots.test.ts tests/reference.test.ts tests/combat.test.ts tests/advanced-phases.test.ts tests/advanced-guild.test.ts tests/kwisatz.test.ts tests/capture.test.ts tests/combat-karama.test.ts tests/advisors.test.ts tests/bg-karama.test.ts tests/special-karama.test.ts tests/guild-stop.test.ts tests/full-plan.test.ts tests/summoned-worm.test.ts tests/tech-tokens.test.ts tests/ix-battle-cards.test.ts tests/tooth-artillery.test.ts tests/sandtrout.test.ts tests/thumper.test.ts tests/cheap-hero-traitor.test.ts tests/amal.test.ts tests/tleilaxu.test.ts tests/tleilaxu-revival.test.ts tests/foreign-gholas.test.ts tests/tleilaxu-special.test.ts tests/ix-forces.test.ts tests/ix-economy.test.ts tests/mobile-stronghold.test.ts tests/ix-technology.test.ts tests/ix-deck.test.ts tests/truthtrance.test.ts tests/battle-promises.test.ts tests/battle-preparation-paths.test.ts tests/choam-charity.test.ts tests/choam-revival.test.ts tests/choam-inflation.test.ts tests/choam-market.test.ts tests/choam-karama.test.ts tests/choam-combat.test.ts tests/choam-worthless.test.ts tests/choam-gamont.test.ts tests/choam-baliset.test.ts tests/choam-jubba.test.ts tests/client-request.test.ts tests/seat-recovery-client.test.ts tests/room-entry-client.test.ts tests/karama-intents.test.ts tests/first-storm.test.ts tests/card-availability.test.ts tests/revival-usage.test.ts tests/fremen-revival.test.ts tests/recruits.test.ts tests/ecaz-cards.test.ts tests/autopilot.test.ts tests/moritani-leaders.test.ts tests/ecaz-leaders.test.ts tests/moritani-terror.test.ts tests/moritani-placement.test.ts tests/moritani-entry.test.ts tests/moritani-entry-overlays.test.ts tests/moritani-force-leader.test.ts tests/moritani-alliance.test.ts tests/duke-vidal.test.ts tests/duke-vidal-engine.test.ts tests/duke-truthtrance.test.ts tests/normal-revival-options.test.ts tests/normal-revival-bots.test.ts tests/moritani-retention.test.ts tests/moritani-retention-bots.test.ts tests/moritani-extortion.test.ts tests/bot-shipment-budget.test.ts tests/ecaz-ambassadors.test.ts tests/ecaz-placement.test.ts tests/automatic-casualties.test.ts tests/automatic-responses.test.ts tests/automatic-decisions.test.ts tests/ecaz-entry.test.ts tests/ecaz-entry-bots.test.ts tests/ecaz-entry-continuations.test.ts tests/choam-richese-leaders.test.ts tests/richese-cards.test.ts tests/richese-auction.test.ts tests/richese-auction-review.test.ts tests/richese-funding.test.ts tests/richese-card-presentation.test.ts tests/richese-engine.test.ts tests/richese-auction-bots.test.ts tests/richese-engine-review.test.ts tests/richese-no-field.test.ts tests/richese-acquisition.test.ts tests/richese-acquisition-review.test.ts tests/richese-acquisition-bots.test.ts tests/force-presence.test.ts tests/richese-no-field-engine.test.ts tests/richese-no-field-review.test.ts tests/richese-no-field-bots.test.ts tests/richese-gift.test.ts tests/richese-allied-no-field.test.ts tests/richese-allied-no-field-review.test.ts tests/richese-allied-no-field-bots.test.ts tests/richese-gift-engine.test.ts tests/richese-gift-review.test.ts tests/richese-gift-bots.test.ts tests/distrans.test.ts tests/distrans-engine.test.ts tests/distrans-engine-review.test.ts tests/distrans-bots.test.ts tests/nullentropy-box.test.ts tests/leader-art.test.ts tests/nullentropy-box-engine.test.ts tests/nullentropy-box-engine-review.test.ts tests/nullentropy-box-bots.test.ts tests/ornithopter.test.ts tests/ornithopter-engine.test.ts tests/ornithopter-engine-review.test.ts tests/ornithopter-bots.test.ts tests/residual-poison.test.ts tests/residual-poison-engine.test.ts tests/residual-poison-engine-review.test.ts tests/residual-poison-bots.test.ts tests/portable-snooper.test.ts tests/portable-snooper-engine.test.ts tests/portable-snooper-review.test.ts tests/portable-snooper-bots.test.ts tests/stone-burner.test.ts tests/stone-burner-engine.test.ts tests/stone-burner-review.test.ts tests/stone-burner-bots.test.ts tests/special-leader-art.test.ts tests/bot-transport-budget.test.ts tests/bot-reachability-review.test.ts tests/bot-plan-commitment.test.ts tests/bot-plan-legality-review.test.ts tests/bot-battle-choice.test.ts tests/bot-worm-ride.test.ts tests/bot-advisor-setup.test.ts tests/fremen-movement-karama.test.ts tests/fremen-movement-review.test.ts tests/base-setup-order.test.ts tests/bot-staged-setup.test.ts tests/truthtrance-spice.test.ts tests/guild-payment-routing.test.ts tests/guild-transport-quote.test.ts tests/pending-shipment-integrity.test.ts tests/effective-weapons.test.ts tests/effective-battle-effects.test.ts tests/stronghold-cards.test.ts tests/stronghold-engine.test.ts tests/stronghold-bots.test.ts tests/stronghold-battle.test.ts tests/choam-auditor.test.ts tests/choam-auditor-engine.test.ts tests/choam-auditor-bots.test.ts tests/choam-auditor-lifecycle.test.ts tests/bot-revival-timing.test.ts tests/choam-market-automatic.test.ts tests/truthtrance-card-count.test.ts tests/shipment-promises.test.ts tests/shipment-promises-bots.test.ts tests/ordered-opportunity.test.ts tests/juice-of-sapho-engine.test.ts tests/juice-of-sapho-engine-review.test.ts tests/juice-of-sapho-bots.test.ts tests/semuta-drug.test.ts tests/treachery-discard-continuations.test.ts tests/battle-discard-continuations.test.ts tests/battle-context-lifecycle.test.ts tests/terror-discard-continuations.test.ts tests/box-discard-continuations.test.ts tests/box-discard-auditor.test.ts tests/ornithopter-discard-continuations.test.ts tests/truthtrance-discard-continuations.test.ts tests/ordinary-card-discard-continuations.test.ts tests/karama-preflight.test.ts tests/karama-promise-preflight.test.ts tests/richese-settlement-preflight.test.ts tests/bg-karama-opportunity.test.ts tests/bg-karama-nested-controls.test.ts tests/karama-battle-preflight.test.ts tests/karama-movement-preflight.test.ts tests/bg-karama-cancel-context.test.ts tests/storm-worm-cancellation.test.ts tests/revival-cancellation.test.ts tests/battle-resolution-quote.test.ts tests/board-resolution-quote.test.ts tests/battle-aftermath-quote.test.ts tests/karama-movement-cancellation.test.ts tests/combat-response-quote.test.ts tests/terminal-cancellation.test.ts tests/karama-no-field-cancellation.test.ts tests/choam-sale-cancellation.test.ts tests/table-turn.test.ts tests/ix-substitution-cancellation.test.ts tests/richese-cancellation.test.ts tests/moritani-alliance-cancellation.test.ts tests/ix-auction-draw-quote.test.ts tests/ix-technology-cancellation.test.ts tests/auction-continuation-quote.test.ts tests/auction-unsold-custody.test.ts tests/choam-storm-quote.test.ts tests/choam-worthless-cancellation.test.ts tests/revival-resume.test.ts tests/placement-cancellation.test.ts tests/phase-resource-quote.test.ts tests/movement-phase-quote.test.ts tests/victory-quote.test.ts tests/ecaz-duke-acquisition.test.ts tests/ecaz-duke-bots.test.ts tests/ecaz-duke-revival.test.ts tests/ghola-lifecycle.test.ts tests/ghola-bots.test.ts tests/richese-ambassador.test.ts tests/richese-ambassador-bots.test.ts tests/richese-ambassador-interruptions.test.ts tests/fremen-ambassador-quote.test.ts tests/fremen-ambassador-engine.test.ts tests/fremen-ambassador-bots.test.ts tests/fremen-ambassador-cancellation.test.ts tests/guild-ambassador-quote.test.ts tests/guild-ambassador-advisor-quote.test.ts tests/guild-ambassador-continuation.test.ts tests/guild-ambassador-cancellation.test.ts tests/ambassador-resume.test.ts tests/guild-ambassador-engine.test.ts tests/guild-ambassador-bots.test.ts tests/guild-ambassador-review.test.ts tests/tleilaxu-ambassador-forces.test.ts tests/ecaz-occupy.test.ts tests/ecaz-occupy-movement.test.ts tests/victory-progress.test.ts tests/ecaz-victory.test.ts",
- "test:multiplayer": "tsx --test tests/multiplayer.test.ts tests/multiplayer-recovery.test.ts tests/seat-recovery.test.ts tests/room-entry-recovery.test.ts tests/bot-continuation.test.ts tests/autopilot-recovery.test.ts tests/bot-pacing.test.ts tests/paced-games.test.ts tests/automatic-response-recovery.test.ts tests/ecaz-entry-recovery.test.ts tests/richese-auction-recovery.test.ts tests/richese-no-field-recovery.test.ts tests/richese-allied-no-field-recovery.test.ts tests/richese-gift-recovery.test.ts tests/distrans-recovery.test.ts tests/nullentropy-box-recovery.test.ts tests/ornithopter-recovery.test.ts tests/residual-poison-recovery.test.ts tests/portable-snooper-recovery.test.ts tests/stone-burner-recovery.test.ts tests/fremen-movement-recovery.test.ts tests/base-setup-recovery.test.ts tests/truthtrance-spice-recovery.test.ts tests/guild-payment-recovery.test.ts tests/pending-shipment-integrity-recovery.test.ts tests/stronghold-recovery.test.ts tests/choam-auditor-recovery.test.ts tests/shipment-promises-recovery.test.ts tests/juice-of-sapho-recovery.test.ts tests/multiplayer-discard-continuations.test.ts tests/battle-discard-recovery.test.ts tests/terror-discard-recovery.test.ts tests/box-discard-recovery.test.ts tests/ornithopter-discard-recovery.test.ts tests/truthtrance-discard-recovery.test.ts tests/ordinary-card-discard-recovery.test.ts tests/bg-karama-opportunity-recovery.test.ts tests/bg-karama-cancel-recovery.test.ts tests/disaster-revival-recovery.test.ts tests/battle-aftermath-recovery.test.ts tests/combat-terminal-recovery.test.ts tests/restored-cancellation-recovery.test.ts tests/auction-cancellation-recovery.test.ts tests/choam-worthless-cancellation-recovery.test.ts tests/placement-cancellation-recovery.test.ts tests/ecaz-duke-recovery.test.ts tests/ghola-lifecycle-recovery.test.ts tests/fremen-ambassador-recovery.test.ts tests/guild-ambassador-recovery.test.ts tests/ecaz-occupy-recovery.test.ts tests/ecaz-victory-recovery.test.ts",
+ "test": "node --import tsx tools/test.ts",
+ "test:multiplayer": "node --import tsx tools/test.ts --suite multiplayer",
"typecheck": "tsc --noEmit",
- "db:local": "wrangler d1 migrations apply DB --local --config tools/wrangler.local.json --persist-to .wrangler/state"
+ "db:local": "wrangler d1 migrations apply DB --local --config tools/wrangler.local.json --persist-to .wrangler/state",
+ "test:unit": "node --import tsx tools/test.ts --suite unit",
+ "test:recovery": "node --import tsx tools/test.ts --suite recovery",
+ "test:integration": "node --import tsx tools/test.ts --suite integration",
+ "test:all": "node --import tsx tools/test.ts --suite all",
+ "check": "npm run typecheck && npm run lint && npm test"
},
"dependencies": {
"react": "19.2.6",
@@ -56,7 +61,8 @@
"typescript": "5.9.3",
"vite": "8.0.13",
"wrangler": "4.92.0",
- "drizzle-kit": "0.31.10"
+ "drizzle-kit": "0.31.10",
+ "tsx": "4.23.13"
},
"type": "module"
}
diff --git a/public/art/homeworlds/caladan-v1.png b/public/art/homeworlds/caladan-v1.png
new file mode 100644
index 00000000..dd43ffee
Binary files /dev/null and b/public/art/homeworlds/caladan-v1.png differ
diff --git a/public/art/homeworlds/ecaz-v1.png b/public/art/homeworlds/ecaz-v1.png
new file mode 100644
index 00000000..e579a8f5
Binary files /dev/null and b/public/art/homeworlds/ecaz-v1.png differ
diff --git a/public/art/homeworlds/giedi_prime-v1.png b/public/art/homeworlds/giedi_prime-v1.png
new file mode 100644
index 00000000..05b1b42f
Binary files /dev/null and b/public/art/homeworlds/giedi_prime-v1.png differ
diff --git a/public/art/homeworlds/grumman-v1.png b/public/art/homeworlds/grumman-v1.png
new file mode 100644
index 00000000..26a07c16
Binary files /dev/null and b/public/art/homeworlds/grumman-v1.png differ
diff --git a/public/art/homeworlds/ix-v1.png b/public/art/homeworlds/ix-v1.png
new file mode 100644
index 00000000..40770dbc
Binary files /dev/null and b/public/art/homeworlds/ix-v1.png differ
diff --git a/public/art/homeworlds/junction-v1.png b/public/art/homeworlds/junction-v1.png
new file mode 100644
index 00000000..f16a686e
Binary files /dev/null and b/public/art/homeworlds/junction-v1.png differ
diff --git a/public/art/homeworlds/kaitain-v1.png b/public/art/homeworlds/kaitain-v1.png
new file mode 100644
index 00000000..4a2dd3e5
Binary files /dev/null and b/public/art/homeworlds/kaitain-v1.png differ
diff --git a/public/art/homeworlds/richese-v1.png b/public/art/homeworlds/richese-v1.png
new file mode 100644
index 00000000..73ce2bc3
Binary files /dev/null and b/public/art/homeworlds/richese-v1.png differ
diff --git a/public/art/homeworlds/salusa_secundus-v1.png b/public/art/homeworlds/salusa_secundus-v1.png
new file mode 100644
index 00000000..d318df5a
Binary files /dev/null and b/public/art/homeworlds/salusa_secundus-v1.png differ
diff --git a/public/art/homeworlds/southern_hemisphere-v1.png b/public/art/homeworlds/southern_hemisphere-v1.png
new file mode 100644
index 00000000..af369741
Binary files /dev/null and b/public/art/homeworlds/southern_hemisphere-v1.png differ
diff --git a/public/art/homeworlds/tleilax-v1.png b/public/art/homeworlds/tleilax-v1.png
new file mode 100644
index 00000000..f399fecd
Binary files /dev/null and b/public/art/homeworlds/tleilax-v1.png differ
diff --git a/public/art/homeworlds/tupile-v1.png b/public/art/homeworlds/tupile-v1.png
new file mode 100644
index 00000000..1766a3fb
Binary files /dev/null and b/public/art/homeworlds/tupile-v1.png differ
diff --git a/public/art/homeworlds/wallach_ix-v1.png b/public/art/homeworlds/wallach_ix-v1.png
new file mode 100644
index 00000000..4bca78b0
Binary files /dev/null and b/public/art/homeworlds/wallach_ix-v1.png differ
diff --git a/tests/ambassador-phase.test.ts b/tests/ambassador-phase.test.ts
new file mode 100644
index 00000000..a722ad77
--- /dev/null
+++ b/tests/ambassador-phase.test.ts
@@ -0,0 +1,304 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createGame, newPlayer, type Game } from '../game/engine';
+import { createHomeworldCustody } from '../game/homeworld-custody';
+import { homeworldContext } from '../game/homeworld-game';
+import {
+ makeHomeworldRevivalReturn,
+ makeHomeworldRevivalProgress,
+ homeworldRevivalArrivalSignature,
+ homeworldRevivalResumeSignature,
+ appendHomeworldRevivalAmbassador,
+ completeHomeworldRevivalAmbassador,
+ validateHomeworldRevivalReturn,
+} from '../game/homeworld-revival-return';
+import { ambassadorPhaseAllowed } from '../game/ambassador-phase';
+import { createAmbassadors, triggerAmbassador } from '../game/ecaz-ambassadors';
+import { validateGuildAmbassadorArrivalContext } from '../game/guild-ambassador-continuation';
+import { validateAmbassadorRelocationContext } from '../game/karama-movement-cancellation';
+
+function fixture(phase = 4) {
+ const g = createGame(
+ 'REVIVAL-AMBASSADOR',
+ newPlayer('f', 'Fremen', 'fremen'),
+ true,
+ );
+ g.players.push(
+ newPlayer('e', 'Ecaz', 'ecaz'),
+ newPlayer('b', 'BG', 'beneGesserit'),
+ newPlayer('i', 'Ixians', 'ixians'),
+ );
+ g.status = 'playing';
+ g.phase = phase;
+ g.turn = 2;
+ g.players[0].reserves = 4;
+ g.players[0].elites = { reserves: 1, tanks: 2, forces: {}, revived: 0 };
+ g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
+ const frame = makeHomeworldRevivalReturn({
+ event: 'revival-1',
+ player: 'f',
+ turn: 2,
+ phase,
+ source: 'ghola',
+ card: 'ghola-1',
+ group: { amount: 1, elite: 1, free: 0 },
+ quote: {
+ kind: 'fedaykin',
+ normal: 0,
+ elite: 1,
+ beforePopulation: 3,
+ afterPopulation: 4,
+ blocked: null,
+ },
+ });
+ frame.stage = 'arrival';
+ frame.resumeResponse = null;
+ frame.resumeSignature = homeworldRevivalResumeSignature(null);
+ frame.destination = 'arrakeen:10';
+ frame.ambassadors = [];
+ frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
+ g.homeworldRevivalReturn = frame;
+ g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
+ g.pendingAmbassador = {
+ event: 'ambassador-1',
+ revivalEvent: frame.event,
+ owner: 'e',
+ entrant: 'f',
+ token: 'token-guild',
+ territory: 'arrakeen',
+ sector: 10,
+ turn: 2,
+ phase,
+ stage: 'offer',
+ copyChoices: [],
+ resume: 'none',
+ };
+ appendHomeworldRevivalAmbassador(frame, g.pendingAmbassador);
+ return g;
+}
+
+void test('legacy phases remain unchanged and extra phases require the exact signed revival arrival', () => {
+ for (let phase = 0; phase <= 8; phase++) {
+ const g = fixture(phase);
+ assert.equal(ambassadorPhaseAllowed(g), true);
+ const legacy = structuredClone(g);
+ delete legacy.homeworldRevivalReturn;
+ delete legacy.pendingAmbassador!.revivalEvent;
+ assert.equal(ambassadorPhaseAllowed(legacy), phase === 1 || phase === 5);
+ delete g.pendingAmbassador!.revivalEvent;
+ assert.equal(
+ ambassadorPhaseAllowed(g),
+ false,
+ 'deleting the tag cannot enable a recorded Ambassador',
+ );
+ }
+});
+
+void test('receipt stage, original entrant, destination, turn and phase cannot be replaced or replayed', () => {
+ const original = fixture();
+ const edits: ((g: Game) => void)[] = [
+ (g) => {
+ g.pendingAmbassador!.entrant = 'i';
+ },
+ (g) => {
+ g.pendingAmbassador!.territory = 'carthag';
+ },
+ (g) => {
+ g.pendingAmbassador!.revivalEvent = 'another';
+ },
+ (g) => {
+ g.pendingAmbassador!.event = 'another';
+ },
+ (g) => {
+ g.pendingAmbassador!.phase = 5;
+ },
+ (g) => {
+ g.pendingAmbassador!.turn++;
+ },
+ (g) => {
+ g.homeworldRevivalReturn!.stage = 'complete';
+ },
+ (g) => {
+ g.homeworldRevivalReturn!.ambassadors = [];
+ },
+ (g) => {
+ g.homeworldRevivalReturn!.quote.elite = 2;
+ },
+ (g) => {
+ delete g.homeworldRevivalReturn!.arrivalSignature;
+ },
+ ];
+ for (const edit of edits) {
+ const g = structuredClone(original);
+ edit(g);
+ const saved = JSON.stringify(g);
+ assert.equal(ambassadorPhaseAllowed(g), false);
+ assert.equal(JSON.stringify(g), saved);
+ }
+});
+
+void test('secondary BG Ambassador uses its recorded Guild parent without reopening the completed predecessor', () => {
+ const g = fixture();
+ const frame = g.homeworldRevivalReturn!;
+ const parent = structuredClone(g.pendingAmbassador!);
+ const child: NonNullable = {
+ ...parent,
+ event: 'ambassador-2',
+ entrant: 'b',
+ territory: 'carthag',
+ sector: 11,
+ guildAdvisorOrigin: {
+ event: parent.event,
+ player: 'i',
+ territory: 'carthag',
+ sector: 11,
+ },
+ };
+ completeHomeworldRevivalAmbassador(frame, parent.event);
+ appendHomeworldRevivalAmbassador(frame, child, parent.event);
+ g.pendingAmbassador = child;
+ assert.equal(ambassadorPhaseAllowed(g), true);
+ assert.equal(
+ ambassadorPhaseAllowed(JSON.parse(JSON.stringify(g)) as Game),
+ true,
+ );
+ g.pendingAmbassador = parent;
+ assert.equal(ambassadorPhaseAllowed(g), false);
+ g.pendingAmbassador = child;
+ child.guildAdvisorOrigin!.event = 'unrelated';
+ assert.equal(ambassadorPhaseAllowed(g), false);
+ child.guildAdvisorOrigin!.event = parent.event;
+ child.guildAdvisorOrigin!.player = 'f';
+ assert.equal(
+ ambassadorPhaseAllowed(g),
+ false,
+ 'Fremen cannot be the accompanying shipment source',
+ );
+});
+
+void test('arrival append validates first source, unique events, recorded parent and preserves rejected frames', () => {
+ const g = fixture();
+ const frame = g.homeworldRevivalReturn!;
+ const entry = g.pendingAmbassador!;
+ for (const [child, parent] of [
+ [{ ...entry, event: 'second' }, undefined],
+ [{ ...entry, event: 'second', entrant: 'b' }, 'missing'],
+ [{ ...entry, entrant: 'b' }, entry.event],
+ [{ ...entry, event: 'second', entrant: 'b', turn: 3 }, entry.event],
+ ] as const) {
+ const saved = JSON.stringify(frame);
+ assert.throws(() => appendHomeworldRevivalAmbassador(frame, child, parent));
+ assert.equal(JSON.stringify(frame), saved);
+ }
+ const empty = structuredClone(frame);
+ empty.ambassadors = [];
+ empty.arrivalSignature = homeworldRevivalArrivalSignature(empty);
+ assert.throws(() =>
+ appendHomeworldRevivalAmbassador(empty, { ...entry, entrant: 'b' }),
+ );
+ assert.throws(() =>
+ appendHomeworldRevivalAmbassador(empty, { ...entry, territory: 'carthag' }),
+ );
+ delete empty.arrivalSignature;
+ assert.throws(() => validateHomeworldRevivalReturn(g, empty));
+});
+
+void test('completed decline keeps an explicit empty arrival proof and no Ambassador grant', () => {
+ const g = fixture();
+ const frame = g.homeworldRevivalReturn!;
+ frame.stage = 'complete';
+ g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
+ frame.destination = 'decline';
+ assert.throws(() => homeworldRevivalArrivalSignature(frame));
+ frame.ambassadors = [];
+ frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
+ validateHomeworldRevivalReturn(g, frame);
+ assert.equal(ambassadorPhaseAllowed(g), false);
+});
+
+void test('Guild shipment and copied Fremen relocation continuations accept a real revival-bound phase four receipt', () => {
+ for (const effect of ['guild', 'fremen'] as const) {
+ const g = fixture();
+ const ambassadors = createAmbassadors(() => 0);
+ const token = ambassadors.tokens.find(
+ (candidate) =>
+ candidate.effect === (effect === 'guild' ? 'guild' : 'beneGesserit'),
+ )!;
+ ambassadors.cohort = [
+ token.id,
+ ...ambassadors.tokens
+ .filter(
+ (candidate) =>
+ candidate.effect !== 'ecaz' &&
+ candidate.effect !== 'fremen' &&
+ candidate.id !== token.id,
+ )
+ .slice(0, 4)
+ .map((candidate) => candidate.id),
+ ];
+ for (const candidate of ambassadors.tokens) {
+ candidate.zone =
+ candidate.effect === 'ecaz' || ambassadors.cohort.includes(candidate.id)
+ ? 'supply'
+ : 'pool';
+ candidate.location = null;
+ }
+ token.zone = 'placed';
+ token.location = 'arrakeen';
+ g.ecazAmbassadors = triggerAmbassador(ambassadors, token.id);
+ const entry = g.pendingAmbassador!;
+ entry.token = token.id;
+ entry.stage = 'arrival';
+ entry.effect = effect;
+ entry.beneficiary = 'i';
+ entry.copyChoices = effect === 'fremen' ? ['fremen'] : [];
+ if (effect === 'guild') {
+ entry.shipmentReceipt = {
+ next: 'accompany',
+ order: {
+ player: 'i',
+ amount: 2,
+ elite: 0,
+ territory: 'carthag',
+ sector: 11,
+ cost: 0,
+ allyPayment: 0,
+ advisors: false,
+ },
+ };
+ assert.equal(
+ validateGuildAmbassadorArrivalContext(g, entry.event, 'accompany').order
+ .amount,
+ 2,
+ );
+ delete entry.revivalEvent;
+ assert.throws(() =>
+ validateGuildAmbassadorArrivalContext(g, entry.event, 'accompany'),
+ );
+ } else {
+ entry.relocation = {
+ next: 'finish',
+ order: {
+ player: 'i',
+ group: [['arrakeen:10', 2]],
+ eliteGroup: {},
+ elite: 0,
+ origin: 'arrakeen',
+ total: 2,
+ to: 'carthag',
+ sector: 11,
+ advisors: false,
+ wantsFighters: false,
+ },
+ };
+ assert.equal(
+ validateAmbassadorRelocationContext(g, entry.event, 'finish').total,
+ 2,
+ );
+ delete entry.revivalEvent;
+ assert.throws(() =>
+ validateAmbassadorRelocationContext(g, entry.event, 'finish'),
+ );
+ }
+ }
+});
diff --git a/tests/automatic-responses.test.ts b/tests/automatic-responses.test.ts
index ca46e785..8a36254e 100644
--- a/tests/automatic-responses.test.ts
+++ b/tests/automatic-responses.test.ts
@@ -362,7 +362,8 @@ void test('setup completion waits for a real response then resumes automatically
});
void test('summoned worm resolves its own window before restoring and automatically completing the saved response', () => {
- let g = fixture(['fremen', 'emperor', 'guild']);
+ // Isolate suspended-response ordering with a correctly seated Guild income owner.
+ let g = fixture(['fremen', 'guild', 'emperor']);
g.phase = 1;
g.storm = 18;
g.players[0].hand = [karamas[0]];
diff --git a/tests/autopilot-recovery.test.ts b/tests/autopilot-recovery.test.ts
index 8d4f33ef..54528c80 100644
--- a/tests/autopilot-recovery.test.ts
+++ b/tests/autopilot-recovery.test.ts
@@ -1,3 +1,4 @@
+// @dune-suite integration
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
diff --git a/tests/battle-discard-continuations.test.ts b/tests/battle-discard-continuations.test.ts
index 58e07703..c67fab73 100644
--- a/tests/battle-discard-continuations.test.ts
+++ b/tests/battle-discard-continuations.test.ts
@@ -240,7 +240,7 @@ void test('last real traitor vote stages a mixed-owner public discard after doub
assert.equal(result.resolvedTreacheryDiscardSequence, 1);
});
-void test('winner hero and loser defense share one mandatory batch before the sole supported casualty allocation', () => {
+void test('loser defense precedes winner casualties and the separate mandatory hero discard', () => {
const initial = fixture();
const hero = hold(initial, 'w', 'hero'),
defense = hold(initial, 'l', 'snooper');
@@ -251,12 +251,17 @@ void test('winner hero and loser defense share one mandatory batch before the so
assert.equal(c.casualties!.options.length, 1);
assert.deepEqual(
pending.pendingTreacheryDiscard!.batch.entries.map((e) => e.discardedBy),
- ['w', 'l'],
+ ['l'],
);
+ assert.deepEqual(pending.pendingWinnerDiscards?.cards, [hero]);
+ assert.ok(player(pending, 'w').hand.some((card) => card.id === hero));
assert.equal(player(pending, 'w').tanks, 0);
assert.equal(player(pending, 'l').tanks, 5);
assert.equal(player(pending, 'w').spice, 18);
const result = resumed(pending);
+ assert.equal(result.resolvedTreacheryDiscardSequence, 2);
+ assert.equal(result.pendingWinnerDiscards, null);
+ assert.ok(!player(result, 'w').hand.some((card) => card.id === hero));
assert.equal(player(result, 'w').tanks, 2);
assert.equal(
result.log.filter((e) => e.automatic?.name === 'Battle casualties').length,
@@ -329,9 +334,13 @@ void test('Moritani reserves loser cards from the mandatory batch and disposes t
const hero = hold(initial, 'w', 'hero'),
weapon = hold(initial, 'l', 'projectile'),
defense = hold(initial, 'l', 'snooper');
- const pending = frame(
+ const pending = inner(
beforeLast(initial, { hero, loserWeapon: weapon, loserDefense: defense }),
+ 'l',
+ finalAction(),
);
+ assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard');
+ assert.equal(player(pending, 'w').tanks, 2);
assert.deepEqual(
pending.pendingTreacheryDiscard!.batch.entries.map((e) => e.card.id),
[hero],
diff --git a/tests/battle-discard-recovery.test.ts b/tests/battle-discard-recovery.test.ts
index 50612ba7..7977a075 100644
--- a/tests/battle-discard-recovery.test.ts
+++ b/tests/battle-discard-recovery.test.ts
@@ -228,8 +228,22 @@ async function fixture(kind: Kind) {
});
g = engine.applyAction(g, winner, { type: 'traitorCall', call: false });
g = inner(g, loser, { type: 'traitorCall', call: false });
- assert.equal(g.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
- assert.equal(g.players[0].tanks, 0);
+ assert.equal(
+ g.pendingTreacheryDiscard?.continuation.kind,
+ kind === 'moritani' ? 'winnerMandatoryDiscard' : 'battleResolved',
+ );
+ assert.equal(g.players[0].tanks, kind === 'moritani' ? 2 : 0);
+ if (kind === 'moritani') {
+ // All loser cards are reserved for retention, so no loser discard batch
+ // delays casualties; the hero starts its own mandatory winner batch.
+ assert.deepEqual(
+ g.pendingTreacheryDiscard!.batch.entries.map((entry) => entry.card.id),
+ [hero],
+ );
+ } else {
+ assert.deepEqual(g.pendingWinnerDiscards?.cards, [hero]);
+ assert.ok(g.players[0].hand.some((card) => card.id === hero));
+ }
if (kind !== 'mandatory') {
g = engine.normalizeAutomaticGame(g);
g = inner(
@@ -314,8 +328,10 @@ async function raceRecovery(f: Fixture) {
assert.equal(after.pendingTreacheryDiscard, null);
assert.equal(
after.resolvedTreacheryDiscardSequence,
- f.initial.treacheryDiscardSequence,
+ f.initial.treacheryDiscardSequence! +
+ (f.initial.pendingWinnerDiscards ? 1 : 0),
);
+ assert.equal(after.pendingWinnerDiscards, null);
assert.deepEqual(inventory(after), f.physicalIds);
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(await f.restart().readRoom(f.code), after);
@@ -330,7 +346,7 @@ void test('real mandatory battle frame survives private reconnect and two SQL wo
f.initial.pendingTreacheryDiscard!.batch.entries.map(
(e) => e.discardedBy,
),
- f.seats.slice(0, 2).map((s) => s.playerId),
+ [f.seats[1].playerId],
);
assert.equal(f.initial.players[0].spice, 21); // support paid and three-spice bounty already earned
assert.equal(f.initial.players[1].leaders[0].deaths, 1);
@@ -462,6 +478,27 @@ void test('forged battle event, casualty allocation and consumed source parent f
else if (c.kind === 'battleCleanup') c.source = 'moritani';
},
];
+ if (kind === 'mandatory') {
+ assert.equal(
+ f.initial.lastBattleContext?.winnerDiscards?.completed,
+ false,
+ );
+ mutations.push(
+ (g) => {
+ // The first saved frame still holds the winner's separate mandatory
+ // discard commitment; its physical IDs must not be replaceable.
+ g.pendingWinnerDiscards!.cards[0] = g.players[0].hand.find(
+ (card) => card.kind === 'projectile',
+ )!.id;
+ },
+ (g) => {
+ g.pendingWinnerDiscards = null;
+ },
+ (g) => {
+ delete g.pendingWinnerDiscards;
+ },
+ );
+ }
for (const mutate of mutations) {
const malformed = structuredClone(f.initial);
mutate(malformed);
diff --git a/tests/bidding-end-controls.test.ts b/tests/bidding-end-controls.test.ts
new file mode 100644
index 00000000..66b49a46
--- /dev/null
+++ b/tests/bidding-end-controls.test.ts
@@ -0,0 +1,279 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { BiddingEnd } from '../components/bidding-end';
+import { EcazPoisonIncome } from '../components/ecaz-poison-income';
+import { baseDeck, type Card } from '../game/cards';
+import { botActions } from '../game/bots';
+import {
+ createGame,
+ joinGame,
+ newPlayer,
+ viewGame,
+ type GameView,
+} from '../game/engine';
+import {
+ biddingEndActions,
+ biddingEndDiscardChoice,
+ choamMarketPolicy,
+} from '../game/bidding-end-options';
+import { tableActionOwner } from '../game/table-turn';
+
+const value = (card: Card) => (card.kind === 'worthless' ? 0 : 10);
+
+/** Projected-window fixtures exercise client privacy, policy and interaction
+ * gates. Engine/recovery suites separately prove the persisted parent flow. */
+function fixture(id = 'e'): GameView {
+ const game = createGame(
+ 'BIDDINGCONTROLS',
+ newPlayer('e', 'Emperor', 'emperor'),
+ true,
+ ['choam'],
+ );
+ joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
+ joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
+ game.status = 'playing';
+ game.phase = 3;
+ game.active = 'a';
+ const cards = baseDeck();
+ const hand = [
+ cards.find((card) => card.kind === 'worthless')!,
+ cards.find((card) => card.effect === 'karama')!,
+ ];
+ for (const player of game.players) {
+ player.spice = 8;
+ player.bot = 'Medium';
+ }
+ game.players[0].hand = hand;
+ const view = viewGame(game, id);
+ view.biddingEnd = {
+ event: 'bidding-end-1',
+ owners: ['e', 'c'],
+ ready: [],
+ canAct: id !== 'a',
+ kaitain: { owner: 'e', eligible: true },
+ };
+ view.choamMarket = {
+ owner: 'c',
+ sales: id === 'c' ? [] : undefined,
+ canTrade: false,
+ tradeAttempted: false,
+ };
+ return view;
+}
+
+void test('Kaitain selection quotes physical cards and current personal affordability without mutation', () => {
+ const view = fixture();
+ const own = view.players[0];
+ const before = structuredClone(view);
+ const ids = own.hand!.map((card) => card.id);
+ assert.deepEqual(biddingEndDiscardChoice(view, ids), {
+ cost: 4,
+ blocked: null,
+ action: {
+ type: 'biddingEnd',
+ event: 'bidding-end-1',
+ mode: 'discard',
+ cards: ids,
+ },
+ });
+ assert.deepEqual(view, before);
+ for (const bad of [[], [ids[0], ids[0]], ['foreign-card']])
+ assert.equal(biddingEndDiscardChoice(view, bad).action, null);
+ own.spice = 3;
+ assert.match(biddingEndDiscardChoice(view, ids).blocked!, /4 spice/);
+ assert.ok(biddingEndDiscardChoice(view, [ids[0]]).action);
+});
+
+void test('Kaitain controls require the current owner, high population and quiet parent', () => {
+ const view = fixture();
+ const ids = [view.players[0].hand![0].id];
+ for (const patch of [
+ { decision: { kind: 'choamTradeReply', player: 'e' } },
+ { response: { kind: 'choamSale', owner: 'c', passed: [] } },
+ { automaticContinuationPending: true },
+ { phase: 4 },
+ { biddingEnd: { ...view.biddingEnd!, canAct: false } },
+ {
+ biddingEnd: {
+ ...view.biddingEnd!,
+ kaitain: { owner: 'e', eligible: false },
+ },
+ },
+ ]) {
+ const blocked = { ...view, ...patch } as GameView;
+ assert.equal(biddingEndDiscardChoice(blocked, ids).action, null);
+ assert.deepEqual(
+ biddingEndActions(blocked, 1, value),
+ blocked.biddingEnd?.kaitain?.eligible === false
+ ? [
+ {
+ type: 'biddingEnd',
+ event: blocked.biddingEnd.event,
+ mode: 'ready',
+ },
+ ]
+ : [],
+ );
+ }
+ assert.equal(biddingEndDiscardChoice(fixture('a'), ids).action, null);
+});
+
+void test('all four Emperor policies preserve useful cards and finish optional paid discards', () => {
+ for (let level = 0; level < 4; level++) {
+ const view = fixture();
+ const own = view.players[0];
+ own.bot = (['Easy', 'Medium', 'Hard', 'Brutal'] as const)[level];
+ const choices = botActions(view);
+ assert.equal(choices[0]?.type, 'biddingEnd');
+ assert.equal(choices[0]?.mode, 'discard');
+ assert.deepEqual(choices[0].cards, [own.hand![0].id]);
+ own.hand = own.hand!.slice(1);
+ assert.deepEqual(botActions(view), [
+ { type: 'biddingEnd', event: view.biddingEnd!.event, mode: 'ready' },
+ ]);
+ view.biddingEnd!.ready = ['e'];
+ assert.deepEqual(botActions(view), []);
+ }
+});
+
+void test('a ready Emperor may use a newly acquired card and low spice does not cause an invalid discard loop', () => {
+ const view = fixture();
+ view.biddingEnd!.ready = ['e'];
+ assert.equal(botActions(view)[0]?.mode, 'discard');
+ view.players[0].spice = 1;
+ assert.deepEqual(botActions(view), []);
+ assert.equal(
+ biddingEndDiscardChoice(view, [view.players[0].hand![0].id]).action,
+ null,
+ );
+ view.biddingEnd!.ready = [];
+ assert.equal(botActions(view)[0]?.mode, 'ready');
+});
+
+void test('CHOAM shared policies retain duplicate-sale priority and current event over the old decision action', () => {
+ const view = fixture('c');
+ const card = baseDeck().find((card) => card.kind === 'worthless')!;
+ view.players[1].hand = [card];
+ view.choamMarket!.sales = [
+ { card: card.id, price: 2, witness: undefined },
+ { card: card.id, price: 3, witness: 'duplicate-witness' },
+ ];
+ for (let level = 0; level < 4; level++) {
+ view.players[1].bot = (['Easy', 'Medium', 'Hard', 'Brutal'] as const)[
+ level
+ ];
+ assert.deepEqual(botActions(view), [
+ {
+ type: 'biddingEnd',
+ event: view.biddingEnd!.event,
+ mode: 'sell',
+ card: card.id,
+ witness: 'duplicate-witness',
+ },
+ ]);
+ }
+ assert.equal(choamMarketPolicy(view, value, card.id).done, true);
+ view.choamMarket!.sales = [];
+ assert.equal(botActions(view)[0]?.mode, 'ready');
+});
+
+void test('CHOAM offers only current private cards and a declined trade does not repeat forever', () => {
+ const view = fixture('c');
+ const worthless = baseDeck().find((card) => card.kind === 'worthless')!;
+ view.players[1].hand = [worthless];
+ view.choamMarket!.canTrade = true;
+ assert.deepEqual(biddingEndActions(view, 1, value), [
+ {
+ type: 'biddingEnd',
+ event: view.biddingEnd!.event,
+ mode: 'trade',
+ card: worthless.id,
+ },
+ ]);
+ view.choamMarket!.tradeAttempted = true;
+ assert.equal(biddingEndActions(view, 1, value)[0].mode, 'ready');
+ view.biddingEnd!.ready = ['c'];
+ assert.deepEqual(biddingEndActions(view, 1, value), []);
+});
+
+void test('shared end-of-Bidding helpers and policies never inspect rival private hands or spice', () => {
+ for (const id of ['e', 'c']) {
+ const view = fixture(id);
+ const expected = botActions(view);
+ for (const player of view.players.filter((player) => player.id !== view.me))
+ for (const key of ['hand', 'spice', 'traitors', 'faceDancers'])
+ Object.defineProperty(player, key, {
+ get() {
+ throw new Error('Private rival data read');
+ },
+ });
+ assert.deepEqual(botActions(view), expected);
+ const own = view.players.find((player) => player.id === id)!;
+ biddingEndDiscardChoice(view, own.hand?.map((card) => card.id) ?? []);
+ }
+});
+
+void test('end-Bidding component labels optional readiness and privately inspectable Kaitain cards', () => {
+ const view = fixture();
+ const html = renderToStaticMarkup(
+ createElement(BiddingEnd, { game: view, act: () => {}, busy: false }),
+ );
+ assert.match(html, /aria-label="End of Bidding"/);
+ assert.match(html, /Emperor: Choosing/);
+ assert.match(html, /CHOAM: Choosing/);
+ for (const card of view.players[0].hand!) {
+ assert.ok(html.includes(`Inspect card: ${card.name}`));
+ assert.ok(html.includes(`Discard ${card.name} for 2 spice`));
+ }
+ assert.match(html, /Pay 0 spice and discard 0 cards/);
+ assert.match(html, /Ready for Revival/);
+ assert.doesNotMatch(html, /undefined|NaN/);
+ const spectator = renderToStaticMarkup(
+ createElement(BiddingEnd, {
+ game: fixture('a'),
+ act: () => {},
+ busy: false,
+ }),
+ );
+ assert.doesNotMatch(
+ spectator,
+ /Inspect card:|Discard .* for 2 spice|Ready for Revival/,
+ );
+});
+
+void test('the turn banner names a sole unready owner and gives nested decisions precedence', () => {
+ const view = fixture();
+ assert.equal(tableActionOwner(view), null);
+ view.biddingEnd!.ready = ['c'];
+ assert.equal(tableActionOwner(view), 'e');
+ view.decision = { kind: 'choamTradeReply', player: 'a' };
+ assert.equal(tableActionOwner(view), 'a');
+ view.response = { kind: 'choamSale', owner: 'c', passed: [] };
+ assert.equal(tableActionOwner(view), null);
+});
+
+void test('Ecaz income receipts remain private and readable after the Bidding window closes', () => {
+ const view = fixture();
+ view.players[0].faction = 'ecaz';
+ view.homeworlds = { worlds: [] };
+ view.biddingEnd = null;
+ view.phase = 4;
+ view.ecazPoisonIncome = [{ turn: 2, phase: 3, count: 2, amount: 6 }];
+ const render = () =>
+ renderToStaticMarkup(createElement(EcazPoisonIncome, { game: view }));
+ assert.match(render(), /Your Ecaz poison income/);
+ assert.match(
+ render(),
+ /Turn 2 · Bidding: \+6 spice from 2 discarded poison weapons/,
+ );
+ view.me = 'c';
+ assert.doesNotMatch(
+ render(),
+ /Your Ecaz poison income|discarded poison weapons/,
+ );
+ view.me = 'e';
+ view.ecazPoisonIncome = [];
+ assert.doesNotMatch(render(), /Your Ecaz poison income/);
+});
diff --git a/tests/bidding-end-engine.test.ts b/tests/bidding-end-engine.test.ts
new file mode 100644
index 00000000..01ae2791
--- /dev/null
+++ b/tests/bidding-end-engine.test.ts
@@ -0,0 +1,506 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeHomeworldGameForAudit,
+ joinGame,
+ newPlayer,
+ normalizeAutomaticGame,
+ viewGame,
+ type Game,
+ type Action,
+} from '../game/engine';
+import { baseDeck, spiceDeck, type Card } from '../game/cards';
+import { createHomeworldCustody } from '../game/homeworld-custody';
+import {
+ homeworldContext,
+ homeworldGameIntegrity,
+} from '../game/homeworld-game';
+import { botActions } from '../game/bots';
+import { richeseCards } from '../game/richese-cards';
+
+const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
+const own = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
+function hold(g: Game, id: string, match: (card: Card) => boolean) {
+ const at = g.deck.findIndex(match);
+ assert.ok(at >= 0);
+ const card = g.deck.splice(at, 1)[0];
+ own(g, id).hand.push(card);
+ return card;
+}
+/** 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) {
+ let g = createGame(
+ 'BIDDINGEND',
+ newPlayer('e', 'Emperor', 'emperor'),
+ advanced,
+ choam ? ['choam'] : [],
+ );
+ joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
+ if (choam) {
+ joinGame(g, newPlayer('c', 'CHOAM', 'choam'));
+ Object.assign(g, {
+ status: 'playing',
+ deck: baseDeck(),
+ spiceDeck: spiceDeck(),
+ });
+ own(g, 'e').elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
+ g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
+ } else {
+ g = applyAction(g, 'e', { 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++) {
+ 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, 'Genuine setup must expose an owned legal choice.');
+ g = next;
+ }
+ assert.equal(g.status, 'playing');
+ for (const p of g.players) g.deck.push(...p.hand.splice(0));
+ }
+ Object.assign(g, {
+ phase: 2,
+ turn: 2,
+ storm: 18,
+ active: null,
+ order: g.players.map((p) => p.id),
+ ready: [],
+ phaseOpening: null,
+ decision: null,
+ response: null,
+ choamCharity: { turn: 2, canceled: false },
+ });
+ for (const p of g.players) p.spice = 20;
+ inventory(g);
+ return g;
+}
+function inventory(g: Game) {
+ homeworldGameIntegrity(g);
+ for (const p of g.players) {
+ assert.equal(
+ p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
+ 20,
+ );
+ if (p.elites)
+ assert.equal(
+ p.elites.reserves +
+ p.elites.tanks +
+ Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
+ 5,
+ );
+ }
+ const ids = [
+ ...g.deck,
+ ...g.discard,
+ ...g.players.flatMap((p) => p.hand),
+ ...(g.auction?.cards.slice(g.auction.index) ?? []),
+ ]
+ .map((card) => card.id)
+ .sort();
+ assert.deepEqual(
+ ids,
+ baseDeck()
+ .map((card) => card.id)
+ .sort(),
+ );
+}
+function endBidding(g: Game) {
+ for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
+ if (g.decision?.kind === 'choamMarket')
+ g = applyAction(g, 'c', { type: 'decision', done: true });
+ assert.equal(g.phase, 3);
+ assert.ok(
+ g.auction,
+ 'The normal auction opens through real phase transition.',
+ );
+ for (let n = 0; g.auction && n < 40; n++)
+ g = g.response
+ ? allow(g)
+ : applyAction(g, g.auction.active, { type: 'passBid' });
+ assert.equal(g.auction, null);
+ assert.equal(g.phase, 3);
+ assert.ok(g.biddingEnd);
+ return g;
+}
+function act(g: Game, id: string, action: Omit) {
+ return applyAction(g, id, {
+ type: 'biddingEnd',
+ event: g.biddingEnd!.event,
+ ...action,
+ });
+}
+function reject(g: Game, id: string, action: Action) {
+ const before = reload(g);
+ assert.throws(() => applyAction(g, id, action));
+ assert.deepEqual(g, before);
+}
+function allow(g: Game) {
+ for (let n = 0; g.response && n < 20; n++) {
+ const responder = g.players.find((p) => {
+ const controls = viewGame(g, p.id).responseControls;
+ return controls && !controls.hasPassed && controls.cancelCards.length;
+ });
+ assert.ok(responder);
+ g = applyAction(g, responder.id, { type: 'passResponse' });
+ }
+ return g;
+}
+function trade(g: Game, offered: string, returned: string) {
+ g = act(g, 'c', { mode: 'trade', card: offered });
+ assert.equal(g.decision?.kind, 'choamTradeReply');
+ assert.equal(viewGame(g, 'e').biddingEnd?.canAct, false);
+ g = applyAction(reload(g), 'e', { type: 'decision', card: returned });
+ g = applyAction(reload(g), 'c', { type: 'decision', accept: true });
+ return g;
+}
+
+void test('actual final normal auction opens Kaitain once and paid multi-card disposal never restarts bidding', () => {
+ let g = fixture();
+ const first = hold(g, 'e', (card) => card.kind === 'worthless');
+ const second = hold(g, 'e', (card) => card.kind === 'shield');
+ g = endBidding(g);
+ assert.deepEqual(g.biddingEnd!.owners, ['e']);
+ const event = g.biddingEnd!.event;
+ const deck = reload(g).deck;
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
+ g = act(reload(g), 'e', { mode: 'discard', cards: [second.id, first.id] });
+ assert.equal(own(g, 'e').spice, 16);
+ assert.equal(
+ g.discard.filter((card) => [first.id, second.id].includes(card.id)).length,
+ 2,
+ );
+ assert.deepEqual(g.deck, deck);
+ assert.equal(g.auction, null);
+ if (g.biddingEnd) {
+ assert.equal(g.biddingEnd.event, event);
+ g = act(g, 'e', { mode: 'ready' });
+ }
+ assert.equal(g.phase, 4);
+ assert.equal(g.biddingEnd, null);
+ inventory(g);
+});
+
+void test('Kaitain rejects empty, repeated, foreign, stale and unaffordable selections without mutation', () => {
+ let g = fixture();
+ const card = hold(g, 'e', (card) => card.kind === 'worthless');
+ const foreign = hold(g, 'a', (card) => card.kind === 'shield');
+ g = endBidding(g);
+ for (const cards of [[], [card.id, card.id], [foreign.id], ['missing']])
+ reject(g, 'e', {
+ type: 'biddingEnd',
+ event: g.biddingEnd!.event,
+ mode: 'discard',
+ cards,
+ });
+ reject(g, 'e', {
+ type: 'biddingEnd',
+ event: 'stale',
+ mode: 'discard',
+ cards: [card.id],
+ });
+ reject(g, 'a', {
+ type: 'biddingEnd',
+ event: g.biddingEnd!.event,
+ mode: 'discard',
+ cards: [foreign.id],
+ });
+ own(g, 'e').spice = 1;
+ reject(g, 'e', {
+ type: 'biddingEnd',
+ event: g.biddingEnd!.event,
+ mode: 'discard',
+ cards: [card.id],
+ });
+ inventory(g);
+});
+
+void test('Kaitain payment spends already held spice and does not activate discarded Karama or consume special Karama', () => {
+ let g = fixture();
+ const karama = hold(g, 'e', (card) => card.effect === 'karama');
+ hold(g, 'e', (card) => card.kind === 'shield');
+ hold(g, 'a', (card) => card.effect === 'karama');
+ g = endBidding(g);
+ const oldUsed = own(g, 'e').specialKaramaUsed;
+ g = act(reload(g), 'e', { mode: 'discard', cards: [karama.id] });
+ assert.equal(own(g, 'e').spice, 18);
+ assert.equal(own(g, 'e').specialKaramaUsed, oldUsed);
+ assert.equal(g.response, null);
+ assert.equal(g.discard.filter((card) => card.id === karama.id).length, 1);
+ inventory(g);
+});
+
+void test('shared owners can choose trade before or after an Emperor discard without faction priority', () => {
+ for (const tradeFirst of [false, true]) {
+ let g = fixture(true);
+ own(g, 'e').ally = 'c';
+ own(g, 'c').ally = 'e';
+ const dispose = hold(g, 'e', (card) => card.kind === 'worthless');
+ const returned = hold(g, 'e', (card) => card.name === 'Shield');
+ const incoming = hold(g, 'c', (card) => card.name === 'Snooper');
+ g = endBidding(g);
+ assert.deepEqual(g.biddingEnd!.owners, ['e', 'c']);
+ assert.equal(viewGame(g, 'e').biddingEnd?.canAct, true);
+ assert.equal(viewGame(g, 'c').biddingEnd?.canAct, true);
+ if (tradeFirst) g = trade(g, incoming.id, returned.id);
+ g = act(g, 'e', { mode: 'discard', cards: [dispose.id] });
+ if (!tradeFirst) g = trade(g, incoming.id, returned.id);
+ assert.equal(own(g, 'e').spice, 18);
+ assert.ok(own(g, 'e').hand.some((card) => card.id === incoming.id));
+ assert.ok(own(g, 'c').hand.some((card) => card.id === returned.id));
+ assert.equal(g.auction, null);
+ assert.equal(g.phase, 3);
+ inventory(g);
+ }
+});
+
+void test('incoming traded cards reopen readiness and an already-ready Emperor can discard them', () => {
+ let g = fixture(true);
+ own(g, 'e').ally = 'c';
+ own(g, 'c').ally = 'e';
+ const returned = hold(g, 'e', (card) => card.name === 'Shield');
+ const incoming = hold(g, 'c', (card) => card.kind === 'worthless');
+ g = endBidding(g);
+ g = act(g, 'e', { mode: 'ready' });
+ assert.ok(g.biddingEnd!.ready.includes('e'));
+ assert.equal(viewGame(g, 'e').biddingEnd?.canAct, true);
+ g = trade(g, incoming.id, returned.id);
+ assert.equal(g.biddingEnd!.ready.length, 0);
+ g = act(reload(g), 'e', { mode: 'discard', cards: [incoming.id] });
+ assert.equal(own(g, 'e').spice, 18);
+ assert.equal(g.discard.filter((card) => card.id === incoming.id).length, 1);
+ assert.equal(g.phase, 3);
+ if (!g.biddingEnd!.ready.includes('e')) g = act(g, 'e', { mode: 'ready' });
+ g = act(g, 'c', { mode: 'ready' });
+ assert.equal(g.phase, 4);
+ inventory(g);
+});
+
+void test('observers see shared timing but never private Emperor card choices or held spice', () => {
+ let g = fixture(true);
+ const card = hold(g, 'e', (card) => card.kind === 'worthless');
+ hold(g, 'c', (card) => card.name === 'Shield');
+ g = endBidding(g);
+ const observer = viewGame(g, 'a');
+ assert.equal(observer.biddingEnd?.canAct, false);
+ assert.equal(observer.players.find((p) => p.id === 'e')!.hand, undefined);
+ assert.equal(observer.players.find((p) => p.id === 'e')!.spice, undefined);
+ assert.ok(!JSON.stringify(observer.biddingEnd).includes(card.id));
+ assert.ok(
+ viewGame(g, 'e')
+ .players.find((p) => p.id === 'e')!
+ .hand!.some((c) => c.id === card.id),
+ );
+ reject(g, 'a', {
+ type: 'biddingEnd',
+ event: g.biddingEnd!.event,
+ mode: 'ready',
+ });
+});
+
+void test('current Kaitain population excludes Salusa and a real Ghola revival can reopen its eligibility', () => {
+ let g = fixture(true);
+ const e = own(g, 'e');
+ e.reserves = 9;
+ e.tanks = 1;
+ e.forces = { 'polar_sink:0': 10 };
+ e.elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
+ g.homeworlds!.custody!.salusa = { normal: 0, elite: 5 };
+ const card = hold(g, 'e', (card) => card.kind === 'worthless');
+ const ghola = hold(g, 'e', (card) => card.effect === 'ghola');
+ hold(g, 'c', (card) => card.name === 'Shield');
+ g = endBidding(g);
+ assert.equal(viewGame(g, 'e').biddingEnd?.kaitain.eligible, false);
+ reject(g, 'e', {
+ type: 'biddingEnd',
+ event: g.biddingEnd!.event,
+ mode: 'discard',
+ cards: [card.id],
+ });
+ g = act(g, 'c', { mode: 'ready' });
+ g = applyAction(reload(g), 'e', {
+ type: 'card',
+ card: ghola.id,
+ amount: 1,
+ elite: 0,
+ });
+ assert.equal(own(g, 'e').reserves, 10);
+ assert.equal(viewGame(g, 'e').biddingEnd?.kaitain.eligible, true);
+ assert.equal(g.biddingEnd!.ready.length, 0);
+ g = act(g, 'e', { mode: 'discard', cards: [card.id] });
+ assert.equal(own(g, 'e').spice, 18);
+ inventory(g);
+});
+
+void test('CHOAM can settle an actual duplicate sale inside the shared window without forcing Emperor readiness', () => {
+ let g = fixture(true);
+ hold(g, 'e', (card) => card.kind === 'worthless');
+ const card = hold(g, 'c', (card) => card.name === 'Snooper');
+ const witness = hold(g, 'c', (card) => card.name === 'Snooper');
+ hold(g, 'a', (card) => card.effect === 'karama');
+ g = endBidding(g);
+ const event = g.biddingEnd!.event;
+ g = act(g, 'c', { mode: 'sell', card: card.id, witness: witness.id });
+ assert.equal(g.response?.kind, 'choamSale');
+ assert.equal(viewGame(g, 'e').biddingEnd?.canAct, false);
+ g = allow(reload(g));
+ assert.equal(g.biddingEnd!.event, event);
+ assert.equal(own(g, 'c').spice, 23);
+ assert.equal(g.phase, 3);
+ assert.equal(g.auction, null);
+ assert.equal(g.decision, null);
+ assert.equal(viewGame(g, 'e').biddingEnd?.canAct, true);
+ inventory(g);
+});
+
+void test('Kaitain threshold is five native counters in both modes, with typed Salusa excluded in Advanced', () => {
+ for (const advanced of [false, true])
+ for (const native of [4, 5]) {
+ let g = fixture(true, advanced);
+ const e = own(g, 'e');
+ e.reserves = native + (advanced ? 5 : 0);
+ e.forces = { 'polar_sink:0': 20 - e.reserves };
+ e.elites = {
+ reserves: advanced ? 5 : 0,
+ tanks: 0,
+ forces: advanced ? {} : { 'polar_sink:0': 5 },
+ revived: 0,
+ };
+ g.homeworlds!.custody!.salusa = advanced ? { normal: 0, elite: 5 } : null;
+ const card = hold(g, 'e', (card) => card.kind === 'worthless');
+ hold(g, 'c', (card) => card.name === 'Shield');
+ g = endBidding(g);
+ assert.equal(viewGame(g, 'e').biddingEnd?.kaitain.eligible, native === 5);
+ if (native === 5) {
+ g = act(g, 'e', { mode: 'discard', cards: [card.id] });
+ assert.equal(own(g, 'e').spice, 18);
+ } else
+ reject(g, 'e', {
+ type: 'biddingEnd',
+ event: g.biddingEnd!.event,
+ mode: 'discard',
+ cards: [card.id],
+ });
+ inventory(g);
+ }
+});
+
+void test('closing both owners returns auction aid once and advances to Revival only after both are ready', () => {
+ let g = fixture(true);
+ hold(g, 'e', (card) => card.kind === 'worthless');
+ hold(g, 'c', (card) => card.name === 'Shield');
+ g = endBidding(g);
+ g.aid = { a: { recipient: 'e', amount: 3 } };
+ own(g, 'a').spice = 17;
+ const event = g.biddingEnd!.event;
+ g = act(reload(g), 'c', { mode: 'ready' });
+ assert.equal(g.phase, 3);
+ assert.equal(own(g, 'a').spice, 17);
+ assert.equal(g.biddingEnd!.event, event);
+ g = act(reload(g), 'e', { mode: 'ready' });
+ assert.equal(g.phase, 4);
+ assert.equal(own(g, 'a').spice, 20);
+ assert.deepEqual(g.aid, {});
+ assert.equal(g.biddingEnd, null);
+ const settled = reload(g);
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), settled);
+ reject(g, 'e', { type: 'biddingEnd', event, mode: 'ready' });
+ inventory(g);
+});
+
+void test('ordinary CHOAM market retains its existing end-of-Bidding decision when Homeworlds are disabled', () => {
+ let g = fixture(true);
+ g.homeworlds = null;
+ hold(g, 'e', (card) => card.kind === 'worthless');
+ hold(g, 'c', (card) => card.name === 'Shield');
+ for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
+ assert.equal(g.decision?.kind, 'choamMarket');
+ g = applyAction(g, 'c', { type: 'decision', done: true });
+ for (let n = 0; g.auction && n < 40; n++)
+ g = g.response
+ ? allow(g)
+ : applyAction(g, g.auction.active, { type: 'passBid' });
+ assert.equal(g.phase, 3);
+ assert.equal(g.biddingEnd ?? null, null);
+ assert.equal(g.decision?.kind, 'choamMarket');
+ g = applyAction(g, 'c', { type: 'decision', done: true });
+ assert.equal(g.phase, 4);
+ inventory(g);
+});
+
+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 = [];
+ 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');
+ g = applyAction(g, 'r', {
+ type: 'decision',
+ event: g.richeseBidding!.event,
+ position: 'last',
+ });
+ for (let n = 0; g.auction && n < 40; n++)
+ g = applyAction(g, g.auction.active, { type: 'passBid' });
+ assert.equal(g.decision?.kind, 'richeseCache');
+ assert.equal(g.biddingEnd ?? null, null);
+ const cacheCard = g.richeseCache![0].id;
+ g = applyAction(reload(g), 'r', {
+ type: 'decision',
+ event: g.richeseBidding!.event,
+ card: cacheCard,
+ method: 'silent',
+ });
+ for (const p of g.players)
+ g = applyAction(g, p.id, {
+ type: 'richeseBid',
+ event: g.richeseAuction!.event,
+ amount: 0,
+ });
+ assert.equal(g.decision?.kind, 'richeseUnbid');
+ assert.equal(g.biddingEnd ?? null, null);
+ g = applyAction(reload(g), 'r', {
+ type: 'decision',
+ event: g.richeseBidding!.event,
+ keep: false,
+ });
+ assert.equal(g.richeseBidding!.stage, 'complete');
+ assert.equal(g.richeseAuction, null);
+ assert.equal(g.phase, 3);
+ assert.deepEqual(g.biddingEnd!.owners, ['e']);
+ assert.equal(
+ g.richeseRemoved!.filter((card) => card.id === cacheCard).length,
+ 1,
+ );
+ g = act(reload(g), 'e', { mode: 'discard', cards: [disposal.id] });
+ assert.equal(own(g, 'e').spice, 18);
+ assert.equal(g.phase, 4);
+ inventory(g);
+ const cacheIds = [...g.richeseCache!, ...g.richeseRemoved!]
+ .map((card) => card.id)
+ .sort();
+ assert.deepEqual(
+ cacheIds,
+ richeseCards()
+ .map((card) => card.id)
+ .sort(),
+ );
+});
diff --git a/tests/bidding-end-recovery.test.ts b/tests/bidding-end-recovery.test.ts
new file mode 100644
index 00000000..e4610eb5
--- /dev/null
+++ b/tests/bidding-end-recovery.test.ts
@@ -0,0 +1,532 @@
+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';
+
+/** 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 };
+}
+
+const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
+async function fixture(ecaz = false) {
+ const store = unitStore();
+ const made = await store.rooms.createRoom(
+ 'Kaitain recovery QA',
+ 'emperor',
+ true,
+ [],
+ );
+ const code = made.view.code;
+ const tokens = [made.token];
+ for (const faction of [
+ 'atreides',
+ 'guild',
+ ...(ecaz ? ['harkonnen'] : []),
+ ] as const) {
+ const joined = await store.rooms.joinRoom(
+ code,
+ faction,
+ faction as engine.Player['faction'],
+ );
+ tokens.push(joined.token!);
+ }
+ const auths = await Promise.all(
+ tokens.map((token) => store.rooms.authenticate(code, token)),
+ );
+ let g = await store.rooms.readRoom(code);
+ g = engine.applyAction(g, g.host, { type: 'homeworlds', enabled: true });
+ for (const player of g.players)
+ g = engine.applyAction(g, player.id, { type: 'ready' });
+ g = engine.initializeHomeworldGameForAudit(g);
+ for (let n = 0; g.status === 'setup' && n < 40; n++) {
+ let next: engine.Game | undefined;
+ for (const player of g.players) {
+ const view = engine.viewGame(g, player.id);
+ view.players.find((p) => p.id === player.id)!.bot = 'Easy';
+ const action = bots.botActions(view)[0];
+ if (action) {
+ next = engine.applyAction(g, player.id, action);
+ break;
+ }
+ }
+ assert.ok(next, 'Genuine Homeworld setup must expose a legal choice.');
+ g = next;
+ }
+ 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';
+ g.players[2].name = 'CHOAM';
+ if (ecaz) {
+ Object.assign(g.players[3], {
+ faction: 'ecaz',
+ name: 'Ecaz',
+ reserves: 20,
+ forces: {},
+ });
+ }
+ Object.assign(g, {
+ phase: 2,
+ turn: 2,
+ storm: 18,
+ active: null,
+ order: ids,
+ ready: [],
+ phaseOpening: null,
+ decision: null,
+ response: null,
+ choamCharity: { turn: 2, canceled: false },
+ });
+ for (const player of g.players) player.spice = 20;
+ const hold = (
+ who: number,
+ predicate: (card: engine.Player['hand'][number]) => boolean,
+ ) => {
+ const at = g.deck.findIndex(predicate);
+ assert.ok(at >= 0);
+ const [card] = g.deck.splice(at, 1);
+ g.players[who].hand.push(card);
+ return card.id;
+ };
+ const poison = hold(0, (c) => c.kind === 'poison');
+ const second = hold(0, (c) => c.kind === 'poison');
+ const returned = hold(0, (c) => c.kind === 'shield');
+ hold(0, (c) => c.kind === 'hero');
+ const offered = hold(2, (c) => c.kind === 'worthless');
+ g.players[0].ally = ids[2];
+ g.players[2].ally = ids[0];
+ homeworldGameIntegrity(g);
+ for (const id of ids) g = engine.applyAction(g, id, { type: 'ready' });
+ assert.equal(g.decision?.kind, 'choamMarket');
+ g = engine.applyAction(g, ids[2], { type: 'decision', done: true });
+ assert.equal(g.phase, 3);
+ assert.ok(g.auction, 'Real phase transition opens the auction.');
+ for (let n = 0; g.auction && n < 40; n++)
+ g = engine.applyAction(g, g.auction.active, { type: 'passBid' });
+ assert.equal(g.auction, null);
+ assert.ok(
+ g.biddingEnd,
+ 'The completed real auction opens the shared window.',
+ );
+ const save = (state: engine.Game) =>
+ store.sqlite
+ .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
+ .run(JSON.stringify(state), state.version, code);
+ save(g);
+ const other = await store.rooms.createRoom(
+ 'Untouched room',
+ 'atreides',
+ false,
+ [],
+ );
+ const otherBefore = await store.rooms.readRoom(other.view.code);
+ const cardIds = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
+ .map((c) => c.id)
+ .sort();
+ store.writes.length = 0;
+ return {
+ ...store,
+ code,
+ tokens,
+ auths,
+ ids,
+ initial: g,
+ poison,
+ second,
+ returned,
+ offered,
+ cardIds,
+ 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 restored(f: Fixture, state: engine.Game) {
+ const rooms = f.restart();
+ for (let i = 0; i < f.tokens.length; i++) {
+ const auth = await rooms.authenticate(f.code, f.tokens[i]);
+ const view = await rooms.readSeatView(f.code, auth);
+ assert.deepEqual(view, engine.viewGame(state, f.ids[i]));
+ for (const opponent of view.players.filter((p) => p.id !== f.ids[i])) {
+ assert.equal(opponent.hand, undefined);
+ assert.equal(opponent.traitors, undefined);
+ assert.equal(opponent.spice, undefined);
+ }
+ if (i !== 3) assert.deepEqual(view.ecazPoisonIncome, []);
+ assert.ok(!JSON.stringify(view.biddingEnd).includes(f.poison));
+ }
+ homeworldGameIntegrity(state);
+ for (const p of state.players) {
+ assert.equal(
+ p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
+ 20,
+ );
+ if (p.elites)
+ assert.equal(
+ p.elites.reserves +
+ p.elites.tanks +
+ Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
+ 5,
+ );
+ }
+ assert.deepEqual(
+ [...state.deck, ...state.discard, ...state.players.flatMap((p) => p.hand)]
+ .map((c) => c.id)
+ .sort(),
+ f.cardIds,
+ );
+ assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore);
+ return rooms;
+}
+function action(
+ g: engine.Game,
+ details: Omit,
+): engine.Action {
+ return { type: 'biddingEnd', event: g.biddingEnd!.event, ...details };
+}
+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 duplicate Kaitain multi-card disposal charges, discards and restores exactly once', async () => {
+ const f = await fixture();
+ try {
+ const rooms = await restored(f, f.initial);
+ const command = action(f.initial, {
+ mode: 'discard',
+ cards: [f.second, f.poison],
+ });
+ barrier(f);
+ const results = await Promise.allSettled([
+ f.rooms.act(f.code, f.auths[0], f.initial.version, command, clock),
+ rooms.act(f.code, f.auths[0], f.initial.version, command, clock),
+ ]);
+ delete f.hooks.beforeWrite;
+ assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
+ assert.deepEqual(
+ f.writes.map((w) => w.changes).sort((a, b) => a - b),
+ [0, 1],
+ );
+ const done = await rooms.readRoom(f.code);
+ assert.equal(done.version, f.initial.version + 1);
+ assert.equal(done.players[0].spice, 16);
+ for (const id of [f.poison, f.second])
+ assert.equal(done.discard.filter((c) => c.id === id).length, 1);
+ assert.equal(done.biddingEnd?.event, f.initial.biddingEnd!.event);
+ assert.equal(done.auction, null);
+ const before = snapshot(f);
+ await assert.rejects(
+ rooms.act(f.code, f.auths[0], f.initial.version, command, clock),
+ );
+ await assert.rejects(
+ rooms.act(f.code, f.auths[0], done.version, command, clock),
+ );
+ await rooms.continueRoomAutomatic(f.code, clock);
+ assert.deepEqual(snapshot(f), before);
+ await restored(f, done);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('production SQL paid private poison disposal restores Ecaz income only to its owner without changing forces', async () => {
+ const f = await fixture(true);
+ try {
+ const rooms = await restored(f, f.initial);
+ await rooms.act(
+ f.code,
+ f.auths[0],
+ f.initial.version,
+ action(f.initial, { mode: 'discard', cards: [f.poison, f.second] }),
+ clock,
+ );
+ const done = await rooms.readRoom(f.code);
+ assert.equal(done.players[0].spice, 16);
+ assert.equal(done.players[3].spice, 26);
+ assert.equal(
+ done.ecazPoisonIncome?.reduce((sum, income) => sum + income.amount, 0),
+ 6,
+ );
+ assert.equal(
+ done.ecazPoisonIncome?.reduce((sum, income) => sum + income.count, 0),
+ 2,
+ );
+ for (let i = 0; i < done.players.length; i++) {
+ assert.equal(done.players[i].reserves, f.initial.players[i].reserves);
+ assert.deepEqual(done.players[i].forces, f.initial.players[i].forces);
+ }
+ const restarted = await restored(f, done);
+ const ecaz = await restarted.readSeatView(f.code, f.auths[3]);
+ assert.equal(
+ ecaz.ecazPoisonIncome.reduce((sum, receipt) => sum + receipt.amount, 0),
+ 6,
+ );
+ const observer = await restarted.readSeatView(f.code, f.auths[1]);
+ const newLogs = observer.log.slice(f.initial.log.length);
+ assert.ok(!JSON.stringify(newLogs).includes('poison'));
+ assert.ok(!JSON.stringify(newLogs).includes(f.poison));
+ const before = snapshot(f);
+ await restarted.continueRoomAutomatic(f.code, clock);
+ assert.deepEqual(snapshot(f), before);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('production SQL restored readiness is reopened by an allied exchange and closes only after both owners agree again', async () => {
+ const f = await fixture();
+ try {
+ let rooms = await restored(f, f.initial);
+ await rooms.act(
+ f.code,
+ f.auths[0],
+ f.initial.version,
+ action(f.initial, { mode: 'ready' }),
+ clock,
+ );
+ let g = await rooms.readRoom(f.code);
+ assert.deepEqual(g.biddingEnd?.ready, [f.ids[0]]);
+ rooms = await restored(f, g);
+ await rooms.act(
+ f.code,
+ f.auths[2],
+ g.version,
+ action(g, { mode: 'trade', card: f.offered }),
+ clock,
+ );
+ g = await rooms.readRoom(f.code);
+ assert.deepEqual(g.biddingEnd?.ready, []);
+ assert.equal(g.decision?.kind, 'choamTradeReply');
+ rooms = await restored(f, g);
+ await rooms.act(
+ f.code,
+ f.auths[0],
+ g.version,
+ { type: 'decision', card: f.returned },
+ clock,
+ );
+ g = await rooms.readRoom(f.code);
+ assert.equal(g.decision?.kind, 'choamTradeConfirm');
+ rooms = await restored(f, g);
+ await rooms.act(
+ f.code,
+ f.auths[2],
+ g.version,
+ { type: 'decision', accept: true },
+ clock,
+ );
+ g = await rooms.readRoom(f.code);
+ assert.ok(g.players[0].hand.some((c) => c.id === f.offered));
+ assert.ok(g.players[2].hand.some((c) => c.id === f.returned));
+ assert.deepEqual(g.biddingEnd?.ready, []);
+ rooms = await restored(f, g);
+ await rooms.act(
+ f.code,
+ f.auths[0],
+ g.version,
+ action(g, { mode: 'ready' }),
+ clock,
+ );
+ g = await rooms.readRoom(f.code);
+ assert.equal(g.phase, 3);
+ rooms = await restored(f, g);
+ const command = action(g, { mode: 'ready' });
+ await rooms.act(f.code, f.auths[2], g.version, command, clock);
+ const done = await rooms.readRoom(f.code);
+ assert.equal(done.phase, 4);
+ assert.equal(done.biddingEnd, null);
+ assert.equal(done.choamMarket, null);
+ const before = snapshot(f);
+ await assert.rejects(
+ rooms.act(f.code, f.auths[2], done.version, command, clock),
+ );
+ assert.deepEqual(snapshot(f), before);
+ await restored(f, done);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('production SQL invalid event, held cards and unaffordable Kaitain requests preserve room hash and avoid writes', async () => {
+ const f = await fixture();
+ try {
+ for (const [state, command] of [
+ [
+ f.initial,
+ action(f.initial, {
+ event: 'expired',
+ mode: 'discard',
+ cards: [f.poison],
+ }),
+ ],
+ [f.initial, action(f.initial, { mode: 'discard', cards: [f.offered] })],
+ [
+ f.initial,
+ action(f.initial, { mode: 'discard', cards: [f.poison, f.poison] }),
+ ],
+ [
+ (() => {
+ const g = structuredClone(f.initial);
+ g.players[0].spice = 1;
+ return g;
+ })(),
+ action(f.initial, { mode: 'discard', cards: [f.poison] }),
+ ],
+ ] as [engine.Game, engine.Action][]) {
+ f.save(state);
+ const rooms = await restored(f, state);
+ const before = snapshot(f);
+ await assert.rejects(
+ rooms.act(f.code, f.auths[0], state.version, command, clock),
+ );
+ assert.deepEqual(snapshot(f), before);
+ assert.equal(f.writes.length, 0);
+ }
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('production SQL corrupted shared bidding parent rejects restored views and disposal without spending or writing', async () => {
+ const f = await fixture();
+ try {
+ for (const mutate of [
+ (g: engine.Game) => {
+ g.biddingEnd!.turn++;
+ },
+ (g: engine.Game) => {
+ g.biddingEnd!.owners.reverse();
+ },
+ (g: engine.Game) => {
+ g.biddingEnd!.ready = [f.ids[0], f.ids[0]];
+ },
+ (g: engine.Game) => {
+ g.choamMarket!.owner = f.ids[1];
+ },
+ ]) {
+ const corrupt = structuredClone(f.initial);
+ mutate(corrupt);
+ f.save(corrupt);
+ const before = snapshot(f);
+ const rooms = f.restart();
+ await assert.rejects(rooms.readSeatView(f.code, f.auths[0]));
+ await assert.rejects(
+ rooms.act(
+ f.code,
+ f.auths[0],
+ corrupt.version,
+ action(corrupt, { mode: 'discard', cards: [f.poison] }),
+ clock,
+ ),
+ );
+ assert.deepEqual(snapshot(f), before);
+ assert.equal(f.writes.length, 0);
+ }
+ f.save(f.initial);
+ await restored(f, f.initial);
+ } finally {
+ f.sqlite.close();
+ }
+});
diff --git a/tests/board-resolution-quote.test.ts b/tests/board-resolution-quote.test.ts
index e4e0edb1..db1104d1 100644
--- a/tests/board-resolution-quote.test.ts
+++ b/tests/board-resolution-quote.test.ts
@@ -113,9 +113,9 @@ void test('collection uses storm order and shared remaining spice, with city rat
const before = structuredClone(g),
q = quoteSpiceCollection(g);
assert.deepEqual(q.receipts, [
- { player: 'a', strongholds: 2, collected: 6, balance: 18 },
- { player: 'e', strongholds: 0, collected: 3, balance: 13 },
- { player: 'b', strongholds: 0, collected: 0, balance: 10 },
+ { player: 'a', strongholds: 2, collected: 6, desert: 6, balance: 18 },
+ { player: 'e', strongholds: 0, collected: 3, desert: 3, balance: 13 },
+ { player: 'b', strongholds: 0, collected: 0, desert: 0, balance: 10 },
]);
assert.equal(q.spice['red_chasm:7'], 0);
assert.deepEqual(g, before);
diff --git a/tests/caladan-ambassador-engine.test.ts b/tests/caladan-ambassador-engine.test.ts
new file mode 100644
index 00000000..7f157dd8
--- /dev/null
+++ b/tests/caladan-ambassador-engine.test.ts
@@ -0,0 +1,225 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ normalizeAutomaticGame,
+ viewGame,
+ type Game,
+} from '../game/engine';
+import {
+ prepareVictoryBattle,
+ commitVictoryPlans,
+ finishVictoryCalls,
+ victoryInventory as inventory,
+ victoryReload as reload,
+ victoryPlayer as p,
+} from './fixture-caladan-victory';
+
+import { caladanAmbassadorFixture as fixture } from './fixture-caladan-ambassador';
+
+function open(native = 6) {
+ const initial = fixture();
+ if (native !== 6) {
+ p(initial, 'a').reserves = native;
+ p(initial, 'a').forces['polar_sink:0'] -= native - 6;
+ }
+ let g = finishVictoryCalls(
+ commitVictoryPlans(prepareVictoryBattle(initial, 'arrakeen')),
+ );
+ assert.equal(g.decision?.kind, 'caladanReinforcement');
+ assert.equal(g.lastBattleContext?.winner, 'a');
+ assert.equal(p(g, 'a').forces['arrakeen:10'], 2);
+ g = applyAction(reload(g), 'a', {
+ type: 'decision',
+ event: g.homeworldVictoryReinforcement!.event,
+ destination: 'arrakeen:10',
+ });
+ assert.equal(g.pendingAmbassador?.stage, 'offer');
+ assert.equal(g.pendingAmbassador?.victoryEvent, g.lastBattleContext!.event);
+ assert.equal(g.pendingAmbassador?.revivalEvent, undefined);
+ assert.equal(g.homeworldRevivalReturn, undefined);
+ inventory(g);
+ return g;
+}
+function allow(g: Game) {
+ for (let n = 0; g.response && n < 30; n++) {
+ const player = g.players.find(
+ (seat) => !g.response!.passed.includes(seat.id),
+ );
+ assert.ok(player);
+ g = applyAction(reload(g), player.id, { type: 'passResponse' });
+ }
+ assert.equal(g.response, null);
+ return g;
+}
+
+void test('actual Caladan victory reinforces once and preserves its battle through Guild, BG and Fremen Ambassador children', () => {
+ let g = open();
+ const event = g.homeworldVictoryReinforcement!.event;
+ const parent = g.pendingAmbassador!.event;
+ assert.equal(p(g, 'a').reserves, 5);
+ assert.equal(p(g, 'a').forces['arrakeen:10'], 3);
+ g = applyAction(reload(g), 'ec', {
+ type: 'decision',
+ event: parent,
+ trigger: true,
+ beneficiary: 'h',
+ });
+ g = applyAction(reload(g), 'h', {
+ type: 'decision',
+ event: parent,
+ amount: 2,
+ territory: 'sietch_tabr',
+ sector: 14,
+ });
+ assert.equal(g.decision?.kind, 'intrusion');
+ g = applyAction(reload(g), 'bg', { type: 'decision', accept: false });
+ g = applyAction(reload(g), 'bg', {
+ type: 'decision',
+ accept: true,
+ accompany: true,
+ });
+ assert.equal(g.response?.kind, 'advisor');
+ g = allow(g);
+ assert.equal(g.pendingAmbassador?.entrant, 'bg');
+ assert.equal(g.pendingAmbassador?.victoryEvent, event);
+ assert.equal(g.pendingAmbassador?.revivalEvent, undefined);
+ assert.deepEqual(
+ g.homeworldVictoryReinforcement!.ambassadors!.map(
+ (entry) => entry.completed,
+ ),
+ [true, false],
+ );
+ assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, false);
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
+ const child = g.pendingAmbassador!.event;
+ g = applyAction(reload(g), 'ec', {
+ type: 'decision',
+ event: child,
+ trigger: true,
+ beneficiary: 'ec',
+ });
+ g = applyAction(reload(g), 'ec', {
+ type: 'decision',
+ event: child,
+ forces: { 'red_chasm:7': 1 },
+ territory: 'carthag',
+ sector: 11,
+ });
+ assert.equal(g.pendingAmbassador, null);
+ assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
+ assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, true);
+ assert.deepEqual(
+ g.homeworldVictoryReinforcement!.ambassadors!.map(
+ (entry) => entry.completed,
+ ),
+ [true, true],
+ );
+ assert.equal(g.homeworldRevivalReturn, undefined);
+ assert.equal(p(g, 'a').reserves, 5);
+ assert.equal(p(g, 'a').forces['arrakeen:10'], 3);
+ assert.equal(p(g, 'h').forces['sietch_tabr:14'], 2);
+ assert.equal(p(g, 'bg').forces['sietch_tabr:14'], 2);
+ assert.equal(p(g, 'ec').forces['carthag:11'], 1);
+ inventory(g);
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
+ for (const action of [
+ { type: 'decision', event, destination: 'arrakeen:10' },
+ {
+ type: 'decision',
+ event: child,
+ forces: { 'red_chasm:7': 1 },
+ territory: 'carthag',
+ sector: 11,
+ },
+ ])
+ assert.throws(() => applyAction(g, 'a', action));
+});
+
+void test('a real victory arrival cannot lose its child, source tag or independent original obligation during recovery', () => {
+ const original = open();
+ for (const edit of [
+ (g: Game) => {
+ g.pendingAmbassador = null;
+ g.decision = null;
+ },
+ (g: Game) => {
+ delete g.pendingAmbassador!.victoryEvent;
+ },
+ (g: Game) => {
+ g.pendingAmbassador!.revivalEvent = g.pendingAmbassador!.victoryEvent;
+ },
+ (g: Game) => {
+ delete g.homeworldVictoryReinforcement;
+ },
+ (g: Game) => {
+ delete g.lastBattleContext!.caladanReinforcement;
+ },
+ (g: Game) => {
+ g.homeworldVictoryReinforcement!.stage = 'complete';
+ },
+ ]) {
+ const g = reload(original);
+ edit(g);
+ const before = reload(g);
+ assert.throws(() => viewGame(g, 'ec'));
+ assert.throws(() => normalizeAutomaticGame(g));
+ assert.throws(() =>
+ applyAction(g, 'ec', {
+ type: 'decision',
+ event: original.pendingAmbassador!.event,
+ trigger: false,
+ }),
+ );
+ assert.deepEqual(g, before);
+ }
+});
+
+void test('declining the real Ambassador completes Caladan across phase advancement without reopening it', () => {
+ let g = open();
+ const event = g.homeworldVictoryReinforcement!.event;
+ g = applyAction(reload(g), 'ec', {
+ type: 'decision',
+ event: g.pendingAmbassador!.event,
+ decline: true,
+ });
+ assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
+ assert.notEqual(g.phase, 6);
+ assert.equal(viewGame(g, 'a').caladanReinforcement, null);
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
+ assert.throws(() =>
+ applyAction(g, 'a', {
+ type: 'decision',
+ event,
+ destination: 'arrakeen:10',
+ }),
+ );
+ inventory(g);
+});
+
+void test('rewinding an actual placed reinforcement to its original choice cannot withdraw a second reserve', () => {
+ const g = open(7);
+ const frame = g.homeworldVictoryReinforcement!;
+ assert.equal(p(g, 'a').reserves, 6);
+ frame.stage = 'choice';
+ delete frame.destination;
+ delete frame.ambassadors;
+ delete frame.arrivalSignature;
+ g.pendingAmbassador = null;
+ g.decision = {
+ kind: 'caladanReinforcement',
+ player: 'a',
+ event: frame.event,
+ };
+ const before = reload(g);
+ assert.throws(() => viewGame(g, 'a'));
+ assert.throws(() => normalizeAutomaticGame(g));
+ assert.throws(() =>
+ applyAction(g, 'a', {
+ type: 'decision',
+ event: frame.event,
+ destination: 'arrakeen:10',
+ }),
+ );
+ assert.deepEqual(g, before);
+});
diff --git a/tests/caladan-reinforcement-controls.test.ts b/tests/caladan-reinforcement-controls.test.ts
new file mode 100644
index 00000000..59e624ed
--- /dev/null
+++ b/tests/caladan-reinforcement-controls.test.ts
@@ -0,0 +1,209 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { CaladanReinforcement } from '../components/caladan-reinforcement';
+import {
+ createGame,
+ joinGame,
+ newPlayer,
+ viewGame,
+ type GameView,
+} from '../game/engine';
+import {
+ caladanReinforcementActions,
+ caladanReinforcementCanAct,
+ caladanReinforcementChoice,
+} from '../game/caladan-reinforcement-options';
+
+/** Projected choices isolate client policy; actual battle/force custody belongs
+ * to the engine and production recovery suites. */
+function fixture(): GameView {
+ const g = createGame(
+ 'CALADANCONTROLS',
+ newPlayer('a', 'Atreides', 'atreides'),
+ true,
+ [],
+ );
+ joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
+ Object.assign(g, {
+ status: 'playing',
+ phase: 6,
+ active: 'e',
+ order: ['a', 'e'],
+ storm: 18,
+ });
+ g.players[0].forces = { 'hagga_basin:12': 2 };
+ const view = viewGame(g, 'a');
+ view.decision = {
+ kind: 'caladanReinforcement',
+ player: 'a',
+ event: 'battle-1',
+ };
+ view.caladanReinforcement = {
+ event: 'battle-1',
+ player: 'a',
+ territory: 'hagga_basin',
+ amount: 1,
+ blocked: null,
+ destinations: [
+ {
+ id: 'hagga_basin:13',
+ name: 'Hagga Basin · sector 13',
+ sector: 13,
+ blocked: null,
+ },
+ {
+ id: 'hagga_basin:12',
+ name: 'Hagga Basin · sector 12',
+ sector: 12,
+ blocked: null,
+ },
+ {
+ id: 'hagga_basin:10',
+ name: 'Hagga Basin · sector 10',
+ sector: 10,
+ blocked: 'Storm entry awaits a ruling.',
+ },
+ ],
+ };
+ return view;
+}
+function markup(game: GameView, busy = false) {
+ return renderToStaticMarkup(
+ createElement(CaladanReinforcement, { game, busy, act() {} }),
+ );
+}
+
+void test('Caladan controls offer one fixed force and preserve the current event without mutating the projected battle', () => {
+ const view = fixture();
+ const before = structuredClone(view);
+ assert.deepEqual(caladanReinforcementChoice(view, 'hagga_basin:12'), {
+ blocked: null,
+ action: {
+ type: 'decision',
+ event: 'battle-1',
+ destination: 'hagga_basin:12',
+ amount: 1,
+ },
+ });
+ const html = markup(view);
+ assert.match(html, /aria-label="Caladan reinforcement"/);
+ assert.match(html, /Add one reserve force/);
+ assert.match(html, /Leave in reserves/);
+ assert.doesNotMatch(html, /type="number"|type="range"/);
+ assert.deepEqual(view, before);
+});
+
+void test('stale or blocked destinations cannot place a force while a current blocked offer remains declinable', () => {
+ const view = fixture();
+ assert.equal(
+ caladanReinforcementChoice(view, 'foreign-destination').action,
+ null,
+ );
+ assert.match(
+ caladanReinforcementChoice(view, 'hagga_basin:10').blocked!,
+ /Storm entry/,
+ );
+ view.caladanReinforcement!.destinations[1].blocked =
+ 'No surviving force remains.';
+ assert.equal(caladanReinforcementChoice(view, 'hagga_basin:12').action, null);
+ view.caladanReinforcement!.blocked =
+ 'The current destination needs a ruling.';
+ assert.deepEqual(caladanReinforcementActions(view), [
+ { type: 'decision', event: 'battle-1', decline: true },
+ ]);
+ const html = markup(view);
+ assert.match(html, /The current destination needs a ruling/);
+ assert.match(
+ html,
+ /]* disabled=""[^>]*>Add one reserve force<\/button>/,
+ );
+ assert.match(
+ html,
+ /]* disabled="")[^>]*>Leave in reserves<\/button>/,
+ );
+ view.caladanReinforcement!.blocked = null;
+ view.caladanReinforcement!.destinations = [];
+ assert.equal(caladanReinforcementActions(view)[0].decline, true);
+ assert.match(markup(view), /No legal reinforcement destination/);
+});
+
+void test('reinforcement policy uses only own occupied sectors for every profile and falls back to a legal Homeworld destination', () => {
+ for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const view = fixture();
+ view.players[0].bot = profile;
+ for (const key of ['hand', 'spice', 'traitors', 'forces'])
+ Object.defineProperty(view.players[1], key, {
+ get() {
+ throw new Error(`Read opponent ${key}`);
+ },
+ });
+ const offer = structuredClone(view.caladanReinforcement);
+ assert.deepEqual(caladanReinforcementActions(view), [
+ {
+ type: 'decision',
+ event: 'battle-1',
+ destination: 'hagga_basin:12',
+ amount: 1,
+ },
+ ]);
+ assert.deepEqual(view.caladanReinforcement, offer);
+ view.caladanReinforcement!.destinations[1].blocked = 'Entry unavailable.';
+ assert.equal(
+ caladanReinforcementActions(view)[0].destination,
+ 'hagga_basin:13',
+ );
+ view.caladanReinforcement!.territory = 'homeworld:emperor';
+ view.caladanReinforcement!.destinations = [
+ { id: 'homeworld:emperor', name: 'Kaitain', blocked: null },
+ ];
+ assert.equal(
+ caladanReinforcementActions(view)[0].destination,
+ 'homeworld:emperor',
+ );
+ }
+});
+
+void test('owner and battle-event fences plus interruption gates prevent stale actions and hide observer controls', () => {
+ const view = fixture();
+ assert.equal(caladanReinforcementCanAct(view), true);
+ for (const patch of [
+ { me: 'e' },
+ { status: 'finished' },
+ { decision: null },
+ {
+ decision: {
+ kind: 'caladanReinforcement',
+ player: 'e',
+ event: 'battle-1',
+ },
+ },
+ {
+ decision: {
+ kind: 'caladanReinforcement',
+ player: 'a',
+ event: 'old-battle',
+ },
+ },
+ { decision: { kind: 'battleCards', player: 'a' } },
+ { response: { kind: 'capture', owner: 'e', passed: [] } },
+ { truthtrance: { stage: 'answer' } },
+ { phaseOpening: { kind: 'ix' } },
+ { automaticContinuationPending: true },
+ ]) {
+ const blocked = { ...view, ...patch } as GameView;
+ assert.equal(caladanReinforcementCanAct(blocked), false);
+ assert.deepEqual(caladanReinforcementActions(blocked), []);
+ assert.equal(
+ caladanReinforcementChoice(blocked, 'hagga_basin:12').action,
+ null,
+ );
+ }
+ assert.equal(markup({ ...view, me: 'e' }), '');
+ assert.equal(markup({ ...view, caladanReinforcement: null }), '');
+ assert.equal(markup({ ...view, decision: null }), '');
+ const html = markup(view, true);
+ assert.equal((html.match(/]* disabled=""/g) ?? []).length, 2);
+ assert.match(html, /]*disabled/);
+});
diff --git a/tests/choam-charity.test.ts b/tests/choam-charity.test.ts
index bc5a5eb3..05c86f3f 100644
--- a/tests/choam-charity.test.ts
+++ b/tests/choam-charity.test.ts
@@ -202,6 +202,9 @@ void test('charity projections expose the payer and own entitlement without leak
const g = allow(ready(fixture()));
const v = viewGame(g, 'e');
assert.deepEqual(v.charity, {
+ ordinary: 2,
+ homeworld: 0,
+ total: 2,
multiplier: 1,
amount: 2,
payer: 'c',
diff --git a/tests/ecaz-alliance-engine.test.ts b/tests/ecaz-alliance-engine.test.ts
new file mode 100644
index 00000000..ce3821aa
--- /dev/null
+++ b/tests/ecaz-alliance-engine.test.ts
@@ -0,0 +1,532 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ newPlayer,
+ normalizeAutomaticGame,
+ viewGame,
+ type Game,
+ type Action,
+} from '../game/engine';
+import { baseDeck } from '../game/cards';
+import type { FactionId } from '../game/catalog';
+import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
+import { acquireDuke, createDukeVidal } from '../game/duke-vidal';
+import { richeseCards } from '../game/richese-cards';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+
+const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
+const json = (g: Game): Game => JSON.parse(JSON.stringify(g));
+function fixture(advanced = false, entrant: FactionId = 'emperor') {
+ const g = createGame('ECAZALLY', newPlayer('ec', 'Ecaz', 'ecaz'), advanced, [
+ 'ecaz',
+ ]);
+ g.players.push(
+ newPlayer('in', 'Entrant', entrant),
+ newPlayer('a', 'Other', 'atreides'),
+ newPlayer('bg', 'Predictor', 'beneGesserit'),
+ );
+ Object.assign(g, {
+ status: 'playing',
+ phase: 5,
+ turn: 2,
+ storm: 18,
+ active: 'in',
+ order: ['in', 'ec', 'a', 'bg'],
+ movementRemaining: ['in', 'ec', 'a', 'bg'],
+ deck: baseDeck(),
+ dukeVidal: createDukeVidal(),
+ ready: [],
+ });
+ for (const p of g.players)
+ Object.assign(p, {
+ forces: {},
+ reserves: 20,
+ tanks: 0,
+ spice: 20,
+ hand: [],
+ traitors: [],
+ traitorChoices: [],
+ });
+ // No reserve advisor option interferes with the tested original shipment.
+ seat(g, 'bg').reserves = 0;
+ seat(g, 'bg').tanks = 20;
+ seat(g, 'bg').prediction = { faction: entrant, turn: 7 };
+ const state = createAmbassadors(() => 0);
+ const token = state.tokens.find((t) => t.effect === 'ecaz')!;
+ g.ecazAmbassadors = placeAmbassador(state, token.id, {
+ turn: 1,
+ availableSpice: 20,
+ destination: {
+ id: 'arrakeen',
+ stronghold: true,
+ inStorm: false,
+ allowed: true,
+ },
+ }).state;
+ return g;
+}
+function enter(g = fixture()) {
+ return applyAction(g, 'in', {
+ type: 'ship',
+ amount: 2,
+ territory: 'arrakeen',
+ sector: 10,
+ });
+}
+function proposal(g: Game): Action {
+ return {
+ type: 'decision',
+ event: g.pendingAmbassador!.event,
+ trigger: true,
+ choice: 'alliance',
+ beneficiary: 'ec',
+ };
+}
+function propose(g: Game) {
+ return applyAction(g, 'ec', proposal(g));
+}
+function answer(g: Game, accept: boolean) {
+ return applyAction(json(g), 'in', {
+ type: 'decision',
+ event: g.pendingAmbassador!.event,
+ accept,
+ });
+}
+function reject(g: Game, id: string, action: Action) {
+ const before = structuredClone(g);
+ assert.throws(() => applyAction(g, id, action));
+ assert.deepEqual(g, before);
+}
+function conserved(g: Game) {
+ for (const p of g.players)
+ assert.equal(
+ p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0),
+ 20,
+ );
+ const cards = [
+ ...g.deck,
+ ...g.discard,
+ ...(g.richeseCache ?? []),
+ ...g.players.flatMap((p) => p.hand),
+ ].map((c) => c.id);
+ assert.equal(new Set(cards).size, cards.length);
+}
+
+for (const advanced of [false, true])
+ void test(`${advanced ? 'Advanced' : 'Basic'} actual shipment allows bilateral Ecaz alliance and resumes the paid entrant without changing Duke`, () => {
+ const initial = fixture(advanced);
+ const token = initial.ecazAmbassadors!.tokens.find(
+ (t) => t.effect === 'ecaz',
+ )!.id;
+ const duke = json(initial).dukeVidal;
+ const entered = enter(initial);
+ assert.equal(entered.decision?.player, 'ec');
+ assert.equal(
+ viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked,
+ null,
+ );
+ const before = json(entered);
+ const offered = propose(entered);
+ assert.deepEqual(entered, before);
+ assert.equal(offered.pendingAmbassador?.stage, 'allianceReply');
+ assert.equal(offered.pendingAmbassador?.beneficiary, 'in');
+ assert.deepEqual(offered.decision, {
+ kind: 'ecazAmbassador',
+ player: 'in',
+ });
+ assert.equal(seat(offered, 'ec').ally, null);
+ assert.equal(
+ offered.ecazAmbassadors!.tokens.find((t) => t.id === token)!.zone,
+ 'supply',
+ );
+ assert.deepEqual(
+ offered.ecazAmbassadors!.cohort,
+ initial.ecazAmbassadors!.cohort,
+ );
+ const done = answer(normalizeAutomaticGame(json(offered)), true);
+ assert.equal(seat(done, 'ec').ally, 'in');
+ assert.equal(seat(done, 'in').ally, 'ec');
+ assert.equal(seat(done, 'ec').allySinceTurn, 2);
+ assert.equal(seat(done, 'in').allySinceTurn, 2);
+ assert.deepEqual(done.dukeVidal, duke);
+ assert.equal(done.pendingAmbassador, null);
+ assert.equal(done.decision, null);
+ assert.equal(done.active, 'in');
+ assert.equal(done.phase, 5);
+ assert.equal(seat(done, 'in').shipped, true);
+ assert.equal(seat(done, 'in').spice, 18);
+ assert.equal(seat(done, 'in').moved, 0);
+ assert.deepEqual(seat(done, 'in').forces, { 'arrakeen:10': 2 });
+ assert.deepEqual(normalizeAutomaticGame(json(done)), json(done));
+ reject(done, 'in', {
+ type: 'decision',
+ event: offered.pendingAmbassador!.event,
+ accept: true,
+ });
+ conserved(done);
+ });
+
+void test('refusal consumes the chosen reusable trigger but does not form an alliance or fall back to Duke acquisition', () => {
+ const entered = enter();
+ const done = answer(propose(entered), false);
+ assert.equal(seat(done, 'ec').ally, null);
+ assert.equal(seat(done, 'in').ally, null);
+ assert.deepEqual(done.dukeVidal, entered.dukeVidal);
+ assert.equal(
+ done.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.zone,
+ 'supply',
+ );
+ assert.equal(done.pendingAmbassador, null);
+ assert.equal(done.active, 'in');
+ const untouched = applyAction(entered, 'ec', {
+ type: 'decision',
+ event: entered.pendingAmbassador!.event,
+ decline: true,
+ });
+ assert.equal(
+ untouched.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.zone,
+ 'placed',
+ );
+});
+
+void test('only Ecaz proposes and only the original entrant answers the exact event with a boolean', () => {
+ const g = enter();
+ reject(g, 'in', proposal(g));
+ reject(g, 'ec', { ...proposal(g), event: 'stale' });
+ reject(g, 'ec', { ...proposal(g), beneficiary: 'in' });
+ const offered = propose(g);
+ for (const id of ['ec', 'a', 'bg'])
+ reject(offered, id, {
+ type: 'decision',
+ event: offered.pendingAmbassador!.event,
+ accept: true,
+ });
+ for (const accept of [undefined, 'yes', 1])
+ reject(offered, 'in', {
+ type: 'decision',
+ event: offered.pendingAmbassador!.event,
+ accept,
+ });
+ reject(offered, 'in', { type: 'decision', event: 'stale', accept: true });
+ const changed = json(offered);
+ changed.turn++;
+ reject(changed, 'in', {
+ type: 'decision',
+ event: offered.pendingAmbassador!.event,
+ accept: true,
+ });
+});
+
+for (const already of ['ec', 'in'])
+ void test(`existing ${already === 'ec' ? 'Ecaz' : 'entrant'} alliance cannot be replaced by this Ambassador`, () => {
+ const g = fixture();
+ seat(g, already).ally = 'a';
+ seat(g, 'a').ally = already;
+ const entered = enter(g);
+ assert.ok(viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked);
+ reject(entered, 'ec', proposal(entered));
+ assert.equal(
+ entered.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.zone,
+ 'placed',
+ );
+ });
+
+void test('acceptance clears obsolete offers involving the new pair and leaves unrelated offers and private prediction intact', () => {
+ const g = fixture();
+ g.allianceOffers = { ec: 'a', a: 'ec', in: 'bg', bg: 'a' };
+ g.ready = ['a'];
+ const prediction = json(g).players.find((p) => p.id === 'bg')!.prediction;
+ const done = answer(propose(enter(g)), true);
+ assert.deepEqual(done.allianceOffers, { bg: 'a' });
+ assert.deepEqual(done.ready, []);
+ assert.deepEqual(seat(done, 'bg').prediction, prediction);
+ for (const id of ['ec', 'in', 'a'])
+ assert.equal(
+ viewGame(done, id).players.find((p) => p.id === 'bg')!.prediction,
+ undefined,
+ );
+});
+
+void test('a dead or previously controlled Duke cannot gate the no-loan alliance or be moved by it', () => {
+ for (const dead of [false, true]) {
+ const g = fixture(true);
+ g.dukeVidal = acquireDuke(g.dukeVidal!, 'a', 1, 'moritani');
+ if (dead) {
+ g.dukeVidal.leader.dead = true;
+ g.dukeVidal.leader.deaths = 1;
+ }
+ const duke = json(g).dukeVidal;
+ const entered = enter(g);
+ assert.equal(
+ viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked,
+ null,
+ );
+ const done = answer(propose(entered), true);
+ assert.deepEqual(done.dukeVidal, duke);
+ }
+ const g = fixture(true);
+ g.players[2] = newPlayer('a', 'Harkonnen', 'harkonnen');
+ const entered = enter(g);
+ assert.ok(viewGame(entered, 'ec').ambassadorEntry!.dukeAcquisition!.blocked);
+ assert.equal(
+ viewGame(entered, 'ec').ambassadorEntry!.allianceOffer!.blocked,
+ null,
+ );
+ assert.equal(seat(answer(propose(entered), true), 'ec').ally, 'in');
+});
+
+void test('changing BG private prediction and unrelated private hands does not change the public alliance offer', () => {
+ const g = enter();
+ const changed = json(g);
+ seat(changed, 'bg').prediction = { faction: 'atreides', turn: 9 };
+ const card = changed.deck.splice(0, 1)[0];
+ seat(changed, 'a').hand.push(card);
+ assert.deepEqual(viewGame(g, 'ec'), viewGame(changed, 'ec'));
+ const offered = propose(g);
+ assert.equal(viewGame(offered, 'a').ambassadorEntry!.allianceOffer, null);
+ assert.deepEqual(viewGame(offered, 'a').decision, {
+ kind: 'ecazAmbassador',
+ player: 'in',
+ });
+});
+
+for (const accept of [false, true])
+ void test(`actual worm ride resumes its next ride once after alliance ${accept ? 'acceptance' : 'refusal'}`, () => {
+ const g = fixture(true, 'fremen');
+ Object.assign(g, {
+ phase: 1,
+ active: null,
+ nexus: false,
+ wormRides: ['hagga_basin'],
+ decision: { kind: 'wormRide', player: 'in', territory: 'imperial_basin' },
+ });
+ seat(g, 'in').forces = { 'imperial_basin:10': 3, 'hagga_basin:11': 1 };
+ seat(g, 'in').reserves = 16;
+ const arrived = applyAction(g, 'in', {
+ type: 'decision',
+ accept: true,
+ territory: 'arrakeen',
+ sector: 10,
+ forces: { 'imperial_basin:10': 2 },
+ });
+ assert.equal(arrived.pendingAmbassador!.resume, 'wormRide');
+ const done = answer(propose(arrived), accept);
+ assert.equal(done.phase, 1);
+ assert.deepEqual(done.decision, {
+ kind: 'wormRide',
+ player: 'in',
+ territory: 'hagga_basin',
+ });
+ assert.deepEqual(done.wormRides, []);
+ assert.deepEqual(seat(done, 'in').forces, seat(arrived, 'in').forces);
+ assert.equal(seat(done, 'in').ally, accept ? 'ec' : null);
+ conserved(done);
+ });
+
+void test('a genuine paid Box suspends the entrant reply and restores it after JSON without repeating the token trigger', () => {
+ const g = fixture();
+ g.richeseCache = richeseCards();
+ const at = g.richeseCache.findIndex((c) => c.effect === 'nullentropyBox');
+ const box = g.richeseCache.splice(at, 1)[0];
+ seat(g, 'in').hand.push(box);
+ for (const name of ['Shield', 'Maula Pistol']) {
+ const i = g.deck.findIndex((c) => c.name === name);
+ g.discard.push(g.deck.splice(i, 1)[0]);
+ }
+ const offered = propose(enter(g));
+ let saved = applyAction(offered, 'in', { type: 'card', card: box.id });
+ assert.equal(saved.decision?.kind, 'nullentropy');
+ assert.equal(saved.pendingNullentropy!.resume.decision?.player, 'in');
+ assert.deepEqual(saved.pendingAmbassador, offered.pendingAmbassador);
+ saved = normalizeAutomaticGame(json(saved));
+ saved = applyAction(saved, 'in', {
+ type: 'decision',
+ event: saved.pendingNullentropy!.event,
+ card: saved.discard[0].id,
+ });
+ assert.equal(saved.decision?.kind, 'ecazAmbassador');
+ const done = answer(saved, true);
+ assert.equal(seat(done, 'in').spice, 16);
+ assert.equal(done.discard.filter((c) => c.id === box.id).length, 1);
+ assert.deepEqual(done.ecazAmbassadors, offered.ecazAmbassadors);
+ conserved(done);
+});
+
+for (const level of DIFFICULTIES)
+ void test(`${level} proposes from the authoritative offer and accepts its owned reply legally`, () => {
+ const g = enter();
+ const owner = viewGame(g, 'ec');
+ owner.players.find((p) => p.id === 'ec')!.bot = level;
+ const action = botActions(owner)[0];
+ assert.equal(action.choice, 'alliance');
+ const offered = applyAction(g, 'ec', action);
+ const entrant = viewGame(offered, 'in');
+ entrant.players.find((p) => p.id === 'in')!.bot = level;
+ const reply = botActions(entrant)[0];
+ assert.equal(reply.accept, true);
+ const done = applyAction(offered, 'in', reply);
+ assert.equal(seat(done, 'ec').ally, 'in');
+ assert.equal(done.pendingAmbassador, null);
+ });
+
+void test('saved alliance replies reject missing events, corrupted token custody and changed entrant bindings before any answer', () => {
+ const offered = propose(enter());
+ const changes: [string, (g: Game) => void][] = [
+ [
+ 'empty event',
+ (g) => {
+ g.pendingAmbassador!.event = '';
+ },
+ ],
+ [
+ 'missing event',
+ (g) => {
+ Reflect.deleteProperty(g.pendingAmbassador!, 'event');
+ },
+ ],
+ [
+ 'missing entry',
+ (g) => {
+ g.pendingAmbassador = null;
+ },
+ ],
+ [
+ 'missing decision',
+ (g) => {
+ g.decision = null;
+ },
+ ],
+ [
+ 'foreign decision owner',
+ (g) => {
+ g.decision!.player = 'a';
+ },
+ ],
+ [
+ 'changed entrant',
+ (g) => {
+ g.pendingAmbassador!.entrant = 'a';
+ },
+ ],
+ [
+ 'foreign Ecaz owner',
+ (g) => {
+ g.pendingAmbassador!.owner = 'a';
+ },
+ ],
+ [
+ 'missing token',
+ (g) => {
+ g.pendingAmbassador!.token = 'missing';
+ },
+ ],
+ [
+ 'returned token replaced on board',
+ (g) => {
+ const token = g.ecazAmbassadors!.tokens.find(
+ (t) => t.effect === 'ecaz',
+ )!;
+ token.zone = 'placed';
+ token.location = 'arrakeen';
+ },
+ ],
+ [
+ 'returned token retains location',
+ (g) => {
+ g.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.location =
+ 'arrakeen';
+ },
+ ],
+ [
+ 'foreign token effect',
+ (g) => {
+ g.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.effect =
+ 'guild';
+ },
+ ],
+ [
+ 'crossed phase-five worm continuation',
+ (g) => {
+ g.pendingAmbassador!.resume = 'wormRide';
+ },
+ ],
+ ];
+ for (const [name, change] of changes) {
+ const malformed = json(offered);
+ change(malformed);
+ const before = structuredClone(malformed);
+ assert.throws(() => normalizeAutomaticGame(malformed), name);
+ assert.deepEqual(
+ malformed,
+ before,
+ `${name}: normalization must not mutate`,
+ );
+ reject(malformed, 'in', {
+ type: 'decision',
+ event: offered.pendingAmbassador!.event,
+ accept: true,
+ });
+ }
+});
+
+void test('a real Box-suspended alliance reply cannot outlive its entry or restore a different entrant', () => {
+ const g = fixture();
+ g.richeseCache = richeseCards();
+ const at = g.richeseCache.findIndex((c) => c.effect === 'nullentropyBox');
+ const box = g.richeseCache.splice(at, 1)[0];
+ seat(g, 'in').hand.push(box);
+ for (const name of ['Shield', 'Maula Pistol']) {
+ const index = g.deck.findIndex((c) => c.name === name);
+ g.discard.push(g.deck.splice(index, 1)[0]);
+ }
+ const saved = applyAction(propose(enter(g)), 'in', {
+ type: 'card',
+ card: box.id,
+ });
+ assert.equal(
+ saved.pendingNullentropy!.resume.decision!.kind,
+ 'ecazAmbassador',
+ );
+ assert.deepEqual(normalizeAutomaticGame(json(saved)), json(saved));
+ const changes: [string, (g: Game) => void][] = [
+ [
+ 'missing suspended parent',
+ (g) => {
+ g.pendingAmbassador = null;
+ },
+ ],
+ [
+ 'missing suspended event',
+ (g) => {
+ Reflect.deleteProperty(g.pendingAmbassador!, 'event');
+ },
+ ],
+ [
+ 'missing suspended decision',
+ (g) => {
+ g.pendingNullentropy!.resume.decision = null;
+ },
+ ],
+ [
+ 'foreign suspended decision owner',
+ (g) => {
+ g.pendingNullentropy!.resume.decision!.player = 'a';
+ },
+ ],
+ ];
+ for (const [name, change] of changes) {
+ const malformed = json(saved);
+ change(malformed);
+ const before = structuredClone(malformed);
+ assert.throws(() => normalizeAutomaticGame(malformed), name);
+ assert.deepEqual(malformed, before);
+ reject(malformed, 'in', {
+ type: 'decision',
+ event: saved.pendingNullentropy!.event,
+ card: saved.discard[0].id,
+ });
+ }
+});
diff --git a/tests/ecaz-alliance-recovery.test.ts b/tests/ecaz-alliance-recovery.test.ts
new file mode 100644
index 00000000..a4d3b4bb
--- /dev/null
+++ b/tests/ecaz-alliance-recovery.test.ts
@@ -0,0 +1,511 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { 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 { baseDeck } from '../game/cards';
+import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
+import { createDukeVidal } from '../game/duke-vidal';
+import type * as Rooms from '../db/rooms';
+
+/** 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 };
+}
+
+const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
+const player = (g: engine.Game, id: string) =>
+ g.players.find((p) => p.id === id)!;
+const clone = (g: engine.Game): engine.Game => JSON.parse(JSON.stringify(g));
+
+/** Only a conserved expansion movement position is staged; credentials, arrival,
+ * proposal, reply and all recovery/CAS operations use production room functions. */
+async function fixture() {
+ const store = unitStore();
+ const made = await store.rooms.createRoom('Ecaz', 'harkonnen', false, []);
+ const code = made.view.code;
+ const entrant = await store.rooms.joinRoom(code, 'Entrant', 'emperor');
+ const observer = await store.rooms.joinRoom(
+ code,
+ 'Predictor',
+ 'beneGesserit',
+ );
+ const tokens = [made.token, entrant.token!, observer.token!];
+ const auths = await Promise.all(
+ tokens.map((token) => store.rooms.authenticate(code, token)),
+ );
+ const [ownerAuth, entrantAuth, observerAuth] = auths;
+ const g = await store.rooms.readRoom(code);
+ g.players[0] = engine.newPlayer(ownerAuth.playerId, 'Ecaz', 'ecaz');
+ Object.assign(g, {
+ status: 'playing',
+ phase: 5,
+ turn: 2,
+ advanced: true,
+ expansions: ['ecaz'],
+ storm: 18,
+ active: entrantAuth.playerId,
+ order: [entrantAuth.playerId, ownerAuth.playerId, observerAuth.playerId],
+ movementRemaining: [
+ entrantAuth.playerId,
+ ownerAuth.playerId,
+ observerAuth.playerId,
+ ],
+ response: null,
+ decision: null,
+ phaseOpening: null,
+ deck: baseDeck(),
+ discard: [],
+ dukeVidal: createDukeVidal(),
+ });
+ for (const p of g.players)
+ Object.assign(p, {
+ forces: {},
+ reserves: 20,
+ tanks: 0,
+ spice: 20,
+ hand: [g.deck.shift()!],
+ traitors: [p.leaders[0].id],
+ traitorChoices: [],
+ moved: 0,
+ shipped: false,
+ });
+ Object.assign(player(g, observerAuth.playerId), {
+ reserves: 0,
+ tanks: 20,
+ prediction: { faction: 'emperor', turn: 7 },
+ });
+ const ambassadors = createAmbassadors(() => 0);
+ const token = ambassadors.tokens.find((t) => t.effect === 'ecaz')!;
+ g.ecazAmbassadors = placeAmbassador(ambassadors, token.id, {
+ turn: 1,
+ availableSpice: 20,
+ destination: {
+ id: 'arrakeen',
+ stronghold: true,
+ inStorm: false,
+ allowed: true,
+ },
+ }).state;
+ g.allianceOffers = {
+ [ownerAuth.playerId]: observerAuth.playerId,
+ [observerAuth.playerId]: entrantAuth.playerId,
+ };
+ const save = (state: engine.Game) =>
+ store.sqlite
+ .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
+ .run(JSON.stringify(state), state.version, code);
+ save(g);
+ const other = await store.rooms.createRoom(
+ 'Untouched',
+ 'atreides',
+ false,
+ [],
+ );
+ const otherBefore = await store.rooms.readRoom(other.view.code);
+ store.writes.length = 0;
+ return {
+ ...store,
+ code,
+ tokens,
+ auths,
+ ownerAuth,
+ entrantAuth,
+ observerAuth,
+ save,
+ initial: clone(g),
+ tokenId: token.id,
+ otherCode: other.view.code,
+ otherBefore,
+ };
+}
+type Fixture = Awaited>;
+async function fresh(f: Fixture) {
+ f.rooms = f.restart();
+ const auths = await Promise.all(
+ f.tokens.map((token) => f.rooms.authenticate(f.code, token)),
+ );
+ assert.deepEqual(
+ auths.map((a) => a.playerId),
+ f.auths.map((a) => a.playerId),
+ );
+ [f.ownerAuth, f.entrantAuth, f.observerAuth] = auths;
+}
+async function views(f: Fixture, g: engine.Game) {
+ for (const auth of [f.ownerAuth, f.entrantAuth, f.observerAuth]) {
+ const view = await f.rooms.readSeatView(f.code, auth);
+ assert.deepEqual(
+ view.players.find((p) => p.id === auth.playerId)!.hand,
+ player(g, auth.playerId).hand,
+ );
+ assert.ok(
+ view.players.every((p) => p.id === auth.playerId || !('hand' in p)),
+ );
+ for (const p of view.players)
+ if (p.id !== auth.playerId) assert.ok(!('prediction' in p));
+ if (g.pendingAmbassador?.stage === 'allianceReply') {
+ assert.equal(view.ambassadorEntry?.stage, 'allianceReply');
+ assert.equal(view.decision?.player, f.entrantAuth.playerId);
+ }
+ }
+}
+async function enterAndPropose(f: Fixture) {
+ await f.rooms.act(
+ f.code,
+ f.entrantAuth,
+ f.initial.version,
+ { type: 'ship', amount: 2, territory: 'arrakeen', sector: 10 },
+ clock,
+ );
+ const entered = await f.rooms.readRoom(f.code);
+ assert.equal(entered.pendingAmbassador?.stage, 'offer');
+ assert.equal(entered.decision?.player, f.ownerAuth.playerId);
+ await fresh(f);
+ await views(f, entered);
+ await f.rooms.act(
+ f.code,
+ f.ownerAuth,
+ entered.version,
+ {
+ type: 'decision',
+ event: entered.pendingAmbassador!.event,
+ trigger: true,
+ choice: 'alliance',
+ beneficiary: f.ownerAuth.playerId,
+ },
+ clock,
+ );
+ const offered = await f.rooms.readRoom(f.code);
+ assert.equal(offered.pendingAmbassador?.stage, 'allianceReply');
+ assert.equal(
+ offered.ecazAmbassadors!.tokens.find((t) => t.id === f.tokenId)!.zone,
+ 'supply',
+ );
+ await fresh(f);
+ await views(f, offered);
+ return offered;
+}
+async function race(f: Fixture, g: engine.Game, answers: [boolean, boolean]) {
+ let release!: () => void;
+ let arrivals = 0;
+ const waiting = new Promise((resolve) => {
+ release = resolve;
+ });
+ f.writes.length = 0;
+ f.hooks.beforeWrite = async () => {
+ if (++arrivals === 2) release();
+ await waiting;
+ };
+ const other = f.restart();
+ const otherAuth = await other.authenticate(f.code, f.tokens[1]);
+ const results = await Promise.allSettled([
+ f.rooms.act(
+ f.code,
+ f.entrantAuth,
+ g.version,
+ {
+ type: 'decision',
+ event: g.pendingAmbassador!.event,
+ accept: answers[0],
+ },
+ clock,
+ ),
+ other.act(
+ f.code,
+ otherAuth,
+ g.version,
+ {
+ type: 'decision',
+ event: g.pendingAmbassador!.event,
+ accept: answers[1],
+ },
+ clock,
+ ),
+ ]);
+ delete f.hooks.beforeWrite;
+ assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
+ assert.equal(results.filter((r) => r.status === 'rejected').length, 1);
+ assert.deepEqual(
+ f.writes.map((w) => w.changes).sort((a, b) => a - b),
+ [0, 1],
+ );
+ const after = await f.rooms.readRoom(f.code);
+ assert.equal(after.version, g.version + 1);
+ return {
+ after,
+ accepted: answers[results.findIndex((r) => r.status === 'fulfilled')],
+ };
+}
+async function invariant(f: Fixture, g: engine.Game) {
+ const entrant = player(g, f.entrantAuth.playerId);
+ assert.deepEqual(entrant.forces, { 'arrakeen:10': 2 });
+ assert.equal(entrant.reserves, 18);
+ assert.equal(entrant.spice, 18);
+ assert.equal(entrant.shipped, true);
+ assert.equal(entrant.moved, 0);
+ assert.equal(g.phase, 5);
+ assert.equal(g.active, entrant.id);
+ assert.equal(g.pendingAmbassador, null);
+ assert.equal(g.decision, null);
+ assert.deepEqual(g.dukeVidal, f.initial.dukeVidal);
+ assert.deepEqual(
+ g.ecazAmbassadors!.cohort,
+ f.initial.ecazAmbassadors!.cohort,
+ );
+ assert.equal(
+ g.ecazAmbassadors!.tokens.find((t) => t.id === f.tokenId)!.zone,
+ 'supply',
+ );
+ for (const p of g.players) {
+ assert.deepEqual(p.hand, player(f.initial, p.id).hand);
+ assert.deepEqual(p.prediction, player(f.initial, p.id).prediction);
+ assert.equal(
+ p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
+ 20,
+ );
+ }
+ const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)];
+ assert.equal(cards.length, baseDeck().length);
+ assert.equal(new Set(cards.map((c) => c.id)).size, cards.length);
+ assert.deepEqual(await f.rooms.readRoom(f.otherCode), f.otherBefore);
+ await views(f, g);
+}
+
+for (const accept of [true, false])
+ void test(`actual Ecaz alliance ${accept ? 'acceptance' : 'refusal'} survives fresh authentication and duplicate SQL CAS without replaying arrival`, async () => {
+ const f = await fixture();
+ try {
+ const offered = await enterAndPropose(f);
+ const { after } = await race(f, offered, [accept, accept]);
+ assert.equal(
+ player(after, f.ownerAuth.playerId).ally,
+ accept ? f.entrantAuth.playerId : null,
+ );
+ assert.equal(
+ player(after, f.entrantAuth.playerId).ally,
+ accept ? f.ownerAuth.playerId : null,
+ );
+ if (accept) {
+ assert.equal(player(after, f.ownerAuth.playerId).allySinceTurn, 2);
+ assert.equal(player(after, f.entrantAuth.playerId).allySinceTurn, 2);
+ assert.deepEqual(after.allianceOffers, {});
+ } else assert.deepEqual(after.allianceOffers, f.initial.allianceOffers);
+ assert.equal(after.log.length, offered.log.length + 1);
+ await fresh(f);
+ await invariant(f, after);
+ f.writes.length = 0;
+ for (const version of [offered.version, after.version])
+ await assert.rejects(() =>
+ f.rooms.act(
+ f.code,
+ f.entrantAuth,
+ version,
+ {
+ type: 'decision',
+ event: offered.pendingAmbassador!.event,
+ accept,
+ },
+ clock,
+ ),
+ );
+ assert.equal(f.writes.length, 0);
+ assert.deepEqual(await f.rooms.readRoom(f.code), after);
+ await f.rooms.act(
+ f.code,
+ f.entrantAuth,
+ after.version,
+ {
+ type: 'move',
+ forces: { 'arrakeen:10': 1 },
+ territory: 'imperial_basin',
+ sector: 10,
+ },
+ clock,
+ );
+ const moved = await f.rooms.readRoom(f.code);
+ assert.equal(player(moved, f.entrantAuth.playerId).moved, 1);
+ assert.equal(player(moved, f.entrantAuth.playerId).spice, 18);
+ assert.deepEqual(player(moved, f.entrantAuth.playerId).forces, {
+ 'arrakeen:10': 1,
+ 'imperial_basin:10': 1,
+ });
+ } finally {
+ f.sqlite.close();
+ }
+ });
+
+void test('competing accept and refuse requests produce one complete consent outcome and one chronicle entry', async () => {
+ const f = await fixture();
+ try {
+ const offered = await enterAndPropose(f);
+ const { after, accepted } = await race(f, offered, [true, false]);
+ assert.equal(
+ player(after, f.ownerAuth.playerId).ally,
+ accepted ? f.entrantAuth.playerId : null,
+ );
+ assert.equal(
+ player(after, f.entrantAuth.playerId).ally,
+ accepted ? f.ownerAuth.playerId : null,
+ );
+ assert.equal(after.log.length, offered.log.length + 1);
+ await invariant(f, after);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('wrong actor, malformed consent and corrupted saved reply provenance reject before SQL writes', async () => {
+ const f = await fixture();
+ try {
+ const offered = await enterAndPropose(f);
+ f.writes.length = 0;
+ for (const auth of [f.ownerAuth, f.observerAuth])
+ await assert.rejects(() =>
+ f.rooms.act(
+ f.code,
+ auth,
+ offered.version,
+ {
+ type: 'decision',
+ event: offered.pendingAmbassador!.event,
+ accept: true,
+ },
+ clock,
+ ),
+ );
+ for (const action of [
+ {
+ type: 'decision',
+ event: offered.pendingAmbassador!.event,
+ accept: 'yes',
+ },
+ { type: 'decision', event: 'expired', accept: true },
+ ])
+ await assert.rejects(() =>
+ f.rooms.act(f.code, f.entrantAuth, offered.version, action, clock),
+ );
+ assert.equal(f.writes.length, 0);
+ assert.deepEqual(await f.rooms.readRoom(f.code), offered);
+ for (const mutate of [
+ (g: engine.Game) => {
+ g.pendingAmbassador!.turn++;
+ },
+ (g: engine.Game) => {
+ g.pendingAmbassador!.beneficiary = f.ownerAuth.playerId;
+ },
+ (g: engine.Game) => {
+ g.decision = null;
+ },
+ (g: engine.Game) => {
+ g.ecazAmbassadors!.tokens.find((t) => t.id === f.tokenId)!.zone =
+ 'used';
+ },
+ (g: engine.Game) => {
+ player(g, f.observerAuth.playerId).ally = f.entrantAuth.playerId;
+ },
+ ]) {
+ const damaged = clone(offered);
+ mutate(damaged);
+ f.save(damaged);
+ await fresh(f);
+ f.writes.length = 0;
+ await assert.rejects(() => f.rooms.readSeatView(f.code, f.entrantAuth));
+ await assert.rejects(() =>
+ f.rooms.act(
+ f.code,
+ f.entrantAuth,
+ damaged.version,
+ {
+ type: 'decision',
+ event: offered.pendingAmbassador!.event,
+ accept: true,
+ },
+ clock,
+ ),
+ );
+ assert.equal(f.writes.length, 0);
+ assert.deepEqual(await f.rooms.readRoom(f.code), damaged);
+ assert.deepEqual(await f.rooms.readRoom(f.otherCode), f.otherBefore);
+ }
+ } finally {
+ f.sqlite.close();
+ }
+});
diff --git a/tests/ecaz-alliance.test.ts b/tests/ecaz-alliance.test.ts
new file mode 100644
index 00000000..868085f5
--- /dev/null
+++ b/tests/ecaz-alliance.test.ts
@@ -0,0 +1,224 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { FACTIONS } from '../game/catalog';
+import {
+ quoteEcazAlliance as quote,
+ ecazAllianceBlock as block,
+ EcazAllianceError,
+ type EcazAllianceContext,
+} from '../game/ecaz-alliance';
+
+function context(): EcazAllianceContext {
+ return {
+ status: 'playing',
+ turn: 4,
+ players: [
+ { id: 'e', faction: 'ecaz', ally: null, allySinceTurn: 2 },
+ { id: 'a', faction: 'atreides', ally: null },
+ { id: 'h', faction: 'harkonnen', ally: 'g', allySinceTurn: 1 },
+ { id: 'g', faction: 'guild', ally: 'h', allySinceTurn: 1 },
+ { id: 'f', faction: 'fremen', ally: null },
+ { id: 'b', faction: 'beneGesserit', ally: null },
+ ],
+ allianceOffers: { e: 'f', a: 'b', h: 'e', g: 'a', f: 'b', b: 'f' },
+ };
+}
+function reject(g: EcazAllianceContext, owner = 'e', entrant = 'a') {
+ const before = structuredClone(g);
+ assert.throws(() => quote(g, owner, entrant), EcazAllianceError);
+ assert.equal(typeof block(g, owner, entrant), 'string');
+ assert.deepEqual(g, before);
+}
+
+void test('forms the reciprocal pair with current tenure and clears all offers to or from either seat', () => {
+ const g = context();
+ const before = structuredClone(g);
+ const result = quote(g, 'e', 'a');
+ assert.deepEqual(result.players.slice(0, 2), [
+ { id: 'e', faction: 'ecaz', ally: 'a', allySinceTurn: 4 },
+ { id: 'a', faction: 'atreides', ally: 'e', allySinceTurn: 4 },
+ ]);
+ assert.deepEqual(result.players.slice(2), g.players.slice(2));
+ assert.deepEqual(result.allianceOffers, { f: 'b', b: 'f' });
+ assert.equal(block(g, 'e', 'a'), null);
+ assert.deepEqual(g, before);
+ for (let i = 0; i < g.players.length; i++)
+ assert.notEqual(result.players[i], g.players[i]);
+ result.players[2].ally = null;
+ result.allianceOffers.f = 'e';
+ assert.deepEqual(g, before);
+});
+
+void test('every other faction can be the consenting unallied entrant without a Duke prerequisite', () => {
+ for (const faction of FACTIONS.filter((f) => f.id !== 'ecaz')) {
+ const g: EcazAllianceContext = {
+ status: 'playing',
+ turn: 1,
+ players: [
+ { id: 'e', faction: 'ecaz', ally: null },
+ { id: 'a', faction: faction.id, ally: null },
+ ],
+ allianceOffers: {},
+ };
+ assert.equal(quote(g, 'e', 'a').players[1].ally, 'e');
+ assert.equal(block(g, 'e', 'a'), null);
+ }
+});
+
+void test('rejects either already-allied participant and all dangling or asymmetric references', () => {
+ for (const member of ['e', 'a']) {
+ const g = context();
+ const seats = g.players.map((p) => ({ ...p }));
+ seats.find((p) => p.id === member)!.ally = 'f';
+ seats.find((p) => p.id === 'f')!.ally = member;
+ g.players = seats;
+ reject(g);
+ }
+ for (const ally of ['e', 'a', 'missing', 'f']) {
+ const g = context();
+ g.players = g.players.map((p) => (p.id === 'f' ? { ...p, ally } : p));
+ reject(g);
+ }
+ const g = context();
+ g.players = g.players.map((p) => (p.id === 'h' ? { ...p, ally: null } : p));
+ reject(g);
+});
+
+void test('rejects malformed roster, lifecycle, identity and tenure without mutation', () => {
+ for (const mutate of [
+ (g: EcazAllianceContext) => {
+ g.status = 'setup';
+ },
+ (g: EcazAllianceContext) => {
+ g.status = 'finished';
+ },
+ (g: EcazAllianceContext) => {
+ g.status = 'lobby';
+ },
+ ...[0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1].map(
+ (turn) => (g: EcazAllianceContext) => {
+ g.turn = turn;
+ },
+ ),
+ (g: EcazAllianceContext) => {
+ g.players = [];
+ },
+ (g: EcazAllianceContext) => {
+ g.players = [...g.players, { ...g.players[0] }];
+ },
+ (g: EcazAllianceContext) => {
+ g.players = [...g.players, { ...g.players[0], id: 'other' }];
+ },
+ ...['', ' ', null, 'unknown'].map((faction) => (g: EcazAllianceContext) => {
+ g.players = g.players.map((p, i) =>
+ i === 0 ? ({ ...p, faction } as typeof p) : p,
+ );
+ }),
+ ...[-1, 5, 0.5, NaN, null].map((stamp) => (g: EcazAllianceContext) => {
+ g.players = g.players.map((p, i) =>
+ i === 0 ? ({ ...p, allySinceTurn: stamp } as typeof p) : p,
+ );
+ }),
+ (g: EcazAllianceContext) => {
+ g.players = g.players.map((p, i) => (i === 0 ? { ...p, id: '' } : p));
+ },
+ (g: EcazAllianceContext) => {
+ g.players = g.players.map((p, i) =>
+ i === 0 ? ({ ...p, ally: undefined } as unknown as typeof p) : p,
+ );
+ },
+ ]) {
+ const g = context();
+ mutate(g);
+ reject(g);
+ }
+ for (const [owner, entrant] of [
+ ['a', 'e'],
+ ['e', 'e'],
+ ['absent', 'a'],
+ ['e', 'absent'],
+ ['', 'a'],
+ ])
+ reject(context(), owner, entrant);
+});
+
+void test('rejects malformed offer records instead of preserving invalid endpoints', () => {
+ for (const offers of [
+ null,
+ [],
+ { absent: 'e' },
+ { e: 'absent' },
+ { e: 'e' },
+ { e: 4 },
+ { e: '' },
+ ]) {
+ const g = context();
+ g.allianceOffers = offers as unknown as Record;
+ reject(g);
+ }
+});
+
+void test('JSON roundtrip preserves the quote and an already-formed result cannot be applied twice', () => {
+ const g = context();
+ const result = quote(g, 'e', 'a');
+ assert.deepEqual(quote(JSON.parse(JSON.stringify(g)), 'e', 'a'), result);
+ reject({ ...g, ...result });
+});
+
+void test('private getters and random state are not read or copied into the public result', () => {
+ const g = context();
+ const forbidden = {
+ get() {
+ throw new Error('private state read');
+ },
+ enumerable: true,
+ };
+ for (const key of ['duke', 'deck', 'rng', 'prediction', 'spice'])
+ Object.defineProperty(g, key, forbidden);
+ for (const seat of g.players)
+ for (const key of ['hand', 'spice', 'leaders', 'prediction'])
+ Object.defineProperty(seat, key, forbidden);
+ Object.freeze(g.allianceOffers);
+ g.players.forEach(Object.freeze);
+ Object.freeze(g.players);
+ Object.freeze(g);
+ const original = Math.random;
+ Math.random = () => {
+ throw new Error('RNG read');
+ };
+ try {
+ const result = quote(g, 'e', 'a');
+ assert.equal(block(g, 'e', 'a'), null);
+ assert.deepEqual(Object.keys(result).sort(), ['allianceOffers', 'players']);
+ assert.deepEqual(Object.keys(result.players[1]).sort(), [
+ 'ally',
+ 'allySinceTurn',
+ 'faction',
+ 'id',
+ ]);
+ } finally {
+ Math.random = original;
+ }
+});
+
+void test('seated prototype-like IDs remain plain own offer keys without prototype mutation', () => {
+ const g: EcazAllianceContext = {
+ status: 'playing',
+ turn: 2,
+ players: [
+ { id: 'e', faction: 'ecaz', ally: null },
+ { id: 'a', faction: 'atreides', ally: null },
+ { id: '__proto__', faction: 'fremen', ally: null },
+ { id: 'constructor', faction: 'guild', ally: null },
+ ],
+ allianceOffers: JSON.parse(
+ '{"__proto__":"constructor","constructor":"__proto__"}',
+ ),
+ };
+ const result = quote(g, 'e', 'a');
+ assert.equal(Object.getPrototypeOf(result.allianceOffers), Object.prototype);
+ assert.deepEqual(Object.entries(result.allianceOffers), [
+ ['__proto__', 'constructor'],
+ ['constructor', '__proto__'],
+ ]);
+});
diff --git a/tests/ecaz-collection-engine.test.ts b/tests/ecaz-collection-engine.test.ts
new file mode 100644
index 00000000..8f45a1bd
--- /dev/null
+++ b/tests/ecaz-collection-engine.test.ts
@@ -0,0 +1,468 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ newPlayer,
+ normalizeAutomaticGame,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import { baseDeck } from '../game/cards';
+import type { FactionId } from '../game/catalog';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { richeseCards } from '../game/richese-cards';
+
+const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
+const json = (g: Game): Game => JSON.parse(JSON.stringify(g));
+
+/** Conserved development position: real last movement, skipped empty Battle,
+ * collection response, and allocation actions create every pending receipt. */
+function fixture(
+ advanced = false,
+ multiple = false,
+ ally: FactionId = 'atreides',
+) {
+ const g = createGame(
+ 'ECAZENGINE',
+ newPlayer('ec', 'Ecaz', 'ecaz'),
+ advanced,
+ ['ecaz'],
+ );
+ g.players.push(
+ newPlayer('al', 'Ally', ally),
+ newPlayer('en', 'Rival', 'emperor'),
+ newPlayer('bg', 'BG', 'beneGesserit'),
+ );
+ Object.assign(g, {
+ status: 'playing',
+ phase: 5,
+ turn: 2,
+ storm: 18,
+ order: ['ec', 'al', 'en', 'bg'],
+ active: 'en',
+ movementRemaining: ['en'],
+ deck: baseDeck(),
+ discard: [],
+ ready: [],
+ phaseOpening: null,
+ spice: { 'wind_pass:14': 5, ...(multiple ? { 'hagga_basin:12': 7 } : {}) },
+ });
+ for (const [index, p] of g.players.entries())
+ Object.assign(p, {
+ spice: 10 + index * 10,
+ hand: [],
+ forces: {},
+ reserves: 20,
+ tanks: 0,
+ traitors: [],
+ traitorChoices: [],
+ shipped: true,
+ moved: 0,
+ });
+ for (const id of ['ec', 'al']) {
+ const p = player(g, id);
+ p.forces = {
+ 'wind_pass:14': 2,
+ ...(multiple ? { 'hagga_basin:12': 2 } : {}),
+ ...(advanced ? { 'arrakeen:10': 1 } : {}),
+ };
+ p.reserves = 20 - Object.values(p.forces).reduce((a, n) => a + n, 0);
+ p.ally = id === 'ec' ? 'al' : 'ec';
+ p.allySinceTurn = 1;
+ }
+ return g;
+}
+function hold(g: Game, id: string, name: string) {
+ const index = g.deck.findIndex((c) => c.name === name);
+ assert.ok(index >= 0, name);
+ const c = g.deck.splice(index, 1)[0];
+ player(g, id).hand.push(c);
+ return c.id;
+}
+function start(g: Game) {
+ const before = json(g);
+ let next = applyAction(g, 'en', { type: 'endMovement' });
+ assert.deepEqual(g, before);
+ // CHOAM's real closing markets precede the collection initializer.
+ for (let i = 0; next.decision?.kind === 'choamMarket' && i < 3; i++)
+ next = applyAction(next, next.decision.player, {
+ type: 'decision',
+ done: true,
+ });
+ assert.equal(next.phase, 7);
+ return next;
+}
+function allow(g: Game) {
+ for (let i = 0; g.response && i < 20; i++) {
+ const p = g.players.find((p) => !g.response!.passed.includes(p.id))!;
+ g = applyAction(json(g), p.id, { type: 'passResponse' });
+ }
+ assert.equal(g.response, null);
+ return g;
+}
+function command(g: Game, allocation: Record): Action {
+ return { type: 'decision', event: g.ecazCollection!.event, allocation };
+}
+function choose(g: Game, allocation: Record) {
+ assert.equal(g.decision?.kind, 'ecazSpice');
+ return applyAction(json(g), g.decision!.player, command(g, allocation));
+}
+function finish(g: Game) {
+ for (let n = 0; g.decision?.kind === 'ecazSpice' && n < 5; n++)
+ g = choose(g, { kind: 'equal' });
+ assert.equal(g.ecazCollection?.stage, 'complete');
+ assert.equal(g.decision, null);
+ return g;
+}
+function conserved(g: Game) {
+ for (const p of g.players)
+ assert.equal(
+ p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0),
+ 20,
+ );
+ const cards = [
+ ...g.deck,
+ ...g.discard,
+ ...g.players.flatMap((p) => p.hand),
+ ].map((c) => c.id);
+ assert.equal(new Set(cards).size, cards.length);
+}
+function rejected(g: Game, id: string, action: Action) {
+ const before = json(g);
+ assert.throws(() => applyAction(g, id, action));
+ assert.deepEqual(g, before);
+}
+
+for (const advanced of [false, true])
+ void test(`${advanced ? 'Advanced' : 'Basic'} last movement enters collection with no automatic-income confirmation or premature shared credit`, () => {
+ const original = fixture(advanced, true);
+ let g = start(original);
+ assert.equal(g.response, null);
+ assert.equal(g.decision?.kind, 'ecazSpice');
+ assert.equal(g.ecazCollection?.stage, 'allocation');
+ assert.equal(g.ecazCollection?.allocation?.lots.length, 2);
+ assert.equal(player(g, 'ec').spice, advanced ? 12 : 10);
+ assert.equal(player(g, 'al').spice, advanced ? 22 : 20);
+ assert.equal(g.spice['wind_pass:14'], 0);
+ assert.equal(g.spice['hagga_basin:12'], 0);
+ g = finish(g);
+ assert.equal(player(g, 'ec').spice, advanced ? 17 : 15);
+ assert.equal(player(g, 'al').spice, advanced ? 29 : 27);
+ assert.equal(g.ecazCollection!.settled.length, 2);
+ conserved(g);
+ });
+
+void test('zero shared spice leaves ordinary collection automatic without a no-choice allocation', () => {
+ const original = fixture(true);
+ original.spice = {};
+ const g = start(original);
+ assert.equal(g.response, null);
+ assert.equal(g.decision, null);
+ assert.equal(g.ecazCollection?.stage, 'complete');
+ assert.equal(player(g, 'ec').spice, 12);
+ assert.equal(player(g, 'al').spice, 22);
+});
+
+void test('real proposals and counteroffers remain private to the allies until one agreed receipt is paid', () => {
+ const base = start(fixture());
+ const left = choose(base, { kind: 'propose', ecazShare: 1 });
+ const right = choose(base, { kind: 'propose', ecazShare: 4 });
+ for (const id of ['en', 'bg']) {
+ assert.deepEqual(viewGame(left, id), viewGame(right, id));
+ assert.equal(viewGame(left, id).ecazSpice?.allocation, null);
+ }
+ assert.equal(viewGame(left, 'al').ecazSpice!.allocation!.offer!.ecazShare, 1);
+ const counter = choose(left, { kind: 'propose', ecazShare: 3 });
+ assert.equal(counter.decision?.player, 'ec');
+ assert.equal(player(counter, 'ec').spice, 10);
+ assert.equal(player(counter, 'al').spice, 20);
+ rejected(counter, 'al', command(counter, { kind: 'accept' }));
+ const done = choose(counter, { kind: 'accept' });
+ assert.equal(player(done, 'ec').spice, 13);
+ assert.equal(player(done, 'al').spice, 22);
+ assert.equal(done.ecazCollection!.settled[0].method, 'agreement');
+});
+
+void test('genuine CHOAM cash-in during shared escrow changes its private balance without losing the negotiated collection parent', () => {
+ const initial = fixture(true, false, 'choam');
+ const activation = hold(initial, 'al', 'Karama');
+ const card = hold(initial, 'al', 'Baliset');
+ let g = allow(start(initial));
+ g = choose(g, { kind: 'propose', ecazShare: 1 });
+ const pending = json(g).ecazCollection;
+ const oldBalance = player(g, 'al').spice;
+ const changed = applyAction(g, 'al', {
+ type: 'card',
+ mode: 'special',
+ card: activation,
+ cards: [card],
+ });
+ assert.equal(player(changed, 'al').spice, oldBalance + 3);
+ assert.deepEqual(changed.ecazCollection, pending);
+ assert.equal(changed.decision?.kind, 'ecazSpice');
+ const done = choose(normalizeAutomaticGame(json(changed)), {
+ kind: 'accept',
+ });
+ assert.equal(player(done, 'al').spice, oldBalance + 7);
+ assert.equal(player(done, 'ec').spice, 13);
+ assert.equal(
+ done.discard.filter((c) => c.id === activation || c.id === card).length,
+ 2,
+ );
+ conserved(done);
+});
+
+void test('saved private balance perturbations preserve rival projection and credits use current balances', () => {
+ const g = choose(start(fixture()), { kind: 'propose', ecazShare: 2 });
+ const changed = json(g);
+ player(changed, 'ec').spice += 100;
+ player(changed, 'al').spice += 200;
+ assert.deepEqual(viewGame(changed, 'en'), viewGame(g, 'en'));
+ const done = choose(normalizeAutomaticGame(changed), { kind: 'accept' });
+ assert.equal(player(done, 'ec').spice, 112);
+ assert.equal(player(done, 'al').spice, 223);
+});
+
+void test('stale event and wrong player commands reject without consuming the current shared lot', () => {
+ const g = start(fixture());
+ rejected(g, 'ec', { ...command(g, { kind: 'equal' }), event: 'old-event' });
+ rejected(g, 'al', command(g, { kind: 'equal' }));
+ rejected(g, 'ec', command(g, { kind: 'propose', ecazShare: 6 }));
+ rejected(g, 'ec', { type: 'ready' });
+});
+
+void test('corrupt saved turn, event shape, roster, source or orphaned allocation rejects in action and normalization', () => {
+ const base = start(fixture());
+ const corruptions: ((g: Game) => void)[] = [
+ (g) => {
+ g.ecazCollection!.turn--;
+ },
+ (g) => {
+ g.ecazCollection!.event = '';
+ },
+ (g) => {
+ player(g, 'al').ally = null;
+ },
+ (g) => {
+ player(g, 'al').faction = 'emperor';
+ },
+ (g) => {
+ g.ecazCollection!.sourceSpice['wind_pass:14']++;
+ },
+ (g) => {
+ g.decision = null;
+ },
+ (g) => {
+ g.decision = { kind: 'ecazSpice', player: 'en' };
+ },
+ ];
+ for (const corrupt of corruptions) {
+ const g = json(base);
+ corrupt(g);
+ const before = json(g);
+ assert.throws(() => normalizeAutomaticGame(g));
+ assert.deepEqual(g, before);
+ rejected(g, 'ec', command(g, { kind: 'equal' }));
+ }
+});
+
+void test('an orphaned ecazSpice decision cannot survive automatic recovery without its collection event', () => {
+ const base = start(fixture());
+ for (const completed of [false, true]) {
+ const g = json(base);
+ const action = command(g, { kind: 'equal' });
+ if (completed) {
+ g.ecazCollection!.stage = 'complete';
+ g.ecazCollection!.allocation = null;
+ } else delete g.ecazCollection;
+ rejected(g, 'ec', action);
+ assert.throws(() => normalizeAutomaticGame(g));
+ }
+});
+
+function assertOrphanRejected(base: Game) {
+ for (const completed of [false, true]) {
+ const g = json(base);
+ if (completed) {
+ g.ecazCollection!.stage = 'complete';
+ g.ecazCollection!.allocation = null;
+ } else delete g.ecazCollection;
+ const before = json(g);
+ assert.throws(() => normalizeAutomaticGame(g));
+ assert.deepEqual(g, before);
+ rejected(g, 'ec', { type: 'advanceBots' });
+ }
+}
+
+for (const converted of [false, true])
+ void test(`orphaned ${converted ? 'paid BG conversion' : 'unpaid Collection'} response rejects missing or falsely completed collection events`, () => {
+ const initial = fixture(true);
+ hold(initial, 'ec', 'Karama');
+ const card = hold(
+ initial,
+ converted ? 'bg' : 'en',
+ converted ? 'Baliset' : 'Karama',
+ );
+ let g = start(initial);
+ assert.equal(g.response?.kind, 'ecazCollection');
+ if (converted) {
+ g = applyAction(g, 'bg', { type: 'card', card, mode: 'cancel' });
+ assert.equal(g.response?.kind, 'worthlessKarama');
+ }
+ assertOrphanRejected(g);
+ const valid = allow(normalizeAutomaticGame(json(g)));
+ assert.equal(valid.ecazCollection?.stage, 'allocation');
+ assert.equal(valid.ecazCollection?.canceled, converted);
+ });
+
+for (const parent of ['allocation', 'response', 'conversion'] as const)
+ void test(`paid Box suspension retains the ${parent} collection parent and rejects orphaned saved controls`, () => {
+ const initial = fixture(parent !== 'allocation');
+ initial.richeseCache = richeseCards();
+ const index = initial.richeseCache.findIndex(
+ (c) => c.effect === 'nullentropyBox',
+ );
+ assert.ok(index >= 0);
+ const box = initial.richeseCache.splice(index, 1)[0];
+ player(initial, 'al').hand.push(box);
+ for (const name of ['Shield', 'Maula Pistol']) {
+ const at = initial.deck.findIndex((c) => c.name === name);
+ assert.ok(at >= 0);
+ initial.discard.push(initial.deck.splice(at, 1)[0]);
+ }
+ if (parent !== 'allocation')
+ hold(initial, parent === 'response' ? 'en' : 'ec', 'Karama');
+ const cancel =
+ parent === 'conversion' ? hold(initial, 'bg', 'Baliset') : null;
+ let g = start(initial);
+ if (parent === 'response') assert.equal(g.response?.kind, 'ecazCollection');
+ if (cancel) {
+ g = applyAction(g, 'bg', { type: 'card', card: cancel, mode: 'cancel' });
+ assert.equal(g.response?.kind, 'worthlessKarama');
+ }
+ const pending = json(g).ecazCollection;
+ const originalResponse = json(g).response;
+ g = applyAction(g, 'al', { type: 'card', card: box.id });
+ assert.equal(g.decision?.kind, 'nullentropy');
+ assert.equal(g.response, null);
+ assert.deepEqual(g.ecazCollection, pending);
+ if (parent === 'allocation')
+ assert.equal(g.pendingNullentropy!.resume.decision?.kind, 'ecazSpice');
+ else
+ assert.deepEqual(g.pendingNullentropy!.resume.response, originalResponse);
+ assert.deepEqual(normalizeAutomaticGame(json(g)), json(g));
+ assertOrphanRejected(g);
+ const picked = g.discard.find((c) => c.name === 'Shield')!.id;
+ g = applyAction(json(g), 'al', {
+ type: 'decision',
+ event: g.pendingNullentropy!.event,
+ card: picked,
+ });
+ assert.equal(g.pendingNullentropy, null);
+ g = allow(g);
+ assert.equal(g.decision?.kind, 'ecazSpice');
+ assert.equal(g.ecazCollection?.event, pending!.event);
+ assert.equal(g.ecazCollection?.canceled, parent === 'conversion');
+ const done = finish(g);
+ assert.equal(done.discard.filter((c) => c.id === box.id).length, 1);
+ assert.equal(
+ player(done, 'al').hand.filter((c) => c.id === picked).length,
+ 1,
+ );
+ });
+
+void test('settled allocation ledger prevents index rewind or advance from duplicating or skipping an escrow payout', () => {
+ const g = choose(start(fixture(false, true)), { kind: 'equal' });
+ assert.equal(g.ecazCollection!.allocation!.index, 1);
+ assert.equal(g.ecazCollection!.settled.length, 1);
+ const changes: ((g: Game) => void)[] = [
+ (g) => {
+ g.ecazCollection!.allocation!.index = 0;
+ },
+ (g) => {
+ g.ecazCollection!.allocation!.index = 2;
+ },
+ (g) => {
+ g.ecazCollection!.settled = [];
+ },
+ (g) => {
+ g.ecazCollection!.settled[0].ecazAmount++;
+ },
+ (g) => {
+ g.ecazCollection!.settled[0].territory = 'red_chasm';
+ },
+ ];
+ for (const change of changes) {
+ const bad = json(g);
+ change(bad);
+ assert.throws(() => normalizeAutomaticGame(bad));
+ rejected(bad, 'ec', command(bad, { kind: 'equal' }));
+ }
+ const done = finish(normalizeAutomaticGame(json(g)));
+ assert.equal(player(done, 'ec').spice, 15);
+ assert.equal(player(done, 'al').spice, 27);
+});
+
+for (const level of DIFFICULTIES)
+ for (const offered of [false, true])
+ void test(`${level} ${offered ? 'accepts a fair proposal' : 'chooses the equal split'} then allows normal phase advancement`, () => {
+ let g = start(fixture());
+ if (offered) g = choose(g, { kind: 'propose', ecazShare: 2 });
+ const id = g.decision!.player;
+ const view = viewGame(g, id);
+ view.players.find((p) => p.id === id)!.bot = level;
+ const before = structuredClone(view);
+ const actions = botActions(view);
+ assert.deepEqual(view, before);
+ assert.deepEqual(actions[0].allocation, {
+ kind: offered ? 'accept' : 'equal',
+ });
+ g = applyAction(g, id, actions[0]);
+ assert.equal(g.ecazCollection!.stage, 'complete');
+ assert.equal(player(g, 'ec').spice, 12);
+ assert.equal(player(g, 'al').spice, 23);
+ for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
+ assert.equal(g.phase, 8);
+ assert.equal(g.decision, null);
+ });
+
+for (const converted of [false, true])
+ void test(`real ${converted ? 'BG-converted' : 'printed'} cancellation denies only Ecaz's bank income before the same multi-lot allocation`, () => {
+ const initial = fixture(true, true);
+ const actor = converted ? 'bg' : 'en';
+ const card = hold(initial, actor, converted ? 'Baliset' : 'Karama');
+ if (converted) hold(initial, 'ec', 'Karama');
+ let g = start(initial);
+ assert.equal(g.response?.kind, 'ecazCollection');
+ assert.equal(player(g, 'ec').spice, 10);
+ assert.equal(g.spice['wind_pass:14'], 5);
+ g = applyAction(g, actor, { type: 'card', card, mode: 'cancel' });
+ if (converted) assert.equal(g.response?.kind, 'worthlessKarama');
+ g = allow(g);
+ assert.equal(g.ecazCollection?.canceled, true);
+ assert.equal(player(g, 'ec').spice, 10);
+ assert.equal(player(g, 'al').spice, 22);
+ g = finish(g);
+ assert.equal(player(g, 'ec').spice, 15);
+ assert.equal(player(g, 'al').spice, 29);
+ assert.equal(g.discard.filter((c) => c.id === card).length, 1);
+ assert.equal(g.ecazCollection!.settled.length, 2);
+ conserved(g);
+ });
+
+void test('completed collection survives repeated JSON normalization without replay, while stale gameplay cannot reopen it', () => {
+ const pending = start(fixture(true, true));
+ const old = command(pending, { kind: 'equal' });
+ const done = finish(pending);
+ const again = normalizeAutomaticGame(normalizeAutomaticGame(json(done)));
+ assert.deepEqual(again, done);
+ rejected(again, 'ec', old);
+ assert.equal(
+ again.log.filter((l) => l.text.includes('shared collection: Ecaz received'))
+ .length,
+ 2,
+ );
+});
diff --git a/tests/ecaz-collection-quote.test.ts b/tests/ecaz-collection-quote.test.ts
index 8697150a..07f8c140 100644
--- a/tests/ecaz-collection-quote.test.ts
+++ b/tests/ecaz-collection-quote.test.ts
@@ -74,6 +74,7 @@ for (const advanced of [false, true])
player: id,
strongholds: 0,
collected: 0,
+ desert: 0,
balance: 10,
});
assert.deepEqual(g, before);
diff --git a/tests/ecaz-duke-acquisition.test.ts b/tests/ecaz-duke-acquisition.test.ts
index 954321cc..e050b4e7 100644
--- a/tests/ecaz-duke-acquisition.test.ts
+++ b/tests/ecaz-duke-acquisition.test.ts
@@ -276,7 +276,7 @@ void test('explicit Duke choice/owner/event and eligibility are checked before t
reject(g, 'in', action);
reject(g, 'ec', { ...action, event: 'stale' });
reject(g, 'ec', { ...action, choice: undefined });
- reject(g, 'ec', { ...action, choice: 'alliance' });
+ reject(g, 'ec', { ...action, choice: 'loan' });
const allied = reload(g);
p(allied, 'ec').ally = 'al';
p(allied, 'al').ally = 'ec';
diff --git a/tests/ecaz-duke-bots.test.ts b/tests/ecaz-duke-bots.test.ts
index 34a0f5ce..ed7c7989 100644
--- a/tests/ecaz-duke-bots.test.ts
+++ b/tests/ecaz-duke-bots.test.ts
@@ -92,10 +92,15 @@ function action(g: Game, level: (typeof DIFFICULTIES)[number]) {
}
for (const level of DIFFICULTIES)
- void test(`${level} explicitly acquires for Ecaz through real entry in Basic/Advanced, with or without an existing ally`, () => {
+ void test(`${level} acquires for Ecaz in Basic/Advanced when an existing alliance rules out a new offer`, () => {
for (const advanced of [false, true])
for (const allied of [false, true]) {
- const g = enter(fixture(advanced, allied));
+ const initial = fixture(advanced, allied);
+ if (!allied) {
+ initial.players[1].ally = 'al';
+ initial.players[2].ally = 'in';
+ }
+ const g = enter(initial);
assert.deepEqual(viewGame(g, 'ec').ambassadorEntry!.dukeAcquisition, {
blocked: null,
});
@@ -165,9 +170,11 @@ const unavailable: [string, (g: Game) => void][] = [
],
];
for (const [name, change] of unavailable)
- void test(`all four profiles leave the token in place when ${name}`, () => {
+ void test(`all four profiles leave the token in place when already allied and ${name}`, () => {
const state = fixture();
change(state);
+ state.players[0].ally = 'al';
+ state.players[2].ally = 'ec';
const g = enter(state);
assert.ok(viewGame(g, 'ec').ambassadorEntry!.dukeAcquisition!.blocked);
for (const level of DIFFICULTIES) {
@@ -193,7 +200,7 @@ void test('only the actual owner gets the choice, and bots cannot acquire from a
}
});
void test('all profiles rely on the server descriptor, with safe decline for a missing descriptor', () => {
- const g = enter(fixture());
+ const g = enter(fixture(false, true));
for (const level of DIFFICULTIES) {
const view = botView(g, level);
Object.defineProperty(view, 'dukeVidal', {
@@ -209,7 +216,7 @@ void test('all profiles rely on the server descriptor, with safe decline for a m
}
});
void test('private opponent hands, resources and traitors do not change acquisition choices or become projected evidence', () => {
- const g = enter(fixture());
+ const g = enter(fixture(false, true));
const changed = structuredClone(g);
changed.players[1].hand.push(changed.deck.shift()!);
changed.players[1].spice = 1;
diff --git a/tests/ecaz-poison-income-engine.test.ts b/tests/ecaz-poison-income-engine.test.ts
new file mode 100644
index 00000000..b67812ef
--- /dev/null
+++ b/tests/ecaz-poison-income-engine.test.ts
@@ -0,0 +1,712 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ newPlayer,
+ normalizeAutomaticGame,
+ viewGame,
+ type Game,
+} from '../game/engine';
+import { baseDeck, treacheryDeck, spiceDeck, type Card } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+import { cashInCards } from '../game/choam-karama';
+import { createHomeworldCustody } from '../game/homeworld-custody';
+import {
+ homeworldContext,
+ homeworldGameIntegrity,
+} from '../game/homeworld-game';
+
+const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
+const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
+function hold(g: Game, id: string, match: (card: Card) => boolean) {
+ const at = g.deck.findIndex(match);
+ assert.ok(at >= 0);
+ const card = g.deck.splice(at, 1)[0];
+ player(g, id).hand.push(card);
+ return card;
+}
+/** Explicit Ecaz module scenario; no complete Ecaz expansion setup claim.
+ * Cards move from the real deck and all native/board/Tanks counters conserve. */
+function fixture(reserves = 7, battle = false) {
+ const g = createGame(
+ 'ECAZPOISON',
+ newPlayer(
+ 'owner',
+ battle ? 'Guild' : 'Emperor',
+ battle ? 'guild' : 'emperor',
+ ),
+ false,
+ );
+ g.players.push(
+ newPlayer('other', 'Atreides', 'atreides'),
+ newPlayer('ec', 'Ecaz', 'ecaz'),
+ );
+ Object.assign(g, {
+ status: 'playing',
+ phase: battle ? 6 : 2,
+ turn: 2,
+ storm: 18,
+ active: battle ? 'owner' : null,
+ order: ['owner', 'other', 'ec'],
+ deck: battle ? treacheryDeck(['ix']) : baseDeck(),
+ spiceDeck: spiceDeck(),
+ phaseOpening: null,
+ response: null,
+ decision: null,
+ ready: [],
+ });
+ for (const p of g.players)
+ Object.assign(p, {
+ spice: 20,
+ reserves: 20,
+ tanks: 0,
+ forces: {},
+ hand: [],
+ traitors: [],
+ traitorChoices: [],
+ });
+ if (!battle)
+ player(g, 'owner').elites = {
+ reserves: 5,
+ tanks: 0,
+ forces: {},
+ revived: 0,
+ };
+ player(g, 'ec').reserves = reserves;
+ player(g, 'ec').forces = { 'polar_sink:0': 20 - reserves };
+ if (battle) {
+ player(g, 'owner').reserves = 14;
+ player(g, 'owner').forces = { 'arrakeen:10': 6 };
+ player(g, 'other').reserves = 18;
+ player(g, 'other').forces = { 'arrakeen:10': 2 };
+ }
+ g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
+ return g;
+}
+function inventory(g: Game, expected: readonly string[]) {
+ homeworldGameIntegrity(g);
+ for (const p of g.players)
+ assert.equal(
+ p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
+ 20,
+ );
+ const ids = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
+ .map((card) => card.id)
+ .sort();
+ assert.deepEqual(ids, [...expected].sort());
+ assert.equal(new Set(ids).size, ids.length);
+}
+const baseIds = baseDeck().map((card) => card.id);
+function endBidding(g: Game) {
+ for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
+ assert.equal(g.phase, 3);
+ for (let n = 0; g.auction && n < 40; n++)
+ g = applyAction(g, g.auction.active, { type: 'passBid' });
+ assert.ok(g.biddingEnd);
+ return g;
+}
+function dispose(g: Game, ids: string[]) {
+ return applyAction(g, 'owner', {
+ type: 'biddingEnd',
+ event: g.biddingEnd!.event,
+ mode: 'discard',
+ cards: ids,
+ });
+}
+function readyBattle(
+ g: Game,
+ weapon?: string,
+ defense?: string,
+ enemyWeapon?: string,
+ enemyDefense?: string,
+ expectedDecision = 'battleCards',
+) {
+ g = applyAction(g, 'owner', {
+ type: 'chooseBattle',
+ territory: 'arrakeen',
+ target: 'other',
+ });
+ for (let n = 0; n < 30; n++) {
+ if (g.response) {
+ const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id;
+ g = applyAction(g, id, { type: 'passResponse' });
+ } else if (g.decision?.kind === 'fullPlanOffer')
+ g = applyAction(g, g.decision.player, {
+ type: 'decision',
+ decline: true,
+ });
+ else if (g.battle?.preparation)
+ g = applyAction(g, g.battle.preparation.owner, {
+ type: 'declineBattlePower',
+ });
+ else break;
+ }
+ assert.equal(g.decision, null, JSON.stringify(g.decision));
+ const leader = [...player(g, 'owner').leaders].sort(
+ (a, b) => b.strength - a.strength,
+ )[0].id;
+ const enemy = [...player(g, 'other').leaders].sort(
+ (a, b) => a.strength - b.strength,
+ )[0].id;
+ g = applyAction(g, 'owner', {
+ type: 'battlePlan',
+ dial: 3,
+ leader,
+ weapon,
+ defense,
+ });
+ g = applyAction(g, 'other', {
+ type: 'battlePlan',
+ dial: 0,
+ leader: enemy,
+ ...(enemyWeapon ? { weapon: enemyWeapon } : {}),
+ ...(enemyDefense ? { defense: enemyDefense } : {}),
+ });
+ if (g.decision?.kind === 'poisonTooth')
+ g = applyAction(g, g.decision.player, { type: 'decision', activate: true });
+ g = applyAction(g, 'owner', { type: 'traitorCall', call: false });
+ g = applyAction(g, 'other', { type: 'traitorCall', call: false });
+ assert.equal(g.decision?.kind, expectedDecision);
+ assert.equal(g.decision?.player, 'owner');
+ return g;
+}
+
+void test('actual Kaitain batch pays Ecaz once per physical poison discard while Emperor pays the full cost first', () => {
+ let g = fixture();
+ const first = hold(g, 'owner', (card) => card.kind === 'poison');
+ const second = hold(g, 'owner', (card) => card.kind === 'poison');
+ hold(g, 'owner', (card) => card.kind === 'shield');
+ g = dispose(endBidding(g), [first.id, second.id]);
+ assert.equal(player(g, 'owner').spice, 16);
+ assert.equal(player(g, 'ec').spice, 26);
+ assert.equal(
+ viewGame(g, 'ec').ecazPoisonIncome.reduce(
+ (sum, income) => sum + income.amount,
+ 0,
+ ),
+ 6,
+ );
+ assert.equal(
+ viewGame(g, 'ec').ecazPoisonIncome.reduce(
+ (sum, income) => sum + income.count,
+ 0,
+ ),
+ 2,
+ );
+ assert.deepEqual(viewGame(g, 'other').ecazPoisonIncome, []);
+ assert.deepEqual(viewGame(g, 'owner').ecazPoisonIncome, []);
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
+ inventory(g, baseIds);
+});
+
+void test('current low Ecaz earns nothing; a real Ghola return reaching seven enables only later poison discards', () => {
+ let g = fixture(6);
+ player(g, 'ec').forces['polar_sink:0']--;
+ player(g, 'ec').tanks = 1;
+ const first = hold(g, 'owner', (card) => card.kind === 'poison');
+ const second = hold(g, 'owner', (card) => card.kind === 'poison');
+ hold(g, 'owner', (card) => card.kind === 'shield');
+ const ghola = hold(g, 'ec', (card) => card.effect === 'ghola');
+ g = dispose(endBidding(g), [first.id]);
+ assert.equal(player(g, 'ec').spice, 20);
+ assert.deepEqual(viewGame(g, 'ec').ecazPoisonIncome, []);
+ g = applyAction(reload(g), 'ec', {
+ type: 'card',
+ card: ghola.id,
+ amount: 1,
+ elite: 0,
+ });
+ assert.equal(player(g, 'ec').reserves, 7);
+ g = dispose(g, [second.id]);
+ assert.equal(player(g, 'ec').spice, 23);
+ assert.equal(viewGame(g, 'ec').ecazPoisonIncome.length, 1);
+ inventory(g, baseIds);
+});
+
+void test('hidden Kaitain poison versus non-poison disposal leaves an uninvolved observer’s complete view equivalent', () => {
+ let g = fixture();
+ const poison = hold(g, 'owner', (card) => card.kind === 'poison');
+ const shield = hold(g, 'owner', (card) => card.kind === 'shield');
+ hold(g, 'owner', (card) => card.kind === 'worthless');
+ g = endBidding(g);
+ const poisonResult = dispose(reload(g), [poison.id]);
+ const plainResult = dispose(reload(g), [shield.id]);
+ assert.equal(player(poisonResult, 'ec').spice, 23);
+ assert.equal(player(plainResult, 'ec').spice, 20);
+ assert.deepEqual(
+ viewGame(poisonResult, 'other'),
+ viewGame(plainResult, 'other'),
+ );
+ assert.deepEqual(poisonResult.log, plainResult.log);
+ assert.equal(
+ viewGame(poisonResult, 'other').players.find((p) => p.id === 'ec')!.spice,
+ undefined,
+ );
+ inventory(poisonResult, baseIds);
+ inventory(plainResult, baseIds);
+});
+
+void test('an unused Chemistry discarded from a hand does not earn poison income', () => {
+ let g = fixture();
+ const chemistry = treacheryDeck(['ix']).find(
+ (card) => card.kind === 'chemistry',
+ )!;
+ g.deck.push(chemistry);
+ hold(g, 'owner', (card) => card.id === chemistry.id);
+ hold(g, 'owner', (card) => card.kind === 'shield');
+ g = dispose(endBidding(g), [chemistry.id]);
+ assert.equal(player(g, 'ec').spice, 20);
+ assert.deepEqual(viewGame(g, 'ec').ecazPoisonIncome, []);
+ inventory(g, [...baseIds, chemistry.id]);
+});
+
+void test('Chemistry pays only when discarded after its actual winning weapon role, including refreshed delayed cleanup', () => {
+ for (const slot of ['weapon', 'defense'] as const) {
+ let g = fixture(7, true);
+ const chemistry = hold(g, 'owner', (card) => card.kind === 'chemistry');
+ const complement = hold(
+ g,
+ 'owner',
+ (card) => card.kind === (slot === 'weapon' ? 'shield' : 'projectile'),
+ );
+ g = readyBattle(
+ g,
+ slot === 'weapon' ? chemistry.id : complement.id,
+ slot === 'defense' ? chemistry.id : complement.id,
+ );
+ assert.equal(
+ player(g, 'ec').spice,
+ 20,
+ 'Keeping a revealed winning card does not discard it.',
+ );
+ assert.equal(
+ g.lastBattleContext!.cardRoles!.owner[chemistry.id].battleSlot,
+ slot,
+ );
+ const saved = reload(g);
+ g = applyAction(saved, 'owner', {
+ type: 'decision',
+ discard: [chemistry.id],
+ });
+ assert.equal(player(g, 'ec').spice, slot === 'weapon' ? 23 : 20);
+ assert.equal(
+ g.discard.filter((card) => card.id === chemistry.id).length,
+ 1,
+ );
+ assert.ok(
+ player(g, 'owner').hand.some((card) => card.id === complement.id),
+ );
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
+ inventory(
+ g,
+ treacheryDeck(['ix']).map((card) => card.id),
+ );
+ }
+});
+
+void test('Mirror remains gated at actual plan selection and cannot award income without a committed battle role', () => {
+ const g = fixture(7, true);
+ const mirror = richeseCards().find((card) => card.effect === 'mirrorWeapon')!;
+ g.deck.push(mirror);
+ hold(g, 'owner', (card) => card.id === mirror.id);
+ const defense = hold(g, 'owner', (card) => card.kind === 'snooper');
+ const poison = hold(g, 'other', (card) => card.kind === 'poison');
+ const before = reload(g);
+ assert.throws(
+ () => readyBattle(g, mirror.id, defense.id, poison.id),
+ /Choose a weapon/,
+ );
+ assert.deepEqual(g, before);
+ assert.equal(player(g, 'ec').spice, 20);
+ assert.deepEqual(viewGame(g, 'ec').ecazPoisonIncome, []);
+ inventory(g, [...treacheryDeck(['ix']).map((card) => card.id), mirror.id]);
+});
+
+void test('mandatory losing Chemistry disposal uses the original battle slot before the hand is cleared', () => {
+ for (const slot of ['weapon', 'defense'] as const) {
+ let g = fixture(7, true);
+ const weapon = hold(g, 'owner', (card) => card.kind === 'projectile');
+ const shield = hold(g, 'owner', (card) => card.kind === 'shield');
+ const chemistry = hold(g, 'other', (card) => card.kind === 'chemistry');
+ const complement = hold(
+ g,
+ 'other',
+ (card) => card.kind === (slot === 'weapon' ? 'snooper' : 'projectile'),
+ );
+ g = readyBattle(
+ g,
+ weapon.id,
+ shield.id,
+ slot === 'weapon' ? chemistry.id : complement.id,
+ slot === 'defense' ? chemistry.id : complement.id,
+ );
+ assert.equal(player(g, 'ec').spice, slot === 'weapon' ? 23 : 20);
+ assert.equal(
+ g.discard.filter((card) => card.id === chemistry.id).length,
+ 1,
+ );
+ assert.equal(player(g, 'other').hand.length, 0);
+ g = applyAction(reload(g), 'owner', { type: 'decision', discard: [] });
+ assert.equal(player(g, 'ec').spice, slot === 'weapon' ? 23 : 20);
+ inventory(
+ g,
+ treacheryDeck(['ix']).map((card) => card.id),
+ );
+ }
+});
+
+void test('saved delayed Chemistry cleanup rejects a corrupted defense-to-weapon role before minting Ecaz income', () => {
+ let g = fixture(7, true);
+ const chemistry = hold(g, 'owner', (card) => card.kind === 'chemistry');
+ const weapon = hold(g, 'owner', (card) => card.kind === 'projectile');
+ g = readyBattle(g, weapon.id, chemistry.id);
+ g.lastBattleContext!.cardRoles!.owner[chemistry.id].battleSlot = 'weapon';
+ const before = reload(g);
+ assert.throws(() => viewGame(g, 'ec'));
+ assert.throws(() =>
+ applyAction(g, 'owner', { type: 'decision', discard: [chemistry.id] }),
+ );
+ assert.deepEqual(g, before);
+});
+
+void test('native Ecaz earns losing poison income at seven before winner losses lower population, then earns none for its own later discard', () => {
+ let g = fixture(7, true);
+ const ecaz = player(g, 'ec');
+ const visitor = player(g, 'owner');
+ visitor.reserves = 18;
+ visitor.forces = {};
+ g.homeworlds!.custody!.visitors['homeworld:ecaz'] = {
+ owner: { normal: 2, elite: 0 },
+ };
+ g.active = 'ec';
+ g.order = ['ec', 'owner', 'other'];
+ const nativePoison = hold(g, 'ec', (card) => card.kind === 'poison');
+ const nativeDefense = hold(g, 'ec', (card) => card.kind === 'snooper');
+ const invadingPoison = hold(g, 'owner', (card) => card.kind === 'poison');
+ const invadingDefense = hold(g, 'owner', (card) => card.kind === 'snooper');
+ const nativeLeader = [...ecaz.leaders].sort(
+ (a, b) => b.strength - a.strength,
+ )[0].id;
+ const invadingLeader = [...visitor.leaders].sort(
+ (a, b) => a.strength - b.strength,
+ )[0].id;
+ g = applyAction(g, 'ec', {
+ type: 'chooseBattle',
+ territory: 'homeworld:ecaz',
+ target: 'owner',
+ });
+ for (let n = 0; g.battle?.preparation && n < 20; n++)
+ g = applyAction(g, g.battle.preparation.owner, {
+ type: 'declineBattlePower',
+ });
+ assert.equal(g.decision, null, JSON.stringify(g.decision));
+ g = applyAction(g, 'ec', {
+ type: 'battlePlan',
+ dial: 1,
+ leader: nativeLeader,
+ weapon: nativePoison.id,
+ defense: nativeDefense.id,
+ });
+ g = applyAction(g, 'owner', {
+ type: 'battlePlan',
+ dial: 0,
+ leader: invadingLeader,
+ weapon: invadingPoison.id,
+ defense: invadingDefense.id,
+ });
+ g = applyAction(g, 'ec', { type: 'traitorCall', call: false });
+ // Only the native faction may call a traitor in a Homeworld battle.
+ assert.equal(g.decision?.kind, 'battleCards');
+ assert.equal(player(g, 'ec').reserves, 6);
+ assert.equal(player(g, 'ec').tanks, 1);
+ assert.equal(player(g, 'ec').spice, 23);
+ assert.equal(
+ g.discard.filter((card) => card.id === invadingPoison.id).length,
+ 1,
+ );
+ assert.equal(g.decision?.kind, 'battleCards');
+ assert.equal(g.decision?.player, 'ec');
+ assert.ok(player(g, 'ec').hand.some((card) => card.id === nativePoison.id));
+ assert.equal(
+ viewGame(g, 'ec').ecazPoisonIncome.reduce(
+ (sum, income) => sum + income.amount,
+ 0,
+ ),
+ 3,
+ );
+ g = applyAction(reload(g), 'ec', {
+ type: 'decision',
+ discard: [nativePoison.id],
+ });
+ assert.equal(player(g, 'ec').spice, 23);
+ assert.equal(
+ g.discard.filter((card) => card.id === nativePoison.id).length,
+ 1,
+ );
+ assert.equal(
+ viewGame(g, 'ec').ecazPoisonIncome.reduce(
+ (sum, income) => sum + income.count,
+ 0,
+ ),
+ 1,
+ );
+ inventory(
+ g,
+ treacheryDeck(['ix']).map((card) => card.id),
+ );
+});
+
+void test('Moritani delayed Chemistry cleanup preserves its actual weapon or defense role and pays only if discarded', () => {
+ for (const slot of ['weapon', 'defense'] as const)
+ for (const keepChemistry of [false, true]) {
+ let g = fixture(7, true);
+ g.advanced = true;
+ const moritani = newPlayer('mo', 'Moritani', 'moritani');
+ moritani.spice = 20;
+ g.players.push(moritani);
+ g.order.push('mo');
+ player(g, 'other').ally = 'mo';
+ moritani.ally = 'other';
+ g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
+ const weapon = hold(g, 'owner', (card) => card.kind === 'projectile');
+ const defense = hold(g, 'owner', (card) => card.kind === 'shield');
+ const chemistry = hold(g, 'other', (card) => card.kind === 'chemistry');
+ const complement = hold(
+ g,
+ 'other',
+ (card) => card.kind === (slot === 'weapon' ? 'snooper' : 'projectile'),
+ );
+ g = readyBattle(
+ g,
+ weapon.id,
+ defense.id,
+ slot === 'weapon' ? chemistry.id : complement.id,
+ slot === 'defense' ? chemistry.id : complement.id,
+ );
+ assert.equal(player(g, 'ec').spice, 20);
+ assert.ok(
+ player(g, 'other').hand.some((card) => card.id === chemistry.id),
+ );
+ g = applyAction(g, 'owner', { type: 'decision', discard: [] });
+ assert.equal(g.decision?.kind, 'moritaniRetention');
+ assert.equal(g.decision?.player, 'other');
+ assert.equal(
+ g.lastBattleContext!.cardRoles!.other[chemistry.id].battleSlot,
+ slot,
+ );
+ g = applyAction(reload(g), 'other', {
+ type: 'decision',
+ keep: keepChemistry ? chemistry.id : null,
+ });
+ assert.equal(
+ player(g, 'ec').spice,
+ slot === 'weapon' && !keepChemistry ? 23 : 20,
+ );
+ assert.equal(
+ g.discard.filter((card) => card.id === chemistry.id).length,
+ keepChemistry ? 0 : 1,
+ );
+ assert.equal(
+ player(g, 'other').hand.some((card) => card.id === chemistry.id),
+ keepChemistry,
+ );
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
+ inventory(
+ g,
+ treacheryDeck(['ix']).map((card) => card.id),
+ );
+ }
+});
+
+void test('winning used Poison Tooth is discarded after native Ecaz losses lower seven to six, while losing poison pays before those losses', () => {
+ let g = fixture(7, true);
+ const ecaz = player(g, 'ec');
+ const visitor = player(g, 'owner');
+ visitor.reserves = 18;
+ visitor.forces = {};
+ g.homeworlds!.custody!.visitors['homeworld:ecaz'] = {
+ owner: { normal: 2, elite: 0 },
+ };
+ g.active = 'ec';
+ g.order = ['ec', 'owner', 'other'];
+ const tooth = hold(g, 'ec', (card) => card.kind === 'poisonTooth');
+ const nativeDefense = hold(g, 'ec', (card) => card.kind === 'snooper');
+ const invadingPoison = hold(g, 'owner', (card) => card.kind === 'poison');
+ const invadingDefense = hold(g, 'owner', (card) => card.kind === 'snooper');
+ const nativeLeader = [...ecaz.leaders].sort(
+ (a, b) => b.strength - a.strength,
+ )[0];
+ const invadingLeader = [...visitor.leaders].sort(
+ (a, b) => a.strength - b.strength,
+ )[0];
+ g = applyAction(g, 'ec', {
+ type: 'chooseBattle',
+ territory: 'homeworld:ecaz',
+ target: 'owner',
+ });
+ for (let n = 0; g.battle?.preparation && n < 20; n++)
+ g = applyAction(g, g.battle.preparation.owner, {
+ type: 'declineBattlePower',
+ });
+ g = applyAction(g, 'ec', {
+ type: 'battlePlan',
+ dial: 1,
+ leader: nativeLeader.id,
+ weapon: tooth.id,
+ defense: nativeDefense.id,
+ });
+ g = applyAction(g, 'owner', {
+ type: 'battlePlan',
+ dial: 0,
+ leader: invadingLeader.id,
+ weapon: invadingPoison.id,
+ defense: invadingDefense.id,
+ });
+ assert.equal(g.decision?.kind, 'poisonTooth');
+ assert.equal(g.decision?.player, 'ec');
+ assert.equal(player(g, 'ec').reserves, 7);
+ assert.equal(player(g, 'ec').spice, 20);
+ g = applyAction(reload(g), 'ec', { type: 'decision', activate: true });
+ g = applyAction(reload(g), 'ec', { type: 'traitorCall', call: false });
+ assert.equal(g.lastBattleContext!.winner, 'ec');
+ assert.equal(player(g, 'ec').reserves, 6);
+ assert.equal(player(g, 'ec').tanks, 1);
+ assert.equal(g.discard.filter((card) => card.id === tooth.id).length, 1);
+ assert.equal(
+ g.discard.filter((card) => card.id === invadingPoison.id).length,
+ 1,
+ );
+ const income = viewGame(g, 'ec').ecazPoisonIncome;
+ assert.equal(
+ income.reduce((sum, entry) => sum + entry.amount, 0),
+ 3,
+ );
+ assert.equal(
+ income.reduce((sum, entry) => sum + entry.count, 0),
+ 1,
+ );
+ assert.equal(g.decision?.kind, 'battleCards');
+ assert.equal(g.decision?.player, 'ec');
+ const afterBattleSpice = player(g, 'ec').spice;
+ g = applyAction(reload(g), 'ec', { type: 'decision', discard: [] });
+ assert.equal(player(g, 'ec').spice, afterBattleSpice);
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
+ inventory(
+ g,
+ treacheryDeck(['ix']).map((card) => card.id),
+ );
+});
+
+void test('used winning Poison Tooth stays reserved through real Ixian substitution and is discarded exactly once afterward', () => {
+ for (const substitute of [false, true]) {
+ let g = fixture(7, true);
+ g.advanced = true;
+ const ix = newPlayer('owner', 'Ixians', 'ixians');
+ Object.assign(ix, {
+ spice: 20,
+ reserves: 12,
+ forces: { 'arrakeen:10': 8 },
+ elites: {
+ reserves: 5,
+ tanks: 0,
+ forces: { 'arrakeen:10': 2 },
+ revived: 0,
+ },
+ });
+ g.players[0] = ix;
+ g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
+ const tooth = hold(g, 'owner', (card) => card.kind === 'poisonTooth');
+ const shield = hold(g, 'owner', (card) => card.kind === 'shield');
+ const distrans = richeseCards().find((card) => card.effect === 'distrans')!;
+ g.deck.push(distrans);
+ hold(g, 'owner', (card) => card.id === distrans.id);
+ g = readyBattle(
+ g,
+ tooth.id,
+ shield.id,
+ undefined,
+ undefined,
+ 'battleLosses',
+ );
+ assert.equal(player(g, 'ec').spice, 20);
+ if (g.decision?.kind !== 'battleLosses')
+ throw new Error('Missing typed Ixian casualty choice');
+ const choice = g.decision.options.findIndex((loss) => loss.elite === 1);
+ assert.ok(choice >= 0);
+ assert.ok(player(g, 'owner').hand.some((card) => card.id === tooth.id));
+ assert.equal(g.discard.filter((card) => card.id === tooth.id).length, 0);
+ g = applyAction(reload(g), 'owner', { type: 'decision', choice });
+ assert.equal(g.decision?.kind, 'ixSubstitution');
+ assert.deepEqual(g.pendingWinnerDiscards!.cards, [tooth.id]);
+ for (const corrupt of [
+ (saved: Game) => {
+ saved.pendingWinnerDiscards = null;
+ },
+ (saved: Game) => {
+ delete saved.pendingWinnerDiscards;
+ },
+ (saved: Game) => {
+ delete saved.lastBattleContext!.winnerDiscards;
+ },
+ ]) {
+ const broken = reload(g);
+ corrupt(broken);
+ const before = reload(broken);
+ assert.throws(() => viewGame(broken, 'owner'), /winner|winning|discard/i);
+ assert.throws(
+ () => normalizeAutomaticGame(broken),
+ /winner|winning|discard/i,
+ );
+ assert.throws(
+ () => applyAction(broken, 'owner', { type: 'decision', decline: true }),
+ /winner|winning|discard/i,
+ );
+ assert.deepEqual(broken, before);
+ }
+ assert.ok(player(g, 'owner').hand.some((card) => card.id === tooth.id));
+ assert.equal(player(g, 'ec').spice, 20);
+ assert.deepEqual(g.pendingWinnerDiscards!.optional, [shield.id]);
+ for (const reserved of [tooth.id, shield.id]) {
+ assert.ok(
+ !cashInCards(g, player(g, 'owner')).some(
+ (card) => card.id === reserved,
+ ),
+ 'Both mandatory and optional winner cards remain reserved through casualties.',
+ );
+ const before = reload(g);
+ assert.throws(
+ () =>
+ applyAction(g, 'owner', {
+ type: 'card',
+ card: distrans.id,
+ target: 'other',
+ give: reserved,
+ }),
+ /committed|discard|reserved/i,
+ );
+ assert.deepEqual(g, before);
+ }
+ g = substitute
+ ? applyAction(reload(g), 'owner', {
+ type: 'decision',
+ sources: { 'arrakeen:10': 1 },
+ recover: { 'arrakeen:10': 1 },
+ })
+ : applyAction(reload(g), 'owner', { type: 'decision', decline: true });
+ assert.equal(g.pendingWinnerDiscards, null);
+ assert.equal(g.pendingIxSubstitution, null);
+ assert.equal(g.discard.filter((card) => card.id === tooth.id).length, 1);
+ assert.equal(player(g, 'ec').spice, 23);
+ assert.equal(g.decision?.kind, 'battleCards');
+ assert.ok(!player(g, 'owner').hand.some((card) => card.id === tooth.id));
+ assert.equal(player(g, 'owner').elites!.tanks, substitute ? 0 : 1);
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
+ g = applyAction(g, 'owner', { type: 'decision', discard: [] });
+ assert.equal(player(g, 'ec').spice, 23);
+ inventory(g, [
+ ...treacheryDeck(['ix']).map((card) => card.id),
+ distrans.id,
+ ]);
+ }
+});
diff --git a/tests/ecaz-poison-income.test.ts b/tests/ecaz-poison-income.test.ts
new file mode 100644
index 00000000..e56198e1
--- /dev/null
+++ b/tests/ecaz-poison-income.test.ts
@@ -0,0 +1,226 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { baseDeck, ixDeck, type Card } from '../game/cards';
+import { newPlayer } from '../game/engine';
+import { createHomeworldCustody } from '../game/homeworld-custody';
+import { homeworldContext } from '../game/homeworld-game';
+import { RICHESE_CARD_DEFINITIONS } from '../game/richese-cards';
+import { resolveBattleWeapons } from '../game/effective-weapons';
+import {
+ EcazPoisonIncomeError,
+ quoteEcazPoisonIncome,
+ type EcazPoisonDiscard,
+} from '../game/ecaz-poison-income';
+
+function fixture(reserves = 7, advanced = false) {
+ const players = [
+ newPlayer('ecaz', 'Ecaz', 'ecaz'),
+ newPlayer('emperor', 'Emperor', 'emperor'),
+ ];
+ players[0].reserves = reserves;
+ const context = { advanced, players };
+ return {
+ ...context,
+ homeworlds: { custody: createHomeworldCustody(homeworldContext(context)) },
+ };
+}
+const ordinary = [...baseDeck(), ...ixDeck()];
+const find = (kind: Card['kind']) =>
+ ordinary.find((card) => card.kind === kind)!;
+const poison = find('poison');
+
+void test('every printed ordinary poison, Poison Blade and Tooth pays once per physical discard', () => {
+ const cards = ordinary.filter((card) =>
+ ['poison', 'poisonBlade', 'poisonTooth'].includes(card.kind),
+ );
+ assert.equal(cards.length, 7);
+ for (const advanced of [false, true]) {
+ const context = fixture(7, advanced);
+ assert.deepEqual(
+ quoteEcazPoisonIncome(
+ context,
+ cards.map((card) => ({ card })),
+ ),
+ {
+ player: 'ecaz',
+ amount: 21,
+ count: 7,
+ },
+ );
+ for (const card of cards)
+ assert.deepEqual(
+ quoteEcazPoisonIncome(context, [{ card, battleSlot: 'weapon' }]),
+ {
+ player: 'ecaz',
+ amount: 3,
+ count: 1,
+ },
+ );
+ }
+});
+
+void test('Chemistry earns only for its actual poison weapon role, not its potential role', () => {
+ const card = find('chemistry');
+ for (const battleSlot of [undefined, 'defense', 'leader'] as const)
+ assert.equal(
+ quoteEcazPoisonIncome(fixture(), [{ card, battleSlot }]),
+ null,
+ );
+ assert.deepEqual(
+ quoteEcazPoisonIncome(fixture(), [{ card, battleSlot: 'weapon' }]),
+ {
+ player: 'ecaz',
+ amount: 3,
+ count: 1,
+ },
+ );
+});
+
+void test('defenses, projectiles, laser, Artillery and named Richese specials pay no poison income', () => {
+ const cards = [
+ ...ordinary.filter(
+ (card) =>
+ !['poison', 'poisonBlade', 'poisonTooth', 'chemistry'].includes(
+ card.kind,
+ ),
+ ),
+ ...RICHESE_CARD_DEFINITIONS.map((definition) => definition.card),
+ ];
+ for (const card of cards)
+ assert.equal(quoteEcazPoisonIncome(fixture(), [{ card }]), null, card.name);
+ const residual = RICHESE_CARD_DEFINITIONS.find(
+ (d) => d.card.effect === 'residualPoison',
+ )!.card;
+ assert.equal(
+ quoteEcazPoisonIncome(fixture(), [
+ { card: { ...residual, kind: 'poison' } },
+ ]),
+ null,
+ );
+});
+
+void test('the exact current native threshold controls receipt in both game modes', () => {
+ for (const advanced of [false, true])
+ for (const reserves of [0, 6, 7, 20])
+ assert.equal(
+ quoteEcazPoisonIncome(fixture(reserves, advanced), [{ card: poison }])
+ ?.amount ?? 0,
+ reserves >= 7 ? 3 : 0,
+ );
+ for (const homeworlds of [undefined, null, { custody: null }])
+ assert.equal(
+ quoteEcazPoisonIncome({ ...fixture(), homeworlds }, [{ card: poison }]),
+ null,
+ );
+});
+
+void test('foreign presence supplies no native population and receives no award', () => {
+ const context = fixture(6);
+ context.players[1].reserves -= 3;
+ context.homeworlds.custody.visitors['homeworld:ecaz'] = {
+ emperor: { normal: 3, elite: 0 },
+ };
+ assert.equal(quoteEcazPoisonIncome(context, [{ card: poison }]), null);
+});
+
+void test('quotation reads no hands, spice, battle plans or card-owner identity and survives JSON', () => {
+ const context = fixture();
+ const restored = JSON.parse(JSON.stringify(context)) as typeof context;
+ const before = JSON.stringify(restored);
+ for (const player of context.players) {
+ Object.defineProperty(player, 'hand', {
+ get: () => {
+ throw new Error('private hand read');
+ },
+ });
+ Object.defineProperty(player, 'spice', {
+ get: () => {
+ throw new Error('private spice read');
+ },
+ });
+ }
+ const entries = Object.freeze([
+ Object.freeze({ card: Object.freeze({ ...poison }) }),
+ ]);
+ assert.deepEqual(
+ quoteEcazPoisonIncome(context, entries),
+ quoteEcazPoisonIncome(restored, entries),
+ );
+ assert.equal(JSON.stringify(restored), before);
+});
+
+void test('duplicate physical discards and malformed slots reject without mutation', () => {
+ const context = fixture();
+ const before = JSON.stringify(context);
+ for (const entries of [
+ [{ card: poison }, { card: poison }],
+ [{ card: poison, battleSlot: 'possible-weapon' }],
+ [{ card: { ...poison, id: '' } }],
+ ])
+ assert.throws(
+ () => quoteEcazPoisonIncome(context, entries as EcazPoisonDiscard[]),
+ EcazPoisonIncomeError,
+ );
+ assert.equal(JSON.stringify(context), before);
+ assert.equal(quoteEcazPoisonIncome(context, []), null);
+});
+
+void test('Mirror disposal uses the retained actual copied weapon, not a guessed physical kind', () => {
+ const card = RICHESE_CARD_DEFINITIONS.find(
+ (d) => d.card.effect === 'mirrorWeapon',
+ )!.card;
+ assert.equal(quoteEcazPoisonIncome(fixture(), [{ card }]), null);
+ assert.throws(
+ () => quoteEcazPoisonIncome(fixture(), [{ card, battleSlot: 'weapon' }]),
+ /retained validated/,
+ );
+ assert.equal(
+ quoteEcazPoisonIncome(fixture(6), [{ card, battleSlot: 'weapon' }]),
+ null,
+ );
+ for (const kind of [
+ 'poison',
+ 'poisonBlade',
+ 'poisonTooth',
+ 'chemistry',
+ 'lasgun',
+ 'artillery',
+ 'projectile',
+ ] as const) {
+ const resolved = resolveBattleWeapons({
+ attacker: { weapon: card },
+ defender: { weapon: find(kind), defense: find('snooper') },
+ });
+ assert.equal(resolved.error, null);
+ assert.equal(
+ quoteEcazPoisonIncome(fixture(), [
+ { card, battleSlot: 'weapon', effectiveWeapon: resolved.attacker },
+ ])?.amount ?? 0,
+ ['poison', 'poisonBlade', 'poisonTooth', 'chemistry'].includes(kind)
+ ? 3
+ : 0,
+ kind,
+ );
+ }
+ const empty = resolveBattleWeapons({
+ attacker: { weapon: card },
+ defender: {},
+ }).attacker;
+ assert.equal(
+ quoteEcazPoisonIncome(fixture(), [
+ { card, battleSlot: 'weapon', effectiveWeapon: empty },
+ ]),
+ null,
+ );
+ assert.throws(
+ () =>
+ quoteEcazPoisonIncome(fixture(), [
+ {
+ card,
+ battleSlot: 'weapon',
+ effectiveWeapon: { ...empty, physicalId: 'another' },
+ },
+ ]),
+ EcazPoisonIncomeError,
+ );
+});
diff --git a/tests/elite-free-support.test.ts b/tests/elite-free-support.test.ts
new file mode 100644
index 00000000..0fd20ebc
--- /dev/null
+++ b/tests/elite-free-support.test.ts
@@ -0,0 +1,226 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { casualtyOptions, type CombatForces } from '../game/combat';
+import {
+ stoneBurnerComparison,
+ stoneBurnerPlanBlock,
+ stoneBurnerCompulsionBlock,
+} from '../game/stone-burner';
+
+const mixed: CombatForces = {
+ normal: 2,
+ elite: 1,
+ eliteStrength: 2,
+ freeSupport: false,
+ eliteFreeSupport: true,
+};
+const ordinary = (normal: number): CombatForces => ({
+ normal,
+ elite: 0,
+ eliteStrength: 1,
+ freeSupport: true,
+});
+
+void test('free special counters retain full strength while normal counters need their own support', () => {
+ assert.deepEqual(casualtyOptions(mixed, 2, 0), [
+ { normal: 0, elite: 1, paidNormal: 0, paidElite: 0 },
+ ]);
+ assert.deepEqual(casualtyOptions(mixed, 3, 0), [
+ { normal: 2, elite: 1, paidNormal: 0, paidElite: 0 },
+ ]);
+ assert.deepEqual(casualtyOptions(mixed, 3, 1), [
+ { normal: 1, elite: 1, paidNormal: 1, paidElite: 0 },
+ ]);
+ assert.deepEqual(casualtyOptions(mixed, 4, 2), [
+ { normal: 2, elite: 1, paidNormal: 2, paidElite: 0 },
+ ]);
+ assert.deepEqual(casualtyOptions(mixed, 4, 0), []);
+ assert.deepEqual(casualtyOptions(mixed, 2, 1), []);
+ assert.deepEqual(casualtyOptions(mixed, 4, 3), []);
+});
+
+void test('all-special armies never pay support and the strength-one exception remains strength one', () => {
+ for (const eliteStrength of [1, 2] as const) {
+ const forces = { ...mixed, normal: 0, elite: 3, eliteStrength };
+ assert.deepEqual(casualtyOptions(forces, eliteStrength * 3, 0), [
+ { normal: 0, elite: 3, paidNormal: 0, paidElite: 0 },
+ ]);
+ assert.deepEqual(casualtyOptions(forces, eliteStrength * 3, 1), []);
+ assert.deepEqual(casualtyOptions(forces, 0.5, 0), []);
+ }
+ assert.deepEqual(casualtyOptions({ ...mixed, eliteStrength: 1 }, 1, 0), [
+ { normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
+ { normal: 0, elite: 1, paidNormal: 0, paidElite: 0 },
+ ]);
+});
+
+void test('fixed-half normals cannot receive support and whole-army free support remains authoritative', () => {
+ for (const freeSupport of [false, true]) {
+ const forces = { ...mixed, normalFixedHalf: true, freeSupport };
+ assert.deepEqual(casualtyOptions(forces, 3, 0), [
+ { normal: 2, elite: 1, paidNormal: 0, paidElite: 0 },
+ ]);
+ assert.deepEqual(casualtyOptions(forces, 3, 1), []);
+ }
+ assert.deepEqual(casualtyOptions({ ...mixed, freeSupport: true }, 4, 0), [
+ { normal: 2, elite: 1, paidNormal: 0, paidElite: 0 },
+ ]);
+ assert.deepEqual(casualtyOptions({ ...mixed, freeSupport: true }, 4, 1), []);
+});
+
+void test('missing or false flag preserves legacy commitments, while a frozen JSON snapshot preserves accepted free support', () => {
+ const legacy = { ...mixed };
+ delete legacy.eliteFreeSupport;
+ for (let halfDial = 0; halfDial <= 8; halfDial++)
+ for (let support = 0; support <= 3; support++)
+ assert.deepEqual(
+ casualtyOptions(legacy, halfDial / 2, support),
+ casualtyOptions(
+ { ...legacy, eliteFreeSupport: false },
+ halfDial / 2,
+ support,
+ ),
+ );
+ const snapshot: CombatForces = Object.freeze(
+ JSON.parse(JSON.stringify(mixed)),
+ );
+ const before = structuredClone(snapshot);
+ assert.deepEqual(casualtyOptions(snapshot, 2, 0), [
+ { normal: 0, elite: 1, paidNormal: 0, paidElite: 0 },
+ ]);
+ assert.notDeepEqual(
+ casualtyOptions(legacy, 2, 0),
+ casualtyOptions(snapshot, 2, 0),
+ );
+ assert.deepEqual(snapshot, before);
+});
+
+void test('malformed restored flags and force counts fail closed before enumeration', () => {
+ const invalid = [
+ null,
+ [],
+ ...[
+ { eliteFreeSupport: 'true' },
+ { eliteFreeSupport: 1 },
+ { eliteFreeSupport: null },
+ { normal: -1 },
+ { normal: Infinity },
+ { normal: Number.MAX_SAFE_INTEGER },
+ { normal: 20 },
+ { elite: 0.5 },
+ { eliteStrength: 3 },
+ { freeSupport: undefined },
+ { normalFixedHalf: 1 },
+ ].map((patch) => ({ ...mixed, ...patch })),
+ ];
+ for (const value of invalid) {
+ const forces = value as CombatForces;
+ const before = structuredClone(value);
+ assert.deepEqual(casualtyOptions(forces, 0, 0), []);
+ assert.deepEqual(
+ stoneBurnerComparison(forces, 0, 0, ordinary(1), 0, 0).attacker,
+ [],
+ );
+ assert.match(
+ stoneBurnerPlanBlock(ordinary(1), 0, 0, forces, 'attacker')!,
+ /physical force pools/,
+ );
+ assert.deepEqual(value, before);
+ }
+});
+
+void test('Stone Burner compares undialed physical counters under the accepted per-type support rule', () => {
+ assert.deepEqual(stoneBurnerComparison(mixed, 3, 1, ordinary(2), 1, 0), {
+ winner: 'attacker',
+ attacker: [1],
+ defender: [1],
+ });
+ assert.deepEqual(stoneBurnerComparison(mixed, 3, 0, ordinary(2), 1, 0), {
+ winner: 'defender',
+ attacker: [0],
+ defender: [1],
+ });
+ assert.deepEqual(stoneBurnerComparison(mixed, 2, 1, ordinary(2), 1, 0), {
+ winner: null,
+ attacker: [],
+ defender: [1],
+ });
+ assert.equal(
+ stoneBurnerCompulsionBlock(
+ { ...mixed, normal: 0 },
+ ordinary(2),
+ 'attacker',
+ ),
+ null,
+ );
+});
+
+void test('Stone Burner preflight cache distinguishes free special support and matches all legal opposing plans', () => {
+ let distinct = false;
+ for (const normalFixedHalf of [false, true])
+ for (const eliteStrength of [1, 2] as const)
+ for (let normal = 0; normal <= 4; normal++) {
+ const own = ordinary(normal);
+ for (let dial = 0; dial <= normal; dial++)
+ for (const side of ['attacker', 'defender'] as const) {
+ const results: boolean[] = [];
+ for (const eliteFreeSupport of [false, true, false, true]) {
+ const opponent = Object.freeze({
+ ...mixed,
+ normal: 3,
+ elite: 2,
+ normalFixedHalf,
+ eliteStrength,
+ eliteFreeSupport,
+ });
+ let ambiguous = false;
+ for (let halfDial = 0; halfDial <= 14; halfDial++)
+ for (let support = 0; support <= 5; support++) {
+ if (!casualtyOptions(opponent, halfDial / 2, support).length)
+ continue;
+ const result =
+ side === 'attacker'
+ ? stoneBurnerComparison(
+ own,
+ dial,
+ 0,
+ opponent,
+ halfDial / 2,
+ support,
+ )
+ : stoneBurnerComparison(
+ opponent,
+ halfDial / 2,
+ support,
+ own,
+ dial,
+ 0,
+ );
+ if (result.winner === null) ambiguous = true;
+ }
+ const blocked =
+ stoneBurnerPlanBlock(
+ own,
+ dial,
+ 0,
+ JSON.parse(JSON.stringify(opponent)),
+ side,
+ ) !== null;
+ assert.equal(
+ blocked,
+ ambiguous,
+ JSON.stringify({ opponent, normal, dial, side }),
+ );
+ results.push(blocked);
+ }
+ assert.equal(results[0], results[2]);
+ assert.equal(results[1], results[3]);
+ distinct ||= results[0] !== results[1];
+ }
+ }
+ assert.equal(
+ distinct,
+ true,
+ 'At least one legal-plan frontier must differ with elite free support.',
+ );
+});
diff --git a/tests/fixture-caladan-ambassador.ts b/tests/fixture-caladan-ambassador.ts
new file mode 100644
index 00000000..c3e2d6ac
--- /dev/null
+++ b/tests/fixture-caladan-ambassador.ts
@@ -0,0 +1,84 @@
+import { newPlayer } from '../game/engine';
+import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
+import {
+ caladanVictoryFixture,
+ holdVictoryCard,
+ victoryInventory as inventory,
+ 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);
+ }
+ g.expansions.push('ecaz');
+ g.order = g.players.map((player) => player.id);
+ for (const player of [p(g, 'a'), p(g, '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;
+ const state = createAmbassadors(() => 0);
+ const guild = state.tokens.find((token) => token.effect === 'guild')!;
+ const fremen = state.tokens.find((token) => token.effect === 'fremen')!;
+ state.cohort = [
+ guild.id,
+ fremen.id,
+ ...state.tokens
+ .filter(
+ (token) =>
+ token.effect !== 'ecaz' &&
+ token.id !== guild.id &&
+ token.id !== fremen.id,
+ )
+ .slice(0, 3)
+ .map((token) => token.id),
+ ];
+ for (const token of state.tokens) {
+ token.zone =
+ token.effect === 'ecaz' || state.cohort.includes(token.id)
+ ? 'supply'
+ : 'pool';
+ token.location = null;
+ }
+ g.ecazAmbassadors = state;
+ for (const [token, destination] of [
+ [guild, 'arrakeen'],
+ [fremen, 'sietch_tabr'],
+ ] as const)
+ g.ecazAmbassadors = placeAmbassador(g.ecazAmbassadors, token.id, {
+ turn: 1,
+ availableSpice: 20,
+ destination: {
+ id: destination,
+ stronghold: true,
+ inStorm: false,
+ allowed: true,
+ },
+ }).state;
+ holdVictoryCard(g, 'ec', 'karama');
+ inventory(g);
+ return g;
+}
diff --git a/tests/fixture-caladan-victory.ts b/tests/fixture-caladan-victory.ts
new file mode 100644
index 00000000..800bd8f7
--- /dev/null
+++ b/tests/fixture-caladan-victory.ts
@@ -0,0 +1,259 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeHomeworldGameForAudit,
+ joinGame,
+ newPlayer,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import { baseDeck, ixDeck } from '../game/cards';
+import {
+ homeworldContext,
+ homeworldGameIntegrity,
+} from '../game/homeworld-game';
+import { quoteHomeworldCustody } from '../game/homeworld-custody';
+
+export const victoryPlayer = (g: Game, id: string) =>
+ g.players.find((player) => player.id === id)!;
+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 } = {},
+): Game {
+ let g = createGame(
+ 'CALADANVICTORY',
+ newPlayer('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 });
+ for (const player of g.players)
+ g = applyAction(g, player.id, { type: 'ready' });
+ g = initializeHomeworldGameForAudit(g);
+ for (let n = 0; g.status === 'setup' && n < 100; n++) {
+ let next: Game | undefined;
+ for (const player of g.players) {
+ const view = viewGame(g, player.id);
+ view.players.find((seat) => seat.id === player.id)!.bot = 'Easy';
+ const command = botActions(view)[0];
+ if (command) {
+ next = applyAction(g, player.id, command);
+ break;
+ }
+ }
+ assert.ok(next, 'Genuine Homeworld setup must supply an action.');
+ g = next;
+ }
+ assert.equal(g.status, 'playing');
+ for (const player of g.players) {
+ g.deck.push(...player.hand.splice(0));
+ player.spice = 20;
+ }
+ Object.assign(g, {
+ phase: 6,
+ turn: 2,
+ storm: 18,
+ phaseOpening: null,
+ response: null,
+ decision: null,
+ active: '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);
+ victoryInventory(g);
+ return g;
+}
+
+export function positionVictoryArmy(
+ g: Game,
+ id: string,
+ native: number,
+ battleForces: number,
+ tanks = 0,
+) {
+ const player = victoryPlayer(g, id);
+ assert.equal(player.elites, undefined);
+ const rest = 20 - native - battleForces - tanks;
+ assert.ok(rest >= 0);
+ Object.assign(player, {
+ reserves: native,
+ tanks,
+ forces: {
+ ...(battleForces ? { 'hagga_basin:12': battleForces } : {}),
+ ...(rest ? { 'polar_sink:0': rest } : {}),
+ },
+ });
+ homeworldGameIntegrity(g);
+}
+
+/** A scenario invasion conserves exact native/visitor custody. Actual shipment
+ * availability and its phase-five reactions are outside these battle tests. */
+export function positionVictoryInvader(
+ g: Game,
+ id: string,
+ destination: string,
+ amount: number,
+) {
+ const quote = quoteHomeworldCustody(
+ homeworldContext(g),
+ g.homeworlds!.custody!,
+ [
+ {
+ homeworld: `homeworld:${victoryPlayer(g, id).faction}`,
+ player: id,
+ withdraw: { normal: amount, elite: 0 },
+ deposit: { normal: 0, elite: 0 },
+ },
+ {
+ homeworld: destination,
+ player: id,
+ withdraw: { normal: 0, elite: 0 },
+ deposit: { normal: amount, elite: 0 },
+ },
+ ],
+ );
+ g.homeworlds!.custody = quote.state;
+ for (const player of quote.players)
+ victoryPlayer(g, player.id).reserves = player.reserves;
+ victoryInventory(g);
+}
+
+export function holdVictoryCard(g: Game, player: string, kind: string): string {
+ const index = g.deck.findIndex(
+ (card) => card.kind === kind || card.effect === kind,
+ );
+ assert.ok(index >= 0, `An actual ${kind} must be available.`);
+ const [card] = g.deck.splice(index, 1);
+ victoryPlayer(g, player).hand.push(card);
+ return card.id;
+}
+
+export function prepareVictoryBattle(
+ state: Game,
+ territory = 'hagga_basin',
+ attacker = 'a',
+ defender = 'g',
+): Game {
+ let g = applyAction(state, attacker, {
+ type: 'chooseBattle',
+ territory,
+ target: defender,
+ });
+ for (let n = 0; n < 40; n++) {
+ if (g.response) {
+ const responder = g.players.find(
+ (player) => !g.response!.passed.includes(player.id),
+ )!;
+ g = applyAction(g, responder.id, { type: 'passResponse' });
+ } else if (g.battle?.preparation) {
+ g = applyAction(g, g.battle.preparation.owner, {
+ type: 'declineBattlePower',
+ });
+ } else if (g.decision) {
+ assert.equal(g.decision.kind, 'fullPlanOffer');
+ g = applyAction(g, g.decision.player, {
+ type: 'decision',
+ decline: true,
+ });
+ } else break;
+ }
+ assert.ok(g.battle && !g.battle.preparation && !g.decision && !g.response);
+ return g;
+}
+
+export function commitVictoryPlans(
+ state: Game,
+ options: {
+ a?: Partial;
+ g?: Partial;
+ } = {},
+): Game {
+ let g = state;
+ const atreides = g.players.find((player) => player.faction === 'atreides')!;
+ const guildSeat = g.players.find((player) => player.faction === 'guild')!;
+ const a = atreides.leaders.reduce((best, leader) =>
+ leader.strength > best.strength ? leader : best,
+ );
+ const guild = guildSeat.leaders.reduce((best, leader) =>
+ leader.strength < best.strength ? leader : best,
+ );
+ g = applyAction(g, atreides.id, {
+ type: 'battlePlan',
+ leader: a.id,
+ dial: 1,
+ support: g.advanced ? 1 : 0,
+ ...options.a,
+ });
+ g = applyAction(g, guildSeat.id, {
+ type: 'battlePlan',
+ leader: guild.id,
+ dial: 0,
+ support: 0,
+ ...options.g,
+ });
+ return g;
+}
+
+export function finishVictoryCalls(
+ state: Game,
+ calls: Record = {},
+): Game {
+ let g = state;
+ for (let n = 0; g.battle && n < 10; n++) {
+ const view = viewGame(g, g.battle!.attacker).battle!;
+ const voter = view.traitorVoters.find(
+ (id) => g.battle!.traitorCalls[id] === undefined,
+ );
+ assert.ok(
+ voter,
+ 'The revealed battle must have an outstanding real traitor voter.',
+ );
+ g = applyAction(g, voter, {
+ type: 'traitorCall',
+ call: calls[voter] ?? false,
+ });
+ }
+ assert.equal(g.battle, null);
+ return g;
+}
+
+export function victoryInventory(g: Game) {
+ homeworldGameIntegrity(g);
+ for (const player of g.players) {
+ const visitors = Object.values(g.homeworlds!.custody!.visitors).reduce(
+ (sum, world) =>
+ sum + (world[player.id]?.normal ?? 0) + (world[player.id]?.elite ?? 0),
+ 0,
+ );
+ assert.equal(
+ player.reserves +
+ player.tanks +
+ Object.values(player.forces).reduce((a, b) => a + b, 0) +
+ visitors,
+ 20,
+ );
+ assert.ok(player.spice >= 0);
+ }
+ const cards = [
+ ...g.deck,
+ ...g.discard,
+ ...g.players.flatMap((player) => player.hand),
+ ];
+ assert.deepEqual(
+ cards.map((card) => card.id).sort(),
+ [...baseDeck(), ...(g.expansions.includes('ix') ? ixDeck() : [])]
+ .map((card) => card.id)
+ .sort(),
+ );
+}
diff --git a/tests/fixture-homeworld-revival-ambassador.ts b/tests/fixture-homeworld-revival-ambassador.ts
new file mode 100644
index 00000000..ff69e9bf
--- /dev/null
+++ b/tests/fixture-homeworld-revival-ambassador.ts
@@ -0,0 +1,82 @@
+import { newPlayer } from '../game/engine';
+import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
+import {
+ homeworldRevivalFixture,
+ enterHomeworldRevival,
+ holdRevivalCard,
+ revivalPlayer as p,
+ revivalInventory as inventory,
+} from './fixture-homeworld-revival';
+
+/** Genuine native revival fixture plus an explicit Ecaz/BG faction seam: 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);
+ }
+ 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;
+ const state = createAmbassadors(() => 0);
+ const first = state.tokens.find((token) => token.effect === 'guild')!;
+ const second = state.tokens.find((token) => token.effect === 'fremen')!;
+ state.cohort = [
+ first.id,
+ second.id,
+ ...state.tokens
+ .filter(
+ (token) =>
+ token.effect !== 'ecaz' &&
+ token.id !== first.id &&
+ token.id !== second.id,
+ )
+ .slice(0, 3)
+ .map((token) => token.id),
+ ];
+ for (const token of state.tokens) {
+ token.zone =
+ token.effect === 'ecaz' || state.cohort.includes(token.id)
+ ? 'supply'
+ : 'pool';
+ token.location = null;
+ }
+ g.ecazAmbassadors = state;
+ for (const [token, destination] of [
+ [first, 'arrakeen'],
+ [second, 'sietch_tabr'],
+ ] as const)
+ g.ecazAmbassadors = placeAmbassador(g.ecazAmbassadors, token.id, {
+ turn: 1,
+ availableSpice: 20,
+ destination: {
+ id: destination,
+ stronghold: true,
+ inStorm: false,
+ allowed: true,
+ },
+ }).state;
+ inventory(g);
+ const entered = enterHomeworldRevival(g);
+ holdRevivalCard(entered, 'ec', 'karama');
+ return entered;
+}
diff --git a/tests/fixture-homeworld-revival.ts b/tests/fixture-homeworld-revival.ts
new file mode 100644
index 00000000..a6db3785
--- /dev/null
+++ b/tests/fixture-homeworld-revival.ts
@@ -0,0 +1,196 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeHomeworldGameForAudit,
+ joinGame,
+ newPlayer,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import { baseDeck, ixDeck } from '../game/cards';
+import { homeworldGameIntegrity } from '../game/homeworld-game';
+
+export const revivalPlayer = (g: Game, id: string) =>
+ g.players.find((player) => player.id === id)!;
+export const revivalReload = (g: Game): Game => JSON.parse(JSON.stringify(g));
+
+/** Real Homeworld audit initialization and setup actions, including Tleilaxu
+ * when requested. Only the later Charity phase and physical positions are
+ * staged; no expansion faction substitution or invented revival receipt. */
+export function homeworldRevivalFixture(
+ options: {
+ advanced?: boolean;
+ tleilaxu?: boolean;
+ } = {},
+): Game {
+ let g = createGame(
+ 'HOMEREVIVAL',
+ newPlayer('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 });
+ for (const player of g.players)
+ g = applyAction(g, player.id, { type: 'ready' });
+ g = initializeHomeworldGameForAudit(g);
+ for (let n = 0; g.status === 'setup' && n < 100; n++) {
+ let next: Game | undefined;
+ for (const player of g.players) {
+ const view = viewGame(g, player.id);
+ view.players.find((seat) => seat.id === player.id)!.bot = 'Easy';
+ const action = botActions(view)[0];
+ if (action) {
+ next = applyAction(g, player.id, action);
+ break;
+ }
+ }
+ assert.ok(next, 'Genuine Homeworld setup must make progress.');
+ g = next;
+ }
+ assert.equal(g.status, 'playing');
+ for (const player of g.players) {
+ g.deck.push(...player.hand.splice(0));
+ player.spice = 20;
+ }
+ Object.assign(g, {
+ phase: 2,
+ turn: 2,
+ storm: 18,
+ active: null,
+ ready: [],
+ order: g.players.map((player) => player.id),
+ phaseOpening: null,
+ response: null,
+ decision: null,
+ });
+ positionRevivalForces(g, 'f', { native: 3, tanks: 5, eliteTanks: 2 });
+ if (options.tleilaxu) positionRevivalForces(g, 't', { native: 9, tanks: 5 });
+ revivalInventory(g);
+ return g;
+}
+
+/** Place only Fremen/Tleilaxu counters, retaining all twenty physical IDs and
+ * the three starred Fremen. Emperor's original Salusa allocation stays intact. */
+export function positionRevivalForces(
+ g: Game,
+ id: string,
+ counts: {
+ native: number;
+ tanks: number;
+ eliteTanks?: number;
+ },
+) {
+ const player = revivalPlayer(g, id);
+ assert.ok(['fremen', 'tleilaxu'].includes(player.faction));
+ const board = 20 - counts.native - counts.tanks;
+ assert.ok(board >= 0);
+ player.reserves = counts.native;
+ player.tanks = counts.tanks;
+ player.forces = board ? { 'arrakeen:10': board } : {};
+ if (player.faction === 'fremen') {
+ const eliteTanks = counts.eliteTanks ?? 0;
+ const native = Math.min(counts.native, 3 - eliteTanks);
+ const eliteBoard = 3 - eliteTanks - native;
+ assert.ok(eliteTanks <= counts.tanks && eliteBoard <= board);
+ player.elites = {
+ reserves: native,
+ tanks: eliteTanks,
+ forces: eliteBoard ? { 'arrakeen:10': eliteBoard } : {},
+ revived: 0,
+ };
+ }
+ homeworldGameIntegrity(g);
+}
+
+/** Enter through actual Charity readiness, ordinary auction passes and Amal. */
+export function enterHomeworldRevival(state: Game): Game {
+ let g = state;
+ for (const player of g.players)
+ g = applyAction(g, player.id, { type: 'ready' });
+ for (let n = 0; (g.phase === 3 || g.phaseOpening) && n < 100; n++) {
+ if (g.phaseOpening) {
+ const id = g.players.find(
+ (player) => !g.phaseOpening!.passed.includes(player.id),
+ )!.id;
+ g = applyAction(g, id, { type: 'ready' });
+ } else {
+ assert.ok(g.auction?.active);
+ g = applyAction(g, g.auction.active, { type: 'passBid' });
+ }
+ }
+ assert.equal(g.phase, 4);
+ assert.equal(g.phaseOpening ?? null, null);
+ revivalInventory(g);
+ return g;
+}
+
+export function holdRevivalCard(
+ g: Game,
+ owner: string,
+ effect: string,
+): string {
+ const index = g.deck.findIndex((card) => card.effect === effect);
+ assert.ok(index >= 0, `${effect} must exist in actual deck custody.`);
+ const [card] = g.deck.splice(index, 1);
+ revivalPlayer(g, owner).hand.push(card);
+ return card.id;
+}
+
+export function revivalInventory(g: Game) {
+ homeworldGameIntegrity(g);
+ const sum = (values: Record) =>
+ Object.values(values).reduce((a, b) => a + b, 0);
+ for (const player of g.players) {
+ const visitors = Object.values(g.homeworlds!.custody!.visitors).map(
+ (groups) => groups[player.id] ?? { normal: 0, elite: 0 },
+ );
+ assert.equal(
+ player.reserves +
+ player.tanks +
+ sum(player.forces) +
+ visitors.reduce(
+ (total, group) => total + group.normal + group.elite,
+ 0,
+ ),
+ 20,
+ );
+ if (player.elites)
+ assert.equal(
+ player.elites.reserves +
+ player.elites.tanks +
+ sum(player.elites.forces) +
+ visitors.reduce((total, group) => total + group.elite, 0),
+ player.faction === 'fremen' ? 3 : 5,
+ );
+ assert.ok(player.spice >= 0);
+ }
+ const cards = [
+ ...g.deck,
+ ...g.discard,
+ ...g.players.flatMap((player) => player.hand),
+ ...(g.auction?.cards.slice(g.auction.index) ?? []),
+ ];
+ assert.deepEqual(
+ cards.map((card) => card.id).sort(),
+ [...baseDeck(), ...(g.expansions.includes('ix') ? ixDeck() : [])]
+ .map((card) => card.id)
+ .sort(),
+ );
+}
+
+export function rejectRevivalAction(
+ g: Game,
+ actor: string,
+ action: Action,
+ error?: RegExp,
+) {
+ const before = structuredClone(g);
+ if (error) assert.throws(() => applyAction(g, actor, action), error);
+ else assert.throws(() => applyAction(g, actor, action));
+ assert.deepEqual(g, before);
+}
diff --git a/tests/guild-homeworld-shipment-controls.test.ts b/tests/guild-homeworld-shipment-controls.test.ts
new file mode 100644
index 00000000..838d6016
--- /dev/null
+++ b/tests/guild-homeworld-shipment-controls.test.ts
@@ -0,0 +1,293 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { GuildHomeworldShipment } from '../components/guild-homeworld-shipment';
+import {
+ guildHomeworldShipmentChoice,
+ guildHomeworldShipmentActions,
+} from '../game/guild-homeworld-shipment-options';
+import {
+ applyAction,
+ createGame,
+ initializeHomeworldGameForAudit,
+ joinGame,
+ newPlayer,
+ viewGame,
+ type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import type { HomeworldShipmentSources } from '../game/homeworld-shipment-options';
+import { homeworldGameIntegrity } from '../game/homeworld-game';
+
+function fixture() {
+ let game = createGame(
+ 'GUILDWORLDCONTROLS',
+ newPlayer('g', 'Guild', 'guild'),
+ false,
+ );
+ joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
+ joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
+ game = applyAction(game, 'g', { type: 'homeworlds', enabled: true });
+ for (const player of game.players)
+ game = applyAction(game, player.id, { type: 'ready' });
+ game = initializeHomeworldGameForAudit(game);
+ for (let step = 0; game.status === 'setup' && step < 40; step++) {
+ let next: Game | undefined;
+ for (const player of game.players) {
+ const view = viewGame(game, player.id);
+ view.players.find((seat) => seat.id === player.id)!.bot = 'Easy';
+ const action = botActions(view)[0];
+ if (action) {
+ next = applyAction(game, player.id, action);
+ break;
+ }
+ }
+ assert.ok(next);
+ game = next;
+ }
+ assert.equal(game.status, 'playing');
+ for (const player of game.players) game.deck.push(...player.hand.splice(0));
+ Object.assign(game, {
+ phase: 5,
+ phaseOpening: null,
+ response: null,
+ decision: null,
+ active: 'g',
+ order: ['g', 'a', 'h'],
+ movementRemaining: ['g', 'a', 'h'],
+ ready: [],
+ storm: 18,
+ spice: {},
+ });
+ const guild = game.players[0];
+ guild.reserves = 3;
+ guild.forces = { 'wind_pass:14': 3, 'wind_pass:15': 7, 'tueks_sietch:5': 7 };
+ guild.spice = 12;
+ for (const player of game.players) {
+ player.shipped = false;
+ player.moved = 0;
+ }
+ homeworldGameIntegrity(game);
+ return { game, view: viewGame(game, 'g') };
+}
+
+void test('Guild Arrakis controls quote combined actual sectors, own return and exact selected allied funding', () => {
+ const { view } = fixture();
+ const before = structuredClone(view);
+ const sources = {
+ 'wind_pass:14': { normal: 2, elite: 0 },
+ 'wind_pass:15': { normal: 3, elite: 0 },
+ };
+ const quoted = guildHomeworldShipmentChoice(view, 'homeworld:guild', sources);
+ assert.ok(quoted.action);
+ assert.equal(quoted.cost, 3);
+ assert.equal(quoted.amount, 5);
+ assert.deepEqual(quoted.action.sources, sources);
+ assert.deepEqual(view, before);
+ view.players[0].ally = 'h';
+ view.players[2].ally = 'g';
+ view.players[0].spice = 1;
+ view.aid.available = 3;
+ assert.equal(
+ guildHomeworldShipmentChoice(view, 'homeworld:guild', sources).action,
+ null,
+ );
+ const funded = guildHomeworldShipmentChoice(
+ view,
+ 'homeworld:guild',
+ sources,
+ 2,
+ );
+ assert.ok(funded.action);
+ assert.equal(funded.ownPayment, 1);
+ for (const amount of [-1, 0.5, 4, NaN])
+ assert.equal(
+ guildHomeworldShipmentChoice(view, 'homeworld:guild', sources, amount)
+ .action,
+ null,
+ );
+ assert.equal(
+ guildHomeworldShipmentChoice(view, 'homeworld:harkonnen', sources, 2)
+ .action,
+ null,
+ );
+});
+
+void test('Guild Arrakis controls reject mixed territories, storm, excess, special counters and inactive timing', () => {
+ const { view } = fixture();
+ const good = { 'wind_pass:14': { normal: 2, elite: 0 } };
+ for (const sources of [
+ {},
+ { 'wind_pass:14': { normal: 4, elite: 0 } },
+ { 'wind_pass:14': { normal: 1, elite: 1 } },
+ { ...good, 'tueks_sietch:5': { normal: 1, elite: 0 } },
+ { 'homeworld:guild': { normal: 1, elite: 0 } },
+ ] as HomeworldShipmentSources[])
+ assert.equal(
+ guildHomeworldShipmentChoice(view, 'homeworld:guild', sources).action,
+ null,
+ );
+ view.storm = 14;
+ assert.equal(
+ guildHomeworldShipmentChoice(view, 'homeworld:guild', good).action,
+ null,
+ );
+ view.storm = 18;
+ view.guildHomeworldShipment!.blocked = 'Finish the current response.';
+ assert.equal(
+ guildHomeworldShipmentChoice(view, 'homeworld:guild', good).blocked,
+ 'Finish the current response.',
+ );
+ for (let level = 0; level < 4; level++)
+ assert.deepEqual(guildHomeworldShipmentActions(view, level), []);
+ view.guildHomeworldShipment!.blocked = null;
+ for (const patch of [
+ { phase: 6 },
+ { active: 'a' },
+ { status: 'setup' as const },
+ { guildHomeworldShipment: null },
+ ])
+ assert.equal(
+ guildHomeworldShipmentChoice(
+ { ...view, ...patch },
+ 'homeworld:guild',
+ good,
+ ).action,
+ null,
+ );
+});
+
+void test('all four Guild policies submit real legal returns, retain stronghold defense and preserve saved physical custody', () => {
+ const { game, view } = fixture();
+ for (const [level, name] of ['Easy', 'Medium', 'Hard', 'Brutal'].entries()) {
+ view.players[0].bot = name as 'Easy';
+ const actions = guildHomeworldShipmentActions(view, level);
+ assert.ok(actions.length, name);
+ const whole = botActions(view);
+ assert.ok(
+ whole.some((action) => action.type === 'guildHomeworldShip'),
+ name,
+ );
+ for (const action of actions) {
+ const after = applyAction(JSON.parse(JSON.stringify(game)), 'g', action);
+ assert.equal(after.players[0].shipped, true);
+ homeworldGameIntegrity(after);
+ assert.equal(
+ after.players[0].reserves +
+ Object.values(after.players[0].forces).reduce((sum, n) => sum + n, 0),
+ 20,
+ );
+ assert.ok((after.players[0].forces['tueks_sietch:5'] ?? 0) >= 2);
+ assert.equal(
+ viewGame(JSON.parse(JSON.stringify(after)), 'g').players[0].reserves,
+ after.players[0].reserves,
+ );
+ }
+ }
+});
+
+void test('Guild public transport options and policies never inspect rival private fields or concealed force maps', () => {
+ const { view } = fixture();
+ const sources = { 'wind_pass:14': { normal: 2, elite: 0 } };
+ const expected = guildHomeworldShipmentChoice(
+ view,
+ 'homeworld:guild',
+ sources,
+ );
+ const actions = guildHomeworldShipmentActions(view, 3);
+ for (const rival of view.players.slice(1))
+ for (const key of ['spice', 'hand', 'traitors', 'forces'])
+ Object.defineProperty(rival, key, {
+ get() {
+ throw new Error('Private rival field read');
+ },
+ });
+ for (const key of ['deck', 'rng', 'plans'])
+ Object.defineProperty(view, key, {
+ get() {
+ throw new Error('Private game field read');
+ },
+ });
+ assert.deepEqual(
+ guildHomeworldShipmentChoice(view, 'homeworld:guild', sources),
+ expected,
+ );
+ assert.deepEqual(guildHomeworldShipmentActions(view, 3), actions);
+});
+
+void test('Guild controls expose keyboard labelled source sectors and a sectorless Homeworld destination', () => {
+ const { view } = fixture();
+ const html = renderToStaticMarkup(
+ createElement(GuildHomeworldShipment, {
+ game: view,
+ act: () => {},
+ busy: false,
+ }),
+ );
+ assert.match(html, /aria-label="Arrakis source territory"/);
+ assert.match(html, /aria-label="Guild forces from Wind Pass sector 14"/);
+ assert.match(html, /aria-label="Guild forces from Wind Pass sector 15"/);
+ assert.match(html, /Junction · return to your Homeworld/);
+ assert.match(html, /one spice per two forces, rounded up/);
+ assert.match(
+ html,
+ /]*disabled[^>]*>Transport to Junction<\/button>/,
+ );
+ assert.doesNotMatch(html, /Junction sector|undefined|NaN/);
+});
+
+void test('all four Guild policies reinforce a foreign invasion through authoritative Arrakis transport', () => {
+ const { game } = fixture();
+ const atreides = game.players[1];
+ const withdrawn = atreides.reserves - 3;
+ atreides.reserves = 3;
+ atreides.forces['arrakeen:10'] =
+ (atreides.forces['arrakeen:10'] ?? 0) + withdrawn;
+ game.players[0].reserves -= 2;
+ game.homeworlds!.custody!.visitors['homeworld:atreides'] = {
+ g: { normal: 2, elite: 0 },
+ };
+ homeworldGameIntegrity(game);
+ for (let level = 0; level < 4; level++) {
+ const view = viewGame(game, 'g');
+ const invasion = guildHomeworldShipmentActions(view, level).find(
+ (action) => action.destination === 'homeworld:atreides',
+ );
+ assert.ok(invasion, `level ${level} reinforces an attainable invasion`);
+ const after = applyAction(JSON.parse(JSON.stringify(game)), 'g', invasion);
+ homeworldGameIntegrity(after);
+ assert.ok(
+ after.homeworlds!.custody!.visitors['homeworld:atreides'].g.normal > 2,
+ );
+ assert.equal(after.players[0].reserves, 1);
+ assert.equal(after.players[0].shipped, true);
+ }
+});
+
+void test('Guild transport policies retain spice collectors and omit storm-trapped origin pools', () => {
+ const { view } = fixture();
+ view.spice.wind_pass = 20;
+ for (let level = 0; level < 4; level++) {
+ const actions = guildHomeworldShipmentActions(view, level);
+ assert.ok(actions.length);
+ assert.ok(
+ actions.every((action) =>
+ Object.keys(action.sources as object).every(
+ (key) => !key.startsWith('wind_pass:'),
+ ),
+ ),
+ );
+ }
+ view.spice.wind_pass = 0;
+ view.storm = 14;
+ for (let level = 0; level < 4; level++) {
+ const actions = guildHomeworldShipmentActions(view, level);
+ assert.ok(actions.length);
+ assert.ok(
+ actions.every(
+ (action) => !Object.hasOwn(action.sources as object, 'wind_pass:14'),
+ ),
+ );
+ }
+});
diff --git a/tests/guild-homeworld-shipment-engine.test.ts b/tests/guild-homeworld-shipment-engine.test.ts
new file mode 100644
index 00000000..484364f5
--- /dev/null
+++ b/tests/guild-homeworld-shipment-engine.test.ts
@@ -0,0 +1,471 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeHomeworldGameForAudit,
+ joinGame,
+ newPlayer,
+ normalizeAutomaticGame,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import type { FactionId } from '../game/catalog';
+import { treacheryDeck } from '../game/cards';
+import {
+ homeworldContext,
+ homeworldGameIntegrity,
+} from '../game/homeworld-game';
+import {
+ homeworldForceGroups,
+ type HomeworldForces,
+} from '../game/homeworld-custody';
+import { createTechTokens } from '../game/tech-tokens';
+
+const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
+const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
+const sum = (counts: Record) =>
+ Object.values(counts).reduce((a, b) => a + b, 0);
+
+function inventory(g: Game) {
+ homeworldGameIntegrity(g);
+ const groups = homeworldForceGroups(
+ homeworldContext(g),
+ g.homeworlds!.custody!,
+ );
+ for (const p of g.players) {
+ const visitors = groups
+ .filter((w) => w.native !== p.id)
+ .map((w) => w.forces[p.id] ?? { normal: 0, elite: 0 });
+ assert.equal(
+ p.reserves +
+ p.tanks +
+ sum(p.forces) +
+ visitors.reduce((n, f) => n + f.normal + f.elite, 0),
+ 20,
+ );
+ if (p.elites)
+ assert.equal(
+ p.elites.reserves +
+ p.elites.tanks +
+ sum(p.elites.forces) +
+ visitors.reduce((n, f) => n + f.elite, 0),
+ p.faction === 'emperor' ? 5 : p.faction === 'fremen' ? 3 : 7,
+ );
+ const view = viewGame(reload(g), p.id);
+ for (const other of view.players.filter((seat) => seat.id !== p.id)) {
+ assert.equal(other.hand, undefined);
+ assert.equal(other.traitors, undefined);
+ }
+ }
+ const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)];
+ assert.equal(cards.length, treacheryDeck(g.expansions).length);
+ assert.equal(new Set(cards.map((c) => c.id)).size, cards.length);
+}
+
+function movement(g: Game, actor: string) {
+ Object.assign(g, {
+ phase: 5,
+ phaseOpening: null,
+ response: null,
+ decision: null,
+ active: actor,
+ order: g.players.map((p) => p.id),
+ movementRemaining: [
+ actor,
+ ...g.players.filter((p) => p.id !== actor).map((p) => p.id),
+ ],
+ ready: [],
+ storm: 18,
+ });
+ for (const p of g.players) {
+ p.shipped = false;
+ p.moved = 0;
+ }
+ return g;
+}
+
+// Actual audit setup and physical deals. Shipment opportunities are staged,
+// with printed Guild forces in Tuek’s Sietch. Every off-board transfer is real.
+function fixture(
+ roster: FactionId[] = ['guild', 'emperor', 'atreides'],
+ advanced = false,
+) {
+ let g = createGame(
+ 'GUILDWORLDENGINE',
+ newPlayer(roster[0], roster[0], roster[0]),
+ advanced,
+ roster.some((f) => f === 'ixians' || f === 'tleilaxu') ? ['ix'] : [],
+ );
+ for (const faction of roster.slice(1))
+ joinGame(g, newPlayer(faction, faction, faction));
+ g = applyAction(g, g.host, { 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 < 40; step++) {
+ let next: Game | undefined;
+ for (const p of g.players) {
+ const view = viewGame(g, p.id);
+ view.players.find((owner) => owner.id === p.id)!.bot = 'Easy';
+ const command = botActions(view)[0];
+ if (command) {
+ next = applyAction(g, p.id, command);
+ break;
+ }
+ }
+ assert.ok(next, 'Genuine setup offers a legal decision.');
+ g = next;
+ }
+ assert.equal(g.status, 'playing');
+ for (const p of g.players) g.deck.push(...p.hand.splice(0));
+ g = movement(g, roster[0]);
+ inventory(g);
+ return g;
+}
+
+function shipment(
+ g: Game,
+ actor: string,
+ destination: string,
+ sources: Record,
+ extra: Record = {},
+): Action {
+ return {
+ type: 'guildHomeworldShip',
+ event: viewGame(g, actor).guildHomeworldShipment!.event,
+ destination,
+ sources,
+ ...extra,
+ };
+}
+function rejects(g: Game, actor: string, command: Action) {
+ const before = structuredClone(g);
+ assert.throws(() => applyAction(g, actor, command));
+ assert.deepEqual(g, before);
+}
+function karama(g: Game) {
+ const index = g.deck.findIndex((c) => c.effect === 'karama');
+ assert.ok(index >= 0);
+ const card = g.deck.splice(index, 1)[0];
+ player(g, 'guild').hand.push(card);
+ return card;
+}
+
+function printedShipment(g: Game, destination = 'homeworld:atreides', n = 3) {
+ return shipment(g, 'guild', destination, {
+ 'tueks_sietch:5': { normal: n, elite: 0 },
+ });
+}
+
+void test('printed Guild army reaches foreign Homeworld battle through Basic and Advanced actions', () => {
+ for (const advanced of [false, true]) {
+ let g = fixture(undefined, advanced);
+ assert.equal(player(g, 'guild').forces['tueks_sietch:5'], 5);
+ const original = structuredClone(g);
+ const command = printedShipment(g);
+ g = applyAction(g, 'guild', command);
+ assert.equal(player(g, 'guild').forces['tueks_sietch:5'], 2);
+ assert.equal(player(g, 'guild').reserves, 15);
+ assert.equal(player(g, 'guild').spice, 3);
+ assert.equal(player(g, 'guild').shipped, true);
+ assert.equal(player(g, 'guild').moved, 0);
+ assert.equal(
+ g.decision,
+ null,
+ 'No physical Karama means automatic permission.',
+ );
+ assert.equal(g.pendingHomeworldShipment, null);
+ assert.deepEqual(
+ g.homeworlds!.custody!.visitors['homeworld:atreides'].guild,
+ { normal: 3, elite: 0 },
+ );
+ assert.deepEqual(original.homeworlds!.custody!.visitors, {});
+ assert.ok(
+ Object.keys(player(g, 'guild').forces).every(
+ (k) => !k.startsWith('homeworld:'),
+ ),
+ );
+ rejects(reload(g), 'guild', command);
+ for (const actor of ['guild', 'emperor', 'atreides'])
+ g = applyAction(g, actor, { type: 'endMovement' });
+ if (g.phaseOpening) g = normalizeAutomaticGame(g);
+ assert.equal(g.phase, 6);
+ assert.ok(
+ viewGame(g, 'guild').battleChoices.some(
+ (b) => b.territory === 'homeworld:atreides',
+ ),
+ );
+ inventory(g);
+ }
+});
+
+void test('Guild native Junction return restores actual reserves at odd and even prices without Heighliner income', () => {
+ for (const advanced of [false, true])
+ for (const amount of [1, 2, 3, 4, 5]) {
+ let g = fixture(undefined, advanced);
+ g.techTokens = createTechTokens(g.players);
+ g.techTokens.heighliners.owner = 'atreides';
+ const technology = structuredClone(g.techTokens);
+ const before = player(g, 'guild').spice;
+ g = applyAction(
+ g,
+ 'guild',
+ printedShipment(g, 'homeworld:guild', amount),
+ );
+ assert.equal(player(g, 'guild').spice, before - Math.ceil(amount / 2));
+ assert.equal(player(g, 'guild').reserves, 15 + amount);
+ assert.equal(sum(player(g, 'guild').forces), 5 - amount);
+ assert.equal(
+ g.homeworlds!.custody!.visitors['homeworld:guild'],
+ undefined,
+ );
+ assert.deepEqual(g.techTokens, technology);
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
+ inventory(g);
+ }
+});
+
+void test('Guild own invasion also preserves the Heighliner Guild-only exception', () => {
+ let g = fixture();
+ g.techTokens = createTechTokens(g.players);
+ g.techTokens.heighliners.owner = 'atreides';
+ const before = structuredClone(g.techTokens);
+ g = applyAction(g, 'guild', printedShipment(g));
+ assert.deepEqual(g.techTokens, before);
+ inventory(g);
+});
+
+void test('actual self-interception holds original force and funding custody until one allow or stop', () => {
+ for (const stop of [false, true]) {
+ let g = fixture(undefined, true);
+ player(g, 'guild').ally = 'emperor';
+ player(g, 'emperor').ally = 'guild';
+ g = applyAction(g, 'emperor', { type: 'pledgeAid', amount: 2 });
+ const card = karama(g);
+ const before = structuredClone(g);
+ const command = {
+ ...printedShipment(g, 'homeworld:atreides', 5),
+ allyPayment: 2,
+ };
+ g = applyAction(g, 'guild', command);
+ assert.equal(g.pendingHomeworldShipment?.route, 'arrakis');
+ assert.equal(g.decision?.kind, 'homeworldShipmentGuild');
+ assert.equal(g.decision?.player, 'guild');
+ assert.equal(g.pendingHomeworldShipment?.cost, 3);
+ assert.deepEqual(g.players, before.players);
+ assert.deepEqual(g.homeworlds, before.homeworlds);
+ assert.deepEqual(g.aid, before.aid);
+ const event = g.pendingHomeworldShipment!.event;
+ rejects(g, 'emperor', { type: 'decision', allow: true, event });
+ rejects(g, 'guild', { type: 'decision', allow: true, event: 'old' });
+ const decision: Action = stop
+ ? { type: 'card', mode: 'special', card: card.id }
+ : { type: 'decision', allow: true, event };
+ inventory(g);
+ g = applyAction(reload(g), 'guild', decision);
+ assert.equal(
+ player(g, 'guild').spice,
+ stop ? 5 : 6,
+ 'Allowed: personal one-spice payment, then the independent two-spice donor receipt.',
+ );
+ assert.equal(g.aid.emperor.amount, stop ? 2 : 0);
+ assert.equal(sum(player(g, 'guild').forces), stop ? 5 : 0);
+ assert.equal(player(g, 'guild').reserves, 15);
+ assert.equal(player(g, 'guild').shipped, true);
+ assert.equal(player(g, 'guild').moved, 0);
+ assert.equal(g.pendingHomeworldShipment, null);
+ assert.equal(g.decision, null);
+ assert.equal(
+ g.discard.filter((c) => c.id === card.id).length,
+ stop ? 1 : 0,
+ );
+ rejects(g, 'guild', decision);
+ inventory(g);
+ }
+});
+
+void test('selected storm source is forbidden but an unselected sector does not block combined shipment', () => {
+ let g = fixture();
+ // Preserve all five printed Guild counters; stage a reachable later board
+ // position split across Wind Pass sectors to isolate source-sector selection.
+ player(g, 'guild').forces = {
+ 'wind_pass:14': 2,
+ 'wind_pass:15': 2,
+ 'wind_pass:16': 1,
+ };
+ g.storm = 16;
+ inventory(g);
+ rejects(
+ g,
+ 'guild',
+ shipment(g, 'guild', 'homeworld:guild', {
+ 'wind_pass:14': { normal: 2, elite: 0 },
+ 'wind_pass:16': { normal: 1, elite: 0 },
+ }),
+ );
+ g = applyAction(
+ g,
+ 'guild',
+ shipment(g, 'guild', 'homeworld:guild', {
+ 'wind_pass:14': { normal: 2, elite: 0 },
+ 'wind_pass:15': { normal: 2, elite: 0 },
+ }),
+ );
+ assert.deepEqual(player(g, 'guild').forces, { 'wind_pass:16': 1 });
+ assert.equal(player(g, 'guild').reserves, 19);
+ assert.equal(player(g, 'guild').spice, 3);
+ inventory(g);
+});
+
+void test('non-Guild ally cannot acquire the Guild route and allied world arrivals reject immutably', () => {
+ const g = fixture();
+ player(g, 'guild').ally = 'atreides';
+ player(g, 'atreides').ally = 'guild';
+ rejects(g, 'guild', printedShipment(g));
+ rejects(g, 'guild', {
+ type: 'guildShip',
+ from: 'tueks_sietch:5',
+ amount: 3,
+ territory: 'homeworld:atreides',
+ });
+ movement(g, 'atreides');
+ assert.equal(viewGame(g, 'atreides').guildHomeworldShipment, null);
+ rejects(g, 'atreides', {
+ type: 'guildHomeworldShip',
+ event: 'invented',
+ destination: 'homeworld:emperor',
+ sources: { 'arrakeen:10': { normal: 1, elite: 0 } },
+ });
+ rejects(g, 'atreides', {
+ type: 'guildShip',
+ from: 'arrakeen:10',
+ amount: 1,
+ territory: 'homeworld:emperor',
+ });
+ inventory(g);
+});
+
+void test('Guild can depart a protected mobile interior while its ordinary-board pointer is in storm', () => {
+ let g = fixture(['guild', 'ixians', 'atreides']);
+ assert.ok(
+ g.mobileStronghold,
+ 'Ixian audit setup places the real mobile component.',
+ );
+ // Stage a later contested interior using the same five physical Guild
+ // counters, leaving every Ixian setup counter and the component intact.
+ player(g, 'guild').forces = { 'hidden_mobile_stronghold:0': 5 };
+ g.mobileStronghold.location = 'wind_pass:14';
+ g.storm = 14;
+ inventory(g);
+ g = applyAction(
+ g,
+ 'guild',
+ shipment(g, 'guild', 'homeworld:guild', {
+ 'hidden_mobile_stronghold:0': { normal: 3, elite: 0 },
+ }),
+ );
+ assert.equal(player(g, 'guild').forces['hidden_mobile_stronghold:0'], 2);
+ assert.equal(player(g, 'guild').reserves, 18);
+ assert.equal(player(g, 'guild').spice, 3);
+ assert.equal(g.mobileStronghold!.location, 'wind_pass:14');
+ inventory(g);
+});
+
+void test('two physically valid territories cannot be combined by either Guild Homeworld API', () => {
+ const g = fixture();
+ // Stage the same five printed counters in two legal later source territories.
+ player(g, 'guild').forces = { 'tueks_sietch:5': 2, 'wind_pass:14': 3 };
+ inventory(g);
+ rejects(
+ g,
+ 'guild',
+ shipment(g, 'guild', 'homeworld:guild', {
+ 'tueks_sietch:5': { normal: 2, elite: 0 },
+ 'wind_pass:14': { normal: 3, elite: 0 },
+ }),
+ );
+ rejects(g, 'guild', {
+ type: 'guildShip',
+ territory: 'reserves',
+ forces: { 'tueks_sietch:5': 2, 'wind_pass:14': 3 },
+ amount: 5,
+ });
+});
+
+void test('stale events, invented sectors, physical types and unavailable worlds reject before mutations', () => {
+ const g = fixture();
+ const command = printedShipment(g);
+ for (const change of [
+ { event: 'old' },
+ { destination: 'homeworld:missing' },
+ { destination: 'arrakeen' },
+ { sources: { 'homeworld:guild': { normal: 1, elite: 0 } } },
+ { sources: { 'tueks_sietch:05': { normal: 1, elite: 0 } } },
+ { sources: { 'tueks_sietch:5': { normal: 1, elite: 1 } } },
+ { sources: { 'tueks_sietch:5': { normal: 6, elite: 0 } } },
+ { sources: { 'tueks_sietch:5': { normal: 0, elite: 0 } } },
+ { sources: { 'tueks_sietch:5': { normal: 1, elite: 0, hidden: 1 } } },
+ { sector: 0 },
+ { noField: 0 },
+ { advisors: true },
+ { allyPayment: 1 },
+ ])
+ rejects(g, 'guild', { ...command, ...change });
+ const stale = reload(g);
+ stale.storm = 17;
+ rejects(stale, 'guild', command);
+ const covered = reload(g);
+ covered.storm = 5;
+ rejects(covered, 'guild', printedShipment(covered));
+ const moved = reload(g);
+ moved.phase = 6;
+ rejects(moved, 'guild', command);
+ inventory(g);
+});
+
+void test('legacy native-return and foreign-world adapters share real pending custody and price boundaries', () => {
+ for (const destination of ['reserves', 'homeworld:atreides']) {
+ let g = fixture(undefined, true);
+ karama(g);
+ const command: Action = {
+ type: 'guildShip',
+ from: 'tueks_sietch:5',
+ amount: 3,
+ territory: destination,
+ sector: 0,
+ };
+ g = applyAction(g, 'guild', command);
+ assert.equal(g.pendingHomeworldShipment?.route, 'arrakis');
+ assert.equal(g.pendingHomeworldShipment?.cost, 2);
+ assert.equal(player(g, 'guild').spice, 5);
+ assert.equal(player(g, 'guild').reserves, 15);
+ g = applyAction(reload(g), 'guild', {
+ type: 'decision',
+ allow: true,
+ event: g.pendingHomeworldShipment!.event,
+ });
+ assert.equal(
+ player(g, 'guild').reserves,
+ destination === 'reserves' ? 18 : 15,
+ );
+ assert.equal(player(g, 'guild').spice, 3);
+ assert.equal(player(g, 'guild').forces['tueks_sietch:5'], 2);
+ rejects(g, 'guild', command);
+ inventory(g);
+ }
+ const g = fixture();
+ for (const extra of [
+ { sector: 5 },
+ { noField: 0 },
+ { territory: 'homeworld:missing' },
+ ])
+ rejects(g, 'guild', {
+ type: 'guildShip',
+ from: 'tueks_sietch:5',
+ amount: 2,
+ territory: 'reserves',
+ ...extra,
+ });
+});
diff --git a/tests/guild-homeworld-shipment-recovery.test.ts b/tests/guild-homeworld-shipment-recovery.test.ts
new file mode 100644
index 00000000..2aad6520
--- /dev/null
+++ b/tests/guild-homeworld-shipment-recovery.test.ts
@@ -0,0 +1,700 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { 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 { richeseCards } from '../game/richese-cards';
+
+/** 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 };
+}
+
+const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
+async function fixture() {
+ const store = unitStore();
+ const made = await store.rooms.createRoom(
+ 'Guild Arrakis recovery QA',
+ 'guild',
+ true,
+ [],
+ );
+ const code = made.view.code;
+ const tokens = [made.token];
+ for (const faction of ['emperor', 'atreides'] as const) {
+ const joined = await store.rooms.joinRoom(code, faction, faction);
+ tokens.push(joined.token!);
+ }
+ const auths = await Promise.all(
+ tokens.map((token) => store.rooms.authenticate(code, token)),
+ );
+ let g = await store.rooms.readRoom(code);
+ g = engine.applyAction(g, g.host, { type: 'homeworlds', enabled: true });
+ for (const player of g.players)
+ g = engine.applyAction(g, player.id, { type: 'ready' });
+ g = engine.initializeHomeworldGameForAudit(g);
+ for (let step = 0; g.status === 'setup' && step < 30; step++) {
+ let next: engine.Game | undefined;
+ for (const player of g.players) {
+ const view = engine.viewGame(g, player.id);
+ view.players.find((p) => p.id === player.id)!.bot = 'Easy';
+ const action = bots.botActions(view)[0];
+ if (action) {
+ next = engine.applyAction(g, player.id, action);
+ break;
+ }
+ }
+ assert.ok(next);
+ g = next;
+ }
+ 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);
+ Object.assign(g, {
+ phase: 5,
+ phaseOpening: null,
+ response: null,
+ decision: null,
+ order: ids,
+ active: ids[0],
+ ready: [],
+ storm: 18,
+ });
+ for (const player of g.players) player.shipped = false;
+ const hold = (index: number, effect: string) => {
+ const at = g.deck.findIndex((card) => card.effect === effect);
+ assert.ok(at >= 0);
+ const [card] = g.deck.splice(at, 1);
+ g.players[index].hand.push(card);
+ return card.id;
+ };
+ const karama = hold(0, 'karama');
+ const save = (state: engine.Game) =>
+ store.sqlite
+ .prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
+ .run(JSON.stringify(state), state.version, code);
+ homeworldGameIntegrity(g);
+ save(g);
+ const other = await store.rooms.createRoom(
+ 'Untouched other table',
+ 'atreides',
+ false,
+ [],
+ );
+ const otherBefore = await store.rooms.readRoom(other.view.code);
+ store.writes.length = 0;
+ return {
+ ...store,
+ code,
+ tokens,
+ auths,
+ ids,
+ save,
+ initial: g,
+ karama,
+ otherCode: other.view.code,
+ otherBefore,
+ };
+}
+type Fixture = Awaited>;
+function declaration(
+ state: engine.Game,
+ destination = 'homeworld:atreides',
+): engine.Action {
+ return {
+ type: 'guildHomeworldShip',
+ event: engine.viewGame(state, state.players[0].id).guildHomeworldShipment!
+ .event,
+ destination,
+ sources: { 'tueks_sietch:5': { normal: 3, elite: 0 } },
+ };
+}
+const allowance = (g: engine.Game): engine.Action => ({
+ type: 'decision',
+ allow: true,
+ event: g.pendingHomeworldShipment!.event,
+});
+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;
+ };
+}
+async function restored(f: Fixture, state: engine.Game) {
+ const rooms = f.restart();
+ for (let i = 0; i < f.tokens.length; i++) {
+ const auth = await rooms.authenticate(f.code, f.tokens[i]);
+ const view = await rooms.readSeatView(f.code, auth);
+ assert.deepEqual(view, engine.viewGame(state, f.ids[i]));
+ for (const opponent of view.players.filter((p) => p.id !== f.ids[i])) {
+ assert.equal(opponent.hand, undefined);
+ assert.equal(opponent.traitors, undefined);
+ }
+ const event =
+ view.guildHomeworldShipment?.event ?? view.homeworldShipment!.event;
+ for (const player of state.players) {
+ for (const card of player.hand)
+ assert.equal(event.includes(card.id), false);
+ for (const traitor of player.traitors)
+ assert.equal(event.includes(traitor), false);
+ }
+ }
+ homeworldGameIntegrity(state);
+ assert.deepEqual(await rooms.readRoom(f.otherCode), f.otherBefore);
+ return rooms;
+}
+async function pending(f: Fixture) {
+ await f.rooms.act(
+ f.code,
+ f.auths[0],
+ f.initial.version,
+ declaration(f.initial),
+ clock,
+ );
+ const g = await f.rooms.readRoom(f.code);
+ assert.equal(g.decision?.kind, 'homeworldShipmentGuild');
+ assert.equal(g.pendingHomeworldShipment!.route, 'arrakis');
+ assert.equal(g.decision.player, g.decision.shipper);
+ assert.equal(g.players[0].spice, f.initial.players[0].spice);
+ assert.deepEqual(g.homeworlds!.custody, f.initial.homeworlds!.custody);
+ f.writes.length = 0;
+ return g;
+}
+
+for (const duplicate of [false, true])
+ void test(`concurrent SQL Guild Arrakis ${duplicate ? 'duplicate' : 'competing'} declarations store one unpaid shipment`, async () => {
+ const f = await fixture();
+ try {
+ const fresh = await restored(f, f.initial);
+ barrier(f);
+ const results = await Promise.allSettled([
+ f.rooms.act(
+ f.code,
+ f.auths[0],
+ f.initial.version,
+ declaration(f.initial),
+ clock,
+ ),
+ fresh.act(
+ f.code,
+ f.auths[0],
+ f.initial.version,
+ declaration(
+ f.initial,
+ duplicate ? 'homeworld:atreides' : 'homeworld:guild',
+ ),
+ clock,
+ ),
+ ]);
+ delete f.hooks.beforeWrite;
+ assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
+ assert.deepEqual(
+ f.writes.map((w) => w.changes).sort((a, b) => a - b),
+ [0, 1],
+ );
+ const after = await fresh.readRoom(f.code);
+ assert.equal(after.version, f.initial.version + 1);
+ assert.equal(after.decision?.kind, 'homeworldShipmentGuild');
+ assert.equal(
+ after.pendingHomeworldShipment!.destination,
+ duplicate || results[0].status === 'fulfilled'
+ ? 'homeworld:atreides'
+ : 'homeworld:guild',
+ );
+ assert.deepEqual(after.players, f.initial.players);
+ assert.deepEqual(
+ after.homeworlds!.custody,
+ f.initial.homeworlds!.custody,
+ );
+ await restored(f, after);
+ } finally {
+ f.sqlite.close();
+ }
+ });
+
+for (const intercepted of [false, true])
+ void test(`concurrent SQL Guild allowance versus ${intercepted ? 'interception' : 'duplicate allowance'} settles one Homeworld shipment`, async () => {
+ const f = await fixture();
+ try {
+ const before = await pending(f);
+ const fresh = await restored(f, before);
+ barrier(f);
+ const results = await Promise.allSettled([
+ f.rooms.act(
+ f.code,
+ f.auths[0],
+ before.version,
+ allowance(before),
+ clock,
+ ),
+ fresh.act(
+ f.code,
+ f.auths[0],
+ before.version,
+ intercepted
+ ? { type: 'card', mode: 'special', card: f.karama }
+ : allowance(before),
+ clock,
+ ),
+ ]);
+ delete f.hooks.beforeWrite;
+ assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
+ assert.deepEqual(
+ f.writes.map((w) => w.changes).sort((a, b) => a - b),
+ [0, 1],
+ );
+ const after = await fresh.readRoom(f.code);
+ const stopped = intercepted && results[1].status === 'fulfilled';
+ assert.equal(after.version, before.version + 1);
+ assert.equal(after.pendingHomeworldShipment, null);
+ assert.equal(after.players[0].shipped, true);
+ assert.equal(
+ after.players[0].spice,
+ before.players[0].spice - (stopped ? 0 : 2),
+ );
+ assert.equal(after.players[0].reserves, before.players[0].reserves);
+ assert.equal(
+ after.players[0].forces['tueks_sietch:5'],
+ before.players[0].forces['tueks_sietch:5'] - (stopped ? 0 : 3),
+ );
+ assert.equal(
+ after.discard.filter((card) => card.id === f.karama).length,
+ stopped ? 1 : 0,
+ );
+ if (stopped)
+ assert.deepEqual(after.homeworlds!.custody, before.homeworlds!.custody);
+ else
+ assert.deepEqual(
+ after.homeworlds!.custody!.visitors['homeworld:atreides'][f.ids[0]],
+ { normal: 3, elite: 0 },
+ );
+ await assert.rejects(
+ fresh.act(f.code, f.auths[0], before.version, allowance(before), clock),
+ );
+ await assert.rejects(
+ fresh.act(f.code, f.auths[0], after.version, allowance(before), clock),
+ );
+ await fresh.continueRoomAutomatic(f.code, clock);
+ assert.deepEqual(await fresh.readRoom(f.code), after);
+ await restored(f, after);
+ } finally {
+ f.sqlite.close();
+ }
+ });
+
+void test('saved Guild Arrakis shipment event, typed pool and Guild parent corruption rejects every seat read and action without SQL writes', async () => {
+ const f = await fixture();
+ try {
+ const before = await pending(f);
+ for (const mutate of [
+ (g: engine.Game) => {
+ g.pendingHomeworldShipment!.event += 'stale';
+ },
+ (g: engine.Game) => {
+ g.players[0].forces['tueks_sietch:5']--;
+ g.players[0].forces['arrakeen:10'] = 1;
+ },
+ (g: engine.Game) => {
+ g.pendingHomeworldShipment!.pools[0].before.normal--;
+ },
+ (g: engine.Game) => {
+ g.pendingHomeworldShipment!.cost++;
+ },
+ (g: engine.Game) => {
+ g.storm = 0;
+ },
+ (g: engine.Game) => {
+ g.storm = 5;
+ },
+ (g: engine.Game) => {
+ delete g.pendingHomeworldShipment!.route;
+ },
+ (g: engine.Game) => {
+ g.pendingHomeworldShipment!.sources['tueks_sietch:5'].normal = 0;
+ },
+ (g: engine.Game) => {
+ g.pendingHomeworldShipment!.pools[0].after.normal++;
+ },
+ (g: engine.Game) => {
+ if (g.decision?.kind === 'homeworldShipmentGuild')
+ g.decision.shipper = f.ids[2];
+ },
+ (g: engine.Game) => {
+ g.pendingHomeworldShipment = null;
+ },
+ ]) {
+ const corrupt = structuredClone(before);
+ mutate(corrupt);
+ f.save(corrupt);
+ const fresh = f.restart();
+ for (const auth of f.auths)
+ await assert.rejects(fresh.readSeatView(f.code, auth));
+ await assert.rejects(
+ fresh.act(
+ f.code,
+ f.auths[0],
+ corrupt.version,
+ allowance(before),
+ clock,
+ ),
+ );
+ await assert.rejects(fresh.continueRoomAutomatic(f.code, clock));
+ assert.equal(f.writes.length, 0);
+ assert.deepEqual(await fresh.readRoom(f.code), corrupt);
+ assert.deepEqual(await fresh.readRoom(f.otherCode), f.otherBefore);
+ }
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('paid Box search preserves a Guild self-interception parent through SQL restart and rejects paused-parent corruption', async () => {
+ const f = await fixture();
+ try {
+ const before = await pending(f);
+ const box = richeseCards().find(
+ (card) => card.effect === 'nullentropyBox',
+ )!;
+ before.players[0].hand.push(box);
+ const recovered = before.deck.shift()!;
+ before.discard.push(recovered);
+ before.discard.push(before.deck.shift()!);
+ const tooPoor = structuredClone(before);
+ tooPoor.players[0].spice = 3;
+ f.save(tooPoor);
+ await assert.rejects(
+ f.rooms.act(
+ f.code,
+ f.auths[0],
+ tooPoor.version,
+ { type: 'card', card: box.id },
+ clock,
+ ),
+ );
+ assert.deepEqual(await f.rooms.readRoom(f.code), tooPoor);
+ assert.equal(f.writes.length, 0);
+ f.save(before);
+ await f.rooms.act(
+ f.code,
+ f.auths[0],
+ before.version,
+ { type: 'card', card: box.id },
+ clock,
+ );
+ const search = await f.rooms.readRoom(f.code);
+ assert.equal(search.decision?.kind, 'nullentropy');
+ assert.deepEqual(
+ search.pendingNullentropy!.resume.decision,
+ before.decision,
+ );
+ assert.equal(search.players[0].spice, before.players[0].spice - 2);
+ await restored(f, search);
+ const selection = {
+ type: 'decision',
+ event: search.pendingNullentropy!.event,
+ card: recovered.id,
+ };
+ f.writes.length = 0;
+ for (const mutate of [
+ (g: engine.Game) => {
+ delete g.pendingHomeworldShipment!.route;
+ },
+ (g: engine.Game) => {
+ g.pendingHomeworldShipment!.pools[0].before.normal--;
+ },
+ (g: engine.Game) => {
+ g.storm = 0;
+ },
+ (g: engine.Game) => {
+ g.pendingHomeworldShipment = null;
+ },
+ (g: engine.Game) => {
+ const d = g.pendingNullentropy!.resume.decision!;
+ if (d.kind === 'homeworldShipmentGuild') d.event += 'stale';
+ },
+ (g: engine.Game) => {
+ const d = g.pendingNullentropy!.resume.decision!;
+ if (d.kind === 'homeworldShipmentGuild') d.player = f.ids[2];
+ },
+ ]) {
+ const corrupt = structuredClone(search);
+ mutate(corrupt);
+ f.save(corrupt);
+ const fresh = f.restart();
+ for (const auth of f.auths)
+ await assert.rejects(fresh.readSeatView(f.code, auth));
+ await assert.rejects(
+ fresh.act(f.code, f.auths[0], corrupt.version, selection, clock),
+ );
+ assert.equal(f.writes.length, 0);
+ assert.deepEqual(await fresh.readRoom(f.code), corrupt);
+ }
+ f.save(search);
+ const fresh = f.restart();
+ await fresh.act(f.code, f.auths[0], search.version, selection, clock);
+ const resumed = await fresh.readRoom(f.code);
+ assert.equal(resumed.pendingNullentropy, null);
+ assert.deepEqual(resumed.decision, before.decision);
+ assert.equal(resumed.players[0].spice, search.players[0].spice);
+ assert.equal(
+ resumed.players[0].hand.filter((c) => c.id === recovered.id).length,
+ 1,
+ );
+ assert.equal(resumed.discard.filter((c) => c.id === box.id).length, 1);
+ await fresh.act(
+ f.code,
+ f.auths[0],
+ resumed.version,
+ allowance(resumed),
+ clock,
+ );
+ const after = await fresh.readRoom(f.code);
+ assert.equal(after.players[0].spice, before.players[0].spice - 4);
+ await restored(f, after);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('concurrent automatic SQL recovery permits an old zero-card Guild Arrakis interception exactly once', async () => {
+ const f = await fixture();
+ try {
+ const before = await pending(f);
+ before.deck.push(...before.players[0].hand.splice(0));
+ f.save(before);
+ const fresh = await restored(f, before);
+ barrier(f);
+ await Promise.all([
+ f.rooms.continueRoomAutomatic(f.code, clock),
+ fresh.continueRoomAutomatic(f.code, clock),
+ ]);
+ delete f.hooks.beforeWrite;
+ const after = await fresh.readRoom(f.code);
+ assert.equal(after.version, before.version + 1);
+ assert.deepEqual(
+ f.writes.map((w) => w.changes).sort((a, b) => a - b),
+ [0, 1],
+ );
+ assert.equal(after.pendingHomeworldShipment, null);
+ assert.equal(after.decision, null);
+ assert.equal(after.players[0].shipped, true);
+ assert.equal(after.players[0].spice, before.players[0].spice - 2);
+ assert.deepEqual(
+ after.homeworlds!.custody!.visitors['homeworld:atreides'][f.ids[0]],
+ { normal: 3, elite: 0 },
+ );
+ await fresh.continueRoomAutomatic(f.code, clock);
+ assert.deepEqual(await fresh.readRoom(f.code), after);
+ await restored(f, after);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('legacy Guild reserve return competes with a world-origin shipment through the same SQL opportunity', async () => {
+ const f = await fixture();
+ try {
+ const fresh = f.restart();
+ const legacy = {
+ type: 'guildShip',
+ from: 'tueks_sietch:5',
+ amount: 3,
+ territory: 'reserves',
+ };
+ const world = {
+ type: 'homeworldShip',
+ event: engine.viewGame(f.initial, f.ids[0]).homeworldShipment!.event,
+ destination: 'homeworld:atreides',
+ sources: { 'homeworld:guild': { normal: 3, elite: 0 } },
+ };
+ barrier(f);
+ const results = await Promise.allSettled([
+ f.rooms.act(f.code, f.auths[0], f.initial.version, legacy, clock),
+ fresh.act(f.code, f.auths[0], f.initial.version, world, clock),
+ ]);
+ delete f.hooks.beforeWrite;
+ assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
+ assert.deepEqual(f.writes.map((w) => w.changes).sort((a, b) => a - b), [0, 1]);
+ const declared = await fresh.readRoom(f.code);
+ const returning = results[0].status === 'fulfilled';
+ assert.equal(
+ declared.pendingHomeworldShipment!.route,
+ returning ? 'arrakis' : undefined,
+ );
+ assert.equal(
+ declared.pendingHomeworldShipment!.destination,
+ returning ? 'homeworld:guild' : 'homeworld:atreides',
+ );
+ assert.equal(declared.players[0].spice, f.initial.players[0].spice);
+ await fresh.act(
+ f.code,
+ f.auths[0],
+ declared.version,
+ allowance(declared),
+ clock,
+ );
+ const after = await fresh.readRoom(f.code);
+ assert.equal(
+ after.players[0].reserves,
+ f.initial.players[0].reserves + (returning ? 3 : -3),
+ );
+ assert.equal(
+ after.players[0].forces['tueks_sietch:5'],
+ f.initial.players[0].forces['tueks_sietch:5'] - (returning ? 3 : 0),
+ );
+ assert.equal(after.players[0].spice, f.initial.players[0].spice - 2);
+ assert.equal(after.players[0].shipped, true);
+ const writes = f.writes.length;
+ await assert.rejects(
+ fresh.act(f.code, f.auths[0], after.version, legacy, clock),
+ );
+ await assert.rejects(
+ fresh.act(f.code, f.auths[0], after.version, world, clock),
+ );
+ assert.equal(f.writes.length, writes);
+ assert.deepEqual(await fresh.readRoom(f.code), after);
+ await restored(f, after);
+ } finally {
+ f.sqlite.close();
+ }
+});
+
+void test('legacy Guild return cannot bypass canonical source, special identity, storm or Homeworld restrictions in SQL', async () => {
+ const f = await fixture();
+ try {
+ const legacy = {
+ type: 'guildShip',
+ from: 'tueks_sietch:5',
+ amount: 3,
+ territory: 'reserves',
+ };
+ for (const action of [
+ { ...legacy, from: 'tueks_sietch:05' },
+ { ...legacy, from: 'reserves' },
+ { ...legacy, amount: 6 },
+ { ...legacy, elite: 1 },
+ { ...legacy, noField: 'invented' },
+ { ...legacy, sector: 5 },
+ { ...legacy, territory: 'homeworld:harkonnen' },
+ { ...legacy, forces: { 'tueks_sietch:05': 3 } },
+ ]) {
+ await assert.rejects(
+ f.rooms.act(f.code, f.auths[0], f.initial.version, action, clock),
+ );
+ assert.equal(f.writes.length, 0);
+ assert.deepEqual(await f.rooms.readRoom(f.code), f.initial);
+ }
+ const storm = structuredClone(f.initial);
+ storm.storm = 5;
+ f.save(storm);
+ await assert.rejects(
+ f.rooms.act(f.code, f.auths[0], storm.version, legacy, clock),
+ );
+ assert.equal(f.writes.length, 0);
+ assert.deepEqual(await f.rooms.readRoom(f.code), storm);
+ const allies = structuredClone(f.initial);
+ allies.players[0].ally = f.ids[1];
+ allies.players[1].ally = f.ids[0];
+ f.save(allies);
+ for (const destination of ['homeworld:emperor', 'homeworld:emperor:salusa'])
+ await assert.rejects(
+ f.rooms.act(
+ f.code,
+ f.auths[0],
+ allies.version,
+ { ...legacy, territory: destination },
+ clock,
+ ),
+ );
+ assert.equal(f.writes.length, 0);
+ assert.deepEqual(await f.rooms.readRoom(f.code), allies);
+ assert.deepEqual(await f.rooms.readRoom(f.otherCode), f.otherBefore);
+ } finally {
+ f.sqlite.close();
+ }
+});
diff --git a/tests/guild-homeworld-shipment.test.ts b/tests/guild-homeworld-shipment.test.ts
new file mode 100644
index 00000000..d5191a7d
--- /dev/null
+++ b/tests/guild-homeworld-shipment.test.ts
@@ -0,0 +1,380 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { MOBILE_LOCATION } from '../game/board';
+import {
+ quoteGuildHomeworldShipment as quote,
+ type GuildHomeworldShipmentContext,
+ type GuildHomeworldShipmentIntent,
+} from '../game/guild-homeworld-shipment';
+import {
+ HomeworldCustodyError,
+ homeworldForceGroups,
+ type HomeworldCustody,
+} from '../game/homeworld-custody';
+
+function fixture(): {
+ context: GuildHomeworldShipmentContext;
+ custody: HomeworldCustody;
+} {
+ return {
+ context: {
+ advanced: true,
+ storm: 12,
+ players: [
+ {
+ id: 'g',
+ faction: 'guild',
+ reserves: 10,
+ eliteReserves: 0,
+ ally: null,
+ },
+ {
+ id: 'e',
+ faction: 'emperor',
+ reserves: 20,
+ eliteReserves: 5,
+ ally: null,
+ },
+ {
+ id: 'a',
+ faction: 'atreides',
+ reserves: 20,
+ eliteReserves: 0,
+ ally: null,
+ },
+ ],
+ board: {
+ g: {
+ forces: {
+ 'false_wall_south:4': 3,
+ 'false_wall_south:5': 4,
+ 'arrakeen:10': 1,
+ },
+ eliteForces: {},
+ },
+ e: { forces: {}, eliteForces: {} },
+ a: { forces: {}, eliteForces: {} },
+ },
+ },
+ custody: {
+ visitors: { 'homeworld:emperor': { g: { normal: 2, elite: 0 } } },
+ salusa: { normal: 0, elite: 5 },
+ },
+ };
+}
+const intent: GuildHomeworldShipmentIntent = {
+ player: 'g',
+ destination: 'homeworld:atreides',
+ sources: {
+ 'false_wall_south:4': { normal: 3, elite: 0 },
+ 'false_wall_south:5': { normal: 2, elite: 0 },
+ },
+};
+function reject(
+ context: GuildHomeworldShipmentContext,
+ custody: HomeworldCustody,
+ action: unknown = intent,
+) {
+ const before = structuredClone({ context, custody, action });
+ assert.throws(
+ () => quote(context, custody, action as GuildHomeworldShipmentIntent),
+ HomeworldCustodyError,
+ );
+ assert.deepEqual({ context, custody, action }, before);
+}
+function total(
+ context: GuildHomeworldShipmentContext,
+ custody: HomeworldCustody,
+) {
+ return (
+ homeworldForceGroups(context, custody).reduce(
+ (sum, w) => sum + (w.forces.g?.normal ?? 0),
+ 0,
+ ) + Object.values(context.board.g.forces).reduce((sum, n) => sum + n, 0)
+ );
+}
+
+void test('Guild combines canonical sectors from one territory and conserves detached physical custody', () => {
+ const { context, custody } = fixture();
+ const before = structuredClone({ context, custody, intent });
+ const result = quote(context, custody, intent);
+ assert.equal(result.origin, 'false_wall_south');
+ assert.equal(result.amount, 5);
+ assert.equal(result.elite, 0);
+ assert.equal(result.cost, 3);
+ assert.deepEqual(result.boardSources, [
+ {
+ key: 'false_wall_south:4',
+ before: { normal: 3, elite: 0 },
+ after: { normal: 0, elite: 0 },
+ },
+ {
+ key: 'false_wall_south:5',
+ before: { normal: 4, elite: 0 },
+ after: { normal: 2, elite: 0 },
+ },
+ ]);
+ assert.deepEqual(result.boardForces, {
+ 'false_wall_south:5': 2,
+ 'arrakeen:10': 1,
+ });
+ assert.deepEqual(result.state.visitors['homeworld:atreides'].g, {
+ normal: 5,
+ elite: 0,
+ });
+ assert.deepEqual(result.receipts, [
+ {
+ homeworld: 'homeworld:atreides',
+ player: 'g',
+ before: { normal: 0, elite: 0 },
+ after: { normal: 5, elite: 0 },
+ },
+ ]);
+ assert.equal(
+ total(
+ {
+ ...context,
+ players: result.players.map((p) => ({ ...p, ally: null })),
+ board: {
+ ...context.board,
+ g: {
+ forces: result.boardForces,
+ eliteForces: result.boardEliteForces,
+ },
+ },
+ },
+ result.state,
+ ),
+ total(context, custody),
+ );
+ assert.deepEqual({ context, custody, intent }, before);
+ result.boardSources[0].before.normal = 17;
+ result.boardForces['arrakeen:10'] = 0;
+ result.state.visitors['homeworld:emperor'].g.normal = 0;
+ result.state.salusa!.elite = 0;
+ result.players[0].reserves = 0;
+ assert.deepEqual({ context, custody, intent }, before);
+});
+
+void test('Junction return increases native reserves while either Emperor world accepts foreign arrivals', () => {
+ for (const destination of [
+ 'homeworld:guild',
+ 'homeworld:emperor',
+ 'homeworld:emperor:salusa',
+ ]) {
+ const { context, custody } = fixture();
+ const result = quote(context, custody, { ...intent, destination });
+ assert.equal(
+ result.players[0].reserves,
+ destination === 'homeworld:guild' ? 15 : 10,
+ );
+ assert.deepEqual(result.state.salusa, custody.salusa);
+ assert.equal(result.state.visitors['homeworld:guild'], undefined);
+ if (destination !== 'homeworld:guild')
+ assert.equal(
+ result.state.visitors[destination].g.normal,
+ destination === 'homeworld:emperor' ? 7 : 5,
+ );
+ }
+});
+
+void test('Guild tariff rounds the combined group once from one through twenty physical counters', () => {
+ for (const amount of [1, 2, 3, 4, 5, 19, 20]) {
+ const { context, custody } = fixture();
+ context.players[0].reserves = 20 - amount;
+ custody.visitors = {};
+ context.board.g.forces = { 'arrakeen:10': amount };
+ const result = quote(context, custody, {
+ ...intent,
+ sources: { 'arrakeen:10': { normal: amount, elite: 0 } },
+ });
+ assert.equal(result.amount, amount);
+ assert.equal(result.cost, Math.ceil(amount / 2));
+ }
+});
+
+void test('Arrakis transport is Guild-only and mutual allies cannot become Homeworld destinations', () => {
+ const { context, custody } = fixture();
+ context.players[0].ally = 'e';
+ context.players[1].ally = 'g';
+ for (const destination of ['homeworld:emperor', 'homeworld:emperor:salusa'])
+ reject(context, custody, { ...intent, destination });
+ reject(context, custody, { ...intent, player: 'e' });
+ assert.equal(quote(context, custody, intent).amount, 5);
+ context.players[1].ally = null;
+ assert.equal(
+ quote(context, custody, { ...intent, destination: 'homeworld:emperor' })
+ .amount,
+ 5,
+ );
+});
+
+void test('storm sectors cannot join a group while other sectors in the territory remain legal', () => {
+ const { context, custody } = fixture();
+ context.storm = 4;
+ reject(context, custody);
+ assert.equal(
+ quote(context, custody, {
+ ...intent,
+ sources: { 'false_wall_south:5': { normal: 4, elite: 0 } },
+ }).amount,
+ 4,
+ );
+ reject(context, custody, {
+ ...intent,
+ sources: {
+ 'false_wall_south:5': { normal: 1, elite: 0 },
+ 'arrakeen:10': { normal: 1, elite: 0 },
+ },
+ });
+});
+
+void test('mobile stronghold interior requires its active pointer and stays storm protected', () => {
+ const { context, custody } = fixture();
+ context.board.g.forces = { [MOBILE_LOCATION]: 8 };
+ const action = {
+ ...intent,
+ sources: { [MOBILE_LOCATION]: { normal: 5, elite: 0 } },
+ };
+ reject(context, custody, action);
+ context.mobileStronghold = 'false_wall_south:4';
+ context.storm = 4;
+ assert.equal(quote(context, custody, action).amount, 5);
+ for (const pointer of [
+ MOBILE_LOCATION,
+ 'false_wall_south:04',
+ 'homeworld:guild',
+ 'false_wall_south:99',
+ '',
+ ]) {
+ context.mobileStronghold = pointer;
+ reject(context, custody, action);
+ }
+});
+
+void test('malformed sources, alias sectors and unavailable force identities reject without mutation', () => {
+ const { context, custody } = fixture();
+ for (const action of [
+ null,
+ [],
+ {},
+ { ...intent, amount: 5 },
+ { ...intent, sources: {} },
+ { ...intent, sources: [] },
+ { ...intent, player: 'absent' },
+ { ...intent, destination: 'homeworld:harkonnen' },
+ ...[
+ 'false_wall_south:04',
+ 'false_wall_south:4:extra',
+ 'false_wall_south:4.0',
+ 'false_wall_south:99',
+ 'homeworld:guild',
+ 'false_wall_south',
+ ].map((key) => ({
+ ...intent,
+ sources: { [key]: { normal: 1, elite: 0 } },
+ })),
+ ...[
+ null,
+ {},
+ { normal: 0, elite: 0 },
+ { normal: 1 },
+ { normal: 1, elite: 0, extra: 0 },
+ { normal: '1', elite: 0 },
+ { normal: NaN, elite: 0 },
+ { normal: Infinity, elite: 0 },
+ { normal: 0.5, elite: 0 },
+ { normal: -1, elite: 0 },
+ { normal: 21, elite: 0 },
+ { normal: 4, elite: 0 },
+ { normal: 1, elite: 1 },
+ ].map((selected) => ({
+ ...intent,
+ sources: { 'false_wall_south:4': selected },
+ })),
+ ])
+ reject(context, custody, action);
+});
+
+void test('corrupt board, reserve and visitor counters cannot be laundered into a valid destination', () => {
+ for (const mutate of [
+ (c: GuildHomeworldShipmentContext) => {
+ c.board.g.forces['arrakeen:10']++;
+ },
+ (c: GuildHomeworldShipmentContext) => {
+ c.board.g.forces['arrakeen:010'] = 0;
+ },
+ (c: GuildHomeworldShipmentContext) => {
+ c.board.g.forces['arrakeen:10'] = -1;
+ },
+ (c: GuildHomeworldShipmentContext) => {
+ c.board.g.eliteForces['false_wall_south:4'] = 1;
+ },
+ (c: GuildHomeworldShipmentContext) => {
+ c.players[0].ally = 'absent';
+ },
+ (c: GuildHomeworldShipmentContext) => {
+ c.storm = 19;
+ },
+ (_c: GuildHomeworldShipmentContext, s: HomeworldCustody) => {
+ s.visitors['homeworld:emperor'].e = { normal: 1, elite: 0 };
+ },
+ (_c: GuildHomeworldShipmentContext, s: HomeworldCustody) => {
+ s.salusa!.elite = 6;
+ },
+ ]) {
+ const { context, custody } = fixture();
+ mutate(context, custody);
+ reject(context, custody);
+ }
+});
+
+void test('JSON restoration preserves a second partial shipment and exact custody', () => {
+ const { context, custody } = fixture();
+ const result = JSON.parse(
+ JSON.stringify(quote(context, custody, intent)),
+ ) as ReturnType;
+ const next = {
+ ...context,
+ players: result.players.map((p) => ({ ...p, ally: null })),
+ board: {
+ ...context.board,
+ g: { forces: result.boardForces, eliteForces: result.boardEliteForces },
+ },
+ };
+ const second = quote(next, result.state, {
+ ...intent,
+ destination: 'homeworld:guild',
+ sources: { 'false_wall_south:5': { normal: 2, elite: 0 } },
+ });
+ assert.equal(second.players[0].reserves, 12);
+ assert.deepEqual(second.boardForces, { 'arrakeen:10': 1 });
+ assert.equal(second.state.visitors['homeworld:atreides'].g.normal, 5);
+});
+
+void test('quote does not inspect secret cards, resources, phase usage or other players’ hidden board values', () => {
+ const { context, custody } = fixture();
+ const expected = quote(context, custody, intent);
+ for (const object of [context, ...context.players, context.board.e])
+ for (const key of [
+ 'hand',
+ 'spice',
+ 'rng',
+ 'shipped',
+ 'noField',
+ 'payment',
+ 'response',
+ ])
+ Object.defineProperty(object, key, {
+ enumerable: true,
+ get() {
+ throw new Error('Caller-owned private field read.');
+ },
+ });
+ Object.defineProperty(context.board.e, 'forces', {
+ get() {
+ throw new Error('Unrelated board read.');
+ },
+ });
+ assert.deepEqual(quote(context, custody, intent), expected);
+});
diff --git a/tests/homeworld-actions-engine.test.ts b/tests/homeworld-actions-engine.test.ts
new file mode 100644
index 00000000..79166fb9
--- /dev/null
+++ b/tests/homeworld-actions-engine.test.ts
@@ -0,0 +1,457 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { runInNewContext } from 'node:vm';
+import { webcrypto } from 'node:crypto';
+import ts from 'typescript';
+import {
+ applyAction,
+ createGame,
+ initializeHomeworldGameForAudit,
+ joinGame,
+ newPlayer,
+ normalizeAutomaticGame,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import { territory, location } from '../game/board';
+import { homeworldGameIntegrity } from '../game/homeworld-game';
+import type { ShipmentPromise } from '../game/shipment-promises';
+
+const emperor = (g: Game) => g.players.find((p) => p.id === 'e')!;
+const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
+
+// Genuine base setup, including physical dealing and traitor selection. Only
+// phase/opportunity positioning below is staged; counters/cards are never minted.
+function fixture(advanced = true) {
+ let g = createGame(
+ 'HOMEWORLDACTIONS',
+ newPlayer('e', 'Emperor', 'emperor'),
+ advanced,
+ );
+ joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
+ g = applyAction(g, 'e', { type: 'homeworlds', enabled: true });
+ for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
+ g = initializeHomeworldGameForAudit(g);
+ while (g.setupStage === 'traitors') {
+ const p = g.players.find((seat) => seat.traitorChoices.length)!;
+ g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
+ }
+ assert.equal(g.status, 'playing');
+ return movement(g);
+}
+function movement(g: Game) {
+ g.phase = 5;
+ g.phaseOpening = null;
+ g.response = null;
+ g.decision = null;
+ g.active = 'e';
+ g.movementRemaining = ['e', 'a'];
+ g.ready = [];
+ for (const p of g.players) {
+ p.shipped = false;
+ p.moved = 0;
+ }
+ homeworldGameIntegrity(g);
+ return g;
+}
+function action(
+ g: Game,
+ origin = 'homeworld:emperor',
+ normal = 2,
+ elite = 0,
+): Action {
+ return {
+ type: 'emperorHomeworldMove',
+ event: viewGame(g, 'e').homeworldMove!.event,
+ origin,
+ normal,
+ elite,
+ };
+}
+function rejects(g: Game, actor: string, command: Action, pattern?: RegExp) {
+ const before = structuredClone(g);
+ if (pattern) assert.throws(() => applyAction(g, actor, command), pattern);
+ else assert.throws(() => applyAction(g, actor, command));
+ assert.deepEqual(g, before);
+}
+function assertPrivate(g: Game) {
+ for (const p of g.players) {
+ const view = viewGame(reload(g), p.id);
+ for (const other of view.players.filter((seat) => seat.id !== p.id)) {
+ assert.equal(other.hand, undefined);
+ assert.equal(other.traitors, undefined);
+ }
+ if (p.id !== 'e') assert.equal(view.homeworldMove, null);
+ }
+}
+function hold(g: Game, effect: string, player = 'e') {
+ const p = g.players.find((seat) => seat.id === player)!;
+ const already = p.hand.find((card) => card.effect === effect);
+ if (already) return already;
+ const source = [
+ g.deck,
+ ...g.players.filter((seat) => seat !== p).map((seat) => seat.hand),
+ ].find((cards) => cards.some((card) => card.effect === effect));
+ assert.ok(source, `Physical ${effect} card exists in the dealt game.`);
+ const index = source.findIndex((card) => card.effect === effect);
+ // The genuine shuffle may deal the desired card to the other player. Swap
+ // custody with one of our dealt cards, preserving the deck and hand sizes.
+ const [card] = source.splice(index, 1, ...p.hand.splice(0, 1));
+ p.hand.push(card);
+ return card;
+}
+function ship(g: Game, extras: Record = {}): Action {
+ return {
+ type: 'ship',
+ territory: 'false_wall_west',
+ sector: territory('false_wall_west').sectors[0],
+ amount: 1,
+ elite: 0,
+ ...extras,
+ };
+}
+
+void test('held-card fixtures recover a unique card dealt to the opponent without changing physical inventory', () => {
+ for (const effect of ['hajr', 'ghola']) {
+ const g = fixture();
+ const inventory = () =>
+ [...g.deck, ...g.players.flatMap((p) => p.hand), ...g.discard].sort(
+ (a, b) => a.id.localeCompare(b.id),
+ );
+ const before = structuredClone(inventory());
+ const handSizes = g.players.map((p) => p.hand.length);
+ const deckSize = g.deck.length;
+ const card = hold(g, effect, 'a');
+ assert.equal(
+ emperor(g).hand.some((held) => held.id === card.id),
+ false,
+ );
+ assert.equal(hold(g, effect).id, card.id);
+ assert.equal(hold(g, effect).id, card.id);
+ assert.deepEqual(inventory(), before);
+ assert.deepEqual(
+ g.players.map((p) => p.hand.length),
+ handSizes,
+ );
+ assert.equal(g.deck.length, deckSize);
+ homeworldGameIntegrity(g);
+ }
+});
+
+void test('actual Emperor transfer preserves native totals, spends one move, seals private cards and rejects a replay after JSON', () => {
+ const g = fixture();
+ const before = structuredClone(g);
+ const command = action(g);
+ const next = applyAction(g, 'e', command);
+ assert.deepEqual(g, before);
+ assert.deepEqual(next.homeworlds!.custody!.salusa, { normal: 2, elite: 5 });
+ assert.equal(emperor(next).reserves, emperor(g).reserves);
+ assert.deepEqual(emperor(next).elites, emperor(g).elites);
+ assert.equal(emperor(next).spice, emperor(g).spice);
+ assert.equal(emperor(next).moved, 1);
+ assert.equal(emperor(next).shipped, true);
+ assert.deepEqual(next.movementRemaining, g.movementRemaining);
+ assert.equal(next.active, 'e');
+ assert.deepEqual(next.deck, g.deck);
+ assert.deepEqual(
+ next.players.map((p) => p.hand),
+ g.players.map((p) => p.hand),
+ );
+ homeworldGameIntegrity(next);
+ assertPrivate(next);
+ rejects(reload(next), 'e', command);
+ rejects(g, 'a', command);
+ rejects(g, 'e', { ...command, event: 'stale' }, /stale/);
+ rejects(g, 'e', { ...command, amount: 2 });
+});
+
+void test('a real Hajr grants a second transfer with a fresh event; no extra transfer or Basic transfer is admitted', () => {
+ let g = fixture();
+ const hajr = hold(g, 'hajr');
+ g = applyAction(g, 'e', { type: 'card', card: hajr.id });
+ assert.ok(g.hajr.includes('e'));
+ const first = action(g);
+ g = applyAction(g, 'e', first);
+ assert.equal(viewGame(g, 'e').homeworldMove!.remaining, 1);
+ rejects(g, 'e', first, /stale/);
+ const second = action(g, 'homeworld:emperor:salusa', 1, 2);
+ g = applyAction(reload(g), 'e', second);
+ assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 1, elite: 3 });
+ assert.equal(emperor(g).moved, 2);
+ assert.equal(g.discard.filter((card) => card.id === hajr.id).length, 1);
+ rejects(g, 'e', action(g));
+ homeworldGameIntegrity(g);
+ const basic = fixture(false);
+ assert.equal(viewGame(basic, 'e').homeworldMove, null);
+ rejects(basic, 'e', { ...first, event: 'basic' });
+});
+
+void test('blocking control and stale physical-source mutations cannot spend the Homeworld movement', () => {
+ const base = fixture();
+ const command = action(base);
+ for (const mutate of [
+ (g: Game) => {
+ g.phaseOpening = { passed: [], initialize: false };
+ },
+ (g: Game) => {
+ g.phase = 4;
+ },
+ (g: Game) => {
+ g.active = 'a';
+ },
+ (g: Game) => {
+ g.homeworlds!.custody!.salusa!.normal = 1;
+ },
+ ]) {
+ const g = reload(base);
+ mutate(g);
+ rejects(g, 'e', command);
+ }
+ // A genuine pending phase-opening interaction is visible as a blocked move.
+ const blocked = reload(base);
+ blocked.phaseOpening = { passed: [], initialize: false };
+ assert.match(
+ viewGame(blocked, 'e').homeworldMove!.blocked!,
+ /current interaction/,
+ );
+});
+
+void test('a later ordinary shipment requires exact dual-Homeworld sources, debits only those physical counters and cannot repeat', () => {
+ let g = fixture();
+ g = applyAction(g, 'e', action(g));
+ // Position the next unused turn, retaining the actual preceding transfer.
+ g = movement(reload(g));
+ g.turn++;
+ const before = structuredClone(g);
+ rejects(g, 'e', ship(g), /Choose.*Homeworld/);
+ for (const homeworldSources of [
+ { 'homeworld:emperor:salusa': { normal: 2, elite: 0 } },
+ { 'homeworld:atreides': { normal: 1, elite: 0 } },
+ { 'homeworld:emperor:salusa': { normal: 0, elite: 1 } },
+ ])
+ rejects(g, 'e', ship(g, { homeworldSources }));
+ const command = ship(g, {
+ homeworldSources: { 'homeworld:emperor:salusa': { normal: 1, elite: 0 } },
+ });
+ g = applyAction(g, 'e', command);
+ assert.equal(emperor(g).reserves, emperor(before).reserves - 1);
+ assert.equal(emperor(g).spice, emperor(before).spice - 2);
+ assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 1, elite: 5 });
+ const key = location(
+ 'false_wall_west',
+ territory('false_wall_west').sectors[0],
+ );
+ assert.equal(emperor(g).forces[key], 1);
+ assert.equal(emperor(g).moved, 0);
+ homeworldGameIntegrity(g);
+ assertPrivate(g);
+ rejects(reload(g), 'e', command);
+});
+
+function stormKilledTransferredSardaukar() {
+ let g = fixture();
+ g = applyAction(g, 'e', action(g, 'homeworld:emperor:salusa', 0, 1));
+ assert.equal(g.homeworlds!.custody!.salusa!.elite, 4);
+ g = movement(reload(g));
+ g = applyAction(
+ g,
+ 'e',
+ ship(g, {
+ territory: 'the_great_flat',
+ sector: 15,
+ elite: 1,
+ homeworldSources: { 'homeworld:emperor': { normal: 0, elite: 1 } },
+ }),
+ );
+ assert.equal(g.homeworlds!.custody!.salusa!.elite, 4);
+ const sector = 15;
+ // Stage only the next Storm opportunity; both casualties and the subsequent
+ // revival are produced by actual dispatcher actions.
+ g.phase = 0;
+ g.active = null;
+ g.storm = sector - 1;
+ g.stormDialers = ['e', 'a'];
+ g.stormDials = {};
+ g.stormPending = null;
+ g.ready = [];
+ g = applyAction(g, 'e', { type: 'stormDial', amount: 1 });
+ g = applyAction(g, 'a', { type: 'stormDial', amount: 0 });
+ for (const id of ['e', 'a']) g = applyAction(g, id, { type: 'ready' });
+ assert.equal(emperor(g).tanks, 1);
+ assert.equal(emperor(g).elites!.tanks, 1);
+ assert.equal(g.homeworlds!.custody!.salusa!.elite, 4);
+ g.phase = 4;
+ g.phaseOpening = null;
+ g.ready = [];
+ return g;
+}
+
+void test('a Sardaukar transferred to Kaitain, shipped, and killed by actual storm dials revives on Salusa exactly once', () => {
+ let g = stormKilledTransferredSardaukar();
+ const before = structuredClone(g);
+ g = applyAction(reload(g), 'e', { type: 'revive', amount: 1, elite: 1 });
+ assert.equal(emperor(g).tanks, 0);
+ assert.equal(emperor(g).elites!.tanks, 0);
+ assert.equal(emperor(g).elites!.revived, 1);
+ assert.equal(emperor(g).reserves, emperor(before).reserves + 1);
+ assert.equal(
+ emperor(g).elites!.reserves,
+ emperor(before).elites!.reserves + 1,
+ );
+ assert.equal(g.homeworlds!.custody!.salusa!.elite, 5);
+ assert.equal(emperor(g).revived, 1);
+ homeworldGameIntegrity(g);
+ assertPrivate(g);
+ rejects(reload(g), 'e', { type: 'revive', amount: 1, elite: 1 });
+});
+
+// Observe the real production dispatcher at its existing saved-frame boundary.
+// No production export or mutation is introduced for these recovery assertions.
+let privateEngine:
+ | {
+ applyActionInner: typeof applyAction;
+ findShipmentCompletion: (
+ g: Game,
+ p: Game['players'][number],
+ promises: ShipmentPromise[],
+ ) => { actions: Action[] } | null;
+ }
+ | undefined;
+function observedEngine() {
+ if (!privateEngine) {
+ const exports = {};
+ runInNewContext(
+ ts.transpileModule(
+ readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
+ '\nexport { applyActionInner, findShipmentCompletion };\n',
+ {
+ compilerOptions: {
+ module: ts.ModuleKind.CommonJS,
+ target: ts.ScriptTarget.ES2022,
+ },
+ },
+ ).outputText,
+ {
+ exports,
+ require: createRequire(new URL('../game/engine.ts', import.meta.url)),
+ structuredClone,
+ JSON,
+ TextEncoder,
+ crypto: webcrypto,
+ },
+ );
+ privateEngine = exports as NonNullable;
+ }
+ return privateEngine;
+}
+
+void test('actual Ghola retirement binds Homeworld allocation, restores JSON once, and rejects a same-total Salusa rewrite', () => {
+ const g = stormKilledTransferredSardaukar();
+ const ghola = hold(g, 'ghola');
+ const command: Action = { type: 'card', card: ghola.id, amount: 1, elite: 1 };
+ const before = structuredClone(g);
+ const frame = reload(observedEngine().applyActionInner(g, 'e', command));
+ assert.deepEqual(g, before);
+ assert.equal(
+ frame.pendingTreacheryDiscard?.continuation.kind,
+ 'ordinaryCardDiscard',
+ );
+ const receipt = frame.pendingTreacheryDiscard!.continuation;
+ assert.equal(
+ receipt.kind === 'ordinaryCardDiscard' && receipt.effect,
+ 'ghola',
+ );
+ assert.equal(emperor(frame).tanks, 0);
+ assert.equal(emperor(frame).elites!.tanks, 0);
+ assert.equal(emperor(frame).elites!.revived, 1);
+ assert.equal(frame.homeworlds!.custody!.salusa!.elite, 5);
+ assert.equal(frame.discard.filter((c) => c.id === ghola.id).length, 1);
+ const paused = structuredClone(frame);
+ for (const id of ['e', 'a']) {
+ const view = viewGame(reload(frame), id);
+ assert.equal(view.automaticContinuationPending, true);
+ assert.equal('pendingTreacheryDiscard' in view, false);
+ assert.equal(view.shipmentCompletion, null);
+ }
+ assert.deepEqual(frame, paused);
+ const done = normalizeAutomaticGame(reload(frame));
+ assert.equal(done.pendingTreacheryDiscard, null);
+ assert.deepEqual(done.homeworlds, frame.homeworlds);
+ assert.deepEqual(done.players, frame.players);
+ assert.deepEqual(done.log, frame.log);
+ assert.deepEqual(done.deck, frame.deck);
+ assert.deepEqual(done.discard, frame.discard);
+ assert.deepEqual(reload(normalizeAutomaticGame(reload(done))), reload(done));
+ assert.deepEqual(reload(applyAction(g, 'e', command)), reload(done));
+ rejects(done, 'e', command);
+ const corrupt = reload(frame);
+ // A physically valid inter-world reassignment preserves every aggregate count
+ // but contradicts the already committed Ghola receipt.
+ corrupt.homeworlds!.custody!.salusa!.elite--;
+ homeworldGameIntegrity(corrupt);
+ assert.deepEqual(corrupt.players, frame.players);
+ const corruptBefore = structuredClone(corrupt);
+ assert.throws(() => viewGame(corrupt, 'e'));
+ assert.throws(() => normalizeAutomaticGame(corrupt));
+ rejects(corrupt, 'e', { type: 'ready' });
+ assert.deepEqual(corrupt, corruptBefore);
+});
+
+void test('shipment completion emits an executable mixed-Homeworld source witness without enabling Advanced Truthtrance promises', () => {
+ let g = fixture();
+ g = applyAction(g, 'e', action(g, 'homeworld:emperor', 14, 0));
+ g = movement(reload(g));
+ const promises: ShipmentPromise[] = [
+ {
+ turn: g.turn,
+ player: 'e',
+ asker: 'a',
+ territory: 'carthag',
+ minimum: 2,
+ answer: true,
+ },
+ ];
+ const before = structuredClone(g);
+ const proof = observedEngine().findShipmentCompletion(
+ g,
+ emperor(g),
+ promises,
+ );
+ assert.ok(proof);
+ assert.deepEqual(
+ observedEngine().findShipmentCompletion(reload(g), emperor(g), promises),
+ proof,
+ );
+ assert.deepEqual(g, before);
+ assert.equal(proof.actions.length, 1);
+ assert.deepEqual(proof.actions[0].homeworldSources, {
+ 'homeworld:emperor': { normal: 1, elite: 0 },
+ 'homeworld:emperor:salusa': { normal: 1, elite: 0 },
+ });
+ const moved = applyAction(g, 'e', proof.actions[0]);
+ assert.equal(emperor(moved).reserves, emperor(g).reserves - 2);
+ assert.equal(moved.homeworlds!.custody!.salusa!.normal, 13);
+ homeworldGameIntegrity(moved);
+ // The witness is private-helper readiness only. The real declaration keeps
+ // its deliberate Basic-only promise gate; no synthetic live promise is saved.
+ const card = hold(g, 'truthtrance', 'a');
+ g = applyAction(g, 'a', { type: 'card', card: card.id });
+ g = applyAction(g, 'e', { type: 'truthPass' });
+ rejects(
+ g,
+ 'a',
+ {
+ type: 'truthAsk',
+ question: {
+ kind: 'shipment',
+ target: 'e',
+ territory: 'carthag',
+ minimum: 2,
+ },
+ },
+ /Basic/,
+ );
+});
diff --git a/tests/homeworld-advisor-ambassador.test.ts b/tests/homeworld-advisor-ambassador.test.ts
new file mode 100644
index 00000000..a9de507b
--- /dev/null
+++ b/tests/homeworld-advisor-ambassador.test.ts
@@ -0,0 +1,383 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeHomeworldGameForAudit,
+ joinGame,
+ newPlayer,
+ normalizeAutomaticGame,
+ viewGame,
+ type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
+import {
+ quoteGuildAmbassadorAdvisor,
+ quoteGuildAmbassadorShipment,
+} from '../game/guild-ambassador';
+import { validateGuildAmbassadorArrivalContext } from '../game/guild-ambassador-continuation';
+import { homeworldGameIntegrity } from '../game/homeworld-game';
+
+const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
+const bg = (g: Game) => g.players.find((p) => p.id === 'b')!;
+const ec = (g: Game) => g.players.find((p) => p.id === 'e')!;
+
+/** Genuine setup/decks; a conserved phase-five board and one legal Ambassador
+ * placement position the interaction without staging a pending decision. */
+function fixture(advanced = true, reserves = 11) {
+ let g = createGame(
+ 'HOMEADVISORAMB',
+ newPlayer('e', 'Ecaz', 'ecaz'),
+ advanced,
+ );
+ joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
+ joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
+ g = applyAction(g, 'e', { 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 < 40; 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);
+ g = next;
+ }
+ assert.equal(g.status, 'playing');
+ Object.assign(g, {
+ phase: 5,
+ phaseOpening: null,
+ response: null,
+ decision: null,
+ active: 'a',
+ order: ['a', 'e', 'b'],
+ movementRemaining: ['a', 'e', 'b'],
+ storm: 18,
+ ready: [],
+ });
+ for (const p of g.players) {
+ g.deck.push(...p.hand.splice(0));
+ p.forces = { 'polar_sink:0': 20 - p.reserves };
+ delete p.advisors;
+ p.shipped = false;
+ p.moved = 0;
+ }
+ const entrant = g.players.find((p) => p.id === 'a')!;
+ entrant.forces = { 'polar_sink:0': 9, 'imperial_basin:10': 1 };
+ entrant.shipped = true;
+ bg(g).reserves = reserves;
+ bg(g).forces = { 'polar_sink:0': 20 - reserves };
+ const state = createAmbassadors(() => 0.2);
+ const guild = state.tokens.find((t) => t.effect === 'guild')!;
+ state.cohort = [
+ guild.id,
+ ...state.tokens
+ .filter((t) => t.effect !== 'ecaz' && t.id !== guild.id)
+ .slice(0, 4)
+ .map((t) => t.id),
+ ];
+ for (const token of state.tokens) {
+ token.zone =
+ token.effect === 'ecaz' || state.cohort.includes(token.id)
+ ? 'supply'
+ : 'pool';
+ token.location = null;
+ }
+ g.ecazAmbassadors = placeAmbassador(state, guild.id, {
+ turn: g.turn,
+ availableSpice: ec(g).spice,
+ destination: {
+ id: 'arrakeen',
+ stronghold: true,
+ inStorm: false,
+ allowed: true,
+ },
+ }).state;
+ homeworldGameIntegrity(g);
+ return g;
+}
+
+function arrive(g: Game) {
+ g = applyAction(g, 'a', {
+ type: 'move',
+ forces: { 'imperial_basin:10': 1 },
+ territory: 'arrakeen',
+ sector: 10,
+ });
+ assert.equal(g.pendingAmbassador?.stage, 'offer');
+ g = applyAction(g, 'e', {
+ type: 'decision',
+ event: g.pendingAmbassador!.event,
+ trigger: true,
+ beneficiary: 'e',
+ });
+ assert.equal(g.pendingAmbassador?.stage, 'ship');
+ return applyAction(g, 'e', {
+ type: 'decision',
+ event: g.pendingAmbassador!.event,
+ amount: 2,
+ territory: 'hagga_basin',
+ sector: 12,
+ });
+}
+function shipment(g: Game) {
+ return quoteGuildAmbassadorShipment(g, 'e', {
+ type: 'decision',
+ amount: 2,
+ territory: 'hagga_basin',
+ sector: 12,
+ });
+}
+function assertConserved(g: Game) {
+ homeworldGameIntegrity(g);
+ for (const p of g.players)
+ assert.equal(
+ p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
+ 20,
+ );
+}
+function allow(g: Game) {
+ for (let i = 0; g.response && i < 20; i++) {
+ const p = g.players.find((p) => !g.response!.passed.includes(p.id));
+ assert.ok(p);
+ g = applyAction(reload(g), p.id, { type: 'passResponse' });
+ }
+ assert.equal(g.response, null);
+ return g;
+}
+
+void test('Ambassador quote applies high Wallach one-or-two Polar Sink and one-only Advanced alternative without mutation', () => {
+ for (const advanced of [false, true]) {
+ const g = fixture(advanced);
+ const order = shipment(g);
+ const before = reload(g);
+ for (const amount of [1, 2] as const)
+ assert.equal(
+ quoteGuildAmbassadorAdvisor(g, 'b', order, {
+ type: 'decision',
+ amount,
+ }).amount,
+ amount,
+ );
+ assert.equal(
+ quoteGuildAmbassadorAdvisor(g, 'b', order, { type: 'decision' }).amount,
+ 1,
+ );
+ if (advanced) {
+ assert.equal(
+ quoteGuildAmbassadorAdvisor(g, 'b', order, {
+ type: 'decision',
+ accompany: true,
+ }).amount,
+ 1,
+ );
+ assert.throws(
+ () =>
+ quoteGuildAmbassadorAdvisor(g, 'b', order, {
+ type: 'decision',
+ accompany: true,
+ amount: 2,
+ }),
+ /two requires/,
+ );
+ }
+ for (const amount of [0, 3, 1.5, '2', null])
+ assert.throws(() =>
+ quoteGuildAmbassadorAdvisor(g, 'b', order, {
+ type: 'decision',
+ amount,
+ }),
+ );
+ assert.deepEqual(g, before);
+ }
+});
+
+void test('low Wallach suppresses all free Ambassador accompaniment while preserving direct shipment joining existing advisors', () => {
+ const g = fixture(true, 10);
+ const order = shipment(g);
+ for (const accompany of [false, true])
+ assert.throws(
+ () =>
+ quoteGuildAmbassadorAdvisor(g, 'b', order, {
+ type: 'decision',
+ accompany,
+ }),
+ /Low-population Wallach/,
+ );
+ bg(g).forces = { 'hagga_basin:12': 10 };
+ bg(g).advisors = { hagga_basin: {} };
+ ec(g).forces['hagga_basin:12'] = 1;
+ ec(g).reserves--;
+ const direct = quoteGuildAmbassadorShipment(g, 'b', {
+ type: 'decision',
+ amount: 2,
+ territory: 'hagga_basin',
+ sector: 12,
+ });
+ assert.equal(direct.advisors, true);
+ assert.equal(direct.amount, 2);
+});
+
+void test('actual Basic and Advanced Ambassador shipments send one batch of two from 11 to 9 and restore the original entrant', () => {
+ for (const advanced of [false, true]) {
+ let g = arrive(fixture(advanced));
+ assert.equal(g.decision?.kind, 'advisor');
+ const source = structuredClone(g.pendingAmbassador!.shipmentReceipt!.order);
+ const balances = g.players.map((p) => p.spice);
+ const command = {
+ type: 'decision',
+ accept: true,
+ amount: 2,
+ accompany: false,
+ };
+ g = applyAction(reload(g), 'b', command);
+ g = allow(g);
+ assert.equal(bg(g).reserves, 9);
+ assert.equal(bg(g).forces['polar_sink:0'], 11);
+ assert.equal(ec(g).forces['hagga_basin:12'], source.amount);
+ assert.deepEqual(
+ g.players.map((p) => p.spice),
+ balances,
+ );
+ assert.equal(g.pendingAmbassador, null);
+ assert.equal(g.decision, null);
+ assert.equal(g.active, 'a');
+ assert.equal(g.players.find((p) => p.id === 'a')!.moved, 1);
+ assert.equal(ec(g).shipped, false);
+ assert.equal(bg(g).shipped, false);
+ assertConserved(g);
+ assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
+ const before = reload(g);
+ assert.throws(() => applyAction(g, 'b', command));
+ assert.deepEqual(g, before);
+ }
+});
+
+void test('actual low Wallach Ambassador shipment continues automatically without unavailable advisor input', () => {
+ const g = arrive(fixture(true, 10));
+ assert.equal(g.pendingAmbassador, null);
+ assert.equal(g.decision, null);
+ assert.equal(bg(g).reserves, 10);
+ assert.equal(ec(g).forces['hagga_basin:12'], 2);
+ assert.equal(g.active, 'a');
+ assertConserved(g);
+});
+
+void test('restored Ambassador choice rechecks Wallach population and does not replay the committed shipment', () => {
+ const g = arrive(fixture());
+ assert.equal(g.decision?.kind, 'advisor');
+ bg(g).reserves--;
+ bg(g).forces['polar_sink:0']++;
+ assertConserved(g);
+ const before = reload(g);
+ assert.throws(
+ () =>
+ applyAction(g, 'b', {
+ type: 'decision',
+ accept: true,
+ amount: 2,
+ accompany: false,
+ }),
+ /Low-population Wallach/,
+ );
+ assert.deepEqual(g, before);
+});
+
+void test('historical two-advisor receipt survives threshold crossing but rejects non-Sink or module-off fabrication', () => {
+ const g = arrive(fixture());
+ const entry = g.pendingAmbassador!;
+ bg(g).reserves -= 2;
+ bg(g).forces['polar_sink:0'] += 2;
+ entry.shipmentReceipt!.next = 'finish';
+ entry.shipmentReceipt!.advisorArrival = {
+ player: 'b',
+ territory: 'polar_sink',
+ sector: 0,
+ amount: 2,
+ elite: 0,
+ };
+ const quote = (state: Game) =>
+ validateGuildAmbassadorArrivalContext(state, entry.event, 'finish');
+ assert.equal(quote(reload(g)).advisorArrival!.amount, 2);
+ for (const mutate of [
+ (state: Game) => {
+ delete state.homeworlds;
+ },
+ (state: Game) => {
+ Object.assign(state.pendingAmbassador!.shipmentReceipt!.advisorArrival!, {
+ territory: 'hagga_basin',
+ sector: 12,
+ });
+ },
+ (state: Game) => {
+ Object.assign(state.pendingAmbassador!.shipmentReceipt!.advisorArrival!, {
+ amount: 3,
+ });
+ },
+ ]) {
+ const bad = reload(g);
+ mutate(bad);
+ const before = reload(bad);
+ assert.throws(() => quote(bad));
+ assert.deepEqual(bad, before);
+ }
+});
+
+void test('a real ordinary Karama cancels the entire two-advisor grant but never reverts its Ambassador parent shipment', () => {
+ let g = fixture();
+ const index = g.deck.findIndex((card) => card.effect === 'karama');
+ assert.ok(index >= 0);
+ const [karama] = g.deck.splice(index, 1);
+ ec(g).hand.push(karama);
+ g = arrive(g);
+ const reserves = ec(g).reserves;
+ g = applyAction(reload(g), 'b', {
+ type: 'decision',
+ accept: true,
+ amount: 2,
+ accompany: false,
+ });
+ assert.equal(g.response?.kind, 'advisor');
+ assert.equal(g.response?.amount, 2);
+ assert.equal(bg(g).reserves, 11);
+ g = applyAction(reload(g), 'e', {
+ type: 'card',
+ mode: 'cancel',
+ card: karama.id,
+ });
+ assert.equal(bg(g).reserves, 11);
+ assert.equal(bg(g).forces['polar_sink:0'], 9);
+ assert.equal(ec(g).reserves, reserves);
+ assert.equal(ec(g).forces['hagga_basin:12'], 2);
+ assert.equal(g.discard.filter((card) => card.id === karama.id).length, 1);
+ assert.equal(g.pendingAmbassador, null);
+ assert.equal(g.active, 'a');
+ assertConserved(g);
+});
+
+void test('Ambassador high-Wallach choices from every AI difficulty execute from their projected private seat', () => {
+ const g = arrive(fixture());
+ for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const view = viewGame(reload(g), 'b');
+ view.players.find((p) => p.id === 'b')!.bot = level;
+ const before = structuredClone(view);
+ const actions = botActions(view);
+ assert.ok(actions.length > 0);
+ assert.deepEqual(view, before);
+ for (const action of actions) {
+ const next = applyAction(reload(g), 'b', action);
+ assertConserved(next);
+ for (const other of view.players.filter((p) => p.id !== 'b')) {
+ assert.equal(other.hand, undefined);
+ assert.equal(other.traitors, undefined);
+ }
+ }
+ }
+});
diff --git a/tests/homeworld-arrival.test.ts b/tests/homeworld-arrival.test.ts
new file mode 100644
index 00000000..20af2f7a
--- /dev/null
+++ b/tests/homeworld-arrival.test.ts
@@ -0,0 +1,212 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createGame, newPlayer, type Game } from '../game/engine';
+import { createHomeworldCustody } from '../game/homeworld-custody';
+import { homeworldContext } from '../game/homeworld-game';
+import {
+ homeworldArrivalSignature,
+ appendHomeworldArrivalAmbassador,
+ completeHomeworldArrivalAmbassador,
+ validateHomeworldArrival,
+} from '../game/homeworld-arrival';
+import {
+ makeHomeworldVictoryReturn,
+ homeworldVictoryObligationSignature,
+ homeworldVictoryOfferSignature,
+} from '../game/homeworld-victory-return';
+import { ambassadorPhaseAllowed } from '../game/ambassador-phase';
+
+function fixture() {
+ const g = createGame('ARRIVAL', newPlayer('a', 'Atreides', 'atreides'), true);
+ g.players.push(
+ newPlayer('h', 'Harkonnen', 'harkonnen'),
+ newPlayer('e', 'Ecaz', 'ecaz'),
+ newPlayer('b', 'BG', 'beneGesserit'),
+ );
+ g.turn = 2;
+ g.phase = 6;
+ g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
+ g.lastBattleContext = {
+ event: 'won:2:1',
+ turn: 2,
+ territory: 'arrakeen',
+ combatants: ['a', 'h'],
+ winner: 'a',
+ result: 'normal',
+ };
+ g.lastBattleContext.caladanReinforcement = {
+ event: g.lastBattleContext.event,
+ completed: false,
+ stage: 'arrival',
+ signature: homeworldVictoryObligationSignature(
+ g.lastBattleContext,
+ false,
+ 'arrival',
+ ),
+ };
+ const frame = makeHomeworldVictoryReturn({
+ event: 'won:2:1',
+ turn: 2,
+ player: 'a',
+ territory: 'arrakeen',
+ result: 'normal',
+ });
+ const offer = { population: 6, survivors: 1, blocked: null };
+ frame.offer = {
+ ...offer,
+ signature: homeworldVictoryOfferSignature(frame, offer),
+ };
+ frame.stage = 'arrival';
+ frame.destination = 'arrakeen:10';
+ frame.ambassadors = [];
+ frame.arrivalSignature = homeworldArrivalSignature(frame);
+ g.homeworldVictoryReinforcement = frame;
+ g.pendingAmbassador = {
+ event: 'ambassador:2:6:1',
+ owner: 'e',
+ entrant: 'a',
+ territory: 'arrakeen',
+ sector: 10,
+ turn: 2,
+ phase: 6,
+ victoryEvent: frame.event,
+ token: 'guild',
+ stage: 'offer',
+ copyChoices: [],
+ resume: 'none',
+ };
+ appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador);
+ return g;
+}
+
+void test('shared arrival preserves the persisted signature format while binding a distinct victory source', () => {
+ const g = fixture();
+ const frame = g.homeworldVictoryReinforcement!;
+ assert.equal(
+ frame.arrivalSignature,
+ JSON.stringify([
+ 'homeworldRevivalArrival',
+ frame.signature,
+ 'arrakeen:10',
+ [['ambassador:2:6:1', 'a', 'arrakeen:10', null, false]],
+ ]),
+ );
+ validateHomeworldArrival(g, frame, 'victoryEvent');
+ assert.equal(ambassadorPhaseAllowed(g), true);
+ assert.equal(
+ ambassadorPhaseAllowed(JSON.parse(JSON.stringify(g)) as Game),
+ true,
+ );
+ assert.throws(() => validateHomeworldArrival(g, frame, 'revivalEvent'));
+});
+
+void test('victory phase permission needs both the original battle obligation and exact arrival tag', () => {
+ const original = fixture();
+ for (const mutate of [
+ (g: Game) => {
+ delete g.lastBattleContext!.caladanReinforcement;
+ },
+ (g: Game) => {
+ g.lastBattleContext!.winner = 'h';
+ },
+ (g: Game) => {
+ g.lastBattleContext!.event = 'other';
+ },
+ (g: Game) => {
+ delete g.pendingAmbassador!.victoryEvent;
+ },
+ (g: Game) => {
+ g.pendingAmbassador!.revivalEvent = g.pendingAmbassador!.victoryEvent;
+ },
+ (g: Game) => {
+ delete g.homeworldVictoryReinforcement;
+ },
+ ]) {
+ const g = structuredClone(original);
+ mutate(g);
+ const before = JSON.stringify(g);
+ assert.equal(ambassadorPhaseAllowed(g), false);
+ assert.equal(JSON.stringify(g), before);
+ }
+});
+
+void test('shared completion cannot be skipped or replayed and actual BG child keeps its own source tag', () => {
+ const g = fixture();
+ const frame = g.homeworldVictoryReinforcement!;
+ const parent = g.pendingAmbassador!;
+ const child = {
+ ...parent,
+ event: 'ambassador:2:6:2',
+ entrant: 'b',
+ territory: 'carthag',
+ sector: 11,
+ guildAdvisorOrigin: {
+ event: parent.event,
+ player: 'h',
+ territory: 'carthag',
+ sector: 11,
+ },
+ };
+ const before = JSON.stringify(frame);
+ assert.throws(() =>
+ appendHomeworldArrivalAmbassador(frame, child, parent.event),
+ );
+ assert.equal(JSON.stringify(frame), before);
+ g.pendingAmbassador = null;
+ assert.throws(() => validateHomeworldArrival(g, frame, 'victoryEvent'));
+ frame.stage = 'complete';
+ assert.throws(() => validateHomeworldArrival(g, frame, 'victoryEvent'));
+ frame.stage = 'arrival';
+ g.pendingAmbassador = parent;
+ completeHomeworldArrivalAmbassador(frame, parent.event);
+ assert.throws(() => completeHomeworldArrivalAmbassador(frame, parent.event));
+ appendHomeworldArrivalAmbassador(frame, child, parent.event);
+ g.pendingAmbassador = child;
+ assert.equal(ambassadorPhaseAllowed(g), true);
+ completeHomeworldArrivalAmbassador(frame, child.event);
+ g.pendingAmbassador = null;
+ frame.stage = 'complete';
+ validateHomeworldArrival(g, frame, 'victoryEvent');
+ g.pendingAmbassador = parent;
+ assert.throws(() => validateHomeworldArrival(g, frame, 'victoryEvent'));
+});
+
+void test('arrival signatures reject malformed histories without depending on forces or private inventories', () => {
+ const g = fixture();
+ const frame = g.homeworldVictoryReinforcement!;
+ for (const p of g.players)
+ for (const key of [
+ 'hand',
+ 'spice',
+ 'forces',
+ 'reserves',
+ 'tanks',
+ 'traitors',
+ ])
+ Object.defineProperty(p, key, {
+ get() {
+ throw new Error(`unrelated ${key}`);
+ },
+ });
+ validateHomeworldArrival(g, frame, 'victoryEvent');
+ for (const mutate of [
+ (value: typeof frame) => {
+ value.ambassadors![0].completed = true;
+ },
+ (value: typeof frame) => {
+ value.ambassadors![0].parent = 'self';
+ },
+ (value: typeof frame) => {
+ value.ambassadors![0].destination = 'arrakeen:9';
+ },
+ (value: typeof frame) => {
+ value.ambassadors!.push({ ...value.ambassadors![0] });
+ },
+ ]) {
+ const copy = structuredClone(frame);
+ mutate(copy);
+ const before = JSON.stringify(copy);
+ assert.throws(() => validateHomeworldArrival(g, copy, 'victoryEvent'));
+ assert.equal(JSON.stringify(copy), before);
+ }
+});
diff --git a/tests/homeworld-basic-counters.test.ts b/tests/homeworld-basic-counters.test.ts
new file mode 100644
index 00000000..194af59c
--- /dev/null
+++ b/tests/homeworld-basic-counters.test.ts
@@ -0,0 +1,314 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeBaseGameForAudit,
+ initializeHomeworldGameForAudit,
+ joinGame,
+ newPlayer,
+ normalizeAutomaticGame,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import type { Casualties } from '../game/combat';
+
+const sum = (counts: Record) =>
+ Object.values(counts).reduce((a, b) => a + b, 0);
+const player = (g: Game, id = 'p') => g.players.find((p) => p.id === id)!;
+const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
+
+/** Real Basic setup first; only the later phase, board and Tanks position is staged. */
+function setup(faction: 'emperor' | 'fremen', homeworlds = true) {
+ let g = createGame(
+ 'BASICHOMECOUNTERS',
+ newPlayer('p', faction, faction),
+ false,
+ );
+ joinGame(g, newPlayer('q', 'Guild opponent', 'guild'));
+ if (homeworlds)
+ g = applyAction(g, 'p', { type: 'homeworlds', enabled: true });
+ for (const id of ['p', 'q']) g = applyAction(g, id, { type: 'ready' });
+ g = homeworlds
+ ? initializeHomeworldGameForAudit(g)
+ : initializeBaseGameForAudit(g);
+ for (let step = 0; g.status === 'setup' && step < 20; step++) {
+ let progressed = false;
+ for (const p of g.players) {
+ const view = viewGame(g, p.id);
+ view.players.find((v) => v.id === p.id)!.bot = 'Easy';
+ const action = botActions(view)[0];
+ if (!action) continue;
+ g = applyAction(g, p.id, action);
+ progressed = true;
+ break;
+ }
+ assert.ok(progressed);
+ }
+ assert.equal(g.status, 'playing');
+ // Return initial Arrakis counters and cards to their actual source piles.
+ for (const p of g.players) {
+ p.reserves += sum(p.forces);
+ p.forces = {};
+ if (p.elites) {
+ p.elites.reserves += sum(p.elites.forces);
+ p.elites.forces = {};
+ }
+ g.deck.push(...p.hand);
+ p.hand = [];
+ }
+ Object.assign(g, {
+ turn: 2,
+ phase: 4,
+ storm: 18,
+ active: null,
+ order: ['p', 'q'],
+ ready: [],
+ });
+ return g;
+}
+function inventory(g: Game) {
+ for (const p of g.players) {
+ assert.equal(p.reserves + p.tanks + sum(p.forces), 20);
+ if (p.elites) {
+ assert.equal(
+ p.elites.reserves + p.elites.tanks + sum(p.elites.forces),
+ p.faction === 'emperor' ? 5 : 3,
+ );
+ assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
+ for (const [key, n] of Object.entries(p.elites.forces))
+ assert.ok(n <= p.forces[key]);
+ }
+ }
+ const physical = [
+ ...g.deck,
+ ...g.discard,
+ ...g.players.flatMap((p) => p.hand),
+ ];
+ assert.equal(new Set(physical.map((c) => c.id)).size, physical.length);
+ assert.doesNotThrow(() => viewGame(g, 'p'));
+ assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
+}
+function reject(g: Game, action: Action) {
+ const before = structuredClone(g);
+ assert.throws(() => applyAction(g, 'p', action));
+ assert.deepEqual(g, before);
+}
+function battle(faction: 'emperor' | 'fremen', homeworlds = true) {
+ let g = setup(faction, homeworlds);
+ Object.assign(g, { phase: 6, active: 'p' });
+ Object.assign(player(g), { reserves: 17, forces: { 'arrakeen:10': 3 } });
+ if (player(g).elites) {
+ player(g).elites!.reserves--;
+ player(g).elites!.forces['arrakeen:10'] = 1;
+ }
+ Object.assign(player(g, 'q'), { reserves: 19, forces: { 'arrakeen:10': 1 } });
+ inventory(g);
+ g = applyAction(g, 'p', {
+ type: 'chooseBattle',
+ territory: 'arrakeen',
+ target: 'q',
+ });
+ assert.equal(
+ g.response,
+ null,
+ 'Basic physical tracking must not create Advanced power responses.',
+ );
+ assert.equal(g.battle!.preparation ?? null, null);
+ return g;
+}
+function resolve(g: Game, ownLeader?: string, rivalLeader?: string) {
+ const own =
+ ownLeader ??
+ player(g)
+ .leaders.slice()
+ .sort((a, b) => b.strength - a.strength)[0].id;
+ const rival =
+ rivalLeader ??
+ player(g, 'q')
+ .leaders.slice()
+ .sort((a, b) => a.strength - b.strength)[0].id;
+ g = applyAction(g, 'p', {
+ type: 'battlePlan',
+ leader: own,
+ dial: 1,
+ support: 0,
+ });
+ g = applyAction(g, 'q', {
+ type: 'battlePlan',
+ leader: rival,
+ dial: 0,
+ support: 0,
+ });
+ g = applyAction(g, 'p', { type: 'traitorCall', call: false });
+ return applyAction(g, 'q', { type: 'traitorCall', call: false });
+}
+function tanks(faction: 'emperor' | 'fremen', homeworlds = true) {
+ const g = setup(faction, homeworlds);
+ const p = player(g);
+ p.reserves -= 3;
+ p.tanks = 3;
+ if (p.elites) {
+ p.elites.reserves -= 3;
+ p.elites.tanks = 3;
+ }
+ inventory(g);
+ return g;
+}
+function holdGhola(g: Game) {
+ const index = g.deck.findIndex((card) => card.effect === 'ghola');
+ assert.ok(index >= 0);
+ const [card] = g.deck.splice(index, 1);
+ player(g).hand.push(card);
+ return card.id;
+}
+
+void test('Basic Homeworld Fedaykin and Sardaukar expose strength one and offer either physical counter as one winning casualty', () => {
+ for (const faction of ['emperor', 'fremen'] as const) {
+ const before = battle(faction);
+ const forces = viewGame(before, 'p').battle!.ownForces!;
+ assert.equal(forces.normal, 2);
+ assert.equal(forces.elite, 1);
+ assert.equal(forces.eliteStrength, 1);
+ assert.equal(forces.freeSupport, true);
+ reject(before, {
+ type: 'battlePlan',
+ leader: player(before).leaders[0].id,
+ dial: 4,
+ support: 0,
+ });
+ const g = resolve(before);
+ assert.equal(g.decision?.kind, 'battleLosses');
+ if (g.decision?.kind !== 'battleLosses')
+ throw Error('Missing typed casualty decision.');
+ assert.deepEqual(
+ g.decision.options
+ .map(({ normal, elite }) => [normal, elite])
+ .sort((a, b) => a[0] - b[0]),
+ [
+ [0, 1],
+ [1, 0],
+ ],
+ );
+ assert.equal(player(g).tanks, 0);
+ for (let choice = 0; choice < g.decision.options.length; choice++) {
+ const losses: Casualties = g.decision.options[choice];
+ const done = applyAction(reload(g), 'p', { type: 'decision', choice });
+ assert.equal(player(done).tanks, 1);
+ assert.equal(player(done).elites!.tanks, losses.elite);
+ assert.equal(sum(player(done).forces), 2);
+ assert.equal(
+ player(done).spice,
+ player(before).spice,
+ 'Basic forces consume no spice support.',
+ );
+ inventory(done);
+ }
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(g, 'p');
+ view.players.find((p) => p.id === 'p')!.bot = difficulty;
+ const candidates = botActions(view);
+ assert.ok(candidates.some((a) => a.type === 'decision'));
+ for (const action of candidates)
+ assert.doesNotThrow(() => applyAction(g, 'p', action));
+ }
+ }
+});
+
+void test('Basic Fremen cannot turn a one-strength dial into a tied win through physical star metadata', () => {
+ const g = battle('fremen');
+ const own = player(g).leaders.find((l) => l.strength === 3)!;
+ const rival = player(g, 'q').leaders.find((l) => l.strength === 5)!;
+ const done = resolve(g, own.id, rival.id);
+ assert.equal(done.lastBattleContext!.winner, 'q');
+ assert.equal(player(done).tanks, 3);
+ assert.equal(player(done).elites!.tanks, 1);
+ inventory(done);
+});
+
+void test('Basic Homeworld normal revival permits a second and third special counter after a genuine first revival', () => {
+ for (const faction of ['emperor', 'fremen'] as const) {
+ let g = tanks(faction);
+ const initialSpice = player(g).spice;
+ g = applyAction(g, 'p', { type: 'revive', amount: 1, elite: 1 });
+ assert.equal(player(g).elites!.revived, 1);
+ assert.equal(viewGame(g, 'p').revival.eliteRemaining, 2);
+ assert.equal(viewGame(g, 'p').revival.forcesRemaining, 2);
+ g = applyAction(reload(g), 'p', { type: 'revive', amount: 2, elite: 2 });
+ assert.equal(player(g).tanks, 0);
+ assert.equal(player(g).elites!.tanks, 0);
+ assert.equal(player(g).elites!.revived, 3);
+ assert.equal(player(g).revived, 3);
+ assert.equal(
+ player(g).spice,
+ initialSpice - (faction === 'emperor' ? 4 : 0),
+ );
+ assert.equal(viewGame(g, 'p').revival.eliteRemaining, 0);
+ inventory(g);
+ }
+});
+
+void test('Basic Homeworld Ghola preserves two further star identities after normal revival without spending another normal allowance', () => {
+ for (const faction of ['emperor', 'fremen'] as const) {
+ let g = tanks(faction);
+ const card = holdGhola(g);
+ g = applyAction(g, 'p', { type: 'revive', amount: 1, elite: 1 });
+ const view = viewGame(g, 'p');
+ assert.equal(view.ghola.available, true);
+ assert.equal(view.ghola.maxForces, 2);
+ assert.equal(view.ghola.eliteRemaining, 2);
+ const before = structuredClone(player(g));
+ g = applyAction(reload(g), 'p', {
+ type: 'card',
+ card,
+ amount: 2,
+ elite: 2,
+ });
+ assert.equal(player(g).tanks, 0);
+ assert.equal(player(g).elites!.tanks, 0);
+ assert.equal(player(g).elites!.revived, 3);
+ assert.equal(player(g).revived, before.revived);
+ assert.equal(player(g).freeForcesRevived, before.freeForcesRevived);
+ assert.equal(player(g).spice, before.spice);
+ assert.equal(g.discard.filter((c) => c.id === card).length, 1);
+ assert.equal(
+ player(g).hand.some((c) => c.id === card),
+ false,
+ );
+ inventory(g);
+ }
+});
+
+void test('Basic Homeworld default elite allocation retains a wholly special Tanks group and rejects excess typed quantities atomically', () => {
+ let g = tanks('fremen');
+ reject(g, { type: 'revive', amount: 2, elite: 0 });
+ reject(g, { type: 'revive', amount: 2, elite: 3 });
+ g = applyAction(g, 'p', { type: 'revive', amount: 2 });
+ assert.equal(player(g).elites!.tanks, 1);
+ assert.equal(player(g).elites!.reserves, 2);
+ inventory(g);
+});
+
+void test('ordinary Basic without Homeworlds keeps automatic untyped winner losses and ordinary revival/Ghola accounting', () => {
+ for (const faction of ['emperor', 'fremen'] as const) {
+ const done = resolve(battle(faction, false));
+ assert.notEqual(done.decision?.kind, 'battleLosses');
+ assert.equal(player(done).tanks, 1);
+ assert.equal(player(done).elites, undefined);
+ assert.equal(viewGame(done, 'p').homeworlds, null);
+ inventory(done);
+ let g = tanks(faction, false);
+ const card = holdGhola(g);
+ g = applyAction(g, 'p', { type: 'revive', amount: 1 });
+ assert.equal(viewGame(g, 'p').revival.eliteRemaining, 0);
+ g = applyAction(g, 'p', { type: 'card', card, amount: 2 });
+ assert.equal(player(g).reserves, 20);
+ assert.equal(player(g).tanks, 0);
+ assert.equal(player(g).revived, 1);
+ assert.equal(player(g).elites, undefined);
+ inventory(g);
+ }
+});
diff --git a/tests/homeworld-battle-engine.test.ts b/tests/homeworld-battle-engine.test.ts
new file mode 100644
index 00000000..83268271
--- /dev/null
+++ b/tests/homeworld-battle-engine.test.ts
@@ -0,0 +1,537 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeHomeworldGameForAudit,
+ joinGame,
+ newPlayer,
+ normalizeAutomaticGame,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import type { FactionId } from '../game/catalog';
+import { baseDeck } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+import {
+ homeworldContext,
+ homeworldGameIntegrity,
+} from '../game/homeworld-game';
+import {
+ homeworldForceGroups,
+ quoteHomeworldCustody,
+ type HomeworldCustodyChange,
+ type HomeworldForces,
+} from '../game/homeworld-custody';
+
+const seat = (g: Game, id: string) =>
+ g.players.find((player) => player.id === id)!;
+const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
+const sum = (counts: Record) =>
+ Object.values(counts).reduce((a, b) => a + b, 0);
+
+function inventory(g: Game, supplementalCards = 0) {
+ homeworldGameIntegrity(g);
+ const groups = homeworldForceGroups(
+ homeworldContext(g),
+ g.homeworlds!.custody!,
+ );
+ for (const player of g.players) {
+ const visitors = groups
+ .filter((home) => home.native !== player.id)
+ .map((home) => home.forces[player.id] ?? { normal: 0, elite: 0 });
+ assert.equal(
+ player.reserves +
+ player.tanks +
+ sum(player.forces) +
+ visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0),
+ 20,
+ );
+ if (player.elites)
+ assert.equal(
+ player.elites.reserves +
+ player.elites.tanks +
+ sum(player.elites.forces) +
+ visitors.reduce((n, pool) => n + pool.elite, 0),
+ player.faction === 'emperor' ? 5 : player.faction === 'fremen' ? 3 : 7,
+ );
+ }
+ const cards = [
+ ...g.deck,
+ ...g.discard,
+ ...g.players.flatMap((player) => player.hand),
+ ];
+ assert.equal(cards.length, baseDeck().length + supplementalCards);
+ assert.equal(new Set(cards.map((card) => card.id)).size, cards.length);
+}
+function setup(roster: FactionId[], advanced = false) {
+ let g = createGame(
+ 'HOMEWORLDBATTLEENGINE',
+ newPlayer(roster[0], roster[0], roster[0]),
+ advanced,
+ );
+ for (const faction of roster.slice(1))
+ joinGame(g, newPlayer(faction, faction, faction));
+ g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
+ for (const player of g.players)
+ g = applyAction(g, player.id, { type: 'ready' });
+ g = initializeHomeworldGameForAudit(g);
+ for (let step = 0; g.status === 'setup' && step < 30; step++) {
+ let next: Game | undefined;
+ for (const player of g.players) {
+ const view = viewGame(g, player.id);
+ view.players.find((owner) => owner.id === player.id)!.bot = 'Easy';
+ const action = botActions(view)[0];
+ if (action) {
+ next = applyAction(g, player.id, action);
+ break;
+ }
+ }
+ assert.ok(next, 'Genuine audit setup must have a legal decision.');
+ g = next;
+ }
+ assert.equal(g.status, 'playing');
+ for (const player of g.players) g.deck.push(...player.hand.splice(0));
+ Object.assign(g, {
+ phase: 6,
+ phaseOpening: null,
+ response: null,
+ decision: null,
+ active: roster[0],
+ order: roster,
+ ready: [],
+ storm: 18,
+ });
+ inventory(g);
+ return g;
+}
+function custody(g: Game, changes: HomeworldCustodyChange[]) {
+ const quote = quoteHomeworldCustody(
+ homeworldContext(g),
+ g.homeworlds!.custody!,
+ changes,
+ );
+ g.homeworlds!.custody = quote.state;
+ for (const update of quote.players) {
+ const player = seat(g, update.id);
+ player.reserves = update.reserves;
+ if (player.elites) player.elites.reserves = update.eliteReserves;
+ }
+ inventory(g);
+}
+function invade(
+ g: Game,
+ player: string,
+ destination: string,
+ normal: number,
+ elite = 0,
+) {
+ // Explicit conserved invasion position, not a claim that invasion shipment
+ // actions are open: withdraw the exact native pieces before depositing them.
+ custody(g, [
+ {
+ homeworld: `homeworld:${seat(g, player).faction}`,
+ player,
+ withdraw: { normal, elite },
+ deposit: { normal: 0, elite: 0 },
+ },
+ {
+ homeworld: destination,
+ player,
+ withdraw: { normal: 0, elite: 0 },
+ deposit: { normal, elite },
+ },
+ ]);
+}
+function hold(g: Game, player: string, kind: string) {
+ const index = g.deck.findIndex((card) => card.kind === kind);
+ assert.ok(index >= 0);
+ const [card] = g.deck.splice(index, 1);
+ seat(g, player).hand.push(card);
+ return card.id;
+}
+function prepared(state: Game, actor: string, target: string, home: string) {
+ let g = applyAction(state, actor, {
+ type: 'chooseBattle',
+ territory: home,
+ target,
+ });
+ for (
+ let step = 0;
+ (g.response || g.battle?.preparation || g.decision) && step < 30;
+ step++
+ ) {
+ if (g.response) {
+ const owner = g.players.find(
+ (player) => !g.response!.passed.includes(player.id),
+ )!;
+ g = applyAction(g, owner.id, { type: 'passResponse' });
+ } else if (g.battle?.preparation)
+ g = applyAction(g, g.battle.preparation.owner, {
+ type: 'declineBattlePower',
+ });
+ else {
+ assert.equal(g.decision!.kind, 'fullPlanOffer');
+ g = applyAction(g, g.decision!.player, {
+ type: 'decision',
+ decline: true,
+ });
+ }
+ }
+ assert.equal(g.response, null);
+ assert.equal(g.decision, null);
+ assert.equal(g.battle!.territory, home);
+ assert.equal(g.battle!.preparation ?? null, null);
+ inventory(g);
+ return g;
+}
+function commit(
+ g: Game,
+ player: string,
+ dial = 0,
+ extras: Partial = {},
+) {
+ const leader =
+ seat(g, player).leaders.find((leader) => leader.strength === 1) ??
+ seat(g, player).leaders[0];
+ return applyAction(g, player, {
+ type: 'battlePlan',
+ dial,
+ support: 0,
+ leader: leader.id,
+ ...extras,
+ });
+}
+function reject(g: Game, player: string, action: Action) {
+ const before = structuredClone(g);
+ assert.throws(() => applyAction(g, player, action));
+ assert.deepEqual(g, before);
+}
+function explosionAction(g: Game, choice: number): Action {
+ return { type: 'decision', event: g.homeworldBattleLoss!.event, choice };
+}
+function legalBots(g: Game, owner: string, supplementalCards = 0) {
+ const candidates: Action[] = [];
+ for (const level of DIFFICULTIES) {
+ const view = viewGame(reload(g), owner);
+ view.players.find((player) => player.id === owner)!.bot = level;
+ const before = structuredClone(view);
+ const actions = botActions(view);
+ assert.ok(
+ actions.length,
+ `${level} must supply the pending Homeworld choice.`,
+ );
+ assert.deepEqual(view, before);
+ for (const action of actions) {
+ assert.equal(action.type, 'decision');
+ const snapshot = structuredClone(g);
+ const done = applyAction(g, owner, action);
+ assert.deepEqual(g, snapshot);
+ inventory(done, supplementalCards);
+ candidates.push(action);
+ }
+ }
+ return candidates;
+}
+
+void test('real Basic Homeworld battle awards native strength without treating the printed bonus as force casualties', () => {
+ let g = setup(['atreides', 'guild']);
+ invade(g, 'atreides', 'homeworld:guild', 3);
+ const before = structuredClone(g);
+ g = prepared(g, 'atreides', 'guild', 'homeworld:guild');
+ assert.deepEqual(viewGame(g, 'atreides').battle!.traitorVoters, ['guild']);
+ g = commit(g, 'atreides', 1);
+ assert.deepEqual(viewGame(reload(g), 'guild').battle!.plans, {});
+ g = commit(reload(g), 'guild');
+ reject(g, 'atreides', { type: 'traitorCall', call: false });
+ g = applyAction(reload(g), 'guild', { type: 'traitorCall', call: false });
+ assert.equal(g.lastBattleContext!.winner, 'guild');
+ assert.equal(g.lastBattleContext!.territory, 'homeworld:guild');
+ assert.equal(seat(g, 'guild').reserves, seat(before, 'guild').reserves);
+ assert.equal(seat(g, 'guild').tanks, 0);
+ assert.equal(seat(g, 'atreides').tanks, 3);
+ assert.equal(
+ g.homeworlds!.custody!.visitors['homeworld:guild']?.atreides,
+ undefined,
+ );
+ assert.deepEqual(
+ g.players.map((player) => player.forces),
+ before.players.map((player) => player.forces),
+ );
+ inventory(g);
+ assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
+});
+
+void test('two foreign armies at an empty native home resolve automatically after reveal with no fabricated traitor voter', () => {
+ let g = setup(['atreides', 'guild', 'harkonnen']);
+ invade(g, 'atreides', 'homeworld:harkonnen', 3);
+ invade(g, 'guild', 'homeworld:harkonnen', 3);
+ const native = seat(g, 'harkonnen');
+ native.tanks += native.reserves;
+ native.reserves = 0;
+ inventory(g);
+ g = prepared(g, 'atreides', 'guild', 'homeworld:harkonnen');
+ assert.deepEqual(viewGame(g, 'atreides').battle!.traitorVoters, []);
+ g = commit(g, 'atreides', 1);
+ const saved = reload(g);
+ assert.deepEqual(viewGame(saved, 'guild').battle!.plans, {});
+ assert.deepEqual(viewGame(saved, 'harkonnen').battle!.plans, {});
+ g = commit(saved, 'guild');
+ assert.equal(
+ g.battle,
+ null,
+ 'No player needs to submit a nonexistent native traitor vote.',
+ );
+ assert.equal(g.lastBattleContext!.winner, 'atreides');
+ assert.equal(seat(g, 'atreides').tanks, 1);
+ assert.equal(seat(g, 'guild').tanks, 3);
+ assert.deepEqual(
+ g.homeworlds!.custody!.visitors['homeworld:harkonnen']!.atreides,
+ { normal: 2, elite: 0 },
+ );
+ assert.equal(seat(g, 'harkonnen').reserves, 0);
+ 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);
+ const laser = hold(g, 'atreides', 'lasgun');
+ const shield = hold(g, 'emperor', 'shield');
+ const originalNative = seat(g, 'emperor').reserves;
+ g = prepared(g, 'atreides', 'emperor', 'homeworld:emperor');
+ g = commit(g, 'atreides', 0, { weapon: laser });
+ const attackerLeader = g.battle!.plans.atreides.leader!;
+ g = commit(reload(g), 'emperor', 0, { defense: shield });
+ const defenderLeader = g.battle!.plans.emperor.leader!;
+ g = applyAction(g, 'emperor', { type: 'traitorCall', call: false });
+ assert.equal(g.decision?.kind, 'homeworldExplosion');
+ if (g.decision?.kind !== 'homeworldExplosion')
+ throw new Error('Missing native Homeworld explosion choice.');
+ assert.equal(g.decision.player, 'emperor');
+ assert.deepEqual(
+ g.decision.options.map((option) => [option.normal, option.elite]),
+ [
+ [2, 0],
+ [1, 1],
+ [0, 2],
+ ],
+ );
+ assert.equal(seat(g, 'atreides').tanks, 3);
+ assert.equal(seat(g, 'emperor').tanks, 0);
+ for (const card of [laser, shield])
+ assert.equal(g.discard.filter((held) => held.id === card).length, 1);
+ assert.equal(
+ seat(g, 'atreides').leaders.find((leader) => leader.id === attackerLeader)!
+ .dead,
+ true,
+ );
+ assert.equal(
+ seat(g, 'emperor').leaders.find((leader) => leader.id === defenderLeader)!
+ .dead,
+ true,
+ );
+ inventory(g);
+ const saved = reload(g);
+ assert.deepEqual(reload(normalizeAutomaticGame(reload(saved))), saved);
+ reject(saved, 'atreides', explosionAction(saved, 0));
+ if (saved.decision?.kind !== 'homeworldExplosion')
+ throw new Error('Missing restored native explosion choice.');
+ legalBots(saved, 'emperor');
+ for (let choice = 0; choice < saved.decision!.options.length; choice++) {
+ const losses: HomeworldForces = saved.decision!.options[choice];
+ const action = explosionAction(saved, choice);
+ const done = applyAction(reload(saved), 'emperor', action);
+ assert.equal(seat(done, 'emperor').reserves, originalNative - 2);
+ assert.equal(seat(done, 'emperor').tanks, 2);
+ assert.equal(seat(done, 'emperor').elites!.tanks, losses.elite);
+ assert.equal(done.battle, null);
+ assert.equal(done.lastBattleContext!.territory, 'homeworld:emperor');
+ assert.equal(done.lastBattleContext!.winner, null);
+ for (const card of [laser, shield])
+ assert.equal(done.discard.filter((held) => held.id === card).length, 1);
+ inventory(done);
+ reject(done, 'emperor', action);
+ }
+});
+
+void test('a saved native explosion allocation rejects a same-total Emperor pool swap instead of applying losses to different typed counters', () => {
+ let g = setup(['atreides', 'emperor'], true);
+ custody(g, [
+ {
+ homeworld: 'homeworld:emperor',
+ player: 'emperor',
+ withdraw: { normal: 1, elite: 0 },
+ deposit: { normal: 0, elite: 1 },
+ },
+ {
+ homeworld: 'homeworld:emperor:salusa',
+ player: 'emperor',
+ withdraw: { normal: 0, elite: 1 },
+ deposit: { normal: 1, elite: 0 },
+ },
+ ]);
+ invade(g, 'atreides', 'homeworld:emperor', 3);
+ const laser = hold(g, 'atreides', 'lasgun');
+ const shield = hold(g, 'emperor', 'shield');
+ g = prepared(g, 'atreides', 'emperor', 'homeworld:emperor');
+ g = commit(g, 'atreides', 0, { weapon: laser });
+ g = commit(g, 'emperor', 0, { defense: shield });
+ g = applyAction(g, 'emperor', { type: 'traitorCall', call: false });
+ assert.equal(g.decision?.kind, 'homeworldExplosion');
+ const saved = reload(g);
+ const corrupt = reload(saved);
+ // Kaitain remains fifteen counters, but its one star moves to Salusa in
+ // exchange for one normal. Aggregate reserve/star totals remain unchanged.
+ corrupt.homeworlds!.custody!.salusa = { normal: 0, elite: 5 };
+ const groups = homeworldForceGroups(
+ homeworldContext(corrupt),
+ corrupt.homeworlds!.custody!,
+ );
+ assert.deepEqual(
+ groups.find((home) => home.id === 'homeworld:emperor')!.forces.emperor,
+ { normal: 15, elite: 0 },
+ );
+ assert.equal(
+ seat(corrupt, 'emperor').reserves,
+ seat(saved, 'emperor').reserves,
+ );
+ assert.equal(
+ seat(corrupt, 'emperor').elites!.reserves,
+ seat(saved, 'emperor').elites!.reserves,
+ );
+ reject(corrupt, 'emperor', explosionAction(corrupt, 0));
+ const done = applyAction(reload(saved), 'emperor', explosionAction(saved, 0));
+ assert.equal(seat(done, 'emperor').tanks, 2);
+ assert.deepEqual(done.homeworlds!.custody!.salusa, { normal: 1, elite: 4 });
+ inventory(done);
+});
+
+void test('an explosion between two invaders automatically reveals and gives only the uninvolved native owner its typed loss choice', () => {
+ let g = setup(['atreides', 'guild', 'emperor']);
+ invade(g, 'atreides', 'homeworld:emperor', 3);
+ invade(g, 'guild', 'homeworld:emperor', 3);
+ const laser = hold(g, 'atreides', 'lasgun');
+ const shield = hold(g, 'guild', 'shield');
+ const nativeLeaders = structuredClone(seat(g, 'emperor').leaders);
+ g = prepared(g, 'atreides', 'guild', 'homeworld:emperor');
+ assert.deepEqual(viewGame(g, 'atreides').battle!.traitorVoters, []);
+ g = commit(g, 'atreides', 0, { weapon: laser });
+ g = commit(reload(g), 'guild', 0, { defense: shield });
+ assert.equal(g.decision?.kind, 'homeworldExplosion');
+ assert.equal(g.decision!.player, 'emperor');
+ assert.equal(seat(g, 'atreides').tanks, 3);
+ assert.equal(seat(g, 'guild').tanks, 3);
+ assert.deepEqual(seat(g, 'emperor').leaders, nativeLeaders);
+ reject(reload(g), 'atreides', explosionAction(g, 0));
+ const done = applyAction(reload(g), 'emperor', explosionAction(g, 0));
+ assert.equal(seat(done, 'emperor').tanks, 2);
+ assert.deepEqual(seat(done, 'emperor').leaders, nativeLeaders);
+ assert.equal(done.battle, null);
+ assert.equal(
+ done.homeworlds!.custody!.visitors['homeworld:emperor'],
+ undefined,
+ );
+ inventory(done);
+});
+
+void test('normalization restores a previously saved revealed Homeworld battle with no native traitor voters exactly once', () => {
+ let g = setup(['atreides', 'guild', 'harkonnen']);
+ invade(g, 'atreides', 'homeworld:harkonnen', 3);
+ invade(g, 'guild', 'homeworld:harkonnen', 3);
+ seat(g, 'harkonnen').tanks += seat(g, 'harkonnen').reserves;
+ seat(g, 'harkonnen').reserves = 0;
+ g = prepared(g, 'atreides', 'guild', 'homeworld:harkonnen');
+ g = commit(g, 'atreides', 1);
+ // Reproduce the observed prior persisted frame: both original plans are
+ // revealed, but the old engine waited forever for an empty voter set.
+ g.battle!.plans.guild = {
+ dial: 0,
+ support: 0,
+ leader: seat(g, 'guild').leaders.find((leader) => leader.strength === 1)!
+ .id,
+ weapon: null,
+ defense: null,
+ };
+ g.battle!.revealed = true;
+ const before = reload(g);
+ const done = normalizeAutomaticGame(reload(g));
+ assert.deepEqual(g, before);
+ assert.equal(done.battle, null);
+ assert.equal(done.lastBattleContext!.winner, 'atreides');
+ assert.equal(seat(done, 'atreides').tanks, 1);
+ assert.equal(seat(done, 'guild').tanks, 3);
+ assert.deepEqual(reload(normalizeAutomaticGame(reload(done))), reload(done));
+ inventory(done);
+});
+
+void test('a real held Portable Snooper gives a nonvoting invader an owned late-defense choice before automatic Homeworld resolution', () => {
+ for (const weaponKind of ['poison', 'projectile'])
+ for (const use of [false, true]) {
+ let g = setup(['atreides', 'guild', 'harkonnen']);
+ invade(g, 'atreides', 'homeworld:harkonnen', 3);
+ invade(g, 'guild', 'homeworld:harkonnen', 3);
+ seat(g, 'harkonnen').tanks += seat(g, 'harkonnen').reserves;
+ seat(g, 'harkonnen').reserves = 0;
+ g = prepared(g, 'atreides', 'guild', 'homeworld:harkonnen');
+ // Explicit single Richese component seam after genuine base setup. The
+ // canonical supplemental card has one physical owner; this does not claim
+ // that a complete Richese deck, sale or expansion start was exercised.
+ const portable = richeseCards().find(
+ (card) => card.effect === 'portableSnooper',
+ )!;
+ seat(g, 'atreides').hand.push(portable);
+ const weapon = hold(g, 'guild', weaponKind);
+ const leader = seat(g, 'atreides').leaders.find(
+ (leader) => leader.strength === 5,
+ )!;
+ g = commit(g, 'atreides', 1, { leader: leader.id });
+ g = commit(reload(g), 'guild', 2, { weapon });
+ assert.equal(g.decision?.kind, 'homeworldDefense');
+ if (g.decision?.kind !== 'homeworldDefense')
+ throw new Error('Missing invader late-defense opportunity.');
+ assert.equal(g.decision.player, 'atreides');
+ const action: Action = { type: 'decision', event: g.decision.event, use };
+ const pending = reload(g);
+ for (const candidate of legalBots(pending, 'atreides', 1))
+ assert.equal(candidate.use, weaponKind === 'poison');
+ assert.deepEqual(
+ reload(normalizeAutomaticGame(reload(pending))),
+ pending,
+ );
+ reject(pending, 'guild', action);
+ reject(pending, 'atreides', { ...action, event: 'stale' });
+ g = applyAction(reload(pending), 'atreides', action);
+ assert.equal(
+ seat(g, 'atreides').leaders.find(
+ (candidate) => candidate.id === leader.id,
+ )!.dead,
+ !(use && weaponKind === 'poison'),
+ );
+ assert.equal(
+ g.lastBattleContext!.winner,
+ use && weaponKind === 'poison' ? 'atreides' : 'guild',
+ );
+ while (g.decision?.kind === 'battleCards')
+ g = applyAction(reload(g), g.decision.player, {
+ type: 'decision',
+ discard: [],
+ });
+ assert.equal(g.battle, null);
+ reject(g, 'atreides', action);
+ assert.equal(
+ seat(g, 'atreides').hand.filter((card) => card.id === portable.id)
+ .length,
+ use && weaponKind === 'projectile' ? 0 : 1,
+ );
+ assert.equal(
+ g.discard.filter((card) => card.id === portable.id).length,
+ use && weaponKind === 'projectile' ? 1 : 0,
+ );
+ inventory(g, 1);
+ }
+});
diff --git a/tests/homeworld-battle-recovery.test.ts b/tests/homeworld-battle-recovery.test.ts
new file mode 100644
index 00000000..30703370
--- /dev/null
+++ b/tests/homeworld-battle-recovery.test.ts
@@ -0,0 +1,489 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { 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 {
+ homeworldContext,
+ homeworldGameIntegrity,
+} from '../game/homeworld-game';
+import {
+ quoteHomeworldCustody,
+ type HomeworldCustodyChange,
+} from '../game/homeworld-custody';
+
+/** 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