Support earlier combined shipment and movement promises
This commit is contained in:
1 parent
d4e82c74b1
commit
8dbec05844
18 files changed
+1321
-46
No files matched your search
@@ -13,21 +13,58 @@ export function TravelPromiseGuideButton(props: TravelProps) {
|
||||
const game = props.game, guide = game.travelPromiseGuide;
|
||||
if (!guide) return null;
|
||||
const actor = game.me, turn = game.turn, phase = game.phase, event = guide.event, action = guide.action;
|
||||
const signature = JSON.stringify(action);
|
||||
const signature = JSON.stringify(action), auction = game.auction, decision = game.decision;
|
||||
const native = game.richeseAuction, order = game.revivalOrder, revival = game.revival;
|
||||
const auctionSource = (current: GameView) => current.auction && JSON.stringify({
|
||||
bid: current.auction.bid, bidder: current.auction.bidder, active: current.auction.active,
|
||||
remaining: current.auction.remaining, allyPayment: current.auction.allyPayment,
|
||||
});
|
||||
const nativeSource = (current: GameView) => current.richeseAuction && JSON.stringify({
|
||||
event: current.richeseAuction.event, method: current.richeseAuction.method,
|
||||
eligible: current.richeseAuction.eligible, submitted: current.richeseAuction.submitted,
|
||||
active: current.richeseAuction.active, outcome: current.richeseAuction.outcome,
|
||||
bid: current.richeseAuction.bid, bidder: current.richeseAuction.bidder,
|
||||
passed: current.richeseAuction.passed, acted: current.richeseAuction.acted,
|
||||
});
|
||||
const auctionSignature = auctionSource(game), nativeSignature = nativeSource(game), orderSignature = JSON.stringify(order);
|
||||
const box = game.nullentropy, search = box?.search;
|
||||
const canAct = () => {
|
||||
const current = props.game, me = current.players.find(player => player.id === actor);
|
||||
const paidBox = action.type === 'decision' && current.decision?.kind === 'nullentropy' &&
|
||||
const paidBox = action.type === 'decision' && current.decision === decision &&
|
||||
current.decision?.kind === 'nullentropy' && current.nullentropy === box && current.nullentropy?.search === search &&
|
||||
current.decision.player === actor && !!current.nullentropy?.search &&
|
||||
current.nullentropy.search.event === action.event && !current.nullentropy.blocked;
|
||||
const opportunity = paidBox || action.type === 'guildShip' && action.from === 'reserves' ||
|
||||
['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type);
|
||||
const currentAuction = current.auction === auction && auctionSource(current) === auctionSignature && !!auction;
|
||||
const payment = phase === 3 && action.type === 'decision' && typeof action.karama === 'boolean' &&
|
||||
(!action.karama || typeof action.card === 'string') &&
|
||||
current.decision === decision && current.decision?.kind === 'auctionPayment' && current.decision.player === actor &&
|
||||
!current.richeseAuction && currentAuction && current.auction?.bidder === actor;
|
||||
const nativePass = phase === 3 && action.type === 'richeseBid' && current.richeseAuction === native &&
|
||||
nativeSource(current) === nativeSignature && native && native.event === action.event && !native.outcome &&
|
||||
native.eligible.includes(actor) && (native.method === 'silent'
|
||||
? !native.submitted.includes(actor) && action.amount === 0
|
||||
: native.active === actor && action.amount === null) && action.allyPayment === 0;
|
||||
const requiredBid = phase === 3 && action.type === 'bid' && !current.richeseAuction && currentAuction &&
|
||||
current.auction?.active === actor && typeof action.amount === 'number' && Number.isSafeInteger(action.amount) &&
|
||||
action.amount > 0;
|
||||
const revivalTurn = current.revival === revival && !current.revival.pending && !current.revival.orderBlocked &&
|
||||
current.revivalOrder === order && JSON.stringify(current.revivalOrder) === orderSignature &&
|
||||
(!order || order.turn === turn && order.remaining[0] === actor && current.active === actor);
|
||||
const opportunity = paidBox || (phase === 2
|
||||
? !current.ready.includes(actor) && ['charity', 'ready', 'card'].includes(action.type)
|
||||
: phase === 3
|
||||
? payment || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor
|
||||
: phase === 4
|
||||
? !current.ready.includes(actor) && revivalTurn && ['revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'ready', 'card'].includes(action.type)
|
||||
: phase === 5 && current.active === actor && (action.type === 'guildShip' && action.from === 'reserves' ||
|
||||
['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type)));
|
||||
return !props.busy && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' &&
|
||||
phase === 5 && current.phase === phase && current.turn === turn && current.me === actor && current.active === actor &&
|
||||
Number.isSafeInteger(phase) && phase >= 0 && phase <= 5 && current.phase === phase && current.turn === turn && current.me === actor &&
|
||||
current.travelPromiseGuide === guide && guide.event === event && guide.action === action &&
|
||||
action.travelPromiseEvent === event && JSON.stringify(action) === signature && opportunity &&
|
||||
action.travelPromiseEvent === event && action.travelPromisePhase === phase && JSON.stringify(action) === signature && opportunity &&
|
||||
current.travelPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released) &&
|
||||
!!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox) &&
|
||||
!!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || payment) &&
|
||||
!current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
|
||||
};
|
||||
const amount = action.forces && typeof action.forces === 'object'
|
||||
@@ -35,10 +72,27 @@ export function TravelPromiseGuideButton(props: TravelProps) {
|
||||
: typeof action.amount === 'number' ? action.amount : 0;
|
||||
const destination = game.combatLocations.find(location => location.id === action.territory)?.name ?? 'the quoted destination';
|
||||
const sector = typeof action.sector === 'number' ? `, sector ${action.sector}` : '';
|
||||
const label = action.type === 'ship' || action.type === 'guildShip' ? `Ship ${amount} physical forces from reserves to ${destination}${sector}${action.type === 'guildShip' ? ' using Guild transport' : ''} while honoring your whole answer`
|
||||
// A projected own face supplies the label only; the native guide owns legality.
|
||||
const paymentCard = action.type === 'decision' && action.karama === true
|
||||
? game.players.find(player => player.id === actor)?.hand?.find(card => card.id === action.card) : undefined;
|
||||
const label = action.type === 'charity' ? 'Claim Charity for your later shipment and movement'
|
||||
: action.type === 'ready' ? 'Finish this preparation phase while preserving your whole answer'
|
||||
: action.type === 'passBid' ? 'Pass this bid and preserve later shipment and movement'
|
||||
: action.type === 'bid' ? `Place the required bid of ${amount} while preserving later shipment and movement`
|
||||
: action.type === 'richeseBid' ? action.amount === 0
|
||||
? 'Submit zero in this Silent auction and preserve later shipment and movement'
|
||||
: 'Pass this Richese bid and preserve later shipment and movement'
|
||||
: action.type === 'revive' ? `Revive ${amount} forces for your later shipment and movement`
|
||||
: action.type === 'reviveLeader' ? 'Revive the quoted leader for your later shipment and movement'
|
||||
: action.type === 'acceptLeaderRevival' ? 'Accept the quoted leader revival for your later shipment and movement'
|
||||
: action.type === 'reviveKwisatz' ? 'Revive your Kwisatz Haderach for your later shipment and movement'
|
||||
: action.type === 'ship' || action.type === 'guildShip' ? `Ship ${amount} physical forces from reserves to ${destination}${sector}${action.type === 'guildShip' ? ' using Guild transport' : ''} while honoring your whole answer`
|
||||
: action.type === 'move' ? `Move ${amount} physical forces to ${destination}${sector} while honoring your whole answer`
|
||||
: action.type === 'endMovement' ? 'Finish movement while honoring your whole answer'
|
||||
: action.type === 'decision' ? 'Complete your paid Box search for your whole answer'
|
||||
: action.type === 'decision' ? game.decision?.kind === 'auctionPayment'
|
||||
? action.karama === true ? `Use ${paymentCard?.name ?? 'the quoted Karama'} for the current auction payment while preserving your whole answer`
|
||||
: 'Pay the current auction price while preserving your whole answer'
|
||||
: 'Complete your paid Box search for your whole answer'
|
||||
: action.type === 'prepareAdvisors' ? `Prepare advisors in ${destination} for your whole answer`
|
||||
: action.type === 'revealNoField' ? 'Reveal your No-Field to prepare physical forces for your whole answer'
|
||||
: 'Use the offered native card preparation for your whole answer';
|
||||
@@ -71,8 +125,9 @@ export function TravelPromises(props: TravelProps) {
|
||||
Earlier actions do not count. A shipment can prepare a move; each action counts only for its own conditions.
|
||||
Original shipment rights, movement opportunities, cards, preparation and costs still apply.
|
||||
This claim grants no extra shipment or movement.</p>
|
||||
{live && game.active !== game.me && <p>Your Movement turn is still ahead. Keep the needed forces, spice and cards available;
|
||||
native controls appear on your turn.</p>}
|
||||
{live && (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 offered current preparation below before confirming readiness.'}</p>}
|
||||
{live && <TravelPromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -85,7 +140,8 @@ export function TravelAnswers(props: TravelProps) {
|
||||
const signature = JSON.stringify(question), sourceSignature = JSON.stringify(source);
|
||||
const canAnswer = (answer: TruthAnswer) => {
|
||||
const current = props.game, me = current.players.find(player => player.id === actor);
|
||||
return !props.busy && current === game && current.status === 'playing' && phase === 5 && current.phase === phase &&
|
||||
return !props.busy && current === game && current.status === 'playing' &&
|
||||
Number.isSafeInteger(phase) && phase >= 0 && phase <= 5 && current.phase === phase &&
|
||||
current.turn === turn && current.me === actor && question.turn === turn &&
|
||||
current.truthtrance === window && window.stage === 'answer' && window.question === question && question.target === actor &&
|
||||
JSON.stringify(question) === signature && !!source && window.queue[0] === source && JSON.stringify(source) === sourceSignature &&
|
||||
|
||||
@@ -589,7 +589,7 @@ export function Truthtrance({
|
||||
<>
|
||||
<TravelClaimFields value={travel} game={g} target={target}
|
||||
disabled={busy || !g.truthTravelTargets.includes(target)} onChange={setTravel} />
|
||||
{!g.truthTravelTargets.includes(target) && <p className="notice">Choose an active or unused native Shipment and Movement opportunity in this phase.</p>}
|
||||
{!g.truthTravelTargets.includes(target) && <p className="notice">Choose this turn’s upcoming, active or unused native Shipment and Movement opportunity.</p>}
|
||||
<p className="notice" aria-label="Public travel question preview">{travelError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'mixedTraitorCall' ? (
|
||||
|
||||
@@ -16,6 +16,47 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Truthtrance / earlier combined shipment and movement — 26 October. Prototyped / Partial.**
|
||||
The combined travel question now uses the existing clean native phases0–5
|
||||
forecast in Basic/Advanced. Original current Charity/Revival/auction obligations
|
||||
share funding; required native bid/Revival actions precede pass or Ready.
|
||||
An old prior-turn shipped flag never counts as current progress or closes the
|
||||
future shipment. Every definite answer uniformly reopens only concurrent
|
||||
Charity/legacy Revival readiness, including constants; completed ordered
|
||||
Advanced Revival remains complete. Exact current-phase native packets include
|
||||
phase/event metadata, preserving owned cash/Karama invoices and paid Box search.
|
||||
Mandatory early progress can release impossibility; discretionary spending or
|
||||
completion cannot remove the last route. Private histories and four minimal
|
||||
profiles retain the original whole-answer/native-event meaning.
|
||||
|
||||
Root integration with disjoint controls/native lanes and bounded review passes
|
||||
**23 files,197/197**, types/lint/build and **three native paths**,25 GameTable SSR
|
||||
renders/three actual answer callbacks/16 guide callbacks. Original owned-Karama
|
||||
Revival-source Charity53 adds32 for native Ready, bid2, actual owned cash invoice,
|
||||
Revival and later paid ship2/ground2. Original Hajr Charity13 adds16 for frozen
|
||||
spice, real bribe3, paid shipment, ground move, physical Hajr and second move.
|
||||
Original earned post-Storm41 adds24: the real prior shipment flag remains true
|
||||
when answering, current progress starts empty, then the later ship/move fulfills.
|
||||
Original physical card inventories remain; no played source state is installed.
|
||||
|
||||
Nineteen native cases cover phases0–4, readiness, native auctions, current costs,
|
||||
prior-turn flags and four profiles; ten early control cases guard current source/
|
||||
phase/owner. Two genuine two-Truth bid cases retain funding and required bids in
|
||||
both orders. Three conserved controlled Revival cases reject double reservation
|
||||
and perform the required return before Ready, including four minimal profiles.
|
||||
V1 catches an allied-destination test assumption; the corrected Basin escrow
|
||||
case defers release to actual foreign income/cancellation. Native bid/Revival
|
||||
resource and guide repairs pass focused regressions. V2 passes all source checks;
|
||||
only its private smoke driver over-settles the invoice. Corrected runtime v3
|
||||
passes on the identical snapshot and explicitly reuses v2's unchanged checks.
|
||||
Proof/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-travel-early-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-truth-travel-early-runtime/`.
|
||||
No saved-game/database/server/reset/restart/deployment operation is performed.
|
||||
Other cross-action families, wider preparation/module compositions and final
|
||||
rules/browser/deployed/assurance remain. No complete mode is certified.
|
||||
[Authority, early forecast and source-bound evidence](TRUTHTRANCE_TRAVEL_PROMISES.md#earlier-native-phases--26-october-2026).
|
||||
|
||||
**Truthtrance / combined shipment and physical movement — 26 October. Prototyped / Partial.**
|
||||
One whole AND/OR answer now combines the future original reserve shipment and
|
||||
physical ground groups, optionally with frozen current facts. Distinct receipts
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Earlier combined shipment and movement commitments
|
||||
|
||||
**Prototyped / Partial.**
|
||||
The combined travel question now uses the existing clean native phases0–5
|
||||
forecast in Basic/Advanced. Original current Charity/Revival/auction obligations
|
||||
share funding; required native bid/Revival actions precede pass or Ready.
|
||||
An old prior-turn shipped flag never counts as current progress or closes the
|
||||
future shipment. Every definite answer uniformly reopens only concurrent
|
||||
Charity/legacy Revival readiness, including constants; completed ordered
|
||||
Advanced Revival remains complete. Exact current-phase native packets include
|
||||
phase/event metadata, preserving owned cash/Karama invoices and paid Box search.
|
||||
Mandatory early progress can release impossibility; discretionary spending or
|
||||
completion cannot remove the last route. Private histories and four minimal
|
||||
profiles retain the original whole-answer/native-event meaning.
|
||||
|
||||
Root integration with disjoint controls/native lanes and bounded review passes
|
||||
**23 files,197/197**, types/lint/build and **three native paths**,25 GameTable SSR
|
||||
renders/three actual answer callbacks/16 guide callbacks. Original owned-Karama
|
||||
Revival-source Charity53 adds32 for native Ready, bid2, actual owned cash invoice,
|
||||
Revival and later paid ship2/ground2. Original Hajr Charity13 adds16 for frozen
|
||||
spice, real bribe3, paid shipment, ground move, physical Hajr and second move.
|
||||
Original earned post-Storm41 adds24: the real prior shipment flag remains true
|
||||
when answering, current progress starts empty, then the later ship/move fulfills.
|
||||
Original physical card inventories remain; no played source state is installed.
|
||||
|
||||
Nineteen native cases cover phases0–4, readiness, native auctions, current costs,
|
||||
prior-turn flags and four profiles; ten early control cases guard current source/
|
||||
phase/owner. Two genuine two-Truth bid cases retain funding and required bids in
|
||||
both orders. Three conserved controlled Revival cases reject double reservation
|
||||
and perform the required return before Ready, including four minimal profiles.
|
||||
V1 catches an allied-destination test assumption; the corrected Basin escrow
|
||||
case defers release to actual foreign income/cancellation. Native bid/Revival
|
||||
resource and guide repairs pass focused regressions. V2 passes all source checks;
|
||||
only its private smoke driver over-settles the invoice. Corrected runtime v3
|
||||
passes on the identical snapshot and explicitly reuses v2's unchanged checks.
|
||||
Proof/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-travel-early-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-truth-travel-early-runtime/`.
|
||||
No saved-game/database/server/reset/restart/deployment operation is performed.
|
||||
Other cross-action families, wider preparation/module compositions and final
|
||||
rules/browser/deployed/assurance remain. No complete mode is certified.
|
||||
[Authority, early forecast and source-bound evidence](TRUTHTRANCE_TRAVEL_PROMISES.md#earlier-native-phases--26-october-2026).
|
||||
|
||||
|
||||
## 26 October 2026 — Combined shipment and physical movement commitments
|
||||
|
||||
**Prototyped / Partial.**
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Rule decision index
|
||||
|
||||
## Earlier combined travel and native preparation — 26 October 2026
|
||||
|
||||
Extend the recorded combined shipment/ground prototype through the existing
|
||||
native phases0–5 envelope, reusing the provisional owned-stage forecast and
|
||||
publisher FAQp8/current-turn plus supplied Advanced physical39 authority.
|
||||
Prior-turn shipment flags never become current future-event progress. Earlier
|
||||
Charity/Revival readiness uniformly reopens for every definite answer, including
|
||||
constants, so public Ready does not disclose a frozen private branch. Completed
|
||||
ordered Advanced Revival stays complete. Prior Revival/bribe obligations share
|
||||
funding with travel. Current-phase native guides carry exact phase/event source;
|
||||
mandatory early progress can release impossibility, discretionary spending or
|
||||
preparation completion cannot evade it. Broader cross-action/module/full-mode
|
||||
acceptance remains unfinished. [Contract/evidence](TRUTHTRANCE_TRAVEL_PROMISES.md#earlier-native-phases--26-october-2026).
|
||||
|
||||
## Combined shipment and physical movement commitments — 26 October 2026
|
||||
|
||||
Publisher November2020 FAQp8 permits current-turn AND/OR action commitments;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Truthtrance nonbattle readiness and first implementation slice
|
||||
|
||||
The [combined travel follow-up](TRUTHTRANCE_TRAVEL_PROMISES.md) adds full AND/OR
|
||||
shipment and physical ground-movement conditions in the current native phase5
|
||||
interval, optionally with frozen facts. Each event retains its physical meaning;
|
||||
other cross-action families, earlier timing and broader enforcement remain open.
|
||||
shipment and physical ground-movement conditions, optionally with frozen facts.
|
||||
Its later early-phase follow-up uses the existing native phases0–5 envelope and
|
||||
current preparation controls. Each event retains its physical meaning; other
|
||||
cross-action families, wider timing/compositions and full enforcement remain open.
|
||||
|
||||
The [mixed current Traitor-decision follow-up](TRUTHTRANCE_MIXED_TRAITOR.md)
|
||||
combines answer-time facts with current revealed native Reveal/Decline choices.
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
# Truthtrance combined shipment and physical movement
|
||||
|
||||
## Earlier native phases — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** The same raw question now uses the existing clean
|
||||
native movement-target envelope in Storm, Spice Blow, Charity, Bidding, Revival
|
||||
and Shipment and Movement (phases0–5), in both Basic and Advanced. Registered
|
||||
factions retain their existing selected native-deck and optional-module scope.
|
||||
This supersedes only the earlier phase5 entry restriction documented below.
|
||||
No shipment, movement, payment or card action happens merely by answering.
|
||||
|
||||
Reuse the existing owned-stage forecast described in
|
||||
[earlier native movement](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-early-advanced-and-choam-opening--26-october-2026).
|
||||
Original Charity/normal Revival/current auction liabilities and owned known
|
||||
preparations remain authoritative. No unknown future card, foreign gift or
|
||||
favorable future Storm supplies a witness. Prior Revival and bribe obligations
|
||||
share the same future purse; a final Revival aggregate must retain combined
|
||||
travel feasibility rather than spend the same spice twice.
|
||||
|
||||
An early record starts with its future shipment pending even when the actual
|
||||
player still has last turn's `shipped` flag. That old physical event never
|
||||
counts as this answer's progress. Original phase5 initialization remains in
|
||||
charge of real allowances. Actual current-turn native shipment/ground hooks
|
||||
record the same distinct receipts and physical maxima; no new event is granted.
|
||||
|
||||
A definite answer uniformly reopens only the respondent's concurrent Charity
|
||||
or legacy Revival readiness, including constant answers. This avoids exposing
|
||||
a private fact branch through a public Ready difference. Constant answers are
|
||||
still terminal without action receipts or a private future guide. Ordered
|
||||
Advanced Revival never reopens an already completed owned turn. A required
|
||||
owned preparation or optional spending must preserve the last legal route;
|
||||
mandatory early progress and completed opposing effects can release actual
|
||||
impossibility rather than trapping the actor. Pending native outcomes retain
|
||||
priority.
|
||||
|
||||
The private guide now offers only an entitled action in the current phase:
|
||||
Charity, Ready, ordinary Revival, current native bid pass or an owned invoice,
|
||||
then the existing phase5 transport/movement/card controls. Original cash or
|
||||
physical Karama payment remains a choice of the actual invoice; exact projected
|
||||
packets preserve that source. Required native bid/Revival commitments take
|
||||
priority over passing or Ready in the guide; their native joint completion
|
||||
retains travel funding. Pending allied auction escrow waits for its actual
|
||||
foreign payment/income outcome before impossible travel can release. A source-bound paid Box search retains its actual
|
||||
phase. No future transport is exposed before phase5. Each packet includes
|
||||
`travelPromisePhase` as well as its event: an old Charity Ready cannot be
|
||||
replayed as a later Revival Ready. The server and saved callbacks verify the
|
||||
current source and exact packet. Four minimal profiles use that same path.
|
||||
|
||||
New source/control evidence: `tests/fixture-truth-travel-early.ts`,
|
||||
`tests/truth-travel-early-runtime.test.ts`,
|
||||
`tests/truth-travel-early-controls.test.tsx`. Separate conserved controlled
|
||||
`tests/truth-travel-early-revival.test.ts` cases actually declare both original
|
||||
Truth cards in opposite acceptance orders to reject a shared-purse conflict;
|
||||
they claim no setup/casualty history. Native
|
||||
`tests/truth-travel-early-bid.test.ts` uses genuine Harkonnen opening custody to
|
||||
protect pending bid funding and legal required declarations in both answer
|
||||
orders.
|
||||
|
||||
Frozen verification passes **23 files,197/197**, types/lint/build and three native
|
||||
runtime journeys with 25 GameTable SSR renders, three actual answer callbacks and
|
||||
16 guide callbacks. Original owned-Karama Charity53 adds32: real bid2 and current
|
||||
cash invoice precede Revival and later paid ship2/ground2. Hajr Charity13 adds16:
|
||||
frozen spice, actual bribe3, paid shipment and two real groups with original Hajr.
|
||||
Earned post-Storm41 adds24, retaining its true prior shipped flag while the new
|
||||
future shipment remains pending with empty progress. Every original physical
|
||||
card inventory remains. Nineteen native and ten early control cases supplement
|
||||
existing movement/shipment/Revival/funding evidence. Two original Harkonnen
|
||||
opening-custody bid cases exercise both answer orders; three conserved controlled
|
||||
Revival cases exercise incompatibility and required actual returns. Four minimal
|
||||
profiles consume legal current-phase guides.
|
||||
|
||||
V1 source feedback finds a controlled ally's Carthag garrison invalidates the
|
||||
chosen destination, so the escrow case uses the original audit's Basin route.
|
||||
Actual foreign invoice/income deferral and native required-bid/Revival guide/
|
||||
funding corrections pass focused regressions. V2 passes types/lint, the full
|
||||
197-case union and build. Its private smoke driver then auto-settles an owned
|
||||
invoice before inspecting it. Only that driver changes; corrected runtime v3
|
||||
advances one native action at a time. `verified-proof.json` explicitly reuses
|
||||
v2's three passing checks on an identical before/after source snapshot.
|
||||
Proof and per-file manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-travel-early-proof-v3/`;
|
||||
journeys/HTML: adjacent `20261026-truth-travel-early-runtime/`.
|
||||
This is local engine/SSR/actual-callback evidence, not DOM/hydration/browser/
|
||||
mobile/deployed acceptance or complete-mode certification.
|
||||
Other cross-action families, wider owned preparations/module compositions and
|
||||
final rules/browser/mobile/deployed/recovery/privacy/custody acceptance remain.
|
||||
|
||||
## Native travel prototype — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** The question is `{kind:'travel',target,turn,mixed}`.
|
||||
|
||||
+2
-1
@@ -4263,7 +4263,8 @@ export function botActions(g: GameView): Action[] {
|
||||
if (g.movementExpressionPromiseGuide && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
|
||||
return [g.movementExpressionPromiseGuide.action];
|
||||
if (g.travelPromiseGuide && g.status === 'playing' && g.phase === 5 && g.active === g.me &&
|
||||
if (g.travelPromiseGuide && g.status === 'playing' && g.phase >= 0 && g.phase <= 5 &&
|
||||
(g.phase < 5 || g.active === g.me) &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
|
||||
return [g.travelPromiseGuide.action];
|
||||
if (g.movementPromisePolicy && g.status === 'playing' &&
|
||||
|
||||
+58
-17
@@ -15708,6 +15708,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
|
||||
// The detached accepted declaration satisfies its own Bid Yes before
|
||||
// another promise checks the remaining purse; it is not a second bid.
|
||||
trial.bidPromises = recordPromisedBid(promises, p.id, opportunity, action.amount as number);
|
||||
reconcileTravelPromises(trial, p.id, action, g.phase);
|
||||
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
|
||||
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
|
||||
reconcileRevivalPromises(trial, { actor: p.id, action });
|
||||
@@ -18661,7 +18662,7 @@ function travelRule<T>(operation: () => T): T {
|
||||
catch (error) { if (error instanceof TravelPromiseError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function travelTargetAvailable(g: Game, actor: string): boolean {
|
||||
return g.phase === 5 && movementPromiseTargetAvailable(g, actor);
|
||||
return movementPromiseTargetAvailable(g, actor);
|
||||
}
|
||||
function appendTravelOrder(g: Game, kind: NonNullable<Game['travelPromiseOrder']>[number]['kind'], index: number) {
|
||||
g.travelPromiseOrder?.push({ kind, index });
|
||||
@@ -18717,10 +18718,18 @@ function travelTruthAnswers(g: Game, p: Player, claim: TravelClaim): TruthAnswer
|
||||
if (compiled.kind === 'constant' && compiled.value !== answer) continue;
|
||||
const promise: TravelPromise = { ...structuredClone(claim), event: 'candidate', player: p.id,
|
||||
asker: g.players.find(player => player.id !== p.id)!.id, answer, compiled,
|
||||
shipment: { pending: shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
|
||||
shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
|
||||
fulfilled: compiled.kind === 'constant', released: false };
|
||||
const trial = { ...g, travelPromises: [...(g.travelPromises ?? []), promise] };
|
||||
if (travelCompletion(trial, p.id) && bribeCompatibleLaterPromises(trial, p, {})) answers.push(answer ? 'yes' : 'no');
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder)
|
||||
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
// Candidate witnesses must include the new row, which has no accepted-order
|
||||
// slot until the real answer binds. The authoritative order stays untouched.
|
||||
const trial = { ...answerSource, travelPromises: [...(g.travelPromises ?? []), promise], travelPromiseOrder: undefined };
|
||||
const lot = bidOpportunity(trial);
|
||||
if (travelCompletion(trial, p.id) && revivalCompatibleLaterPromises(trial, p, {}) &&
|
||||
bribeCompatibleLaterPromises(trial, p, {}) &&
|
||||
(!lot || !bidRequired(trial.bidPromises ?? [], p.id, lot) || !!bidPromiseCompletion(trial, p)))
|
||||
answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
@@ -18729,30 +18738,39 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean
|
||||
'Choose a whole travel answer compatible with the original shipment and ground movements.');
|
||||
const compiled = compileMixedActionExpression(claim.mixed,
|
||||
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
|
||||
reopenMovementPreparation(g, p);
|
||||
initializeTravelOrder(g);
|
||||
(g.travelPromises ??= []).push({ ...structuredClone(claim), event: crypto.randomUUID(), player: p.id, asker, answer,
|
||||
compiled, shipment: { pending: shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
|
||||
compiled, shipment: { pending: g.phase < 5 || shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false,
|
||||
fulfilled: compiled.kind === 'constant', released: false });
|
||||
appendTravelOrder(g, 'travel', g.travelPromises!.length - 1);
|
||||
}
|
||||
function reconcileTravelPromises(g: Game, actor?: string, action?: Action) {
|
||||
function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
|
||||
if (!g.travelPromises?.length) return;
|
||||
travelRule(() => validateTravelPromises(g.travelPromises!, g.players.map(player => player.id)));
|
||||
for (const promise of g.travelPromises) if (!promise.fulfilled && !promise.released &&
|
||||
(promise.turn !== g.turn || g.phase > 5 || g.status !== 'playing')) promise.released = true;
|
||||
if (g.phase !== 5) return;
|
||||
if (g.phase < 0 || g.phase > 5) return;
|
||||
initializeTravelOrder(g);
|
||||
for (const p of g.players) {
|
||||
if (!liveTravelPromises(g.travelPromises, p.id, g.turn).length) continue;
|
||||
const voluntary = actor === p.id && action?.type !== 'passResponse';
|
||||
if (g.truthtrance || g.phaseOpening || g.pendingFremenMove?.order.player === p.id ||
|
||||
const early = g.phase < 5 || sourcePhase < 5;
|
||||
const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action &&
|
||||
(['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive',
|
||||
'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) ||
|
||||
action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 ||
|
||||
action.type === 'ready' && [2, 4].includes(sourcePhase)));
|
||||
if (g.truthtrance || !voluntary && g.phaseOpening || g.pendingFremenMove?.order.player === p.id ||
|
||||
g.pendingIxMove?.player === p.id || g.pendingChoamMove?.player === p.id ||
|
||||
g.pendingAdvisorMatching?.intent.kind === 'movement' && g.pendingAdvisorMatching.intent.order.player === p.id ||
|
||||
g.response?.kind === 'advisorFlip' && g.response.owner === p.id ||
|
||||
g.pendingAutomaticAdvisorFlips?.some(source => source.owner === p.id ||
|
||||
source.producer === 'groundMovement' && source.mover === p.id)) continue;
|
||||
if (!voluntary && (g.response || g.decision || g.pendingAmbassador || g.pendingTerrorEntry ||
|
||||
g.pendingArrivalOverlap || g.pendingArrivalReaction?.length || g.pendingTreacheryDiscard)) continue;
|
||||
g.pendingArrivalOverlap || g.pendingArrivalReaction?.length || g.pendingTreacheryDiscard ||
|
||||
g.phase === 3 && !g.currentAuctionSale &&
|
||||
(g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 ||
|
||||
g.richeseAuction && !g.richeseAuction.outcome))) continue;
|
||||
const shipments: ShipmentPromise[] = [], movements: MovementPromise[] = [], expressions: MovementExpressionPromise[] = [], travel: TravelPromise[] = [];
|
||||
for (const entry of g.travelPromiseOrder!) {
|
||||
const ledger = entry.kind === 'shipment' ? g.shipmentPromises : entry.kind === 'movement' ? g.movementPromises
|
||||
@@ -18780,9 +18798,31 @@ function projectedTravelPromises(g: Game, actor: string) {
|
||||
}
|
||||
function projectedTravelGuide(g: Game, player: Player): { event: string; action: Action } | null {
|
||||
const promise = liveTravelPromises(g.travelPromises ?? [], player.id, g.turn)[0];
|
||||
const policy = promise && g.phase === 5 && g.active === player.id ? projectedGroundMovementPolicy(g, player) : null;
|
||||
const lot = promise && g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
|
||||
const needsBid = !!lot && bidRequired(g.bidPromises ?? [], player.id, lot);
|
||||
const requiredBid = needsBid ? projectedBidPromiseGuide(g, player.id) : null;
|
||||
const needsRevival = !!promise && g.phase <= 4 && [
|
||||
...liveRevivalPromises(g.revivalPromises ?? [], player.id, g.turn),
|
||||
...liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], player.id, g.turn),
|
||||
...liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player.id, g.turn),
|
||||
].some(record => record.answer);
|
||||
const requiredRevival = !requiredBid && needsRevival
|
||||
? projectedRevivalExpressionPromiseGuide(g, player.id) ?? projectedLeaderRevivalPromiseGuide(g, player.id) ??
|
||||
projectedRevivalPromiseGuide(g, player.id) : null;
|
||||
const prior = requiredBid ?? requiredRevival;
|
||||
const paidBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id;
|
||||
if (promise && (needsBid || needsRevival) && !paidBox) {
|
||||
if (!prior) return null;
|
||||
const action = { ...structuredClone(prior.action) };
|
||||
delete action.bidPromiseEvent;
|
||||
delete action.revivalExpressionPromiseEvent;
|
||||
delete action.leaderRevivalPromiseEvent;
|
||||
delete action.revivalPromiseEvent;
|
||||
return { event: promise.event, action: { ...action, travelPromiseEvent: promise.event, travelPromisePhase: g.phase } };
|
||||
}
|
||||
const policy = promise && (g.phase < 5 || g.active === player.id) ? projectedGroundMovementPolicy(g, player) : null;
|
||||
const action = policy?.[0];
|
||||
return promise && action ? { event: promise.event, action: { ...structuredClone(action), travelPromiseEvent: promise.event } } : null;
|
||||
return promise && action ? { event: promise.event, action: { ...structuredClone(action), travelPromiseEvent: promise.event, travelPromisePhase: g.phase } } : null;
|
||||
}
|
||||
function groundMovementCompletion(source: Game, actor: string,
|
||||
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
|
||||
@@ -27617,7 +27657,7 @@ function finishShipmentPromises(
|
||||
shipment: { territory: string; amount: number } | null,
|
||||
) {
|
||||
checkShipmentPromises(g, p, shipment);
|
||||
if (g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn,
|
||||
if (g.phase === 5 && g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn,
|
||||
shipment ? { territory: shipment.territory, amount: shipment.amount } : null));
|
||||
for (const promise of liveShipmentPromises(
|
||||
g.shipmentPromises ?? [],
|
||||
@@ -27639,7 +27679,7 @@ function ownGuildStopPromiseBlock(g: Game, p: Player): string | null {
|
||||
* the respondent's promise cannot veto the opponent's printed power. */
|
||||
function settleStoppedShipmentPromises(g: Game, p: Player, actor: string) {
|
||||
if (actor === p.id) checkShipmentPromises(g, p, null);
|
||||
if (g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn, null));
|
||||
if (g.phase === 5 && g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn, null));
|
||||
for (const promise of liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn)) {
|
||||
if (matchesShipment(promise, null) === promise.answer) promise.fulfilled = true;
|
||||
else {
|
||||
@@ -31016,6 +31056,7 @@ function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival)
|
||||
if (!trial.ready.includes(p.id)) trial.ready.push(p.id);
|
||||
try {
|
||||
const action = { type: 'ready' };
|
||||
reconcileTravelPromises(trial, p.id, action, g.phase);
|
||||
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
|
||||
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
|
||||
return true;
|
||||
@@ -36316,9 +36357,9 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
|
||||
'This alliance commitment guide is stale or belongs to another Nexus choice.');
|
||||
}
|
||||
if (action.travelPromiseEvent !== undefined) {
|
||||
if (action.travelPromiseEvent !== undefined || action.travelPromisePhase !== undefined) {
|
||||
const guide = projectedTravelGuide(state, getPlayer(state, id));
|
||||
requireRule(guide && movementGuideActionMatches(action, guide.action),
|
||||
requireRule(guide && action.travelPromisePhase === state.phase && movementGuideActionMatches(action, guide.action),
|
||||
'This travel guide is stale or belongs to another opportunity.');
|
||||
}
|
||||
if (action.movementExpressionPromiseEvent !== undefined) {
|
||||
@@ -36351,7 +36392,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
'This revival commitment guide is stale or belongs to another native opportunity.');
|
||||
}
|
||||
const g = applyActionInner(state, id, action);
|
||||
reconcileTravelPromises(g, id, action);
|
||||
reconcileTravelPromises(g, id, action, state.phase);
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
reconcileBribePromises(g, { actor: id, action });
|
||||
|
||||
+6
-6
@@ -3191,20 +3191,20 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
|
||||
summary: 'Combine future native reserve shipment and physical ground-movement conditions in one whole answer, optionally with current facts.',
|
||||
steps: [
|
||||
'During Shipment and Movement, choose Combine this turn’s shipment and ground movements and an active or unused waiting native respondent. Full AND/OR groups can include ship, move and optional current-fact clauses. Earlier-phase and other cross-action questions remain unfinished.',
|
||||
'Each shipment condition refers to the same one future original reserve shipment after answering. A used or skipped shipment does not count. Moving, cross-planet transport and reserve shipment retain their original distinct meanings and costs.',
|
||||
'From Storm through Shipment and Movement, choose Combine this turn’s shipment and ground movements for a native respondent with an upcoming or active opportunity. Full AND/OR groups can include ship, move and optional current-fact clauses. Other action families and wider compositions remain unfinished.',
|
||||
'Each shipment condition refers to the same one future original reserve shipment after answering. A current-turn shipment used before answering does not count; last turn’s shipment does not close this turn’s upcoming opportunity. Moving, cross-planet transport and reserve shipment retain their original distinct meanings and costs.',
|
||||
'Each movement minimum requires one actual future physical ground group to that destination. Smaller moves never add together. Original owned Hajr, Ornithopter, card preparation, types, routes and movement allowances stay authoritative.',
|
||||
'Yes requires the whole expression; OR preserves legal alternatives. No negates the whole question and stays binding through your actual movement end. A qualifying Yes completes immediately. Original legal actions must leave a possible completion; an opposing completed effect may release impossibility.',
|
||||
'Current facts freeze at the actual answer and impose no promise to keep the same holdings. Unknown facts require an aggregate guaranteed without guessing; constant answers need no future action. Separate frozen values and private branch choices are not published.',
|
||||
'Review the exact native next step or use another legal manual route. Private own history persists after the interval. Four minimal profiles participate legally; complete modes, broader compositions and final deployed acceptance remain unfinished.',
|
||||
'Review the entitled current-phase preparation, bid pass or owned invoice, then the native shipment/movement step; other legal manual routes remain. A definite answer uniformly reopens concurrent Charity or legacy Revival readiness, including constants. Completed ordered Advanced Revival stays complete. Prior Revival and bribe payments retain their funding. Private own history persists; four minimal profiles participate legally. Complete modes, broader compositions and final deployed acceptance remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-shipment', 'truthtrance-movement-expression', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Private signed branches share the original shipment and movement completion search, recording distinct native physical receipts and accepted-order release.', evidence: ['game/travel-expression-promises.ts', 'game/mixed-action-question.ts', 'game/movement-shipment-preparation.ts', 'game/engine.ts', 'game/truthtrance.ts', 'tests/travel-expression-promises.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full optional-fact editor, raw Ask/private answers, persistent aggregate history and exact guarded native guide.', evidence: ['components/travel-claim-fields.tsx', 'components/travel-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-travel-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume the native guide; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-travel-runtime.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full optional-fact editor, raw Ask/private answers, persistent aggregate history and exact guarded native guide.', evidence: ['components/travel-claim-fields.tsx', 'components/travel-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-travel-controls.test.tsx', 'tests/truth-travel-early-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume the native guide; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-travel-runtime.test.ts', 'tests/truth-travel-early-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Single-shipment, physical group maxima, whole No, snapshot, native order and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TRAVEL_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper/control/native journeys; broader optional/faction/preparation/cross-action and deployed acceptance remain open.', evidence: ['tests/fixture-truth-travel.ts', 'tests/truth-travel-runtime.test.ts', 'tests/fixture-truth-travel-order.ts', 'tests/truth-travel-order.test.ts', 'tests/truth-travel-guild-fremen.test.ts', 'tests/truth-travel-controls.test.tsx', 'tests/travel-expression-promises.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper/control/native journeys; broader optional/faction/preparation/cross-action and deployed acceptance remain open.', evidence: ['tests/fixture-truth-travel.ts', 'tests/truth-travel-runtime.test.ts', 'tests/fixture-truth-travel-order.ts', 'tests/truth-travel-order.test.ts', 'tests/truth-travel-guild-fremen.test.ts', 'tests/truth-travel-controls.test.tsx', 'tests/travel-expression-promises.test.ts', 'tests/fixture-truth-travel-early.ts', 'tests/truth-travel-early-runtime.test.ts', 'tests/truth-travel-early-revival.test.ts', 'tests/truth-travel-early-bid.test.ts', 'tests/truth-travel-early-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -374,8 +374,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
}
|
||||
if (v.kind === 'travel') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && g.status === 'playing' &&
|
||||
g.phase === 5 && v.turn === g.turn,
|
||||
'Choose a current Shipment and Movement turn for this combined travel question.');
|
||||
Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 5 && v.turn === g.turn,
|
||||
'Choose this turn’s upcoming or active Shipment and Movement for this combined travel question.');
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
return { kind: 'travel', target: v.target as string, turn: g.turn,
|
||||
mixed: parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), parseTravelActionClaim,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { createTruthMovementEarlyFixture } from './fixture-truth-movement-early';
|
||||
import {
|
||||
createTruthMovementNativeEarlyFixture, nextTruthMovementNativeEarlyStep,
|
||||
type TruthMovementNativeEarlyAuction, type TruthMovementNativeEarlyRoster,
|
||||
} from './fixture-truth-movement-native-early';
|
||||
import { createTruthMixedMovementFixture } from './fixture-truth-mixed-movement';
|
||||
import { createTruthRevivalEarlyFixture } from './fixture-truth-revival-early';
|
||||
import {
|
||||
actTruthTravel, truthTravelClean, truthTravelFact, truthTravelGroup,
|
||||
truthTravelInventory, truthTravelMove, truthTravelPlayer, truthTravelPromise,
|
||||
truthTravelShip, type TruthTravelExpression, type TruthTravelStep,
|
||||
} from './fixture-truth-travel';
|
||||
|
||||
export type TruthTravelEarlyStep = TruthTravelStep;
|
||||
export const actTruthTravelEarly = actTruthTravel;
|
||||
export const truthTravelEarlyClean = truthTravelClean;
|
||||
export const truthTravelEarlyInventory = truthTravelInventory;
|
||||
export const truthTravelEarlyPlayer = truthTravelPlayer;
|
||||
export const truthTravelEarlyPromise = truthTravelPromise;
|
||||
export { truthTravelFact, truthTravelGroup, truthTravelMove, truthTravelShip };
|
||||
export type { TruthTravelExpression };
|
||||
|
||||
/** Replay current native controls. A private guide always belongs to this
|
||||
* phase and event; the source's real auction and response controls do the rest. */
|
||||
export function nextTruthTravelEarly(game: Game,
|
||||
auction: TruthMovementNativeEarlyAuction = {}): TruthTravelEarlyStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
for (const player of game.players) {
|
||||
const guide = viewGame(game, player.id).travelPromiseGuide;
|
||||
if (guide) return { actor: player.id, action: guide.action };
|
||||
}
|
||||
if (game.players.some(player => ['richese', 'choam', 'ecaz', 'moritani', 'ixians', 'tleilaxu'].includes(player.faction)))
|
||||
return nextTruthMovementNativeEarlyStep(game, auction);
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'revivalStop')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthTravelEarly(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthTravelEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original early travel source ended before its native boundary');
|
||||
const next = nextTruthTravelEarly(game, auction);
|
||||
assert.ok(next, 'Supply the actual Truthtrance question, answer or owned movement before continuing');
|
||||
game = actTruthTravelEarly(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original early travel source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthTravelEarly(game: Game, actions: TruthTravelEarlyStep[] = [],
|
||||
auction: TruthMovementNativeEarlyAuction = {}): Game {
|
||||
return advanceTruthTravelEarly(game, state => truthTravelEarlyClean(state) && !state.pendingShipment &&
|
||||
!state.pendingRevival, actions, auction);
|
||||
}
|
||||
|
||||
/** Original sources only. Earlier snapshots exclude the later factory suffix;
|
||||
* unopened Spice selections are reapplied before their real phase controls.
|
||||
* The earned Revival source includes a real paid shipment and Storm casualties,
|
||||
* so its previous-turn shipment flag needs no controlled installation. */
|
||||
export function createTruthTravelEarlyFixture(options: {
|
||||
source?: 'classic' | 'hajr' | 'native' | 'revival'; phase?: 0 | 1 | 2 | 3 | 4;
|
||||
advanced?: boolean; hajr?: boolean; poor?: boolean; roster?: TruthMovementNativeEarlyRoster;
|
||||
targetKarama?: boolean; minimum?: number; mixed?: TruthTravelExpression;
|
||||
} = {}) {
|
||||
const kind = options.source ?? 'classic', phase = options.phase ?? 2;
|
||||
const classicSource = kind === 'classic'
|
||||
? createTruthMovementEarlyFixture({ phase: phase < 2 ? 0 : 2 }) : null;
|
||||
const hajrSource = kind === 'hajr'
|
||||
? createTruthMixedMovementFixture({ advanced: options.advanced, hajr: options.hajr, window: 'charity' }) : null;
|
||||
const nativeSource = kind === 'native' ? createTruthMovementNativeEarlyFixture({ roster: options.roster }) : null;
|
||||
const revivalSource = kind === 'revival' ? createTruthRevivalEarlyFixture({
|
||||
advanced: options.advanced, phase: phase === 4 ? 3 : phase, poor: options.poor,
|
||||
targetKarama: options.targetKarama,
|
||||
}) : null;
|
||||
const original = classicSource ?? hajrSource ?? nativeSource ?? revivalSource!;
|
||||
let game = structuredClone(original.game), actions: TruthTravelEarlyStep[] = structuredClone(original.actions);
|
||||
const initialPrefixCount = actions.length;
|
||||
const selectedSpice: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
if (phase < 2 && (hajrSource || nativeSource)) {
|
||||
const base = hajrSource ?? nativeSource!;
|
||||
assert.ok(base.afterSetupActionCount !== null);
|
||||
game = structuredClone(base.afterSetup);
|
||||
actions = structuredClone(base.actions.slice(0, base.afterSetupActionCount));
|
||||
for (const { position, card } of base.spiceOrder) {
|
||||
const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
|
||||
JSON.stringify(candidate) === JSON.stringify(card));
|
||||
assert.ok(from >= position, 'Select the same original still-unopened Spice card');
|
||||
const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected);
|
||||
selectedSpice.push({ position, from, card: structuredClone(selected) });
|
||||
}
|
||||
}
|
||||
if (game.phase !== phase) game = advanceTruthTravelEarly(game,
|
||||
state => state.phase === phase && truthTravelEarlyClean(state), actions);
|
||||
const asker = original.asker, target = original.target;
|
||||
const truth = classicSource?.truth ?? hajrSource?.truth ?? nativeSource?.truth ?? revivalSource!.truth;
|
||||
const originalCards = original.originalCards;
|
||||
const minimum = options.minimum ?? (hajrSource ? 1 : revivalSource ? 2 : 8);
|
||||
const shipmentAction: Action = hajrSource
|
||||
? { type: 'ship', territory: 'polar_sink', sector: 0, amount: minimum, elite: 0, allyPayment: 0 }
|
||||
: { type: 'ship', territory: 'arrakeen', sector: 10, amount: minimum, elite: 0, allyPayment: 0 };
|
||||
const firstMove: Action = hajrSource
|
||||
? { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: minimum }
|
||||
: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: minimum };
|
||||
const secondMove: Action = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: minimum };
|
||||
const mixed = options.mixed ?? truthTravelGroup('and',
|
||||
truthTravelShip(String(shipmentAction.territory), minimum), truthTravelMove(String(firstMove.territory), minimum));
|
||||
const question = { kind: 'travel' as const, target, turn: game.turn, mixed };
|
||||
assert.equal(game.status, 'playing'); assert.equal(game.phase, phase);
|
||||
assert.ok(truthTravelEarlyClean(game));
|
||||
assert.equal(game.travelPromises?.length ?? 0, 0);
|
||||
assert.deepEqual(truthTravelEarlyInventory(game), originalCards);
|
||||
assert.ok(truthTravelEarlyPlayer(game, asker).hand.some(card => card.id === truth));
|
||||
const sourceDisclosure = 'sourceDisclosure' in original ? original.sourceDisclosure : original.staging.join(' ');
|
||||
return {
|
||||
game, source: structuredClone(game), kind, phase, base: original,
|
||||
classicSource, hajrSource, nativeSource, revivalSource,
|
||||
asker, target, truth, card: truth, hajr: hajrSource?.hajr ?? null,
|
||||
setup: original.setup, lobby: original.lobby, beforeStartingDeal: original.beforeStartingDeal,
|
||||
afterSetup: original.afterSetup, originalCards, originalSpice: original.originalSpice,
|
||||
dealOrder: original.dealOrder, spiceOrder: original.spiceOrder, selectedSpice,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length,
|
||||
sourceCounts: { inherited: initialPrefixCount, selected: actions.length,
|
||||
setup: actions.findIndex(step => step.action.type === 'start') + 1,
|
||||
priorShipments: actions.filter(step => step.actor === target && step.action.type === 'ship').length,
|
||||
priorRevivals: actions.filter(step => step.actor === target && step.action.type === 'revive').length },
|
||||
minimum, mixed, question, shipmentAction, firstMove, secondMove,
|
||||
shipmentCost: minimum * (hajrSource ? 2 : 1),
|
||||
declaration: { type: 'card', card: truth } satisfies Action,
|
||||
questionAction: { type: 'truthAsk', question } satisfies Action,
|
||||
hajrAction: hajrSource?.hajr ? { type: 'card', card: hajrSource.hajr } satisfies Action : null,
|
||||
currentEnd: { type: 'endMovement' } satisfies Action,
|
||||
sourceDisclosure: `${sourceDisclosure} This early travel adapter retains only the recorded ${actions.length}-action ` +
|
||||
`phase${phase} prefix. The caller supplies the whole travel question, answer and all later real actions. ` +
|
||||
'No card, dealt hand, wallet, army, clock, ready list, old shipment flag, promise or receipt is installed.',
|
||||
};
|
||||
}
|
||||
export type TruthTravelEarlyFixture = ReturnType<typeof createTruthTravelEarlyFixture>;
|
||||
|
||||
export function declareTruthTravelEarly(f: TruthTravelEarlyFixture, source = f.game,
|
||||
actions: TruthTravelEarlyStep[] = []) {
|
||||
let game = actTruthTravelEarly(source, f.asker, f.declaration, actions);
|
||||
const opened = structuredClone(game);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(player);
|
||||
game = actTruthTravelEarly(game, player.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, opened, actions };
|
||||
}
|
||||
export function askTruthTravelEarly(f: TruthTravelEarlyFixture, source = f.game,
|
||||
mixed = f.mixed, actions: TruthTravelEarlyStep[] = []) {
|
||||
const declared = declareTruthTravelEarly(f, source, actions);
|
||||
assert.ok(viewGame(declared.game, f.asker).truthTravelTargets.includes(f.target));
|
||||
const game = actTruthTravelEarly(declared.game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'travel', target: f.target, turn: source.turn, mixed: structuredClone(mixed),
|
||||
} }, actions);
|
||||
return { game, declared, actions };
|
||||
}
|
||||
export function bindTruthTravelEarly(f: TruthTravelEarlyFixture, answer: 'yes' | 'no' = 'yes',
|
||||
mixed = f.mixed, source = f.game, actions: TruthTravelEarlyStep[] = []) {
|
||||
const asked = askTruthTravelEarly(f, source, mixed, actions);
|
||||
assert.ok(viewGame(asked.game, f.target).truthTravelAnswers?.includes(answer));
|
||||
const game = settleTruthTravelEarly(actTruthTravelEarly(asked.game, f.target,
|
||||
{ type: 'truthAnswer', answer }, actions), actions);
|
||||
return { game, asked, actions, actionCount: actions.length };
|
||||
}
|
||||
export function reachTruthTravelEarlyMovement(f: TruthTravelEarlyFixture, game: Game,
|
||||
actions: TruthTravelEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}) {
|
||||
return advanceTruthTravelEarly(game, state => state.phase === 5 && state.active === f.target &&
|
||||
truthTravelEarlyClean(state) && !state.pendingShipment, actions, auction);
|
||||
}
|
||||
export function finishTruthTravelEarly(f: TruthTravelEarlyFixture, game: Game,
|
||||
actions: TruthTravelEarlyStep[] = []): Game {
|
||||
game = settleTruthTravelEarly(game, actions);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, f.target);
|
||||
game = settleTruthTravelEarly(actTruthTravelEarly(game, f.target, f.currentEnd, actions), actions);
|
||||
assert.ok(!game.movementRemaining?.includes(f.target));
|
||||
return game;
|
||||
}
|
||||
export function completeTruthTravelEarly(f: TruthTravelEarlyFixture, game: Game,
|
||||
actions: TruthTravelEarlyStep[] = []): Game {
|
||||
return advanceTruthTravelEarly(game, state => {
|
||||
const row = truthTravelEarlyPromise(state, f.target);
|
||||
return !!row.fulfilled || !!row.released;
|
||||
}, actions);
|
||||
}
|
||||
@@ -54,7 +54,8 @@ function projected(next: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'a
|
||||
game.status = 'playing'; game.turn = 2; game.phase = 5; game.active = 'p'; game.ready = [];
|
||||
game.travelPromises = [{ event: 'controlled-travel-event', player: 'p', asker: 'a', turn: 2,
|
||||
mixed: claim(), answer: true, fulfilled: false, released: false }];
|
||||
game.travelPromiseGuide = { event: 'controlled-travel-event', action: { ...next, travelPromiseEvent: 'controlled-travel-event' } };
|
||||
game.travelPromiseGuide = { event: 'controlled-travel-event', action: { ...next,
|
||||
travelPromiseEvent: 'controlled-travel-event', travelPromisePhase: game.phase } };
|
||||
game.truthTravelAnswers = null;
|
||||
return game;
|
||||
}
|
||||
@@ -206,6 +207,7 @@ void test('travel guide rejects stale view, actor, source, turn, phase, native o
|
||||
game => { game.travelPromiseGuide = { ...game.travelPromiseGuide!, action: { ...game.travelPromiseGuide!.action } }; },
|
||||
game => { game.travelPromiseGuide!.event = 'changed'; },
|
||||
game => { game.travelPromiseGuide!.action.travelPromiseEvent = 'changed'; },
|
||||
game => { game.travelPromiseGuide!.action.travelPromisePhase = 4; },
|
||||
game => { (game.travelPromiseGuide!.action.forces as Record<string, number>)['arrakeen:10'] = 2; },
|
||||
game => { (game.travelPromiseGuide!.action.eliteForces as Record<string, number>)['arrakeen:10'] = 0; },
|
||||
game => { game.travelPromises[0].event = 'expired'; }, game => { game.travelPromises[0].player = 'a'; },
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createTruthBidFixture, truthBidInventory } from './fixture-truth-bid';
|
||||
|
||||
/** Genuine Harkonnen two-Truth opening at the original current ordinary lot.
|
||||
* Both questions use distinct actually dealt cards; no ledger is installed. */
|
||||
function ask(game: Game, asker: string, card: string, question: unknown) {
|
||||
game = applyAction(game, asker, { type: 'card', card });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = applyAction(game, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
return applyAction(game, asker, { type: 'truthAsk', question });
|
||||
}
|
||||
function questions(f: ReturnType<typeof createTruthBidFixture>, minimum: number) {
|
||||
return {
|
||||
bid: { kind: 'bid', target: f.target, turn: f.game.turn, lot: f.game.auction!.index + 1, minimum: 4 },
|
||||
travel: { kind: 'travel', target: f.target, turn: f.game.turn, mixed: { kind: 'and', terms: [
|
||||
{ kind: 'action', action: { kind: 'ship', territory: 'arrakeen', minimum } },
|
||||
{ kind: 'action', action: { kind: 'move', territory: 'carthag', minimum } },
|
||||
] } },
|
||||
};
|
||||
}
|
||||
void test('native compatible bid and travel answers in either order offer the required declaration before any pass', () => {
|
||||
for (const bidFirst of [true, false]) {
|
||||
const f = createTruthBidFixture({ window: 'active' }), q = questions(f, 1);
|
||||
let game = ask(f.game, f.asker, f.truths[0], bidFirst ? q.bid : q.travel);
|
||||
game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = ask(game, f.asker, f.truths[1], bidFirst ? q.travel : q.bid);
|
||||
game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
const guide = viewGame(game, f.target).travelPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4);
|
||||
assert.equal(guide.action.travelPromisePhase, 3); assert.ok(!('bidPromiseEvent' in guide.action));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.deepEqual(action, view.travelPromiseGuide!.action);
|
||||
const done = applyAction(game, f.target, action);
|
||||
assert.equal(done.bidPromises?.[0].fulfilled, true); assert.equal(done.travelPromises?.[0].released, false);
|
||||
assert.equal(done.auction?.bid, 4); assert.deepEqual(truthBidInventory(done), f.originalCards);
|
||||
}
|
||||
assert.equal(game.truthHistory?.filter(row => row.answer === 'yes').length, 2);
|
||||
}
|
||||
});
|
||||
void test('native pending bid invoice and future travel cannot reserve the same original spice in either answer order', () => {
|
||||
for (const bidFirst of [true, false]) {
|
||||
const f = createTruthBidFixture({ window: 'active' }), q = questions(f, 8);
|
||||
let game = ask(f.game, f.asker, f.truths[0], bidFirst ? q.bid : q.travel);
|
||||
game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = ask(game, f.asker, f.truths[1], bidFirst ? q.travel : q.bid);
|
||||
const view = viewGame(game, f.target);
|
||||
assert.deepEqual(bidFirst ? view.truthTravelAnswers : view.truthBidAnswers, ['no']);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' })); assert.deepEqual(game, before);
|
||||
game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(game.truthHistory?.length, 2); assert.deepEqual(truthBidInventory(game), f.originalCards);
|
||||
assert.equal(game.discard.filter(card => f.truths.includes(card.id)).length, 2);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TravelAnswers, TravelPromiseGuideButton, TravelPromises } from '../components/travel-promises';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { createRevivalOrder } from '../game/revival-order';
|
||||
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
|
||||
|
||||
type Props = Parameters<typeof TravelPromiseGuideButton>[0];
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
const event = 'controlled-early-travel';
|
||||
|
||||
/** Controlled personalized quotes test current UI ownership and dispatch only.
|
||||
* They do not establish native acquisition, answer feasibility or progress. */
|
||||
function projected(phase: number, next: Action = { type: 'ready' }): GameView {
|
||||
const source = createGame('EARLYTRAVELCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
|
||||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||||
const game = viewGame(source, 'p');
|
||||
game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = 'a'; game.ready = [];
|
||||
game.travelPromises = [{ event, player: 'p', asker: 'a', turn: 2,
|
||||
mixed: { kind: 'or', terms: [
|
||||
{ kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 3 } },
|
||||
{ kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 4 } },
|
||||
] }, answer: true, fulfilled: false, released: false }];
|
||||
game.travelPromiseGuide = { event, action: { ...next, travelPromiseEvent: event, travelPromisePhase: phase } };
|
||||
game.truthTravelAnswers = null;
|
||||
return game;
|
||||
}
|
||||
function auction(next: Action = { type: 'passBid' }): GameView {
|
||||
const game = projected(3, next);
|
||||
game.auction = { bid: 7, bidder: next.type === 'decision' ? 'p' : 'a', active: 'p',
|
||||
remaining: 2, inspectionBlocked: null, card: null };
|
||||
if (next.type === 'decision') {
|
||||
game.auction.active = 'a';
|
||||
game.decision = { kind: 'auctionPayment', player: 'p' };
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView {
|
||||
const game = projected(4, next);
|
||||
game.active = 'p';
|
||||
game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a'], ready: [] });
|
||||
return game;
|
||||
}
|
||||
function richese(method: 'silent' | 'onceAround'): GameView {
|
||||
const native = createRicheseAuction({ event: 'controlled-native-lot', cardId: 'controlled-native-card',
|
||||
owner: 'a', source: 'cache', method, eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] });
|
||||
const game = projected(3, { type: 'richeseBid', event: native.event, amount: method === 'silent' ? 0 : null, allyPayment: 0 });
|
||||
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
|
||||
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
|
||||
return game;
|
||||
}
|
||||
function guide(props: Props) { const button = TravelPromiseGuideButton(props); assert.ok(button); return button; }
|
||||
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
|
||||
const markup = (game: GameView) => renderToStaticMarkup(createElement(TravelPromises, { game, busy: false, act() {} }));
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<ElementProps>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
function answer(game: GameView, send: (action: Action) => void) {
|
||||
const tree = TravelAnswers({ game, busy: false, act: send });
|
||||
const button = elements(tree).find(element => element.props['data-travel-answer'] === 'yes');
|
||||
assert.ok(button); return button;
|
||||
}
|
||||
const click = (button: Element) => (button.props.onClick as () => void)();
|
||||
function answering(phase: number): GameView {
|
||||
const game = projected(phase); game.travelPromiseGuide = null;
|
||||
game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth' }], passed: [],
|
||||
question: { kind: 'travel', target: 'p', turn: 2, mixed: game.travelPromises[0].mixed } };
|
||||
game.truthTravelAnswers = ['yes', 'no'];
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('early travel guide forwards a required native bid and rejects a changed ordinary opportunity', () => {
|
||||
const game = auction({ type: 'bid', amount: 8, allyPayment: 0 }), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button);
|
||||
assert.deepEqual(sent, [game.travelPromiseGuide!.action]);
|
||||
assert.match(markup(game), /Place the required bid of 8/);
|
||||
game.auction!.active = 'a'; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
void test('early whole-travel history stays readable before Movement and forwards only current Charity, Ready or card preparation', () => {
|
||||
for (const [phase, action, label] of [
|
||||
[2, { type: 'charity' }, /Claim Charity for your later shipment and movement/],
|
||||
[2, { type: 'ready' }, /Finish this preparation phase while preserving your whole answer/],
|
||||
[4, { type: 'ready' }, /Finish this preparation phase while preserving your whole answer/],
|
||||
[2, { type: 'card', card: 'controlled-preparation', amount: 3 }, /Use the offered native card preparation/],
|
||||
[4, { type: 'card', card: 'controlled-preparation', amount: 3 }, /Use the offered native card preparation/],
|
||||
] satisfies [number, Action, RegExp][]) {
|
||||
const game = projected(phase, action), sent: Action[] = [];
|
||||
const props = { game, busy: false, act(value: Action) { sent.push(value); } };
|
||||
const html = markup(game);
|
||||
assert.match(html, /Your Movement turn is still ahead/);
|
||||
assert.match(html, /Complete any offered current preparation below before confirming readiness/);
|
||||
assert.match(html, label); assert.doesNotMatch(html, />Ship \d|>Move \d/);
|
||||
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
|
||||
assert.deepEqual(sent, [game.travelPromiseGuide!.action]); assert.equal(sent[0], game.travelPromiseGuide!.action);
|
||||
props.busy = true; press(button); assert.equal(sent.length, 1);
|
||||
props.busy = false; game.ready.push('p'); press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
for (const phase of [0, 1]) {
|
||||
const game = projected(phase); game.travelPromiseGuide = null;
|
||||
assert.match(markup(game), /Your Movement turn is still ahead/); assert.doesNotMatch(markup(game), /<button/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('early native ordinary force, leader and Kwisatz revival packets retain the projected current Revival opportunity', () => {
|
||||
for (const [action, label] of [
|
||||
[{ type: 'revive', amount: 3, elite: 1, allyPayment: 2 }, /Revive 3 forces/],
|
||||
[{ type: 'reviveLeader', leader: 'controlled-leader', early: true, allyPayment: 2 }, /Revive the quoted leader/],
|
||||
[{ type: 'acceptLeaderRevival', leaderRevivalPrice: 3 }, /Accept the quoted leader revival/],
|
||||
[{ type: 'reviveKwisatz' }, /Revive your Kwisatz Haderach/],
|
||||
] satisfies [Action, RegExp][]) {
|
||||
const game = revival(action), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); assert.match(markup(game), label);
|
||||
press(button); assert.equal(sent[0], game.travelPromiseGuide!.action);
|
||||
assert.deepEqual(sent[0], { ...action, travelPromiseEvent: event, travelPromisePhase: 4 });
|
||||
}
|
||||
});
|
||||
|
||||
void test('current normal-auction pass and owned cash or physical Karama invoices preserve their exact native packets', () => {
|
||||
const card = baseDeck().find(card => card.effect === 'karama'); assert.ok(card);
|
||||
for (const next of [{ type: 'passBid' }, { type: 'decision', karama: false },
|
||||
{ type: 'decision', karama: true, card: card.id }] satisfies Action[]) {
|
||||
const game = auction(next), sent: Action[] = [];
|
||||
game.players.find(player => player.id === 'p')!.hand = [card];
|
||||
const button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); press(button);
|
||||
assert.equal(sent[0], game.travelPromiseGuide!.action);
|
||||
assert.match(markup(game), next.type === 'passBid'
|
||||
? /Pass this bid and preserve later shipment and movement/
|
||||
: next.karama ? /Use Karama for the current auction payment/ : /Pay the current auction price while preserving your whole answer/);
|
||||
if (next.type === 'decision') assert.equal(game.auction!.active, 'a', 'The owned invoice may belong to a different player than the next bid.');
|
||||
}
|
||||
});
|
||||
|
||||
void test('an already-paid owned Box search forwards its exact native card choice in phases zero through five', () => {
|
||||
for (const phase of [0, 1, 2, 3, 4, 5]) {
|
||||
const game = projected(phase, { type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-hajr' });
|
||||
game.decision = { kind: 'nullentropy', player: 'p' };
|
||||
game.nullentropy = { card: { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' }, blocked: null,
|
||||
search: { event: 'controlled-paid-search', cards: [{ id: 'controlled-recovered-hajr', name: 'Hajr', kind: 'special', effect: 'hajr' }] } };
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.travelPromiseGuide!.action);
|
||||
assert.match(markup(game), /Complete your paid Box search for your whole answer/);
|
||||
game.nullentropy.search = { ...game.nullentropy.search! }; press(button); assert.equal(sent.length, 1);
|
||||
const current = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
game.decision = { kind: 'nullentropy', player: 'a' }; press(current); assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('current Richese Silent zero and Once Around pass guides retain their native lot without reading hidden bids', () => {
|
||||
for (const method of ['silent', 'onceAround'] as const) {
|
||||
const game = richese(method), sent: Action[] = [];
|
||||
Object.defineProperty(game.richeseAuction!, 'sealed', { get() { throw Error('No hidden bid access'); } });
|
||||
Object.defineProperty(game.richeseAuction!, 'card', { get() { throw Error('No hidden lot face access'); } });
|
||||
const button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.travelPromiseGuide!.action);
|
||||
assert.match(markup(game), method === 'silent' ? /Submit zero in this Silent auction/ : /Pass this Richese bid/);
|
||||
if (method === 'silent') game.richeseAuction!.submitted.push('p'); else game.richeseAuction!.active = 'a';
|
||||
press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('early guides refuse stale phase metadata, current source identity and changed native invoice or Revival ownership', () => {
|
||||
const cases: [() => GameView, ((game: GameView) => void)[]][] = [
|
||||
[() => projected(2), [
|
||||
game => { game.phase = 4; }, game => { game.travelPromiseGuide!.action.travelPromisePhase = 4; },
|
||||
game => { delete game.travelPromiseGuide!.action.travelPromisePhase; },
|
||||
game => { game.travelPromiseGuide = { ...game.travelPromiseGuide! }; },
|
||||
game => { game.travelPromiseGuide!.action = { ...game.travelPromiseGuide!.action }; },
|
||||
game => { game.travelPromises[0].event = 'another-answer'; }, game => { game.me = 'a'; }, game => { game.turn = 3; },
|
||||
]],
|
||||
[() => auction(), [
|
||||
game => { game.auction = { ...game.auction! }; }, game => { game.auction!.active = 'a'; },
|
||||
game => { game.auction!.bid = 8; }, game => { game.auction!.remaining = 1; },
|
||||
]],
|
||||
[() => auction({ type: 'decision', karama: false }), [
|
||||
game => { game.decision = { ...game.decision! }; }, game => { game.decision = null; },
|
||||
game => { game.decision = { kind: 'auctionPayment', player: 'a' }; },
|
||||
game => { game.auction!.bidder = 'a'; }, game => { game.auction!.bid = 8; },
|
||||
game => { game.auction!.allyPayment = 1; },
|
||||
game => { game.travelPromiseGuide!.action.karama = true; },
|
||||
]],
|
||||
[() => richese('silent'), [
|
||||
game => { game.richeseAuction = { ...game.richeseAuction! }; }, game => { game.richeseAuction!.event = 'another-lot'; },
|
||||
game => { game.richeseAuction!.eligible = ['a']; }, game => { game.richeseAuction!.outcome = { kind: 'unbid' }; },
|
||||
game => { game.travelPromiseGuide!.action.amount = 1; }, game => { game.travelPromiseGuide!.action.allyPayment = 1; },
|
||||
]],
|
||||
[() => revival(), [
|
||||
game => { game.revivalOrder = { ...game.revivalOrder! }; }, game => { game.revivalOrder!.turn = 3; },
|
||||
game => { game.revivalOrder!.remaining = ['a', 'p']; }, game => { game.active = 'a'; },
|
||||
game => { game.revival = { ...game.revival }; }, game => { game.revival.orderBlocked = 'Wait for your Revival opportunity.'; },
|
||||
game => { game.revival.pending = true; }, game => { game.ready.push('p'); },
|
||||
]],
|
||||
];
|
||||
for (const [build, changes] of cases) for (const change of changes) {
|
||||
const game = build(), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const props = { game: projected(2), busy: false, act() { assert.fail('A replaced view cannot dispatch'); } };
|
||||
const button = guide(props); props.game = { ...props.game }; press(button);
|
||||
const retained = projected(2); retained.phase = 4;
|
||||
const refreshed = guide({ game: retained, busy: false, act() { assert.fail('An old Charity Ready packet cannot become a Revival Ready packet'); } });
|
||||
assert.equal(refreshed.props.disabled, true); press(refreshed);
|
||||
});
|
||||
|
||||
void test('early whole-travel guides refuse foreign or absent invoices, foreign Revival turns, wrong phases and intervening modals', () => {
|
||||
const invalid: GameView[] = [];
|
||||
for (const phase of [0, 1, 2, 3, 4, 6]) {
|
||||
for (const next of [{ type: 'ship', amount: 3, territory: 'carthag', sector: 11 },
|
||||
{ type: 'move', forces: { 'arrakeen:10': 4 }, territory: 'carthag', sector: 11 }, { type: 'endMovement' }] satisfies Action[])
|
||||
invalid.push(projected(phase, next));
|
||||
}
|
||||
for (const phase of [0, 1, 3]) invalid.push(projected(phase, { type: 'ready' }));
|
||||
invalid.push(projected(2, { type: 'revive', amount: 3 }), projected(4, { type: 'charity' }),
|
||||
projected(3, { type: 'decision', karama: false }), auction({ type: 'decision', karama: true }));
|
||||
const foreignInvoice = auction({ type: 'decision', karama: false }); foreignInvoice.decision = { kind: 'auctionPayment', player: 'a' };
|
||||
const foreignRevival = revival(); foreignRevival.revivalOrder!.remaining = ['a', 'p']; foreignRevival.active = 'a';
|
||||
const blockedRevival = revival({ type: 'ready' }); blockedRevival.revival.orderBlocked = 'Wait for your Revival opportunity.';
|
||||
invalid.push(foreignInvoice, foreignRevival, blockedRevival);
|
||||
for (const game of invalid) {
|
||||
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
for (const change of [
|
||||
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
(game: GameView) => { game.truthtrance = answering(2).truthtrance; },
|
||||
(game: GameView) => { game.phaseOpening = { passed: [] }; },
|
||||
(game: GameView) => { game.automaticContinuationPending = true; },
|
||||
(game: GameView) => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
]) {
|
||||
const game = projected(2), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
|
||||
change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('whole travel answers accept only the exact current phase-zero-through-five answer window without opening private eligibility', () => {
|
||||
for (const phase of [0, 1, 2, 3, 4, 5]) {
|
||||
const game = answering(phase), sent: Action[] = [];
|
||||
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
|
||||
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
|
||||
const button = answer(game, value => sent.push(value)); assert.equal(button.props.disabled, false); click(button);
|
||||
assert.deepEqual(sent, [{ type: 'truthAnswer', answer: 'yes' }]);
|
||||
game.phase = phase === 5 ? 4 : phase + 1; click(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
for (const phase of [-1, 6, 1.5]) {
|
||||
const game = answering(phase), sent: Action[] = [], button = answer(game, value => sent.push(value));
|
||||
assert.equal(button.props.disabled, true); click(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('early answer callbacks retain target, source, phase and modal ownership after rendering', () => {
|
||||
for (const change of [
|
||||
(game: GameView) => { game.truthtrance!.queue[0] = { ...game.truthtrance!.queue[0] }; },
|
||||
(game: GameView) => { game.truthtrance!.queue[0].card = 'another-truth'; },
|
||||
(game: GameView) => { game.truthtrance!.question!.target = 'a'; },
|
||||
(game: GameView) => { game.truthtrance!.question = { ...game.truthtrance!.question! }; },
|
||||
(game: GameView) => { game.truthTravelAnswers = ['yes']; },
|
||||
(game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
(game: GameView) => { game.phaseOpening = { passed: [] }; },
|
||||
(game: GameView) => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
]) {
|
||||
const game = answering(2), sent: Action[] = [], button = answer(game, value => sent.push(value));
|
||||
change(game); click(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
|
||||
/** Controlled conserved native resources isolate joint quote compatibility.
|
||||
* Both original physical Truths are present; every promise is made through
|
||||
* actual declare/priority/Ask/answer actions. No setup/casualty history claimed. */
|
||||
function controlled(spice = 2) {
|
||||
const game = createGame('EARLYTRAVELREVIVAL', newPlayer('a', 'Asker', 'harkonnen'), false);
|
||||
game.players.push(newPlayer('p', 'Emperor', 'emperor'), newPlayer('o', 'Other', 'atreides'));
|
||||
Object.assign(game, { status: 'playing', phase: 4, turn: 2, storm: 18, active: 'p',
|
||||
order: ['p', 'a', 'o'], movementRemaining: ['p', 'a', 'o'], ready: [], deck: baseDeck(), discard: [] });
|
||||
for (const player of game.players) {
|
||||
player.hand = []; player.forces = {}; player.reserves = 20; player.spice = 10; player.traitorChoices = [];
|
||||
}
|
||||
const target = game.players[1]; target.reserves = 17; target.tanks = 3; target.spice = spice;
|
||||
const cards = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(cards.length, 2);
|
||||
game.deck = game.deck.filter(card => card.effect !== 'truthtrance'); game.players[0].hand = cards;
|
||||
const inventory = (g: Game) => [...g.deck, ...g.discard, ...g.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
return { game, truths: cards.map(card => card.id), inventory, original: inventory(game) };
|
||||
}
|
||||
function ask(game: Game, card: string, question: unknown) {
|
||||
game = applyAction(game, 'a', { type: 'card', card });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(player);
|
||||
game = applyAction(game, player.id, { type: 'truthPass' });
|
||||
}
|
||||
return applyAction(game, 'a', { type: 'truthAsk', question });
|
||||
}
|
||||
const revival = { kind: 'revival', target: 'p', turn: 2, counter: 'total', minimum: 2 };
|
||||
const travel = { kind: 'travel', target: 'p', turn: 2, mixed: { kind: 'and', terms: [
|
||||
{ kind: 'action', action: { kind: 'ship', territory: 'arrakeen', minimum: 1 } },
|
||||
{ kind: 'action', action: { kind: 'move', territory: 'carthag', minimum: 1 } },
|
||||
] } };
|
||||
void test('controlled earlier Revival Yes prevents a later travel Yes from spending the same last spice', () => {
|
||||
const f = controlled(); let game = ask(f.game, f.truths[0], revival);
|
||||
assert.ok(viewGame(game, 'p').truthRevivalAnswers?.includes('yes'));
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
game = ask(game, f.truths[1], travel);
|
||||
assert.deepEqual(viewGame(game, 'p').truthTravelAnswers, ['no']);
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' }));
|
||||
assert.deepEqual(game, before);
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'no' });
|
||||
game = applyAction(game, 'p', { type: 'revive', amount: 2 });
|
||||
assert.equal(game.players[1].spice, 0); assert.equal(game.players[1].reserves, 19);
|
||||
assert.equal(game.revivalPromises?.[0].fulfilled, true); assert.equal(game.travelPromises?.[0].released, false);
|
||||
game = applyAction(game, 'p', { type: 'ready' }); assert.ok(game.ready.includes('p'));
|
||||
assert.deepEqual(f.inventory(game), f.original);
|
||||
});
|
||||
void test('controlled earlier travel Yes prevents a later paid Revival Yes while leaving the compatible No legal', () => {
|
||||
const f = controlled(); let game = ask(f.game, f.truths[0], travel);
|
||||
assert.ok(viewGame(game, 'p').truthTravelAnswers?.includes('yes'));
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
game = ask(game, f.truths[1], revival);
|
||||
assert.deepEqual(viewGame(game, 'p').truthRevivalAnswers, ['no']);
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' }));
|
||||
assert.deepEqual(game, before);
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'no' });
|
||||
game = applyAction(game, 'p', { type: 'ready' }); assert.ok(game.ready.includes('p'));
|
||||
assert.equal(game.travelPromises?.[0].released, false); assert.equal(game.travelPromises?.[0].fulfilled, false);
|
||||
assert.deepEqual(f.inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('controlled compatible Revival and travel Yes use the required real return before offering Revival Ready', () => {
|
||||
const f = controlled(3); let game = ask(f.game, f.truths[0], revival);
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
game = ask(game, f.truths[1], travel);
|
||||
game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
const guide = viewGame(game, 'p').travelPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'revive'); assert.equal(guide.action.amount, 2);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'p'); view.players.find(player => player.id === 'p')!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.deepEqual(action, view.travelPromiseGuide!.action);
|
||||
const done = applyAction(game, 'p', action); assert.equal(done.revivalPromises?.[0].fulfilled, true);
|
||||
}
|
||||
game = applyAction(game, 'p', guide.action);
|
||||
assert.equal(game.revivalPromises?.[0].fulfilled, true); assert.equal(game.players[1].spice, 1);
|
||||
const ready = viewGame(game, 'p').travelPromiseGuide; assert.ok(ready); assert.equal(ready.action.type, 'ready');
|
||||
game = applyAction(game, 'p', ready.action); assert.ok(game.ready.includes('p'));
|
||||
assert.equal(game.travelPromises?.[0].released, false); assert.deepEqual(f.inventory(game), f.original);
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { truthFactAnswer, type TruthFact } from '../game/truthtrance';
|
||||
import {
|
||||
actTruthTravelEarly as act, advanceTruthTravelEarly as advance,
|
||||
askTruthTravelEarly as ask, bindTruthTravelEarly as bind,
|
||||
completeTruthTravelEarly as complete, createTruthTravelEarlyFixture as fixture,
|
||||
declareTruthTravelEarly as declare, finishTruthTravelEarly as finish,
|
||||
reachTruthTravelEarlyMovement as reach, settleTruthTravelEarly as settle,
|
||||
truthTravelEarlyClean as clean, truthTravelEarlyInventory as inventory,
|
||||
truthTravelEarlyPlayer as player, truthTravelEarlyPromise as promise,
|
||||
truthTravelFact as fact, truthTravelGroup as group, truthTravelMove as move,
|
||||
truthTravelShip as ship, type TruthTravelEarlyStep,
|
||||
} from './fixture-truth-travel-early';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
const pending = (game: Game, target: string) => game.travelPromises!.find(row => row.player === target)!;
|
||||
function guide(game: Game, target: string) {
|
||||
const next = viewGame(game, target).travelPromiseGuide; assert.ok(next);
|
||||
assert.equal(next.event, promise(game, target).event);
|
||||
assert.equal(next.action.travelPromiseEvent, next.event);
|
||||
assert.equal(next.action.travelPromisePhase, game.phase);
|
||||
return next;
|
||||
}
|
||||
function finishPaidRoute(f: ReturnType<typeof fixture>, source: Game, actions: TruthTravelEarlyStep[] = []) {
|
||||
let game = reach(f, source, actions);
|
||||
const spice = player(game, f.target).spice;
|
||||
rejected(game, f.target, f.currentEnd);
|
||||
game = settle(act(game, f.target, f.shipmentAction, actions), actions);
|
||||
assert.equal(player(game, f.target).spice, spice - f.shipmentCost);
|
||||
assert.deepEqual(pending(game, f.target).progress, []);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = settle(act(game, f.target, f.firstMove, actions), actions);
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.ok(!promise(game, f.target).released);
|
||||
game = finish(f, game, actions); assert.deepEqual(inventory(game), f.originalCards);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('native early travel source records only original setup and phase actions with no installed future event', () => {
|
||||
for (const source of ['classic', 'hajr', 'native', 'revival'] as const) {
|
||||
const f = fixture({ source });
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.equal(f.sourceCounts.selected, f.sourceActions.length);
|
||||
assert.equal(f.sourceCounts.priorShipments,
|
||||
f.actions.filter(step => step.actor === f.target && step.action.type === 'ship').length);
|
||||
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.equal(f.game.phase, 2); assert.equal(f.game.truthtrance ?? null, null);
|
||||
assert.equal(f.game.travelPromises?.length ?? 0, 0);
|
||||
assert.deepEqual(inventory(f.game), f.originalCards);
|
||||
if (source === 'hajr') {
|
||||
assert.equal(f.sourceActionCount, 13);
|
||||
assert.deepEqual(f.sourceActions, f.hajrSource!.sourceActions);
|
||||
assert.equal(player(f.game, f.target).hand.find(card => card.id === f.hajr)?.effect, 'hajr');
|
||||
assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10 });
|
||||
}
|
||||
if (source === 'classic' || source === 'native') {
|
||||
assert.equal(player(f.game, f.target).spice, 10);
|
||||
assert.equal(player(f.game, f.target).reserves, 20);
|
||||
assert.deepEqual(player(f.game, f.target).forces, {});
|
||||
}
|
||||
if (source === 'revival') {
|
||||
assert.equal(f.sourceCounts.priorShipments, 1);
|
||||
assert.equal(player(f.game, f.target).tanks, 3);
|
||||
assert.equal(player(f.game, f.target).spice, 4);
|
||||
assert.deepEqual(f.revivalSource!.base.shipment.steps[0].action, f.revivalSource!.base.shipmentAction);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Storm Spice Charity Bidding and Revival travel Yes all retain one future paid shipment and distinct ground event', () => {
|
||||
for (const phase of [0, 1, 2, 3, 4] as const) {
|
||||
const f = fixture({ phase }), actions: TruthTravelEarlyStep[] = [];
|
||||
let game = bind(f, 'yes', f.mixed, f.game, actions).game;
|
||||
assert.equal(game.phase, phase); assert.equal(pending(game, f.target).shipment.pending, true);
|
||||
assert.equal(pending(game, f.target).shipment.event, null);
|
||||
assert.deepEqual(pending(game, f.target).progress, []);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, f.shipmentAction); rejected(game, f.target, f.firstMove);
|
||||
const early = viewGame(game, f.target).travelPromiseGuide;
|
||||
assert.ok(!early || !['ship', 'move', 'endMovement'].includes(early.action.type));
|
||||
game = finishPaidRoute(f, game, actions);
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.ok((phase < 4 ? actions : f.actions).some(step => step.actor === f.target && step.action.type === 'passBid'));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native concurrent Charity readiness reopens after either definite whole travel answer', () => {
|
||||
const f = fixture();
|
||||
const ready = act(f.game, f.target, { type: 'ready' }); assert.ok(ready.ready.includes(f.target));
|
||||
for (const answer of ['yes', 'no'] as const) {
|
||||
const game = bind(f, answer, f.mixed, ready).game;
|
||||
assert.equal(game.ready.includes(f.target), false);
|
||||
const next = viewGame(game, f.target).travelPromiseGuide;
|
||||
if (next) { assert.equal(guide(game, f.target).action.type, 'ready'); }
|
||||
assert.equal(pending(game, f.target).shipment.pending, true);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
assert.ok(act(game, f.target, next?.action ?? { type: 'ready' }).ready.includes(f.target));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native current-phase guide has exact event and phase authority through Charity bid invoice and Revival', () => {
|
||||
const f = fixture({ source: 'revival', phase: 2, targetKarama: true }); let game = bind(f).game;
|
||||
const initialSpice = player(game, f.target).spice;
|
||||
const charity = guide(game, f.target); assert.equal(charity.action.type, 'ready');
|
||||
rejected(game, f.asker, charity.action);
|
||||
rejected(game, f.target, { ...charity.action, travelPromiseEvent: 'stale-travel-answer' });
|
||||
rejected(game, f.target, { ...charity.action, travelPromisePhase: 3 });
|
||||
game = act(game, f.target, charity.action);
|
||||
rejected(game, f.target, charity.action);
|
||||
game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
|
||||
const pass = guide(game, f.target); assert.equal(pass.action.type, 'passBid');
|
||||
rejected(game, f.target, charity.action);
|
||||
game = act(game, f.target, { type: 'bid', amount: 2, allyPayment: 0 });
|
||||
const purchased = game.auction!.cards[game.auction!.index].id;
|
||||
game = advance(game, state => !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'auctionPayment' && state.decision.player === f.target);
|
||||
const invoice = guide(game, f.target); assert.equal(invoice.action.type, 'decision');
|
||||
assert.equal(invoice.action.karama, false);
|
||||
rejected(game, f.target, { ...invoice.action, karama: true });
|
||||
game = act(game, f.target, JSON.parse(JSON.stringify(invoice.action)) as Action);
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
assert.equal(player(game, f.target).spice, initialSpice - 2);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === purchased));
|
||||
const revival = guide(game, f.target); assert.equal(revival.action.type, 'ready');
|
||||
rejected(game, f.target, invoice.action);
|
||||
finishPaidRoute(f, game);
|
||||
});
|
||||
|
||||
void test('native early voluntary bribe and excess bid cannot consume the last original eight-spice paid route', () => {
|
||||
const f = fixture(); let game = bind(f).game;
|
||||
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
|
||||
game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
|
||||
rejected(game, f.target, { type: 'bid', amount: 3, allyPayment: 0 });
|
||||
game = act(game, f.target, { type: 'bid', amount: 2, allyPayment: 0 });
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
assert.equal(player(game, f.target).spice, 8);
|
||||
finishPaidRoute(f, game);
|
||||
});
|
||||
|
||||
void test('native standing bid reserves its unpaid invoice before offering an early travel Yes', () => {
|
||||
const f = fixture({ phase: 3 });
|
||||
for (const amount of [2, 3]) {
|
||||
let game = advance(f.game, state => clean(state) && state.auction?.active === f.target);
|
||||
game = act(game, f.target, { type: 'bid', amount, allyPayment: 0 });
|
||||
const asked = ask(f, game);
|
||||
assert.equal(viewGame(asked.game, f.target).truthTravelAnswers?.includes('yes'), amount === 2);
|
||||
assert.equal(player(asked.game, f.target).spice, 10, 'The standing bid has not paid its real invoice');
|
||||
if (amount === 3) rejected(asked.game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
}
|
||||
});
|
||||
|
||||
void test('native whole No to AND survives all early preparation and the matching shipment until actual own end', () => {
|
||||
const f = fixture(); let game = bind(f, 'no').game;
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = reach(f, game);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = settle(act(game, f.target, f.shipmentAction));
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, f.firstMove);
|
||||
game = finish(f, game); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.ok(!promise(game, f.target).released); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native early OR retains both manual routes without choosing a branch at answer time', () => {
|
||||
const f = fixture({ source: 'hajr' }), mixed = group('or', ship(), move());
|
||||
const bound = bind(f, 'yes', mixed).game;
|
||||
for (const action of [f.shipmentAction, f.firstMove]) {
|
||||
let game = reach(f, bound); assert.ok(!promise(game, f.target).fulfilled);
|
||||
game = settle(act(game, f.target, action));
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(promise(game, f.target).mixed, mixed);
|
||||
game = finish(f, game); assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native early Hajr travel freezes its current spice fact and later performs both original physical ground groups', () => {
|
||||
const f = fixture({ source: 'hajr' }), actions: TruthTravelEarlyStep[] = [];
|
||||
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: player(f.game, f.target).spice };
|
||||
const mixed = group('and', fact(spiceFact), ship(), move(), move('carthag'));
|
||||
let game = bind(f, 'yes', mixed, f.game, actions).game;
|
||||
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 }, actions);
|
||||
assert.equal(truthFactAnswer(player(game, f.target), spiceFact), 'no');
|
||||
game = reach(f, game, actions);
|
||||
game = settle(act(game, f.target, f.shipmentAction, actions), actions);
|
||||
game = settle(act(game, f.target, f.firstMove, actions), actions);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
rejected(game, f.target, f.currentEnd);
|
||||
const hajr = guide(game, f.target); assert.equal(hajr.action.type, 'card'); assert.equal(hajr.action.card, f.hajr);
|
||||
game = settle(act(game, f.target, hajr.action, actions), actions);
|
||||
game = settle(act(game, f.target, f.secondMove, actions), actions);
|
||||
assert.equal(player(game, f.target).moved, 2);
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(pending(game, f.target).progress,
|
||||
[{ territory: 'imperial_basin', maximum: 1 }, { territory: 'carthag', maximum: 1 }]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
assert.deepEqual(inventory(finish(f, game, actions)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native early optional current facts settle constants without future receipts and reopen readiness uniformly', () => {
|
||||
const f = fixture({ source: 'hajr' });
|
||||
const held = f.setup.deck.find(card => card.id === f.hajr); assert.ok(held);
|
||||
const heldFact: TruthFact = { kind: 'hand', name: held.name };
|
||||
const projectile = f.setup.deck.find(card => card.kind === 'projectile'); assert.ok(projectile);
|
||||
const absentFact: TruthFact = { kind: 'hand', name: projectile.name };
|
||||
const ready = act(f.game, f.target, { type: 'ready' }); assert.ok(ready.ready.includes(f.target));
|
||||
for (const [mixed, answer] of [
|
||||
[group('or', fact(heldFact), ship(), move()), 'yes'],
|
||||
[group('and', fact(absentFact), ship(), move()), 'no'],
|
||||
] as const) {
|
||||
const game = bind(f, answer, mixed, ready).game;
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.equal(pending(game, f.target).shipment.event, null);
|
||||
assert.deepEqual(pending(game, f.target).progress, []);
|
||||
assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
|
||||
assert.ok(!game.ready.includes(f.target));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native earned prior-turn shipment is not current travel progress and still leaves the next original shipment pending', () => {
|
||||
const f = fixture({ source: 'revival', phase: 1 });
|
||||
assert.equal(f.game.turn, 2); assert.equal(f.sourceCounts.priorShipments, 1);
|
||||
assert.equal(player(f.game, f.target).shipped, true,
|
||||
'This flag is earned by the recorded turn1 paid shipment, never installed');
|
||||
const actions: TruthTravelEarlyStep[] = []; let game = bind(f, 'yes', f.mixed, f.game, actions).game;
|
||||
assert.equal(pending(game, f.target).shipment.pending, true);
|
||||
assert.equal(pending(game, f.target).shipment.event, null);
|
||||
assert.deepEqual(pending(game, f.target).progress, []);
|
||||
assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
|
||||
game = finishPaidRoute(f, game, actions);
|
||||
assert.equal(pending(game, f.target).shipment.event?.amount, 2);
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'ship'));
|
||||
});
|
||||
|
||||
void test('native Charity poverty uses the original paid bribe and protects the two-spice route against Ready and paid Revival', () => {
|
||||
const f = fixture({ source: 'revival', phase: 2, poor: true });
|
||||
assert.equal(f.revivalSource!.funding?.steps[0].action.type, 'bribe');
|
||||
assert.equal(player(f.game, f.target).spice, 0); assert.equal(player(f.game, f.target).tanks, 3);
|
||||
let game = bind(f).game;
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
const charity = guide(game, f.target); assert.equal(charity.action.type, 'charity');
|
||||
game = act(game, f.target, charity.action); assert.equal(player(game, f.target).spice, 2);
|
||||
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 1 });
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
rejected(game, f.target, { type: 'revive', amount: 2, elite: 0 });
|
||||
game = settle(act(game, f.target, { type: 'revive', amount: 1, elite: 0 }));
|
||||
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).tanks, 2);
|
||||
finishPaidRoute(f, game);
|
||||
});
|
||||
|
||||
void test('native Basic Revival readiness reopens while Advanced completed owned turns stay closed', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ source: 'revival', phase: 4, advanced });
|
||||
let game = f.game;
|
||||
if (advanced) game = advance(game, state => state.phase === 4 && clean(state) &&
|
||||
state.revivalOrder?.remaining[0] === f.target);
|
||||
game = act(game, f.target, { type: 'ready' });
|
||||
if (advanced) {
|
||||
assert.ok(!game.revivalOrder?.remaining.includes(f.target));
|
||||
const completed = structuredClone(game.revivalOrder);
|
||||
game = bind(f, 'yes', f.mixed, game).game;
|
||||
assert.deepEqual(game.revivalOrder, completed);
|
||||
assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
|
||||
rejected(game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
} else {
|
||||
assert.ok(game.ready.includes(f.target)); game = bind(f, 'yes', f.mixed, game).game;
|
||||
assert.equal(game.ready.includes(f.target), false);
|
||||
assert.equal(guide(game, f.target).action.type, 'ready');
|
||||
}
|
||||
finishPaidRoute(f, game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Richese first and last Once Around or Silent controls keep the exact legal current auction guide', () => {
|
||||
for (const position of ['first', 'last'] as const) for (const method of ['onceAround', 'silent'] as const) {
|
||||
const f = fixture({ source: 'native' }), auction = { position, method };
|
||||
let game = advance(bind(f).game, state => state.phase === 3 && clean(state) && !!state.richeseAuction &&
|
||||
!state.richeseAuction.outcome && (method === 'silent'
|
||||
? !Object.hasOwn(state.richeseAuction.sealed, f.target) : state.richeseAuction.active === f.target), [], auction);
|
||||
const next = guide(game, f.target);
|
||||
assert.equal(next.action.type, 'richeseBid'); assert.equal(next.action.event, game.richeseAuction!.event);
|
||||
assert.equal(next.action.amount, method === 'silent' ? 0 : null);
|
||||
game = act(game, f.target, next.action);
|
||||
assert.ok(!promise(game, f.target).released);
|
||||
assert.equal(viewGame(game, f.target).travelPromiseGuide, null);
|
||||
game = reach(f, game, [], auction); finishPaidRoute(f, game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native E1 and E3 future travel answers survive their original setup and real independent phase controls', () => {
|
||||
for (const roster of ['ixians-tleilaxu', 'ecaz-moritani'] as const) {
|
||||
const f = fixture({ source: 'native', roster });
|
||||
const game = bind(f).game; assert.equal(guide(game, f.target).action.type, 'ready');
|
||||
finishPaidRoute(f, game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled alliance-only escrow uses original forces funds and actual Harkonnen Karama then releases only at the foreign outcome', () => {
|
||||
for (const canceled of [false, true]) {
|
||||
const f = fixture(), source = structuredClone(f.game);
|
||||
// Only this alliance relation is controlled. Every card, counter and purse
|
||||
// is the source's original native one; no forecast or receipt is installed.
|
||||
player(source, f.target).ally = 'harkonnen'; player(source, 'harkonnen').ally = f.target;
|
||||
const mixed = group('and', ship('arrakeen', 8), move('imperial_basin', 8));
|
||||
let game = bind(f, 'yes', mixed, source).game;
|
||||
game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
|
||||
game = act(game, f.target, { type: 'pledgeAid', amount: 8 });
|
||||
game = advance(game, state => state.phase === 3 && clean(state) && state.auction?.active === 'harkonnen');
|
||||
game = act(game, 'harkonnen', { type: 'bid', amount: 3, allyPayment: 3 });
|
||||
assert.ok(!promise(game, f.target).released);
|
||||
game = advance(game, state => state.response?.kind === 'emperorIncome');
|
||||
assert.ok(!promise(game, f.target).released);
|
||||
if (canceled) {
|
||||
const karama = player(game, 'harkonnen').hand.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
game = act(game, 'harkonnen', { type: 'card', card: karama.id, mode: 'cancel' });
|
||||
}
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
assert.equal(player(game, f.target).spice, canceled ? 7 : 10);
|
||||
assert.equal(promise(game, f.target).released, canceled);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles answer an original Charity travel question and legally consume exact early and phase5 guides', () => {
|
||||
const f = fixture(), asked = ask(f).game;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(asked); const actions: TruthTravelEarlyStep[] = [], phases = new Set<number>();
|
||||
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const answer = botActions(answering).find(action => action.type === 'truthAnswer'); assert.ok(answer, difficulty);
|
||||
assert.equal(answer.answer, 'yes'); game = settle(act(game, f.target, answer, actions), actions);
|
||||
for (let count = 0; count < 160 && !promise(game, f.target).fulfilled; count++) {
|
||||
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const current = view.travelPromiseGuide;
|
||||
if (current) {
|
||||
assert.equal(current.action.travelPromisePhase, game.phase); phases.add(game.phase);
|
||||
const before = structuredClone(view), action = botActions(view)
|
||||
.find(action => action.travelPromiseEvent === current.event);
|
||||
assert.ok(action, `${difficulty} phase${game.phase}`); assert.deepEqual(action, current.action);
|
||||
assert.deepEqual(view, before); game = settle(act(game, f.target, action, actions), actions);
|
||||
} else {
|
||||
const next = advance(game, state => !!viewGame(state, f.target).travelPromiseGuide ||
|
||||
!!promise(state, f.target).fulfilled, actions);
|
||||
assert.notEqual(next, game); game = next;
|
||||
}
|
||||
}
|
||||
assert.equal(promise(game, f.target).fulfilled, true, difficulty);
|
||||
assert.ok(phases.has(2) && phases.has(3) && phases.has(4) && phases.has(5), difficulty);
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'ship'));
|
||||
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'move'));
|
||||
assert.deepEqual(inventory(finish(f, game, actions)), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('early authoring still rejects a stale turn and a target whose actual phase5 interval ended', () => {
|
||||
const f = fixture(), declared = declare(f);
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
|
||||
let game = reach(f, f.game); game = finish(f, game);
|
||||
const after = declare(f, game).game;
|
||||
assert.ok(!viewGame(after, f.asker).truthTravelTargets.includes(f.target));
|
||||
rejected(after, f.asker, f.questionAction);
|
||||
assert.ok(player(after, f.asker).hand.some(card => card.id === f.truth));
|
||||
assert.deepEqual(inventory(after), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native early exact guide completes through the unchanged original phase5 shipment movement and own-end boundary', () => {
|
||||
const f = fixture({ source: 'hajr' }), actions: TruthTravelEarlyStep[] = [];
|
||||
let game = bind(f, 'yes', f.mixed, f.game, actions).game;
|
||||
game = complete(f, game, actions);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, f.target);
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
const route = actions.filter(step => step.actor === f.target && ['ship', 'move'].includes(step.action.type));
|
||||
assert.deepEqual(route.map(step => step.action.type), ['ship', 'move']);
|
||||
assert.ok(route.every(step => step.action.travelPromisePhase === 5));
|
||||
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.deepEqual(inventory(finish(f, game, actions)), f.originalCards);
|
||||
});
|
||||
@@ -225,11 +225,12 @@ void test('native waiting travel answer preserves the current owner queue until
|
||||
game = finish(game, f.target); physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native travel authoring excludes earlier phases and a respondent whose own movement interval has closed', () => {
|
||||
void test('native travel authoring admits upcoming Charity and excludes a respondent whose own movement interval has closed', () => {
|
||||
const charity = fixture({ window: 'charity' }); assert.equal(charity.game.phase, 2);
|
||||
const declaredCharity = declare(charity);
|
||||
assert.ok(!viewGame(declaredCharity.game, charity.asker).truthTravelTargets.includes(charity.target));
|
||||
rejected(declaredCharity.game, charity.asker, charity.questionAction);
|
||||
assert.ok(viewGame(declaredCharity.game, charity.asker).truthTravelTargets.includes(charity.target));
|
||||
const askedCharity = act(declaredCharity.game, charity.asker, charity.questionAction);
|
||||
assert.equal(askedCharity.truthtrance?.question?.kind, 'travel'); assert.equal(askedCharity.phase, 2);
|
||||
assert.equal(declaredCharity.game.discard.filter(card => card.id === charity.truth).length, 0);
|
||||
const f = fixture(); let game = finish(f.game, f.target);
|
||||
assert.equal(game.active, f.asker); assert.ok(!game.movementRemaining?.includes(f.target));
|
||||
@@ -257,8 +258,10 @@ void test('native travel projection keeps only the owner raw aggregate while exa
|
||||
assert.deepEqual(own.travelPromises, [row]);
|
||||
const guide = own.travelPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.event, row.event); assert.equal(guide.action.travelPromiseEvent, row.event);
|
||||
assert.equal(guide.action.travelPromisePhase, 5);
|
||||
rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, { ...guide.action, travelPromiseEvent: 'stale-native-travel-event' });
|
||||
rejected(game, f.target, { ...guide.action, travelPromisePhase: 4 });
|
||||
rejected(game, f.target, { ...guide.action, amount: Number(guide.action.amount) + 1 });
|
||||
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
|
||||
assert.equal(promise(game, f.target).event, row.event); rejected(game, f.target, wire);
|
||||
|
||||
Reference in new issue
Block a user