From 8053e1fb268780b1dc0380a8a63c8b26afad70c6 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Mon, 26 Oct 2026 05:57:10 +1100
Subject: [PATCH] Allow early Advanced Truthtrance shipment promises
---
components/truthtrance.tsx | 9 +-
docs/CURRENT_STATUS.md | 40 ++++
docs/IMPLEMENTATION_STATUS.md | 38 ++++
docs/RULE_DECISIONS.md | 13 ++
docs/TRUTHTRANCE_SHIPMENT_PROMISES.md | 93 ++++++++-
game/engine.ts | 11 +-
game/reference.ts | 12 +-
game/shipment-promises.ts | 2 +-
...uthtrance-advanced-shipment-preparation.ts | 180 ++++++++++++++++
...ure-truthtrance-early-advanced-shipment.ts | 186 +++++++++++++++++
...vanced-shipment-preparation-engine.test.ts | 193 ++++++++++++++++++
...nce-early-advanced-shipment-engine.test.ts | 158 ++++++++++++++
tests/truthtrance-future-shipment.test.ts | 3 +-
13 files changed, 926 insertions(+), 12 deletions(-)
create mode 100644 tests/fixture-truthtrance-advanced-shipment-preparation.ts
create mode 100644 tests/fixture-truthtrance-early-advanced-shipment.ts
create mode 100644 tests/truthtrance-advanced-shipment-preparation-engine.test.ts
create mode 100644 tests/truthtrance-early-advanced-shipment-engine.test.ts
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index cac272a0..ce73b049 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -384,13 +384,14 @@ export function Truthtrance({
before delivery. A stopped shipment can release an
impossible answer; an unfinished response does not fulfill it.
+ {g.phase < 5 && Your shipment comes later this turn. Answers use the current board and preparations you can make yourself. A change outside your control can release an answer that becomes impossible. Keep playing the current phase; shipping waits for your turn.
}
{!shipmentAvailable && (
Automatic enforcement supports a later shipment this turn
- in Basic base rules without optional modules. During
- Shipment and Movement it also supports unused active or
- waiting turns in Advanced base rules, including the Guild,
- or a classic payer in the Richese Semuta profile. Select
+ in classic Basic or Advanced rules without optional modules,
+ including the Guild. Active or waiting turns are eligible
+ during Shipment and Movement; the Richese Semuta profile
+ also supports classic payers at that time. Select
an eligible player and finish pending decisions first.
Native No-Field shipments and other rules configurations
remain unfinished.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 81fbc8ed..d6b8c284 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,46 @@ open mode gates.
## Current checkpoint and work
+**Earlier Advanced Truthtrance shipment promises — 26 October. Prototyped / Partial.**
+Classic Advanced respondents can now answer this-turn reserve-shipment questions
+during phases0–4, using the existing current-board/owned-stage policy. Shipment
+still waits for the actual turn and native Guild controls. The shared witness
+now includes wealthy BG's allowed charity payment through native `payCharity`;
+the actual counter stays live and a denial may release an impossible answer.
+Typed revival preserves current-turn elite usage. Existing readiness reopening,
+voluntary-cost checks, guidance and minimal legal bots serve the timing. Earlier
+expansion/module envelopes and public mode gates stay unchanged.
+
+Two disjoint source lanes, bounded preparation review and root integration pass
+**15 files,120/120**, types/lint/build and **eleven native engine/GameTable SSR
+journeys with actual current-preparation/rate/shipment callbacks** on unchanged
+source. Seventeen new cases cover Storm/Charity/Bidding questions, Yes/No,
+actual purchase2/delivery8, native Guild TAKE/WAIT/income, forced Storm release,
+wealthy BG Charity and denial, typed revival, readiness, legal controls and four
+minimal profiles. Public Advanced sources retain all33 original cards; no played
+hand, force, wallet, clock, Tank, elite or promise is installed.
+
+Storm/Charity/Bidding prefixes use8/26/33 actions. E really buys its ordinary
+weapon for2, retains8 and delivers8; a No can deliver7. Recorded Storm distance
+does not release a current-board promise before actual movement closes the
+route. BG's24-action source needs its real5→7 charity; E's original Karama
+cancellation leaves5 and releases the old promise before a legal5 replacement.
+The94-action elite source earns A's actual bid4/E income4, paid two-Sardaukar
+shipment and next-Storm losses. One free Sardaukar returns19 reserves; the held
+Karama pays10 for19 actual counters, including four elites, leaving one in Tanks.
+
+Bounded core/Discovery/card/Skill lookups found no wholly absent standalone
+primitive in those sets; that is not complete-rule certification. The concrete
+early-Advanced guard drove this change. Shared-witness review identifies the BG
+payment omission and confirms elite reset belongs to Storm, not Revival. The
+first frozen proof passes without source repairs. Private report/logs:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truthtrance-early-advanced-proof/`;
+journeys/HTML: adjacent `20261026-truthtrance-early-advanced-runtime/`.
+Broader promises/modules, full rules and final assurance remain open. No saved-
+room/D1/server, reset/restart, deployment or recovery operation occurs; DOM/
+hydration, browser/mobile and deployed acceptance remain due.
+[Source and scope](TRUTHTRANCE_SHIPMENT_PROMISES.md#earlier-classic-advanced-phases--26-october-2026).
+
**Semuta on Guild Ambassador and Revival Spiritual grants — 26 October. Prototyped / Partial.**
Existing terminal/advisor cost continuations now retain the original Guild
Ambassador child or Tleilax SEND return through Spiritual shipping, matching and
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index abfe6267..54f17d6c 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,43 @@
# Dune implementation status
+## 26 October 2026 — Earlier Advanced Truthtrance shipment promises
+
+**Prototyped / Partial.** Extend current-turn reserve-shipment questions to bare
+classic Advanced phases0–4. The existing current-board/owned-stage policy remains
+provisional; it predicts no future Storm result, rival gift or unknown purchase.
+Actual shipment/Guild timing stays later. The shared detached witness now applies
+wealthy BG's allowed charity payment with native `payCharity`, while preserving
+the live counter/denial. Current elite usage is retained; its reset belongs to
+Storm opening. Existing readiness, preparation, voluntary-cost and minimal legal
+policies are reused. Earlier modules and public mode gates remain unchanged.
+[Source and scope](TRUTHTRANCE_SHIPMENT_PROMISES.md#earlier-classic-advanced-phases--26-october-2026).
+
+Two disjoint source lanes, bounded preparation review and root integration pass
+**15 files,120/120**, types/lint/build and **eleven native engine/GameTable SSR
+journeys with actual current-preparation/rate/shipment callbacks** on unchanged
+source. Seventeen new cases cover three earlier phases, Yes/No, real purchase/
+delivery, Guild TAKE/WAIT/income, actual Storm release, wealthy BG allowance/
+cancellation, typed revival, readiness, legal controls and four profiles. All33
+original cards remain physical; no played hand, force, wallet, clock, Tank, elite
+or promise is installed. Existing controlled fixtures remain separate evidence.
+
+Storm/Charity/Bidding8/26/33 earns E's real purchase2→eight-force shipment;
+No can deliver7. BG24 earns actual5→7 charity or original E Karama cancellation
+and affordable5 delivery after release. Elite94 earns actual A bid4/E income4,
+paid two-Sardaukar Hagga shipment and next-Storm loss. One free elite return,
+owned Karama and native bank invoice10 deliver19 counters/four elites while
+retaining the last elite in Tanks. No last preparation can be skipped voluntarily.
+
+Bounded gap lookups find no absent standalone primitive in the inspected core,
+Discovery/card/Skill sets, not full completion. Current code verifies the real
+early-Advanced timing exclusion and the BG forecast-payment omission; no unrelated
+combination campaign is added. First frozen proof passes. Private report/logs:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truthtrance-early-advanced-proof/`;
+scripts/journeys/HTML: adjacent `20261026-truthtrance-early-advanced-runtime/`.
+Wider promises/modules and final rules/recovery/privacy/custody assurance stay
+open. SSR/component callbacks do not certify DOM/hydration, browser/mobile or
+deployment. No saved-room/D1/server, reset/restart or recovery operation occurs.
+
## 26 October 2026 — Semuta on Guild Ambassador and Revival Spiritual grants
**Prototyped / Partial.** Existing terminal/advisor cost continuations now retain
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 3126d4a0..6d67a4db 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,18 @@
# Rule decision index
+## Earlier Advanced shipment commitments — 26 October 2026
+
+Extend the selected current-board/owned-stage shipment-promise policy to bare
+classic Advanced phases0–4. The FAQ already supplies current-turn binding and
+release after genuine impossibility; under the 14 October recommended-policy
+authority this uses owned Charity/Revival preparations rather than predicting
+unresolved Storm movement or future rival funding. Wealthy BG's allowed charity
+payment may witness feasibility, while its real counter stays live; actual
+denial may release an impossible answer. Preserve the current one-elite revival
+limit and actual later Guild timing. This is provisional online composition,
+not a new publisher worked example or public mode gate.
+[Source, implementation and limits](TRUTHTRANCE_SHIPMENT_PROMISES.md#earlier-classic-advanced-phases--26-october-2026).
+
## Sapho after a started combined turn — 25 October 2026
Under the 14 October choose-recommendation authority, select the source review's
diff --git a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md
index 84f200c0..2861db24 100644
--- a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md
+++ b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md
@@ -4,7 +4,98 @@ Original checkpoint, 7 September 2026; Advanced follow-up, 13 September 2026. Th
The former Advanced Guild exclusion is superseded by the
[18 October native Guild composition](#advanced-guild-interception-and-rates--18-october-2026).
-The [24 October waiting-turn follow-up](#same-phase-waiting-respondents--24-october-2026) supersedes the active-only timing boundary. The [earlier Basic phase follow-up](#earlier-basic-phases--24-october-2026) extends current-turn timing before Movement. Earlier scope descriptions and verification totals remain historical.
+The [24 October waiting-turn follow-up](#same-phase-waiting-respondents--24-october-2026) supersedes the active-only timing boundary. The [earlier Basic phase follow-up](#earlier-basic-phases--24-october-2026) extends current-turn timing before Movement; the [26 October Advanced extension](#earlier-classic-advanced-phases--26-october-2026) adds bare classic Advanced while retaining earlier module fences. Earlier scope descriptions and verification totals remain historical.
+
+## Earlier classic Advanced phases — 26 October 2026
+
+**Prototyped / Partial.** Bare classic Advanced now accepts the same structured
+this-turn reserve-shipment and mixed current-fact questions during phases0–4.
+Actual shipment still waits for the respondent's real turn, preparations and
+native Guild decisions. Earlier expansion and optional-module configurations
+remain outside this envelope. Public Advanced/mode gates are unchanged.
+
+The existing GF9 FAQ supplies current-turn commitment and release after genuine
+impossibility. Under the 14 October recommended-policy authority, extend the
+earlier Basic current-board/owned-stage interpretation to these Advanced roles.
+This is explicit provisional online composition, not a new publisher example.
+Do not predict unresolved Storm movement, vacated destinations, unknown purchases
+or future rival gifts/income. An actual opposing/forced change may release the
+answer only after it makes the route impossible. A recorded future Storm distance
+alone does not become an already changed board. Existing voluntary spending,
+auction liabilities, distinct card costs and expiring escrow remain binding.
+
+The shared [mode helper](../game/shipment-promises.ts) now admits bare classic
+Advanced before Movement. Existing early Charity/Revival/payment witnesses,
+private answers, readiness reopening and future-turn obligations remain shared.
+One concrete omission is corrected: wealthy Advanced BG's native charity claim
+opens `bgCharity` before its fixed two spice is paid. The detached witness now
+uses native `payCharity` for that owned allowed outcome. It neither credits the
+real table nor bypasses its counter. Actual allowance pays once; cancellation
+can leave no legal route and release the original promise. The same seed is used
+by early ground-movement reachability.
+
+Keep physical counter counts separate from combat strength. Native typed stock,
+free/ordinary quotas and the current one-elite-per-turn limit stay authoritative.
+Elite usage resets at native Storm opening, not when forecasting Revival; a
+current use is never erased. Before Movement, only an available current Charity
+or Revival action is executable. Price/ship controls wait for the real turn.
+The question editor explains current-board preparation and later shipment; the
+promise guide and all four profiles reuse the same minimal legal policy.
+
+Two disjoint [earlier-phase](../tests/truthtrance-early-advanced-shipment-engine.test.ts)
+and [preparation](../tests/truthtrance-advanced-shipment-preparation-engine.test.ts)
+source lanes, bounded preparation review and root integration pass
+**15 files,120/120**, types/lint/build and **eleven native engine/GameTable SSR
+journeys with actual current-preparation/rate/shipment callbacks** on unchanged
+source. Seventeen new cases cover native Storm/Charity/Bidding questions,
+Yes/No, paid purchase/delivery, Guild TAKE/WAIT/income, actual forced release,
+wealthy BG payment/counter, typed revival, readiness, legal controls and four
+minimal profiles. Existing controlled preparation/engine and UI fixtures are
+separate evidence; this does not claim saved-game or browser certification.
+
+The public Advanced A/E/H/G source stops after8/26/33 actions in Storm/Charity/
+Bidding. Original setup really deals A Truthtrance, E Baliset, H Karama+Shield
+and G Snooper; an unopened ordinary weapon remains the actual first lot. A real
+eight-force promise retains E's printed10 cash/20 reserves, permits its actual
+purchase2 and delivers8 after real Guild TAKE or WAIT and interception/income.
+No can deliver7 instead. Another original first-Storm source records distance10
+without releasing, then actual movement to sector11 closes Carthag's route and
+releases once. No clock, promise or physical source is installed.
+
+The24-action BG source uses actual public A/BG/E Advanced setup, with A's original
+Truthtrance, BG's ordinary card and E's original Karama. BG starts5 cash/19
+reserves with its original Arrakeen advisor beside A10. A seven-city-counter
+promise needs its real charity2. The current guide's actual callback claims it;
+the live counter keeps BG5 until allowance makes7. E may instead spend its real
+Karama, keeping BG5 and releasing the now-impossible promise before an actual
+affordable5 shipment. Eight remains infeasible without an owned funding source.
+
+The94-action elite source uses public Advanced A/E/H/G with original A Truthtrance
+and E Karama. A really bids4 for an original ordinary card, giving E income4;
+E then pays4 to ship two Sardaukar to Hagga13 after dials11+0 reach12. The next
+native StormCard1 reaches13 and kills both. At turn2 Revival, E has10 cash,
+18 reserves and two elite-only Tanks. One actual free Sardaukar returns19
+reserves/four elites. The owned Karama buys the actual10-spice bank tariff for
+19 city counters; the second elite remains in Tanks. Neither a twentieth force
+nor another elite return is forecast. Actual preparation/rate/shipment callbacks
+and native controls deliver that original group once.
+
+All33 original Treachery Cards remain physical. Only original unopened deck/
+Spice positions and native scalar lotteries are selected; no played hand, wallet,
+force, Tank, elite, clock, promise or response is installed. The first frozen
+proof passes without source repairs and binds2934 files to
+`f78252b76d0f53433bb8d1db6db008c05b4df7ad5763ff0430406c633ff8c82b`.
+Private report/logs:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truthtrance-early-advanced-proof/`;
+scripts/journeys/HTML: adjacent `20261026-truthtrance-early-advanced-runtime/`.
+
+Bounded core/Discovery/card/Skill lookups found no absent standalone primitive
+in those sets; that is not a complete-rule audit. The concrete timing guard and
+BG witness omission drove this work. Earlier modules, other future actions,
+arbitrary promise enforcement and final rules/recovery/privacy/custody assurance
+remain open. SSR/callback results do not certify DOM/hydration, browser/mobile or
+deployed acceptance. No saved-room, D1, server, reset/restart, deployment or
+recovery operation occurs.
## Authority and supported meaning
diff --git a/game/engine.ts b/game/engine.ts
index 248721e5..f452efac 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -25928,7 +25928,16 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string
if (source.phase < 2 && byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn) {
if (p.faction === 'choam') continue;
p.spice += charityQuote(g, p).total; p.charityTurn = g.turn; next = g;
- } else next = applyActionInner(g, owner, action, 'shipmentPreparation');
+ } else {
+ next = applyActionInner(g, owner, action, 'shipmentPreparation');
+ // Wealthy Advanced BG claims before its counter pays. Witness only
+ // that owned allowed outcome; the actual table keeps every response.
+ const response = next.response;
+ if (response?.kind === 'bgCharity' && response.owner === owner) {
+ next.response = null;
+ payCharity(next,getPlayer(next,owner),response.amount ?? 2,response.charityHomeworld ?? 0);
+ }
+ }
charitySeeds.push({ game: next, actions: [action], early: [{ phase: 2, action }] });
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
diff --git a/game/reference.ts b/game/reference.ts
index db9fa2a3..ee44d60c 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3172,13 +3172,13 @@ export const RULE_TOPICS: RuleTopic[] = [
summary:
'Bind this turn’s reserve shipment to a destination and physical minimum, including earlier Basic phases and players whose shipment turn is still ahead.',
steps: [
- 'Declare Truthtrance, select an eligible player and choose Bind a shipment from reserves. Bare Basic rules allow the question during Storm through Revival as well as before that player’s shipment. During Shipment and Movement, base Basic and Advanced, including Guild, also support an unused active or waiting turn. Finish pending decisions first. Choose a printed destination and minimum of one to twenty forces, or combine conditions using AND or OR; each describes the same shipment. Answering never grants an early shipment. Earlier Advanced, expansion and optional configurations keep their existing boundaries.',
+ 'Declare Truthtrance, select an eligible player and choose Bind a shipment from reserves. Bare classic Basic and Advanced rules allow the question during Storm through Revival as well as before that player’s shipment. During Shipment and Movement, base Basic and Advanced, including Guild, support an unused active or waiting turn. Finish pending decisions first. Choose a printed destination and minimum of one to twenty forces, or combine conditions using AND or OR; each describes the same shipment. Answering never grants an early shipment. Earlier expansion and optional configurations keep their existing boundaries.',
'Yes requires the complete statement to match while possible: all AND conditions or at least one OR condition. No requires the whole statement to be false: at least one AND condition false, or every OR condition false. A smaller count, different destination, transport of forces already on the board or no shipment may satisfy No. Forces already in the destination and ground movement do not satisfy the question. Fremen reinforcements and Guild transport from southern reserves do count.',
'The target privately sees feasible answers based on the current board, owned resources and all earlier promises. Before Movement, include remaining owned Charity and normal Revival, the current winning invoice and unused outgoing Bidding refunds; incoming Bidding aid expires. Auction payment and shipping discounts need distinct physical Karamas. At shipment time, available Ghola, Karama and current pledged aid retain their normal rules. Unknown purchases, unresolved Storm outcomes, rival gifts and future income are not assumed.',
'Accepted whole answers are public and constrain the event together. A new answer reopens your Charity or Revival readiness when needed. Claim or revive before marking Ready if that is your last legal route; bids, gifts and paid revivals cannot spend it away. Honor a required shipment before ground movement or finishing. An unresolved foreign bid and its income responses finish before release. A completed opposing effect that makes compliance impossible releases it publicly.',
'Guild interception and rate or transport-permission responses finish before delivery. An opposing stop consumes the shipment without payment or arrival: answers matching no shipment fulfill and impossible answers release. You cannot stop your own shipment to evade your answer. An unaffordable canceled rate leaves shipment available; a retained owned discount or ordinary Fremen reinforcement may still provide a legal route.',
'Choose Mixed current fact and shipment for nested AND/OR groups of supported current facts and this-turn reserve shipments. Current facts freeze privately when answered: Yes OR a true current fact can allow skipping even after later losing that holding; No AND a false current fact can allow shipping even after that fact changes. Unknown is not false. Only the whole question and answer are public.',
- 'Your promises retain the original grouped question without exposing private compiled facts. Earlier phases show later-this-turn guidance and only an available current Charity or Revival preparation. Shipment actions wait for your actual phase5 turn and finished controls. The owned-route/readiness protocol is a provisional interpretation. All four profiles retain a minimal legal path; answers allowing a skip retain ordinary legal payment choices. Earlier Advanced/modules, expansion shipment modes, other future actions and arbitrary prose enforcement remain unfinished.',
+ 'Your promises retain the original grouped question without exposing private compiled facts. Earlier phases show later-this-turn guidance and only an available current Charity or Revival preparation. Shipment actions wait for your actual phase5 turn and finished controls. The owned-route/readiness protocol is a provisional interpretation. All four profiles retain a minimal legal path; answers allowing a skip retain ordinary legal payment choices. Earlier modules, expansion shipment modes, other future actions and arbitrary prose enforcement remain unfinished.',
],
example:
'Yes to at least six forces to Carthag and No to at least eight there leaves a shipment of six or seven. Moving existing forces into Carthag does not fulfill either shipment event.',
@@ -3188,7 +3188,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
- 'Bare Basic phases0–4 use explicit provisional current-board/owned-stage feasibility, auction liabilities, distinct card costs, expiring aid and readiness reopening. Voluntary bids/payments/closure preserve a route; foreign invoices await their actual outcome. Existing phase5 waiting/native Guild interception, permission and rates remain. Earlier Advanced/modules and wider future actions remain unfinished.',
+ 'Bare classic Basic/Advanced phases0–4 use explicit provisional current-board/owned-stage feasibility, auction liabilities, distinct card costs, expiring aid and readiness reopening. Wealthy Advanced BG Charity retains its native counter; the detached witness uses its allowed fixed payment. Typed revival keeps the current one-elite limit. Voluntary bids/payments/closure preserve a route; foreign invoices await their actual outcome. Existing phase5 waiting/native Guild interception, permission and rates remain. Earlier modules and wider future actions remain unfinished.',
},
{
area: 'Player controls',
@@ -3212,7 +3212,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
- 'Current early-phase batch passes18 files131/131, types/lint/build, two original Storm/Charity question programmes with actual purchase2 and shipment8, rendered table guidance and four finite legal profiles. Controlled preparation, readiness, physical cards, invoice allow/cancel and No overbids remain separate. Earlier persistence/privacy evidence is historical; full Truthtrance, modes, assurance and deployed acceptance remain open.',
+ 'Source programmes cover original Basic and Advanced early-phase questions, actual purchase/payment and delivery, wealthy BG charity/counter, typed revival, real forced Storm release, current preparation controls and four minimal legal profiles. Current frozen totals and limits are in the source contract/current checkpoint; earlier persistence/privacy evidence remains historical. Full Truthtrance, modes, assurance and deployed acceptance remain open.',
evidence: [
'tests/shipment-promises.test.ts',
'tests/shipment-promises-bots.test.ts',
@@ -3226,6 +3226,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/truthtrance-early-shipment-engine.test.ts',
'tests/truthtrance-early-shipment-controls.test.tsx',
'tests/fixture-truthtrance-early-shipment.ts',
+ 'tests/fixture-truthtrance-early-advanced-shipment.ts',
+ 'tests/truthtrance-early-advanced-shipment-engine.test.ts',
+ 'tests/fixture-truthtrance-advanced-shipment-preparation.ts',
+ 'tests/truthtrance-advanced-shipment-preparation-engine.test.ts',
'components/shipment-promises.tsx',
'components/truthtrance.tsx',
'components/game-table.tsx',
diff --git a/game/shipment-promises.ts b/game/shipment-promises.ts
index a162287d..42c8e240 100644
--- a/game/shipment-promises.ts
+++ b/game/shipment-promises.ts
@@ -163,7 +163,7 @@ export function shipmentPromiseModeSupported(game: {
ecazTreachery?: unknown;
}, respondent = game.active): boolean {
if (game.phase !== undefined && game.phase < 5 &&
- (game.advanced || game.expansions.length || game.techTokens || game.strongholdCards ||
+ (game.expansions.length || game.techTokens || game.strongholdCards ||
game.nexusCards || game.homeworlds || game.leaderSkills || game.discoveryEnabled || game.ecazTreachery)) return false;
if (game.semutaPreview === true && game.expansions.length === 1 &&
game.expansions[0] === 'choam' && !game.techTokens &&
diff --git a/tests/fixture-truthtrance-advanced-shipment-preparation.ts b/tests/fixture-truthtrance-advanced-shipment-preparation.ts
new file mode 100644
index 00000000..e53aa9ae
--- /dev/null
+++ b/tests/fixture-truthtrance-advanced-shipment-preparation.ts
@@ -0,0 +1,180 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import type { TruthQuestion } from '../game/truthtrance';
+import { isAdvisor } from '../game/advisors';
+import { actFutureShipment, nextFutureShipmentStep, futureShipmentClean, futureShipmentPlayer } from './fixture-truthtrance-future-shipment';
+import { truthMovementInventory } from './fixture-truth-movement';
+
+export type TruthtranceAdvancedShipmentPreparationKind = 'bgCharity' | 'elite';
+export type TruthtranceAdvancedShipmentPreparationStep = { actor: string; action: Action };
+export const actTruthtranceAdvancedShipmentPreparation = actFutureShipment;
+export const truthtranceAdvancedShipmentPreparationPlayer = futureShipmentPlayer;
+export const truthtranceAdvancedShipmentPreparationInventory = truthMovementInventory;
+export const truthtranceAdvancedShipmentPreparationClean = futureShipmentClean;
+
+/** Actual public setup/readiness controls. An earned owned preparation stays
+ * caller-controlled instead of being skipped by a generic Ready action. */
+export function nextTruthtranceAdvancedShipmentPreparation(game: Game): TruthtranceAdvancedShipmentPreparationStep | null {
+ if (!game.response && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard) {
+ if (game.status === 'setup' && game.setupStage === 'prediction')
+ return { actor: 'beneGesserit', action: { type: 'predict', faction: 'emperor', turn: 10 } };
+ const bg = game.players.find(p => p.faction === 'beneGesserit');
+ if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup &&
+ viewGame(game, bg.id).setupPending.includes(bg.id))
+ return { actor: bg.id, action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
+ if (game.decision?.kind === 'advisorBattle' || game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
+ return { actor: game.decision.player, action: { type: 'decision', accept: false } };
+ if (game.status === 'playing' && game.phase === 0 && game.stormPending === null) {
+ const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
+ if (actor) return { actor, action: { type: 'stormDial',
+ amount: game.turn === 1 ? actor === game.stormDialers[0] ? 11 : 0 : 1 } };
+ }
+ if ([2, 4].includes(game.phase) && game.players.some(p => viewGame(game, p.id).shipmentPreparation?.actions.length)) return null;
+ }
+ return nextFutureShipmentStep(game);
+}
+export const nextTruthtranceAdvancedShipmentPreparationStep = nextTruthtranceAdvancedShipmentPreparation;
+export function advanceTruthtranceAdvancedShipmentPreparation(game: Game, until: (state: Game) => boolean,
+ actions: TruthtranceAdvancedShipmentPreparationStep[] = []): Game {
+ for (let count = 0; count < 2000; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished');
+ const step = nextTruthtranceAdvancedShipmentPreparation(game);
+ assert.ok(step, `Choose the original question, preparation or shipment at ${game.turn}/${game.phase}/${game.decision?.kind}`);
+ game = actTruthtranceAdvancedShipmentPreparation(game, step.actor, step.action, actions);
+ }
+ throw Error('The original Advanced shipment-preparation chronology did not reach its finite boundary');
+}
+
+/** Public Advanced-only sources. BG's real wealthy Charity and Emperor's
+ * real elite-only Tanks are earned without assigning played resource state. */
+export function createTruthtranceAdvancedShipmentPreparationFixture(options: { kind: TruthtranceAdvancedShipmentPreparationKind }) {
+ const { kind } = options, asker = 'atreides', target = kind === 'bgCharity' ? 'beneGesserit' : 'emperor';
+ const roster = kind === 'bgCharity' ? ['beneGesserit', 'emperor'] as const : ['emperor', 'harkonnen', 'guild'] as const;
+ let game = createGame('TRUTHADVANCEDSHIPMENTPREPARATION', newPlayer(asker, 'Atreides', 'atreides'), false);
+ for (const faction of roster) joinGame(game, newPlayer(faction, faction, faction));
+ const initial = structuredClone(game), actions: TruthtranceAdvancedShipmentPreparationStep[] = [];
+ const act = (actor: string, action: Action) => { game = actTruthtranceAdvancedShipmentPreparation(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceAdvancedShipmentPreparation(game, until, actions); };
+ const transition = (run: () => void) => {
+ const before = structuredClone(game), at = actions.length; run();
+ return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
+ };
+ const rules = transition(() => { act(game.host, { type: 'rules', advanced: true }); });
+ for (const p of game.players) act(p.id, { type: 'ready' });
+ const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
+ const setup = structuredClone(game);
+ assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.advancedPreview, true);
+ assert.deepEqual(game.expansions, []); assert.ok(game.players.every(p => p.hand.length === 0));
+ assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
+ !game.discoveryEnabled && !game.ecazTreachery);
+ const originalInventory = truthtranceAdvancedShipmentPreparationInventory(game); assert.equal(originalInventory.length, 33);
+ const truth = game.deck.find(card => card.effect === 'truthtrance');
+ const printed = game.deck.find(card => card.effect === 'karama');
+ const shield = game.deck.find(card => card.kind === 'shield'), snooper = game.deck.find(card => card.kind === 'snooper');
+ const ordinary = game.deck.find(card => card.kind === 'projectile'), auctionFace = game.deck.find(card => card.name === 'Baliset');
+ assert.ok(truth && printed && shield && snooper && ordinary && auctionFace);
+ const selected = kind === 'bgCharity' ? [truth, shield, printed] : [truth, printed, shield, snooper, ordinary];
+ const deckSelection = [...selected, ...(kind === 'elite' ? [auctionFace] : [])];
+ const dealOrder = deckSelection.map((card, position) => {
+ const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ return { position, from, card: card.id };
+ });
+ const beforeStartingDeal = structuredClone(game);
+ let advisorSetup: { before: Game; steps: TruthtranceAdvancedShipmentPreparationStep[]; after: Game } | null = null;
+ for (let count = 0; game.status === 'setup' && count < 100; count++) {
+ const step = nextTruthtranceAdvancedShipmentPreparation(game); assert.ok(step);
+ const before = step.action.type === 'advisorSetup' ? structuredClone(game) : null;
+ act(step.actor, step.action);
+ if (before) advisorSetup = { before, steps: [structuredClone(step)], after: structuredClone(game) };
+ }
+ assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
+ assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
+ const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
+ const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
+ for (let position = 0; position < (kind === 'elite' ? 4 : 2); position++) {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
+ !['hagga_basin', 'carthag', 'arrakeen', 'tueks_sietch'].includes(card.territory));
+ assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
+ spiceOrder.push({ position, from, card: structuredClone(card) });
+ }
+ const firstStorm = transition(() => { reach(state => state.turn === 1 && state.phase === 1); });
+ assert.equal(game.storm, 12);
+ let purchase: ReturnType | null = null, beforeBid: Game | null = null;
+ let shipment: ReturnType | null = null, beforeStormMovement: Game | null = null;
+ let stormLoss: ReturnType | null = null, originalStormSource: Game['stormMovementSource'] | null = null;
+ let auctionCard: string | null = null;
+ if (kind === 'elite') {
+ reach(state => state.turn === 1 && state.phase === 3 && truthtranceAdvancedShipmentPreparationClean(state) &&
+ !!state.auction && state.auction.active === asker && state.auction.bid === 0);
+ beforeBid = structuredClone(game); auctionCard = game.auction!.cards[game.auction!.index].id;
+ assert.equal(auctionCard, auctionFace.id);
+ purchase = transition(() => {
+ act(asker, { type: 'bid', amount: 4 });
+ reach(state => state.turn === 1 && state.phase === 4 && truthtranceAdvancedShipmentPreparationClean(state));
+ });
+ assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, 'emperor').spice, 14);
+ assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, asker).spice, 6);
+ assert.ok(truthtranceAdvancedShipmentPreparationPlayer(game, asker).hand.some(card => card.id === auctionCard));
+ reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthtranceAdvancedShipmentPreparationClean(state));
+ shipment = transition(() => {
+ act(target, { type: 'ship', territory: 'hagga_basin', sector: 13, amount: 2, elite: 2, allyPayment: 0 });
+ reach(truthtranceAdvancedShipmentPreparationClean);
+ });
+ assert.equal(truthtranceAdvancedShipmentPreparationPlayer(shipment.before, target).spice - truthtranceAdvancedShipmentPreparationPlayer(game, target).spice, 4);
+ assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, target).spice, 10);
+ assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, target).elites?.forces['hagga_basin:13'], 2);
+ reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening && state.stormPending === 1);
+ beforeStormMovement = structuredClone(game); originalStormSource = structuredClone(game.stormMovementSource);
+ stormLoss = transition(() => { reach(state => state.turn === 2 && state.phase === 1); });
+ assert.equal(game.storm, 13); assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, target).elites?.tanks, 2);
+ reach(state => state.turn === 2 && state.phase === 4 && truthtranceAdvancedShipmentPreparationClean(state));
+ const p = truthtranceAdvancedShipmentPreparationPlayer(game, target);
+ assert.equal(p.spice, 10); assert.equal(p.reserves, 18); assert.equal(p.tanks, 2);
+ assert.equal(p.elites?.reserves, 3); assert.equal(p.elites?.tanks, 2); assert.equal(p.elites?.revived, 0);
+ assert.ok(p.hand.some(card => card.id === printed.id));
+ } else {
+ reach(state => state.turn === 1 && state.phase === 2 && truthtranceAdvancedShipmentPreparationClean(state));
+ const p = truthtranceAdvancedShipmentPreparationPlayer(game, target); assert.ok(advisorSetup);
+ assert.equal(p.spice, 5); assert.equal(p.reserves, 19); assert.equal(p.tanks, 0);
+ assert.equal(p.forces['arrakeen:10'], 1); assert.equal(isAdvisor(p, 'arrakeen'), true);
+ assert.ok(!p.hand.some(card => card.effect === 'karama'));
+ }
+ const source = structuredClone(game), minimum = kind === 'bgCharity' ? 7 : 19;
+ const question: TruthQuestion = { kind: 'shipment', target, territory: 'carthag', minimum };
+ const declarationStep: TruthtranceAdvancedShipmentPreparationStep = { actor: asker, action: { type: 'card', card: truth.id } };
+ const askStep: TruthtranceAdvancedShipmentPreparationStep = { actor: asker, action: { type: 'truthAsk', question } };
+ const charityAction: Action = { type: 'charity' }, revivalAction: Action = { type: 'revive', amount: 1, elite: 1, freeElite: 1 };
+ const rateAction: Action | null = kind === 'elite' ? { type: 'card', card: printed.id, mode: 'shipment', target } : null;
+ const shipmentAction: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: minimum,
+ elite: kind === 'elite' ? 4 : 0, allyPayment: 0 };
+ assert.ok(truthtranceAdvancedShipmentPreparationPlayer(game, asker).hand.some(card => card.id === truth.id));
+ assert.deepEqual(truthtranceAdvancedShipmentPreparationInventory(game), originalInventory);
+ return { game, source, beforeQuestion: source, advanced: true as const, kind, asker, target, emperor: 'emperor', guild: kind === 'elite' ? 'guild' : null,
+ card: truth.id, truth: truth.id, truthtrance: truth.id, karama: printed.id,
+ targetKarama: kind === 'elite' ? printed.id : null, counterHolder: kind === 'bgCharity' ? 'emperor' : null,
+ question, minimum, impossibleMinimum: minimum + 1, territory: 'carthag', sector: 11, location: 'carthag:11',
+ declarationStep, askStep, charityAction, revivalAction, rateAction, shipmentAction,
+ initial, rules, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup,
+ deal: selected.map(card => card.id), dealOrder, originalInventory, originalCards: originalInventory, originalSpice, spiceOrder,
+ firstStorm, beforeBid, auctionCard, purchase, shipment, beforeStormMovement, originalStormSource, stormLoss,
+ balances: Object.fromEntries(game.players.map(p => [p.id, p.spice])),
+ actions, steps: actions, sourceActionCount: actions.length,
+ staging: [
+ 'Actual public classic lobby rules advanced:true, real Ready and start advancedPreview:true, original base33 and no modules. Only original unopened starting/auction deck positions, safe original Spice positions and native scalar lotteries are selected. No played hand, wallet, phase, force, Tank, elite or promise is installed.',
+ kind === 'bgCharity'
+ ? 'Original A/BG/E deal gives A Truthtrance, BG an ordinary card and Emperor printed Karama. BG really places its starting advisor beside native A10 Arrakeen, retaining five spice and nineteen reserves. Stop at original first Charity, before wealthy Advanced BG actually claims two. The seven-force city question needs that real owned preparation; no Karama is held by its target. The independently held original Emperor card can actually cancel that Charity.'
+ : 'Original A/E/H/G deal gives A Truthtrance, E printed Karama, H its two ordinary cards and G an ordinary card. An actual A bid4 buys the selected original ordinary first auction lot and pays E four through native seller income. E then really pays four for two Sardaukar at Hagga13 after first dials11+0→12. Original next StormCard1 reaches13 and kills both. Native turn2 phases/auction passes reach Revival with E10 cash, eighteen reserves and two actual elite-only Tanks. One free elite return plus the held original Karama can fund nineteen city counters; the one-elite limit prevents a twentieth.',
+ ],
+ };
+}
+export type TruthtranceAdvancedShipmentPreparationFixture = ReturnType;
+
+export function askTruthtranceAdvancedShipmentPreparation(game: Game, fixture: TruthtranceAdvancedShipmentPreparationFixture,
+ minimum = fixture.minimum, actions: TruthtranceAdvancedShipmentPreparationStep[] = []): Game {
+ game = actTruthtranceAdvancedShipmentPreparation(game, fixture.asker, fixture.declarationStep.action, actions);
+ game = advanceTruthtranceAdvancedShipmentPreparation(game, state => state.truthtrance?.stage === 'ask', actions);
+ return actTruthtranceAdvancedShipmentPreparation(game, fixture.asker,
+ { type: 'truthAsk', question: { ...fixture.question, minimum } }, actions);
+}
diff --git a/tests/fixture-truthtrance-early-advanced-shipment.ts b/tests/fixture-truthtrance-early-advanced-shipment.ts
new file mode 100644
index 00000000..dedc29fc
--- /dev/null
+++ b/tests/fixture-truthtrance-early-advanced-shipment.ts
@@ -0,0 +1,186 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
+import type { TruthQuestion } from '../game/truthtrance';
+import {
+ actFutureShipment, advanceFutureShipment, nextFutureShipmentStep, futureShipmentClean, futureShipmentPlayer,
+ type FutureShipmentStep,
+} from './fixture-truthtrance-future-shipment';
+
+export type TruthtranceEarlyAdvancedShipmentPhase = 'storm' | 'charity' | 'bidding';
+export type TruthtranceEarlyAdvancedShipmentStep = FutureShipmentStep;
+export const actTruthtranceEarlyAdvancedShipment = actFutureShipment;
+export const nextTruthtranceEarlyAdvancedShipmentStep = nextFutureShipmentStep;
+export const nextTruthtranceEarlyAdvancedShipment = nextFutureShipmentStep;
+export const advanceTruthtranceEarlyAdvancedShipment = advanceFutureShipment;
+export const truthtranceEarlyAdvancedShipmentPlayer = futureShipmentPlayer;
+export const truthtranceEarlyAdvancedShipmentClean = futureShipmentClean;
+export function truthtranceEarlyAdvancedShipmentInventory(game: Game) {
+ const ids = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
+ .map(card => card.id).sort();
+ assert.equal(new Set(ids).size, ids.length, 'Every original base33 Treachery Card retains one physical slot'); return ids;
+}
+export const truthtranceEarlyAdvancedShipmentPhysical = (game: Game) => ({
+ turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active,
+ ready: game.ready, movementRemaining: game.movementRemaining,
+ players: game.players.map(player => ({ id: player.id, spice: player.spice, forces: player.forces,
+ elites: player.elites, reserves: player.reserves, tanks: player.tanks, shipped: player.shipped, moved: player.moved })),
+});
+
+/** Actual public Advanced classic start and original five-card deal. Only
+ * unopened starting/first-auction/dual-Spice positions and scalar lotteries
+ * are selected. Stop before Truthtrance; no played state is installed. */
+export function createTruthtranceEarlyAdvancedShipmentFixture(options: {
+ phase?: TruthtranceEarlyAdvancedShipmentPhase;
+} = {}) {
+ const phase = options.phase ?? 'charity', phaseNumber = phase === 'storm' ? 0 : phase === 'charity' ? 2 : 3;
+ const asker = 'atreides', target = 'emperor', counterHolder = 'harkonnen', guild = 'guild';
+ let game = createGame('TRUTHEARLYADVANCEDSHIPMENT', newPlayer(asker, 'Atreides', 'atreides'), true);
+ for (const faction of [target, counterHolder, guild] as const) joinGame(game, newPlayer(faction, faction, faction));
+ const initial = structuredClone(game), actions: TruthtranceEarlyAdvancedShipmentStep[] = [];
+ const act = (actor: string, action: Action) => { game = actTruthtranceEarlyAdvancedShipment(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceEarlyAdvancedShipment(game, until, actions); };
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game);
+ act(game.host, { type: 'start', advancedPreview: true });
+ const setup = structuredClone(game);
+ assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, []);
+ assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
+ !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
+ assert.ok(game.players.every(player => player.hand.length === 0));
+ const originalInventory = truthtranceEarlyAdvancedShipmentInventory(game); assert.equal(originalInventory.length, 33);
+ const truth = game.deck.find(card => card.effect === 'truthtrance'), karama = game.deck.find(card => card.effect === 'karama');
+ const baliset = game.deck.find(card => card.name === 'Baliset'), shield = game.deck.find(card => card.kind === 'shield'),
+ snooper = game.deck.find(card => card.kind === 'snooper');
+ assert.ok(truth && karama && baliset && shield && snooper);
+ const deal = [truth.id, baliset.id, karama.id, shield.id, snooper.id];
+ const dealOrder = deal.map((id, position) => {
+ const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ return { position, from, card: id };
+ });
+ // A genuinely bought ordinary weapon cannot introduce a second Karama
+ // coupon into the respondent's later funding witness.
+ const ordinaryLot = game.deck.find(card => !deal.includes(card.id) && card.kind === 'projectile' && !card.effect);
+ assert.ok(ordinaryLot);
+ const auctionFrom = game.deck.findIndex(card => card.id === ordinaryLot.id); assert.ok(auctionFrom >= deal.length);
+ [game.deck[deal.length], game.deck[auctionFrom]] = [game.deck[auctionFrom], game.deck[deal.length]];
+ const auctionOrder = { position: deal.length, from: auctionFrom, card: ordinaryLot.id };
+ const beforeStartingDeal = structuredClone(game);
+ reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game), afterSetupActionCount = actions.length;
+ assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
+ const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeSpiceOrder = structuredClone(game);
+ const spiceOrder = [0, 1].map(position => {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
+ assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
+ game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
+ });
+ assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
+ reach(state => state.turn === 1 && state.phase === phaseNumber && futureShipmentClean(state) &&
+ (phaseNumber !== 3 || !!state.auction));
+ const respondent = futureShipmentPlayer(game, target);
+ assert.equal(respondent.spice, 10); assert.equal(respondent.reserves, 20); assert.equal(respondent.tanks, 0);
+ assert.deepEqual(respondent.forces, {}); assert.equal(respondent.shipped, false); assert.equal(respondent.moved, 0);
+ assert.ok(futureShipmentPlayer(game, asker).hand.some(card => card.id === truth.id));
+ assert.ok(futureShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id));
+ assert.ok(respondent.hand.some(card => card.id === baliset.id));
+ assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises, undefined);
+ assert.deepEqual(truthtranceEarlyAdvancedShipmentInventory(game), originalInventory);
+ const question: TruthQuestion = { kind: 'shipment', target, territory: 'carthag', minimum: 8 };
+ const declarationStep: TruthtranceEarlyAdvancedShipmentStep = { actor: asker, action: { type: 'card', card: truth.id } };
+ const askStep: TruthtranceEarlyAdvancedShipmentStep = { actor: asker, action: { type: 'truthAsk', question } };
+ const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 8, elite: 0, allyPayment: 0 };
+ return { game, source: structuredClone(game), phase, phaseNumber, advanced: true as const,
+ asker, target, counterHolder, guild, card: truth.id, truth: truth.id, karama: karama.id, baliset: baliset.id,
+ question, declarationStep, askStep, territory: 'carthag', sector: 11, location: 'carthag:11', minimum: 8,
+ shipment, bid: { type: 'bid', amount: 2, allyPayment: 0 } satisfies Action,
+ bigBid: { type: 'bid', amount: 3, allyPayment: 0 } satisfies Action,
+ bigBribe: { type: 'bribe', target: asker, amount: 3 } satisfies Action,
+ purchaseCost: 2, shipmentCost: 8, purchaseCard: ordinaryLot.id,
+ initial, lobby, setup, beforeStartingDeal, afterSetup, afterSetupActionCount,
+ beforeSpiceOrder, spiceOrder, deal, dealOrder, auctionOrder, originalSpice,
+ originalInventory, originalTreachery: originalInventory,
+ physical: structuredClone(truthtranceEarlyAdvancedShipmentPhysical(game)),
+ originalPhysical: structuredClone(truthtranceEarlyAdvancedShipmentPhysical(game)),
+ actions, sourceActions: actions, sourceActionCount: actions.length,
+ staging: [
+ 'Actual public Advanced A/E/H/G ready/start with advancedPreview:true, original base33 and no expansion or optional module. Every private Traitor and original setup control is an actual native action.',
+ 'Select five original unopened starting-card positions: A Truthtrance, E Baliset, H printed Karama plus Shield, G Snooper. Select one still-unopened ordinary first-auction weapon and two original ordinary Spice positions; physical inventory remains all33 originals.',
+ phase === 'storm'
+ ? 'Stop at clean original first Storm before its actual secret dials. The proposed commitment uses the current board, not an assumed future Storm outcome.'
+ : phase === 'charity'
+ ? 'Actually complete first Storm dials and both Advanced Spice blows, then stop at clean original Charity before Bidding.'
+ : 'Actually complete first Storm, both Advanced Spice blows and Charity plus native auction-peek responses, then stop at the clean first ordinary lot before any bid.',
+ 'E retains its actual printed ten spice, twenty native reserves, no board forces and unused shipment. Truthtrance declaration and eight-force Carthag question are returned unplayed. No played hand, wallet, army, clock, promise or response is installed.',
+ 'The optional continuation genuinely buys the original weapon for two, uses actual Guild TAKE or WAIT/allowance controls, and ships at E actual native turn. It resolves original Guild interception/income and ends only E original turn, without a fabricated early shipment.',
+ ],
+ };
+}
+export type TruthtranceEarlyAdvancedShipmentFixture = ReturnType;
+
+/** Native paid purchase, original Guild timing, and exact reserve delivery.
+ * The caller has already asked/answered; this helper creates no promise. */
+export function finishTruthtranceEarlyAdvancedShipment(f: TruthtranceEarlyAdvancedShipmentFixture, source: Game,
+ options: { guildTake?: boolean; amount?: number } = {}) {
+ const amount = options.amount ?? 8, take = options.guildTake ?? false;
+ let game = source;
+ const actions: TruthtranceEarlyAdvancedShipmentStep[] = [];
+ const act = (actor: string, action: Action) => { game = actTruthtranceEarlyAdvancedShipment(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceEarlyAdvancedShipment(game, until, actions); };
+ const transition = (run: () => void) => {
+ const before = structuredClone(game), start = actions.length; run();
+ return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
+ };
+ assert.equal(game.truthtrance ?? null, null);
+ reach(state => state.phase === 3 && futureShipmentClean(state) && state.auction?.active === f.target);
+ assert.equal(game.auction?.index, 0); assert.equal(game.auction?.cards[0].id, f.purchaseCard);
+ const purchase = transition(() => {
+ act(f.target, f.bid);
+ reach(state => state.phase === 4 && futureShipmentClean(state));
+ });
+ assert.equal(futureShipmentPlayer(purchase.before, f.target).spice, 10);
+ assert.equal(futureShipmentPlayer(game, f.target).spice, 8);
+ assert.ok(futureShipmentPlayer(game, f.target).hand.some(card => card.id === f.purchaseCard));
+ reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
+ const timing: ReturnType[] = [];
+ const guildTurn: ReturnType[] = [];
+ const chooseTiming = (choice: boolean) => {
+ assert.equal(game.decision?.kind, 'guildTiming');
+ timing.push(transition(() => {
+ act(f.guild, { type: 'decision', take: choice });
+ reach(state => futureShipmentClean(state));
+ }));
+ };
+ chooseTiming(take);
+ if (take) {
+ assert.equal(game.active, f.guild);
+ guildTurn.push(transition(() => { act(f.guild, { type: 'endMovement' }); }));
+ }
+ reach(state => state.phase === 5 && futureShipmentClean(state) && state.active === f.asker);
+ const askerEnd = transition(() => { act(f.asker, { type: 'endMovement' }); });
+ if (!take) {
+ reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
+ chooseTiming(false);
+ }
+ reach(state => state.phase === 5 && futureShipmentClean(state) && state.active === f.target);
+ const beforeShipment = structuredClone(game), shipmentAction: Action = { ...f.shipment, amount };
+ const start = actions.length;
+ act(f.target, shipmentAction);
+ const declared = structuredClone(game), originalControls: Game[] = [];
+ for (let count = 0; !futureShipmentClean(game) || !!game.pendingShipment; count++) {
+ assert.ok(count < 100, 'Resolve only the original shipment permission/payment/income');
+ originalControls.push(structuredClone(game));
+ const step = nextFutureShipmentStep(game); assert.ok(step);
+ act(step.actor, step.action);
+ }
+ const delivered = structuredClone(game), shipment = { before: beforeShipment, action: shipmentAction,
+ declared, controls: originalControls, steps: structuredClone(actions.slice(start)), after: delivered };
+ assert.equal(futureShipmentPlayer(game, f.target).forces[f.location], amount);
+ assert.equal(futureShipmentPlayer(game, f.target).reserves, 20 - amount);
+ assert.equal(futureShipmentPlayer(game, f.target).spice, 8 - amount);
+ const end = transition(() => { act(f.target, { type: 'endMovement' }); });
+ assert.deepEqual(truthtranceEarlyAdvancedShipmentInventory(game), f.originalInventory);
+ return { game, purchase, timing, guildTurn, askerEnd, beforeShipment, shipmentAction, shipment, declared,
+ delivered, end, actions, amount, guildTake: take };
+}
diff --git a/tests/truthtrance-advanced-shipment-preparation-engine.test.ts b/tests/truthtrance-advanced-shipment-preparation-engine.test.ts
new file mode 100644
index 00000000..2a2b98aa
--- /dev/null
+++ b/tests/truthtrance-advanced-shipment-preparation-engine.test.ts
@@ -0,0 +1,193 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { isAdvisor } from '../game/advisors';
+import {
+ createTruthtranceAdvancedShipmentPreparationFixture as fixture,
+ askTruthtranceAdvancedShipmentPreparation as ask, actTruthtranceAdvancedShipmentPreparation as act,
+ advanceTruthtranceAdvancedShipmentPreparation as advance, truthtranceAdvancedShipmentPreparationClean as clean,
+ truthtranceAdvancedShipmentPreparationPlayer as player, truthtranceAdvancedShipmentPreparationInventory as inventory,
+ type TruthtranceAdvancedShipmentPreparationFixture,
+} from './fixture-truthtrance-advanced-shipment-preparation';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
+}
+function nativeSource(f: TruthtranceAdvancedShipmentPreparationFixture) {
+ assert.equal(f.initial.advanced, false); assert.notEqual(f.initial.status, 'playing');
+ assert.deepEqual(f.rules.steps, [{ actor: f.asker, action: { type: 'rules', advanced: true } }]);
+ assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.advancedPreview, true);
+ assert.deepEqual(f.setup.expansions, []); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
+ assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
+ assert.equal(player(f.afterSetup, f.asker).hand[0].id, f.truthtrance);
+ assert.ok(player(f.afterSetup, f.emperor).hand.some(card => card.id === f.karama && card.effect === 'karama'));
+ assert.deepEqual(f.firstStorm.steps.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [11, 0]);
+ assert.equal(f.firstStorm.after.storm, 12); assert.equal(f.game.advanced, true);
+ assert.equal(f.game.truthtrance ?? null, null); assert.equal(f.game.shipmentPromises?.length ?? 0, 0);
+ if (f.kind === 'bgCharity') {
+ assert.ok(f.advisorSetup); assert.deepEqual(f.advisorSetup.steps[0].action, { type: 'advisorSetup', territory: 'arrakeen', sector: 10 });
+ assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 2); assert.equal(f.counterHolder, f.emperor);
+ assert.equal(player(f.game, f.target).spice, 5); assert.equal(player(f.game, f.target).reserves, 19);
+ assert.equal(player(f.game, f.target).forces['arrakeen:10'], 1); assert.equal(isAdvisor(player(f.game, f.target), 'arrakeen'), true);
+ assert.equal(player(f.game, f.asker).forces['arrakeen:10'], 10); assert.equal(f.targetKarama, null);
+ assert.equal(player(f.game, f.target).hand.some(card => card.effect === 'karama'), false);
+ } else {
+ assert.ok(f.purchase && f.beforeBid && f.shipment && f.beforeStormMovement && f.stormLoss && f.guild);
+ assert.deepEqual(f.purchase.steps[0], { actor: f.asker, action: { type: 'bid', amount: 4 } });
+ assert.equal(player(f.purchase.before, f.emperor).spice, 10); assert.equal(player(f.purchase.after, f.emperor).spice, 14);
+ assert.equal(player(f.purchase.after, f.asker).spice, 6);
+ assert.ok(player(f.purchase.after, f.asker).hand.some(card => card.id === f.auctionCard));
+ assert.deepEqual(f.shipment.steps[0], { actor: f.target,
+ action: { type: 'ship', territory: 'hagga_basin', sector: 13, amount: 2, elite: 2, allyPayment: 0 } });
+ assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 4);
+ assert.equal(player(f.shipment.after, f.target).elites?.forces['hagga_basin:13'], 2);
+ assert.equal(f.beforeStormMovement.storm, 12); assert.equal(f.beforeStormMovement.stormPending, 1);
+ assert.equal(f.stormLoss.after.storm, 13); assert.equal(player(f.stormLoss.after, f.target).elites?.tanks, 2);
+ assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
+ const p = player(f.game, f.target); assert.equal(p.spice, 10); assert.equal(p.reserves, 18); assert.equal(p.tanks, 2);
+ assert.equal(p.elites?.reserves, 3); assert.equal(p.elites?.tanks, 2); assert.equal(p.elites?.revived, 0);
+ assert.equal(p.revived, 0); assert.equal(p.freeForcesRevived, 0); assert.equal(f.targetKarama, f.karama);
+ }
+ assert.equal(f.originalInventory.length, 33); assert.deepEqual(inventory(f.game), f.originalInventory);
+ assert.equal(f.sourceActionCount, f.actions.length);
+}
+function bound(f: TruthtranceAdvancedShipmentPreparationFixture, game = structuredClone(f.game)) {
+ game = ask(game, f);
+ assert.ok(viewGame(game, f.asker).truthShipmentTargets.includes(f.target));
+ assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['yes', 'no']);
+ game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises?.length, 1);
+ assert.equal(game.shipmentPromises![0].answer, true); assert.equal(game.shipmentPromises![0].released, undefined);
+ return game;
+}
+function prepare(game: Game, f: TruthtranceAdvancedShipmentPreparationFixture, action?: Action) {
+ const guide = viewGame(game, f.target).shipmentPreparation; assert.ok(guide?.actions.length);
+ assert.equal(guide.actions[0].type, f.kind === 'bgCharity' ? 'charity' : 'revive');
+ reject(game, f.target, { type: 'ready' });
+ if (f.kind === 'elite') {
+ assert.equal(guide.actions[0].amount, 1);
+ reject(game, f.target, { type: 'revive', amount: 2, elite: 2, freeElite: 1 });
+ }
+ game = act(game, f.target, action ?? guide.actions[0]);
+ if (f.kind === 'bgCharity') {
+ assert.equal(game.response?.kind, 'bgCharity'); assert.equal(player(game, f.target).spice, 5);
+ }
+ game = advance(game, clean);
+ assert.equal(viewGame(game, f.target).shipmentPreparation, null);
+ assert.equal(game.shipmentPromises![0].released, undefined);
+ if (f.kind === 'bgCharity') assert.equal(player(game, f.target).spice, 7);
+ else {
+ const p = player(game, f.target); assert.equal(p.spice, 10); assert.equal(p.reserves, 19); assert.equal(p.tanks, 1);
+ assert.equal(p.elites?.reserves, 4); assert.equal(p.elites?.tanks, 1); assert.equal(p.elites?.revived, 1);
+ assert.equal(p.revived, 1); assert.equal(p.freeForcesRevived, 1);
+ reject(game, f.target, { type: 'revive', amount: 1, elite: 1, freeElite: 0 });
+ }
+ return game;
+}
+function ship(game: Game, f: TruthtranceAdvancedShipmentPreparationFixture) {
+ game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
+ assert.equal(game.shipmentPromises![0].released, undefined); assert.equal(game.shipmentPromises![0].fulfilled, undefined);
+ const before = structuredClone(game), guildBefore = f.guild ? player(game, f.guild).spice : null;
+ if (f.kind === 'elite') {
+ const rate = viewGame(game, f.target).shipmentCompletion?.actions[0]; assert.ok(rate);
+ assert.equal(rate.type, 'card'); assert.equal(rate.card, f.karama); assert.equal(rate.mode, 'shipment');
+ assert.ok(f.rateAction); game = act(game, f.target, f.rateAction);
+ assert.equal(player(game, f.target).spice, 10); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
+ }
+ const completion = viewGame(game, f.target).shipmentCompletion; assert.ok(completion);
+ const shipment = completion.actions.find(action => action.type === 'ship'); assert.ok(shipment);
+ assert.equal(shipment.amount, f.minimum); assert.equal(shipment.territory, f.territory);
+ if (f.kind === 'elite') assert.equal(shipment.elite, 4);
+ reject(game, f.target, { ...f.shipmentAction, amount: f.minimum - 1 });
+ game = act(game, f.target, f.shipmentAction); game = advance(game, clean);
+ const p = player(game, f.target); assert.equal(p.spice, 0); assert.equal(p.forces[f.location], f.minimum);
+ assert.equal(player(before, f.target).spice - p.spice, f.kind === 'elite' ? 10 : 7);
+ assert.equal(p.shipped, true); assert.equal(p.moved, 0); assert.equal(game.shipmentPromises![0].fulfilled, true);
+ assert.equal(game.shipmentPromises![0].released, undefined);
+ assert.equal(p.reserves, player(before, f.target).reserves - f.minimum);
+ if (f.kind === 'elite') {
+ assert.equal(p.elites?.forces[f.location], 4); assert.equal(p.elites?.reserves, 0); assert.equal(p.elites?.tanks, 1);
+ assert.equal(player(game, f.guild!).spice, guildBefore, 'The real purchased rate sends its invoice to the bank');
+ } else {
+ assert.equal(p.forces['arrakeen:10'], 1); assert.equal(isAdvisor(p, 'arrakeen'), true); assert.equal(isAdvisor(p, 'carthag'), false);
+ assert.equal(p.reserves, 12);
+ }
+ assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.target, f.shipmentAction); return game;
+}
+
+void test('native Advanced wealthy BG Charity prepares and pays the real seven-counter city commitment', () => {
+ const f = fixture({ kind: 'bgCharity' }); nativeSource(f); ship(prepare(bound(f), f), f);
+});
+
+void test('native Advanced original Emperor Karama prevents wealthy BG Charity and releases the otherwise impossible shipment once', () => {
+ const f = fixture({ kind: 'bgCharity' }); nativeSource(f);
+ let game = bound(f); reject(game, f.target, { type: 'ready' });
+ game = act(game, f.target, f.charityAction); assert.equal(game.response?.kind, 'bgCharity');
+ assert.equal(player(game, f.target).spice, 5); assert.ok(f.counterHolder);
+ game = act(game, f.counterHolder, { type: 'card', card: f.karama, mode: 'cancel' }); game = advance(game, clean);
+ assert.equal(player(game, f.target).spice, 5); assert.equal(game.shipmentPromises![0].released, true);
+ assert.equal(game.shipmentPromises![0].fulfilled, undefined); assert.equal(viewGame(game, f.target).shipmentPreparation, null);
+ assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
+ const released = structuredClone(game.shipmentPromises); game = act(game, f.target, { type: 'ready' });
+ game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
+ reject(game, f.target, f.shipmentAction);
+ game = act(game, f.target, { ...f.shipmentAction, amount: 5 }); game = advance(game, clean);
+ assert.equal(player(game, f.target).forces[f.location], 5); assert.equal(player(game, f.target).spice, 0);
+ assert.deepEqual(game.shipmentPromises, released); assert.deepEqual(inventory(game), f.originalInventory);
+});
+
+void test('native Advanced wealthy BG source cannot promise eight without an owned funding source', () => {
+ const f = fixture({ kind: 'bgCharity' }), game = ask(structuredClone(f.game), f, 8);
+ assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
+ reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ assert.doesNotThrow(() => act(game, f.target, { type: 'truthAnswer', answer: 'no' }));
+});
+
+void test('native Advanced elite-only Storm losses prepare exactly one Sardaukar then original Karama pays nineteen city counters', () => {
+ const f = fixture({ kind: 'elite' }); nativeSource(f); ship(prepare(bound(f), f), f);
+});
+
+void test('native Advanced elite-only source cannot promise twenty or revive its second Sardaukar this turn', () => {
+ const f = fixture({ kind: 'elite' }); nativeSource(f);
+ const game = ask(structuredClone(f.game), f, 20); assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
+ reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ reject(f.game, f.target, { type: 'revive', amount: 2, elite: 2, freeElite: 1 });
+ const prepared = prepare(bound(f), f); assert.equal(player(prepared, f.target).elites?.revived, 1);
+ reject(prepared, f.target, { type: 'revive', amount: 1, elite: 1 });
+});
+
+void test('a genuine earlier Advanced Ready is reopened by either required owned preparation answer', () => {
+ for (const kind of ['bgCharity', 'elite'] as const) {
+ const f = fixture({ kind }); let game = act(structuredClone(f.game), f.target, { type: 'ready' });
+ assert.equal(game.ready.includes(f.target), true); game = bound(f, game);
+ assert.equal(game.ready.includes(f.target), false); assert.ok(viewGame(game, f.target).shipmentPreparation?.actions.length);
+ prepare(game, f);
+ }
+});
+
+void test('native Advanced pending shipment questions reject foreign answers and premature shipment or phase skipping', () => {
+ for (const kind of ['bgCharity', 'elite'] as const) {
+ const f = fixture({ kind }), game = ask(structuredClone(f.game), f);
+ reject(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); reject(game, f.target, f.shipmentAction);
+ reject(game, f.target, { type: 'ready' }); reject(game, f.asker, f.askStep.action);
+ const done = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ reject(done, f.target, { type: 'truthAnswer', answer: 'yes' }); assert.deepEqual(inventory(done), f.originalInventory);
+ }
+});
+
+void test('all four minimal profiles choose legal original Advanced answers and execute the required owned preparation', () => {
+ for (const kind of ['bgCharity', 'elite'] as const) {
+ const f = fixture({ kind }), asked = ask(structuredClone(f.game), f), promised = bound(f);
+ for (const difficulty of DIFFICULTIES) {
+ const answering = viewGame(asked, f.target); answering.players.find(p => p.id === f.target)!.bot = difficulty;
+ const answer = botActions(answering)[0]; assert.ok(answer); assert.equal(answer.type, 'truthAnswer');
+ assert.ok(answer.answer === 'yes' || answer.answer === 'no'); assert.doesNotThrow(() => act(asked, f.target, answer));
+ const own = viewGame(promised, f.target); own.players.find(p => p.id === f.target)!.bot = difficulty;
+ const proposed = botActions(own)[0]; assert.ok(proposed);
+ assert.equal(proposed.type, kind === 'bgCharity' ? 'charity' : 'revive');
+ ship(prepare(structuredClone(promised), f, proposed), f);
+ }
+ }
+});
diff --git a/tests/truthtrance-early-advanced-shipment-engine.test.ts b/tests/truthtrance-early-advanced-shipment-engine.test.ts
new file mode 100644
index 00000000..59bce080
--- /dev/null
+++ b/tests/truthtrance-early-advanced-shipment-engine.test.ts
@@ -0,0 +1,158 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import {
+ createTruthtranceEarlyAdvancedShipmentFixture as fixture, actTruthtranceEarlyAdvancedShipment as act,
+ advanceTruthtranceEarlyAdvancedShipment as advance, finishTruthtranceEarlyAdvancedShipment as finish,
+ truthtranceEarlyAdvancedShipmentClean as clean, truthtranceEarlyAdvancedShipmentPlayer as player,
+ truthtranceEarlyAdvancedShipmentInventory as inventory, truthtranceEarlyAdvancedShipmentPhysical as physical,
+ type TruthtranceEarlyAdvancedShipmentFixture,
+} from './fixture-truthtrance-early-advanced-shipment';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
+}
+function nativeSource(f: TruthtranceEarlyAdvancedShipmentFixture) {
+ assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.deepEqual(f.setup.expansions, []);
+ assert.ok(!f.setup.homeworlds && !f.setup.nexusCards && !f.setup.leaderSkills && !f.setup.techTokens &&
+ !f.setup.strongholdCards && !f.setup.discoveryEnabled && !f.setup.ecazTreachery);
+ assert.ok(f.actions.some(step => step.actor === f.lobby.host && step.action.type === 'start' && step.action.advancedPreview === true));
+ assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
+ assert.deepEqual(f.afterSetup.players.flatMap(player => player.hand.map(card => card.id)), f.deal);
+ assert.ok(player(f.afterSetup, f.asker).hand.some(card => card.id === f.card && card.effect === 'truthtrance'));
+ assert.ok(player(f.afterSetup, f.counterHolder).hand.some(card => card.id === f.karama && card.effect === 'karama'));
+ assert.ok(player(f.afterSetup, f.target).hand.some(card => card.id === f.baliset && card.name === 'Baliset'));
+ assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.target).reserves, 20);
+ assert.deepEqual(player(f.game, f.target).forces, {}); assert.equal(player(f.game, f.target).tanks, 0);
+ assert.equal(player(f.game, f.target).shipped, false); assert.equal(player(f.game, f.target).moved, 0);
+ assert.equal(f.game.turn, 1); assert.equal(f.game.phase, f.phaseNumber); assert.ok(clean(f.game));
+ assert.equal(f.game.truthtrance ?? null, null); assert.equal(f.game.shipmentPromises, undefined);
+ assert.equal(f.originalInventory.length, 33); assert.deepEqual(inventory(f.game), f.originalInventory);
+ assert.deepEqual(physical(f.game), f.originalPhysical); assert.equal(f.sourceActionCount, f.actions.length);
+ if (f.phase === 'storm') assert.deepEqual(f.game.stormDials, {});
+ else {
+ assert.equal(f.actions.filter(step => step.action.type === 'stormDial').length, 2);
+ assert.equal(f.game.spiceSequence ?? null, null);
+ assert.ok(f.game.spiceDiscard[0].some(card => 'territory' in card));
+ assert.ok(f.game.spiceDiscard[1].some(card => 'territory' in card));
+ }
+}
+function asked(f: TruthtranceEarlyAdvancedShipmentFixture) {
+ const actions: { actor: string; action: Action }[] = [];
+ let game = act(structuredClone(f.game), f.declarationStep.actor, f.declarationStep.action, actions);
+ const declared = structuredClone(game);
+ game = advance(game, state => state.truthtrance?.stage === 'ask', actions);
+ assert.ok(viewGame(game, f.asker).truthShipmentTargets.includes(f.target));
+ const beforeQuestion = structuredClone(game);
+ game = act(game, f.askStep.actor, f.askStep.action, actions);
+ assert.equal(game.truthtrance?.stage, 'answer');
+ assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes'));
+ assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('no'));
+ return { game, declared, beforeQuestion, actions };
+}
+function bound(f: TruthtranceEarlyAdvancedShipmentFixture, answer: 'yes' | 'no' = 'yes') {
+ const question = asked(f), game = act(question.game, f.target, { type: 'truthAnswer', answer }, question.actions);
+ assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises?.length, 1);
+ assert.equal(game.shipmentPromises![0].player, f.target);
+ assert.equal(game.shipmentPromises![0].answer, answer === 'yes');
+ assert.equal(game.shipmentPromises![0].released, undefined);
+ assert.equal(game.shipmentPromises![0].fulfilled, undefined);
+ assert.equal(game.phase, f.phaseNumber); assert.deepEqual(physical(game), f.originalPhysical);
+ assert.deepEqual(inventory(game), f.originalInventory); return game;
+}
+
+for (const phase of ['storm', 'charity', 'bidding'] as const) void test(
+ `public Advanced ${phase} Truthtrance binds an eight-force future shipment without granting an early action`, () => {
+ const f = fixture({ phase }); nativeSource(f); const game = bound(f);
+ const view = viewGame(game, f.target);
+ assert.equal(view.shipmentCompletion, null); assert.equal(view.shipmentPreparation, null);
+ reject(game, f.target, f.shipment); reject(game, f.target, { type: 'endMovement' });
+ reject(game, f.target, f.bigBribe);
+ assert.equal(game.shipmentPromises![0].released, undefined);
+ });
+
+for (const guildTake of [false, true]) void test(
+ `public Advanced early shipment Yes retains purchase2 and native Guild ${guildTake ? 'TAKE' : 'WAIT'} through actual eight-force delivery`, () => {
+ const f = fixture(); nativeSource(f); const game = bound(f);
+ const completed = finish(f, game, { guildTake });
+ reject(completed.purchase.before, f.target, f.bigBid);
+ assert.equal(player(completed.purchase.before, f.target).spice, 10);
+ assert.equal(player(completed.purchase.after, f.target).spice, 8);
+ assert.ok(player(completed.purchase.after, f.target).hand.some(card => card.id === f.purchaseCard));
+ assert.equal(completed.purchase.after.shipmentPromises![0].released, undefined);
+ assert.ok(completed.timing.some(record => record.steps.some(step => step.actor === f.guild &&
+ step.action.type === 'decision' && step.action.take === guildTake)));
+ if (guildTake) {
+ assert.equal(completed.guildTurn.length, 1);
+ assert.deepEqual(completed.guildTurn[0].steps, [{ actor: f.guild, action: { type: 'endMovement' } }]);
+ } else assert.equal(completed.guildTurn.length, 0);
+ assert.equal(completed.beforeShipment.active, f.target);
+ assert.equal(viewGame(completed.beforeShipment, f.target).shipmentCompletion?.actions.at(-1)?.amount, 8);
+ assert.ok(completed.shipment.controls.some(control => control.decision?.kind === 'guildShipment'));
+ assert.ok(completed.shipment.controls.some(control => control.response?.kind === 'guildIncome'));
+ assert.equal(player(completed.delivered, f.guild).spice, player(completed.beforeShipment, f.guild).spice + 8);
+ assert.equal(player(completed.delivered, f.target).spice, 0);
+ assert.equal(player(completed.delivered, f.target).forces[f.location], 8);
+ assert.equal(player(completed.delivered, f.target).reserves, 12);
+ assert.equal(completed.delivered.shipmentPromises![0].fulfilled, true);
+ assert.equal(completed.delivered.shipmentPromises![0].released, undefined);
+ assert.ok(player(completed.game, f.counterHolder).hand.some(card => card.id === f.karama));
+ assert.equal(completed.game.discard.filter(card => card.id === f.card).length, 1);
+ reject(completed.delivered, f.target, f.shipment);
+ assert.deepEqual(inventory(completed.game), f.originalInventory);
+ });
+
+void test('public Advanced early shipment No allows voluntary costs and a real smaller Carthag shipment', () => {
+ const f = fixture({ phase: 'bidding' }); nativeSource(f); const game = bound(f, 'no');
+ const voluntary = act(structuredClone(game), f.target, f.bigBribe);
+ assert.equal(player(voluntary, f.target).spice, 7); assert.equal(voluntary.shipmentPromises![0].released, undefined);
+ const completed = finish(f, game, { amount: 7, guildTake: true });
+ reject(completed.beforeShipment, f.target, f.shipment);
+ assert.equal(player(completed.delivered, f.target).forces[f.location], 7);
+ assert.equal(player(completed.delivered, f.target).spice, 1);
+ assert.equal(completed.delivered.shipmentPromises![0].fulfilled, true);
+ assert.equal(completed.delivered.shipmentPromises![0].released, undefined);
+ assert.deepEqual(inventory(completed.game), f.originalInventory);
+});
+
+void test('an actual first Storm releases an early Advanced shipment only after its current-board route becomes unavailable', () => {
+ const f = fixture({ phase: 'storm' }); nativeSource(f); let game = bound(f);
+ const initialStorm = game.storm, distance = (f.sector - initialStorm + 18) % 18;
+ assert.ok(distance > 0 && distance <= 20); assert.equal(game.stormDialers.length, 2);
+ for (const [index, actor] of game.stormDialers.entries())
+ game = act(game, actor, { type: 'stormDial', amount: index === 0 ? distance : 0 });
+ assert.equal(game.storm, initialStorm); assert.equal(game.stormPending, distance);
+ assert.equal(game.shipmentPromises![0].released, undefined, 'Recorded future distance is not a future-board promise prediction');
+ game = advance(game, state => state.phase === 2 && clean(state));
+ assert.equal(game.storm, f.sector); assert.equal(game.shipmentPromises![0].released, true);
+ const spent = act(game, f.target, f.bigBribe);
+ assert.equal(player(spent, f.target).spice, 7);
+ assert.deepEqual(inventory(spent), f.originalInventory);
+});
+
+void test('the genuine early Advanced Truth question rejects foreign and underlying replies before its native answer', () => {
+ const f = fixture(), question = asked(f), game = question.game;
+ reject(game, f.asker, { type: 'truthAnswer', answer: 'yes' });
+ reject(game, f.target, { type: 'ready' }); reject(game, f.target, f.shipment);
+ reject(game, f.target, f.bigBribe); reject(game, f.asker, f.askStep.action);
+ const done = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ reject(done, f.target, { type: 'truthAnswer', answer: 'yes' });
+ reject(done, f.asker, f.askStep.action);
+ assert.deepEqual(physical(done), f.originalPhysical); assert.deepEqual(inventory(done), f.originalInventory);
+});
+
+void test('all four minimal policies participate legally in the actual early Advanced bid while retaining shipment funds', () => {
+ const f = fixture(), boundGame = bound(f);
+ const game = advance(boundGame, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty;
+ const before = structuredClone(view), action = botActions(view)[0]; assert.ok(action, difficulty);
+ assert.deepEqual(view, before);
+ const done = act(structuredClone(game), f.target, action);
+ assert.equal(done.shipmentPromises![0].released, undefined);
+ assert.ok(player(done, f.target).spice >= 8);
+ assert.deepEqual(inventory(done), f.originalInventory);
+ }
+});
diff --git a/tests/truthtrance-future-shipment.test.ts b/tests/truthtrance-future-shipment.test.ts
index 8d1495a7..b95dc58f 100644
--- a/tests/truthtrance-future-shipment.test.ts
+++ b/tests/truthtrance-future-shipment.test.ts
@@ -76,7 +76,8 @@ void test('current-turn target timing distinguishes earlier phases from unused m
assert.equal(shipmentPromiseTurnAvailable({ ...g, phase: 2 }, 'p', false), true,
'A prior-turn shipped flag does not consume the future current-turn opportunity');
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4 }, 'p'), true);
- assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4, advanced: true }, 'p'), false);
+ assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4, advanced: true }, 'p'), true);
+ assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4, advanced: true, homeworlds: {} }, 'p'), false);
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 2, techTokens: {} }, 'p'), false);
const mode = { ...g, advanced: true, active: 'r', expansions: ['choam'], semutaPreview: true,
players: [{ id: 'r', faction: 'richese' }, { id: 'p', faction: 'emperor' }] };