Enforce ordinary auction bidding deadlines online

This commit is contained in:
Quintine committed 2026-10-27 00:07:15 +11:00
1 parent f76c8dfb93
commit f548039cd8
17 files changed
+1363 -33

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
export const AUCTION_BID_MS = 10_000;
export type AuctionClock = {
event: string;
source: string;
player: string;
/** Budget remaining at startedAt, or the frozen budget while suspended. */
remainingMs: number;
startedAt: number | null;
controlEpoch: string;
};
export type AuctionClockView = {
/** Null is reserved for a never-armed projection built by the caller. */
event: string | null;
player: string;
remainingMs: number;
running: boolean;
};
export class AuctionClockError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuctionClockError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new AuctionClockError(message);
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
const keys = (value: Record<string, unknown>, expected: string): boolean =>
Object.keys(value).sort().join(',') === expected;
function validateClock(value: unknown): asserts value is AuctionClock {
check(object(value) && keys(value, 'controlEpoch,event,player,remainingMs,source,startedAt') &&
identifier(value.event) && identifier(value.source) && identifier(value.player) && identifier(value.controlEpoch) &&
finite(value.remainingMs) && value.remainingMs >= 0 && value.remainingMs <= AUCTION_BID_MS &&
(value.startedAt === null || finite(value.startedAt)),
'Retain the complete original ordinary-auction clock, finite budget and control epoch.');
}
function remaining(clock: AuctionClock, now: number): number {
if (clock.startedAt === null) return clock.remainingMs;
// A wall-clock rollback cannot consume time or enlarge the retained budget.
const elapsed = Math.max(0, now - clock.startedAt);
return Math.max(0, clock.remainingMs - elapsed);
}
/** Pure quotation. Source/event/epoch and the current time come from the
* integrating engine/store; no timer, random value or I/O is created here. */
export function quoteAuctionClock(input: {
clock?: AuctionClock;
opportunity: { source: string; player: string } | null;
now: number;
blocked: boolean;
controlEpoch: string;
event: string;
}): { clock: AuctionClock | undefined; expired: boolean; delayMs: number | null } {
check(object(input) && (keys(input, 'blocked,controlEpoch,event,now,opportunity') ||
keys(input, 'blocked,clock,controlEpoch,event,now,opportunity')) && finite(input.now) &&
typeof input.blocked === 'boolean' && identifier(input.controlEpoch) && identifier(input.event),
'Supply the exact ordinary-auction opportunity, finite time, blocker and control epoch.');
if (input.clock !== undefined) validateClock(input.clock);
check(input.opportunity === null || object(input.opportunity) && keys(input.opportunity, 'player,source') &&
identifier(input.opportunity.source) && identifier(input.opportunity.player),
'Choose the original ordinary-auction source and current bidder, or no opportunity.');
if (input.opportunity === null) return { clock: undefined, expired: false, delayMs: null };
const { opportunity, now, blocked, controlEpoch } = input;
const previous = input.clock;
let clock: AuctionClock = !previous || previous.source !== opportunity.source || previous.player !== opportunity.player ||
previous.controlEpoch !== controlEpoch ? {
event: input.event, source: opportunity.source, player: opportunity.player,
remainingMs: AUCTION_BID_MS, startedAt: blocked ? null : now, controlEpoch,
} : previous;
const budget = remaining(clock, now);
if (blocked) {
if (clock.startedAt !== null) clock = { ...clock, remainingMs: budget, startedAt: null };
return { clock, expired: false, delayMs: null };
}
if (budget === 0) {
if (clock.remainingMs !== 0 || clock.startedAt !== null) clock = { ...clock, remainingMs: 0, startedAt: null };
return { clock, expired: true, delayMs: null };
}
if (clock.startedAt === null) clock = { ...clock, startedAt: now };
return { clock, expired: false, delayMs: budget };
}
/** Suspended/expired frames retain their original event for the client. */
export function viewAuctionClock(clock: AuctionClock, now: number): AuctionClockView {
validateClock(clock);
check(finite(now), 'Project the ordinary-auction clock at a finite time.');
const remainingMs = remaining(clock, now);
return { event: clock.event, player: clock.player, remainingMs,
running: clock.startedAt !== null && remainingMs > 0 };
}
+120 -2
View File
@@ -1,3 +1,5 @@
import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
type AuctionClock, type AuctionClockView } from './auction-clock';
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn,
validateTurnActionPromises, recordTurnActionBid, closeTurnActionBidding, recordTurnActionRevival,
@@ -918,6 +920,8 @@ export type Battle = {
voice?: { target: string; kind: Card['kind'] | 'stoneBurner' | 'mirrorWeapon'; must: boolean };
};
export type Auction = {
/** New ordinary lots use trusted online timing; old open lots stay unstamped. */
clockVersion?: 1;
/** Sapho changes this lot's cycle, never physical order or the next opener. */
saphoOrder?: string[];
peekKnown?: boolean;
@@ -2544,6 +2548,8 @@ export type Game = {
nexusMoritaniRetention?: NexusMoritaniRetentionReceipt;
} | null;
auction: Auction | null;
/** Private source and trusted time. Player views expose the countdown only. */
auctionClock?: AuctionClock;
battle: Battle | null;
nexus: boolean;
allianceOffers: Record<string, string>;
@@ -14501,6 +14507,7 @@ function setAuction(g: Game, cards?: Card[]) {
(id) => eligible.includes(id),
)!;
g.auction = {
clockVersion: 1,
...(g.homeworldOccupationPreview ? { occupiedEvent: crypto.randomUUID() } : {}),
cards,
index: 0,
@@ -15877,6 +15884,115 @@ function guardPromisedBid(g: Game, p: Player, action: Action, amount?: number) {
requireRule(!bidRequired(g.bidPromises ?? [], p.id, opportunity),
'Place the qualifying bid required by your Truthtrance Yes before passing or taking a lower bid.');
}
function ordinaryAuctionClockOpportunity(g: Game) {
const auction = g.auction;
if (g.status !== 'playing' || g.phase !== 3 || auction?.clockVersion !== 1 ||
!auction.cards[auction.index] || !g.players.some(player => player.id === auction.active)) return null;
return { player: auction.active, source: JSON.stringify({ turn: g.turn,
index: auction.index, card: auction.cards[auction.index].id, active: auction.active,
bid: auction.bid, bidder: auction.bidder, passed: auction.passed,
opener: auction.opener, order: auction.saphoOrder ?? g.order }) };
}
export function hasAuctionClockOpportunity(g: Game): boolean {
return ordinaryAuctionClockOpportunity(g) !== null;
}
function ordinaryAuctionClockBlocked(g: Game): boolean {
return !!(g.active !== g.auction?.active || g.richeseAuction || g.truthtrance || g.response ||
g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
g.pendingNullentropy || g.pendingKull || g.pendingHomeworldTupileCleanup ||
g.pendingRicheseBetrayal || g.pendingGuildBetrayal ||
g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador ||
g.pendingNexusHarkonnenBetrayal || g.pendingNexusIxianBetrayal || g.pendingNexusIxianReplacement ||
g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove || g.pendingAdvisorMatching ||
pendingNexusTraitors(g));
}
function auctionClockQuote(g: Game, now: number, controlEpoch: string, blocked = false) {
const opportunity = ordinaryAuctionClockOpportunity(g);
try {
return quoteAuctionClock({ clock: g.auctionClock, opportunity, now, controlEpoch,
blocked: blocked || ordinaryAuctionClockBlocked(g),
event: opportunity && (!g.auctionClock || g.auctionClock.source !== opportunity.source ||
g.auctionClock.player !== opportunity.player || g.auctionClock.controlEpoch !== controlEpoch)
? crypto.randomUUID() : g.auctionClock?.event ?? 'no-auction' });
} catch (error) {
if (error instanceof AuctionClockError) throw new RuleError(error.message);
throw error;
}
}
/** Only trusted room time calls this. Pure actions and offline games never
* consult wall time, and legacy unclocked lots are not retroactively timed. */
export function synchronizeAuctionClock(source: Game, now: number, controlEpoch: string, blocked = false): Game {
const quote = auctionClockQuote(source, now, controlEpoch, blocked);
if (JSON.stringify(source.auctionClock) === JSON.stringify(quote.clock)) return source;
const next = { ...source };
if (quote.clock) next.auctionClock = quote.clock;
else delete next.auctionClock;
return next;
}
/** Bid predicates change only at their declared minima. Testing those finite
* breakpoints through detached exact native bounds finds the least compelled
* bid without scanning a large purse or speculatively settling a purchase. */
function minimumCompelledAuctionBid(game: Game, actor: string): Action | null {
const opportunity = bidOpportunity(game), player = getPlayer(game, actor);
if (!opportunity || !game.auction) return null;
const amounts = new Set<number>([game.auction.bid + 1]);
for (const promise of liveBidPromises(game.bidPromises ?? [], actor, opportunity)) amounts.add(promise.minimum);
const visit = (node: TurnActionClaim['mixed']): void => {
if (node.kind === 'action') {
if (node.action.kind === 'bid' && node.action.lot === opportunity.lot) amounts.add(node.action.minimum);
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
for (const promise of liveTurnActionPromises(game.turnActionPromises ?? [], actor, game.turn)) visit(promise.mixed);
const maximum = normalBidMaximum(game, player);
const whole = liveTurnActionPromises(game.turnActionPromises ?? [], actor, game.turn).length > 0;
for (const amount of [...amounts].filter(value => Number.isSafeInteger(value) && value > game.auction!.bid && value <= maximum).sort((a, b) => a - b)) {
const metadata = { turn: game.turn, lot: opportunity.lot, source: opportunity.source,
player: actor, asker: game.players.find(seat => seat.id !== actor)!.id };
const bounds: BidPromise[] = [{ ...metadata, event: 'auction-clock-minimum', minimum: amount, answer: true }];
if (amount < Number.MAX_SAFE_INTEGER)
bounds.push({ ...metadata, event: 'auction-clock-maximum', minimum: amount + 1, answer: false });
const trial = { ...game, bidPromises: [...(game.bidPromises ?? []), ...bounds] };
const action = whole ? turnActionCompletion(trial, actor)?.bid : bidPromiseCompletion(trial, player);
if (action?.type === 'bid' && action.amount === amount) return action;
}
return null;
}
/** A deadline uses the same native pass as a human. If an accepted Truthtrance
* answer forbids passing, execute only its quoted required legal bid instead
* of letting silence evade that answer or make a strategic bid for the seat. */
export function advanceAuctionClock(source: Game, now: number, controlEpoch: string, blocked = false): Game {
const game = synchronizeAuctionClock(source, now, controlEpoch, blocked);
const quote = auctionClockQuote(game, now, controlEpoch, blocked);
if (!quote.expired || !game.auctionClock) return game;
const actor = game.auctionClock.player;
let next: Game, compelled = false;
try { next = applyAction(game, actor, { type: 'passBid' }); }
catch (error) {
if (!(error instanceof RuleError)) throw error;
const whole = liveTurnActionPromises(game.turnActionPromises ?? [], actor, game.turn).length
? turnActionCompletion(game, actor)?.bid : undefined;
const opportunity = bidOpportunity(game);
const required = opportunity && bidRequired(game.bidPromises ?? [], actor, opportunity);
if (!whole && !required) throw error;
const completion = minimumCompelledAuctionBid(game, actor);
requireRule(completion, 'The expired ordinary bid needs a legal completion of its binding Truthtrance answer.');
next = applyAction(game, actor, completion);
compelled = true;
}
log(next, compelled
? `${getPlayer(game, actor).name}'s ten-second bidding time expired. The legal bid required by their binding Truthtrance answer was submitted.`
: `${getPlayer(game, actor).name}'s ten-second bidding time expired; they passed this bid.`);
return synchronizeAuctionClock(next, now, controlEpoch, blocked);
}
function projectedAuctionClock(g: Game, now?: number): AuctionClockView | null {
const opportunity = ordinaryAuctionClockOpportunity(g);
if (!opportunity) return null;
const clock = g.auctionClock;
if (!clock || clock.source !== opportunity.source || clock.player !== opportunity.player)
return { event: null, player: opportunity.player, remainingMs: AUCTION_BID_MS, running: false };
return viewAuctionClock(clock, now ?? clock.startedAt ?? 0);
}
function auctionNext(g: Game) {
const a = g.auction!;
const eligible = g.order.filter((id) => {
@@ -16063,7 +16179,7 @@ function commitNormalAuctionRetirement(g: Game, next: NormalAuctionRetirementQuo
g.auction = null;
finishNormalBidding(g);
} else {
g.auction = next.auction;
g.auction = { ...next.auction, clockVersion: 1 };
g.active = next.active;
if (next.offer?.kind === 'ixTechnology') g.decision = next.offer;
else if (next.offer) offerAuctionPeek(g);
@@ -41412,7 +41528,7 @@ function applyActionInner(
}
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
export function viewGame(state: Game, id: string, now?: number) {
revivalOrderIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
@@ -41518,6 +41634,7 @@ export function viewGame(state: Game, id: string) {
? findReachableBattlePlan(g, me)
: null;
return {
auctionClock: projectedAuctionClock(g, now),
harkonnenExchangeInspection: projectedHarkonnenInspection(g, id),
ecazPoisonIncome: (g.ecazPoisonIncome ?? []).filter((income) => income.player === id)
.map(({turn, phase, amount, count}) => ({turn, phase, amount, count})),
@@ -42648,6 +42765,7 @@ export function viewGame(state: Game, id: string) {
: null,
auction: g.auction
? {
...(g.auction.clockVersion === 1 ? { clockVersion: 1 as const } : {}),
bid: g.auction.bid,
bidder: g.auction.bidder,
active: g.auction.active,
+25 -2
View File
@@ -45,7 +45,7 @@ export const PHASE_HELP = [
'Complete the table’s storm dials or card reveal. After the distance is revealed, play any storm cards and confirm movement.',
'Reveal a spice blow, resolve worms and card responses, then finish any Nexus and Fremen rides.',
'Claim charity once if below two spice. Advanced Bene Gesserit receives two spice regardless of wealth. Then mark yourself ready.',
'Bid or pass in turn. A winning bidder chooses how to pay before income and bonus-card responses resolve.',
'Bid or pass in turn. New ordinary lots allow ten seconds per bidder while native decisions hold the clock. A winning bidder chooses how to pay before income and bonus-card responses resolve.',
'Return eligible forces and leaders from the tanks to your reserves. New Advanced Revival phases begin from the First Player counterclockwise; Sapho may change unfinished positions. Finish your own force and leader choices before the next player acts. Basic and already-opened older phases retain their current readiness timing.',
'Ship before moving. Select a destination sector and, for a move, the forces leaving one territory.',
'Choose a battle, resolve faction powers, seal plans, decide traitors, then choose which winning cards to keep.',
@@ -126,6 +126,7 @@ const phaseDetails = [
[
'Before each auction, resolve the Atreides foresight response. The card stays hidden until that response is allowed. Cancellation affects this card only.',
'The highlighted player may raise the bid or pass. A new high bid clears previous passes.',
'New ordinary lots give the current bidder ten seconds once bidding is ready. Silence becomes a native pass; if passing would break a binding Truthtrance answer, the online convention submits only its required legal bid. Native decisions hold remaining time. Administrative controls re-arm the clock after resumption. Old open unstamped lots retain their saved protocol.',
'Holding Karama permits a bid above your available spice. If outbid, keep the card. A winning bid above your funds requires Karama.',
'After everyone else passes, the winner chooses spice payment or Karama. The table then resolves applicable Emperor-income and Harkonnen-bonus responses.',
'In the fresh local ixian-betrayal preview, native Ixian Bidding inspection and Advanced Technology first finish their original Karama responses, then pause for neutral Nexus acknowledgements before their effects. A legal rival Use prevents only the current attempt; all required passes allow it. See Ixian Nexus Betrayal for both alternatives, private-card limits and the unchanged public release gate.',
@@ -2573,7 +2574,7 @@ export const RULE_TOPICS: RuleTopic[] = [
: phase === 4
? ['ecaz-ambassadors', 'tleilaxu-revival']
: phase === 3
? ['ix-technology', 'nexus-ixian-betrayal', 'nexus-ixian-replacement', 'richese-cards']
? ['ordinary-auction-clock', 'ix-technology', 'nexus-ixian-betrayal', 'nexus-ixian-replacement', 'richese-cards']
: phase === 1
? ['card-harvester', 'faction-fremen']
: phase === 0
@@ -2581,6 +2582,28 @@ export const RULE_TOPICS: RuleTopic[] = [
: undefined,
}),
),
{
id: 'ordinary-auction-clock',
title: 'Ordinary auction: ten seconds to bid',
category: 'Turn phases', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'The server times each new ordinary bidding opportunity and uses the native pass when its ten seconds expire.',
steps: [
'Watch the persistent ordinary-auction countdown. Bid or pass before it reaches zero; server time decides the deadline. The local display never submits a pass.',
'Inspection, Truthtrance, cancellation, payment and other current native decisions hold the remaining budget. Unrelated polls do not grant more time. The next actual bidder receives ten seconds.',
'If passing would violate a binding Truthtrance answer, the explicit online convention submits only its required legal bid. Normal payment is owed if that bid wins; no extra strategic bid is chosen.',
'Administrative room-control or closure changes conservatively re-arm ten seconds once play resumes, including pauses no client observed. Paused, closed and removed rooms do not progress.',
'An already-open older unstamped lot keeps its saved untimed protocol; the next new native lot is timed. Pure offline games do not use wall time. A stored online deadline is evaluated on the next authenticated request after a stopped host.',
'Silent and Once-Around procedures retain their own timing. Full special-auction, browser, deployed and comprehensive acceptance remain unfinished.',
],
related: ['bidding', 'truthtrance-bid', 'truthtrance-turn-actions', 'ai-pacing'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Versioned native source, trusted clock, held budget, canonical pass and required-Truth bid use the original room and native rule paths.', evidence: ['game/auction-clock.ts', 'game/engine.ts', 'db/rooms.ts', 'db/room-continuation.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Persistent countdown distinguishes starting, running, interrupted, paused and server-resolution states; no client action is dispatched.', evidence: ['components/auction-countdown.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing online AI pacing runs alongside the deadline; no strategy refinement or calibration is added.', evidence: ['db/room-continuation.ts', 'db/rooms.ts', 'tests/bot-pacing.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Printed Basic/Advanced source, provisional online conventions and legacy timing boundaries are explicit.', evidence: ['docs/AUCTION_TIME_LIMIT.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, native, display and trusted-room clock tests; final hosted and full-mode acceptance remains.', evidence: ['tests/auction-clock.test.ts', 'tests/auction-clock-runtime.test.ts', 'tests/auction-countdown.test.tsx', 'tests/rooms-auction-clock.test.ts'] },
],
},
{
id: 'storm-cards',
title: 'Storm Cards: forecast and revealed faces',