Enable early native-faction Truthtrance promises

This commit is contained in:
Quintine committed 2026-10-26 06:21:34 +11:00
1 parent 8053e1fb26
commit d5c5342d2d
22 files changed
+1371 -44

No files matched your search

+6 -6
View File
@@ -360,10 +360,10 @@ export function Truthtrance({
<>
<MovementClaimFields id={`${factId}-movement`} value={movement} onChange={setMovement} />
<p className="fine">One ground movement of the chosen physical minimum, from now until the respondent’s Movement turn ends. Shipment, HMS relocation and Ambassador relocation do not count. Owned Hajr, Ornithopter, native Kulon and paid Box recovery of a known discard can prepare a later move. No stays binding after earlier nonmatching moves.</p>
{g.phase < 5 && <p className="notice">Movement comes later this round. Keep the forces, spice and cards needed by your answer; available Charity or Revival may prepare the route. Current auction commitments reserve their actual funding. Current preparation appears when it is available.</p>}
{g.phase < 5 && <p className="notice">Movement comes later this round. Keep the forces, spice and cards needed by your answer; available Charity, your own CHOAM opening income or Revival may prepare the route. Current auction commitments reserve their actual funding. Current preparation appears when it is available.</p>}
{g.advanced && <p className="fine">Advanced movement counts physical forces, including each elite once. Hajr remains available only immediately after a completed movement and its required arrival decisions.</p>}
<p className="fine">An owned No-Field may prepare a route through its normal shipment, movement or voluntary reveal. The concealed marker does not count toward the promised physical minimum; revelation uses only available reserves.</p>
{!g.truthMovementTargets.includes(target) && <p className="notice">Choose a supported Basic respondent this round or an Advanced respondent with declared native families during Movement, and finish pending decisions. Optional modules and earlier Advanced movement forecasts remain unfinished.</p>}
{!g.truthMovementTargets.includes(target) && <p className="notice">Choose a supported Basic or Advanced respondent with declared native families and an unused turn this round, and finish pending decisions. Optional modules remain unfinished.</p>}
<p className="notice" aria-label="Public movement question preview">{truthQuestionText(question, leaderName, locationName)}</p>
</>
) : kind === 'shipment' || kind === 'mixedShipment' ? (
@@ -388,10 +388,10 @@ export function Truthtrance({
{!shipmentAvailable && (
<p className="notice">
Automatic enforcement supports a later shipment this turn
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
under Basic or Advanced rules with selected native faction
decks and no optional modules, including the Guild. Active
or waiting turns are eligible during Shipment and Movement;
existing optional profiles retain their separate scope. Select
an eligible player and finish pending decisions first.
Native No-Field shipments and other rules configurations
remain unfinished.
+28
View File
@@ -16,6 +16,34 @@ open mode gates.
## Current checkpoint and work
**Native early Truthtrance promises — 26 October. Prototyped / Partial.**
Bare registered native factions with selected decks can answer current-turn
shipment and physical ground-movement questions in Basic/Advanced phases0–5.
CHOAM's own fixed allowed opening uses the actual settlement formula in the
witness; cash, cancellation and its current-turn receipt stay native. Richese
shipment guidance passes or submits Silent zero with the original event.
Existing preparation, voluntary-cost checks, persistent controls and all four
minimal legal profiles are reused. Optional-module boundaries and public mode
gates remain separate.
Three disjoint native source lanes and root integration pass **24 files,
223/223**, types/lint/build and **sixteen native engine/GameTable SSR/actual
promise-control journeys** on unchanged source. Forty-five native cases plus
one scope case cover real income/cancellation/invoices, Yes/No, original
physical events, stale/foreign rejection and four minimal profiles. Native
CHOAM8-action sources retain35 cards/2 cash until opening8; Richese Basic18/
Advanced22 retain45/5 cash; Ecaz20/23 retain33/original Basin6. Only unopened
positions/native lotteries are selected; no played physical sources or promises
are installed. Controlled configuration negatives remain labelled supplements.
Final frozen v4 report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-truthtrance-native-families-proof-v4/`;
journeys/HTML: adjacent `20261026-truthtrance-native-families-runtime/`.
Broader promises/modules, complete rules and final assurance remain open.
No saved-room/D1/server, restart, deployment or recovery operation occurs.
DOM/hydration, browser/mobile and deployed acceptance remain due.
[Source and scope](TRUTHTRANCE_SHIPMENT_PROMISES.md#native-factions-and-owned-choam-opening--26-october-2026).
**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
+35
View File
@@ -1,5 +1,40 @@
# Dune implementation status
## 26 October 2026 — Native early Truthtrance promises
**Prototyped / Partial.** Bare registered native factions with selected decks can answer current-turn
shipment and physical ground-movement questions in Basic/Advanced phases0–5.
CHOAM's own fixed allowed opening uses the actual settlement formula in the
witness; cash, cancellation and its current-turn receipt stay native. Richese
shipment guidance passes or submits Silent zero with the original event.
Existing preparation, voluntary-cost checks, persistent controls and all four
minimal legal profiles are reused. Optional-module boundaries and public mode
gates remain separate.
Three disjoint native source lanes and root integration pass **24 files,
223/223**, types/lint/build and **sixteen native engine/GameTable SSR/actual
promise-control journeys** on unchanged source. Forty-five native cases plus
one scope case cover real income/cancellation/invoices, Yes/No, original
physical events, stale/foreign rejection and four minimal profiles. Native
CHOAM8-action sources retain35 cards/2 cash until opening8; Richese Basic18/
Advanced22 retain45/5 cash; Ecaz20/23 retain33/original Basin6. Only unopened
positions/native lotteries are selected; no played physical sources or promises
are installed. Controlled configuration negatives remain labelled supplements.
Feedback corrects fixture-only card/start/group expectations and awaits actual
movement-No closure and Karama/Semuta disposal before No-Field release. The
shared quote signature retains Advanced context. A known-failing union is
stopped; the Richese policy smoke is narrowed to fixed genuine cache controls,
then all four answer/Silent-zero/completion branches, avoiding unrelated special
acquisition and repeated views during foreign passes. Final v4 is complete.
Final frozen v4 report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-truthtrance-native-families-proof-v4/`;
journeys/HTML: adjacent `20261026-truthtrance-native-families-runtime/`.
Broader promises/modules, complete rules and final assurance remain open.
No saved-room/D1/server, restart, deployment or recovery operation occurs.
DOM/hydration, browser/mobile and deployed acceptance remain due.
[Source and scope](TRUTHTRANCE_SHIPMENT_PROMISES.md#native-factions-and-owned-choam-opening--26-october-2026).
## 26 October 2026 — Earlier Advanced Truthtrance shipment promises
**Prototyped / Partial.** Extend current-turn reserve-shipment questions to bare
+20 -2
View File
@@ -1,5 +1,23 @@
# Rule decision index
## Native early promises and owned CHOAM opening — 26 October 2026
Extend both current-turn shipment and physical ground-movement questions to
registered native factions with their selected decks in Basic and Advanced
phases0–5, retaining optional-module boundaries. Under the 14 October selected-
recommendation authority, forecast CHOAM's own fixed opening collection as an
allowed owned source, using the same calculation as actual settlement. Keep
the live Karama response and current cash untouched; a completed cancellation
may release an answer that has become impossible. A current-turn receipt,
including a canceled one, cannot supply another opening. No rival future
income, unknown purchase, future Storm movement or early shipment is invented.
Native Richese guidance passes an active lot or submits zero to Silent with its
original event; actual committed invoices still reserve their physical funding.
This is provisional current-board/owned-stage software composition of the
general FAQ, not a publisher worked example or public start-gate change.
[Shipment scope](TRUTHTRANCE_SHIPMENT_PROMISES.md#native-factions-and-owned-choam-opening--26-october-2026),
[ground-movement scope](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-early-advanced-and-choam-opening--26-october-2026).
## Earlier Advanced shipment commitments — 26 October 2026
Extend the selected current-board/owned-stage shipment-promise policy to bare
@@ -1278,7 +1296,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Purchased price with immune Junction | Keep the original offered half/full tariff and normal payees; no quarter price or bank-only overlay. Under the standing choose-recommendation instruction, accepting this source with a purchased grant requires explicit acknowledgment that it ends unused, with no card refund. Consume only upon actual delivery, not failed, stale, unfunded or deferred acceptance; the ordinary purchased route remains available. This is a conservative product composition, not a publisher worked example. | [Source immunity and selected choice](JUNCTION_TRANSPORT_RULES.md#purchased-price-and-immune-offer--15-october-2026), [original versus controlled proof](JUNCTION_TRANSPORT_RUNTIME.md#purchased-price-source-choice--15-october-2026) |
| Deferred shipment arrival | Quote unsupported ordinary arrivals before saving Guild interception. A validated legacy ordinary declaration may return unspent on the typed arrival error; corrupted declarations and special parent/card receipts are not silently cleared. This repairs continuation without deciding simultaneous reaction priority. | [Reproduction and bounded recovery](DEFERRED_SHIPMENT_ARRIVAL.md) |
| Native Ambassador own-arrival continuation | Under the standing choose-recommended-option instruction, reuse committed entry after its own Guild income, BG Intrusion and accompaniment controls. Existing callbacks put a legitimate accompanying BG fighter child before the deferred original entrant; each sees the then-live token, so declining the child preserves a later original offer and consuming it does not recreate a token. Advisors remain exempt. This child-first/live-token composition is provisional, not a located publisher priority ruling. Actual busy controls, unsupported paired/module guards and separate native rate-window admission remain distinct. | [Native original-action evidence and boundary](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026) |
| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Bare Basic current-turn shipment promises now include phases0–4 using current-board feasibility, remaining owned Charity/normal Revival and net current auction liabilities. Incoming Bidding aid expires; retained Karama payment and shipping discounts need distinct physical cards. Accepting reopens owned Charity/Revival readiness; voluntary spending or closing needed preparation cannot evade an answer. An unresolved foreign invoice waits for its actual outcome. This owned-witness/readiness protocol is explicit product composition, not a publisher worked example. Existing phase5 Basic/base Advanced promises include waiting respondents, native Guild timing/interception and rate/permission responses; no earlier Advanced/module profile opens. Opposing stops settle the whole claim against absent shipment; own stops cannot evade a promise. Answering preserves the original queue; executable guidance waits for the actual turn and native controls. Own voluntary outflows cannot remove the last completion; prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md#earlier-basic-phases--24-october-2026), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) |
| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Bare native Basic/Advanced current-turn shipment promises now include phases0–4 using current-board feasibility, remaining owned Charity/normal Revival and net current auction liabilities. Incoming Bidding aid expires; retained Karama payment and shipping discounts need distinct physical cards. Accepting reopens owned Charity/Revival readiness; voluntary spending or closing needed preparation cannot evade an answer. An unresolved foreign invoice waits for its actual outcome. This owned-witness/readiness protocol is explicit product composition, not a publisher worked example. Existing phase5 Basic/base Advanced promises include waiting respondents, native Guild timing/interception and rate/permission responses; optional-module profiles keep their separate boundaries. Opposing stops settle the whole claim against absent shipment; own stops cannot evade a promise. Answering preserves the original queue; executable guidance waits for the actual turn and native controls. Own voluntary outflows cannot remove the last completion; prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md#earlier-basic-phases--24-october-2026), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) |
| Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) |
| Moritani Advanced assassination preview | After an eligible battle loss, a different opposing-faction Traitor Card may be revealed even if its leader is already dead (zero bounty). Face-up set-aside and one private replacement occur during Mentat; one use per faction. Normal-call duration, uniform hidden-eligibility UX and exceptional combinations remain explicit. | [Runtime/source contract](MORITANI_ASSASSINATE_LEADERS.md) |
| Moritani Extortion at Mentat | The user selected normal Terror placement/relocation first, including any Karama response; only then collect the reserved five bank spice and approach other players in storm order for a possible three-spice payment. Payment removes the token; all declines recover it with rotated hidden supply IDs. The relative placement timing is a user-selected interpretation, not a publisher ruling. | [Publisher sequence and selected timing](MORITANI_EXTORTION_RULES.md#mentat-ordering-selected-interpretation-not-a-publisher-ruling) |
@@ -1335,7 +1353,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Advanced Kull specific effect | Supplied physical29/logical27 restricts the attempted Karama for a specific effect, unlike publisher Basic phase-wide wording. New signed Advanced attempts bind semantic purpose; purchase/payment and native special variants normalize, as do enhanced Prescience/advisor conversion. CHOAM effects and special-force faction powers stay distinct. Retaining turn/phase expiry is explicit provisional composition under the 14 October policy, not supplied wording. Legacy unstamped pending/restriction frames retain broad scope. Prior distinct counter, BG-before-conversion and deferred overbid selections stay. | [Source cutover and keys](CHOAM_KULL_SOURCE_UPDATE.md#advanced-specific-effect-scope--18-october-2026), [connected implementation and limits](CHOAM_KULL_DESIGN.md#advanced-effect-scope--18-october-2026) |
| Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. Pending declaration is not a ban; only success stamps turn/phase and retains original/special once-use. Decline or prevention resumes the original once. Old `kull` stays printed-only; fresh `nexus-kull` admits native CHOAM plus classic Basic/Advanced seats, CHOAM/Ix Treachery and Nexus alone, without physical Sandtrout. Nexus Use needs actual unallied CHOAM Nexus and canonical eligible own fuel: accepted declaration spends Nexus even if prevented, fuel stays held until success. Use requires source/event/card, with no old-source action shim or proactive Kull. Verified prior printed counter saves migrate only their selection binding; no new-choice retrofit. Reservations, promises and remaining winning-payment coverage constrain fuel/counters; ordinary retained Karama payment is not a canceled Cunning effect. Priority/custody/overbid are user policy; neutral timing/spend conventions are application policy. Bounded checks/CLI/runtime/phone cases pass; broader verification is Partial and no full/public/deployed gate opens. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary), [runtime and remaining limits](CHOAM_KULL_DESIGN.md), [new evidence](NEXUS_CHOAM_RUNTIME.md#five-part-status-for-the-new-interaction) |
| Semuta full-hand exchange | User selected **Allow exchange**: acquisition followed by Semuta disposal is an atomic net-zero hand exchange at the faction limit. Existing incoming reservations still consume capacity; this does not permit an already overfull hand or settle Box/Richese purchase timing. | [Selected capacity policy](SEMUTA_SOURCE_UPDATE_20260907.md#user-selected-full-hand-exchange--30-september-2026) |
| Truthtrance / Basic ground movement | Compose the general FAQ binding-while-possible/current-round rule with one future qualifying physical ground movement before the respondent's actual own turn ends. Classic Basic phases0–4 include owned Charity/ordinary Revival and a currently leading ordinary invoice, reopening Ready and preserving joint shipment predicates; current-phase guidance only. Actual cash/held Karama and a distinct later shipment-rate card retain their costs; mandatory Storm losses release impossibility and stale movement allowances reset. Declared native early entries also price exact Richese own/donor commitments, lock the full submitted-ally Silent pledge, avoid double-debiting paid sales and retain current event-bearing zero/pass controls. Foreign invoice outcomes precede release. Non-CHOAM preopening Charity quotes its fixed entitlement only; owned normal revival projects shared physical accounting while native counters/income stay unresolved. CHOAM own unsettled opening remains outside the forecast. Active or unused waiting turns may answer; forecast current board/resources without foreign outcomes, and defer ordinary execution until active. Other players' moves/ends cannot settle this ledger. Actual paid Box selection remains actionable while waiting and may choose any entitled card preserving the route. Deferred gift/accompaniment costs retain owner preflight; native pending moves defer only their own moving player. Ordinary reserve preparation retains prices, typed withdrawals and pledged splits; joint answers and exact pending declarations remain. Yes survives nonmatches while possible; No forbids later matches through Hajr/Ornithopter until actual closure. Commit before arrival/disposal, preserve native controls and compatible answers after opposing loss. Held Kulon, physical Ghola force returns and known-discard Box retain real counters, unused affordable shipment, fee/event/full-hand policy and normal revival allowances; possible Tleilaxu income is not forecast cash. Legitimate Box recovery can replay the single Ghola. No unknown inspection or erased historical memory. Shipment/HMS/Ambassador relocation and No-Field markers do not fulfill physical ground movement. Predicate/release/forecast policy is software composition, not a movement-specific publisher FAQ. Later rounds, native transport preparation, wider/module combinations and full acceptance remain unfinished. | [Authority, behavior and bounded evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md) |
| Truthtrance / Basic ground movement | Compose the general FAQ binding-while-possible/current-round rule with one future qualifying physical ground movement before the respondent's actual own turn ends. Classic Basic phases0–4 include owned Charity/ordinary Revival and a currently leading ordinary invoice, reopening Ready and preserving joint shipment predicates; current-phase guidance only. Actual cash/held Karama and a distinct later shipment-rate card retain their costs; mandatory Storm losses release impossibility and stale movement allowances reset. Declared native early entries also price exact Richese own/donor commitments, lock the full submitted-ally Silent pledge, avoid double-debiting paid sales and retain current event-bearing zero/pass controls. Foreign invoice outcomes precede release. Non-CHOAM preopening Charity quotes its fixed entitlement only; owned normal revival projects shared physical accounting while native counters/income stay unresolved. CHOAM own fixed allowed opening now supplies a detached witness; its real counter stays live, and a current-turn receipt prevents repeated credit. Active or unused waiting turns may answer; forecast current board/resources without foreign outcomes, and defer ordinary execution until active. Other players' moves/ends cannot settle this ledger. Actual paid Box selection remains actionable while waiting and may choose any entitled card preserving the route. Deferred gift/accompaniment costs retain owner preflight; native pending moves defer only their own moving player. Ordinary reserve preparation retains prices, typed withdrawals and pledged splits; joint answers and exact pending declarations remain. Yes survives nonmatches while possible; No forbids later matches through Hajr/Ornithopter until actual closure. Commit before arrival/disposal, preserve native controls and compatible answers after opposing loss. Held Kulon, physical Ghola force returns and known-discard Box retain real counters, unused affordable shipment, fee/event/full-hand policy and normal revival allowances; possible Tleilaxu income is not forecast cash. Legitimate Box recovery can replay the single Ghola. No unknown inspection or erased historical memory. Shipment/HMS/Ambassador relocation and No-Field markers do not fulfill physical ground movement. Predicate/release/forecast policy is software composition, not a movement-specific publisher FAQ. Later rounds, native transport preparation, wider/module combinations and full acceptance remain unfinished. | [Authority, behavior and bounded evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md) |
| Semuta / native Basic entries | Fresh bare public Basic Richese previews initialize the existing protocol before genuine setup with selected native E1/E2/E3 families; no optional/Advanced inheritance or old-game retrofit. Clean source-witnessed shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: keep CHOAM's committed credit and draw the Ixian replacement only after the reaction. This composes printed component rules, not a new timing FAQ. Existing Basic Terror overflow retains the neutral all-seat response, committed-only face access, original victim/draw, removed token, payment, forces and optional gift. Completed Ornithopter admits its fresh group in the bare Basic profile, then the original movement-caused clean single-token Ambassador/Terror suffix and its distinct discard. Original physical movement/counters, CHOAM credit, Ix replacement and Terror draw/gift stay once-only. Overlap, other nested parents and wider module/full-game coverage remain unfinished implementation boundaries, not printed Semuta prohibitions. | [Fresh Basic and Ambassador scope](SEMUTA_SOURCE_UPDATE_20260907.md#fresh-basic-starts-and-ambassador-discards--24-october-2026), [native Terror suffix](TERROR_DISCARD_CONTINUATIONS.md) |
Shipment and mixed current-fact/shipment AND/OR follow the FAQ's whole-question
+30
View File
@@ -1,5 +1,35 @@
# Truthtrance ground-movement promises
## Native early Advanced and CHOAM opening — 26 October 2026
**Prototyped / Partial.** Both Basic and Advanced now admit clean current-round
questions during phases0–4 with registered native factions and their selected
Ix/Tleilax, CHOAM/Richese or Ecaz/Moritani decks. This supersedes the earlier
Advanced phase5-only guard below. The same typed, physical-only witness serves
both rulesets; optional modules and independent Ecaz cards retain their fences.
Answering neither ships nor moves early. Actual movement still waits for the
respondent's native turn and normal arrival/counter decisions.
CHOAM's own unresolved opening may supply its fixed allowed income in the
detached witness, using the actual settlement calculation. Current cash stays
unchanged until the real opening counter is allowed. A current-turn receipt,
including cancellation, prevents repeated credit; completed cancellation may
release impossibility. Foreign future income and unknown purchases supply no
forecast cash. This is the provisional owned-stage composition in the
[decision index](RULE_DECISIONS.md#native-early-promises-and-owned-choam-opening--26-october-2026),
not new publisher authority.
Genuine native [CHOAM](../tests/fixture-truthtrance-choam-promises.ts),
[Richese](../tests/fixture-truthtrance-richese-promises.ts) and
[Ecaz/Moritani](../tests/fixture-truthtrance-native-family-promises.ts) sources
start before Movement with no installed promises or preparatory armies.
CHOAM's actual opening funds later shipment and movement; empty-board Richese
must pay for real shipment before ground movement; Ecaz moves its original six
Basin counters. Existing current-phase preparation/pass controls and minimal
legal policies serve these sources. Exact frozen results are recorded in the
[shared native promise checkpoint](TRUTHTRANCE_SHIPMENT_PROMISES.md#native-factions-and-owned-choam-opening--26-october-2026).
No complete-mode, DOM/hydration, browser/deployed or final assurance is claimed.
## Native Advanced and No-Field preparation — 25 October 2026
**Prototyped / Partial.** Advanced phase5 now accepts declared native expansion
+67 -1
View File
@@ -4,7 +4,73 @@ 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; 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.
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. The [native-faction follow-up](#native-factions-and-owned-choam-opening--26-october-2026) adds native early scope and owned CHOAM income. Earlier scope descriptions and verification totals remain historical.
## Native factions and owned CHOAM opening — 26 October 2026
**Prototyped / Partial.** Both shipment and
physical ground-movement questions now accept registered native factions with
selected Ix/Tleilax, CHOAM/Richese and Ecaz/Moritani decks in Basic/Advanced
phases0–5. Earlier classic-only/Advanced phase5-only scope below is historical.
Optional-module and independent Ecaz-card boundaries remain separate; existing
late-phase shipment profiles retain their prior admission. No public game-start
gate changes, and a concealed No-Field is still not a physical reserve shipment.
Forecast CHOAM's own allowed fixed opening with the actual settlement formula
on a detached witness only. The live opening response, physical cash and
cancellation decision remain. A current-turn collection/cancellation receipt
prevents another forecast credit. Native Richese pass/Silent-zero guidance
carries the original event; voluntary winning invoices retain their actual
funding. Current-board feasibility assumes no unknown purchases, rival future
income or future Storm movement. Genuine opposing effects may release an
impossible answer. This is provisional owned-stage composition under the
[recorded authority](RULE_DECISIONS.md#native-early-promises-and-owned-choam-opening--26-october-2026).
Three disjoint native source lanes and root integration pass **24 files,
223/223**, types/lint/build and **sixteen native engine/GameTable SSR/actual
promise-control journeys** on unchanged source. Forty-five native cases plus
one scope case cover real income/cancellation/invoices, Yes/No, original
physical events, stale/foreign rejection and four minimal profiles. Native
CHOAM8-action sources retain35 cards/2 cash until opening8; Richese Basic18/
Advanced22 retain45/5 cash; Ecaz20/23 retain33/original Basin6. Only unopened
positions/native lotteries are selected; no played physical sources or promises
are installed. Controlled configuration negatives remain labelled supplements.
The [CHOAM source](../tests/fixture-truthtrance-choam-promises.ts) earns public
A/C/E/H setup before first Storm. Actual opening8 funds shipment10, or actual
purchase2 followed by paid city shipment6 and ground6 to Carthag. E's original
Karama cancellation keeps C2 and releases impossibility. No permits shipment9
or ground5; movement No fulfills only after actual `endMovement`.
The [Richese source](../tests/fixture-truthtrance-richese-promises.ts) earns public
R/A/E Charity with20 reserves and no board force. Actual native Silent/ordinary
own bid1 leaves only4 for city shipment after real payment. Early movement
requires an actual paid landing first. The ordinary case explicitly ships1 to
Arrakeen before its legal move; profiles may use their native No-Field route.
The [Ecaz/Moritani source](../tests/fixture-truthtrance-native-family-promises.ts)
earns public A/Ecaz/Mori Charity: Ecaz12 cash/14 reserves/Basin6, Mori Oh Gap6.
A real city shipment5 costs5, or original Basin6 moves to Arrakeen beside A10.
Native optional placement is really declined. Shipment never fulfills movement.
Actual ShipmentPromises/MovementPromises buttons execute original physical
events on the real turn; native Silent-zero callbacks retain event and notice.
Feedback repairs CHOAM's no-Richese35 count, observes the actual Advanced start
argument, accepts scalar/grouped move packets, and waits for movement-No closure
and Karama/Semuta disposal. Scope tests reject unsupported configurations rather
than declared native decks. The initial known-failing union is stopped. Richese
profile proof uses fixed genuine cache controls plus all four answer/Silent-zero/
completion branches, avoiding unrelated special acquisition and repeated private
views during foreign passes. No strategy or strength calibration is performed.
Final v4 binds2941 files to
`8975b9722db46d85d58f1441c2b989769a26d39338ccd570e73a49a0327b7e04` on base `8053e1fb268780b1dc0380a8a63c8b26afad70c6`.
Final frozen v4 report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-truthtrance-native-families-proof-v4/`;
journeys/HTML: adjacent `20261026-truthtrance-native-families-runtime/`.
Broader promises/modules, complete rules and final assurance remain open.
No saved-room/D1/server, restart, deployment or recovery operation occurs.
DOM/hydration, browser/mobile and deployed acceptance remain due.
## Earlier classic Advanced phases — 26 October 2026
+8
View File
@@ -36,6 +36,14 @@ export function charityAmount(
return charityQuote(g, p).total;
}
/** Native phase-opening collection, distinct from a player's ordinary claim. */
export function choamOpeningIncome(
g: Pick<Game, 'advanced' | 'turn' | 'inflation' | 'players' | 'homeworlds'>,
owner: Pick<Player, 'id'>,
) {
return (2 * g.players.length + homeworldLowBonus(g, owner.id)) * charityMultiplier(g);
}
export function charityPayer(
g: Pick<Game, 'players' | 'turn' | 'choamCharity'>,
beneficiary?: string,
+17 -5
View File
@@ -552,6 +552,7 @@ import {
quoteCharityFunding,
CharityFundingError,
charityMultiplier,
choamOpeningIncome,
type Inflation,
} from './charity';
import {
@@ -25913,6 +25914,15 @@ type ShipmentCompletionSeed = { game: Game; actions: Action[]; early: { phase: n
function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string): ShipmentCompletionSeed[] {
let seeds: ShipmentCompletionSeed[] = [{ game: initial, actions: [], early: [] }];
if (source.phase > 4) return seeds;
if (source.phase <= 2 && source.choamCharity?.turn !== source.turn && getPlayer(source,owner).faction === 'choam') {
// The owned opening is automatic, not an executable future claim. Witness
// its allowed fixed income only; the real table retains the counter.
const projected = structuredClone(initial);
const choam = getPlayer(projected,owner);
choam.spice += choamOpeningIncome(projected,choam);
projected.choamCharity = {turn:projected.turn,canceled:false};
seeds.push({game:projected,actions:[],early:[]});
}
if (source.phase <= 2 && !(source.phase === 2 && source.ready.includes(owner))) {
const charitySeeds: ShipmentCompletionSeed[] = [];
for (const seed of seeds) {
@@ -26194,8 +26204,13 @@ function earlyShipmentGuidance(g: Game, p: Player): { preparation: { actions: Ac
const current = completion.early?.find(step => step.phase === g.phase)?.action;
if (g.decision?.kind === 'auctionPayment')
return { preparation: null, policy: [current?.type === 'decision' ? current : { type: 'decision', karama: false }] };
if (g.phase === 3)
if (g.phase === 3) {
const lot = g.richeseAuction;
if (lot) return {preparation:null,policy:!lot.outcome && lot.eligible.includes(p.id) &&
(lot.method === 'silent' ? !Object.hasOwn(lot.sealed,p.id) : lot.active === p.id)
? [{type:'richeseBid',event:lot.event,amount:lot.method === 'silent' ? 0 : null,allyPayment:0}] : []};
return { preparation: null, policy: g.auction?.active === p.id ? [{ type: 'passBid' }] : [] };
}
if (g.phase === 2 || g.phase === 4)
return { preparation: current ? { actions: [current] } : null,
policy: current ? [current] : g.ready.includes(p.id) ? [] : [{ type: 'ready' }] };
@@ -30396,10 +30411,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
// User ruling 7 October 2026: CHOAM's phase-opening income is a
// qualifying collection, so a low-population native Homeworld adds its
// bank bonus under the same Inflation multiplier as ordinary charity.
const multiplier = charityMultiplier(g);
const amount =
2 * g.players.length * multiplier +
homeworldLowBonus(g, choam.id) * multiplier;
const amount = choamOpeningIncome(g,choam);
choam.spice += amount;
log(g, `${choam.name} collected ${amount} spice before charity claims.`);
} else
+2 -15
View File
@@ -1,5 +1,5 @@
import { TERRITORIES, territory } from './board';
import { FACTIONS } from './catalog';
import { nativePromiseScopeSupported } from './native-promise-scope';
import type { Game } from './engine';
/** One qualifying ground movement, never the sum of several smaller groups. */
@@ -84,20 +84,7 @@ export function finishMovementPromises<T extends MovementPromise>(
/** Prototype profile/timing only. Actual route feasibility stays authoritative
* in the engine. Truthtrance and an ordinary active Ornithopter may remain. */
export function movementPromiseModeSupported(g: Game): boolean {
// Basic retains early-phase admission. Advanced native families use the same
// declared roster during Movement; earlier forecasts/modules stay separate.
return (g.advanced === false ||
g.advanced === true && g.phase === 5) &&
g.players.length >= 2 && g.players.length <= 6 &&
g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
new Set(g.expansions).size === g.expansions.length &&
g.players.every(candidate => {
const family = FACTIONS.find(entry => entry.id === candidate.faction)?.expansion;
return family === 'base' || (!!family && g.expansions.includes(family));
}) &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
!g.strongholdCards && !g.discoveryEnabled && !g.discoveries &&
!g.discoveryStash && !g.greatMaker && !g.ecazTreachery;
return nativePromiseScopeSupported(g);
}
export function movementPromiseTargetAvailable(g: Game, player: string): boolean {
const target = g.players.find(candidate => candidate.id === player);
+31
View File
@@ -0,0 +1,31 @@
import { FACTIONS } from './catalog';
export type NativePromiseScope = {
advanced: boolean;
expansions: readonly unknown[];
players: readonly { faction: string }[];
homeworlds?: unknown;
nexusCards?: unknown;
leaderSkills?: unknown;
techTokens?: unknown;
strongholdCards?: unknown;
discoveryEnabled?: unknown;
discoveries?: unknown;
discoveryStash?: unknown;
greatMaker?: unknown;
ecazTreachery?: unknown;
};
/** Registered native factions with their selected decks. Optional modules
* keep their separate promise envelopes; this never opens a game-start gate. */
export function nativePromiseScopeSupported(g: NativePromiseScope): boolean {
return typeof g.advanced === 'boolean' && g.players.length >= 2 && g.players.length <= 6 &&
g.expansions.every(id => typeof id === 'string' && ['ix','choam','ecaz'].includes(id)) &&
new Set(g.expansions).size === g.expansions.length &&
g.players.every(p => {
const family = FACTIONS.find(f => f.id === p.faction)?.expansion;
return family === 'base' || !!family && g.expansions.includes(family);
}) && !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
!g.strongholdCards && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
!g.greatMaker && !g.ecazTreachery;
}
+7 -7
View File
@@ -3140,12 +3140,12 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'Bind one future qualifying physical ground movement before a supported Basic or Advanced respondent with declared native families finishes their current-round turn.',
steps: [
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Basic with declared native families supports clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Finish pending decisions first. Advanced with declared native families and no modules supports its current Movement phase. Earlier Advanced phases, optional modules, later rounds and wider owned preparations remain unfinished.',
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Basic and Advanced with declared native families support clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Finish pending decisions first. Optional modules, later rounds and wider owned preparations remain unfinished.',
'Yes requires one qualifying ground movement from now until that player’s Shipment and Movement turn ends, while possible. Smaller groups do not add together and earlier moves do not count. Shipment, HMS relocation and Ambassador relocation do not fulfill this statement; a concealed No-Field marker itself is not a physical on-board force counter.',
'A nonmatching move leaves Yes pending when an owned later route remains. One matching physical commitment fulfills it before card disposal and later Intrusion, Ambassador or Terror effects. You are promising the move, not survival afterward. No forbids every later matching move, including Hajr and both Ornithopter groups, until the actual turn closes.',
'Ordinary reserve shipment may prepare or reinforce a later ground group using its actual cost, native allocation and pledged payment split. Shipment itself neither fulfills movement Yes nor violates movement No. Joint guidance honors a required shipment before End; an already declared reserve or native Guild transport keeps its exact source, destination and funding while responses finish.',
'Waiting answers use current board/resources without assuming foreign income, movement, cooperation or discarded cards. Another player cannot fulfill or close your promise by moving or ending. Ordinary guidance appears on your actual turn; your already-paid Box choice can finish now. Deferred gifts/accompaniment cannot voluntarily spend the last route before another player responds.',
'Earlier classic answers include owned Charity and ordinary Revival, reopen readiness when needed, and reserve a current winning auction invoice using cash or a held Karama. Only current preparation, Ready, bid-pass or owned payment appears before Movement. Unknown purchases and future income are not assumed. Forced Storm losses can release impossibility; old movement allowances reset at actual phase entry. A separate held Karama may prepare Guild shipment rates. Native Once Around and owned Silent bids reserve exact funding; a submitted ally locks its full pledge until reveal. Current native guidance passes or submits zero with the original event. A paid sale is not charged again, and foreign invoice outcomes precede release.',
'Earlier classic answers include owned Charity and ordinary Revival, reopen readiness when needed, and reserve a current winning auction invoice using cash or a held Karama. Only current preparation, Ready, bid-pass or owned payment appears before Movement. Owned fixed CHOAM opening income uses its allowed native settlement while the actual counter stays live; a canceled current-turn receipt cannot be credited again. Unknown purchases and foreign future income are not assumed. Forced Storm losses can release impossibility; old movement allowances reset at actual phase entry. A separate held Karama may prepare Guild shipment rates. Native Once Around and owned Silent bids reserve exact funding; a submitted ally locks its full pledge until reveal. Current native guidance passes or submits zero with the original event. A paid sale is not charged again, and foreign invoice outcomes precede release.',
'Private feasible answers and the next owned step use actual movement legality, typed groups, held Hajr, both Ornithopter modes and original cohorts, current city access, held native Kulon, physical Ghola force returns and owned Box recovery. Before paying the two-spice Box fee, only a known card still in discard supplies a route; unknown future draws/gifts are not invented. Native relocation and wider preparation remain unfinished.',
'Ghola returns up to five eligible forces to reserves using the physical card and current typed allowance, without using normal revival slots. It does not fulfill ground movement: an unused affordable shipment and later matching move remain necessary. A known spent Ghola can be recovered and replayed through Box. Possible Tleilaxu income remains conditional and supplies no forecast cash.',
'A paid Box search retains its full private choice and original event. A guided choice preserves Yes; an incompatible selection rejects before shuffle and keeps that paid search available without another fee. No-only promises retain ordinary selection before movement can end. The existing provisional atomic full-hand exchange remains.',
@@ -3156,7 +3156,7 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-shipment', 'movement', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Declared native Advanced phase5 now reuses the typed route witness, immediate Hajr timing and owned advisor preparation/arrival flips with real counters. Owned No-Field shipment/reveal/marker movement uses native events, reserve caps and physical-only fulfillment. Basic declared native families join classic phases0–4. Exact Richese own/donor invoices, full submitted-ally Silent locks and already-paid boundaries prepare the owned turn without speculative bought cards/income. Fixed non-CHOAM preopening Charity and shared ordinary force-revival accounting preserve real native counter decisions. Mandatory Ix phase5 opening retains the future turn/reset. Held Ghola and known/paid Box recovery now add exact force returns and later shipment; normal revival allowances stay separate and native income is not precredited. CHOAM own unresolved opening, later rounds, wider preparations/modules remain unfinished.', evidence: ['game/ghola-force-preparation.ts', 'game/early-auction-funding.ts', 'game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/movement-shipment-preparation.ts', 'game/movement-discard-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Declared native Advanced phase5 now reuses the typed route witness, immediate Hajr timing and owned advisor preparation/arrival flips with real counters. Owned No-Field shipment/reveal/marker movement uses native events, reserve caps and physical-only fulfillment. Basic declared native families join classic phases0–4. Exact Richese own/donor invoices, full submitted-ally Silent locks and already-paid boundaries prepare the owned turn without speculative bought cards/income. Fixed non-CHOAM preopening Charity and shared ordinary force-revival accounting preserve real native counter decisions. Mandatory Ix phase5 opening retains the future turn/reset. Held Ghola and known/paid Box recovery now add exact force returns and later shipment; normal revival allowances stay separate and native income is not precredited. Basic/Advanced native phases0–4 now include the owned CHOAM fixed allowed opening, retaining its real cancellation and receipt. Later rounds, wider preparations/modules remain unfinished.', evidence: ['game/native-promise-scope.ts', 'tests/fixture-truthtrance-choam-promises.ts', 'tests/truthtrance-choam-promises-engine.test.ts', 'tests/fixture-truthtrance-richese-promises.ts', 'tests/truthtrance-richese-promises-engine.test.ts', 'tests/fixture-truthtrance-native-family-promises.ts', 'tests/truthtrance-native-family-promises-engine.test.ts', 'game/ghola-force-preparation.ts', 'game/early-auction-funding.ts', 'game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/movement-shipment-preparation.ts', 'game/movement-discard-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Native No-Field preparation now has distinct persistent reveal/shipment/marker/mixed labels and actual reveal callbacks. Classic Advanced questions and persistent guidance now connect an actual legal movement callback; native advisor preparation has a named action. Physical Ghola force-return guidance now precedes actual reserve shipment and ground movement, with income/disposal controls retaining priority. Persistent busy-safe guidance includes current Charity, Revival, Ready, ordinary bid-pass/payment and event-bearing native Once Around pass/Silent zero. Foreign pending funding cannot force a speculative positive bid or expose future movement. Actual native pass/zero handlers preserve unread obligations. Existing waiting/card controls remain; browser/visual acceptance stays open.', evidence: ['tests/truth-movement-advanced-controls.test.tsx', 'components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/shipment-promises.tsx', 'components/truthtrance.tsx', 'components/nullentropy-box.tsx', 'components/game-table.tsx', 'tests/truth-movement-waiting-controls.test.tsx', 'tests/truth-movement-early-controls.test.tsx', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/truth-movement-ghola-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles use actual native Advanced Richese reveal/physical movement legally. All four existing profiles finish a genuine classic Advanced movement question legally without strategy changes. Eight earlier existing-profile held-Ghola/known-Box runs accept40 proposals plus24 separate native continuations. Physical return, exact paid recovery, unused shipment and qualifying ground move use the same private legal guidance. Earlier native funding paths remain. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-ghola-engine.test.ts', 'tests/truth-movement-native-early-engine.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'General FAQ authority, existential single-event meaning, explicit software composition, source selection and unfinished scope are documented.', evidence: ['docs/TRUTHTRANCE_MOVEMENT_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
@@ -3172,7 +3172,7 @@ 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 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.',
'Declare Truthtrance, select an eligible player and choose Bind a shipment from reserves. Bare Basic and Advanced rules with selected native faction decks 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. Optional configurations keep their separate 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.',
@@ -3188,7 +3188,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'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.',
'Bare registered native Basic/Advanced phases0–4 with selected faction decks use explicit provisional current-board/owned-stage feasibility, auction liabilities, distinct card costs, expiring aid and readiness reopening. Wealthy Advanced BG Charity and owned CHOAM opening income retain their native counters; the detached witness uses their allowed fixed payment. A current-turn CHOAM receipt prevents repeated credit. Native Richese guidance passes or submits Silent zero with its exact event. 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,8 +3212,8 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'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: [
'Source programmes include genuine native CHOAM, Richese and Ecaz/Moritani Basic/Advanced early questions, own opening allowance/cancellation, real native auctions, reserve shipment and physical ground movement, alongside original classic 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: ['game/native-promise-scope.ts', 'tests/fixture-truthtrance-choam-promises.ts', 'tests/truthtrance-choam-promises-engine.test.ts', 'tests/fixture-truthtrance-richese-promises.ts', 'tests/truthtrance-richese-promises-engine.test.ts', 'tests/fixture-truthtrance-native-family-promises.ts', 'tests/truthtrance-native-family-promises-engine.test.ts',
'tests/shipment-promises.test.ts',
'tests/shipment-promises-bots.test.ts',
'tests/shipment-promises-recovery.test.ts',
+6 -3
View File
@@ -1,6 +1,7 @@
import { TERRITORIES, territory } from './board';
import type { MixedShipmentExpression } from './mixed-shipment-question';
import type { TruthAnswer } from './truthtrance';
import { nativePromiseScopeSupported } from './native-promise-scope';
/** Every shipment leaf describes the same eventual reserve shipment, or its absence. */
export type ShipmentLeaf = { territory: string; minimum: number };
@@ -160,11 +161,13 @@ export function shipmentPromiseModeSupported(game: {
semutaPreview?: unknown;
leaderSkills?: unknown;
discoveryEnabled?: unknown;
discoveries?: unknown;
discoveryStash?: unknown;
greatMaker?: unknown;
ecazTreachery?: unknown;
}, respondent = game.active): boolean {
if (game.phase !== undefined && game.phase < 5 &&
(game.expansions.length || game.techTokens || game.strongholdCards ||
game.nexusCards || game.homeworlds || game.leaderSkills || game.discoveryEnabled || game.ecazTreachery)) return false;
if (nativePromiseScopeSupported(game)) return true;
if (game.phase !== undefined && game.phase < 5) return false;
if (game.semutaPreview === true && game.expansions.length === 1 &&
game.expansions[0] === 'choam' && !game.techTokens &&
!game.strongholdCards && !game.nexusCards && !game.homeworlds &&
+1 -1
View File
@@ -218,7 +218,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory',
'Supply one ground-movement destination, minimum and respondent.');
check(movementPromiseTargetAvailable(g, v.target as string),
'Ground-movement promises need a supported Basic opportunity this round or an Advanced Movement turn with declared native families and pending decisions finished. Optional modules and earlier Advanced forecasts remain unfinished.');
'Ground-movement promises need a supported unused opportunity this round under Basic or Advanced rules with declared native families and pending decisions finished. Optional modules remain unfinished.');
try { return { kind: 'movement', target: v.target as string, ...parseMovementClaim(v) }; }
catch (error) { if (error instanceof MovementClaimError) throw new TruthError(error.message); throw error; }
}
+202
View File
@@ -0,0 +1,202 @@
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, nextFutureShipmentStep, futureShipmentClean, futureShipmentPlayer,
type FutureShipmentStep,
} from './fixture-truthtrance-future-shipment';
export type TruthtranceChoamPromiseKind = 'shipment' | 'movement';
export type TruthtranceChoamPromisesStep = FutureShipmentStep;
export const actTruthtranceChoamPromises = actFutureShipment;
export const truthtranceChoamPromisesPlayer = futureShipmentPlayer;
export const truthtranceChoamPromisesClean = futureShipmentClean;
export const truthtranceChoamPromisesPhysical = (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 })),
});
export function truthtranceChoamPromisesInventory(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, 'Each original exact-CHOAM Treachery Card has one physical slot'); return ids;
}
/** Never infer allowance from an early Yes. The real CHOAM opening response
* stays caller-owned until an explicit native pass or cancellation. */
export function nextTruthtranceChoamPromisesStep(game: Game): TruthtranceChoamPromisesStep | null {
if (game.response?.kind === 'choamCharity') return null;
if (!game.truthtrance && !game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'choamMarket' || game.decision?.kind === 'choamMentat')
return { actor: game.decision.player, action: { type: 'decision', done: true } };
if (game.decision?.kind === 'choamMovement')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
}
return nextFutureShipmentStep(game);
}
export const nextTruthtranceChoamPromises = nextTruthtranceChoamPromisesStep;
export function advanceTruthtranceChoamPromises(game: Game, until: (state: Game) => boolean,
actions: TruthtranceChoamPromisesStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextTruthtranceChoamPromisesStep(game);
assert.ok(step, 'Choose the real CHOAM income response or structured Truth answer before continuing');
game = actTruthtranceChoamPromises(game, step.actor, step.action, actions);
}
throw Error('The native CHOAM promise programme did not reach its finite boundary');
}
export function allowTruthtranceChoamPromisesIncome(game: Game, actions: TruthtranceChoamPromisesStep[] = []): Game {
assert.equal(game.response?.kind, 'choamCharity');
for (let count = 0; game.response?.kind === 'choamCharity' && count < game.players.length; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actTruthtranceChoamPromises(game, actor.id, { type: 'passResponse' }, actions);
}
assert.notEqual(game.response?.kind, 'choamCharity');
return advanceTruthtranceChoamPromises(game, state => state.phase === 2 && futureShipmentClean(state), actions);
}
/** Fresh public native A/C/E/H source before the first CHOAM opening income.
* Only original unopened deal/first-lot/Spice positions and native scalar
* lotteries are selected. Raw cash remains two until the real response ends. */
export function createTruthtranceChoamPromisesFixture(options: {
advanced?: boolean; kind?: TruthtranceChoamPromiseKind;
} = {}) {
const advanced = options.advanced ?? false, kind = options.kind ?? 'shipment';
const asker = 'atreides', target = 'choam', counterHolder = 'emperor', harkonnen = 'harkonnen';
let game = createGame('TRUTHCHOAMPROMISES', newPlayer(asker, 'Atreides', 'atreides'), advanced, ['choam']);
for (const faction of [target, counterHolder, harkonnen] as const)
joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: TruthtranceChoamPromisesStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthtranceChoamPromises(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceChoamPromises(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['choam']);
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));
const originalInventory = truthtranceChoamPromisesInventory(game); assert.equal(originalInventory.length, 35);
assert.equal(game.richeseCache ?? null, null);
const truth = game.deck.find(card => card.effect === 'truthtrance'), karama = game.deck.find(card => card.effect === 'karama');
const ordinary = game.deck.find(card => card.kind === 'projectile' && !card.effect),
shield = game.deck.find(card => card.kind === 'shield'), snooper = game.deck.find(card => card.kind === 'snooper');
assert.ok(truth && karama && ordinary && shield && snooper);
const deal = [truth.id, ordinary.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 };
});
const auctionCard = game.deck.find(card => !deal.includes(card.id) && card.kind === 'poison' && !card.effect); assert.ok(auctionCard);
const auctionFrom = game.deck.findIndex(card => card.id === auctionCard.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: auctionCard.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 rawCash = futureShipmentPlayer(game, target).spice, originalReserves = futureShipmentPlayer(game, target).reserves;
assert.equal(rawCash, 2); assert.equal(originalReserves, 20);
assert.deepEqual(futureShipmentPlayer(game, target).forces, {});
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(futureShipmentPlayer(game, target).hand.every(card => !card.effect && card.kind === 'projectile'));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeSpiceOrder = structuredClone(game);
const spiceOrder = Array.from({ length: advanced ? 2 : 1 }, (_, 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.phase === 0 && futureShipmentClean(state));
assert.equal(game.turn, 1); assert.equal(futureShipmentPlayer(game, target).spice, rawCash);
assert.equal(game.choamCharity, undefined); assert.equal(game.truthtrance ?? null, null);
assert.equal(game.shipmentPromises, undefined); assert.equal(game.movementPromises, undefined);
const incomeAmount = game.players.length * 2, minimum = kind === 'shipment' ? 10 : 6;
assert.equal(incomeAmount, 8);
const question: TruthQuestion = kind === 'shipment'
? { kind: 'shipment', target, territory: 'carthag', minimum }
: { kind: 'movement', target, territory: 'carthag', minimum };
const declarationStep: TruthtranceChoamPromisesStep = { actor: asker, action: { type: 'card', card: truth.id } };
const askStep: TruthtranceChoamPromisesStep = { actor: asker, action: { type: 'truthAsk', question } };
const shipment: Action = { type: 'ship', territory: kind === 'shipment' ? 'carthag' : 'arrakeen',
sector: kind === 'shipment' ? 11 : 10, amount: minimum, elite: 0, allyPayment: 0 };
const move: Action = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 6, elite: 0 };
return { game, source: structuredClone(game), advanced, kind, asker, target, choam: target, counterHolder,
spender: counterHolder, emperor: counterHolder, harkonnen, card: truth.id, truth: truth.id, karama: karama.id,
question, declarationStep, askStep, territory: 'carthag', sector: 11, location: 'carthag:11', minimum,
rawCash, incomeAmount, allowedCash: rawCash + incomeAmount, originalReserves,
shipment, move, shipmentLocation: kind === 'shipment' ? 'carthag:11' : 'arrakeen:10',
bid: { type: 'bid', amount: 2, allyPayment: 0 } satisfies Action, purchaseCard: auctionCard.id,
purchaseCost: kind === 'movement' ? 2 : 0,
blockedBid: { type: 'bid', amount: kind === 'shipment' ? 1 : 5, allyPayment: 0 } satisfies Action,
blockedBribe: { type: 'bribe', target: asker, amount: kind === 'shipment' ? 1 : 5 } satisfies Action,
cancelIncome: { type: 'card', card: karama.id, mode: 'cancel' } satisfies Action,
initial, lobby, setup, beforeStartingDeal, afterSetup, afterSetupActionCount,
beforeSpiceOrder, spiceOrder, originalSpice, deal, dealOrder, auctionOrder,
originalInventory, originalTreachery: originalInventory,
originalPhysical: structuredClone(truthtranceChoamPromisesPhysical(game)),
actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
`Actual public ${advanced ? 'Advanced preview' : 'Basic'} native A/C/E/H ready/start, exact CHOAM with all35 original main-deck Treachery Cards, no Richese cache and no optional modules. No Basic game is converted or new profile gate opened.`,
'Select only five unopened starting positions: A Truthtrance, C ordinary projectile with no Karama/Worthless effect, E printed Karama, H Shield/Snooper. Select a distinct ordinary first-auction poison card and original ordinary first-Spice positions; no played hand or future draw is installed.',
'Actually finish original private Traitor/setup controls and stop at clean first Storm. C still has its native two spice, twenty reserves, no board forces and no opening-income stamp. Truth declaration and question remain unplayed.',
kind === 'shipment'
? 'The proposed ten-force Carthag shipment needs the printed eight-spice four-seat opening. Actual allowance later yields ten; no ordinary auction cost can consume that reserved shipment budget.'
: 'The proposed six-force ground move needs a real six-force city shipment first. Actual opening allowance later funds a genuine normal purchase2, shipment6 to Arrakeen and ornithopter-range movement6 to Carthag. The preceding shipment alone does not fulfill the ground promise.',
'The native CHOAM opening response remains explicitly caller-owned. An early answer creates no credit, waiver or invented allowance. Native printed Karama cancellation, native income allowance and all later controls use their original actions; no wallet, force, clock, promise or response is installed.',
],
};
}
export type TruthtranceChoamPromisesFixture = ReturnType<typeof createTruthtranceChoamPromisesFixture>;
/** Continue only after the caller has allowed the actual opening. No hidden
* auction windfall, foreign gift or scripted income is supplied to the route. */
export function finishTruthtranceChoamPromises(f: TruthtranceChoamPromisesFixture, source: Game,
options: { answer?: 'yes' | 'no' } = {}) {
const answer = options.answer ?? 'yes'; let game = source;
const actions: TruthtranceChoamPromisesStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthtranceChoamPromises(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceChoamPromises(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.deepEqual(game.choamCharity, { turn: 1, canceled: false });
assert.equal(futureShipmentPlayer(game, f.target).spice, f.allowedCash);
reach(state => state.phase === 3 && futureShipmentClean(state) && state.auction?.active === f.target);
const beforeBid = structuredClone(game);
let purchase: ReturnType<typeof transition> | null = null;
if (f.kind === 'movement') {
assert.equal(game.auction?.index, 0); assert.equal(game.auction.cards[0].id, f.purchaseCard);
purchase = transition(() => {
act(f.target, f.bid); reach(state => state.phase === 4 && futureShipmentClean(state));
});
assert.equal(futureShipmentPlayer(game, f.target).spice, f.allowedCash - 2);
assert.ok(futureShipmentPlayer(game, f.target).hand.some(card => card.id === f.purchaseCard));
}
reach(state => state.phase === 5 && futureShipmentClean(state) && state.active === f.target);
const beforeShipment = structuredClone(game);
const shipmentAction: Action = f.kind === 'shipment' && answer === 'no' ? { ...f.shipment, amount: 9 } : f.shipment;
const shipment = transition(() => { act(f.target, shipmentAction); reach(state => futureShipmentClean(state) && !state.pendingShipment); });
const afterShipment = structuredClone(game);
let move: ReturnType<typeof transition> | null = null;
const moveAction: Action = f.kind === 'movement' && answer === 'no' ? { ...f.move, amount: 5 } : f.move;
if (f.kind === 'movement') move = transition(() => {
act(f.target, moveAction); reach(futureShipmentClean);
});
const delivered = structuredClone(game);
let turnEnd: ReturnType<typeof transition> | null = null;
if (f.kind === 'movement' && answer === 'no') turnEnd = transition(() => {
act(f.target, { type: 'endMovement' });
assert.ok(!game.movementRemaining?.includes(f.target));
});
const finished = structuredClone(game);
assert.deepEqual(truthtranceChoamPromisesInventory(game), f.originalInventory);
return { game, beforeBid, purchase, beforeShipment, shipmentAction, shipment, afterShipment,
moveAction, move, delivered, turnEnd, finished, answer, actions };
}
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import {
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
type TruthMovementStep,
} from './fixture-truth-movement';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type TruthtranceNativeFamilyPromisesKind = 'shipment' | 'movement';
export type TruthtranceNativeFamilyPromisesStep = TruthMovementStep;
export const actTruthtranceNativeFamilyPromises = actTruthMovement;
export const truthtranceNativeFamilyPromisesPlayer = truthMovementPlayer;
export const truthtranceNativeFamilyPromisesInventory = truthMovementInventory;
export const truthtranceNativeFamilyPromisesClean = truthMovementClean;
export const truthtranceNativeFamilyPromisesPhysical = (game: Game) => game.players.map(p => ({
id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks,
elites: p.elites, leaders: p.leaders, ally: p.ally, moved: p.moved, shipped: p.shipped,
}));
/** Native quiet controls only. The original question and Ecaz's actual
* shipment/movement remain caller-owned, including choosing to skip them. */
export function nextTruthtranceNativeFamilyPromises(game: Game): TruthMovementStep | null {
if (game.truthtrance) return null;
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'moritaniSetup')
return { actor: decision.player, action: { type: 'decision', territory: 'oh_gap', sector: 9 } };
if (decision?.kind === 'ecazPlacement')
return { actor: decision.player, action: { type: 'decision', decline: true } };
if (game.status === 'setup' && game.setupStage === 'forces' &&
truthMovementPlayer(game, 'ecaz').reserves === 20)
return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
}
if (game.status === 'playing' && game.phase === 5 && game.active === 'ecaz' && truthMovementClean(game)) return null;
return nextStrongholdFactionsNativeStep(game);
}
export const nextTruthtranceNativeFamilyPromisesStep = nextTruthtranceNativeFamilyPromises;
export function advanceTruthtranceNativeFamilyPromises(game: Game, until: (state: Game) => boolean,
actions: TruthMovementStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextTruthtranceNativeFamilyPromises(game);
assert.ok(next, `Supply the original Truthtrance or Ecaz action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actTruthMovement(game, next.actor, next.action, actions);
}
throw Error('The original E3 promise programme did not reach its finite boundary');
}
export function finishTruthtranceNativeFamilyPromises(game: Game, actions: TruthMovementStep[] = []): Game {
return advanceTruthtranceNativeFamilyPromises(game, truthMovementClean, actions);
}
/** Actual public Basic/Advanced native E3 setup, original undealt card
* positions and an unopened land Spice selection. No played custody is set. */
export function createTruthtranceNativeFamilyPromisesFixture(options: {
advanced?: boolean; kind?: TruthtranceNativeFamilyPromisesKind;
} = {}) {
const advanced = options.advanced ?? false, kind = options.kind ?? 'shipment';
const asker = 'atreides', target = 'ecaz', moritani = 'moritani';
let game = createGame(`TRUTHNATIVEFAMILYPROMISES${advanced ? 'ADV' : 'BASIC'}${kind.toUpperCase()}`,
newPlayer(asker, 'Atreides', 'atreides'), advanced, ['ecaz']);
joinGame(game, newPlayer(target, 'Ecaz', 'ecaz'));
joinGame(game, newPlayer(moritani, 'Moritani', 'moritani'));
const initial = structuredClone(game), actions: TruthMovementStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceNativeFamilyPromises(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
const start: TruthMovementStep = { actor: game.host, action: advanced ? { type: 'start', advancedPreview: true } : { type: 'start' } };
act(start.actor, start.action);
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, ['ecaz']);
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 originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 33);
const truth = game.deck.find(card => card.effect === 'truthtrance');
const ordinary = game.deck.filter(card => card.kind === 'projectile' || card.kind === 'poison').slice(0, 2);
assert.ok(truth); assert.equal(ordinary.length, 2);
const selected = [truth, ...ordinary], deal = selected.map(card => card.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 { card: id, position, from };
});
const beforeStartingDeal = structuredClone(game), setupAt = actions.length;
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), setupSteps = structuredClone(actions.slice(setupAt));
const ecazSetup = setupSteps.find(step => step.action.type === 'ecazSetup'); assert.ok(ecazSetup);
const moritaniSetup = setupSteps.find(step => step.action.type === 'decision' && step.action.territory === 'oh_gap'); assert.ok(moritaniSetup);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
assert.deepEqual(truthMovementPlayer(game, target).forces, { 'imperial_basin:10': 6 });
assert.equal(truthMovementPlayer(game, target).reserves, 14);
assert.equal(truthMovementPlayer(game, target).spice, 12);
assert.equal(truthMovementPlayer(game, asker).forces['arrakeen:10'], 10);
assert.deepEqual(truthMovementPlayer(game, moritani).forces, { 'oh_gap:9': 6 });
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const beforeSpiceOrder = structuredClone(game);
const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0);
const land = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(land);
const spiceOrder = [{ position: 0, from, card: structuredClone(land) }], phaseAt = actions.length;
reach(state => state.turn === 1 && state.phase === 2 && truthMovementClean(state));
const nativePhases = { before: beforeSpiceOrder, steps: structuredClone(actions.slice(phaseAt)), after: structuredClone(game) };
assert.equal(game.advanced, advanced); assert.deepEqual(game.ready, []);
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises, undefined); assert.equal(game.movementPromises, undefined);
assert.equal(truthMovementPlayer(game, target).spice, 12);
assert.equal(truthMovementPlayer(game, target).reserves, 14);
assert.equal(truthMovementPlayer(game, target).forces['imperial_basin:10'], 6);
assert.deepEqual(truthMovementInventory(game), originalCards);
assert.deepEqual([...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(), originalSpice);
const question = { kind, target, territory: kind === 'shipment' ? 'carthag' : 'arrakeen', minimum: kind === 'shipment' ? 5 : 6 };
const declarationStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truth.id } };
const askStep: TruthMovementStep = { actor: asker, action: { type: 'truthAsk', question } };
const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 5, elite: 0, allyPayment: 0 };
const move: Action = { type: 'move', from: 'imperial_basin:10', territory: 'arrakeen', sector: 10, amount: 6 };
return { game, source: structuredClone(game), advanced, kind, asker, target, owner: target, moritani,
truth: truth.id, card: truth.id, question, claim: question, declarationStep, askStep, shipment, move,
eventAction: kind === 'shipment' ? shipment : move, endMovement: { type: 'endMovement' } as Action,
initial, lobby, start, setup, beforeStartingDeal, afterSetup, setupSteps, ecazSetup, moritaniSetup,
beforeSpiceOrder, spiceOrder, originalSpice, originalCards, originalInventory: originalCards, deal, dealOrder, nativePhases,
physical: structuredClone(truthtranceNativeFamilyPromisesPhysical(game)), actions, sourceActionCount: actions.length,
staging: [
`Original public ${advanced ? 'Advanced preview' : 'Basic'} Atreides/Ecaz/Moritani setup selects exact Ecaz and no optional module or independent Treachery variant. Original unopened starting positions deal Truthtrance to Atreides and ordinary cards to Ecaz/Moritani. Ecaz actually chooses six in Basin10, Moritani six in Oh Gap9, and Atreides retains its original Arrakeen10 garrison.`,
'Actual Storm/Spice and readiness reach first Charity before declaration. Ecaz retains printed12 cash,14 reserves and its native six-counter group. A future reserve-city5 shipment pays real5, or a future adjacent ground move sends the original six to Arrakeen beside original Atreides10. No hand, army, wallet, phase clock, promise, parent or completed event is installed.',
] };
}
export type TruthtranceNativeFamilyPromisesFixture = ReturnType<typeof createTruthtranceNativeFamilyPromisesFixture>;
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import {
actTruthMovement, truthMovementClean, truthMovementPlayer, type TruthMovementStep,
} from './fixture-truth-movement';
import {
nextTruthMovementNativeEarlyStep, type TruthMovementNativeEarlyAuction,
} from './fixture-truth-movement-native-early';
export type TruthtranceRichesePromisesKind = 'shipment' | 'movement';
export type TruthtranceRichesePromisesStep = TruthMovementStep;
export type TruthtranceRichesePromisesTransition = {
before: Game; steps: TruthtranceRichesePromisesStep[]; after: Game;
};
export const actTruthtranceRichesePromises = actTruthMovement;
export const truthtranceRichesePromisesClean = truthMovementClean;
export const truthtranceRichesePromisesPlayer = (game: Game, actor = 'richese') => truthMovementPlayer(game, actor);
export function truthtranceRichesePromisesInventory(game: Game) {
const start = (game.auction?.index ?? 0) + Number(game.currentAuctionSale?.origin === 'normal');
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(start) ?? []),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original physical card has one current location');
return ids;
}
export const truthtranceRichesePromisesPhysical = (game: Game) => game.players.map(p => ({
player: p.id, reserves: p.reserves, tanks: p.tanks, forces: structuredClone(p.forces),
elites: structuredClone(p.elites), noField: structuredClone(p.noField),
shipped: p.shipped, moved: p.moved,
}));
/** Only original controls. Richese owns its real shipment/move and questions;
* native cache defaults are first / Once Around / all pass / remove unbid. */
export function nextTruthtranceRichesePromises(game: Game,
auction: TruthMovementNativeEarlyAuction = {}): TruthtranceRichesePromisesStep | null {
if (game.truthtrance) {
if (game.truthtrance.stage !== 'priority') return null;
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'truthPass' } };
}
if (truthtranceRichesePromisesClean(game) && game.status === 'playing' && game.phase === 5) {
if (game.active === 'richese') return null;
if (game.active === 'emperor') return { actor: 'emperor', action: { type: 'endMovement' } };
}
return nextTruthMovementNativeEarlyStep(game, {
position: 'first', method: 'onceAround', direction: 'clockwise', keepUnbid: false,
card: 'richese-stone-burner', ...auction,
});
}
export const nextTruthtranceRichesePromisesStep = nextTruthtranceRichesePromises;
export function advanceTruthtranceRichesePromises(game: Game, until: (state: Game) => boolean,
actions: TruthtranceRichesePromisesStep[] = [], auction: TruthMovementNativeEarlyAuction = {}): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Richese source ended before its requested control');
const step = nextTruthtranceRichesePromises(game, auction);
assert.ok(step, `Choose the original Richese question, bid, shipment or move at ${game.turn}/${game.phase}/${game.truthtrance?.stage ?? game.decision?.kind}`);
game = actTruthtranceRichesePromises(game, step.actor, step.action, actions);
}
throw Error('The native Richese promise source did not reach its finite boundary');
}
export function finishTruthtranceRichesePromises(game: Game, actions: TruthtranceRichesePromisesStep[] = []): Game {
for (let count = 0; count < 800; count++) {
if (game.phase === 7 && truthtranceRichesePromisesClean(game)) return game;
const step = truthtranceRichesePromisesClean(game) && game.phase === 5 && game.active === 'richese'
? { actor: 'richese', action: { type: 'endMovement' } as Action }
: nextTruthtranceRichesePromises(game);
assert.ok(step, 'Complete the accepted original Richese promise before finishing movement');
game = actTruthtranceRichesePromises(game, step.actor, step.action, actions);
}
throw Error('The native Richese movement did not reach original Collection');
}
/** Genuine public R/A/E, exact E2 main35/cache10, no modules. Select original
* unopened deal/normal-lot/Spice positions only; native actions earn all state. */
export function createTruthtranceRichesePromisesFixture(options: {
advanced?: boolean; kind?: TruthtranceRichesePromisesKind;
} = {}) {
const advanced = options.advanced ?? false, kind = options.kind ?? 'shipment';
const target = 'richese', asker = 'atreides', observer = 'emperor';
let game = createGame('TRUTHRICHESENATIVEPROMISES', newPlayer(target, 'Richese', 'richese'), false, ['choam']);
joinGame(game, newPlayer(asker, 'Atreides', 'atreides'));
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthtranceRichesePromisesStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthtranceRichesePromises(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceRichesePromises(game, until, actions); };
const transition = (run: () => void): TruthtranceRichesePromisesTransition => {
const before = structuredClone(game), at = actions.length; run();
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
};
const rules = transition(() => { if (advanced) 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, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, ['choam']); assert.ok(game.players.every(p => !p.hand.length));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = truthtranceRichesePromisesInventory(game);
const originalMainDeck = game.deck.map(card => card.id), originalCache = game.richeseCache!.map(card => card.id);
assert.equal(originalInventory.length, 45); assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10);
const truth = game.deck.find(card => card.effect === 'truthtrance');
const shield = game.deck.find(card => card.kind === 'shield'), snooper = game.deck.find(card => card.kind === 'snooper');
const ordinaryLot = game.deck.find(card => card.name === 'Baliset'); assert.ok(truth && shield && snooper && ordinaryLot);
const deal = [shield.id, truth.id, snooper.id];
const dealOrder = [...deal, ordinaryLot.id].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 };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
for (const [index, p] of game.players.entries()) assert.deepEqual(p.hand.map(card => card.id), [deal[index]]);
const originalNoField = structuredClone(truthtranceRichesePromisesPlayer(game).noField);
assert.ok(originalNoField); assert.equal(originalNoField.deployed, null);
assert.deepEqual(truthtranceRichesePromisesPlayer(game).forces, {});
assert.equal(truthtranceRichesePromisesPlayer(game).reserves, 20);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
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) };
});
reach(state => state.turn === 1 && state.phase === 2 && truthtranceRichesePromisesClean(state));
const earlySource = structuredClone(game), earlyActionCount = actions.length;
assert.equal(truthtranceRichesePromisesPlayer(game).spice, 5);
const beforeBidding = structuredClone(game);
const shipmentAction: Action = { type: 'ship', territory: kind === 'shipment' ? 'carthag' : 'arrakeen',
sector: kind === 'shipment' ? 11 : 10,
amount: kind === 'shipment' ? 5 : 1, elite: 0, allyPayment: 0 };
const source = structuredClone(game), territory = 'carthag';
const minimum = kind === 'shipment' ? 5 : 1, sector = 11;
const question: TruthQuestion = { kind, target, territory, minimum };
const moveAction: Action = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 };
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises?.length ?? 0, 0);
assert.equal(game.movementPromises?.length ?? 0, 0);
assert.deepEqual(truthtranceRichesePromisesInventory(game), originalInventory);
assert.deepEqual([...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(), originalSpice);
return { game, source, advanced, kind, asker, target, richese: target, observer, emperor: observer,
card: truth.id, truth: truth.id, truthtrance: truth.id, targetCard: shield.id, ordinaryLot: ordinaryLot.id,
question, claim: question, minimum, impossibleMinimum: minimum + 1, territory, sector, location: `${territory}:${sector}`,
declarationStep: { actor: asker, action: { type: 'card', card: truth.id } } satisfies TruthtranceRichesePromisesStep,
askStep: { actor: asker, action: { type: 'truthAsk', question } } satisfies TruthtranceRichesePromisesStep,
shipmentAction, shipmentStep: { actor: target, action: shipmentAction },
moveAction, moveStep: { actor: target, action: moveAction }, action: kind === 'shipment' ? shipmentAction : moveAction,
cacheCard: 'richese-stone-burner', cacheBid: { type: 'richeseBid', amount: 1, allyPayment: 0 } satisfies Action,
normalBid: { type: 'bid', amount: 1, allyPayment: 0 } satisfies Action,
initial, rules, lobby, setup, beforeStartingDeal, afterSetup, deal, dealOrder,
originalMainDeck, originalCache, originalCards: originalInventory, originalInventory, originalSpice, spiceOrder, originalNoField,
earlySource, beforeBidding, budget: truthtranceRichesePromisesPlayer(game).spice,
originalRound: structuredClone(game.richeseBidding), originalFunding: structuredClone(game.richeseFunding),
originalPhysical: truthtranceRichesePromisesPhysical(game),
actions, steps: actions, sourceActionCount: actions.length, sourceCounts: { early: earlyActionCount, source: actions.length },
staging: [
`Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor, exact CHOAM main35/cache10 and no modules. Only original unopened deal positions (R Shield, A Truthtrance, E Snooper, first ordinary lot Baliset), two unopened land Spice positions and original scalar lotteries are selected. No played hand, wallet, army, phase, No-Field, promise or receipt is installed.`,
kind === 'shipment'
? 'Stop at actual turn1 Charity with native Richese five spice, twenty reserves, no board forces and unused shipment/movement. Original Black Market (Advanced), owned cache position/card/terms, native bids/unbid removal and ordinary invoices remain real future controls. Baseline asks for five actual ordinary city counters, financed solely by current Richese cash. Native one-spice own purchases leave four; no later cache-sale income is assumed.'
: 'Stop at actual turn1 Charity with native Richese five spice, twenty reserves, no board forces and unused shipment/movement. The question asks for a later real one-counter ground move to Carthag. The native target-private completion guide must find an affordable original route on the later turn and may prefer an original No-Field. The ordinary physical case explicitly ships1 to Arrakeen, then uses the actual ground completion; minimal profiles retain their native chosen guide. No future landing, force, payment, route or outcome is preinstalled. Original cache and ordinary auctions remain caller-visible native controls.',
],
};
}
export type TruthtranceRichesePromisesFixture = ReturnType<typeof createTruthtranceRichesePromisesFixture>;
export function askTruthtranceRichesePromises(game: Game, fixture: TruthtranceRichesePromisesFixture,
question: TruthQuestion = fixture.question, actions: TruthtranceRichesePromisesStep[] = []): Game {
game = actTruthtranceRichesePromises(game, fixture.asker, fixture.declarationStep.action, actions);
game = advanceTruthtranceRichesePromises(game, state => state.truthtrance?.stage === 'ask', actions);
return actTruthtranceRichesePromises(game, fixture.asker, { type: 'truthAsk', question }, actions);
}
+15 -2
View File
@@ -32,6 +32,19 @@ void test('Advanced shipment scope excludes separately enabled optional modules
true,
);
});
void test('native faction decks admit early Basic and Advanced shipment scope while retaining module fences', () => {
const families = [
['ix', 'ixians'], ['ix', 'tleilaxu'], ['choam', 'choam'],
['choam', 'richese'], ['ecaz', 'ecaz'], ['ecaz', 'moritani'],
];
for (const advanced of [false, true]) for (const phase of [0, 2, 5]) for (const [family, faction] of families) {
const native = { advanced, phase, expansions: [family], players: [{ faction: 'atreides' }, { faction }] };
assert.equal(shipmentPromiseModeSupported(native), true, `${faction}/${advanced}/${phase}`);
assert.equal(shipmentPromiseModeSupported({ ...native, expansions: [family, family] }), false);
assert.equal(shipmentPromiseModeSupported({ ...native, ecazTreachery: true }), false);
if (phase < 5) assert.equal(shipmentPromiseModeSupported({ ...native, expansions: [] }), false);
}
});
import {
applyAction,
createGame,
@@ -528,14 +541,14 @@ void test('malformed questions and unsupported public scopes reject without disc
},
});
for (const change of [
'expansion',
'duplicateDeck',
'phase',
'spent',
'inactive',
'territory',
]) {
const bad = structuredClone(g);
if (change === 'expansion') bad.expansions = ['ix'];
if (change === 'duplicateDeck') bad.expansions = ['ix', 'ix'];
if (change === 'phase') bad.phase = 6;
if (change === 'spent') bad.players[1].shipped = true;
if (change === 'inactive') { bad.active = 'a'; bad.movementRemaining = ['a', 'o']; }
@@ -121,6 +121,11 @@ void test('controlled original Karama prevents a native No-Field preparation bef
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
g = act(g, f.asker, { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(player(g, f.target).shipped, false); assert.equal(player(g, f.target).spice, before.spice);
// The original Karama disposal must finish its native Semuta offer before
// the deferred prevention and impossibility release actually settle.
if (g.pendingTreacheryDiscard) assert.equal(g.movementPromises![0].released, undefined);
g = advance(g, clean);
assert.equal(player(g, f.target).shipped, false); assert.equal(player(g, f.target).spice, before.spice);
assert.equal(g.movementPromises![0].released, true); assert.deepEqual(inventory(g), f.originalCards);
});
+2 -2
View File
@@ -568,9 +568,9 @@ void test('unrelated opponent cards and wealth cannot change Advanced answer or
}
});
void test('selected expansions still reject unsupported questions immutably before binding or discard', () => {
void test('duplicate expansion declarations reject unsupported questions immutably before binding or discard', () => {
const g = fixture('emperor');
g.expansions = ['ix'];
g.expansions = ['ix', 'ix'];
const pending = openQuestion(g);
reject(pending, 'a', {
type: 'truthAsk',
@@ -0,0 +1,181 @@
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 {
createTruthtranceChoamPromisesFixture as fixture, actTruthtranceChoamPromises as act,
advanceTruthtranceChoamPromises as advance, allowTruthtranceChoamPromisesIncome as allowIncome,
finishTruthtranceChoamPromises as finish, truthtranceChoamPromisesPlayer as player,
truthtranceChoamPromisesInventory as inventory, truthtranceChoamPromisesPhysical as physical,
type TruthtranceChoamPromisesFixture,
} from './fixture-truthtrance-choam-promises';
const variants = [
{ advanced: false, kind: 'shipment' as const }, { advanced: true, kind: 'shipment' as const },
{ advanced: false, kind: 'movement' as const }, { advanced: true, kind: 'movement' as const },
];
const promise = (game: Game, f: TruthtranceChoamPromisesFixture) =>
f.kind === 'shipment' ? game.shipmentPromises?.[0] : game.movementPromises?.[0];
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: TruthtranceChoamPromisesFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced);
assert.deepEqual(f.setup.expansions, ['choam']);
assert.deepEqual(f.game.players.map(player => player.faction), ['atreides', 'choam', 'emperor', 'harkonnen']);
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.action.type === 'start' && (!f.advanced || 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.equal(player(f.afterSetup, f.target).hand.length, 1);
assert.ok(player(f.afterSetup, f.target).hand.every(card => card.kind === 'projectile' && !card.effect));
assert.equal(f.game.phase, 0); assert.equal(f.game.turn, 1); assert.equal(f.game.truthtrance ?? null, null);
assert.equal(player(f.game, f.target).spice, 2); 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(f.game.choamCharity, undefined); assert.equal(f.incomeAmount, 8); assert.equal(f.allowedCash, 10);
assert.equal(f.originalInventory.length, 35); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.deepEqual(physical(f.game), f.originalPhysical); assert.equal(f.sourceActionCount, f.actions.length);
}
function asked(f: TruthtranceChoamPromisesFixture) {
let game = act(structuredClone(f.game), f.declarationStep.actor, f.declarationStep.action);
game = advance(game, state => state.truthtrance?.stage === 'ask');
game = act(game, f.askStep.actor, f.askStep.action);
const answers = f.kind === 'shipment' ? viewGame(game, f.target).truthShipmentAnswers : viewGame(game, f.target).truthMovementAnswers;
assert.ok(answers?.includes('yes'), 'Native owned opening income supplies an allowed-outcome route');
assert.ok(answers?.includes('no')); return game;
}
function bound(f: TruthtranceChoamPromisesFixture, answer: 'yes' | 'no' = 'yes') {
const game = act(asked(f), f.target, { type: 'truthAnswer', answer });
assert.equal(game.truthtrance ?? null, null); assert.equal(promise(game, f)?.answer, answer === 'yes');
assert.equal(promise(game, f)?.released, undefined); assert.equal(promise(game, f)?.fulfilled, undefined);
assert.equal(player(game, f.target).spice, 2); assert.equal(game.choamCharity, undefined);
assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(inventory(game), f.originalInventory);
const view = viewGame(game, f.target); assert.equal(view.shipmentCompletion, null);
assert.ok(!(view.movementPromisePolicy ?? []).some(action => action.type === 'ship' || action.type === 'move'));
reject(game, f.target, f.shipment); reject(game, f.target, f.move); return game;
}
function incomePending(game: Game, f: TruthtranceChoamPromisesFixture) {
const actions: { actor: string; action: Action }[] = [];
const before = structuredClone(game);
game = advance(game, state => state.phase === 2 && state.response?.kind === 'choamCharity', actions);
assert.equal(game.response?.owner, f.target); assert.equal(player(game, f.target).spice, 2);
assert.equal(game.choamCharity, undefined); assert.equal(promise(game, f)?.released, undefined);
assert.ok(viewGame(game, f.counterHolder).responseControls?.cancelCards.includes(f.karama));
reject(game, f.target, { type: 'ready' }); reject(game, f.target, { type: 'charity' });
reject(game, f.target, f.shipment); return { game, before, actions };
}
for (const variant of variants) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native CHOAM ${variant.kind} promise awaits real opening income then completes its funded native route`, () => {
const f = fixture(variant); nativeSource(f);
const pending = incomePending(bound(f), f), originalResponse = structuredClone(pending.game.response);
assert.equal(promise(pending.game, f)?.fulfilled, undefined);
const incomeActions: { actor: string; action: Action }[] = [];
const allowed = allowIncome(pending.game, incomeActions);
assert.equal(player(allowed, f.target).spice, 10);
assert.deepEqual(allowed.choamCharity, { turn: 1, canceled: false });
assert.equal(originalResponse?.kind, 'choamCharity'); assert.ok(incomeActions.some(step => step.action.type === 'passResponse'));
assert.equal(promise(allowed, f)?.released, undefined);
reject(allowed, f.target, f.blockedBribe);
const completed = finish(f, allowed);
reject(completed.beforeBid, f.target, f.blockedBid);
assert.equal(completed.afterShipment.choamCharity?.canceled, false);
assert.equal(player(completed.delivered, f.target).forces[f.location], f.minimum);
assert.equal(promise(completed.delivered, f)?.fulfilled, true);
assert.equal(promise(completed.delivered, f)?.released, undefined);
if (f.kind === 'shipment') {
assert.equal(completed.purchase, null); assert.equal(completed.move, null);
assert.equal(player(completed.delivered, f.target).spice, 0);
assert.equal(player(completed.delivered, f.target).reserves, 10);
assert.equal(player(completed.delivered, f.target).moved, 0);
} else {
assert.ok(completed.purchase && completed.move);
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(player(completed.afterShipment, f.target).forces['arrakeen:10'], 6);
assert.equal(promise(completed.afterShipment, f)?.fulfilled, undefined, 'The reserve shipment is not the promised ground event');
assert.equal(player(completed.delivered, f.target).forces['arrakeen:10'] ?? 0, 0);
assert.equal(player(completed.delivered, f.target).reserves, 14);
assert.equal(player(completed.delivered, f.target).spice, 2);
assert.equal(player(completed.delivered, f.target).moved, 1);
}
assert.equal(player(completed.delivered, f.target).shipped, true);
assert.ok(player(completed.delivered, f.counterHolder).hand.some(card => card.id === f.karama));
assert.equal(completed.delivered.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(completed.game), f.originalInventory);
});
for (const variant of variants) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native CHOAM ${variant.kind} opening cancellation spends the real Karama and releases only the unfunded obligation`, () => {
const f = fixture(variant); nativeSource(f); const pending = incomePending(bound(f), f);
const game = act(pending.game, f.counterHolder, f.cancelIncome);
assert.equal(game.response ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(game.choamCharity, { turn: 1, canceled: true });
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).reserves, 20);
assert.deepEqual(player(game, f.target).forces, {});
assert.equal(promise(game, f)?.released, true); assert.equal(promise(game, f)?.fulfilled, undefined);
assert.equal(player(game, f.counterHolder).hand.some(card => card.id === f.karama), false);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
reject(game, f.counterHolder, f.cancelIncome); reject(game, f.target, { type: 'passResponse' });
assert.deepEqual(inventory(game), f.originalInventory);
});
for (const variant of variants) void test(
`${variant.advanced ? 'Advanced' : 'Basic'} native CHOAM ${variant.kind} No retains a real smaller alternative without reserving Yes funds`, () => {
const f = fixture(variant), pending = incomePending(bound(f, 'no'), f), allowed = allowIncome(pending.game);
const spent = act(structuredClone(allowed), f.target, { type: 'bribe', target: f.asker, amount: 1 });
assert.equal(player(spent, f.target).spice, 9); assert.equal(promise(spent, f)?.released, undefined);
const completed = finish(f, allowed, { answer: 'no' });
assert.equal(player(completed.delivered, f.target).forces[f.location], f.minimum - 1);
assert.equal(promise(completed.delivered, f)?.released, undefined);
if (f.kind === 'shipment') {
assert.equal(promise(completed.delivered, f)?.fulfilled, true);
assert.equal(player(completed.delivered, f.target).spice, 1); assert.equal(completed.turnEnd, null);
} else {
assert.equal(promise(completed.delivered, f)?.fulfilled, undefined, 'A later move could still match until this actual turn ends');
assert.equal(player(completed.delivered, f.target).forces['arrakeen:10'], 1);
assert.ok(completed.turnEnd);
assert.deepEqual(completed.turnEnd.steps, [{ actor: f.target, action: { type: 'endMovement' } }]);
assert.equal(promise(completed.finished, f)?.fulfilled, true);
assert.ok(!completed.finished.movementRemaining?.includes(f.target));
assert.deepEqual(player(completed.finished, f.target).forces, player(completed.delivered, f.target).forces);
assert.equal(player(completed.finished, f.target).spice, player(completed.delivered, f.target).spice);
assert.equal(player(completed.finished, f.target).reserves, player(completed.delivered, f.target).reserves);
}
assert.deepEqual(inventory(completed.game), f.originalInventory);
});
void test('native CHOAM promise questions reject foreign and underlying replies while raw cash stays uncredited', () => {
for (const kind of ['shipment', 'movement'] as const) {
const f = fixture({ advanced: true, kind }), game = asked(f);
reject(game, f.asker, { type: 'truthAnswer', answer: 'yes' });
reject(game, f.target, { type: 'ready' }); reject(game, f.target, f.shipment);
reject(game, f.asker, f.askStep.action);
assert.equal(player(game, f.target).spice, 2); assert.equal(game.choamCharity, undefined);
const answered = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(answered, f.target, { type: 'truthAnswer', answer: 'yes' });
const pending = incomePending(answered, f);
assert.equal(player(pending.game, f.target).spice, 2); assert.equal(promise(pending.game, f)?.released, undefined);
}
});
void test('all four minimal policies preserve native CHOAM promise funding during actual Bidding', () => {
for (const variant of variants) {
const f = fixture(variant), pending = incomePending(bound(f), f), allowed = allowIncome(pending.game);
const game = advance(allowed, state => state.phase === 3 && !state.response && !state.phaseOpening &&
!state.decision && 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(promise(done, f)?.released, undefined);
assert.ok(player(done, f.target).spice >= (f.kind === 'shipment' ? 10 : 6));
assert.deepEqual(inventory(done), f.originalInventory);
}
}
});
@@ -0,0 +1,167 @@
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 {
createTruthtranceNativeFamilyPromisesFixture as fixture,
actTruthtranceNativeFamilyPromises as act, advanceTruthtranceNativeFamilyPromises as advance,
finishTruthtranceNativeFamilyPromises as finish, truthtranceNativeFamilyPromisesClean as clean,
truthtranceNativeFamilyPromisesPlayer as player, truthtranceNativeFamilyPromisesInventory as inventory,
truthtranceNativeFamilyPromisesPhysical as physical,
type TruthtranceNativeFamilyPromisesFixture as Fixture, type TruthtranceNativeFamilyPromisesStep as Step,
} from './fixture-truthtrance-native-family-promises';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function source(f: Fixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced);
assert.deepEqual(f.game.expansions, ['ecaz']);
assert.deepEqual(f.game.players.map(p => p.faction), ['atreides', 'ecaz', 'moritani']);
assert.equal(f.lobby.advanced, f.advanced);
assert.deepEqual(f.start.action, f.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
assert.ok(!f.game.homeworlds && !f.game.nexusCards && !f.game.leaderSkills && !f.game.techTokens &&
!f.game.strongholdCards && !f.game.discoveryEnabled && !f.game.ecazTreachery);
assert.ok(f.beforeStartingDeal.players.every(p => !p.hand.length));
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.deepEqual(f.ecazSetup, { actor: f.target, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } });
assert.deepEqual(f.moritaniSetup, { actor: f.moritani, action: { type: 'decision', territory: 'oh_gap', sector: 9 } });
assert.equal(player(f.game, f.target).spice, 12); assert.equal(player(f.game, f.target).reserves, 14);
assert.deepEqual(player(f.game, f.target).forces, { 'imperial_basin:10': 6 });
assert.equal(player(f.game, f.asker).forces['arrakeen:10'], 10);
assert.deepEqual(player(f.game, f.moritani).forces, { 'oh_gap:9': 6 });
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth));
assert.equal(f.game.status, 'playing'); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 2);
assert.ok(clean(f.game)); assert.deepEqual(f.game.ready, []);
assert.equal(f.game.shipmentPromises, undefined); assert.equal(f.game.movementPromises, undefined);
assert.equal(f.originalCards.length, 33); assert.deepEqual(inventory(f.game), f.originalCards);
assert.deepEqual(physical(f.game), f.physical); assert.equal(f.sourceActionCount, f.actions.length);
}
function asking(f: Fixture, actions: Step[] = []) {
let game = act(structuredClone(f.game), f.declarationStep.actor, f.declarationStep.action, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
const view = viewGame(game, f.asker);
assert.ok((f.kind === 'shipment' ? view.truthShipmentTargets : view.truthMovementTargets).includes(f.target));
return game;
}
function asked(f: Fixture, actions: Step[] = []) {
const game = act(asking(f, actions), f.asker, f.askStep.action, actions);
const own = viewGame(game, f.target), answers = f.kind === 'shipment' ? own.truthShipmentAnswers : own.truthMovementAnswers;
assert.ok(answers?.includes('yes')); assert.ok(answers?.includes('no'));
assert.deepEqual(physical(game), f.physical); return game;
}
function promises(game: Game, f: Fixture) {
return f.kind === 'shipment' ? game.shipmentPromises : game.movementPromises;
}
function bind(f: Fixture, answer: 'yes' | 'no' = 'yes', actions: Step[] = []) {
const game = finish(act(asked(f, actions), f.target, { type: 'truthAnswer', answer }, actions), actions);
assert.equal(game.phase, 2); assert.equal(game.turn, 1); assert.deepEqual(physical(game), f.physical);
const entries = promises(game, f); assert.equal(entries?.length, 1);
assert.equal(entries![0].answer, answer === 'yes'); assert.equal(entries![0].player, f.target);
assert.equal(entries![0].fulfilled, undefined); assert.equal(entries![0].released, undefined);
assert.equal(inventory(game).length, 33); assert.deepEqual(inventory(game), f.originalCards);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(viewGame(game, f.target).shipmentCompletion, null);
reject(game, f.target, f.shipment); reject(game, f.target, f.move);
reject(game, f.asker, f.askStep.action); reject(game, f.asker, f.declarationStep.action);
return game;
}
function atMovement(game: Game, f: Fixture, actions: Step[] = []) {
const next = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions);
assert.equal(player(next, f.target).spice, 12); assert.equal(player(next, f.target).reserves, 14);
assert.equal(player(next, f.target).forces['imperial_basin:10'], 6);
assert.equal(player(next, f.target).shipped, false); assert.equal(player(next, f.target).moved, 0);
assert.equal(promises(next, f)![0].fulfilled, undefined); assert.equal(promises(next, f)![0].released, undefined);
assert.ok(actions.every(step => !['ship', 'move'].includes(step.action.type)), 'The native phase path installs no preparatory army');
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'decision' && step.action.decline === true),
'The real optional Ecaz placement is declined rather than charging the promised shipment');
assert.deepEqual(inventory(next), f.originalCards); return next;
}
function complete(game: Game, f: Fixture, action: Action = f.eventAction) {
const before = structuredClone(game), actions: Step[] = [];
game = finish(act(game, f.target, action, actions), actions);
assert.equal(promises(game, f)!.length, 1); assert.equal(promises(game, f)![0].fulfilled, true);
assert.equal(promises(game, f)![0].released, undefined); assert.equal(game.active, f.target); assert.equal(game.phase, 5);
if (f.kind === 'shipment') {
const amount = Number(action.amount), key = `${String(action.territory)}:${Number(action.sector)}`;
assert.ok(amount >= 5); assert.equal(action.territory, 'carthag');
assert.equal(player(game, f.target).forces[key], amount);
assert.equal(player(before, f.target).reserves - player(game, f.target).reserves, amount);
assert.equal(player(before, f.target).spice - player(game, f.target).spice, amount);
assert.equal(player(game, f.target).forces['imperial_basin:10'], 6);
assert.equal(player(game, f.target).shipped, true); assert.equal(player(game, f.target).moved, 0);
} else {
assert.equal(action.territory, 'arrakeen');
assert.equal(player(game, f.target).forces['arrakeen:10'], 6);
assert.equal(player(game, f.target).forces['imperial_basin:10'] ?? 0, 0);
assert.equal(player(game, f.target).reserves, 14); assert.equal(player(game, f.target).spice, 12);
assert.equal(player(game, f.target).moved, 1); assert.equal(player(game, f.asker).forces['arrakeen:10'], 10);
}
assert.deepEqual(player(game, f.moritani).forces, player(before, f.moritani).forces);
assert.equal(actions.filter(step => step.actor === f.target && ['ship', 'move'].includes(step.action.type)).length, 1);
reject(game, f.target, f.eventAction); assert.deepEqual(inventory(game), f.originalCards);
return game;
}
for (const advanced of [false, true]) for (const kind of ['shipment', 'movement'] as const) {
const label = `${advanced ? 'Advanced' : 'Basic'} native E3 ${kind}`;
void test(`${label} promise binds at real Charity and fulfills only at its original physical event`, () => {
const f = fixture({ advanced, kind }); source(f);
const actions: Step[] = [], game = atMovement(bind(f), f, actions);
reject(game, f.target, f.endMovement);
const done = complete(game, f);
assert.equal(game.shipmentPromises?.[0]?.fulfilled ?? game.movementPromises?.[0]?.fulfilled, undefined,
'The original pre-event game remains unchanged');
assert.equal(promises(done, f)![0].fulfilled, true);
});
void test(`${label} No stays bound through native phase controls and fulfills by actually ending the unused turn`, () => {
const f = fixture({ advanced, kind }), actions: Step[] = [];
const game = atMovement(bind(f, 'no'), f, actions);
reject(game, f.target, f.eventAction);
const done = act(game, f.target, f.endMovement);
assert.equal(promises(done, f)![0].fulfilled, true); assert.equal(promises(done, f)![0].released, undefined);
assert.deepEqual(player(done, f.target).forces, { 'imperial_basin:10': 6 });
assert.equal(player(done, f.target).reserves, 14); assert.deepEqual(inventory(done), f.originalCards);
});
void test(`${label} rejects phony questions and early underlying actions without installing or spending their sources`, () => {
const f = fixture({ advanced, kind }), window = asking(f);
for (const question of [
{ ...f.question, target: 'unseated' }, { ...f.question, territory: 'invented_destination' },
{ ...f.question, minimum: 21 },
...(kind === 'movement' ? [{ ...f.question, futureIncome: 20 }] : []),
]) reject(window, f.asker, { type: 'truthAsk', question });
reject(window, f.target, { type: 'truthAsk', question: f.question });
reject(window, f.target, f.eventAction);
assert.ok(player(window, f.asker).hand.some(card => card.id === f.truth));
assert.equal(window.shipmentPromises, undefined); assert.equal(window.movementPromises, undefined);
const answer = act(window, f.asker, f.askStep.action);
reject(answer, f.asker, { type: 'truthAnswer', answer: 'yes' }); reject(answer, f.target, f.eventAction);
assert.deepEqual(physical(answer), f.physical); assert.deepEqual(inventory(answer), f.originalCards);
});
void test(`all four minimal profiles legally answer and complete ${label} through the original native controls`, () => {
const f = fixture({ advanced, kind }), answer = asked(f);
for (const difficulty of DIFFICULTIES) {
const own = viewGame(answer, f.target); own.players.find(p => p.id === f.target)!.bot = difficulty;
const before = structuredClone(own), selected = botActions(own)[0]; assert.ok(selected, difficulty);
assert.deepEqual(own, before); assert.deepEqual(selected, { type: 'truthAnswer', answer: 'yes' });
let game = finish(act(structuredClone(answer), f.target, selected));
game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
const bidding = viewGame(game, f.target); bidding.players.find(p => p.id === f.target)!.bot = difficulty;
const pass = botActions(bidding)[0]; assert.deepEqual(pass, { type: 'passBid' });
game = act(game, f.target, pass);
const phases: Step[] = [];
game = atMovement(game, f, phases);
const moving = viewGame(game, f.target); moving.players.find(p => p.id === f.target)!.bot = difficulty;
const event = botActions(moving)[0]; assert.ok(event, difficulty); assert.equal(event.type, kind === 'shipment' ? 'ship' : 'move');
complete(game, f, event);
}
});
}
@@ -0,0 +1,244 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createTruthtranceRichesePromisesFixture as fixture,
actTruthtranceRichesePromises as act, askTruthtranceRichesePromises as ask,
advanceTruthtranceRichesePromises as advance, finishTruthtranceRichesePromises as finish,
truthtranceRichesePromisesClean as clean, truthtranceRichesePromisesPlayer as player,
truthtranceRichesePromisesInventory as inventory, truthtranceRichesePromisesPhysical as physical,
type TruthtranceRichesePromisesFixture as Fixture, type TruthtranceRichesePromisesStep as Step,
} from './fixture-truthtrance-richese-promises';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function source(f: Fixture) {
assert.equal(f.initial.advanced, false); assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced);
assert.deepEqual(f.rules.steps, f.advanced ? [{ actor: f.target, action: { type: 'rules', advanced: true } }] : []);
assert.deepEqual(f.game.expansions, ['choam']); assert.equal(f.game.advanced, f.advanced);
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 2); assert.ok(clean(f.game));
assert.equal(f.game.auction ?? null, null); assert.equal(f.game.richeseAuction ?? null, null);
assert.equal(f.game.richeseBidding ?? null, null); assert.equal(f.game.truthtrance ?? null, null);
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 3).map(card => card.id), f.deal);
assert.equal(f.beforeStartingDeal.deck[3].id, f.ordinaryLot);
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.equal(player(f.game, f.asker).hand[0].id, f.truthtrance);
assert.equal(player(f.game).spice, 5); assert.equal(player(f.game).reserves, 20); assert.equal(player(f.game).tanks, 0);
assert.deepEqual(player(f.game).forces, {}); assert.equal(player(f.game).shipped, false); assert.equal(player(f.game).moved, 0);
assert.deepEqual(player(f.game).noField, f.originalNoField); assert.equal(player(f.game).noField!.deployed, null);
assert.equal(player(f.game).hand.some(card => ['karama', 'hajr'].includes(card.effect ?? '')), false);
assert.equal(f.originalMainDeck.length, 35); assert.equal(f.originalCache.length, 10);
assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.deepEqual(physical(f.game), f.originalPhysical); assert.equal(f.sourceActionCount, f.actions.length);
assert.ok(f.actions.some(step => step.action.type === 'start'));
assert.ok(f.actions.some(step => step.action.type === 'stormDial'));
}
function bound(f: Fixture, game = structuredClone(f.game), question: TruthQuestion = f.question) {
const before = physical(game); game = ask(game, f, question);
const view = viewGame(game, f.target);
assert.ok((question.kind === 'shipment' ? view.truthShipmentAnswers : view.truthMovementAnswers)?.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = advance(game, clean);
assert.deepEqual(physical(game), before); assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1);
return game;
}
function reachTurn(game: Game, actions: Step[] = []) {
return advance(game, state => state.phase === 5 && state.active === 'richese' && clean(state), actions);
}
function ship(game: Game, f: Fixture, amount: number, actions: Step[] = []) {
game = reachTurn(game, actions); const before = structuredClone(game);
const action: Action = { type: 'ship', territory: 'carthag', sector: 11, amount, elite: 0, allyPayment: 0 };
game = act(game, f.target, action, actions); game = advance(game, clean, actions);
assert.equal(player(before).spice - player(game).spice, amount);
assert.equal(player(before).reserves - player(game).reserves, amount);
assert.equal(player(game).forces['carthag:11'], amount); assert.equal(player(game).shipped, true);
assert.equal(game.shipmentPromises![0].fulfilled, true); assert.equal(game.shipmentPromises![0].released, undefined);
assert.deepEqual(player(game).noField, f.originalNoField); assert.deepEqual(inventory(game), f.originalInventory);
reject(game, f.target, action); return game;
}
function nativeBidding(actions: Step[], advanced: boolean) {
assert.ok(actions.some(step => step.action.type === 'decision' && step.action.position === 'first'));
assert.ok(actions.some(step => step.action.type === 'decision' && step.action.card === 'richese-stone-burner'));
assert.ok(actions.some(step => step.action.type === 'richeseBid'));
assert.ok(actions.some(step => step.action.type === 'passBid'));
if (advanced) assert.ok(actions.some(step => step.action.type === 'decision' && step.action.decline === true));
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
void test(`native ${band} Richese early shipment pays five current spice after actual cache and normal auctions`, () => {
const f = fixture({ advanced }); source(f); const actions: Step[] = [];
let game = bound(f);
assert.ok(viewGame(f.game, f.asker).truthShipmentTargets.includes(f.target));
assert.equal(game.shipmentPromises![0].fulfilled, undefined); reject(game, f.target, f.shipmentAction);
game = reachTurn(game, actions); nativeBidding(actions, advanced);
assert.equal(player(game).spice, 5); assert.deepEqual(player(game).forces, {});
assert.deepEqual(player(game).hand.map(card => card.id), [f.targetCard]);
assert.ok(game.richeseRemoved?.some(card => card.id === f.cacheCard));
const completion = viewGame(game, f.target).shipmentCompletion; assert.ok(completion);
assert.ok(completion.actions.some(action => action.type === 'ship' && action.amount === 5 && action.territory === 'carthag'));
reject(game, f.target, { type: 'endMovement' }); reject(game, f.target, { ...f.shipmentAction, amount: 4 });
game = ship(game, f, 5, actions); assert.equal(player(game).spice, 0); assert.equal(player(game).reserves, 15);
game = finish(game, actions); assert.equal(game.phase, 7); assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
});
void test(`native ${band} Richese early No forbids five but permits the actual four-counter city shipment`, () => {
const f = fixture({ advanced }); let game = ask(structuredClone(f.game), f);
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['yes', 'no']);
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = reachTurn(game);
reject(game, f.target, f.shipmentAction); game = ship(game, f, 4);
assert.equal(player(game).spice, 1); assert.equal(game.shipmentPromises![0].answer, false);
});
void test(`native ${band} Richese current cash and physical stock cannot promise six while original Emperor can`, () => {
const f = fixture({ advanced }); source(f);
let game = ask(structuredClone(f.game), f, { kind: 'shipment', target: f.target, territory: 'carthag', minimum: 6 });
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); assert.deepEqual(inventory(game), f.originalInventory);
const other = ask(structuredClone(f.game), f, { kind: 'shipment', target: f.observer, territory: 'carthag', minimum: 6 });
assert.deepEqual(viewGame(other, f.observer).truthShipmentAnswers, ['yes', 'no']);
assert.equal(player(other, f.observer).spice, 10); assert.equal(player(other).spice, 5);
});
void test(`native ${band} Richese Silent own invoice reserves one before a truthful four-counter shipment`, () => {
const f = fixture({ advanced }), auction = { method: 'silent' as const }, actions: Step[] = [];
let game = advance(f.game, state => state.phase === 3 && clean(state) &&
state.richeseAuction?.method === 'silent' && !state.richeseAuction.outcome, actions, auction);
const event = game.richeseAuction!.event, card = game.richeseAuction!.cardId;
game = act(game, f.target, { type: 'richeseBid', event, amount: 1, allyPayment: 0 }, actions);
assert.equal(player(game).spice, 5); assert.equal(game.richeseFunding?.[f.target].amount, 1);
const unpaid = structuredClone(game);
const impossible = ask(structuredClone(game), f);
assert.deepEqual(viewGame(impossible, f.target).truthShipmentAnswers, ['no']);
reject(impossible, f.target, { type: 'truthAnswer', answer: 'yes' });
game = bound(f, game, { kind: 'shipment', target: f.target, territory: 'carthag', minimum: 4 });
game = advance(game, state => state.phase === 4 && clean(state), actions, auction);
assert.equal(player(game).spice, 4); assert.equal(player(game, f.observer).spice, player(unpaid, f.observer).spice + 1);
assert.ok(player(game).hand.some(candidate => candidate.id === card)); assert.deepEqual(inventory(game), f.originalInventory);
game = ship(game, f, 4, actions); assert.equal(player(game).spice, 0);
reject(game, f.target, { type: 'richeseBid', event, amount: 1, allyPayment: 0 });
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'richeseBid' && step.action.amount === 1).length, 1);
});
void test(`native ${band} Richese ordinary standing invoice pays once and leaves four for the accepted city shipment`, () => {
const f = fixture({ advanced }), actions: Step[] = [];
let game = advance(f.game, state => state.phase === 3 && clean(state) && !!state.auction &&
state.auction.active === f.target && state.auction.bid === 0, actions);
const lot = game.auction!.cards[game.auction!.index].id; assert.equal(lot, f.ordinaryLot);
game = act(game, f.target, f.normalBid, actions);
assert.equal(player(game).spice, 5); assert.equal(game.auction!.bidder, f.target); assert.equal(game.auction!.bid, 1);
const impossible = ask(structuredClone(game), f);
assert.deepEqual(viewGame(impossible, f.target).truthShipmentAnswers, ['no']);
game = bound(f, game, { kind: 'shipment', target: f.target, territory: 'carthag', minimum: 4 });
game = advance(game, state => state.phase === 4 && clean(state), actions);
assert.equal(player(game).spice, 4); assert.equal(player(game, f.observer).spice, 11);
assert.ok(player(game).hand.some(card => card.id === lot)); assert.deepEqual(inventory(game), f.originalInventory);
game = ship(game, f, 4, actions); assert.equal(player(game).spice, 0);
assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1);
game = finish(game, actions); assert.equal(game.phase, 7); assert.equal(player(game, f.observer).spice, 11);
});
void test(`native ${band} Richese empty-board early movement earns a paid landing then the actual ground event`, () => {
const f = fixture({ advanced, kind: 'movement' }); source(f); const actions: Step[] = [];
let game = bound(f); assert.ok(viewGame(f.game, f.asker).truthMovementTargets.includes(f.target));
assert.deepEqual(player(game).forces, {}); assert.equal(player(game).reserves, 20);
reject(game, f.target, f.moveAction); game = reachTurn(game, actions); nativeBidding(actions, advanced);
assert.equal(game.movementPromises![0].fulfilled, undefined); reject(game, f.target, { type: 'endMovement' });
const offered = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.ok(offered);
assert.equal(offered.type, 'ship');
// The native witness may prefer an original No-Field. This case takes the
// independent ordinary physical route; profiles below keep their own guide.
const shipment = f.shipmentAction;
assert.equal(shipment.noField, undefined);
const before = structuredClone(game);
game = act(game, f.target, shipment, actions); game = advance(game, clean, actions);
assert.equal(player(game).shipped, true); assert.equal(player(game).moved, 0);
assert.ok(player(before).spice > player(game).spice); assert.ok(player(game).spice >= 0);
assert.equal(player(before).reserves - player(game).reserves, shipment.amount);
assert.equal(game.movementPromises![0].fulfilled, undefined, 'A reserve shipment is not the promised ground event');
const movement = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.ok(movement);
assert.equal(movement.type, 'move'); assert.equal(movement.territory, 'carthag');
const moved = movement.forces && typeof movement.forces === 'object'
? Object.values(movement.forces as Record<string, number>).reduce((total, amount) => total + amount, 0)
: Number(movement.amount);
assert.ok(moved >= 1);
game = act(game, f.target, movement, actions); game = advance(game, clean, actions);
assert.equal(game.movementPromises![0].fulfilled, true); assert.equal(game.movementPromises![0].released, undefined);
assert.ok(player(game).forces['carthag:11'] >= 1); assert.deepEqual(player(game).noField, f.originalNoField);
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.target, movement);
game = finish(game, actions); assert.equal(game.phase, 7);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'move').length, 1);
});
}
void test('native Richese early No on movement permits skipping and rejects the actual later matching ground route', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced, kind: 'movement' }); let game = ask(f.game, f);
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = reachTurn(game);
game = act(game, f.target, f.shipmentAction); game = advance(game, clean);
reject(game, f.target, f.moveAction);
game = act(game, f.target, { type: 'endMovement' }); assert.equal(game.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalInventory);
}
});
void test('native Richese early questions retain actor, pending-answer and stale-action boundaries', () => {
for (const advanced of [false, true]) for (const kind of ['shipment', 'movement'] as const) {
const f = fixture({ advanced, kind }), game = ask(f.game, f);
reject(game, f.observer, { type: 'truthAnswer', answer: 'yes' }); reject(game, f.asker, { type: 'truthAnswer', answer: 'yes' });
reject(game, f.target, { type: 'ready' }); reject(game, f.target, f.action); 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.declarationStep.action);
assert.deepEqual(physical(done), f.originalPhysical); assert.deepEqual(inventory(done), f.originalInventory);
}
});
void test('all four minimal profiles use the original Richese early answer, owned auction controls and actual completion', () => {
for (const advanced of [false, true]) for (const kind of ['shipment', 'movement'] as const) {
const f = fixture({ advanced, kind }), asked = ask(f.game, f), promised = bound(f);
// Once Around skips its owner after every other seat passes. A separate
// actual Silent lot supplies R's event-bound zero submission; it creates no
// acquired cache card and is not the completion branch's auction prefix.
const silent = advance(promised, state => state.phase === 3 && clean(state) &&
state.richeseAuction?.method === 'silent' && !state.richeseAuction.outcome, [], { method: 'silent' });
const expectedPass: Action = { type: 'richeseBid', event: silent.richeseAuction!.event, amount: 0, allyPayment: 0 };
const prefix: Step[] = [], turn = reachTurn(promised, prefix);
nativeBidding(prefix, advanced);
assert.equal(player(turn).spice, 5); assert.deepEqual(player(turn).hand.map(card => card.id), [f.targetCard]);
assert.ok(turn.richeseRemoved?.some(card => card.id === f.cacheCard));
assert.ok(prefix.some(step => step.actor === f.target && step.action.type === 'decision' && step.action.keep === false));
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.doesNotThrow(() => act(asked, f.target, answer));
const bidding = viewGame(silent, f.target);
assert.deepEqual(kind === 'shipment' ? bidding.shipmentPromisePolicy : bidding.movementPromisePolicy, [expectedPass]);
bidding.players.find(p => p.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(bidding), [expectedPass]);
const submitted = act(silent, f.target, expectedPass);
assert.equal(submitted.richeseAuction!.sealed[f.target], 0); assert.equal(player(submitted).spice, 5);
assert.deepEqual(physical(submitted), physical(silent)); assert.deepEqual(inventory(submitted), f.originalInventory);
let game = structuredClone(turn), count = 0;
const actions: Step[] = [];
while (!(kind === 'shipment' ? game.shipmentPromises![0].fulfilled : game.movementPromises![0].fulfilled) && count++ < 12) {
assert.equal(game.active, f.target); assert.ok(clean(game));
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action, `The ${difficulty} profile needs the actual ${kind} completion`);
game = act(game, f.target, action, actions); game = advance(game, clean, actions);
}
assert.ok(count < 12); assert.equal(kind === 'shipment' ? game.shipmentPromises![0].released : game.movementPromises![0].released, undefined);
assert.ok(player(game).forces['carthag:11'] >= f.minimum);
assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
if (kind === 'movement') assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'move').length, 1);
}
}
});