Enforce ordinary auction bidding deadlines online
This commit is contained in:
1 parent
f76c8dfb93
commit
f548039cd8
17 files changed
+1363
-33
No files matched your search
@@ -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**.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AuctionClockView } from '@/game/auction-clock';
|
||||
|
||||
/** Browser time only animates the latest public server frame. It never submits
|
||||
* an auction action or decides that an authoritative deadline has expired. */
|
||||
export function AuctionCountdown({ clock, playerName, paused = false }: {
|
||||
clock: AuctionClockView | null;
|
||||
playerName: string;
|
||||
paused?: boolean;
|
||||
}) {
|
||||
const [local, setLocal] = useState<{ clock: AuctionClockView; remainingMs: number } | null>(null);
|
||||
const running = !!clock?.event && clock.running && !paused;
|
||||
useEffect(() => {
|
||||
if (!clock || !running || clock.remainingMs <= 0) return;
|
||||
const startedAt = performance.now();
|
||||
const timer = setInterval(() => {
|
||||
const remainingMs = Math.max(0, clock.remainingMs - Math.max(0, performance.now() - startedAt));
|
||||
setLocal({ clock, remainingMs });
|
||||
if (remainingMs === 0) clearInterval(timer);
|
||||
}, 200);
|
||||
return () => clearInterval(timer);
|
||||
}, [clock, running]);
|
||||
|
||||
if (!clock) return null;
|
||||
const remainingMs = running && local?.clock === clock ? local.remainingMs : Math.max(0, clock.remainingMs);
|
||||
const seconds = Math.ceil(remainingMs / 1000);
|
||||
const status = clock.event !== null && seconds === 0
|
||||
? `${paused ? 'Auction paused. ' : ''}Awaiting server resolution.`
|
||||
: clock.event === null
|
||||
? `${paused ? 'Auction paused. ' : ''}Clock starts when ordinary bidding is ready.`
|
||||
: paused ? 'Auction paused.'
|
||||
: !clock.running ? 'Bidding clock held while the current game decision finishes.'
|
||||
: `${playerName} may bid or pass.`;
|
||||
return <section className="notice space-y-2" aria-label="Ordinary auction countdown" data-testid="auction-countdown">
|
||||
<output className="block">{status}</output>
|
||||
<p role="timer" aria-live="off" aria-atomic="true" aria-label={`${playerName}’s remaining bidding time`}
|
||||
className="font-semibold tabular-nums">{seconds} {seconds === 1 ? 'second' : 'seconds'} {clock.event === null ? 'available' : 'remaining'}</p>
|
||||
<p className="fine">Ordinary bidding gives each player 10 seconds to bid or pass. The server controls the deadline and resolves expiry.</p>
|
||||
<p className="fine">Online convention: if passing would violate a binding Truthtrance answer, the server submits only the required legal bid.
|
||||
If that bid wins, its normal payment is still owed.</p>
|
||||
</section>;
|
||||
}
|
||||
@@ -262,6 +262,7 @@ import {
|
||||
splitLocation,
|
||||
} from '@/game/board';
|
||||
import type { Action, GameView, Plan } from '@/game/engine';
|
||||
import { AuctionCountdown } from './auction-countdown';
|
||||
import { PLAYER_CIRCLE_SECTORS } from '@/game/player-positions';
|
||||
import { SeatAutopilot } from './seat-autopilot';
|
||||
import {
|
||||
@@ -2088,6 +2089,11 @@ export function GameTable({
|
||||
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
|
||||
{g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />}
|
||||
{g.status === 'playing' && <TurnActionPromises game={g} busy={busy} act={act} fillPlan={fillBattlePlan} />}
|
||||
{g.status === 'playing' && g.phase === 3 && g.auction && (
|
||||
<AuctionCountdown clock={g.auctionClock}
|
||||
playerName={g.players.find(player => player.id === g.auction!.active)?.name ?? 'The current bidder'}
|
||||
paused={g.roomControl?.paused || g.roomControl?.closed} />
|
||||
)}
|
||||
{g.guildBetrayalReaction ? (
|
||||
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
|
||||
) : g.richeseBetrayalReaction ? (
|
||||
@@ -6540,7 +6546,7 @@ export function GameTable({
|
||||
<p>
|
||||
Still in progress: expansion factions and modules, advanced rules,
|
||||
several special cards, optional faction reactions, ally support,
|
||||
auction deadlines, and a printed-board connectivity audit.
|
||||
special-auction timing, and a printed-board connectivity audit.
|
||||
</p>
|
||||
<a href="/rules">
|
||||
Open the rules coverage ledger <ArrowRight size={14} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { continueRoomAutomatic, continueRoomBots } from './rooms';
|
||||
import { continueRoomAutomatic, continueRoomAuctionClock, continueRoomBots } from './rooms';
|
||||
|
||||
// Coalesce polls and control requests inside this isolate. Database CAS fences
|
||||
// other isolates, restarts, seat recovery and human actions.
|
||||
@@ -7,8 +7,13 @@ export function resumeRoom(code: string): Promise<void> {
|
||||
const existing = continuations.get(code);
|
||||
if (existing) return existing;
|
||||
const pending = (async () => {
|
||||
await continueRoomAutomatic(code);
|
||||
await continueRoomBots(code);
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
await continueRoomAutomatic(code);
|
||||
await continueRoomBots(code);
|
||||
})(),
|
||||
continueRoomAuctionClock(code, undefined, true),
|
||||
]);
|
||||
})().finally(() => {
|
||||
if (continuations.get(code) === pending) continuations.delete(code);
|
||||
});
|
||||
|
||||
+112
-22
@@ -6,6 +6,9 @@ import {
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
synchronizeAuctionClock,
|
||||
advanceAuctionClock,
|
||||
hasAuctionClockOpportunity,
|
||||
RuleError,
|
||||
type Action,
|
||||
type Game,
|
||||
@@ -266,16 +269,24 @@ export async function createRoom(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
type RoomControlRow = { room_control: string };
|
||||
type RoomControlRow = { room_control: string; closure_revision?: number };
|
||||
// SQLite constructs an explicit public allowlist, consistent with delegation views.
|
||||
const roomControlProjection = `json_patch(json_object(
|
||||
'paused', json(CASE WHEN c.paused = 1 THEN 'true' ELSE 'false' END),
|
||||
'joinLocked', json(CASE WHEN c.join_locked = 1 THEN 'true' ELSE 'false' END),
|
||||
'revision', COALESCE(c.revision, 0), 'updatedAt', c.updated_at
|
||||
), CASE WHEN EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) THEN '{"closed":true}' ELSE '{}' END) AS room_control`;
|
||||
const roomClosureRevision = `COALESCE((SELECT revision FROM room_closures WHERE room_code = rooms.code), 0) AS closure_revision`;
|
||||
const roomClockControlFence = `AND COALESCE((SELECT revision FROM room_controls WHERE room_code = rooms.code), 0) = ?
|
||||
AND COALESCE((SELECT revision FROM room_closures WHERE room_code = rooms.code), 0) = ?`;
|
||||
function publicRoomControl(row: RoomControlRow): RoomControl {
|
||||
return JSON.parse(row.room_control) as RoomControl;
|
||||
}
|
||||
function roomClockAuthority(row: RoomControlRow) {
|
||||
const controlRevision = publicRoomControl(row).revision;
|
||||
const closureRevision = row.closure_revision ?? 0;
|
||||
return { controlRevision, closureRevision, controlEpoch: `${controlRevision}:${closureRevision}` };
|
||||
}
|
||||
function attachRoomControl(view: GameView, row: RoomControlRow) {
|
||||
const control = publicRoomControl(row);
|
||||
// Untouched rooms retain the ordinary game-view shape. Once an administrator
|
||||
@@ -284,7 +295,7 @@ function attachRoomControl(view: GameView, row: RoomControlRow) {
|
||||
}
|
||||
async function readRoomSnapshot(code: string) {
|
||||
const row = await db()
|
||||
.prepare(`SELECT rooms.state, rooms.version, ${roomControlProjection}
|
||||
.prepare(`SELECT rooms.state, rooms.version, ${roomControlProjection}, ${roomClosureRevision}
|
||||
FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code WHERE rooms.code = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)`)
|
||||
.bind(code)
|
||||
.first<{ state: string; version: number } & RoomControlRow>();
|
||||
@@ -294,7 +305,7 @@ async function readRoomSnapshot(code: string) {
|
||||
}
|
||||
const g: Game = JSON.parse(row.state);
|
||||
g.version = row.version;
|
||||
return { game: g, control: publicRoomControl(row) };
|
||||
return { game: g, control: publicRoomControl(row), ...roomClockAuthority(row) };
|
||||
}
|
||||
export async function readRoom(code: string) {
|
||||
const row = await db()
|
||||
@@ -442,7 +453,15 @@ export async function act(
|
||||
action: Action,
|
||||
clock: RoomsClock = roomsClock,
|
||||
) {
|
||||
const { game: current, control } = await readRoomSnapshot(code);
|
||||
// Authenticate before a trusted preflight can advance the shared table.
|
||||
const seat = await db().prepare('SELECT player_id FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0')
|
||||
.bind(code, auth.playerId, auth.tokenHash).first();
|
||||
if (!seat) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new RuleError('Your seat could not be verified.');
|
||||
}
|
||||
await continueRoomAuctionClock(code, clock);
|
||||
const { game: current, control, controlEpoch, controlRevision, closureRevision } = await readRoomSnapshot(code);
|
||||
const takingBackControl =
|
||||
action?.type === 'setAutopilot' && action.difficulty === null;
|
||||
if (control.closed) throw new RoomClosedError();
|
||||
@@ -460,14 +479,14 @@ export async function act(
|
||||
throw new RuleError('Take back control before making a game decision.');
|
||||
// This is a wake-up request, not another game action. The route schedules the
|
||||
// persisted worker; repeated or early wake-ups cannot bypass its deadline.
|
||||
if (action.type === 'advanceBots') return readSeatView(code, auth);
|
||||
const next = applyAction(current, id, action);
|
||||
queueRoomBots(next, current, clock.now());
|
||||
next.version++;
|
||||
if (action.type === 'advanceBots') return readSeatView(code, auth, clock);
|
||||
const now = clock.now();
|
||||
const next = synchronizeAuctionClock(applyAction(current, id, action), now, controlEpoch, control.paused || control.closed);
|
||||
queueRoomBots(next, current, now);
|
||||
next.version++;
|
||||
const update = db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND (? = 1 OR NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
|
||||
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND (? = 1 OR NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) ${roomClockControlFence}`,
|
||||
)
|
||||
.bind(
|
||||
JSON.stringify(next),
|
||||
@@ -478,6 +497,8 @@ export async function act(
|
||||
code,
|
||||
auth.playerId,
|
||||
auth.tokenHash,
|
||||
controlRevision,
|
||||
closureRevision,
|
||||
);
|
||||
const results =
|
||||
action.type === 'setAutopilot'
|
||||
@@ -497,14 +518,14 @@ export async function act(
|
||||
'Another action arrived first. Your view has refreshed; try again.',
|
||||
);
|
||||
}
|
||||
return readSeatView(code, auth);
|
||||
return readSeatView(code, auth, clock);
|
||||
}
|
||||
|
||||
/** Check the credential and load its private state in one database statement. */
|
||||
export async function readSeatView(code: string, auth: SeatAuth) {
|
||||
const row = await db()
|
||||
/** Verify the credential before clock preflight, then project the current private row. */
|
||||
export async function readSeatView(code: string, auth: SeatAuth, clock: Pick<RoomsClock, 'now'> = roomsClock) {
|
||||
const load = () => db()
|
||||
.prepare(
|
||||
`SELECT rooms.state, rooms.version, ${roomControlProjection},
|
||||
`SELECT rooms.state, rooms.version, ${roomControlProjection}, ${roomClosureRevision},
|
||||
CASE WHEN COUNT(delegations.grant_id) = 0 THEN NULL ELSE
|
||||
json_group_array(json_object(
|
||||
'grantId', delegations.grant_id,
|
||||
@@ -547,13 +568,25 @@ export async function readSeatView(code: string, auth: SeatAuth) {
|
||||
seat_ai_delegations: string | null;
|
||||
}
|
||||
>();
|
||||
let row = await load();
|
||||
if (!row) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new RuleError('Your seat could not be verified.');
|
||||
}
|
||||
// Never resume a room for an unverified viewer. Reload through the same
|
||||
// credential boundary if arming or a due native action changes its version.
|
||||
if (await continueRoomAuctionClock(code, { now: () => clock.now(), sleep: ms => roomsClock.sleep(ms) })) {
|
||||
row = await load();
|
||||
if (!row) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new RuleError('Your seat could not be verified.');
|
||||
}
|
||||
}
|
||||
const g: Game = JSON.parse(row.state);
|
||||
g.version = row.version;
|
||||
const view = viewGame(g, auth.playerId) as GameView;
|
||||
const control = publicRoomControl(row), now = clock.now();
|
||||
const projected = synchronizeAuctionClock(g, now, roomClockAuthority(row).controlEpoch, control.paused || control.closed);
|
||||
const view = viewGame(projected, auth.playerId, now) as GameView;
|
||||
attachRoomControl(view, row);
|
||||
if (row.seat_ai_delegations) {
|
||||
const delegations = JSON.parse(
|
||||
@@ -571,9 +604,12 @@ export function needsAutomaticRoomRecovery(
|
||||
battle?: { revealed: boolean; territory: string } | null;
|
||||
automaticContinuationPending?: boolean;
|
||||
grummanCollection?: { event: string; stage?: string } | null;
|
||||
auction?: { clockVersion?: number } | null;
|
||||
auctionClock?: unknown;
|
||||
},
|
||||
) {
|
||||
return (
|
||||
state.auction?.clockVersion === 1 || !!state.auctionClock ||
|
||||
state.automaticContinuationPending === true ||
|
||||
state.grummanCollection?.stage === 'waiting' ||
|
||||
!!state.pendingTreacheryDiscard ||
|
||||
@@ -603,6 +639,58 @@ async function readWorkerRoomSnapshot(code: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** One ordinary bid deadline through the existing trusted clock and room CAS.
|
||||
* A worker may wait once for at most ten seconds, then rereads all authority.
|
||||
* No durable alarm is claimed: a later authenticated read/action resumes a
|
||||
* persisted deadline after the process stops. Administrative revision changes
|
||||
* deliberately rearm a full budget after a pause or closure. A true result
|
||||
* requests a fresh private read after a commit or a lost authority fence. */
|
||||
export async function continueRoomAuctionClock(
|
||||
code: string,
|
||||
clock: RoomsClock = roomsClock,
|
||||
wait = false,
|
||||
): Promise<boolean> {
|
||||
let changed = false, waited = false;
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const snapshot = await readWorkerRoomSnapshot(code);
|
||||
if (!snapshot || snapshot.control.paused || snapshot.control.closed) return changed;
|
||||
const { game: current, controlEpoch, controlRevision, closureRevision } = snapshot;
|
||||
if (!hasAuctionClockOpportunity(current) && !current.auctionClock) return changed;
|
||||
const now = clock.now();
|
||||
const next = advanceAuctionClock(current, now, controlEpoch);
|
||||
const nativeProgress = JSON.stringify(next.auction) !== JSON.stringify(current.auction) ||
|
||||
next.phase !== current.phase || next.turn !== current.turn;
|
||||
if (JSON.stringify(next) !== JSON.stringify(current)) {
|
||||
queueRoomBots(next, current, now);
|
||||
next.version = current.version + 1;
|
||||
const result = await db().prepare(
|
||||
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
|
||||
${roomClockControlFence}`,
|
||||
).bind(JSON.stringify(next), now, code, current.version, controlRevision, closureRevision).run();
|
||||
if (result.meta.changes !== 1) {
|
||||
// A lost game/control fence also requires an authenticated caller to
|
||||
// reload its private row, even when this worker committed no write.
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
if (nativeProgress || !wait) return changed;
|
||||
}
|
||||
const timer = next.auctionClock;
|
||||
if (!wait || waited || !timer || timer.startedAt === null) return changed;
|
||||
const delay = Math.max(0, timer.remainingMs - Math.max(0, clock.now() - timer.startedAt));
|
||||
if (delay <= 0) return changed;
|
||||
waited = true;
|
||||
await clock.sleep(Math.min(delay, 10000));
|
||||
// A human bid, seat change or administrative revision may have won while
|
||||
// asleep. Only a fresh snapshot can arm, suspend or expire its own event.
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Persist an old automatic continuation through the same version fence as current actions. */
|
||||
export async function continueRoomAutomatic(
|
||||
code: string,
|
||||
@@ -611,7 +699,7 @@ export async function continueRoomAutomatic(
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
const snapshot = await readWorkerRoomSnapshot(code);
|
||||
if (!snapshot) return;
|
||||
const { game: current, control } = snapshot;
|
||||
const { game: current, control, controlEpoch, controlRevision, closureRevision } = snapshot;
|
||||
if (control.paused || control.closed) return;
|
||||
if (
|
||||
!needsAutomaticRoomRecovery(current) ||
|
||||
@@ -621,15 +709,15 @@ export async function continueRoomAutomatic(
|
||||
current.status === 'finished'))
|
||||
)
|
||||
return;
|
||||
const next = normalizeAutomaticGame(current);
|
||||
const next = synchronizeAuctionClock(normalizeAutomaticGame(current), clock.now(), controlEpoch);
|
||||
if (JSON.stringify(next) === JSON.stringify(current)) return;
|
||||
queueRoomBots(next, current, clock.now());
|
||||
next.version = current.version + 1;
|
||||
const result = await db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
|
||||
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1) ${roomClockControlFence}`,
|
||||
)
|
||||
.bind(JSON.stringify(next), clock.now(), code, current.version)
|
||||
.bind(JSON.stringify(next), clock.now(), code, current.version, controlRevision, closureRevision)
|
||||
.run();
|
||||
if (result.meta.changes === 1) return;
|
||||
}
|
||||
@@ -645,6 +733,7 @@ export async function continueRoomBots(
|
||||
clock: RoomsClock = roomsClock,
|
||||
) {
|
||||
for (let batch = 0; batch < batches; batch++) {
|
||||
await continueRoomAuctionClock(code, clock);
|
||||
let snapshot = await readWorkerRoomSnapshot(code);
|
||||
if (!snapshot) return;
|
||||
let current = snapshot.game;
|
||||
@@ -657,6 +746,7 @@ export async function continueRoomBots(
|
||||
const delay = (current.botNextActionAt ?? clock.now()) - clock.now();
|
||||
if (delay > 0) {
|
||||
await clock.sleep(Math.min(delay, ONLINE_BOT_INTERVAL_MS));
|
||||
await continueRoomAuctionClock(code, clock);
|
||||
// A human may have taken control or another worker committed while asleep.
|
||||
snapshot = await readWorkerRoomSnapshot(code);
|
||||
if (!snapshot) return;
|
||||
@@ -671,16 +761,16 @@ export async function continueRoomBots(
|
||||
}
|
||||
// Offline simulations retain runBots' fast default. Online persistence makes
|
||||
// at most one AI decision visible per successful compare-and-swap.
|
||||
const next = runBots(current, 1);
|
||||
const next = synchronizeAuctionClock(runBots(current, 1), clock.now(), snapshot.controlEpoch);
|
||||
if (next.botsPending)
|
||||
next.botNextActionAt = clock.now() + ONLINE_BOT_INTERVAL_MS;
|
||||
else delete next.botNextActionAt;
|
||||
next.version = current.version + 1;
|
||||
const result = await db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
|
||||
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1) ${roomClockControlFence}`,
|
||||
)
|
||||
.bind(JSON.stringify(next), clock.now(), code, current.version)
|
||||
.bind(JSON.stringify(next), clock.now(), code, current.version, snapshot.controlRevision, snapshot.closureRevision)
|
||||
.run();
|
||||
// A different worker, seat recovery, or Take back control won. Re-read its authority.
|
||||
if (result.meta.changes !== 1) continue;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Ordinary-auction time limit
|
||||
|
||||
## Source and scope
|
||||
|
||||
**Prototyped / Partial.** Publisher Basic rulebook printed8, Time Limit,
|
||||
requires a bid within ten seconds of the previous player and treats silence as
|
||||
a pass. The user-authorized supplied revised Advanced rulebook repeats it on
|
||||
physical8/printed6. This is a Basic and Advanced rule, not an AI pacing option.
|
||||
[Publisher rulebook](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=8).
|
||||
|
||||
New native ordinary lots use a versioned clock. An already-open older lot with
|
||||
no stamp retains its saved untimed protocol; the next newly opened native lot
|
||||
uses the clock. Special silent/once-around procedures retain their own rules.
|
||||
Pure engine/offline simulation has no wall-clock dependency. Only trusted
|
||||
room time arms, suspends or expires an online clock; clients cannot supply time.
|
||||
|
||||
## Online timing convention
|
||||
|
||||
The clock starts when the current bidder can actually bid. Inspection,
|
||||
Truthtrance, cancellation, payment and other native decisions hold the exact
|
||||
remaining budget. Their native owners and mandatory continuation stay intact.
|
||||
An ordinary bid or pass starts ten seconds for the next original bidder;
|
||||
unrelated polls, chat and repeated requests cannot refresh the deadline.
|
||||
At the exact deadline the server follows the canonical pass continuation.
|
||||
|
||||
A binding Truthtrance answer can legally prohibit passing. Under the standing
|
||||
14 October choose-recommended policy, provisionally submit only its quoted
|
||||
lowest legal bid compatible with the whole answer instead of letting silence
|
||||
evade it. Finite predicate thresholds are quoted with exact private native
|
||||
bounds; no speculative purchase is executed to search for an amount. This is an
|
||||
explicit online composition, not a publisher timeout/Truthtrance worked example.
|
||||
It grants no free purchase, extra bid or strategic escalation; normal payment
|
||||
is still due if that bid wins. A whole-answer alternative permitting pass keeps
|
||||
that ordinary pass available. Existing actual impossibility rules remain.
|
||||
|
||||
An administrative room-control or closure revision re-arms the current bid
|
||||
with ten seconds once play resumes. This conservative online convention also
|
||||
covers a pause that no active client observed. No paused, closed or removed
|
||||
room advances. Current native interruptions retain their remaining budget.
|
||||
Source and control epochs are private; the public countdown contains only an
|
||||
opaque event, current bidder, remaining time and running status.
|
||||
|
||||
## Server continuation and controls
|
||||
|
||||
The persisted clock binds the exact native turn/lot/bidder/bid/pass/order
|
||||
source. Version and operational-control fences prevent concurrent workers or
|
||||
late requests from acting on a superseded bid. Authenticated reads/actions
|
||||
resume or expire it before accepting a late bid. A bounded background worker
|
||||
runs alongside existing automatic/AI work; it is not a durable alarm. After a
|
||||
stopped host, the stored deadline is evaluated on the next authenticated
|
||||
request. Server restarts neither invent a bid nor reset a game.
|
||||
|
||||
The ordinary-auction notice persists. Its local animation is display only;
|
||||
only the server decides expiry and emits the native action. The notice shows
|
||||
held/paused/starting states and explains any compulsory Truthtrance bid.
|
||||
No client-generated pass, hidden-card source or disposable popup is used.
|
||||
|
||||
## Evidence and remaining scope
|
||||
|
||||
Helper: game/auction-clock.ts. Engine timing/source/expiry and private-to-public
|
||||
projection: game/engine.ts. Trusted storage scheduling: db/rooms.ts and
|
||||
room-continuation.ts. Human countdown: components/auction-countdown.tsx and the
|
||||
native ordinary-auction section of components/game-table.tsx.
|
||||
## Verified checkpoint — 27 October 2026
|
||||
|
||||
The frozen source passes17 files,199/199, types/lint and production build.
|
||||
Thirteen pure clock, four real-component/display, nine native engine and14
|
||||
trusted SQLite/fake-clock cases add40 focused checks. Existing bid, auction
|
||||
retirement, continuation, funding, controls, bot pacing and reference cases
|
||||
remain in the related union. Exact-deadline, held/resumed timing, source change,
|
||||
old-lot, both control-revision fences, current-seat authorization, competing
|
||||
writes and scalar/whole Truth obligations have bounded evidence.
|
||||
|
||||
Five native journeys plus one actual local HTTP journey pass, with14 full
|
||||
GameTable SSR renders and43 HTTP requests. Original Basic source20 and Advanced
|
||||
source23 expire by canonical pass. Source22 covers forced whole bid3 then pay2
|
||||
and its real invoice, actual Truth holding8s until resolution, and a voluntary
|
||||
first alternative5 whose compelled minimum is3. The HTTP room is genuinely
|
||||
created/joined/readied/started, then reaches ordinary bidding through12 actual
|
||||
controls; bid1 succeeds and the next bidder's silence records a native pass.
|
||||
Late bid2 with stale version and forged now0 is rejected. Cookies stay in memory
|
||||
and are neither logged nor saved. Native original inventories remain.
|
||||
|
||||
The first runtime smoke catches a countdown hidden behind the Truth control;
|
||||
it is moved into persistent notices. Type and lint feedback is repaired. The
|
||||
first related union reports197/199: its OR-pass test assumed the old lot stayed
|
||||
open, and its VM concurrency test waited too few cross-context microtasks.
|
||||
The first now compares actual canonical native pass/retirement and resolves
|
||||
native aftermath before testing later payment. The second waits an event-loop
|
||||
barrier while still requiring bots to start before releasing the sleeping
|
||||
clock. Both exact repaired cases pass, then the related union passes199/199.
|
||||
|
||||
V5 retains V4's six passing runtime journeys through a per-file manifest:
|
||||
only the two failing test files changed; all production code, native fixtures
|
||||
and the private runtime script are identical. The final source passes quick
|
||||
checks, related tests and build without recreating that HTTP room again.
|
||||
Proof/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-auction-clock-proof-v5/`;
|
||||
reused journeys/HTML: adjacent `20261026-auction-clock-runtime-v4/`.
|
||||
|
||||
These are local native/SSR/controlled-display and real HTTP checks, not browser
|
||||
or deployed acceptance. Special-auction timers, wider module/order interactions,
|
||||
full-mode acceptance and comprehensive recovery/privacy/custody assurance remain
|
||||
open. Existing publication/AI gates are preserved.
|
||||
The stopped local server was started without resetting storage. HTTP proof
|
||||
uses a dedicated QA room only; no existing game action, reset, deletion, restart
|
||||
or deployment is performed.
|
||||
+46
-1
@@ -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
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
export const AUCTION_BID_MS = 10_000;
|
||||
|
||||
export type AuctionClock = {
|
||||
event: string;
|
||||
source: string;
|
||||
player: string;
|
||||
/** Budget remaining at startedAt, or the frozen budget while suspended. */
|
||||
remainingMs: number;
|
||||
startedAt: number | null;
|
||||
controlEpoch: string;
|
||||
};
|
||||
|
||||
export type AuctionClockView = {
|
||||
/** Null is reserved for a never-armed projection built by the caller. */
|
||||
event: string | null;
|
||||
player: string;
|
||||
remainingMs: number;
|
||||
running: boolean;
|
||||
};
|
||||
|
||||
export class AuctionClockError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AuctionClockError';
|
||||
}
|
||||
}
|
||||
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new AuctionClockError(message);
|
||||
}
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
||||
const keys = (value: Record<string, unknown>, expected: string): boolean =>
|
||||
Object.keys(value).sort().join(',') === expected;
|
||||
|
||||
function validateClock(value: unknown): asserts value is AuctionClock {
|
||||
check(object(value) && keys(value, 'controlEpoch,event,player,remainingMs,source,startedAt') &&
|
||||
identifier(value.event) && identifier(value.source) && identifier(value.player) && identifier(value.controlEpoch) &&
|
||||
finite(value.remainingMs) && value.remainingMs >= 0 && value.remainingMs <= AUCTION_BID_MS &&
|
||||
(value.startedAt === null || finite(value.startedAt)),
|
||||
'Retain the complete original ordinary-auction clock, finite budget and control epoch.');
|
||||
}
|
||||
|
||||
function remaining(clock: AuctionClock, now: number): number {
|
||||
if (clock.startedAt === null) return clock.remainingMs;
|
||||
// A wall-clock rollback cannot consume time or enlarge the retained budget.
|
||||
const elapsed = Math.max(0, now - clock.startedAt);
|
||||
return Math.max(0, clock.remainingMs - elapsed);
|
||||
}
|
||||
|
||||
/** Pure quotation. Source/event/epoch and the current time come from the
|
||||
* integrating engine/store; no timer, random value or I/O is created here. */
|
||||
export function quoteAuctionClock(input: {
|
||||
clock?: AuctionClock;
|
||||
opportunity: { source: string; player: string } | null;
|
||||
now: number;
|
||||
blocked: boolean;
|
||||
controlEpoch: string;
|
||||
event: string;
|
||||
}): { clock: AuctionClock | undefined; expired: boolean; delayMs: number | null } {
|
||||
check(object(input) && (keys(input, 'blocked,controlEpoch,event,now,opportunity') ||
|
||||
keys(input, 'blocked,clock,controlEpoch,event,now,opportunity')) && finite(input.now) &&
|
||||
typeof input.blocked === 'boolean' && identifier(input.controlEpoch) && identifier(input.event),
|
||||
'Supply the exact ordinary-auction opportunity, finite time, blocker and control epoch.');
|
||||
if (input.clock !== undefined) validateClock(input.clock);
|
||||
check(input.opportunity === null || object(input.opportunity) && keys(input.opportunity, 'player,source') &&
|
||||
identifier(input.opportunity.source) && identifier(input.opportunity.player),
|
||||
'Choose the original ordinary-auction source and current bidder, or no opportunity.');
|
||||
if (input.opportunity === null) return { clock: undefined, expired: false, delayMs: null };
|
||||
|
||||
const { opportunity, now, blocked, controlEpoch } = input;
|
||||
const previous = input.clock;
|
||||
let clock: AuctionClock = !previous || previous.source !== opportunity.source || previous.player !== opportunity.player ||
|
||||
previous.controlEpoch !== controlEpoch ? {
|
||||
event: input.event, source: opportunity.source, player: opportunity.player,
|
||||
remainingMs: AUCTION_BID_MS, startedAt: blocked ? null : now, controlEpoch,
|
||||
} : previous;
|
||||
const budget = remaining(clock, now);
|
||||
if (blocked) {
|
||||
if (clock.startedAt !== null) clock = { ...clock, remainingMs: budget, startedAt: null };
|
||||
return { clock, expired: false, delayMs: null };
|
||||
}
|
||||
if (budget === 0) {
|
||||
if (clock.remainingMs !== 0 || clock.startedAt !== null) clock = { ...clock, remainingMs: 0, startedAt: null };
|
||||
return { clock, expired: true, delayMs: null };
|
||||
}
|
||||
if (clock.startedAt === null) clock = { ...clock, startedAt: now };
|
||||
return { clock, expired: false, delayMs: budget };
|
||||
}
|
||||
|
||||
/** Suspended/expired frames retain their original event for the client. */
|
||||
export function viewAuctionClock(clock: AuctionClock, now: number): AuctionClockView {
|
||||
validateClock(clock);
|
||||
check(finite(now), 'Project the ordinary-auction clock at a finite time.');
|
||||
const remainingMs = remaining(clock, now);
|
||||
return { event: clock.event, player: clock.player, remainingMs,
|
||||
running: clock.startedAt !== null && remainingMs > 0 };
|
||||
}
|
||||
+120
-2
@@ -1,3 +1,5 @@
|
||||
import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
|
||||
type AuctionClock, type AuctionClockView } from './auction-clock';
|
||||
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
|
||||
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn,
|
||||
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival,
|
||||
@@ -918,6 +920,8 @@ export type Battle = {
|
||||
voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon'; must: boolean };
|
||||
};
|
||||
export type Auction = {
|
||||
/** New ordinary lots use trusted online timing; old open lots stay unstamped. */
|
||||
clockVersion?: 1;
|
||||
/** Sapho changes this lot's cycle, never physical order or the next opener. */
|
||||
saphoOrder?: string[];
|
||||
peekKnown?: boolean;
|
||||
@@ -2544,6 +2548,8 @@ export type Game = {
|
||||
nexusMoritaniRetention?: NexusMoritaniRetentionReceipt;
|
||||
} | null;
|
||||
auction: Auction | null;
|
||||
/** Private source and trusted time. Player views expose the countdown only. */
|
||||
auctionClock?: AuctionClock;
|
||||
battle: Battle | null;
|
||||
nexus: boolean;
|
||||
allianceOffers: Record<string, string>;
|
||||
@@ -14501,6 +14507,7 @@ function setAuction(g: Game, cards?: Card[]) {
|
||||
(id) => eligible.includes(id),
|
||||
)!;
|
||||
g.auction = {
|
||||
clockVersion: 1,
|
||||
...(g.homeworldOccupationPreview ? { occupiedEvent: crypto.randomUUID() } : {}),
|
||||
cards,
|
||||
index: 0,
|
||||
@@ -15877,6 +15884,115 @@ function guardPromisedBid(g: Game, p: Player, action: Action, amount?: number) {
|
||||
requireRule(!bidRequired(g.bidPromises ?? [], p.id, opportunity),
|
||||
'Place the qualifying bid required by your Truthtrance Yes before passing or taking a lower bid.');
|
||||
}
|
||||
function ordinaryAuctionClockOpportunity(g: Game) {
|
||||
const auction = g.auction;
|
||||
if (g.status !== 'playing' || g.phase !== 3 || auction?.clockVersion !== 1 ||
|
||||
!auction.cards[auction.index] || !g.players.some(player => player.id === auction.active)) return null;
|
||||
return { player: auction.active, source: JSON.stringify({ turn: g.turn,
|
||||
index: auction.index, card: auction.cards[auction.index].id, active: auction.active,
|
||||
bid: auction.bid, bidder: auction.bidder, passed: auction.passed,
|
||||
opener: auction.opener, order: auction.saphoOrder ?? g.order }) };
|
||||
}
|
||||
export function hasAuctionClockOpportunity(g: Game): boolean {
|
||||
return ordinaryAuctionClockOpportunity(g) !== null;
|
||||
}
|
||||
function ordinaryAuctionClockBlocked(g: Game): boolean {
|
||||
return !!(g.active !== g.auction?.active || g.richeseAuction || g.truthtrance || g.response ||
|
||||
g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
|
||||
g.pendingNullentropy || g.pendingKull || g.pendingHomeworldTupileCleanup ||
|
||||
g.pendingRicheseBetrayal || g.pendingGuildBetrayal ||
|
||||
g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador ||
|
||||
g.pendingNexusHarkonnenBetrayal || g.pendingNexusIxianBetrayal || g.pendingNexusIxianReplacement ||
|
||||
g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
|
||||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove || g.pendingAdvisorMatching ||
|
||||
pendingNexusTraitors(g));
|
||||
}
|
||||
function auctionClockQuote(g: Game, now: number, controlEpoch: string, blocked = false) {
|
||||
const opportunity = ordinaryAuctionClockOpportunity(g);
|
||||
try {
|
||||
return quoteAuctionClock({ clock: g.auctionClock, opportunity, now, controlEpoch,
|
||||
blocked: blocked || ordinaryAuctionClockBlocked(g),
|
||||
event: opportunity && (!g.auctionClock || g.auctionClock.source !== opportunity.source ||
|
||||
g.auctionClock.player !== opportunity.player || g.auctionClock.controlEpoch !== controlEpoch)
|
||||
? crypto.randomUUID() : g.auctionClock?.event ?? 'no-auction' });
|
||||
} catch (error) {
|
||||
if (error instanceof AuctionClockError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Only trusted room time calls this. Pure actions and offline games never
|
||||
* consult wall time, and legacy unclocked lots are not retroactively timed. */
|
||||
export function synchronizeAuctionClock(source: Game, now: number, controlEpoch: string, blocked = false): Game {
|
||||
const quote = auctionClockQuote(source, now, controlEpoch, blocked);
|
||||
if (JSON.stringify(source.auctionClock) === JSON.stringify(quote.clock)) return source;
|
||||
const next = { ...source };
|
||||
if (quote.clock) next.auctionClock = quote.clock;
|
||||
else delete next.auctionClock;
|
||||
return next;
|
||||
}
|
||||
/** Bid predicates change only at their declared minima. Testing those finite
|
||||
* breakpoints through detached exact native bounds finds the least compelled
|
||||
* bid without scanning a large purse or speculatively settling a purchase. */
|
||||
function minimumCompelledAuctionBid(game: Game, actor: string): Action | null {
|
||||
const opportunity = bidOpportunity(game), player = getPlayer(game, actor);
|
||||
if (!opportunity || !game.auction) return null;
|
||||
const amounts = new Set<number>([game.auction.bid + 1]);
|
||||
for (const promise of liveBidPromises(game.bidPromises ?? [], actor, opportunity)) amounts.add(promise.minimum);
|
||||
const visit = (node: TurnActionClaim['mixed']): void => {
|
||||
if (node.kind === 'action') {
|
||||
if (node.action.kind === 'bid' && node.action.lot === opportunity.lot) amounts.add(node.action.minimum);
|
||||
} else if (node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
for (const promise of liveTurnActionPromises(game.turnActionPromises ?? [], actor, game.turn)) visit(promise.mixed);
|
||||
const maximum = normalBidMaximum(game, player);
|
||||
const whole = liveTurnActionPromises(game.turnActionPromises ?? [], actor, game.turn).length > 0;
|
||||
for (const amount of [...amounts].filter(value => Number.isSafeInteger(value) && value > game.auction!.bid && value <= maximum).sort((a, b) => a - b)) {
|
||||
const metadata = { turn: game.turn, lot: opportunity.lot, source: opportunity.source,
|
||||
player: actor, asker: game.players.find(seat => seat.id !== actor)!.id };
|
||||
const bounds: BidPromise[] = [{ ...metadata, event: 'auction-clock-minimum', minimum: amount, answer: true }];
|
||||
if (amount < Number.MAX_SAFE_INTEGER)
|
||||
bounds.push({ ...metadata, event: 'auction-clock-maximum', minimum: amount + 1, answer: false });
|
||||
const trial = { ...game, bidPromises: [...(game.bidPromises ?? []), ...bounds] };
|
||||
const action = whole ? turnActionCompletion(trial, actor)?.bid : bidPromiseCompletion(trial, player);
|
||||
if (action?.type === 'bid' && action.amount === amount) return action;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** A deadline uses the same native pass as a human. If an accepted Truthtrance
|
||||
* answer forbids passing, execute only its quoted required legal bid instead
|
||||
* of letting silence evade that answer or make a strategic bid for the seat. */
|
||||
export function advanceAuctionClock(source: Game, now: number, controlEpoch: string, blocked = false): Game {
|
||||
const game = synchronizeAuctionClock(source, now, controlEpoch, blocked);
|
||||
const quote = auctionClockQuote(game, now, controlEpoch, blocked);
|
||||
if (!quote.expired || !game.auctionClock) return game;
|
||||
const actor = game.auctionClock.player;
|
||||
let next: Game, compelled = false;
|
||||
try { next = applyAction(game, actor, { type: 'passBid' }); }
|
||||
catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
const whole = liveTurnActionPromises(game.turnActionPromises ?? [], actor, game.turn).length
|
||||
? turnActionCompletion(game, actor)?.bid : undefined;
|
||||
const opportunity = bidOpportunity(game);
|
||||
const required = opportunity && bidRequired(game.bidPromises ?? [], actor, opportunity);
|
||||
if (!whole && !required) throw error;
|
||||
const completion = minimumCompelledAuctionBid(game, actor);
|
||||
requireRule(completion, 'The expired ordinary bid needs a legal completion of its binding Truthtrance answer.');
|
||||
next = applyAction(game, actor, completion);
|
||||
compelled = true;
|
||||
}
|
||||
log(next, compelled
|
||||
? `${getPlayer(game, actor).name}'s ten-second bidding time expired. The legal bid required by their binding Truthtrance answer was submitted.`
|
||||
: `${getPlayer(game, actor).name}'s ten-second bidding time expired; they passed this bid.`);
|
||||
return synchronizeAuctionClock(next, now, controlEpoch, blocked);
|
||||
}
|
||||
function projectedAuctionClock(g: Game, now?: number): AuctionClockView | null {
|
||||
const opportunity = ordinaryAuctionClockOpportunity(g);
|
||||
if (!opportunity) return null;
|
||||
const clock = g.auctionClock;
|
||||
if (!clock || clock.source !== opportunity.source || clock.player !== opportunity.player)
|
||||
return { event: null, player: opportunity.player, remainingMs: AUCTION_BID_MS, running: false };
|
||||
return viewAuctionClock(clock, now ?? clock.startedAt ?? 0);
|
||||
}
|
||||
function auctionNext(g: Game) {
|
||||
const a = g.auction!;
|
||||
const eligible = g.order.filter((id) => {
|
||||
@@ -16063,7 +16179,7 @@ function commitNormalAuctionRetirement(g: Game, next: NormalAuctionRetirementQuo
|
||||
g.auction = null;
|
||||
finishNormalBidding(g);
|
||||
} else {
|
||||
g.auction = next.auction;
|
||||
g.auction = { ...next.auction, clockVersion: 1 };
|
||||
g.active = next.active;
|
||||
if (next.offer?.kind === 'ixTechnology') g.decision = next.offer;
|
||||
else if (next.offer) offerAuctionPeek(g);
|
||||
@@ -41412,7 +41528,7 @@ function applyActionInner(
|
||||
}
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string) {
|
||||
export function viewGame(state: Game, id: string, now?: number) {
|
||||
revivalOrderIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
@@ -41518,6 +41634,7 @@ export function viewGame(state: Game, id: string) {
|
||||
? findReachableBattlePlan(g, me)
|
||||
: null;
|
||||
return {
|
||||
auctionClock: projectedAuctionClock(g, now),
|
||||
harkonnenExchangeInspection: projectedHarkonnenInspection(g, id),
|
||||
ecazPoisonIncome: (g.ecazPoisonIncome ?? []).filter((income) => income.player === id)
|
||||
.map(({turn, phase, amount, count}) => ({turn, phase, amount, count})),
|
||||
@@ -42648,6 +42765,7 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
auction: g.auction
|
||||
? {
|
||||
...(g.auction.clockVersion === 1 ? { clockVersion: 1 as const } : {}),
|
||||
bid: g.auction.bid,
|
||||
bidder: g.auction.bidder,
|
||||
active: g.auction.active,
|
||||
|
||||
+25
-2
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
AUCTION_BID_MS,
|
||||
AuctionClockError,
|
||||
quoteAuctionClock,
|
||||
viewAuctionClock,
|
||||
type AuctionClock,
|
||||
} from '../game/auction-clock';
|
||||
|
||||
const opportunity = { source: 'ordinary-lot:1', player: 'atreides' };
|
||||
function input(extra: Partial<Parameters<typeof quoteAuctionClock>[0]> = {}): Parameters<typeof quoteAuctionClock>[0] {
|
||||
return { opportunity, now: 1_000, blocked: false, controlEpoch: 'control:1', event: 'clock:1', ...extra };
|
||||
}
|
||||
function armed(extra: Partial<AuctionClock> = {}): AuctionClock {
|
||||
return { event: 'clock:1', source: opportunity.source, player: opportunity.player,
|
||||
remainingMs: AUCTION_BID_MS, startedAt: 1_000, controlEpoch: 'control:1', ...extra };
|
||||
}
|
||||
|
||||
void test('a new ordinary bidder receives exactly ten seconds with the supplied source and event', () => {
|
||||
assert.equal(AUCTION_BID_MS, 10_000);
|
||||
const result = quoteAuctionClock(input());
|
||||
assert.deepEqual(result, { clock: armed(), expired: false, delayMs: 10_000 });
|
||||
assert.deepEqual(viewAuctionClock(result.clock!, 1_000), {
|
||||
event: 'clock:1', player: 'atreides', remainingMs: 10_000, running: true,
|
||||
});
|
||||
});
|
||||
|
||||
void test('unrelated same-source requests retain the original event and deadline instead of resetting the budget', () => {
|
||||
const clock = armed();
|
||||
for (const now of [1_001, 2_500, 7_500, 10_999]) {
|
||||
const result = quoteAuctionClock(input({ clock, now, event: `unused:${now}` }));
|
||||
assert.equal(result.clock, clock);
|
||||
assert.equal(result.delayMs, 11_000 - now);
|
||||
assert.equal(result.expired, false);
|
||||
assert.equal(result.clock.event, 'clock:1');
|
||||
}
|
||||
});
|
||||
|
||||
void test('the exact deadline expires once eligible and cannot be extended by a later request', () => {
|
||||
const clock = armed();
|
||||
assert.equal(quoteAuctionClock(input({ clock, now: 10_999.5 })).expired, false);
|
||||
const expired = quoteAuctionClock(input({ clock, now: 11_000 }));
|
||||
assert.deepEqual(expired, { clock: armed({ remainingMs: 0, startedAt: null }), expired: true, delayMs: null });
|
||||
assert.deepEqual(viewAuctionClock(expired.clock!, 11_000), {
|
||||
event: 'clock:1', player: 'atreides', remainingMs: 0, running: false,
|
||||
});
|
||||
const repeated = quoteAuctionClock(input({ clock: expired.clock, now: 20_000, event: 'unused:new' }));
|
||||
assert.equal(repeated.clock, expired.clock);
|
||||
assert.equal(repeated.expired, true);
|
||||
assert.equal(repeated.delayMs, null);
|
||||
});
|
||||
|
||||
void test('a blocker suspends the exact retained budget and resumption does not grant a fresh ten seconds', () => {
|
||||
const clock = armed();
|
||||
const paused = quoteAuctionClock(input({ clock, now: 3_750, blocked: true }));
|
||||
assert.deepEqual(paused, { clock: armed({ remainingMs: 7_250, startedAt: null }), expired: false, delayMs: null });
|
||||
assert.deepEqual(viewAuctionClock(paused.clock!, 50_000), {
|
||||
event: 'clock:1', player: 'atreides', remainingMs: 7_250, running: false,
|
||||
});
|
||||
const stillPaused = quoteAuctionClock(input({ clock: paused.clock, now: 50_000, blocked: true, event: 'unused:pause' }));
|
||||
assert.equal(stillPaused.clock, paused.clock);
|
||||
const resumed = quoteAuctionClock(input({ clock: paused.clock, now: 50_000, event: 'unused:resume' }));
|
||||
assert.deepEqual(resumed, { clock: armed({ remainingMs: 7_250, startedAt: 50_000 }), expired: false, delayMs: 7_250 });
|
||||
assert.equal(quoteAuctionClock(input({ clock: resumed.clock, now: 57_249 })).expired, false);
|
||||
assert.equal(quoteAuctionClock(input({ clock: resumed.clock, now: 57_250 })).expired, true);
|
||||
assert.equal(clock.startedAt, 1_000);
|
||||
assert.equal(clock.remainingMs, 10_000);
|
||||
});
|
||||
|
||||
void test('a newly observed blocked opportunity starts suspended and never spends its initial budget', () => {
|
||||
const paused = quoteAuctionClock(input({ blocked: true }));
|
||||
assert.deepEqual(paused, { clock: armed({ startedAt: null }), expired: false, delayMs: null });
|
||||
const resumed = quoteAuctionClock(input({ clock: paused.clock, now: 500_000 }));
|
||||
assert.equal(resumed.clock!.startedAt, 500_000);
|
||||
assert.equal(resumed.clock!.event, 'clock:1');
|
||||
assert.equal(resumed.delayMs, 10_000);
|
||||
});
|
||||
|
||||
void test('a blocker suppresses an already elapsed deadline until the native opportunity resumes', () => {
|
||||
const paused = quoteAuctionClock(input({ clock: armed(), now: 11_000, blocked: true }));
|
||||
assert.equal(paused.expired, false);
|
||||
assert.equal(paused.delayMs, null);
|
||||
assert.deepEqual(paused.clock, armed({ remainingMs: 0, startedAt: null }));
|
||||
assert.equal(viewAuctionClock(paused.clock!, 50_000).event, 'clock:1');
|
||||
const resumed = quoteAuctionClock(input({ clock: paused.clock, now: 50_000 }));
|
||||
assert.equal(resumed.expired, true);
|
||||
assert.equal(resumed.delayMs, null);
|
||||
});
|
||||
|
||||
void test('source and bidder changes each bind a fresh supplied event and full budget', () => {
|
||||
const clock = armed({ remainingMs: 500 });
|
||||
for (const next of [{ source: 'ordinary-lot:2', player: 'atreides' },
|
||||
{ source: opportunity.source, player: 'emperor' }]) {
|
||||
const result = quoteAuctionClock(input({ clock, opportunity: next, now: 5_000, event: 'clock:2' }));
|
||||
assert.deepEqual(result.clock, { ...clock, ...next, event: 'clock:2', remainingMs: 10_000, startedAt: 5_000 });
|
||||
assert.equal(result.delayMs, 10_000);
|
||||
assert.equal(result.expired, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a changed control epoch re-arms even when no intermediate admin pause was observed', () => {
|
||||
const expired = armed({ remainingMs: 0, startedAt: null });
|
||||
const resumed = quoteAuctionClock(input({ clock: expired, now: 60_000,
|
||||
controlEpoch: 'control:2', event: 'clock:2' }));
|
||||
assert.deepEqual(resumed, { clock: armed({ controlEpoch: 'control:2', event: 'clock:2', startedAt: 60_000 }),
|
||||
expired: false, delayMs: 10_000 });
|
||||
const suspended = quoteAuctionClock(input({ clock: expired, now: 60_000, blocked: true,
|
||||
controlEpoch: 'control:2', event: 'clock:2' }));
|
||||
assert.deepEqual(suspended, { clock: armed({ controlEpoch: 'control:2', event: 'clock:2', startedAt: null }),
|
||||
expired: false, delayMs: null });
|
||||
});
|
||||
|
||||
void test('removing the native opportunity removes its frame and a later opportunity arms anew', () => {
|
||||
const result = quoteAuctionClock(input({ clock: armed(), opportunity: null, now: 5_000 }));
|
||||
assert.deepEqual(result, { clock: undefined, expired: false, delayMs: null });
|
||||
assert.equal(quoteAuctionClock(input({ clock: result.clock, now: 50_000, event: 'clock:2' })).delayMs, 10_000);
|
||||
});
|
||||
|
||||
void test('clock rollback consumes no retained time and never expands its budget', () => {
|
||||
const clock = armed({ remainingMs: 2_500 });
|
||||
const result = quoteAuctionClock(input({ clock, now: 500 }));
|
||||
assert.equal(result.clock, clock);
|
||||
assert.equal(result.delayMs, 2_500);
|
||||
assert.equal(result.expired, false);
|
||||
assert.equal(viewAuctionClock(clock, 500).remainingMs, 2_500);
|
||||
const paused = quoteAuctionClock(input({ clock, now: 500, blocked: true }));
|
||||
assert.deepEqual(paused.clock, { ...clock, startedAt: null });
|
||||
assert.equal(quoteAuctionClock(input({ clock: paused.clock, now: 5_000 })).delayMs, 2_500);
|
||||
});
|
||||
|
||||
void test('fractional elapsed time remains exact and a projected view never mutates the frame', () => {
|
||||
const clock = Object.freeze(armed({ remainingMs: 1_000.75, startedAt: 10.25 }));
|
||||
const before = structuredClone(clock);
|
||||
assert.equal(viewAuctionClock(clock, 110.5).remainingMs, 900.5);
|
||||
const paused = quoteAuctionClock(input({ clock, now: 110.5, blocked: true }));
|
||||
assert.equal(paused.clock!.remainingMs, 900.5);
|
||||
assert.deepEqual(clock, before);
|
||||
});
|
||||
|
||||
void test('clock validation rejects missing, extra, mistyped or nonfinite stored fields before projecting or discarding', () => {
|
||||
const clock = armed();
|
||||
const invalid: unknown[] = [null, [], {}, { ...clock, event: '' }, { ...clock, source: ' source' },
|
||||
{ ...clock, player: '' }, { ...clock, controlEpoch: '' }, { ...clock, deadline: 11_000 },
|
||||
...[-1, 10_001, NaN, Infinity, '10000'].map(remainingMs => ({ ...clock, remainingMs })),
|
||||
...[undefined, NaN, Infinity, '1000'].map(startedAt => ({ ...clock, startedAt }))];
|
||||
for (const field of Object.keys(clock)) {
|
||||
const missing: Record<string, unknown> = { ...clock };
|
||||
delete missing[field];
|
||||
invalid.push(missing);
|
||||
}
|
||||
for (const value of invalid) {
|
||||
assert.throws(() => viewAuctionClock(value as AuctionClock, 1_000), AuctionClockError);
|
||||
assert.throws(() => quoteAuctionClock(input({ clock: value as AuctionClock, opportunity: null })), AuctionClockError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('quotation validates complete native opportunity metadata and finite caller time without coercion', () => {
|
||||
const valid = input();
|
||||
const invalid: unknown[] = [null, [], {}, { ...valid, event: '' }, { ...valid, controlEpoch: ' epoch' },
|
||||
{ ...valid, blocked: 0 }, { ...valid, deadline: 11_000 },
|
||||
...[undefined, NaN, Infinity, '1000'].map(now => ({ ...valid, now })),
|
||||
...[undefined, [], {}, { source: '', player: 'atreides' }, { source: 'lot', player: ' atreides' },
|
||||
{ ...opportunity, bid: 1 }].map(opportunity => ({ ...valid, opportunity }))];
|
||||
for (const field of Object.keys(valid)) {
|
||||
const missing: Record<string, unknown> = { ...valid };
|
||||
delete missing[field];
|
||||
invalid.push(missing);
|
||||
}
|
||||
for (const value of invalid) assert.throws(() => quoteAuctionClock(value as typeof valid), AuctionClockError);
|
||||
for (const now of [NaN, Infinity, '1000', undefined])
|
||||
assert.throws(() => viewAuctionClock(armed(), now as number), AuctionClockError);
|
||||
assert.doesNotThrow(() => quoteAuctionClock(input({ opportunity: null })));
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import * as jsxRuntime from 'react/jsx-runtime';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import ts from 'typescript';
|
||||
import { AuctionCountdown } from '../components/auction-countdown';
|
||||
import type { AuctionClockView } from '../game/auction-clock';
|
||||
|
||||
const frame = (remainingMs = 10_000, running = true, event: string | null = 'ordinary-bid-event'): AuctionClockView =>
|
||||
({ event, player: 'p', remainingMs, running });
|
||||
const html = (clock: AuctionClockView | null, paused = false, playerName = 'Respondent') =>
|
||||
renderToStaticMarkup(createElement(AuctionCountdown, { clock, playerName, paused }));
|
||||
|
||||
void test('auction countdown renders public whole-second time and explains the native deadline and compelled-bid convention', () => {
|
||||
for (const [remainingMs, label] of [[10_000, '10 seconds'], [9_001, '10 seconds'], [1_001, '2 seconds'], [1_000, '1 second'], [1, '1 second']] as const) {
|
||||
const clock = frame(remainingMs), before = structuredClone(clock), markup = html(clock);
|
||||
assert.match(markup, new RegExp(`${label} remaining`)); assert.deepEqual(clock, before);
|
||||
assert.match(markup, /Respondent may bid or pass/); assert.match(markup, /role="timer" aria-live="off"/);
|
||||
assert.match(markup, /each player 10 seconds to bid or pass/); assert.match(markup, /server controls the deadline/);
|
||||
assert.match(markup, /Online convention: if passing would violate a binding Truthtrance answer/);
|
||||
assert.match(markup, /server submits only the required legal bid/); assert.match(markup, /normal payment is still owed/);
|
||||
assert.doesNotMatch(markup, /<button|<form|Continue|onClick/);
|
||||
}
|
||||
assert.equal(html(null), '');
|
||||
assert.match(html(frame(), false, '<script>send pass</script>'), /<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, /<button|<form|Continue/);
|
||||
if (roomPaused) assert.match(expired, /Auction paused/);
|
||||
}
|
||||
});
|
||||
|
||||
/** Run the real component effects with a controlled monotonic browser clock.
|
||||
* React markup remains real; no DOM package or server action mock is needed. */
|
||||
function browserDisplay(initial: Parameters<typeof AuctionCountdown>[0]) {
|
||||
type Effect = { dependencies: readonly unknown[]; cleanup?: () => void };
|
||||
const states: unknown[] = [], effects: Effect[] = [], pending: (() => void)[] = [];
|
||||
const intervals = new Map<number, () => void>();
|
||||
let stateIndex = 0, effectIndex = 0, now = 0, nextTimer = 0, props = initial;
|
||||
const exports: { AuctionCountdown?: typeof AuctionCountdown } = {};
|
||||
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/auction-countdown.tsx', import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText, {
|
||||
exports,
|
||||
performance: { now: () => now },
|
||||
setInterval(callback: () => void, ms: number) { assert.equal(ms, 200); const timer = ++nextTimer; intervals.set(timer, callback); return timer; },
|
||||
clearInterval(timer: number) { intervals.delete(timer); },
|
||||
require(name: string) {
|
||||
if (name === 'react/jsx-runtime') return jsxRuntime;
|
||||
assert.equal(name, 'react');
|
||||
return {
|
||||
useState(initialValue: unknown) {
|
||||
const index = stateIndex++;
|
||||
if (!(index in states)) states[index] = initialValue;
|
||||
return [states[index], (next: unknown) => { states[index] = next; }];
|
||||
},
|
||||
useEffect(callback: () => (() => void) | undefined, dependencies: readonly unknown[]) {
|
||||
const index = effectIndex++, previous = effects[index];
|
||||
if (previous && dependencies.every((dependency, i) => Object.is(dependency, previous.dependencies[i]))) return;
|
||||
pending.push(() => { previous?.cleanup?.(); effects[index] = { dependencies, cleanup: callback() }; });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.ok(exports.AuctionCountdown);
|
||||
const render = () => {
|
||||
stateIndex = 0; effectIndex = 0;
|
||||
const node: ReactNode = exports.AuctionCountdown!(props);
|
||||
const markup = renderToStaticMarkup(node);
|
||||
while (pending.length) pending.shift()!();
|
||||
return markup;
|
||||
};
|
||||
return {
|
||||
render,
|
||||
update(next: typeof initial) { props = next; return render(); },
|
||||
advance(ms: number) { now += ms; for (const callback of intervals.values()) callback(); return render(); },
|
||||
get ticking() { return intervals.size; },
|
||||
};
|
||||
}
|
||||
|
||||
void test('local countdown follows elapsed browser time, accepts replacement server frames and stops at server-resolution status', () => {
|
||||
const display = browserDisplay({ clock: frame(), playerName: 'Respondent' });
|
||||
assert.match(display.render(), /10 seconds remaining/); assert.equal(display.ticking, 1);
|
||||
// One delayed callback still accounts for the actual elapsed time.
|
||||
assert.match(display.advance(5_401), /5 seconds remaining/);
|
||||
assert.match(display.update({ clock: frame(2_000), playerName: 'Respondent' }), /2 seconds remaining/);
|
||||
assert.equal(display.ticking, 1); assert.match(display.advance(1_001), /1 second remaining/);
|
||||
const expired = display.advance(999); assert.match(expired, /Awaiting server resolution/); assert.equal(display.ticking, 0);
|
||||
assert.doesNotMatch(expired, /<button|<form/);
|
||||
assert.match(display.update({ clock: { ...frame(), event: 'next-bid-event', player: 'a' }, playerName: 'Next bidder' }), /10 seconds remaining/);
|
||||
assert.match(display.render(), /Next bidder may bid or pass/); assert.equal(display.ticking, 1);
|
||||
assert.equal(display.update({ clock: null, playerName: 'Next bidder' }), ''); assert.equal(display.ticking, 0);
|
||||
});
|
||||
|
||||
void test('interrupted and room-paused frames retain their balance across local time and resume from the current server frame', () => {
|
||||
const display = browserDisplay({ clock: frame(6_200, false), playerName: 'Respondent' });
|
||||
assert.match(display.render(), /7 seconds remaining/); assert.equal(display.ticking, 0);
|
||||
assert.match(display.advance(20_000), /7 seconds remaining/);
|
||||
const paused = { clock: frame(4_500), playerName: 'Respondent', paused: true };
|
||||
assert.match(display.update(paused), /Auction paused/); assert.equal(display.ticking, 0);
|
||||
assert.match(display.advance(20_000), /5 seconds remaining/);
|
||||
assert.match(display.update({ ...paused, paused: false }), /5 seconds remaining/); assert.equal(display.ticking, 1);
|
||||
assert.match(display.advance(4_500), /Awaiting server resolution/); assert.equal(display.ticking, 0);
|
||||
const unarmed = display.update({ clock: frame(10_000, false, null), playerName: 'Respondent' });
|
||||
assert.match(unarmed, /Clock starts when ordinary bidding is ready/); assert.equal(display.ticking, 0);
|
||||
});
|
||||
@@ -9,7 +9,7 @@ export function loadRoomContinuation(
|
||||
rooms: Pick<
|
||||
typeof Rooms,
|
||||
'continueRoomAutomatic' | 'continueRoomBots'
|
||||
>,
|
||||
> & Partial<Pick<typeof Rooms, 'continueRoomAuctionClock'>>,
|
||||
) {
|
||||
const exports = {};
|
||||
runInNewContext(
|
||||
@@ -28,7 +28,7 @@ export function loadRoomContinuation(
|
||||
{
|
||||
exports,
|
||||
require: (name: string) => {
|
||||
if (name === './rooms') return rooms;
|
||||
if (name === './rooms') return { continueRoomAuctionClock: async () => false, ...rooms };
|
||||
throw new Error('Unexpected continuation dependency ' + name);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as engine from '../game/engine';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { loadRoomContinuation } from './fixture-room-continuation';
|
||||
import {
|
||||
actTruthBid, bindTruthBid, createTruthBidFixture, declareTruthBid, truthBidInventory,
|
||||
} from './fixture-truth-bid';
|
||||
|
||||
function fakeClock(start = 10000) {
|
||||
let time = start;
|
||||
const sleeps: number[] = [];
|
||||
return { now: () => time, advance: (ms: number) => { time += ms; }, sleeps,
|
||||
sleep: async (ms: number) => { sleeps.push(ms); time += ms; } } satisfies Rooms.RoomsClock & {
|
||||
advance(ms: number): void; sleeps: number[];
|
||||
};
|
||||
}
|
||||
function fixture(options: { required?: 'scalar' | 'aggregate'; legacy?: boolean } = {}) {
|
||||
const native = createTruthBidFixture({ window: options.required ? 'active' : 'waiting' });
|
||||
let game = structuredClone(native.game);
|
||||
if (options.required === 'scalar') game = bindTruthBid(native, 'yes', 4).game;
|
||||
if (options.required === 'aggregate') {
|
||||
game = declareTruthBid(native).game;
|
||||
game = actTruthBid(game, native.asker, { type: 'truthAsk', question: { kind: 'turnActions',
|
||||
target: native.target, turn: game.turn, mixed: { kind: 'and', terms: [
|
||||
{ kind: 'action', action: { kind: 'bid', lot: game.auction!.index + 1, minimum: 4 } },
|
||||
{ kind: 'action', action: { kind: 'payment', recipient: native.asker, minimum: 2 } },
|
||||
] } } });
|
||||
game = actTruthBid(game, native.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
}
|
||||
assert.equal(game.auction?.clockVersion, 1, 'The genuine new ordinary lot opts into trusted timing');
|
||||
// This one controlled metadata omission represents a current saved lot
|
||||
// predating the clock marker; no card, wallet, force or played action changes.
|
||||
if (options.legacy) delete game.auction!.clockVersion;
|
||||
const store = unitStore(), clock = fakeClock(), code = game.code;
|
||||
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,0,?)').run(code, JSON.stringify(game), clock.now());
|
||||
const auth = Object.fromEntries(game.players.map(player => {
|
||||
const tokenHash = createHash('sha256').update(`auction-clock-test:${player.id}`).digest('hex');
|
||||
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)').run(tokenHash, code, player.id);
|
||||
return [player.id, { playerId: player.id, tokenHash }];
|
||||
})) as Record<string, Rooms.SeatAuth>;
|
||||
return { ...store, clock, code, native, auth, actor: game.auction!.active };
|
||||
}
|
||||
|
||||
void test('trusted private read arms a genuine ordinary lot and projects remaining time without per-poll writes', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const view = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
assert.equal(view.version, 1); assert.equal(view.auctionClock?.player, f.actor);
|
||||
assert.equal(view.auctionClock?.remainingMs, 10000); assert.equal(view.auctionClock?.running, true);
|
||||
assert.equal(typeof view.auctionClock?.event, 'string');
|
||||
const stored = await f.rooms.readRoom(f.code); assert.equal(stored.auctionClock?.controlEpoch, '0:0');
|
||||
assert.equal(stored.auctionClock?.startedAt, f.clock.now());
|
||||
assert.equal(f.rooms.needsAutomaticRoomRecovery(stored), true);
|
||||
assert.equal(f.rooms.needsAutomaticRoomRecovery(view), true);
|
||||
f.clock.advance(4321);
|
||||
const later = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
assert.equal(later.version, view.version); assert.equal(later.auctionClock?.remainingMs, 5679);
|
||||
assert.equal(later.auctionClock?.event, view.auctionClock?.event);
|
||||
assert.deepEqual(await f.rooms.readRoom(f.code), stored);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('trusted deadline acts exactly at ten seconds and a waiting worker sleeps once before one native pass', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
f.clock.advance(9999); assert.equal(await f.rooms.continueRoomAuctionClock(f.code, f.clock), false);
|
||||
let game = await f.rooms.readRoom(f.code); assert.equal(game.auction?.active, f.actor);
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock, true);
|
||||
assert.deepEqual(f.clock.sleeps, [1]); game = await f.rooms.readRoom(f.code);
|
||||
assert.ok(game.auction?.passed.includes(f.actor)); assert.notEqual(game.auction?.active, f.actor);
|
||||
assert.equal(game.version, 2); assert.equal(game.auctionClock?.remainingMs, 10000);
|
||||
assert.equal(game.auctionClock?.startedAt, f.clock.now());
|
||||
assert.deepEqual(truthBidInventory(game), f.native.originalCards);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('timely authenticated bid starts its successor clock while a missed deadline rejects a late bid before submission', async () => {
|
||||
for (const timely of [true, false]) {
|
||||
const f = fixture();
|
||||
try {
|
||||
const view = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
f.clock.advance(timely ? 9999 : 10000);
|
||||
const action: engine.Action = { type: 'bid', amount: 1, allyPayment: 0 };
|
||||
if (timely) {
|
||||
const done = await f.rooms.act(f.code, f.auth[f.actor], view.version, action, f.clock);
|
||||
assert.equal(done.auction?.bid, 1); assert.equal(done.auction?.bidder, f.actor);
|
||||
assert.notEqual(done.auctionClock?.event, view.auctionClock?.event);
|
||||
assert.equal(done.auctionClock?.remainingMs, 10000);
|
||||
} else {
|
||||
await assert.rejects(f.rooms.act(f.code, f.auth[f.actor], view.version, action, f.clock), /table changed/i);
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.auction?.bid, 0); assert.ok(game.auction?.passed.includes(f.actor));
|
||||
assert.notEqual(game.auction?.active, f.actor);
|
||||
}
|
||||
} finally { f.sqlite.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
void test('a fresh room-module instance resumes the persisted missed deadline on an authenticated read', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const before = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
f.clock.advance(10000);
|
||||
const restarted = f.restart();
|
||||
const view = await restarted.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
assert.equal(view.version, before.version + 1); assert.notEqual(view.auctionClock?.player, f.actor);
|
||||
const game = await restarted.readRoom(f.code); assert.ok(game.auction?.passed.includes(f.actor));
|
||||
assert.deepEqual(f.clock.sleeps, []);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('actual Truthtrance interrupts suspend the retained budget and actual completion resumes it without charging blocked time', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
let view = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
f.clock.advance(4000);
|
||||
view = await f.rooms.act(f.code, f.auth[f.native.asker], view.version,
|
||||
{ type: 'card', card: f.native.truth }, f.clock);
|
||||
let game = await f.rooms.readRoom(f.code); assert.ok(game.truthtrance);
|
||||
assert.equal(game.auctionClock?.remainingMs, 6000); assert.equal(game.auctionClock?.startedAt, null);
|
||||
f.clock.advance(60000); const before = structuredClone(game);
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock, true);
|
||||
assert.deepEqual(f.clock.sleeps, []); assert.deepEqual(await f.rooms.readRoom(f.code), before);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
await f.rooms.act(f.code, f.auth[actor.id], game.version, { type: 'truthPass' }, f.clock);
|
||||
game = await f.rooms.readRoom(f.code);
|
||||
}
|
||||
await f.rooms.act(f.code, f.auth[f.native.asker], game.version, { type: 'truthAsk', question: {
|
||||
kind: 'fact', target: f.native.target, fact: { kind: 'spice', compare: 'gte', value: 0 },
|
||||
} }, f.clock);
|
||||
game = await f.rooms.readRoom(f.code);
|
||||
await f.rooms.act(f.code, f.auth[f.native.target], game.version, { type: 'truthAnswer', answer: 'yes' }, f.clock);
|
||||
game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.truthtrance, null); assert.equal(game.auctionClock?.remainingMs, 6000);
|
||||
assert.equal(game.auctionClock?.startedAt, f.clock.now());
|
||||
f.clock.advance(5999); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
assert.equal((await f.rooms.readRoom(f.code)).auction?.active, f.actor);
|
||||
f.clock.advance(1); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
assert.ok((await f.rooms.readRoom(f.code)).auction?.passed.includes(f.actor));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
for (const boundary of ['pause', 'closure'] as const)
|
||||
void test(`trusted ${boundary} revisions prevent clock writes while blocked and rearm a full budget on reopening`, async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(4000);
|
||||
if (boundary === 'pause') f.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,1,0,1,?)')
|
||||
.run(f.code, f.clock.now());
|
||||
else f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,?,?)')
|
||||
.run(f.code, f.clock.now(), f.clock.now());
|
||||
const before = await f.rooms.readRoom(f.code); f.clock.advance(60000);
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock, true);
|
||||
const blocked = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
assert.equal(blocked.auctionClock?.running, false); assert.equal(blocked.auctionClock?.remainingMs, 10000);
|
||||
assert.deepEqual(await f.rooms.readRoom(f.code), before); assert.deepEqual(f.clock.sleeps, []);
|
||||
if (boundary === 'pause') f.sqlite.prepare('UPDATE room_controls SET paused=0,revision=2,updated_at=? WHERE room_code=?')
|
||||
.run(f.clock.now(), f.code);
|
||||
else f.sqlite.prepare('UPDATE room_closures SET closed=0,closed_at=NULL,revision=2,updated_at=? WHERE room_code=?')
|
||||
.run(f.clock.now(), f.code);
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
let game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.auctionClock?.controlEpoch, boundary === 'pause' ? '2:0' : '0:2');
|
||||
assert.equal(game.auctionClock?.remainingMs, 10000); assert.equal(game.auctionClock?.startedAt, f.clock.now());
|
||||
f.clock.advance(9999); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
assert.equal((await f.rooms.readRoom(f.code)).auction?.active, f.actor);
|
||||
f.clock.advance(1); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
game = await f.rooms.readRoom(f.code); assert.ok(game.auction?.passed.includes(f.actor));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('trusted clock CAS fences both administrative revisions even if a new revision is currently unpaused and open', async () => {
|
||||
for (const boundary of ['control', 'closure'] as const) {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(10000);
|
||||
f.hooks.beforeWrite = async () => {
|
||||
delete f.hooks.beforeWrite;
|
||||
if (boundary === 'control') f.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,0,0,1,?)')
|
||||
.run(f.code, f.clock.now());
|
||||
else f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,0,1,NULL,?)')
|
||||
.run(f.code, f.clock.now());
|
||||
};
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.auction?.active, f.actor); assert.equal(game.auction?.bid, 0);
|
||||
assert.equal(game.auctionClock?.controlEpoch, boundary === 'control' ? '1:0' : '0:1');
|
||||
assert.equal(game.auctionClock?.startedAt, f.clock.now()); assert.equal(game.auctionClock?.remainingMs, 10000);
|
||||
assert.ok(f.writes.some(write => write.changes === 0));
|
||||
} finally { f.sqlite.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
void test('a competing timely native bid wins the version CAS without a duplicate expired pass', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(10000);
|
||||
f.hooks.beforeWrite = async () => {
|
||||
delete f.hooks.beforeWrite;
|
||||
const current = await f.rooms.readRoom(f.code);
|
||||
// Another request passed trusted preflight one millisecond before the
|
||||
// deadline and commits its legal native bid before this timer's CAS.
|
||||
const next = engine.synchronizeAuctionClock(engine.applyAction(current, f.actor,
|
||||
{ type: 'bid', amount: 1, allyPayment: 0 }), f.clock.now() - 1, '0:0');
|
||||
next.version = current.version + 1;
|
||||
f.sqlite.prepare('UPDATE rooms SET state=?,version=version+1 WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(next), f.code, current.version);
|
||||
};
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.auction?.bid, 1); assert.equal(game.auction?.bidder, f.actor);
|
||||
assert.ok(!game.auction?.passed.includes(f.actor)); assert.notEqual(game.auction?.active, f.actor);
|
||||
assert.equal(game.version, 2); assert.ok(f.writes.some(write => write.changes === 0));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('recoverable room removal fences a due timer write without advancing the saved native lot', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
const before = await f.rooms.readRoom(f.code); f.clock.advance(10000);
|
||||
f.hooks.beforeWrite = async () => {
|
||||
delete f.hooks.beforeWrite;
|
||||
f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,?,?)')
|
||||
.run(f.code, f.clock.now(), f.clock.now());
|
||||
};
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
const stored = f.sqlite.prepare('SELECT state,version FROM rooms WHERE code=?').get(f.code)!;
|
||||
assert.equal(stored.version, before.version); assert.deepEqual(JSON.parse(stored.state as string), before);
|
||||
assert.ok(f.writes.some(write => write.changes === 0));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
for (const required of ['scalar', 'aggregate'] as const)
|
||||
void test(`trusted expired ${required} Truth bid submits only its required legal native bid instead of evading by pass`, async () => {
|
||||
const f = fixture({ required });
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(10000);
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.auction?.bidder, f.native.target); assert.equal(game.auction?.bid, 4);
|
||||
assert.equal(game.players.find(player => player.id === f.native.target)!.spice, 10);
|
||||
if (required === 'scalar') assert.equal(game.bidPromises?.[0].fulfilled, true);
|
||||
else {
|
||||
assert.equal(game.turnActionPromises?.[0].preparation?.bid?.maximum, 4);
|
||||
assert.deepEqual(game.turnActionPromises?.[0].payments, []);
|
||||
assert.equal(game.turnActionPromises?.[0].fulfilled, false);
|
||||
}
|
||||
assert.equal(game.discard.filter(card => card.id === f.native.truth).length, 1);
|
||||
assert.deepEqual(truthBidInventory(game), f.native.originalCards);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('legacy unstamped current lots stay untimed and invalid credentials cannot arm or expire another room', async () => {
|
||||
const legacy = fixture({ legacy: true }), f = fixture();
|
||||
try {
|
||||
const view = await legacy.rooms.readSeatView(legacy.code, legacy.auth[legacy.actor], legacy.clock);
|
||||
assert.equal(view.auctionClock, null); assert.equal(view.version, 0);
|
||||
legacy.clock.advance(60000); await legacy.rooms.continueRoomAuctionClock(legacy.code, legacy.clock, true);
|
||||
assert.equal((await legacy.rooms.readRoom(legacy.code)).version, 0); assert.deepEqual(legacy.clock.sleeps, []);
|
||||
const forged = { playerId: f.actor, tokenHash: 'not-the-seat-hash' };
|
||||
await assert.rejects(f.rooms.readSeatView(f.code, forged, f.clock), /verified/);
|
||||
await assert.rejects(f.rooms.act(f.code, forged, 0, { type: 'passBid' }, f.clock), /verified/);
|
||||
const unchanged = await f.rooms.readRoom(f.code); assert.equal(unchanged.version, 0);
|
||||
assert.equal(unchanged.auctionClock, undefined);
|
||||
} finally { legacy.sqlite.close(); f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('resume runs native automatic then bots alongside a sleeping auction clock without delaying the bot lane', async () => {
|
||||
const calls: string[] = []; let release!: () => void;
|
||||
const sleeping = new Promise<void>(resolve => { release = resolve; });
|
||||
const continuation = loadRoomContinuation({
|
||||
continueRoomAutomatic: async () => { calls.push('automatic'); },
|
||||
continueRoomBots: async () => { calls.push('bots'); },
|
||||
continueRoomAuctionClock: async (_code, _clock, wait) => {
|
||||
assert.equal(wait, true); calls.push('clock'); await sleeping; return false;
|
||||
},
|
||||
});
|
||||
const first = continuation.resumeRoom('CLOCK'); const coalesced = continuation.resumeRoom('CLOCK');
|
||||
assert.equal(first, coalesced);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
assert.ok(calls.includes('automatic') && calls.includes('clock') && calls.includes('bots'));
|
||||
assert.ok(calls.indexOf('automatic') < calls.indexOf('bots'));
|
||||
release(); await first;
|
||||
});
|
||||
Reference in new issue
Block a user