@flayerlabs/gamemode-spec 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/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@flayerlabs/gamemode-spec",
3
+ "version": "0.1.0",
4
+ "description": "Pure rules, round, scheduling, live-room and embed contracts for Flaunch Game Modes",
5
+ "license": "MIT",
6
+ "author": "Flayer Labs",
7
+ "homepage": "https://github.com/flayerlabs/gamemode-sdk#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/flayerlabs/gamemode-sdk.git",
11
+ "directory": "packages/spec"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/flayerlabs/gamemode-sdk/issues"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "type": "module",
20
+ "main": "./dist/index.js",
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./round": {
28
+ "types": "./dist/round.d.ts",
29
+ "default": "./dist/round.js"
30
+ },
31
+ "./schedule": {
32
+ "types": "./dist/schedule.d.ts",
33
+ "default": "./dist/schedule.js"
34
+ },
35
+ "./live": {
36
+ "types": "./dist/live.d.ts",
37
+ "default": "./dist/live.js"
38
+ },
39
+ "./embed": {
40
+ "types": "./dist/embed.d.ts",
41
+ "default": "./dist/embed.js"
42
+ }
43
+ },
44
+ "scripts": {
45
+ "prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
46
+ "build": "tsc -p tsconfig.build.json",
47
+ "prepack": "pnpm build",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "vitest run"
50
+ },
51
+ "types": "./dist/index.d.ts",
52
+ "engines": {
53
+ "node": ">=20"
54
+ },
55
+ "files": [
56
+ "dist",
57
+ "src"
58
+ ]
59
+ }
package/src/embed.ts ADDED
@@ -0,0 +1,450 @@
1
+ import type { PlayerId } from './index.js';
2
+ import { isLaunchContext, type BuyFailure, type LaunchContext, type PlayerIdentity, type WireMarketState } from './live.js';
3
+
4
+ export const EMBED_CHANNEL = 'flaunch.game-mode' as const;
5
+ export const EMBED_PROTOCOL_VERSION = 1 as const;
6
+
7
+ const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
8
+ const BYTES32 = /^0x[0-9a-fA-F]{64}$/;
9
+ const HEX = /^0x(?:[0-9a-fA-F]{2})*$/;
10
+ const DECIMAL = /^(?:0|[1-9][0-9]{0,77})$/;
11
+ const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
12
+ const DEPLOY_ID = /^[0-9a-f]{32}$/;
13
+ const PUBLIC_HOSTNAME = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
14
+
15
+ export interface GameRegistration {
16
+ chainId: number;
17
+ coin: string;
18
+ gameId: string;
19
+ gateOrigin: string;
20
+ deployId: string;
21
+ }
22
+
23
+ /** Parse the central registry response into the exact browser-safe shape the parent may trust. */
24
+ export function parseGameRegistration(input: unknown): GameRegistration | null {
25
+ const value = record(input);
26
+ if (
27
+ !value ||
28
+ !Number.isSafeInteger(value.chainId) ||
29
+ (value.chainId as number) <= 0 ||
30
+ typeof value.coin !== 'string' ||
31
+ !ADDRESS.test(value.coin) ||
32
+ value.coin !== value.coin.toLowerCase() ||
33
+ typeof value.gameId !== 'string' ||
34
+ value.gameId.length === 0 ||
35
+ value.gameId.length > 128 ||
36
+ value.gameId !== value.gameId.trim() ||
37
+ typeof value.deployId !== 'string' ||
38
+ !DEPLOY_ID.test(value.deployId) ||
39
+ typeof value.gateOrigin !== 'string' ||
40
+ value.gateOrigin.length > 253 ||
41
+ /[^\x20-\x7e]/.test(value.gateOrigin)
42
+ ) {
43
+ return null;
44
+ }
45
+
46
+ try {
47
+ const gate = new URL(value.gateOrigin);
48
+ if (
49
+ gate.protocol !== 'https:' ||
50
+ gate.origin !== value.gateOrigin ||
51
+ !PUBLIC_HOSTNAME.test(gate.hostname)
52
+ ) {
53
+ return null;
54
+ }
55
+ } catch {
56
+ return null;
57
+ }
58
+
59
+ return {
60
+ chainId: value.chainId as number,
61
+ coin: value.coin,
62
+ gameId: value.gameId,
63
+ gateOrigin: value.gateOrigin,
64
+ deployId: value.deployId,
65
+ };
66
+ }
67
+
68
+ export interface SpendAuthorisation {
69
+ buyer: string;
70
+ poolId: string;
71
+ deadline: string;
72
+ maxSpendWei: string;
73
+ nonce: string;
74
+ signature: string;
75
+ signer: string;
76
+ /** Opaque proof from the gate. The parent passes it to the Flaunch SDK unchanged. */
77
+ hookData: `0x${string}`;
78
+ }
79
+
80
+ export interface SpendAuthorisationFields {
81
+ buyer: string;
82
+ poolId: string;
83
+ deadline: string | bigint;
84
+ maxSpendWei: string | bigint;
85
+ nonce: string | bigint;
86
+ }
87
+
88
+ const SPEND_AUTHORIZATION_TYPES = {
89
+ SpendAuthorization: [
90
+ { name: 'buyer', type: 'address' },
91
+ { name: 'poolId', type: 'bytes32' },
92
+ { name: 'deadline', type: 'uint256' },
93
+ { name: 'maxSpendWei', type: 'uint256' },
94
+ { name: 'nonce', type: 'uint256' },
95
+ ],
96
+ } as const;
97
+
98
+ function uint256(value: string | bigint, field: string): bigint {
99
+ if (typeof value === 'string' && !DECIMAL.test(value)) {
100
+ throw new RangeError(`${field} must be an unsigned decimal integer`);
101
+ }
102
+ const parsed = BigInt(value);
103
+ if (parsed < 0n || parsed >= 1n << 256n) throw new RangeError(`${field} must fit uint256`);
104
+ return parsed;
105
+ }
106
+
107
+ /** Build the one typed-data shape shared by the gate signer and trusted parent verifier. */
108
+ export function spendAuthorisationTypedData(
109
+ fields: SpendAuthorisationFields,
110
+ chainId: number,
111
+ verifyingContract: string,
112
+ ) {
113
+ if (!ADDRESS.test(fields.buyer)) throw new TypeError('buyer must be an EVM address');
114
+ if (!BYTES32.test(fields.poolId)) throw new TypeError('poolId must be 32 bytes');
115
+ if (!Number.isSafeInteger(chainId) || chainId <= 0) throw new RangeError('chainId must be a positive safe integer');
116
+ if (!ADDRESS.test(verifyingContract)) throw new TypeError('verifyingContract must be an EVM address');
117
+
118
+ return {
119
+ domain: {
120
+ name: 'FlaunchSpendGate',
121
+ version: '1',
122
+ chainId,
123
+ verifyingContract: verifyingContract as `0x${string}`,
124
+ },
125
+ types: SPEND_AUTHORIZATION_TYPES,
126
+ primaryType: 'SpendAuthorization' as const,
127
+ message: {
128
+ buyer: fields.buyer as `0x${string}`,
129
+ poolId: fields.poolId as `0x${string}`,
130
+ deadline: uint256(fields.deadline, 'deadline'),
131
+ maxSpendWei: uint256(fields.maxSpendWei, 'maxSpendWei'),
132
+ nonce: uint256(fields.nonce, 'nonce'),
133
+ },
134
+ };
135
+ }
136
+
137
+ export interface EmbeddedContext {
138
+ gateUrl: string;
139
+ roundId: string;
140
+ launch: LaunchContext;
141
+ }
142
+
143
+ export type EmbedRequest =
144
+ | { method: 'address' }
145
+ | { method: 'sign-in'; message: string }
146
+ | { method: 'session-evidence' }
147
+ | { method: 'buy'; authorisation: SpendAuthorisation }
148
+ | { method: 'identity'; players: readonly PlayerId[] };
149
+
150
+ export type GameToHostMessage =
151
+ | { channel: typeof EMBED_CHANNEL; v: typeof EMBED_PROTOCOL_VERSION; type: 'ready' }
152
+ | {
153
+ channel: typeof EMBED_CHANNEL;
154
+ v: typeof EMBED_PROTOCOL_VERSION;
155
+ type: 'request';
156
+ id: string;
157
+ request: EmbedRequest;
158
+ };
159
+
160
+ export type EmbedFailure = BuyFailure | 'no-wallet';
161
+
162
+ export type HostToGameMessage =
163
+ | {
164
+ channel: typeof EMBED_CHANNEL;
165
+ v: typeof EMBED_PROTOCOL_VERSION;
166
+ type: 'context';
167
+ context: EmbeddedContext;
168
+ }
169
+ | {
170
+ channel: typeof EMBED_CHANNEL;
171
+ v: typeof EMBED_PROTOCOL_VERSION;
172
+ type: 'response';
173
+ id: string;
174
+ ok: true;
175
+ value: unknown;
176
+ }
177
+ | {
178
+ channel: typeof EMBED_CHANNEL;
179
+ v: typeof EMBED_PROTOCOL_VERSION;
180
+ type: 'response';
181
+ id: string;
182
+ ok: false;
183
+ error: EmbedFailure;
184
+ }
185
+ | {
186
+ channel: typeof EMBED_CHANNEL;
187
+ v: typeof EMBED_PROTOCOL_VERSION;
188
+ type: 'buy-progress';
189
+ id: string;
190
+ transactionHash?: string;
191
+ }
192
+ | {
193
+ channel: typeof EMBED_CHANNEL;
194
+ v: typeof EMBED_PROTOCOL_VERSION;
195
+ type: 'market';
196
+ market: WireMarketState;
197
+ };
198
+
199
+ function record(input: unknown): Record<string, unknown> | null {
200
+ return typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : null;
201
+ }
202
+
203
+ function bounded(value: unknown, max: number): value is string {
204
+ return typeof value === 'string' && value.length > 0 && value.length <= max;
205
+ }
206
+
207
+ function finite(value: unknown): value is number {
208
+ return typeof value === 'number' && Number.isFinite(value);
209
+ }
210
+
211
+ function isWireMarketState(input: unknown): input is WireMarketState {
212
+ const value = record(input);
213
+ if (
214
+ !value ||
215
+ typeof value.status !== 'string' ||
216
+ !['unavailable', 'loading', 'live', 'stale'].includes(value.status) ||
217
+ !Array.isArray(value.prices) ||
218
+ value.prices.length > 1_000 ||
219
+ !Array.isArray(value.trades) ||
220
+ value.trades.length > 500 ||
221
+ (value.marketCapUsd !== null && !finite(value.marketCapUsd))
222
+ ) {
223
+ return false;
224
+ }
225
+
226
+ for (const inputPrice of value.prices) {
227
+ const price = record(inputPrice);
228
+ if (!price || !finite(price.at) || !finite(price.priceEth) || price.priceEth <= 0) return false;
229
+ }
230
+
231
+ for (const inputTrade of value.trades) {
232
+ const trade = record(inputTrade);
233
+ if (
234
+ !trade ||
235
+ !bounded(trade.id, 128) ||
236
+ !finite(trade.at) ||
237
+ (trade.player !== null && !bounded(trade.player, 128)) ||
238
+ (trade.side !== 'buy' && trade.side !== 'sell') ||
239
+ typeof trade.spendWei !== 'string' ||
240
+ !DECIMAL.test(trade.spendWei) ||
241
+ (trade.priceEth !== null && !finite(trade.priceEth)) ||
242
+ (trade.transactionHash !== undefined && !bounded(trade.transactionHash, 128))
243
+ ) {
244
+ return false;
245
+ }
246
+ }
247
+
248
+ return true;
249
+ }
250
+
251
+ function embeddedContext(input: unknown): input is EmbeddedContext {
252
+ const value = record(input);
253
+ if (!value || !bounded(value.gateUrl, 2_048) || !bounded(value.roundId, 128) || !isLaunchContext(value.launch)) {
254
+ return false;
255
+ }
256
+ if (value.roundId !== value.launch.roundId) return false;
257
+ try {
258
+ const gate = new URL(value.gateUrl);
259
+ return (gate.protocol === 'https:' || gate.protocol === 'http:') && gate.origin === value.gateUrl;
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ export function isSpendAuthorisation(input: unknown): input is SpendAuthorisation {
266
+ const value = record(input);
267
+ return Boolean(
268
+ value &&
269
+ typeof value.buyer === 'string' &&
270
+ ADDRESS.test(value.buyer) &&
271
+ typeof value.poolId === 'string' &&
272
+ BYTES32.test(value.poolId) &&
273
+ typeof value.deadline === 'string' &&
274
+ DECIMAL.test(value.deadline) &&
275
+ typeof value.maxSpendWei === 'string' &&
276
+ DECIMAL.test(value.maxSpendWei) &&
277
+ typeof value.nonce === 'string' &&
278
+ DECIMAL.test(value.nonce) &&
279
+ typeof value.signature === 'string' &&
280
+ HEX.test(value.signature) &&
281
+ value.signature.length <= 512 &&
282
+ typeof value.signer === 'string' &&
283
+ ADDRESS.test(value.signer) &&
284
+ typeof value.hookData === 'string' &&
285
+ HEX.test(value.hookData) &&
286
+ value.hookData.length <= 8_194,
287
+ );
288
+ }
289
+
290
+ const uint256Word = (value: string): string | null => {
291
+ try {
292
+ const parsed = BigInt(value);
293
+ return parsed < 1n << 256n ? parsed.toString(16).padStart(64, '0') : null;
294
+ } catch {
295
+ return null;
296
+ }
297
+ };
298
+
299
+ /**
300
+ * Require the exact ABI payload the gate emits, including its zero referrer.
301
+ *
302
+ * The calculator signature covers the spend fields but not the outer referrer. A creator iframe
303
+ * can therefore redirect attribution while preserving a valid signature unless the trusted
304
+ * bridge checks the complete canonical encoding before opening wallet UI.
305
+ */
306
+ export function isCanonicalSpendHookData(input: unknown): input is SpendAuthorisation {
307
+ if (!isSpendAuthorisation(input)) return false;
308
+ const deadline = uint256Word(input.deadline);
309
+ const maxSpendWei = uint256Word(input.maxSpendWei);
310
+ const nonce = uint256Word(input.nonce);
311
+ if (!deadline || !maxSpendWei || !nonce) return false;
312
+
313
+ const signature = input.signature.slice(2).toLowerCase();
314
+ const signatureLength = uint256Word(String(signature.length / 2));
315
+ if (!signatureLength) return false;
316
+ const paddedSignature = signature.padEnd(Math.ceil(signature.length / 64) * 64, '0');
317
+ const expected = `0x${[
318
+ '0'.repeat(64),
319
+ uint256Word('64'),
320
+ input.buyer.slice(2).toLowerCase().padStart(64, '0'),
321
+ input.poolId.slice(2).toLowerCase(),
322
+ deadline,
323
+ maxSpendWei,
324
+ nonce,
325
+ uint256Word('192'),
326
+ signatureLength,
327
+ paddedSignature,
328
+ ].join('')}`;
329
+ return input.hookData.toLowerCase() === expected;
330
+ }
331
+
332
+ export function parseGameToHostMessage(input: unknown): GameToHostMessage | null {
333
+ const value = record(input);
334
+ if (value?.channel !== EMBED_CHANNEL || value.v !== EMBED_PROTOCOL_VERSION) return null;
335
+ if (value.type === 'ready') return { channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'ready' };
336
+ if (value.type !== 'request' || typeof value.id !== 'string' || !REQUEST_ID.test(value.id)) return null;
337
+ const request = record(value.request);
338
+ if (!request) return null;
339
+
340
+ let parsed: EmbedRequest | null = null;
341
+ if (request.method === 'address') parsed = { method: 'address' };
342
+ if (request.method === 'session-evidence') parsed = { method: 'session-evidence' };
343
+ if (request.method === 'sign-in' && bounded(request.message, 4_096)) {
344
+ parsed = { method: 'sign-in', message: request.message };
345
+ }
346
+ if (request.method === 'buy' && isSpendAuthorisation(request.authorisation)) {
347
+ const authorisation = request.authorisation;
348
+ parsed = {
349
+ method: 'buy',
350
+ authorisation: {
351
+ buyer: authorisation.buyer,
352
+ poolId: authorisation.poolId,
353
+ deadline: authorisation.deadline,
354
+ maxSpendWei: authorisation.maxSpendWei,
355
+ nonce: authorisation.nonce,
356
+ signature: authorisation.signature,
357
+ signer: authorisation.signer,
358
+ hookData: authorisation.hookData,
359
+ },
360
+ };
361
+ }
362
+ if (
363
+ request.method === 'identity' &&
364
+ Array.isArray(request.players) &&
365
+ request.players.length <= 100 &&
366
+ request.players.every((player) => typeof player === 'string' && ADDRESS.test(player))
367
+ ) {
368
+ parsed = { method: 'identity', players: [...new Set(request.players)] as PlayerId[] };
369
+ }
370
+ return parsed ? { channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'request', id: value.id, request: parsed } : null;
371
+ }
372
+
373
+ export function parseHostToGameMessage(input: unknown): HostToGameMessage | null {
374
+ const value = record(input);
375
+ if (value?.channel !== EMBED_CHANNEL || value.v !== EMBED_PROTOCOL_VERSION) return null;
376
+ if (value.type === 'context' && embeddedContext(value.context)) {
377
+ return { channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'context', context: value.context };
378
+ }
379
+ if (value.type === 'market' && isWireMarketState(value.market)) return value as unknown as HostToGameMessage;
380
+ if (typeof value.id !== 'string' || !REQUEST_ID.test(value.id)) return null;
381
+ if (value.type === 'buy-progress') {
382
+ if (value.transactionHash !== undefined && (typeof value.transactionHash !== 'string' || !BYTES32.test(value.transactionHash))) {
383
+ return null;
384
+ }
385
+ return value as unknown as HostToGameMessage;
386
+ }
387
+ if (value.type !== 'response' || typeof value.ok !== 'boolean') return null;
388
+ if (value.ok) return value as unknown as HostToGameMessage;
389
+ return ['nothing-to-spend', 'declined', 'not-enough-for-fees', 'window-closed', 'no-wallet', 'try-again'].includes(
390
+ String(value.error),
391
+ )
392
+ ? (value as unknown as HostToGameMessage)
393
+ : null;
394
+ }
395
+
396
+ /** Reject an iframe trying to turn the trusted parent into a generic signing oracle. */
397
+ export function isSessionChallenge(
398
+ message: string,
399
+ address: string,
400
+ gateOrigin: string,
401
+ domain = 'flaunch.gg',
402
+ ): boolean {
403
+ if (!ADDRESS.test(address) || message.length > 4_096) return false;
404
+ try {
405
+ const gate = new URL(gateOrigin);
406
+ if ((gate.protocol !== 'https:' && gate.protocol !== 'http:') || gate.origin !== gateOrigin) return false;
407
+ } catch {
408
+ return false;
409
+ }
410
+ const lines = message.split('\n');
411
+ if (lines.length !== 7 || !lines[4]?.startsWith('Wallet: ') || !lines[6]?.startsWith('Code: ')) return false;
412
+ const wallet = lines[4].slice('Wallet: '.length);
413
+ const code = lines[6].slice('Code: '.length);
414
+ return (
415
+ wallet.toLowerCase() === address.toLowerCase() &&
416
+ code.length > 0 &&
417
+ code.length <= 2_048 &&
418
+ message === formatSessionChallenge(domain, wallet, gateOrigin, code)
419
+ );
420
+ }
421
+
422
+ /** Canonical wallet challenge shared by the gate and the trusted embedding parent. */
423
+ export function formatSessionChallenge(domain: string, address: string, gateOrigin: string, code: string): string {
424
+ return [
425
+ `${domain} wants you to sign in with your wallet.`,
426
+ '',
427
+ 'Signing proves this wallet is yours. It costs nothing and moves nothing.',
428
+ '',
429
+ `Wallet: ${address}`,
430
+ `Gate: ${gateOrigin}`,
431
+ `Code: ${code}`,
432
+ ].join('\n');
433
+ }
434
+
435
+ export function isPlayerIdentities(input: unknown): input is readonly PlayerIdentity[] {
436
+ return (
437
+ Array.isArray(input) &&
438
+ input.length <= 100 &&
439
+ input.every((identity) => {
440
+ const value = record(identity);
441
+ return Boolean(
442
+ value &&
443
+ typeof value.player === 'string' &&
444
+ ADDRESS.test(value.player) &&
445
+ (value.displayName === null || (typeof value.displayName === 'string' && value.displayName.length <= 256)) &&
446
+ (value.avatarUrl === null || (typeof value.avatarUrl === 'string' && value.avatarUrl.length <= 2_048)),
447
+ );
448
+ })
449
+ );
450
+ }
package/src/index.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The contract between a game and the platform.
3
+ *
4
+ * Nothing in this file knows what any game is about. If a concept here can be named after a
5
+ * particular game — a score curve, a question, a projectile — it is in the wrong file.
6
+ *
7
+ * The whole platform rests on one property: a game's rules are a PURE FUNCTION of state and a
8
+ * command. Everything else — durability, replay, reconnect, anti-cheat forensics, running the
9
+ * same rules in a browser and on a server — is a consequence of that, not an addition to it.
10
+ */
11
+
12
+ /** A player, opaque to the platform. In practice a lowercased wallet address. */
13
+ export type PlayerId = string;
14
+
15
+ /** When a round's window opens and closes. Absolute epoch milliseconds. */
16
+ export interface RoundWindow {
17
+ opensAt: number;
18
+ closesAt: number;
19
+ }
20
+
21
+ /**
22
+ * What the platform asks a game to decide on.
23
+ *
24
+ * Every command carries `at` — the authoritative time it happened. A game reads time from here
25
+ * and nowhere else. There is no clock in scope and no ambient randomness: `seed` is supplied so
26
+ * anything a game wants to randomise is derived, and therefore reproducible.
27
+ */
28
+ export type Command<Action> =
29
+ | { kind: 'join'; player: PlayerId; seed: number; at: number }
30
+ | { kind: 'leave'; player: PlayerId; at: number }
31
+ | { kind: 'action'; player: PlayerId; action: Action; at: number }
32
+ /** The platform woke the round at a time the game asked for via {@link GameModule.nextWakeAt}. */
33
+ | { kind: 'wake'; at: number };
34
+
35
+ /** Points awarded to a player. The platform converts these to spending allowance; it does not
36
+ * know or care how they were earned. */
37
+ export interface Award {
38
+ player: PlayerId;
39
+ points: number;
40
+ }
41
+
42
+ /**
43
+ * What a game decides in response to a command.
44
+ *
45
+ * Awards are separate from events on purpose. A game may accept an action now and pay for it
46
+ * later — a quiz must, or a player learns they answered correctly by watching their own balance
47
+ * before the reveal, and the reveal stops meaning anything.
48
+ */
49
+ export interface Decision<Event> {
50
+ events: readonly Event[];
51
+ awards?: readonly Award[];
52
+ }
53
+
54
+ /**
55
+ * A refused command. `code` is stable and machine-readable — alerting and months of history key
56
+ * on it, so it is never renamed, only added to. Player-facing copy lives in the client.
57
+ *
58
+ * Never put a threshold in a code. A refusal a cheater can calibrate against is an oracle.
59
+ */
60
+ export interface Refusal {
61
+ refuse: string;
62
+ }
63
+
64
+ export function isRefusal<E>(result: Decision<E> | Refusal): result is Refusal {
65
+ return 'refuse' in result;
66
+ }
67
+
68
+ /** The most a single player can earn in one round. The platform checks this against the pool's
69
+ * on-chain per-wallet cap before a round is allowed to start. */
70
+ export interface RewardBounds {
71
+ maxPointsPerPlayer: number;
72
+ }
73
+
74
+ /**
75
+ * A game.
76
+ *
77
+ * `decide` and `evolve` must be pure: same inputs, same outputs, forever. No clock, no
78
+ * randomness, no I/O, no module-level mutable state. This is enforced by lint rather than by
79
+ * review, because it is the property everything else depends on.
80
+ */
81
+ export interface GameModule<Config, State, Event, Action, PublicView, PlayerView> {
82
+ /** Stable identifier, unique across games. */
83
+ readonly id: string;
84
+
85
+ /** Reject anything that is not a well-formed action, including anything oversized. Returning
86
+ * null refuses the command before it reaches {@link decide}. */
87
+ parseAction(input: unknown): Action | null;
88
+
89
+ /** Build the starting state. Deterministic in `seed`. */
90
+ initRound(config: Config, seed: number, window: RoundWindow): State;
91
+
92
+ /** The trust boundary. Pure. The only thing that may award points. */
93
+ decide(state: State, command: Command<Action>): Decision<Event> | Refusal;
94
+
95
+ /** Apply one event. Pure. */
96
+ evolve(state: State, event: Event): State;
97
+
98
+ /**
99
+ * What everyone sees, including spectators and anyone reading the socket.
100
+ *
101
+ * A value that must stay hidden must not appear in this type. Do not return it and filter
102
+ * downstream — the type is the enforcement, which is what makes a leak unrenderable rather
103
+ * than merely unlikely.
104
+ */
105
+ publicView(state: State): PublicView;
106
+
107
+ /** What one player sees. Anything private belongs here. */
108
+ playerView(state: State, player: PlayerId): PlayerView;
109
+
110
+ /**
111
+ * When the game next needs waking, or null if it is waiting only on players. Absolute epoch
112
+ * milliseconds.
113
+ *
114
+ * Each wake must ask for a time strictly LATER than the one being served. A phase that ends at
115
+ * the instant it begins has no duration, and asking to be woken at the time it already is would
116
+ * spin forever. If two things must happen back to back, they are two phases with real durations —
117
+ * which is usually what the product wanted anyway.
118
+ */
119
+ nextWakeAt(state: State): number | null;
120
+
121
+ /** Declared up front so the platform can refuse an incoherent economy before anyone plays. */
122
+ rewardBounds(config: Config): RewardBounds;
123
+ }
124
+
125
+ /**
126
+ * Authoring entry point. Exists for type inference — writing a `GameModule` literal directly
127
+ * means naming six type arguments by hand.
128
+ */
129
+ export function defineGame<Config, State, Event, Action, PublicView, PlayerView>(
130
+ game: GameModule<Config, State, Event, Action, PublicView, PlayerView>,
131
+ ): GameModule<Config, State, Event, Action, PublicView, PlayerView> {
132
+ return game;
133
+ }