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