Include Ghola force returns in Basic movement promises

Use exact physical Ghola preparation and known/paid Box recovery without
crediting conditional income or normal revival allowances. Guide real
force return, paid shipment and qualifying ground movement; retain the
existing incomplete-mode and final acceptance boundaries.
This commit is contained in:
Quintine committed 2026-10-24 16:22:32 +11:00
1 parent c311ed4251
commit 49992d322f
14 files changed
+767 -14

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac
: next?.type === 'richeseBid' ? next.amount === 0 ? 'Submit zero in this Silent auction and preserve later movement' : 'Pass this Richese bid and preserve later movement'
: next?.type === 'card' && next.mode === 'shipment' ? `Use ${card?.name ?? 'your card'} for Guild shipment rates`
: next?.type === 'card' && card?.effect === 'nullentropyBox' ? 'Pay 2 spice and search with Nullentropy Box'
: next?.type === 'card' && card?.effect === 'ghola' ? `Play Ghola to return ${amount} ${amount === 1 ? 'force' : 'forces'} to reserves for the required movement`
: next?.type === 'card' && next.mode === 'choam' ? 'Play Kulon to extend the required movement'
: next?.type === 'card' ? 'Play Hajr to prepare the required movement'
: next?.type === 'ship' ? `Ship ${amount} forces to ${destinationLabel}, sector ${sector}, before the required movement`
+21
View File
@@ -16,6 +16,27 @@ open mode gates.
## Current checkpoint and work
**Owned Ghola movement preparation — 24 October. Prototyped / Partial.**
Held physical Ghola and known/paid Box recovery now prepare exact force returns
before an unused affordable shipment and qualifying ground move. Keep normal
revival allowances separate, typed eligibility authoritative and possible
Tleilaxu income conditional. Persistent guidance and minimal legal bots use the
same current action. The single Ghola can be recovered and replayed legitimately.
Disjoint source/control lanes, bounded review and owner integration pass
**22 files182/182**, types/lint/build, four gameplay/control/table-rendering
programmes and eight existing-profile runs (40 accepted proposals plus24 separate
native continuations). Original held/Box sources42/104/75/151 use actual native
setup, cache purchase, desert shipment, Storm casualties and settled Emperor
bribes; Basic city occupation grants no invented income. Controlled prior-card,
loss, knowledge and counter-enabled income cases disclose staging. Review fixed
Ghola discard relevance; source feedback repaired geography/funding and a
snapshot/action field collision before the final frozen batch.
[Authority, exact proof and limits](TRUTHTRANCE_MOVEMENT_PROMISES.md#owned-ghola-preparation--24-october-2026).
Wider preparations, later rounds, modules/Advanced, complete rules and
comprehensive/browser/deployed acceptance remain unfinished. No full AI tuning
or saved-room/D1/reset/restart/deployment operation.
**Native early Basic movement promises — 24 October. Prototyped / Partial.**
Earlier questions now include declared native families. Richese own/donor
funding retains exact unpaid invoices, full submitted-ally Silent pledge locks
+24
View File
@@ -1,5 +1,29 @@
# Dune implementation status
## 24 October 2026 — Owned Ghola movement preparation
Basic movement witnesses use current physical Ghola force choices and exact
typed eligibility, including known-discard or already-paid Box recovery. Return
to reserves, preserve unused affordable shipment, then commit matching ground
movement. Normal revival slots are unchanged; possible Tleilaxu income supplies
no forecast cash. Real disposal/income controls retain their continuations.
Persistent busy-safe guidance and existing minimal profiles follow the same
path; legitimate Box recovery can replay the single physical Ghola.
Disjoint source/control lanes, bounded review and owner integration pass22 files
182/182, types/lint/build and four actual gameplay/control/GameTable SSR journeys.
Original held/Box prefixes42/104/75/151 retain native setup/cards, actual cache
purchase, paid separate desert shipments, 17+0 then1+1 Storm dials and settled
Emperor bribes2/6. Eight profile runs accept40 proposals plus24 separate native
continuations. Controlled acquisitions/losses/knowledge and counter-enabled
income are labelled separately. Source feedback corrected nonexistent desert
geometry, invalid Basic city income and a duplicate fixture field. Review caught
missing Ghola discard relevance; final frozen proof passes.
[Source, exact artifacts and unfinished scope](TRUTHTRANCE_MOVEMENT_PROMISES.md#owned-ghola-preparation--24-october-2026).
No complete-mode/combination or browser/deployed acceptance, full AI refinement,
comprehensive assurance or saved-room/D1/reset/restart/deployment operation.
## 24 October 2026 — Native early Basic movement promises
Extend earlier questions to declared native families using actual Richese
+1 -1
View File
@@ -907,7 +907,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 and known-discard Box retain fee/event/full-hand policy; 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 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) |
| 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. Nested parents, completed movement-card provenance 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
+73 -2
View File
@@ -1,6 +1,6 @@
# Truthtrance ground-movement promises
The [native early Basic follow-up](#native-early-basic-funding--24-october-2026)
The [Ghola preparation follow-up](#owned-ghola-preparation--24-october-2026)
supersedes preceding timing boundaries. Previous checkpoint counts remain dated
evidence, not complete rules or final acceptance.
@@ -45,7 +45,7 @@ reconfigured and absent promise fields remain absent until a real answer binds.
declaration validator, not the strategic bot shortlist. The finite search
considers ordinary moves, held Hajr, both held/active Ornithopter modes and
their original cohort, current city access, current native bonuses, held
CHOAM Kulon and the owned Box recovery described below. Preparations use
CHOAM Kulon, held Ghola force returns and the owned Box recovery described below. Preparations use
private detached execution; actual cards retain
their ordinary effects, counters, discard/response and arrival continuations.
Unknown future draws/gifts and unresolved opposing outcomes are not invented.
@@ -65,6 +65,77 @@ Other owned preparation families (including native relocation preparation),
later rounds, optional/Advanced profiles and comprehensive
combination/recovery/privacy/custody acceptance remain due.
## Owned Ghola preparation — 24 October 2026
**Prototyped / Partial.** The Basic owned-movement witness includes a held
physical Ghola's force choice using the current authoritative card eligibility,
maximum and typed-force allowance. Each candidate plays that exact card through
the shared engine on a detached copy. Returned pieces go to reserves; neither
the card play nor shipment fulfills a ground-movement promise. The actual later
shipment must remain unused and affordable, then one qualifying ground movement
must commit. Normal force/leader revival allowances are unchanged.
A publicly observed Ghola discard or the owner's privately observed card can
supply a known-discard Box route. An unpaid unknown face supplies none. A paid
Box search retains its complete private choice, original event and single fee;
choosing a card which erases the last route rejects before shuffle. Both held
recovery and an already-paid search keep the conservative all-Tanks bound, while
every actual return, typed composition and payment is validated separately.
The single physical Ghola may return more than five total over legitimate
Box recovery and replay; no extra Ghola is manufactured.
Tleilaxu's possible Ghola income is not forecast spending money. Actual card
disposal and revival-income responses retain their original controls and
suspend movement guidance until they finish. Persistent guidance names the
force return, then shipment, then ground movement; busy controls submit nothing.
Existing minimal profiles follow this same legal path without strategy tuning.
This composes the existing printed Ghola/Box mechanics with the movement
predicate above; it is not a new publisher movement-specific ruling. Wider
owned preparations, native relocation, later rounds, modules/Advanced and
comprehensive full-mode/recovery/privacy/deployed acceptance remain unfinished.
### Ghola source and proof
The original held source reaches turn2 unshipped with10 Arrakeen forces,
9 reserves,1 real Storm casualty and10 spice; its optional Tleilaxu variant
retains the same owned pieces. Actual 17+0 first dials move Storm Start1 to18;
next-turn1+1 crosses the paid sacrificial Cielago North1 shipment and ends2.
Emperor's actual2-spice bribe settles before the source. Actual Revival Ready
declines ordinary returns. Basic city occupation grants no bank income.
The native Richese source actually sells its original cache Box for1, ships
separate sacrificial groups and plays Richese's original Ghola on the real
casualty. Emperor's actual6-spice bribe leaves Atreides13; the known Ghola stays
in current discard. Owned Box2 → Ghola1 → city shipment10 → ground20 leaves1.
Original source prefixes are42/104 actions held without/with Tleilaxu, and75/151
for Box. Only unopened original deal/Spice positions and scalar lotteries are
selected; no dealt hand, wallet, army, death, clock or completed receipt is set.
Controlled extra-card/prior-loss/knowledge projections, ten-Tank reuse of the
single Ghola and a conserved acquired Karama holding the real income window
open are separately labelled tests, not claims about those natural prefixes.
The final frozen batch passes **22 files182/182**, types/lint (npm run check:quick),
production build and four actual-control/GameTable SSR programmes. Eight
existing-profile held/Box runs accept40 proposals plus24 separate native
continuations. Busy handlers submit nothing; actual answer, recovery, force
return, shipment and ground controls pass. The first frozen batch passed the
rule union but failed type checking on a duplicate fixture snapshot/action key;
the corrected final batch passes. Earlier feedback corrected source geography
and Basic funding; bounded review repaired missing Ghola discard knowledge.
Private artifacts: /home/quintine/.local/share/dune/checkpoints/20261024-movement-ghola/,
final frozen-integrated-2/report.json, runtime-smoke.json, private HTML/logs,
verified-files.json and documentation-overlay.json. Before final prose:
base c311ed42519bbc19d81655a82a2363da64d36ccd, source tree
58bfc5420edfd9bb358f85d5901c605474d6f39944272b29465fca4ae1dd02e5,
2657 tracked/nonignored files unchanged throughout. Only four checkpoint-prose/
reference overlays follow proof; local links/evidence and reference syntax/keys
are reviewed separately. Framework aliases/CSS stub are rendering smoke, not
browser/visual/deployed acceptance. Full modes, wider preparations, combinations
and comprehensive assurance stay open; no saved-room/D1/reset/restart/deployment
operation or AI strategy calibration occurs.
## Native early Basic funding — 24 October 2026
Clean earlier Basic entry now includes declared E1/E2/E3 native factions without
+10 -1
View File
@@ -1,3 +1,4 @@
import { gholaForcePreparations } from './ghola-force-preparation';
import { earlyAuctionFunding } from './early-auction-funding';
import { recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge, recordMovementClaimKnowledge, clearMovementDiscardKnowledge, type MovementDiscardKnowledge } from './movement-discard-knowledge';
import { movementShipmentPreparations } from './movement-shipment-preparation';
@@ -16552,7 +16553,7 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
},
*preparations(state, owner) {
const player = getPlayer(state, owner), paid = state.pendingNullentropy;
const relevant = (card: Card) => card.effect === 'hajr' || card.effect === 'ornithopter' ||
const relevant = (card: Card) => card.effect === 'hajr' || card.effect === 'ornithopter' || card.effect === 'ghola' ||
(player.faction === 'choam' && card.kind === 'worthless' && card.name === 'Kulon');
if (paid) {
if (paid.player !== owner || nullentropyIntegrity(state)) return;
@@ -16572,6 +16573,14 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
try { return groundMovementProbe(applyActionInner(source, actor, action, 'movementPreparation'), actor); }
catch (error) { if (error instanceof RuleError) return null; throw error; }
});
const ghola = gholaOptions(state, player);
if (ghola.available) for (const action of gholaForcePreparations({
cards: ghola.cards, tanks: player.tanks, eliteTanks: player.elites?.tanks ?? 0,
maxForces: ghola.maxForces, eliteRemaining: ghola.eliteRemaining,
})) {
try { yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation'), owner), actions: [action] }; }
catch (error) { if (!(error instanceof RuleError)) throw error; }
}
for (const card of player.hand) {
if (!player.shipped && !state.karamaShipping && canUseAsKaramaRole(state, player, card)) {
const action: Action = { type: 'card', card: card.id, mode: 'shipment', target: owner };
+16
View File
@@ -0,0 +1,16 @@
import type { Action } from './engine';
/** Enumerate physical choices from the owner's current authoritative Ghola
* options. This is not a new revival allowance or a strategic shortlist. */
export function* gholaForcePreparations(quote: {
cards: readonly string[];
tanks: number;
eliteTanks: number;
maxForces: number;
eliteRemaining: number;
}): Generator<Action> {
for (const card of quote.cards) for (let amount = 1; amount <= quote.maxForces; amount++)
for (let elite = Math.max(0, amount - (quote.tanks - quote.eliteTanks));
elite <= Math.min(amount, quote.eliteTanks, quote.eliteRemaining); elite++)
yield { type: 'card', card, amount, elite };
}
+1 -1
View File
@@ -7,7 +7,7 @@ type Entries = FreshDiscardBatch['entries'];
type Knowledge = MovementDiscardKnowledge | undefined;
const relevant = (card: Card): boolean =>
(card.kind === 'special' && card.effect === 'hajr') ||
(card.kind === 'special' && (card.effect === 'hajr' || card.effect === 'ghola')) ||
(card.kind === 'special' && card.id === 'richese-ornithopter' && card.effect === 'ornithopter') ||
(card.kind === 'worthless' && card.name === 'Kulon');
+7 -3
View File
@@ -32,7 +32,7 @@ function* groups(player: Player, keys: string[], amount: number, index = 0,
}
/** Finite owned-action reachability. Each move consumes a real entitlement;
* Hajr, Ornithopter, Kulon and Box use owned physical cards and known or
* Hajr, Ornithopter, Ghola, Kulon and Box use owned physical cards and known or
* already-paid recovery choices, never invented unknown future draws.
* The adapter shares authoritative movement validation/physical commitment.
* Opposing arrival outcomes are not simulated or assumed. */
@@ -45,8 +45,12 @@ export function findMovementCompletion(source: Game, actor: string,
if (!needsMove && adapter.canFinish(game, actor)) return { actions };
const player = game.players.find(candidate => candidate.id === actor)!;
const physical = Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
if (live.some(promise => promise.answer && promise.minimum > physical + (player.shipped ? 0 : player.reserves))) return null;
const key = JSON.stringify([player.forces, player.elites, player.moved, player.shipped, player.reserves, player.hand.map(card => card.id),
// A held recovery card may return more than five over several real plays
// or Box reuse. Tanks are only an optimistic bound; the adapter still
// validates every owned card and never inspects unknown Box faces here.
const recovery = player.hand.some(card => card.effect === 'ghola' || card.effect === 'nullentropyBox') ? player.tanks : 0;
if (live.some(promise => promise.answer && promise.minimum > physical + (player.shipped ? 0 : player.reserves + recovery))) return null;
const key = JSON.stringify([player.forces, player.elites, player.moved, player.shipped, player.reserves, player.tanks, player.hand.map(card => card.id),
player.hajrPlays, player.ornithopterExtraMoves, player.spice, game.choamMovement,
game.aid, game.karamaShipping, game.guildRateBlocked, game.shipmentPromises,
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
+8 -6
View File
@@ -2641,6 +2641,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Choose a dead leader for leader revival, or leave the leader choice empty to return up to five forces. A card pays for one of these choices; it cannot revive both a leader and the Kwisatz Haderach.',
'A binding battle answer or prescience commitment may require this preparation. The private guide identifies a Ghola play that can fulfill the answer, without spending or revealing the card until you choose to play it. Resolve any revival-income response before using that income to support a battle.',
'A Basic ground-movement promise can also require Ghola force preparation. The guide returns physical Tanks to reserves, then preserves an unused affordable shipment and one later matching ground move. Ghola itself does not fulfill movement or consume normal revival allowances. A known discarded Ghola may be recovered with the paid Box and played again.',
'Returned forces go to reserves. A leader revived with this card can fight again this turn, including in another territory. Atreides can also select a dead Kwisatz Haderach; revive it separately from its accompanying leader. Returning a controlled foreign ghola does not transfer it back to its original faction. Additional advanced and expansion interactions remain under audit.',
'An actual Ecaz native Ghola return uses that disc in its current ordinary cohort without consuming the ordinary phase slot. A repeat waits for the remaining native cohort and another opening. Independent Duke Ghola preserves a satisfied five-Tanks opening and returns him set aside; only Ecaz may revive him.',
'A saved play preserves the revived pieces, any earned technology income and the discarded card. Recovery restores any pending Tleilaxu income response without reviving or paying twice. Private preparation guidance waits for recovery to finish.',
@@ -2651,7 +2652,7 @@ export const RULE_TOPICS: RuleTopic[] = [
status: 'Partial',
detail:
'Ordinary and typed forces, controlled leaders and Kwisatz retain original use and custody. Actual Ecaz native Ghola/foreign returns now record completed native cohort identity; independent Duke resurrection preserves its five-Tanks opening without aligning death counters. Ecaz-only living set-aside custody remains; broader combinations and assurance stay unfinished.',
evidence: ['game/engine.ts', 'game/ecaz-duke-revival.ts', 'game/ecaz-revival-cohort.ts', 'game/revival.ts'],
evidence: ['game/ghola-force-preparation.ts', 'game/engine.ts', 'game/ecaz-duke-revival.ts', 'game/ecaz-revival-cohort.ts', 'game/revival.ts'],
},
{
area: 'Player controls',
@@ -2936,18 +2937,19 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'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 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.',
'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.',
'Voluntary actions cannot erase the last required route. Native counter responses finish before impossibility is decided. An opposing loss releases only incompatible answers, retaining No constraints and still-possible Yes answers in accepted order. This owned-route and release priority is a provisional software composition of the general current-turn rule, not a publisher movement-specific FAQ.',
'Persistent answer and next-step controls do not time out. All four profiles use the same private legal guidance. Broader Truthtrance questions, complete modes, full combinations and final acceptance remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-shipment', 'movement', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Basic declared native families now 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. CHOAM own unresolved opening, later rounds, wider preparations/modules remain unfinished.', evidence: ['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: '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: ['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'] },
{ area: 'AI', status: 'Partial', detail: 'Eight existing-profile runs across native Once Around/Silent make56 accepted early-preparation/later-movement proposals plus148 separate native continuations. Zero/pass preserves the route while foreign funding resolves; all-No retains ordinary payment. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-waiting-engine.test.ts', 'tests/truth-movement-early-engine.test.ts', 'tests/truth-movement-native-early-engine.test.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Basic declared native families now 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: 'Player controls', status: 'Partial', detail: '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: ['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: 'Eight 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'] },
{ area: 'Verification', status: 'Partial', detail: 'Current29-file244/244, types/lint/build and six gameplay/control/table-rendering programmes pass. E2/E1/E3 original Charity prefixes25/36/25 retain native setup/cards and Emperor10 spice/20 reserves, then actual invoice2/shipment8/ground8. Controlled escrow, original-card/prior-loss and allowed/canceled Charity/revival cases disclose staging. Initial Ix opening failure is repaired and final frozen union passes. Earlier counts remain historical. Full modes, wider forecast scope, browser/deployed and comprehensive assurance remain open.', evidence: ['tests/truth-movement-waiting-engine.test.ts', 'tests/truth-movement-waiting-controls.test.tsx', 'tests/fixture-truth-movement-waiting.ts', 'tests/fixture-truth-movement-waiting-box.ts', 'tests/fixture-hajr-replay.ts', 'tests/truth-movement-early-engine.test.ts', 'tests/truth-movement-early-controls.test.tsx', 'tests/fixture-truth-movement-early.ts', 'tests/early-auction-funding.test.ts', 'tests/truth-movement-native-early-engine.test.ts', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/fixture-truth-movement-native-early.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Current22-file182/182, types/lint/build and four actual-control/GameTable SSR programmes pass. Original held/Box prefixes42/104/75/151 use native setup, actual cache purchase, paid desert shipments, Storm casualties and settled bribes without Advanced city income. Eight minimal-profile runs accept40 proposals plus24 separate native continuations. Controlled acquisitions/losses/knowledge and counter-enabled income disclose staging. Ghola discard relevance and a fixture field collision are repaired before final frozen proof. Earlier counts remain historical. Full modes, wider preparations, browser/deployed and comprehensive assurance remain open.', evidence: ['tests/truth-movement-waiting-engine.test.ts', 'tests/truth-movement-waiting-controls.test.tsx', 'tests/fixture-truth-movement-waiting.ts', 'tests/fixture-truth-movement-waiting-box.ts', 'tests/fixture-hajr-replay.ts', 'tests/truth-movement-early-engine.test.ts', 'tests/truth-movement-early-controls.test.tsx', 'tests/fixture-truth-movement-early.ts', 'tests/early-auction-funding.test.ts', 'tests/truth-movement-native-early-engine.test.ts', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/fixture-truth-movement-native-early.ts', 'tests/fixture-truth-movement-ghola.ts', 'tests/truth-movement-ghola-engine.test.ts', 'tests/truth-movement-ghola-controls.test.tsx', 'tests/movement-discard-knowledge.test.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
],
},
{
+293
View File
@@ -0,0 +1,293 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import {
actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
type TruthMovementStep, type TruthMovementTransition,
} from './fixture-truth-movement';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type TruthMovementGholaStep = TruthMovementStep;
export const actTruthMovementGhola = actTruthMovement;
export const truthMovementGholaClean = truthMovementClean;
export const truthMovementGholaPlayer = truthMovementPlayer;
export const truthMovementGholaInventory = truthMovementInventory;
const box = richeseCards().find(card => card.effect === 'nullentropyBox');
assert.ok(box);
export const TRUTH_MOVEMENT_GHOLA_BOX = box.id;
/** Real quiet controls; an owned shipped target and any unsealed human battle
* remain caller choices. The optional purchase is an actual one-spice bid. */
export function nextTruthMovementGholaStep(game: Game, options: { buyBox?: boolean } = {}): TruthMovementGholaStep | null {
if (game.truthtrance) return null;
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard && game.decision) {
const decision = game.decision, actor = decision.player, event = game.richeseBidding?.event;
if (decision.kind === 'richeseBlackMarket')
return { actor, action: { type: 'decision', event, decline: true } };
if (decision.kind === 'richeseDeclaration')
return { actor, action: { type: 'decision', event, position: 'first' } };
if (decision.kind === 'richeseCache') {
const card = game.richeseCache?.find(card => card.id === TRUTH_MOVEMENT_GHOLA_BOX) ?? game.richeseCache?.[0];
assert.ok(card, 'Choose an original card still in the native cache');
return { actor, action: { type: 'decision', event, card: card.id,
method: 'onceAround', direction: 'clockwise' } };
}
if (decision.kind === 'richeseUnbid')
return { actor, action: { type: 'decision', event, keep: false } };
if (decision.kind === 'faceDance') return { actor, action: { type: 'decision', reveal: false } };
}
if (truthMovementGholaClean(game)) {
const lot = game.richeseAuction;
if (lot && !lot.outcome) {
const actor = lot.method === 'silent'
? lot.eligible.find(id => !Object.hasOwn(lot.sealed, id)) : lot.active;
assert.ok(actor);
const purchase = options.buyBox && actor === 'atreides' && lot.cardId === TRUTH_MOVEMENT_GHOLA_BOX && lot.bid === 0;
return { actor, action: { type: 'richeseBid', event: lot.event,
amount: purchase ? 1 : lot.method === 'silent' ? 0 : null, ...(purchase ? { allyPayment: 0 } : {}) } };
}
if (game.phase === 5 && game.active === 'atreides' && truthMovementGholaPlayer(game).shipped) return null;
}
const next = nextStrongholdFactionsNativeStep(game);
// Printed Storm Start is sector1. Select actual first-turn dials to reach
// sector18, so both later desert shipments are legal before the real 1+1
// sweep crosses sector1 and ends in sector2. Never replace the Storm field.
if (next?.action.type === 'stormDial' && game.turn === 1)
next.action.amount = next.actor === game.stormDialers[0] ? 17 : 0;
return next;
}
export function advanceTruthMovementGhola(game: Game, until: (state: Game) => boolean,
actions: TruthMovementGholaStep[] = [], options: { buyBox?: boolean } = {}): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Ghola programme ended before its requested control');
const next = nextTruthMovementGholaStep(game, options);
assert.ok(next, 'Finish the original owned movement or human battle before continuing the Ghola source');
game = actTruthMovementGhola(game, next.actor, next.action, actions);
}
throw Error('The original Ghola programme did not reach its finite boundary');
}
/** Original public source only: no dealt hand, wallet, army or receipt is set. */
function originalGholaSetup(options: { box: boolean; tleilaxu: boolean }) {
const asker = 'emperor', target = 'atreides';
const expansions = options.box ? ['choam', ...(options.tleilaxu ? ['ix'] : [])] : options.tleilaxu ? ['ix'] : [];
let game = createGame(options.box ? 'TRUTHMOVEMENTBOXGHOLA' : 'TRUTHMOVEMENTOWNGHOLA',
newPlayer(target, 'Atreides', 'atreides'), false, expansions);
joinGame(game, newPlayer(asker, 'Emperor', 'emperor'));
if (options.box) joinGame(game, newPlayer('richese', 'Richese', 'richese'));
if (options.tleilaxu) joinGame(game, newPlayer('tleilaxu', 'Tleilaxu', 'tleilaxu'));
const actions: TruthMovementGholaStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthMovementGhola(game, actor, action, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game), lobbyActionCount = actions.length;
act(game.host, { type: 'start' });
const setup = structuredClone(game), setupActionCount = actions.length;
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
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 = truthMovementGholaInventory(game);
const originalMainDeck = game.deck.map(card => card.id), originalCache = (game.richeseCache ?? []).map(card => card.id);
const ghola = game.deck.find(card => card.effect === 'ghola');
const truth = game.deck.find(card => card.effect === 'truthtrance');
const shield = game.deck.find(card => card.kind === 'shield');
const baliset = game.deck.find(card => card.name === 'Baliset');
assert.ok(ghola && truth && shield && baliset);
const deal = options.box ? [shield.id, truth.id, ghola.id] : [ghola.id, truth.id];
if (options.tleilaxu) deal.push(baliset.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 beforeStartingDeal = structuredClone(game);
game = advanceTruthMovementGhola(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game), afterSetupActionCount = actions.length;
for (const [index, player] of game.players.entries()) assert.deepEqual(player.hand.map(card => card.id), [deal[index]]);
const beforeSpiceOrder = structuredClone(game), originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = Array.from({ length: options.box ? 4 : 2 }, (_, position) => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
!card.discovery && !['cielago_north', 'cielago_depression'].includes(card.territory));
assert.ok(from >= position, 'Choose an original ordinary Spice card outside both sacrificial sources');
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);
return { game, actions, asker, target, ghola: ghola.id, truth: truth.id, expansions,
tleilaxu: options.tleilaxu ? 'tleilaxu' : null, richese: options.box ? 'richese' : null,
lobby, lobbyActionCount, setup, setupActionCount, beforeStartingDeal, deal, dealOrder,
afterSetup, afterSetupActionCount, beforeSpiceOrder, spiceOrder, originalCards, originalMainDeck,
originalCache, originalSpice };
}
const atTargetShipment = (game: Game) => game.phase === 5 && game.active === 'atreides' && truthMovementGholaClean(game);
function verifyGholaTarget(game: Game, expectedSpice: number) {
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false); assert.ok(atTargetShipment(game));
const target = truthMovementGholaPlayer(game);
assert.equal(target.shipped, false); assert.equal(target.moved, 0);
assert.equal(target.reserves, 9); assert.equal(target.tanks, 1); assert.equal(target.spice, expectedSpice);
assert.deepEqual(target.forces, { 'arrakeen:10': 10 });
assert.equal(game.truthtrance ?? null, null); assert.equal(game.movementPromises, undefined);
}
function futureGholaMovement(target: string, asker: string, truth: string, ghola: string) {
const claim = { kind: 'movement' as const, target, territory: 'carthag', minimum: 20 };
return {
card: truth, claim, question: claim,
truthStep: { actor: asker, action: { type: 'card', card: truth } } satisfies TruthMovementGholaStep,
declarationStep: { actor: asker, action: { type: 'card', card: truth } } satisfies TruthMovementGholaStep,
askStep: { actor: asker, action: { type: 'truthAsk', question: claim } } satisfies TruthMovementGholaStep,
gholaStep: { actor: target, action: { type: 'card', card: ghola, amount: 1, elite: 0 } } satisfies TruthMovementGholaStep,
shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 10, elite: 0, allyPayment: 0 } satisfies Action,
shipmentCost: 10,
move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 20 } satisfies Action,
endMovement: { type: 'endMovement' } satisfies Action,
};
}
/** A real paid turn-one desert shipment becomes one Tank casualty in the next
* ordinary 1+1 Storm. Actual Revival readiness leaves it dead and Ghola held. */
export function createTruthMovementGholaFixture(options: { tleilaxu?: boolean } = {}) {
const seed = originalGholaSetup({ box: false, tleilaxu: options.tleilaxu ?? false });
let game = seed.game;
const actions = seed.actions;
const act = (actor: string, action: Action) => { game = actTruthMovementGhola(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthMovementGhola(game, until, actions); };
reach(state => state.turn === 1 && atTargetShipment(state));
const beforeBribe = structuredClone(game), bribeStep: TruthMovementGholaStep = {
actor: seed.asker, action: { type: 'bribe', target: seed.target, amount: 2 },
};
act(bribeStep.actor, bribeStep.action);
const afterBribe = structuredClone(game), bribeActionCount = actions.length;
assert.equal(truthMovementGholaPlayer(afterBribe, seed.asker).spice,
truthMovementGholaPlayer(beforeBribe, seed.asker).spice - 2);
assert.equal(truthMovementGholaPlayer(afterBribe).spice, truthMovementGholaPlayer(beforeBribe).spice);
assert.equal(truthMovementGholaPlayer(afterBribe).bribes, 2);
const beforeShipment = structuredClone(game), shipmentStep: TruthMovementGholaStep = { actor: seed.target, action: {
type: 'ship', territory: 'cielago_north', sector: 1, amount: 1, elite: 0, allyPayment: 0,
} };
act(shipmentStep.actor, shipmentStep.action); reach(truthMovementGholaClean);
const afterShipment = structuredClone(game), shipmentActionCount = actions.length;
const sourceShipment: TruthMovementTransition = { before: beforeShipment, step: shipmentStep, after: afterShipment };
act(seed.target, { type: 'endMovement' });
reach(state => state.turn === 1 && state.phase === 8 && truthMovementGholaClean(state));
const afterFirstCollection = structuredClone(game), collectionActionCount = actions.length;
assert.equal(truthMovementGholaPlayer(game).spice, 10);
reach(state => state.turn === 2 && state.phase === 0 && truthMovementGholaClean(state));
const beforeStorm = structuredClone(game), beforeStormActionCount = actions.length;
reach(state => state.turn === 2 && state.phase === 1 && truthMovementGholaClean(state));
const afterStorm = structuredClone(game), stormActionCount = actions.length;
assert.equal(game.storm, 2); assert.equal(truthMovementGholaPlayer(game).tanks, 1);
assert.deepEqual(truthMovementGholaPlayer(game).forces, { 'arrakeen:10': 10 });
reach(state => state.turn === 2 && state.phase === 4 && truthMovementGholaClean(state));
const beforeRevival = structuredClone(game), beforeRevivalActionCount = actions.length;
reach(state => state.turn === 2 && atTargetShipment(state));
verifyGholaTarget(game, 10);
assert.ok(truthMovementGholaPlayer(game).hand.some(card => card.id === seed.ghola));
assert.ok(truthMovementGholaPlayer(game, seed.asker).hand.some(card => card.id === seed.truth));
assert.deepEqual(truthMovementGholaInventory(game), seed.originalCards);
return { ...seed, game, source: structuredClone(game), actions,
beforeBribe, bribeStep, afterBribe, bribeActionCount,
beforeShipment, shipmentStep, sourceShipment, afterShipment, shipmentActionCount,
afterFirstCollection, collectionActionCount, beforeStorm, beforeStormActionCount, afterStorm, stormActionCount,
beforeRevival, beforeRevivalActionCount, sourceActionCount: actions.length,
sourceCounts: { setup: seed.afterSetupActionCount, bribe: bribeActionCount, shipment: shipmentActionCount,
collection: collectionActionCount, beforeStorm: beforeStormActionCount, storm: stormActionCount,
revival: beforeRevivalActionCount, source: actions.length },
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
...futureGholaMovement(seed.target, seed.asker, seed.truth, seed.ghola),
staging: [
`Original public Basic Atreides/Emperor${seed.tleilaxu ? '/Tleilaxu' : ''} setup genuinely deals the original Ghola to Atreides and Truthtrance to Emperor. Only unopened original deal/ordinary Spice positions and scalar lotteries are selected. Actual first-turn 17+0 dials move printed Storm Start1 to18. Atreides really pays two spice to ship one reserve to printed Cielago North1, ends movement without a ground event, receives Emperor's actual two-spice bribe at Mentat Pause and waits for next-turn 1+1 dials. The actual 1+1 Storm sweep crosses sector1 and ends at2, killing that counter; actual Revival Ready controls decline ordinary revival. The clean turn-2 unshipped source retains ten original Arrakeen forces, nine reserves, one Tank, ten spice and its held original Ghola. Cielago North1 replaces the nonexistent Great Flat1; Great Flat has only printed sector15. Basic city occupation supplies no bank income. The bribe is an actual paid control, not an installed balance. No dealt hand, wallet, army, clock, death, answer, promise or receipt is installed. Future Ghola1, city shipment10 and ground movement20 remain caller actions.`,
],
};
}
export type TruthMovementGholaFixture = ReturnType<typeof createTruthMovementGholaFixture>;
/** Native Richese sells its original cache Box for one spice. Separate actual
* sacrificial shipments avoid a human battle; Richese's later real Ghola play
* exposes that original card in the discard, while Atreides keeps its Box. */
export function createTruthMovementBoxGholaFixture(options: { tleilaxu?: boolean } = {}) {
const seed = originalGholaSetup({ box: true, tleilaxu: options.tleilaxu ?? false });
let game = seed.game;
const actions = seed.actions;
const act = (actor: string, action: Action) => { game = actTruthMovementGhola(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean, buyBox = false) => {
game = advanceTruthMovementGhola(game, until, actions, { buyBox });
};
reach(state => state.turn === 1 && state.phase === 2 && truthMovementGholaClean(state));
const beforePurchases = structuredClone(game), beforePurchasesActionCount = actions.length;
reach(state => truthMovementGholaPlayer(state).hand.some(card => card.id === TRUTH_MOVEMENT_GHOLA_BOX), true);
reach(truthMovementGholaClean);
const afterPurchase = structuredClone(game), purchaseActionCount = actions.length;
assert.equal(truthMovementGholaPlayer(game).spice, 9);
reach(state => state.turn === 1 && state.phase === 5 && truthMovementGholaClean(state));
const beforeBribe = structuredClone(game), bribeStep: TruthMovementGholaStep = {
actor: seed.asker, action: { type: 'bribe', target: seed.target, amount: 6 },
};
act(bribeStep.actor, bribeStep.action);
const afterBribe = structuredClone(game), bribeActionCount = actions.length;
assert.equal(truthMovementGholaPlayer(afterBribe, seed.asker).spice,
truthMovementGholaPlayer(beforeBribe, seed.asker).spice - 6);
assert.equal(truthMovementGholaPlayer(afterBribe).spice, truthMovementGholaPlayer(beforeBribe).spice);
assert.equal(truthMovementGholaPlayer(afterBribe).bribes, 6);
const shipments: TruthMovementTransition[] = [];
for (let count = 0; count < 500; count++) {
if (game.turn === 1 && game.phase === 8 && truthMovementGholaClean(game)) break;
if (game.turn === 1 && game.phase === 5 && truthMovementGholaClean(game) &&
(game.active === seed.target || game.active === 'richese')) {
const actor = game.active, before = structuredClone(game), step: TruthMovementGholaStep = { actor, action: {
type: 'ship', territory: actor === seed.target ? 'cielago_north' : 'cielago_depression',
sector: 1, amount: 1, elite: 0, allyPayment: 0,
} };
assert.equal(truthMovementGholaPlayer(game, actor).shipped, false);
act(step.actor, step.action); reach(truthMovementGholaClean);
shipments.push({ before, step, after: structuredClone(game) });
act(actor, { type: 'endMovement' });
} else {
const next = nextTruthMovementGholaStep(game); assert.ok(next, 'Do not invent a human battle or owned movement');
act(next.actor, next.action);
}
}
assert.equal(game.turn, 1); assert.equal(game.phase, 8); assert.ok(truthMovementGholaClean(game));
assert.equal(shipments.length, 2);
const afterFirstCollection = structuredClone(game), collectionActionCount = actions.length;
assert.equal(truthMovementGholaPlayer(game).spice, 13);
reach(state => state.turn === 2 && state.phase === 0 && truthMovementGholaClean(state));
const beforeStorm = structuredClone(game), beforeStormActionCount = actions.length;
reach(state => state.turn === 2 && state.phase === 1 && truthMovementGholaClean(state));
const afterStorm = structuredClone(game), stormActionCount = actions.length;
assert.equal(game.storm, 2); assert.equal(truthMovementGholaPlayer(game).tanks, 1);
assert.equal(truthMovementGholaPlayer(game, 'richese').tanks, 1);
reach(state => state.turn === 2 && state.phase === 4 && truthMovementGholaClean(state));
const beforeGhola = structuredClone(game), richeseGholaStep: TruthMovementGholaStep = { actor: 'richese', action: {
type: 'card', card: seed.ghola, amount: 1, elite: 0,
} };
act(richeseGholaStep.actor, richeseGholaStep.action); reach(truthMovementGholaClean);
const afterGhola = structuredClone(game), gholaActionCount = actions.length;
assert.equal(truthMovementGholaPlayer(game, 'richese').tanks, 0);
assert.ok(game.discard.some(card => card.id === seed.ghola));
reach(state => state.turn === 2 && atTargetShipment(state));
verifyGholaTarget(game, 13);
assert.ok(truthMovementGholaPlayer(game).hand.some(card => card.id === TRUTH_MOVEMENT_GHOLA_BOX));
assert.ok(truthMovementGholaPlayer(game).hand.length < 4, 'The held Box must have its actual free hand slot');
assert.ok(truthMovementGholaPlayer(game, seed.asker).hand.some(card => card.id === seed.truth));
assert.ok(game.discard.some(card => card.id === seed.ghola), 'The actual revealed Ghola remains in current discard');
assert.deepEqual(truthMovementGholaInventory(game), seed.originalCards);
return { ...seed, game, source: structuredClone(game), actions, box: TRUTH_MOVEMENT_GHOLA_BOX,
beforeBribe, bribeStep, afterBribe, bribeActionCount,
beforePurchases, beforePurchasesActionCount, afterPurchase, purchaseActionCount, shipments,
afterFirstCollection, collectionActionCount, beforeStorm, beforeStormActionCount, afterStorm, stormActionCount,
beforeGhola, richeseGholaStep, afterGhola, gholaActionCount, sourceActionCount: actions.length,
sourceCounts: { setup: seed.afterSetupActionCount, beforePurchases: beforePurchasesActionCount,
purchase: purchaseActionCount, bribe: bribeActionCount, collection: collectionActionCount, beforeStorm: beforeStormActionCount,
storm: stormActionCount, ghola: gholaActionCount, source: actions.length },
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
...futureGholaMovement(seed.target, seed.asker, seed.truth, seed.ghola),
boxStep: { actor: seed.target, action: { type: 'card', card: TRUTH_MOVEMENT_GHOLA_BOX } } satisfies TruthMovementGholaStep,
boxSelection: { type: 'decision', card: seed.ghola } satisfies Action,
boxCost: 2,
staging: [
`Original public Basic Atreides/Emperor/Richese${seed.tleilaxu ? '/Tleilaxu' : ''} setup genuinely deals Shield/Truthtrance/Ghola and preserves the original main deck and ten-card cache. Actual first-turn 17+0 dials move printed Storm Start1 to18. Atreides actually buys the original cache Box for one spice in Richese's first Once Around auction. Atreides and Richese really pay for separate one-force shipments to Cielago North1 and Cielago Depression1, then end without ground movement. Emperor actually pays a six-spice bribe, which becomes spendable at Mentat Pause. Native next-turn 1+1 dials ending at2 supply both Tank casualties without a shared-territory battle. Basic city occupation supplies no bank income. Richese really plays its held Ghola to return its own counter; that exposed original Ghola remains in the current discard. Actual normal revival declines reach clean turn-2 Atreides Shipment with Box held, a free hand slot, ten Arrakeen forces, nine reserves, one Tank and thirteen spice. No unknown card is acquired, and no dealt hand, wallet, army, clock, death, discard, answer, promise or completed receipt is installed. Future Box2/search, Ghola1, city shipment10 and ground movement20 remain caller actions.`,
],
};
}
export type TruthMovementBoxGholaFixture = ReturnType<typeof createTruthMovementBoxGholaFixture>;
+13
View File
@@ -10,12 +10,25 @@ import {
const seats = ['owner', 'rival', 'observer'];
const hajr: Card = { id: 'physical-hajr', kind: 'special', name: 'Hajr', effect: 'hajr' };
const ghola: Card = { id: 'physical-ghola', kind: 'special', name: 'Tleilaxu Ghola', effect: 'ghola' };
const ornithopter: Card = { id: 'richese-ornithopter', kind: 'special', name: 'Ornithopter', effect: 'ornithopter' };
const kulon: Card = { id: 'physical-kulon', kind: 'worthless', name: 'Kulon' };
const shield: Card = { id: 'physical-shield', kind: 'shield', name: 'Shield' };
const entry = (card: Card, discardedBy = 'owner', publicFace = false): FreshDiscardBatch['entries'][number] =>
({ card, discardedBy, publicFace });
void test('Ghola force preparation uses only actually observed public, private or paid Box faces', () => {
const privateKnowledge = recordMovementDiscardKnowledge(undefined, seats, [entry(ghola)]);
assert.deepEqual(knownMovementDiscards(privateKnowledge, 'owner', [ghola]), [ghola]);
assert.deepEqual(knownMovementDiscards(privateKnowledge, 'rival', [ghola]), []);
const publicKnowledge = recordMovementDiscardKnowledge(privateKnowledge, seats, [entry(ghola, 'owner', true)]);
for (const player of seats) assert.deepEqual(knownMovementDiscards(publicKnowledge, player, [ghola]), [ghola]);
const paidKnowledge = recordMovementBoxKnowledge(publicKnowledge, seats, 'rival', [ghola, shield]);
assert.deepEqual(knownMovementDiscards(paidKnowledge, 'rival', [ghola, shield]), [ghola]);
assert.deepEqual(knownMovementDiscards(paidKnowledge, 'owner', [ghola]), []);
assert.deepEqual(knownMovementDiscards(paidKnowledge, 'rival', []), []);
});
void test('a public relevant disposal is remembered by all seats without creating unrelated face knowledge', () => {
const entries = [entry(hajr, 'rival', true), entry(shield, 'rival', true)];
const before = structuredClone(entries);
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, type ReactElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MovementPromises } from '../components/movement-promises';
import { NullentropySearch } from '../components/nullentropy-box';
import { viewGame, type Action, type Game } from '../game/engine';
import {
createTruthMovementGholaFixture, createTruthMovementBoxGholaFixture,
actTruthMovementGhola as act, advanceTruthMovementGhola as advance,
truthMovementGholaClean as clean, truthMovementGholaPlayer as player,
type TruthMovementGholaFixture,
} from './fixture-truth-movement-ghola';
type Props = Parameters<typeof MovementPromises>[0];
type Control = { children?: unknown; disabled?: boolean; onClick: () => void };
function controls(node: unknown): Control[] {
if (Array.isArray(node)) return node.flatMap(controls);
if (!node || typeof node !== 'object' || !('props' in node)) return [];
const props = (node as ReactElement<{ children?: unknown; disabled?: boolean; onClick?: () => void }>).props;
return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...controls(props.children)];
}
const markup = (props: Props) => renderToStaticMarkup(createElement(MovementPromises, props));
type Source = Pick<TruthMovementGholaFixture, 'game' | 'asker' | 'target' | 'truthStep' | 'askStep'>;
/** Actual declaration, answer and native discard controls; no installed policy. */
function bind(f: Source): Game {
let game = act(f.game, f.asker, f.truthStep.action);
while (game.truthtrance?.stage === 'priority') {
const p = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(p);
game = act(game, p.id, { type: 'truthPass' });
}
game = act(game, f.asker, f.askStep.action);
assert.ok(viewGame(game, f.target).truthMovementAnswers?.includes('yes'));
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
return advance(game, clean);
}
function clickCurrent(game: Game, actor: string, label: string): Action {
const sent: Action[] = [], props = { game: viewGame(game, actor), busy: false,
act: (action: Action) => { sent.push(action); } };
const available = controls(MovementPromises(props));
assert.equal(available.length, 1); assert.equal(available[0].children, label);
assert.equal(available[0].disabled, false);
assert.match(markup(props), /Yes: you must make a ground movement of at least 20 physical forces to Carthag/);
assert.match(markup(props), /a shipment can prepare that move but does not fulfill it/);
const busy = controls(MovementPromises({ ...props, busy: true }));
assert.equal(busy.length, 1); assert.equal(busy[0].disabled, true);
busy[0].onClick(); assert.deepEqual(sent, []);
assert.match(markup({ ...props, busy: true }), /Your ground-movement promises/);
available[0].onClick(); assert.deepEqual(sent, props.game.movementPromisePolicy);
assert.equal(sent[0], props.game.movementPromisePolicy![0]);
return sent[0];
}
function assertWaitingForNativeControl(game: Game, actor: string) {
const props = { game: viewGame(game, actor), busy: false,
act() { assert.fail('A suspended native response must not submit movement'); } };
assert.equal(props.game.movementPromisePolicy, null);
assert.equal(controls(MovementPromises(props)).length, 0);
assert.match(markup(props), /Your ground-movement promises/);
assert.doesNotMatch(markup(props), /<button/);
}
const normalRevival = (game: Game, actor: string) => {
const p = player(game, actor);
return { revived: p.revived, freeForcesRevived: p.freeForcesRevived, leaderRevived: p.leaderRevived };
};
void test('real held Ghola guidance returns a Tank before shipment and only the later ground move fulfills Yes', () => {
const f = createTruthMovementGholaFixture(); let game = bind(f);
const allowances = normalRevival(game, f.target);
const ghola = clickCurrent(game, f.target,
'Play Ghola to return 1 force to reserves for the required movement');
assert.deepEqual(ghola, f.gholaStep.action);
// UI-only phase projection checks timing, not an earned early-rule source.
const early = viewGame(game, f.target); early.phase = 4;
const earlyProps = { game: early, busy: false, act() { assert.fail('Do not offer a future Ghola control early'); } };
assert.equal(controls(MovementPromises(earlyProps)).length, 0);
assert.match(markup(earlyProps), /Your Movement turn is still ahead/);
game = act(game, f.target, ghola); game = advance(game, clean);
assert.equal(player(game, f.target).reserves, 10); assert.equal(player(game, f.target).tanks, 0);
assert.deepEqual(normalRevival(game, f.target), allowances);
assert.equal(game.movementPromises![0].fulfilled, undefined);
const shipment = clickCurrent(game, f.target,
'Ship 10 forces to Arrakeen, sector 10, before the required movement');
assert.deepEqual(shipment, f.shipment);
game = advance(act(game, f.target, shipment), clean);
assert.equal(player(game, f.target).spice, 0);
assert.equal(game.movementPromises![0].fulfilled, undefined);
const move = clickCurrent(game, f.target, 'Move 20 forces to Carthag, sector 11');
assert.equal(move.type, 'move');
game = act(game, f.target, move);
assert.equal(game.movementPromises![0].fulfilled, true);
assert.equal(MovementPromises({ game: viewGame(game, f.target), busy: false, act() {} }), null);
});
void test('a controlled original counter keeps real Ghola income pending and suspends guidance until native controls finish', () => {
const f = createTruthMovementGholaFixture({ tleilaxu: true });
// Explicit conserved prior acquisition; do not invent a conditional window
// when no other player actually holds an eligible counter.
const index = f.game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
player(f.game, f.asker).hand.push(f.game.deck.splice(index, 1)[0]);
let game = bind(f);
const originalIncome = player(game, 'tleilaxu').spice, allowances = normalRevival(game, f.target);
const ghola = clickCurrent(game, f.target,
'Play Ghola to return 1 force to reserves for the required movement');
game = act(game, f.target, ghola);
assert.equal(player(game, f.target).reserves, 10);
assert.deepEqual(normalRevival(game, f.target), allowances);
assert.equal(player(game, 'tleilaxu').spice, originalIncome, 'The real income remains conditional');
assert.ok(game.response || game.pendingTreacheryDiscard);
assertWaitingForNativeControl(game, f.target);
game = advance(game, state => !state.pendingTreacheryDiscard);
assert.equal(game.response?.kind, 'revivalIncome');
assertWaitingForNativeControl(game, f.target);
game = advance(game, clean);
assert.equal(player(game, 'tleilaxu').spice, originalIncome + 1);
assert.equal(game.movementPromises![0].fulfilled, undefined);
clickCurrent(game, f.target, 'Ship 10 forces to Arrakeen, sector 10, before the required movement');
});
void test('real known Ghola Box guidance pays once and forwards its original private selection before force return', () => {
const f = createTruthMovementBoxGholaFixture(); let game = bind(f);
const box = clickCurrent(game, f.target, 'Pay 2 spice and search with Nullentropy Box');
assert.deepEqual(box, f.boxStep.action);
game = act(game, f.target, box);
assert.equal(player(game, f.target).spice, 11);
const view = viewGame(game, f.target), paid = { game: view, busy: false, act() {} };
const html = markup(paid);
assert.match(html, /fee is paid only once/); assert.doesNotMatch(html, /Pay 2 spice and search with/);
const inspection = renderToStaticMarkup(createElement(NullentropySearch, paid));
assert.match(inspection, /Take Tleilaxu Ghola and finish search/);
assert.match(inspection, /Take Truthtrance and finish search/);
const selection = clickCurrent(game, f.target, 'Choose Tleilaxu Ghola in your paid Box search');
assert.deepEqual(selection, { type: 'decision', event: view.nullentropy!.search!.event, card: f.ghola });
game = advance(act(game, f.target, selection), clean);
assert.equal(game.pendingNullentropy, null); assert.equal(player(game, f.target).spice, 11);
const ghola = clickCurrent(game, f.target,
'Play Ghola to return 1 force to reserves for the required movement');
game = advance(act(game, f.target, ghola), clean);
assert.equal(game.movementPromises![0].fulfilled, undefined);
const shipment = clickCurrent(game, f.target,
'Ship 10 forces to Arrakeen, sector 10, before the required movement');
game = advance(act(game, f.target, shipment), clean);
assert.equal(player(game, f.target).spice, 1);
const move = clickCurrent(game, f.target, 'Move 20 forces to Carthag, sector 11');
game = act(game, f.target, move); assert.equal(game.movementPromises![0].fulfilled, true);
});
+153
View File
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { createTruthMovementShipmentFixture } from './fixture-truth-movement-shipment';
import { actTruthMovement as act, truthMovementPlayer as player } from './fixture-truth-movement';
import { createTruthMovementGholaFixture as native, createTruthMovementBoxGholaFixture as box,
truthMovementGholaInventory as inventory } from './fixture-truth-movement-ghola';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function ask(game: Game, asker: string, card: string, target: string, minimum = 20) {
game = act(game, asker, { type: 'card', card });
while (game.truthtrance?.stage === 'priority') {
const p = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(p);
game = act(game, p.id, { type: 'truthPass' });
}
return act(game, asker, { type: 'truthAsk', question: { kind: 'movement', target, territory: 'carthag', minimum } });
}
void test('a controlled conserved Ghola and one Tank make the otherwise missing twentieth movement counter reachable', () => {
const f = createTruthMovementShipmentFixture({ hajr: false }), source = structuredClone(f.game);
const p = player(source, f.target); p.reserves--; p.tanks++;
const index = source.deck.findIndex(card => card.effect === 'ghola'); assert.ok(index >= 0);
p.hand.push(source.deck.splice(index, 1)[0]);
// Explicit prior casualty/original-card acquisition, not a natural loss source.
const game = ask(source, f.asker, f.truth, f.target);
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
});
function settle(game: Game): Game {
if (game.pendingTreacheryDiscard?.reaction) {
const event = game.pendingTreacheryDiscard.batch.event;
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
}
while (game.response) {
const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p);
game = act(game, p.id, { type: 'passResponse' });
}
return game;
}
function bind(f: ReturnType<typeof native> | ReturnType<typeof box>, source = f.game) {
const asked = ask(source, f.asker, f.truth, f.target);
assert.ok(viewGame(asked, f.target).truthMovementAnswers!.includes('yes'));
return settle(act(asked, f.target, { type: 'truthAnswer', answer: 'yes' }));
}
function next(game: Game, target: string) {
const action = viewGame(game, target).movementPromisePolicy?.[0]; assert.ok(action); return action;
}
void test('an original held Ghola returns the real Storm casualty before paid shipment and twenty-force ground movement', () => {
const f = native(); let game = bind(f);
const before = structuredClone(game), quotas = [player(game).revived, player(game).freeForcesRevived, player(game).leaderRevived];
assert.deepEqual(next(game, f.target), f.gholaStep.action);
assert.deepEqual(game, before, 'Forecast cannot play the live card or return a live counter');
game = settle(act(game, f.target, next(game, f.target)));
assert.equal(player(game).tanks, 0); assert.equal(player(game).reserves, 10);
assert.equal(player(game).spice, 10); assert.equal(player(game).shipped, false);
assert.deepEqual([player(game).revived, player(game).freeForcesRevived, player(game).leaderRevived], quotas);
assert.equal(game.movementPromises![0].fulfilled, undefined);
assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1);
assert.equal(next(game, f.target).type, 'ship');
game = settle(act(game, f.target, f.shipment));
assert.equal(player(game).spice, 0); assert.equal(player(game).forces['arrakeen:10'], 20);
assert.equal(game.movementPromises![0].fulfilled, undefined);
game = settle(act(game, f.target, f.move));
assert.equal(game.movementPromises![0].fulfilled, true);
game = act(game, f.target, f.endMovement); assert.deepEqual(inventory(game), f.originalCards);
});
void test('Ghola returns reserves but cannot replace a shipment already spent before the question', () => {
const f = native();
const source = settle(act(f.game, f.target, { ...f.shipment, amount: 9 }));
assert.equal(player(source).forces['arrakeen:10'], 19); assert.equal(player(source).tanks, 1);
const game = ask(source, f.asker, f.truth, f.target);
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['no']);
});
void test('the original publicly spent Ghola is recovered with a real Box fee and its complete paid choice', () => {
const f = box(); let game = bind(f);
assert.deepEqual(next(game, f.target), f.boxStep.action);
game = act(game, f.target, next(game, f.target));
assert.equal(player(game).spice, 11);
const search = viewGame(game, f.target).nullentropy!.search!;
assert.ok(search.cards.some(card => card.id === f.ghola)); assert.ok(search.cards.some(card => card.id === f.truth));
assert.deepEqual(next(game, f.target), { type: 'decision', event: search.event, card: f.ghola });
const before = structuredClone(game);
assert.throws(() => act(game, f.target, { type: 'decision', event: search.event, card: f.truth }));
assert.deepEqual(game, before, 'Wrong selection retains the actual paid event, cards and fee');
game = settle(act(game, f.target, next(game, f.target)));
assert.deepEqual(next(game, f.target), f.gholaStep.action);
game = settle(act(game, f.target, f.gholaStep.action));
game = settle(act(game, f.target, f.shipment)); game = settle(act(game, f.target, f.move));
assert.equal(game.movementPromises![0].fulfilled, true); assert.equal(player(game).spice, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('controlled hidden discard knowledge cannot supply an unpaid Ghola recovery witness', () => {
const f = box(), source = structuredClone(f.game);
// A knowledge-boundary projection, not a claim that the source's public play was hidden.
delete source.movementDiscardKnowledge;
const game = ask(source, f.asker, f.truth, f.target);
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['no']);
});
void test('a controlled conserved Ghola plus Box can return ten Tanks by reusing the single original Ghola', () => {
const f = box(), source = structuredClone(f.game), owner = player(source);
// Explicit prior acquisition/loss projection. No second Ghola is manufactured:
// the source's original public card moves to hand; nine reserves join Tanks.
const index = source.discard.findIndex(card => card.id === f.ghola); assert.ok(index >= 0);
owner.hand.push(source.discard.splice(index, 1)[0]); owner.tanks += owner.reserves; owner.reserves = 0;
let game = bind(f, source), plays = 0, searches = 0;
for (let count = 0; count < 6 && !game.movementPromises![0].fulfilled; count++) {
const action = next(game, f.target);
if (action.type === 'card' && action.card === f.ghola) { assert.equal(action.amount, 5); plays++; }
if (action.type === 'card' && action.card === f.box) searches++;
game = settle(act(game, f.target, action));
}
assert.equal(plays, 2); assert.equal(searches, 1);
assert.equal(game.movementPromises![0].fulfilled, true); assert.equal(player(game).tanks, 0);
assert.equal(player(game).spice, 1); assert.deepEqual(inventory(game), f.originalCards);
});
void test('a controlled acquired original counter keeps native Ghola income conditional without speculative forecast credit', () => {
const f = native({ tleilaxu: true }), source = structuredClone(f.game);
// Conserved original-card acquisition keeps the real counter window open.
const counter = source.deck.findIndex(card => card.effect === 'karama'); assert.ok(counter >= 0);
player(source, f.asker).hand.push(source.deck.splice(counter, 1)[0]);
let game = bind(f, source);
const income = player(game, 'tleilaxu').spice;
assert.deepEqual(next(game, f.target), f.gholaStep.action);
assert.equal(player(game, 'tleilaxu').spice, income);
game = act(game, f.target, f.gholaStep.action);
assert.equal(player(game).reserves, 10); assert.equal(player(game).tanks, 0);
if (game.pendingTreacheryDiscard?.reaction) {
const event = game.pendingTreacheryDiscard.batch.event;
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
}
assert.equal(game.response?.kind, 'revivalIncome');
assert.equal(player(game, 'tleilaxu').spice, income);
game = settle(game); assert.equal(player(game, 'tleilaxu').spice, income + 1);
game = settle(act(game, f.target, f.shipment)); game = settle(act(game, f.target, f.move));
assert.equal(game.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('all minimal profiles play the actual held Ghola, reinforce and fulfill the same ground promise', () => {
const f = native(), bound = bind(f);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(bound);
for (let count = 0; count < 5 && !game.movementPromises![0].fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const options = botActions(view); let accepted = false;
for (const action of options) {
try { game = settle(act(game, f.target, action)); accepted = true; break; } catch { /* try next legal proposal */ }
}
assert.ok(accepted, difficulty);
}
assert.equal(game.movementPromises![0].fulfilled, true, difficulty);
assert.equal(player(game).tanks, 0); assert.deepEqual(inventory(game), f.originalCards);
}
});