@flayerlabs/gamemode-gate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +79 -0
  3. package/dist/chain/discover.d.ts +158 -0
  4. package/dist/chain/discover.d.ts.map +1 -0
  5. package/dist/chain/discover.js +169 -0
  6. package/dist/chain/discover.js.map +1 -0
  7. package/dist/chain/signer.d.ts +46 -0
  8. package/dist/chain/signer.d.ts.map +1 -0
  9. package/dist/chain/signer.js +60 -0
  10. package/dist/chain/signer.js.map +1 -0
  11. package/dist/claims.d.ts +62 -0
  12. package/dist/claims.d.ts.map +1 -0
  13. package/dist/claims.js +128 -0
  14. package/dist/claims.js.map +1 -0
  15. package/dist/demo.d.ts +40 -0
  16. package/dist/demo.d.ts.map +1 -0
  17. package/dist/demo.js +90 -0
  18. package/dist/demo.js.map +1 -0
  19. package/dist/economy.d.ts +46 -0
  20. package/dist/economy.d.ts.map +1 -0
  21. package/dist/economy.js +100 -0
  22. package/dist/economy.js.map +1 -0
  23. package/dist/game-registry.d.ts +17 -0
  24. package/dist/game-registry.d.ts.map +1 -0
  25. package/dist/game-registry.js +90 -0
  26. package/dist/game-registry.js.map +1 -0
  27. package/dist/index.d.ts +23 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +14 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/ledger.d.ts +87 -0
  32. package/dist/ledger.d.ts.map +1 -0
  33. package/dist/ledger.js +222 -0
  34. package/dist/ledger.js.map +1 -0
  35. package/dist/registry.d.ts +55 -0
  36. package/dist/registry.d.ts.map +1 -0
  37. package/dist/registry.js +164 -0
  38. package/dist/registry.js.map +1 -0
  39. package/dist/room.d.ts +98 -0
  40. package/dist/room.d.ts.map +1 -0
  41. package/dist/room.js +215 -0
  42. package/dist/room.js.map +1 -0
  43. package/dist/server.d.ts +92 -0
  44. package/dist/server.d.ts.map +1 -0
  45. package/dist/server.js +695 -0
  46. package/dist/server.js.map +1 -0
  47. package/dist/sessions.d.ts +40 -0
  48. package/dist/sessions.d.ts.map +1 -0
  49. package/dist/sessions.js +144 -0
  50. package/dist/sessions.js.map +1 -0
  51. package/dist/settlement.d.ts +24 -0
  52. package/dist/settlement.d.ts.map +1 -0
  53. package/dist/settlement.js +69 -0
  54. package/dist/settlement.js.map +1 -0
  55. package/dist/store.d.ts +24 -0
  56. package/dist/store.d.ts.map +1 -0
  57. package/dist/store.js +125 -0
  58. package/dist/store.js.map +1 -0
  59. package/dist/turnstile.d.ts +19 -0
  60. package/dist/turnstile.d.ts.map +1 -0
  61. package/dist/turnstile.js +61 -0
  62. package/dist/turnstile.js.map +1 -0
  63. package/package.json +54 -0
  64. package/src/chain/discover.ts +243 -0
  65. package/src/chain/signer.ts +118 -0
  66. package/src/claims.ts +140 -0
  67. package/src/demo.ts +149 -0
  68. package/src/economy.ts +126 -0
  69. package/src/game-registry.ts +107 -0
  70. package/src/index.ts +45 -0
  71. package/src/ledger.ts +355 -0
  72. package/src/registry.ts +212 -0
  73. package/src/room.ts +297 -0
  74. package/src/server.ts +833 -0
  75. package/src/sessions.ts +155 -0
  76. package/src/settlement.ts +72 -0
  77. package/src/store.ts +127 -0
  78. package/src/turnstile.ts +74 -0
