114 lines
6.0 KiB
TypeScript
114 lines
6.0 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import test from 'node:test';
|
|
import { applyAction, type Action } from '../game/engine';
|
|
import type * as Rooms from '../db/rooms';
|
|
import { unitStore } from './fixture-nexus-room-store';
|
|
import { createTruthRicheseBidFixture, inventory } from './fixture-truth-richese-bid';
|
|
|
|
function fakeClock(start = 10_000) {
|
|
let time = start;
|
|
const sleeps: number[] = [];
|
|
return { now: () => time, advance: (ms: number) => { time += ms; }, sleeps,
|
|
sleep: async (ms: number) => { sleeps.push(ms); time += ms; } } satisfies Rooms.RoomsClock & {
|
|
advance(ms: number): void; sleeps: number[];
|
|
};
|
|
}
|
|
|
|
/** The source is a genuine public Basic game and native Once Around cache
|
|
* lot. Only the parent fixture's unopened cards and scalar lotteries are
|
|
* selected; no wallet, force, lot, response or stored deadline is staged. */
|
|
function fixture() {
|
|
const native = createTruthRicheseBidFixture(), game = structuredClone(native.game);
|
|
const lot = game.richeseAuction; assert.ok(lot && lot.method === 'onceAround' && lot.active);
|
|
assert.equal(lot.clockVersion, 1, 'A newly opened sequential special lot uses trusted timing');
|
|
const store = unitStore(), clock = fakeClock(), code = game.code;
|
|
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,0,?)')
|
|
.run(code, JSON.stringify(game), clock.now());
|
|
const auth = Object.fromEntries(game.players.map(player => {
|
|
const tokenHash = createHash('sha256').update(`richese-auction-clock-test:${player.id}`).digest('hex');
|
|
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)').run(tokenHash, code, player.id);
|
|
return [player.id, { playerId: player.id, tokenHash }];
|
|
})) as Record<string, Rooms.SeatAuth>;
|
|
return { ...store, clock, code, native, auth, actor: lot.active, event: lot.event };
|
|
}
|
|
|
|
void test('a trusted room clock arms and expires a genuine Once Around bid through its native pass', async () => {
|
|
const f = fixture();
|
|
try {
|
|
const view = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
|
assert.equal(view.auctionClock?.player, f.actor);
|
|
assert.equal(view.auctionClock?.remainingMs, 10_000);
|
|
assert.equal(view.auctionClock?.running, true);
|
|
assert.equal(typeof view.auctionClock?.event, 'string');
|
|
assert.equal(f.rooms.needsAutomaticRoomRecovery(await f.rooms.readRoom(f.code)), true);
|
|
f.clock.advance(9999);
|
|
assert.equal(await f.rooms.continueRoomAuctionClock(f.code, f.clock), false);
|
|
assert.equal((await f.rooms.readRoom(f.code)).richeseAuction?.active, f.actor);
|
|
const expected = applyAction(f.native.game, f.actor,
|
|
{ type: 'richeseBid', event: f.event, amount: null, allyPayment: 0 });
|
|
f.clock.advance(1);
|
|
assert.equal(await f.rooms.continueRoomAuctionClock(f.code, f.clock), true);
|
|
const game = await f.rooms.readRoom(f.code);
|
|
assert.deepEqual(game.richeseAuction, expected.richeseAuction);
|
|
assert.ok(game.richeseAuction?.acted.includes(f.actor));
|
|
assert.notEqual(game.richeseAuction?.active, f.actor);
|
|
assert.equal(game.auctionClock?.remainingMs, 10_000);
|
|
assert.equal(game.auctionClock?.startedAt, f.clock.now());
|
|
assert.deepEqual(inventory(game), f.native.originalCards);
|
|
} finally { f.sqlite.close(); }
|
|
});
|
|
|
|
void test('a timely special bid advances the source and a late bid cannot beat its committed timeout', async () => {
|
|
for (const timely of [true, false]) {
|
|
const f = fixture();
|
|
try {
|
|
const view = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
|
f.clock.advance(timely ? 9999 : 10_000);
|
|
const bid: Action = { type: 'richeseBid', event: f.event, amount: 1, allyPayment: 0 };
|
|
if (timely) {
|
|
const result = await f.rooms.act(f.code, f.auth[f.actor], view.version, bid, f.clock);
|
|
assert.equal(result.richeseAuction?.bid, 1);
|
|
assert.equal(result.richeseAuction?.bidder, f.actor);
|
|
assert.notEqual(result.auctionClock?.event, view.auctionClock?.event);
|
|
assert.equal(result.auctionClock?.remainingMs, 10_000);
|
|
await assert.rejects(f.rooms.act(f.code, f.auth[f.actor], view.version, bid, f.clock), /table changed/i);
|
|
} else {
|
|
await assert.rejects(f.rooms.act(f.code, f.auth[f.actor], view.version, bid, f.clock), /table changed/i);
|
|
const game = await f.rooms.readRoom(f.code);
|
|
assert.equal(game.richeseAuction?.bid, 0);
|
|
assert.ok(game.richeseAuction?.acted.includes(f.actor));
|
|
assert.notEqual(game.richeseAuction?.active, f.actor);
|
|
}
|
|
} finally { f.sqlite.close(); }
|
|
}
|
|
});
|
|
|
|
void test('a paused room cannot expire a special bid and reopening re-arms its trusted budget', async () => {
|
|
const f = fixture();
|
|
try {
|
|
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
|
f.clock.advance(4000);
|
|
f.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,1,0,1,?)')
|
|
.run(f.code, f.clock.now());
|
|
const before = await f.rooms.readRoom(f.code);
|
|
f.clock.advance(60_000);
|
|
assert.equal(await f.rooms.continueRoomAuctionClock(f.code, f.clock, true), false);
|
|
assert.deepEqual(await f.rooms.readRoom(f.code), before);
|
|
assert.deepEqual(f.clock.sleeps, []);
|
|
const blocked = await f.rooms.readSeatView(f.code, f.auth[f.actor], f.clock);
|
|
assert.equal(blocked.auctionClock?.running, false);
|
|
f.sqlite.prepare('UPDATE room_controls SET paused=0,revision=2,updated_at=? WHERE room_code=?')
|
|
.run(f.clock.now(), f.code);
|
|
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
|
let game = await f.rooms.readRoom(f.code);
|
|
assert.equal(game.auctionClock?.controlEpoch, '2:0');
|
|
assert.equal(game.auctionClock?.remainingMs, 10_000);
|
|
assert.equal(game.auctionClock?.startedAt, f.clock.now());
|
|
f.clock.advance(10_000);
|
|
await f.rooms.continueRoomAuctionClock(f.code, f.clock);
|
|
game = await f.rooms.readRoom(f.code);
|
|
assert.ok(game.richeseAuction?.acted.includes(f.actor));
|
|
} finally { f.sqlite.close(); }
|
|
});
|