Time sequential Richese auctions
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 02:11:18 +11:00
1 parent e7520e8679
commit 7fc6f1cd43
15 files changed
+692 -38

No files matched your search

+7 -5
View File
@@ -47,11 +47,13 @@ and difficulty. The mode cannot be disabled while a controller has a second
faction. Counts for optional modules and wider interactions remain provisional.
See [the implementation scope](docs/TWO_PLAYER_TWO_FACTIONS.md).
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
New ordinary lots and sequential Richese Once Around or normal Black Market
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. Once
Around timing is provisional; Silent bidding remains untimed. 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,
+6
View File
@@ -272,6 +272,7 @@ import {
} from '@/game/board';
import type { Action, GameView, Plan } from '@/game/engine';
import { AuctionCountdown } from './auction-countdown';
import { RicheseAuctionCountdown } from './richese-auction-countdown';
import { PLAYER_CIRCLE_SECTORS } from '@/game/player-positions';
import { SeatAutopilot } from './seat-autopilot';
import {
@@ -2140,6 +2141,11 @@ export function GameTable({
playerName={g.players.find(player => player.id === g.auction!.active)?.name ?? 'The current bidder'}
paused={g.roomControl?.paused || g.roomControl?.closed} />
)}
{g.status === 'playing' && g.phase === 3 && g.richeseAuction && g.richeseAuction.method !== 'silent' && (
<RicheseAuctionCountdown clock={g.auctionClock} method={g.richeseAuction.method}
playerName={g.players.find(player => player.id === g.richeseAuction!.active)?.name ?? 'The current bidder'}
paused={g.roomControl?.paused || g.roomControl?.closed} />
)}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
+48
View File
@@ -0,0 +1,48 @@
'use client';
import { useEffect, useState } from 'react';
import type { AuctionClockView } from '@/game/auction-clock';
import type { RicheseAuctionMethod } from '@/game/richese-auction';
/** Display only: the authoritative room clock performs the expiry action. */
export function RicheseAuctionCountdown({ clock, playerName, method, paused = false }: {
clock: AuctionClockView | null;
playerName: string;
method: Exclude<RicheseAuctionMethod, 'silent'>;
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 name = method === 'onceAround' ? 'Once Around' : 'Black Market';
const status = clock.event !== null && seconds === 0
? `${paused ? 'Auction paused. ' : ''}Awaiting server resolution.`
: clock.event === null
? `${paused ? 'Auction paused. ' : ''}Clock starts when 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={`${name} auction countdown`} data-testid="richese-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">This timed {name} auction gives each bidder 10 seconds to bid or pass. The server controls the deadline and resolves expiry.</p>
{method === 'onceAround' && <>
<p className="fine">Once Around timing is a provisional online interpretation of the standard bid deadline.</p>
<p className="fine">If passing would break a binding Truthtrance answer, the server submits only its required legal bid; normal payment still applies.</p>
</>}
</section>;
}
+2 -1
View File
@@ -699,7 +699,7 @@ async function readWorkerRoomSnapshot(code: string) {
}
}
/** One ordinary bid deadline through the existing trusted clock and room CAS.
/** One sequential 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
@@ -719,6 +719,7 @@ export async function continueRoomAuctionClock(
const now = clock.now();
const next = advanceAuctionClock(current, now, controlEpoch);
const nativeProgress = JSON.stringify(next.auction) !== JSON.stringify(current.auction) ||
JSON.stringify(next.richeseAuction) !== JSON.stringify(current.richeseAuction) ||
next.phase !== current.phase || next.turn !== current.turn;
if (JSON.stringify(next) !== JSON.stringify(current)) {
queueRoomBots(next, current, now);
+29 -2
View File
@@ -1,5 +1,31 @@
# Ordinary-auction time limit
## Sequential Richese auctions — 29 October 2026
**Prototyped / Partial.** Fresh Once Around cache/Black Market lots and normal
Black Market lots use the same trusted ten-second room clock as ordinary
auctions. Expiry performs the actual Richese pass. A binding Once Around
Truthtrance Yes instead submits its lowest compatible funded bid, retaining
the existing provisional online convention. Truthtrance and other decisions
hold the remaining budget; changing a later bidder's order does not give the
current bidder more time. Room pause/control fences, stale-action checks and
the existing bounded worker apply. The public countdown does not expose the
concealed Black Market card.
The publisher's Basic ten-second rule and the authorized Advanced revision
physical p. 8 / printed p. 6 govern ordinary bidding. Applying that limit to
Once Around is a provisional online interpretation, not a separate publisher
ruling. Silent auctions stay untimed; previously open unstamped special lots
retain their saved behavior. Real Basic cache and Advanced Black Market tests,
display and isolated trusted-room cases plus one authenticated HTTP QA room
cover this slice. Complete modes, other auction interactions and final deployed
acceptance remain open.
[Native cases](../tests/richese-auction-clock-runtime.test.ts),
[display](../tests/richese-auction-countdown.test.tsx),
[trusted-room cases](../tests/rooms-richese-auction-clock.test.ts),
[HTTP journey](../tests/richese-auction-clock-http.test.ts).
## Source and scope
**Prototyped / Partial.** Publisher Basic rulebook printed8, Time Limit,
@@ -10,7 +36,8 @@ physical8/printed6. This is a Basic and Advanced rule, not an AI pacing option.
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.
uses the clock. At that checkpoint special procedures retained their own rules;
the sequential Richese extension is described above.
Pure engine/offline simulation has no wall-clock dependency. Only trusted
room time arms, suspends or expires an online clock; clients cannot supply time.
@@ -99,7 +126,7 @@ Proof/manifest:
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,
or deployed acceptance. At that checkpoint 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
+8
View File
@@ -17,6 +17,14 @@ open mode gates.
## Current checkpoint and work
**Sequential Richese bidding clock — 29 October. Prototyped / Partial.**
Fresh Once Around and normal Black Market lots use the trusted ten-second
countdown and native pass, with supported binding Truthtrance bids honored.
Silent and old unstamped lots remain untimed. Nine related files pass 70 cases,
and one dedicated authenticated HTTP room reaches and expires a real cache
lot; typecheck, lint and the production build pass. The Once Around interpretation and wider auction interactions remain
provisional. [Scope](AUCTION_TIME_LIMIT.md#sequential-richese-auctions--29-october-2026).
**Basic paid-sale responses with Kull and Semuta — 29 October. Prototyped / Partial.**
Emperor income cancellation after a normal or Richese cache sale and Ixian
ally replacement cancellation after a normal sale can now retain the original
+7
View File
@@ -1,5 +1,12 @@
# Rule decision index
## Sequential Richese auction clocks — 29 October 2026
Fresh Once Around and normal Black Market lots use the trusted ten-second
bid clock; applying it to Once Around is a provisional interpretation. Silent
and already-open unstamped lots retain their prior behavior.
[Scope](AUCTION_TIME_LIMIT.md#sequential-richese-auctions--29-october-2026).
## Basic paid-sale responses and Kull — 29 October 2026
The supported Basic printed-Kull and Semuta profile retains Emperor income
+4 -4
View File
@@ -42,7 +42,7 @@ function validateClock(value: unknown): asserts value is AuctionClock {
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.');
'Retain the complete original auction clock, finite budget and control epoch.');
}
function remaining(clock: AuctionClock, now: number): number {
@@ -65,11 +65,11 @@ export function quoteAuctionClock(input: {
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.');
'Supply the exact 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.');
'Choose the original 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;
@@ -95,7 +95,7 @@ export function quoteAuctionClock(input: {
/** 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.');
check(finite(now), 'Project the 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 };
+50 -18
View File
@@ -15501,6 +15501,7 @@ function beginRicheseLot(
order,
tieOrder: g.order,
});
if (lot.method !== 'silent') lot.clockVersion = 1;
g.richeseAuction = lot;
if (source === 'cache' && g.earlyRicheseBidPromises?.length) {
const attached = earlyRicheseBidRule(() => attachEarlyRicheseBids(g.earlyRicheseBidPromises!, g.turn, lot.event));
@@ -17906,11 +17907,28 @@ function ordinaryAuctionClockOpportunity(g: Game) {
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;
/** A source is public auction progress, never the concealed Black Market card.
* A saved sequential lot without this fresh version remains untimed. */
function richeseAuctionClockOpportunity(g: Game) {
const lot = g.richeseAuction;
if (g.status !== 'playing' || g.phase !== 3 || lot?.clockVersion !== 1 ||
lot.method === 'silent' || lot.outcome || !lot.active ||
!lot.eligible.includes(lot.active) || !lot.order.includes(lot.active) ||
!g.players.some(player => player.id === lot.active)) return null;
return { player: lot.active, source: JSON.stringify({kind:'richese', turn:g.turn,
event:lot.event, method:lot.method, source:lot.source, active:lot.active,
bid:lot.bid, bidder:lot.bidder, passed:lot.passed, acted:lot.acted}) };
}
function ordinaryAuctionClockBlocked(g: Game): boolean {
return !!(g.active !== g.auction?.active || g.richeseAuction || g.truthtrance || g.response ||
function currentAuctionClockOpportunity(g: Game) {
return richeseAuctionClockOpportunity(g) ?? ordinaryAuctionClockOpportunity(g);
}
export function hasAuctionClockOpportunity(g: Game): boolean {
return currentAuctionClockOpportunity(g) !== null;
}
function auctionClockBlocked(g: Game): boolean {
const richese = richeseAuctionClockOpportunity(g);
return !!(g.active !== (richese ? g.richeseAuction?.active : g.auction?.active) ||
!richese && g.richeseAuction || g.truthtrance || g.response ||
g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
g.pendingNullentropy || g.pendingKull || g.pendingHomeworldTupileCleanup ||
g.pendingRicheseBetrayal || g.pendingGuildBetrayal ||
@@ -17921,10 +17939,10 @@ function ordinaryAuctionClockBlocked(g: Game): boolean {
pendingNexusTraitors(g));
}
function auctionClockQuote(g: Game, now: number, controlEpoch: string, blocked = false) {
const opportunity = ordinaryAuctionClockOpportunity(g);
const opportunity = currentAuctionClockOpportunity(g);
try {
return quoteAuctionClock({ clock: g.auctionClock, opportunity, now, controlEpoch,
blocked: blocked || ordinaryAuctionClockBlocked(g),
blocked: blocked || auctionClockBlocked(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' });
@@ -17980,19 +17998,33 @@ export function advanceAuctionClock(source: Game, now: number, controlEpoch: str
if (!quote.expired || !game.auctionClock) return game;
const actor = game.auctionClock.player;
let next: Game, compelled = false;
try { next = applyAction(game, actor, { type: 'passBid' }); }
const lot = richeseAuctionClockOpportunity(game) && game.richeseAuction;
try { next = applyAction(game, actor, lot
? {type:'richeseBid',event:lot.event,amount:null,allyPayment:0}
: { 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);
const phaseGuide = projectedPhaseBidGuide(game, actor)?.action;
if (!whole && !required && phaseGuide?.type !== 'bid') throw error;
const completion = phaseGuide?.type === 'bid' ? phaseGuide : 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;
if (lot) {
const context = {turn:game.turn,event:lot.event,player:actor};
const required = richeseBidRequired(game.richeseBidPromises ?? [], context);
if (!required) throw error;
const completion = projectedRicheseBidGuide(game, actor)?.action;
requireRule(completion?.type === 'richeseBid',
'The expired Richese bid needs a legal completion of its binding Truthtrance answer.');
next = applyAction(game, actor, completion);
compelled = true;
} else {
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);
const phaseGuide = projectedPhaseBidGuide(game, actor)?.action;
if (!whole && !required && phaseGuide?.type !== 'bid') throw error;
const completion = phaseGuide?.type === 'bid' ? phaseGuide : 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.`
@@ -18000,7 +18032,7 @@ export function advanceAuctionClock(source: Game, now: number, controlEpoch: str
return synchronizeAuctionClock(next, now, controlEpoch, blocked);
}
function projectedAuctionClock(g: Game, now?: number): AuctionClockView | null {
const opportunity = ordinaryAuctionClockOpportunity(g);
const opportunity = currentAuctionClockOpportunity(g);
if (!opportunity) return null;
const clock = g.auctionClock;
if (!clock || clock.source !== opportunity.source || clock.player !== opportunity.player)
+8 -8
View File
@@ -45,7 +45,7 @@ export const PHASE_HELP = [
'Complete the table’s storm dials or card reveal. After the distance is revealed, play any storm cards and confirm movement.',
'Reveal a spice blow, resolve worms and card responses, then finish any Nexus and Fremen rides.',
'Claim charity once if below two spice. Advanced Bene Gesserit receives two regardless of wealth. New Advanced phases follow the First Player counterclockwise: claim if eligible, then finish your Charity turn. Basic and older unstamped phases retain concurrent readiness.',
'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.',
'Bid or pass in turn. New ordinary and sequential Richese 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.',
@@ -127,7 +127,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.',
'New ordinary lots and sequential Richese 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. Once Around timing is provisional; Silent auctions remain untimed. 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.',
@@ -2638,24 +2638,24 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
id: 'ordinary-auction-clock',
title: 'Ordinary auction: ten seconds to bid',
title: 'Sequential auctions: 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.',
summary: 'The server times each new ordinary or sequential Richese 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.',
'Watch the persistent countdown for ordinary, Once Around and normal Black Market bidding. 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.',
'Once Around timing is a provisional application of the normal bid deadline. Silent auctions remain untimed. 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: 'Player controls', status: 'Partial', detail: 'Persistent countdowns distinguish starting, running, interrupted, paused and server-resolution states for ordinary and sequential Richese lots; no client action is dispatched.', evidence: ['components/auction-countdown.tsx', 'components/richese-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'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, native, display, trusted-room and dedicated HTTP 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', 'tests/richese-auction-clock-runtime.test.ts', 'tests/richese-auction-countdown.test.tsx', 'tests/rooms-richese-auction-clock.test.ts', 'tests/richese-auction-clock-http.test.ts'] },
],
},
{
+2
View File
@@ -11,6 +11,8 @@ export type RicheseAuction = {
source: RicheseAuctionSource;
owner: string;
method: RicheseAuctionMethod;
/** Fresh sequential lots opt into trusted room deadlines. Older saves omit it. */
clockVersion?: 1;
eligible: string[];
order: string[];
/** Original native normal-lot opener; Sapho changes this lot's cycle only. */
+109
View File
@@ -0,0 +1,109 @@
// @dune-suite integration
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import type { Action, GameView } from '../game/engine';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
async function request(path: string, cookie?: string, body?: unknown) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
signal: AbortSignal.timeout(15_000),
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(cookie ? { cookie } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return {
status: response.status,
cookie: response.headers.get('set-cookie')?.split(';')[0],
data: await response.json() as GameView & { error?: string },
};
}
/** Only this new QA room is used. Both factions are real human seats; the
* existing bot adapter proposes setup/phase choices from each seat's private
* view without changing the server's seat control or staging game state. */
void test('authenticated Once Around cache clock expires to the native pass and rejects a late bid', async () => {
const host = await request('/api/rooms', undefined, {
name: 'Richese clock QA', faction: 'richese', advanced: false, expansions: ['choam'],
});
assert.equal(host.status, 201, JSON.stringify(host.data)); assert.ok(host.cookie);
const path = `/api/rooms/${host.data.code}`;
const peer = await request(path, undefined, {type:'join', name:'Clock QA opponent', faction:'atreides'});
assert.equal(peer.status, 200, JSON.stringify(peer.data)); assert.ok(peer.cookie);
const seats = [{id:host.data.me, cookie:host.cookie}, {id:peer.data.me, cookie:peer.cookie}];
let view = peer.data;
async function read(cookie: string) {
const result = await request(path, cookie);
assert.equal(result.status, 200, JSON.stringify(result.data));
view = result.data;
return view;
}
async function act(cookie: string, action: Action) {
const result = await request(path, cookie, {version:view.version, action});
assert.equal(result.status, 200, `Rejected ${action.type}: ${JSON.stringify(result.data)}`);
view = result.data;
return view;
}
for (const seat of seats) await act(seat.cookie, {type:'ready'});
await act(host.cookie, {type:'start'});
// Stop at the native response before the first cache, then choose its
// printed sequential method. Every other action comes from real views.
let clocked: GameView | null = null;
for (let count = 0; count < 400; count++) {
const owner = await read(host.cookie);
assert.notEqual(owner.status, 'finished', 'The QA game ended before its first cache lot.');
if (owner.richeseAuction?.source === 'cache' && owner.richeseAuction.method === 'onceAround' &&
owner.richeseAuction.active && owner.auctionClock?.event && owner.auctionClock.running &&
!owner.response && !owner.decision && !owner.phaseOpening) {
clocked = owner;
break;
}
if (owner.decision?.kind === 'richeseDeclaration' && owner.decision.player === owner.me && owner.richeseBidding) {
await act(host.cookie, {type:'decision', event:owner.richeseBidding.event, position:'first'});
continue;
}
if (owner.decision?.kind === 'richeseCache' && owner.decision.player === owner.me && owner.richeseBidding) {
const card = owner.richeseBidding.cache?.[0]; assert.ok(card);
await act(host.cookie, {type:'decision', event:owner.richeseBidding.event,
card:card.id, method:'onceAround', direction:'clockwise'});
continue;
}
let choice: {cookie:string; action:Action} | null = null;
for (const seat of seats) {
const current = await read(seat.cookie);
const local = structuredClone(current);
const self = local.players.find(player => player.id === seat.id); assert.ok(self);
self.bot = 'Easy';
const action = botActions(local)[0];
if (action) { choice = {cookie:seat.cookie, action}; break; }
}
assert.ok(choice, `No native continuation at ${owner.status}/${owner.phase}/${owner.decision?.kind ?? 'no decision'}.`);
if (view.response && !view.response.passed.includes(view.me)) choice.action = {type:'passResponse'};
await act(choice.cookie, choice.action);
}
assert.ok(clocked, 'The dedicated room did not reach an active Once Around clock.');
const lot = clocked.richeseAuction; assert.ok(lot?.active);
const clock = clocked.auctionClock; assert.ok(clock?.event);
assert.equal(clock.player, lot.active);
const bidder = seats.find(seat => seat.id === lot.active); assert.ok(bidder);
const oldVersion = clocked.version;
const lotEvent = lot.event;
// The HTTP boundary uses the real server clock. The room worker or the
// authenticated late-action preflight must expire the original bid once.
await new Promise(resolve => setTimeout(resolve, 11_500));
const late = await request(path, bidder.cookie, {version:oldVersion,
action:{type:'richeseBid', event:lotEvent, amount:1, allyPayment:0}});
assert.ok(late.status >= 400, 'A bid from the expired version must not be accepted.');
const after = await read(host.cookie);
assert.ok(after.version > oldVersion);
assert.equal(after.richeseAuction?.event, lotEvent);
assert.ok(after.richeseAuction?.passed.includes(bidder.id));
assert.equal(after.richeseAuction?.bid, 0);
assert.equal(after.richeseAuction?.outcome?.kind, 'unbid');
assert.equal(after.decision?.kind, 'richeseUnbid');
});
+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { advanceAuctionClock, hasAuctionClockOpportunity, synchronizeAuctionClock,
viewGame, type Game } from '../game/engine';
import {
actSemutaRicheseCacheKarama as act,
advanceSemutaRicheseCacheKarama as advance,
createSemutaRicheseCacheKaramaFixture as fixture,
semutaRicheseCacheKaramaInventory as inventory,
} from './fixture-semuta-richese-cache-karama';
import { createTruthRicheseBidFixture, bindTruthRicheseBid, declareTruthRicheseBid,
passTruthRicheseFreshDiscard } from './fixture-truth-richese-bid';
import { createSaphoCyclicAuctionFixture, actSaphoCyclic, saphoCyclicAction,
saphoCyclicBid } from './fixture-sapho-cyclic-auction';
const epoch = '0:0';
type Method = 'normal' | 'onceAround' | 'silent';
/** Genuine Basic cache: Richese previously sold Semuta and now declares its
* second-turn first cache lot. The fixture selects only unopened card/spice
* positions; this helper takes the original response and owner decisions. */
function cache(method: 'onceAround' | 'silent'): Game {
const f = fixture({position:'first'});
let game = advance(f.game, state => !state.response && !state.phaseOpening &&
state.decision?.kind === 'richeseCache');
const card = game.richeseCache?.[0]; assert.ok(card && game.richeseBidding);
game = act(game, 'richese', {type:'decision', event:game.richeseBidding.event,
card:card.id, method, direction:'clockwise'});
assert.equal(game.richeseAuction?.source, 'cache');
assert.equal(game.richeseAuction?.method, method);
assert.ok(!game.response && !game.decision && !game.phaseOpening);
return game;
}
/** An actual original Advanced game reaches its first Black Market decision.
* The original owner offers a real held card and all response/inspection
* windows are resolved through the existing native action driver. */
function blackMarket(method: Method): Game {
const f = fixture({advanced:true});
let game = structuredClone(f.afterSetup);
const front: Game['spiceDeck'] = [];
for (let i = 0; i < 4; i++) {
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index,1)[0]);
}
game.spiceDeck.unshift(...front);
game = advance(game, state => !state.response && !state.phaseOpening &&
state.decision?.kind === 'richeseBlackMarket');
const owner = game.players.find(player => player.id === 'richese');
assert.ok(owner && owner.hand.length && game.richeseBidding);
game = act(game, 'richese', {type:'decision', event:game.richeseBidding.event,
card:owner.hand[0].id, method, ...(method === 'onceAround' ? {direction:'clockwise'} : {})});
game = advance(game, state => state.richeseAuction?.source === 'blackMarket' &&
state.richeseAuction.method === method && !state.response && !state.phaseOpening && !state.decision);
assert.equal(game.richeseAuction?.source, 'blackMarket');
return game;
}
for (const source of ['cache', 'blackMarket'] as const)
for (const method of (source === 'cache' ? ['onceAround'] : ['normal','onceAround']) as Exclude<Method,'silent'>[])
void test(`${source} ${method} expires at the deadline through its native pass`,()=>{
const initial = source === 'cache' ? cache('onceAround') : blackMarket(method);
const lot = initial.richeseAuction; assert.ok(lot && lot.active);
const original = structuredClone(initial), originalCards = inventory(initial);
assert.equal(hasAuctionClockOpportunity(initial),true);
const armed = synchronizeAuctionClock(initial,1_000,epoch);
assert.equal(armed.auctionClock?.player,lot.active);
assert.equal(armed.auctionClock?.remainingMs,10_000);
assert.equal(armed.auctionClock?.startedAt,1_000);
assert.equal(advanceAuctionClock(armed,10_999,epoch),armed);
const expected = act(initial,lot.active,{type:'richeseBid',event:lot.event,amount:null});
const expired = advanceAuctionClock(armed,11_000,epoch);
assert.deepEqual(expired.richeseAuction,expected.richeseAuction);
assert.equal(expired.active,expected.active);
assert.ok(expired.richeseAuction?.passed.includes(lot.active));
assert.notEqual(expired.auctionClock?.event,armed.auctionClock?.event);
assert.equal(expired.auctionClock?.remainingMs,10_000);
assert.equal(expired.auctionClock?.startedAt,11_000);
assert.deepEqual(inventory(expired),originalCards);
assert.deepEqual(initial,original);
});
void test('a native special-lot bid resets the next bidder and an administrative pause preserves the remainder',()=>{
const initial=blackMarket('normal'), lot=initial.richeseAuction; assert.ok(lot?.active);
let game=synchronizeAuctionClock(initial,1_000,epoch);
const original=game.auctionClock!.event;
game=act(game,lot.active,{type:'richeseBid',event:lot.event,amount:1,allyPayment:0});
game=synchronizeAuctionClock(game,2_000,epoch);
assert.notEqual(game.auctionClock!.event,original);
assert.equal(game.auctionClock!.player,game.richeseAuction!.active);
game=synchronizeAuctionClock(game,5_000,epoch,true);
assert.equal(game.auctionClock!.remainingMs,7_000); assert.equal(game.auctionClock!.startedAt,null);
const held=advanceAuctionClock(game,50_000,epoch,true); assert.deepEqual(held,game);
game=synchronizeAuctionClock(held,50_000,epoch);
assert.equal(game.auctionClock!.remainingMs,7_000); assert.equal(game.auctionClock!.startedAt,50_000);
assert.equal(advanceAuctionClock(game,56_999,epoch),game);
const actor=game.richeseAuction!.active!;
const expected=act(game,actor,{type:'richeseBid',event:game.richeseAuction!.event,amount:null});
const expired=advanceAuctionClock(game,57_000,epoch);
assert.deepEqual(expired.richeseAuction,expected.richeseAuction);
});
for (const source of ['cache','blackMarket'] as const)
void test(`${source} Silent auction is simultaneous and has no sequential bidding clock`,()=>{
const game=source==='cache'?cache('silent'):blackMarket('silent');
const lot=game.richeseAuction; assert.ok(lot && lot.active===null);
assert.equal(hasAuctionClockOpportunity(game),false);
assert.equal(synchronizeAuctionClock(game,1_000,epoch),game);
assert.equal(advanceAuctionClock(game,1_000_000,epoch),game);
for (const seat of game.players) assert.equal(viewGame(game,seat.id,10_000).auctionClock,null);
});
void test('the special-lot clock exposes only public countdown fields, never a Black Market card identity',()=>{
const source=blackMarket('onceAround'), lot=source.richeseAuction; assert.ok(lot && lot.active);
const game=synchronizeAuctionClock(source,1_000,epoch);
for (const seat of game.players) {
const view=viewGame(game,seat.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,lot.active);
assert.ok(!JSON.stringify(view.auctionClock).includes(lot.cardId));
}
});
void test('a previously opened unstamped special lot is not retroactively timed',()=>{
const game=blackMarket('onceAround');
assert.ok(game.richeseAuction);
const old=structuredClone(game);
delete old.richeseAuction!.clockVersion;
assert.equal(hasAuctionClockOpportunity(old),false);
assert.equal(synchronizeAuctionClock(old,1_000,epoch),old);
assert.equal(advanceAuctionClock(old,1_000_000,epoch),old);
assert.equal(viewGame(old,'richese',1_000_000).auctionClock,null);
});
void test('a Once Around timeout honors the original binding Truthtrance Yes with the legal required bid',()=>{
const f=createTruthRicheseBidFixture();
const source=act(f.game,f.asker,{type:'richeseBid',event:f.lot,amount:null});
assert.equal(source.richeseAuction?.active,f.target);
const game=bindTruthRicheseBid(f,'yes',4,source).game;
const guide=viewGame(game,f.target).richeseBidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type,'richeseBid'); assert.equal(guide.action.amount,4);
const armed=synchronizeAuctionClock(game,1_000,epoch);
const expired=advanceAuctionClock(armed,11_000,epoch);
assert.equal(expired.richeseAuction?.bid,4);
assert.equal(expired.richeseAuction?.bidder,f.target);
assert.ok(expired.richeseBidPromises?.some(row=>row.player===f.target && row.fulfilled));
assert.deepEqual(inventory(expired),f.originalCards);
});
void test('a Once Around timeout may pass when a binding Truthtrance No forbids the qualifying bid',()=>{
const f=createTruthRicheseBidFixture();
const source=act(f.game,f.asker,{type:'richeseBid',event:f.lot,amount:null});
assert.equal(source.richeseAuction?.active,f.target);
const game=bindTruthRicheseBid(f,'no',4,source).game;
const expected=act(game,f.target,{type:'richeseBid',event:f.lot,amount:null});
const armed=synchronizeAuctionClock(game,1_000,epoch);
const expired=advanceAuctionClock(armed,11_000,epoch);
assert.deepEqual(expired.richeseAuction,expected.richeseAuction);
assert.ok(expired.richeseAuction?.passed.includes(f.target));
assert.notEqual(expired.richeseAuction?.bidder,f.target);
assert.deepEqual(inventory(expired),f.originalCards);
});
void test('Sapho moving another Black Market bidder last does not refresh the unchanged current bidder',()=>{
const f=createSaphoCyclicAuctionFixture({advanced:true,blackMarket:true});
const first=saphoCyclicBid(f.game,1);
let game=actSaphoCyclic(f.game,first.actor,first.action);
const lot=game.richeseAuction; assert.ok(lot?.active);
assert.notEqual(lot.active,f.owner,'The Sapho owner is not the current bidder');
const action=saphoCyclicAction(game,'last');
game=synchronizeAuctionClock(game,1_000,epoch);
const original=game.auctionClock!;
const next=actSaphoCyclic(game,f.owner,action);
assert.equal(next.richeseAuction?.active,lot.active);
game=synchronizeAuctionClock(next,3_000,epoch);
assert.equal(game.auctionClock?.event,original.event);
assert.equal(game.auctionClock?.player,original.player);
assert.equal(viewGame(game,f.owner,3_000).auctionClock?.remainingMs,8_000);
assert.equal(game.auctionClock?.startedAt,1_000);
});
void test('the native Truthtrance interruption holds and resumes the same Once Around clock',()=>{
const f=createTruthRicheseBidFixture(), armed=synchronizeAuctionClock(f.game,1_000,epoch);
const declared=declareTruthRicheseBid(f,armed), held=synchronizeAuctionClock(declared,3_000,epoch);
assert.equal(held.auctionClock!.event,armed.auctionClock!.event);
assert.equal(held.auctionClock!.remainingMs,8_000); assert.equal(held.auctionClock!.startedAt,null);
assert.deepEqual(advanceAuctionClock(held,50_000,epoch),held);
let game=act(held,f.asker,{type:'truthAsk',question:{kind:'richeseBid',target:f.target,
turn:held.turn,event:f.lot,minimum:4}});
game=act(game,f.target,{type:'truthAnswer',answer:'no'});
game=passTruthRicheseFreshDiscard(game);
game=synchronizeAuctionClock(game,50_000,epoch);
assert.equal(game.auctionClock!.event,armed.auctionClock!.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.ok(expired.richeseAuction?.passed.includes(f.asker));
assert.deepEqual(inventory(expired),f.originalCards);
});
+99
View File
@@ -0,0 +1,99 @@
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 { RicheseAuctionCountdown } from '../components/richese-auction-countdown';
import type { AuctionClockView } from '../game/auction-clock';
const frame = (remainingMs = 10_000, running = true, event: string | null = 'bid-event'): AuctionClockView =>
({ event, player: 'p', remainingMs, running });
const html = (clock: AuctionClockView | null, method: 'onceAround' | 'normal', paused = false) =>
renderToStaticMarkup(createElement(RicheseAuctionCountdown, { clock, playerName: 'Respondent', method, paused }));
void test('sequential Richese countdown presents the live bidder, method and server-controlled expiry', () => {
for (const [method, label] of [['onceAround', 'Once Around'], ['normal', 'Black Market']] as const) {
const clock = frame(1001), before = structuredClone(clock), markup = html(clock, method);
assert.match(markup, new RegExp(`${label} auction countdown`));
assert.match(markup, /Respondent may bid or pass/);
assert.match(markup, /2 seconds remaining/);
assert.match(markup, /role="timer" aria-live="off"/);
assert.match(markup, /server controls the deadline/);
if (method === 'onceAround') {
assert.match(markup, /provisional online interpretation/);
assert.match(markup, /binding Truthtrance answer/);
}
assert.doesNotMatch(markup, /<button|<form|Continue/);
assert.deepEqual(clock, before);
}
assert.equal(html(null, 'onceAround'), '');
});
void test('Richese countdown keeps unarmed, held, paused and expired states distinct', () => {
assert.match(html(frame(10_000, false, null), 'onceAround'), /Clock starts when bidding is ready/);
assert.match(html(frame(6_200, false), 'normal'), /held while the current game decision finishes/);
assert.match(html(frame(6_200), 'onceAround', true), /Auction paused/);
assert.match(html(frame(0, false), 'onceAround'), /Awaiting server resolution/);
assert.match(html(frame(0, false), 'onceAround'), /0 seconds remaining/);
});
/** Exercise the real effect with a controlled monotonic browser clock. */
function browserDisplay(initial: Parameters<typeof RicheseAuctionCountdown>[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: { RicheseAuctionCountdown?: typeof RicheseAuctionCountdown } = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/richese-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.RicheseAuctionCountdown);
const render = () => {
stateIndex = 0; effectIndex = 0;
const node: ReactNode = exports.RicheseAuctionCountdown!(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 time and stops while paused or awaiting the server', () => {
const display = browserDisplay({ clock: frame(), playerName: 'Respondent', method: 'onceAround' });
assert.match(display.render(), /10 seconds remaining/); assert.equal(display.ticking, 1);
assert.match(display.advance(5401), /5 seconds remaining/);
assert.match(display.update({ clock: frame(2000, false), playerName: 'Respondent', method: 'onceAround' }), /2 seconds remaining/);
assert.equal(display.ticking, 0);
assert.match(display.update({ clock: frame(1000), playerName: 'Respondent', method: 'normal' }), /Black Market/);
assert.match(display.advance(1000), /Awaiting server resolution/); assert.equal(display.ticking, 0);
assert.equal(display.update({ clock: null, playerName: 'Respondent', method: 'normal' }), '');
});
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import test from 'node:test';
import { applyAction, type Action } from '../game/engine';
import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { createTruthRicheseBidFixture, inventory } from './fixture-truth-richese-bid';
function fakeClock(start = 10_000) {
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[];
};
}
/** The source is a genuine public Basic game and native Once Around cache
* lot. Only the parent fixture's unopened cards and scalar lotteries are
* selected; no wallet, force, lot, response or stored deadline is staged. */
function fixture() {
const native = createTruthRicheseBidFixture(), game = structuredClone(native.game);
const lot = game.richeseAuction; assert.ok(lot && lot.method === 'onceAround' && lot.active);
assert.equal(lot.clockVersion, 1, 'A newly opened sequential special lot uses trusted timing');
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(`richese-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: lot.active, event: lot.event };
}
void test('a trusted room clock arms and expires a genuine Once Around bid through its native pass', async () => {
const f = fixture();
try {
const view = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
assert.equal(view.auctionClock?.player, f.actor);
assert.equal(view.auctionClock?.remainingMs, 10_000);
assert.equal(view.auctionClock?.running, true);
assert.equal(typeof view.auctionClock?.event, 'string');
assert.equal(f.rooms.needsAutomaticRoomRecovery(await f.rooms.readRoom(f.code)), true);
f.clock.advance(9999);
assert.equal(await f.rooms.continueRoomAuctionClock(f.code, f.clock), false);
assert.equal((await f.rooms.readRoom(f.code)).richeseAuction?.active, f.actor);
const expected = applyAction(f.native.game, f.actor,
{ type: 'richeseBid', event: f.event, amount: null, allyPayment: 0 });
f.clock.advance(1);
assert.equal(await f.rooms.continueRoomAuctionClock(f.code, f.clock), true);
const game = await f.rooms.readRoom(f.code);
assert.deepEqual(game.richeseAuction, expected.richeseAuction);
assert.ok(game.richeseAuction?.acted.includes(f.actor));
assert.notEqual(game.richeseAuction?.active, f.actor);
assert.equal(game.auctionClock?.remainingMs, 10_000);
assert.equal(game.auctionClock?.startedAt, f.clock.now());
assert.deepEqual(inventory(game), f.native.originalCards);
} finally { f.sqlite.close(); }
});
void test('a timely special bid advances the source and a late bid cannot beat its committed timeout', 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 : 10_000);
const bid: Action = { type: 'richeseBid', event: f.event, amount: 1, allyPayment: 0 };
if (timely) {
const result = await f.rooms.act(f.code, f.auth[f.actor], view.version, bid, f.clock);
assert.equal(result.richeseAuction?.bid, 1);
assert.equal(result.richeseAuction?.bidder, f.actor);
assert.notEqual(result.auctionClock?.event, view.auctionClock?.event);
assert.equal(result.auctionClock?.remainingMs, 10_000);
await assert.rejects(f.rooms.act(f.code, f.auth[f.actor], view.version, bid, f.clock), /table changed/i);
} else {
await assert.rejects(f.rooms.act(f.code, f.auth[f.actor], view.version, bid, f.clock), /table changed/i);
const game = await f.rooms.readRoom(f.code);
assert.equal(game.richeseAuction?.bid, 0);
assert.ok(game.richeseAuction?.acted.includes(f.actor));
assert.notEqual(game.richeseAuction?.active, f.actor);
}
} finally { f.sqlite.close(); }
}
});
void test('a paused room cannot expire a special bid and reopening re-arms its trusted budget', async () => {
const f = fixture();
try {
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
f.clock.advance(4000);
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());
const before = await f.rooms.readRoom(f.code);
f.clock.advance(60_000);
assert.equal(await f.rooms.continueRoomAuctionClock(f.code, f.clock, true), false);
assert.deepEqual(await f.rooms.readRoom(f.code), before);
assert.deepEqual(f.clock.sleeps, []);
const blocked = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
assert.equal(blocked.auctionClock?.running, false);
f.sqlite.prepare('UPDATE room_controls SET paused=0,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, '2:0');
assert.equal(game.auctionClock?.remainingMs, 10_000);
assert.equal(game.auctionClock?.startedAt, f.clock.now());
f.clock.advance(10_000);
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
game = await f.rooms.readRoom(f.code);
assert.ok(game.richeseAuction?.acted.includes(f.actor));
} finally { f.sqlite.close(); }
});