package/src/server.ts ADDED
@@ -0,0 +1,833 @@
1
+ import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';
2
+ import websocket from '@fastify/websocket';
3
+ import cors from '@fastify/cors';
4
+ import type { Pool } from 'pg';
5
+ import type { Command, GameModule, PlayerId } from '@flayerlabs/gamemode-spec';
6
+ import { isRefusal } from '@flayerlabs/gamemode-spec';
7
+ import type { PresenceState } from '@flayerlabs/gamemode-spec/live';
8
+ import { isReactionId, LIVE_PROTOCOL_VERSION } from '@flayerlabs/gamemode-spec/live';
9
+ import { Room } from './room.js';
10
+ import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
11
+ import { NotSignedIn, Sessions } from './sessions.js';
12
+ import type { Discovery, Launch, PoolKey } from './chain/discover.js';
13
+ import type { Settlement } from './settlement.js';
14
+ import { resolveEconomy } from './economy.js';
15
+ import { encodeHookData } from './chain/signer.js';
16
+
17
+ /**
18
+ * The gate's door.
19
+ *
20
+ * Deliberately small. Everything interesting already lives behind it — the room decides, the
21
+ * ledger and signer authorise — so this file is transport and nothing else. Any rule that appears
22
+ * here is a rule in the wrong place.
23
+ *
24
+ * Views are sent whole rather than as deltas. Whole snapshots stop being sensible somewhere in the
25
+ * low thousands of players in one room; deltas are the fix, and adding them before there is a room
26
+ * that size would be guessing at the shape of a problem nobody has.
27
+ */
28
+
29
+ interface GateBaseOptions<Config, State, Event, Action, PublicView, PlayerView> {
30
+ pool: Pool;
31
+ game: GameModule<Config, State, Event, Action, PublicView, PlayerView>;
32
+ sessions: Sessions;
33
+ claims: Claims;
34
+ /** How many points a dollar buys. The only economic number anyone sets by hand. */
35
+ pointsPerDollar: number;
36
+ usdPerEth: number;
37
+ /** Verifies from the chain that a coin really is a game launch. */
38
+ discovery?: Discovery;
39
+ /** Keeps the ledger's view of spending level with the chain's. */
40
+ settlement?: Settlement;
41
+ /** How long charts and final spend reconciliation remain live after gameplay closes. */
42
+ settlementTailMs?: number;
43
+ /** Origins allowed to call this gate: the game's own, and nothing else. */
44
+ allowedOrigins: string[];
45
+ /**
46
+ * Decide whether a caller may open a session or spend a request, or refuse them.
47
+ *
48
+ * One optional seam rather than a bot layer, because the pieces of a bot layer are not
49
+ * game-neutral and this library is self-hosted. A human-verification vendor needs keys only the
50
+ * operator has; a request budget needs a cadence only the game knows — a quiz taking one answer
51
+ * every ten seconds and a shooter taking thirty a second cannot share a number; and a false
52
+ * refusal on a claim costs a player a purchase inside a window that will not come back.
53
+ *
54
+ * So the policy belongs to whoever runs the gate. What belongs here is the place to put it, and
55
+ * the guarantee it is consulted before a session is issued and before allowance is committed.
56
+ *
57
+ * Omitted, everything is admitted, which is what a laptop and a test want.
58
+ */
59
+ admit?: Admit;
60
+ /** The only cosmetic reaction IDs this game may broadcast. Omitted disables reactions. */
61
+ reactionIds?: readonly string[];
62
+ }
63
+
64
+ /** Resolve one immutable rules config after the launch has been verified. */
65
+ export type VerifiedLaunch = Readonly<Omit<Launch, 'poolKey'>> & { readonly poolKey: Readonly<PoolKey> };
66
+ export type ConfigForLaunch<Config> = (launch: VerifiedLaunch) => Promise<Config>;
67
+
68
+ export type GateOptions<Config, State, Event, Action, PublicView, PlayerView> = GateBaseOptions<
69
+ Config,
70
+ State,
71
+ Event,
72
+ Action,
73
+ PublicView,
74
+ PlayerView
75
+ > & {
76
+ /** A static serialisable config, or a factory resolved once after each launch is verified. */
77
+ config: Config | ConfigForLaunch<Config>;
78
+ };
79
+
80
+ /** Where an admission check is being made. Enough to tell a sign-in from a spend. */
81
+ export interface AdmissionRequest {
82
+ /** `session` on sign-in, `action` on gameplay, `claim` when allowance is about to be committed. */
83
+ at: 'session' | 'action' | 'claim';
84
+ /** Null at `session`, because nobody has proved who they are yet. */
85
+ player: PlayerId | null;
86
+ roundId: string | null;
87
+ /** Straight from the socket, so a limiter can key on it. Absent behind some proxies. */
88
+ ip: string | null;
89
+ /** Opaque and operator-defined: a captcha solution, a device token, whatever was sent. */
90
+ evidence: unknown;
91
+ }
92
+
93
+ /**
94
+ * Allow, or refuse with a reason a player can be shown.
95
+ *
96
+ * The reason reaches the player, so write it for them: "too many tries, wait a minute", not a rule
97
+ * name or a score.
98
+ */
99
+ export type Admission = { ok: true } | { ok: false; reason: string };
100
+
101
+ export type Admit = (request: AdmissionRequest) => Admission | Promise<Admission>;
102
+
103
+ const ADMIT_ALL: Admit = () => ({ ok: true });
104
+
105
+ /** What the socket says, which is only the truth when nothing is proxying in front of the gate. */
106
+ const ipOf = (request: FastifyRequest): string | null => request.ip || null;
107
+
108
+ /** How often each open room is nudged so a deadline nobody is watching still fires. */
109
+ const TICK_MS = 250;
110
+
111
+ /**
112
+ * How often the chain is asked what has actually been spent.
113
+ *
114
+ * Far slower than the tick, because it is one read per player per pass. At the tick rate a busy
115
+ * round would start a new sweep every quarter second while the previous one was still running, and
116
+ * they would pile up until the endpoint rate-limited us.
117
+ */
118
+ const RECONCILE_MS = 15_000;
119
+
120
+ /** Promise queues bound application work; these limits bound bytes already handed to `ws`. */
121
+ const MAX_QUEUED_COSMETIC_FRAMES = 64;
122
+ const MAX_COSMETIC_BUFFERED_BYTES = 256 * 1_024;
123
+ const MAX_AUTHORITATIVE_BUFFERED_BYTES = 1_024 * 1_024;
124
+ /** A final snapshot is best effort. A stuck balance read must never keep a dead room connected. */
125
+ const TERMINAL_DRAIN_DEADLINE_MS = 1_000;
126
+ /** Reactions are also bounded across a whole room, not only per sending socket. */
127
+ const MAX_ROOM_REACTIONS_PER_SECOND = 20;
128
+ export const DEFAULT_SETTLEMENT_TAIL_MS = CLAIM_AUTHORIZATION_TTL_MS;
129
+
130
+ type AdoptionResult<Config, State, Event, Action, PublicView, PlayerView> =
131
+ | { ok: true; room: Room<Config, State, Event, Action, PublicView, PlayerView>; created: boolean }
132
+ | { ok: false; status: 409 | 503; message: string };
133
+
134
+ export function createGate<Config, State, Event, Action, PublicView, PlayerView>(
135
+ options: GateOptions<Config, State, Event, Action, PublicView, PlayerView>,
136
+ ): FastifyInstance {
137
+ const { pool, game, sessions, claims, discovery, settlement } = options;
138
+ const admit = options.admit ?? ADMIT_ALL;
139
+ // Rules config is persisted as JSON, so a function here can only be the documented factory.
140
+ const configIsFactory = typeof options.config === 'function';
141
+ const allowedReactions = new Set(options.reactionIds ?? []);
142
+ if ([...allowedReactions].some((id) => !isReactionId(id))) {
143
+ throw new RangeError('reactionIds must contain only valid reaction identifiers');
144
+ }
145
+ const settlementTailMs = options.settlementTailMs ?? DEFAULT_SETTLEMENT_TAIL_MS;
146
+ if (!Number.isSafeInteger(settlementTailMs) || settlementTailMs < CLAIM_AUTHORIZATION_TTL_MS) {
147
+ throw new RangeError(`settlementTailMs must be at least ${CLAIM_AUTHORIZATION_TTL_MS}`);
148
+ }
149
+ // A body limit costs nothing and is the cheapest protection against an endpoint that parses.
150
+ const app = Fastify({ logger: process.env.GATE_LOG === '1', bodyLimit: 64 * 1024 });
151
+ const rooms = new Map<string, Room<Config, State, Event, Action, PublicView, PlayerView>>();
152
+ type Watcher = {
153
+ viewer: PlayerId | null;
154
+ snapshot(): void;
155
+ frame(value: unknown): void;
156
+ close(code: number, reason: string, drain?: boolean): void;
157
+ };
158
+ const watchers = new Map<string, Set<Watcher>>();
159
+ const activePlayerSockets = new Map<string, Watcher>();
160
+ const cachedPresence = new Map<string, PresenceState>();
161
+ const roomReactionTimes = new Map<string, number[]>();
162
+ const adoptions = new Map<
163
+ string,
164
+ Promise<AdoptionResult<Config, State, Event, Action, PublicView, PlayerView>>
165
+ >();
166
+
167
+ const playerSocketKey = (id: string, player: PlayerId): string => `${id}:${player}`;
168
+
169
+ void app.register(cors, { origin: options.allowedOrigins });
170
+ void app.register(websocket, { options: { maxPayload: 1_024 } });
171
+
172
+ /** One Room per round in this process — the optimistic lock assumes a single writer. */
173
+ async function roomFor(id: string) {
174
+ const existing = rooms.get(id);
175
+ if (existing) return existing;
176
+ const loaded = await Room.load(pool, game, id);
177
+ // A durable row is history after its books close, not permission to resurrect an active
178
+ // ticker and market stream every time somebody refreshes an old coin page. It still gets one
179
+ // bounded catch-up through closesAt: a restart spanning the whole tail must not strand final
180
+ // wakes or awards merely because no process was alive to serve them.
181
+ if (loaded && Date.now() <= loaded.launch.booksCloseAt) {
182
+ rooms.set(id, loaded);
183
+ } else if (loaded) {
184
+ if (loaded.nextWakeAt() !== null) await loaded.tick(loaded.window.closesAt);
185
+ return null;
186
+ }
187
+ return loaded;
188
+ }
189
+
190
+ async function createAdoptedRoom(
191
+ launch: Launch,
192
+ ): Promise<AdoptionResult<Config, State, Event, Action, PublicView, PlayerView>> {
193
+ const existing = await roomFor(launch.poolId);
194
+ if (existing) return { ok: true, room: existing, created: false };
195
+
196
+ if (Date.now() >= launch.closesAt) {
197
+ return { ok: false, status: 409, message: 'this game has already ended' };
198
+ }
199
+
200
+ let config: Config;
201
+ try {
202
+ const verifiedLaunch: VerifiedLaunch = Object.freeze({
203
+ ...launch,
204
+ poolKey: Object.freeze({ ...launch.poolKey }),
205
+ });
206
+ config = configIsFactory
207
+ ? await (options.config as ConfigForLaunch<Config>)(verifiedLaunch)
208
+ : (options.config as Config);
209
+ } catch (error) {
210
+ app.log.error(error);
211
+ return { ok: false, status: 503, message: 'this game is still being prepared' };
212
+ }
213
+
214
+ // Config generation is allowed to take time, but it must not create a round whose on-chain
215
+ // permission has expired while the factory was working.
216
+ if (Date.now() >= launch.closesAt) {
217
+ return { ok: false, status: 409, message: 'this game has already ended' };
218
+ }
219
+
220
+ let maxPointsPerPlayer: number;
221
+ try {
222
+ maxPointsPerPlayer = game.rewardBounds(config).maxPointsPerPlayer;
223
+ } catch (error) {
224
+ app.log.error(error);
225
+ return { ok: false, status: 503, message: 'this game is still being prepared' };
226
+ }
227
+ const economy = resolveEconomy({
228
+ maxPointsPerPlayer,
229
+ walletCapWei: launch.walletCapWei,
230
+ pointsPerDollar: options.pointsPerDollar,
231
+ usdPerEth: options.usdPerEth,
232
+ });
233
+ if (!economy.ok) {
234
+ app.log.error(
235
+ `${launch.coinAddress}: ${economy.problem} (try ${economy.suggestedPointsPerDollar} points per dollar)`,
236
+ );
237
+ return { ok: false, status: 409, message: 'this game is not set up correctly yet' };
238
+ }
239
+
240
+ let room: Room<Config, State, Event, Action, PublicView, PlayerView>;
241
+ try {
242
+ room = await Room.create(pool, game, {
243
+ id: launch.poolId,
244
+ config,
245
+ seed: Number(BigInt(launch.poolId) % 2147483647n),
246
+ window: { opensAt: launch.opensAt, closesAt: launch.closesAt },
247
+ weiPerPoint: economy.economy.weiPerPoint,
248
+ walletCapWei: launch.walletCapWei,
249
+ poolId: launch.poolId,
250
+ booksCloseAt: launch.closesAt + settlementTailMs,
251
+ launch: {
252
+ coinAddress: launch.coinAddress,
253
+ name: launch.name,
254
+ symbol: launch.symbol,
255
+ imageUrl: launch.imageUrl ?? null,
256
+ },
257
+ });
258
+ } catch (error) {
259
+ app.log.error(error);
260
+ return { ok: false, status: 503, message: 'this game is still being prepared' };
261
+ }
262
+ rooms.set(launch.poolId, room);
263
+ return { ok: true, room, created: true };
264
+ }
265
+
266
+ function announce(id: string): void {
267
+ for (const watcher of watchers.get(id) ?? []) watcher.snapshot();
268
+ }
269
+
270
+ function announcePlayer(id: string, player: PlayerId): void {
271
+ for (const watcher of watchers.get(id) ?? []) {
272
+ if (watcher.viewer === player) watcher.snapshot();
273
+ }
274
+ }
275
+
276
+ function calculatePresence(id: string): PresenceState {
277
+ const connected = new Set(
278
+ [...(watchers.get(id) ?? [])].flatMap((watcher) =>
279
+ watcher.viewer && activePlayerSockets.get(playerSocketKey(id, watcher.viewer)) === watcher
280
+ ? [watcher.viewer]
281
+ : [],
282
+ ),
283
+ );
284
+ return { connectedCount: connected.size };
285
+ }
286
+
287
+ function presence(id: string): PresenceState {
288
+ const existing = cachedPresence.get(id);
289
+ if (existing) return existing;
290
+ const calculated = calculatePresence(id);
291
+ cachedPresence.set(id, calculated);
292
+ return calculated;
293
+ }
294
+
295
+ const invalidatePresence = (id: string): void => {
296
+ cachedPresence.delete(id);
297
+ };
298
+
299
+ /** The same for everyone, so it is built once rather than per socket. */
300
+ function announcePresence(id: string): void {
301
+ const frame = { v: LIVE_PROTOCOL_VERSION, type: 'presence', presence: presence(id) };
302
+ for (const watcher of watchers.get(id) ?? []) watcher.frame(frame);
303
+ }
304
+
305
+ const player = (request: FastifyRequest): PlayerId =>
306
+ sessions.verify((request.headers.authorization ?? '').replace(/^Bearer /, ''));
307
+
308
+ app.setErrorHandler((error, _request, reply: FastifyReply) => {
309
+ // Players see plain sentences. Codes and stack traces are for us, and a refusal that explains
310
+ // its own threshold is a refusal a cheater can calibrate against.
311
+ if (error instanceof NotSignedIn) return reply.code(401).send({ message: error.message });
312
+ app.log.error(error);
313
+ return reply.code(500).send({ message: 'something went wrong at our end, please try again' });
314
+ });
315
+
316
+ app.after(() => {
317
+ app.get('/health', () => ({ ok: true }));
318
+
319
+ app.post('/session/challenge', async (request) => {
320
+ const { address } = request.body as { address?: string };
321
+ if (typeof address !== 'string') throw new NotSignedIn('a wallet address is required');
322
+ return sessions.challenge(address);
323
+ });
324
+
325
+ app.post('/session', async (request, reply) => {
326
+ const { address, signature, nonce, evidence } = request.body as Record<string, string>;
327
+ if (!address || !signature || !nonce) throw new NotSignedIn('sign-in is missing something');
328
+
329
+ // Before the token exists, because a session is the thing worth rationing — everything after it
330
+ // is bounded by what the ledger will authorise.
331
+ const admission = await admit({ at: 'session', player: null, roundId: null, ip: ipOf(request), evidence });
332
+ if (!admission.ok) return reply.code(403).send({ message: admission.reason });
333
+
334
+ return { token: await sessions.signIn(address, signature as `0x${string}`, nonce) };
335
+ });
336
+
337
+ /**
338
+ * Open a room for a coin, if the chain says it qualifies.
339
+ *
340
+ * Unauthenticated on purpose. The permission is already on chain — a launch that wants a game
341
+ * names this gate's signer in its own parameters, which is public state the creator paid to write
342
+ * and nobody else can forge. So anyone may ask; the chain decides. An API key here would add a
343
+ * gate in front of a gate.
344
+ */
345
+ app.post('/rounds/adopt', async (request, reply) => {
346
+ if (!discovery) return reply.code(503).send({ message: 'this game is not open for new rounds' });
347
+
348
+ const { coin } = request.body as { coin?: string };
349
+ if (typeof coin !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(coin)) {
350
+ return reply.code(400).send({ message: 'that is not a coin address' });
351
+ }
352
+
353
+ const launch = await discovery.verify(coin.toLowerCase() as `0x${string}`);
354
+ if (!launch.ok) {
355
+ // Retryable and permanent are different answers and must reach the caller as different
356
+ // answers. Told "no" for a chain that had not caught up, a coin page caches it and shows a
357
+ // player nothing for the whole window.
358
+ return reply
359
+ .code(launch.retryable ? 503 : 404)
360
+ .send({ message: launch.retryable ? 'that game is still starting up' : 'that coin has no game' });
361
+ }
362
+
363
+ // A room per pool, so asking twice returns the same room rather than making a second one.
364
+ // Discovery caches its verdicts for as long as a window is open, so a coin page that reloads
365
+ // costs one map lookup rather than five chain reads.
366
+ const id = launch.poolId;
367
+ const existingFlight = adoptions.get(id);
368
+ const createdByThisRequest = existingFlight === undefined;
369
+ const flight = existingFlight ?? createAdoptedRoom(launch);
370
+ if (createdByThisRequest) {
371
+ adoptions.set(id, flight);
372
+ void flight.then(
373
+ () => adoptions.delete(id),
374
+ () => adoptions.delete(id),
375
+ );
376
+ }
377
+ const adopted = await flight;
378
+ if (!adopted.ok) return reply.code(adopted.status).send({ message: adopted.message });
379
+ return reply
380
+ .code(createdByThisRequest && adopted.created ? 201 : 200)
381
+ .send(adopted.room.launch);
382
+ });
383
+
384
+ app.get('/rounds/:id', async (request, reply) => {
385
+ const id = (request.params as { id: string }).id;
386
+ const room = await roomFor(id);
387
+ if (!room) return reply.code(404).send({ message: 'that game is not running' });
388
+ const before = room.sequence();
389
+ await room.tick(Date.now());
390
+ if (room.sequence() !== before) announce(id);
391
+ return { sequence: room.sequence(), view: room.publicView(), launch: room.launch };
392
+ });
393
+
394
+ app.post('/rounds/:id/join', async (request, reply) => {
395
+ const room = await roomFor((request.params as { id: string }).id);
396
+ if (!room) return reply.code(404).send({ message: 'that game is not running' });
397
+
398
+ const id = (request.params as { id: string }).id;
399
+ const before = room.sequence();
400
+ const result = await room.apply({
401
+ kind: 'join',
402
+ player: player(request),
403
+ // The platform supplies the randomness so a game never has to reach for any.
404
+ seed: Math.floor(Math.random() * 0x7fffffff),
405
+ at: Date.now(),
406
+ });
407
+ if (room.sequence() !== before) announce(id);
408
+ return isRefusal(result) ? reply.code(409).send({ message: 'you cannot join right now' }) : { joined: true };
409
+ });
410
+
411
+ app.post('/rounds/:id/actions', async (request, reply) => {
412
+ const id = (request.params as { id: string }).id;
413
+ const room = await roomFor(id);
414
+ if (!room) return reply.code(404).send({ message: 'that game is not running' });
415
+
416
+ // Identity first: parsing is the game's code, and running a stranger's parser for an
417
+ // unauthenticated caller is work anyone can ask for.
418
+ const who = player(request);
419
+ const admission = await admit({ at: 'action', player: who, roundId: id, ip: ipOf(request), evidence: undefined });
420
+ if (!admission.ok) return reply.code(403).send({ message: admission.reason });
421
+
422
+ const action = game.parseAction((request.body as { action?: unknown })?.action);
423
+ if (action === null) return reply.code(400).send({ message: 'that move was not understood' });
424
+
425
+ const before = room.sequence();
426
+ const result = await room.apply({ kind: 'action', player: who, action, at: Date.now() });
427
+ if (room.sequence() !== before) announce(id);
428
+ // The code goes to our logs; the player gets a sentence the client chooses.
429
+ return isRefusal(result) ? reply.code(409).send({ refuse: result.refuse }) : { accepted: true };
430
+ });
431
+
432
+ app.post('/rounds/:id/claim', async (request, reply) => {
433
+ const id = (request.params as { id: string }).id;
434
+ const who = player(request);
435
+ const { maxSpendWei, requestId } = request.body as { maxSpendWei?: string; requestId?: unknown };
436
+ let upTo: bigint;
437
+ try {
438
+ upTo = BigInt(maxSpendWei ?? '0');
439
+ } catch {
440
+ return reply.code(400).send({ message: 'that amount was not understood' });
441
+ }
442
+ if (!isClaimAmount(upTo)) return reply.code(400).send({ message: 'that amount was not understood' });
443
+ // Required, not defaulted. Minting an id here for a caller that omitted one makes every retry a
444
+ // fresh claim, which is precisely the double-hold idempotency exists to prevent — and it fails
445
+ // silently, for the one caller that needed it most. Nothing is live, so there is no old client
446
+ // to keep working.
447
+ if (!isClaimRequestId(requestId)) {
448
+ return reply.code(400).send({ message: 'that request was not understood' });
449
+ }
450
+
451
+ const admission = await admit({ at: 'claim', player: who, roundId: id, ip: ipOf(request), evidence: undefined });
452
+ if (!admission.ok) return reply.code(403).send({ message: admission.reason });
453
+
454
+ const authorised = await claims.authorise(id, who, upTo, requestId);
455
+ if (!authorised.ok) return reply.code(409).send({ refuse: authorised.refuse });
456
+
457
+ const { payload } = authorised;
458
+ if (rooms.has(id)) announcePlayer(id, who);
459
+ return {
460
+ // Strings, because these do not survive JSON as numbers.
461
+ buyer: payload.buyer,
462
+ poolId: payload.poolId,
463
+ deadline: payload.deadline.toString(),
464
+ maxSpendWei: payload.maxSpendWei.toString(),
465
+ nonce: payload.nonce.toString(),
466
+ signature: payload.signature,
467
+ signer: payload.signer,
468
+ // The embed passes this opaque value to the Flaunch buy call. Game code never reconstructs
469
+ // the ABI and therefore cannot accidentally sign or submit a different transaction shape.
470
+ hookData: encodeHookData(payload),
471
+ };
472
+ });
473
+
474
+ app.get('/rounds/:id/live', { websocket: true }, async (socket, request) => {
475
+ const id = (request.params as { id: string }).id;
476
+ const room = await roomFor(id);
477
+ if (!room) return socket.close(4404, 'that game is not running');
478
+
479
+ let viewer: PlayerId | null = null;
480
+ try {
481
+ // A browser cannot set headers on a WebSocket, so the token rides the query string here and
482
+ // ONLY here — on an ordinary request it would end up in every access log along the way.
483
+ viewer = sessions.verify((request.query as { token?: string })?.token);
484
+ } catch {
485
+ // Watching without signing in is allowed. Playing is not — actions go over HTTP, which
486
+ // requires a token, so a spectator socket can only ever receive.
487
+ }
488
+
489
+ let outbound = Promise.resolve();
490
+ let accepting = true;
491
+ let sendable = true;
492
+ let snapshotQueued = false;
493
+ let snapshotDirty = false;
494
+ let queuedCosmeticFrames = 0;
495
+ let terminalDrainTimer: ReturnType<typeof setTimeout> | undefined;
496
+
497
+ const terminateSocket = (): void => {
498
+ accepting = false;
499
+ sendable = false;
500
+ if (socket.readyState !== socket.CLOSED) socket.terminate();
501
+ };
502
+
503
+ const write = (value: unknown, authoritative: boolean): void => {
504
+ if (!sendable || socket.readyState !== socket.OPEN) return;
505
+ const encoded = JSON.stringify(value);
506
+ const encodedBytes = Buffer.byteLength(encoded);
507
+ if (authoritative) {
508
+ // An authoritative frame cannot safely be dropped: reconnecting is how the client obtains
509
+ // a fresh whole snapshot after it falls too far behind. A snapshot larger than the entire
510
+ // allowance is unservable too, so terminate rather than allocate an unbounded transport
511
+ // queue; reconnect may recover once the game's whole view becomes smaller.
512
+ if (socket.bufferedAmount + encodedBytes > MAX_AUTHORITATIVE_BUFFERED_BYTES) {
513
+ terminateSocket();
514
+ return;
515
+ }
516
+ } else if (socket.bufferedAmount >= MAX_COSMETIC_BUFFERED_BYTES) {
517
+ return;
518
+ }
519
+
520
+ if (
521
+ !authoritative &&
522
+ socket.bufferedAmount + encodedBytes > MAX_COSMETIC_BUFFERED_BYTES
523
+ ) {
524
+ return;
525
+ }
526
+ socket.send(encoded);
527
+ };
528
+
529
+ const queue = (
530
+ make: () => unknown | Promise<unknown>,
531
+ authoritative: boolean,
532
+ settled?: () => void,
533
+ failed?: () => void,
534
+ ): boolean => {
535
+ if (!accepting) return false;
536
+ outbound = outbound
537
+ .then(async () => {
538
+ try {
539
+ if (!sendable) return;
540
+ const value = await make();
541
+ write(value, authoritative);
542
+ } finally {
543
+ settled?.();
544
+ }
545
+ })
546
+ .catch((error) => {
547
+ app.log.error(error);
548
+ failed?.();
549
+ });
550
+ return true;
551
+ };
552
+ const makeSnapshot = async () => {
553
+ // `now` is what every countdown in every game is drawn through, so a player with a wrong
554
+ // clock still sees the same round as everyone else.
555
+ // An authenticated read failure is not a zero balance. Let this snapshot fail so an already
556
+ // connected client retains its last authoritative value, and a first-time client gets a
557
+ // connection failure instead of being told its earned allowance vanished.
558
+ const balance = viewer
559
+ ? await claims.economyBalance(id, viewer)
560
+ : {
561
+ weiPerPoint: room.weiPerPoint,
562
+ earnedWei: 0n,
563
+ heldWei: 0n,
564
+ spentWei: 0n,
565
+ availableWei: 0n,
566
+ holdExpiresAt: null,
567
+ };
568
+ const economy = {
569
+ weiPerPoint: balance.weiPerPoint.toString(),
570
+ earnedWei: balance.earnedWei.toString(),
571
+ heldWei: balance.heldWei.toString(),
572
+ spentWei: balance.spentWei.toString(),
573
+ availableWei: balance.availableWei.toString(),
574
+ holdExpiresAt: balance.holdExpiresAt,
575
+ };
576
+ return {
577
+ v: LIVE_PROTOCOL_VERSION,
578
+ type: 'snapshot',
579
+ sequence: room.sequence(),
580
+ now: Date.now(),
581
+ view: room.publicView(),
582
+ you: viewer ? room.playerView(viewer) : null,
583
+ economy,
584
+ launch: room.launch,
585
+ presence: presence(id),
586
+ };
587
+ };
588
+ const watcher: Watcher = {
589
+ viewer,
590
+ frame: (value) => {
591
+ if (
592
+ !accepting ||
593
+ socket.bufferedAmount >= MAX_COSMETIC_BUFFERED_BYTES ||
594
+ queuedCosmeticFrames >= MAX_QUEUED_COSMETIC_FRAMES
595
+ ) {
596
+ return;
597
+ }
598
+ queuedCosmeticFrames += 1;
599
+ if (
600
+ !queue(
601
+ () => value,
602
+ false,
603
+ () => {
604
+ queuedCosmeticFrames -= 1;
605
+ },
606
+ )
607
+ ) {
608
+ queuedCosmeticFrames -= 1;
609
+ }
610
+ },
611
+ snapshot: () => {
612
+ if (!accepting) return;
613
+ if (snapshotQueued) {
614
+ snapshotDirty = true;
615
+ return;
616
+ }
617
+ snapshotQueued = true;
618
+ queue(
619
+ async () => {
620
+ try {
621
+ return await makeSnapshot();
622
+ } finally {
623
+ snapshotQueued = false;
624
+ if (snapshotDirty && accepting) {
625
+ snapshotDirty = false;
626
+ watcher.snapshot();
627
+ }
628
+ }
629
+ },
630
+ true,
631
+ undefined,
632
+ terminateSocket,
633
+ );
634
+ },
635
+ close: (code, reason, drain = false) => {
636
+ if (!accepting) return;
637
+ accepting = false;
638
+ if (!drain) {
639
+ sendable = false;
640
+ if (socket.readyState === socket.OPEN) socket.close(code, reason);
641
+ return;
642
+ }
643
+
644
+ // Do not put the deadline behind `outbound`: either an earlier snapshot or this final
645
+ // balance read may never settle. The timer owns eventual transport and map cleanup.
646
+ terminalDrainTimer = setTimeout(terminateSocket, TERMINAL_DRAIN_DEADLINE_MS);
647
+ terminalDrainTimer.unref();
648
+ outbound = outbound
649
+ .then(async () => {
650
+ try {
651
+ if (!sendable) return;
652
+ const value = await makeSnapshot();
653
+ write(value, true);
654
+ } catch (error) {
655
+ // A final balance read can fail during shutdown. Closing the socket is still
656
+ // mandatory: otherwise clients wait forever on a room the ticker has already ended.
657
+ app.log.error(error);
658
+ } finally {
659
+ if (sendable && socket.readyState === socket.OPEN) socket.close(code, reason);
660
+ }
661
+ })
662
+ .catch((error) => app.log.error(error));
663
+ },
664
+ };
665
+
666
+ const playerSocket = viewer ? playerSocketKey(id, viewer) : null;
667
+ if (playerSocket) {
668
+ const earlier = activePlayerSockets.get(playerSocket);
669
+ activePlayerSockets.set(playerSocket, watcher);
670
+ earlier?.close(4409, 'superseded');
671
+ }
672
+
673
+ const watching = watchers.get(id) ?? new Set<Watcher>();
674
+ watching.add(watcher);
675
+ watchers.set(id, watching);
676
+ if (viewer) invalidatePresence(id);
677
+
678
+ // Immediately, so a client renders from its first frame rather than waiting for something to
679
+ // happen — and this doubles as reconnect: a returning socket gets the whole current state.
680
+ watcher.snapshot();
681
+ // Spectators are deliberately absent from presence counts, so their churn must not fan a
682
+ // redundant frame out to every authenticated player in the room.
683
+ if (viewer) announcePresence(id);
684
+
685
+ const messageTimes: number[] = [];
686
+ const reactionTimes: number[] = [];
687
+ socket.on('message', (raw) => {
688
+ const text = String(raw);
689
+ if (!viewer) return socket.close(4401, 'sign in to interact');
690
+ // A superseded transport may remain physically open briefly. It no longer represents this
691
+ // player and cannot restore readiness or emit reactions while its close handshake finishes.
692
+ if (!playerSocket || activePlayerSockets.get(playerSocket) !== watcher) return;
693
+
694
+ const now = Date.now();
695
+ while (messageTimes[0] !== undefined && messageTimes[0] <= now - 1_000) messageTimes.shift();
696
+ if (messageTimes.length >= 20) return;
697
+ messageTimes.push(now);
698
+
699
+ let frame: { v?: unknown; type?: unknown; id?: unknown };
700
+ try {
701
+ frame = JSON.parse(text) as typeof frame;
702
+ } catch {
703
+ return;
704
+ }
705
+ if (frame.v !== LIVE_PROTOCOL_VERSION) return;
706
+
707
+ if (
708
+ frame.type === 'reaction' &&
709
+ typeof frame.id === 'string' &&
710
+ isReactionId(frame.id) &&
711
+ allowedReactions.has(frame.id)
712
+ ) {
713
+ while (reactionTimes[0] !== undefined && reactionTimes[0] <= now - 1_000) reactionTimes.shift();
714
+ const acrossRoom = roomReactionTimes.get(id) ?? [];
715
+ while (acrossRoom[0] !== undefined && acrossRoom[0] <= now - 1_000) acrossRoom.shift();
716
+ if (reactionTimes.length >= 5 || acrossRoom.length >= MAX_ROOM_REACTIONS_PER_SECOND) return;
717
+ reactionTimes.push(now);
718
+ acrossRoom.push(now);
719
+ roomReactionTimes.set(id, acrossRoom);
720
+ for (const watchingPlayer of watching) {
721
+ watchingPlayer.frame({
722
+ v: LIVE_PROTOCOL_VERSION,
723
+ type: 'reaction',
724
+ reaction: { player: viewer, id: frame.id },
725
+ });
726
+ }
727
+ }
728
+ });
729
+
730
+ socket.on('close', () => {
731
+ if (terminalDrainTimer) clearTimeout(terminalDrainTimer);
732
+ accepting = false;
733
+ sendable = false;
734
+ watching.delete(watcher);
735
+ if (watching.size === 0) {
736
+ watchers.delete(id);
737
+ cachedPresence.delete(id);
738
+ roomReactionTimes.delete(id);
739
+ }
740
+ // Only the socket currently representing this player changes the count. A superseded socket
741
+ // closing later must not decrement a player who is still here on their replacement.
742
+ if (viewer && playerSocket && activePlayerSockets.get(playerSocket) === watcher) {
743
+ activePlayerSockets.delete(playerSocket);
744
+ invalidatePresence(id);
745
+ if (watchers.has(id)) announcePresence(id);
746
+ }
747
+ });
748
+ });
749
+
750
+ });
751
+
752
+ // One whole pass at a time. Slow database or chain reads must not stack ticker work forever.
753
+ let ticking = false;
754
+ let reconciledAt = 0;
755
+
756
+ const ticker = setInterval(() => {
757
+ if (ticking) return;
758
+ ticking = true;
759
+ void (async () => {
760
+ try {
761
+ const now = Date.now();
762
+ const sweeping = Boolean(settlement && now - reconciledAt >= RECONCILE_MS);
763
+ // Authorisations nobody spent come back to the player who earned them.
764
+ const released = await claims.releaseExpired().catch((error) => {
765
+ app.log.error(error);
766
+ return [];
767
+ });
768
+ for (const balance of released) announcePlayer(balance.roundId, balance.player);
769
+
770
+ for (const [id, room] of rooms) {
771
+ // Gameplay is over, but a buzzer-time authorisation and the chart remain honourable through
772
+ // the independent books tail. A process can wake for the first time after this tail (for
773
+ // example after a stall or restart), so persist every due game wake before deleting it.
774
+ if (now > room.launch.booksCloseAt) {
775
+ try {
776
+ if (room.nextWakeAt() !== null) await room.tick(Math.min(now, room.window.closesAt));
777
+ } catch (error) {
778
+ // Retain the room and retry: deleting after a failed final wake would make earned
779
+ // awards disappear permanently from an otherwise healthy durable round.
780
+ app.log.error(error);
781
+ continue;
782
+ }
783
+ if (settlement) {
784
+ const final = await settlement.reconcile(id).catch(() => ({ read: 0, failed: 1, changed: [] }));
785
+ if (final.failed > 0) app.log.error(`${id}: ${final.failed} final spend reads failed`);
786
+ }
787
+ for (const watcher of watchers.get(id) ?? []) {
788
+ if (
789
+ watcher.viewer &&
790
+ activePlayerSockets.get(playerSocketKey(id, watcher.viewer)) === watcher
791
+ ) {
792
+ activePlayerSockets.delete(playerSocketKey(id, watcher.viewer));
793
+ }
794
+ watcher.close(4410, 'ended', true);
795
+ }
796
+ watchers.delete(id);
797
+ cachedPresence.delete(id);
798
+ roomReactionTimes.delete(id);
799
+ rooms.delete(id);
800
+ continue;
801
+ }
802
+ // What the chain says was spent, which is the only figure that is actually true.
803
+ if (sweeping) {
804
+ const swept = await settlement!.reconcile(id).catch(() => ({ read: 0, failed: 0, changed: [] }));
805
+ if (swept.failed > 0) app.log.error(`${id}: ${swept.failed} spend reads failed`);
806
+ for (const player of swept.changed) announcePlayer(id, player);
807
+ }
808
+ const before = room.sequence();
809
+ // A delayed process may first wake well after the old one-tick grace. Honour every game
810
+ // wake through the immutable close, but never advance gameplay into the settlement tail.
811
+ try {
812
+ if (room.nextWakeAt() !== null) {
813
+ await room.tick(Math.min(now, room.window.closesAt));
814
+ }
815
+ } catch (error) {
816
+ app.log.error(error);
817
+ }
818
+ if (room.sequence() !== before) announce(id);
819
+ }
820
+
821
+ if (sweeping) reconciledAt = Date.now();
822
+ } finally {
823
+ ticking = false;
824
+ }
825
+ })();
826
+ }, TICK_MS);
827
+
828
+ app.addHook('onClose', async () => clearInterval(ticker));
829
+ return app;
830
+ }
831
+
832
+ export { Room, Claims, Sessions };
833
+ export type { Command };