@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/room.ts ADDED
@@ -0,0 +1,297 @@
1
+ import type { Pool } from 'pg';
2
+ import { Round } from '@flayerlabs/gamemode-spec/round';
3
+ import type { Award, Command, Decision, GameModule, PlayerId, Refusal, RoundWindow } from '@flayerlabs/gamemode-spec';
4
+ import type { LaunchContext } from '@flayerlabs/gamemode-spec/live';
5
+ import { isRefusal } from '@flayerlabs/gamemode-spec';
6
+ import { inTransaction, toBigInt } from './store.js';
7
+
8
+ /**
9
+ * Everything except a wake.
10
+ *
11
+ * Wakes belong to {@link Room.tick}. Passing one to {@link Room.apply} would fire the same deadline
12
+ * twice — `send` runs due wakes on the way to the command — collapsing whatever phase sat between
13
+ * them. Excluding it from the type is better than checking for it: the mistake stops compiling.
14
+ */
15
+ type PlayerCommand<Action> = Exclude<Command<Action>, { kind: 'wake' }>;
16
+
17
+ /**
18
+ * A round, made durable.
19
+ *
20
+ * One command at a time, and each one commits in a single transaction: the command itself, the
21
+ * resulting state, and every point it awarded. Nothing can be half-applied, so a process that dies
22
+ * mid-command leaves a round that either took it or did not.
23
+ *
24
+ * Rebuilding is a snapshot read, not a replay. The command log exists to answer "why does this
25
+ * state look like this" during an incident, which is a different job from reconstructing it.
26
+ */
27
+ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
28
+ /**
29
+ * HTTP actions and the server ticker can arrive together. Keep the one-writer promise true
30
+ * inside a process by running both through the same serial tail.
31
+ */
32
+ private pending: Promise<void> = Promise.resolve();
33
+
34
+ private constructor(
35
+ private readonly pool: Pool,
36
+ private readonly game: GameModule<Config, State, Event, Action, PublicView, PlayerView>,
37
+ readonly id: string,
38
+ private round: Round<Config, State, Event, Action, PublicView, PlayerView>,
39
+ readonly window: RoundWindow,
40
+ readonly launch: LaunchContext,
41
+ readonly weiPerPoint: bigint,
42
+ private seq: number,
43
+ ) {}
44
+
45
+ static async create<Config, State, Event, Action, PublicView, PlayerView>(
46
+ pool: Pool,
47
+ game: GameModule<Config, State, Event, Action, PublicView, PlayerView>,
48
+ round: {
49
+ id: string;
50
+ config: Config;
51
+ seed: number;
52
+ window: RoundWindow;
53
+ weiPerPoint: bigint;
54
+ walletCapWei: bigint;
55
+ poolId: string;
56
+ booksCloseAt?: number;
57
+ launch?: {
58
+ coinAddress?: string | null;
59
+ name?: string;
60
+ symbol?: string;
61
+ imageUrl?: string | null;
62
+ };
63
+ },
64
+ ): Promise<Room<Config, State, Event, Action, PublicView, PlayerView>> {
65
+ const booksCloseAt = round.booksCloseAt ?? round.window.closesAt;
66
+ if (!Number.isSafeInteger(booksCloseAt) || booksCloseAt < round.window.closesAt) {
67
+ throw new RangeError('booksCloseAt must be a safe timestamp at or after closesAt');
68
+ }
69
+ const launch = Object.freeze({
70
+ roundId: round.id,
71
+ poolId: round.poolId,
72
+ coinAddress: round.launch?.coinAddress ?? null,
73
+ name: round.launch?.name ?? '',
74
+ symbol: round.launch?.symbol ?? '',
75
+ imageUrl: round.launch?.imageUrl ?? null,
76
+ opensAt: round.window.opensAt,
77
+ closesAt: round.window.closesAt,
78
+ booksCloseAt,
79
+ });
80
+ const started = Round.start(game, round.config, round.seed, round.window);
81
+ await pool.query(
82
+ `insert into rounds (
83
+ id, wei_per_point, wallet_cap_wei, pool_id, opens_at, closes_at, books_close_at,
84
+ coin_address, coin_name, coin_symbol, coin_image_url, state, seq
85
+ ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 0)`,
86
+ [
87
+ round.id,
88
+ round.weiPerPoint.toString(),
89
+ round.walletCapWei.toString(),
90
+ round.poolId,
91
+ round.window.opensAt,
92
+ round.window.closesAt,
93
+ booksCloseAt,
94
+ launch.coinAddress,
95
+ launch.name,
96
+ launch.symbol,
97
+ launch.imageUrl,
98
+ serialise(game.id, started.snapshot()),
99
+ ],
100
+ );
101
+ return new Room(pool, game, round.id, started, round.window, launch, round.weiPerPoint, 0);
102
+ }
103
+
104
+ /** Pick up a round that already exists. Returns null if there is no such round. */
105
+ static async load<Config, State, Event, Action, PublicView, PlayerView>(
106
+ pool: Pool,
107
+ game: GameModule<Config, State, Event, Action, PublicView, PlayerView>,
108
+ id: string,
109
+ ): Promise<Room<Config, State, Event, Action, PublicView, PlayerView> | null> {
110
+ const result = await pool.query<{
111
+ state: State;
112
+ seq: number;
113
+ pool_id: string;
114
+ opens_at: string;
115
+ closes_at: string;
116
+ books_close_at: string;
117
+ coin_address: string | null;
118
+ coin_name: string;
119
+ coin_symbol: string;
120
+ coin_image_url: string | null;
121
+ wei_per_point: string;
122
+ }>(
123
+ `select state, seq, pool_id, opens_at, closes_at, books_close_at, wei_per_point,
124
+ coin_address, coin_name, coin_symbol, coin_image_url
125
+ from rounds where id = $1`,
126
+ [id],
127
+ );
128
+ const row = result.rows[0];
129
+ if (!row) return null;
130
+
131
+ const window = { opensAt: Number(row.opens_at), closesAt: Number(row.closes_at) };
132
+ const launch = Object.freeze({
133
+ roundId: id,
134
+ poolId: row.pool_id,
135
+ coinAddress: row.coin_address,
136
+ name: row.coin_name,
137
+ symbol: row.coin_symbol,
138
+ imageUrl: row.coin_image_url,
139
+ ...window,
140
+ booksCloseAt: Number(row.books_close_at),
141
+ });
142
+ return new Room(
143
+ pool,
144
+ game,
145
+ id,
146
+ Round.resume(game, row.state, window),
147
+ window,
148
+ launch,
149
+ toBigInt(row.wei_per_point),
150
+ row.seq,
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Apply one command and commit everything it caused.
156
+ *
157
+ * A refusal is not persisted. It changed nothing, and writing a row for every rejected action
158
+ * would let anyone with a keyboard grow the database.
159
+ */
160
+ async apply(command: PlayerCommand<Action>): Promise<Decision<Event> | Refusal> {
161
+ return this.serially(() => this.applyNow(command));
162
+ }
163
+
164
+ private async applyNow(command: PlayerCommand<Action>): Promise<Decision<Event> | Refusal> {
165
+ const awards: Award[] = [];
166
+ const round = this.working((award) => awards.push(award));
167
+
168
+ // Catch up first, and keep whatever that produced even if the command itself is refused. A
169
+ // reveal that fired on the way to a rejected action really happened: discarding it would
170
+ // silently un-reveal the round and delay everyone's points until something else arrived.
171
+ const caughtUp = round.advanceTo(command.at) > 0;
172
+
173
+ const result = round.send(command);
174
+ const refused = isRefusal(result);
175
+ if (refused && !caughtUp) return result;
176
+
177
+ const seq = this.seq + 1;
178
+ await this.commit(seq, refused ? { kind: 'wake', at: command.at } : command, round.snapshot(), awards);
179
+ this.round = round;
180
+ this.seq = seq;
181
+ return result;
182
+ }
183
+
184
+ /**
185
+ * A round over a COPY of the committed state.
186
+ *
187
+ * `evolve` is supposed to be pure, and a copy is what makes that a rule rather than a hope: a
188
+ * game that mutates its state in place would otherwise corrupt the live round even when the
189
+ * transaction that was meant to save it failed.
190
+ */
191
+ private working(onAward: (award: Award) => void): Round<Config, State, Event, Action, PublicView, PlayerView> {
192
+ return Round.resume(this.game, structuredClone(this.round.snapshot()), this.window, onAward);
193
+ }
194
+
195
+ /**
196
+ * Write one step: the command, the state it produced, and the points it awarded.
197
+ *
198
+ * The update is conditional on the sequence we read, so a second writer for the same round —
199
+ * another process, or a second Room in this one — loses instead of silently overwriting. There
200
+ * is meant to be one writer per round; this is what makes a broken assumption loud.
201
+ */
202
+ private async commit(seq: number, command: Command<Action>, state: State, awards: Award[]): Promise<void> {
203
+ await inTransaction(this.pool, async (tx) => {
204
+ const updated = await tx.query(
205
+ `update rounds set state = $2, seq = $3 where id = $1 and seq = $4`,
206
+ [this.id, serialise(this.game.id, state), seq, seq - 1],
207
+ );
208
+ if (updated.rowCount !== 1) {
209
+ throw new Error(`${this.id}: round moved on while this command was being applied`);
210
+ }
211
+ await tx.query(`insert into commands (round_id, seq, command) values ($1, $2, $3)`, [
212
+ this.id,
213
+ seq,
214
+ JSON.stringify(command),
215
+ ]);
216
+ for (const award of awards) {
217
+ // Same transaction as the state that earned them. Points credited without the state that
218
+ // justifies them, or the reverse, is a ledger nobody can reconcile.
219
+ await tx.query(
220
+ `insert into balances (round_id, player, points) values ($1, $2, $3)
221
+ on conflict (round_id, player) do update set points = balances.points + excluded.points`,
222
+ [this.id, award.player, Math.floor(award.points)],
223
+ );
224
+ }
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Catch the round up to `now`, firing every wake it has missed.
230
+ *
231
+ * This lets the round advance itself rather than sending it a wake command. Sending one would
232
+ * fire the same deadline twice — `send` runs due wakes before applying anything — which skips
233
+ * whatever phase sat between them and reveals a quiz answer early.
234
+ */
235
+ async tick(now: number): Promise<void> {
236
+ return this.serially(() => this.tickNow(now));
237
+ }
238
+
239
+ private async tickNow(now: number): Promise<void> {
240
+ const awards: Award[] = [];
241
+ const round = this.working((award) => awards.push(award));
242
+ if (round.advanceTo(now) === 0) return;
243
+
244
+ const seq = this.seq + 1;
245
+ await this.commit(seq, { kind: 'wake', at: now }, round.snapshot(), awards);
246
+ this.round = round;
247
+ this.seq = seq;
248
+ }
249
+
250
+ private serially<T>(work: () => Promise<T>): Promise<T> {
251
+ const result = this.pending.then(work, work);
252
+ this.pending = result.then(
253
+ () => undefined,
254
+ () => undefined,
255
+ );
256
+ return result;
257
+ }
258
+
259
+ nextWakeAt(): number | null {
260
+ return this.game.nextWakeAt(this.round.snapshot());
261
+ }
262
+
263
+ publicView(): PublicView {
264
+ return this.round.publicView();
265
+ }
266
+
267
+ playerView(player: PlayerId): PlayerView {
268
+ return this.round.playerView(player);
269
+ }
270
+
271
+ /** The cursor a reconnecting client resumes from. */
272
+ sequence(): number {
273
+ return this.seq;
274
+ }
275
+
276
+ }
277
+
278
+ /**
279
+ * Game state has to survive a round trip through JSON, and one thing silently does not: `bigint`
280
+ * makes `JSON.stringify` throw. It is an easy mistake — a game holding wei or a large counter — and
281
+ * the default message says only "Do not know how to serialize a BigInt", naming neither the game
282
+ * nor the field. Saying which game, and what to do instead, turns a confusing crash into an
283
+ * instruction.
284
+ */
285
+ function serialise(gameId: string, state: unknown): string {
286
+ try {
287
+ return JSON.stringify(state);
288
+ } catch (error) {
289
+ if (error instanceof TypeError && /BigInt/i.test(error.message)) {
290
+ throw new TypeError(
291
+ `${gameId}: game state contains a bigint, which cannot be stored. Keep bigints out of state — ` +
292
+ 'points are plain numbers and the platform owns anything measured in wei.',
293
+ );
294
+ }
295
+ throw error;
296
+ }
297
+ }