Support earlier classic Basic movement promises
Allow current-round classic Basic questions before Movement, with owned Charity, ordinary auction payment and Revival preparation. Reopen readiness, retain combined shipment obligations, reset stale allowances and release forced storm impossibility. Current-phase guidance and minimal bots preserve the route; held Karama now prepares its actual Guild shipment rate. Validation: affected rules/control tests, types/lint, production build and four gameplay/control/table-rendering programmes. Native early invoices, wider preparations/modules, full rules and deployed acceptance remain open. No saved-game, database, restart or deployment operation.
This commit is contained in:
1 parent
49586efd2f
commit
f995b6f4cd
18 files changed
+708
-46
No files matched your search
@@ -6,7 +6,13 @@ import { Button } from './ui/button';
|
||||
export function MovementPromises({ game, act, busy }: { game: GameView; act: (action: Action) => void; busy: boolean }) {
|
||||
const own = liveMovementPromises(game.movementPromises ?? [], game.me, game.turn);
|
||||
if (!own.length) return null;
|
||||
const next = game.movementPromisePolicy?.[0];
|
||||
const proposed = game.movementPromisePolicy?.[0];
|
||||
const paidDecision = proposed?.type === 'decision' && game.decision?.player === game.me &&
|
||||
(game.decision.kind === 'nullentropy' || game.phase === 3 && game.decision.kind === 'auctionPayment');
|
||||
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;
|
||||
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;
|
||||
@@ -14,7 +20,14 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac
|
||||
const sector = typeof next?.sector === 'number' ? next.sector : 0;
|
||||
const card = game.players.find(player => player.id === game.me)?.hand?.find(card => card.id === next?.card);
|
||||
const recovered = game.nullentropy?.search?.cards.find(card => card.id === next?.card);
|
||||
const label = next?.type === 'decision' ? `Choose ${recovered?.name ?? 'the required card'} in your paid Box search`
|
||||
const payment = next?.type === 'decision' && game.decision?.kind === 'auctionPayment';
|
||||
const label = payment ? next.karama === true ? `Use ${card?.name ?? 'your card'} for the current auction payment` : 'Pay the current auction price'
|
||||
: next?.type === 'decision' ? `Choose ${recovered?.name ?? 'the required card'} in your paid Box search`
|
||||
: next?.type === 'charity' ? 'Claim Charity for your later movement'
|
||||
: 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 === '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'
|
||||
: next?.type === 'card' ? 'Play Hajr to prepare the required movement'
|
||||
@@ -23,7 +36,7 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac
|
||||
: `${next?.movementCard ? 'Use Ornithopter and move' : 'Move'} ${amount} forces to ${destinationLabel}, sector ${sector}`;
|
||||
return <section className="notice space-y-3" aria-label="Your ground-movement promises">
|
||||
<h4>Your ground-movement promises</h4>
|
||||
{game.active !== game.me && <p>Your Movement turn is still ahead. Keep the needed forces, spice and cards available; movement controls appear on your turn.{next?.type === 'decision' && ' Your already-paid Box search can finish now.'}</p>}
|
||||
{(game.phase < 5 || game.active !== game.me) && <p>Your Movement turn is still ahead. Keep the needed forces, spice and cards available; movement controls appear on your turn.{game.phase < 5 && ' Complete any current preparation below before confirming readiness.'}{next?.type === 'decision' && game.decision?.kind === 'nullentropy' && ' Your already-paid Box search can finish now.'}</p>}
|
||||
{own.map((promise, index) => <p key={index}>{promise.answer ? 'Yes: you must' : 'No: you must not'} {movementClaimText(promise)} before your Movement turn ends this round.</p>)}
|
||||
<p className="fine">One qualifying ground move fulfills Yes; a shipment can prepare that move but does not fulfill it. No remains binding through every later Hajr or Ornithopter move. A paid Box search must finish before moving; its fee is paid only once. Opposing effects release an answer only if it becomes impossible.</p>
|
||||
{next && <Button className="game-action min-h-11 whitespace-normal" disabled={busy} onClick={() => { if (!busy) act(next); }}>{label}</Button>}
|
||||
|
||||
@@ -21,9 +21,10 @@ export function ShipmentPromises({
|
||||
if (!promises.length) return null;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const early = g.phase < 5;
|
||||
const jointMovement = liveMovementPromises(g.movementPromises ?? [], g.me, g.turn).length > 0;
|
||||
const movement = liveMovementPromises(g.movementPromises ?? [], g.me, g.turn);
|
||||
const jointMovement = movement.length > 0;
|
||||
const next = g.phase === 5 && !jointMovement ? g.shipmentCompletion?.actions[0] : undefined;
|
||||
const preparation = early ? g.shipmentPreparation?.actions[0] : undefined;
|
||||
const preparation = early && !movement.some(promise => promise.answer) ? g.shipmentPreparation?.actions[0] : undefined;
|
||||
const preparationCard = preparation?.type === 'card'
|
||||
? me.hand?.find(card => card.id === preparation.card) : undefined;
|
||||
const preparationLabel = preparation?.type === 'charity'
|
||||
|
||||
@@ -349,7 +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.truthMovementTargets.includes(target) && <p className="notice">Choose an active or still-waiting Basic player this round and finish pending decisions. 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 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>}
|
||||
<p className="notice" aria-label="Public movement question preview">{truthQuestionText(question, leaderName, locationName)}</p>
|
||||
</>
|
||||
) : kind === 'shipment' || kind === 'mixedShipment' ? (
|
||||
|
||||
@@ -16,6 +16,28 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**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
|
||||
the actual winning invoice and retain joint shipment predicates. Mandatory Storm
|
||||
losses release impossibility; old Hajr/Ornithopter allowances reset at Movement.
|
||||
Held Karama now prepares its actual Guild shipment rate, with a separate card
|
||||
when another pays the auction. Persistent controls offer only current preparation,
|
||||
Ready, bid-pass or owned payment; minimal bots follow the combined route.
|
||||
|
||||
A disjoint original-source/control lane, bounded continuation review and owner
|
||||
integration pass **21 files155/155**, types/lint/build and four actual-control/
|
||||
table-rendering programmes. Genuine Storm8/Charity22 prefixes retain Emperor's
|
||||
printed10 spice/20 reserves/zero board; actual purchase2 → city shipment8 →
|
||||
ground8 fulfills Yes. Controlled prior losses, conserved cards, escrow and stale
|
||||
allowances disclose their staging. Four profiles make24 accepted proposals plus
|
||||
76 separate native continuations. Tests exposed the missing held shipment-rate
|
||||
preparation; it now uses the existing card action and actual tariff.
|
||||
[Source, exact proof and limits](TRUTHTRANCE_MOVEMENT_PROMISES.md#earlier-classic-basic-phases--24-october-2026).
|
||||
Native early auction liabilities, later rounds, other preparations/modules,
|
||||
complete modes, comprehensive assurance and deployed acceptance remain open.
|
||||
No strategy tuning or saved-room/D1/reset/restart/deployment operation.
|
||||
|
||||
**Waiting-turn Basic movement promises — 24 October. Prototyped / Partial.**
|
||||
Unused respondents in the current Movement queue can now answer. Forecast their
|
||||
current board/resources without resolving foreign controls or granting future
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 24 October 2026 — Earlier classic Basic movement promises
|
||||
|
||||
Extend current-round questions to clean classic Basic phases0–4. Owned Charity,
|
||||
ordinary auction payment and Revival prepare the later route; new answers reopen
|
||||
Ready. Retain joint shipment predicates, actual winning cash/Karama costs and
|
||||
aid expiry/refund. Forced Storm losses release impossibility; old movement
|
||||
allowances reset exactly as phase entry. Current-phase guidance and minimal bots
|
||||
preserve the combined route. Held Karama also prepares its actual Guild shipment
|
||||
rate, independently of any card paying the auction invoice.
|
||||
|
||||
A disjoint source/control lane, bounded continuation review and owner integration
|
||||
pass21 files155/155, types/lint/build and four gameplay/control/table-rendering
|
||||
programmes. Original Storm8/Charity22 prefixes keep Emperor10 spice/20 reserves/
|
||||
zero board; actual purchase2, shipment8 and ground8 fulfill Yes. Controlled prior
|
||||
losses/cards, escrow, stale credits and exposed Oh Gap storm cases disclose their
|
||||
staging. Four existing profiles make24 accepted proposals and76 separate native
|
||||
continuations, without tuning. The new invoice regression exposed missing held
|
||||
Karama shipment preparation; the shared graph now uses the actual card action.
|
||||
Initial storm fixtures were corrected to respect distance, current storm sector
|
||||
and Imperial Basin protection rather than changing those printed rules.
|
||||
[Authority, exact proof and unfinished scope](TRUTHTRANCE_MOVEMENT_PROMISES.md#earlier-classic-basic-phases--24-october-2026).
|
||||
Native early invoices, later rounds, other preparations/modules, complete rules,
|
||||
comprehensive assurance and deployed acceptance remain open. No saved-room/D1/
|
||||
reset/restart/deployment operation.
|
||||
|
||||
## 24 October 2026 — Waiting-turn Basic movement promises
|
||||
|
||||
Allow unused respondents in the current Movement queue to answer against their
|
||||
|
||||
@@ -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. 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 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. 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) |
|
||||
| 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
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Truthtrance ground-movement promises
|
||||
|
||||
The [earlier classic Basic follow-up](#earlier-classic-basic-phases--24-october-2026)
|
||||
supersedes preceding timing boundaries. Previous checkpoint counts remain dated
|
||||
evidence, not complete rules or final acceptance.
|
||||
|
||||
## Basic prototype — 24 October 2026
|
||||
|
||||
**Prototyped / Partial.** The structured question asks whether the respondent
|
||||
@@ -17,12 +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.
|
||||
|
||||
Fresh or existing Basic native tables can ask at a clean **active or still-unused
|
||||
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
|
||||
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
|
||||
phase/later-round movement questions, compound movement/fact questions and wider
|
||||
phases with native factions, 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.
|
||||
|
||||
@@ -56,9 +62,73 @@ 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 phases/later rounds, optional/Advanced profiles and comprehensive
|
||||
earlier native phases/later rounds, optional/Advanced profiles and comprehensive
|
||||
combination/recovery/privacy/custody acceptance remain due.
|
||||
|
||||
## Earlier classic Basic phases — 24 October 2026
|
||||
|
||||
The current-round question now includes classic Basic Storm, Spice Blow,
|
||||
Charity, Bidding and Revival. This is the same provisional owned-stage forecast
|
||||
used by early shipment promises, not a new printed payment or future-income
|
||||
rule. Use current board/resources plus available owned Charity and ordinary
|
||||
Revival. Price a currently leading ordinary bid against cash or one held
|
||||
physical Karama; do not invent the purchased card, future income, gifts,
|
||||
cooperation or a favorable Storm outcome. Bidding aid retains its committed
|
||||
floor and refunds only its uncommitted remainder; incoming Bidding aid expires
|
||||
before Movement. Actual foreign invoices/income responses finish before an
|
||||
impossible promise releases. Native early auction/funding liabilities remain
|
||||
unfinished; selected native phase5 entries are unchanged.
|
||||
|
||||
A new answer reopens its owner's Charity/Revival readiness. Claim or revive
|
||||
before Ready when needed; a bid, payment or optional revival cannot spend the
|
||||
last route. Mandatory Storm dials/readiness and forced casualties can release
|
||||
impossible answers rather than trapping their owner. Forecasts reset old Hajr
|
||||
and Ornithopter allowances exactly as real Movement entry does. Joint shipment
|
||||
predicates remain in the same completion search. Ground preparation also
|
||||
enumerates held Karama for its actual Guild shipment rate, with a distinct card
|
||||
when another Karama pays the auction; only the actual shipment consumes that
|
||||
rate. Pending owned Worthless conversion projects its allowed result without
|
||||
settling its real response.
|
||||
|
||||
Persistent guidance offers only the current Charity, ordinary Revival, Ready,
|
||||
bid-pass or owned invoice control. It never exposes future shipment/movement
|
||||
before phase5. Existing paid Box decisions retain their original phase before
|
||||
forecast promotion; no genuine classic Box source is claimed. Positive movement
|
||||
guidance owns the combined route before independent shipment suggestions;
|
||||
all-No answers keep normal auction-payment choices. All four profiles retain
|
||||
minimal legal participation; strategic refinement remains deferred.
|
||||
|
||||
`tests/fixture-truth-movement-early.ts` retains genuine original classic Basic
|
||||
Storm/Charity prefixes with Atreides Truth, Emperor Baliset, Harkonnen
|
||||
Karama/Shield and Guild Snooper. Emperor keeps printed10 spice,20 reserves and
|
||||
zero board/Tanks. The caller performs its later normal purchase2, city
|
||||
shipment8 and ground8. Controlled prior losses, conserved Karama/second-Truth
|
||||
transfers, ally escrow and stale allowance cases are explicitly separate from
|
||||
that natural source. The storm-loss case stages prior resources/position, then
|
||||
uses actual dials and last owner Ready over exposed Oh Gap; Imperial Basin's
|
||||
printed storm protection is retained.
|
||||
|
||||
The frozen **21-file union passes155/155**, types/lint and production build.
|
||||
Four actual guidance-handler/GameTable programmes cover genuine Storm8 and
|
||||
Charity22 sources with actual purchase2/city shipment8/ground8, original Charity
|
||||
No, and an explicitly controlled Charity/Revival1 preparation. Busy handlers
|
||||
submit nothing. Four existing profiles make24 accepted current-phase/later-turn
|
||||
proposals plus76 separate native pass/readiness continuations. No strategy
|
||||
refinement or relative-difficulty calibration is claimed. Focused feedback
|
||||
repaired missing held Karama shipment preparation; fixture corrections retained
|
||||
actual geometry, current-sector blocking and Imperial Basin storm protection.
|
||||
|
||||
Frozen base `49586efd2fffcf2ba990693fedc9307a5b522150`, tree
|
||||
`e56b10d3a3c602078451d125b55aeaabc7acff1b348f8f682e04149e3831382d`,
|
||||
2648 files. Private source-bound logs, actual-state control HTML and receipts:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-movement-early/`,
|
||||
`frozen-integrated/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.
|
||||
|
||||
Full native early phases, later rounds, other preparations/modules, complete
|
||||
modes, comprehensive assurance and browser/deployed acceptance remain open.
|
||||
|
||||
## Same-phase waiting respondents — 24 October 2026
|
||||
|
||||
The general current-round FAQ contract now includes a respondent whose unused
|
||||
|
||||
+3
-3
@@ -4076,12 +4076,12 @@ export function botActions(g: GameView): Action[] {
|
||||
if (g.homeworldTupileBlocked) return [];
|
||||
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
|
||||
return tupileCleanupActions(g);
|
||||
if (g.shipmentPromisePolicy && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
|
||||
return g.shipmentPromisePolicy;
|
||||
if (g.movementPromisePolicy && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
|
||||
return g.movementPromisePolicy;
|
||||
if (g.shipmentPromisePolicy && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
|
||||
return g.shipmentPromisePolicy;
|
||||
if (g.nexusHarkonnenBetrayalReaction) {
|
||||
if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
|
||||
g.automaticContinuationPending) return [];
|
||||
|
||||
+74
-16
@@ -1,6 +1,6 @@
|
||||
import { recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge, recordMovementClaimKnowledge, clearMovementDiscardKnowledge, type MovementDiscardKnowledge } from './movement-discard-knowledge';
|
||||
import { movementShipmentPreparations } from './movement-shipment-preparation';
|
||||
import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises';
|
||||
import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseModeSupported, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises';
|
||||
import { findMovementCompletion, type MovementCompletion } from './movement-promise-search';
|
||||
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile } from './faction-module-profile';
|
||||
@@ -16433,6 +16433,7 @@ function recoverableUnsupportedShipment(shipment: PendingShipment) {
|
||||
* owned movement reachability; unresolved opposing outcomes are never replayed. */
|
||||
function groundMovementProbe(source: Game, actor: string): Game {
|
||||
const game = structuredClone(source);
|
||||
projectPendingShipmentKarama(game, actor);
|
||||
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
|
||||
@@ -16503,7 +16504,33 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
|
||||
}
|
||||
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) {
|
||||
const paid = source.pendingNullentropy;
|
||||
if (paid?.player === actor) {
|
||||
if (nullentropyIntegrity(source)) return null;
|
||||
for (const card of eligibleNullentropyCards(source.discard)) {
|
||||
const projected = projectMovementBoxRecovery(source, actor, paid.box, card.id, true);
|
||||
const completion = projected && groundMovementCompletion(projected, actor, promises);
|
||||
if (completion) return { ...completion,
|
||||
actions: [{ type: 'decision', event: paid.event, card: card.id }, ...completion.actions] };
|
||||
}
|
||||
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];
|
||||
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises);
|
||||
if (completion) return { actions: [...seed.actions, ...completion.actions], early: seed.early };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return groundMovementCompletionAtMovement(game, actor, promises);
|
||||
}
|
||||
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, {
|
||||
allowance: movesAllowed,
|
||||
@@ -16534,6 +16561,11 @@ function groundMovementCompletion(source: Game, actor: string,
|
||||
catch (error) { if (error instanceof RuleError) return null; 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 };
|
||||
try { yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation'), owner), actions: [action] }; }
|
||||
catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
if (card.effect === 'hajr' && hajrAvailable(state, player) && !transferCardBlock(state, player, card)) {
|
||||
try { yield { game: groundMovementProbe(applyActionInner(state, owner,
|
||||
{ type: 'card', card: card.id }, 'movementPreparation'), owner), actions: [{ type: 'card', card: card.id }] }; }
|
||||
@@ -16572,6 +16604,7 @@ function groundMovementCompletion(source: Game, actor: string,
|
||||
function projectedGroundMovementPolicy(g: Game, player: Player): Action[] | null {
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], player.id, g.turn);
|
||||
const paid = g.pendingNullentropy?.player === player.id;
|
||||
if (!paid && g.phase < 5) return earlyGroundMovementPolicy(g, player);
|
||||
if (g.truthtrance || !live.length || (!paid && g.active !== player.id) ||
|
||||
(paid && !live.some(promise => promise.answer))) return null;
|
||||
const available = paid ? groundMovementProbe(g, player.id) : g;
|
||||
@@ -16579,16 +16612,35 @@ function projectedGroundMovementPolicy(g: Game, player: Player): Action[] | null
|
||||
const completion = groundMovementCompletion(g, player.id);
|
||||
return completion ? completion.actions.length ? completion.actions.slice(0, 1) : [{ type: 'endMovement' }] : null;
|
||||
}
|
||||
function earlyGroundMovementPolicy(g: Game, player: Player): Action[] | null {
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], player.id, g.turn);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAnswer[] {
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
|
||||
const answers: TruthAnswer[] = [];
|
||||
for (const answer of [true, false]) {
|
||||
const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer };
|
||||
if (groundMovementCompletion(g, p.id, [...live, promise])) answers.push(answer ? 'yes' : 'no');
|
||||
const answerSource = g.phase === 2 || g.phase === 4 ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
if (groundMovementCompletion(answerSource, p.id, [...live, promise])) answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) {
|
||||
if ((g.phase === 2 || g.phase === 4) && g.ready.includes(p.id)) {
|
||||
g.ready = g.ready.filter(id => id !== p.id);
|
||||
log(g, `${p.name} has a new Truthtrance movement commitment and must confirm readiness again after any needed preparation.`);
|
||||
}
|
||||
(g.movementPromises ??= []).push({ ...claim, turn: g.turn, player: p.id, asker, answer });
|
||||
}
|
||||
function checkGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
|
||||
@@ -16611,7 +16663,7 @@ function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
|
||||
log(g, `${p.name} fulfilled a Truthtrance ground-movement promise with this committed move.`);
|
||||
});
|
||||
}
|
||||
function reconcileGroundMovementPromises(g: Game, actor?: string) {
|
||||
function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
|
||||
// A declared native movement is awaiting a real opponent choice. It is not
|
||||
// impossible and must not be fulfilled before physical commitment.
|
||||
for (const p of g.players) {
|
||||
@@ -16619,11 +16671,16 @@ function reconcileGroundMovementPromises(g: Game, actor?: string) {
|
||||
if (!live.some(promise => promise.answer)) continue;
|
||||
if (g.pendingFremenMove?.order.player === p.id || g.pendingIxMove?.player === p.id ||
|
||||
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) ||
|
||||
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);
|
||||
if (busy && actor !== p.id) continue;
|
||||
g.pendingAmbassador || g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
|
||||
g.phase === 3 && !g.currentAuctionSale && g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0);
|
||||
if (busy && !voluntary) continue;
|
||||
if (groundMovementCompletion(g, p.id, live)) continue;
|
||||
requireRule(actor !== p.id, 'Your action would remove the last legal completion of an accepted ground-movement promise.');
|
||||
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted ground-movement promise.');
|
||||
const kept = live.filter(promise => !promise.answer);
|
||||
// Keep still-feasible earlier answers and every No constraint; an opposing
|
||||
// loss does not erase independent promises that remain possible.
|
||||
@@ -20495,6 +20552,13 @@ function advisorBattleOptions(g: Game) {
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
return quote.territories;
|
||||
}
|
||||
function resetPlayerMovement(p: Player) {
|
||||
p.shipped = false;
|
||||
p.moved = 0;
|
||||
delete p.hajrPlays;
|
||||
delete p.ornithopterExtraMoves;
|
||||
delete p.hajrWindow;
|
||||
}
|
||||
function commitMovementPhaseStart(g: Game, quote: MovementPhaseQuote) {
|
||||
if (quote.kind === 'opening') {
|
||||
g.phaseOpening = quote.phaseOpening;
|
||||
@@ -20514,13 +20578,7 @@ function commitMovementPhaseStart(g: Game, quote: MovementPhaseQuote) {
|
||||
passed: [],
|
||||
};
|
||||
}
|
||||
for (const p of g.players) {
|
||||
p.shipped = false;
|
||||
p.moved = 0;
|
||||
delete p.hajrPlays;
|
||||
delete p.ornithopterExtraMoves;
|
||||
delete p.hajrWindow;
|
||||
}
|
||||
for (const p of g.players) resetPlayerMovement(p);
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
g.decision = quote.decision;
|
||||
g.active = quote.active;
|
||||
@@ -31260,7 +31318,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
return normalizeAutomaticGame(state);
|
||||
}
|
||||
const g = applyActionInner(state, id, action);
|
||||
reconcileGroundMovementPromises(g, id);
|
||||
reconcileGroundMovementPromises(g, id, action, state.phase);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
if (g.pendingNexusHarkonnenBetrayal) return g;
|
||||
ixianNexusBetrayalIntegrity(g);
|
||||
@@ -33468,7 +33526,7 @@ function applyActionInner(
|
||||
const afterAdvisor = structuredClone(g);
|
||||
finishResponse(afterAdvisor, false);
|
||||
reconcileShipmentPromises(afterAdvisor, { actor: p.id, action });
|
||||
reconcileGroundMovementPromises(afterAdvisor, p.id);
|
||||
reconcileGroundMovementPromises(afterAdvisor, p.id, action, g.phase);
|
||||
}
|
||||
} else log(g, `${p.name} declined the free shipment.`);
|
||||
} else if (decision.kind === 'wormPlacement') {
|
||||
@@ -34414,7 +34472,7 @@ function applyActionInner(
|
||||
getPlayer(afterPayment, p.id).spice -= t === 'emperorGift'
|
||||
? amount : paidForceRevivalCost(recipient, amount, elite);
|
||||
reconcileShipmentPromises(afterPayment, { actor: p.id, action });
|
||||
reconcileGroundMovementPromises(afterPayment, p.id);
|
||||
reconcileGroundMovementPromises(afterPayment, p.id, action, g.phase);
|
||||
}
|
||||
g.response = {
|
||||
kind: t,
|
||||
|
||||
@@ -9,7 +9,7 @@ export type MovementPromiseAdapter = {
|
||||
reachable: (game: Game, player: Player, origin: string, destination: string, sector: number) => boolean;
|
||||
project: (game: Game, player: string, action: Action) => { game: Game; event: MovementEvent } | null;
|
||||
};
|
||||
export type MovementCompletion = { actions: Action[] };
|
||||
export type MovementCompletion = { actions: Action[]; early?: { phase: number; action: Action }[] };
|
||||
|
||||
/** Enumerate actual physical subgroups from one territory, including native
|
||||
* elite allocations and several sectors. No strategic shortlist proves legality. */
|
||||
|
||||
@@ -83,11 +83,12 @@ export function finishMovementPromises<T extends MovementPromise>(
|
||||
|
||||
/** Prototype profile/timing only. Actual route feasibility stays authoritative
|
||||
* in the engine. Truthtrance and an ordinary active Ornithopter may remain. */
|
||||
export function movementPromiseTargetAvailable(g: Game, player: string): boolean {
|
||||
const target = g.players.find(candidate => candidate.id === player);
|
||||
return g.status === 'playing' && g.advanced === false && g.phase === 5 &&
|
||||
!!target && (g.active === player || (!target.shipped && target.moved === 0)) &&
|
||||
!!g.movementRemaining?.includes(player) &&
|
||||
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 &&
|
||||
@@ -97,7 +98,14 @@ export function movementPromiseTargetAvailable(g: Game, player: string): boolean
|
||||
}) &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
|
||||
!g.strongholdCards && !g.discoveryEnabled && !g.discoveries &&
|
||||
!g.discoveryStash && !g.greatMaker && !g.ecazTreachery &&
|
||||
!g.discoveryStash && !g.greatMaker && !g.ecazTreachery;
|
||||
}
|
||||
export function movementPromiseTargetAvailable(g: Game, player: string): boolean {
|
||||
const target = g.players.find(candidate => candidate.id === player);
|
||||
return movementPromiseModeSupported(g) && g.status === 'playing' &&
|
||||
Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 5 && !!target &&
|
||||
(g.phase < 5 || ((g.active === player || (!target.shipped && target.moved === 0)) &&
|
||||
!!g.movementRemaining?.includes(player))) &&
|
||||
!g.response && !g.decision && !g.phaseOpening &&
|
||||
!g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingGuildTransport &&
|
||||
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingArrivalOverlap &&
|
||||
|
||||
+8
-7
@@ -2928,13 +2928,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Bind one future qualifying physical ground movement before an active Basic respondent finishes their current turn.',
|
||||
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. Ask an active Basic player before or after shipment, or a still-unused waiting respondent in the current Movement queue. Finish pending decisions first. Selected native families are supported; optional modules, Advanced, earlier phases and later rounds remain unfinished.',
|
||||
'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.',
|
||||
'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.',
|
||||
'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.',
|
||||
@@ -2942,11 +2943,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-shipment', 'movement', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'The pure ledger and shared movement/shipment search now include unused waiting Basic respondents. Forecast current owned resources without foreign outcomes; ordinary policy waits until active, while the original paid Box can select any entitled card preserving the route. Deferred gift/advisor costs retain preflight; native moves defer only their owner. Earlier phases/later rounds, remaining preparation families, No-Field/modules and wider interactions 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 question/private answers and persistent busy-safe guidance include waiting obligations, actual-turn reinforcement and paid search selection while waiting. Both ordinary paid faces remain inspectable; selecting returns to waiting with no premature movement button. Prior active/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'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Eight runs across all four profiles make32 accepted waiting-answer or paid-selection/later-turn proposals, with12 separate neutral acknowledgements. Optional accompaniment may be declined under a positive promise. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-waiting-engine.test.ts'] },
|
||||
{ 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: '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: 'Current eighteen-file125/125, types/lint, retained passing app build and four original-action/control/table programmes pass. Classic source19 reverses its unopened deal; native source44 buys Box/Hajr and spends the first Truth; the actual later question supplies the second discard. Controlled pact, BG losses, foreign Ornithopter and native Fremen/Box cases disclose staging and prove rejected/affordable costs. Only the regression amended after build; app identity is verified and final tests/types/runtime rerun. Prior165/165,231/231,218/218 stay historical. No browser/visual/deployed, full-mode or comprehensive assurance claim.', 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', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -3043,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. Active post-shipment Basic native turns also support one future ground-movement minimum: 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 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.',
|
||||
'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
@@ -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 require an active or still-waiting Basic turn this round, with pending decisions finished and no optional modules.');
|
||||
'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.');
|
||||
try { return { kind: 'movement', target: v.target as string, ...parseMovementClaim(v) }; }
|
||||
catch (error) { if (error instanceof MovementClaimError) throw new TruthError(error.message); throw error; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Action } from '../game/engine';
|
||||
import {
|
||||
createTruthtranceEarlyShipmentFixture, actTruthtranceEarlyShipment,
|
||||
advanceTruthtranceEarlyShipment, nextTruthtranceEarlyShipmentStep,
|
||||
truthtranceEarlyShipmentClean, truthtranceEarlyShipmentPlayer,
|
||||
type TruthtranceEarlyShipmentStep,
|
||||
} from './fixture-truthtrance-early-shipment';
|
||||
import { truthMovementInventory } from './fixture-truth-movement';
|
||||
|
||||
export type TruthMovementEarlyStep = TruthtranceEarlyShipmentStep;
|
||||
export const actTruthMovementEarly = actTruthtranceEarlyShipment;
|
||||
export const advanceTruthMovementEarly = advanceTruthtranceEarlyShipment;
|
||||
export const nextTruthMovementEarlyStep = nextTruthtranceEarlyShipmentStep;
|
||||
export const truthMovementEarlyClean = truthtranceEarlyShipmentClean;
|
||||
export const truthMovementEarlyPlayer = truthtranceEarlyShipmentPlayer;
|
||||
export const truthMovementEarlyInventory = truthMovementInventory;
|
||||
|
||||
/** Genuine original Basic setup, before the selected early movement question.
|
||||
* Phase0 uses only the real setup prefix and unopened ordinary Spice selection;
|
||||
* phase2 retains the original Storm/Spice continuation through clean Charity.
|
||||
* The caller owns the question, subsequent auction and later shipment/move. */
|
||||
export function createTruthMovementEarlyFixture(options: { phase?: 0 | 2 } = {}) {
|
||||
const base = createTruthtranceEarlyShipmentFixture(), phase = options.phase ?? 2;
|
||||
const game = structuredClone(phase === 0 ? base.afterSetup : base.game);
|
||||
const count = phase === 0 ? base.afterSetupActionCount : base.sourceActionCount;
|
||||
const actions: TruthMovementEarlyStep[] = structuredClone(base.actions.slice(0, count));
|
||||
const spiceOrder = phase === 0 ? base.spiceOrder.map(({ position, card }) => {
|
||||
const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
|
||||
JSON.stringify(candidate) === JSON.stringify(card));
|
||||
assert.ok(from >= position, 'Select the same original unopened ordinary Spice card');
|
||||
const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected);
|
||||
return { position, from, card: structuredClone(selected) };
|
||||
}) : structuredClone(base.spiceOrder);
|
||||
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, phase);
|
||||
assert.deepEqual(game.expansions, []); assert.ok(truthMovementEarlyClean(game));
|
||||
const owner = truthMovementEarlyPlayer(game, base.target);
|
||||
assert.equal(owner.spice, 10); assert.equal(owner.reserves, 20); assert.equal(owner.tanks, 0);
|
||||
assert.deepEqual(owner.forces, {}); assert.equal(owner.shipped, false); assert.equal(owner.moved, 0);
|
||||
assert.ok(truthMovementEarlyPlayer(game, base.asker).hand.some(card => card.id === base.card));
|
||||
assert.ok(truthMovementEarlyPlayer(game, base.counterHolder).hand.some(card => card.id === base.karama));
|
||||
assert.equal(game.truthtrance ?? null, null); assert.equal(game.movementPromises, undefined);
|
||||
assert.deepEqual(truthMovementInventory(game), base.originalTreachery);
|
||||
const claim = { kind: 'movement' as const, target: base.target, territory: 'carthag', minimum: 8 };
|
||||
return {
|
||||
game, source: structuredClone(game), base, phase,
|
||||
lobby: base.lobby, setup: base.setup, beforeStartingDeal: base.beforeStartingDeal,
|
||||
afterSetup: base.afterSetup, afterSetupActionCount: base.afterSetupActionCount,
|
||||
beforeSpiceOrder: base.beforeSpiceOrder, spiceOrder, deal: base.deal, dealOrder: base.dealOrder,
|
||||
originalCards: base.originalTreachery, originalTreachery: base.originalTreachery, originalSpice: base.originalSpice,
|
||||
asker: base.asker, target: base.target, counterHolder: base.counterHolder, guild: base.guild,
|
||||
truth: base.card, card: base.card, karama: base.karama, claim, question: claim,
|
||||
actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { storm: base.afterSetupActionCount, charity: base.sourceActionCount },
|
||||
balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
|
||||
declarationStep: { actor: base.asker, action: { type: 'card', card: base.card } } satisfies TruthMovementEarlyStep,
|
||||
askStep: { actor: base.asker, action: { type: 'truthAsk', question: claim } } satisfies TruthMovementEarlyStep,
|
||||
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 Basic Atreides/Emperor/Harkonnen/Guild setup selects only the original five unopened deal positions, one ordinary unopened Spice position and native scalar lotteries. Atreides genuinely holds Truthtrance, Emperor Baliset, Harkonnen Karama/Shield and Guild Snooper. The phase${phase} source uses exactly its original ${actions.length}-action prefix: ${phase === 0 ? 'the Charity factory suffix is excluded, and the same still-unopened ordinary Spice position is selected before any Storm/Spice continuation' : 'actual Storm/Spice controls reach clean Charity before any question or Bidding'}. Emperor retains its printed ten spice, twenty reserves, zero board forces and no Tanks. The suggested future normal purchase2, city shipment8 and eight-force ground movement remain caller actions; no purchase, question, answer, dealt hand, wallet, army, phase clock, queue or receipt is installed. The movement predicate concerns the later ground event, not the preceding shipment.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TruthMovementEarlyFixture = ReturnType<typeof createTruthMovementEarlyFixture>;
|
||||
@@ -54,7 +54,7 @@ export function createTruthtranceEarlyShipmentFixture() {
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const afterSetup = structuredClone(game), afterSetupActionCount = actions.length;
|
||||
assert.ok(truthtranceEarlyShipmentPlayer(game, asker).hand.some(card => card.id === truth.id));
|
||||
assert.ok(truthtranceEarlyShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id));
|
||||
assert.ok(truthtranceEarlyShipmentPlayer(game, target).hand.some(card => card.id === filler[0]!.id));
|
||||
@@ -80,7 +80,7 @@ export function createTruthtranceEarlyShipmentFixture() {
|
||||
const declarationStep: TruthtranceEarlyShipmentStep = { actor: asker,
|
||||
action: { type: 'card', card: truth.id } };
|
||||
const askStep: TruthtranceEarlyShipmentStep = { actor: asker, action: { type: 'truthAsk', question } };
|
||||
return { game, lobby, setup, beforeStartingDeal, afterSetup, beforeSpiceOrder, spiceOrder,
|
||||
return { game, lobby, setup, beforeStartingDeal, afterSetup, afterSetupActionCount, beforeSpiceOrder, spiceOrder,
|
||||
deal, dealOrder, originalTreachery, originalSpice, asker, target, counterHolder, guild: 'guild',
|
||||
card: truth.id, karama: karama.id, question, declarationStep, askStep,
|
||||
territory: 'carthag', sector: 11, location: 'carthag:11', minimum: 8,
|
||||
|
||||
@@ -129,3 +129,17 @@ void test('Basic native selected families qualify, while optional modules and un
|
||||
assert.equal(movementPromiseTargetAvailable(configured, 'target'), false, key);
|
||||
}
|
||||
});
|
||||
|
||||
void test('earlier current-round movement questions support classic Basic while native early invoices remain unfinished', () => {
|
||||
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);
|
||||
classic.advanced = true; assert.equal(movementPromiseTargetAvailable(classic, 'target'), false);
|
||||
}
|
||||
for (const phase of [-1, 6, 8]) {
|
||||
const g = movementBoundary(); g.phase = phase;
|
||||
assert.equal(movementPromiseTargetAvailable(g, 'target'), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
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 { baseDeck } from '../game/cards';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { createTruthMovementEarlyFixture, actTruthMovementEarly as act } from './fixture-truth-movement-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));
|
||||
|
||||
/** Controlled personalized UI projections for label/dispatch boundaries only.
|
||||
* They do not establish earned Charity, Tanks, bids, cards or early feasibility.
|
||||
* The separate original-source case below binds a real early answer. */
|
||||
function projectedView(phase: number, policy: Action[] | null = null, active: string | null = 'a'): GameView {
|
||||
const source = createGame('EARLYMOVEMENTCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
|
||||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||||
const game = viewGame(source, 'p');
|
||||
game.status = 'playing'; game.turn = 1; game.phase = phase; game.active = active;
|
||||
game.movementPromises = [{ player: 'p', asker: 'a', turn: 1,
|
||||
territory: 'carthag', minimum: 8, answer: true }];
|
||||
game.movementPromisePolicy = policy;
|
||||
return game;
|
||||
}
|
||||
function assertCurrentControl(game: GameView, action: Action, label: string) {
|
||||
const sent: Action[] = [], props = { game, busy: false, act: (value: Action) => { sent.push(value); } };
|
||||
const html = markup(props);
|
||||
assert.match(html, /Your Movement turn is still ahead/);
|
||||
assert.match(html, /Complete any current preparation below before confirming readiness/);
|
||||
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 available = controls(MovementPromises(props));
|
||||
assert.equal(available.length, 1); assert.equal(available[0].children, label);
|
||||
assert.equal(available[0].disabled, false);
|
||||
available[0].onClick(); assert.equal(sent.length, 1); assert.equal(sent[0], action);
|
||||
const busy = controls(MovementPromises({ ...props, busy: true }));
|
||||
assert.equal(busy.length, 1); assert.equal(busy[0].disabled, true);
|
||||
busy[0].onClick(); assert.equal(sent.length, 1, 'A stale busy handler cannot submit again');
|
||||
assert.match(markup({ ...props, busy: true }), /Your ground-movement promises/);
|
||||
return props;
|
||||
}
|
||||
|
||||
void test('real early Charity binding exposes only current readiness and keeps its obligation readable after submission', () => {
|
||||
const f = createTruthMovementEarlyFixture();
|
||||
let game = act(f.game, f.asker, { type: 'card', card: f.truth });
|
||||
for (const player of game.players)
|
||||
if (!game.truthtrance!.passed.includes(player.id)) game = act(game, player.id, { type: 'truthPass' });
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: f.claim });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
if (game.pendingTreacheryDiscard?.reaction) {
|
||||
const event = game.pendingTreacheryDiscard.batch.event;
|
||||
for (const player of game.players) game = act(game, player.id, { type: 'semutaPass', event });
|
||||
}
|
||||
assert.equal(game.phase, 2);
|
||||
const view = viewGame(game, f.target), next = view.movementPromisePolicy?.[0];
|
||||
assert.deepEqual(next, { type: 'ready' });
|
||||
const sent: Action[] = [], props = { game: view, busy: false, act: (action: Action) => { sent.push(action); } };
|
||||
const before = markup(props);
|
||||
assert.match(before, /Your Movement turn is still ahead/);
|
||||
assert.match(before, /Finish this preparation phase while preserving your promises/);
|
||||
assert.equal(controls(MovementPromises(props)).length, 1);
|
||||
assert.doesNotMatch(before, />Ship \d|>Move \d|>Finish movement while honoring No/);
|
||||
controls(MovementPromises({ ...props, busy: true }))[0].onClick(); assert.equal(sent.length, 0);
|
||||
controls(MovementPromises(props))[0].onClick(); assert.deepEqual(sent, [next]);
|
||||
game = act(game, f.target, sent[0]); assert.ok(game.ready.includes(f.target));
|
||||
const waiting = { ...props, game: viewGame(game, f.target) };
|
||||
assert.equal(controls(MovementPromises(waiting)).length, 0);
|
||||
const after = markup(waiting);
|
||||
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.match(after, /needed forces, spice and cards available/);
|
||||
});
|
||||
|
||||
void test('projected Charity and Revival controls forward only their current preparation with busy protection', () => {
|
||||
const charity: Action = { type: 'charity' };
|
||||
assertCurrentControl(projectedView(2, [charity], null), charity, 'Claim Charity for your later movement');
|
||||
const revival: Action = { type: 'revive', amount: 3, elite: 1 };
|
||||
assertCurrentControl(projectedView(4, [revival], null), revival, 'Revive 3 forces for your later movement');
|
||||
});
|
||||
|
||||
void test('projected Charity and Revival readiness remains a current control while later movement stays deferred', () => {
|
||||
for (const phase of [2, 4]) {
|
||||
const ready: Action = { type: 'ready' };
|
||||
assertCurrentControl(projectedView(phase, [ready], null), ready,
|
||||
'Finish this preparation phase while preserving your promises');
|
||||
}
|
||||
});
|
||||
|
||||
void test('the current bid-pass control preserves its exact action without offering a future shipment', () => {
|
||||
const pass: Action = { type: 'passBid' };
|
||||
assertCurrentControl(projectedView(3, [pass], 'p'), pass, 'Pass this bid and preserve later movement');
|
||||
});
|
||||
|
||||
void test('current auction guidance distinguishes original cash and physical Karama payment decisions', () => {
|
||||
const cash: Action = { type: 'decision', karama: false }, cashView = projectedView(3, [cash], 'p');
|
||||
cashView.decision = { kind: 'auctionPayment', player: cashView.me };
|
||||
assertCurrentControl(cashView, cash, 'Pay the current auction price');
|
||||
const card = baseDeck().find(card => card.effect === 'karama'); assert.ok(card);
|
||||
const payment: Action = { type: 'decision', karama: true, card: card.id };
|
||||
const cardView = projectedView(3, [payment], 'p');
|
||||
cardView.decision = { kind: 'auctionPayment', player: cardView.me };
|
||||
cardView.players.find(player => player.id === cardView.me)!.hand = [card];
|
||||
assertCurrentControl(cardView, payment, `Use ${card.name} for the current auction payment`);
|
||||
});
|
||||
|
||||
void test('early notices persist without a current control and cannot turn future movement policy into an early action', () => {
|
||||
const future: Action[] = [
|
||||
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 8, elite: 0, allyPayment: 0 },
|
||||
{ type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 8 },
|
||||
{ type: 'endMovement' },
|
||||
{ type: 'card', card: 'future-hajr' },
|
||||
];
|
||||
for (const phase of [0, 1, 2, 3, 4]) for (const policy of [null, ...future.map(action => [action])]) {
|
||||
const props = { game: projectedView(phase, policy), busy: false,
|
||||
act() { assert.fail('An early notice cannot execute a later shipment or move'); } };
|
||||
assert.equal(controls(MovementPromises(props)).length, 0);
|
||||
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.doesNotMatch(html, /<button/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('an out-of-phase preparation or foreign auction payment never becomes an owned early control', () => {
|
||||
for (const [phase, action] of [[2, { type: 'revive', amount: 1 }], [4, { type: 'charity' }],
|
||||
[3, { type: 'ready' }]] satisfies [number, Action][]) {
|
||||
const props = { game: projectedView(phase, [action]), busy: false, act() { assert.fail('Wrong preparation phase'); } };
|
||||
assert.equal(controls(MovementPromises(props)).length, 0);
|
||||
}
|
||||
const foreign = projectedView(3, [{ type: 'decision', karama: false }]);
|
||||
foreign.decision = { kind: 'auctionPayment', player: 'a' };
|
||||
assert.equal(controls(MovementPromises({ game: foreign, busy: false,
|
||||
act() { assert.fail('A foreign invoice cannot become an owned control'); } })).length, 0);
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
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 { createTruthMovementEarlyFixture as fixture, actTruthMovementEarly as act,
|
||||
advanceTruthMovementEarly as advance, truthMovementEarlyClean as clean,
|
||||
truthMovementEarlyPlayer as player, truthMovementEarlyInventory as inventory } from './fixture-truth-movement-early';
|
||||
|
||||
function asked(options: { phase?: 0 | 1 | 2 | 3 | 4; minimum?: number; territory?: string; control?: (g: Game) => void } = {}) {
|
||||
const f = fixture({ phase: options.phase === 0 || options.phase === 1 ? 0 : 2 });
|
||||
let game = options.phase !== undefined && ![0, 2].includes(options.phase)
|
||||
? advance(f.game, g => g.phase === options.phase && clean(g)) : f.game;
|
||||
options.control?.(game);
|
||||
game = act(game, f.asker, f.declarationStep.action);
|
||||
game = advance(game, g => g.truthtrance?.stage === 'ask');
|
||||
assert.ok(viewGame(game, f.asker).truthMovementTargets.includes(f.target));
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: { ...f.claim,
|
||||
territory: options.territory ?? f.claim.territory, minimum: options.minimum ?? 8 } });
|
||||
return { f, game };
|
||||
}
|
||||
function bound(options: Parameters<typeof asked>[0] = {}, answer = 'yes') {
|
||||
const { f, game } = asked(options);
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes(answer as 'yes' | 'no'));
|
||||
return { f, game: act(game, f.target, { type: 'truthAnswer', answer }) };
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function giveKarama(game: Game, count = 1) {
|
||||
for (let n = 0; n < count; n++) {
|
||||
const holder = game.players.find(p => p.id !== 'emperor' && p.hand.some(c => c.effect === 'karama'));
|
||||
const cards = holder?.hand ?? game.deck, index = cards.findIndex(c => c.effect === 'karama');
|
||||
assert.ok(index >= 0); player(game).hand.push(cards.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
function allies(game: Game) { player(game).ally = 'harkonnen'; player(game, 'harkonnen').ally = 'emperor'; }
|
||||
|
||||
void test('an original Charity movement answer retains its physical source without granting early shipment or movement', () => {
|
||||
const { f, game } = bound();
|
||||
assert.equal(game.phase, 2); assert.equal(player(game).spice, 10);
|
||||
assert.equal(player(game).reserves, 20); assert.deepEqual(player(game).forces, {});
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'ready' }]);
|
||||
reject(game, f.target, f.shipment); reject(game, f.target, f.move);
|
||||
});
|
||||
|
||||
void test('the actual winning invoice reserves spice for shipment and only the later ground event fulfills Yes', () => {
|
||||
const { f, game: initial } = bound();
|
||||
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
|
||||
const purchased = game.auction!.cards[game.auction!.index].id;
|
||||
reject(game, f.target, { type: 'bid', amount: 3 });
|
||||
game = act(game, f.target, f.bid);
|
||||
game = advance(game, g => g.phase === 4 && clean(g));
|
||||
assert.equal(player(game).spice, 8); assert.ok(player(game).hand.some(c => c.id === purchased));
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
|
||||
game = advance(act(game, f.target, f.shipment), clean);
|
||||
assert.equal(player(game).spice, 0); assert.equal(player(game).forces['arrakeen:10'], 8);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
reject(game, f.target, f.endMovement);
|
||||
game = advance(act(game, f.target, f.move), clean);
|
||||
assert.equal(player(game).forces['carthag:11'], 8); assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
game = act(game, f.target, f.endMovement); assert.notEqual(game.active, f.target);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('original Storm, Spice, Bidding and Revival questions retain the same current-round future movement', () => {
|
||||
for (const phase of [0, 1, 3, 4] as const) {
|
||||
const { f, game: initial } = bound({ phase });
|
||||
assert.equal(initial.phase, phase);
|
||||
assert.ok(!viewGame(initial, f.target).movementPromisePolicy?.some(a => ['ship', 'move', 'endMovement'].includes(a.type)));
|
||||
let game = advance(initial, 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);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
const needPreparation = (g: Game) => {
|
||||
const p = player(g); p.spice = 0; p.reserves = 0; p.tanks = 3; p.forces = { 'carthag:11': 17 };
|
||||
g.ready = [p.id];
|
||||
};
|
||||
void test('controlled Charity and Revival needs reopen readiness and cannot be skipped or spent on an excess revival', () => {
|
||||
const { f, game: initial } = bound({ control: needPreparation, minimum: 1 });
|
||||
let game = initial;
|
||||
assert.equal(game.ready.includes(f.target), false);
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'charity' }]);
|
||||
reject(game, f.target, { type: 'ready' });
|
||||
game = act(game, f.target, { type: 'charity' });
|
||||
game = advance(game, g => g.phase === 4 && clean(g));
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'revive', amount: 1 }]);
|
||||
reject(game, f.target, { type: 'ready' }); reject(game, f.target, { type: 'revive', amount: 2 });
|
||||
game = act(game, f.target, { type: 'revive', amount: 1 });
|
||||
assert.equal(player(game).reserves, 1); assert.equal(game.movementPromises![0].released, undefined);
|
||||
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);
|
||||
});
|
||||
|
||||
void test('a controlled already-Ready Revival respondent can answer and use only its current remaining preparation', () => {
|
||||
const { f, game: initial } = bound({ phase: 4, minimum: 1, control: g => {
|
||||
needPreparation(g); player(g).spice = 1;
|
||||
} });
|
||||
assert.equal(initial.ready.includes(f.target), false);
|
||||
assert.deepEqual(viewGame(initial, f.target).movementPromisePolicy, [{ type: 'revive', amount: 1 }]);
|
||||
reject(initial, f.target, { type: 'ready' });
|
||||
const game = act(initial, f.target, { type: 'revive', amount: 1 });
|
||||
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'ready' }]);
|
||||
});
|
||||
|
||||
void test('controlled physical Karamas price the actual invoice separately from later shipment', () => {
|
||||
for (const count of [1, 2]) {
|
||||
const { f, game: initial } = bound({ control: g => { player(g).spice = 4; giveKarama(g, count); } });
|
||||
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
|
||||
if (count === 1) { reject(game, f.target, { type: 'bid', amount: 1 }); continue; }
|
||||
game = act(game, f.target, { type: 'bid', amount: 1 });
|
||||
game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target);
|
||||
reject(game, f.target, { type: 'decision', karama: false });
|
||||
const payment = viewGame(game, f.target).movementPromisePolicy![0];
|
||||
assert.equal(payment.karama, true);
|
||||
game = act(game, f.target, payment);
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
|
||||
const price = viewGame(game, f.target).movementPromisePolicy![0];
|
||||
assert.equal(price.type, 'card'); assert.notEqual(price.card, payment.card);
|
||||
assert.equal(player(game).hand.filter(c => c.effect === 'karama').length, 1);
|
||||
game = advance(act(game, f.target, price), clean);
|
||||
assert.equal(game.karamaShipping?.player, f.target);
|
||||
game = advance(act(game, f.target, f.shipment), clean);
|
||||
assert.equal(player(game).spice, 0); assert.equal(game.karamaShipping, null);
|
||||
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
||||
game = advance(act(game, f.target, f.move), clean);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled ally escrow waits for actual income or cancellation before releasing a movement shortfall', () => {
|
||||
for (const canceled of [false, true]) {
|
||||
const { f, game: initial } = bound({ control: allies, territory: 'imperial_basin' });
|
||||
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
|
||||
game = act(game, f.target, { type: 'pledgeAid', amount: 8 });
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = advance(game, g => g.phase === 3 && clean(g) && g.auction?.active === 'harkonnen');
|
||||
game = act(game, 'harkonnen', { type: 'bid', amount: 3, allyPayment: 3 });
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = advance(game, g => g.response?.kind === 'emperorIncome');
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
if (canceled) game = act(game, 'harkonnen', { type: 'card',
|
||||
card: player(game, 'harkonnen').hand.find(c => c.effect === 'karama')!.id, mode: 'cancel' });
|
||||
game = advance(game, g => g.phase === 4 && clean(g));
|
||||
assert.equal(player(game).spice, canceled ? 7 : 10);
|
||||
assert.equal(game.movementPromises![0].released, canceled ? true : undefined);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled all-No answers leave native Karama overbid payment available to every existing profile', () => {
|
||||
const { f, game: initial } = bound({ control: g => giveKarama(g) }, 'no');
|
||||
let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target);
|
||||
game = act(game, f.target, { type: 'bid', amount: 11 });
|
||||
game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target);
|
||||
assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.equal(action.karama, true);
|
||||
assert.doesNotThrow(() => applyAction(game, f.target, action));
|
||||
}
|
||||
});
|
||||
|
||||
void test('a controlled last Storm Ready resolves forced losses instead of rejecting its owner or preserving a false route', () => {
|
||||
const { f, game: initial } = bound({ phase: 0, territory: 'arrakeen', control: g => {
|
||||
const p = player(g); p.spice = 0; p.reserves = 0; p.tanks = 12; p.forces = { 'oh_gap:11': 8 };
|
||||
g.storm = 9;
|
||||
} });
|
||||
let game = initial;
|
||||
for (const [index, actor] of game.stormDialers.entries()) game = act(game, actor, { type: 'stormDial', amount: index === 0 ? 2 : 0 });
|
||||
assert.equal(game.stormPending, 2);
|
||||
for (const p of game.players) if (p.id !== f.target) game = act(game, p.id, { type: 'ready' });
|
||||
assert.equal(game.movementPromises![0].released, undefined);
|
||||
game = act(game, f.target, { type: 'ready' });
|
||||
assert.equal(player(game).forces['oh_gap:11'] ?? 0, 0); assert.equal(player(game).tanks, 20);
|
||||
assert.equal(game.movementPromises![0].released, true);
|
||||
});
|
||||
|
||||
function secondQuestion(game: Game, question: Record<string, unknown>): Game {
|
||||
// Controlled acquisition of another conserved original Truthtrance, without
|
||||
// staging an answer, future card, board, balance or movement allowance.
|
||||
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
||||
const card = game.deck.splice(index, 1)[0]; player(game, 'atreides').hand.push(card);
|
||||
game = act(game, 'atreides', { type: 'card', card: card.id });
|
||||
game = advance(game, g => g.truthtrance?.stage === 'ask');
|
||||
return act(game, 'atreides', { type: 'truthAsk', question });
|
||||
}
|
||||
void test('controlled prior Ornithopter credit expires at the real movement boundary and cannot supply a second promised move', () => {
|
||||
const { f, game: initial } = bound({ phase: 4, minimum: 2, control: g => {
|
||||
const p = player(g); p.spice = 0; p.reserves = 0; p.tanks = 18;
|
||||
p.forces = { 'arrakeen:10': 2 }; p.ornithopterExtraMoves = 1;
|
||||
} });
|
||||
let game = secondQuestion(initial, { kind: 'movement', target: f.target, territory: 'arrakeen', minimum: 2 });
|
||||
assert.equal(viewGame(game, f.target).truthMovementAnswers!.includes('yes'), false);
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
|
||||
assert.equal(player(game).ornithopterExtraMoves, undefined);
|
||||
game = advance(act(game, f.target, { ...f.move, amount: 2 }), clean);
|
||||
game = act(game, f.target, f.endMovement);
|
||||
assert.ok(game.movementPromises!.every(promise => promise.fulfilled));
|
||||
});
|
||||
|
||||
void test('a controlled second original question keeps an early shipment Yes in the combined movement witness', () => {
|
||||
const f = fixture();
|
||||
let game = act(f.game, f.asker, f.declarationStep.action);
|
||||
game = advance(game, g => g.truthtrance?.stage === 'ask');
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: { ...f.claim, kind: 'shipment' } });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = secondQuestion(game, f.claim);
|
||||
assert.equal(viewGame(game, f.target).truthMovementAnswers!.includes('yes'), false);
|
||||
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('no'));
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(game.shipmentPromises![0].released, undefined);
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target);
|
||||
const policy = viewGame(game, f.target).movementPromisePolicy![0]; assert.equal(policy.type, 'ship');
|
||||
assert.equal(policy.territory, 'carthag');
|
||||
game = advance(act(game, f.target, policy), clean);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled, true);
|
||||
game = act(game, f.target, f.endMovement);
|
||||
assert.equal(game.movementPromises![0].fulfilled, true);
|
||||
});
|
||||
Reference in new issue
Block a user