Files
Dune/components/travel-promises.tsx
T

110 lines
8.4 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);
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === actor);
const paidBox = action.type === 'decision' && current.decision?.kind === 'nullentropy' &&
current.decision.player === actor && !!current.nullentropy?.search &&
current.nullentropy.search.event === action.event && !current.nullentropy.blocked;
const opportunity = paidBox || 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' &&
phase === 5 && current.phase === phase && current.turn === turn && current.me === actor && current.active === actor &&
current.travelPromiseGuide === guide && guide.event === event && guide.action === action &&
action.travelPromiseEvent === event && 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) &&
!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}` : '';
const label = 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' ? '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.active !== game.me && <p>Your Movement turn is still ahead. Keep the needed forces, spice and cards available;
native controls appear on your turn.</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' && 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>;
}