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,140 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, advanceAuctionClock, hasAuctionClockOpportunity, synchronizeAuctionClock,
|
||||
viewGame } from '../game/engine';
|
||||
import { createTruthBidFixture as fixture, declareTruthBid as declare, askTruthBid as ask,
|
||||
answerTruthBid as answer, bindTruthBid as bind, continueTruthBidLot as closeLot,
|
||||
truthBidInventory as inventory, truthBidPlayer as player } from './fixture-truth-bid';
|
||||
import { createTruthTurnPreparationFixture as preparation, bindTruthTurnPreparation as bindPreparation,
|
||||
truthTurnPreparationBid as bid, truthTurnPreparationGroup as group,
|
||||
truthTurnPreparationPayment as payment, settleTruthTurnPreparation as settle } from './fixture-truth-turn-preparation';
|
||||
const epoch = '0:0';
|
||||
|
||||
void test('native Basic and Advanced original auction lots expire at ten seconds through the canonical pass', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ advanced }), before = structuredClone(f.game), actor = f.game.auction!.active;
|
||||
assert.equal(f.game.auction!.clockVersion, 1); assert.equal(f.game.auctionClock, undefined);
|
||||
assert.equal(hasAuctionClockOpportunity(f.game), true);
|
||||
const armed = synchronizeAuctionClock(f.game, 1_000, epoch);
|
||||
const early = advanceAuctionClock(armed, 10_999, epoch);
|
||||
assert.deepEqual(early, armed); assert.deepEqual(f.game, before);
|
||||
const expected = applyAction(f.game, actor, { type: 'passBid' });
|
||||
const expired = advanceAuctionClock(early, 11_000, epoch);
|
||||
assert.deepEqual(expired.auction, expected.auction); assert.equal(expired.active, expected.active);
|
||||
assert.notEqual(expired.auctionClock!.event, armed.auctionClock!.event);
|
||||
assert.equal(expired.auctionClock!.remainingMs, 10_000); assert.equal(expired.auctionClock!.startedAt, 11_000);
|
||||
assert.match(expired.log.at(-1)!.text, /ten-second bidding time expired; they passed/);
|
||||
assert.deepEqual(inventory(expired), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a real raised bid starts the next bidder clock while unchanged polls cannot refresh it', () => {
|
||||
const f = fixture(), actor = f.game.auction!.active;
|
||||
let game = synchronizeAuctionClock(f.game, 1_000, epoch);
|
||||
const original = game.auctionClock!.event;
|
||||
game = applyAction(game, actor, { type: 'bid', amount: 1, allyPayment: 0 });
|
||||
game = synchronizeAuctionClock(game, 5_000, epoch);
|
||||
const next = game.auctionClock!; assert.notEqual(next.event, original);
|
||||
assert.equal(next.player, game.auction!.active); assert.equal(next.startedAt, 5_000);
|
||||
const observed = advanceAuctionClock(game, 14_999, epoch); assert.equal(observed, game);
|
||||
assert.equal(observed.auctionClock, next);
|
||||
const expired = advanceAuctionClock(observed, 15_000, epoch);
|
||||
assert.ok(expired.auction?.passed.includes(next.player) || expired.auction?.index !== game.auction!.index);
|
||||
assert.deepEqual(inventory(expired), f.originalCards);
|
||||
});
|
||||
|
||||
void test('actual Truthtrance suspends the remaining bid budget and the resolved native question resumes it', () => {
|
||||
const f = fixture({ window: 'active' });
|
||||
let game = synchronizeAuctionClock(f.game, 1_000, epoch);
|
||||
game = declare(f, game).game; game = synchronizeAuctionClock(game, 3_000, epoch);
|
||||
assert.equal(game.auctionClock!.remainingMs, 8_000); assert.equal(game.auctionClock!.startedAt, null);
|
||||
const event = game.auctionClock!.event, waiting = advanceAuctionClock(game, 50_000, epoch);
|
||||
assert.deepEqual(waiting, game); assert.equal(waiting.auction!.active, f.target);
|
||||
game = ask(waiting, f.asker, f.target, 4); game = answer(game, f.target, 'no');
|
||||
game = synchronizeAuctionClock(game, 50_000, epoch);
|
||||
assert.equal(game.auctionClock!.event, 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.match(expired.log.at(-1)!.text, /passed this bid/); assert.deepEqual(inventory(expired), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a timeout cannot evade a native Truthtrance Yes and submits only its required bid before normal paid purchase', () => {
|
||||
const f = fixture({ window: 'active' });
|
||||
let game = bind(f, 'yes', 4).game; game = synchronizeAuctionClock(game, 1_000, epoch);
|
||||
const before = structuredClone(game); game = advanceAuctionClock(game, 11_000, epoch);
|
||||
assert.equal(before.auction!.bid, 0); assert.equal(game.auction!.bid, 4); assert.equal(game.auction!.bidder, f.target);
|
||||
assert.equal(player(game, f.target).spice, 10, 'The bid is a declaration, not a free or prepaid purchase');
|
||||
assert.match(game.log.at(-1)!.text, /legal bid required by their binding Truthtrance answer was submitted/);
|
||||
game = closeLot(game); assert.equal(player(game, f.target).spice, 6);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
|
||||
assert.ok(game.bidPromises!.every(promise => promise.fulfilled)); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a whole bid AND payment deadline selects the required bid even when its voluntary guide offers payment first', () => {
|
||||
const f = preparation();
|
||||
const mixed = group('and', bid(f.lot!, 3), payment(f.recipient));
|
||||
let game = bindPreparation(f, 'yes', mixed).game;
|
||||
assert.equal(viewGame(game, f.target).turnActionGuide!.action.type, 'bribe');
|
||||
game = synchronizeAuctionClock(game, 1_000, epoch); game = advanceAuctionClock(game, 11_000, epoch);
|
||||
assert.equal(game.auction!.bidder, f.target); assert.equal(game.auction!.bid, 3);
|
||||
assert.equal(game.turnActionPromises![0].preparation!.bid!.maximum, 3);
|
||||
assert.equal(game.turnActionPromises![0].fulfilled, false);
|
||||
game = applyAction(game, f.target, f.payment); assert.equal(game.turnActionPromises![0].fulfilled, true);
|
||||
game = closeLot(game); assert.equal(player(game, f.target).spice, 5);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a whole bid OR payment keeps its legal timeout pass and the remaining real payment alternative', () => {
|
||||
const f = preparation(), mixed = group('or', bid(f.lot!, 3), payment(f.recipient));
|
||||
let game = bindPreparation(f, 'yes', mixed).game;
|
||||
game = synchronizeAuctionClock(game, 1_000, epoch);
|
||||
const nativePass = applyAction(game, f.target, { type: 'passBid' });
|
||||
game = advanceAuctionClock(game, 11_000, epoch);
|
||||
assert.deepEqual(game.auction, nativePass.auction); assert.equal(game.phase, nativePass.phase);
|
||||
assert.notEqual(game.auction?.bidder, f.target);
|
||||
assert.match(game.log.at(-1)!.text, /passed this bid/);
|
||||
assert.equal(game.turnActionPromises![0].fulfilled, false); assert.equal(game.turnActionPromises![0].released, false);
|
||||
game = settle(game); game = applyAction(game, f.target, f.payment);
|
||||
assert.equal(game.turnActionPromises![0].fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a compelled bid uses the lowest legal whole-answer alternative regardless of question order', () => {
|
||||
const f = preparation();
|
||||
const mixed = group('or', bid(f.lot!, 5), bid(f.lot!, 3));
|
||||
let game = bindPreparation(f, 'yes', mixed).game;
|
||||
assert.equal(viewGame(game, f.target).turnActionGuide!.action.amount, 5);
|
||||
game = synchronizeAuctionClock(game, 1_000, epoch);
|
||||
game = advanceAuctionClock(game, 11_000, epoch);
|
||||
assert.equal(game.auction!.bid, 3); assert.equal(game.auction!.bidder, f.target);
|
||||
assert.equal(game.turnActionPromises![0].fulfilled, true);
|
||||
game = closeLot(game); assert.equal(player(game, f.target).spice, 7);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a controlled legacy open lot remains untimed and its actual newly retired next lot adopts the clock', () => {
|
||||
const f = fixture(), legacy = structuredClone(f.game); delete legacy.auction!.clockVersion;
|
||||
assert.equal(hasAuctionClockOpportunity(legacy), false);
|
||||
assert.equal(synchronizeAuctionClock(legacy, 1_000, epoch), legacy);
|
||||
assert.equal(advanceAuctionClock(legacy, 1_000_000, epoch), legacy);
|
||||
assert.equal(viewGame(legacy, f.asker).auctionClock, null);
|
||||
let game = applyAction(legacy, legacy.auction!.active, { type: 'bid', amount: 1, allyPayment: 0 });
|
||||
game = closeLot(game); assert.equal(game.auction!.index, 1); assert.equal(game.auction!.clockVersion, 1);
|
||||
assert.equal(hasAuctionClockOpportunity(game), true); assert.equal(synchronizeAuctionClock(game, 1_000_000, epoch).auctionClock!.remainingMs, 10_000);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('only public bidder time is projected and the private physical-card source never enters the clock view', () => {
|
||||
const f = fixture(); const game = synchronizeAuctionClock(f.game, 1_000, epoch);
|
||||
for (const owner of game.players) {
|
||||
const view = viewGame(game, owner.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, game.auction!.active);
|
||||
assert.ok(!JSON.stringify(view.auctionClock).includes(f.lotCard));
|
||||
assert.equal(view.auction?.clockVersion, 1);
|
||||
}
|
||||
const rearmed = synchronizeAuctionClock(game, 100_000, '2:0');
|
||||
assert.equal(rearmed.auctionClock!.remainingMs, 10_000); assert.equal(rearmed.auctionClock!.startedAt, 100_000);
|
||||
assert.equal(game.auctionClock!.startedAt, 1_000);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
AUCTION_BID_MS,
|
||||
AuctionClockError,
|
||||
quoteAuctionClock,
|
||||
viewAuctionClock,
|
||||
type AuctionClock,
|
||||
} from '../game/auction-clock';
|
||||
|
||||
const opportunity = { source: 'ordinary-lot:1', player: 'atreides' };
|
||||
function input(extra: Partial<Parameters<typeof quoteAuctionClock>[0]> = {}): Parameters<typeof quoteAuctionClock>[0] {
|
||||
return { opportunity, now: 1_000, blocked: false, controlEpoch: 'control:1', event: 'clock:1', ...extra };
|
||||
}
|
||||
function armed(extra: Partial<AuctionClock> = {}): AuctionClock {
|
||||
return { event: 'clock:1', source: opportunity.source, player: opportunity.player,
|
||||
remainingMs: AUCTION_BID_MS, startedAt: 1_000, controlEpoch: 'control:1', ...extra };
|
||||
}
|
||||
|
||||
void test('a new ordinary bidder receives exactly ten seconds with the supplied source and event', () => {
|
||||
assert.equal(AUCTION_BID_MS, 10_000);
|
||||
const result = quoteAuctionClock(input());
|
||||
assert.deepEqual(result, { clock: armed(), expired: false, delayMs: 10_000 });
|
||||
assert.deepEqual(viewAuctionClock(result.clock!, 1_000), {
|
||||
event: 'clock:1', player: 'atreides', remainingMs: 10_000, running: true,
|
||||
});
|
||||
});
|
||||
|
||||
void test('unrelated same-source requests retain the original event and deadline instead of resetting the budget', () => {
|
||||
const clock = armed();
|
||||
for (const now of [1_001, 2_500, 7_500, 10_999]) {
|
||||
const result = quoteAuctionClock(input({ clock, now, event: `unused:${now}` }));
|
||||
assert.equal(result.clock, clock);
|
||||
assert.equal(result.delayMs, 11_000 - now);
|
||||
assert.equal(result.expired, false);
|
||||
assert.equal(result.clock.event, 'clock:1');
|
||||
}
|
||||
});
|
||||
|
||||
void test('the exact deadline expires once eligible and cannot be extended by a later request', () => {
|
||||
const clock = armed();
|
||||
assert.equal(quoteAuctionClock(input({ clock, now: 10_999.5 })).expired, false);
|
||||
const expired = quoteAuctionClock(input({ clock, now: 11_000 }));
|
||||
assert.deepEqual(expired, { clock: armed({ remainingMs: 0, startedAt: null }), expired: true, delayMs: null });
|
||||
assert.deepEqual(viewAuctionClock(expired.clock!, 11_000), {
|
||||
event: 'clock:1', player: 'atreides', remainingMs: 0, running: false,
|
||||
});
|
||||
const repeated = quoteAuctionClock(input({ clock: expired.clock, now: 20_000, event: 'unused:new' }));
|
||||
assert.equal(repeated.clock, expired.clock);
|
||||
assert.equal(repeated.expired, true);
|
||||
assert.equal(repeated.delayMs, null);
|
||||
});
|
||||
|
||||
void test('a blocker suspends the exact retained budget and resumption does not grant a fresh ten seconds', () => {
|
||||
const clock = armed();
|
||||
const paused = quoteAuctionClock(input({ clock, now: 3_750, blocked: true }));
|
||||
assert.deepEqual(paused, { clock: armed({ remainingMs: 7_250, startedAt: null }), expired: false, delayMs: null });
|
||||
assert.deepEqual(viewAuctionClock(paused.clock!, 50_000), {
|
||||
event: 'clock:1', player: 'atreides', remainingMs: 7_250, running: false,
|
||||
});
|
||||
const stillPaused = quoteAuctionClock(input({ clock: paused.clock, now: 50_000, blocked: true, event: 'unused:pause' }));
|
||||
assert.equal(stillPaused.clock, paused.clock);
|
||||
const resumed = quoteAuctionClock(input({ clock: paused.clock, now: 50_000, event: 'unused:resume' }));
|
||||
assert.deepEqual(resumed, { clock: armed({ remainingMs: 7_250, startedAt: 50_000 }), expired: false, delayMs: 7_250 });
|
||||
assert.equal(quoteAuctionClock(input({ clock: resumed.clock, now: 57_249 })).expired, false);
|
||||
assert.equal(quoteAuctionClock(input({ clock: resumed.clock, now: 57_250 })).expired, true);
|
||||
assert.equal(clock.startedAt, 1_000);
|
||||
assert.equal(clock.remainingMs, 10_000);
|
||||
});
|
||||
|
||||
void test('a newly observed blocked opportunity starts suspended and never spends its initial budget', () => {
|
||||
const paused = quoteAuctionClock(input({ blocked: true }));
|
||||
assert.deepEqual(paused, { clock: armed({ startedAt: null }), expired: false, delayMs: null });
|
||||
const resumed = quoteAuctionClock(input({ clock: paused.clock, now: 500_000 }));
|
||||
assert.equal(resumed.clock!.startedAt, 500_000);
|
||||
assert.equal(resumed.clock!.event, 'clock:1');
|
||||
assert.equal(resumed.delayMs, 10_000);
|
||||
});
|
||||
|
||||
void test('a blocker suppresses an already elapsed deadline until the native opportunity resumes', () => {
|
||||
const paused = quoteAuctionClock(input({ clock: armed(), now: 11_000, blocked: true }));
|
||||
assert.equal(paused.expired, false);
|
||||
assert.equal(paused.delayMs, null);
|
||||
assert.deepEqual(paused.clock, armed({ remainingMs: 0, startedAt: null }));
|
||||
assert.equal(viewAuctionClock(paused.clock!, 50_000).event, 'clock:1');
|
||||
const resumed = quoteAuctionClock(input({ clock: paused.clock, now: 50_000 }));
|
||||
assert.equal(resumed.expired, true);
|
||||
assert.equal(resumed.delayMs, null);
|
||||
});
|
||||
|
||||
void test('source and bidder changes each bind a fresh supplied event and full budget', () => {
|
||||
const clock = armed({ remainingMs: 500 });
|
||||
for (const next of [{ source: 'ordinary-lot:2', player: 'atreides' },
|
||||
{ source: opportunity.source, player: 'emperor' }]) {
|
||||
const result = quoteAuctionClock(input({ clock, opportunity: next, now: 5_000, event: 'clock:2' }));
|
||||
assert.deepEqual(result.clock, { ...clock, ...next, event: 'clock:2', remainingMs: 10_000, startedAt: 5_000 });
|
||||
assert.equal(result.delayMs, 10_000);
|
||||
assert.equal(result.expired, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a changed control epoch re-arms even when no intermediate admin pause was observed', () => {
|
||||
const expired = armed({ remainingMs: 0, startedAt: null });
|
||||
const resumed = quoteAuctionClock(input({ clock: expired, now: 60_000,
|
||||
controlEpoch: 'control:2', event: 'clock:2' }));
|
||||
assert.deepEqual(resumed, { clock: armed({ controlEpoch: 'control:2', event: 'clock:2', startedAt: 60_000 }),
|
||||
expired: false, delayMs: 10_000 });
|
||||
const suspended = quoteAuctionClock(input({ clock: expired, now: 60_000, blocked: true,
|
||||
controlEpoch: 'control:2', event: 'clock:2' }));
|
||||
assert.deepEqual(suspended, { clock: armed({ controlEpoch: 'control:2', event: 'clock:2', startedAt: null }),
|
||||
expired: false, delayMs: null });
|
||||
});
|
||||
|
||||
void test('removing the native opportunity removes its frame and a later opportunity arms anew', () => {
|
||||
const result = quoteAuctionClock(input({ clock: armed(), opportunity: null, now: 5_000 }));
|
||||
assert.deepEqual(result, { clock: undefined, expired: false, delayMs: null });
|
||||
assert.equal(quoteAuctionClock(input({ clock: result.clock, now: 50_000, event: 'clock:2' })).delayMs, 10_000);
|
||||
});
|
||||
|
||||
void test('clock rollback consumes no retained time and never expands its budget', () => {
|
||||
const clock = armed({ remainingMs: 2_500 });
|
||||
const result = quoteAuctionClock(input({ clock, now: 500 }));
|
||||
assert.equal(result.clock, clock);
|
||||
assert.equal(result.delayMs, 2_500);
|
||||
assert.equal(result.expired, false);
|
||||
assert.equal(viewAuctionClock(clock, 500).remainingMs, 2_500);
|
||||
const paused = quoteAuctionClock(input({ clock, now: 500, blocked: true }));
|
||||
assert.deepEqual(paused.clock, { ...clock, startedAt: null });
|
||||
assert.equal(quoteAuctionClock(input({ clock: paused.clock, now: 5_000 })).delayMs, 2_500);
|
||||
});
|
||||
|
||||
void test('fractional elapsed time remains exact and a projected view never mutates the frame', () => {
|
||||
const clock = Object.freeze(armed({ remainingMs: 1_000.75, startedAt: 10.25 }));
|
||||
const before = structuredClone(clock);
|
||||
assert.equal(viewAuctionClock(clock, 110.5).remainingMs, 900.5);
|
||||
const paused = quoteAuctionClock(input({ clock, now: 110.5, blocked: true }));
|
||||
assert.equal(paused.clock!.remainingMs, 900.5);
|
||||
assert.deepEqual(clock, before);
|
||||
});
|
||||
|
||||
void test('clock validation rejects missing, extra, mistyped or nonfinite stored fields before projecting or discarding', () => {
|
||||
const clock = armed();
|
||||
const invalid: unknown[] = [null, [], {}, { ...clock, event: '' }, { ...clock, source: ' source' },
|
||||
{ ...clock, player: '' }, { ...clock, controlEpoch: '' }, { ...clock, deadline: 11_000 },
|
||||
...[-1, 10_001, NaN, Infinity, '10000'].map(remainingMs => ({ ...clock, remainingMs })),
|
||||
...[undefined, NaN, Infinity, '1000'].map(startedAt => ({ ...clock, startedAt }))];
|
||||
for (const field of Object.keys(clock)) {
|
||||
const missing: Record<string, unknown> = { ...clock };
|
||||
delete missing[field];
|
||||
invalid.push(missing);
|
||||
}
|
||||
for (const value of invalid) {
|
||||
assert.throws(() => viewAuctionClock(value as AuctionClock, 1_000), AuctionClockError);
|
||||
assert.throws(() => quoteAuctionClock(input({ clock: value as AuctionClock, opportunity: null })), AuctionClockError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('quotation validates complete native opportunity metadata and finite caller time without coercion', () => {
|
||||
const valid = input();
|
||||
const invalid: unknown[] = [null, [], {}, { ...valid, event: '' }, { ...valid, controlEpoch: ' epoch' },
|
||||
{ ...valid, blocked: 0 }, { ...valid, deadline: 11_000 },
|
||||
...[undefined, NaN, Infinity, '1000'].map(now => ({ ...valid, now })),
|
||||
...[undefined, [], {}, { source: '', player: 'atreides' }, { source: 'lot', player: ' atreides' },
|
||||
{ ...opportunity, bid: 1 }].map(opportunity => ({ ...valid, opportunity }))];
|
||||
for (const field of Object.keys(valid)) {
|
||||
const missing: Record<string, unknown> = { ...valid };
|
||||
delete missing[field];
|
||||
invalid.push(missing);
|
||||
}
|
||||
for (const value of invalid) assert.throws(() => quoteAuctionClock(value as typeof valid), AuctionClockError);
|
||||
for (const now of [NaN, Infinity, '1000', undefined])
|
||||
assert.throws(() => viewAuctionClock(armed(), now as number), AuctionClockError);
|
||||
assert.doesNotThrow(() => quoteAuctionClock(input({ opportunity: null })));
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
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 { AuctionCountdown } from '../components/auction-countdown';
|
||||
import type { AuctionClockView } from '../game/auction-clock';
|
||||
|
||||
const frame = (remainingMs = 10_000, running = true, event: string | null = 'ordinary-bid-event'): AuctionClockView =>
|
||||
({ event, player: 'p', remainingMs, running });
|
||||
const html = (clock: AuctionClockView | null, paused = false, playerName = 'Respondent') =>
|
||||
renderToStaticMarkup(createElement(AuctionCountdown, { clock, playerName, paused }));
|
||||
|
||||
void test('auction countdown renders public whole-second time and explains the native deadline and compelled-bid convention', () => {
|
||||
for (const [remainingMs, label] of [[10_000, '10 seconds'], [9_001, '10 seconds'], [1_001, '2 seconds'], [1_000, '1 second'], [1, '1 second']] as const) {
|
||||
const clock = frame(remainingMs), before = structuredClone(clock), markup = html(clock);
|
||||
assert.match(markup, new RegExp(`${label} remaining`)); assert.deepEqual(clock, before);
|
||||
assert.match(markup, /Respondent may bid or pass/); assert.match(markup, /role="timer" aria-live="off"/);
|
||||
assert.match(markup, /each player 10 seconds to bid or pass/); assert.match(markup, /server controls the deadline/);
|
||||
assert.match(markup, /Online convention: if passing would violate a binding Truthtrance answer/);
|
||||
assert.match(markup, /server submits only the required legal bid/); assert.match(markup, /normal payment is still owed/);
|
||||
assert.doesNotMatch(markup, /<button|<form|Continue|onClick/);
|
||||
}
|
||||
assert.equal(html(null), '');
|
||||
assert.match(html(frame(), false, '<script>send pass</script>'), /<script>send pass<\/script>/);
|
||||
});
|
||||
|
||||
void test('auction countdown distinguishes unarmed, interrupted, administrative pause and zero without creating an action control', () => {
|
||||
const unarmed = html(frame(10_000, false, null));
|
||||
assert.match(unarmed, /Clock starts when ordinary bidding is ready/); assert.match(unarmed, /10 seconds available/);
|
||||
const interrupted = html(frame(6_200, false));
|
||||
assert.match(interrupted, /held while the current game decision finishes/); assert.match(interrupted, /7 seconds remaining/);
|
||||
assert.doesNotMatch(interrupted, /may bid or pass/);
|
||||
const paused = html(frame(6_200), true); assert.match(paused, /Auction paused/); assert.match(paused, /7 seconds remaining/);
|
||||
for (const running of [false, true]) for (const roomPaused of [false, true]) {
|
||||
const expired = html(frame(0, running), roomPaused);
|
||||
assert.match(expired, /Awaiting server resolution/); assert.match(expired, /0 seconds remaining/);
|
||||
assert.doesNotMatch(expired, /<button|<form|Continue/);
|
||||
if (roomPaused) assert.match(expired, /Auction paused/);
|
||||
}
|
||||
});
|
||||
|
||||
/** Run the real component effects with a controlled monotonic browser clock.
|
||||
* React markup remains real; no DOM package or server action mock is needed. */
|
||||
function browserDisplay(initial: Parameters<typeof AuctionCountdown>[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: { AuctionCountdown?: typeof AuctionCountdown } = {};
|
||||
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/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.AuctionCountdown);
|
||||
const render = () => {
|
||||
stateIndex = 0; effectIndex = 0;
|
||||
const node: ReactNode = exports.AuctionCountdown!(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 browser time, accepts replacement server frames and stops at server-resolution status', () => {
|
||||
const display = browserDisplay({ clock: frame(), playerName: 'Respondent' });
|
||||
assert.match(display.render(), /10 seconds remaining/); assert.equal(display.ticking, 1);
|
||||
// One delayed callback still accounts for the actual elapsed time.
|
||||
assert.match(display.advance(5_401), /5 seconds remaining/);
|
||||
assert.match(display.update({ clock: frame(2_000), playerName: 'Respondent' }), /2 seconds remaining/);
|
||||
assert.equal(display.ticking, 1); assert.match(display.advance(1_001), /1 second remaining/);
|
||||
const expired = display.advance(999); assert.match(expired, /Awaiting server resolution/); assert.equal(display.ticking, 0);
|
||||
assert.doesNotMatch(expired, /<button|<form/);
|
||||
assert.match(display.update({ clock: { ...frame(), event: 'next-bid-event', player: 'a' }, playerName: 'Next bidder' }), /10 seconds remaining/);
|
||||
assert.match(display.render(), /Next bidder may bid or pass/); assert.equal(display.ticking, 1);
|
||||
assert.equal(display.update({ clock: null, playerName: 'Next bidder' }), ''); assert.equal(display.ticking, 0);
|
||||
});
|
||||
|
||||
void test('interrupted and room-paused frames retain their balance across local time and resume from the current server frame', () => {
|
||||
const display = browserDisplay({ clock: frame(6_200, false), playerName: 'Respondent' });
|
||||
assert.match(display.render(), /7 seconds remaining/); assert.equal(display.ticking, 0);
|
||||
assert.match(display.advance(20_000), /7 seconds remaining/);
|
||||
const paused = { clock: frame(4_500), playerName: 'Respondent', paused: true };
|
||||
assert.match(display.update(paused), /Auction paused/); assert.equal(display.ticking, 0);
|
||||
assert.match(display.advance(20_000), /5 seconds remaining/);
|
||||
assert.match(display.update({ ...paused, paused: false }), /5 seconds remaining/); assert.equal(display.ticking, 1);
|
||||
assert.match(display.advance(4_500), /Awaiting server resolution/); assert.equal(display.ticking, 0);
|
||||
const unarmed = display.update({ clock: frame(10_000, false, null), playerName: 'Respondent' });
|
||||
assert.match(unarmed, /Clock starts when ordinary bidding is ready/); assert.equal(display.ticking, 0);
|
||||
});
|
||||
@@ -9,7 +9,7 @@ export function loadRoomContinuation(
|
||||
rooms: Pick<
|
||||
typeof Rooms,
|
||||
'continueRoomAutomatic' | 'continueRoomBots'
|
||||
>,
|
||||
> & Partial<Pick<typeof Rooms, 'continueRoomAuctionClock'>>,
|
||||
) {
|
||||
const exports = {};
|
||||
runInNewContext(
|
||||
@@ -28,7 +28,7 @@ export function loadRoomContinuation(
|
||||
{
|
||||
exports,
|
||||
require: (name: string) => {
|
||||
if (name === './rooms') return rooms;
|
||||
if (name === './rooms') return { continueRoomAuctionClock: async () => false, ...rooms };
|
||||
throw new Error('Unexpected continuation dependency ' + name);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as engine from '../game/engine';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { loadRoomContinuation } from './fixture-room-continuation';
|
||||
import {
|
||||
actTruthBid, bindTruthBid, createTruthBidFixture, declareTruthBid, truthBidInventory,
|
||||
} from './fixture-truth-bid';
|
||||
|
||||
function fakeClock(start = 10000) {
|
||||
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[];
|
||||
};
|
||||
}
|
||||
function fixture(options: { required?: 'scalar' | 'aggregate'; legacy?: boolean } = {}) {
|
||||
const native = createTruthBidFixture({ window: options.required ? 'active' : 'waiting' });
|
||||
let game = structuredClone(native.game);
|
||||
if (options.required === 'scalar') game = bindTruthBid(native, 'yes', 4).game;
|
||||
if (options.required === 'aggregate') {
|
||||
game = declareTruthBid(native).game;
|
||||
game = actTruthBid(game, native.asker, { type: 'truthAsk', question: { kind: 'turnActions',
|
||||
target: native.target, turn: game.turn, mixed: { kind: 'and', terms: [
|
||||
{ kind: 'action', action: { kind: 'bid', lot: game.auction!.index + 1, minimum: 4 } },
|
||||
{ kind: 'action', action: { kind: 'payment', recipient: native.asker, minimum: 2 } },
|
||||
] } } });
|
||||
game = actTruthBid(game, native.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
}
|
||||
assert.equal(game.auction?.clockVersion, 1, 'The genuine new ordinary lot opts into trusted timing');
|
||||
// This one controlled metadata omission represents a current saved lot
|
||||
// predating the clock marker; no card, wallet, force or played action changes.
|
||||
if (options.legacy) delete game.auction!.clockVersion;
|
||||
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(`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: game.auction!.active };
|
||||
}
|
||||
|
||||
void test('trusted private read arms a genuine ordinary lot and projects remaining time without per-poll writes', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const view = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
assert.equal(view.version, 1); assert.equal(view.auctionClock?.player, f.actor);
|
||||
assert.equal(view.auctionClock?.remainingMs, 10000); assert.equal(view.auctionClock?.running, true);
|
||||
assert.equal(typeof view.auctionClock?.event, 'string');
|
||||
const stored = await f.rooms.readRoom(f.code); assert.equal(stored.auctionClock?.controlEpoch, '0:0');
|
||||
assert.equal(stored.auctionClock?.startedAt, f.clock.now());
|
||||
assert.equal(f.rooms.needsAutomaticRoomRecovery(stored), true);
|
||||
assert.equal(f.rooms.needsAutomaticRoomRecovery(view), true);
|
||||
f.clock.advance(4321);
|
||||
const later = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
assert.equal(later.version, view.version); assert.equal(later.auctionClock?.remainingMs, 5679);
|
||||
assert.equal(later.auctionClock?.event, view.auctionClock?.event);
|
||||
assert.deepEqual(await f.rooms.readRoom(f.code), stored);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('trusted deadline acts exactly at ten seconds and a waiting worker sleeps once before one native pass', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
f.clock.advance(9999); assert.equal(await f.rooms.continueRoomAuctionClock(f.code, f.clock), false);
|
||||
let game = await f.rooms.readRoom(f.code); assert.equal(game.auction?.active, f.actor);
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock, true);
|
||||
assert.deepEqual(f.clock.sleeps, [1]); game = await f.rooms.readRoom(f.code);
|
||||
assert.ok(game.auction?.passed.includes(f.actor)); assert.notEqual(game.auction?.active, f.actor);
|
||||
assert.equal(game.version, 2); assert.equal(game.auctionClock?.remainingMs, 10000);
|
||||
assert.equal(game.auctionClock?.startedAt, f.clock.now());
|
||||
assert.deepEqual(truthBidInventory(game), f.native.originalCards);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('timely authenticated bid starts its successor clock while a missed deadline rejects a late bid before submission', 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 : 10000);
|
||||
const action: engine.Action = { type: 'bid', amount: 1, allyPayment: 0 };
|
||||
if (timely) {
|
||||
const done = await f.rooms.act(f.code, f.auth[f.actor], view.version, action, f.clock);
|
||||
assert.equal(done.auction?.bid, 1); assert.equal(done.auction?.bidder, f.actor);
|
||||
assert.notEqual(done.auctionClock?.event, view.auctionClock?.event);
|
||||
assert.equal(done.auctionClock?.remainingMs, 10000);
|
||||
} else {
|
||||
await assert.rejects(f.rooms.act(f.code, f.auth[f.actor], view.version, action, f.clock), /table changed/i);
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.auction?.bid, 0); assert.ok(game.auction?.passed.includes(f.actor));
|
||||
assert.notEqual(game.auction?.active, f.actor);
|
||||
}
|
||||
} finally { f.sqlite.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
void test('a fresh room-module instance resumes the persisted missed deadline on an authenticated read', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const before = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
f.clock.advance(10000);
|
||||
const restarted = f.restart();
|
||||
const view = await restarted.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
assert.equal(view.version, before.version + 1); assert.notEqual(view.auctionClock?.player, f.actor);
|
||||
const game = await restarted.readRoom(f.code); assert.ok(game.auction?.passed.includes(f.actor));
|
||||
assert.deepEqual(f.clock.sleeps, []);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('actual Truthtrance interrupts suspend the retained budget and actual completion resumes it without charging blocked time', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
let view = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
f.clock.advance(4000);
|
||||
view = await f.rooms.act(f.code, f.auth[f.native.asker], view.version,
|
||||
{ type: 'card', card: f.native.truth }, f.clock);
|
||||
let game = await f.rooms.readRoom(f.code); assert.ok(game.truthtrance);
|
||||
assert.equal(game.auctionClock?.remainingMs, 6000); assert.equal(game.auctionClock?.startedAt, null);
|
||||
f.clock.advance(60000); const before = structuredClone(game);
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock, true);
|
||||
assert.deepEqual(f.clock.sleeps, []); assert.deepEqual(await f.rooms.readRoom(f.code), before);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
await f.rooms.act(f.code, f.auth[actor.id], game.version, { type: 'truthPass' }, f.clock);
|
||||
game = await f.rooms.readRoom(f.code);
|
||||
}
|
||||
await f.rooms.act(f.code, f.auth[f.native.asker], game.version, { type: 'truthAsk', question: {
|
||||
kind: 'fact', target: f.native.target, fact: { kind: 'spice', compare: 'gte', value: 0 },
|
||||
} }, f.clock);
|
||||
game = await f.rooms.readRoom(f.code);
|
||||
await f.rooms.act(f.code, f.auth[f.native.target], game.version, { type: 'truthAnswer', answer: 'yes' }, f.clock);
|
||||
game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.truthtrance, null); assert.equal(game.auctionClock?.remainingMs, 6000);
|
||||
assert.equal(game.auctionClock?.startedAt, f.clock.now());
|
||||
f.clock.advance(5999); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
assert.equal((await f.rooms.readRoom(f.code)).auction?.active, f.actor);
|
||||
f.clock.advance(1); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
assert.ok((await f.rooms.readRoom(f.code)).auction?.passed.includes(f.actor));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
for (const boundary of ['pause', 'closure'] as const)
|
||||
void test(`trusted ${boundary} revisions prevent clock writes while blocked and rearm a full budget on reopening`, async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(4000);
|
||||
if (boundary === 'pause') 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());
|
||||
else f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,?,?)')
|
||||
.run(f.code, f.clock.now(), f.clock.now());
|
||||
const before = await f.rooms.readRoom(f.code); f.clock.advance(60000);
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock, true);
|
||||
const blocked = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
||||
assert.equal(blocked.auctionClock?.running, false); assert.equal(blocked.auctionClock?.remainingMs, 10000);
|
||||
assert.deepEqual(await f.rooms.readRoom(f.code), before); assert.deepEqual(f.clock.sleeps, []);
|
||||
if (boundary === 'pause') f.sqlite.prepare('UPDATE room_controls SET paused=0,revision=2,updated_at=? WHERE room_code=?')
|
||||
.run(f.clock.now(), f.code);
|
||||
else f.sqlite.prepare('UPDATE room_closures SET closed=0,closed_at=NULL,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, boundary === 'pause' ? '2:0' : '0:2');
|
||||
assert.equal(game.auctionClock?.remainingMs, 10000); assert.equal(game.auctionClock?.startedAt, f.clock.now());
|
||||
f.clock.advance(9999); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
assert.equal((await f.rooms.readRoom(f.code)).auction?.active, f.actor);
|
||||
f.clock.advance(1); await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
game = await f.rooms.readRoom(f.code); assert.ok(game.auction?.passed.includes(f.actor));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('trusted clock CAS fences both administrative revisions even if a new revision is currently unpaused and open', async () => {
|
||||
for (const boundary of ['control', 'closure'] as const) {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(10000);
|
||||
f.hooks.beforeWrite = async () => {
|
||||
delete f.hooks.beforeWrite;
|
||||
if (boundary === 'control') f.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,0,0,1,?)')
|
||||
.run(f.code, f.clock.now());
|
||||
else f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,0,1,NULL,?)')
|
||||
.run(f.code, f.clock.now());
|
||||
};
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.auction?.active, f.actor); assert.equal(game.auction?.bid, 0);
|
||||
assert.equal(game.auctionClock?.controlEpoch, boundary === 'control' ? '1:0' : '0:1');
|
||||
assert.equal(game.auctionClock?.startedAt, f.clock.now()); assert.equal(game.auctionClock?.remainingMs, 10000);
|
||||
assert.ok(f.writes.some(write => write.changes === 0));
|
||||
} finally { f.sqlite.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
void test('a competing timely native bid wins the version CAS without a duplicate expired pass', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(10000);
|
||||
f.hooks.beforeWrite = async () => {
|
||||
delete f.hooks.beforeWrite;
|
||||
const current = await f.rooms.readRoom(f.code);
|
||||
// Another request passed trusted preflight one millisecond before the
|
||||
// deadline and commits its legal native bid before this timer's CAS.
|
||||
const next = engine.synchronizeAuctionClock(engine.applyAction(current, f.actor,
|
||||
{ type: 'bid', amount: 1, allyPayment: 0 }), f.clock.now() - 1, '0:0');
|
||||
next.version = current.version + 1;
|
||||
f.sqlite.prepare('UPDATE rooms SET state=?,version=version+1 WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(next), f.code, current.version);
|
||||
};
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.auction?.bid, 1); assert.equal(game.auction?.bidder, f.actor);
|
||||
assert.ok(!game.auction?.passed.includes(f.actor)); assert.notEqual(game.auction?.active, f.actor);
|
||||
assert.equal(game.version, 2); assert.ok(f.writes.some(write => write.changes === 0));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('recoverable room removal fences a due timer write without advancing the saved native lot', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
const before = await f.rooms.readRoom(f.code); f.clock.advance(10000);
|
||||
f.hooks.beforeWrite = async () => {
|
||||
delete f.hooks.beforeWrite;
|
||||
f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,?,?)')
|
||||
.run(f.code, f.clock.now(), f.clock.now());
|
||||
};
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
const stored = f.sqlite.prepare('SELECT state,version FROM rooms WHERE code=?').get(f.code)!;
|
||||
assert.equal(stored.version, before.version); assert.deepEqual(JSON.parse(stored.state as string), before);
|
||||
assert.ok(f.writes.some(write => write.changes === 0));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
for (const required of ['scalar', 'aggregate'] as const)
|
||||
void test(`trusted expired ${required} Truth bid submits only its required legal native bid instead of evading by pass`, async () => {
|
||||
const f = fixture({ required });
|
||||
try {
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock); f.clock.advance(10000);
|
||||
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
assert.equal(game.auction?.bidder, f.native.target); assert.equal(game.auction?.bid, 4);
|
||||
assert.equal(game.players.find(player => player.id === f.native.target)!.spice, 10);
|
||||
if (required === 'scalar') assert.equal(game.bidPromises?.[0].fulfilled, true);
|
||||
else {
|
||||
assert.equal(game.turnActionPromises?.[0].preparation?.bid?.maximum, 4);
|
||||
assert.deepEqual(game.turnActionPromises?.[0].payments, []);
|
||||
assert.equal(game.turnActionPromises?.[0].fulfilled, false);
|
||||
}
|
||||
assert.equal(game.discard.filter(card => card.id === f.native.truth).length, 1);
|
||||
assert.deepEqual(truthBidInventory(game), f.native.originalCards);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('legacy unstamped current lots stay untimed and invalid credentials cannot arm or expire another room', async () => {
|
||||
const legacy = fixture({ legacy: true }), f = fixture();
|
||||
try {
|
||||
const view = await legacy.rooms.readSeatView(legacy.code, legacy.auth[legacy.actor], legacy.clock);
|
||||
assert.equal(view.auctionClock, null); assert.equal(view.version, 0);
|
||||
legacy.clock.advance(60000); await legacy.rooms.continueRoomAuctionClock(legacy.code, legacy.clock, true);
|
||||
assert.equal((await legacy.rooms.readRoom(legacy.code)).version, 0); assert.deepEqual(legacy.clock.sleeps, []);
|
||||
const forged = { playerId: f.actor, tokenHash: 'not-the-seat-hash' };
|
||||
await assert.rejects(f.rooms.readSeatView(f.code, forged, f.clock), /verified/);
|
||||
await assert.rejects(f.rooms.act(f.code, forged, 0, { type: 'passBid' }, f.clock), /verified/);
|
||||
const unchanged = await f.rooms.readRoom(f.code); assert.equal(unchanged.version, 0);
|
||||
assert.equal(unchanged.auctionClock, undefined);
|
||||
} finally { legacy.sqlite.close(); f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('resume runs native automatic then bots alongside a sleeping auction clock without delaying the bot lane', async () => {
|
||||
const calls: string[] = []; let release!: () => void;
|
||||
const sleeping = new Promise<void>(resolve => { release = resolve; });
|
||||
const continuation = loadRoomContinuation({
|
||||
continueRoomAutomatic: async () => { calls.push('automatic'); },
|
||||
continueRoomBots: async () => { calls.push('bots'); },
|
||||
continueRoomAuctionClock: async (_code, _clock, wait) => {
|
||||
assert.equal(wait, true); calls.push('clock'); await sleeping; return false;
|
||||
},
|
||||
});
|
||||
const first = continuation.resumeRoom('CLOCK'); const coalesced = continuation.resumeRoom('CLOCK');
|
||||
assert.equal(first, coalesced);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
assert.ok(calls.includes('automatic') && calls.includes('clock') && calls.includes('bots'));
|
||||
assert.ok(calls.indexOf('automatic') < calls.indexOf('bots'));
|
||||
release(); await first;
|
||||
});
|
||||
Reference in new issue
Block a user