Support native early Basic movement promises

Price native Once Around and Silent funding in early movement forecasts, retaining full sealed donor locks and exact paid-sale boundaries. Add current zero/pass guidance, fixed non-CHOAM preopening Charity entitlement and shared owned normal-revival projection while actual counters remain pending. Preserve the mandatory Ix phase-opening continuation.

Validation: affected rules/control tests, types/lint, production build and six gameplay/control/table-rendering programmes with eight existing-profile native runs. Wider preparations, CHOAM own unresolved opening, modules/Advanced, full rules and deployed acceptance remain open. No saved-room, database, restart or deployment operation.
This commit is contained in:
Quintine committed 2026-10-24 16:05:41 +11:00
1 parent f995b6f4cd
commit c311ed4251
16 files changed
+787 -54

No files matched your search

+2 -1
View File
@@ -12,7 +12,7 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac
const next = game.phase >= 5 || paidDecision ||
(game.phase === 2 && ['charity', 'ready'].includes(proposed?.type ?? '')) ||
(game.phase === 4 && ['revive', 'ready'].includes(proposed?.type ?? '')) ||
(game.phase === 3 && proposed?.type === 'passBid') ? proposed : undefined;
(game.phase === 3 && ['passBid', 'richeseBid'].includes(proposed?.type ?? '')) ? proposed : undefined;
const amount = next?.forces && typeof next.forces === 'object'
? Object.values(next.forces as Record<string, number>).reduce((sum, count) => sum + count, 0) : typeof next?.amount === 'number' ? next.amount : 0;
const destination = next && game.combatLocations.find(location => location.id === next.territory)?.name;
@@ -27,6 +27,7 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac
: next?.type === 'revive' ? `Revive ${amount} forces for your later movement`
: next?.type === 'ready' ? 'Finish this preparation phase while preserving your promises'
: next?.type === 'passBid' ? 'Pass this bid and preserve later movement'
: 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' && next.mode === 'choam' ? 'Play Kulon to extend the required movement'
+2 -2
View File
@@ -349,8 +349,8 @@ 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 preparation appears when it is available. Earlier-phase questions currently support classic factions.</p>}
{!g.truthMovementTargets.includes(target) && <p className="notice">Choose a supported Basic respondent this round and finish pending decisions. Earlier phases use classic factions; native questions currently begin during Movement. Optional-module and Advanced movement promises remain unfinished.</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.truthMovementTargets.includes(target) && <p className="notice">Choose a supported Basic respondent this round and finish pending decisions. Optional-module and Advanced movement promises remain unfinished.</p>}
<p className="notice" aria-label="Public movement question preview">{truthQuestionText(question, leaderName, locationName)}</p>
</>
) : kind === 'shipment' || kind === 'mixedShipment' ? (
+22
View File
@@ -16,6 +16,28 @@ open mode gates.
## Current checkpoint and work
**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
and already-paid sale boundaries. Current native controls pass or submit zero
without exposing future movement. Non-CHOAM preopening Charity quotes its fixed
entitlement only; ordinary owned revival shares physical accounting while real
native counters remain pending. The mandatory Ix Movement opening retains the
future turn instead of vetoing preceding Ready.
Disjoint source/control lanes, bounded funding review and owner integration pass
**29 files244/244**, types/lint/build and six gameplay/control/table-rendering
programmes. Original E2/E1/E3 Charity prefixes25/36/25 retain actual setup/cards
and Emperor10 spice/20 reserves; real first/last Once Around/Silent invoice2,
shipment8 and ground8 fulfill Yes. Eight profile runs make56 accepted proposals
plus148 separate native continuations. Controlled escrow, original-card/prior
loss and allowed/canceled Charity/revival cases disclose staging. The first
integrated run exposed the Ix opening boundary; its narrow repair passes before
the final frozen union. [Source, exact proof and limits](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-early-basic-funding--24-october-2026).
CHOAM's unresolved own opening, wider preparations, later rounds, modules/
Advanced, full modes, comprehensive assurance and deployed acceptance remain
unfinished. No strategy tuning or saved-room/D1/reset/restart/deployment operation.
**Earlier classic Basic movement promises — 24 October. Prototyped / Partial.**
Clean current-round phases0–4 now include owned Charity, ordinary auction
payment and Revival before later ground movement. Reopen readiness, reserve
+28
View File
@@ -1,5 +1,33 @@
# Dune implementation status
## 24 October 2026 — Native early Basic movement promises
Extend earlier questions to declared native families using actual Richese
own/donor funding and current paid-sale boundaries. Keep a submitted Silent
ally's whole pledge locked until reveal; exact private splits do not leak through
the forecast. Native current controls pass or submit zero even while a foreign
invoice temporarily prevents a witness. Positive own bids/gifts preserve the
route; actual opposing settlement may release it. Quote only non-CHOAM fixed
preopening Charity, with no fictional payer receipt or future native income.
Owned ordinary revival uses shared force accounting while native counters stay
pending. The mandatory Ix phase5 opening retains the owned future turn/reset.
Disjoint source/control lanes, bounded funding review and owner integration pass
29 files244/244, types/lint/build and six gameplay/control/table-rendering
programmes. Original E2/E1/E3 Charity prefixes25/36/25 retain actual native setup
and cards, including Ixian Shield selection. Emperor retains10 spice/20 reserves;
actual first/last Once Around/Silent cache purchases2, shipment8 and ground8
fulfill Yes. Eight profile runs make56 accepted proposals plus148 separate native
continuations. Controlled escrow, original-card acquisition/prior losses and
allowed/canceled Charity/revival cases disclose staging. Source feedback repaired
Silent actor selection and a nonexistent setup field; engine feedback repaired
owned ordinary revival projection and the mandatory Ix phase-opening gap. The
final frozen batch passes after the initially failing opening regression.
[Authority, exact source/proof and unfinished scope](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-early-basic-funding--24-october-2026).
Unresolved CHOAM own opening, wider preparations, later rounds, optional/Advanced
and complete modes remain unfinished. No full AI refinement, comprehensive
assurance or saved-room/D1/reset/restart/deployment operation.
## 24 October 2026 — Earlier classic Basic movement promises
Extend current-round questions to clean classic Basic phases0–4. Owned Charity,
+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. Native early auction liabilities stay unfinished. 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. Earlier native phases/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 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) |
| 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
+79 -7
View File
@@ -1,6 +1,6 @@
# Truthtrance ground-movement promises
The [earlier classic Basic follow-up](#earlier-classic-basic-phases--24-october-2026)
The [native early Basic follow-up](#native-early-basic-funding--24-october-2026)
supersedes preceding timing boundaries. Previous checkpoint counts remain dated
evidence, not complete rules or final acceptance.
@@ -21,14 +21,14 @@ movement statement, the owned-action witness and accepted-order release policy
below are explicit software compositions, not a publisher movement-specific FAQ
or Basic adoption of the unofficial Advanced revision.
Classic Basic tables can also ask in clean earlier phases0–4 of the current
round, with the owned preparations below. Fresh or existing Basic native tables
can ask at a clean **active or still-unused
Basic tables with the declared native families can also ask in clean earlier
phases0–4 of the current round, with the owned preparations below. During
Movement, ask at a clean **active or still-unused
waiting turn in the current Shipment and Movement queue**, before or after the
active respondent's shipment, including selected E1/E2/E3 factions. No optional modules or Advanced
inherit this entry. The actual native roster, physical inventory and route
validation remain authoritative; no mode or publication gate opens. Earlier
phases with native factions, later-round movement questions, compound movement/fact questions and wider
validation remain authoritative; no mode or publication gate opens.
Later-round movement questions, compound movement/fact questions and wider
owned preparations/interactions remain unfinished. Existing saves are not
reconfigured and absent promise fields remain absent until a real answer binds.
@@ -62,9 +62,81 @@ reconfigured and absent promise fields remain absent until a real answer binds.
This is bounded reachability, not complete arbitrary Truthtrance enforcement.
Other owned preparation families (including native relocation preparation),
earlier native phases/later rounds, optional/Advanced profiles and comprehensive
later rounds, optional/Advanced profiles and comprehensive
combination/recovery/privacy/custody acceptance remain due.
## Native early Basic funding — 24 October 2026
Clean earlier Basic entry now includes declared E1/E2/E3 native factions without
optional modules. The existing [native auction contract](RICHESE_AUCTION_RULES.md)
and `richeseOwnCommitment` / `richeseAllyCommitment` price real pending invoices.
Subtract the current leading Once Around offer or an owned submitted Silent
split; losing and unsubmitted offers spend nothing. Native cache invoices have
no ordinary Karama payment alternative. A current paid sale is not charged
again. Clear only the detached auction/funding controls before the phase5
forecast, so reserved own spice is not deducted twice. No unknown purchased
card or future seller/Emperor income supplies a route.
During unfinished Silent bidding, a submitted ally locks the **entire outgoing
pledge**, using public submission status rather than exposing its private split
through feasibility. Only the actual revealed winner determines the final
donated cost/refund. Foreign auction/income controls finish before release.
Positive owner bids and card gifts cannot voluntarily erase the route. Current
guidance passes a live Once Around bid or submits Silent zero with its original
event; it does so even while foreign escrow temporarily prevents a witness.
A zero/pass is not a voluntary promise-breaking spend. Original cache choices,
native setup and post-auction decisions retain their own controls.
The mandatory Ix-enabled phase5 opening is also a forecast boundary: retain the
owned future turn with its normal reset until real opening readiness initializes
Movement. It cannot veto the owner's preceding Ready or expose movement early.
Before CHOAM's opening response, another faction may quote its fixed ordinary
Charity entitlement using current Inflation. This detached credit creates no
CHOAM income, payment or fictional receipt; the actual phase2 claim still waits
for the real allowed/canceled payer receipt and affordability. CHOAM's own
unresolved opening income/claim is not precredited and remains a wider forecast
boundary. A declared owned ordinary force revival projects only its quoted
allowed physical return/cost through the same accounting as real revival.
CHOAM/Tleilaxu decisions, benefit checks and income remain real unresolved
controls; no quote executes their choices or awards a future receipt. A real
denial can release impossibility, or leave an affordable paid route intact.
This continues the explicit provisional owned-outcome policy above, not a new
publisher Truthtrance clarification.
`tests/fixture-truth-movement-native-early.ts` supplies genuine original public
Basic Atreides/Emperor plus Richese/CHOAM, Ixians/Tleilaxu or Ecaz/Moritani.
Actual Ixian card selection/mobile placement and Ecaz/Moritani placement retain
their original counters; Atreides genuinely receives Truth and Emperor Baliset,
ten spice and twenty reserves. Actual questions, native first/last Once Around
or Silent invoices, shipment8 and ground8 are caller actions. Controlled prior
losses, original-card transfers, alliance escrow and preopening Charity cases
are separately disclosed.
The final **29-file union passes244/244**, types/lint and production build.
Six actual gameplay/guidance-handler/GameTable programmes cover first/last
Once Around/Silent cache purchase2, shipment8 and ground8, plus original E1/E3
early continuations. E2/E1/E3 source prefixes have25/36/25 actual actions. Eight
existing-profile runs make56 accepted guided proposals plus148 separate native
continuations, without tuning. Actual native zero/pass handlers and busy
protection also pass their two original-source control tests. Controlled tests
retain a real pending free revival before CHOAM's decision, then distinguish an
unaffordable denial from a retained paid route. Source corrections use actual
Silent submission status and Ix/Ecaz setup fields. Initial integrated feedback
exposed the Ix Movement opening; the narrow repair and final frozen union pass.
Frozen base `f995b6f4cd9008954893f2d646ecb861986a43de`, tree
`5c366c0cd7200c61e4461ac148a3a15a98f53ef1dec5fd661d425cced6c1f675`,
2653 files. Private source-bound logs, actual-state HTML and receipts:
`/home/quintine/.local/share/dune/checkpoints/20261024-movement-native-early/`,
`frozen-integrated-2/report.json`. Only final checkpoint/reference prose overlays
follow proof. Framework aliases/CSS stubbing establish server rendering, not
browser/visual/deployed acceptance. No saved-room/D1/reset/restart/deployment.
Wider owned preparations, CHOAM's unresolved own opening, later rounds,
optional/Advanced and mixed questions, full modes, comprehensive assurance and
browser/deployed acceptance remain unfinished. Native shipment-promise entry
is unchanged; this does not certify all promise families or faction rules.
## Earlier classic Basic phases — 24 October 2026
The current-round question now includes classic Basic Storm, Spice Blow,
+34
View File
@@ -0,0 +1,34 @@
import type { RicheseAuction } from './richese-auction';
import { richeseOwnCommitment, richeseAllyCommitment, type RicheseFunding } from './richese-funding';
export type EarlyAuctionFunding = {
phase: number;
owner: string;
ally: string | null;
paid: boolean;
normal: { bidder: string | null; bid: number; allyPayment?: number } | null;
native?: RicheseAuction | null;
funding?: RicheseFunding;
pledge: number;
};
/** Private owned-stage funding, not settlement or a prediction of who wins.
* Silent donor locks use submission status, never the ally's private split.
* A current sale already paid its invoice and consumed any donated escrow. */
export function earlyAuctionFunding(input: EarlyAuctionFunding) {
const { owner, ally, pledge } = input;
const normal = input.phase === 3 && !input.paid ? input.normal : null;
const native = input.phase === 3 && !input.paid ? input.native : null;
const funding = input.funding ?? {};
const normalCash = normal?.bidder === owner ? normal.bid - (normal.allyPayment ?? 0) : 0;
const nativeCash = richeseOwnCommitment(native, funding, owner);
const normalDonor = ally && normal?.bidder === ally ? normal.allyPayment ?? 0 : 0;
const sealedLock = native?.method === 'silent' && !native.outcome && ally &&
Object.hasOwn(native.sealed, ally);
const nativeDonor = sealedLock ? pledge : richeseAllyCommitment(native, funding, owner);
const refund = pledge - normalDonor - nativeDonor;
for (const amount of [normalCash, nativeCash, refund])
if (!Number.isSafeInteger(amount) || amount < 0)
throw new RangeError('The current auction funding exceeds its owned cash or pledged escrow.');
return { normalCash, nativeCash, refund };
}
+83 -29
View File
@@ -1,3 +1,4 @@
import { earlyAuctionFunding } from './early-auction-funding';
import { recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge, recordMovementClaimKnowledge, clearMovementDiscardKnowledge, type MovementDiscardKnowledge } from './movement-discard-knowledge';
import { movementShipmentPreparations } from './movement-shipment-preparation';
import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseModeSupported, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises';
@@ -16434,6 +16435,10 @@ function recoverableUnsupportedShipment(shipment: PendingShipment) {
function groundMovementProbe(source: Game, actor: string): Game {
const game = structuredClone(source);
projectPendingShipmentKarama(game, actor);
if (game.phase < 5) {
if (game.pendingRevival?.player === actor) projectOwnedOrdinaryRevival(game, actor);
else game.pendingRevival = null;
}
if (game.active !== actor) {
// Forecast only this owner's unused turn on the current board. Do not
// complete a foreign declaration, price, flight or private search, and do
@@ -16506,6 +16511,10 @@ function groundMovementCompletion(source: Game, actor: string,
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn)): MovementCompletion | null {
if (!movementPromiseModeSupported(source) || source.phase < 0 || source.phase > 5) return null;
const game = groundMovementProbe(source, actor);
if (source.phase === 5 && source.phaseOpening?.initialize) {
resetGroundMovementForecast(game, actor);
return groundMovementCompletionAtMovement(game, actor, promises);
}
if (source.phase < 5) {
const paid = source.pendingNullentropy;
if (paid?.player === actor) {
@@ -16519,10 +16528,7 @@ function groundMovementCompletion(source: Game, actor: string,
return null;
}
for (const seed of earlyShipmentCompletionSeeds(game, source, actor)) {
resetPlayerMovement(getPlayer(seed.game, actor));
seed.game.hajr = seed.game.hajr.filter(player => player !== actor);
seed.game.ornithopter = null;
seed.game.movementRemaining = [actor];
resetGroundMovementForecast(seed.game, actor);
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises);
if (completion) return { actions: [...seed.actions, ...completion.actions], early: seed.early };
}
@@ -16530,6 +16536,12 @@ function groundMovementCompletion(source: Game, actor: string,
}
return groundMovementCompletionAtMovement(game, actor, promises);
}
function resetGroundMovementForecast(game: Game, actor: string) {
resetPlayerMovement(getPlayer(game, actor));
game.hajr = game.hajr.filter(player => player !== actor);
game.ornithopter = null;
game.movementRemaining = [actor];
}
function groundMovementCompletionAtMovement(game: Game, actor: string, promises: readonly MovementPromise[]): MovementCompletion | null {
if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null;
return findMovementCompletion(game, actor, promises, {
@@ -16617,12 +16629,21 @@ function earlyGroundMovementPolicy(g: Game, player: Player): Action[] | null {
if (g.status !== 'playing' || !movementPromiseModeSupported(g) || !live.some(promise => promise.answer) ||
g.truthtrance || g.response || g.phaseOpening || g.pendingTreacheryDiscard ||
(g.decision && !(g.decision.kind === 'auctionPayment' && g.decision.player === player.id))) return null;
// Passing cannot spend the reserved route. In particular, a foreign Silent
// invoice may still refund escrow: do not replace a legal zero with a bid
// merely because that unresolved reservation prevents a current witness.
if (g.phase === 3 && !g.decision) {
const lot = g.richeseAuction;
if (lot) return !lot.outcome && lot.eligible.includes(player.id) &&
(lot.method === 'silent' ? !Object.hasOwn(lot.sealed, player.id) : lot.active === player.id)
? [{ type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null, allyPayment: 0 }] : [];
return g.auction?.active === player.id ? [{ type: 'passBid' }] : [];
}
const completion = groundMovementCompletion(g, player.id);
if (!completion) return null;
const current = completion.early?.find(step => step.phase === g.phase)?.action;
if (g.decision?.kind === 'auctionPayment')
return [current?.type === 'decision' ? current : { type: 'decision', karama: false }];
if (g.phase === 3) return g.auction?.active === player.id ? [{ type: 'passBid' }] : [];
if (g.phase === 2 || g.phase === 4) return current ? [current] : g.ready.includes(player.id) ? [] : [{ type: 'ready' }];
return null;
}
@@ -16673,11 +16694,14 @@ function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Actio
g.pendingChoamMove?.player === p.id) continue;
const early = g.phase < 5 || sourcePhase < 5;
const voluntary = actor === p.id && (!early || !!action &&
(['card', 'bribe', 'pledgeAid', 'emperorGift', 'bid', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) ||
(['card', 'bribe', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) ||
action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 ||
action.type === 'ready' && [2, 4].includes(sourcePhase)));
const busy = !!(g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
g.pendingAmbassador || g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
g.phase === 3 && !g.currentAuctionSale && g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0);
g.phase === 3 && !g.currentAuctionSale &&
(g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 ||
g.richeseAuction && !g.richeseAuction.outcome));
if (busy && !voluntary) continue;
if (groundMovementCompletion(g, p.id, live)) continue;
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted ground-movement promise.');
@@ -23341,7 +23365,14 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string
if (p.charityTurn === g.turn || charityQuote(g, p).total <= 0) continue;
try {
const action: Action = { type: 'charity' };
const next = applyActionInner(g, owner, action, 'shipmentPreparation');
// Before CHOAM's opening response, another faction's fixed ordinary
// entitlement is unchanged by its outcome. Quote only that entitlement:
// no CHOAM income, payment or invented receipt enters this forecast.
let next: Game;
if (source.phase < 2 && byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn) {
if (p.faction === 'choam') continue;
p.spice += charityQuote(g, p).total; p.charityTurn = g.turn; next = g;
} else next = applyActionInner(g, owner, action, 'shipmentPreparation');
charitySeeds.push({ game: next, actions: [action], early: [{ phase: 2, action }] });
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
@@ -23352,15 +23383,17 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string
for (const seed of seeds) {
const p = getPlayer(seed.game, owner), a = source.phase === 3 && !source.currentAuctionSale ? source.auction : null;
const ownBid = a?.bidder === owner && a.bid > 0;
const cash = ownBid ? a.bid - (a.allyPayment ?? 0) : 0;
const committed = source.phase === 3 && source.currentAuctionSale ? 0 : committedOutgoingAid(source, getPlayer(source, owner));
const refund = (source.aid[owner]?.amount ?? 0) - committed;
const funding = earlyAuctionFunding({ phase: source.phase, owner, ally: p.ally,
paid: !!source.currentAuctionSale, normal: source.auction,
native: source.richeseAuction, funding: source.richeseFunding,
pledge: source.aid[owner]?.amount ?? 0 });
const cash = funding.normalCash;
const alternatives: (string | null)[] = [null, ...(ownBid ? p.hand
.filter(card => canUseAsKaramaRole(seed.game, p, card)).map(card => card.id) : [])];
for (const card of alternatives) {
const g = structuredClone(seed.game), beneficiary = getPlayer(g, owner);
if (!card && beneficiary.spice < cash) continue;
beneficiary.spice += refund - (card ? 0 : cash);
if (beneficiary.spice < funding.nativeCash + (card ? 0 : cash)) continue;
beneficiary.spice += funding.refund - (card ? 0 : cash) - funding.nativeCash;
if (!Number.isSafeInteger(beneficiary.spice) || beneficiary.spice < 0) continue;
const actions = [...seed.actions], early = [...seed.early];
if (card) {
@@ -23372,6 +23405,7 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string
// Native Bidding refunds its unused pledges. An ally's incoming
// Bidding escrow is never a promised future Shipment contribution.
g.aid = {}; g.auction = null; g.currentAuctionSale = null;
g.richeseAuction = null; g.richeseFunding = {}; g.richeseBidding = null;
fundingSeeds.push({ game: g, actions, early });
}
}
@@ -23391,6 +23425,7 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string
try {
const action: Action = { type: 'revive', amount };
const next = applyActionInner(g, owner, action, 'shipmentPreparation');
projectOwnedOrdinaryRevival(next, owner);
revivalSeeds.push({ game: next, actions: [...seed.actions, action], early: [...seed.early, { phase: 4, action }] });
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
@@ -23701,9 +23736,10 @@ function reconcileShipmentPromises(
const future = g.phase < 5 || g.active !== p.id;
const earlyVoluntary = (voluntary?.phase ?? g.phase) < 5 && voluntary &&
(['bid', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival'].includes(voluntary.action.type) ||
voluntary.action.type === 'richeseBid' && typeof voluntary.action.amount === 'number' && voluntary.action.amount > 0 ||
voluntary.action.type === 'ready' && [2, 4].includes(voluntary.phase ?? g.phase));
const ownVoluntary = voluntary?.actor === p.id &&
(['card', 'bribe', 'pledgeAid', 'emperorGift'].includes(voluntary.action.type) ||
(['card', 'bribe', 'pledgeAid', 'emperorGift', 'richeseGift'].includes(voluntary.action.type) ||
earlyVoluntary ||
future && voluntary.action.type === 'decision');
// A foreign pending parent may spend or refund its approved funding. Wait
@@ -23711,7 +23747,9 @@ function reconcileShipmentPromises(
if (future && !ownVoluntary && (g.truthtrance || g.response || g.decision ||
g.phaseOpening || g.pendingShipment || g.pendingGuildTransport ||
g.pendingHomeworldShipment || g.pendingTreacheryDiscard || g.ornithopter ||
g.phase === 3 && !g.currentAuctionSale && g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0)) {
g.phase === 3 && !g.currentAuctionSale &&
(g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 ||
g.richeseAuction && !g.richeseAuction.outcome))) {
kept.push(promise);
continue;
}
@@ -26490,6 +26528,35 @@ function currentRevivalResumeQuote(g: Game, stage: 'stop' | 'finish') {
throw error;
}
}
/** Shared physical accounting after the authoritative revival quote. */
function commitRevivalForcePieces(g: Game, p: Player, revival: PendingRevival) {
const n = revival.amount!, elite = revival.elite ?? 0;
addNativeReserves(g, p, n, elite);
p.tanks -= n;
if (revival.emperorExtra)
g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n;
else if (!revival.nexusEmperorEvent) {
// Keep unknown historical free usage unknown in both real and quoted paths.
if (p.freeForcesRevived !== undefined || p.revived === 0)
p.freeForcesRevived = (p.freeForcesRevived ?? 0) + revival.free;
p.revived += n;
}
if (p.elites) { p.elites.tanks -= elite; p.elites.revived += elite; }
}
/** Private allowed-outcome projection for the owner's declared ordinary force
* request. Native CHOAM/Tleilaxu controls remain real unresolved decisions.
* Do not execute their choices, income, technology or continuation effects. */
function projectOwnedOrdinaryRevival(g: Game, owner: string) {
const pending = g.pendingRevival;
if (!pending || pending.player !== owner || pending.kind !== 'forces' ||
pending.emperorExtra || pending.nexusEmperorEvent || pending.payer && pending.payer !== owner || g.homeworlds) return;
const quote = currentRevivalResumeQuote({ ...g, pendingRevival: { ...pending, checks: [] } }, 'finish');
if (quote.outcome !== 'revive') return;
const p = getPlayer(g, owner);
p.spice -= quote.pending.cost;
commitRevivalForcePieces(g, p, quote.pending);
g.pendingRevival = null; g.decision = null; g.response = null;
}
function finishRevival(
g: Game,
quote: RevivalResumeQuote = currentRevivalResumeQuote(g, 'finish'),
@@ -26538,20 +26605,7 @@ function finishRevival(
const source = revival.nexusEmperorEvent ? 'nexusEmperor' :
revival.emperorExtra ? 'emperorExtra' : 'normal';
const group = { amount: n, elite, free: revival.free };
addNativeReserves(g, p, n, elite);
p.tanks -= n;
if (revival.emperorExtra)
g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n;
else if (!revival.nexusEmperorEvent) {
// Preserve unknown legacy usage instead of guessing how earlier paid returns were priced.
if (p.freeForcesRevived !== undefined || p.revived === 0)
p.freeForcesRevived = (p.freeForcesRevived ?? 0) + revival.free;
p.revived += n;
}
if (p.elites) {
p.elites.tanks -= elite;
p.elites.revived += elite;
}
commitRevivalForcePieces(g, p, revival);
finishNexusEmperorRevival(g, revival, 'returned');
if (quote.techIncome) {
const income = quote.techIncome;
-4
View File
@@ -85,10 +85,6 @@ export function finishMovementPromises<T extends MovementPromise>(
* in the engine. Truthtrance and an ordinary active Ornithopter may remain. */
export function movementPromiseModeSupported(g: Game): boolean {
return g.advanced === false &&
// Early owned-stage forecasts currently price ordinary classic invoices.
// Native phase5 coverage does not establish Richese/CHOAM bidding prices.
(g.phase >= 5 || (!g.expansions.length && g.players.every(player =>
FACTIONS.find(entry => entry.id === player.faction)?.expansion === 'base'))) &&
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 &&
+7 -7
View File
@@ -2930,12 +2930,12 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'Bind one future qualifying physical ground movement before a Basic respondent finishes their current-round turn.',
steps: [
'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Classic Basic supports clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Selected native families currently begin in phase5; native early invoices, optional modules, Advanced and later rounds remain unfinished.',
'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. Optional modules, Advanced, 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.',
'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.',
'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.',
@@ -2943,11 +2943,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-shipment', 'movement', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Classic Basic phases0–4 forecast owned Charity, ordinary auction payment and Revival, reopen readiness, preserve joint shipment predicates and reset old allowances. Current cash/held Karama and a distinct later shipment-rate card retain costs. Forced Storm losses release impossibility; foreign invoices finish before release. Native phase5 waiting/card support remains. Native early invoices, later rounds, remaining preparations and modules remain unfinished.', evidence: ['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: 'Dedicated public questions/private answers and persistent busy-safe guidance now include only current Charity, Revival, Ready, bid-pass or owned auction payment before Movement. No future shipment/move control appears early; combined guidance avoids conflicting shipment preparation. Existing waiting/paid-search 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'] },
{ area: 'AI', status: 'Partial', detail: 'Four existing profiles make24 accepted early-preparation/later-movement proposals plus76 separate native continuations. Combined movement guidance precedes independent shipment suggestions; all-No leaves native auction payment intact. 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'] },
{ 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: '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: 'Current21-file155/155, types/lint/build and four gameplay/control/table-rendering programmes pass. Original Storm8/Charity22 sources retain Emperor10 spice/20 reserves/zero board, then perform purchase2/shipment8/ground8. Controlled prior losses, conserved cards, ally escrow and stale allowance cases disclose staging. Prior125/125,165/165,231/231,218/218 remain historical. Native early invoices, complete modes, 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', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.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'] },
],
},
{
@@ -3044,7 +3044,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The questioned player answers publicly. A definite answer discards the card. If the answer cannot be known, the holder may ask a different question or save Truthtrance for later.',
'A saved definite answer resumes automatically: its public answer, binding promise and consumed card are recorded once, then the next queued question or interrupted action resumes. Private answer and preparation guidance is unavailable during this automatic recovery.',
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier classic Basic phases with owned Charity/ordinary invoice/Revival preparation, and active or unused waiting native Basic phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
+1 -1
View File
@@ -197,7 +197,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 with pending decisions finished. Earlier phases currently use the classic roster; optional modules and Advanced remain unfinished.');
'Ground-movement promises need a supported Basic opportunity this round with pending decisions finished. Optional modules and Advanced 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; }
}
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { earlyAuctionFunding, type EarlyAuctionFunding } from '../game/early-auction-funding';
import { createRicheseAuction, submitRicheseBid } from '../game/richese-auction';
const input = (): EarlyAuctionFunding => ({ phase: 3, owner: 'buyer', ally: 'ally',
paid: false, normal: null, pledge: 8, funding: {} });
function lot(method: 'onceAround' | 'silent') {
return createRicheseAuction({ event: 'native-lot', cardId: 'held-cache-card',
source: 'cache', owner: 'richese', method, eligible: ['buyer', 'ally', 'richese'],
order: ['buyer', 'ally', 'richese'], tieOrder: ['buyer', 'ally', 'richese'] });
}
void test('an unpaid ordinary invoice keeps its own cash and donated escrow distinct', () => {
assert.deepEqual(earlyAuctionFunding({ ...input(), normal: { bidder: 'buyer', bid: 7, allyPayment: 2 } }),
{ normalCash: 5, nativeCash: 0, refund: 8 });
assert.deepEqual(earlyAuctionFunding({ ...input(), normal: { bidder: 'ally', bid: 7, allyPayment: 3 } }),
{ normalCash: 0, nativeCash: 0, refund: 5 });
});
void test('native Once Around owns only the leading exact split and releases an outbid offer', () => {
const funding = { buyer: { amount: 7, allyPayment: 2, donor: 'ally' },
ally: { amount: 8, allyPayment: 3, donor: 'buyer' } };
let native = submitRicheseBid(lot('onceAround'), { event: 'native-lot', actor: 'buyer', amount: 7 }, 20);
assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 5, refund: 8 });
native = submitRicheseBid(native, { event: 'native-lot', actor: 'ally', amount: 8 }, 20);
assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 0, refund: 5 });
});
void test('own Silent funding is exact while a submitted ally locks the whole pledge regardless of secret split', () => {
let native = submitRicheseBid(lot('silent'), { event: 'native-lot', actor: 'buyer', amount: 7 }, 20);
const funding = { buyer: { amount: 7, allyPayment: 2, donor: 'ally' },
ally: { amount: 8, allyPayment: 1, donor: 'buyer' } };
assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 5, refund: 8 });
native = submitRicheseBid(native, { event: 'native-lot', actor: 'ally', amount: 8 }, 20);
for (const allyPayment of [0, 1, 5, 8]) {
const quote = earlyAuctionFunding({ ...input(), native, funding: { ...funding,
ally: { ...funding.ally, allyPayment } } });
assert.deepEqual(quote, { normalCash: 0, nativeCash: 5, refund: 0 });
}
});
void test('a revealed native winner reserves its actual donor leg and a losing offer spends nothing', () => {
let native = lot('silent');
for (const [actor, amount] of [['buyer', 7], ['ally', 8], ['richese', 0]] as const)
native = submitRicheseBid(native, { event: native.event, actor, amount }, 20);
const funding = { buyer: { amount: 7, allyPayment: 2, donor: 'ally' },
ally: { amount: 8, allyPayment: 3, donor: 'buyer' } };
assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 0, refund: 5 });
});
void test('an already-paid sale and a later phase do not charge an old invoice twice', () => {
const native = submitRicheseBid(lot('onceAround'), { event: 'native-lot', actor: 'buyer', amount: 7 }, 20);
const state = { ...input(), native, funding: { buyer: { amount: 7, allyPayment: 0, donor: null } },
normal: { bidder: 'buyer', bid: 4, allyPayment: 0 } };
assert.deepEqual(earlyAuctionFunding({ ...state, paid: true }), { normalCash: 0, nativeCash: 0, refund: 8 });
assert.deepEqual(earlyAuctionFunding({ ...state, phase: 2 }), { normalCash: 0, nativeCash: 0, refund: 8 });
});
void test('a malformed donated invoice cannot create a negative refund', () => {
assert.throws(() => earlyAuctionFunding({ ...input(), pledge: 2,
normal: { bidder: 'ally', bid: 4, allyPayment: 4 } }), RangeError);
});
@@ -0,0 +1,187 @@
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 TruthMovementNativeEarlyStep = TruthMovementStep;
export type TruthMovementNativeEarlyRoster = 'richese-choam' | 'ecaz-moritani' | 'ixians-tleilaxu';
export type TruthMovementNativeEarlyAuction = {
position?: 'first' | 'last';
method?: 'onceAround' | 'silent';
direction?: 'clockwise' | 'counterclockwise';
card?: string;
keepUnbid?: boolean;
};
export const actTruthMovementNativeEarly = actTruthMovement;
export const truthMovementNativeEarlyClean = truthMovementClean;
export const truthMovementNativeEarlyPlayer = truthMovementPlayer;
export const truthMovementNativeEarlyInventory = truthMovementInventory;
/** Consume live native controls only. Callers own Truthtrance, paid bids and
* Emperor's shipment/movement; cache defaults are actual zero/pass actions. */
export function nextTruthMovementNativeEarlyStep(game: Game,
auction: TruthMovementNativeEarlyAuction = {}): TruthMovementNativeEarlyStep | null {
if (game.truthtrance) return null;
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision) {
const 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: auction.position ?? 'first' } };
if (decision.kind === 'richeseCacheTerms') return { actor, action: {
type: 'decision', event, method: auction.method ?? 'onceAround',
direction: auction.direction ?? 'clockwise',
} };
if (decision.kind === 'richeseCache') {
const card = game.richeseCache?.find(card => auction.card ? card.id === auction.card : true);
assert.ok(card, 'Choose an original card still held in the native Richese cache');
return { actor, action: { type: 'decision', event, card: card.id,
...(actor === game.richeseBidding?.owner ? {
method: auction.method ?? 'onceAround', direction: auction.direction ?? 'clockwise',
} : {}),
} };
}
if (decision.kind === 'richeseUnbid')
return { actor, action: { type: 'decision', event, keep: auction.keepUnbid ?? false } };
if (decision.kind === 'ecazPlacement' || decision.kind === 'choamMovement')
return { actor, action: { type: 'decision', decline: true } };
if (decision.kind === 'moritaniSetup')
return { actor, action: { type: 'decision', territory: 'oh_gap', sector: 9 } };
if (decision.kind === 'ixSetup') {
const card = game.ixSetupCards?.find(card => card.kind === 'shield');
assert.ok(card, 'Ixians must actually choose the original Shield from their starting-card inspection');
return { actor, action: { type: 'decision', card: card.id } };
}
}
const ecaz = game.players.find(player => player.faction === 'ecaz');
if (!decision && game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20)
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
}
if (truthMovementNativeEarlyClean(game)) {
if (game.richeseAuction && !game.richeseAuction.outcome) {
const lot = game.richeseAuction;
const actor = lot.method === 'silent'
? lot.eligible.find(id => !Object.hasOwn(lot.sealed, id)) : lot.active;
assert.ok(actor, 'An unfinished native cache lot needs its next eligible bidder');
return { actor, action: { type: 'richeseBid',
event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
}
if (game.status === 'playing' && game.phase === 5 && game.active === 'emperor') return null;
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceTruthMovementNativeEarly(game: Game, until: (state: Game) => boolean,
actions: TruthMovementNativeEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original native source ended before its requested control');
const step = nextTruthMovementNativeEarlyStep(game, auction);
assert.ok(step, 'Supply the real Truthtrance, bid or Emperor movement choice before continuing');
game = actTruthMovementNativeEarly(game, step.actor, step.action, actions);
}
throw Error('The original native early movement source did not reach its finite boundary');
}
/** Fresh public Basic expansion setup, stopped at clean Charity before any
* question or Bidding. Select only original unopened deal/Spice positions and
* scalar lotteries; original setup controls supply every physical counter. */
export function createTruthMovementNativeEarlyFixture(options: {
roster?: TruthMovementNativeEarlyRoster;
} = {}) {
const roster = options.roster ?? 'richese-choam', asker = 'atreides', target = 'emperor';
const native = roster === 'richese-choam' ? ['richese', 'choam'] as const
: roster === 'ecaz-moritani' ? ['ecaz', 'moritani'] as const : ['ixians', 'tleilaxu'] as const;
const expansions = roster === 'richese-choam' ? ['choam'] : roster === 'ecaz-moritani' ? ['ecaz'] : ['ix'];
let game = createGame(`TRUTHMOVEMENTEARLY${roster.toUpperCase()}`,
newPlayer(asker, 'Atreides', 'atreides'), false, expansions);
for (const faction of [target, ...native] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: TruthMovementNativeEarlyStep[] = [];
const act = (actor: string, action: Action) => {
game = actTruthMovementNativeEarly(game, actor, action, actions);
};
const reach = (until: (state: Game) => boolean) => {
game = advanceTruthMovementNativeEarly(game, until, 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.deepEqual(game.expansions, expansions);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0), 'Choose only an unopened original deal');
const originalMainDeck = game.deck.map(card => card.id), originalCache = (game.richeseCache ?? []).map(card => card.id);
const originalCards = truthMovementNativeEarlyInventory(game);
if (roster === 'richese-choam') {
assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10);
}
const truth = game.deck.find(card => card.effect === 'truthtrance');
const baliset = game.deck.find(card => card.name === 'Baliset');
const shield = game.deck.find(card => card.kind === 'shield');
const snooper = game.deck.find(card => card.kind === 'snooper');
assert.ok(truth && baliset && shield && snooper);
const deal = [truth.id, baliset.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 beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
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 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) }];
reach(state => state.turn === 1 && state.phase === 2 && truthMovementNativeEarlyClean(state));
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
assert.deepEqual(game.ready, []); assert.equal(game.auction ?? null, null);
assert.equal(game.richeseBidding ?? null, null); assert.equal(game.richeseAuction ?? null, null);
assert.equal(game.truthtrance ?? null, null); assert.equal(game.movementPromises, undefined);
const emperor = truthMovementNativeEarlyPlayer(game, target);
assert.equal(emperor.spice, 10); assert.equal(emperor.reserves, 20); assert.equal(emperor.tanks, 0);
assert.deepEqual(emperor.forces, {}); assert.equal(emperor.shipped, false); assert.equal(emperor.moved, 0);
assert.ok(truthMovementNativeEarlyPlayer(game, asker).hand.some(card => card.id === truth.id));
assert.deepEqual(truthMovementNativeEarlyInventory(game), originalCards);
assert.deepEqual([...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(), originalSpice);
const claim = { kind: 'movement' as const, target, territory: 'carthag', minimum: 8 };
return {
game, source: structuredClone(game), roster, expansions, asker, target, native,
richese: roster === 'richese-choam' ? 'richese' : null,
choam: roster === 'richese-choam' ? 'choam' : null,
ecaz: roster === 'ecaz-moritani' ? 'ecaz' : null,
moritani: roster === 'ecaz-moritani' ? 'moritani' : null,
ixians: roster === 'ixians-tleilaxu' ? 'ixians' : null,
tleilaxu: roster === 'ixians-tleilaxu' ? 'tleilaxu' : null,
lobby, lobbyActionCount, setup, setupActionCount, beforeStartingDeal, afterSetup, afterSetupActionCount,
beforeSpiceOrder, spiceOrder, deal, dealOrder, startingDealSelection: dealOrder,
originalMainDeck, originalCache, originalCards, originalTreachery: originalCards, originalSpice,
actions, sourceActionCount: actions.length,
sourceCounts: { lobby: lobbyActionCount, setup: setupActionCount, storm: afterSetupActionCount, charity: actions.length },
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
card: truth.id, truth: truth.id, claim, question: claim,
declarationStep: { actor: asker, action: { type: 'card', card: truth.id } } satisfies TruthMovementNativeEarlyStep,
askStep: { actor: asker, action: { type: 'truthAsk', question: claim } } satisfies TruthMovementNativeEarlyStep,
bid: { type: 'bid', amount: 2, allyPayment: 0 } satisfies Action,
payment: { type: 'decision', karama: false } satisfies Action,
purchaseCost: 2,
shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 8, elite: 0, allyPayment: 0 } satisfies Action,
shipmentCost: 8,
move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 8 } satisfies Action,
endMovement: { type: 'endMovement' } satisfies Action,
staging: [
`Original public bare Basic Atreides/Emperor/${native.join('/')} setup uses four original unopened deal positions, one ordinary unopened Spice position and native scalar lotteries. Actual setup deals Truthtrance to Atreides and Baliset to Emperor, and actual Storm/Spice controls reach clean turn-1 Charity in exactly ${actions.length} recorded actions. Emperor retains its printed ten spice, twenty reserves, zero board forces and unused movement. ${roster === 'richese-choam' ? 'The original 35-card main deck and ten-card cache are conserved; native first/last Once Around/Silent auctions remain future controls.' : roster === 'ecaz-moritani' ? 'Ecaz actually places six in Imperial Basin and Moritani actually places six in empty Oh Gap; neither spends Emperor resources.' : 'Ixians actually inspect the four original starting cards and choose the original Shield; the remaining Truthtrance/Baliset/Snooper are natively shuffled and dealt to Atreides/Emperor/Tleilaxu. The native mobile-stronghold placement retains its original six-counter garrison, and Tleilaxu receives its actual original Face Dancers.'} The constructor stops before any Truthtrance declaration, answer, auction declaration, bid or payment. Future purchase2, city shipment8, ground movement8 and ending movement remain caller actions; no dealt hand, wallet, force, clock, answer, promise or completed receipt is installed.`,
],
};
}
export type TruthMovementNativeEarlyFixture = ReturnType<typeof createTruthMovementNativeEarlyFixture>;
+2 -2
View File
@@ -130,12 +130,12 @@ void test('Basic native selected families qualify, while optional modules and un
}
});
void test('earlier current-round movement questions support classic Basic while native early invoices remain unfinished', () => {
void test('earlier current-round movement question profiles include declared Basic native families', () => {
for (const phase of [0, 1, 2, 3, 4]) {
const classic = movementBoundary(); classic.phase = phase; classic.movementRemaining = [];
assert.equal(movementPromiseTargetAvailable(classic, 'target'), true);
const native = structuredClone(classic); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
assert.equal(movementPromiseTargetAvailable(native, 'target'), false);
assert.equal(movementPromiseTargetAvailable(native, 'target'), true);
classic.advanced = true; assert.equal(movementPromiseTargetAvailable(classic, 'target'), false);
}
for (const phase of [-1, 6, 8]) {
@@ -0,0 +1,95 @@
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 { viewGame, type Action, type Game } from '../game/engine';
import {
createTruthMovementNativeEarlyFixture, actTruthMovementNativeEarly as act,
advanceTruthMovementNativeEarly as advance, truthMovementNativeEarlyClean as clean,
type TruthMovementNativeEarlyFixture,
} from './fixture-truth-movement-native-early';
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));
/** Real source controls bind the answer and decline the original discard
* reaction. No promise, native auction, submitted bid or policy is installed. */
function bind(f: TruthMovementNativeEarlyFixture): Game {
let game = act(f.game, f.asker, f.declarationStep.action);
while (game.truthtrance?.stage === 'priority') {
const player = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(player);
game = act(game, player.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' });
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
assert.ok(reaction?.stage === 'offer');
const player = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(player);
game = act(game, player.id, { type: 'semutaPass', event: pending.batch.event });
}
assert.ok(clean(game), 'Finish the original Truthtrance and Semuta controls before the native auction');
assert.equal(game.phase, 2);
return game;
}
function nativeControl(method: 'onceAround' | 'silent') {
const f = createTruthMovementNativeEarlyFixture();
const game = advance(bind(f), state => state.phase === 3 && clean(state) && !!state.richeseAuction &&
!state.richeseAuction.outcome && (method === 'silent'
? !Object.hasOwn(state.richeseAuction.sealed, f.target)
: state.richeseAuction.active === f.target), [], { method, card: 'richese-nullentropy-box' });
const lot = game.richeseAuction!;
assert.equal(lot.method, method); assert.equal(lot.source, 'cache');
const expected: Action = { type: 'richeseBid', event: lot.event,
amount: method === 'silent' ? 0 : null, allyPayment: 0 };
const view = viewGame(game, f.target);
assert.deepEqual(view.movementPromisePolicy, [expected]);
const sent: Action[] = [], props = { game: view, busy: false, act: (action: Action) => { sent.push(action); } };
const html = markup(props);
assert.match(html, /Your Movement turn is still ahead/);
assert.match(html, /movement controls appear on your turn/);
assert.match(html, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
assert.match(html, /a shipment can prepare that move but does not fulfill it/);
assert.doesNotMatch(html, />Ship \d|>Move \d|>Use Ornithopter and move|>Finish movement while honoring No/);
const button = controls(MovementPromises(props));
assert.equal(button.length, 1); assert.equal(button[0].disabled, false);
assert.equal(button[0].children, method === 'silent'
? 'Submit zero in this Silent auction and preserve later movement'
: 'Pass this Richese bid and preserve later movement');
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 Movement turn is still ahead/);
button[0].onClick(); assert.deepEqual(sent, [expected]);
assert.equal(sent[0], view.movementPromisePolicy![0], 'Forward the actual event-bound private policy action');
const next = act(game, f.target, sent[0]);
assert.equal(next.movementPromises![0].released, undefined);
assert.equal(next.movementPromises![0].fulfilled, undefined);
if (method === 'silent') assert.equal(next.richeseAuction!.sealed[f.target], 0);
else assert.ok(next.richeseAuction!.passed.includes(f.target));
const waiting = { ...props, game: viewGame(next, f.target) };
assert.deepEqual(waiting.game.movementPromisePolicy, []);
assert.equal(controls(MovementPromises(waiting)).length, 0);
const after = markup(waiting);
assert.match(after, /Your ground-movement promises/);
assert.match(after, /Your Movement turn is still ahead/);
assert.match(after, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
assert.doesNotMatch(after, /<button/);
}
void test('real Once Around movement guidance submits only the current native pass and retains its notice', () => {
nativeControl('onceAround');
});
void test('real Silent movement guidance submits zero with the original native event and retains its notice', () => {
nativeControl('silent');
});
@@ -0,0 +1,187 @@
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 { createTruthMovementNativeEarlyFixture as fixture, actTruthMovementNativeEarly as act,
advanceTruthMovementNativeEarly as advance, truthMovementNativeEarlyClean as clean,
truthMovementNativeEarlyPlayer as player, truthMovementNativeEarlyInventory as inventory,
type TruthMovementNativeEarlyFixture as Fixture } from './fixture-truth-movement-native-early';
const owner = (game: Game) => player(game, 'emperor');
function ask(f: Fixture, source = f.game, question: Record<string, unknown> = f.claim) {
let game = act(source, f.asker, f.declarationStep.action);
while (game.truthtrance?.stage === 'priority') {
const owner = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(owner);
game = act(game, owner.id, { type: 'truthPass' });
}
assert.ok(viewGame(game, f.asker).truthMovementTargets.includes(f.target));
return act(game, f.asker, { type: 'truthAsk', question });
}
function bound(f = fixture(), source = f.game, question: Record<string, unknown> = f.claim) {
const game = ask(f, source, question);
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
return advance(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
void test('fresh Basic E1, E2 and E3 Charity sources can bind later physical movement without granting an early move', () => {
for (const roster of ['richese-choam', 'ecaz-moritani', 'ixians-tleilaxu'] as const) {
const f = fixture({ roster }), game = bound(f);
assert.equal(game.phase, 2); assert.equal(owner(game).spice, 10);
assert.deepEqual(owner(game).forces, {}); assert.equal(owner(game).reserves, 20);
assert.deepEqual(inventory(game), f.originalCards);
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'ready' }]);
reject(game, f.target, f.shipment); reject(game, f.target, f.move);
}
});
void test('a question after a native standing or sealed bid prices its own split before offering Yes', () => {
for (const method of ['onceAround', 'silent'] as const) for (const minimum of [8, 9]) {
const f = fixture(), auction = { method };
let game = advance(f.game, g => g.phase === 3 && clean(g) && !!g.richeseAuction &&
(method === 'silent' ? !Object.hasOwn(g.richeseAuction.sealed, f.target) : g.richeseAuction.active === f.target), [], auction);
game = act(game, f.target, { type: 'richeseBid', event: game.richeseAuction!.event, amount: 2, allyPayment: 0 });
game = ask(f, game, { ...f.claim, minimum });
assert.equal(viewGame(game, f.target).truthMovementAnswers!.includes('yes'), minimum === 8);
assert.equal(owner(game).spice, 10, 'A quote does not pay its current invoice');
assert.equal(game.richeseFunding?.[f.target].amount, 2);
}
});
void test('controlled Silent ally funding keeps the full pledge locked and resolves only the actual winning donor cost', () => {
for (const loses of [false, true]) for (const allyPayment of [1, 3]) {
const f = fixture(), source = structuredClone(f.game), auction = { method: 'silent' as const };
owner(source).ally = 'choam'; player(source, 'choam').ally = f.target;
let game = advance(bound(f, source), g => g.phase === 3 && clean(g) && !!g.richeseAuction && !g.richeseAuction.outcome, [], auction);
game = act(game, f.target, { type: 'pledgeAid', amount: 8 });
const event = game.richeseAuction!.event;
game = act(game, 'choam', { type: 'richeseBid', event, amount: 3, allyPayment });
assert.equal(game.movementPromises![0].released, undefined);
reject(game, f.target, { type: 'pledgeAid', amount: 7 });
const zero = { type: 'richeseBid', event, amount: 0, allyPayment: 0 };
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [zero]);
game = act(game, f.target, zero);
assert.equal(game.movementPromises![0].released, undefined);
game = act(game, 'richese', { type: 'richeseBid', event, amount: loses ? 4 : 0, allyPayment: 0 });
game = advance(game, g => g.phase === 4 && clean(g), [], auction);
assert.equal(owner(game).spice, loses ? 14 : 10 - allyPayment);
assert.equal(game.movementPromises![0].released, !loses && allyPayment === 3 ? true : undefined);
}
});
void test('controlled preopening non-CHOAM Charity forecasts wait for the real allowed or canceled payer receipt', () => {
for (const canceled of [false, true]) {
const f = fixture(), source = structuredClone(f.afterSetup), p = owner(source);
// Explicit prior losses/wallet and conserved original Karama acquisition.
// Only the original unopened ordinary Spice card is selected; no CHOAM
// result, receipt or future income is installed by this controlled source.
p.spice = 0; p.reserves = 0; p.tanks = 3; p.forces = { 'carthag:11': 17 };
const index = source.spiceDeck.findIndex(card => JSON.stringify(card) === JSON.stringify(f.spiceOrder[0].card));
assert.ok(index >= 0); source.spiceDeck.unshift(source.spiceDeck.splice(index, 1)[0]);
const from = source.deck.findIndex(card => card.effect === 'karama'); assert.ok(from >= 0);
const karama = source.deck.splice(from, 1)[0]; player(source, 'richese').hand.push(karama);
let game = bound(f, source, { ...f.claim, minimum: 1 });
assert.equal(owner(game).spice, 0); assert.equal(game.choamCharity, undefined);
game = advance(game, g => g.response?.kind === 'choamCharity');
if (canceled) game = act(game, 'richese', { type: 'card', card: karama.id, mode: 'cancel' });
game = advance(game, g => g.phase === 2 && clean(g));
assert.equal(game.choamCharity?.canceled, canceled);
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'charity' }]);
const before = player(game, 'choam').spice;
game = act(game, f.target, { type: 'charity' });
assert.equal(owner(game).spice, 2); assert.equal(player(game, 'choam').spice, before - (canceled ? 0 : 2));
game = advance(game, g => g.phase === 4 && clean(g));
game = act(game, f.target, { type: 'revive', amount: 1 });
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
for (let count = 0; !game.movementPromises![0].fulfilled && count < 4; count++) {
const action = viewGame(game, f.target).movementPromisePolicy![0]; assert.ok(action);
game = advance(act(game, f.target, action), clean);
}
assert.equal(game.movementPromises![0].fulfilled, true);
assert.equal(game.movementPromises![0].released, undefined); assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('actual first/last Once Around and Silent cache invoices preserve only the affordable later eight-force route', () => {
for (const position of ['first', 'last'] as const) for (const method of ['onceAround', 'silent'] as const) {
const f = fixture(), auction = { position, method, card: 'richese-nullentropy-box' };
let game = advance(bound(f), g => g.phase === 3 && clean(g) && !!g.richeseAuction &&
!g.richeseAuction.outcome && (method === 'silent' ? !Object.hasOwn(g.richeseAuction.sealed, f.target)
: g.richeseAuction.active === f.target), [], auction);
const event = game.richeseAuction!.event;
reject(game, f.target, { type: 'richeseBid', event, amount: 3, allyPayment: 0 });
game = act(game, f.target, { type: 'richeseBid', event, amount: 2, allyPayment: 0 });
assert.equal(game.movementPromises![0].released, undefined);
game = advance(game, g => g.phase === 4 && clean(g), [], auction);
assert.equal(owner(game).spice, 8); assert.ok(owner(game).hand.some(card => card.id === auction.card));
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target, [], auction);
game = advance(act(game, f.target, f.shipment), clean, [], auction);
assert.equal(owner(game).spice, 0); assert.equal(game.movementPromises![0].fulfilled, undefined);
game = advance(act(game, f.target, f.move), clean, [], auction);
assert.equal(game.movementPromises![0].fulfilled, true);
game = act(game, f.target, f.endMovement);
assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('native current-phase minimal policies pass an owned Once Around bid or submit Silent zero with the real event', () => {
for (const method of ['onceAround', 'silent'] as const) {
const f = fixture(), auction = { method };
const game = advance(bound(f), g => g.phase === 3 && clean(g) && !!g.richeseAuction &&
(method === 'silent' ? !Object.hasOwn(g.richeseAuction.sealed, f.target) : g.richeseAuction.active === f.target), [], auction);
const expected = { type: 'richeseBid', event: game.richeseAuction!.event,
amount: method === 'silent' ? 0 : null, allyPayment: 0 };
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [expected]);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
assert.deepEqual(botActions(view), [expected]);
const next = act(game, f.target, expected);
assert.equal(next.movementPromises![0].released, undefined);
assert.deepEqual(viewGame(next, f.target).movementPromisePolicy, []);
}
}
});
void test('original E1 and E3 early answers survive their native controls and fulfill only on the actual ground event', () => {
for (const roster of ['ixians-tleilaxu', 'ecaz-moritani'] as const) {
const f = fixture({ roster });
let game = advance(bound(f), g => g.phase === 5 && clean(g) && g.active === f.target);
assert.equal(game.movementPromises![0].released, undefined);
game = advance(act(game, f.target, f.shipment), clean);
assert.equal(game.movementPromises![0].fulfilled, undefined);
game = advance(act(game, f.target, f.move), clean);
assert.equal(game.movementPromises![0].fulfilled, true);
game = act(game, f.target, f.endMovement); assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('controlled CHOAM denial retains the real pending revival and releases only an unaffordable later route', () => {
for (const spice of [1, 3]) {
const f = fixture(), source = advance(f.game, g => g.phase === 4 && clean(g));
const p = owner(source); p.spice = spice; p.reserves = 0; p.tanks = 3; p.forces = { 'carthag:11': 17 };
const index = source.deck.findIndex(card => card.name === 'La La La'); assert.ok(index >= 0);
const card = source.deck.splice(index, 1)[0]; player(source, 'choam').hand.push(card);
let game = bound(f, source, { ...f.claim, minimum: 1 });
game = act(game, f.target, { type: 'revive', amount: 1 });
assert.equal(game.decision?.kind, 'choamFreeRevival');
assert.equal(owner(game).reserves, 0); assert.equal(owner(game).spice, spice);
assert.equal(game.movementPromises![0].released, undefined);
game = act(game, 'choam', { type: 'card', card: card.id, mode: 'choam' });
game = advance(game, clean);
assert.equal(owner(game).reserves, 0); assert.equal(owner(game).spice, spice);
assert.equal(game.movementPromises![0].released, spice === 1 ? true : undefined);
if (spice === 3) {
game = advance(act(game, f.target, { type: 'revive', amount: 1 }), clean);
assert.equal(owner(game).spice, 1); assert.equal(owner(game).reserves, 1);
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
for (let count = 0; !game.movementPromises![0].fulfilled && count < 4; count++)
game = advance(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]), clean);
assert.equal(game.movementPromises![0].fulfilled, true);
}
assert.deepEqual(inventory(game), f.originalCards);
}
});