Connect phase-opening Amal promises and native payment order

This commit is contained in:
Quintine committed 2026-10-27 05:08:40 +11:00
1 parent 00741a37d0
commit b81823bbec
20 files changed
+954 -51

No files matched your search

+1
View File
@@ -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 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. 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. 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.</p>} Ordinary payments may remain available after their Shipment and Movement opportunity ends.</p>}
<p className="notice" aria-label="Public turn-action question preview">{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}</p> <p className="notice" aria-label="Public turn-action question preview">{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</> </>
+9 -3
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { TERRITORIES } from '@/game/board'; import { TERRITORIES } from '@/game/board';
import { PHASES } from '@/game/catalog';
import type { GameView } from '@/game/engine'; import type { GameView } from '@/game/engine';
import type { MixedActionExpression } from '@/game/mixed-action-question'; import type { MixedActionExpression } from '@/game/mixed-action-question';
import { parseBattleActionClaim } from '@/game/mixed-battle-question'; import { parseBattleActionClaim } from '@/game/mixed-battle-question';
@@ -50,7 +51,7 @@ const newPowerClaim = (game: GameView, target: string): BattlePowerClaim =>
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester', const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester',
karamaAuction: 'Spend a printed Karama for this current ordinary lot', karamaAuction: 'Spend a printed Karama for this current ordinary lot',
karamaShipment: 'Spend a printed Karama for the current shipment-price opportunity', 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<TurnActionLeaf> = { const config: MixedActionConfig<TurnActionLeaf> = {
prefix: 'turn-action', prefix: 'turn-action',
legend: 'Current battle and this-turn action conditions, with optional current facts', legend: 'Current battle and this-turn action conditions, with optional current facts',
@@ -92,6 +93,9 @@ const config: MixedActionConfig<TurnActionLeaf> = {
Thumper conditions count an actual accepted future printed play before this turn&apos;s original first spice reveal. Thumper conditions count an actual accepted future printed play before this turn&apos;s original first spice reveal.
Earlier plays, disposal, other roles and recovery alone do not count. A later reveal is a different opportunity. 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. 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. 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. 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. Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
@@ -143,7 +147,8 @@ const config: MixedActionConfig<TurnActionLeaf> = {
const minimum = 'minimum' in value ? value.minimum : 1; const minimum = 'minimum' in value ? value.minimum : 1;
if (kind === 'cardPlay') onChange({ kind, effect: 'hajr' }); if (kind === 'cardPlay') onChange({ kind, effect: 'hajr' });
if (kind === 'windowCardPlay') { 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 ?? '' }); onChange({ kind, effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
} }
if (kind === 'battlePower') { if (kind === 'battlePower') {
@@ -189,7 +194,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
<select id={`${id}-window-effect`} value={WINDOW_CARD_EFFECTS.includes(value.effect) ? value.effect : ''} <select id={`${id}-window-effect`} value={WINDOW_CARD_EFFECTS.includes(value.effect) ? value.effect : ''}
disabled={disabled} aria-invalid={!WINDOW_CARD_EFFECTS.includes(value.effect)} onChange={event => { disabled={disabled} aria-invalid={!WINDOW_CARD_EFFECTS.includes(value.effect)} onChange={event => {
const effect = event.currentTarget.value; 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 ?? '' }); onChange({ kind: 'windowCardPlay', effect, source: windowCardOpportunity(game, effect)?.source ?? '' });
}}> }}>
<option value="">Choose a printed role</option> <option value="">Choose a printed role</option>
@@ -207,6 +212,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
? `Turn ${currentWindow.turn} · Fresh spice blow in ${game.combatLocations.find(location => location.id === game.spiceWindow?.territory)?.name ?? game.spiceWindow?.territory}` ? `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 === 'karamaAuction' ? `Turn ${currentWindow.turn} · Ordinary lot ${game.bidPromiseLot?.lot}`
: value.effect === 'thumper' ? `Turn ${currentWindow.turn} · Before the original first spice reveal` : 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` : 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`}</option>} : `Turn ${currentWindow.turn} · Before this Storm movement`}</option>}
</select> </select>
+33 -8
View File
@@ -27,9 +27,9 @@ const publicBattleContext = (game: GameView) => game.battle && ({
traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted, traitorVoters: game.battle.traitorVoters, traitorSubmitted: game.battle.traitorSubmitted,
}); });
const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction' | 'karamaShipment'> => const isWindowEffect = (effect: unknown): effect is Exclude<WindowCardEffect, 'karamaAuction' | 'karamaShipment'> =>
effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'thumper'; effect === 'weather' || effect === 'atomics' || effect === 'harvester' || effect === 'thumper' || effect === 'amal';
const windowRoleNames: Record<WindowCardEffect, string> = { weather: 'Weather Control', atomics: 'Family Atomics', harvester: 'Harvester', const windowRoleNames: Record<WindowCardEffect, string> = { 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<TurnActionLeaf>, effect: WindowCardEffect, source?: string): boolean => const namesWindow = (mixed: MixedActionExpression<TurnActionLeaf>, effect: WindowCardEffect, source?: string): boolean =>
mixed.kind === 'action' ? mixed.action.kind === 'windowCardPlay' && mixed.action.effect === effect && (source === undefined || mixed.action.source === source) 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)); : 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 box = game.nullentropy, search = box?.search;
const windowPromise = game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn); const windowPromise = game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn);
const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed); 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 currentAuctionWindow = windowCardOpportunity(game, 'karamaAuction');
const auctionRoleNamed = !!windowPromise && !!currentAuctionWindow && namesWindow(windowPromise.mixed, 'karamaAuction', currentAuctionWindow.source); const auctionRoleNamed = !!windowPromise && !!currentAuctionWindow && namesWindow(windowPromise.mixed, 'karamaAuction', currentAuctionWindow.source);
const auctionPurchaseAction = action.type === 'card' && action.mode === 'purchase'; 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)), : WINDOW_CARD_EFFECTS.map(effect => windowCardOpportunity(current, effect)),
stormOpen: current.stormCardWindowOpen, stormOpen: current.stormCardWindowOpen,
thumperOpen: current.thumperWindowOpen, thumperReady: current.thumperReady, nexus: current.nexus, thumperOpen: current.thumperWindowOpen, thumperReady: current.thumperReady, nexus: current.nexus,
amalOpen: current.amalWindowOpen, amalReady: current.amalReady, opening: current.phaseOpening,
stormPending: current.stormPending, wallDestroyed: current.shieldWallDestroyed, stormPending: current.stormPending, wallDestroyed: current.shieldWallDestroyed,
blow: current.spiceWindow, blow: current.spiceWindow,
...(current.phase === 3 ? { auction: auctionSource(current) } : {}), ...(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, ...(windowEffect === 'atomics' ? { forces: current.players.find(player => player.id === actor)?.forces,
advisors: current.players.find(player => player.id === actor)?.advisors } : {}), 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 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 earlyBoxSignature = box && JSON.stringify({ card: box.card.id, blocked: box.blocked, search: box.search?.event });
const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards); const auctionCards = game.karamaAuctionCards, auctionCardsSignature = JSON.stringify(auctionCards);
const shipmentCards = game.karamaShipmentCards, shipmentCardsSignature = JSON.stringify(shipmentCards); const shipmentCards = game.karamaShipmentCards, shipmentCardsSignature = JSON.stringify(shipmentCards);
const thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards); const thumperCards = game.thumperCards, thumperCardsSignature = JSON.stringify(thumperCards);
const amalCards = game.amalCards, amalCardsSignature = JSON.stringify(amalCards);
const auctionHandSource = (current: GameView) => { const auctionHandSource = (current: GameView) => {
const player = current.players.find(player => player.id === actor); 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 }); 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 && const nativeThumperCard = windowEffect === 'thumper' && !!heldCard && phase === 1 && !current.spiceWindow && !current.nexus &&
Array.isArray(current.thumperCards) && current.thumperCards === thumperCards && Array.isArray(current.thumperCards) && current.thumperCards === thumperCards &&
JSON.stringify(current.thumperCards) === thumperCardsSignature && current.thumperCards.includes(heldCard.id); 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 && const printedWindow = action.type === 'card' && printedWindowSource && !!heldCard && windowPacket &&
(windowEffect === 'karamaAuction' ? action.mode === 'purchase' && nativeAuctionCard && !!me && !current.decision && (windowEffect === 'karamaAuction' ? action.mode === 'purchase' && nativeAuctionCard && !!me && !current.decision &&
Number.isSafeInteger(me.handLimit) && Array.isArray(me.hand) && me.hand.length < me.handLimit Number.isSafeInteger(me.handLimit) && Array.isArray(me.hand) && me.hand.length < me.handLimit
: windowEffect === 'karamaShipment' ? action.mode === 'shipment' && nativeShipmentCard : windowEffect === 'karamaShipment' ? action.mode === 'shipment' && nativeShipmentCard
: windowEffect === 'thumper' ? action.mode === undefined && nativeThumperCard : 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 && const printedAuctionInvoice = printedInvoiceAction && printedWindowSource && nativeAuctionCard &&
Object.keys(action).every(key => ['type', 'card', 'karama', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key)); Object.keys(action).every(key => ['type', 'card', 'karama', 'turnActionPromiseEvent', 'turnActionPromisePhase'].includes(key));
const quotedBoxCard = heldCard?.effect !== 'nullentropyBox' || !!box && current.nullentropy === box && const quotedBoxCard = heldCard?.effect !== 'nullentropyBox' || !!box && current.nullentropy === box &&
@@ -246,7 +263,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const battlePreparation = genericCard && planWindow; const battlePreparation = genericCard && planWindow;
const declaration = action.type === 'traitorCall' && typeof action.call === 'boolean' && !!battle && currentBattle && battle.revealed && const declaration = action.type === 'traitorCall' && typeof action.call === 'boolean' && !!battle && currentBattle && battle.revealed &&
battle.traitorVoters.includes(actor) && !battle.traitorSubmitted.includes(actor); 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) ? !current.ready.includes(actor) && (['charity', 'ready'].includes(action.type) || genericCard)
: phase === 3 : phase === 3
? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor ? 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 && action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity &&
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn && current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
!promise.fulfilled && !promise.released) && !promise.fulfilled && !promise.released) &&
(!amalReadyAction || openingReady) &&
!!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || invoice || earlyWindowBox && ownInvoiceSource) && !!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' const amount = action.forces && typeof action.forces === 'object'
? Object.values(action.forces as Record<string, number>).reduce((sum, count) => sum + count, 0) ? Object.values(action.forces as Record<string, number>).reduce((sum, count) => sum + count, 0)
@@ -292,7 +310,8 @@ export function TurnActionGuideButton(props: TurnActionProps) {
: 'Withdraw your alliance offer while preserving your whole answer' : 'Withdraw your alliance offer while preserving your whole answer'
: `Accept ${recipient}’s alliance invitation 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 === '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 && (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}`) : earlyWindowReadyAction && game.thumperReady && !!windowPromise && namesWindow(windowPromise.mixed, 'thumper', `first-spice-reveal:${turn}`)
? 'Ready for the original first spice reveal while preserving your whole answer' ? '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&apos;s original first spice reveal. Thumper conditions count an actual accepted future printed play before this turn&apos;s original first spice reveal.
Earlier plays, disposal, other roles and recovery alone do not count. A later reveal is a different opportunity. 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. 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, 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. 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. 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; 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 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 signature = JSON.stringify(question), sourceSignature = JSON.stringify(source);
const opening = game.phaseOpening, openingSignature = JSON.stringify(opening);
const canAnswer = (answer: TruthAnswer) => { const canAnswer = (answer: TruthAnswer) => {
const current = props.game, me = current.players.find(player => player.id === actor); const current = props.game, me = current.players.find(player => player.id === actor);
return !props.busy && current === game && current.status === 'playing' && 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 && window.question === question && question.target === actor && JSON.stringify(question) === signature &&
!!source && window.queue[0] === source && JSON.stringify(source) === sourceSignature && !!source && window.queue[0] === source && JSON.stringify(source) === sourceSignature &&
current.truthTurnActionAnswers === answers && answers.includes(answer) && 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; !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
}; };
return <section aria-label="Answer the whole battle and turn-action question" className="space-y-3"> return <section aria-label="Answer the whole battle and turn-action question" className="space-y-3">
@@ -425,6 +449,7 @@ export function TurnActionAnswers(props: TurnActionProps) {
Bids close with their lot; Revival closes with your native Revival opportunity. 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. 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. 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.</p> Original rights, preparation, timing and costs still apply. Only your whole answer is shared.</p>
{answers.map(answer => { {answers.map(answer => {
const action: Action = { type: 'truthAnswer', answer }; const action: Action = { type: 'truthAnswer', answer };
+28
View File
@@ -20,6 +20,34 @@ open mode gates.
Continue remaining Basic functions before Advanced and wider interactions. Continue remaining Basic functions before Advanced and wider interactions.
Prototype labels and final acceptance gates remain. 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.** **Truthtrance / printed Thumper — 27 October. Prototyped / Partial.**
Original-first-reveal promises now connect real printed costs, persistent Original-first-reveal promises now connect real printed costs, persistent
first-turn warning, exact native play/Ready controls and four legal profiles. first-turn warning, exact native play/Ready controls and four legal profiles.
+13
View File
@@ -1,5 +1,18 @@
# Dune implementation status # 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 ## 27 October 2026 — Printed Thumper promises
Prototyped / Partial. Original first-reveal sources, actual printed costs and Prototyped / Partial. Original first-reveal sources, actual printed costs and
+14
View File
@@ -1,5 +1,19 @@
# Rule decision index # 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 ## Truthtrance printed Thumper — 27 October 2026
Prototyped / Partial. Extend existing whole current-turn answers to an actual Prototyped / Partial. Extend existing whole current-turn answers to an actual
+46
View File
@@ -1,5 +1,51 @@
# Truthtrance combined current-turn actions # 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 ## Printed Thumper — 27 October 2026
**Prototyped / Partial.** `windowCardPlay/thumper` binds **Prototyped / Partial.** `windowCardPlay/thumper` binds
+1
View File
@@ -1565,6 +1565,7 @@ function policyActions(g: GameView): Action[] {
// Rival spice is private. Low own losses inform this choice. // Rival spice is private. Low own losses inform this choice.
if ( if (
amal && amal &&
g.amalCards?.includes(amal.id) === true &&
g.turn > 1 && g.turn > 1 &&
(me.spice ?? 0) <= (level === 0 ? 4 : 2) && (me.spice ?? 0) <= (level === 0 ? 4 : 2) &&
(level > 0 || variation(g, 'amal') > 0.4) (level > 0 || variation(g, 'amal') > 0.4)
+136 -27
View File
@@ -2507,6 +2507,8 @@ export type Game = {
stormCardWindowOpen?: boolean; stormCardWindowOpen?: boolean;
/** Original first Spice reveal; temporary summoned-worm controls do not retire it. */ /** Original first Spice reveal; temporary summoned-worm controls do not retire it. */
thumperWindowOpen?: boolean; thumperWindowOpen?: boolean;
/** Original phase opening; a discard response can temporarily suspend its controls. */
amalWindowOpen?: boolean;
stormCard: number | null; stormCard: number | null;
stormCardKnown: boolean; stormCardKnown: boolean;
stormResolution: { stormResolution: {
@@ -19017,9 +19019,23 @@ function quotedThumperCards(g: Game, player: Player): string[] {
return player.hand.filter(card => card.kind === 'special' && card.effect === 'thumper' && return player.hand.filter(card => card.kind === 'special' && card.effect === 'thumper' &&
!transferCardBlock(g, player, card)).map(card => card.id); !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<Action> { function* nativeWindowCardActions(g: Game, actor: string, effect: WindowCardEffect): Generator<Action> {
if (!windowCardOpportunity(g, effect)) return; if (!windowCardOpportunity(g, effect)) return;
const player = getPlayer(g, actor); const player = getPlayer(g, actor);
if (effect === 'amal') {
for (const card of quotedAmalCards(g, player)) yield { type: 'card', card };
return;
}
if (effect === 'karamaAuction') { if (effect === 'karamaAuction') {
for (const card of quotedAuctionKaramaCards(g, player)) yield g.decision?.kind === 'auctionPayment' for (const card of quotedAuctionKaramaCards(g, player)) yield g.decision?.kind === 'auctionPayment'
? { type: 'decision', karama: true, card } : { type: 'card', card, mode: 'purchase' }; ? { 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 /** A concurrent Ready grants permission to close this window. Quote that
* closure without installing it before the real global continuation. */ * 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 ?? []; let closed = g.turnActionPromises ?? [];
for (const promise of liveTurnActionPromises(closed, actor, g.turn)) 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]; const stage = promise.windowCards?.[effect];
if (stage && !stage.closed) closed = turnActionRule(() => if (stage && !stage.closed) closed = turnActionRule(() =>
closeTurnActionWindowCardPlay(closed, actor, g.turn, effect, stage.source)); 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); guardTurnActionShipmentPriceBoundary(trial, shipment.player, original, undefined, receipt);
} }
function turnActionTargetAvailable(g: Game, _actor: string): boolean { 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 { 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 /** Read only the original predicates while the native sealed cards are still
* held. Receipts contain booleans, never copies of private hands or plans. */ * 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; } } 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 }; 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 /** Whole-answer alternatives stay private. Only detached native rows quote a
* route, sharing actual interval progress and the remaining physical purse. */ * route, sharing actual interval progress and the remaining physical purse. */
function turnActionCompletion(source: Game, actor: string, unknownConsent?: { value: boolean }): TurnActionCompletion | null { 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; return null;
} }
const trial = structuredClone(source); let trial = structuredClone(source);
trial.truthtrance = null; trial.truthtrance = null;
delete trial.turnActionPromises; delete trial.turnActionPromises;
delete trial.travelPromises; delete trial.travelPromises;
delete trial.travelPromiseOrder; delete trial.travelPromiseOrder;
const owner = getPlayer(trial, actor);
const cardRequirements: TurnActionCardRequirement[] = leaves.flatMap(leaf => leaf.kind === 'cardPlay' const cardRequirements: TurnActionCardRequirement[] = leaves.flatMap(leaf => leaf.kind === 'cardPlay'
? [{ effect: leaf.effect, answer: leaf.answer, baseline: nativePrintedCardCount(source, actor, leaf.effect) }] : []); ? [{ effect: leaf.effect, answer: leaf.answer, baseline: nativePrintedCardCount(source, actor, leaf.effect) }] : []);
if (cardRequirements.length) (trial as TurnActionCardProbe)._turnActionCardPolicy = { if (cardRequirements.length) (trial as TurnActionCardProbe)._turnActionCardPolicy = {
@@ -19541,6 +19616,11 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
...forbiddenWindows, ...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.bribePromises ??= [];
trial.shipmentPromises ??= []; trial.shipmentPromises ??= [];
trial.movementPromises ??= []; trial.movementPromises ??= [];
@@ -19624,7 +19704,7 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
for (const payment of required) for (const payment of required)
checkBureaucratContributors(paid, payment.recipient, [{ payer: actor, amount: payment.amount }]); checkBureaucratContributors(paid, payment.recipient, [{ payer: actor, amount: payment.amount }]);
for (const cardSeed of turnActionPrintedPreparationSeeds(paid, actor, cardRequirements, 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); const funded = cardSeed.game, payer = getPlayer(funded, actor);
try { try {
reconcileBattlePromises(funded, voluntary); reconcileBattlePromises(funded, voluntary);
@@ -19668,7 +19748,8 @@ function turnActionCompletion(source: Game, actor: string, unknownConsent?: { va
: !cardsPending || battlePlan?.cardPlaysComplete ? { actions: [] } : null; : !cardsPending || battlePlan?.cardPlaysComplete ? { actions: [] } : null;
if (completion) { if (completion) {
if (alliancePlan?.uncertain) { unknownConsent!.value = true; continue; } 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 } : {}), ...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
...(power.action ? { battlePower: power.action } : {}), ...(power.action ? { battlePower: power.action } : {}),
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}), ...(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 } : {}), ...(combat ? { combat } : {}),
...(preparation ? { preparation } : {}), ...(preparation ? { preparation } : {}),
...(nexus ? { alliance: { source: nexus.source, partner: null, closed: false } } : {}), ...(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, progress: [], payments: [], transportClosed: !open, paymentClosed: false,
fulfilled: compiled.kind === 'constant' && compiled.value === answer, released: false }; fulfilled: compiled.kind === 'constant' && compiled.value === answer, released: false };
if (combat?.plan && battle?.plans[p.id]) promise = turnActionRule(() => recordTurnActionPlan([promise], p.id, 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; return promise;
} }
function turnActionAnswerSource(g: Game, player: string, alliance = false, window = false): Game { 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.phase === 7 && bribesCloseBeforeNextMentat(g)
? { ...g, ready: g.ready.filter(id => id !== player) } : 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[] { function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !turnActionTargetAvailable(g, p.id)) return []; 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'), requireRule(turnActionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a whole turn-action answer compatible with current funding and native actions.'); 'Choose a whole turn-action answer compatible with current funding and native actions.');
const promise = turnActionPromise(g, p, claim, answer, crypto.randomUUID(), asker); 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 ??= []; g.turnActionPromises ??= [];
initializeTurnActionOrder(g); initializeTurnActionOrder(g);
g.turnActionPromises.push(promise); 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 promise = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn)[0];
const ownedBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id; const ownedBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id;
const ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.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; g.decision && !ownedBox && !ownedInvoice) return null;
const completion = turnActionCompletion(g, player.id); const completion = turnActionCompletion(g, player.id);
if (!completion) return null; 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; let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
if (!action) { if (!action) {
const preparation = completion.cardPreparations?.[0]; 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. */ /** Open the same public window for every Ix table, independently of who holds Amal. */
function openPhase(g: Game, initialize = true) { function openPhase(g: Game, initialize = true) {
if (g.phase === 1) g.thumperWindowOpen = 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) if (g.phase === 1 && g.nexusCards && g.nexusCards.phase?.turn !== g.turn)
g.nexusCards.phase = createNexusCardPhase(g.turn); g.nexusCards.phase = createNexusCardPhase(g.turn);
if (initialize && g.phase === 4 && g.homeworlds) { if (initialize && g.phase === 4 && g.homeworlds) {
@@ -26170,6 +26270,18 @@ function startNativeRevivalOrder(g: Game, player: string) {
if (g.revivalOrder) if (g.revivalOrder)
g.revivalOrder = revivalOrderRule(() => startRevivalOrder(revivalOrderContext(g),g.revivalOrder!,player)); 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) { function beginPhase(g: Game) {
if (g.phase === 0) { if (g.phase === 0) {
g.auditorInsight = null; g.auditorInsight = null;
@@ -26227,20 +26339,7 @@ function beginPhase(g: Game) {
collectBankerIncome(g); collectBankerIncome(g);
drawHarkonnenNexusBetrayalReplacement(g); drawHarkonnenNexusBetrayalReplacement(g);
replaceMoritaniAssassinationTraitors(g); replaceMoritaniAssassinationTraitors(g);
if (g.inflation && g.inflation.updatedTurn < g.turn) { advanceMentatInflation(g);
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.`,
);
}
}
for (const p of g.players) { for (const p of g.players) {
p.spice += p.bribes; p.spice += p.bribes;
p.bribes = 0; p.bribes = 0;
@@ -38776,10 +38875,12 @@ function applyActionInner(
'You have already passed the phase opening.', 'You have already passed the phase opening.',
); );
if (t === 'ready') { if (t === 'ready') {
if (execution === 'live') guardTurnActionOpening(g, id, true);
g.phaseOpening.passed.push(id); g.phaseOpening.passed.push(id);
if (g.phaseOpening.passed.length === g.players.length) { if (g.phaseOpening.passed.length === g.players.length) {
const initialize = g.phaseOpening.initialize; const initialize = g.phaseOpening.initialize;
g.phaseOpening = null; g.phaseOpening = null;
g.amalWindowOpen = false;
if (initialize) beginPhase(g); if (initialize) beginPhase(g);
} }
} else { } else {
@@ -38790,13 +38891,18 @@ function applyActionInner(
t === 'card' && card && !action.mode, t === 'card' && card && !action.mode,
'Play Amal or pass the phase opening before taking other actions.', '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 const before = g.semutaPreview
? g.players.map(player => ({ player: player.id, spice: player.spice })) ? g.players.map(player => ({ player: player.id, spice: player.spice }))
: null; : null;
const used = discard(g, p, card.id); const used = discard(g, p, card.id);
rememberMovementDiscards(g, [{ card: used, discardedBy: id, publicFace: true }]);
for (const player of g.players) for (const player of g.players)
player.spice = Math.floor(player.spice / 2); player.spice = Math.floor(player.spice / 2);
g.phaseOpening.passed = []; g.phaseOpening.passed = [];
if (execution === 'live') guardTurnActionOpening(g, id, false);
log( log(
g, g,
`${p.name} played Amal. Each faction returned half its available spice to the bank, rounded up.`, `${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); throw new RuleError(availability.reason);
if (isWindowCardEffect(c.effect)) { if (isWindowCardEffect(c.effect)) {
const source = windowCardOpportunity(g, 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); recordNativeTurnActionWindowCard(g, id, c.effect, source.source);
} }
if (c.effect === 'hajr') { if (c.effect === 'hajr') {
@@ -42439,6 +42545,9 @@ export function viewGame(state: Game, id: string, now?: number) {
thumperCards: quotedThumperCards(g, me), thumperCards: quotedThumperCards(g, me),
thumperReady: thumperReadyStartsDraw(g), thumperReady: thumperReadyStartsDraw(g),
thumperWindowOpen: !!windowCardOpportunity(g, 'thumper'), thumperWindowOpen: !!windowCardOpportunity(g, 'thumper'),
amalCards: quotedAmalCards(g, me),
amalReady: quotedAmalReady(g, id),
amalWindowOpen: !!windowCardOpportunity(g, 'amal'),
truthBidTargets: bidQuestionWindow(g) ? g.players.filter(player => { truthBidTargets: bidQuestionWindow(g) ? g.players.filter(player => {
const lot = bidOpportunity(g)!; const lot = bidOpportunity(g)!;
return lot.eligible.includes(player.id) && !lot.passed.includes(player.id) && lot.bidder !== player.id; return lot.eligible.includes(player.id) && !lot.passed.includes(player.id) && lot.bidder !== player.id;
+3 -2
View File
@@ -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 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 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 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.', '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.', '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.', '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'], related: ['card-truthtrance', 'truthtrance-alliance', 'truthtrance-travel', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-bribe-expression', 'implementation-checklist'],
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: '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: '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: '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'] },
], ],
}, },
{ {
+2 -1
View File
@@ -381,7 +381,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
if (v.kind === 'turnActions') { if (v.kind === 'turnActions') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn && 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 && 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.'); '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 respondent = g.players.find(player => player.id === v.target)!;
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => { 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, if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => { action => {
if (action.kind === 'windowCardPlay') { 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 === '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') { if (action.effect === 'karamaShipment') {
const recipient = parseKaramaShipmentSource(action.source)?.recipient; const recipient = parseKaramaShipmentSource(action.source)?.recipient;
+1 -1
View File
@@ -145,7 +145,7 @@ export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
try { try {
if (value.kind === 'windowCardPlay') { if (value.kind === 'windowCardPlay') {
check(keys(value, 'effect,kind,source') && windowEffect(value.effect) && identifier(value.source), 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 }; return { kind: 'windowCardPlay', effect: value.effect, source: value.source };
} }
if (value.kind === 'battlePower') { if (value.kind === 'battlePower') {
+13 -2
View File
@@ -1,8 +1,8 @@
import { harvesterAvailable } from './cards'; 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 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; return effect === 'karamaAuction' || effect === 'karamaShipment' ? 'karama' : effect;
} }
export type KaramaShipmentOpportunity = { effect: 'karamaShipment'; source: string; turn: number; recipient: string }; export type KaramaShipmentOpportunity = { effect: 'karamaShipment'; source: string; turn: number; recipient: string };
@@ -18,6 +18,7 @@ export type WindowCardGame = {
stormCardWindowOpen?: boolean; stormCardWindowOpen?: boolean;
stormResolution?: unknown; stormResolution?: unknown;
thumperWindowOpen?: boolean; thumperWindowOpen?: boolean;
amalWindowOpen?: boolean;
spiceSequence?: unknown; spiceSequence?: unknown;
spiceResolution?: unknown; spiceResolution?: unknown;
nexus?: unknown; nexus?: unknown;
@@ -123,6 +124,16 @@ export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmen
* force conditions and chosen effect parameters remain native engine rules. */ * force conditions and chosen effect parameters remain native engine rules. */
function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null { function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return 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 (effect === 'thumper') {
if (game.phase !== 1 || game.thumperWindowOpen !== undefined && typeof game.thumperWindowOpen !== 'boolean' || if (game.phase !== 1 || game.thumperWindowOpen !== undefined && typeof game.thumperWindowOpen !== 'boolean' ||
game.thumperWindowOpen === false || game.thumperWindowOpen === undefined && game.thumperWindowOpen === false || game.thumperWindowOpen === undefined &&
+1
View File
@@ -172,6 +172,7 @@ void test('zero/one spice become zero and charity remains available after the op
const before = fixture(); const before = fixture();
before.phase = 2; before.phase = 2;
before.phaseOpening = { passed: [], initialize: true }; before.phaseOpening = { passed: [], initialize: true };
before.amalWindowOpen = true;
before.players[0].spice = 0; before.players[0].spice = 0;
before.players[1].spice = 1; before.players[1].spice = 1;
let g = ready(play(before)); let g = ready(play(before));
+163
View File
@@ -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<TruthTurnAmalLeaf>;
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<typeof orderIxSpiceCards> = [];
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<typeof createTruthTurnAmalFixture>;
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<TruthTurnAmalFixture, 'source'>, 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);
}
+110 -2
View File
@@ -36,7 +36,7 @@ const shipmentPriceSource = (recipient = 'p', cunning?: string) => JSON.stringif
...(cunning ? ['cunning', cunning] : ['original'])]); ...(cunning ? ['cunning', cunning] : ['original'])]);
const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow' const windowClaim = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'controlled-native-blow'
: effect === 'karamaAuction' ? 'ordinary-auction:2:2' : effect === 'karamaShipment' ? shipmentPriceSource() : 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 } }); ({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression => const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } }); ({ 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.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
game.truthTurnActionAnswers = null; game.truthTurnActionAnswers = null;
if (next.type === 'card' && typeof next.card === 'string') { 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'; .find(effect => cardId.includes(effect)) ?? 'karama';
const face = [...baseDeck(), ...ixSpecialCards()].find(card => card.effect === effect); assert.ok(face); const face = [...baseDeck(), ...ixSpecialCards()].find(card => card.effect === effect); assert.ok(face);
const me = game.players.find(player => player.id === 'p')!; 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()] }; game.turnActionPromises[0].mixed = { kind: 'or', terms: [windowClaim('thumper'), payment()] };
return game; 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 { function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
const game = projected(1, next); const game = projected(1, next);
game.nexus = true; game.spiceWindow = null; game.active = 'a'; 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); 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', () => { void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
const game = projected(); const game = projected();
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable']) for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
+289
View File
@@ -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);
}
}
});
+3 -2
View File
@@ -20,12 +20,13 @@ import {
} from '../game/truthtrance'; } from '../game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '../game/traitors'; import { CHEAP_HERO_TRAITOR } from '../game/traitors';
void test('whole card questions name Thumper and the actual printed Karama role and shipment beneficiary', () => { void test('whole card questions name Amal, Thumper and the actual printed Karama role and shipment beneficiary', () => {
const question = (effect: 'thumper' | 'karamaAuction' | 'karamaShipment', source: string) => const question = (effect: 'amal' | 'thumper' | 'karamaAuction' | 'karamaShipment', source: string) =>
truthQuestionText({ kind: 'turnActions', target: 'emperor', turn: 2, mixed: { kind: 'action', action: { truthQuestionText({ kind: 'turnActions', target: 'emperor', turn: 2, mixed: { kind: 'action', action: {
kind: 'windowCardPlay', effect, source, kind: 'windowCardPlay', effect, source,
} } }, id => id, id => id, id => id === 'emperor' ? 'Emperor' : id); } } }, 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('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('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/); assert.match(question('karamaShipment', '["karamaShipment",2,"emperor","original"]'), /Karama.*Emperor’s original unused shipment price/);
}); });
+47 -1
View File
@@ -61,7 +61,8 @@ const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expressi
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } }); ({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment' const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' : ? 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 => const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression =>
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } }); ({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } }); 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); assert.equal(recordTurnActionWindowCardPlay(paid, 'respondent', 1, 'thumper', 'first-spice-reveal:1')[0].fulfilled, true);
validateTurnActionPromises([expiry], seats); 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);
});
+41 -2
View File
@@ -23,8 +23,47 @@ function shipments(): WindowCardGame {
} }
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => { 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, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper', 'amal']);
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'thumper']); 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', () => { void test('Thumper binds the public first-reveal entitlement without any hidden deck or physical-card source', () => {