103 lines
4.6 KiB
TypeScript
103 lines
4.6 KiB
TypeScript
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 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 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 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 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 };
|
|
}
|