Enforce ordinary auction bidding deadlines online
This commit is contained in:
1 parent
f76c8dfb93
commit
f548039cd8
17 files changed
+1363
-33
No files matched your search
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user