Enforce ordinary auction bidding deadlines online

This commit is contained in:
Quintine committed 2026-10-27 00:07:15 +11:00
1 parent f76c8dfb93
commit f548039cd8
17 files changed
+1363 -33

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
'use client';
import { useEffect, useState } from 'react';
import type { AuctionClockView } from '@/game/auction-clock';
/** Browser time only animates the latest public server frame. It never submits
* an auction action or decides that an authoritative deadline has expired. */
export function AuctionCountdown({ clock, playerName, paused = false }: {
clock: AuctionClockView | null;
playerName: string;
paused?: boolean;
}) {
const [local, setLocal] = useState<{ clock: AuctionClockView; remainingMs: number } | null>(null);
const running = !!clock?.event && clock.running && !paused;
useEffect(() => {
if (!clock || !running || clock.remainingMs <= 0) return;
const startedAt = performance.now();
const timer = setInterval(() => {
const remainingMs = Math.max(0, clock.remainingMs - Math.max(0, performance.now() - startedAt));
setLocal({ clock, remainingMs });
if (remainingMs === 0) clearInterval(timer);
}, 200);
return () => clearInterval(timer);
}, [clock, running]);
if (!clock) return null;
const remainingMs = running && local?.clock === clock ? local.remainingMs : Math.max(0, clock.remainingMs);
const seconds = Math.ceil(remainingMs / 1000);
const status = clock.event !== null && seconds === 0
? `${paused ? 'Auction paused. ' : ''}Awaiting server resolution.`
: clock.event === null
? `${paused ? 'Auction paused. ' : ''}Clock starts when ordinary bidding is ready.`
: paused ? 'Auction paused.'
: !clock.running ? 'Bidding clock held while the current game decision finishes.'
: `${playerName} may bid or pass.`;
return <section className="notice space-y-2" aria-label="Ordinary auction countdown" data-testid="auction-countdown">
<output className="block">{status}</output>
<p role="timer" aria-live="off" aria-atomic="true" aria-label={`${playerName}’s remaining bidding time`}
className="font-semibold tabular-nums">{seconds} {seconds === 1 ? 'second' : 'seconds'} {clock.event === null ? 'available' : 'remaining'}</p>
<p className="fine">Ordinary bidding gives each player 10 seconds to bid or pass. The server controls the deadline and resolves expiry.</p>
<p className="fine">Online convention: if passing would violate a binding Truthtrance answer, the server submits only the required legal bid.
If that bid wins, its normal payment is still owed.</p>
</section>;
}
+7 -1
View File
@@ -262,6 +262,7 @@ import {
splitLocation,
} from '@/game/board';
import type { Action, GameView, Plan } from '@/game/engine';
import { AuctionCountdown } from './auction-countdown';
import { PLAYER_CIRCLE_SECTORS } from '@/game/player-positions';
import { SeatAutopilot } from './seat-autopilot';
import {
@@ -2088,6 +2089,11 @@ export function GameTable({
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
{g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />}
{g.status === 'playing' && <TurnActionPromises game={g} busy={busy} act={act} fillPlan={fillBattlePlan} />}
{g.status === 'playing' && g.phase === 3 && g.auction && (
<AuctionCountdown clock={g.auctionClock}
playerName={g.players.find(player => player.id === g.auction!.active)?.name ?? 'The current bidder'}
paused={g.roomControl?.paused || g.roomControl?.closed} />
)}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
@@ -6540,7 +6546,7 @@ export function GameTable({
<p>
Still in progress: expansion factions and modules, advanced rules,
several special cards, optional faction reactions, ally support,
auction deadlines, and a printed-board connectivity audit.
special-auction timing, and a printed-board connectivity audit.
</p>
<a href="/rules">
Open the rules coverage ledger <ArrowRight size={14} />