@flayerlabs/gamemode-gate 0.1.0 → 0.2.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 (48) hide show
  1. package/README.md +48 -2
  2. package/dist/bearer.d.ts +3 -0
  3. package/dist/bearer.d.ts.map +1 -0
  4. package/dist/bearer.js +8 -0
  5. package/dist/bearer.js.map +1 -0
  6. package/dist/demo.d.ts.map +1 -1
  7. package/dist/demo.js +4 -1
  8. package/dist/demo.js.map +1 -1
  9. package/dist/economy.js +1 -1
  10. package/dist/economy.js.map +1 -1
  11. package/dist/game-registry.d.ts.map +1 -1
  12. package/dist/game-registry.js +2 -7
  13. package/dist/game-registry.js.map +1 -1
  14. package/dist/game-server-awards.d.ts +39 -0
  15. package/dist/game-server-awards.d.ts.map +1 -0
  16. package/dist/game-server-awards.js +123 -0
  17. package/dist/game-server-awards.js.map +1 -0
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/ledger.d.ts +0 -2
  23. package/dist/ledger.d.ts.map +1 -1
  24. package/dist/ledger.js +0 -7
  25. package/dist/ledger.js.map +1 -1
  26. package/dist/room.d.ts +5 -0
  27. package/dist/room.d.ts.map +1 -1
  28. package/dist/room.js +32 -7
  29. package/dist/room.js.map +1 -1
  30. package/dist/server.d.ts +14 -3
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +77 -0
  33. package/dist/server.js.map +1 -1
  34. package/dist/store.d.ts +1 -1
  35. package/dist/store.d.ts.map +1 -1
  36. package/dist/store.js +39 -0
  37. package/dist/store.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/bearer.ts +8 -0
  40. package/src/demo.ts +4 -1
  41. package/src/economy.ts +1 -1
  42. package/src/game-registry.ts +2 -8
  43. package/src/game-server-awards.ts +199 -0
  44. package/src/index.ts +8 -0
  45. package/src/ledger.ts +0 -10
  46. package/src/room.ts +47 -6
  47. package/src/server.ts +132 -11
  48. package/src/store.ts +39 -0
