diff --git a/README.md b/README.md index 5591865c..ae4c2fa6 100644 --- a/README.md +++ b/README.md @@ -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**. diff --git a/components/auction-countdown.tsx b/components/auction-countdown.tsx new file mode 100644 index 00000000..b2a17f0d --- /dev/null +++ b/components/auction-countdown.tsx @@ -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
+ {status} +

{seconds} {seconds === 1 ? 'second' : 'seconds'} {clock.event === null ? 'available' : 'remaining'}

+

Ordinary bidding gives each player 10 seconds to bid or pass. The server controls the deadline and resolves expiry.

+

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.

+
; +} diff --git a/components/game-table.tsx b/components/game-table.tsx index 9ee09221..925f8965 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -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' && } {g.status === 'playing' && } {g.status === 'playing' && } + {g.status === 'playing' && g.phase === 3 && g.auction && ( + player.id === g.auction!.active)?.name ?? 'The current bidder'} + paused={g.roomControl?.paused || g.roomControl?.closed} /> + )} {g.guildBetrayalReaction ? ( ) : g.richeseBetrayalReaction ? ( @@ -6540,7 +6546,7 @@ export function GameTable({

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.

Open the rules coverage ledger diff --git a/db/room-continuation.ts b/db/room-continuation.ts index ea218fa5..b6f916c6 100644 --- a/db/room-continuation.ts +++ b/db/room-continuation.ts @@ -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 { 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); }); diff --git a/db/rooms.ts b/db/rooms.ts index 64a2a323..6cbea8de 100644 --- a/db/rooms.ts +++ b/db/rooms.ts @@ -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) { + 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 { + 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; diff --git a/docs/AUCTION_TIME_LIMIT.md b/docs/AUCTION_TIME_LIMIT.md new file mode 100644 index 00000000..b17656fc --- /dev/null +++ b/docs/AUCTION_TIME_LIMIT.md @@ -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. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 05c47cfe..e75fabc1 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index e8720709..06fd853b 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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.** diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0cd602dc..1f2ce231 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 diff --git a/game/auction-clock.ts b/game/auction-clock.ts new file mode 100644 index 00000000..76e129c6 --- /dev/null +++ b/game/auction-clock.ts @@ -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 { + 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, 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 }; +} diff --git a/game/engine.ts b/game/engine.ts index 056989d4..42d6f355 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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; @@ -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([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, diff --git a/game/reference.ts b/game/reference.ts index 9fa9d893..752ba85f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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', diff --git a/tests/auction-clock-runtime.test.ts b/tests/auction-clock-runtime.test.ts new file mode 100644 index 00000000..a1f17ac7 --- /dev/null +++ b/tests/auction-clock-runtime.test.ts @@ -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); +}); diff --git a/tests/auction-clock.test.ts b/tests/auction-clock.test.ts new file mode 100644 index 00000000..9c77b675 --- /dev/null +++ b/tests/auction-clock.test.ts @@ -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[0]> = {}): Parameters[0] { + return { opportunity, now: 1_000, blocked: false, controlEpoch: 'control:1', event: 'clock:1', ...extra }; +} +function armed(extra: Partial = {}): 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 = { ...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 = { ...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 }))); +}); diff --git a/tests/auction-countdown.test.tsx b/tests/auction-countdown.test.tsx new file mode 100644 index 00000000..168b3fbe --- /dev/null +++ b/tests/auction-countdown.test.tsx @@ -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, /send pass'), /<script>send pass<\/script>/); +}); + +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, /[0]) { + type Effect = { dependencies: readonly unknown[]; cleanup?: () => void }; + const states: unknown[] = [], effects: Effect[] = [], pending: (() => void)[] = []; + const intervals = new Map 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, / { + 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); +}); diff --git a/tests/fixture-room-continuation.ts b/tests/fixture-room-continuation.ts index 9a45f4c6..7770df85 100644 --- a/tests/fixture-room-continuation.ts +++ b/tests/fixture-room-continuation.ts @@ -9,7 +9,7 @@ export function loadRoomContinuation( rooms: Pick< typeof Rooms, 'continueRoomAutomatic' | 'continueRoomBots' - >, + > & Partial>, ) { 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); }, }, diff --git a/tests/rooms-auction-clock.test.ts b/tests/rooms-auction-clock.test.ts new file mode 100644 index 00000000..1e6e9b7e --- /dev/null +++ b/tests/rooms-auction-clock.test.ts @@ -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; + 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(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(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; +});