Files
Dune/components/travel-promises.tsx
T

166 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import type { Action, GameView } from '@/game/engine';
import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { Button } from './ui/button';
type TravelProps = { game: GameView; busy: boolean; act: (action: Action) => void };
/** Forward the exact current native packet without choosing a branch or route. */
export function TravelPromiseGuideButton(props: TravelProps) {
const game = props.game, guide = game.travelPromiseGuide;
if (!guide) return null;
const actor = game.me, turn = game.turn, phase = game.phase, event = guide.event, action = guide.action;
const signature = JSON.stringify(action), auction = game.auction, decision = game.decision;
const native = game.richeseAuction, order = game.revivalOrder, revival = game.revival;
const auctionSource = (current: GameView) => current.auction && JSON.stringify({
bid: current.auction.bid, bidder: current.auction.bidder, active: current.auction.active,
remaining: current.auction.remaining, allyPayment: current.auction.allyPayment,
});
const nativeSource = (current: GameView) => current.richeseAuction && JSON.stringify({
event: current.richeseAuction.event, method: current.richeseAuction.method,
eligible: current.richeseAuction.eligible, submitted: current.richeseAuction.submitted,
active: current.richeseAuction.active, outcome: current.richeseAuction.outcome,
bid: current.richeseAuction.bid, bidder: current.richeseAuction.bidder,
passed: current.richeseAuction.passed, acted: current.richeseAuction.acted,
});
const auctionSignature = auctionSource(game), nativeSignature = nativeSource(game), orderSignature = JSON.stringify(order);
const box = game.nullentropy, search = box?.search;
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === actor);
const paidBox = action.type === 'decision' && current.decision === decision &&
current.decision?.kind === 'nullentropy' && current.nullentropy === box && current.nullentropy?.search === search &&
current.decision.player === actor && !!current.nullentropy?.search &&
current.nullentropy.search.event === action.event && !current.nullentropy.blocked;
const currentAuction = current.auction === auction && auctionSource(current) === auctionSignature && !!auction;
const payment = phase === 3 && action.type === 'decision' && typeof action.karama === 'boolean' &&
(!action.karama || typeof action.card === 'string') &&
current.decision === decision && current.decision?.kind === 'auctionPayment' && current.decision.player === actor &&
!current.richeseAuction && currentAuction && current.auction?.bidder === actor;
const nativePass = phase === 3 && action.type === 'richeseBid' && current.richeseAuction === native &&
nativeSource(current) === nativeSignature && native && native.event === action.event && !native.outcome &&
native.eligible.includes(actor) && (native.method === 'silent'
? !native.submitted.includes(actor) && action.amount === 0
: native.active === actor && action.amount === null) && action.allyPayment === 0;
const requiredBid = phase === 3 && action.type === 'bid' && !current.richeseAuction && currentAuction &&
current.auction?.active === actor && typeof action.amount === 'number' && Number.isSafeInteger(action.amount) &&
action.amount > 0;
const revivalTurn = current.revival === revival && !current.revival.pending && !current.revival.orderBlocked &&
current.revivalOrder === order && JSON.stringify(current.revivalOrder) === orderSignature &&
(!order || order.turn === turn && order.remaining[0] === actor && current.active === actor);
const opportunity = paidBox || (phase === 2
? !current.ready.includes(actor) && ['charity', 'ready', 'card'].includes(action.type)
: phase === 3
? payment || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor
: phase === 4
? !current.ready.includes(actor) && revivalTurn && ['revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'ready', 'card'].includes(action.type)
: phase === 5 && current.active === actor && (action.type === 'guildShip' && action.from === 'reserves' ||
['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type)));
return !props.busy && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' &&
Number.isSafeInteger(phase) && phase >= 0 && phase <= 5 && current.phase === phase && current.turn === turn && current.me === actor &&
current.travelPromiseGuide === guide && guide.event === event && guide.action === action &&
action.travelPromiseEvent === event && action.travelPromisePhase === phase && JSON.stringify(action) === signature && opportunity &&
current.travelPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
!promise.fulfilled && !promise.released) &&
!!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || payment) &&
!current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
const amount = action.forces && typeof action.forces === 'object'
? Object.values(action.forces as Record<string, number>).reduce((sum, count) => sum + count, 0)
: typeof action.amount === 'number' ? action.amount : 0;
const destination = game.combatLocations.find(location => location.id === action.territory)?.name ?? 'the quoted destination';
const sector = typeof action.sector === 'number' ? `, sector ${action.sector}` : '';
// A projected own face supplies the label only; the native guide owns legality.
const paymentCard = action.type === 'decision' && action.karama === true
? game.players.find(player => player.id === actor)?.hand?.find(card => card.id === action.card) : undefined;
const label = action.type === 'charity' ? 'Claim Charity for your later shipment and movement'
: action.type === 'ready' ? 'Finish this preparation phase while preserving your whole answer'
: action.type === 'passBid' ? 'Pass this bid and preserve later shipment and movement'
: action.type === 'bid' ? `Place the required bid of ${amount} while preserving later shipment and movement`
: action.type === 'richeseBid' ? action.amount === 0
? 'Submit zero in this Silent auction and preserve later shipment and movement'
: 'Pass this Richese bid and preserve later shipment and movement'
: action.type === 'revive' ? `Revive ${amount} forces for your later shipment and movement`
: action.type === 'reviveLeader' ? 'Revive the quoted leader for your later shipment and movement'
: action.type === 'acceptLeaderRevival' ? 'Accept the quoted leader revival for your later shipment and movement'
: action.type === 'reviveKwisatz' ? 'Revive your Kwisatz Haderach for your later shipment and movement'
: action.type === 'ship' || action.type === 'guildShip' ? `Ship ${amount} physical forces from reserves to ${destination}${sector}${action.type === 'guildShip' ? ' using Guild transport' : ''} while honoring your whole answer`
: action.type === 'move' ? `Move ${amount} physical forces to ${destination}${sector} while honoring your whole answer`
: action.type === 'endMovement' ? 'Finish movement while honoring your whole answer'
: action.type === 'decision' ? game.decision?.kind === 'auctionPayment'
? action.karama === true ? `Use ${paymentCard?.name ?? 'the quoted Karama'} for the current auction payment while preserving your whole answer`
: 'Pay the current auction price while preserving your whole answer'
: 'Complete your paid Box search for your whole answer'
: action.type === 'prepareAdvisors' ? `Prepare advisors in ${destination} for your whole answer`
: action.type === 'revealNoField' ? 'Reveal your No-Field to prepare physical forces for your whole answer'
: 'Use the offered native card preparation for your whole answer';
return <Button className="game-action min-h-11 whitespace-normal" data-testid="travel-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) props.act(action); }}>{label}</Button>;
}
/** Retain own raw history, never a compiled branch or individual factual answer. */
export function TravelPromises(props: TravelProps) {
const game = props.game, own = game.travelPromises.filter(promise => promise.player === game.me);
if (!own.length) return null;
const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released);
return <section className="notice space-y-3" aria-label="Your Truthtrance shipment and movement promises" id="travel-promise-guidance">
<h3>Your shipment and movement promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'travel', target: promise.player,
turn: promise.turn, mixed: promise.mixed }, id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
: game.allLeaders.find(leader => leader.id === id)?.name ?? id,
id => game.combatLocations.find(location => location.id === id)?.name ?? id,
id => game.players.find(player => player.id === id)?.name ?? id)}.
{promise.released ? ' Released: this whole commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding until your Movement turn ends this round.'}
</li>)}</ul>
<p>AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
Legal alternatives remain available; no fixed branch is selected for you.
Current facts are evaluated at the answer. They create no promise to keep cards, spice or forces afterward.
Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private.</p>
<p className="fine">Shipment conditions concern one future original reserve shipment after the answer.
Ground-movement conditions count the maximum physical forces in one actual future move, never the sum of smaller moves.
Earlier actions do not count. A shipment can prepare a move; each action counts only for its own conditions.
Original shipment rights, movement opportunities, cards, preparation and costs still apply.
This claim grants no extra shipment or movement.</p>
{live && (game.phase < 5 || game.active !== game.me) && <p>Your Movement turn is still ahead. Keep the needed forces, spice and cards available;
movement controls appear on your turn.
{game.phase < 5 && ' Complete any offered current preparation below before confirming readiness.'}</p>}
{live && <TravelPromiseGuideButton {...props} />}
</section>;
}
/** One whole answer, using only the current target's offered native choices. */
export function TravelAnswers(props: TravelProps) {
const game = props.game, window = game.truthtrance, question = window?.question;
if (window?.stage !== 'answer' || question?.kind !== 'travel' || question.target !== game.me || !game.truthTravelAnswers) return null;
const actor = game.me, turn = game.turn, phase = game.phase, source = window.queue[0], answers = game.truthTravelAnswers;
const signature = JSON.stringify(question), sourceSignature = JSON.stringify(source);
const canAnswer = (answer: TruthAnswer) => {
const current = props.game, me = current.players.find(player => player.id === actor);
return !props.busy && current === game && current.status === 'playing' &&
Number.isSafeInteger(phase) && phase >= 0 && phase <= 5 && current.phase === phase &&
current.turn === turn && current.me === actor && question.turn === turn &&
current.truthtrance === window && window.stage === 'answer' && window.question === question && question.target === actor &&
JSON.stringify(question) === signature && !!source && window.queue[0] === source && JSON.stringify(source) === sourceSignature &&
current.truthTravelAnswers === answers && answers.includes(answer) &&
!!me && !me.autopilot && !current.response && !current.decision && !current.phaseOpening &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
return <section aria-label="Answer the whole shipment and movement question" className="space-y-3">
<p className="notice">Answer the whole shipment and ground-movement expression. Current facts freeze now.
OR preserves legal alternatives; No keeps the whole expression false.
Only a future original reserve shipment and actual future ground moves count, with physical forces in one action.
Original rights, preparation and costs still apply. Only your whole answer is shared.</p>
{answers.map(answer => {
const action: Action = { type: 'truthAnswer', answer };
return <Button key={answer} className="game-action min-h-11 whitespace-normal" data-travel-answer={answer}
disabled={!canAnswer(answer)} onClick={() => { if (canAnswer(answer)) props.act(action); }}>
{answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`}
</Button>;
})}
</section>;
}