package/src/economy.ts CHANGED
@@ -53,7 +53,7 @@ export function resolveEconomy(input: EconomyInput): EconomyResult {
53
53
  if (!Number.isFinite(usdPerEth) || usdPerEth <= 0) {
54
54
  return { ok: false, problem: 'the ETH price must be a positive number', suggestedPointsPerDollar: pointsPerDollar };
55
55
  }
56
- if (!Number.isFinite(maxPointsPerPlayer) || maxPointsPerPlayer <= 0) {
56
+ if (!Number.isSafeInteger(maxPointsPerPlayer) || maxPointsPerPlayer <= 0) {
57
57
  return {
58
58
  ok: false,
59
59
  problem: 'the game says a player can earn at most 0 points, so there is nothing to pay',
@@ -1,8 +1,8 @@
1
1
  import Fastify, { type FastifyInstance } from 'fastify';
2
2
  import cors from '@fastify/cors';
3
- import { createHash, timingSafeEqual } from 'node:crypto';
4
3
  import type { Pool } from 'pg';
5
4
  import { isAddress } from 'viem';
5
+ import { hasBearerToken } from './bearer.js';
6
6
  import {
7
7
  InvalidRegistration,
8
8
  RegistrationConflict,
@@ -19,12 +19,6 @@ export interface GameRegistryOptions {
19
19
  writerToken: string;
20
20
  }
21
21
 
22
- const authorised = (header: string | undefined, token: string): boolean => {
23
- const supplied = header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
24
- const digest = (value: string): Buffer => createHash('sha256').update(value).digest();
25
- return timingSafeEqual(digest(supplied), digest(token));
26
- };
27
-
28
22
  const routeChainId = (value: string): number | null => {
29
23
  if (!/^[1-9][0-9]*$/.test(value)) return null;
30
24
  const parsed = Number(value);
@@ -35,7 +29,7 @@ const internalRoute = (
35
29
  request: { headers: { authorization?: string | undefined }; params: unknown },
36
30
  token: string,
37
31
  ) => {
38
- if (!authorised(request.headers.authorization, token)) {
32
+ if (!hasBearerToken(request.headers.authorization, token)) {
39
33
  return { ok: false as const, status: 401 as const, message: 'registry writer authentication failed' };
40
34
  }
41
35
  const { chainId: rawChainId, coin } = request.params as { chainId: string; coin: string };
@@ -0,0 +1,199 @@
1
+ import type { Pool } from 'pg';
2
+ import { isAddress } from 'viem';
3
+ import type { PlayerId } from '@flayerlabs/gamemode-spec';
4
+ import { inTransaction } from './store.js';
5
+
6
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
7
+
8
+ /** The JSON body a trusted creator server sends to the gate. */
9
+ export interface GameServerAwardRequest {
10
+ eventId: string;
11
+ player: PlayerId;
12
+ points: number;
13
+ }
14
+
15
+ /** The durable receipt returned for a new award or an identical retry. */
16
+ export interface GameServerAwardReceipt extends GameServerAwardRequest {
17
+ roundId: string;
18
+ totalPoints: number;
19
+ }
20
+
21
+ export type GameServerAwardRefusal =
22
+ | 'award.invalid'
23
+ | 'award.no_such_round'
24
+ | 'award.wrong_authority'
25
+ | 'award.not_open_yet'
26
+ | 'award.window_closed'
27
+ | 'award.event_mismatch'
28
+ | 'award.limit_exceeded';
29
+
30
+ export type GameServerAwardResult =
31
+ | { ok: true; created: boolean; award: GameServerAwardReceipt }
32
+ | { ok: false; refuse: GameServerAwardRefusal };
33
+
34
+ interface GameServerAwardInput extends GameServerAwardRequest {
35
+ roundId: string;
36
+ }
37
+
38
+ interface StoredAward {
39
+ round_id: string;
40
+ event_id: string;
41
+ player: string;
42
+ points: string;
43
+ total_points: string;
44
+ }
45
+
46
+ /** A result that must roll back any setup writes before it reaches the caller. */
47
+ class RollbackResult extends Error {
48
+ constructor(readonly result: GameServerAwardResult) {
49
+ super('game server award did not commit');
50
+ }
51
+ }
52
+
53
+ const receipt = (row: StoredAward): GameServerAwardReceipt => ({
54
+ roundId: row.round_id,
55
+ eventId: row.event_id,
56
+ player: row.player,
57
+ points: Number(row.points),
58
+ totalPoints: Number(row.total_points),
59
+ });
60
+
61
+ const matches = (stored: GameServerAwardReceipt, input: GameServerAwardInput): boolean =>
62
+ stored.roundId === input.roundId &&
63
+ stored.eventId === input.eventId &&
64
+ stored.player === input.player &&
65
+ stored.points === input.points;
66
+
67
+ const replay = (stored: GameServerAwardReceipt, input: GameServerAwardInput): GameServerAwardResult =>
68
+ matches(stored, input)
69
+ ? { ok: true, created: false, award: stored }
70
+ : { ok: false, refuse: 'award.event_mismatch' };
71
+
72
+ /**
73
+ * Credit events from one trusted creator game server.
74
+ *
75
+ * The event receipt and balance move in one transaction. A stable event id makes retries safe,
76
+ * while the adopted round fixes the wallet, time and reward ceilings this module enforces.
77
+ */
78
+ export class GameServerAwards {
79
+ constructor(
80
+ private readonly pool: Pool,
81
+ private readonly gameId: string,
82
+ ) {}
83
+
84
+ async record(candidate: GameServerAwardInput): Promise<GameServerAwardResult> {
85
+ if (
86
+ !candidate ||
87
+ typeof candidate.roundId !== 'string' ||
88
+ !IDENTIFIER.test(candidate.roundId) ||
89
+ typeof candidate.eventId !== 'string' ||
90
+ !IDENTIFIER.test(candidate.eventId) ||
91
+ typeof candidate.player !== 'string' ||
92
+ !isAddress(candidate.player, { strict: false }) ||
93
+ !Number.isSafeInteger(candidate.points) ||
94
+ candidate.points <= 0
95
+ ) {
96
+ return { ok: false, refuse: 'award.invalid' };
97
+ }
98
+
99
+ const input = { ...candidate, player: candidate.player.toLowerCase() };
100
+ const receivedAt = Date.now();
101
+
102
+ try {
103
+ return await inTransaction(this.pool, async (tx) => {
104
+ const prior = await tx.query<StoredAward>(
105
+ `select awards.round_id, awards.event_id, awards.player,
106
+ awards.points::text, awards.total_points::text
107
+ from award_events awards
108
+ join rounds on rounds.id = awards.round_id
109
+ where awards.round_id = $1 and awards.event_id = $2 and rounds.game_id = $3`,
110
+ [input.roundId, input.eventId, this.gameId],
111
+ );
112
+ if (prior.rows[0]) return replay(receipt(prior.rows[0]), input);
113
+
114
+ const round = await tx.query<{
115
+ opens_at: string;
116
+ closes_at: string;
117
+ max_points_per_player: string;
118
+ score_authority: 'rules' | 'external-server';
119
+ game_id: string;
120
+ }>(
121
+ `select opens_at, closes_at, max_points_per_player, score_authority, game_id
122
+ from rounds where id = $1`,
123
+ [input.roundId],
124
+ );
125
+ const terms = round.rows[0];
126
+ if (!terms || terms.game_id !== this.gameId) {
127
+ return { ok: false, refuse: 'award.no_such_round' } as const;
128
+ }
129
+ if (terms.score_authority !== 'external-server') {
130
+ return { ok: false, refuse: 'award.wrong_authority' } as const;
131
+ }
132
+
133
+ // Reserve the event id before checking the balance. A concurrent retry waits on this
134
+ // unique row and then reads the first request's receipt. Without that ordering, it could
135
+ // see the first request's new balance and incorrectly fail the reward ceiling check.
136
+ const inserted = await tx.query<StoredAward>(
137
+ `insert into award_events (round_id, event_id, player, points, total_points)
138
+ values ($1, $2, $3, $4, $4)
139
+ on conflict (round_id, event_id) do nothing
140
+ returning round_id, event_id, player, points::text, total_points::text`,
141
+ [input.roundId, input.eventId, input.player, input.points],
142
+ );
143
+ if (!inserted.rows[0]) {
144
+ const concurrent = await tx.query<StoredAward>(
145
+ `select round_id, event_id, player, points::text, total_points::text
146
+ from award_events where round_id = $1 and event_id = $2`,
147
+ [input.roundId, input.eventId],
148
+ );
149
+ const stored = concurrent.rows[0];
150
+ if (!stored) throw new Error(`${input.roundId}: award conflict disappeared`);
151
+ throw new RollbackResult(replay(receipt(stored), input));
152
+ }
153
+
154
+ // Establish idempotency before checking the clock. A retry may arrive after closesAt while
155
+ // the original request is still committing; it must recover that result, not lose it.
156
+ if (receivedAt < Number(terms.opens_at)) {
157
+ throw new RollbackResult({ ok: false, refuse: 'award.not_open_yet' });
158
+ }
159
+ if (receivedAt >= Number(terms.closes_at)) {
160
+ throw new RollbackResult({ ok: false, refuse: 'award.window_closed' });
161
+ }
162
+
163
+ // A claim locks the same row. Creating it before locking means a first award and first claim
164
+ // cannot both pass through a missing row without seeing each other.
165
+ await tx.query(
166
+ `insert into balances (round_id, player, points) values ($1, $2, 0)
167
+ on conflict (round_id, player) do nothing`,
168
+ [input.roundId, input.player],
169
+ );
170
+ const balance = await tx.query<{ points: string }>(
171
+ `select points::text from balances where round_id = $1 and player = $2 for update`,
172
+ [input.roundId, input.player],
173
+ );
174
+ const total = BigInt(balance.rows[0]?.points ?? '0') + BigInt(input.points);
175
+ if (total > BigInt(terms.max_points_per_player)) {
176
+ throw new RollbackResult({ ok: false, refuse: 'award.limit_exceeded' });
177
+ }
178
+
179
+ await tx.query(`update balances set points = $3 where round_id = $1 and player = $2`, [
180
+ input.roundId,
181
+ input.player,
182
+ total.toString(),
183
+ ]);
184
+ await tx.query(
185
+ `update award_events set total_points = $3 where round_id = $1 and event_id = $2`,
186
+ [input.roundId, input.eventId, total.toString()],
187
+ );
188
+ return {
189
+ ok: true,
190
+ created: true,
191
+ award: { ...input, totalPoints: Number(total) },
192
+ };
193
+ });
194
+ } catch (error) {
195
+ if (error instanceof RollbackResult) return error.result;
196
+ throw error;
197
+ }
198
+ }
199
+ }
package/src/index.ts CHANGED
@@ -5,13 +5,16 @@ export {
5
5
  Room,
6
6
  Sessions,
7
7
  createGate,
8
+ createGameServerGate,
8
9
  } from './server.js';
9
10
  export type {
10
11
  Admission,
11
12
  AdmissionRequest,
12
13
  Admit,
13
14
  ConfigForLaunch,
15
+ GameServerGateOptions,
14
16
  GateOptions,
17
+ MaxPointsForLaunch,
15
18
  VerifiedLaunch,
16
19
  } from './server.js';
17
20
  export { createTurnstileAdmit } from './turnstile.js';
@@ -43,3 +46,8 @@ export {
43
46
  export type { Registration, SecurityProfile } from './registry.js';
44
47
  export { createGameRegistry } from './game-registry.js';
45
48
  export type { GameRegistryOptions } from './game-registry.js';
49
+ export type {
50
+ GameServerAwardReceipt,
51
+ GameServerAwardRefusal,
52
+ GameServerAwardRequest,
53
+ } from './game-server-awards.js';
package/src/ledger.ts CHANGED
@@ -87,16 +87,6 @@ export class Ledger {
87
87
  await migrate(this.pool);
88
88
  }
89
89
 
90
- /** Credit points. Called once per award the game emits. */
91
- async award(roundId: string, player: PlayerId, points: number): Promise<void> {
92
- if (points <= 0) return;
93
- await this.pool.query(
94
- `insert into balances (round_id, player, points) values ($1, $2, $3)
95
- on conflict (round_id, player) do update set points = balances.points + excluded.points`,
96
- [roundId, player, Math.floor(points)],
97
- );
98
- }
99
-
100
90
  /**
101
91
  * Authorise up to `upToWei`, or refuse.
102
92
  *
package/src/room.ts CHANGED
@@ -13,6 +13,7 @@ import { inTransaction, toBigInt } from './store.js';
13
13
  * them. Excluding it from the type is better than checking for it: the mistake stops compiling.
14
14
  */
15
15
  type PlayerCommand<Action> = Exclude<Command<Action>, { kind: 'wake' }>;
16
+ export type ScoreAuthority = 'rules' | 'external-server';
16
17
 
17
18
  /**
18
19
  * A round, made durable.
@@ -39,6 +40,8 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
39
40
  readonly window: RoundWindow,
40
41
  readonly launch: LaunchContext,
41
42
  readonly weiPerPoint: bigint,
43
+ readonly maxPointsPerPlayer: number,
44
+ readonly scoreAuthority: ScoreAuthority,
42
45
  private seq: number,
43
46
  ) {}
44
47
 
@@ -52,6 +55,8 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
52
55
  window: RoundWindow;
53
56
  weiPerPoint: bigint;
54
57
  walletCapWei: bigint;
58
+ maxPointsPerPlayer: number;
59
+ scoreAuthority: ScoreAuthority;
55
60
  poolId: string;
56
61
  booksCloseAt?: number;
57
62
  launch?: {
@@ -66,6 +71,9 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
66
71
  if (!Number.isSafeInteger(booksCloseAt) || booksCloseAt < round.window.closesAt) {
67
72
  throw new RangeError('booksCloseAt must be a safe timestamp at or after closesAt');
68
73
  }
74
+ if (!Number.isSafeInteger(round.maxPointsPerPlayer) || round.maxPointsPerPlayer <= 0) {
75
+ throw new RangeError('maxPointsPerPlayer must be a positive safe integer');
76
+ }
69
77
  const launch = Object.freeze({
70
78
  roundId: round.id,
71
79
  poolId: round.poolId,
@@ -80,13 +88,17 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
80
88
  const started = Round.start(game, round.config, round.seed, round.window);
81
89
  await pool.query(
82
90
  `insert into rounds (
83
- id, wei_per_point, wallet_cap_wei, pool_id, opens_at, closes_at, books_close_at,
91
+ id, game_id, wei_per_point, wallet_cap_wei, max_points_per_player, score_authority,
92
+ pool_id, opens_at, closes_at, books_close_at,
84
93
  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)`,
94
+ ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 0)`,
86
95
  [
87
96
  round.id,
97
+ game.id,
88
98
  round.weiPerPoint.toString(),
89
99
  round.walletCapWei.toString(),
100
+ round.maxPointsPerPlayer,
101
+ round.scoreAuthority,
90
102
  round.poolId,
91
103
  round.window.opensAt,
92
104
  round.window.closesAt,
@@ -98,7 +110,18 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
98
110
  serialise(game.id, started.snapshot()),
99
111
  ],
100
112
  );
101
- return new Room(pool, game, round.id, started, round.window, launch, round.weiPerPoint, 0);
113
+ return new Room(
114
+ pool,
115
+ game,
116
+ round.id,
117
+ started,
118
+ round.window,
119
+ launch,
120
+ round.weiPerPoint,
121
+ round.maxPointsPerPlayer,
122
+ round.scoreAuthority,
123
+ 0,
124
+ );
102
125
  }
103
126
 
104
127
  /** Pick up a round that already exists. Returns null if there is no such round. */
@@ -119,14 +142,19 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
119
142
  coin_symbol: string;
120
143
  coin_image_url: string | null;
121
144
  wei_per_point: string;
145
+ max_points_per_player: string;
146
+ score_authority: ScoreAuthority;
147
+ game_id: string;
122
148
  }>(
123
149
  `select state, seq, pool_id, opens_at, closes_at, books_close_at, wei_per_point,
150
+ max_points_per_player, score_authority, game_id,
124
151
  coin_address, coin_name, coin_symbol, coin_image_url
125
152
  from rounds where id = $1`,
126
153
  [id],
127
154
  );
128
155
  const row = result.rows[0];
129
156
  if (!row) return null;
157
+ if (row.game_id !== game.id) return null;
130
158
 
131
159
  const window = { opensAt: Number(row.opens_at), closesAt: Number(row.closes_at) };
132
160
  const launch = Object.freeze({
@@ -147,6 +175,8 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
147
175
  window,
148
176
  launch,
149
177
  toBigInt(row.wei_per_point),
178
+ Number(row.max_points_per_player),
179
+ row.score_authority,
150
180
  row.seq,
151
181
  );
152
182
  }
@@ -200,6 +230,9 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
200
230
  * is meant to be one writer per round; this is what makes a broken assumption loud.
201
231
  */
202
232
  private async commit(seq: number, command: Command<Action>, state: State, awards: Award[]): Promise<void> {
233
+ if (awards.length > 0 && this.scoreAuthority !== 'rules') {
234
+ throw new Error(`${this.id}: rules cannot award points for an externally scored round`);
235
+ }
203
236
  await inTransaction(this.pool, async (tx) => {
204
237
  const updated = await tx.query(
205
238
  `update rounds set state = $2, seq = $3 where id = $1 and seq = $4`,
@@ -216,11 +249,19 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
216
249
  for (const award of awards) {
217
250
  // Same transaction as the state that earned them. Points credited without the state that
218
251
  // justifies them, or the reverse, is a ledger nobody can reconcile.
219
- await tx.query(
252
+ const points = Math.floor(award.points);
253
+ if (points > this.maxPointsPerPlayer) {
254
+ throw new Error(`${this.game.id}: awards exceeded the declared maximum for ${award.player}`);
255
+ }
256
+ const credited = await tx.query(
220
257
  `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)],
258
+ on conflict (round_id, player) do update set points = balances.points + excluded.points
259
+ where balances.points + excluded.points <= $4`,
260
+ [this.id, award.player, points, this.maxPointsPerPlayer],
223
261
  );
262
+ if (credited.rowCount !== 1) {
263
+ throw new Error(`${this.game.id}: awards exceeded the declared maximum for ${award.player}`);
264
+ }
224
265
  }
225
266
  });
226
267
  }
package/src/server.ts CHANGED
@@ -6,13 +6,15 @@ import type { Command, GameModule, PlayerId } from '@flayerlabs/gamemode-spec';
6
6
  import { isRefusal } from '@flayerlabs/gamemode-spec';
7
7
  import type { PresenceState } from '@flayerlabs/gamemode-spec/live';
8
8
  import { isReactionId, LIVE_PROTOCOL_VERSION } from '@flayerlabs/gamemode-spec/live';
9
- import { Room } from './room.js';
9
+ import { Room, type ScoreAuthority } from './room.js';
10
10
  import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
11
11
  import { NotSignedIn, Sessions } from './sessions.js';
12
12
  import type { Discovery, Launch, PoolKey } from './chain/discover.js';
13
13
  import type { Settlement } from './settlement.js';
14
14
  import { resolveEconomy } from './economy.js';
15
15
  import { encodeHookData } from './chain/signer.js';
16
+ import { hasBearerToken } from './bearer.js';
17
+ import { GameServerAwards, type GameServerAwardRequest } from './game-server-awards.js';
16
18
 
17
19
  /**
18
20
  * The gate's door.
@@ -26,9 +28,8 @@ import { encodeHookData } from './chain/signer.js';
26
28
  * that size would be guessing at the shape of a problem nobody has.
27
29
  */
28
30
 
29
- interface GateBaseOptions<Config, State, Event, Action, PublicView, PlayerView> {
31
+ interface GateBaseOptions {
30
32
  pool: Pool;
31
- game: GameModule<Config, State, Event, Action, PublicView, PlayerView>;
32
33
  sessions: Sessions;
33
34
  claims: Claims;
34
35
  /** How many points a dollar buys. The only economic number anyone sets by hand. */
@@ -65,18 +66,24 @@ interface GateBaseOptions<Config, State, Event, Action, PublicView, PlayerView>
65
66
  export type VerifiedLaunch = Readonly<Omit<Launch, 'poolKey'>> & { readonly poolKey: Readonly<PoolKey> };
66
67
  export type ConfigForLaunch<Config> = (launch: VerifiedLaunch) => Promise<Config>;
67
68
 
68
- export type GateOptions<Config, State, Event, Action, PublicView, PlayerView> = GateBaseOptions<
69
- Config,
70
- State,
71
- Event,
72
- Action,
73
- PublicView,
74
- PlayerView
75
- > & {
69
+ export type GateOptions<Config, State, Event, Action, PublicView, PlayerView> = GateBaseOptions & {
70
+ game: GameModule<Config, State, Event, Action, PublicView, PlayerView>;
76
71
  /** A static serialisable config, or a factory resolved once after each launch is verified. */
77
72
  config: Config | ConfigForLaunch<Config>;
78
73
  };
79
74
 
75
+ export type MaxPointsForLaunch = (launch: VerifiedLaunch) => Promise<number>;
76
+
77
+ /** Run a gate beside a creator-owned authoritative game server. */
78
+ export interface GameServerGateOptions extends GateBaseOptions {
79
+ /** Stable game identifier used to protect durable room state from a different game module. */
80
+ gameId: string;
81
+ /** The most one wallet can earn in a round, or a value resolved once for each verified launch. */
82
+ maxPointsPerPlayer: number | MaxPointsForLaunch;
83
+ /** Dedicated server-to-server token. Never expose it to browser game code. */
84
+ awardToken: string;
85
+ }
86
+
80
87
  /** Where an admission check is being made. Enough to tell a sign-in from a spend. */
81
88
  export interface AdmissionRequest {
82
89
  /** `session` on sign-in, `action` on gameplay, `claim` when allowance is about to be committed. */
@@ -131,8 +138,83 @@ type AdoptionResult<Config, State, Event, Action, PublicView, PlayerView> =
131
138
  | { ok: true; room: Room<Config, State, Event, Action, PublicView, PlayerView>; created: boolean }
132
139
  | { ok: false; status: 409 | 503; message: string };
133
140
 
141
+ interface AwardEndpoint {
142
+ token: string;
143
+ awards: GameServerAwards;
144
+ }
145
+
146
+ interface GameServerConfig {
147
+ maxPointsPerPlayer: number;
148
+ }
149
+
150
+ type GameServerState = Record<string, never>;
151
+ type GameServerEvent = never;
152
+ type GameServerAction = never;
153
+ type GameServerPublicView = Record<string, never>;
154
+ type GameServerPlayerView = Record<string, never>;
155
+
156
+ const GAME_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
157
+
158
+ function gameServerModule(
159
+ id: string,
160
+ ): GameModule<
161
+ GameServerConfig,
162
+ GameServerState,
163
+ GameServerEvent,
164
+ GameServerAction,
165
+ GameServerPublicView,
166
+ GameServerPlayerView
167
+ > {
168
+ return {
169
+ id,
170
+ parseAction: () => null,
171
+ initRound: () => ({}),
172
+ decide: (_state, command) => {
173
+ if (command.kind === 'action') return { refuse: 'gameplay.external' };
174
+ return { events: [] };
175
+ },
176
+ evolve: (state) => state,
177
+ publicView: () => ({}),
178
+ playerView: () => ({}),
179
+ nextWakeAt: () => null,
180
+ rewardBounds: (config) => ({ maxPointsPerPlayer: config.maxPointsPerPlayer }),
181
+ };
182
+ }
183
+
134
184
  export function createGate<Config, State, Event, Action, PublicView, PlayerView>(
135
185
  options: GateOptions<Config, State, Event, Action, PublicView, PlayerView>,
186
+ ): FastifyInstance {
187
+ return createGateRuntime(options, 'rules');
188
+ }
189
+
190
+ export function createGameServerGate(options: GameServerGateOptions): FastifyInstance {
191
+ if (typeof options.gameId !== 'string' || !GAME_ID.test(options.gameId)) {
192
+ throw new RangeError('gameId must be a valid identifier');
193
+ }
194
+ if (typeof options.awardToken !== 'string' || options.awardToken.length < 32) {
195
+ throw new RangeError('awardToken must be at least 32 characters');
196
+ }
197
+
198
+ const maximum = options.maxPointsPerPlayer;
199
+ if (typeof maximum !== 'function' && (!Number.isSafeInteger(maximum) || maximum <= 0)) {
200
+ throw new RangeError('maxPointsPerPlayer must be a positive safe integer');
201
+ }
202
+ const config: GameServerConfig | ConfigForLaunch<GameServerConfig> =
203
+ typeof maximum === 'function'
204
+ ? async (launch) => ({ maxPointsPerPlayer: await maximum(launch) })
205
+ : { maxPointsPerPlayer: maximum };
206
+ const { gameId, maxPointsPerPlayer: _maximum, awardToken, ...base } = options;
207
+ return createGateRuntime(
208
+ { ...base, game: gameServerModule(gameId), config },
209
+ 'external-server',
210
+ { token: awardToken, awards: new GameServerAwards(options.pool, gameId) },
211
+ );
212
+ }
213
+
214
+ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>(
215
+ options: GateOptions<Config, State, Event, Action, PublicView, PlayerView>,
216
+ scoreAuthority: ScoreAuthority,
217
+ awardEndpoint?: AwardEndpoint,
136
218
  ): FastifyInstance {
137
219
  const { pool, game, sessions, claims, discovery, settlement } = options;
138
220
  const admit = options.admit ?? ADMIT_ALL;
@@ -174,6 +256,10 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
174
256
  const existing = rooms.get(id);
175
257
  if (existing) return existing;
176
258
  const loaded = await Room.load(pool, game, id);
259
+ if (loaded && loaded.scoreAuthority !== scoreAuthority) {
260
+ app.log.error(`${id}: round belongs to ${loaded.scoreAuthority}, not ${scoreAuthority}`);
261
+ return null;
262
+ }
177
263
  // A durable row is history after its books close, not permission to resurrect an active
178
264
  // ticker and market stream every time somebody refreshes an old coin page. It still gets one
179
265
  // bounded catch-up through closesAt: a restart spanning the whole tail must not strand final
@@ -246,6 +332,8 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
246
332
  window: { opensAt: launch.opensAt, closesAt: launch.closesAt },
247
333
  weiPerPoint: economy.economy.weiPerPoint,
248
334
  walletCapWei: launch.walletCapWei,
335
+ maxPointsPerPlayer,
336
+ scoreAuthority,
249
337
  poolId: launch.poolId,
250
338
  booksCloseAt: launch.closesAt + settlementTailMs,
251
339
  launch: {
@@ -381,6 +469,33 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
381
469
  .send(adopted.room.launch);
382
470
  });
383
471
 
472
+ if (awardEndpoint) {
473
+ app.post('/internal/rounds/:id/awards', async (request, reply) => {
474
+ if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
475
+ return reply.code(401).send({ message: 'game server authentication failed' });
476
+ }
477
+ const body = (request.body ?? {}) as Partial<GameServerAwardRequest>;
478
+ const result = await awardEndpoint.awards.record({
479
+ roundId: (request.params as { id: string }).id,
480
+ eventId: body.eventId as string,
481
+ player: body.player as PlayerId,
482
+ points: body.points as number,
483
+ });
484
+ if (!result.ok) {
485
+ const status =
486
+ result.refuse === 'award.invalid'
487
+ ? 400
488
+ : result.refuse === 'award.no_such_round' || result.refuse === 'award.wrong_authority'
489
+ ? 404
490
+ : 409;
491
+ return reply.code(status).send({ refuse: result.refuse });
492
+ }
493
+
494
+ announcePlayer(result.award.roundId, result.award.player);
495
+ return reply.code(result.created ? 201 : 200).send(result.award);
496
+ });
497
+ }
498
+
384
499
  app.get('/rounds/:id', async (request, reply) => {
385
500
  const id = (request.params as { id: string }).id;
386
501
  const room = await roomFor(id);
@@ -396,6 +511,10 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
396
511
  if (!room) return reply.code(404).send({ message: 'that game is not running' });
397
512
 
398
513
  const id = (request.params as { id: string }).id;
514
+ if (scoreAuthority === 'external-server') {
515
+ player(request);
516
+ return { joined: true };
517
+ }
399
518
  const before = room.sequence();
400
519
  const result = await room.apply({
401
520
  kind: 'join',
@@ -432,6 +551,8 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
432
551
  app.post('/rounds/:id/claim', async (request, reply) => {
433
552
  const id = (request.params as { id: string }).id;
434
553
  const who = player(request);
554
+ const room = await roomFor(id);
555
+ if (!room) return reply.code(404).send({ message: 'that game is not running' });
435
556
  const { maxSpendWei, requestId } = request.body as { maxSpendWei?: string; requestId?: unknown };
436
557
  let upTo: bigint;
437
558
  try {