Prototype conditional future battle Truthtrance
Verify and publish checkpoint container / container (push) Failing after 11m11s

This commit is contained in:
Quintine committed 2026-10-28 21:17:44 +11:00
1 parent 71009d73e3
commit 02695aea7c
15 files changed
+721 -8

No files matched your search

+2
View File
@@ -5,6 +5,7 @@ import { TravelPromises } from './travel-promises';
import { TurnActionPromises } from './turn-action-promises';
import { MixedTraitorPromises } from './mixed-traitor-promises';
import { MixedBattlePromises } from './mixed-battle-promises';
import { FutureBattleLeaderPromises } from './truth-future-battle-leader';
import { TraitorDecisionPromises } from './traitor-decision-promises';
import { MovementPromises } from './movement-promises';
import { MovementExpressionPromises } from './movement-expression-promises';
@@ -2102,6 +2103,7 @@ export function GameTable({
{g.status === 'playing' && <EarlyRicheseBidPromises game={g} />}
{g.status === 'playing' && <MixedBidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBattlePromises game={g} />}
{g.status === 'playing' && <FutureBattleLeaderPromises game={g} />}
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
{g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />}
{g.status === 'playing' && <TurnActionPromises game={g} busy={g.semutaReaction &&
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { leaders } from '@/game/cards';
import type { GameView } from '@/game/engine';
import { Button } from './ui/button';
/** The parent owns timing and target availability; this editor only uses
* publicly listed native leader identities and never tests private plans. */
export function TruthFutureBattleLeader({ game, target, leader, available, busy, onLeaderChange, onAsk }: {
game: GameView;
target: string;
leader: string;
available: boolean;
busy: boolean;
onLeaderChange: (leader: string) => void;
onAsk: (target: string, leader: string) => void;
}) {
const respondent = target !== game.me ? game.players.find(player => player.id === target) : undefined;
const nativeIds = new Set(respondent ? leaders(respondent.faction).map(item => item.id) : []);
const nativeLeaders = game.allLeaders.filter(item => nativeIds.has(item.id) && item.faction === respondent?.faction);
const uniqueLeaders = nativeLeaders.filter((item, index) => nativeLeaders.findIndex(other => other.id === item.id) === index);
const selected = uniqueLeaders.find(item => item.id === leader);
const disabled = busy || !available || !respondent;
const blocked = disabled || !selected;
return <>
{!respondent && <p className="notice">Choose another seated player for this question.</p>}
{respondent && <label>
Leader
<select data-testid="truth-future-battle-leader-select" value={selected?.id ?? ''} disabled={disabled}
onChange={event => {
const next = event.target.value;
if (!disabled && uniqueLeaders.some(item => item.id === next)) onLeaderChange(next);
}}>
<option value="">Choose a leader</option>
{uniqueLeaders.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</label>}
<blockquote data-testid="truth-future-battle-leader-preview">
If you fight a battle later this turn, will you use {selected?.name ?? '[leader]'} in your first such battle?
</blockquote>
<p className="notice">If you do not fight a later battle this turn, there is no opportunity to use that leader.</p>
<Button className="game-action" data-testid="truth-future-battle-leader-ask" disabled={blocked}
onClick={() => { if (!blocked) onAsk(target, leader); }}>Ask publicly</Button>
</>;
}
/** Only the answering seat receives this receipt in its projection. */
export function FutureBattleLeaderPromises({ game }: {game: GameView}) {
const promises = game.futureBattleLeaderPromises.filter(row => row.turn === game.turn &&
(row.status === 'waiting' || row.status === 'bound'));
if (!promises.length) return null;
return <section className="notice" aria-label="Your conditional battle answer">
{promises.map((row, index) => <p key={`${row.turn}-${row.player}-${index}`}>
Your Truthtrance answer commits you to {row.answer ? 'use' : 'not use'}{' '}
{game.allLeaders.find(leader => leader.id === row.leader)?.name ?? row.leader} in your first later battle this turn.
{row.status === 'waiting' ? ' If you fight no battle, there is no opportunity.' : ' This battle is the opportunity.'}
</p>)}
</section>;
}
+18 -2
View File
@@ -1,5 +1,6 @@
'use client';
import { SaphoTruthOrder } from './sapho-truth-order';
import { TruthFutureBattleLeader } from './truth-future-battle-leader';
import { RicheseBidPromiseFields, richeseBidMinimumError } from './richese-bid-promise-fields';
import { TravelClaimFields, travelClaimInputError } from './travel-claim-fields';
@@ -125,7 +126,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'charity' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'earlyRicheseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
'fact' | 'freeform' | 'charity' | 'battlePlan' | 'futureBattleLeader' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'earlyRicheseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -225,6 +226,7 @@ export function Truthtrance({
const invalidMixed = mixedShipmentInputError(mixed, g, target);
const shipmentAvailable = g.truthShipmentTargets.includes(target);
const [planClaim, setPlanClaim] = useState<PlanClaim>({ kind: 'dial', compare: 'gte', value: 1 });
const [futureBattleLeader, setFutureBattleLeader] = useState('');
const [scope, setScope] = useState<'fact' | 'currentTurn'>('fact');
const [text, setText] = useState('');
const [fact, setFact] = useState<TruthFact>({ kind: 'hand', name: 'Shield' });
@@ -242,6 +244,7 @@ export function Truthtrance({
target,
fact,
}
: kind === 'futureBattleLeader' ? {kind, target, turn: g.turn, leader: futureBattleLeader}
: kind === 'battlePlan'
? {
kind,
@@ -521,6 +524,13 @@ export function Truthtrance({
<TravelAnswers game={g} act={act} busy={busy} />
) : w.question!.kind === 'traitorCall' || w.question!.kind === 'mixedTraitorCall' ? (
<TraitorDecisionAnswers game={g} act={act} busy={busy} />
) : w.question!.kind === 'futureBattleLeader' ? (
<>
<p className="notice">Your answer applies only if you fight a later battle this turn.</p>
{(g.truthFutureBattleLeaderAnswers ?? []).map(answer => <div key={answer}>
{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, {type: 'truthAnswer', answer})}
</div>)}
</>
) : w.question!.kind === 'battlePlan' ? (
<>
<p className="notice">
@@ -658,6 +668,7 @@ export function Truthtrance({
Combine current facts and this-turn shipment
</option>
<option value="freeform">Write a game question</option>
{g.truthFutureBattleLeaderTargets.length > 0 && <option value="futureBattleLeader">Ask about a leader in a later battle</option>}
{g.battle && <option value="mixedBattlePlan">Combine current facts and this battle plan</option>}
{g.battle && (
<option value="battlePlan">
@@ -891,6 +902,11 @@ export function Truthtrance({
{invalidMixed ?? truthQuestionText(question, leaderName, locationName, name)}
</p>}
</>
) : kind === 'futureBattleLeader' ? (
<TruthFutureBattleLeader game={g} target={target} leader={futureBattleLeader}
available={g.truthFutureBattleLeaderTargets.includes(target)} busy={busy}
onLeaderChange={setFutureBattleLeader}
onAsk={(player, leader) => act({type: 'truthAsk', question: {kind: 'futureBattleLeader', target: player, turn: g.turn, leader}})} />
) : kind === 'battlePlan' ? (
question.kind === 'battlePlan' && (
<TruthBattlePlanQuestion game={g} question={question} busy={busy}
@@ -933,7 +949,7 @@ export function Truthtrance({
</p>
</>
)}
{!['fact', 'battlePlan', 'shipment'].includes(kind) && button(
{!['fact', 'battlePlan', 'futureBattleLeader', 'shipment'].includes(kind) && button(
'Ask publicly',
{ type: 'truthAsk', question },
(kind === 'mixedMovement' && (!!mixedMovementError || !g.truthMixedMovementTargets.includes(target))) ||