Support phase-wide Truthtrance bid commitments

This commit is contained in:
Quintine committed 2026-10-27 17:44:53 +11:00
1 parent 474674f7fe
commit 2916b4d48f
19 files changed
+1512 -15

No files matched your search

+2
View File
@@ -43,6 +43,7 @@ import { RevivalPromises } from './revival-promises';
import { RevivalExpressionPromises } from './revival-expression-promises';
import { LeaderRevivalPromises } from './leader-revival-promises';
import { BidPromises } from './bid-promises';
import { PhaseBidPromises } from './phase-bid-promises';
import { HomeworldShipment } from './homeworld-shipment';
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
import { CaladanReinforcement } from './caladan-reinforcement';
@@ -2086,6 +2087,7 @@ export function GameTable({
{g.status === 'playing' && <LeaderRevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <RevivalPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <PhaseBidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBattlePromises game={g} />}
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
+38
View File
@@ -0,0 +1,38 @@
'use client';
import { parsePhaseBidClaim, type PhaseBidClaim } from '@/game/turn-bid-promises';
import { Input } from './ui/input';
export function phaseBidClaimInputError(value: unknown): string | null {
try { parsePhaseBidClaim(value); return null; }
catch (error) { return error instanceof Error ? error.message : 'Choose a positive whole bid minimum.'; }
}
/** Turn is fixed by the authoritative question context; the asker chooses the threshold. */
export function PhaseBidClaimFields(props: {
value: PhaseBidClaim;
onChange: (claim: PhaseBidClaim) => void;
disabled?: boolean;
}) {
const error = phaseBidClaimInputError(props.value);
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Bidding phase condition">
<legend>Bid during this turn</legend>
<p>Turn {props.value.turn} · Remaining ordinary Bidding phase</p>
<label htmlFor="truth-phase-bid-minimum">Minimum bid in spice</label>
<Input id="truth-phase-bid-minimum" type="number" min={1} max={Number.MAX_SAFE_INTEGER} step={1} required
disabled={props.disabled} value={Number.isFinite(props.value.minimum) ? props.value.minimum : ''}
aria-invalid={!!error} aria-describedby="truth-phase-bid-help"
onChange={event => {
if (props.disabled) return;
const next = { ...props.value, minimum: event.currentTarget.valueAsNumber };
if (phaseBidClaimInputError(next)) return;
props.onChange(parsePhaseBidClaim(next));
}} />
{error && <p role="alert">{error}</p>}
<p className="fine" id="truth-phase-bid-help">
This asks about one accepted bid of at least this size after the answer,
before Bidding ends this turn. Earlier bids do not count; it does not
promise a win or change the winning payment.
</p>
</fieldset>;
}
+67
View File
@@ -0,0 +1,67 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { phaseBidClaimText, phaseBidPromiseModeSupported } from '@/game/turn-bid-promises';
import { Button } from './ui/button';
type PhaseBidPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
/** Only operate the exact server-selected bid/pass for a live owned promise.
* The native auction remains responsible for its own card, funding and turn. */
export function PhaseBidPromiseGuideButton(props: PhaseBidPromiseProps) {
const game = props.game, guide = game.phaseBidPromiseGuide;
if (!guide) return null;
const actor = game.me, turn = game.turn, lot = game.auction;
const lotSignature = lot && JSON.stringify([lot.bid, lot.bidder, lot.active, lot.remaining]);
const action = guide.action, event = action.phaseBidPromiseEvent, lotNumber = action.phaseBidPromiseLot;
const signature = JSON.stringify(action);
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === current.me);
return !props.busy && current === game && phaseBidPromiseModeSupported(current) &&
current.status === 'playing' && current.phase === 3 &&
current.me === actor && current.turn === turn && !!lot && current.auction === lot &&
current.auction.active === actor &&
JSON.stringify([lot.bid, lot.bidder, lot.active, lot.remaining]) === lotSignature &&
typeof lotNumber === 'number' && Number.isSafeInteger(lotNumber) && lotNumber > 0 &&
current.bidPromiseLot?.turn === turn && current.bidPromiseLot.lot === lotNumber &&
current.phaseBidPromiseGuide === guide && current.phaseBidPromiseGuide.action === action &&
JSON.stringify(action) === signature && typeof event === 'string' &&
(action.type === 'bid' || action.type === 'passBid') &&
current.phaseBidPromises.some(promise => promise.event === event && promise.player === actor &&
promise.turn === turn && !promise.fulfilled && !promise.released) && !!me && !me.autopilot &&
!current.truthtrance && !current.response && !current.decision && !current.phaseOpening &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
return <Button className="game-action min-h-11 whitespace-normal" data-testid="phase-bid-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>
{action.type === 'bid'
? `Bid ${typeof action.amount === 'number' ? action.amount : 'the suggested amount'} spice while honoring your phase answers`
: 'Pass this lot while honoring your phase answers'}
</Button>;
}
export function PhaseBidPromises(props: PhaseBidPromiseProps) {
const own = props.game.phaseBidPromises.filter(promise => promise.player === props.game.me);
if (!own.length) return null;
const waiting = own.some(promise => promise.turn === props.game.turn && !promise.fulfilled && !promise.released);
return <section className="notice space-y-3" aria-label="Your Truthtrance Bidding phase promises" id="phase-bid-promise-guidance">
<h3>Your Bidding phase promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {phaseBidClaimText(promise)}.
{promise.released ? ' Released: this commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding through this Bidding phase.'}
</li>)}</ul>
<p>
Yes requires a qualifying future bid while it remains possible. No forbids
qualifying bids through Bidding close. A bid can fulfill Yes even if another
player wins; the original payment rules still apply.
</p>
<p className="fine">
Earlier bids do not count. Ordinary auctions retain their original order and
funding; the answer gives no extra opportunity, spice or free purchase.
</p>
{waiting && props.game.auction?.active !== props.game.me &&
<p>Your next bid opportunity is still ahead; this promise remains visible while others bid.</p>}
{waiting && <PhaseBidPromiseGuideButton {...props} />}
</section>;
}
+20 -1
View File
@@ -42,6 +42,7 @@ import type { BribeClaim } from '@/game/bribe-promises';
import { RevivalClaimFields, revivalClaimInputError } from './revival-claim-fields';
import type { RevivalClaim } from '@/game/revival-promises';
import { BidClaimFields, bidClaimInputError } from './bid-claim-fields';
import { PhaseBidClaimFields, phaseBidClaimInputError } from './phase-bid-claim-fields';
import type { BidClaim } from '@/game/bid-promises';
import { TraitorDecisionAnswers } from './traitor-decision-promises';
import { validMovementClaim, type MovementClaim } from '@/game/movement-promises';
@@ -96,7 +97,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | '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 ?? '');
@@ -138,6 +139,7 @@ export function Truthtrance({
const [bidMinimum, setBidMinimum] = useState(1);
const bidClaim: BidClaim = { turn: g.bidPromiseLot?.turn ?? g.turn,
lot: g.bidPromiseLot?.lot ?? 1, minimum: bidMinimum };
const phaseBidClaim = {turn:g.turn,minimum:bidMinimum};
const [movement, setMovement] = useState<MovementClaim>({ territory: 'carthag', minimum: 6 });
const [movementExpression, setMovementExpression] = useState<MovementExpression>({ kind: 'and', terms: [
{ kind: 'move', territory: 'imperial_basin', minimum: 1 }, { kind: 'move', territory: 'carthag', minimum: 1 },
@@ -239,6 +241,7 @@ export function Truthtrance({
: kind === 'revival' ? { kind, target, ...revivalClaim }
: kind === 'mixedBid' ? { kind, target, turn: bidClaim.turn, lot: bidClaim.lot, mixed: mixedBid }
: kind === 'bid' ? { kind, target, ...bidClaim }
: kind === 'phaseBid' ? { kind, target, ...phaseBidClaim }
: kind === 'shipment'
? {
kind,
@@ -423,6 +426,13 @@ export function Truthtrance({
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'phaseBid' ? (
<>
<p className="notice">Yes requires one qualifying ordinary bid before this turn’s Bidding closes while possible. No forbids all qualifying later bids. A bid does not promise a win; winning still requires payment.</p>
{(g.truthPhaseBidAnswers ?? []).map(answer => <div key={answer}>
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'bid' ? (
<>
<p className="notice">Yes requires one qualifying bid in this ordinary lot while possible. No forbids qualifying bids until it closes. A bid does not promise a win; winning still requires the original payment. Keep the funding needed by your answer.</p>
@@ -578,6 +588,7 @@ export function Truthtrance({
<option value="leaderRevival">Bind a named leader revival this turn</option>
<option value="revival">Bind ordinary force revivals this turn</option>
<option value="bid">Bind a bid in this ordinary lot</option>
<option value="phaseBid">Bind a bid this Bidding phase</option>
<option value="mixedBid">Combine current facts and ordinary bids</option>
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
@@ -721,6 +732,13 @@ export function Truthtrance({
{!g.truthRevivalTargets.includes(target) && <p className="notice">Choose a respondent whose native Revival opportunity remains this turn and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.</p>}
<p className="notice" aria-label="Public revival question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'phaseBid' ? (
<>
<PhaseBidClaimFields value={phaseBidClaim} disabled={busy}
onChange={claim => setBidMinimum(claim.minimum)} />
{!g.truthPhaseBidTargets.includes(target) && <p className="notice">Choose a supported current-turn Bidding question and finish pending decisions.</p>}
<p className="notice" aria-label="Public phase bid question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'bid' ? (
<>
<BidClaimFields value={bidClaim} disabled={busy || !g.bidPromiseLot}
@@ -859,6 +877,7 @@ export function Truthtrance({
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'phaseBid' && (!!phaseBidClaimInputError(phaseBidClaim) || !g.truthPhaseBidTargets.includes(target))) ||
(kind === 'turnActions' && (!!turnActionsError || !g.truthTurnActionTargets.includes(target))) ||
(kind === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) ||
(kind === 'mixedTraitorCall' && (!!mixedTraitorError || !g.truthMixedTraitorTargets.includes(target))) ||