Bind Truthtrance shipments before respondent turns

Admit supported active or originally queued unused shipments during the current Shipment & Movement phase. Answering preserves the real active player and queue; private future reachability uses owned resources, joint whole promises and the actual respondent profile. Hide executable guidance until that owner is active. Keep native Guild WAIT/TAKE, frozen mixed facts, interception, costs and actual delivery.

Do not release a waiting promise while another declaration is unresolved or count its approved ally contribution as freely recoverable. Before response declaration, reject Emperor gifts or optional BG accompaniment that remove the last legal completion; affordable options remain legal. Minimal AI declines optional accompaniment while bound rather than attempting to spend required reserves. No strategy tuning or new profile/module/earlier-phase opening.

Validation and exact frozen-source evidence are recorded in the canonical Truthtrance shipment contract. Three real public Basic/Advanced programmes use original unopened starting deals, paid intervening turns and a later Emperor or native out-of-order Guild fulfillment. Four minimal policies answer while waiting and ship only on their actual turn. Controlled funding, temporary price, gift and advisor probes remain separate. Existing stale scope fixtures and two type declarations were repaired during feedback.

Earlier phases, broader modules/actions, full Truthtrance/modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
This commit is contained in:
Quintine committed 2026-10-24 12:07:05 +11:00
1 parent 2939ffa900
commit ae2d0371ce
16 files changed
+745 -56

No files matched your search

