From b81823bbec7215a4eaa545b6c77c6edca9c3a117 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Tue, 27 Oct 2026 05:08:40 +1100
Subject: [PATCH] Connect phase-opening Amal promises and native payment order
---
components/truthtrance.tsx | 1 +
components/turn-action-fields.tsx | 12 +-
components/turn-action-promises.tsx | 41 ++-
docs/CURRENT_STATUS.md | 28 +++
docs/IMPLEMENTATION_STATUS.md | 13 +
docs/RULE_DECISIONS.md | 14 ++
docs/TRUTHTRANCE_TURN_ACTIONS.md | 46 ++++
game/bots.ts | 1 +
game/engine.ts | 163 ++++++++++--
game/reference.ts | 5 +-
game/truthtrance.ts | 3 +-
game/turn-action-promises.ts | 2 +-
game/window-card-play.ts | 15 +-
tests/amal.test.ts | 1 +
tests/fixture-truth-turn-amal.ts | 163 ++++++++++++
tests/truth-turn-action-controls.test.tsx | 112 ++++++++-
tests/truth-turn-amal-runtime.test.ts | 289 ++++++++++++++++++++++
tests/truthtrance.test.ts | 5 +-
tests/turn-action-promises.test.ts | 48 +++-
tests/window-card-play.test.ts | 43 +++-
20 files changed, 954 insertions(+), 51 deletions(-)
create mode 100644 tests/fixture-truth-turn-amal.ts
create mode 100644 tests/truth-turn-amal-runtime.test.ts
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index 76f25e97..79ce8da8 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -607,6 +607,7 @@ export function Truthtrance({
Printed Karama auction use refers only to the current ordinary lot, through immediate purchase or its winning invoice.
Printed Karama shipment use grants the named active recipient’s current native shipment price; it does not require an actual force shipment.
Thumper refers only to this turn’s original first spice reveal. On turn 1 its card is still discarded, but the worm encounter is ignored.
+ Amal refers only to this original phase opening and halves available cash before ordinary payments become legal.
Ordinary payments may remain available after their Shipment and Movement opportunity ends.
}
{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}
>
diff --git a/components/turn-action-fields.tsx b/components/turn-action-fields.tsx
index 048f0e5f..ff299646 100644
--- a/components/turn-action-fields.tsx
+++ b/components/turn-action-fields.tsx
@@ -1,6 +1,7 @@
'use client';
import { TERRITORIES } from '@/game/board';
+import { PHASES } from '@/game/catalog';
import type { GameView } from '@/game/engine';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { parseBattleActionClaim } from '@/game/mixed-battle-question';
@@ -50,7 +51,7 @@ const newPowerClaim = (game: GameView, target: string): BattlePowerClaim =>
const windowRoleNames: Record = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
karamaAuction: 'Spend a printed Karama for this current ordinary lot',
karamaShipment: 'Spend a printed Karama for the current shipment-price opportunity',
- thumper: 'Play Thumper before this turn’s first spice reveal' };
+ thumper: 'Play Thumper before this turn’s first spice reveal', amal: 'Play Amal in this original phase opening' };
const config: MixedActionConfig = {
prefix: 'turn-action',
legend: 'Current battle and this-turn action conditions, with optional current facts',
@@ -92,6 +93,9 @@ const config: MixedActionConfig = {
Thumper conditions count an actual accepted future printed play before this turn's original first spice reveal.
Earlier plays, disposal, other roles and recovery alone do not count. A later reveal is a different opportunity.
This condition closes when the actual first spice draw begins.
+ Amal conditions count an actual accepted future printed play in this original phase opening.
+ Earlier plays, disposal, other roles and recovery alone do not count. The condition closes with this opening.
+ Amal halves available cash before ordinary payments become legal; preserve enough remaining cash for any payment condition.
Shipment conditions concern the single future original reserve shipment after the answer.
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
@@ -143,7 +147,8 @@ const config: MixedActionConfig = {
const minimum = 'minimum' in value ? value.minimum : 1;
if (kind === 'cardPlay') onChange({ kind, effect: 'hajr' });
if (kind === 'windowCardPlay') {
- const effect = WINDOW_CARD_EFFECTS.find(effect => windowCardOpportunity(game, effect)) ?? 'weather';
+ const effect = windowCardOpportunity(game, 'amal') ? 'amal'
+ : WINDOW_CARD_EFFECTS.find(effect => windowCardOpportunity(game, effect)) ?? 'weather';
onChange({ kind, effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
}
if (kind === 'battlePower') {
@@ -189,7 +194,7 @@ const config: MixedActionConfig = {
{
const effect = event.currentTarget.value;
- if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'thumper')
+ if (effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'karamaAuction' || effect === 'karamaShipment' || effect === 'thumper' || effect === 'amal')
onChange({ kind: 'windowCardPlay', effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
}}>
Choose a printed role
@@ -207,6 +212,7 @@ const config: MixedActionConfig = {
? `Turn ${currentWindow.turn} · Fresh spice blow in ${game.combatLocations.find(location => location.id === game.spiceWindow?.territory)?.name ?? game.spiceWindow?.territory}`
: value.effect === 'karamaAuction' ? `Turn ${currentWindow.turn} · Ordinary lot ${game.bidPromiseLot?.lot}`
: value.effect === 'thumper' ? `Turn ${currentWindow.turn} · Before the original first spice reveal`
+ : value.effect === 'amal' ? `Turn ${currentWindow.turn} · Original ${PHASES[game.phase]} opening`
: currentWindow.effect === 'karamaShipment' ? `Turn ${currentWindow.turn} · ${game.players.find(player => player.id === currentWindow.recipient)?.name ?? currentWindow.recipient}’s current native shipment price`
: `Turn ${currentWindow.turn} · Before this Storm movement`}}
diff --git a/components/turn-action-promises.tsx b/components/turn-action-promises.tsx
index b5426c79..c6bc3e65 100644
--- a/components/turn-action-promises.tsx
+++ b/components/turn-action-promises.tsx
@@ -27,9 +27,9 @@ const publicBattleContext = (game: GameView) => game.battle && ({
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
});
const isWindowEffect = (effect: unknown): effect is Exclude =>
- effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'thumper';
+ effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'thumper' || effect === 'amal';
const windowRoleNames: Record = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
- karamaAuction: 'printed Karama for this ordinary lot', karamaShipment: 'printed Karama for this shipment price', thumper: 'Thumper' };
+ karamaAuction: 'printed Karama for this ordinary lot', karamaShipment: 'printed Karama for this shipment price', thumper: 'Thumper', amal: 'Amal' };
const namesWindow = (mixed: MixedActionExpression, effect: WindowCardEffect, source?: string): boolean =>
mixed.kind === 'action' ? mixed.action.kind === 'windowCardPlay' && mixed.action.effect === effect && (source === undefined || mixed.action.source === source)
: mixed.kind !== 'fact' && mixed.terms.some(term => namesWindow(term, effect, source));
@@ -65,6 +65,10 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const box = game.nullentropy, search = box?.search;
const windowPromise = game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn);
const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed);
+ const opening = game.phaseOpening, openingSignature = JSON.stringify(opening);
+ const currentAmalWindow = windowCardOpportunity(game, 'amal');
+ const amalRoleNamed = !!windowPromise && !!currentAmalWindow && namesWindow(windowPromise.mixed, 'amal', currentAmalWindow.source);
+ const amalReadyAction = action.type === 'ready' && !!opening && amalRoleNamed;
const currentAuctionWindow = windowCardOpportunity(game, 'karamaAuction');
const auctionRoleNamed = !!windowPromise && !!currentAuctionWindow && namesWindow(windowPromise.mixed, 'karamaAuction', currentAuctionWindow.source);
const auctionPurchaseAction = action.type === 'card' && action.mode === 'purchase';
@@ -86,6 +90,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
: WINDOW_CARD_EFFECTS.map(effect => windowCardOpportunity(current, effect)),
stormOpen: current.stormCardWindowOpen,
thumperOpen: current.thumperWindowOpen, thumperReady: current.thumperReady, nexus: current.nexus,
+ amalOpen: current.amalWindowOpen, amalReady: current.amalReady, opening: current.phaseOpening,
stormPending: current.stormPending, wallDestroyed: current.shieldWallDestroyed,
blow: current.spiceWindow,
...(current.phase === 3 ? { auction: auctionSource(current) } : {}),
@@ -94,12 +99,13 @@ export function TurnActionGuideButton(props: TurnActionProps) {
...(windowEffect === 'atomics' ? { forces: current.players.find(player => player.id === actor)?.forces,
advisors: current.players.find(player => player.id === actor)?.advisors } : {}),
});
- const windowSignature = windowEffect || heldCard?.effect === 'nullentropyBox' || earlyWindowReadyAction ? publicWindowState(game) : null;
+ const windowSignature = windowEffect || heldCard?.effect === 'nullentropyBox' || earlyWindowReadyAction || amalReadyAction ? publicWindowState(game) : null;
const originalBlow = windowEffect === 'harvester' || earlyWindowReadyAction && phase === 1 ? game.spiceWindow : null;
const earlyBoxSignature = box && JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event });
const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards);
const shipmentCards = game.karamaShipmentCards, shipmentCardsSignature = JSON.stringify(shipmentCards);
const thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards);
+ const amalCards = game.amalCards, amalCardsSignature = JSON.stringify(amalCards);
const auctionHandSource = (current: GameView) => {
const player = current.players.find(player => player.id === actor);
return JSON.stringify({ hand: player?.hand?.map(card => ({ id: card.id, kind: card.kind, effect: card.effect })), limit: player?.handLimit });
@@ -155,12 +161,23 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const nativeThumperCard = windowEffect === 'thumper' && !!heldCard && phase === 1 && !current.spiceWindow && !current.nexus &&
Array.isArray(current.thumperCards) && current.thumperCards === thumperCards &&
JSON.stringify(current.thumperCards) === thumperCardsSignature && current.thumperCards.includes(heldCard.id);
+ const currentOpening = !!opening && current.phaseOpening === opening && JSON.stringify(current.phaseOpening) === openingSignature &&
+ !opening.passed.includes(actor);
+ const nativeAmalCard = windowEffect === 'amal' && !!heldCard && currentOpening &&
+ Array.isArray(current.amalCards) && current.amalCards === amalCards && JSON.stringify(current.amalCards) === amalCardsSignature &&
+ current.amalCards.includes(heldCard.id);
const printedWindow = action.type === 'card' && printedWindowSource && !!heldCard && windowPacket &&
(windowEffect === 'karamaAuction' ? action.mode === 'purchase' && nativeAuctionCard && !!me && !current.decision &&
Number.isSafeInteger(me.handLimit) && Array.isArray(me.hand) && me.hand.length < me.handLimit
: windowEffect === 'karamaShipment' ? action.mode === 'shipment' && nativeShipmentCard
: windowEffect === 'thumper' ? action.mode === undefined && nativeThumperCard
- : action.mode === undefined && ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true);
+ : windowEffect === 'amal' ? action.mode === undefined && nativeAmalCard
+ : action.mode === undefined && ordinaryCardAvailability(current, heldCard.id, windowEffect === 'weather' ? { amount: action.amount } : undefined)?.available === true);
+ const openingReady = amalReadyAction && currentOpening && current.amalReady === true &&
+ Object.keys(action).every(key => ['type', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)) &&
+ !!windowPromise && current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
+ publicWindowState(current) === windowSignature;
+ const allowedOpeningAction = openingReady || windowEffect === 'amal' && printedWindow;
const printedAuctionInvoice = printedInvoiceAction && printedWindowSource && nativeAuctionCard &&
Object.keys(action).every(key => ['type', 'card', 'karama', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key));
const quotedBoxCard = heldCard?.effect !== 'nullentropyBox' || !!box && current.nullentropy === box &&
@@ -246,7 +263,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const battlePreparation = genericCard && planWindow;
const declaration = action.type === 'traitorCall' && typeof action.call === 'boolean' && !!battle && currentBattle && battle.revealed &&
battle.traitorVoters.includes(actor) && !battle.traitorSubmitted.includes(actor);
- const opportunity = earlyWindowReady || earlyWindowBox || printedWindow || ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
+ const opportunity = openingReady || earlyWindowReady || earlyWindowBox || printedWindow || ownedPower || payment || paidBox || printedHajr || printedGhola || plan || battlePreparation || declaration || nexusWindow && (ownedAlliance || action.type === 'ready') || (phase === 2
? !current.ready.includes(actor) && (['charity', 'ready'].includes(action.type) || genericCard)
: phase === 3
? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor
@@ -263,8 +280,9 @@ export function TurnActionGuideButton(props: TurnActionProps) {
action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity &&
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
!promise.fulfilled && !promise.released) &&
+ (!amalReadyAction || openingReady) &&
!!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || invoice || earlyWindowBox && ownInvoiceSource) &&
- !current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
+ (!current.phaseOpening || allowedOpeningAction) && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
const amount = action.forces && typeof action.forces === 'object'
? Object.values(action.forces as Record).reduce((sum, count) => sum + count, 0)
@@ -292,7 +310,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
: 'Withdraw your alliance offer while preserving your whole answer'
: `Accept ${recipient}’s alliance invitation while preserving your whole answer`
: action.type === 'charity' ? 'Claim Charity while preserving your whole answer'
- : action.type === 'ready' ? phase === 8 ? 'Ready to end this turn while honoring your whole answer'
+ : action.type === 'ready' ? amalReadyAction ? 'Pass this original phase opening while preserving your whole answer'
+ : phase === 8 ? 'Ready to end this turn while honoring your whole answer'
: earlyWindowReadyAction && (phase === 0 || !!game.spiceWindow) ? `Accept this ${phase === 0 ? 'Storm' : 'fresh spice-blow'} window while preserving your whole answer`
: earlyWindowReadyAction && game.thumperReady && !!windowPromise && namesWindow(windowPromise.mixed, 'thumper', `first-spice-reveal:${turn}`)
? 'Ready for the original first spice reveal while preserving your whole answer'
@@ -379,6 +398,9 @@ export function TurnActionPromises(props: TurnActionProps) {
Thumper conditions count an actual accepted future printed play before this turn's original first spice reveal.
Earlier plays, disposal, other roles and recovery alone do not count. A later reveal is a different opportunity.
This condition closes when the actual first spice draw begins.
+ Amal conditions count an actual accepted future printed play in this original phase opening.
+ Earlier plays, disposal, other roles and recovery alone do not count. The condition closes with this opening.
+ Amal halves available cash before ordinary payments become legal; preserve enough remaining cash for any payment condition.
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
never the sum of smaller moves. Earlier shipment and movement do not count.
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
@@ -400,6 +422,7 @@ export function TurnActionAnswers(props: TurnActionProps) {
if (window?.stage !== 'answer' || question?.kind !== 'turnActions' || question.target !== game.me || !game.truthTurnActionAnswers) return null;
const actor = game.me, turn = game.turn, phase = game.phase, source = window.queue[0], answers = game.truthTurnActionAnswers;
const signature = JSON.stringify(question), sourceSignature = JSON.stringify(source);
+ const opening = game.phaseOpening, openingSignature = JSON.stringify(opening);
const canAnswer = (answer: TruthAnswer) => {
const current = props.game, me = current.players.find(player => player.id === actor);
return !props.busy && current === game && current.status === 'playing' &&
@@ -408,7 +431,8 @@ export function TurnActionAnswers(props: TurnActionProps) {
window.question === question && question.target === actor && JSON.stringify(question) === signature &&
!!source && window.queue[0] === source && JSON.stringify(source) === sourceSignature &&
current.truthTurnActionAnswers === answers && answers.includes(answer) &&
- !!me && !me.autopilot && !current.response && !current.decision && !current.phaseOpening &&
+ !!me && !me.autopilot && !current.response && !current.decision &&
+ current.phaseOpening === opening && JSON.stringify(current.phaseOpening) === openingSignature &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
return
@@ -425,6 +449,7 @@ export function TurnActionAnswers(props: TurnActionProps) {
Bids close with their lot; Revival closes with your native Revival opportunity.
Shipment and movement close at your own Movement turn end; payment conditions last until the actual turn ends.
A known Inflation Double opening can close payments earlier without closing Ghola.
+ An original-opening Amal play halves available cash before ordinary payments become legal.
Original rights, preparation, timing and costs still apply. Only your whole answer is shared.
{answers.map(answer => {
const action: Action = { type: 'truthAnswer', answer };
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 5dbc7f28..8b87230a 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -20,6 +20,34 @@ open mode gates.
Continue remaining Basic functions before Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
+**Truthtrance / phase-opening Amal — 27 October. Prototyped / Partial.**
+Supported original openings now admit whole questions and actual printed Amal
+conditions. Native halving precedes ordinary payments. Definite answers reopen
+own opening passes; exact card/Ready guides and four legal profiles are connected.
+A known four-spice Mentat receipt arrives after cash 10 becomes 5, permitting 9.
+Own costs/acceptance retain completion; a foreign Amal releases genuine final-
+Mentat impossibility before another player takes the last pass.
+
+Frozen **17 files, 350/350**, types/lint/build and seven native journeys
+pass, with 42 GameTable SSR renders, 7 actual answer callbacks and 11 exact
+guide callbacks. Genuine selected-Ix sources use 6 actions at the first Storm
+opening, 14 at the first Spice opening and 56/57 at the recorded Mentat sources.
+Complete original card/Traitor/Spice inventories remain. New cases include
+actual escrow, source closure, cumulative caps, native priority and stale guides.
+
+Related feedback preserves Amal priority over a queued Fremen forecast response,
+updates the older manufactured unit fixture's lifecycle flag, and corrects native
+quote versus commitment assumptions. Source proof derives the actual Collection
+transition; Advanced earns the current native city bonus, Basic does not.
+A bounded inventory found the checked historical independent absences superseded;
+this is no Basic/Advanced certificate. Broader future-income scheduling, Semuta/
+Box, uncertain collection, other compositions and final acceptance remain open.
+
+Proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-amal-proof-v2/`;
+journeys/HTML: adjacent `20261027-truth-amal-runtime/run-v2/`.
+No HTTP/storage boundary, saved-game/database, restart or deployed operation
+changes. [Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-amal-and-phase-openings--27-october-2026).
+
**Truthtrance / printed Thumper — 27 October. Prototyped / Partial.**
Original-first-reveal promises now connect real printed costs, persistent
first-turn warning, exact native play/Ready controls and four legal profiles.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index fbf23f4a..53e9669a 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,18 @@
# Dune implementation status
+## 27 October 2026 — Phase-opening Amal commitments
+
+Prototyped / Partial. Original opening questions, actual printed Amal costs,
+native half-before-payments, known later Mentat receipts and exact controls/
+minimal profiles are connected. Own costs retain completion; actual foreign
+loss releases before the last passer. Frozen 17 files, 350/350, types/lint/
+build and seven journeys (42 SSR renders, 7 answer callbacks, 11 guide
+callbacks) pass. Native Fremen forecast priority is preserved and source/test
+oracles are corrected. Broader funding/compositions and full acceptance remain.
+No deployment or saved-game operation occurs.
+[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-amal-and-phase-openings--27-october-2026).
+Private proof: `/home/quintine/.local/share/dune/checkpoints/20261027-truth-amal-proof-v2/`.
+
## 27 October 2026 — Printed Thumper promises
Prototyped / Partial. Original first-reveal sources, actual printed costs and
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 5c9054ae..45498a23 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,19 @@
# Rule decision index
+## Truthtrance printed Amal and phase openings — 27 October 2026
+
+Prototyped / Partial. Extend the existing whole current-turn answer composition
+to the original phase-opening interval, including actual printed Amal costs
+after answering. Its native reduction still rounds the loss upward; it does
+not cancel a phase. The original phase/turn source remains through temporary
+priority and pass resets. Ordinary payments wait until the opening ends, so
+private quotation applies Amal before reserving those payments. Known Mentat
+bribe receipts arrive afterward, without reading hidden cards or choosing
+foreign responses. Own costs/acceptance retain a completion; a foreign Amal
+can release genuine loss before the last passer acts. This is bounded product
+composition, not an exhaustive publisher worked example or complete mode gate.
+[Contract](TRUTHTRANCE_TURN_ACTIONS.md#printed-amal-and-phase-openings--27-october-2026).
+
## Truthtrance printed Thumper — 27 October 2026
Prototyped / Partial. Extend existing whole current-turn answers to an actual
diff --git a/docs/TRUTHTRANCE_TURN_ACTIONS.md b/docs/TRUTHTRANCE_TURN_ACTIONS.md
index 2f32da91..fb609a24 100644
--- a/docs/TRUTHTRANCE_TURN_ACTIONS.md
+++ b/docs/TRUTHTRANCE_TURN_ACTIONS.md
@@ -1,5 +1,51 @@
# Truthtrance combined current-turn actions
+## Printed Amal and phase openings — 27 October 2026
+
+**Prototyped / Partial.** Whole questions can now be asked during a supported
+native phase opening. `windowCardPlay/amal` binds
+`phase-opening:turn:phase` and counts an actual printed cost after this answer.
+It retains Amal's native half-available-spice reduction and pass reset, rather
+than promising a later selected balance. Temporary priority/discard suspension
+keeps the original interval. Actual opening completion retires it; a later phase
+or earlier printed play cannot inherit the receipt. Definite answers reopen the
+respondent's own opening pass, including constant answers and No.
+
+Amal must precede ordinary payments in the same opening. The private quote
+executes its real cost/reduction first, then quotes post-opening payments with
+the remaining physical purse. It does not prepay while payments are blocked.
+Known Mentat incoming bribes remain outside the reduction and arrive after
+confirmations: original cash 10 becomes 5, then an actual prior four-spice receipt
+becomes spendable 9. With no incoming payment, a final Mentat payment of 5 can be
+funded after Amal; 6 cannot. These are closing-Mentat comparisons, not a claim that
+early-phase future Charity, collection or third-party income has been scheduled.
+The existing bounded current-purse quotation remains; broader future-income
+funding and combinations remain open.
+
+Only deterministic native opening preparation is projected. Bidding cards are
+not drawn by a quote, and foreign choices are not selected. Own printed loss or
+opening acceptance cannot discard an existing completion. A real foreign Amal
+propagates actual impossibility to the affected answer before the later last
+passer, retaining original history and payment progress. Basic and already-open
+native timing remain; this does not change the printed Amal effect or turn on
+new optional modules.
+
+Source-bound owner quotes feed the editor, actual answer callbacks, exact native
+Amal/Ready guides and four minimally legal profiles. Opening Ready uses its own
+pass list, independently of ordinary phase readiness; other native card guides
+and Box recovery retain their existing modal boundaries. Fresh H/A/E selected-Ix
+sources deal both original Truthtrances and the physical Amal, and reach their
+openings through real setup/dials/phases. No dealt wallet, army or receipt is
+installed. Semuta/Box, uncertain shared collection, broader future-action
+families, full modes and final deployed acceptance remain open.
+Frozen evidence: 17 related files, 350/350, types/lint/build and seven
+native/SSR/control journeys pass (42 GameTable renders, 7 answer callbacks,
+11 guide callbacks). Recorded original sources use 6, 14 or 56/57 native
+actions. Private proof:
+`/home/quintine/.local/share/dune/checkpoints/20261027-truth-amal-proof-v2/`;
+journeys/HTML: adjacent `20261027-truth-amal-runtime/run-v2/`.
+This is local native/SSR evidence, not browser hydration or deployed acceptance.
+
## Printed Thumper — 27 October 2026
**Prototyped / Partial.** `windowCardPlay/thumper` binds
diff --git a/game/bots.ts b/game/bots.ts
index cf6000e1..0ccc27ec 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1565,6 +1565,7 @@ function policyActions(g: GameView): Action[] {
// Rival spice is private. Low own losses inform this choice.
if (
amal &&
+ g.amalCards?.includes(amal.id) === true &&
g.turn > 1 &&
(me.spice ?? 0) <= (level === 0 ? 4 : 2) &&
(level > 0 || variation(g, 'amal') > 0.4)
diff --git a/game/engine.ts b/game/engine.ts
index 62740cdd..50ec4642 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -2507,6 +2507,8 @@ export type Game = {
stormCardWindowOpen?: boolean;
/** Original first Spice reveal; temporary summoned-worm controls do not retire it. */
thumperWindowOpen?: boolean;
+ /** Original phase opening; a discard response can temporarily suspend its controls. */
+ amalWindowOpen?: boolean;
stormCard: number | null;
stormCardKnown: boolean;
stormResolution: {
@@ -19017,9 +19019,23 @@ function quotedThumperCards(g: Game, player: Player): string[] {
return player.hand.filter(card => card.kind === 'special' && card.effect === 'thumper' &&
!transferCardBlock(g, player, card)).map(card => card.id);
}
+function quotedAmalCards(g: Game, player: Player): string[] {
+ if (!windowCardOpportunity(g, 'amal') || !g.phaseOpening || g.phaseOpening.passed.includes(player.id) ||
+ g.truthtrance || g.pendingTreacheryDiscard) return [];
+ return player.hand.filter(card => card.kind === 'special' && card.effect === 'amal' &&
+ !transferCardBlock(g, player, card)).map(card => card.id);
+}
+function quotedAmalReady(g: Game, actor: string): boolean {
+ return !!windowCardOpportunity(g, 'amal') && !!g.phaseOpening && !g.phaseOpening.passed.includes(actor) &&
+ !g.truthtrance && !g.pendingTreacheryDiscard;
+}
function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator {
if (!windowCardOpportunity(g, effect)) return;
const player = getPlayer(g, actor);
+ if (effect === 'amal') {
+ for (const card of quotedAmalCards(g, player)) yield { type: 'card', card };
+ return;
+ }
if (effect === 'karamaAuction') {
for (const card of quotedAuctionKaramaCards(g, player)) yield g.decision?.kind === 'auctionPayment'
? { type: 'decision', karama: true, card } : { type: 'card', card, mode: 'purchase' };
@@ -19155,10 +19171,10 @@ function reconcileTurnActionWindowCards(g: Game) {
}
/** A concurrent Ready grants permission to close this window. Quote that
* closure without installing it before the real global continuation. */
-function ownTurnActionWindowClosure(g: Game, actor: string): TurnActionPromise[] {
+function ownTurnActionWindowClosure(g: Game, actor: string, effects: readonly WindowCardEffect[] = WINDOW_CARD_EFFECTS): TurnActionPromise[] {
let closed = g.turnActionPromises ?? [];
for (const promise of liveTurnActionPromises(closed, actor, g.turn))
- for (const effect of WINDOW_CARD_EFFECTS) {
+ for (const effect of effects) {
const stage = promise.windowCards?.[effect];
if (stage && !stage.closed) closed = turnActionRule(() =>
closeTurnActionWindowCardPlay(closed, actor, g.turn, effect, stage.source));
@@ -19283,10 +19299,10 @@ function guardTurnActionShipmentPriceDelivery(g: Game, shipment: PendingShipment
guardTurnActionShipmentPriceBoundary(trial, shipment.player, original, undefined, receipt);
}
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
- return bribeQuestionWindow(g) && movementPromiseModeSupported(g);
+ return bribeQuestionWindow(g.phaseOpening ? { ...g, phaseOpening: null } : g) && movementPromiseModeSupported(g);
}
function turnActionTransportOpen(g: Game, actor: string): boolean {
- return g.phase < 5 || g.phase === 5 && !!g.movementRemaining?.includes(actor);
+ return g.phase < 5 || g.phase === 5 && (!!g.phaseOpening?.initialize || !!g.movementRemaining?.includes(actor));
}
/** Read only the original predicates while the native sealed cards are still
* held. Receipts contain booleans, never copies of private hands or plans. */
@@ -19504,6 +19520,66 @@ function turnActionAllianceCanClose(g: Game, player: Player): boolean {
} catch (error) { if (error instanceof RuleError) return false; throw error; }
}
type TurnActionCompletion = MovementCompletion & { payments: Action[]; bid?: Action; revival?: RevivalPromisePlan; alliance?: Action[]; battlePlan?: BattleCompletion; traitor?: Action; cardPreparations?: Action[]; battlePower?: Action };
+/** Forecast deterministic opening work only. Auction/deck draws and foreign
+ * choices stay at the real table; fixed Mentat receipts arrive after Amal. */
+function projectTurnActionOpeningEnd(g: Game) {
+ const initialize = g.phaseOpening?.initialize;
+ g.phaseOpening = null; g.amalWindowOpen = false;
+ if (!initialize) return;
+ if ([4, 5, 7].includes(g.phase)) beginPhase(g);
+ if (g.phase === 8) {
+ advanceMentatInflation(g);
+ for (const player of g.players) { player.spice += player.bribes; player.bribes = 0; }
+ }
+}
+/** Amal must precede payments in this opening. Other native card windows may
+ * still be used after its confirmations, with the resulting physical purse. */
+function prepareTurnActionOpening(source: Game, actor: string, requirements: readonly TurnActionWindowCardRequirement[]):
+ { game: Game; actions: Action[] } | null {
+ if (!source.phaseOpening) return { game: source, actions: [] };
+ const opening = requirements.filter(required => required.effect === 'amal');
+ let game = source;
+ const actions: Action[] = [];
+ if (opening.some(required => required.answer)) {
+ const opportunity = windowCardOpportunity(source, 'amal');
+ if (!opportunity || opening.some(required => !required.answer || required.source !== opportunity.source)) return null;
+ const action = nativeWindowCardActions(source, actor, 'amal').next().value;
+ if (!action) return null;
+ try { game = applyActionInner(source, actor, action, 'movementPreparation'); }
+ catch (error) { if (error instanceof RuleError) return null; throw error; }
+ actions.push(action);
+ }
+ projectTurnActionOpeningEnd(game);
+ return { game, actions };
+}
+/** Check the owner before a discard/pass hands the opening away. Propagate
+ * only genuine foreign releases, so the later last passer cannot inherit
+ * responsibility for another player's already completed Amal reduction. */
+function guardTurnActionOpening(g: Game, actor: string, pass: boolean) {
+ const keys = ['turnActionPromises', 'travelPromises', 'shipmentPromises', 'movementPromises',
+ 'movementExpressionPromises', 'bidPromises', 'revivalPromises', 'leaderRevivalPromises',
+ 'revivalExpressionPromises', 'bribePromises', 'bribeExpressionPromises'] as const;
+ if (!keys.some(key => g[key]?.some(row => !row.fulfilled && !row.released))) return;
+ const trial = structuredClone(g);
+ if (pass && trial.turnActionPromises) trial.turnActionPromises = ownTurnActionWindowClosure(trial, actor, ['amal']);
+ projectTurnActionOpeningEnd(trial);
+ const action: Action = { type: 'card' };
+ reconcileTravelPromises(trial, actor, action, g.phase);
+ reconcileBidPromises(trial, { actor, action });
+ reconcileRevivalPromises(trial, { actor, action });
+ reconcileBribePromises(trial, { actor, action });
+ reconcileGroundMovementPromises(trial, actor, action, g.phase);
+ if (pass) return;
+ for (const key of keys) {
+ const quoted = trial[key];
+ g[key]?.forEach((row, index) => {
+ if (row.player !== actor && !row.fulfilled && !row.released && quoted?.[index]?.released) {
+ row.released = true;
+ log(g, `${getPlayer(g, row.player).name} can no longer fulfill a Truthtrance answer after another player's Amal; that answer is no longer binding.`);
+ }
+ });
+ }
+}
/** Whole-answer alternatives stay private. Only detached native rows quote a
* route, sharing actual interval progress and the remaining physical purse. */
function turnActionCompletion(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null {
@@ -19521,12 +19597,11 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
}
return null;
}
- const trial = structuredClone(source);
+ let trial = structuredClone(source);
trial.truthtrance = null;
delete trial.turnActionPromises;
delete trial.travelPromises;
delete trial.travelPromiseOrder;
- const owner = getPlayer(trial, actor);
const cardRequirements: TurnActionCardRequirement[] = leaves.flatMap(leaf => leaf.kind === 'cardPlay'
? [{ effect: leaf.effect, answer: leaf.answer, baseline: nativePrintedCardCount(source, actor, leaf.effect) }] : []);
if (cardRequirements.length) (trial as TurnActionCardProbe)._turnActionCardPolicy = {
@@ -19541,6 +19616,11 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
...forbiddenWindows,
],
};
+ const windowRequirements = leaves.filter((leaf): leaf is TurnActionWindowCardRequirement => leaf.kind === 'windowCardPlay');
+ const opening = prepareTurnActionOpening(trial, actor, windowRequirements);
+ if (!opening) return null;
+ trial = opening.game;
+ const owner = getPlayer(trial, actor);
trial.bribePromises ??= [];
trial.shipmentPromises ??= [];
trial.movementPromises ??= [];
@@ -19624,7 +19704,7 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
for (const payment of required)
checkBureaucratContributors(paid, payment.recipient, [{ payer: actor, amount: payment.amount }]);
for (const cardSeed of turnActionPrintedPreparationSeeds(paid, actor, cardRequirements,
- leaves.filter((leaf): leaf is TurnActionWindowCardRequirement => leaf.kind === 'windowCardPlay'))) {
+ source.phaseOpening ? windowRequirements.filter(required => required.effect !== 'amal') : windowRequirements)) {
const funded = cardSeed.game, payer = getPlayer(funded, actor);
try {
reconcileBattlePromises(funded, voluntary);
@@ -19668,7 +19748,8 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
: !cardsPending || battlePlan?.cardPlaysComplete ? { actions: [] } : null;
if (completion) {
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; }
- return { ...completion, ...(cardSeed.actions.length ? { cardPreparations: cardSeed.actions } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
+ const cardPreparations = [...opening.actions, ...cardSeed.actions];
+ return { ...completion, ...(cardPreparations.length ? { cardPreparations } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
...(power.action ? { battlePower: power.action } : {}),
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
@@ -19739,7 +19820,7 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
...(combat ? { combat } : {}),
...(preparation ? { preparation } : {}),
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}),
- shipment: { pending: open && (g.phase < 5 || shipmentAvailable(g, p)), event: null },
+ shipment: { pending: open && (g.phase < 5 || !!g.phaseOpening?.initialize || shipmentAvailable(g, p)), event: null },
progress: [], payments: [], transportClosed: !open, paymentClosed: false,
fulfilled: compiled.kind === 'constant' && compiled.value === answer, released: false };
if (combat?.plan && battle?.plans[p.id]) promise = turnActionRule(() => recordTurnActionPlan([promise], p.id,
@@ -19747,9 +19828,11 @@ function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: b
return promise;
}
function turnActionAnswerSource(g: Game, player: string, alliance = false, window = false): Game {
- return g.phase === 0 && window || g.phase === 1 && (alliance || window) || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
+ const source = g.phase === 0 && window || g.phase === 1 && (alliance || window) || g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
g.phase === 7 && bribesCloseBeforeNextMentat(g)
? { ...g, ready: g.ready.filter(id => id !== player) } : g;
+ return source.phaseOpening ? { ...source, phaseOpening: { ...source.phaseOpening,
+ passed: source.phaseOpening.passed.filter(id => id !== player) } } : source;
}
function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !turnActionTargetAvailable(g, p.id)) return [];
@@ -19770,7 +19853,8 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer:
requireRule(turnActionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a whole turn-action answer compatible with current funding and native actions.');
const promise = turnActionPromise(g, p, claim, answer, crypto.randomUUID(), asker);
- g.ready = turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards).ready;
+ const reopened = turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards);
+ g.ready = reopened.ready; g.phaseOpening = reopened.phaseOpening;
g.turnActionPromises ??= [];
initializeTurnActionOrder(g);
g.turnActionPromises.push(promise);
@@ -19858,10 +19942,25 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act
const promise = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn)[0];
const ownedBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id;
const ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
- if (!promise || g.truthtrance || g.response || g.phaseOpening || g.pendingTreacheryDiscard ||
+ if (!promise || g.truthtrance || g.response || g.pendingTreacheryDiscard ||
g.decision && !ownedBox && !ownedInvoice) return null;
const completion = turnActionCompletion(g, player.id);
if (!completion) return null;
+ if (g.phaseOpening) {
+ const opportunity = windowCardOpportunity(g, 'amal');
+ const row = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn).find(row =>
+ row.windowCards?.amal && !row.windowCards.amal.closed && row.windowCards.amal.source === opportunity?.source);
+ if (!row) return null;
+ const preparation = completion.cardPreparations?.[0];
+ let action = preparation?.type === 'card' && quotedAmalCards(g, player).includes(preparation.card as string)
+ ? preparation : undefined;
+ if (!action && quotedAmalReady(g, player.id)) {
+ const closed = ownTurnActionWindowClosure(g, player.id, ['amal']);
+ if (turnActionCompletion({ ...g, turnActionPromises: closed }, player.id)) action = { type: 'ready' };
+ }
+ return action ? { event: row.event, action: { ...action,
+ turnActionPromiseEvent: row.event, turnActionPromisePhase: g.phase } } : null;
+ }
let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
if (!action) {
const preparation = completion.cardPreparations?.[0];
@@ -26133,6 +26232,7 @@ function completePhase(g: Game) {
/** Open the same public window for every Ix table, independently of who holds Amal. */
function openPhase(g: Game, initialize = true) {
if (g.phase === 1) g.thumperWindowOpen = true;
+ g.amalWindowOpen = g.expansions.includes('ix');
if (g.phase === 1 && g.nexusCards && g.nexusCards.phase?.turn !== g.turn)
g.nexusCards.phase = createNexusCardPhase(g.turn);
if (initialize && g.phase === 4 && g.homeworlds) {
@@ -26170,6 +26270,18 @@ function startNativeRevivalOrder(g: Game, player: string) {
if (g.revivalOrder)
g.revivalOrder = revivalOrderRule(() => startRevivalOrder(revivalOrderContext(g),g.revivalOrder!,player));
}
+function advanceMentatInflation(g: Game) {
+ if (!g.inflation || g.inflation.updatedTurn >= g.turn) return;
+ if (g.inflation.flipped) {
+ g.inflation = null;
+ log(g, 'The Inflation token was removed from the game.');
+ } else {
+ g.inflation.side = g.inflation.side === 'double' ? 'cancel' : 'double';
+ g.inflation.flipped = true;
+ g.inflation.updatedTurn = g.turn;
+ log(g, `Inflation flipped to ${g.inflation.side} for next turn’s charity.`);
+ }
+}
function beginPhase(g: Game) {
if (g.phase === 0) {
g.auditorInsight = null;
@@ -26227,20 +26339,7 @@ function beginPhase(g: Game) {
collectBankerIncome(g);
drawHarkonnenNexusBetrayalReplacement(g);
replaceMoritaniAssassinationTraitors(g);
- if (g.inflation && g.inflation.updatedTurn < g.turn) {
- if (g.inflation.flipped) {
- g.inflation = null;
- log(g, 'The Inflation token was removed from the game.');
- } else {
- g.inflation.side = g.inflation.side === 'double' ? 'cancel' : 'double';
- g.inflation.flipped = true;
- g.inflation.updatedTurn = g.turn;
- log(
- g,
- `Inflation flipped to ${g.inflation.side} for next turn’s charity.`,
- );
- }
- }
+ advanceMentatInflation(g);
for (const p of g.players) {
p.spice += p.bribes;
p.bribes = 0;
@@ -38776,10 +38875,12 @@ function applyActionInner(
'You have already passed the phase opening.',
);
if (t === 'ready') {
+ if (execution === 'live') guardTurnActionOpening(g, id, true);
g.phaseOpening.passed.push(id);
if (g.phaseOpening.passed.length === g.players.length) {
const initialize = g.phaseOpening.initialize;
g.phaseOpening = null;
+ g.amalWindowOpen = false;
if (initialize) beginPhase(g);
}
} else {
@@ -38790,13 +38891,18 @@ function applyActionInner(
t === 'card' && card && !action.mode,
'Play Amal or pass the phase opening before taking other actions.',
);
+ const window = windowCardOpportunity(g, 'amal');
+ requireRule(window && quotedAmalCards(g, p).includes(card.id), 'Retain your original native Amal opening and physical card.');
+ recordNativeTurnActionWindowCard(g, id, 'amal', window.source);
const before = g.semutaPreview
? g.players.map(player => ({ player: player.id, spice: player.spice }))
: null;
const used = discard(g, p, card.id);
+ rememberMovementDiscards(g, [{ card: used, discardedBy: id, publicFace: true }]);
for (const player of g.players)
player.spice = Math.floor(player.spice / 2);
g.phaseOpening.passed = [];
+ if (execution === 'live') guardTurnActionOpening(g, id, false);
log(
g,
`${p.name} played Amal. Each faction returned half its available spice to the bank, rounded up.`,
@@ -42123,7 +42229,7 @@ function applyActionInner(
throw new RuleError(availability.reason);
if (isWindowCardEffect(c.effect)) {
const source = windowCardOpportunity(g, c.effect);
- requireRule(source, 'Retain the original open Storm or fresh spice-blow window.');
+ requireRule(source, 'Retain this card’s original native timing window.');
recordNativeTurnActionWindowCard(g, id, c.effect, source.source);
}
if (c.effect === 'hajr') {
@@ -42439,6 +42545,9 @@ export function viewGame(state: Game, id: string, now?: number) {
thumperCards: quotedThumperCards(g, me),
thumperReady: thumperReadyStartsDraw(g),
thumperWindowOpen: !!windowCardOpportunity(g, 'thumper'),
+ amalCards: quotedAmalCards(g, me),
+ amalReady: quotedAmalReady(g, id),
+ amalWindowOpen: !!windowCardOpportunity(g, 'amal'),
truthBidTargets: bidQuestionWindow(g) ? g.players.filter(player => {
const lot = bidOpportunity(g)!;
return lot.eligible.includes(player.id) && !lot.passed.includes(player.id) && lot.bidder !== player.id;
diff --git a/game/reference.ts b/game/reference.ts
index 5dc05135..ef9c84b9 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3233,6 +3233,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A current-lot Karama condition counts a future physical printed Karama cost for this original ordinary auction card, either immediate purchase or its winning invoice. Bidding alone, another Karama role, a converted Worthless card, an earlier play, recovery or a later lot does not count. A bid-and-Karama answer must retain the real bid before the purchase; free acquisition still uses the original native controls and hand limit. A whole No shares its payment and bid funding without assuming a forbidden free invoice. Unseen future auction cards cannot fund another promised card play; actual owned knowledge remains usable.',
'A printed Karama shipment-price clause names the current beneficiary’s unused native shipment. The giver spends the physical printed card; the beneficiary still pays the native bank tariff, such as two spice becoming one. Playing that cost counts even if its price is unused. The source remains while that original recipient is entitled, including another actor’s turn; a different beneficiary or a Cunning second opportunity has its own interval. Own shipping or ending must preserve the whole answer before a foreign counter, while foreign actions retain their rights.',
'A Thumper condition counts an actual printed play after answering before this turn’s original first Spice reveal. Its cost counts even when the existing first-turn rule ignores the encounter or waiting Sandtrout suppresses it. Own acceptance must retain the whole answer. Known card effects enter the quote; the next hidden Spice Card does not. A forced later draw can release genuine impossibility, while choosing known casualties cannot evade an earlier answer.',
+ 'A printed Amal clause names the original phase opening and counts a future printed play after this answer. The actual available-spice reduction occurs before ordinary payments become possible; known Mentat bribe receipts arrive afterward. Definite answers reopen your own opening pass. Own cost or acceptance must retain a completion, while a foreign Amal can release genuine impossibility before another player takes the last pass. Later openings keep separate intervals.',
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
'Transport and Hajr close at your actual Movement end. Ghola or a payment alternative may remain after that close, through the full current turn including Mentat. A known Inflation Double opening closes payments earlier without closing Ghola. Complete a required payment before its actual opportunity closes; No payment caps remain binding until then.',
@@ -3240,11 +3241,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse printed Thumper with known-encounter quotation, Karama shipment pricing and current-lot costs, original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse phase-opening Amal with native payment order, printed Thumper with known-encounter quotation, Karama shipment pricing and current-lot costs, original-window plays, native power declarations, printed Hajr/Ghola, battle, Traitor, final-alliance, bid, Revival, payment and transport constraints with separate actual intervals, resources and deadlines.', evidence: ['game/window-card-play.ts', 'game/battle-power-question.ts', 'game/turn-action-promises.ts', 'game/printed-card-play.ts', 'game/movement-promise-search.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides, including printed Hajr/Ghola and original power declarations; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-thumper-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources, including physical printed plays, original Storm/blow sources, shared Revival resources and native power declarations; broader composition and final acceptance remain.', evidence: ['tests/window-card-play.test.ts', 'tests/battle-power-question.test.ts', 'tests/printed-card-play.test.ts', 'tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts', 'tests/fixture-truth-turn-preparation.ts', 'tests/truth-turn-preparation-runtime.test.ts', 'tests/fixture-truth-turn-alliance.ts', 'tests/truth-turn-alliance-runtime.test.ts', 'tests/fixture-truth-turn-combat.ts', 'tests/truth-turn-combat-runtime.test.ts', 'tests/fixture-truth-turn-card-play.ts', 'tests/truth-turn-card-play-runtime.test.ts', 'tests/fixture-truth-turn-power.ts', 'tests/truth-turn-power-runtime.test.ts', 'tests/fixture-truth-turn-window-card.ts', 'tests/truth-turn-window-card-runtime.test.ts', 'tests/fixture-truth-turn-karama.ts', 'tests/truth-turn-karama-runtime.test.ts', 'tests/truth-turn-karama-forecast.test.ts', 'tests/fixture-truth-turn-karama-shipment.ts', 'tests/truth-turn-karama-shipment-runtime.test.ts', 'tests/truth-turn-karama-shipment-funding.test.ts', 'tests/fixture-truth-turn-thumper.ts', 'tests/truth-turn-thumper-runtime.test.ts', 'tests/fixture-truth-turn-amal.ts', 'tests/truth-turn-amal-runtime.test.ts'] },
],
},
{
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index faa86a7f..ecb2330d 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -381,7 +381,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
if (v.kind === 'turnActions') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
bribePromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 8 &&
- !g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
+ !g.response && !g.decision && (!g.phaseOpening || !!windowCardOpportunity(g, 'amal')) && !g.pendingTreacheryDiscard && !g.pendingKarama,
'Supply the current turn, complete turn-action question and a supported quiet native window.');
const respondent = g.players.find(player => player.id === v.target)!;
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
@@ -741,6 +741,7 @@ export function truthQuestionText(
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => {
if (action.kind === 'windowCardPlay') {
+ if (action.effect === 'amal') return 'you play Amal after this answer during this original phase opening, halving available spice before ordinary payments';
if (action.effect === 'karamaAuction') return 'you spend a printed Karama after this answer on this original ordinary auction lot, by immediate purchase or its winning invoice';
if (action.effect === 'karamaShipment') {
const recipient = parseKaramaShipmentSource(action.source)?.recipient;
diff --git a/game/turn-action-promises.ts b/game/turn-action-promises.ts
index 686e5dce..084674f6 100644
--- a/game/turn-action-promises.ts
+++ b/game/turn-action-promises.ts
@@ -145,7 +145,7 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
try {
if (value.kind === 'windowCardPlay') {
check(keys(value, 'effect,kind,source') && windowEffect(value.effect) && identifier(value.source),
- 'Choose one original Storm, first-reveal, fresh-blow, ordinary-lot or shipment-price printed-card play after this answer.');
+ 'Choose one original phase-opening, Storm, first-reveal, fresh-blow, ordinary-lot or shipment-price printed-card play after this answer.');
return { kind: 'windowCardPlay', effect: value.effect, source: value.source };
}
if (value.kind === 'battlePower') {
diff --git a/game/window-card-play.ts b/game/window-card-play.ts
index 74fe47b2..0bf541aa 100644
--- a/game/window-card-play.ts
+++ b/game/window-card-play.ts
@@ -1,8 +1,8 @@
import { harvesterAvailable } from './cards';
-export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper'] as const;
+export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper', 'amal'] as const;
export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
-export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' | 'thumper' {
+export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' | 'thumper' | 'amal' {
return effect === 'karamaAuction' || effect === 'karamaShipment' ? 'karama' : effect;
}
export type KaramaShipmentOpportunity = { effect: 'karamaShipment'; source: string; turn: number; recipient: string };
@@ -18,6 +18,7 @@ export type WindowCardGame = {
stormCardWindowOpen?: boolean;
stormResolution?: unknown;
thumperWindowOpen?: boolean;
+ amalWindowOpen?: boolean;
spiceSequence?: unknown;
spiceResolution?: unknown;
nexus?: unknown;
@@ -123,6 +124,16 @@ export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmen
* force conditions and chosen effect parameters remain native engine rules. */
function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null;
+ if (effect === 'amal') {
+ if (!whole(game.phase) || game.phase > 8 || game.amalWindowOpen !== undefined && typeof game.amalWindowOpen !== 'boolean' ||
+ game.amalWindowOpen === false) return null;
+ if (game.amalWindowOpen === undefined) {
+ const opening = game.phaseOpening;
+ if (!object(opening) || !Array.isArray(opening.passed) || !opening.passed.every(identifier) ||
+ new Set(opening.passed).size !== opening.passed.length) return null;
+ }
+ return { effect, source: `phase-opening:${game.turn}:${game.phase}`, turn: game.turn };
+ }
if (effect === 'thumper') {
if (game.phase !== 1 || game.thumperWindowOpen !== undefined && typeof game.thumperWindowOpen !== 'boolean' ||
game.thumperWindowOpen === false || game.thumperWindowOpen === undefined &&
diff --git a/tests/amal.test.ts b/tests/amal.test.ts
index 2879b083..2050b12c 100644
--- a/tests/amal.test.ts
+++ b/tests/amal.test.ts
@@ -172,6 +172,7 @@ void test('zero/one spice become zero and charity remains available after the op
const before = fixture();
before.phase = 2;
before.phaseOpening = { passed: [], initialize: true };
+ before.amalWindowOpen = true;
before.players[0].spice = 0;
before.players[1].spice = 1;
let g = ready(play(before));
diff --git a/tests/fixture-truth-turn-amal.ts b/tests/fixture-truth-turn-amal.ts
new file mode 100644
index 00000000..d399d529
--- /dev/null
+++ b/tests/fixture-truth-turn-amal.ts
@@ -0,0 +1,163 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { treacheryDeck } from '../game/cards';
+import type { MixedActionExpression } from '../game/mixed-action-question';
+import { traitorDeck } from '../game/traitors';
+import type { TruthFact, TruthQuestion } from '../game/truthtrance';
+import { windowCardOpportunity } from '../game/window-card-play';
+import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
+import { actTruthTurnThumper, nextTruthTurnThumperNative, truthTurnThumperInventory,
+ truthTurnThumperPlayer, truthTurnThumperSpice, truthTurnThumperTraitors,
+ type TruthTurnThumperLeaf, type TruthTurnThumperStep } from './fixture-truth-turn-thumper';
+
+export type TruthTurnAmalLeaf = TruthTurnThumperLeaf | { kind: 'windowCardPlay'; effect: 'amal'; source: string };
+export type TruthTurnAmalExpression = MixedActionExpression;
+export type TruthTurnAmalStep = TruthTurnThumperStep;
+export const actTruthTurnAmal = actTruthTurnThumper;
+export const truthTurnAmalPlayer = truthTurnThumperPlayer;
+export const truthTurnAmalInventory = truthTurnThumperInventory;
+export const truthTurnAmalTraitors = truthTurnThumperTraitors;
+export const truthTurnAmalSpice = truthTurnThumperSpice;
+/** Quiet original opening remains available; only temporary interactions stop. */
+export const truthTurnAmalQuiet = (game: Game) => !game.response && !game.decision && !game.truthtrance &&
+ !game.pendingTreacheryDiscard && !game.pendingRevival && !game.pendingShipment;
+export const truthTurnAmalClean = (game: Game) => truthTurnAmalQuiet(game) && !game.phaseOpening;
+export const truthTurnAmalFact = (fact: TruthFact): TruthTurnAmalExpression => ({ kind: 'fact', fact });
+export const truthTurnAmalPlay = (source: string): TruthTurnAmalExpression =>
+ ({ kind: 'action', action: { kind: 'windowCardPlay', effect: 'amal', source } });
+export const truthTurnAmalPayment = (recipient: string, minimum = 5): TruthTurnAmalExpression =>
+ ({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
+export const truthTurnAmalGroup = (kind: 'and' | 'or', ...terms: TruthTurnAmalExpression[]): TruthTurnAmalExpression => ({ kind, terms });
+export const nextTruthTurnAmalNative = nextTruthTurnThumperNative;
+export function nextTruthTurnAmal(game: Game): TruthTurnAmalStep | null {
+ if (game.truthtrance) return null;
+ for (const player of game.players) {
+ const guide = viewGame(game, player.id).turnActionGuide;
+ if (guide) return { actor: player.id, action: guide.action };
+ }
+ return nextTruthTurnAmalNative(game);
+}
+export function advanceTruthTurnAmal(game: Game, until: (state: Game) => boolean,
+ actions: TruthTurnAmalStep[] = [], nativeOnly = false): Game {
+ for (let count = 0; count < 1900; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Amal source ended before its native boundary');
+ const step = nativeOnly ? nextTruthTurnAmalNative(game) : nextTruthTurnAmal(game);
+ assert.ok(step, 'Supply the actual Truthtrance question or private Battle Plan');
+ game = actTruthTurnAmal(game, step.actor, step.action, actions);
+ }
+ throw Error('The genuine Amal chronology did not reach its finite boundary');
+}
+export function settleTruthTurnAmal(game: Game, actions: TruthTurnAmalStep[] = []): Game {
+ return advanceTruthTurnAmal(game, truthTurnAmalQuiet, actions, true);
+}
+
+/** Original public selected-Ix H/A/E. Only the actual unopened deal and safe
+ * unopened Spice prefix are selected. Every requested opening is opened by
+ * real setup, dials, passes, readiness and original movement endings. */
+export function createTruthTurnAmalFixture(options: { phase?: 0 | 1 | 8; advanced?: boolean; incoming?: 0 | 4 } = {}) {
+ const phase = options.phase ?? 0, advanced = options.advanced ?? false, incoming = options.incoming ?? 0;
+ assert.ok(!incoming || phase === 8, 'The real incoming bribe is earned in the preceding phase7');
+ const asker = 'harkonnen', observer = 'atreides', target = 'emperor', recipient = asker;
+ let game = createGame('TRUTHTURNAMAL', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced, ['ix']);
+ joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(target, 'Emperor', 'emperor'));
+ const initial = structuredClone(game), actions: TruthTurnAmalStep[] = [];
+ const act = (actor: string, action: Action) => { game = actTruthTurnAmal(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceTruthTurnAmal(game, until, actions, true); };
+ 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 originalCards = truthTurnAmalInventory(game), originalTraitors = truthTurnAmalTraitors(game), originalSpice = truthTurnAmalSpice(game);
+ assert.deepEqual(originalCards, treacheryDeck(['ix']).map(card => card.id).sort());
+ assert.deepEqual(originalTraitors, traitorDeck(game.players, true).sort());
+ const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
+ const amal = game.deck.find(card => card.effect === 'amal'), filler = game.deck.find(card => card.name === 'Baliset'); assert.ok(amal && filler);
+ const deal = [...truths.map(card => card.id), filler.id, amal.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.equal(game.phase, 0); assert.ok(game.phaseOpening);
+ let spiceOrder: ReturnType = [];
+ let collection: { before: Game; steps: TruthTurnAmalStep[]; after: Game } | null = null;
+ let incomingReceipt: { before: Game; steps: TruthTurnAmalStep[]; after: Game } | null = null;
+ if (phase === 1) reach(state => state.phase === 1 && !!state.phaseOpening && truthTurnAmalQuiet(state));
+ if (phase === 8) {
+ reach(beforeIxSpiceDraw);
+ spiceOrder = orderIxSpiceCards(game, [{ territory: 'hagga_basin' }, ...(advanced ? [{ territory: 'broken_land' }] : [])]);
+ reach(state => state.phase === 7 && !!state.phaseOpening && truthTurnAmalQuiet(state));
+ const beforeCollection = structuredClone(game), collectAt = actions.length;
+ reach(state => state.phase === 7 && truthTurnAmalClean(state));
+ collection = { before: beforeCollection, steps: structuredClone(actions.slice(collectAt)), after: structuredClone(game) };
+ if (incoming) {
+ const before = structuredClone(game), from = actions.length;
+ act(asker, { type: 'bribe', target, amount: incoming });
+ incomingReceipt = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
+ assert.equal(truthTurnAmalPlayer(game, target).bribes, incoming);
+ }
+ reach(state => state.phase === 8 && !!state.phaseOpening && truthTurnAmalQuiet(state));
+ }
+ assert.equal(game.turn, 1); assert.equal(game.phase, phase); assert.ok(game.phaseOpening);
+ assert.deepEqual(game.phaseOpening.passed, []); assert.equal(truthTurnAmalPlayer(game, target).spice, 10);
+ assert.equal(truthTurnAmalPlayer(game, target).bribes, incoming);
+ assert.deepEqual(truthTurnAmalPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
+ assert.deepEqual(truthTurnAmalPlayer(game, target).hand.map(card => card.id), [amal.id]);
+ assert.deepEqual(truthTurnAmalInventory(game), originalCards); assert.deepEqual(truthTurnAmalTraitors(game), originalTraitors);
+ assert.deepEqual(truthTurnAmalSpice(game), originalSpice);
+ const opportunity = windowCardOpportunity(game, 'amal'); assert.ok(opportunity);
+ const source = opportunity.source; assert.equal(source, `phase-opening:1:${phase}`);
+ const mixed = truthTurnAmalGroup('and', truthTurnAmalPlay(source), truthTurnAmalPayment(recipient));
+ const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
+ const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnAmalPlayer(game, target).spice };
+ return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, asker, target, recipient, observer,
+ phase, advanced, incoming, collection, incomingReceipt, truths: truths.map(card => card.id), truth: truths[0].id,
+ card: amal.id, amal: amal.id, effect: 'amal' as const, source, opportunity, mixed, question, spiceFact,
+ nativeAction: { type: 'card', card: amal.id } satisfies Action,
+ payment: { type: 'bribe', target: recipient, amount: 5 } satisfies Action,
+ dealOrder, spiceOrder, originalCards, originalTraitors, originalSpice,
+ actions, sourceActions: actions, sourceActionCount: actions.length,
+ sourceCounts: { selected: actions.length, originalSetup: actions.findLastIndex(step => step.action.type === 'traitor') + 1,
+ actualCollection: collection?.steps.length ?? 0, actualIncomingPayment: incomingReceipt?.steps.length ?? 0 },
+ sourceDisclosure: 'Actual public Harkonnen/Atreides/Emperor selected-Ix setup: H receives both original Truthtrances, A Baliset and E original Amal through the unopened native deal. ' +
+ `Only original unopened deal/Spice positions and scalar lotteries are selected. Real setup and original native controls open turn1 phase${phase}; other original openings are passed without Amal. ` +
+ (phase === 8 ? advanced
+ ? 'Original Harkonnen Carthag and Atreides Arrakeen each actually earn their Advanced two-spice city income during Collection; Emperor has no city income. '
+ : 'Actual Basic Collection gives no automatic city income, and no original army occupies the selected Spice blow. '
+ : '') +
+ (incoming ? 'H actually pays E four spice during quiet phase7; that known receipt stays queued through the original Mentat opening until actual initialization. ' : '') +
+ 'Early-opening funding examples use owned current resources; absolute post-halving limits use the original Mentat opening after Charity and Bidding. ' +
+ 'Owner cash is the original ten before the requested opening. No hand, wallet, ready/pass list, phase, turn, body, income, halving or promise is installed. All counts derive from actual action arrays.',
+ };
+}
+export type TruthTurnAmalFixture = ReturnType;
+export function declareTruthTurnAmal(f: TruthTurnAmalFixture, source = f.game, copy: 0 | 1 = 0, actions: TruthTurnAmalStep[] = []) {
+ let game = actTruthTurnAmal(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
+ while (game.truthtrance?.stage === 'priority') {
+ const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
+ game = actTruthTurnAmal(game, actor.id, { type: 'truthPass' }, actions);
+ }
+ assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
+}
+export function askTruthTurnAmal(game: Game, asker: string, target: string, mixed: TruthTurnAmalExpression, actions: TruthTurnAmalStep[] = []): Game {
+ return actTruthTurnAmal(game, asker, { type: 'truthAsk', question: { kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed) } }, actions);
+}
+export function answerTruthTurnAmal(game: Game, target: string, answer: 'yes' | 'no' | 'unknown', actions: TruthTurnAmalStep[] = []): Game {
+ return actTruthTurnAmal(game, target, { type: 'truthAnswer', answer }, actions);
+}
+export function bindTruthTurnAmal(f: TruthTurnAmalFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
+ source = f.game, copy: 0 | 1 = 0, actions: TruthTurnAmalStep[] = []) {
+ const declared = declareTruthTurnAmal(f, source, copy, actions), asked = askTruthTurnAmal(declared.game, f.asker, f.target, mixed, actions);
+ return { game: answerTruthTurnAmal(asked, f.target, answer, actions), declared, asked, actions };
+}
+export function truthTurnAmalPromise(game: Game, target = game.turnActionPromises![0].player) {
+ const row = viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); assert.ok(row); return row;
+}
+export function closeTruthTurnAmal(game: Game, f: Pick, actions: TruthTurnAmalStep[] = []): Game {
+ return advanceTruthTurnAmal(game, state => truthTurnAmalQuiet(state) && !windowCardOpportunity(state, 'amal', f.source), actions, true);
+}
+export function finishTruthTurnAmal(game: Game, actions: TruthTurnAmalStep[] = []): Game {
+ const turn = game.turn;
+ return advanceTruthTurnAmal(game, state => state.turn === turn + 1 && state.phase === 0 && !!state.phaseOpening && truthTurnAmalQuiet(state), actions);
+}
diff --git a/tests/truth-turn-action-controls.test.tsx b/tests/truth-turn-action-controls.test.tsx
index 1911e829..47d4e8fd 100644
--- a/tests/truth-turn-action-controls.test.tsx
+++ b/tests/truth-turn-action-controls.test.tsx
@@ -36,7 +36,7 @@ const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringif
...(cunning ? ['cunning', cunning] : ['original'])]);
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : effect === 'karamaShipment' ? shipmentPriceSource()
- : effect === 'thumper' ? 'first-spice-reveal:2' : 'storm:2'): Expression =>
+ : effect === 'thumper' ? 'first-spice-reveal:2' : effect === 'amal' ? 'phase-opening:2:4' : 'storm:2'): Expression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
@@ -86,7 +86,7 @@ function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
game.truthTurnActionAnswers = null;
if (next.type === 'card' && typeof next.card === 'string') {
- const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester', 'thumper'] as const)
+ const cardId = next.card, effect = (['hajr', 'ghola', 'weather', 'atomics', 'harvester', 'thumper', 'amal'] as const)
.find(effect => cardId.includes(effect)) ?? 'karama';
const face = [...baseDeck(), ...ixSpecialCards()].find(card => card.effect === effect); assert.ok(face);
const me = game.players.find(player => player.id === 'p')!;
@@ -131,6 +131,12 @@ function firstReveal(next: Action = { type: 'card', card: 'controlled-thumper' }
game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('thumper'), payment()] };
return game;
}
+function openingAmal(phase = 4, next: Action = { type: 'card', card: 'controlled-amal' }): GameView {
+ const game = projected(phase, next); game.amalWindowOpen = true; game.phaseOpening = { passed: [] };
+ game.amalCards = ['controlled-amal']; game.amalReady = true; game.active = 'a'; game.ready = ['p'];
+ game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('amal', `phase-opening:2:${phase}`), payment()] };
+ return game;
+}
function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
const game = projected(1, next);
game.nexus = true; game.spiceWindow = null; game.active = 'a';
@@ -1135,6 +1141,108 @@ void test('Thumper first-reveal questions retain the quoted phase-one Box route
game.thumperWindowOpen = false; press(button); assert.equal(sent.length, 1);
});
+void test('Amal editor uses the public original phase opening and preserves repairable sixteen-leaf four-level expressions', () => {
+ const game = openingAmal(), e = editor(physical(), false, game);
+ for (const player of game.players) for (const key of ['hand', 'spice', 'forces', 'leaders', 'traitors'])
+ Object.defineProperty(player, key, { get() { throw Error(`No private Amal authoring ${key} read`); }, configurable: true });
+ for (const key of ['amalCards', 'amalReady']) Object.defineProperty(game, key, { get() { throw Error(`No native Amal authoring ${key} quote read`); } });
+ select(e.tree(), '1', 'action', 'windowCardPlay'); assert.deepEqual(e.value, windowClaim('amal')); assert.equal(e.error(), null);
+ assert.match(e.html(), /Original Revival opening/); assert.match(e.html(), /Amal halves available cash before ordinary payments become legal/);
+ game.phaseOpening!.passed.push('p'); assert.equal(e.error(), null, 'The public source is separate from individual native eligibility.');
+ game.phaseOpening = null; assert.equal(e.error(), null, 'The explicit original source survives temporary controls.');
+ select(e.tree(), '1', 'window-source', ''); assert.ok(e.error()); select(e.tree(), '1', 'window-source', 'phase-opening:2:4'); assert.equal(e.error(), null);
+ game.phase = 0; assert.ok(e.error()); select(e.tree(), '1', 'window-source', 'phase-opening:2:0'); assert.equal(e.error(), null);
+ game.amalWindowOpen = false; assert.ok(e.error()); assert.match(e.html(), /no current original open native window/);
+ const fullGame = openingAmal(), full = editor({ kind: 'or', terms: [windowClaim('amal'), fact()] }, false, fullGame);
+ for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'windowCardPlay'); }
+ assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
+ const deep = editor({ kind: 'and', terms: [fact(), windowClaim('amal')] }, false, fullGame);
+ click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
+ assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
+});
+
+void test('Amal play and Ready guides use exact native packets and opening passes rather than ordinary readiness', () => {
+ for (const phase of [0, 4]) for (const ready of [false, true]) {
+ const game = openingAmal(phase, ready ? { type: 'ready' } : { type: 'card', card: 'controlled-amal' }), sent: Action[] = [];
+ for (const key of ['windowCards', 'compiled', 'preparation'])
+ Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private Amal ${key} read`); }, configurable: true });
+ if (ready) { game.amalCards = []; game.players.find(player => player.id === 'p')!.hand = []; }
+ assert.ok(game.ready.includes('p')); assert.ok(!game.phaseOpening!.passed.includes('p'));
+ const button = guide({ game, busy: false, act(action) { sent.push(action); } });
+ assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
+ assert.equal(sent[0].turnActionPromiseEvent, event); assert.equal(sent[0].turnActionPromisePhase, phase);
+ if (ready) assert.match(renderToStaticMarkup(button), /Pass this original phase opening/);
+ else assert.equal(sent[0].card, 'controlled-amal');
+ game.turnActionGuide = null;
+ const html = markup(game); assert.match(html, /condition closes with this opening/);
+ assert.match(html, /Amal halves available cash before ordinary payments become legal/); assert.doesNotMatch(html, /windowCards|controlled-amal/);
+ }
+});
+
+void test('saved Amal callbacks retain opening identity, passed list, original source, owner quotes and physical custody', () => {
+ for (const ready of [false, true]) for (const change of [
+ (game: GameView) => { game.phaseOpening = { passed: [] }; },
+ (game: GameView) => { game.phaseOpening!.passed.push('a'); },
+ (game: GameView) => { game.phaseOpening!.passed.push('p'); },
+ (game: GameView) => { game.phaseOpening = null; game.ready = []; },
+ (game: GameView) => { game.amalWindowOpen = false; },
+ (game: GameView) => { game.turnActionPromises[0].mixed = windowClaim('amal', 'phase-opening:2:3'); },
+ (game: GameView) => { game.response = { kind: 'voice', owner: 'a', passed: [] }; },
+ ]) {
+ const game = openingAmal(4, ready ? { type: 'ready' } : { type: 'card', card: 'controlled-amal' });
+ const saved = guide({ game, busy: false, act() { assert.fail('An obsolete original-opening action cannot send'); } });
+ assert.equal(saved.props.disabled, false); change(game); press(saved);
+ }
+ const quoted = openingAmal(), saved = guide({ game: quoted, busy: false, act() { assert.fail('A removed physical Amal quote cannot send'); } });
+ quoted.amalCards = []; press(saved);
+ const cardGame = openingAmal(), cardButton = guide({ game: cardGame, busy: false, act() { assert.fail('A changed physical Amal cannot send'); } });
+ const card = cardGame.players.find(player => player.id === 'p')!.hand?.[0]; assert.ok(card); card.effect = 'ghola'; press(cardButton);
+ const readyGame = openingAmal(4, { type: 'ready' }), readyButton = guide({ game: readyGame, busy: false, act() { assert.fail('A removed native Amal Ready quote cannot send'); } });
+ readyGame.amalReady = false; press(readyButton);
+});
+
+void test('Amal opening exception rejects extra fields, unquoted actions, Box and generic card fallback', () => {
+ for (const ready of [false, true]) for (const extra of [{ amount: 0 }, { mode: 'shipment' }, { target: 'p' }, { event: 'literal-event' }, { phase: 4 }]) {
+ const game = openingAmal(4, ready ? { type: 'ready' } : { type: 'card', card: 'controlled-amal' });
+ Object.assign(game.turnActionGuide!.action, extra);
+ const button = guide({ game, busy: false, act() { assert.fail('Only the original native Amal packet and existing envelope may send'); } });
+ assert.equal(button.props.disabled, true); press(button);
+ }
+ const unquoted = openingAmal(); unquoted.amalCards = [];
+ assert.equal(guide({ game: unquoted, busy: false, act() { assert.fail('The actual Amal card must be natively quoted'); } }).props.disabled, true);
+ const unavailableReady = openingAmal(4, { type: 'ready' }); unavailableReady.amalReady = false; unavailableReady.ready = [];
+ assert.equal(guide({ game: unavailableReady, busy: false, act() { assert.fail('Ordinary Ready cannot bypass an unavailable opening Ready'); } }).props.disabled, true);
+ const outside = openingAmal(); outside.phaseOpening = null;
+ assert.equal(guide({ game: outside, busy: false, act() { assert.fail('An explicit source alone does not create a native Amal action'); } }).props.disabled, true);
+ const other = openingAmal(5, { type: 'card', card: 'controlled-owned-karama' }); other.ready = []; other.active = 'p';
+ assert.equal(guide({ game: other, busy: false, act() { assert.fail('Other native cards keep the phase-opening veto'); } }).props.disabled, true);
+ const boxed = openingAmal(0), me = boxed.players.find(player => player.id === 'p')!;
+ const box = { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' };
+ me.hand = [box]; boxed.nullentropy = { card: box, blocked: null, search: null };
+ boxed.turnActionGuide!.action = { type: 'card', card: box.id, turnActionPromiseEvent: event, turnActionPromisePhase: 0 };
+ assert.equal(guide({ game: boxed, busy: false, act() { assert.fail('A quoted Box cannot bypass the native opening priority'); } }).props.disabled, true);
+});
+
+void test('native turn-action answers work in an unchanged quoted opening and reject changed passed or modal sources', () => {
+ for (const mixed of [windowClaim('amal'), claim()]) {
+ const game = answering(4); game.phaseOpening = { passed: ['a'] }; game.amalWindowOpen = true;
+ game.truthtrance!.question = { kind: 'turnActions', target: 'p', turn: 2, mixed };
+ const sent: Action[] = [], tree = TurnActionAnswers({ game, busy: false, act(action) { sent.push(action); } });
+ const button = find(tree, 'data-turn-action-answer', 'yes'); assert.equal(button.props.disabled, false); click(button);
+ assert.deepEqual(sent, [{ type: 'truthAnswer', answer: 'yes' }]);
+ game.phaseOpening.passed.push('r'); click(button); assert.equal(sent.length, 1);
+ }
+ for (const change of [
+ (game: GameView) => { game.phaseOpening = { passed: [] }; },
+ (game: GameView) => { game.phaseOpening = null; },
+ (game: GameView) => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
+ ]) {
+ const game = answering(4); game.phaseOpening = { passed: [] };
+ const button = find(TurnActionAnswers({ game, busy: false, act() { assert.fail('A changed opening answer source cannot send'); } }), 'data-turn-action-answer', 'yes');
+ assert.equal(button.props.disabled, false); change(game); click(button);
+ }
+});
+
void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
const game = projected();
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
diff --git a/tests/truth-turn-amal-runtime.test.ts b/tests/truth-turn-amal-runtime.test.ts
new file mode 100644
index 00000000..d666dd19
--- /dev/null
+++ b/tests/truth-turn-amal-runtime.test.ts
@@ -0,0 +1,289 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { spiceDeck, treacheryDeck } from '../game/cards';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors';
+import { truthFactAnswer } from '../game/truthtrance';
+import { windowCardOpportunity } from '../game/window-card-play';
+import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
+import {
+ actTruthTurnAmal as act, advanceTruthTurnAmal as advance, answerTruthTurnAmal as answer,
+ askTruthTurnAmal as ask, bindTruthTurnAmal as bind, closeTruthTurnAmal as close,
+ createTruthTurnAmalFixture as fixture, declareTruthTurnAmal as declare, finishTruthTurnAmal as finish,
+ nextTruthTurnAmalNative as nativeStep, settleTruthTurnAmal as settle,
+ truthTurnAmalFact as fact, truthTurnAmalGroup as group, truthTurnAmalInventory as inventory,
+ truthTurnAmalPayment as payment, truthTurnAmalPlay as play, truthTurnAmalPlayer as player,
+ truthTurnAmalPromise as promise, truthTurnAmalQuiet as quiet, truthTurnAmalSpice as spice,
+ truthTurnAmalTraitors as traitors, type TruthTurnAmalFixture, type TruthTurnAmalStep,
+} from './fixture-truth-turn-amal';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
+}
+function physical(game: Game, f: TruthTurnAmalFixture, copies: 1 | 2 = 1) {
+ for (const truth of f.truths.slice(0, copies)) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
+ assert.deepEqual(spice(game), f.originalSpice);
+}
+function receipt(game: Game, index = 0) {
+ const stage = game.turnActionPromises![index].windowCards?.amal; assert.ok(stage); return stage;
+}
+
+void test('native Amal sources retain original selected-Ix inventories and real first Storm, Spice and Mentat openings', () => {
+ for (const advanced of [false, true]) for (const phase of [0, 1, 8] as const) {
+ const f = fixture({ phase, advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
+ assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
+ assert.deepEqual(f.originalCards, treacheryDeck(['ix']).map(card => card.id).sort());
+ assert.deepEqual(f.originalTraitors, traitorDeck(f.setup.players, true).sort()); assert.ok(f.originalTraitors.includes(CHEAP_HERO_TRAITOR));
+ assert.equal(f.originalCards.length, 47); assert.equal(f.originalSpice.length, spiceDeck(true).length);
+ assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
+ assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.amal]);
+ assert.equal(f.game.phase, phase); assert.equal(f.game.turn, 1); assert.ok(f.game.phaseOpening);
+ assert.deepEqual(f.game.phaseOpening.passed, []);
+ const collectionIncome = f.game.players.map(candidate => f.collection
+ ? player(f.collection.after, candidate.id).spice - player(f.collection.before, candidate.id).spice : 0);
+ assert.deepEqual(f.game.players.map(candidate => candidate.spice), f.afterSetup.players.map((candidate, index) => candidate.spice + collectionIncome[index]));
+ if (phase === 8) {
+ assert.ok(f.collection); assert.equal(f.collection.before.phase, 7); assert.ok(f.collection.before.phaseOpening);
+ assert.equal(f.collection.after.phase, 7); assert.equal(f.collection.after.phaseOpening, null);
+ assert.deepEqual(collectionIncome, advanced ? [2, 2, 0] : [0, 0, 0]);
+ assert.equal(f.sourceCounts.actualCollection, f.collection.steps.length);
+ assert.ok(f.collection.steps.some(step => step.action.type === 'ready'));
+ assert.equal(player(f.collection.before, f.asker).forces['carthag:11'], 10);
+ assert.equal(player(f.collection.before, f.observer).forces['arrakeen:10'], 10);
+ }
+ assert.equal(f.source, `phase-opening:1:${phase}`); assert.deepEqual(windowCardOpportunity(f.game, 'amal'), f.opportunity);
+ assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActions.length);
+ assert.ok(f.sourceCounts.originalSetup <= f.sourceActionCount);
+ if (phase > 0) assert.ok(f.sourceActions.some(step => step.action.type === 'ready'));
+ assert.deepEqual(viewGame(f.game, f.target).amalCards, [f.amal]); assert.equal(viewGame(f.game, f.target).amalReady, true);
+ assert.deepEqual(viewGame(f.game, f.asker).amalCards, []); assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
+ assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
+ assert.deepEqual(spice(f.game), f.originalSpice);
+ }
+});
+
+void test('native Amal AND payment quotes the real halving before payment and freezes the original spice fact', () => {
+ for (const advanced of [false, true]) for (const phase of [0, 1] as const) {
+ const f = fixture({ phase, advanced });
+ const mixed = group('and', fact(f.spiceFact), play(f.source), payment(f.recipient));
+ let game = bind(f, 'yes', mixed).game; rejected(game, f.target, f.payment);
+ const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.amal);
+ game = settle(act(game, f.target, guide.action));
+ assert.deepEqual(game.players.map(candidate => candidate.spice), [5, 5, 5]); assert.ok(game.phaseOpening);
+ assert.deepEqual(game.phaseOpening.passed, []); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
+ assert.equal(receipt(game).played, true); assert.ok(!promise(game, f.target).fulfilled); rejected(game, f.target, f.payment);
+ game = close(game, f); game = act(game, f.target, f.payment);
+ assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 5);
+ assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
+ assert.equal(game.discard.filter(card => card.id === f.amal).length, 1); physical(game, f);
+ }
+ // Absolute five-versus-six funding uses the closing Mentat opening. Early
+ // examples above prove an owned-current-purse path, not all future income.
+ const closing = fixture({ phase: 8 }), impossible = group('and', play(closing.source), payment(closing.recipient, 6));
+ const asked = ask(declare(closing).game, closing.asker, closing.target, impossible);
+ assert.deepEqual(viewGame(asked, closing.target).truthTurnActionAnswers, ['no']);
+ rejected(asked, closing.target, { type: 'truthAnswer', answer: 'yes' });
+ let funded = bind(closing).game; funded = settle(act(funded, closing.target, closing.nativeAction));
+ funded = close(funded, closing); funded = act(funded, closing.target, closing.payment);
+ assert.equal(player(funded, closing.target).spice, 0); assert.equal(promise(funded, closing.target).fulfilled, true); physical(funded, closing);
+});
+
+void test('whole Amal OR payment Yes preserves printed halving or an ordinary payment after passing without halving', () => {
+ const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
+ const bound = bind(f, 'yes', mixed).game;
+ const played = settle(act(bound, f.target, f.nativeAction));
+ assert.equal(promise(played, f.target).fulfilled, true); assert.equal(player(played, f.recipient).bribes, 0);
+ assert.equal(player(played, f.target).spice, 5);
+ let paid = act(bound, f.target, { type: 'ready' }); paid = close(paid, f);
+ assert.equal(player(paid, f.target).spice, 10); assert.equal(receipt(paid).played, false);
+ paid = act(paid, f.target, f.payment); assert.equal(player(paid, f.target).spice, 5);
+ assert.equal(promise(paid, f.target).fulfilled, true); assert.ok(player(paid, f.target).hand.some(card => card.id === f.amal));
+ physical(played, f); physical(paid, f);
+});
+
+void test('whole Amal AND payment No limits the real payment after cost but permits payment after the unplayed opening closes', () => {
+ const f = fixture(); let played = bind(f, 'no').game;
+ played = settle(act(played, f.target, f.nativeAction)); played = close(played, f);
+ rejected(played, f.target, f.payment);
+ played = act(played, f.target, { type: 'bribe', target: f.recipient, amount: 4 });
+ rejected(played, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
+ assert.equal(receipt(played).played, true); assert.ok(!promise(played, f.target).released);
+ played = finish(played); assert.equal(promise(played, f.target).fulfilled, true); physical(played, f);
+ let paid = close(bind(f, 'no').game, f); assert.equal(receipt(paid).played, false);
+ paid = act(paid, f.target, f.payment); assert.equal(player(paid, f.target).spice, 5);
+ paid = finish(paid); assert.equal(promise(paid, f.target).fulfilled, true); physical(paid, f);
+});
+
+void test('whole Amal OR payment No preserves its cumulative payment cap through the original opening and current turn', () => {
+ const f = fixture(), mixed = group('or', play(f.source), payment(f.recipient));
+ let game = bind(f, 'no', mixed).game; rejected(game, f.target, f.nativeAction); rejected(game, f.target, f.payment);
+ game = close(game, f); assert.deepEqual(receipt(game), { source: f.source, played: false, closed: true });
+ game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 4 });
+ rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
+ game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
+});
+
+void test('native own opening Ready cannot evade a required Amal while foreign passes remain legal and the actual play resets them', () => {
+ const f = fixture({ phase: 1 }); let game = bind(f, 'yes', play(f.source)).game;
+ const view = viewGame(game, f.target); assert.equal(view.amalReady, true);
+ assert.equal(view.turnActionGuide?.action.type, 'card'); assert.equal(view.turnActionGuide?.action.card, f.amal);
+ rejected(game, f.target, { type: 'ready' });
+ game = act(game, f.asker, { type: 'ready' }); game = act(game, f.observer, { type: 'ready' });
+ assert.ok(game.phaseOpening?.passed.includes(f.asker) && game.phaseOpening.passed.includes(f.observer));
+ rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.nativeAction));
+ assert.deepEqual(game.phaseOpening?.passed, []); assert.equal(promise(game, f.target).fulfilled, true);
+ game = close(game, f); assert.equal(game.phaseOpening, null); physical(game, f);
+});
+
+void test('definite Yes and No including constants reopen a real earlier own opening pass', () => {
+ const f = fixture(), passed = act(f.game, f.target, { type: 'ready' }); assert.ok(passed.phaseOpening?.passed.includes(f.target));
+ for (const [selected, mixed] of [
+ ['yes', play(f.source)], ['no', play(f.source)],
+ ['yes', group('or', fact(f.spiceFact), play(f.source))],
+ ['no', group('and', fact({ kind: 'spice', compare: 'gte', value: 100 }), play(f.source))],
+ ] as const) {
+ const game = bind(f, selected, mixed, passed).game;
+ assert.ok(game.phaseOpening); assert.equal(game.phaseOpening.passed.includes(f.target), false);
+ assert.equal(windowCardOpportunity(game, 'amal')?.source, f.source); physical(game, f);
+ }
+});
+
+void test('temporary Truthtrance priority and question stages preserve the original actual Amal opening without halving', () => {
+ const f = fixture({ phase: 1 }), declared = declare(f);
+ assert.ok(declared.game.phaseOpening); assert.equal(windowCardOpportunity(declared.game, 'amal')?.source, f.source);
+ const asked = ask(declared.game, f.asker, f.target, f.mixed);
+ assert.ok(asked.phaseOpening); assert.equal(windowCardOpportunity(asked, 'amal')?.source, f.source);
+ assert.deepEqual(asked.players.map(candidate => candidate.spice), [10, 10, 10]);
+ assert.equal(player(asked, f.target).hand.some(card => card.id === f.amal), true);
+ const game = answer(asked, f.target, 'yes'); assert.deepEqual(game.players.map(candidate => candidate.spice), [10, 10, 10]);
+ assert.equal(receipt(game).played, false); assert.deepEqual(game.phaseOpening?.passed, []); physical(game, f);
+});
+
+void test('a foreign actual Mentat Amal may release another respondent full-purse payment without vetoing the cost or last pass', () => {
+ const f = fixture({ phase: 8 }), amount = player(f.game, f.observer).spice, mixed = payment(f.recipient, amount);
+ const asked = ask(declare(f).game, f.asker, f.observer, mixed);
+ let game = answer(asked, f.observer, 'yes'); const history = structuredClone(game.truthHistory!.at(-1));
+ const purses = game.players.map(candidate => candidate.spice);
+ game = settle(act(game, f.target, f.nativeAction));
+ assert.deepEqual(game.players.map(candidate => candidate.spice), purses.map(value => Math.floor(value / 2)));
+ assert.equal(promise(game, f.observer).released, true); assert.ok(!promise(game, f.observer).fulfilled);
+ assert.deepEqual(game.truthHistory!.at(-1), history); assert.deepEqual(game.turnActionPromises![0].payments, []);
+ game = act(game, f.asker, { type: 'ready' }); game = act(game, f.target, { type: 'ready' });
+ game = act(game, f.observer, { type: 'ready' }); assert.equal(game.phaseOpening, null);
+ assert.equal(promise(game, f.observer).released, true);
+ assert.equal(game.discard.filter(card => card.id === f.amal).length, 1); physical(game, f);
+});
+
+void test('actual queued phase7 bribes arrive only after Mentat opening halving and fund nine rather than ten', () => {
+ const f = fixture({ phase: 8, incoming: 4 }); assert.ok(f.incomingReceipt);
+ assert.equal(f.sourceCounts.actualIncomingPayment, f.incomingReceipt.steps.length);
+ assert.ok(f.incomingReceipt.steps.some(step => step.actor === f.asker && step.action.type === 'bribe' && step.action.amount === 4));
+ assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.target).bribes, 4);
+ const impossible = group('and', play(f.source), payment(f.recipient, 10));
+ const asked = ask(declare(f).game, f.asker, f.target, impossible);
+ assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
+ const mixed = group('and', fact(f.spiceFact), play(f.source), payment(f.recipient, 9));
+ let game = bind(f, 'yes', mixed).game; game = settle(act(game, f.target, f.nativeAction));
+ assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.target).bribes, 4);
+ rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 9 });
+ game = close(game, f); assert.equal(player(game, f.target).spice, 9); assert.equal(player(game, f.target).bribes, 0);
+ const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'bribe'); assert.equal(guide.action.amount, 9); game = act(game, f.target, guide.action);
+ assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 9);
+ assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
+});
+
+void test('an original first-opening No cannot count or forbid the actual Amal promised by a second Truth in the next phase', () => {
+ const f = fixture(), actions: TruthTurnAmalStep[] = [];
+ let game = close(bind(f, 'no', play(f.source), f.game, 0, actions).game, f, actions);
+ const earlier = structuredClone(receipt(game)); assert.equal(promise(game, f.target).fulfilled, true);
+ assert.ok(player(game, f.target).hand.some(card => card.id === f.amal));
+ game = advance(game, state => state.phase === 1 && !!state.phaseOpening && quiet(state), actions, true);
+ const next = windowCardOpportunity(game, 'amal'); assert.ok(next); assert.notEqual(next.source, f.source);
+ game = bind(f, 'yes', play(next.source), game, 1, actions).game;
+ const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.event, game.turnActionPromises![1].event);
+ game = settle(act(game, f.target, guide.action, actions), actions);
+ assert.equal(game.turnActionPromises![1].fulfilled, true); assert.equal(receipt(game, 1).played, true);
+ assert.deepEqual(receipt(game), earlier); assert.equal(receipt(game).played, false);
+ assert.deepEqual(game.players.map(candidate => candidate.spice), [5, 5, 5]);
+ assert.equal(game.discard.filter(card => card.id === f.amal).length, 1); assert.ok(actions.length > f.sourceActionCount); physical(game, f, 2);
+});
+
+void test('exact Amal guides retain the original opening, physical owner and event while stale or late sources reject', () => {
+ const f = fixture({ phase: 1 }), declared = declare(f);
+ for (const source of ['phase-opening:1:0', 'phase-opening:2:1', `${f.source}:extra`])
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: play(source) } });
+ rejected(declared.game, f.target, { type: 'truthAsk', question: f.question });
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, mixed: { kind: 'action', action: {
+ kind: 'windowCardPlay', effect: 'amal', source: f.source, amount: 5,
+ } } } });
+ let game = bind(f, 'yes', play(f.source)).game;
+ const own = viewGame(game, f.target), guide = own.turnActionGuide; assert.ok(guide);
+ assert.deepEqual(Object.keys(guide.action).sort(), ['type', 'card', 'turnActionPromiseEvent', 'turnActionPromisePhase'].sort());
+ assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.amal);
+ assert.equal(guide.event, promise(game, f.target).event); assert.equal(guide.action.turnActionPromiseEvent, guide.event);
+ assert.equal(guide.action.turnActionPromisePhase, 1); assert.deepEqual(own.amalCards, [f.amal]); assert.equal(own.amalReady, true);
+ assert.deepEqual(viewGame(game, f.asker).amalCards, []);
+ assert.ok(Object.keys(promise(game, f.target)).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
+ rejected(game, f.target, { type: 'ready' }); rejected(game, f.asker, guide.action);
+ rejected(game, f.target, { ...guide.action, mode: 'special' });
+ rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-amal-event' });
+ rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 0 });
+ const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, packet));
+ rejected(game, f.target, packet); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
+ const closed = close(f.game, f); rejected(closed, f.target, f.nativeAction);
+ const after = declare(f, closed); rejected(after.game, f.asker, { type: 'truthAsk', question: f.question });
+});
+
+void test('an actual bidding opening quote leaves the unopened auction pool untouched until real global passes initialize it', () => {
+ const f = fixture({ phase: 1 }); let game = close(f.game, f); assert.equal(beforeIxSpiceDraw(game), true);
+ orderIxSpiceCards(game, [{ territory: 'hagga_basin' }]);
+ game = advance(game, state => state.phase === 3 && !!state.phaseOpening && quiet(state), [], true);
+ assert.equal(game.auction, null); const deck = game.deck.map(card => card.id), current = windowCardOpportunity(game, 'amal'); assert.ok(current);
+ const mixed = group('and', play(current.source), payment(f.recipient));
+ game = bind(f, 'yes', mixed, game).game;
+ assert.equal(game.auction, null); assert.deepEqual(game.deck.map(card => card.id), deck);
+ assert.equal(player(game, f.target).spice, 10);
+ game = settle(act(game, f.target, f.nativeAction)); assert.equal(game.auction, null); assert.deepEqual(game.deck.map(card => card.id), deck);
+ game = close(game, { source: current.source }); assert.ok(game.auction); assert.ok(game.deck.length < deck.length);
+ game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
+});
+
+void test('all four minimal profiles execute real Amal before payment and pass a No through native opening Ready', () => {
+ for (const phase of [0, 1] as const) {
+ const f = fixture({ phase }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
+ for (const difficulty of DIFFICULTIES) {
+ let game = structuredClone(asked); const actions: TruthTurnAmalStep[] = [];
+ const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
+ const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, difficulty);
+ assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
+ for (let count = 0; count < 22 && !promise(game, f.target).fulfilled; count++) {
+ const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
+ const guide = view.turnActionGuide;
+ if (guide) {
+ const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
+ assert.ok(action, `${phase}/${difficulty}`); assert.deepEqual(action, guide.action);
+ game = act(game, f.target, action, actions);
+ } else {
+ const next = nativeStep(game); assert.ok(next, `${phase}/${difficulty}`); game = act(game, next.actor, next.action, actions);
+ }
+ }
+ assert.equal(promise(game, f.target).fulfilled, true, `${phase}/${difficulty}`);
+ const costAt = actions.findIndex(step => step.action.type === 'card' && step.action.card === f.amal);
+ const payAt = actions.findIndex(step => step.actor === f.target && step.action.type === 'bribe');
+ assert.ok(costAt >= 0 && payAt > costAt); assert.equal(player(game, f.target).spice, 0); physical(game, f);
+ const no = bind(f, 'no', play(f.source)).game, view = viewGame(no, f.target);
+ view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
+ assert.equal(view.amalReady, true); assert.deepEqual(view.amalCards, [f.amal]); rejected(no, f.target, f.nativeAction);
+ const ready = botActions(view)[0]; assert.equal(ready.type, 'ready'); assert.deepEqual(ready, view.turnActionGuide?.action);
+ const passed = act(no, f.target, ready), passedView = viewGame(passed, f.target);
+ assert.equal(passedView.amalReady, false); assert.deepEqual(passedView.amalCards, []);
+ const completed = close(passed, f);
+ assert.equal(promise(completed, f.target).fulfilled, true); assert.equal(receipt(completed).played, false); physical(completed, f);
+ }
+ }
+});
diff --git a/tests/truthtrance.test.ts b/tests/truthtrance.test.ts
index 2098057a..075993da 100644
--- a/tests/truthtrance.test.ts
+++ b/tests/truthtrance.test.ts
@@ -20,12 +20,13 @@ import {
} from '../game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
-void test('whole card questions name Thumper and the actual printed Karama role and shipment beneficiary', () => {
- const question = (effect: 'thumper' | 'karamaAuction' | 'karamaShipment', source: string) =>
+void test('whole card questions name Amal, Thumper and the actual printed Karama role and shipment beneficiary', () => {
+ const question = (effect: 'amal' | 'thumper' | 'karamaAuction' | 'karamaShipment', source: string) =>
truthQuestionText({ kind: 'turnActions', target: 'emperor', turn: 2, mixed: { kind: 'action', action: {
kind: 'windowCardPlay', effect, source,
} } }, id => id, id => id, id => id === 'emperor' ? 'Emperor' : id);
assert.match(question('thumper', 'first-spice-reveal:2'), /Thumper.*original first Spice reveal.*ignored or suppressed/);
+ assert.match(question('amal', 'phase-opening:2:8'), /Amal.*original phase opening.*before ordinary payments/);
assert.match(question('karamaAuction', 'ordinary-auction:2:1'), /Karama.*original ordinary auction lot.*winning invoice/);
assert.match(question('karamaShipment', '["karamaShipment",2,"emperor","original"]'), /Karama.*Emperor’s original unused shipment price/);
});
diff --git a/tests/turn-action-promises.test.ts b/tests/turn-action-promises.test.ts
index dd542ef3..23c11c77 100644
--- a/tests/turn-action-promises.test.ts
+++ b/tests/turn-action-promises.test.ts
@@ -61,7 +61,8 @@ const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expressi
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
- effect === 'thumper' ? 'first-spice-reveal:1' : effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
+ effect === 'amal' ? 'phase-opening:1:2' : effect === 'thumper' ? 'first-spice-reveal:1' :
+ effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
@@ -1572,3 +1573,48 @@ void test('Thumper whole AND No and OR Yes retain their later payment correlatio
assert.equal(recordTurnActionWindowCardPlay(paid, 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0].fulfilled, true);
validateTurnActionPromises([expiry], seats);
});
+
+void test('Amal No caps actual printed cost in only the original answer interval and preserves whole No payment correlation', () => {
+ const source = windowSource('amal'), no = windowed(windowCard('amal'), false);
+ for (const [player, turn, opening] of [['other', 1, source], ['respondent', 2, source],
+ ['respondent', 1, 'phase-opening:1:3']] as const) {
+ assert.equal(recordTurnActionWindowCardPlay([no], player, turn, 'amal', opening)[0], no);
+ assert.equal(closeTurnActionWindowCardPlay([no], player, turn, 'amal', opening)[0], no);
+ }
+ assert.throws(() => recordTurnActionWindowCardPlay([no], 'respondent', 1, 'amal', source), TurnActionError);
+ assert.equal(no.windowCards!.amal!.played, false);
+ const closed = closeTurnActionWindowCardPlay([no], 'respondent', 1, 'amal', source)[0];
+ assert.equal(closed.fulfilled, true);
+ assert.equal(recordTurnActionWindowCardPlay([closed], 'respondent', 1, 'amal', source)[0], closed);
+ const wholeNo = windowed(group('and', windowCard('amal'), pay()), false, 'turn-action:2');
+ const played = recordTurnActionWindowCardPlay([wholeNo], 'respondent', 1, 'amal', source);
+ assert.equal(played[0].fulfilled, false);
+ const later = closeTurnActionWindowCardPlay(played, 'respondent', 1, 'amal', source);
+ assert.deepEqual(branches(later[0]), [[signedPay('recipient', 3, 0, false)]]);
+ assert.throws(() => recordTurnActionPayment(later, 'respondent', 1, payment()), TurnActionError);
+ assert.deepEqual(later[0].payments, []);
+ assert.equal(finishTurnActionTurn(later, 'respondent', 1)[0].fulfilled, true);
+ validateTurnActionPromises([closed], seats);
+});
+
+void test('Amal whole AND and OR keep printed receipt and later payment alternatives without storing a halving choice', () => {
+ const source = windowSource('amal');
+ const and = windowed(group('and', windowCard('amal'), pay()));
+ const or = windowed(group('or', windowCard('amal'), pay()), true, 'turn-action:2');
+ for (const extra of [{ amount: 3 }, { target: 'recipient' }, { half: 2 }, { card: 'physical-amal' }])
+ assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect: 'amal', source, ...extra }), TurnActionError);
+ const played = recordTurnActionWindowCardPlay([and, or], 'respondent', 1, 'amal', source);
+ assert.deepEqual(branches(played[0]), [[signedPay()]]);
+ assert.equal(played[1].fulfilled, true);
+ const closed = closeTurnActionWindowCardPlay(played, 'respondent', 1, 'amal', source);
+ assert.equal(closed[1], played[1]);
+ assert.equal(recordTurnActionPayment(closed, 'respondent', 1, payment())[0].fulfilled, true);
+ const missed = closeTurnActionWindowCardPlay([or], 'respondent', 1, 'amal', source);
+ assert.deepEqual(branches(missed[0]), [[signedPay()]]);
+ assert.equal(recordTurnActionPayment(missed, 'respondent', 1, payment())[0].fulfilled, true);
+ const no = windowed(windowCard('amal'), false, 'turn-action:3');
+ const expired = finishTurnActionTurn([no], 'respondent', 1)[0];
+ assert.deepEqual(expired.windowCards!.amal, { source, played: false, closed: true });
+ assert.equal(expired.fulfilled, true);
+ validateTurnActionPromises([expired], seats);
+});
diff --git a/tests/window-card-play.test.ts b/tests/window-card-play.test.ts
index ae86034c..503bb192 100644
--- a/tests/window-card-play.test.ts
+++ b/tests/window-card-play.test.ts
@@ -23,8 +23,47 @@ function shipments(): WindowCardGame {
}
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
- assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper']);
- assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'thumper']);
+ assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper', 'amal']);
+ assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'thumper', 'amal']);
+});
+
+void test('Amal names each original phase opening with only its public turn and phase identity', () => {
+ for (let phase = 0; phase <= 8; phase++) {
+ const source = { ...storm(), phase, amalWindowOpen: true };
+ assert.deepEqual(windowCardOpportunity(source, 'amal'), { effect: 'amal', source: `phase-opening:3:${phase}`, turn: 3 });
+ }
+ for (const phase of [-1, 0.5, 9, NaN, Infinity])
+ assert.equal(windowCardOpportunity({ ...storm(), phase, amalWindowOpen: true }, 'amal'), null);
+});
+
+void test('explicit Amal entitlement survives temporary Truth and discard suspension but actual close never reopens it', () => {
+ const source = { ...storm(), phase: 4, amalWindowOpen: true, phaseOpening: { passed: ['first'] } };
+ for (const suspended of [{ truthtrance: {} }, { response: {} }, { decision: {} }, { phaseOpening: null },
+ { phaseOpening: null, pendingTreacheryDiscard: {} }]) {
+ const waiting = { ...source, ...suspended };
+ assert.equal(windowCardOpportunity(waiting, 'amal')!.source, 'phase-opening:3:4');
+ assert.equal(windowCardOpportunity({ ...waiting, amalWindowOpen: false }, 'amal'), null);
+ }
+ assert.equal(windowCardOpportunity({ ...source, amalWindowOpen: false, phaseOpening: { passed: [] } }, 'amal'), null);
+});
+
+void test('legacy Amal openings accept the public passed list without requiring native initialization metadata', () => {
+ for (const phaseOpening of [{ passed: [] }, { passed: ['first', 'later'] }, { passed: [], initialize: true }])
+ assert.equal(windowCardOpportunity({ ...storm(), phase: 2, phaseOpening }, 'amal')!.source, 'phase-opening:3:2');
+ for (const phaseOpening of [undefined, null, [], {}, { passed: null }, { passed: 'first' },
+ { passed: ['first', 'first'] }, { passed: [''] }, { passed: [' first'] }, { passed: [1] }])
+ assert.equal(windowCardOpportunity({ ...storm(), phaseOpening }, 'amal'), null);
+});
+
+void test('Amal rejects invalid flags and stale opening identities without changing the original state', () => {
+ const source = { ...storm(), phase: 7, amalWindowOpen: true }, before = structuredClone(source);
+ assert.equal(windowCardOpportunity(source, 'amal', 'phase-opening:3:7')!.source, 'phase-opening:3:7');
+ for (const expected of ['phase-opening:3:6', 'phase-opening:4:7', 'phase-opening:03:7', 'phase-opening:3:7 '])
+ assert.equal(windowCardOpportunity(source, 'amal', expected), null);
+ for (const invalid of [{ amalWindowOpen: 'true' }, { amalWindowOpen: null }, { amalWindowOpen: 1 },
+ { status: 'setup' }, { turn: 0 }, { turn: 1.5 }, { turn: Number.MAX_SAFE_INTEGER + 1 }])
+ assert.equal(windowCardOpportunity({ ...source, ...invalid } as WindowCardGame, 'amal'), null);
+ assert.deepEqual(source, before);
});
void test('Thumper binds the public first-reveal entitlement without any hidden deck or physical-card source', () => {