Add current-lot Truthtrance bid commitments

This commit is contained in:
Quintine committed 2026-10-26 12:44:00 +11:00
1 parent 4ab20a2838
commit bd6e5bbf7f
18 files changed
+1346 -27

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
'use client';
import { parseBidClaim, type BidClaim } from '@/game/bid-promises';
import { Input } from './ui/input';
export function bidClaimInputError(value: unknown): string | null {
try { parseBidClaim(value); return null; }
catch (error) { return error instanceof Error ? error.message : 'Choose a positive whole bid minimum.'; }
}
/** The parent supplies the public current lot; only the minimum is editable. */
export function BidClaimFields(props: {
value: BidClaim;
onChange: (claim: BidClaim) => void;
disabled?: boolean;
}) {
const error = bidClaimInputError(props.value);
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Ordinary lot bid condition">
<legend>Current ordinary-lot bid</legend>
<p>Turn {props.value.turn} · Ordinary lot {props.value.lot}</p>
<label htmlFor="truth-bid-minimum">Minimum bid in spice</label>
<Input id="truth-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-bid-help"
onChange={event => {
if (props.disabled) return;
const next = { ...props.value, minimum: event.currentTarget.valueAsNumber };
if (bidClaimInputError(next)) return;
props.onChange(parseBidClaim(next));
}} />
{error && <p role="alert">{error}</p>}
<p className="fine" id="truth-bid-help">
This asks whether you will place at least one bid of this size from now until this lot closes.
It does not promise a win. If you win, the original payment rules still apply. Ordinary bank auctions only;
optional modules and native special auctions are excluded.
</p>
</fieldset>;
}
+62
View File
@@ -0,0 +1,62 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { bidClaimText, bidPromiseModeSupported } from '@/game/bid-promises';
import { Button } from './ui/button';
type BidPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
/** The server's current guide owns bid feasibility and funding. This leaf only
* checks that its personalized event and public action opportunity stay live. */
export function BidPromiseGuideButton(props: BidPromiseProps) {
const game = props.game, guide = game.bidPromiseGuide, lot = game.bidPromiseLot;
if (!guide || !lot) return null;
const actor = game.me, turn = lot.turn, number = lot.lot;
const action = guide.action, event = action.bidPromiseEvent, signature = JSON.stringify(action);
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === current.me);
return !props.busy && current === game && bidPromiseModeSupported(current) && current.status === 'playing' && current.phase === 3 &&
current.me === actor && current.auction?.active === actor &&
current.turn === turn && current.bidPromiseLot?.turn === turn && current.bidPromiseLot.lot === number &&
current.bidPromiseGuide === guide && current.bidPromiseGuide.action === action && JSON.stringify(action) === signature &&
typeof event === 'string' && (action.type === 'bid' || action.type === 'passBid') &&
current.bidPromises.some(promise => promise.event === event && promise.player === actor &&
promise.turn === turn && promise.lot === number && !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="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 answers`
: 'Pass this bid while honoring your answers'}
</Button>;
}
export function BidPromises(props: BidPromiseProps) {
const own = props.game.bidPromises.filter(promise => promise.player === props.game.me);
if (!own.length) return null;
const waiting = own.some(promise => !promise.fulfilled && !promise.released &&
promise.turn === props.game.turn && promise.lot === props.game.bidPromiseLot?.lot);
return <section className="notice space-y-3" aria-label="Your Truthtrance bid promises" id="bid-promise-guidance">
<h3>Your bid promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}, ordinary lot {promise.lot}: You answered {promise.answer ? 'Yes' : 'No'} to {bidClaimText(promise)}.
{promise.released ? ' Released: this commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding for this lot.'}
</li>)}</ul>
<p>
Yes requires one qualifying bid from your answer until this lot closes, while it remains possible.
No forbids every later bid at or above that minimum through the close of this lot.
A qualifying declaration fulfills this answer even if another player wins.
If you win, the original payment rules still apply.
</p>
<p className="fine">
<a href="/rules#truthtrance-bid">Bid promise rules</a>.{' '}
These commitments concern the current ordinary bank lot. Optional modules and native special auctions are excluded.
Ordinary bids keep their existing funding and payment rules.
</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>}
<BidPromiseGuideButton {...props} />
</section>;
}
+2
View File
@@ -24,6 +24,7 @@ import {
liveShipmentPromises,
} from '@/game/shipment-promises';
import { ShipmentPromises } from './shipment-promises';
import { BidPromises } from './bid-promises';
import { HomeworldShipment } from './homeworld-shipment';
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
import { CaladanReinforcement } from './caladan-reinforcement';
@@ -2030,6 +2031,7 @@ export function GameTable({
{g.status === 'playing' && g.phase < 5 && (
<ShipmentPromises game={g} act={act} busy={busy} />
)}
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
+26 -3
View File
@@ -1,5 +1,7 @@
'use client';
import { MovementClaimFields } from './movement-claim-fields';
import { BidClaimFields, bidClaimInputError } from './bid-claim-fields';
import type { BidClaim } from '@/game/bid-promises';
import { TraitorDecisionAnswers } from './traitor-decision-promises';
import { validMovementClaim, type MovementClaim } from '@/game/movement-promises';
import { TruthShipmentQuestion } from './truth-shipment-question';
@@ -53,8 +55,11 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid'
>('fact');
const [bidMinimum, setBidMinimum] = useState(1);
const bidClaim: BidClaim = { turn: g.bidPromiseLot?.turn ?? g.turn,
lot: g.bidPromiseLot?.lot ?? 1, minimum: bidMinimum };
const [movement, setMovement] = useState<MovementClaim>({ territory: 'carthag', minimum: 6 });
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
const [mixed, setMixed] = useState<MixedShipmentExpression>({
@@ -93,6 +98,7 @@ export function Truthtrance({
}
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
: kind === 'movement' ? { kind, target, ...movement }
: kind === 'bid' ? { kind, target, ...bidClaim }
: kind === 'shipment'
? {
kind,
@@ -200,6 +206,13 @@ export function Truthtrance({
</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>
{(g.truthBidAnswers ?? []).map(answer => <div key={answer}>
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'movement' ? (
<>
<p className="notice">Yes requires one matching ground movement before your turn ends, while possible. No forbids every matching move through later Hajr or Ornithopter opportunities. Separate smaller groups do not add together. The No-Field marker itself is not a physical force counter.</p>
@@ -317,6 +330,7 @@ export function Truthtrance({
Verified current facts
</option>
<option value="movement">Bind ground movement this turn</option>
<option value="bid">Bind a bid in this ordinary lot</option>
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
<option value="shipment">
Bind a shipment from reserves
@@ -332,7 +346,15 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'traitorCall' ? (
{kind === 'bid' ? (
<>
<BidClaimFields value={bidClaim} disabled={busy || !g.bidPromiseLot}
onChange={claim => setBidMinimum(claim.minimum)} />
<p className="fine">One accepted bid from now until this lot closes. Earlier bids do not count. A larger opposing bid may make Yes impossible; your own pass, lower bid or voluntary spending cannot evade it.</p>
{!g.truthBidTargets.includes(target) && <p className="notice">Choose a bidder with an unused opportunity in the current ordinary lot and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported; other auctions and module compositions remain unfinished.</p>}
<p className="notice" aria-label="Public bid question preview">{truthQuestionText(question, leaderName, locationName)}</p>
</>
) : kind === 'traitorCall' ? (
<>
<p className="notice">Choose a still-unsubmitted voter in the current revealed battle. A definite answer binds that player’s original declaration, without reopening any sealed vote or promising that a later response will allow it.</p>
{!g.truthTraitorTargets.includes(target) && <p>Reveal both plans and choose a voter who has not submitted their Traitor decision.</p>}
@@ -429,6 +451,7 @@ export function Truthtrance({
'Ask publicly',
{ type: 'truthAsk', question },
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
(kind === 'mixedShipment' &&
(!!invalidMixed || !shipmentAvailable)) ||
@@ -449,7 +472,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
<details className="notice truthtrance-history">
<summary>Truthtrance answers · Turn {g.turn}</summary>
<p className="fine">
Structured battle-plan and supported reserve-shipment answers are
Structured battle-plan, ordinary-bid and supported reserve-shipment answers are
enforced. Current-turn freeform promises remain binding when possible,
but their automatic enforcement is unfinished.
</p>