From 7fc6f1cd43345a01ec6d14d681fcc303bbd09c76 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:11:18 +1100 Subject: [PATCH] Time sequential Richese auctions --- README.md | 12 +- components/game-table.tsx | 6 + components/richese-auction-countdown.tsx | 48 +++++ db/rooms.ts | 3 +- docs/AUCTION_TIME_LIMIT.md | 31 ++- docs/CURRENT_STATUS.md | 8 + docs/RULE_DECISIONS.md | 7 + game/auction-clock.ts | 8 +- game/engine.ts | 68 +++++-- game/reference.ts | 16 +- game/richese-auction.ts | 2 + tests/richese-auction-clock-http.test.ts | 109 +++++++++++ tests/richese-auction-clock-runtime.test.ts | 200 ++++++++++++++++++++ tests/richese-auction-countdown.test.tsx | 99 ++++++++++ tests/rooms-richese-auction-clock.test.ts | 113 +++++++++++ 15 files changed, 692 insertions(+), 38 deletions(-) create mode 100644 components/richese-auction-countdown.tsx create mode 100644 tests/richese-auction-clock-http.test.ts create mode 100644 tests/richese-auction-clock-runtime.test.ts create mode 100644 tests/richese-auction-countdown.test.tsx create mode 100644 tests/rooms-richese-auction-clock.test.ts diff --git a/README.md b/README.md index e46b1612..75ec4370 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/components/game-table.tsx b/components/game-table.tsx index f46f18f7..0556293d 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -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' && ( + player.id === g.richeseAuction!.active)?.name ?? 'The current bidder'} + paused={g.roomControl?.paused || g.roomControl?.closed} /> + )} {g.guildBetrayalReaction ? ( ) : g.richeseBetrayalReaction ? ( diff --git a/components/richese-auction-countdown.tsx b/components/richese-auction-countdown.tsx new file mode 100644 index 00000000..e7d2e20a --- /dev/null +++ b/components/richese-auction-countdown.tsx @@ -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; + 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
+ {status} +

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

+

This timed {name} auction gives each bidder 10 seconds to bid or pass. The server controls the deadline and resolves expiry.

+ {method === 'onceAround' && <> +

Once Around timing is a provisional online interpretation of the standard bid deadline.

+

If passing would break a binding Truthtrance answer, the server submits only its required legal bid; normal payment still applies.

+ } +
; +} diff --git a/db/rooms.ts b/db/rooms.ts index ee03a0f6..2a52cf30 100644 --- a/db/rooms.ts +++ b/db/rooms.ts @@ -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); diff --git a/docs/AUCTION_TIME_LIMIT.md b/docs/AUCTION_TIME_LIMIT.md index b17656fc..d256c0f1 100644 --- a/docs/AUCTION_TIME_LIMIT.md +++ b/docs/AUCTION_TIME_LIMIT.md @@ -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 diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ca4a704d..c8a3d322 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index d4503476..28be64fa 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 diff --git a/game/auction-clock.ts b/game/auction-clock.ts index 76e129c6..3dbdb6b5 100644 --- a/game/auction-clock.ts +++ b/game/auction-clock.ts @@ -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 }; diff --git a/game/engine.ts b/game/engine.ts index 08aa9766..90d33053 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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) diff --git a/game/reference.ts b/game/reference.ts index 0e43431b..0dbd460c 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -45,7 +45,7 @@ export const PHASE_HELP = [ 'Complete the table’s storm dials or card reveal. After the distance is revealed, play any storm cards and confirm movement.', 'Reveal a spice blow, resolve worms and card responses, then finish any Nexus and Fremen rides.', 'Claim charity once if below two spice. Advanced Bene Gesserit receives two 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'] }, ], }, { diff --git a/game/richese-auction.ts b/game/richese-auction.ts index 6c53c67d..c651eb73 100644 --- a/game/richese-auction.ts +++ b/game/richese-auction.ts @@ -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. */ diff --git a/tests/richese-auction-clock-http.test.ts b/tests/richese-auction-clock-http.test.ts new file mode 100644 index 00000000..1362eab3 --- /dev/null +++ b/tests/richese-auction-clock-http.test.ts @@ -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'); +}); diff --git a/tests/richese-auction-clock-runtime.test.ts b/tests/richese-auction-clock-runtime.test.ts new file mode 100644 index 00000000..5a2b52a9 --- /dev/null +++ b/tests/richese-auction-clock-runtime.test.ts @@ -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[]) + 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); +}); diff --git a/tests/richese-auction-countdown.test.tsx b/tests/richese-auction-countdown.test.tsx new file mode 100644 index 00000000..113feef8 --- /dev/null +++ b/tests/richese-auction-countdown.test.tsx @@ -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, / { + 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[0]) { + type Effect = { dependencies: readonly unknown[]; cleanup?: () => void }; + const states: unknown[] = [], effects: Effect[] = [], pending: (() => void)[] = []; + const intervals = new Map void>(); + let stateIndex = 0, effectIndex = 0, now = 0, nextTimer = 0, props = initial; + const exports: { 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' }), ''); +}); diff --git a/tests/rooms-richese-auction-clock.test.ts b/tests/rooms-richese-auction-clock.test.ts new file mode 100644 index 00000000..cbc8ce96 --- /dev/null +++ b/tests/rooms-richese-auction-clock.test.ts @@ -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; + 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(); } +});