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

+7
View File
@@ -14,6 +14,13 @@ npm run dev -- --host 0.0.0.0
Open `http://localhost:3000`. Create a room, share its eight-character invitation, and add human or AI seats. The host can configure Easy, Medium, Hard and Brutal AI opponents. Current calibration demonstrates a substantially weaker Easy policy; it does not yet demonstrate a consistent ordering among the upper three levels.
New ordinary-auction lots show a **ten-second bidding countdown**. Native
inspection and response decisions hold the clock. At expiry, the server passes
or submits only a bid required by a binding Truthtrance answer; normal payment
remains due. Administrative controls re-arm time after resumption. Already-open
older unstamped lots retain their saved timing. See the
[time-limit scope and online conventions](docs/AUCTION_TIME_LIMIT.md).
For Advanced play, choose **Rules → Advanced preview** before creating a room,
or use the same selector as host in an existing lobby. Everyone must
ready again after changing rules, then the host chooses **Begin Advanced preview**.
+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} />
+8 -3
View File
@@ -1,4 +1,4 @@
import { continueRoomAutomatic, continueRoomBots } from './rooms';
import { continueRoomAutomatic, continueRoomAuctionClock, continueRoomBots } from './rooms';
// Coalesce polls and control requests inside this isolate. Database CAS fences
// other isolates, restarts, seat recovery and human actions.
@@ -7,8 +7,13 @@ export function resumeRoom(code: string): Promise<void> {
const existing = continuations.get(code);
if (existing) return existing;
const pending = (async () => {
await continueRoomAutomatic(code);
await continueRoomBots(code);
await Promise.all([
(async () => {
await continueRoomAutomatic(code);
await continueRoomBots(code);
})(),
continueRoomAuctionClock(code, undefined, true),
]);
})().finally(() => {
if (continuations.get(code) === pending) continuations.delete(code);
});
+112 -22
View File
@@ -6,6 +6,9 @@ import {
joinGame,
newPlayer,
viewGame,
synchronizeAuctionClock,
advanceAuctionClock,
hasAuctionClockOpportunity,
RuleError,
type Action,
type Game,
@@ -266,16 +269,24 @@ export async function createRoom(
throw error;
}
}
type RoomControlRow = { room_control: string };
type RoomControlRow = { room_control: string; closure_revision?: number };
// SQLite constructs an explicit public allowlist, consistent with delegation views.
const roomControlProjection = `json_patch(json_object(
'paused', json(CASE WHEN c.paused = 1 THEN 'true' ELSE 'false' END),
'joinLocked', json(CASE WHEN c.join_locked = 1 THEN 'true' ELSE 'false' END),
'revision', COALESCE(c.revision, 0), 'updatedAt', c.updated_at
), CASE WHEN EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) THEN '{"closed":true}' ELSE '{}' END) AS room_control`;
const roomClosureRevision = `COALESCE((SELECT revision FROM room_closures WHERE room_code = rooms.code), 0) AS closure_revision`;
const roomClockControlFence = `AND COALESCE((SELECT revision FROM room_controls WHERE room_code = rooms.code), 0) = ?
AND COALESCE((SELECT revision FROM room_closures WHERE room_code = rooms.code), 0) = ?`;
function publicRoomControl(row: RoomControlRow): RoomControl {
return JSON.parse(row.room_control) as RoomControl;
}
function roomClockAuthority(row: RoomControlRow) {
const controlRevision = publicRoomControl(row).revision;
const closureRevision = row.closure_revision ?? 0;
return { controlRevision, closureRevision, controlEpoch: `${controlRevision}:${closureRevision}` };
}
function attachRoomControl(view: GameView, row: RoomControlRow) {
const control = publicRoomControl(row);
// Untouched rooms retain the ordinary game-view shape. Once an administrator
@@ -284,7 +295,7 @@ function attachRoomControl(view: GameView, row: RoomControlRow) {
}
async function readRoomSnapshot(code: string) {
const row = await db()
.prepare(`SELECT rooms.state, rooms.version, ${roomControlProjection}
.prepare(`SELECT rooms.state, rooms.version, ${roomControlProjection}, ${roomClosureRevision}
FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code WHERE rooms.code = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)`)
.bind(code)
.first<{ state: string; version: number } & RoomControlRow>();
@@ -294,7 +305,7 @@ async function readRoomSnapshot(code: string) {
}
const g: Game = JSON.parse(row.state);
g.version = row.version;
return { game: g, control: publicRoomControl(row) };
return { game: g, control: publicRoomControl(row), ...roomClockAuthority(row) };
}
export async function readRoom(code: string) {
const row = await db()
@@ -442,7 +453,15 @@ export async function act(
action: Action,
clock: RoomsClock = roomsClock,
) {
const { game: current, control } = await readRoomSnapshot(code);
// Authenticate before a trusted preflight can advance the shared table.
const seat = await db().prepare('SELECT player_id FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0')
.bind(code, auth.playerId, auth.tokenHash).first();
if (!seat) {
await requireRoomNotRemoved(code);
throw new RuleError('Your seat could not be verified.');
}
await continueRoomAuctionClock(code, clock);
const { game: current, control, controlEpoch, controlRevision, closureRevision } = await readRoomSnapshot(code);
const takingBackControl =
action?.type === 'setAutopilot' && action.difficulty === null;
if (control.closed) throw new RoomClosedError();
@@ -460,14 +479,14 @@ export async function act(
throw new RuleError('Take back control before making a game decision.');
// This is a wake-up request, not another game action. The route schedules the
// persisted worker; repeated or early wake-ups cannot bypass its deadline.
if (action.type === 'advanceBots') return readSeatView(code, auth);
const next = applyAction(current, id, action);
queueRoomBots(next, current, clock.now());
next.version++;
if (action.type === 'advanceBots') return readSeatView(code, auth, clock);
const now = clock.now();
const next = synchronizeAuctionClock(applyAction(current, id, action), now, controlEpoch, control.paused || control.closed);
queueRoomBots(next, current, now);
next.version++;
const update = db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND (? = 1 OR NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND (? = 1 OR NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) ${roomClockControlFence}`,
)
.bind(
JSON.stringify(next),
@@ -478,6 +497,8 @@ export async function act(
code,
auth.playerId,
auth.tokenHash,
controlRevision,
closureRevision,
);
const results =
action.type === 'setAutopilot'
@@ -497,14 +518,14 @@ export async function act(
'Another action arrived first. Your view has refreshed; try again.',
);
}
return readSeatView(code, auth);
return readSeatView(code, auth, clock);
}
/** Check the credential and load its private state in one database statement. */
export async function readSeatView(code: string, auth: SeatAuth) {
const row = await db()
/** Verify the credential before clock preflight, then project the current private row. */
export async function readSeatView(code: string, auth: SeatAuth, clock: Pick<RoomsClock, 'now'> = roomsClock) {
const load = () => db()
.prepare(
`SELECT rooms.state, rooms.version, ${roomControlProjection},
`SELECT rooms.state, rooms.version, ${roomControlProjection}, ${roomClosureRevision},
CASE WHEN COUNT(delegations.grant_id) = 0 THEN NULL ELSE
json_group_array(json_object(
'grantId', delegations.grant_id,
@@ -547,13 +568,25 @@ export async function readSeatView(code: string, auth: SeatAuth) {
seat_ai_delegations: string | null;
}
>();
let row = await load();
if (!row) {
await requireRoomNotRemoved(code);
throw new RuleError('Your seat could not be verified.');
}
// Never resume a room for an unverified viewer. Reload through the same
// credential boundary if arming or a due native action changes its version.
if (await continueRoomAuctionClock(code, { now: () => clock.now(), sleep: ms => roomsClock.sleep(ms) })) {
row = await load();
if (!row) {
await requireRoomNotRemoved(code);
throw new RuleError('Your seat could not be verified.');
}
}
const g: Game = JSON.parse(row.state);
g.version = row.version;
const view = viewGame(g, auth.playerId) as GameView;
const control = publicRoomControl(row), now = clock.now();
const projected = synchronizeAuctionClock(g, now, roomClockAuthority(row).controlEpoch, control.paused || control.closed);
const view = viewGame(projected, auth.playerId, now) as GameView;
attachRoomControl(view, row);
if (row.seat_ai_delegations) {
const delegations = JSON.parse(
@@ -571,9 +604,12 @@ export function needsAutomaticRoomRecovery(
battle?: { revealed: boolean; territory: string } | null;
automaticContinuationPending?: boolean;
grummanCollection?: { event: string; stage?: string } | null;
auction?: { clockVersion?: number } | null;
auctionClock?: unknown;
},
) {
return (
state.auction?.clockVersion === 1 || !!state.auctionClock ||
state.automaticContinuationPending === true ||
state.grummanCollection?.stage === 'waiting' ||
!!state.pendingTreacheryDiscard ||
@@ -603,6 +639,58 @@ async function readWorkerRoomSnapshot(code: string) {
}
}
/** One ordinary bid deadline through the existing trusted clock and room CAS.
* A worker may wait once for at most ten seconds, then rereads all authority.
* No durable alarm is claimed: a later authenticated read/action resumes a
* persisted deadline after the process stops. Administrative revision changes
* deliberately rearm a full budget after a pause or closure. A true result
* requests a fresh private read after a commit or a lost authority fence. */
export async function continueRoomAuctionClock(
code: string,
clock: RoomsClock = roomsClock,
wait = false,
): Promise<boolean> {
let changed = false, waited = false;
for (let attempt = 0; attempt < 6; attempt++) {
const snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot || snapshot.control.paused || snapshot.control.closed) return changed;
const { game: current, controlEpoch, controlRevision, closureRevision } = snapshot;
if (!hasAuctionClockOpportunity(current) && !current.auctionClock) return changed;
const now = clock.now();
const next = advanceAuctionClock(current, now, controlEpoch);
const nativeProgress = JSON.stringify(next.auction) !== JSON.stringify(current.auction) ||
next.phase !== current.phase || next.turn !== current.turn;
if (JSON.stringify(next) !== JSON.stringify(current)) {
queueRoomBots(next, current, now);
next.version = current.version + 1;
const result = await db().prepare(
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ?
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
${roomClockControlFence}`,
).bind(JSON.stringify(next), now, code, current.version, controlRevision, closureRevision).run();
if (result.meta.changes !== 1) {
// A lost game/control fence also requires an authenticated caller to
// reload its private row, even when this worker committed no write.
changed = true;
continue;
}
changed = true;
if (nativeProgress || !wait) return changed;
}
const timer = next.auctionClock;
if (!wait || waited || !timer || timer.startedAt === null) return changed;
const delay = Math.max(0, timer.remainingMs - Math.max(0, clock.now() - timer.startedAt));
if (delay <= 0) return changed;
waited = true;
await clock.sleep(Math.min(delay, 10000));
// A human bid, seat change or administrative revision may have won while
// asleep. Only a fresh snapshot can arm, suspend or expire its own event.
}
return changed;
}
/** Persist an old automatic continuation through the same version fence as current actions. */
export async function continueRoomAutomatic(
code: string,
@@ -611,7 +699,7 @@ export async function continueRoomAutomatic(
for (let attempt = 0; attempt < 4; attempt++) {
const snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot) return;
const { game: current, control } = snapshot;
const { game: current, control, controlEpoch, controlRevision, closureRevision } = snapshot;
if (control.paused || control.closed) return;
if (
!needsAutomaticRoomRecovery(current) ||
@@ -621,15 +709,15 @@ export async function continueRoomAutomatic(
current.status === 'finished'))
)
return;
const next = normalizeAutomaticGame(current);
const next = synchronizeAuctionClock(normalizeAutomaticGame(current), clock.now(), controlEpoch);
if (JSON.stringify(next) === JSON.stringify(current)) return;
queueRoomBots(next, current, clock.now());
next.version = current.version + 1;
const result = await db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1) ${roomClockControlFence}`,
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.bind(JSON.stringify(next), clock.now(), code, current.version, controlRevision, closureRevision)
.run();
if (result.meta.changes === 1) return;
}
@@ -645,6 +733,7 @@ export async function continueRoomBots(
clock: RoomsClock = roomsClock,
) {
for (let batch = 0; batch < batches; batch++) {
await continueRoomAuctionClock(code, clock);
let snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot) return;
let current = snapshot.game;
@@ -657,6 +746,7 @@ export async function continueRoomBots(
const delay = (current.botNextActionAt ?? clock.now()) - clock.now();
if (delay > 0) {
await clock.sleep(Math.min(delay, ONLINE_BOT_INTERVAL_MS));
await continueRoomAuctionClock(code, clock);
// A human may have taken control or another worker committed while asleep.
snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot) return;
@@ -671,16 +761,16 @@ export async function continueRoomBots(
}
// Offline simulations retain runBots' fast default. Online persistence makes
// at most one AI decision visible per successful compare-and-swap.
const next = runBots(current, 1);
const next = synchronizeAuctionClock(runBots(current, 1), clock.now(), snapshot.controlEpoch);
if (next.botsPending)
next.botNextActionAt = clock.now() + ONLINE_BOT_INTERVAL_MS;
else delete next.botNextActionAt;
next.version = current.version + 1;
const result = await db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1) ${roomClockControlFence}`,
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.bind(JSON.stringify(next), clock.now(), code, current.version, snapshot.controlRevision, snapshot.closureRevision)
.run();
// A different worker, seat recovery, or Take back control won. Re-read its authority.
if (result.meta.changes !== 1) continue;
+107
View File
@@ -0,0 +1,107 @@
# Ordinary-auction time limit
## Source and scope
**Prototyped / Partial.** Publisher Basic rulebook printed8, Time Limit,
requires a bid within ten seconds of the previous player and treats silence as
a pass. The user-authorized supplied revised Advanced rulebook repeats it on
physical8/printed6. This is a Basic and Advanced rule, not an AI pacing option.
[Publisher rulebook](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=8).
New native ordinary lots use a versioned clock. An already-open older lot with
no stamp retains its saved untimed protocol; the next newly opened native lot
uses the clock. Special silent/once-around procedures retain their own rules.
Pure engine/offline simulation has no wall-clock dependency. Only trusted
room time arms, suspends or expires an online clock; clients cannot supply time.
## Online timing convention
The clock starts when the current bidder can actually bid. Inspection,
Truthtrance, cancellation, payment and other native decisions hold the exact
remaining budget. Their native owners and mandatory continuation stay intact.
An ordinary bid or pass starts ten seconds for the next original bidder;
unrelated polls, chat and repeated requests cannot refresh the deadline.
At the exact deadline the server follows the canonical pass continuation.
A binding Truthtrance answer can legally prohibit passing. Under the standing
14 October choose-recommended policy, provisionally submit only its quoted
lowest legal bid compatible with the whole answer instead of letting silence
evade it. Finite predicate thresholds are quoted with exact private native
bounds; no speculative purchase is executed to search for an amount. This is an
explicit online composition, not a publisher timeout/Truthtrance worked example.
It grants no free purchase, extra bid or strategic escalation; normal payment
is still due if that bid wins. A whole-answer alternative permitting pass keeps
that ordinary pass available. Existing actual impossibility rules remain.
An administrative room-control or closure revision re-arms the current bid
with ten seconds once play resumes. This conservative online convention also
covers a pause that no active client observed. No paused, closed or removed
room advances. Current native interruptions retain their remaining budget.
Source and control epochs are private; the public countdown contains only an
opaque event, current bidder, remaining time and running status.
## Server continuation and controls
The persisted clock binds the exact native turn/lot/bidder/bid/pass/order
source. Version and operational-control fences prevent concurrent workers or
late requests from acting on a superseded bid. Authenticated reads/actions
resume or expire it before accepting a late bid. A bounded background worker
runs alongside existing automatic/AI work; it is not a durable alarm. After a
stopped host, the stored deadline is evaluated on the next authenticated
request. Server restarts neither invent a bid nor reset a game.
The ordinary-auction notice persists. Its local animation is display only;
only the server decides expiry and emits the native action. The notice shows
held/paused/starting states and explains any compulsory Truthtrance bid.
No client-generated pass, hidden-card source or disposable popup is used.
## Evidence and remaining scope
Helper: game/auction-clock.ts. Engine timing/source/expiry and private-to-public
projection: game/engine.ts. Trusted storage scheduling: db/rooms.ts and
room-continuation.ts. Human countdown: components/auction-countdown.tsx and the
native ordinary-auction section of components/game-table.tsx.
## Verified checkpoint — 27 October 2026
The frozen source passes17 files,199/199, types/lint and production build.
Thirteen pure clock, four real-component/display, nine native engine and14
trusted SQLite/fake-clock cases add40 focused checks. Existing bid, auction
retirement, continuation, funding, controls, bot pacing and reference cases
remain in the related union. Exact-deadline, held/resumed timing, source change,
old-lot, both control-revision fences, current-seat authorization, competing
writes and scalar/whole Truth obligations have bounded evidence.
Five native journeys plus one actual local HTTP journey pass, with14 full
GameTable SSR renders and43 HTTP requests. Original Basic source20 and Advanced
source23 expire by canonical pass. Source22 covers forced whole bid3 then pay2
and its real invoice, actual Truth holding8s until resolution, and a voluntary
first alternative5 whose compelled minimum is3. The HTTP room is genuinely
created/joined/readied/started, then reaches ordinary bidding through12 actual
controls; bid1 succeeds and the next bidder's silence records a native pass.
Late bid2 with stale version and forged now0 is rejected. Cookies stay in memory
and are neither logged nor saved. Native original inventories remain.
The first runtime smoke catches a countdown hidden behind the Truth control;
it is moved into persistent notices. Type and lint feedback is repaired. The
first related union reports197/199: its OR-pass test assumed the old lot stayed
open, and its VM concurrency test waited too few cross-context microtasks.
The first now compares actual canonical native pass/retirement and resolves
native aftermath before testing later payment. The second waits an event-loop
barrier while still requiring bots to start before releasing the sleeping
clock. Both exact repaired cases pass, then the related union passes199/199.
V5 retains V4's six passing runtime journeys through a per-file manifest:
only the two failing test files changed; all production code, native fixtures
and the private runtime script are identical. The final source passes quick
checks, related tests and build without recreating that HTTP room again.
Proof/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-auction-clock-proof-v5/`;
reused journeys/HTML: adjacent `20261026-auction-clock-runtime-v4/`.
These are local native/SSR/controlled-display and real HTTP checks, not browser
or deployed acceptance. Special-auction timers, wider module/order interactions,
full-mode acceptance and comprehensive recovery/privacy/custody assurance remain
open. Existing publication/AI gates are preserved.
The stopped local server was started without resetting storage. HTTP proof
uses a dedicated QA room only; no existing game action, reset, deletion, restart
or deployment is performed.
+46 -1
View File
@@ -1,6 +1,6 @@
# Current development status
Updated 26 October 2026. **The full twelve-faction goal is unfinished. Basic
Updated 27 October 2026. **The full twelve-faction goal is unfinished. Basic
is playable. Advanced, expansion-faction and supported optional-module starts
are labelled previews; complete rules/publication acceptance remains gated.**
@@ -16,6 +16,51 @@ open mode gates.
## Current checkpoint and work
**Ordinary-auction ten-second bidding — 27 October. Prototyped / Partial.**
New ordinary lots in Basic and Advanced now use trusted server time. Silence
follows the actual native pass; when a binding Truthtrance answer forbids it,
the provisional online convention submits the lowest legal required bid across
whole-answer alternatives. Native quote bounds select that amount without
speculatively settling purchases. Winning bids retain normal payment.
Inspection, Truthtrance and other native decisions hold remaining time. The
countdown persists while another control owns the table. Administrative room-
control/closure revisions conservatively re-arm ten seconds on resumption,
including unobserved pauses. Those online conventions are explicit. Old open
unstamped lots retain their saved timing; future new lots are timed. Only
trusted room time can advance the clock. Existing online AI pacing runs in
parallel; no strategy or calibration changes are made.
The frozen batch passes **17 files,199/199**, types/lint/build. Thirteen helper,
four display, nine native and14 SQLite/fake-clock cases provide40 new checks.
They cover exact deadlines, native interruption, late/forged-time requests,
minimal compelled bids, actual purchases, source changes, old lots, authenticated
access and existing version/control fences. Six runtime paths pass: five native
journeys plus one real local HTTP room, with14 GameTable SSR renders and43 HTTP
requests. Basic source20 and Advanced source23 time out through actual pass;
source22 covers whole bid3/pay2, held Truth time and alternatives5/3 choosing3.
The public HTTP path reaches bidding through12 actual controls after lobby/start,
accepts bid1, records the next bidder's timeout/pass and rejects late bid2.
Original native inventories remain; no played source state is installed.
Runtime-first feedback catches the hidden held countdown and moves it into the
persistent notice area. Types/lint feedback is repaired. The affected union
then catches two test assumptions: a pass can retire the lot, and cross-context
async callbacks need an event-loop barrier. Both exact repaired cases pass.
V5's manifest proves only those two test files changed after V4's passing runtime;
all production, native fixtures and the runtime script are identical. The six
runtime journeys are reused, then final quick checks,199 tests and build pass.
Proof/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-auction-clock-proof-v5/`;
reused journeys/HTML: adjacent `20261026-auction-clock-runtime-v4/`.
The stopped local server was started without resetting storage and remains
healthy after build (HTTP200). Three dedicated local QA rooms from iteration
are preserved; no existing room was acted on. No reset, deletion, restart or
deployment is performed. Special-auction timing, wider interactions and final
browser/deployed/recovery/privacy/custody acceptance remain; no mode is certified.
[Source, conventions and scope](AUCTION_TIME_LIMIT.md).
**Truthtrance / combined current battle decisions — 26 October. Prototyped / Partial.**
Whole turn-action questions now combine atomic current battle-plan predicates
or the original revealed-battle Traitor declaration with payments and frozen
+47
View File
@@ -1,5 +1,52 @@
# Dune implementation status
## 27 October 2026 — Ordinary-auction ten-second deadline
**Ordinary-auction ten-second bidding — 27 October. Prototyped / Partial.**
New ordinary lots in Basic and Advanced now use trusted server time. Silence
follows the actual native pass; when a binding Truthtrance answer forbids it,
the provisional online convention submits the lowest legal required bid across
whole-answer alternatives. Native quote bounds select that amount without
speculatively settling purchases. Winning bids retain normal payment.
Inspection, Truthtrance and other native decisions hold remaining time. The
countdown persists while another control owns the table. Administrative room-
control/closure revisions conservatively re-arm ten seconds on resumption,
including unobserved pauses. Those online conventions are explicit. Old open
unstamped lots retain their saved timing; future new lots are timed. Only
trusted room time can advance the clock. Existing online AI pacing runs in
parallel; no strategy or calibration changes are made.
The frozen batch passes **17 files,199/199**, types/lint/build. Thirteen helper,
four display, nine native and14 SQLite/fake-clock cases provide40 new checks.
They cover exact deadlines, native interruption, late/forged-time requests,
minimal compelled bids, actual purchases, source changes, old lots, authenticated
access and existing version/control fences. Six runtime paths pass: five native
journeys plus one real local HTTP room, with14 GameTable SSR renders and43 HTTP
requests. Basic source20 and Advanced source23 time out through actual pass;
source22 covers whole bid3/pay2, held Truth time and alternatives5/3 choosing3.
The public HTTP path reaches bidding through12 actual controls after lobby/start,
accepts bid1, records the next bidder's timeout/pass and rejects late bid2.
Original native inventories remain; no played source state is installed.
Runtime-first feedback catches the hidden held countdown and moves it into the
persistent notice area. Types/lint feedback is repaired. The affected union
then catches two test assumptions: a pass can retire the lot, and cross-context
async callbacks need an event-loop barrier. Both exact repaired cases pass.
V5's manifest proves only those two test files changed after V4's passing runtime;
all production, native fixtures and the runtime script are identical. The six
runtime journeys are reused, then final quick checks,199 tests and build pass.
Proof/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-auction-clock-proof-v5/`;
reused journeys/HTML: adjacent `20261026-auction-clock-runtime-v4/`.
The stopped local server was started without resetting storage and remains
healthy after build (HTTP200). Three dedicated local QA rooms from iteration
are preserved; no existing room was acted on. No reset, deletion, restart or
deployment is performed. Special-auction timing, wider interactions and final
browser/deployed/recovery/privacy/custody acceptance remain; no mode is certified.
[Source, conventions and scope](AUCTION_TIME_LIMIT.md).
## 26 October 2026 — Combined current battle decisions
**Truthtrance / combined current battle decisions — 26 October. Prototyped / Partial.**
+13
View File
@@ -1,5 +1,18 @@
# Rule decision index
## Ordinary-auction ten-second deadline — 26 October 2026
Publisher Basic printed8 and supplied Advanced physical8/printed6 require a
bid within ten seconds, otherwise pass. New versioned ordinary lots use trusted
server time and native pass/order/settlement; old open unstamped lots retain
their saved protocol. Native decisions suspend remaining time. The standing
choose-recommended policy provisionally submits only a required legal Truth
bid if passing would violate a binding answer; normal payment remains owed.
Room-control/closure revisions conservatively re-arm ten seconds after play
resumes, including unobserved pauses. These online compositions are explicit,
not publisher timing examples. Special-auction and full-mode gates remain.
[Contract and source](AUCTION_TIME_LIMIT.md).
## Combined final Nexus membership — 26 October 2026
Compose final current-Nexus membership with the whole native turn-action
+102
View File
@@ -0,0 +1,102 @@
export const AUCTION_BID_MS = 10_000;
export type AuctionClock = {
event: string;
source: string;
player: string;
/** Budget remaining at startedAt, or the frozen budget while suspended. */
remainingMs: number;
startedAt: number | null;
controlEpoch: string;
};
export type AuctionClockView = {
/** Null is reserved for a never-armed projection built by the caller. */
event: string | null;
player: string;
remainingMs: number;
running: boolean;
};
export class AuctionClockError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuctionClockError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new AuctionClockError(message);
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
const keys = (value: Record<string, unknown>, expected: string): boolean =>
Object.keys(value).sort().join(',') === expected;
function validateClock(value: unknown): asserts value is AuctionClock {
check(object(value) && keys(value, 'controlEpoch,event,player,remainingMs,source,startedAt') &&
identifier(value.event) && identifier(value.source) && identifier(value.player) && identifier(value.controlEpoch) &&
finite(value.remainingMs) && value.remainingMs >= 0 && value.remainingMs <= AUCTION_BID_MS &&
(value.startedAt === null || finite(value.startedAt)),
'Retain the complete original ordinary-auction clock, finite budget and control epoch.');
}
function remaining(clock: AuctionClock, now: number): number {
if (clock.startedAt === null) return clock.remainingMs;
// A wall-clock rollback cannot consume time or enlarge the retained budget.
const elapsed = Math.max(0, now - clock.startedAt);
return Math.max(0, clock.remainingMs - elapsed);
}
/** Pure quotation. Source/event/epoch and the current time come from the
* integrating engine/store; no timer, random value or I/O is created here. */
export function quoteAuctionClock(input: {
clock?: AuctionClock;
opportunity: { source: string; player: string } | null;
now: number;
blocked: boolean;
controlEpoch: string;
event: string;
}): { clock: AuctionClock | undefined; expired: boolean; delayMs: number | null } {
check(object(input) && (keys(input, 'blocked,controlEpoch,event,now,opportunity') ||
keys(input, 'blocked,clock,controlEpoch,event,now,opportunity')) && finite(input.now) &&
typeof input.blocked === 'boolean' && identifier(input.controlEpoch) && identifier(input.event),
'Supply the exact ordinary-auction opportunity, finite time, blocker and control epoch.');
if (input.clock !== undefined) validateClock(input.clock);
check(input.opportunity === null || object(input.opportunity) && keys(input.opportunity, 'player,source') &&
identifier(input.opportunity.source) && identifier(input.opportunity.player),
'Choose the original ordinary-auction source and current bidder, or no opportunity.');
if (input.opportunity === null) return { clock: undefined, expired: false, delayMs: null };
const { opportunity, now, blocked, controlEpoch } = input;
const previous = input.clock;
let clock: AuctionClock = !previous || previous.source !== opportunity.source || previous.player !== opportunity.player ||
previous.controlEpoch !== controlEpoch ? {
event: input.event, source: opportunity.source, player: opportunity.player,
remainingMs: AUCTION_BID_MS, startedAt: blocked ? null : now, controlEpoch,
} : previous;
const budget = remaining(clock, now);
if (blocked) {
if (clock.startedAt !== null) clock = { ...clock, remainingMs: budget, startedAt: null };
return { clock, expired: false, delayMs: null };
}
if (budget === 0) {
if (clock.remainingMs !== 0 || clock.startedAt !== null) clock = { ...clock, remainingMs: 0, startedAt: null };
return { clock, expired: true, delayMs: null };
}
if (clock.startedAt === null) clock = { ...clock, startedAt: now };
return { clock, expired: false, delayMs: budget };
}
/** Suspended/expired frames retain their original event for the client. */
export function viewAuctionClock(clock: AuctionClock, now: number): AuctionClockView {
validateClock(clock);
check(finite(now), 'Project the ordinary-auction clock at a finite time.');
const remainingMs = remaining(clock, now);
return { event: clock.event, player: clock.player, remainingMs,
running: clock.startedAt !== null && remainingMs > 0 };
}
+120 -2
View File
@@ -1,3 +1,5 @@
import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
type AuctionClock, type AuctionClockView } from './auction-clock';
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn,
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival,
@@ -918,6 +920,8 @@ export type Battle = {
voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon'; must: boolean };
};
export type Auction = {
/** New ordinary lots use trusted online timing; old open lots stay unstamped. */
clockVersion?: 1;
/** Sapho changes this lot's cycle, never physical order or the next opener. */
saphoOrder?: string[];
peekKnown?: boolean;
@@ -2544,6 +2548,8 @@ export type Game = {
nexusMoritaniRetention?: NexusMoritaniRetentionReceipt;
} | null;
auction: Auction | null;
/** Private source and trusted time. Player views expose the countdown only. */
auctionClock?: AuctionClock;
battle: Battle | null;
nexus: boolean;
allianceOffers: Record<string, string>;
@@ -14501,6 +14507,7 @@ function setAuction(g: Game, cards?: Card[]) {
(id) => eligible.includes(id),
)!;
g.auction = {
clockVersion: 1,
...(g.homeworldOccupationPreview ? { occupiedEvent: crypto.randomUUID() } : {}),
cards,
index: 0,
@@ -15877,6 +15884,115 @@ function guardPromisedBid(g: Game, p: Player, action: Action, amount?: number) {
requireRule(!bidRequired(g.bidPromises ?? [], p.id, opportunity),
'Place the qualifying bid required by your Truthtrance Yes before passing or taking a lower bid.');
}
function ordinaryAuctionClockOpportunity(g: Game) {
const auction = g.auction;
if (g.status !== 'playing' || g.phase !== 3 || auction?.clockVersion !== 1 ||
!auction.cards[auction.index] || !g.players.some(player => player.id === auction.active)) return null;
return { player: auction.active, source: JSON.stringify({ turn: g.turn,
index: auction.index, card: auction.cards[auction.index].id, active: auction.active,
bid: auction.bid, bidder: auction.bidder, passed: auction.passed,
opener: auction.opener, order: auction.saphoOrder ?? g.order }) };
}
export function hasAuctionClockOpportunity(g: Game): boolean {
return ordinaryAuctionClockOpportunity(g) !== null;
}
function ordinaryAuctionClockBlocked(g: Game): boolean {
return !!(g.active !== g.auction?.active || g.richeseAuction || g.truthtrance || g.response ||
g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
g.pendingNullentropy || g.pendingKull || g.pendingHomeworldTupileCleanup ||
g.pendingRicheseBetrayal || g.pendingGuildBetrayal ||
g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador ||
g.pendingNexusHarkonnenBetrayal || g.pendingNexusIxianBetrayal || g.pendingNexusIxianReplacement ||
g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove || g.pendingAdvisorMatching ||
pendingNexusTraitors(g));
}
function auctionClockQuote(g: Game, now: number, controlEpoch: string, blocked = false) {
const opportunity = ordinaryAuctionClockOpportunity(g);
try {
return quoteAuctionClock({ clock: g.auctionClock, opportunity, now, controlEpoch,
blocked: blocked || ordinaryAuctionClockBlocked(g),
event: opportunity && (!g.auctionClock || g.auctionClock.source !== opportunity.source ||
g.auctionClock.player !== opportunity.player || g.auctionClock.controlEpoch !== controlEpoch)
? crypto.randomUUID() : g.auctionClock?.event ?? 'no-auction' });
} catch (error) {
if (error instanceof AuctionClockError) throw new RuleError(error.message);
throw error;
}
}
/** Only trusted room time calls this. Pure actions and offline games never
* consult wall time, and legacy unclocked lots are not retroactively timed. */
export function synchronizeAuctionClock(source: Game, now: number, controlEpoch: string, blocked = false): Game {
const quote = auctionClockQuote(source, now, controlEpoch, blocked);
if (JSON.stringify(source.auctionClock) === JSON.stringify(quote.clock)) return source;
const next = { ...source };
if (quote.clock) next.auctionClock = quote.clock;
else delete next.auctionClock;
return next;
}
/** Bid predicates change only at their declared minima. Testing those finite
* breakpoints through detached exact native bounds finds the least compelled
* bid without scanning a large purse or speculatively settling a purchase. */
function minimumCompelledAuctionBid(game: Game, actor: string): Action | null {
const opportunity = bidOpportunity(game), player = getPlayer(game, actor);
if (!opportunity || !game.auction) return null;
const amounts = new Set<number>([game.auction.bid + 1]);
for (const promise of liveBidPromises(game.bidPromises ?? [], actor, opportunity)) amounts.add(promise.minimum);
const visit = (node: TurnActionClaim['mixed']): void => {
if (node.kind === 'action') {
if (node.action.kind === 'bid' && node.action.lot === opportunity.lot) amounts.add(node.action.minimum);
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
for (const promise of liveTurnActionPromises(game.turnActionPromises ?? [], actor, game.turn)) visit(promise.mixed);
const maximum = normalBidMaximum(game, player);
const whole = liveTurnActionPromises(game.turnActionPromises ?? [], actor, game.turn).length > 0;
for (const amount of [...amounts].filter(value => Number.isSafeInteger(value) && value > game.auction!.bid && value <= maximum).sort((a, b) => a - b)) {
const metadata = { turn: game.turn, lot: opportunity.lot, source: opportunity.source,
player: actor, asker: game.players.find(seat => seat.id !== actor)!.id };
const bounds: BidPromise[] = [{ ...metadata, event: 'auction-clock-minimum', minimum: amount, answer: true }];
if (amount < Number.MAX_SAFE_INTEGER)
bounds.push({ ...metadata, event: 'auction-clock-maximum', minimum: amount + 1, answer: false });
const trial = { ...game, bidPromises: [...(game.bidPromises ?? []), ...bounds] };
const action = whole ? turnActionCompletion(trial, actor)?.bid : bidPromiseCompletion(trial, player);
if (action?.type === 'bid' && action.amount === amount) return action;
}
return null;
}
/** A deadline uses the same native pass as a human. If an accepted Truthtrance
* answer forbids passing, execute only its quoted required legal bid instead
* of letting silence evade that answer or make a strategic bid for the seat. */
export function advanceAuctionClock(source: Game, now: number, controlEpoch: string, blocked = false): Game {
const game = synchronizeAuctionClock(source, now, controlEpoch, blocked);
const quote = auctionClockQuote(game, now, controlEpoch, blocked);
if (!quote.expired || !game.auctionClock) return game;
const actor = game.auctionClock.player;
let next: Game, compelled = false;
try { next = applyAction(game, actor, { type: 'passBid' }); }
catch (error) {
if (!(error instanceof RuleError)) throw error;
const whole = liveTurnActionPromises(game.turnActionPromises ?? [], actor, game.turn).length
? turnActionCompletion(game, actor)?.bid : undefined;
const opportunity = bidOpportunity(game);
const required = opportunity && bidRequired(game.bidPromises ?? [], actor, opportunity);
if (!whole && !required) throw error;
const completion = minimumCompelledAuctionBid(game, actor);
requireRule(completion, 'The expired ordinary bid needs a legal completion of its binding Truthtrance answer.');
next = applyAction(game, actor, completion);
compelled = true;
}
log(next, compelled
? `${getPlayer(game, actor).name}'s ten-second bidding time expired. The legal bid required by their binding Truthtrance answer was submitted.`
: `${getPlayer(game, actor).name}'s ten-second bidding time expired; they passed this bid.`);
return synchronizeAuctionClock(next, now, controlEpoch, blocked);
}
function projectedAuctionClock(g: Game, now?: number): AuctionClockView | null {
const opportunity = ordinaryAuctionClockOpportunity(g);
if (!opportunity) return null;
const clock = g.auctionClock;
if (!clock || clock.source !== opportunity.source || clock.player !== opportunity.player)
return { event: null, player: opportunity.player, remainingMs: AUCTION_BID_MS, running: false };
return viewAuctionClock(clock, now ?? clock.startedAt ?? 0);
}
function auctionNext(g: Game) {
const a = g.auction!;
const eligible = g.order.filter((id) => {
@@ -16063,7 +16179,7 @@ function commitNormalAuctionRetirement(g: Game, next: NormalAuctionRetirementQuo
g.auction = null;
finishNormalBidding(g);
} else {
g.auction = next.auction;
g.auction = { ...next.auction, clockVersion: 1 };
g.active = next.active;
if (next.offer?.kind === 'ixTechnology') g.decision = next.offer;
else if (next.offer) offerAuctionPeek(g);
@@ -41412,7 +41528,7 @@ function applyActionInner(
}
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
export function viewGame(state: Game, id: string, now?: number) {
revivalOrderIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
@@ -41518,6 +41634,7 @@ export function viewGame(state: Game, id: string) {
? findReachableBattlePlan(g, me)
: null;
return {
auctionClock: projectedAuctionClock(g, now),
harkonnenExchangeInspection: projectedHarkonnenInspection(g, id),
ecazPoisonIncome: (g.ecazPoisonIncome ?? []).filter((income) => income.player === id)
.map(({turn, phase, amount, count}) => ({turn, phase, amount, count})),
@@ -42648,6 +42765,7 @@ export function viewGame(state: Game, id: string) {
: null,
auction: g.auction
? {
...(g.auction.clockVersion === 1 ? { clockVersion: 1 as const } : {}),
bid: g.auction.bid,
bidder: g.auction.bidder,
active: g.auction.active,
+25 -2
View File
@@ -45,7 +45,7 @@ export const PHASE_HELP = [
'Complete the table’s storm dials or card reveal. After the distance is revealed, play any storm cards and confirm movement.',
'Reveal a spice blow, resolve worms and card responses, then finish any Nexus and Fremen rides.',
'Claim charity once if below two spice. Advanced Bene Gesserit receives two spice regardless of wealth. Then mark yourself ready.',
'Bid or pass in turn. A winning bidder chooses how to pay before income and bonus-card responses resolve.',
'Bid or pass in turn. New ordinary lots allow ten seconds per bidder while native decisions hold the clock. A winning bidder chooses how to pay before income and bonus-card responses resolve.',
'Return eligible forces and leaders from the tanks to your reserves. New Advanced Revival phases begin from the First Player counterclockwise; Sapho may change unfinished positions. Finish your own force and leader choices before the next player acts. Basic and already-opened older phases retain their current readiness timing.',
'Ship before moving. Select a destination sector and, for a move, the forces leaving one territory.',
'Choose a battle, resolve faction powers, seal plans, decide traitors, then choose which winning cards to keep.',
@@ -126,6 +126,7 @@ const phaseDetails = [
[
'Before each auction, resolve the Atreides foresight response. The card stays hidden until that response is allowed. Cancellation affects this card only.',
'The highlighted player may raise the bid or pass. A new high bid clears previous passes.',
'New ordinary lots give the current bidder ten seconds once bidding is ready. Silence becomes a native pass; if passing would break a binding Truthtrance answer, the online convention submits only its required legal bid. Native decisions hold remaining time. Administrative controls re-arm the clock after resumption. Old open unstamped lots retain their saved protocol.',
'Holding Karama permits a bid above your available spice. If outbid, keep the card. A winning bid above your funds requires Karama.',
'After everyone else passes, the winner chooses spice payment or Karama. The table then resolves applicable Emperor-income and Harkonnen-bonus responses.',
'In the fresh local ixian-betrayal preview, native Ixian Bidding inspection and Advanced Technology first finish their original Karama responses, then pause for neutral Nexus acknowledgements before their effects. A legal rival Use prevents only the current attempt; all required passes allow it. See Ixian Nexus Betrayal for both alternatives, private-card limits and the unchanged public release gate.',
@@ -2573,7 +2574,7 @@ export const RULE_TOPICS: RuleTopic[] = [
: phase === 4
? ['ecaz-ambassadors', 'tleilaxu-revival']
: phase === 3
? ['ix-technology', 'nexus-ixian-betrayal', 'nexus-ixian-replacement', 'richese-cards']
? ['ordinary-auction-clock', 'ix-technology', 'nexus-ixian-betrayal', 'nexus-ixian-replacement', 'richese-cards']
: phase === 1
? ['card-harvester', 'faction-fremen']
: phase === 0
@@ -2581,6 +2582,28 @@ export const RULE_TOPICS: RuleTopic[] = [
: undefined,
}),
),
{
id: 'ordinary-auction-clock',
title: 'Ordinary auction: ten seconds to bid',
category: 'Turn phases', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'The server times each new ordinary bidding opportunity and uses the native pass when its ten seconds expire.',
steps: [
'Watch the persistent ordinary-auction countdown. Bid or pass before it reaches zero; server time decides the deadline. The local display never submits a pass.',
'Inspection, Truthtrance, cancellation, payment and other current native decisions hold the remaining budget. Unrelated polls do not grant more time. The next actual bidder receives ten seconds.',
'If passing would violate a binding Truthtrance answer, the explicit online convention submits only its required legal bid. Normal payment is owed if that bid wins; no extra strategic bid is chosen.',
'Administrative room-control or closure changes conservatively re-arm ten seconds once play resumes, including pauses no client observed. Paused, closed and removed rooms do not progress.',
'An already-open older unstamped lot keeps its saved untimed protocol; the next new native lot is timed. Pure offline games do not use wall time. A stored online deadline is evaluated on the next authenticated request after a stopped host.',
'Silent and Once-Around procedures retain their own timing. Full special-auction, browser, deployed and comprehensive acceptance remain unfinished.',
],
related: ['bidding', 'truthtrance-bid', 'truthtrance-turn-actions', 'ai-pacing'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Versioned native source, trusted clock, held budget, canonical pass and required-Truth bid use the original room and native rule paths.', evidence: ['game/auction-clock.ts', 'game/engine.ts', 'db/rooms.ts', 'db/room-continuation.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Persistent countdown distinguishes starting, running, interrupted, paused and server-resolution states; no client action is dispatched.', evidence: ['components/auction-countdown.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing online AI pacing runs alongside the deadline; no strategy refinement or calibration is added.', evidence: ['db/room-continuation.ts', 'db/rooms.ts', 'tests/bot-pacing.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Printed Basic/Advanced source, provisional online conventions and legacy timing boundaries are explicit.', evidence: ['docs/AUCTION_TIME_LIMIT.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, native, display and trusted-room clock tests; final hosted and full-mode acceptance remains.', evidence: ['tests/auction-clock.test.ts', 'tests/auction-clock-runtime.test.ts', 'tests/auction-countdown.test.tsx', 'tests/rooms-auction-clock.test.ts'] },
],
},
{
id: 'storm-cards',
title: 'Storm Cards: forecast and revealed faces',
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, advanceAuctionClock, hasAuctionClockOpportunity, synchronizeAuctionClock,
viewGame } from '../game/engine';
import { createTruthBidFixture as fixture, declareTruthBid as declare, askTruthBid as ask,
answerTruthBid as answer, bindTruthBid as bind, continueTruthBidLot as closeLot,
truthBidInventory as inventory, truthBidPlayer as player } from './fixture-truth-bid';
import { createTruthTurnPreparationFixture as preparation, bindTruthTurnPreparation as bindPreparation,
truthTurnPreparationBid as bid, truthTurnPreparationGroup as group,
truthTurnPreparationPayment as payment, settleTruthTurnPreparation as settle } from './fixture-truth-turn-preparation';
const epoch = '0:0';
void test('native Basic and Advanced original auction lots expire at ten seconds through the canonical pass', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced }), before = structuredClone(f.game), actor = f.game.auction!.active;
assert.equal(f.game.auction!.clockVersion, 1); assert.equal(f.game.auctionClock, undefined);
assert.equal(hasAuctionClockOpportunity(f.game), true);
const armed = synchronizeAuctionClock(f.game, 1_000, epoch);
const early = advanceAuctionClock(armed, 10_999, epoch);
assert.deepEqual(early, armed); assert.deepEqual(f.game, before);
const expected = applyAction(f.game, actor, { type: 'passBid' });
const expired = advanceAuctionClock(early, 11_000, epoch);
assert.deepEqual(expired.auction, expected.auction); assert.equal(expired.active, expected.active);
assert.notEqual(expired.auctionClock!.event, armed.auctionClock!.event);
assert.equal(expired.auctionClock!.remainingMs, 10_000); assert.equal(expired.auctionClock!.startedAt, 11_000);
assert.match(expired.log.at(-1)!.text, /ten-second bidding time expired; they passed/);
assert.deepEqual(inventory(expired), f.originalCards);
}
});
void test('a real raised bid starts the next bidder clock while unchanged polls cannot refresh it', () => {
const f = fixture(), actor = f.game.auction!.active;
let game = synchronizeAuctionClock(f.game, 1_000, epoch);
const original = game.auctionClock!.event;
game = applyAction(game, actor, { type: 'bid', amount: 1, allyPayment: 0 });
game = synchronizeAuctionClock(game, 5_000, epoch);
const next = game.auctionClock!; assert.notEqual(next.event, original);
assert.equal(next.player, game.auction!.active); assert.equal(next.startedAt, 5_000);
const observed = advanceAuctionClock(game, 14_999, epoch); assert.equal(observed, game);
assert.equal(observed.auctionClock, next);
const expired = advanceAuctionClock(observed, 15_000, epoch);
assert.ok(expired.auction?.passed.includes(next.player) || expired.auction?.index !== game.auction!.index);
assert.deepEqual(inventory(expired), f.originalCards);
});
void test('actual Truthtrance suspends the remaining bid budget and the resolved native question resumes it', () => {
const f = fixture({ window: 'active' });
let game = synchronizeAuctionClock(f.game, 1_000, epoch);
game = declare(f, game).game; game = synchronizeAuctionClock(game, 3_000, epoch);
assert.equal(game.auctionClock!.remainingMs, 8_000); assert.equal(game.auctionClock!.startedAt, null);
const event = game.auctionClock!.event, waiting = advanceAuctionClock(game, 50_000, epoch);
assert.deepEqual(waiting, game); assert.equal(waiting.auction!.active, f.target);
game = ask(waiting, f.asker, f.target, 4); game = answer(game, f.target, 'no');
game = synchronizeAuctionClock(game, 50_000, epoch);
assert.equal(game.auctionClock!.event, event); assert.equal(game.auctionClock!.remainingMs, 8_000);
assert.equal(game.auctionClock!.startedAt, 50_000);
assert.equal(advanceAuctionClock(game, 57_999, epoch), game);
const expired = advanceAuctionClock(game, 58_000, epoch);
assert.match(expired.log.at(-1)!.text, /passed this bid/); assert.deepEqual(inventory(expired), f.originalCards);
});
void test('a timeout cannot evade a native Truthtrance Yes and submits only its required bid before normal paid purchase', () => {
const f = fixture({ window: 'active' });
let game = bind(f, 'yes', 4).game; game = synchronizeAuctionClock(game, 1_000, epoch);
const before = structuredClone(game); game = advanceAuctionClock(game, 11_000, epoch);
assert.equal(before.auction!.bid, 0); assert.equal(game.auction!.bid, 4); assert.equal(game.auction!.bidder, f.target);
assert.equal(player(game, f.target).spice, 10, 'The bid is a declaration, not a free or prepaid purchase');
assert.match(game.log.at(-1)!.text, /legal bid required by their binding Truthtrance answer was submitted/);
game = closeLot(game); assert.equal(player(game, f.target).spice, 6);
assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.ok(game.bidPromises!.every(promise => promise.fulfilled)); assert.deepEqual(inventory(game), f.originalCards);
});
void test('a whole bid AND payment deadline selects the required bid even when its voluntary guide offers payment first', () => {
const f = preparation();
const mixed = group('and', bid(f.lot!, 3), payment(f.recipient));
let game = bindPreparation(f, 'yes', mixed).game;
assert.equal(viewGame(game, f.target).turnActionGuide!.action.type, 'bribe');
game = synchronizeAuctionClock(game, 1_000, epoch); game = advanceAuctionClock(game, 11_000, epoch);
assert.equal(game.auction!.bidder, f.target); assert.equal(game.auction!.bid, 3);
assert.equal(game.turnActionPromises![0].preparation!.bid!.maximum, 3);
assert.equal(game.turnActionPromises![0].fulfilled, false);
game = applyAction(game, f.target, f.payment); assert.equal(game.turnActionPromises![0].fulfilled, true);
game = closeLot(game); assert.equal(player(game, f.target).spice, 5);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a whole bid OR payment keeps its legal timeout pass and the remaining real payment alternative', () => {
const f = preparation(), mixed = group('or', bid(f.lot!, 3), payment(f.recipient));
let game = bindPreparation(f, 'yes', mixed).game;
game = synchronizeAuctionClock(game, 1_000, epoch);
const nativePass = applyAction(game, f.target, { type: 'passBid' });
game = advanceAuctionClock(game, 11_000, epoch);
assert.deepEqual(game.auction, nativePass.auction); assert.equal(game.phase, nativePass.phase);
assert.notEqual(game.auction?.bidder, f.target);
assert.match(game.log.at(-1)!.text, /passed this bid/);
assert.equal(game.turnActionPromises![0].fulfilled, false); assert.equal(game.turnActionPromises![0].released, false);
game = settle(game); game = applyAction(game, f.target, f.payment);
assert.equal(game.turnActionPromises![0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a compelled bid uses the lowest legal whole-answer alternative regardless of question order', () => {
const f = preparation();
const mixed = group('or', bid(f.lot!, 5), bid(f.lot!, 3));
let game = bindPreparation(f, 'yes', mixed).game;
assert.equal(viewGame(game, f.target).turnActionGuide!.action.amount, 5);
game = synchronizeAuctionClock(game, 1_000, epoch);
game = advanceAuctionClock(game, 11_000, epoch);
assert.equal(game.auction!.bid, 3); assert.equal(game.auction!.bidder, f.target);
assert.equal(game.turnActionPromises![0].fulfilled, true);
game = closeLot(game); assert.equal(player(game, f.target).spice, 7);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a controlled legacy open lot remains untimed and its actual newly retired next lot adopts the clock', () => {
const f = fixture(), legacy = structuredClone(f.game); delete legacy.auction!.clockVersion;
assert.equal(hasAuctionClockOpportunity(legacy), false);
assert.equal(synchronizeAuctionClock(legacy, 1_000, epoch), legacy);
assert.equal(advanceAuctionClock(legacy, 1_000_000, epoch), legacy);
assert.equal(viewGame(legacy, f.asker).auctionClock, null);
let game = applyAction(legacy, legacy.auction!.active, { type: 'bid', amount: 1, allyPayment: 0 });
game = closeLot(game); assert.equal(game.auction!.index, 1); assert.equal(game.auction!.clockVersion, 1);
assert.equal(hasAuctionClockOpportunity(game), true); assert.equal(synchronizeAuctionClock(game, 1_000_000, epoch).auctionClock!.remainingMs, 10_000);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('only public bidder time is projected and the private physical-card source never enters the clock view', () => {
const f = fixture(); const game = synchronizeAuctionClock(f.game, 1_000, epoch);
for (const owner of game.players) {
const view = viewGame(game, owner.id, 3_250);
assert.deepEqual(Object.keys(view.auctionClock!).sort(), ['event', 'player', 'remainingMs', 'running']);
assert.equal(view.auctionClock!.remainingMs, 7_750); assert.equal(view.auctionClock!.player, game.auction!.active);
assert.ok(!JSON.stringify(view.auctionClock).includes(f.lotCard));
assert.equal(view.auction?.clockVersion, 1);
}
const rearmed = synchronizeAuctionClock(game, 100_000, '2:0');
assert.equal(rearmed.auctionClock!.remainingMs, 10_000); assert.equal(rearmed.auctionClock!.startedAt, 100_000);
assert.equal(game.auctionClock!.startedAt, 1_000);
});
+174
View File
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AUCTION_BID_MS,
AuctionClockError,
quoteAuctionClock,
viewAuctionClock,
type AuctionClock,
} from '../game/auction-clock';
const opportunity = { source: 'ordinary-lot:1', player: 'atreides' };
function input(extra: Partial<Parameters<typeof quoteAuctionClock>[0]> = {}): Parameters<typeof quoteAuctionClock>[0] {
return { opportunity, now: 1_000, blocked: false, controlEpoch: 'control:1', event: 'clock:1', ...extra };
}
function armed(extra: Partial<AuctionClock> = {}): AuctionClock {
return { event: 'clock:1', source: opportunity.source, player: opportunity.player,
remainingMs: AUCTION_BID_MS, startedAt: 1_000, controlEpoch: 'control:1', ...extra };
}
void test('a new ordinary bidder receives exactly ten seconds with the supplied source and event', () => {
assert.equal(AUCTION_BID_MS, 10_000);
const result = quoteAuctionClock(input());
assert.deepEqual(result, { clock: armed(), expired: false, delayMs: 10_000 });
assert.deepEqual(viewAuctionClock(result.clock!, 1_000), {
event: 'clock:1', player: 'atreides', remainingMs: 10_000, running: true,
});
});
void test('unrelated same-source requests retain the original event and deadline instead of resetting the budget', () => {
const clock = armed();
for (const now of [1_001, 2_500, 7_500, 10_999]) {
const result = quoteAuctionClock(input({ clock, now, event: `unused:${now}` }));
assert.equal(result.clock, clock);
assert.equal(result.delayMs, 11_000 - now);
assert.equal(result.expired, false);
assert.equal(result.clock.event, 'clock:1');
}
});
void test('the exact deadline expires once eligible and cannot be extended by a later request', () => {
const clock = armed();
assert.equal(quoteAuctionClock(input({ clock, now: 10_999.5 })).expired, false);
const expired = quoteAuctionClock(input({ clock, now: 11_000 }));
assert.deepEqual(expired, { clock: armed({ remainingMs: 0, startedAt: null }), expired: true, delayMs: null });
assert.deepEqual(viewAuctionClock(expired.clock!, 11_000), {
event: 'clock:1', player: 'atreides', remainingMs: 0, running: false,
});
const repeated = quoteAuctionClock(input({ clock: expired.clock, now: 20_000, event: 'unused:new' }));
assert.equal(repeated.clock, expired.clock);
assert.equal(repeated.expired, true);
assert.equal(repeated.delayMs, null);
});
void test('a blocker suspends the exact retained budget and resumption does not grant a fresh ten seconds', () => {
const clock = armed();
const paused = quoteAuctionClock(input({ clock, now: 3_750, blocked: true }));
assert.deepEqual(paused, { clock: armed({ remainingMs: 7_250, startedAt: null }), expired: false, delayMs: null });
assert.deepEqual(viewAuctionClock(paused.clock!, 50_000), {
event: 'clock:1', player: 'atreides', remainingMs: 7_250, running: false,
});
const stillPaused = quoteAuctionClock(input({ clock: paused.clock, now: 50_000, blocked: true, event: 'unused:pause' }));
assert.equal(stillPaused.clock, paused.clock);
const resumed = quoteAuctionClock(input({ clock: paused.clock, now: 50_000, event: 'unused:resume' }));
assert.deepEqual(resumed, { clock: armed({ remainingMs: 7_250, startedAt: 50_000 }), expired: false, delayMs: 7_250 });
assert.equal(quoteAuctionClock(input({ clock: resumed.clock, now: 57_249 })).expired, false);
assert.equal(quoteAuctionClock(input({ clock: resumed.clock, now: 57_250 })).expired, true);
assert.equal(clock.startedAt, 1_000);
assert.equal(clock.remainingMs, 10_000);
});
void test('a newly observed blocked opportunity starts suspended and never spends its initial budget', () => {
const paused = quoteAuctionClock(input({ blocked: true }));
assert.deepEqual(paused, { clock: armed({ startedAt: null }), expired: false, delayMs: null });
const resumed = quoteAuctionClock(input({ clock: paused.clock, now: 500_000 }));
assert.equal(resumed.clock!.startedAt, 500_000);
assert.equal(resumed.clock!.event, 'clock:1');
assert.equal(resumed.delayMs, 10_000);
});
void test('a blocker suppresses an already elapsed deadline until the native opportunity resumes', () => {
const paused = quoteAuctionClock(input({ clock: armed(), now: 11_000, blocked: true }));
assert.equal(paused.expired, false);
assert.equal(paused.delayMs, null);
assert.deepEqual(paused.clock, armed({ remainingMs: 0, startedAt: null }));
assert.equal(viewAuctionClock(paused.clock!, 50_000).event, 'clock:1');
const resumed = quoteAuctionClock(input({ clock: paused.clock, now: 50_000 }));
assert.equal(resumed.expired, true);
assert.equal(resumed.delayMs, null);
});
void test('source and bidder changes each bind a fresh supplied event and full budget', () => {
const clock = armed({ remainingMs: 500 });
for (const next of [{ source: 'ordinary-lot:2', player: 'atreides' },
{ source: opportunity.source, player: 'emperor' }]) {
const result = quoteAuctionClock(input({ clock, opportunity: next, now: 5_000, event: 'clock:2' }));
assert.deepEqual(result.clock, { ...clock, ...next, event: 'clock:2', remainingMs: 10_000, startedAt: 5_000 });
assert.equal(result.delayMs, 10_000);
assert.equal(result.expired, false);
}
});
void test('a changed control epoch re-arms even when no intermediate admin pause was observed', () => {
const expired = armed({ remainingMs: 0, startedAt: null });
const resumed = quoteAuctionClock(input({ clock: expired, now: 60_000,
controlEpoch: 'control:2', event: 'clock:2' }));
assert.deepEqual(resumed, { clock: armed({ controlEpoch: 'control:2', event: 'clock:2', startedAt: 60_000 }),
expired: false, delayMs: 10_000 });
const suspended = quoteAuctionClock(input({ clock: expired, now: 60_000, blocked: true,
controlEpoch: 'control:2', event: 'clock:2' }));
assert.deepEqual(suspended, { clock: armed({ controlEpoch: 'control:2', event: 'clock:2', startedAt: null }),
expired: false, delayMs: null });
});
void test('removing the native opportunity removes its frame and a later opportunity arms anew', () => {
const result = quoteAuctionClock(input({ clock: armed(), opportunity: null, now: 5_000 }));
assert.deepEqual(result, { clock: undefined, expired: false, delayMs: null });
assert.equal(quoteAuctionClock(input({ clock: result.clock, now: 50_000, event: 'clock:2' })).delayMs, 10_000);
});
void test('clock rollback consumes no retained time and never expands its budget', () => {
const clock = armed({ remainingMs: 2_500 });
const result = quoteAuctionClock(input({ clock, now: 500 }));
assert.equal(result.clock, clock);
assert.equal(result.delayMs, 2_500);
assert.equal(result.expired, false);
assert.equal(viewAuctionClock(clock, 500).remainingMs, 2_500);
const paused = quoteAuctionClock(input({ clock, now: 500, blocked: true }));
assert.deepEqual(paused.clock, { ...clock, startedAt: null });
assert.equal(quoteAuctionClock(input({ clock: paused.clock, now: 5_000 })).delayMs, 2_500);
});
void test('fractional elapsed time remains exact and a projected view never mutates the frame', () => {
const clock = Object.freeze(armed({ remainingMs: 1_000.75, startedAt: 10.25 }));
const before = structuredClone(clock);
assert.equal(viewAuctionClock(clock, 110.5).remainingMs, 900.5);
const paused = quoteAuctionClock(input({ clock, now: 110.5, blocked: true }));
assert.equal(paused.clock!.remainingMs, 900.5);
assert.deepEqual(clock, before);
});
void test('clock validation rejects missing, extra, mistyped or nonfinite stored fields before projecting or discarding', () => {
const clock = armed();
const invalid: unknown[] = [null, [], {}, { ...clock, event: '' }, { ...clock, source: ' source' },
{ ...clock, player: '' }, { ...clock, controlEpoch: '' }, { ...clock, deadline: 11_000 },
...[-1, 10_001, NaN, Infinity, '10000'].map(remainingMs => ({ ...clock, remainingMs })),
...[undefined, NaN, Infinity, '1000'].map(startedAt => ({ ...clock, startedAt }))];
for (const field of Object.keys(clock)) {
const missing: Record<string, unknown> = { ...clock };
delete missing[field];
invalid.push(missing);
}
for (const value of invalid) {
assert.throws(() => viewAuctionClock(value as AuctionClock, 1_000), AuctionClockError);
assert.throws(() => quoteAuctionClock(input({ clock: value as AuctionClock, opportunity: null })), AuctionClockError);
}
});
void test('quotation validates complete native opportunity metadata and finite caller time without coercion', () => {
const valid = input();
const invalid: unknown[] = [null, [], {}, { ...valid, event: '' }, { ...valid, controlEpoch: ' epoch' },
{ ...valid, blocked: 0 }, { ...valid, deadline: 11_000 },
...[undefined, NaN, Infinity, '1000'].map(now => ({ ...valid, now })),
...[undefined, [], {}, { source: '', player: 'atreides' }, { source: 'lot', player: ' atreides' },
{ ...opportunity, bid: 1 }].map(opportunity => ({ ...valid, opportunity }))];
for (const field of Object.keys(valid)) {
const missing: Record<string, unknown> = { ...valid };
delete missing[field];
invalid.push(missing);
}
for (const value of invalid) assert.throws(() => quoteAuctionClock(value as typeof valid), AuctionClockError);
for (const now of [NaN, Infinity, '1000', undefined])
assert.throws(() => viewAuctionClock(armed(), now as number), AuctionClockError);
assert.doesNotThrow(() => quoteAuctionClock(input({ opportunity: null })));
});
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createElement, type ReactNode } from 'react';
import * as jsxRuntime from 'react/jsx-runtime';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
import { AuctionCountdown } from '../components/auction-countdown';
import type { AuctionClockView } from '../game/auction-clock';
const frame = (remainingMs = 10_000, running = true, event: string | null = 'ordinary-bid-event'): AuctionClockView =>
({ event, player: 'p', remainingMs, running });
const html = (clock: AuctionClockView | null, paused = false, playerName = 'Respondent') =>
renderToStaticMarkup(createElement(AuctionCountdown, { clock, playerName, paused }));
void test('auction countdown renders public whole-second time and explains the native deadline and compelled-bid convention', () => {
for (const [remainingMs, label] of [[10_000, '10 seconds'], [9_001, '10 seconds'], [1_001, '2 seconds'], [1_000, '1 second'], [1, '1 second']] as const) {
const clock = frame(remainingMs), before = structuredClone(clock), markup = html(clock);
assert.match(markup, new RegExp(`${label} remaining`)); assert.deepEqual(clock, before);
assert.match(markup, /Respondent may bid or pass/); assert.match(markup, /role="timer" aria-live="off"/);
assert.match(markup, /each player 10 seconds to bid or pass/); assert.match(markup, /server controls the deadline/);
assert.match(markup, /Online convention: if passing would violate a binding Truthtrance answer/);
assert.match(markup, /server submits only the required legal bid/); assert.match(markup, /normal payment is still owed/);
assert.doesNotMatch(markup, /<button|<form|Continue|onClick/);
}
assert.equal(html(null), '');
assert.match(html(frame(), false, '<script>send pass</script>'), /&lt;script&gt;send pass&lt;\/script&gt;/);
});
void test('auction countdown distinguishes unarmed, interrupted, administrative pause and zero without creating an action control', () => {
const unarmed = html(frame(10_000, false, null));
assert.match(unarmed, /Clock starts when ordinary bidding is ready/); assert.match(unarmed, /10 seconds available/);
const interrupted = html(frame(6_200, false));
assert.match(interrupted, /held while the current game decision finishes/); assert.match(interrupted, /7 seconds remaining/);
assert.doesNotMatch(interrupted, /may bid or pass/);
const paused = html(frame(6_200), true); assert.match(paused, /Auction paused/); assert.match(paused, /7 seconds remaining/);
for (const running of [false, true]) for (const roomPaused of [false, true]) {
const expired = html(frame(0, running), roomPaused);
assert.match(expired, /Awaiting server resolution/); assert.match(expired, /0 seconds remaining/);
assert.doesNotMatch(expired, /<button|<form|Continue/);
if (roomPaused) assert.match(expired, /Auction paused/);
}
});
/** Run the real component effects with a controlled monotonic browser clock.
* React markup remains real; no DOM package or server action mock is needed. */
function browserDisplay(initial: Parameters<typeof AuctionCountdown>[0]) {
type Effect = { dependencies: readonly unknown[]; cleanup?: () => void };
const states: unknown[] = [], effects: Effect[] = [], pending: (() => void)[] = [];
const intervals = new Map<number, () => void>();
let stateIndex = 0, effectIndex = 0, now = 0, nextTimer = 0, props = initial;
const exports: { AuctionCountdown?: typeof AuctionCountdown } = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/auction-countdown.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText, {
exports,
performance: { now: () => now },
setInterval(callback: () => void, ms: number) { assert.equal(ms, 200); const timer = ++nextTimer; intervals.set(timer, callback); return timer; },
clearInterval(timer: number) { intervals.delete(timer); },
require(name: string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
assert.equal(name, 'react');
return {
useState(initialValue: unknown) {
const index = stateIndex++;
if (!(index in states)) states[index] = initialValue;
return [states[index], (next: unknown) => { states[index] = next; }];
},
useEffect(callback: () => (() => void) | undefined, dependencies: readonly unknown[]) {
const index = effectIndex++, previous = effects[index];
if (previous && dependencies.every((dependency, i) => Object.is(dependency, previous.dependencies[i]))) return;
pending.push(() => { previous?.cleanup?.(); effects[index] = { dependencies, cleanup: callback() }; });
},
};
},
});
assert.ok(exports.AuctionCountdown);
const render = () => {
stateIndex = 0; effectIndex = 0;
const node: ReactNode = exports.AuctionCountdown!(props);
const markup = renderToStaticMarkup(node);
while (pending.length) pending.shift()!();
return markup;
};
return {
render,
update(next: typeof initial) { props = next; return render(); },
advance(ms: number) { now += ms; for (const callback of intervals.values()) callback(); return render(); },
get ticking() { return intervals.size; },
};
}
void test('local countdown follows elapsed browser time, accepts replacement server frames and stops at server-resolution status', () => {
const display = browserDisplay({ clock: frame(), playerName: 'Respondent' });
assert.match(display.render(), /10 seconds remaining/); assert.equal(display.ticking, 1);
// One delayed callback still accounts for the actual elapsed time.
assert.match(display.advance(5_401), /5 seconds remaining/);
assert.match(display.update({ clock: frame(2_000), playerName: 'Respondent' }), /2 seconds remaining/);
assert.equal(display.ticking, 1); assert.match(display.advance(1_001), /1 second remaining/);
const expired = display.advance(999); assert.match(expired, /Awaiting server resolution/); assert.equal(display.ticking, 0);
assert.doesNotMatch(expired, /<button|<form/);
assert.match(display.update({ clock: { ...frame(), event: 'next-bid-event', player: 'a' }, playerName: 'Next bidder' }), /10 seconds remaining/);
assert.match(display.render(), /Next bidder may bid or pass/); assert.equal(display.ticking, 1);
assert.equal(display.update({ clock: null, playerName: 'Next bidder' }), ''); assert.equal(display.ticking, 0);
});
void test('interrupted and room-paused frames retain their balance across local time and resume from the current server frame', () => {
const display = browserDisplay({ clock: frame(6_200, false), playerName: 'Respondent' });
assert.match(display.render(), /7 seconds remaining/); assert.equal(display.ticking, 0);
assert.match(display.advance(20_000), /7 seconds remaining/);
const paused = { clock: frame(4_500), playerName: 'Respondent', paused: true };
assert.match(display.update(paused), /Auction paused/); assert.equal(display.ticking, 0);
assert.match(display.advance(20_000), /5 seconds remaining/);
assert.match(display.update({ ...paused, paused: false }), /5 seconds remaining/); assert.equal(display.ticking, 1);
assert.match(display.advance(4_500), /Awaiting server resolution/); assert.equal(display.ticking, 0);
const unarmed = display.update({ clock: frame(10_000, false, null), playerName: 'Respondent' });
assert.match(unarmed, /Clock starts when ordinary bidding is ready/); assert.equal(display.ticking, 0);
});
+2 -2
View File
@@ -9,7 +9,7 @@ export function loadRoomContinuation(
rooms: Pick<
typeof Rooms,
'continueRoomAutomatic' | 'continueRoomBots'
>,
> & Partial<Pick<typeof Rooms, 'continueRoomAuctionClock'>>,
) {
const exports = {};
runInNewContext(
@@ -28,7 +28,7 @@ export function loadRoomContinuation(
{
exports,
require: (name: string) => {
if (name === './rooms') return rooms;
if (name === './rooms') return { continueRoomAuctionClock: async () => false, ...rooms };
throw new Error('Unexpected continuation dependency ' + name);
},
},
+290
View File
@@ -0,0 +1,290 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createHash } from 'node:crypto';
import * as engine from '../game/engine';
import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { loadRoomContinuation } from './fixture-room-continuation';
import {
actTruthBid, bindTruthBid, createTruthBidFixture, declareTruthBid, truthBidInventory,
} from './fixture-truth-bid';
function fakeClock(start = 10000) {
let time = start;
const sleeps: number[] = [];
return { now: () => time, advance: (ms: number) => { time += ms; }, sleeps,
sleep: async (ms: number) => { sleeps.push(ms); time += ms; } } satisfies Rooms.RoomsClock & {
advance(ms: number): void; sleeps: number[];
};
}
function fixture(options: { required?: 'scalar' | 'aggregate'; legacy?: boolean } = {}) {
const native = createTruthBidFixture({ window: options.required ? 'active' : 'waiting' });
let game = structuredClone(native.game);
if (options.required === 'scalar') game = bindTruthBid(native, 'yes', 4).game;
if (options.required === 'aggregate') {
game = declareTruthBid(native).game;
game = actTruthBid(game, native.asker, { type: 'truthAsk', question: { kind: 'turnActions',
target: native.target, turn: game.turn, mixed: { kind: 'and', terms: [
{ kind: 'action', action: { kind: 'bid', lot: game.auction!.index + 1, minimum: 4 } },
{ kind: 'action', action: { kind: 'payment', recipient: native.asker, minimum: 2 } },
] } } });
game = actTruthBid(game, native.target, { type: 'truthAnswer', answer: 'yes' });
}
assert.equal(game.auction?.clockVersion, 1, 'The genuine new ordinary lot opts into trusted timing');
// This one controlled metadata omission represents a current saved lot
// predating the clock marker; no card, wallet, force or played action changes.
if (options.legacy) delete game.auction!.clockVersion;
const store = unitStore(), clock = fakeClock(), code = game.code;
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,0,?)').run(code, JSON.stringify(game), clock.now());
const auth = Object.fromEntries(game.players.map(player => {
const tokenHash = createHash('sha256').update(`auction-clock-test:${player.id}`).digest('hex');
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)').run(tokenHash, code, player.id);
return [player.id, { playerId: player.id, tokenHash }];
})) as Record<string, Rooms.SeatAuth>;
return { ...store, clock, code, native, auth, actor: game.auction!.active };
}
void test('trusted private read arms a genuine ordinary lot and projects remaining time without per-poll writes', async () => {
const f = fixture();
try {
const view = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
assert.equal(view.version, 1); assert.equal(view.auctionClock?.player, f.actor);
assert.equal(view.auctionClock?.remainingMs, 10000); assert.equal(view.auctionClock?.running, true);
assert.equal(typeof view.auctionClock?.event, 'string');
const stored = await f.rooms.readRoom(f.code); assert.equal(stored.auctionClock?.controlEpoch, '0:0');
assert.equal(stored.auctionClock?.startedAt, f.clock.now());
assert.equal(f.rooms.needsAutomaticRoomRecovery(stored), true);
assert.equal(f.rooms.needsAutomaticRoomRecovery(view), true);
f.clock.advance(4321);
const later = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
assert.equal(later.version, view.version); assert.equal(later.auctionClock?.remainingMs, 5679);
assert.equal(later.auctionClock?.event, view.auctionClock?.event);
assert.deepEqual(await f.rooms.readRoom(f.code), stored);
} finally { f.sqlite.close(); }
});
void test('trusted deadline acts exactly at ten seconds and a waiting worker sleeps once before one native pass', async () => {
const f = fixture();
try {
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
f.clock.advance(9999); assert.equal(await f.rooms.continueRoomAuctionClock(f.code, f.clock), false);
let game = await f.rooms.readRoom(f.code); assert.equal(game.auction?.active, f.actor);
await f.rooms.continueRoomAuctionClock(f.code, f.clock, true);
assert.deepEqual(f.clock.sleeps, [1]); game = await f.rooms.readRoom(f.code);
assert.ok(game.auction?.passed.includes(f.actor)); assert.notEqual(game.auction?.active, f.actor);
assert.equal(game.version, 2); assert.equal(game.auctionClock?.remainingMs, 10000);
assert.equal(game.auctionClock?.startedAt, f.clock.now());
assert.deepEqual(truthBidInventory(game), f.native.originalCards);
} finally { f.sqlite.close(); }
});
void test('timely authenticated bid starts its successor clock while a missed deadline rejects a late bid before submission', async () => {
for (const timely of [true, false]) {
const f = fixture();
try {
const view = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
f.clock.advance(timely ? 9999 : 10000);
const action: engine.Action = { type: 'bid', amount: 1, allyPayment: 0 };
if (timely) {
const done = await f.rooms.act(f.code, f.auth[f.actor], view.version, action, f.clock);
assert.equal(done.auction?.bid, 1); assert.equal(done.auction?.bidder, f.actor);
assert.notEqual(done.auctionClock?.event, view.auctionClock?.event);
assert.equal(done.auctionClock?.remainingMs, 10000);
} else {
await assert.rejects(f.rooms.act(f.code, f.auth[f.actor], view.version, action, f.clock), /table changed/i);
const game = await f.rooms.readRoom(f.code);
assert.equal(game.auction?.bid, 0); assert.ok(game.auction?.passed.includes(f.actor));
assert.notEqual(game.auction?.active, f.actor);
}
} finally { f.sqlite.close(); }
}
});
void test('a fresh room-module instance resumes the persisted missed deadline on an authenticated read', async () => {
const f = fixture();
try {
const before = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
f.clock.advance(10000);
const restarted = f.restart();
const view = await restarted.readSeatView(f.code, f.auth[f.actor], f.clock);
assert.equal(view.version, before.version + 1); assert.notEqual(view.auctionClock?.player, f.actor);
const game = await restarted.readRoom(f.code); assert.ok(game.auction?.passed.includes(f.actor));
assert.deepEqual(f.clock.sleeps, []);
} finally { f.sqlite.close(); }
});
void test('actual Truthtrance interrupts suspend the retained budget and actual completion resumes it without charging blocked time', async () => {
const f = fixture();
try {
let view = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
f.clock.advance(4000);
view = await f.rooms.act(f.code, f.auth[f.native.asker], view.version,
{ type: 'card', card: f.native.truth }, f.clock);
let game = await f.rooms.readRoom(f.code); assert.ok(game.truthtrance);
assert.equal(game.auctionClock?.remainingMs, 6000); assert.equal(game.auctionClock?.startedAt, null);
f.clock.advance(60000); const before = structuredClone(game);
await f.rooms.continueRoomAuctionClock(f.code, f.clock, true);
assert.deepEqual(f.clock.sleeps, []); assert.deepEqual(await f.rooms.readRoom(f.code), before);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
await f.rooms.act(f.code, f.auth[actor.id], game.version, { type: 'truthPass' }, f.clock);
game = await f.rooms.readRoom(f.code);
}
await f.rooms.act(f.code, f.auth[f.native.asker], game.version, { type: 'truthAsk', question: {
kind: 'fact', target: f.native.target, fact: { kind: 'spice', compare: 'gte', value: 0 },
} }, f.clock);
game = await f.rooms.readRoom(f.code);
await f.rooms.act(f.code, f.auth[f.native.target], game.version, { type: 'truthAnswer', answer: 'yes' }, f.clock);
game = await f.rooms.readRoom(f.code);
assert.equal(game.truthtrance, null); assert.equal(game.auctionClock?.remainingMs, 6000);
assert.equal(game.auctionClock?.startedAt, f.clock.now());
f.clock.advance(5999); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
assert.equal((await f.rooms.readRoom(f.code)).auction?.active, f.actor);
f.clock.advance(1); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
assert.ok((await f.rooms.readRoom(f.code)).auction?.passed.includes(f.actor));
} finally { f.sqlite.close(); }
});
for (const boundary of ['pause', 'closure'] as const)
void test(`trusted ${boundary} revisions prevent clock writes while blocked and rearm a full budget on reopening`, async () => {
const f = fixture();
try {
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(4000);
if (boundary === 'pause') f.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,1,0,1,?)')
.run(f.code, f.clock.now());
else f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,?,?)')
.run(f.code, f.clock.now(), f.clock.now());
const before = await f.rooms.readRoom(f.code); f.clock.advance(60000);
await f.rooms.continueRoomAuctionClock(f.code, f.clock, true);
const blocked = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
assert.equal(blocked.auctionClock?.running, false); assert.equal(blocked.auctionClock?.remainingMs, 10000);
assert.deepEqual(await f.rooms.readRoom(f.code), before); assert.deepEqual(f.clock.sleeps, []);
if (boundary === 'pause') f.sqlite.prepare('UPDATE room_controls SET paused=0,revision=2,updated_at=? WHERE room_code=?')
.run(f.clock.now(), f.code);
else f.sqlite.prepare('UPDATE room_closures SET closed=0,closed_at=NULL,revision=2,updated_at=? WHERE room_code=?')
.run(f.clock.now(), f.code);
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
let game = await f.rooms.readRoom(f.code);
assert.equal(game.auctionClock?.controlEpoch, boundary === 'pause' ? '2:0' : '0:2');
assert.equal(game.auctionClock?.remainingMs, 10000); assert.equal(game.auctionClock?.startedAt, f.clock.now());
f.clock.advance(9999); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
assert.equal((await f.rooms.readRoom(f.code)).auction?.active, f.actor);
f.clock.advance(1); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
game = await f.rooms.readRoom(f.code); assert.ok(game.auction?.passed.includes(f.actor));
} finally { f.sqlite.close(); }
});
void test('trusted clock CAS fences both administrative revisions even if a new revision is currently unpaused and open', async () => {
for (const boundary of ['control', 'closure'] as const) {
const f = fixture();
try {
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(10000);
f.hooks.beforeWrite = async () => {
delete f.hooks.beforeWrite;
if (boundary === 'control') f.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,0,0,1,?)')
.run(f.code, f.clock.now());
else f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,0,1,NULL,?)')
.run(f.code, f.clock.now());
};
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
const game = await f.rooms.readRoom(f.code);
assert.equal(game.auction?.active, f.actor); assert.equal(game.auction?.bid, 0);
assert.equal(game.auctionClock?.controlEpoch, boundary === 'control' ? '1:0' : '0:1');
assert.equal(game.auctionClock?.startedAt, f.clock.now()); assert.equal(game.auctionClock?.remainingMs, 10000);
assert.ok(f.writes.some(write => write.changes === 0));
} finally { f.sqlite.close(); }
}
});
void test('a competing timely native bid wins the version CAS without a duplicate expired pass', async () => {
const f = fixture();
try {
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(10000);
f.hooks.beforeWrite = async () => {
delete f.hooks.beforeWrite;
const current = await f.rooms.readRoom(f.code);
// Another request passed trusted preflight one millisecond before the
// deadline and commits its legal native bid before this timer's CAS.
const next = engine.synchronizeAuctionClock(engine.applyAction(current, f.actor,
{ type: 'bid', amount: 1, allyPayment: 0 }), f.clock.now() - 1, '0:0');
next.version = current.version + 1;
f.sqlite.prepare('UPDATE rooms SET state=?,version=version+1 WHERE code=? AND version=?')
.run(JSON.stringify(next), f.code, current.version);
};
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
const game = await f.rooms.readRoom(f.code);
assert.equal(game.auction?.bid, 1); assert.equal(game.auction?.bidder, f.actor);
assert.ok(!game.auction?.passed.includes(f.actor)); assert.notEqual(game.auction?.active, f.actor);
assert.equal(game.version, 2); assert.ok(f.writes.some(write => write.changes === 0));
} finally { f.sqlite.close(); }
});
void test('recoverable room removal fences a due timer write without advancing the saved native lot', async () => {
const f = fixture();
try {
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
const before = await f.rooms.readRoom(f.code); f.clock.advance(10000);
f.hooks.beforeWrite = async () => {
delete f.hooks.beforeWrite;
f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,?,?)')
.run(f.code, f.clock.now(), f.clock.now());
};
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
const stored = f.sqlite.prepare('SELECT state,version FROM rooms WHERE code=?').get(f.code)!;
assert.equal(stored.version, before.version); assert.deepEqual(JSON.parse(stored.state as string), before);
assert.ok(f.writes.some(write => write.changes === 0));
} finally { f.sqlite.close(); }
});
for (const required of ['scalar', 'aggregate'] as const)
void test(`trusted expired ${required} Truth bid submits only its required legal native bid instead of evading by pass`, async () => {
const f = fixture({ required });
try {
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(10000);
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
const game = await f.rooms.readRoom(f.code);
assert.equal(game.auction?.bidder, f.native.target); assert.equal(game.auction?.bid, 4);
assert.equal(game.players.find(player => player.id === f.native.target)!.spice, 10);
if (required === 'scalar') assert.equal(game.bidPromises?.[0].fulfilled, true);
else {
assert.equal(game.turnActionPromises?.[0].preparation?.bid?.maximum, 4);
assert.deepEqual(game.turnActionPromises?.[0].payments, []);
assert.equal(game.turnActionPromises?.[0].fulfilled, false);
}
assert.equal(game.discard.filter(card => card.id === f.native.truth).length, 1);
assert.deepEqual(truthBidInventory(game), f.native.originalCards);
} finally { f.sqlite.close(); }
});
void test('legacy unstamped current lots stay untimed and invalid credentials cannot arm or expire another room', async () => {
const legacy = fixture({ legacy: true }), f = fixture();
try {
const view = await legacy.rooms.readSeatView(legacy.code, legacy.auth[legacy.actor], legacy.clock);
assert.equal(view.auctionClock, null); assert.equal(view.version, 0);
legacy.clock.advance(60000); await legacy.rooms.continueRoomAuctionClock(legacy.code, legacy.clock, true);
assert.equal((await legacy.rooms.readRoom(legacy.code)).version, 0); assert.deepEqual(legacy.clock.sleeps, []);
const forged = { playerId: f.actor, tokenHash: 'not-the-seat-hash' };
await assert.rejects(f.rooms.readSeatView(f.code, forged, f.clock), /verified/);
await assert.rejects(f.rooms.act(f.code, forged, 0, { type: 'passBid' }, f.clock), /verified/);
const unchanged = await f.rooms.readRoom(f.code); assert.equal(unchanged.version, 0);
assert.equal(unchanged.auctionClock, undefined);
} finally { legacy.sqlite.close(); f.sqlite.close(); }
});
void test('resume runs native automatic then bots alongside a sleeping auction clock without delaying the bot lane', async () => {
const calls: string[] = []; let release!: () => void;
const sleeping = new Promise<void>(resolve => { release = resolve; });
const continuation = loadRoomContinuation({
continueRoomAutomatic: async () => { calls.push('automatic'); },
continueRoomBots: async () => { calls.push('bots'); },
continueRoomAuctionClock: async (_code, _clock, wait) => {
assert.equal(wait, true); calls.push('clock'); await sleeping; return false;
},
});
const first = continuation.resumeRoom('CLOCK'); const coalesced = continuation.resumeRoom('CLOCK');
assert.equal(first, coalesced);
await new Promise<void>(resolve => setImmediate(resolve));
assert.ok(calls.includes('automatic') && calls.includes('clock') && calls.includes('bots'));
assert.ok(calls.indexOf('automatic') < calls.indexOf('bots'));
release(); await first;
});