Time sequential Richese auctions
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
e7520e8679
commit
7fc6f1cd43
15 files changed
+692
-38
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' }), '');
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
Reference in new issue
Block a user