diff --git a/components/game-table.tsx b/components/game-table.tsx index 56317bd7..6acbb5f0 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1997,6 +1997,9 @@ export function GameTable({ onSectorChange={setSector} /> } + {g.status === 'playing' && g.phase < 5 && ( + + )} {g.guildBetrayalReaction ? ( ) : g.richeseBetrayalReaction ? ( diff --git a/components/shipment-promises.tsx b/components/shipment-promises.tsx index 3ad4c2e3..2b99f505 100644 --- a/components/shipment-promises.tsx +++ b/components/shipment-promises.tsx @@ -19,7 +19,17 @@ export function ShipmentPromises({ const promises = liveShipmentPromises(g.shipmentPromises, g.me, g.turn); if (!promises.length) return null; const me = g.players.find((p) => p.id === g.me)!; - const next = g.shipmentCompletion?.actions[0]; + const early = g.phase < 5; + const next = g.phase === 5 ? g.shipmentCompletion?.actions[0] : undefined; + const preparation = early ? g.shipmentPreparation?.actions[0] : undefined; + const preparationCard = preparation?.type === 'card' + ? me.hand?.find(card => card.id === preparation.card) : undefined; + const preparationLabel = preparation?.type === 'charity' + ? 'Claim charity' + : preparation?.type === 'revive' + ? `Revive ${Number(preparation.amount)} force${Number(preparation.amount) === 1 ? '' : 's'} in Revival` + : preparationCard ? `Play ${preparationCard.name}` : null; + const blocked = busy || !!g.truthtrance || !!g.response || !!g.decision || !!g.phaseOpening; const shipping = next && (next.type === 'ship' || next.type === 'guildShip'); const halfRate = g.karamaShipping?.player === me.id || (!g.guildRateCanceled && @@ -62,7 +72,11 @@ export function ShipmentPromises({ id="shipment-promise-guidance" >

Your shipment promises

- {g.active !== g.me &&

+ {early ?

+ Your shipment opportunity is later this turn, during Shipment and + Movement. Keep your needed spice, forces and cards available to honor + these answers while you continue playing the current phase normally. +

: g.active !== g.me &&

Your shipment turn is still ahead. This answer stays binding while other players finish; shipment actions become available on your turn.

} @@ -84,8 +98,10 @@ export function ShipmentPromises({ {promises.some((p) => matchesShipment(p, null) !== p.answer) ? (

- Complete a qualifying shipment before ground movement or finishing - your turn: skipping would not honor the whole claim you answered. + {early + ? 'During your later Shipment and Movement turn, complete a qualifying shipment before ground movement or finishing that turn: skipping would not honor the whole claim you answered.' + : 'Complete a qualifying shipment before ground movement or finishing your turn: skipping would not honor the whole claim you answered.'} + {' '} You can choose any legal count, sector and funding that honor all your answers. Current facts remain fixed at the time of your answer.

@@ -96,6 +112,20 @@ export function ShipmentPromises({ and create no later card, spice or force holding obligation.

)} + {preparation && preparationLabel && ( +
+

+ This preparation is available in the current phase. Your shipment + remains later this turn. +

+ +
+ )} {next && (
Suggested next step · private diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 4e22f3d4..84edf177 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -352,11 +352,11 @@ export function Truthtrance({

{!shipmentAvailable && (

- 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 + Automatic enforcement supports a later shipment this turn + in Basic base rules without optional modules. During + Shipment and Movement it also supports unused active or + waiting turns in Advanced base rules, including the Guild, + or a classic payer in the Richese Semuta profile. Select an eligible player and finish pending decisions first. Native No-Field shipments and other rules configurations remain unfinished. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 81f1fd7e..e422d96b 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,26 @@ open mode gates. ## Current checkpoint and work +**Earlier Basic Truthtrance shipment promises — 24 October. Prototyped / Partial.** +Bare classic Basic phases0–4 now bind the current-turn reserve event using +current-board feasibility, remaining owned Charity/normal Revival and net current +auction liabilities. Distinct Karama payment/shipping costs and Bidding escrow +expiration remain physical. A new answer reopens owned Charity/Revival readiness; +voluntary bids, payments or closure cannot erase the last route. Unresolved ally +invoices wait for actual income outcomes. The selected witness/readiness protocol +is provisional product composition; earlier Advanced/module boundaries remain. + +A disjoint fixture/control lane, bounded review and owner integration pass +**18 files131/131**, types/lint/build, two original Storm/Charity question +programmes and four finite legal profiles. Actual purchase2, later shipment8/cost8 +and turn completion use52/38 continuations from the original source snapshots. +The real table retains earlier promises; shipping controls stay at phase5. +Focused feedback repaired foreign-invoice release, deferred extra-revival cost, +a No-answer overbid policy and obsolete phase4 negative fixtures/type feedback. +[Source, explicit policy, exact proof and limits](TRUTHTRANCE_SHIPMENT_PROMISES.md#earlier-basic-phases--24-october-2026). +Full modes, wider future actions, final assurance and deployed acceptance remain +open. No strategy calibration or saved-room/reset/restart/deployment operation. + **Readable notices — 24 October follow-up.** Current source already keeps room and seat confirmations/action notices until Continue, and toasts until Dismiss. The new local create check retained its confirmation for 30 seconds through diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index a2374fbd..d11e8512 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,30 @@ # Dune implementation status +## 24 October 2026 — Earlier Basic Truthtrance shipment commitments + +Extend bare classic Basic current-turn questions to phases0–4 with remaining +owned Charity/normal Revival and net current auction invoices. Distinct physical +Karama costs cannot be double-counted; incoming Bidding aid expires and unused +outgoing funds return net actual contributions. A definite answer reopens owned +Charity/Revival readiness. Voluntary payments and closing needed preparation +cannot evade a promise; unresolved foreign invoices wait for actual outcomes. +The Emperor's gift/extra-revival invoice is checked before responses. The actual +table retains earlier guidance/current preparation while shipping stays at the +original phase5 turn. Nonrestrictive answers keep native legal payment policy. + +A disjoint fixture/control lane, bounded review and owner integration pass +18-file131/131, types/lint/build, two original Basic Storm/Charity gameplay/rendering +programmes and four finite legal profiles. Real purchase2 and later shipment8/cost8 +fulfill before explicitly finishing. Sources are original public setup snapshots; +controlled readiness, Karama, funding and extra-revival probes remain distinct. +Feedback repaired early ally-invoice release, deferred paid-revival evasion, +No-answer overbid payment, obsolete phase4 negatives and a test getter type. +[Policy, source and exact frozen proof](TRUTHTRANCE_SHIPMENT_PROMISES.md#earlier-basic-phases--24-october-2026). +The owned-witness/readiness protocol is explicitly provisional. Earlier Advanced/ +modules, wider promises, full modes, comprehensive assurance and deployed +acceptance remain open. No strategic calibration or saved-room/reset/restart/ +deployment operation. + ## 24 October 2026 — Tleilaxu income from committed Ambassador returns Positive free force/native leader/KH/Duke returns now share the existing diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index cf57f0cb..e405348c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -855,7 +855,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 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 | Current facts are boolean disclosures, not promises to preserve resources. Bare Basic current-turn shipment promises now include phases0–4 using current-board feasibility, remaining owned Charity/normal Revival and net current auction liabilities. Incoming Bidding aid expires; retained Karama payment and shipping discounts need distinct physical cards. Accepting reopens owned Charity/Revival readiness; voluntary spending or closing needed preparation cannot evade an answer. An unresolved foreign invoice waits for its actual outcome. This owned-witness/readiness protocol is explicit product composition, not a publisher worked example. Existing phase5 Basic/base Advanced promises include waiting respondents, native Guild timing/interception and rate/permission responses; no earlier Advanced/module profile 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#earlier-basic-phases--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) | diff --git a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md index 80c53dc5..84f200c0 100644 --- a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md +++ b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md @@ -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). -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. +The [24 October waiting-turn follow-up](#same-phase-waiting-respondents--24-october-2026) supersedes the active-only timing boundary. The [earlier Basic phase follow-up](#earlier-basic-phases--24-october-2026) extends current-turn timing before Movement. Earlier scope descriptions and verification totals remain historical. ## Authority and supported meaning @@ -244,3 +244,99 @@ 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. + + +## Earlier Basic phases — 24 October 2026 + +**Prototyped / Partial.** Bare classic Basic games now accept structured reserve +shipment and mixed current-fact questions during phases0–4, as well as the +existing unused phase5 opportunity. This removes an implementation boundary; +the FAQ already describes a this-turn promise. Advanced, faction expansions, +optional modules and independent expansion Treachery keep their earlier-phase +boundaries. Existing phase5 profiles retain their own support. Answering never +ships early or changes another player's turn. + +The selected provisional interpretation evaluates the current board and +respondent's owned route. It is not a prediction of unresolved Storm movement, +unknown future purchases, rival gifts/income or vacated destinations. Native +forced/opposing changes can release an answer after they actually make it +impossible. Future rule breadth and comprehensive assurance remain unfinished. +The FAQ supplies the current-turn binding/release rule; this private witness and +readiness protocol is an explicit product interpretation, not a new publisher +worked example. + +The witness includes remaining owned Charity and ordinary force Revival using +actual quotes, physical Tanks and remaining/free allowances. It does not assume +unoffered ally revival benefits. Current winning auction invoices reserve their +actual own-spice share or a retained physical Karama. The same card cannot also +supply the later shipping discount. Unknown purchased cards contribute nothing. +Unused outgoing Bidding escrow returns to its donor at phase completion, net +any actual committed contribution; incoming Bidding aid expires rather than +becoming future shipping funds. An unresolved ally invoice and its income +responses finish before release; an actual funded outcome may preserve the +route, while canceled income can leave a real shortfall. + +A definite answer reopens the respondent's already-confirmed Charity/Revival +readiness. Own Ready cannot forego the last required preparation. Own bids, +normal force/leader revival, bribes, gifts, extra paid ally revival and card +spending must preserve a route. The Emperor's proposed gift/revival invoice is +checked before its response so another responder cannot turn that voluntary +cost into an escape. Passing a bid or allowing a foreign power remains native +continuation, not a new owner payment. + +The actual table retains the original question and later-this-turn guidance +through earlier phases. Only current available Charity/Revival preparation +gets a button; future shipment and price controls remain absent. Actual shipping +guidance still waits for phase5, the original turn and completed control windows. +The editor describes both timing scopes. Existing profiles use minimal legal +preparation, pass Bidding and choose a valid committed payment when delivery is +required. Where skipping honors all answers, ordinary legal policy remains, +including Karama-funded overbids. No strategy or difficulty calibration is added. + +### Targeted proof and limits + +A disjoint original-fixture/control lane, bounded continuation review and owner +integration pass **18 files131/131**, `npm run check:quick`, production build and +two original gameplay/rendered-table programmes. Both actual Storm and Charity +questions survive native Bidding/Revival, buy the actual auction card for2, then +ship8 to Carthag for8 and explicitly finish the respondent's turn. The source +fixture uses22 original actions, full original Basic decks and selected unopened +deal/Spice positions plus scalar lotteries; no dealt hand, army, wallet, clock or +promise is installed. The Storm programme uses the genuine earlier after-setup +snapshot. Its52 and the Charity programme's38 continuations are distinct from +the controlled probes. + +All four existing profiles traverse Charity/Bidding/Revival/Shipment with four +legal owner proposals and20 total continuations apiece, fulfill and finish. +Actual GameTable rendering retains the early promise during Storm, Charity, +Bidding and Revival, hides future shipment controls and exposes the real phase5 +cost/action. Framework aliases/CSS stubs mean runtime rendering, not browser or +visual acceptance. The six isolated control cases cover phases0–4, current +preparation payloads/busy controls, original grouped questions and phase5 behavior. + +The eleven early engine cases include original eight-force purchase/delivery, +controlled owned Charity/Revival and reopened readiness, distinct physical +auction/shipping Karamas, expired incoming escrow, actual ally-invoice income +allow/cancel, pre-response extra-revival costs and native No-answer overbids. +Related existing tests cover joint claims, waiting/native Guild, typed preparation, +normal auction and Revival. Earlier malformed-scope cases now use post-Movement +phase6 rather than newly supported phase4. Focused feedback found and fixed early +foreign-invoice release, delayed extra-revival evasion and a No-answer policy +forcing an unfunded cash payment. A controlled retained-card test was corrected +from3 to7 because the actual remaining Karama legally funds half-price shipping; +type feedback gave the poisoned-view getter an explicit throwing return type. + +The passing frozen source is based on +`6d30e7c5e177b45a03bfbd95e93d9e3c0e0fe15f`, tree +`36da1bc827d67564f7fae17a65d40d399583d87faeda8bb6a42823c29e9306af`, +2,614 tracked/nonignored files. The untracked supplied PDF contributes only its +hash and is not committed or republished. Private reports, logs, HTML and runtime +results are in +`/home/quintine/.local/share/dune/checkpoints/20261024-truth-early-shipment/`; +`frozen-integrated-3/report.json` is the complete passing batch. Final checkpoint +prose and reference evidence are checked against every frozen source hash before +push. Earlier failed feedback reports remain separate. + +Earlier-phase Advanced/modules, wider future actions, full Truthtrance/modes, +comprehensive assurance and browser/deployed acceptance remain open. No saved- +room/database/reset/restart/deployment operation or strategic calibration. diff --git a/game/bots.ts b/game/bots.ts index 68bf3512..5d915bcb 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -4070,6 +4070,9 @@ export function botActions(g: GameView): Action[] { if (g.homeworldTupileBlocked) return []; if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup) return tupileCleanupActions(g); + if (g.shipmentPromisePolicy && g.status === 'playing' && + !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) + return g.shipmentPromisePolicy; if (g.nexusHarkonnenBetrayalReaction) { if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed || g.automaticContinuationPending) return []; diff --git a/game/engine.ts b/game/engine.ts index 71acd449..fb594685 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -22758,7 +22758,7 @@ function shipmentPromiseIntegrity(g: Game) { requireRule( 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.', + 'The saved shipment promise does not belong to a supported unused shipment opportunity this turn.', ); } } @@ -22773,6 +22773,81 @@ function projectPendingShipmentKarama(g: Game, player: string) { g.pendingKarama = null; completeKarama(g, getPlayer(g, pending.owner), pending.use); } +type ShipmentCompletion = { actions: Action[]; early?: { phase: number; action: Action }[] }; +type ShipmentCompletionSeed = { game: Game; actions: Action[]; early: { phase: number; action: Action }[] }; +/** Private owned-stage witnesses, not an early action or a simulation of rival + * choices, unknown draws, future gifts or the unresolved Storm outcome. */ +function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string): ShipmentCompletionSeed[] { + let seeds: ShipmentCompletionSeed[] = [{ game: initial, actions: [], early: [] }]; + if (source.phase > 4) return seeds; + if (source.phase <= 2 && !(source.phase === 2 && source.ready.includes(owner))) { + const charitySeeds: ShipmentCompletionSeed[] = []; + for (const seed of seeds) { + const g = structuredClone(seed.game); g.phase = 2; + const p = getPlayer(g, owner); + if (p.charityTurn === g.turn || charityQuote(g, p).total <= 0) continue; + try { + const action: Action = { type: 'charity' }; + const next = applyActionInner(g, owner, action, 'shipmentPreparation'); + charitySeeds.push({ game: next, actions: [action], early: [{ phase: 2, action }] }); + } catch (error) { if (!(error instanceof RuleError)) throw error; } + } + seeds.push(...charitySeeds); + } + if (source.phase <= 3) { + const fundingSeeds: ShipmentCompletionSeed[] = []; + for (const seed of seeds) { + const p = getPlayer(seed.game, owner), a = source.phase === 3 && !source.currentAuctionSale ? source.auction : null; + const ownBid = a?.bidder === owner && a.bid > 0; + const cash = ownBid ? a.bid - (a.allyPayment ?? 0) : 0; + const committed = source.phase === 3 && source.currentAuctionSale ? 0 : committedOutgoingAid(source, getPlayer(source, owner)); + const refund = (source.aid[owner]?.amount ?? 0) - committed; + const alternatives: (string | null)[] = [null, ...(ownBid ? p.hand + .filter(card => canUseAsKaramaRole(seed.game, p, card)).map(card => card.id) : [])]; + for (const card of alternatives) { + const g = structuredClone(seed.game), beneficiary = getPlayer(g, owner); + if (!card && beneficiary.spice < cash) continue; + beneficiary.spice += refund - (card ? 0 : cash); + if (!Number.isSafeInteger(beneficiary.spice) || beneficiary.spice < 0) continue; + const actions = [...seed.actions], early = [...seed.early]; + if (card) { + const index = beneficiary.hand.findIndex(c => c.id === card); + g.discard.push(beneficiary.hand.splice(index, 1)[0]); + const action: Action = { type: 'decision', karama: true, card }; + actions.push(action); early.push({ phase: 3, action }); + } + // Native Bidding refunds its unused pledges. An ally's incoming + // Bidding escrow is never a promised future Shipment contribution. + g.aid = {}; g.auction = null; g.currentAuctionSale = null; + fundingSeeds.push({ game: g, actions, early }); + } + } + seeds = fundingSeeds; + } + const revivalSeeds: ShipmentCompletionSeed[] = []; + for (const seed of seeds) { + const g = structuredClone(seed.game); g.phase = 4; + if (source.phase < 4) { + g.revivalRules = newRevivalRules(); g.revivalRequests = {}; g.freeRevival = []; g.emperorExtra = {}; + const p = getPlayer(g, owner); p.revived = 0; p.freeForcesRevived = 0; p.leaderRevived = false; + } + revivalSeeds.push({ ...seed, game: g }); + if (source.phase === 4 && source.ready.includes(owner)) continue; + const p = getPlayer(g, owner); + for (let amount = 1; amount <= forceRevivalRemaining(g, p); amount++) { + try { + const action: Action = { type: 'revive', amount }; + const next = applyActionInner(g, owner, action, 'shipmentPreparation'); + revivalSeeds.push({ game: next, actions: [...seed.actions, action], early: [...seed.early, { phase: 4, action }] }); + } catch (error) { if (!(error instanceof RuleError)) throw error; } + } + } + return revivalSeeds.map(seed => { + seed.game.phase = 5; seed.game.ready = []; seed.game.karamaShipping = null; + const p = getPlayer(seed.game, owner); p.shipped = false; p.moved = 0; + return seed; + }); +} /** Search owned preparation and canonical shipment quotes, never the AI's scored shortlist. */ function findShipmentCompletion( state: Game, @@ -22782,7 +22857,7 @@ function findShipmentCompletion( owner.id, state.turn, ), -): { actions: Action[] } | null { +): ShipmentCompletion | null { if ( !shipmentPromiseTurnAvailable(state, owner.id, shipmentAvailable(state,owner)) ) @@ -22792,7 +22867,7 @@ function findShipmentCompletion( const initial = structuredClone(state); initial.truthtrance = null; projectPendingShipmentKarama(initial, owner.id); - const waiting = state.active !== owner.id; + const waiting = state.phase < 5 || state.active !== owner.id; const outgoingFloor = committedOutgoingAid(state, owner); if (waiting) { // Quote this owner's later turn without taking it or resolving another @@ -22810,13 +22885,11 @@ function findShipmentCompletion( initial.decision = null; initial.phaseOpening = null; initial.shipmentPromises = []; - const queue: { game: Game; actions: Action[] }[] = [ - { game: initial, actions: [] }, - ]; + const queue = earlyShipmentCompletionSeeds(initial, state, owner.id); const visited = new Set(); const destinations = [...new Set(promises.flatMap(shipmentClaimDestinations))]; for (let index = 0; index < queue.length; index++) { - const { game: g, actions } = queue[index]; + const { game: g, actions, early } = queue[index]; const p = getPlayer(g, owner.id); const signature = JSON.stringify([ p.spice, @@ -22882,6 +22955,7 @@ function findShipmentCompletion( if (nexus && !guildSecretEvent) recordNexusRicheseShipment(trial,shipment); validatePhysicalShipment(trial,shipment); return { + early, actions: [ ...actions, { @@ -22911,7 +22985,7 @@ function findShipmentCompletion( }; try { applyActionInner(g, p.id, action); - return { actions: [...actions, action] }; + return { actions: [...actions, action], early }; } catch (error) { if (!(error instanceof RuleError)) throw error; } @@ -22944,7 +23018,7 @@ function findShipmentCompletion( try { const next = applyActionInner(g, p.id, action, 'shipmentPreparation'); projectPendingShipmentKarama(next, p.id); - queue.push({ game: next, actions: [...actions, action] }); + queue.push({ game: next, actions: [...actions, action], early }); } catch (error) { if (!(error instanceof RuleError)) throw error; } @@ -22952,6 +23026,28 @@ function findShipmentCompletion( } return null; } +/** Minimal current-phase controls for an early commitment. Strategic bidding + * remains deferred; preserve a legal owned route instead of exposing a future + * shipment or asking a bot to spend the resources reserved for it. */ +function earlyShipmentGuidance(g: Game, p: Player): { preparation: { actions: Action[] } | null; policy?: Action[] } { + const none = { preparation: null }; + const promises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn); + if (g.status !== 'playing' || g.phase >= 5 || !shipmentPromiseModeSupported(g, p.id) || + !promises.length || promises.every(promise => matchesShipment(promise, null) === promise.answer) || + g.truthtrance || g.response || g.phaseOpening || g.pendingTreacheryDiscard || + (g.decision && !(g.decision.kind === 'auctionPayment' && g.decision.player === p.id))) return none; + const completion = findShipmentCompletion(g, p); + if (!completion) return none; + const current = completion.early?.find(step => step.phase === g.phase)?.action; + if (g.decision?.kind === 'auctionPayment') + return { preparation: null, policy: [current?.type === 'decision' ? current : { type: 'decision', karama: false }] }; + if (g.phase === 3) + return { preparation: null, policy: g.auction?.active === p.id ? [{ type: 'passBid' }] : [] }; + if (g.phase === 2 || g.phase === 4) + return { preparation: current ? { actions: [current] } : null, + policy: current ? [current] : g.ready.includes(p.id) ? [] : [{ type: 'ready' }] }; + return none; +} function shipmentTruthAnswers( g: Game, p: Player, @@ -22964,7 +23060,10 @@ function shipmentTruthAnswers( ...prior, { ...claim, turn: g.turn, player: p.id, asker: '', answer }, ]; - if (findShipmentCompletion(g, p, promises)) + // A new commitment reopens its respondent's owned Charity/Revival Ready. + const answerSource = g.phase === 2 || g.phase === 4 + ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; + if (findShipmentCompletion(answerSource, p, promises)) answers.push(answer ? 'yes' : 'no'); } return answers.length ? answers : ['unknown']; @@ -22977,6 +23076,10 @@ function bindShipmentTruth( asker: string, mixed?: MixedShipmentExpression, ) { + if ((g.phase === 2 || g.phase === 4) && g.ready.includes(p.id)) { + g.ready = g.ready.filter(id => id !== p.id); + log(g, `${p.name} has a new Truthtrance shipment commitment and must confirm readiness again after completing any needed preparation.`); + } (g.shipmentPromises ??= []).push({ turn: g.turn, player: p.id, @@ -23034,7 +23137,7 @@ function settleStoppedShipmentPromises(g: Game, p: Player, actor: string) { } function reconcileShipmentPromises( g: Game, - voluntary?: { actor: string; action: Action }, + voluntary?: { actor: string; action: Action; phase?: number }, ) { if (g.pendingKull || g.pendingRicheseBetrayal) return; const kept: ShipmentPromise[] = []; @@ -23042,15 +23145,20 @@ 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 future = g.phase < 5 || g.active !== p.id; + const earlyVoluntary = (voluntary?.phase ?? g.phase) < 5 && voluntary && + (['bid', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival'].includes(voluntary.action.type) || + voluntary.action.type === 'ready' && [2, 4].includes(voluntary.phase ?? g.phase)); const ownVoluntary = voluntary?.actor === p.id && (['card', 'bribe', 'pledgeAid', 'emperorGift'].includes(voluntary.action.type) || + earlyVoluntary || 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)) { + g.pendingHomeworldShipment || g.pendingTreacheryDiscard || g.ornithopter || + g.phase === 3 && !g.currentAuctionSale && g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0)) { kept.push(promise); continue; } @@ -30755,7 +30863,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { finishLeaderSkillCustody(g, state); leaderSkillsIntegrity(g); reconcileBattlePromises(g, { actor: id, action }); - reconcileShipmentPromises(g, { actor: id, action }); + reconcileShipmentPromises(g, { actor: id, action, phase: state.phase }); settleAutomaticContinuations(g); observeOccupation(g); discoveryIntegrity(g); @@ -33758,13 +33866,6 @@ 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( @@ -33783,6 +33884,14 @@ function applyActionInner( p.spice >= paidForceRevivalCost(recipient, amount, elite), 'Not enough spice for these extra revivals.', ); + if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length) { + // A later responder cannot erase the owner's voluntary gift/revival + // invoice. Check its allowed outcome before opening the response. + const afterPayment = structuredClone(g); + getPlayer(afterPayment, p.id).spice -= t === 'emperorGift' + ? amount : paidForceRevivalCost(recipient, amount, elite); + reconcileShipmentPromises(afterPayment, { actor: p.id, action }); + } g.response = { kind: t, elite, @@ -35212,6 +35321,7 @@ export function viewGame(state: Game, id: string) { settleAdvisors(g); normalizeBattle(g); const me = getPlayer(g, id); + const earlyShipment = earlyShipmentGuidance(g, me); const sharedDuke = projectedSharedDuke(g, id); const choamPlayer = byFaction(g, 'choam'); let victoryProgress: StrongholdProgress[] = []; @@ -35285,8 +35395,10 @@ export function viewGame(state: Game, id: string) { // Mixed compiled claims contain private past-clause answers. The complete // original question and whole answer remain public through truthHistory. shipmentPromises: (g.shipmentPromises ?? []).filter(p => !p.mixed || p.player === id), + shipmentPreparation: earlyShipment.preparation, + shipmentPromisePolicy: earlyShipment.policy, shipmentCompletion: - g.active === id && + g.phase === 5 && g.active === id && !g.pendingTreacheryDiscard && !g.response && !g.pendingKarama && !g.truthtrance && !g.decision && !g.phaseOpening && !g.pendingShipment && diff --git a/game/reference.ts b/game/reference.ts index cbdb7ceb..de03fca7 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2921,16 +2921,17 @@ export const RULE_TOPICS: RuleTopic[] = [ title: 'Truthtrance: shipment promises', category: 'Cards', coverage: 'Partial', + developmentStage: 'Prototyped', summary: - 'Bind an unused reserve shipment this phase, including a player whose turn is still ahead, to a named destination and minimum physical count.', + 'Bind this turn’s reserve shipment to a destination and physical minimum, including earlier Basic phases and players whose shipment turn is still ahead.', steps: [ - '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.', + 'Declare Truthtrance, select an eligible player and choose Bind a shipment from reserves. Bare Basic rules allow the question during Storm through Revival as well as before that player’s shipment. During Shipment and Movement, base Basic and Advanced, including Guild, also support an unused active or waiting turn. Finish pending decisions first. Choose a printed destination and minimum of one to twenty forces, or combine conditions using AND or OR; each describes the same shipment. Answering never grants an early shipment. Earlier Advanced, expansion and 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.', + 'The target privately sees feasible answers based on the current board, owned resources and all earlier promises. Before Movement, include remaining owned Charity and normal Revival, the current winning invoice and unused outgoing Bidding refunds; incoming Bidding aid expires. Auction payment and shipping discounts need distinct physical Karamas. At shipment time, available Ghola, Karama and current pledged aid retain their normal rules. Unknown purchases, unresolved Storm outcomes, rival gifts and future income are not assumed.', + 'Accepted whole answers are public and constrain the event together. A new answer reopens your Charity or Revival readiness when needed. Claim or revive before marking Ready if that is your last legal route; bids, gifts and paid revivals cannot spend it away. Honor a required shipment before ground movement or finishing. An unresolved foreign bid and its income responses finish before release. 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. 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.', + 'Your promises retain the original grouped question without exposing private compiled facts. Earlier phases show later-this-turn guidance and only an available current Charity or Revival preparation. Shipment actions wait for your actual phase5 turn and finished controls. The owned-route/readiness protocol is a provisional interpretation. All four profiles retain a minimal legal path; answers allowing a skip retain ordinary legal payment choices. Earlier Advanced/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.', @@ -2940,7 +2941,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. 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.', + 'Bare Basic phases0–4 use explicit provisional current-board/owned-stage feasibility, auction liabilities, distinct card costs, expiring aid and readiness reopening. Voluntary bids/payments/closure preserve a route; foreign invoices await their actual outcome. Existing phase5 waiting/native Guild interception, permission and rates remain. Earlier Advanced/modules and wider future actions remain unfinished.', }, { area: 'Player controls', @@ -2964,7 +2965,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - '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.', + 'Current early-phase batch passes18 files131/131, types/lint/build, two original Storm/Charity question programmes with actual purchase2 and shipment8, rendered table guidance and four finite legal profiles. Controlled preparation, readiness, physical cards, invoice allow/cancel and No overbids remain separate. Earlier persistence/privacy evidence is historical; full Truthtrance, modes, assurance and deployed acceptance remain open.', evidence: [ 'tests/shipment-promises.test.ts', 'tests/shipment-promises-bots.test.ts', @@ -2975,6 +2976,14 @@ 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-early-shipment-engine.test.ts', + 'tests/truthtrance-early-shipment-controls.test.tsx', + 'tests/fixture-truthtrance-early-shipment.ts', + 'components/shipment-promises.tsx', + 'components/truthtrance.tsx', + 'components/game-table.tsx', + 'game/shipment-promises.ts', + 'game/truthtrance.ts', 'tests/truthtrance-future-shipment.test.ts', 'tests/truthtrance-future-shipment-engine.test.ts', 'tests/fixture-truthtrance-future-shipment.ts', @@ -3001,7 +3010,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The questioned player answers publicly. A definite answer discards the card. If the answer cannot be known, the holder may ask a different question or save Truthtrance for later.', 'A saved definite answer resumes automatically: its public answer, binding promise and consumed card are recorded once, then the next queued question or interrupted action resumes. Private answer and preparation guidance is unavailable during this automatic recovery.', 'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.', - 'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises are enforced for the active unused shipment in base Basic and no-Guild Advanced games; see the shipment promise guide.', + 'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide.', 'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.', 'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.', 'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.', diff --git a/game/shipment-promises.ts b/game/shipment-promises.ts index 3bf120a2..a162287d 100644 --- a/game/shipment-promises.ts +++ b/game/shipment-promises.ts @@ -149,6 +149,7 @@ export function shipmentClaimText( /** Software readiness boundary; this is not a printed restriction on Truthtrance. */ export function shipmentPromiseModeSupported(game: { advanced: boolean; + phase?: number; expansions: readonly unknown[]; players: readonly { id?: string; faction: string }[]; techTokens?: unknown; @@ -159,7 +160,11 @@ export function shipmentPromiseModeSupported(game: { semutaPreview?: unknown; leaderSkills?: unknown; discoveryEnabled?: unknown; + ecazTreachery?: unknown; }, respondent = game.active): boolean { + if (game.phase !== undefined && game.phase < 5 && + (game.advanced || game.expansions.length || game.techTokens || game.strongholdCards || + game.nexusCards || game.homeworlds || game.leaderSkills || game.discoveryEnabled || game.ecazTreachery)) return false; if (game.semutaPreview === true && game.expansions.length === 1 && game.expansions[0] === 'choam' && !game.techTokens && !game.strongholdCards && !game.nexusCards && !game.homeworlds && @@ -188,8 +193,9 @@ export function shipmentPromiseTurnAvailable(game: { 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)); + return game.status === 'playing' && Number.isInteger(game.phase) && game.phase >= 0 && + (game.phase < 5 || game.phase === 5 && unused && + (game.active === player || !!game.movementRemaining?.includes(player))); } export type ShipmentPromise = ShipmentClaim & { turn: number; diff --git a/game/truthtrance.ts b/game/truthtrance.ts index a59657dc..6449a866 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -201,7 +201,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { !g.decision && !g.phaseOpening && !g.pendingShipment, - '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.', + 'Structured shipment promises require a supported unused shipment this turn. During Shipment and Movement the respondent must be active or still waiting. Finish pending decisions first.', ); try { if (v.kind === 'mixedShipment') { diff --git a/tests/fixture-truthtrance-early-shipment.ts b/tests/fixture-truthtrance-early-shipment.ts new file mode 100644 index 00000000..7fd4dd0e --- /dev/null +++ b/tests/fixture-truthtrance-early-shipment.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, type Action } from '../game/engine'; +import type { TruthQuestion } from '../game/truthtrance'; +import { + actFutureShipment, advanceFutureShipment, futureShipmentClean, futureShipmentPlayer, + nextFutureShipmentStep, type FutureShipmentStep, +} from './fixture-truthtrance-future-shipment'; + +export type TruthtranceEarlyShipmentStep = FutureShipmentStep; +export const actTruthtranceEarlyShipment = actFutureShipment; +export const advanceTruthtranceEarlyShipment = advanceFutureShipment; +export const nextTruthtranceEarlyShipmentStep = nextFutureShipmentStep; +export const truthtranceEarlyShipmentClean = futureShipmentClean; +export const truthtranceEarlyShipmentPlayer = futureShipmentPlayer; + +/** Genuine public Basic classic setup, original five-card deal and original + * Storm/Spice controls. Stop at clean Charity BEFORE playing Truthtrance or + * asking the new early question. Only unopened deck positions/scalar lotteries + * are selected; no dealt card, force, wallet, clock or promise is installed. */ +export function createTruthtranceEarlyShipmentFixture() { + const asker = 'atreides', target = 'emperor', counterHolder = 'harkonnen'; + let game = createGame('TRUTHEARLYSHIPMENT', newPlayer(asker, 'Atreides', 'atreides'), false); + for (const faction of ['emperor', 'harkonnen', 'guild'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + const actions: TruthtranceEarlyShipmentStep[] = []; + const act = (actor: string, action: Action) => { + game = actTruthtranceEarlyShipment(game, actor, action, actions); + }; + const reach = (until: Parameters[1]) => { + game = advanceTruthtranceEarlyShipment(game, until, actions); + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, { type: 'start' }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); + assert.deepEqual(game.expansions, []); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + assert.ok(game.players.every(player => player.hand.length === 0)); + const originalTreachery = game.deck.map(card => card.id).sort(); + assert.equal(new Set(originalTreachery).size, originalTreachery.length); + 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)); + // The Harkonnen receives two original consecutive cards, all other seats one. + const deal = [truth.id, filler[0]!.id, karama.id, filler[1]!.id, filler[2]!.id]; + const dealOrder = deal.map((id, position) => { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: id }; + }); + const beforeStartingDeal = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + assert.ok(truthtranceEarlyShipmentPlayer(game, asker).hand.some(card => card.id === truth.id)); + assert.ok(truthtranceEarlyShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id)); + assert.ok(truthtranceEarlyShipmentPlayer(game, target).hand.some(card => card.id === filler[0]!.id)); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const beforeSpiceOrder = structuredClone(game); + const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0); + const land = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(land); + const spiceOrder = [{ position: 0, from, card: structuredClone(land) }]; + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + reach(state => state.turn === 1 && state.phase === 2 && truthtranceEarlyShipmentClean(state)); + assert.equal(game.status, 'playing'); assert.equal(game.advanced, false); + assert.deepEqual(game.ready, []); assert.equal(game.auction ?? null, null); + assert.equal(game.truthtrance ?? null, null); + const respondent = truthtranceEarlyShipmentPlayer(game, target); + assert.equal(respondent.spice, 10); assert.equal(respondent.reserves, 20); + assert.equal(respondent.tanks, 0); assert.deepEqual(respondent.forces, {}); + assert.equal(respondent.shipped, false); assert.equal(respondent.moved, 0); + assert.ok(truthtranceEarlyShipmentPlayer(game, asker).hand.some(card => card.id === truth.id)); + assert.ok(truthtranceEarlyShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id)); + assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)] + .map(card => card.id).sort(), originalTreachery); + const question: TruthQuestion = { kind: 'shipment', target, territory: 'carthag', minimum: 8 }; + const declarationStep: TruthtranceEarlyShipmentStep = { actor: asker, + action: { type: 'card', card: truth.id } }; + const askStep: TruthtranceEarlyShipmentStep = { actor: asker, action: { type: 'truthAsk', question } }; + return { game, lobby, setup, beforeStartingDeal, afterSetup, beforeSpiceOrder, spiceOrder, + deal, dealOrder, originalTreachery, originalSpice, asker, target, counterHolder, guild: 'guild', + card: truth.id, karama: karama.id, question, declarationStep, askStep, + territory: 'carthag', sector: 11, location: 'carthag:11', minimum: 8, + advanced: false as const, sourceActionCount: actions.length, actions, + staging: ['Only five unopened original starting-deal positions, one unopened ordinary land Spice position and original scalar lotteries are selected. Public Basic Atreides/Emperor/Harkonnen/Guild setup actually deals Truthtrance to Atreides, Baliset to Emperor, Karama and Shield to Harkonnen, and Snooper to Guild. Original traitor choices, Storm dials, Spice Blow and readiness reach clean turn-1 Charity before Bidding. Emperor retains its printed 10 spice, 20 reserve forces and unused shipment; the held physical Truthtrance and Karama are unchanged. The constructor stops before the Truthtrance declaration and proposed eight-force Carthag question. No postdeal hand, army, wallet, phase, turn, queue or promise is installed. The Fremen storm-loss source is outside this bounded programme.'] }; +} +export type TruthtranceEarlyShipmentFixture = ReturnType; diff --git a/tests/shipment-promises.test.ts b/tests/shipment-promises.test.ts index 869da841..60361ac1 100644 --- a/tests/shipment-promises.test.ts +++ b/tests/shipment-promises.test.ts @@ -536,7 +536,7 @@ void test('malformed questions and unsupported public scopes reject without disc ]) { const bad = structuredClone(g); if (change === 'expansion') bad.expansions = ['ix']; - if (change === 'phase') bad.phase = 4; + if (change === 'phase') bad.phase = 6; if (change === 'spent') bad.players[1].shipped = true; if (change === 'inactive') { bad.active = 'a'; bad.movementRemaining = ['a', 'o']; } reject(bad, 'a', { @@ -603,7 +603,7 @@ void test('saved malformed promise bindings reject before action or normalizatio g.shipmentPromises![0].released = true; }, (g) => { - g.phase = 4; + g.phase = 6; }, (g) => { g.active = 'a'; diff --git a/tests/truthtrance-early-shipment-controls.test.tsx b/tests/truthtrance-early-shipment-controls.test.tsx new file mode 100644 index 00000000..7967b419 --- /dev/null +++ b/tests/truthtrance-early-shipment-controls.test.tsx @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Children, createElement, isValidElement, type ButtonHTMLAttributes, type MouseEvent, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ShipmentPromises } from '../components/shipment-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import { baseDeck } from '../game/cards'; + +const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 3, elite: 0 }; +type Props = Parameters[0]; + +/** Controlled personalized UI projection, not an earned early Truthtrance or + * preparation history. Native current-phase legality is separate engine proof. */ +function controlledView(phase = 3, active: string | null = 'p'): GameView { + const source = createGame('EARLYTRUTHCONTROLS', 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 = active; + game.players.find(player => player.id === 'p')!.spice = 6; + game.shipmentPromises = [{ player: 'p', asker: 'a', turn: 2, territory: 'carthag', minimum: 3, answer: true }]; + game.shipmentCompletion = null; + game.shipmentPreparation = null; + return game; +} +const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(ShipmentPromises, { + game, busy, act() { assert.fail('Rendering must not submit current preparation or early shipment'); }, +})); +function actionButton(props: Props, label: string): ReactElement> { + function find(node: ReactNode): ReactElement> | undefined { + if (!isValidElement<{ children?: ReactNode; onClick?: unknown }>(node)) return; + if (node.props.children === label && typeof node.props.onClick === 'function') + return node as ReactElement>; + return Children.toArray(node.props.children).map(find).find(Boolean); + } + const button = find(ShipmentPromises(props)); assert.ok(button, label); return button; +} +const click = (button: ReactElement>) => + button.props.onClick!({} as MouseEvent); + +void test('early active and waiting respondents see later-this-turn guidance without executable future shipment', () => { + for (const phase of [0, 1, 2, 3, 4]) for (const active of ['p', 'a', null]) { + const game = controlledView(phase, active); + game.shipmentCompletion = { get actions(): Action[] { throw new Error('Early UI must not inspect future executable shipment actions'); } }; + const html = markup(game); + assert.match(html, /shipment opportunity is later this turn, during Shipment and Movement/); + assert.match(html, /needed spice, forces and cards available/); + assert.match(html, /continue playing the current phase normally/); + assert.match(html, /During your later Shipment and Movement turn/); + assert.doesNotMatch(html, /other players finish|Suggested next step| { + const game = controlledView(2, null), charity: Action = { type: 'charity' }; + game.shipmentPreparation = { actions: [charity] }; + const sent: Action[] = [], props = { game, busy: false, act: (action: Action) => sent.push(action) }; + const html = markup(game); + assert.match(html, /aria-label="Current shipment preparation"/); + assert.match(html, /This preparation is available in the current phase/); + assert.doesNotMatch(html, /Suggested next step|Ship 3 forces|Cost:/); + const available = actionButton(props, 'Claim charity'); + assert.equal(available.props.disabled, false); + click(available); + assert.equal(sent.length, 1); + assert.equal(sent[0], charity); + const busy = actionButton({ ...props, busy: true }, 'Claim charity'); + assert.equal(busy.props.disabled, true); + click(busy); + assert.equal(sent.length, 1); +}); + +void test('current Revival preparation retains its projected force count and elite payload without revealing a future route', () => { + const game = controlledView(4), revival: Action = { type: 'revive', amount: 3, elite: 1 }; + game.shipmentPreparation = { actions: [revival] }; + const sent: Action[] = [], props = { game, busy: false, act: (action: Action) => sent.push(action) }; + assert.match(markup(game), /Revive 3 forces in Revival/); + assert.doesNotMatch(markup(game), /Ship 3 forces|Suggested next step|Cost:/); + click(actionButton(props, 'Revive 3 forces in Revival')); + assert.equal(sent[0], revival); + assert.deepEqual(sent, [{ type: 'revive', amount: 3, elite: 1 }]); + const busy = actionButton({ ...props, busy: true }, 'Revive 3 forces in Revival'); + assert.equal(busy.props.disabled, true); click(busy); + assert.equal(sent.length, 1); + game.shipmentPreparation = { actions: [shipment] }; + assert.doesNotMatch(markup(game), / { + const game = controlledView(4), card = baseDeck().find(card => card.effect === 'ghola'); assert.ok(card); + const own = game.players.find(player => player.id === game.me)!; + own.hand = [card]; + const action: Action = { type: 'card', card: card.id, amount: 3, elite: 1 }; + game.shipmentPreparation = { actions: [action] }; + const sent: Action[] = [], props = { game, busy: false, act: (value: Action) => sent.push(value) }; + assert.match(markup(game), new RegExp(`Play ${card.name}`)); + click(actionButton(props, `Play ${card.name}`)); + assert.equal(sent[0], action); + own.hand = []; + assert.doesNotMatch(markup(game), / { + const game = controlledView(5); + game.shipmentCompletion = { actions: [shipment] } as NonNullable; + game.shipmentPreparation = { actions: [{ type: 'charity' }] }; + const sent: Action[] = [], props = { game, busy: false, act: (action: Action) => sent.push(action) }; + const html = markup(game); + assert.match(html, /Suggested next step · private/); + assert.match(html, /Ship 3 forces to Carthag, sector 11/); + assert.match(html, /Cost: 3 spice/); + assert.doesNotMatch(html, /Current shipment preparation|Claim charity|opportunity is later this turn/); + click(actionButton(props, 'Ship 3 forces to Carthag, sector 11')); + assert.equal(sent[0], shipment); + game.active = 'a'; game.shipmentCompletion = null; + assert.match(markup(game), /Your shipment turn is still ahead/); + assert.doesNotMatch(markup(game), / { + const game = controlledView(); + game.shipmentPromises = [ + { player: 'p', asker: 'a', turn: 2, answer: true, + claim: { op: 'and', terms: [{ constant: 'yes' }, { territory: 'carthag', minimum: 3 }] }, + mixed: { kind: 'and', terms: [ + { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 1 } }, + { kind: 'shipment', territory: 'carthag', minimum: 3 }, + ] } }, + { player: 'a', asker: 'p', turn: 2, answer: false, territory: 'arrakeen', minimum: 19 }, + ]; + Object.defineProperty(game.players.find(player => player.id === 'a')!, 'hand', { + get() { assert.fail('Shipment guidance must not inspect another player hand'); }, + }); + const html = markup(game); + assert.match(html, /You answered Yes to/); + assert.match(html, /AND/); assert.match(html, /Carthag/); + assert.doesNotMatch(html, /constant|Arrakeen|19 physical|You answered No/); + assert.doesNotMatch(html, / void, minimum = 8, phase = 2) { + const f = createTruthtranceEarlyShipmentFixture(); + const initial = phase === 2 ? f.game : advance(f.game, g => g.phase === phase && clean(g)); + control?.(initial); + let game = act(initial, f.declarationStep.actor, f.declarationStep.action); + while (game.truthtrance?.stage === 'priority') + game = act(game, game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id, { type: 'truthPass' }); + assert.ok(viewGame(game, f.asker).truthShipmentTargets.includes(f.target)); + game = act(game, f.askStep.actor, { ...f.askStep.action, question: { ...f.question, minimum } }); + assert.ok(viewGame(game, f.target).truthShipmentAnswers!.includes('yes')); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + return { f, game }; +} +const reject = (g: Game, actor: string, action: Parameters[2]) => { + const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before); +}; +const player = (g: Game, id = 'emperor') => g.players.find(p => p.id === id)!; +function giveKarama(g: Game, count = 1) { + const own = player(g); + for (let n = 0; n < count; n++) { + const holder = g.players.find(p => p.id !== own.id && p.hand.some(c => c.effect === 'karama')); + if (holder) own.hand.push(holder.hand.splice(holder.hand.findIndex(c => c.effect === 'karama'), 1)[0]); + else { + const index = g.deck.findIndex(c => c.effect === 'karama'); assert.ok(index >= 0); + own.hand.push(g.deck.splice(index, 1)[0]); + } + } +} +function allies(g: Game) { + player(g).ally = 'harkonnen'; player(g, 'harkonnen').ally = 'emperor'; +} + +void test('an original Basic Charity question binds the later eight-force shipment without granting an early action', () => { + const { f, game } = bound(); + assert.equal(game.phase, 2); assert.equal(game.shipmentPromises!.length, 1); + assert.equal(game.shipmentPromises![0].player, f.target); + assert.equal(game.shipmentPromises![0].released, undefined); + assert.equal(viewGame(game, f.target).shipmentCompletion, null); + reject(game, f.target, { type: 'ship', territory: 'carthag', sector: 11, amount: 8 }); +}); + +void test('a pre-Bidding commitment reserves the winning invoice before payment and fulfills after the real purchase', () => { + const { f, game: initial } = bound(); + let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target); + const initialCard = game.auction!.cards[game.auction!.index].id; + reject(game, f.target, { type: 'bid', amount: 3 }); + game = act(game, f.target, { type: 'bid', amount: 2 }); + assert.equal(game.shipmentPromises![0].released, undefined); + game = advance(game, g => g.phase === 4 && clean(g)); + assert.equal(game.players.find(p => p.id === f.target)!.spice, 8); + assert.ok(game.players.find(p => p.id === f.target)!.hand.some(c => c.id === initialCard)); + game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target); + const shipment = viewGame(game, f.target).shipmentCompletion!.actions.at(-1)!; + assert.equal(shipment.type, 'ship'); assert.equal(shipment.amount, 8); + game = act(game, f.target, shipment); + while (game.response) game = act(game, game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' }); + assert.equal(game.shipmentPromises![0].fulfilled, true); + assert.equal(game.players.find(p => p.id === f.target)!.spice, 0); + assert.equal(game.players.find(p => p.id === f.target)!.forces['carthag:11'], 8); +}); + +void test('controlled early resources include owned Charity and normal Revival, reopen readiness and forbid skipping required preparation', () => { + const { f, game: initial } = bound(g => { + const p = g.players.find(p => p.id === 'emperor')!; + p.spice = 0; p.reserves = 0; p.tanks = 3; p.forces = { 'polar_sink:0': 17 }; + g.ready = [p.id]; + }, 1); + let game = initial; + assert.equal(game.ready.includes(f.target), false); + assert.deepEqual(viewGame(game, f.target).shipmentPreparation?.actions, [{ type: 'charity' }]); + reject(game, f.target, { type: 'ready' }); + game = act(game, f.target, { type: 'charity' }); + game = advance(game, g => g.phase === 4 && clean(g)); + assert.equal(viewGame(game, f.target).shipmentPreparation?.actions[0].type, 'revive'); + reject(game, f.target, { type: 'ready' }); + reject(game, f.target, { type: 'revive', amount: 2 }); + game = act(game, f.target, { type: 'revive', amount: 1 }); + assert.equal(game.shipmentPromises![0].released, undefined); + assert.equal(game.players.find(p => p.id === f.target)!.reserves, 1); + assert.equal(viewGame(game, f.target).shipmentPreparation, null); + game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target); + assert.ok(viewGame(game, f.target).shipmentCompletion); +}); + +void test('all existing profiles retain minimal legal participation during early Bidding', () => { + const { f, game: boundGame } = bound(); + const game = advance(boundGame, g => g.phase === 3 && clean(g) && g.auction?.active === f.target); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty; + const action = botActions(view)[0]; assert.equal(action.type, 'passBid'); + const done = applyAction(game, f.target, action); + assert.equal(done.shipmentPromises![0].released, undefined); + } +}); + +void test('a controlled retained Karama pays the actual winning invoice without spending the reserved shipping spice', () => { + const { f, game: initial } = bound(g => giveKarama(g)); + let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target); + const card = player(game).hand.find(c => c.effect === 'karama')!.id; + game = act(game, f.target, { type: 'bid', amount: 7 }); + game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target); + assert.equal(viewGame(game, f.target).shipmentCompletion, null); + reject(game, f.target, { type: 'decision', karama: false }); + assert.deepEqual(viewGame(game, f.target).shipmentPromisePolicy, [{ type: 'decision', karama: true, card }]); + game = act(game, f.target, { type: 'decision', karama: true, card }); + game = advance(game, g => g.phase === 4 && clean(g)); + assert.equal(player(game).spice, 10); + assert.equal(game.discard.filter(c => c.id === card).length, 1); + assert.equal(game.shipmentPromises![0].released, undefined); +}); + +void test('controlled auction and shipping discounts need distinct physical Karamas', () => { + for (const count of [1, 2]) { + const { f, game: initial } = bound(g => { player(g).spice = 4; giveKarama(g, count); }); + let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target); + if (count === 1) reject(game, f.target, { type: 'bid', amount: 1 }); + else { + game = act(game, f.target, { type: 'bid', amount: 1 }); + game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target); + const policy = viewGame(game, f.target).shipmentPromisePolicy!; + assert.equal(policy[0].karama, true); + game = act(game, f.target, policy[0]); + game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target); + assert.equal(player(game).hand.filter(c => c.effect === 'karama').length, 1); + const price = viewGame(game, f.target).shipmentCompletion!.actions[0]; + assert.equal(price.type, 'card'); assert.notEqual(price.card, policy[0].card); + } + } +}); + +void test('controlled Bidding escrow retains a promise through an unresolved ally bid and releases only its settled shortfall', () => { + for (const canceled of [false, true]) { + const { f, game: initial } = bound(allies); + let game = advance(initial, g => g.phase === 3 && clean(g) && g.auction?.active === f.target); + game = act(game, f.target, { type: 'pledgeAid', amount: 8 }); + assert.equal(player(game).spice, 2); + assert.equal(game.shipmentPromises![0].released, undefined); + game = advance(game, g => g.phase === 3 && clean(g) && g.auction?.active === 'harkonnen'); + game = act(game, 'harkonnen', { type: 'bid', amount: 3, allyPayment: 3 }); + assert.equal(game.shipmentPromises![0].released, undefined); + game = advance(game, g => g.response?.kind === 'emperorIncome'); + assert.equal(game.shipmentPromises![0].released, undefined); + if (canceled) game = act(game, 'harkonnen', { type: 'card', + card: player(game, 'harkonnen').hand.find(c => c.effect === 'karama')!.id, mode: 'cancel' }); + game = advance(game, g => g.phase === 4 && clean(g)); + assert.equal(player(game).spice, canceled ? 7 : 10); + assert.equal(game.shipmentPromises![0].released, canceled ? true : undefined); + } +}); + +void test('controlled incoming Bidding aid expires before shipment and cannot fund an early Yes', () => { + const f = createTruthtranceEarlyShipmentFixture(); + let game = advance(f.game, g => g.phase === 3 && clean(g)); + allies(game); player(game).spice = 0; + game = act(game, 'harkonnen', { type: 'pledgeAid', amount: 8 }); + game = act(game, f.asker, f.declarationStep.action); + game = advance(game, g => g.truthtrance?.stage === 'ask'); + game = act(game, f.asker, { ...f.askStep.action, question: { ...f.question, minimum: 1 } }); + assert.equal(viewGame(game, f.target).truthShipmentAnswers!.includes('yes'), false); + assert.ok(viewGame(game, f.target).truthShipmentAnswers!.includes('no')); +}); + +void test('controlled extra ally revival cannot defer a voluntary promise-breaking cost to another responder', () => { + const { f, game: initial } = bound(g => { + allies(g); const ally = player(g, 'harkonnen'); ally.reserves--; ally.tanks++; + }, 10); + const game = advance(initial, g => g.phase === 4 && clean(g)); + reject(game, f.target, { type: 'emperorRevival', amount: 1 }); +}); + +void test('a controlled Revival answer reopens Ready and uses only the remaining current ordinary quota', () => { + const { f, game: initial } = bound(g => { + const p = player(g); p.spice = 1; p.reserves = 0; p.tanks = 3; p.forces = { 'polar_sink:0': 17 }; + g.ready = [p.id]; + }, 1, 4); + assert.equal(initial.ready.includes(f.target), false); + assert.deepEqual(viewGame(initial, f.target).shipmentPreparation?.actions, [{ type: 'revive', amount: 1 }]); + reject(initial, f.target, { type: 'ready' }); + let game = act(initial, f.target, { type: 'revive', amount: 1 }); + game = advance(game, g => g.phase === 5 && clean(g) && g.active === f.target); + game = act(game, f.target, viewGame(game, f.target).shipmentCompletion!.actions[0]); + game = advance(game, clean); + assert.equal(game.shipmentPromises![0].fulfilled, true); + assert.equal(player(game).spice, 0); +}); + +void test('a controlled No answer retains the native legal Karama overbid payment for every existing profile', () => { + const f = createTruthtranceEarlyShipmentFixture(); + giveKarama(f.game); + let game = act(f.game, f.asker, f.declarationStep.action); + game = advance(game, g => g.truthtrance?.stage === 'ask'); + game = act(game, f.asker, f.askStep.action); + game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); + game = advance(game, g => g.phase === 3 && clean(g) && g.auction?.active === f.target); + game = act(game, f.target, { type: 'bid', amount: 11 }); + game = advance(game, g => g.decision?.kind === 'auctionPayment' && g.decision.player === f.target); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty; + const action = botActions(view)[0]; + assert.equal(action.karama, true); + assert.doesNotThrow(() => applyAction(game, f.target, action)); + } +}); diff --git a/tests/truthtrance-future-shipment.test.ts b/tests/truthtrance-future-shipment.test.ts index 89d5b48d..8d1495a7 100644 --- a/tests/truthtrance-future-shipment.test.ts +++ b/tests/truthtrance-future-shipment.test.ts @@ -66,13 +66,18 @@ 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', () => { +void test('current-turn target timing distinguishes earlier phases from unused movement turns 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); + assert.equal(shipmentPromiseTurnAvailable({ ...g, phase: 4 }, 'p', true), true); + assert.equal(shipmentPromiseTurnAvailable({ ...g, phase: 2 }, 'p', false), true, + 'A prior-turn shipped flag does not consume the future current-turn opportunity'); + assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4 }, 'p'), true); + assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4, advanced: true }, 'p'), false); + assert.equal(shipmentPromiseModeSupported({ ...g, phase: 2, techTokens: {} }, 'p'), 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);