+4
View File
@@ -62,6 +62,10 @@ export function ShipmentPromises({
id="shipment-promise-guidance"
>
<h3>Your shipment promises</h3>
{g.active !== g.me && <p>
Your shipment turn is still ahead. This answer stays binding while
other players finish; shipment actions become available on your turn.
</p>}
<ul>
{promises.map((p, index) => (
<li key={index}>
+5 -12
View File
@@ -11,7 +11,6 @@ import type { MixedShipmentExpression } from '@/game/mixed-shipment-question';
import type { PlanClaim } from '@/game/battle-promises';
import { useId, useState } from 'react';
import { Button } from '@/components/ui/button';
import { shipmentAvailable as targetShipmentAvailable } from '@/game/shipment-opportunity';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import {
truthQuestionText,
@@ -67,14 +66,7 @@ export function Truthtrance({
});
const invalidMixed = mixedShipmentInputError(mixed, g, target);
const invalidShipment = shipmentClauses.slice(0, shipmentJoin === 'single' ? 1 : 2).some(invalidShipmentClause);
const shipmentAvailable =
g.truthShipmentSupported &&
g.phase === 5 &&
g.active === target &&
targetShipmentAvailable(g, {id:target,shipped:g.players.find(p => p.id === target)?.shipped ?? false}) &&
!g.response &&
!g.decision &&
!g.phaseOpening;
const shipmentAvailable = g.truthShipmentTargets.includes(target);
const [planClauses, setPlanClauses] = useState<PlanClaim[]>([
{ kind: 'dial', compare: 'gte', value: 1 },
{ kind: 'weapon', name: null },
@@ -360,11 +352,12 @@ export function Truthtrance({
</p>
{!shipmentAvailable && (
<p className="notice">
Automatic enforcement currently supports the active
player’s unused ordinary shipment in Basic or Advanced
Automatic enforcement supports an unused shipment this
phase, including a player still waiting for their turn,
in Basic or Advanced
base rules, including the Guild, or a
classic payer in the Richese Semuta profile. Select
that player and finish pending decisions first.
an eligible player and finish pending decisions first.
Native No-Field shipments and other rules configurations
remain unfinished.
</p>
+22 -1
View File
@@ -1,6 +1,6 @@
# Current development status
Updated 18 October 2026. **The full twelve-faction goal is unfinished. Basic
Updated 24 October 2026. **The full twelve-faction goal is unfinished. Basic
is playable. Advanced, expansion-faction and supported optional-module starts
are labelled previews; complete rules/publication acceptance remains gated.**
@@ -16,6 +16,27 @@ open mode gates.
## Current checkpoint and work
**Truthtrance before the respondent's turn — 24 October. Prototyped / Partial.**
Same-phase questions now include a supported unused turn still in the original
queue. Waiting promises remain visible without an executable early action;
private reachability uses owned resources, and native Guild WAIT/TAKE retains its
original timing. Pending foreign declarations defer release; their approved ally
funding stays locked. Voluntary Emperor gifts/BG accompaniment cannot spend the
last completion. Minimal policies legally decline optional accompaniment while
bound; no strategy tuning, new profile/module or earlier-phase opening.
A disjoint genuine-fixture lane, bounded continuation review and owner
integration pass **14 files146/146**, one updated existing in-memory scope case,
types/lint/build and three original-action/rendered-table programmes plus four
finite policies. Public classic Basic/Advanced starting deals use40/45 source
actions; paid intervening turns and later Emperor3/cost3 or Guild3/half2 fulfill
in16/19/29 continuations. Four profiles make eight legal answer/shipment proposals
and finish. Separate controlled funding, price, gift and advisor probes pass.
Feedback repaired an obsolete negative waiting-target fixture and two type
declarations. No broader recovery campaign, browser/visual/deployed or full-mode
acceptance. No saved-room/database/reset/restart/deployment operation.
[Canonical behavior and exact proof](TRUTHTRANCE_SHIPMENT_PROMISES.md#same-phase-waiting-respondents--24-october-2026).
**Truthtrance with Advanced Guild — 18 October. Prototyped / Partial.** Base
Advanced questions include Guild without another profile/module opening. Native
interception, permission and rate responses retain the promise until delivery or
+22
View File
@@ -1,5 +1,27 @@
# Dune implementation status
## 24 October 2026 — Truthtrance before the respondent's turn
Supported unused shipments still in the original same-phase queue now admit
structured promises without granting an early action. Private owned-resource
reachability, frozen mixed facts, native Guild WAIT/TAKE and actual paid delivery
retain their original boundaries. Pending foreign funding outcomes defer release;
approved contributions stay locked. Voluntary gifts/accompaniment cannot spend
the last legal completion. The minimal advisor policy declines while bound.
A disjoint public-action fixture lane, bounded continuation review and owner
integration pass14-file146/146, one existing in-memory scope case, types/lint/build
and three actual gameplay/rendering programmes plus four finite policies.
Original Basic/Advanced deals use40/45 source actions. Paid earlier turns and
later Emperor3/cost3 or Guild3/half2 finish in16/19/29 continuations; four profiles
make eight legal answer/shipment proposals. Controlled probes remain separate.
Feedback repaired an obsolete waiting-target negative and two type declarations.
[Canonical behavior, source and exact proof](TRUTHTRANCE_SHIPMENT_PROMISES.md#same-phase-waiting-respondents--24-october-2026).
No other profile/module/earlier-phase opening, strategy refinement, broad recovery
campaign, complete-mode or browser/deployed acceptance. Final assurance remains
due. No saved-room/database/reset/restart/deployment operation.
## 18 October 2026 — Truthtrance with native Advanced Guild
Base Advanced shipment promises now include Guild. Native responses remain
+1 -1
View File
@@ -854,7 +854,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Purchased price with immune Junction | Keep the original offered half/full tariff and normal payees; no quarter price or bank-only overlay. Under the standing choose-recommendation instruction, accepting this source with a purchased grant requires explicit acknowledgment that it ends unused, with no card refund. Consume only upon actual delivery, not failed, stale, unfunded or deferred acceptance; the ordinary purchased route remains available. This is a conservative product composition, not a publisher worked example. | [Source immunity and selected choice](JUNCTION_TRANSPORT_RULES.md#purchased-price-and-immune-offer--15-october-2026), [original versus controlled proof](JUNCTION_TRANSPORT_RUNTIME.md#purchased-price-source-choice--15-october-2026) |
| Deferred shipment arrival | Quote unsupported ordinary arrivals before saving Guild interception. A validated legacy ordinary declaration may return unspent on the typed arrival error; corrupted declarations and special parent/card receipts are not silently cleared. This repairs continuation without deciding simultaneous reaction priority. | [Reproduction and bounded recovery](DEFERRED_SHIPMENT_ARRIVAL.md) |
| Native Ambassador own-arrival continuation | Under the standing choose-recommended-option instruction, reuse committed entry after its own Guild income, BG Intrusion and accompaniment controls. Existing callbacks put a legitimate accompanying BG fighter child before the deferred original entrant; each sees the then-live token, so declining the child preserves a later original offer and consuming it does not recreate a token. Advisors remain exempt. This child-first/live-token composition is provisional, not a located publisher priority ruling. Actual busy controls, unsupported paired/module guards and separate native rate-window admission remain distinct. | [Native original-action evidence and boundary](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026) |
| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic/base Advanced active-opportunity shipment promises include native Guild interception and rate/permission responses; no other profile/module opens. Opposing stops settle the whole claim against absent shipment; own stops cannot evade a promise. Executable guidance waits for native controls, and prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md#advanced-guild-interception-and-rates--18-october-2026), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) |
| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic/base Advanced unused current-phase shipment promises include waiting respondents, native Guild timing/interception and rate/permission responses; no other profile/module opens. Opposing stops settle the whole claim against absent shipment; own stops cannot evade a promise. Answering preserves the original queue; executable guidance waits for the actual turn and native controls. Own voluntary outflows cannot remove the last completion; prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md#same-phase-waiting-respondents--24-october-2026), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) |
| Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) |
| Moritani Advanced assassination preview | After an eligible battle loss, a different opposing-faction Traitor Card may be revealed even if its leader is already dead (zero bounty). Face-up set-aside and one private replacement occur during Mentat; one use per faction. Normal-call duration, uniform hidden-eligibility UX and exceptional combinations remain explicit. | [Runtime/source contract](MORITANI_ASSASSINATE_LEADERS.md) |
| Moritani Extortion at Mentat | The user selected normal Terror placement/relocation first, including any Karama response; only then collect the reserved five bank spice and approach other players in storm order for a possible three-spice payment. Payment removes the token; all declines recover it with rotated hidden supply IDs. The relative placement timing is a user-selected interpretation, not a publisher ruling. | [Publisher sequence and selected timing](MORITANI_EXTORTION_RULES.md#mentat-ordering-selected-interpretation-not-a-publisher-ruling) |
+88 -1
View File
@@ -4,7 +4,7 @@ Original checkpoint, 7 September 2026; Advanced follow-up, 13 September 2026. Th
The former Advanced Guild exclusion is superseded by the
[18 October native Guild composition](#advanced-guild-interception-and-rates--18-october-2026).
Earlier scope descriptions and verification totals remain historical.
The [24 October waiting-turn follow-up](#same-phase-waiting-respondents--24-october-2026) supersedes the active-only timing boundary. Earlier scope descriptions and verification totals remain historical.
## Authority and supported meaning
@@ -157,3 +157,90 @@ reference evidence edits are checked against all frozen file hashes before push.
Broader phases/routes/modules, full Truthtrance/modes, final assurance and
browser/deployed acceptance remain open. No saved-room/database/reset/restart/
deployment operation was performed.
## Same-phase waiting respondents — 24 October 2026
**Prototyped / Partial.** Structured reserve-shipment and mixed current-fact
questions now include a respondent whose unused turn remains in the original
Shipment & Movement queue, as well as the active player. The same current-turn
FAQ commitment/release rule applies; this removes a software timing boundary,
not a new printed restriction or numerical ruling. Existing base Basic/Advanced
and classic-payer Richese Semuta profiles remain; no other module or earlier
phase opens. Semuta eligibility reads the actual respondent, not the active
faction. Original Truthtrance priority and the interrupted turn stay intact.
Feasibility uses the respondent's existing resources, owned preparation and
joint whole promises. A private quote selects that owner's later turn and clears
a foreign transient shipping price/declaration, without changing the real active
player, queue or intervening decisions. No future income, voluntary cooperation,
returned troops or vacated destination is assumed. Mixed facts remain frozen at
the original answer. The public accepted question and owner promise panel stay
visible; executable completion guidance waits for the actual turn and a clean
control boundary. Native Guild WAIT/TAKE still chooses its real opportunity.
Foreign pending decisions retain the future promise until the outcome is known.
An ally's already approved shipment contribution cannot be withdrawn or counted
as freely recoverable preparation while its original declaration is pending.
Delivery may spend it and release an impossible answer; a stop preserves the
original refund and later recovery route. Other opposing completed effects still
release only when no legal completion remains. A waiting owner may not declare
an Emperor gift or accept BG accompaniment that spends the last completion;
the allowed outflow is checked before opening its response, where the eventual
responder is another player. Affordable gifts/accompaniment remain legal.
### Targeted proof and limits
A disjoint original-action fixture lane, bounded continuation review and owner
integration pass **14 files146/146**, one updated existing in-memory scope case,
`npm run check:quick`, production build and three actual-gameplay/rendered-table
programmes. First feedback updated an old negative pending-question fixture:
a waiting respondent is now legal, so the invalid case also removes that turn.
Type feedback added the queue's existing null case to the helper signature and
narrowed the generic policy answer in its test. No rule change followed feedback.
The passing frozen source is based on
`2939ffa900f8f201fd0b55173cb2406657ccc93b`, tree
`96bf67b82570590afe79feda3491373f02714ff8b591c8897f81f0f07a261285`,
2,598 tracked/nonignored files. The supplied untracked PDF contributes only its
hash; it is neither committed nor republished. Final checkpoint/reference prose
is checked against all frozen file hashes before push.
`tests/fixture-truthtrance-future-shipment.ts` earns the question from public
classic four-seat setup and the original unopened physical starting deal. Only
five unopened card positions, unopened land Spice positions and scalar lotteries
are selected. No dealt holdings, wallets, armies, phase, turn queue, timing flags
or promise receipts are installed. Three programmes finish the respondent turn:
- Basic Emperor uses40 source and16 continuation actions. Atreides really ships1
to Imperial Basin for2 first; Emperor later ships3 to Sietch Tabr for3.
- Advanced Emperor uses45 source and19 continuations, including original Guild
WAIT before the question and another afterward. The later3 include one
physical Sardaukar; the original tariff is3.
- A mixed future Guild answer uses45 source and29 continuations. Atreides and
Emperor each really pay2 for an earlier shipment; Guild waits once more while
bound, then takes its native turn before still-unstarted Harkonnen. Guild3
pays half2 and fulfills once; its earlier current-spice fact stays compiled
from the answer even as earned incoming shipment income accrues.
`tests/truthtrance-future-shipment-engine.test.ts` covers those actual paths,
original queue, no early action/witness, native timing, one physical discard and
later paid fulfillment. Four existing profiles make eight legal answer/shipment
proposals, with no early movement action, and complete their native turn in the
runtime smoke. `tests/truthtrance-future-shipment.test.ts` separately controls
foreign temporary prices, actual respondent Semuta scope, locked approved ally
funding with allowance/stop, and both rejected and affordable Emperor gifts/BG
accompaniment. All four minimal profiles legally decline the optional advisor
choice while bound; this is legality, not strategic refinement.
Actual GameTable/ShipmentPromises render the question, persistent waiting
promise without executable guidance, active private guide and finished turn.
Framework aliases/CSS stubs do not establish browser clicks, visual or deployed
acceptance. Private reports, source hashes, HTML and runtime results are in
`/home/quintine/.local/share/dune/checkpoints/20261024-truth-future-shipment/`;
`frozen-integrated-3/report.json` is the complete passing batch. Earlier failed
feedback is retained. No new save/recovery audit was added.
Earlier phases, broader routes/modules/actions, full Truthtrance/modes, final
assurance and browser/deployed acceptance remain open. No saved-room/database/
reset/restart/deployment operation was performed.
+4
View File
@@ -2720,6 +2720,10 @@ function policyActions(g: GameView): Action[] {
{ type: 'decision', accept: false },
];
}
if (d.kind === 'advisor' && liveShipmentPromises(g.shipmentPromises ?? [], me.id, g.turn).length)
// A waiting promise may need these reserves. Declining is the minimal
// legal policy until optional accompaniment strategy is implemented.
return [{ type: 'decision', accept: false }];
if (d.kind === 'advisor')
return [
...(g.advanced && d.destination && (g.homeworldMobility?.advisorAccompanyMaximum ?? 1) > 0
+62 -24
View File
@@ -373,6 +373,7 @@ import {
shipmentClaimDestinations,
liveShipmentPromises,
shipmentPromiseModeSupported,
shipmentPromiseTurnAvailable,
type ShipmentClaim,
type ShipmentPromise,
} from './shipment-promises';
@@ -13457,6 +13458,16 @@ function settleStrongholdOwnership(g: Game, quoted?: StrongholdState) {
);
}
}
function committedOutgoingAid(g: Game, p: Player): number {
if (!p.ally) return 0;
return Math.max(
g.pendingHomeworldShipment?.player === p.ally ? g.pendingHomeworldShipment.allyPayment : 0,
g.pendingShipment?.player === p.ally ? g.pendingShipment.allyPayment : 0,
g.pendingGuildTransport?.player === p.ally ? g.pendingGuildTransport.allyPayment : 0,
g.phase === 6 ? (g.battle?.plans[p.ally!]?.allyPayment ?? 0)
: g.auction?.bidder === p.ally ? (g.auction.allyPayment ?? 0) : 0,
);
}
function pledgeAid(g: Game, p: Player, requested: unknown) {
requireRule(
p.ally && getPlayer(g, p.ally).ally === p.id,
@@ -13473,14 +13484,7 @@ function pledgeAid(g: Game, p: Player, requested: unknown) {
),
'Ally funding cannot be reduced until the submitted Silent bids are revealed.',
);
const committed =
g.pendingHomeworldShipment?.player === p.ally
? g.pendingHomeworldShipment.allyPayment
: g.phase === 6
? (g.battle?.plans[p.ally]?.allyPayment ?? 0)
: g.auction?.bidder === p.ally
? (g.auction.allyPayment ?? 0)
: 0;
const committed = committedOutgoingAid(g, p);
const amount = integer(
requested,
g.richeseAuction?.method === 'silent' &&
@@ -22671,12 +22675,9 @@ function shipmentPromiseIntegrity(g: Game) {
);
if (promise.turn === g.turn && !promise.released && !promise.fulfilled)
requireRule(
shipmentPromiseModeSupported(g) &&
g.status === 'playing' &&
g.phase === 5 &&
g.active === promise.player &&
shipmentAvailable(g,getPlayer(g,promise.player)),
'The saved shipment promise does not belong to a supported unused shipment opportunity.',
shipmentPromiseModeSupported(g, promise.player) &&
shipmentPromiseTurnAvailable(g, promise.player, shipmentAvailable(g,getPlayer(g,promise.player))),
'The saved shipment promise does not belong to a supported unused shipment opportunity this phase.',
);
}
}
@@ -22702,10 +22703,7 @@ function findShipmentCompletion(
),
): { actions: Action[] } | null {
if (
state.status !== 'playing' ||
state.phase !== 5 ||
state.active !== owner.id ||
!shipmentAvailable(state,owner)
!shipmentPromiseTurnAvailable(state, owner.id, shipmentAvailable(state,owner))
)
return null;
if (promises.every(p => matchesShipment(p, null) === p.answer))
@@ -22713,6 +22711,18 @@ function findShipmentCompletion(
const initial = structuredClone(state);
initial.truthtrance = null;
projectPendingShipmentKarama(initial, owner.id);
const waiting = state.active !== owner.id;
const outgoingFloor = committedOutgoingAid(state, owner);
if (waiting) {
// Quote this owner's later turn without taking it or resolving another
// player's declaration. Keep all actual resources and the original queue.
initial.active = owner.id;
if (initial.karamaShipping?.player !== owner.id) initial.karamaShipping = null;
if (initial.pendingShipment?.player !== owner.id) initial.pendingShipment = null;
if (initial.pendingGuildTransport?.player !== owner.id) initial.pendingGuildTransport = null;
if (initial.pendingHomeworldShipment?.player !== owner.id) initial.pendingHomeworldShipment = null;
initial.pendingKarama = null;
}
// Test choices after the interrupt, while leaving the real continuation intact.
// Every actual action still revalidates its timing, quote and physical custody.
initial.response = null;
@@ -22831,8 +22841,8 @@ function findShipmentCompletion(
// conversion effects. More reserves/lower cost cannot remove
// a previously available choice of physical shipment count.
const preparations: Action[] = [];
if (g.aid[p.id]?.amount > 0)
preparations.push({ type: 'pledgeAid', amount: 0 });
if ((g.aid[p.id]?.amount ?? 0) > outgoingFloor)
preparations.push({ type: 'pledgeAid', amount: outgoingFloor });
const karama = p.hand.find((c) => canUseAsKaramaRole(g, p, c));
if (karama && !g.karamaShipping)
preparations.push({
@@ -22951,6 +22961,18 @@ function reconcileShipmentPromises(
if (promise.turn !== g.turn || promise.released || promise.fulfilled)
continue;
const p = getPlayer(g, promise.player);
const future = g.active !== p.id;
const ownVoluntary = voluntary?.actor === p.id &&
(['card', 'bribe', 'pledgeAid', 'emperorGift'].includes(voluntary.action.type) ||
future && voluntary.action.type === 'decision');
// A foreign pending parent may spend or refund its approved funding. Wait
// for that outcome; never release a future promise just for waiting.
if (future && !ownVoluntary && (g.truthtrance || g.response || g.decision ||
g.phaseOpening || g.pendingShipment || g.pendingGuildTransport ||
g.pendingHomeworldShipment || g.pendingTreacheryDiscard || g.ornithopter)) {
kept.push(promise);
continue;
}
if (
findShipmentCompletion(g, p, [
...kept.filter((old) => old.player === p.id),
@@ -22960,9 +22982,7 @@ function reconcileShipmentPromises(
kept.push(promise);
else {
requireRule(
!voluntary ||
voluntary.actor !== p.id ||
!['card', 'bribe', 'pledgeAid'].includes(voluntary.action.type),
!ownVoluntary,
'You cannot voluntarily make your Truthtrance shipment answer impossible.',
);
promise.released = true;
@@ -32727,6 +32747,13 @@ function applyActionInner(
location: location(dest.territory, dest.sector),
amount,
};
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length) {
// Accepting accompaniment is voluntary even though a later responder
// commits its reserve withdrawal. Check that allowed outcome now.
const afterAdvisor = structuredClone(g);
finishResponse(afterAdvisor, false);
reconcileShipmentPromises(afterAdvisor, { actor: p.id, action });
}
} else log(g, `${p.name} declined the free shipment.`);
} else if (decision.kind === 'wormPlacement') {
requireRule(
@@ -33645,6 +33672,13 @@ function applyActionInner(
maximum,
t === 'emperorGift' ? 'Spice' : 'Forces',
);
if (t === 'emperorGift' && liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length) {
// Permission to cancel the gift is not funding for an already promised
// shipment. Validate the voluntary outflow before opening its response.
const afterGift = structuredClone(g);
getPlayer(afterGift, p.id).spice -= amount;
reconcileShipmentPromises(afterGift, { actor: p.id, action });
}
const elite =
t === 'emperorRevival'
? eliteChoice(
@@ -35150,7 +35184,10 @@ export function viewGame(state: Game, id: string) {
battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [],
battleOrder: projectedBattleOrder(g),
guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id),
truthShipmentSupported: shipmentPromiseModeSupported(g),
truthShipmentSupported: g.players.some(p => shipmentPromiseModeSupported(g, p.id)),
truthShipmentTargets: !g.response && !g.decision && !g.phaseOpening && !g.pendingShipment
? g.players.filter(p => shipmentPromiseModeSupported(g, p.id) &&
shipmentPromiseTurnAvailable(g, p.id, shipmentAvailable(g, p))).map(p => p.id) : [],
guildShipmentStopPromiseBlock: ownGuildStopPromiseBlock(g, me),
truthShipmentAnswers:
g.truthtrance?.stage === 'answer' &&
@@ -35162,6 +35199,7 @@ export function viewGame(state: Game, id: string) {
// original question and whole answer remain public through truthHistory.
shipmentPromises: (g.shipmentPromises ?? []).filter(p => !p.mixed || p.player === id),
shipmentCompletion:
g.active === id &&
!g.pendingTreacheryDiscard &&
!g.response && !g.pendingKarama && !g.truthtrance &&
!g.decision && !g.phaseOpening && !g.pendingShipment &&
+8 -5
View File
@@ -2912,15 +2912,15 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
summary:
'Bind a shipment from reserves to a named territory with a minimum physical force count during the active supported shipment opportunity.',
'Bind an unused reserve shipment this phase, including a player whose turn is still ahead, to a named destination and minimum physical count.',
steps: [
'Declare Truthtrance, select the active player and choose Bind a shipment from reserves. Choose a printed destination and minimum of one to twenty forces, or combine two shipment conditions using AND or OR. Each describes the same shipment. Automatic enforcement supports base Basic and base Advanced games, including Guild, before that active player ships and after pending decisions finish. Other optional configurations keep their existing boundaries.',
'Declare Truthtrance, select an eligible player whose unused shipment is active or still ahead this phase, and choose Bind a shipment from reserves. Choose a printed destination and minimum of one to twenty forces, or combine two shipment conditions using AND or OR. Each describes the same shipment. Automatic enforcement supports base Basic and base Advanced games, including Guild, before that player ships and after pending decisions finish. Earlier players keep their original turns; answering never grants an early shipment. Other optional configurations keep their existing boundaries.',
'Yes requires the complete statement to match while possible: all AND conditions or at least one OR condition. No requires the whole statement to be false: at least one AND condition false, or every OR condition false. A smaller count, different destination, transport of forces already on the board or no shipment may satisfy No. Forces already in the destination and ground movement do not satisfy the question. Fremen reinforcements and Guild transport from southern reserves do count.',
'The target privately sees feasible answers based on their own resources and earlier promises. Available Ghola revival, Karama discounts, incoming pledged aid and reclaiming their own unused pledge are included. Other players’ unplayed cards and future voluntary gifts are not assumed.',
'Accepted whole answers are public and constrain the event together with earlier promises. When an answer requires a shipment, honor it before ground movement or finishing and do not voluntarily spend away the last legal completion. A completed opposing effect that makes compliance impossible releases it publicly.',
'Guild interception and rate or transport-permission responses finish before delivery. An opposing stop consumes the shipment without payment or arrival: answers matching no shipment fulfill and impossible answers release. You cannot stop your own shipment to evade your answer. An unaffordable canceled rate leaves shipment available; a retained owned discount or ordinary Fremen reinforcement may still provide a legal route.',
'Choose Mixed current fact and shipment for nested AND/OR groups of supported current facts and this-turn reserve shipments. Current facts freeze privately when answered: Yes OR a true current fact can allow skipping even after later losing that holding; No AND a false current fact can allow shipping even after that fact changes. Unknown is not false. Only the whole question and answer are public.',
'Your own promises show the original grouped question, not private compiled clause results. Optional private next-step guidance includes preparation, funding or shipment, only after pending controls finish. All four legal policies use server-feasible choices and the same complete route. Earlier phases, other modules, expansion shipment modes, other future actions and arbitrary prose enforcement remain unfinished.',
'Your own promises show the original grouped question, not private compiled clause results. While waiting, the promise remains visible without an executable action. Optional private next-step guidance includes preparation, funding or shipment only on your actual turn after pending controls finish. All four legal policies use server-feasible choices and the same complete route. Earlier phases, other modules, expansion shipment modes, other future actions and arbitrary prose enforcement remain unfinished.',
],
example:
'Yes to at least six forces to Carthag and No to at least eight there leaves a shipment of six or seven. Moving existing forces into Carthag does not fulfill either shipment event.',
@@ -2930,7 +2930,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Authoritative reserve commitments, mixed current-fact AND/OR, frozen clauses, owned preparation, actual shipment/skip and opposing release include base Advanced Guild interception, permission and rates. Self-stop cannot evade an answer; broader timing/modules remain unfinished.',
'Authoritative reserve commitments, mixed current-fact AND/OR, frozen clauses, owned preparation, actual shipment/skip and opposing release include base Advanced Guild interception, permission and rates. Same-phase waiting turns retain joint promises through native Guild WAIT/TAKE and paid intervening turns. Own gifts/accompaniment cannot spend the last completion; broader phases/modules remain unfinished.',
},
{
area: 'Player controls',
@@ -2954,7 +2954,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'Current Guild batch passes106 rule cases plus one updated existing in-memory scope case, types/lint/build, four original-action/rendered-table programmes and four finite policy continuations. Earlier persistence/privacy evidence is historical; full Truthtrance, modes, final assurance and deployed acceptance remain open.',
'Current waiting-turn batch passes146 rule cases plus one updated existing in-memory scope case, types/lint/build, three original-action/rendered-table programmes and four finite legal policy continuations. Earlier persistence/privacy evidence is historical; full Truthtrance, modes, final assurance and deployed acceptance remain open.',
evidence: [
'tests/shipment-promises.test.ts',
'tests/shipment-promises-bots.test.ts',
@@ -2965,6 +2965,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/compound-shipment-recovery.test.ts',
'tests/mixed-shipment-question.test.ts',
'tests/mixed-shipment-engine.test.ts',
'tests/truthtrance-future-shipment.test.ts',
'tests/truthtrance-future-shipment-engine.test.ts',
'tests/fixture-truthtrance-future-shipment.ts',
'tests/truthtrance-guild-shipment.test.ts',
'tests/truthtrance-guild-shipment-engine.test.ts',
'tests/fixture-truthtrance-guild-shipment.ts',
+12 -2
View File
@@ -159,7 +159,7 @@ export function shipmentPromiseModeSupported(game: {
semutaPreview?: unknown;
leaderSkills?: unknown;
discoveryEnabled?: unknown;
}): boolean {
}, respondent = game.active): boolean {
if (game.semutaPreview === true && game.expansions.length === 1 &&
game.expansions[0] === 'choam' && !game.techTokens &&
!game.strongholdCards && !game.nexusCards && !game.homeworlds &&
@@ -169,7 +169,7 @@ export function shipmentPromiseModeSupported(game: {
player.faction === 'atreides' || player.faction === 'harkonnen' ||
player.faction === 'emperor' || player.faction === 'beneGesserit' ||
player.faction === 'fremen')) {
const target = game.players.find(player => player.id === game.active);
const target = game.players.find(player => player.id === respondent);
return !!target && target.faction !== 'richese';
}
return (
@@ -181,6 +181,16 @@ export function shipmentPromiseModeSupported(game: {
!game.homeworlds))
);
}
/** Current-turn eligibility, distinct from permission to execute an action now. */
export function shipmentPromiseTurnAvailable(game: {
status: string;
phase: number;
active?: string | null;
movementRemaining?: readonly string[] | null;
}, player: string, unused: boolean): boolean {
return game.status === 'playing' && game.phase === 5 && unused &&
(game.active === player || !!game.movementRemaining?.includes(player));
}
export type ShipmentPromise = ShipmentClaim & {
turn: number;
player: string;
+6 -9
View File
@@ -2,6 +2,7 @@ import { shipmentAvailable } from './shipment-opportunity';
import type { ShipmentClaim } from './shipment-promises';
import {
shipmentPromiseModeSupported,
shipmentPromiseTurnAvailable,
parseShipmentClaim,
shipmentClaimText,
ShipmentClaimError,
@@ -192,19 +193,15 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
);
if (v.kind === 'shipment' || v.kind === 'mixedShipment') {
check(
shipmentPromiseModeSupported(g) &&
g.status === 'playing' &&
g.phase === 5 &&
g.active === v.target &&
shipmentAvailable(
g,
g.players.find((p) => p.id === v.target)!,
) &&
shipmentPromiseModeSupported(g, v.target) &&
shipmentPromiseTurnAvailable(g, v.target, shipmentAvailable(
g, g.players.find((p) => p.id === v.target)!,
)) &&
!g.response &&
!g.decision &&
!g.phaseOpening &&
!g.pendingShipment,
'Structured shipment promises require a supported active unused shipment: Basic or Advanced base rules, or a classic payer in the Richese Semuta profile. Finish pending decisions first.',
'Structured shipment promises require a supported unused shipment this phase: the respondent must be active or still waiting for their turn. Finish pending decisions first.',
);
try {
if (v.kind === 'mixedShipment') {
@@ -0,0 +1,181 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type FutureShipmentStep = { actor: string; action: Action };
export type FutureShipmentTransition = { before: Game; step: FutureShipmentStep; after: Game };
export const futureShipmentPlayer = (game: Game, actor = 'emperor') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const futureShipmentClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function actFutureShipment(game: Game, actor: string, action: Action, actions: FutureShipmentStep[] = []): Game {
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
/** Real original decisions only. The owned programme supplies its questions,
* answers, shipments and each deliberately selected Guild timing choice. */
export function nextFutureShipmentStep(game: Game): FutureShipmentStep | null {
if (game.truthtrance) {
if (game.truthtrance.stage !== 'priority') return null;
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'truthPass' } };
}
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard && game.decision?.kind === 'guildTiming')
return { actor: game.decision.player, action: { type: 'decision', take: false } };
return nextStrongholdFactionsNativeStep(game);
}
export function advanceFutureShipment(game: Game, until: (state: Game) => boolean, actions: FutureShipmentStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original future-shipment programme ended before its next control');
const step = nextFutureShipmentStep(game); assert.ok(step, 'Choose the actual structured question and answer before advancing');
game = actFutureShipment(game, step.actor, step.action, actions);
}
throw Error('The original future-shipment programme did not reach its finite boundary');
}
/** Original public Basic/Advanced classic setup deals selected unopened cards,
* including the physical Truthtrance. Advanced starts with an actual Guild
* WAIT, leaving the distinct respondent's original turn still ahead. No dealt
* custody, armies, resources, phase, queue, timing flags or promises are staged. */
export function createFutureShipmentFixture(options: {
advanced?: boolean; target?: 'emperor' | 'guild'; mixed?: boolean;
} = {}) {
const target = options.target ?? 'emperor', advanced = options.advanced ?? target === 'guild';
assert.ok(target !== 'guild' || advanced, 'The bounded future Guild programme uses its native Advanced timing');
const mixed = options.mixed ?? target === 'guild';
let game = createGame('TRUTHFUTURESHIPMENT', newPlayer('atreides', 'Atreides', 'atreides'), advanced);
for (const faction of ['emperor', 'harkonnen', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: FutureShipmentStep[] = [];
const act = (actor: string, action: Action) => { game = actFutureShipment(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceFutureShipment(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0));
const truth = game.deck.find(card => card.effect === 'truthtrance');
const karama = game.deck.find(card => card.effect === 'karama');
const filler = [game.deck.find(card => card.name === 'Baliset'),
game.deck.find(card => card.kind === 'shield'), game.deck.find(card => card.kind === 'snooper')];
assert.ok(truth && karama && filler.every(Boolean));
const deal = [truth.id, filler[0]!.id, karama.id, filler[1]!.id, filler[2]!.id];
for (const [position, id] of deal.entries()) {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
}
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
assert.ok(futureShipmentPlayer(game, 'atreides').hand.some(card => card.id === truth.id));
assert.ok(futureShipmentPlayer(game, 'harkonnen').hand.some(card => card.id === karama.id));
for (let count = 0; count < (advanced ? 2 : 1); count++) {
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(index >= 0);
front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
let initialWait: FutureShipmentTransition | null = null;
if (advanced) {
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
const before = structuredClone(game), step: FutureShipmentStep = { actor: 'guild', action: { type: 'decision', take: false } };
act(step.actor, step.action); reach(futureShipmentClean);
initialWait = { before, step, after: structuredClone(game) };
} else reach(state => state.phase === 5 && state.active === 'atreides' && futureShipmentClean(state));
assert.equal(game.active, 'atreides');
assert.notEqual(game.active, target);
assert.ok(game.movementRemaining!.includes(target));
assert.equal(futureShipmentPlayer(game, target).shipped, false);
const question: TruthQuestion = mixed ? { kind: 'mixedShipment', target, mixed: { kind: 'and', terms: [
{ kind: 'shipment', territory: 'sietch_tabr', minimum: 3 },
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 3 } },
] } } : { kind: 'shipment', target, territory: 'sietch_tabr', minimum: 3 };
return { game, lobby, setup, beforeStartingDeal, afterSetup, initialWait, question,
advanced, mixed, target, asker: 'atreides', guild: 'guild', counterHolder: 'harkonnen',
card: truth.id, karama: karama.id, location: 'sietch_tabr:14', deal,
sourceActionCount: actions.length, actions,
staging: ['Only five unopened original starting-deal positions, unopened land Spice positions and scalar lotteries are selected. Public classic Basic/Advanced setup actually deals Truthtrance to Atreides and a distinct Karama to Harkonnen for genuine native response passes. Advanced begins with the real Guild WAIT. No postdeal holdings, resources, armies, clocks, movement queue, timing flags or promise receipts are assigned.'] };
}
export type FutureShipmentFixture = ReturnType<typeof createFutureShipmentFixture>;
export function askFutureShipment(fixture: FutureShipmentFixture) {
const actions: FutureShipmentStep[] = [], before = structuredClone(fixture.game);
const declaration: FutureShipmentStep = { actor: fixture.asker, action: { type: 'card', card: fixture.card } };
let game = actFutureShipment(before, declaration.actor, declaration.action, actions);
const opened = structuredClone(game);
game = advanceFutureShipment(game, game => game.truthtrance?.stage === 'ask', actions);
const beforeQuestion = structuredClone(game), question: FutureShipmentStep = { actor: fixture.asker,
action: { type: 'truthAsk', question: fixture.question } };
game = actFutureShipment(game, question.actor, question.action, actions);
assert.equal(game.truthtrance?.stage, 'answer');
return { game, before, declaration, opened, beforeQuestion, question, actions };
}
export function bindFutureShipment(fixture: FutureShipmentFixture) {
const asked = askFutureShipment(fixture), actions = [...asked.actions];
const answer: FutureShipmentStep = { actor: fixture.target, action: { type: 'truthAnswer', answer: 'yes' } };
const game = actFutureShipment(asked.game, answer.actor, answer.action, actions);
assert.equal(game.active, fixture.asker);
assert.ok(futureShipmentClean(game));
return { ...asked, asked: asked.game, game, answer, actions };
}
/** Keep the original promise throughout paid intervening turns. For a Guild
* respondent, WAIT before Emperor is an actual new choice while still bound;
* TAKE before Harkonnen then uses the original out-of-order timing response. */
export function startFutureShipmentTarget(fixture: FutureShipmentFixture, state: Game) {
let game = structuredClone(state);
const actions: FutureShipmentStep[] = [], prefixes: {
before: Game; shipment: FutureShipmentStep; declared: Game; afterShipment: Game;
end: FutureShipmentStep; afterEnd: Game;
}[] = [], timing: { before: Game; step: FutureShipmentStep; declared: Game; after: Game }[] = [];
const act = (actor: string, action: Action) => { game = actFutureShipment(game, actor, action, actions); };
const settle = () => { game = advanceFutureShipment(game,
game => futureShipmentClean(game) && !game.pendingShipment, actions); };
const completePrefix = (actor: string, territory: string, sector: number) => {
assert.equal(game.active, actor);
const before = structuredClone(game), shipment: FutureShipmentStep = { actor,
action: { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 } };
act(shipment.actor, shipment.action);
const declared = structuredClone(game); settle();
const afterShipment = structuredClone(game), end: FutureShipmentStep = { actor, action: { type: 'endMovement' } };
act(end.actor, end.action);
prefixes.push({ before, shipment, declared, afterShipment, end, afterEnd: structuredClone(game) });
};
const chooseTiming = (take: boolean) => {
assert.equal(game.decision?.kind, 'guildTiming');
const before = structuredClone(game), step: FutureShipmentStep = { actor: fixture.guild,
action: { type: 'decision', take } };
act(step.actor, step.action);
const declared = structuredClone(game); settle();
timing.push({ before, step, declared, after: structuredClone(game) });
};
completePrefix(fixture.asker, 'imperial_basin', 9);
if (fixture.advanced) chooseTiming(false);
if (fixture.target === fixture.guild) {
assert.equal(game.active, 'emperor');
completePrefix('emperor', 'polar_sink', 0);
chooseTiming(true);
}
assert.equal(game.active, fixture.target);
assert.equal(futureShipmentPlayer(game, fixture.target).shipped, false);
return { game, prefixes, timing, actions };
}
/** Execute only at the target's actual native turn, then explicitly finish it.
* Delivery alone settles the promise; earlier payments and timing never do. */
export function completeFutureShipment(fixture: FutureShipmentFixture) {
const binding = bindFutureShipment(fixture), started = startFutureShipmentTarget(fixture, binding.game);
const actions = [...binding.actions, ...started.actions];
let game = started.game;
const beforeShipment = structuredClone(game), shipment: FutureShipmentStep = { actor: fixture.target, action: {
type: 'ship', territory: 'sietch_tabr', sector: 14, amount: 3,
elite: fixture.advanced && fixture.target === 'emperor' ? 1 : 0, allyPayment: 0,
} };
game = actFutureShipment(game, shipment.actor, shipment.action, actions);
const declared = structuredClone(game);
game = advanceFutureShipment(game, game => futureShipmentClean(game) && !game.pendingShipment, actions);
const settled = structuredClone(game), end: FutureShipmentStep = { actor: fixture.target, action: { type: 'endMovement' } };
game = actFutureShipment(game, end.actor, end.action, actions);
return { game, binding, started, beforeShipment, shipment, declared, settled, end, actions };
}
+2
View File
@@ -539,6 +539,7 @@ void test('corrupt persisted promise records and pending shipment questions reje
},
(g) => {
g.active = f.seats[2].playerId;
g.movementRemaining = g.movementRemaining!.filter(id => id !== f.seats[1].playerId);
},
(g) => {
g.phase = 6;
@@ -580,6 +581,7 @@ void test('corrupt persisted promise records and pending shipment questions reje
Object.assign(g.truthtrance!.question!, {
target: f.seats[2].playerId,
});
g.movementRemaining = g.movementRemaining!.filter(id => id !== f.seats[2].playerId);
},
(g) => {
g.truthtrance!.queue[0].player = 'missing-holder';
+2 -1
View File
@@ -538,7 +538,7 @@ void test('malformed questions and unsupported public scopes reject without disc
if (change === 'expansion') bad.expansions = ['ix'];
if (change === 'phase') bad.phase = 4;
if (change === 'spent') bad.players[1].shipped = true;
if (change === 'inactive') bad.active = 'a';
if (change === 'inactive') { bad.active = 'a'; bad.movementRemaining = ['a', 'o']; }
reject(bad, 'a', {
type: 'truthAsk',
question: {
@@ -607,6 +607,7 @@ void test('saved malformed promise bindings reject before action or normalizatio
},
(g) => {
g.active = 'a';
g.movementRemaining = ['a', 'o'];
},
(g) => {
g.players[1].shipped = true;
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
actFutureShipment, advanceFutureShipment, askFutureShipment, completeFutureShipment,
createFutureShipmentFixture, futureShipmentClean, futureShipmentPlayer as player,
startFutureShipmentTarget, type FutureShipmentFixture,
} from './fixture-truthtrance-future-shipment';
const promise = (game: Game) => { assert.equal(game.shipmentPromises!.length, 1); return game.shipmentPromises![0]; };
function live(game: Game, target: string) {
const held = promise(game);
assert.equal(held.player, target);
assert.equal(held.answer, true);
assert.equal(held.fulfilled, undefined);
assert.equal(held.released, undefined);
}
function source(f: FutureShipmentFixture) {
assert.equal(f.game.advanced, f.advanced);
assert.deepEqual(f.game.expansions, []);
assert.ok(!f.game.techTokens && !f.game.strongholdCards && !f.game.homeworlds && !f.game.nexusCards &&
!f.game.leaderSkills && !f.game.discoveryEnabled);
assert.ok(f.actions.some(step => step.actor === f.game.host && step.action.type === 'start'));
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 5).map(card => card.id), f.deal);
assert.ok(player(f.afterSetup, f.asker).hand.some(card => card.id === f.card && card.effect === 'truthtrance'));
assert.ok(player(f.afterSetup, f.counterHolder).hand.some(card => card.id === f.karama && card.effect === 'karama'));
assert.equal(player(f.afterSetup, f.counterHolder).hand.length, 2);
assert.equal(f.game.phase, 5);
assert.equal(f.game.active, f.asker);
assert.notEqual(f.game.active, f.target);
assert.ok(f.game.movementRemaining!.includes(f.target));
assert.equal(player(f.game, f.target).shipped, false);
assert.ok(viewGame(f.game, f.asker).truthShipmentTargets.includes(f.target));
if (f.advanced) {
assert.ok(f.initialWait);
assert.equal(f.initialWait.step.action.take, false);
assert.equal(f.initialWait.before.decision?.kind, 'guildTiming');
}
assert.equal(f.sourceActionCount, f.actions.length);
}
function completed(f: FutureShipmentFixture, result: ReturnType<typeof completeFutureShipment>) {
assert.ok(viewGame(result.binding.asked, f.target).truthShipmentAnswers!.includes('yes'));
live(result.binding.game, f.target);
assert.equal(result.binding.game.active, f.asker);
assert.equal(result.binding.game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(viewGame(result.binding.game, f.target).shipmentCompletion, null);
assert.deepEqual(result.binding.game.movementRemaining, f.game.movementRemaining);
assert.deepEqual(result.binding.game.order, f.game.order);
for (const prefix of result.started.prefixes) {
live(prefix.before, f.target); live(prefix.declared, f.target); live(prefix.afterShipment, f.target); live(prefix.afterEnd, f.target);
assert.equal(player(prefix.before, prefix.shipment.actor).spice - player(prefix.afterShipment, prefix.shipment.actor).spice, 2);
assert.equal(player(prefix.before, prefix.shipment.actor).reserves - player(prefix.afterShipment, prefix.shipment.actor).reserves, 1);
assert.equal(prefix.afterEnd.movementRemaining!.includes(prefix.shipment.actor), false);
assert.equal(viewGame(prefix.declared, f.target).shipmentCompletion, null);
}
for (const timing of result.started.timing) {
live(timing.before, f.target); live(timing.declared, f.target); live(timing.after, f.target);
assert.deepEqual(timing.before.order, timing.after.order);
}
live(result.beforeShipment, f.target);
assert.equal(result.beforeShipment.active, f.target);
assert.ok(viewGame(result.beforeShipment, f.target).shipmentCompletion);
assert.throws(() => actFutureShipment(result.beforeShipment, f.target, { type: 'endMovement' }), /Truthtrance/i);
assert.throws(() => actFutureShipment(result.beforeShipment, f.target,
{ ...result.shipment.action, amount: 2, elite: 0 }), /Truthtrance/i);
const cost = f.target === f.guild ? 2 : 3;
assert.equal(player(result.beforeShipment, f.target).spice - player(result.settled, f.target).spice, cost);
assert.equal(player(result.beforeShipment, f.target).reserves - player(result.settled, f.target).reserves, 3);
assert.equal(player(result.settled, f.target).forces[f.location], 3);
assert.equal(player(result.settled, f.target).shipped, true);
assert.equal(promise(result.settled).fulfilled, true);
assert.equal(promise(result.settled).released, undefined);
assert.equal(result.settled.discard.filter(card => card.id === f.card).length, 1);
assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(result.actions.filter(step => step.actor === f.target && step.action.type === 'ship').length, 1);
assert.equal(result.game.movementRemaining!.includes(f.target), false);
assert.deepEqual(result.game.order, f.game.order);
assert.throws(() => actFutureShipment(result.settled, f.target, result.shipment.action));
}
for (const advanced of [false, true]) {
void test(`genuine ${advanced ? 'Advanced' : 'Basic'} future Emperor promise survives another player's paid turn then fulfills its actual shipment once`, () => {
const f = createFutureShipmentFixture({ advanced }); source(f);
const result = completeFutureShipment(f); completed(f, result);
assert.equal(result.started.prefixes.length, 1);
assert.equal(result.started.prefixes[0].shipment.actor, f.asker);
assert.equal(player(result.settled, f.asker).forces['imperial_basin:9'], 1);
assert.equal(player(result.settled, f.guild).spice - player(result.beforeShipment, f.guild).spice, 3);
assert.throws(() => actFutureShipment(result.binding.game, f.target, result.shipment.action));
assert.throws(() => actFutureShipment(result.binding.game, f.target, { type: 'endMovement' }));
if (advanced) {
assert.equal(player(result.settled, f.target).elites!.forces[f.location], 1);
assert.equal(player(result.beforeShipment, f.target).elites!.reserves - player(result.settled, f.target).elites!.reserves, 1);
assert.equal(result.declared.decision?.kind, 'guildShipment');
live(result.declared, f.target);
assert.equal(viewGame(result.declared, f.target).shipmentCompletion, null);
assert.deepEqual(result.started.timing.map(choice => choice.step.action.take), [false]);
}
});
}
void test('genuine future Guild mixed promise remains binding through WAIT and earlier paid turns before native TAKE and half-rate delivery', () => {
const f = createFutureShipmentFixture({ target: 'guild', mixed: true }); source(f);
const result = completeFutureShipment(f); completed(f, result);
assert.equal(result.binding.asked.truthtrance!.question!.kind, 'mixedShipment');
assert.ok(promise(result.binding.game).mixed);
assert.deepEqual(result.started.prefixes.map(prefix => prefix.shipment.actor), ['atreides', 'emperor']);
assert.deepEqual(result.started.timing.map(choice => choice.step.action.take), [false, true]);
assert.equal(result.started.timing[0].after.active, 'emperor');
assert.equal(result.started.timing[1].before.decision?.kind, 'guildTiming');
if (result.started.timing[1].before.decision?.kind === 'guildTiming')
assert.equal(result.started.timing[1].before.decision.next, 'harkonnen');
assert.equal(result.started.timing[1].declared.response?.kind, 'guildTiming');
assert.equal(result.started.timing[1].after.active, f.guild);
assert.equal(result.started.timing[1].after.guildTimingGranted, true);
assert.ok(result.started.actions.some(step => step.actor === f.counterHolder && step.action.type === 'passResponse'));
assert.equal(player(result.beforeShipment, f.guild).spice - player(result.binding.game, f.guild).spice, 4);
assert.equal(player(result.settled, 'emperor').forces['polar_sink:0'], 1);
assert.equal(result.beforeShipment.movementRemaining!.includes('atreides'), false);
assert.equal(result.beforeShipment.movementRemaining!.includes('emperor'), false);
assert.ok(result.game.movementRemaining!.includes('harkonnen'));
assert.equal(player(result.game, 'harkonnen').shipped, false);
assert.equal(player(result.game, 'harkonnen').moved, 0);
});
void test('all four minimal profiles answer the actual future question without an early witness and ship legally only after the intervening native turn', () => {
const f = createFutureShipmentFixture({ advanced: true }), asked = askFutureShipment(f);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const answerView = viewGame(asked.game, f.target);
answerView.players.find(player => player.id === f.target)!.bot = difficulty;
const answer = botActions(answerView).find(action => action.type === 'truthAnswer'); assert.ok(answer);
assert.ok(answer.answer === 'yes');
assert.ok(answerView.truthShipmentAnswers!.includes(answer.answer));
const bound = actFutureShipment(structuredClone(asked.game), f.target, answer);
live(bound, f.target);
const waiting = viewGame(bound, f.target);
waiting.players.find(player => player.id === f.target)!.bot = difficulty;
assert.equal(waiting.shipmentCompletion, null);
assert.equal(botActions(waiting).some(action => ['ship', 'guildShip', 'move', 'endMovement'].includes(action.type)), false);
const started = startFutureShipmentTarget(f, bound), active = viewGame(started.game, f.target);
active.players.find(player => player.id === f.target)!.bot = difficulty;
const shipment = botActions(active).find(action => action.type === 'ship'); assert.ok(shipment);
assert.equal(shipment.territory, 'sietch_tabr');
assert.ok(Number(shipment.amount) >= 3);
let game = actFutureShipment(started.game, f.target, shipment);
game = advanceFutureShipment(game, game => futureShipmentClean(game) && !game.pendingShipment);
assert.equal(promise(game).fulfilled, true);
assert.equal(promise(game).released, undefined);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
}
});
+173
View File
@@ -0,0 +1,173 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { shipmentPromiseModeSupported, shipmentPromiseTurnAvailable } from '../game/shipment-promises';
import { ShipmentPromises } from '../components/shipment-promises';
import { botActions } from '../game/bots';
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
// Controlled timing/funding probes; the separate fixture earns its source.
function fixture(spice = 6, advanced = false) {
const g = createGame('FUTURESHIPPROBE', newPlayer('a', 'Current', 'atreides'), advanced);
g.players.push(newPlayer('p', 'Waiting Emperor', 'emperor'), newPlayer('o', 'Other', advanced ? 'guild' : 'harkonnen'));
Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'a',
order: ['a', 'p', 'o'], movementRemaining: ['a', 'p', 'o'], deck: baseDeck() });
for (const p of g.players) { p.hand = []; p.forces = {}; p.reserves = 20; p.spice = 12; }
player(g, 'p').spice = spice;
return g;
}
function hold(g: Game, actor: string, effect: string) {
const index = g.deck.findIndex(c => c.effect === effect); assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0]; player(g, actor).hand.push(card); return card.id;
}
function bind(state: Game, minimum = 6) {
const g = structuredClone(state), card = hold(g, 'a', 'truthtrance');
let game = applyAction(g, 'a', { type: 'card', card });
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!;
game = applyAction(game, actor.id, { type: 'truthPass' });
}
assert.ok(viewGame(game, 'a').truthShipmentTargets.includes('p'));
game = applyAction(game, 'a', { type: 'truthAsk', question: { kind: 'shipment', target: 'p', territory: 'carthag', minimum } });
assert.ok(viewGame(game, 'p').truthShipmentAnswers!.includes('yes'));
return applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' });
}
function pass(state: Game) {
let g = state;
for (let n = 0; g.response && n < 30; n++) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id))!;
g = applyAction(g, actor.id, { type: 'passResponse' });
}
assert.equal(g.response, null); return g;
}
function startTarget(state: Game) {
let g = applyAction(state, 'a', { type: 'endMovement' });
if (g.decision?.kind === 'guildTiming')
g = pass(applyAction(g, g.decision.player, { type: 'decision', take: false }));
assert.equal(g.active, 'p'); return g;
}
function complete(state: Game) {
let g = state;
for (let n = 0; !player(g, 'p').shipped && n < 20; n++) {
if (g.response) g = pass(g);
else if (g.decision?.kind === 'guildShipment')
g = applyAction(g, g.decision.player, { type: 'decision', allow: true });
else {
const action = viewGame(g, 'p').shipmentCompletion?.actions[0]; assert.ok(action);
g = applyAction(g, 'p', action);
}
}
g = pass(g); assert.equal(player(g, 'p').shipped, true); return g;
}
function reject(g: Game, actor: string, action: Action) {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
}
void test('same-phase target eligibility requires an unused active or original remaining turn and evaluates the actual Semuta respondent', () => {
const g = fixture();
assert.equal(shipmentPromiseTurnAvailable(g, 'a', true), true);
assert.equal(shipmentPromiseTurnAvailable(g, 'p', true), true);
assert.equal(shipmentPromiseTurnAvailable(g, 'p', false), false);
assert.equal(shipmentPromiseTurnAvailable({ ...g, movementRemaining: ['a'] }, 'p', true), false);
assert.equal(shipmentPromiseTurnAvailable({ ...g, phase: 4 }, 'p', true), false);
const mode = { ...g, advanced: true, active: 'r', expansions: ['choam'], semutaPreview: true,
players: [{ id: 'r', faction: 'richese' }, { id: 'p', faction: 'emperor' }] };
assert.equal(shipmentPromiseModeSupported(mode, 'p'), true);
assert.equal(shipmentPromiseModeSupported(mode, 'r'), false);
});
void test('a waiting promise supplies no early executable action, survives the intervening turn and fulfills only actual later delivery', () => {
const bound = bind(fixture());
assert.equal(bound.active, 'a'); assert.equal(viewGame(bound, 'p').shipmentCompletion, null);
const html = renderToStaticMarkup(createElement(ShipmentPromises, { game: viewGame(bound, 'p'), busy: false, act() { assert.fail('Render cannot act'); } }));
assert.match(html, /Your shipment turn is still ahead/); assert.doesNotMatch(html, /Suggested next step/);
reject(bound, 'p', { type: 'ship', territory: 'carthag', sector: 11, amount: 6 });
reject(bound, 'p', { type: 'endMovement' });
const active = startTarget(bound); assert.equal(active.shipmentPromises![0].released, undefined);
assert.ok(viewGame(active, 'p').shipmentCompletion);
const done = complete(active);
assert.equal(done.shipmentPromises![0].fulfilled, true);
assert.equal(player(done, 'p').forces['carthag:11'], 6); assert.equal(player(done, 'p').spice, 0);
});
void test('a foreign temporary price does not hide the waiting respondent’s owned Karama completion', () => {
const g = fixture(3), own = hold(g, 'p', 'karama'), foreign = hold(g, 'a', 'karama');
const bound = bind(g), priced = pass(applyAction(bound, 'a', { type: 'card', card: foreign, mode: 'shipment', target: 'a' }));
assert.equal(priced.karamaShipping?.player, 'a');
assert.equal(priced.shipmentPromises![0].released, undefined);
assert.equal(viewGame(priced, 'p').shipmentCompletion, null);
const active = startTarget(priced);
assert.equal(viewGame(active, 'p').shipmentCompletion?.actions[0].card, own);
const done = complete(active);
assert.equal(done.shipmentPromises![0].fulfilled, true);
assert.equal(player(done, 'p').spice, 0); assert.equal(player(done, 'p').forces['carthag:11'], 6);
assert.equal(done.discard.filter(c => c.id === own).length, 1);
});
void test('a waiting Emperor cannot declare a gift that removes the last completion, while an affordable gift remains legal', () => {
for (const spice of [6, 7]) {
const g = fixture(spice); player(g, 'p').ally = 'a'; player(g, 'a').ally = 'p';
const bound = bind(g), gift = { type: 'emperorGift', amount: 1 };
if (spice === 6) reject(bound, 'p', gift);
else {
const gifted = pass(applyAction(bound, 'p', gift));
assert.equal(player(gifted, 'p').spice, 6); assert.equal(gifted.shipmentPromises![0].released, undefined);
assert.equal(complete(startTarget(gifted)).shipmentPromises![0].fulfilled, true);
}
}
});
void test('a waiting Bene Gesserit can accompany only while retaining a legal promised reserve shipment', () => {
for (const reserves of [1, 2]) {
const g = fixture(6);
player(g, 'p').faction = 'beneGesserit';
player(g, 'p').name = 'Waiting Bene Gesserit';
player(g, 'p').reserves = reserves;
player(g, 'p').forces = { 'polar_sink:0': 20 - reserves };
const bound = bind(g, 1);
const shipped = pass(applyAction(bound, 'a', {
type: 'ship', territory: 'carthag', sector: 11, amount: 1,
}));
assert.equal(shipped.decision?.kind, 'advisor');
assert.equal(shipped.shipmentPromises![0].released, undefined);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(shipped, 'p');
view.players.find(p => p.id === 'p')!.bot = difficulty;
const choices = botActions(view);
assert.deepEqual(choices, [{ type: 'decision', accept: false }]);
assert.equal(applyAction(shipped, 'p', choices[0]).decision, null);
}
if (reserves === 1) reject(shipped, 'p', { type: 'decision', accept: true });
const choice = pass(applyAction(shipped, 'p', { type: 'decision', accept: reserves === 2 }));
assert.equal(player(choice, 'p').reserves, 1);
const done = complete(startTarget(choice));
assert.equal(done.shipmentPromises![0].fulfilled, true);
assert.equal(player(done, 'p').forces['carthag:11'], 1);
}
});
for (const stop of [false, true]) void test(
`a foreign approved shipment locks outgoing funding until ${stop ? 'stop refunds it for the waiting completion' : 'delivery spends it and releases the impossible answer'}`, () => {
const g = fixture(6, true); player(g, 'p').ally = 'a'; player(g, 'a').ally = 'p';
const karama = hold(g, 'o', 'karama');
const pledged = applyAction(g, 'p', { type: 'pledgeAid', amount: 2 });
const bound = bind(pledged), pending = applyAction(bound, 'a', {
type: 'ship', territory: 'carthag', sector: 11, amount: 2, allyPayment: 2,
});
assert.equal(pending.decision?.kind, 'guildShipment');
assert.equal(pending.shipmentPromises![0].released, undefined);
reject(pending, 'p', { type: 'pledgeAid', amount: 0 });
const settled = pass(applyAction(pending, 'o', stop
? { type: 'card', mode: 'special', card: karama }
: { type: 'decision', allow: true }));
assert.equal(settled.shipmentPromises![0].released === true, !stop);
assert.equal(settled.aid.p.amount, stop ? 2 : 0);
if (stop) {
const done = complete(startTarget(settled));
assert.equal(done.shipmentPromises![0].fulfilled, true);
assert.equal(player(done, 'p').forces['carthag:11'], 6);
}
});