@flayerlabs/gamemode-gate 0.2.0 → 0.3.1

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 (55) hide show
  1. package/README.md +37 -4
  2. package/dist/demo.d.ts.map +1 -1
  3. package/dist/demo.js +2 -0
  4. package/dist/demo.js.map +1 -1
  5. package/dist/economy.d.ts +9 -0
  6. package/dist/economy.d.ts.map +1 -1
  7. package/dist/economy.js +1 -1
  8. package/dist/economy.js.map +1 -1
  9. package/dist/game-registry.d.ts.map +1 -1
  10. package/dist/game-registry.js +4 -1
  11. package/dist/game-registry.js.map +1 -1
  12. package/dist/game-server-awards.d.ts +37 -0
  13. package/dist/game-server-awards.d.ts.map +1 -1
  14. package/dist/game-server-awards.js +49 -0
  15. package/dist/game-server-awards.js.map +1 -1
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/player-join-tickets.d.ts +29 -0
  21. package/dist/player-join-tickets.d.ts.map +1 -0
  22. package/dist/player-join-tickets.js +112 -0
  23. package/dist/player-join-tickets.js.map +1 -0
  24. package/dist/registry.d.ts +5 -9
  25. package/dist/registry.d.ts.map +1 -1
  26. package/dist/registry.js +24 -4
  27. package/dist/registry.js.map +1 -1
  28. package/dist/room.d.ts +4 -3
  29. package/dist/room.d.ts.map +1 -1
  30. package/dist/room.js +27 -11
  31. package/dist/room.js.map +1 -1
  32. package/dist/server.d.ts +2 -0
  33. package/dist/server.d.ts.map +1 -1
  34. package/dist/server.js +91 -13
  35. package/dist/server.js.map +1 -1
  36. package/dist/sessions.d.ts +2 -0
  37. package/dist/sessions.d.ts.map +1 -1
  38. package/dist/sessions.js +4 -0
  39. package/dist/sessions.js.map +1 -1
  40. package/dist/store.d.ts +1 -1
  41. package/dist/store.d.ts.map +1 -1
  42. package/dist/store.js +14 -0
  43. package/dist/store.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/demo.ts +2 -0
  46. package/src/economy.ts +10 -1
  47. package/src/game-registry.ts +10 -2
  48. package/src/game-server-awards.ts +86 -0
  49. package/src/index.ts +5 -0
  50. package/src/player-join-tickets.ts +151 -0
  51. package/src/registry.ts +34 -10
  52. package/src/room.ts +31 -11
  53. package/src/server.ts +105 -14
  54. package/src/sessions.ts +5 -0
  55. package/src/store.ts +14 -0
@@ -0,0 +1,151 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { isAddress } from 'viem';
3
+ import type { PlayerId } from '@flayerlabs/gamemode-spec';
4
+ import type { PlayerJoinTicket } from '@flayerlabs/gamemode-spec/live';
5
+
6
+ export const PLAYER_JOIN_TICKET_TTL_MS = 30_000;
7
+
8
+ const DOMAIN = 'flaunch.game-mode/player-join/v1';
9
+ const CLOCK_SKEW_MS = 5_000;
10
+ const MAX_TICKET_LENGTH = 2_048;
11
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
12
+
13
+ export interface VerifiedPlayerJoin {
14
+ player: PlayerId;
15
+ gameId: string;
16
+ roundId: string;
17
+ audience: string;
18
+ issuedAt: number;
19
+ expiresAt: number;
20
+ jti: string;
21
+ }
22
+
23
+ export interface VerifyPlayerJoinTicketOptions {
24
+ awardToken: string;
25
+ gameId: string;
26
+ roundId: string;
27
+ audience: string;
28
+ }
29
+
30
+ interface IssuePlayerJoinTicketOptions extends VerifyPlayerJoinTicketOptions {
31
+ player: PlayerId;
32
+ closesAt: number;
33
+ now: number;
34
+ }
35
+
36
+ interface TicketBody extends VerifiedPlayerJoin {
37
+ v: 1;
38
+ kind: 'player-join';
39
+ }
40
+
41
+ // The authoritative game server already holds award authority. Domain separation stops a ticket
42
+ // MAC from being usable as an award bearer token while keeping deployment to one server secret.
43
+ const key = (awardToken: string): Buffer => createHmac('sha256', awardToken).update(DOMAIN).digest();
44
+ const mac = (body: string, awardToken: string): string => createHmac('sha256', key(awardToken)).update(body).digest('base64url');
45
+
46
+ function exactOrigin(value: string): boolean {
47
+ try {
48
+ const parsed = new URL(value);
49
+ return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && parsed.origin === value;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ function matches(body: string, signature: string, awardToken: string): boolean {
56
+ const expected = Buffer.from(mac(body, awardToken));
57
+ const received = Buffer.from(signature);
58
+ return expected.length === received.length && timingSafeEqual(expected, received);
59
+ }
60
+
61
+ /** Internal issuer used by an authenticated external-server gate route. */
62
+ export function issuePlayerJoinTicket(options: IssuePlayerJoinTicketOptions): PlayerJoinTicket {
63
+ if (options.awardToken.length < 32) throw new RangeError('awardToken must be at least 32 characters');
64
+ const { now } = options;
65
+ if (!Number.isSafeInteger(now)) throw new RangeError('now must be a safe integer');
66
+ if (!Number.isSafeInteger(options.closesAt) || options.closesAt <= now) {
67
+ throw new RangeError('the round must still be open');
68
+ }
69
+ const expiresAt = Math.min(now + PLAYER_JOIN_TICKET_TTL_MS, options.closesAt);
70
+ const claims: TicketBody = {
71
+ v: 1,
72
+ kind: 'player-join',
73
+ player: options.player.toLowerCase(),
74
+ gameId: options.gameId,
75
+ roundId: options.roundId,
76
+ audience: options.audience,
77
+ issuedAt: now,
78
+ expiresAt,
79
+ jti: randomUUID(),
80
+ };
81
+ const body = Buffer.from(JSON.stringify(claims)).toString('base64url');
82
+ return { ticket: `gmj1.${body}.${mac(body, options.awardToken)}`, expiresAt };
83
+ }
84
+
85
+ /** Verify an opaque browser join ticket before binding a socket to its wallet. */
86
+ export function verifyPlayerJoinTicket(
87
+ ticket: unknown,
88
+ options: VerifyPlayerJoinTicketOptions,
89
+ ): VerifiedPlayerJoin | null {
90
+ if (
91
+ options.awardToken.length < 32 ||
92
+ typeof ticket !== 'string' ||
93
+ ticket.length === 0 ||
94
+ ticket.length > MAX_TICKET_LENGTH
95
+ ) {
96
+ return null;
97
+ }
98
+ const parts = ticket.split('.');
99
+ if (parts.length !== 3 || parts[0] !== 'gmj1') return null;
100
+ const [, body, signature] = parts as [string, string, string];
101
+ if (!body || !signature || !matches(body, signature, options.awardToken)) return null;
102
+
103
+ let value: Record<string, unknown>;
104
+ try {
105
+ const parsed: unknown = JSON.parse(Buffer.from(body, 'base64url').toString());
106
+ if (typeof parsed !== 'object' || parsed === null) return null;
107
+ value = parsed as Record<string, unknown>;
108
+ } catch {
109
+ return null;
110
+ }
111
+
112
+ const now = Date.now();
113
+ if (
114
+ value.v !== 1 ||
115
+ value.kind !== 'player-join' ||
116
+ typeof value.player !== 'string' ||
117
+ !isAddress(value.player, { strict: false }) ||
118
+ value.player !== value.player.toLowerCase() ||
119
+ typeof value.gameId !== 'string' ||
120
+ !ID.test(value.gameId) ||
121
+ typeof value.roundId !== 'string' ||
122
+ !ID.test(value.roundId) ||
123
+ typeof value.audience !== 'string' ||
124
+ !exactOrigin(value.audience) ||
125
+ typeof value.issuedAt !== 'number' ||
126
+ !Number.isSafeInteger(value.issuedAt) ||
127
+ typeof value.expiresAt !== 'number' ||
128
+ !Number.isSafeInteger(value.expiresAt) ||
129
+ typeof value.jti !== 'string' ||
130
+ !ID.test(value.jti) ||
131
+ value.gameId !== options.gameId ||
132
+ value.roundId !== options.roundId ||
133
+ value.audience !== options.audience ||
134
+ value.issuedAt > now + CLOCK_SKEW_MS ||
135
+ value.expiresAt <= now ||
136
+ value.expiresAt <= value.issuedAt ||
137
+ value.expiresAt - value.issuedAt > PLAYER_JOIN_TICKET_TTL_MS
138
+ ) {
139
+ return null;
140
+ }
141
+
142
+ return {
143
+ player: value.player,
144
+ gameId: value.gameId,
145
+ roundId: value.roundId,
146
+ audience: value.audience,
147
+ issuedAt: value.issuedAt,
148
+ expiresAt: value.expiresAt,
149
+ jti: value.jti,
150
+ };
151
+ }
package/src/registry.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { Pool } from 'pg';
2
- import type { GameRegistration } from '@flayerlabs/gamemode-spec/embed';
2
+ import {
3
+ parseGameServerOrigins,
4
+ type GameRegistration,
5
+ type GameSecurityProfile,
6
+ } from '@flayerlabs/gamemode-spec/embed';
3
7
 
4
8
  /**
5
9
  * Which game a coin plays, and everything the page embedding it needs to know.
@@ -15,10 +19,7 @@ export type Registration = GameRegistration;
15
19
  * Semantic fields, never CSP text: a policy assembled by string concatenation somewhere else is a
16
20
  * policy someone can inject into.
17
21
  */
18
- export interface SecurityProfile {
19
- kind: 'game';
20
- connect: { https: string; wss: string };
21
- }
22
+ export type SecurityProfile = GameSecurityProfile;
22
23
 
23
24
  export class InvalidRegistration extends Error {
24
25
  constructor(message: string) {
@@ -96,16 +97,32 @@ export function gateOrigin(value: unknown): string {
96
97
  return url.origin;
97
98
  }
98
99
 
100
+ /** Validate the reviewed origins a creator-owned authoritative game server may use. */
101
+ export function gameServerOrigins(value: unknown): readonly string[] {
102
+ const origins = parseGameServerOrigins(value);
103
+ if (origins === null) {
104
+ throw new InvalidRegistration('game server origins must be at most four distinct public HTTPS origins');
105
+ }
106
+ return origins;
107
+ }
108
+
99
109
  /** The profile for a registration. The websocket address is derived, never supplied separately. */
100
- export function securityProfile(registration: Pick<Registration, 'gateOrigin'>): SecurityProfile {
110
+ export function securityProfile(
111
+ registration: Pick<Registration, 'gateOrigin' | 'gameServerOrigins'>,
112
+ ): SecurityProfile {
101
113
  // Revalidated here on purpose: this is the value that leaves for another system, and checking it
102
114
  // at the boundary costs nothing.
103
115
  const origin = gateOrigin(registration.gateOrigin);
116
+ const servers = gameServerOrigins(registration.gameServerOrigins);
104
117
  return {
105
118
  kind: 'game',
106
119
  // Same host, same port, different scheme. Deriving it means the two can never name different
107
120
  // servers, which a second hand-entered field eventually would.
108
- connect: { https: origin, wss: `wss://${new URL(origin).host}` },
121
+ connect: {
122
+ https: origin,
123
+ wss: `wss://${new URL(origin).host}`,
124
+ ...(servers.length === 0 ? {} : { gameServerOrigins: servers }),
125
+ },
109
126
  };
110
127
  }
111
128
 
@@ -136,12 +153,14 @@ export function checkRegistration(entry: Registration): Registration {
136
153
  if (typeof entry?.coin !== 'string' || !ADDRESS.test(entry.coin)) {
137
154
  throw new InvalidRegistration('coin must be a wallet address');
138
155
  }
156
+ const servers = gameServerOrigins(entry.gameServerOrigins);
139
157
  return {
140
158
  chainId: chainId(entry.chainId),
141
159
  coin: entry.coin.toLowerCase(),
142
160
  gameId: id(entry.gameId, 'game'),
143
161
  deployId: deployId(entry.deployId),
144
162
  gateOrigin: gateOrigin(entry.gateOrigin),
163
+ ...(servers.length === 0 ? {} : { gameServerOrigins: servers }),
145
164
  };
146
165
  }
147
166
 
@@ -150,16 +169,19 @@ export function checkRegistration(entry: Registration): Registration {
150
169
  */
151
170
  export async function pinGameRegistration(pool: Pool, entry: Registration): Promise<Registration> {
152
171
  const checked = checkRegistration(entry);
172
+ const servers = checked.gameServerOrigins ?? [];
153
173
  const result = await pool.query(
154
- `insert into registrations (chain_id, coin, game_id, gate_origin, deploy_id) values ($1, $2, $3, $4, $5)
174
+ `insert into registrations (chain_id, coin, game_id, gate_origin, deploy_id, game_server_origins)
175
+ values ($1, $2, $3, $4, $5, $6)
155
176
  on conflict (chain_id, coin) do update set
156
177
  updated_at = registrations.updated_at
157
178
  where registrations.disabled_at is null
158
179
  and registrations.game_id = excluded.game_id
159
180
  and registrations.gate_origin = excluded.gate_origin
160
181
  and registrations.deploy_id = excluded.deploy_id
182
+ and registrations.game_server_origins = excluded.game_server_origins
161
183
  returning coin`,
162
- [checked.chainId, checked.coin, checked.gameId, checked.gateOrigin, checked.deployId],
184
+ [checked.chainId, checked.coin, checked.gameId, checked.gateOrigin, checked.deployId, servers],
163
185
  );
164
186
  if (result.rows.length === 0) throw new RegistrationConflict();
165
187
  return checked;
@@ -180,8 +202,9 @@ export async function resolveGameRegistration(
180
202
  game_id: string;
181
203
  gate_origin: string;
182
204
  deploy_id: string;
205
+ game_server_origins: string[];
183
206
  }>(
184
- `select chain_id::text, coin, game_id, gate_origin, deploy_id
207
+ `select chain_id::text, coin, game_id, gate_origin, deploy_id, game_server_origins
185
208
  from registrations where chain_id = $1 and coin = $2 and disabled_at is null`,
186
209
  [checkedChainId, coin.toLowerCase()],
187
210
  );
@@ -193,6 +216,7 @@ export async function resolveGameRegistration(
193
216
  gameId: row.game_id,
194
217
  gateOrigin: row.gate_origin,
195
218
  deployId: row.deploy_id,
219
+ gameServerOrigins: row.game_server_origins,
196
220
  })
197
221
  : null;
198
222
  }
package/src/room.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Pool } from 'pg';
2
2
  import { Round } from '@flayerlabs/gamemode-spec/round';
3
3
  import type { Award, Command, Decision, GameModule, PlayerId, Refusal, RoundWindow } from '@flayerlabs/gamemode-spec';
4
- import type { LaunchContext } from '@flayerlabs/gamemode-spec/live';
4
+ import type { LaunchContext, RoundTerms } from '@flayerlabs/gamemode-spec/live';
5
5
  import { isRefusal } from '@flayerlabs/gamemode-spec';
6
6
  import { inTransaction, toBigInt } from './store.js';
7
7
 
@@ -39,8 +39,7 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
39
39
  private round: Round<Config, State, Event, Action, PublicView, PlayerView>,
40
40
  readonly window: RoundWindow,
41
41
  readonly launch: LaunchContext,
42
- readonly weiPerPoint: bigint,
43
- readonly maxPointsPerPlayer: number,
42
+ readonly terms: RoundTerms,
44
43
  readonly scoreAuthority: ScoreAuthority,
45
44
  private seq: number,
46
45
  ) {}
@@ -55,6 +54,8 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
55
54
  window: RoundWindow;
56
55
  weiPerPoint: bigint;
57
56
  walletCapWei: bigint;
57
+ pointsPerDollar: number;
58
+ usdPerEth: number;
58
59
  maxPointsPerPlayer: number;
59
60
  scoreAuthority: ScoreAuthority;
60
61
  poolId: string;
@@ -85,18 +86,28 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
85
86
  closesAt: round.window.closesAt,
86
87
  booksCloseAt,
87
88
  });
89
+ const terms: RoundTerms = Object.freeze({
90
+ maxPointsPerPlayer: round.maxPointsPerPlayer,
91
+ walletCapWei: round.walletCapWei,
92
+ weiPerPoint: round.weiPerPoint,
93
+ pointsPerDollar: round.pointsPerDollar,
94
+ usdPerEth: round.usdPerEth,
95
+ });
88
96
  const started = Round.start(game, round.config, round.seed, round.window);
89
97
  await pool.query(
90
98
  `insert into rounds (
91
- id, game_id, wei_per_point, wallet_cap_wei, max_points_per_player, score_authority,
99
+ id, game_id, wei_per_point, wallet_cap_wei, points_per_dollar, usd_per_eth,
100
+ max_points_per_player, score_authority,
92
101
  pool_id, opens_at, closes_at, books_close_at,
93
102
  coin_address, coin_name, coin_symbol, coin_image_url, state, seq
94
- ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 0)`,
103
+ ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 0)`,
95
104
  [
96
105
  round.id,
97
106
  game.id,
98
107
  round.weiPerPoint.toString(),
99
108
  round.walletCapWei.toString(),
109
+ round.pointsPerDollar,
110
+ round.usdPerEth,
100
111
  round.maxPointsPerPlayer,
101
112
  round.scoreAuthority,
102
113
  round.poolId,
@@ -117,8 +128,7 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
117
128
  started,
118
129
  round.window,
119
130
  launch,
120
- round.weiPerPoint,
121
- round.maxPointsPerPlayer,
131
+ terms,
122
132
  round.scoreAuthority,
123
133
  0,
124
134
  );
@@ -142,11 +152,15 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
142
152
  coin_symbol: string;
143
153
  coin_image_url: string | null;
144
154
  wei_per_point: string;
155
+ wallet_cap_wei: string;
156
+ points_per_dollar: number;
157
+ usd_per_eth: number;
145
158
  max_points_per_player: string;
146
159
  score_authority: ScoreAuthority;
147
160
  game_id: string;
148
161
  }>(
149
162
  `select state, seq, pool_id, opens_at, closes_at, books_close_at, wei_per_point,
163
+ wallet_cap_wei, points_per_dollar, usd_per_eth,
150
164
  max_points_per_player, score_authority, game_id,
151
165
  coin_address, coin_name, coin_symbol, coin_image_url
152
166
  from rounds where id = $1`,
@@ -167,6 +181,13 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
167
181
  ...window,
168
182
  booksCloseAt: Number(row.books_close_at),
169
183
  });
184
+ const terms: RoundTerms = Object.freeze({
185
+ maxPointsPerPlayer: Number(row.max_points_per_player),
186
+ walletCapWei: toBigInt(row.wallet_cap_wei),
187
+ weiPerPoint: toBigInt(row.wei_per_point),
188
+ pointsPerDollar: Number(row.points_per_dollar),
189
+ usdPerEth: Number(row.usd_per_eth),
190
+ });
170
191
  return new Room(
171
192
  pool,
172
193
  game,
@@ -174,8 +195,7 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
174
195
  Round.resume(game, row.state, window),
175
196
  window,
176
197
  launch,
177
- toBigInt(row.wei_per_point),
178
- Number(row.max_points_per_player),
198
+ terms,
179
199
  row.score_authority,
180
200
  row.seq,
181
201
  );
@@ -250,14 +270,14 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
250
270
  // Same transaction as the state that earned them. Points credited without the state that
251
271
  // justifies them, or the reverse, is a ledger nobody can reconcile.
252
272
  const points = Math.floor(award.points);
253
- if (points > this.maxPointsPerPlayer) {
273
+ if (points > this.terms.maxPointsPerPlayer) {
254
274
  throw new Error(`${this.game.id}: awards exceeded the declared maximum for ${award.player}`);
255
275
  }
256
276
  const credited = await tx.query(
257
277
  `insert into balances (round_id, player, points) values ($1, $2, $3)
258
278
  on conflict (round_id, player) do update set points = balances.points + excluded.points
259
279
  where balances.points + excluded.points <= $4`,
260
- [this.id, award.player, points, this.maxPointsPerPlayer],
280
+ [this.id, award.player, points, this.terms.maxPointsPerPlayer],
261
281
  );
262
282
  if (credited.rowCount !== 1) {
263
283
  throw new Error(`${this.game.id}: awards exceeded the declared maximum for ${award.player}`);
package/src/server.ts CHANGED
@@ -4,7 +4,13 @@ import cors from '@fastify/cors';
4
4
  import type { Pool } from 'pg';
5
5
  import type { Command, GameModule, PlayerId } from '@flayerlabs/gamemode-spec';
6
6
  import { isRefusal } from '@flayerlabs/gamemode-spec';
7
- import type { PresenceState } from '@flayerlabs/gamemode-spec/live';
7
+ import type {
8
+ EconomyBalance,
9
+ PresenceState,
10
+ RoundTerms,
11
+ WireEconomyBalance,
12
+ WireRoundTerms,
13
+ } from '@flayerlabs/gamemode-spec/live';
8
14
  import { isReactionId, LIVE_PROTOCOL_VERSION } from '@flayerlabs/gamemode-spec/live';
9
15
  import { Room, type ScoreAuthority } from './room.js';
10
16
  import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
@@ -15,6 +21,8 @@ import { resolveEconomy } from './economy.js';
15
21
  import { encodeHookData } from './chain/signer.js';
16
22
  import { hasBearerToken } from './bearer.js';
17
23
  import { GameServerAwards, type GameServerAwardRequest } from './game-server-awards.js';
24
+ import { issuePlayerJoinTicket } from './player-join-tickets.js';
25
+ import { gameServerOrigins } from './registry.js';
18
26
 
19
27
  /**
20
28
  * The gate's door.
@@ -82,6 +90,8 @@ export interface GameServerGateOptions extends GateBaseOptions {
82
90
  maxPointsPerPlayer: number | MaxPointsForLaunch;
83
91
  /** Dedicated server-to-server token. Never expose it to browser game code. */
84
92
  awardToken: string;
93
+ /** Reviewed HTTPS origins that may receive player join tickets. The gate origin is always allowed. */
94
+ gameServerOrigins?: readonly string[];
85
95
  }
86
96
 
87
97
  /** Where an admission check is being made. Enough to tell a sign-in from a spend. */
@@ -112,6 +122,25 @@ const ADMIT_ALL: Admit = () => ({ ok: true });
112
122
  /** What the socket says, which is only the truth when nothing is proxying in front of the gate. */
113
123
  const ipOf = (request: FastifyRequest): string | null => request.ip || null;
114
124
 
125
+ /** Wei has no JSON form, so terms cross as decimal strings, exactly as the economy balance does. */
126
+ const wireTerms = (terms: RoundTerms): WireRoundTerms => ({
127
+ maxPointsPerPlayer: terms.maxPointsPerPlayer,
128
+ walletCapWei: terms.walletCapWei.toString(),
129
+ weiPerPoint: terms.weiPerPoint.toString(),
130
+ pointsPerDollar: terms.pointsPerDollar,
131
+ usdPerEth: terms.usdPerEth,
132
+ });
133
+
134
+ /** The same encoding for the per-player balance, wherever it leaves the process. */
135
+ const wireEconomy = (balance: EconomyBalance): WireEconomyBalance => ({
136
+ weiPerPoint: balance.weiPerPoint.toString(),
137
+ earnedWei: balance.earnedWei.toString(),
138
+ heldWei: balance.heldWei.toString(),
139
+ spentWei: balance.spentWei.toString(),
140
+ availableWei: balance.availableWei.toString(),
141
+ holdExpiresAt: balance.holdExpiresAt,
142
+ });
143
+
115
144
  /** How often each open room is nudged so a deadline nobody is watching still fires. */
116
145
  const TICK_MS = 250;
117
146
 
@@ -141,6 +170,7 @@ type AdoptionResult<Config, State, Event, Action, PublicView, PlayerView> =
141
170
  interface AwardEndpoint {
142
171
  token: string;
143
172
  awards: GameServerAwards;
173
+ ticketAudiences: ReadonlySet<string>;
144
174
  }
145
175
 
146
176
  interface GameServerConfig {
@@ -203,11 +233,12 @@ export function createGameServerGate(options: GameServerGateOptions): FastifyIns
203
233
  typeof maximum === 'function'
204
234
  ? async (launch) => ({ maxPointsPerPlayer: await maximum(launch) })
205
235
  : { maxPointsPerPlayer: maximum };
206
- const { gameId, maxPointsPerPlayer: _maximum, awardToken, ...base } = options;
236
+ const ticketAudiences = new Set([options.sessions.origin, ...gameServerOrigins(options.gameServerOrigins)]);
237
+ const { gameId, maxPointsPerPlayer: _maximum, awardToken, gameServerOrigins: _origins, ...base } = options;
207
238
  return createGateRuntime(
208
239
  { ...base, game: gameServerModule(gameId), config },
209
240
  'external-server',
210
- { token: awardToken, awards: new GameServerAwards(options.pool, gameId) },
241
+ { token: awardToken, awards: new GameServerAwards(options.pool, gameId), ticketAudiences },
211
242
  );
212
243
  }
213
244
 
@@ -331,6 +362,8 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
331
362
  seed: Number(BigInt(launch.poolId) % 2147483647n),
332
363
  window: { opensAt: launch.opensAt, closesAt: launch.closesAt },
333
364
  weiPerPoint: economy.economy.weiPerPoint,
365
+ pointsPerDollar: economy.economy.pointsPerDollar,
366
+ usdPerEth: economy.economy.usdPerEth,
334
367
  walletCapWei: launch.walletCapWei,
335
368
  maxPointsPerPlayer,
336
369
  scoreAuthority,
@@ -464,12 +497,40 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
464
497
  }
465
498
  const adopted = await flight;
466
499
  if (!adopted.ok) return reply.code(adopted.status).send({ message: adopted.message });
500
+ // Terms ride alongside the launch rather than replacing it, so a caller that already parses
501
+ // this body as a LaunchContext keeps working and one that wants the round's economy does not
502
+ // need a second request for a value that was fixed by this very call.
467
503
  return reply
468
504
  .code(createdByThisRequest && adopted.created ? 201 : 200)
469
- .send(adopted.room.launch);
505
+ .send({ ...adopted.room.launch, terms: wireTerms(adopted.room.terms) });
470
506
  });
471
507
 
472
508
  if (awardEndpoint) {
509
+ app.post('/rounds/:id/join-ticket', async (request, reply) => {
510
+ const who = player(request);
511
+ const id = (request.params as { id: string }).id;
512
+ const room = await roomFor(id);
513
+ if (!room) return reply.code(404).send({ message: 'that game is not running' });
514
+ const now = Date.now();
515
+ if (now >= room.window.closesAt) {
516
+ return reply.code(409).send({ message: 'that game has ended' });
517
+ }
518
+ const requested = (request.body as { audience?: unknown } | null)?.audience;
519
+ const audience = requested === undefined ? sessions.origin : requested;
520
+ if (typeof audience !== 'string' || !awardEndpoint.ticketAudiences.has(audience)) {
521
+ return reply.code(400).send({ message: 'that game server is not registered' });
522
+ }
523
+ return issuePlayerJoinTicket({
524
+ awardToken: awardEndpoint.token,
525
+ player: who,
526
+ gameId: game.id,
527
+ roundId: id,
528
+ audience,
529
+ closesAt: room.window.closesAt,
530
+ now,
531
+ });
532
+ });
533
+
473
534
  app.post('/internal/rounds/:id/awards', async (request, reply) => {
474
535
  if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
475
536
  return reply.code(401).send({ message: 'game server authentication failed' });
@@ -494,6 +555,37 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
494
555
  announcePlayer(result.award.roundId, result.award.player);
495
556
  return reply.code(result.created ? 201 : 200).send(result.award);
496
557
  });
558
+
559
+ /**
560
+ * What one wallet has earned, so a game server can refuse a seat before the player sits in it.
561
+ *
562
+ * Advisory, and deliberately so. The award route still enforces the ceiling inside the
563
+ * transaction that moves the balance, so a caller that stops handling `award.limit_exceeded`
564
+ * because it checked here first has moved the check out of the only place it is atomic.
565
+ *
566
+ * Never routed through `roomFor`: that resurrects rooms, advances game state and returns
567
+ * nothing once the books close — none of which a read should do.
568
+ */
569
+ app.get('/internal/rounds/:id/players/:player', async (request, reply) => {
570
+ if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
571
+ return reply.code(401).send({ message: 'game server authentication failed' });
572
+ }
573
+ const params = request.params as { id: string; player: string };
574
+ const result = await awardEndpoint.awards.pointsFor({
575
+ roundId: params.id,
576
+ player: params.player as PlayerId,
577
+ });
578
+ if (!result.ok) {
579
+ return reply.code(result.refuse === 'award.invalid' ? 400 : 404).send({ refuse: result.refuse });
580
+ }
581
+
582
+ // `heldWei` moves on its own when an authorisation expires, so this answer is only true at the
583
+ // moment it is read. Points are monotone; the wei beside them are not.
584
+ const balance = await claims.economyBalance(result.standing.roundId, result.standing.player);
585
+ return reply
586
+ .header('cache-control', 'no-store')
587
+ .send({ ...result.standing, economy: wireEconomy(balance) });
588
+ });
497
589
  }
498
590
 
499
591
  app.get('/rounds/:id', async (request, reply) => {
@@ -503,7 +595,12 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
503
595
  const before = room.sequence();
504
596
  await room.tick(Date.now());
505
597
  if (room.sequence() !== before) announce(id);
506
- return { sequence: room.sequence(), view: room.publicView(), launch: room.launch };
598
+ return {
599
+ sequence: room.sequence(),
600
+ view: room.publicView(),
601
+ launch: room.launch,
602
+ terms: wireTerms(room.terms),
603
+ };
507
604
  });
508
605
 
509
606
  app.post('/rounds/:id/join', async (request, reply) => {
@@ -679,21 +776,14 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
679
776
  const balance = viewer
680
777
  ? await claims.economyBalance(id, viewer)
681
778
  : {
682
- weiPerPoint: room.weiPerPoint,
779
+ weiPerPoint: room.terms.weiPerPoint,
683
780
  earnedWei: 0n,
684
781
  heldWei: 0n,
685
782
  spentWei: 0n,
686
783
  availableWei: 0n,
687
784
  holdExpiresAt: null,
688
785
  };
689
- const economy = {
690
- weiPerPoint: balance.weiPerPoint.toString(),
691
- earnedWei: balance.earnedWei.toString(),
692
- heldWei: balance.heldWei.toString(),
693
- spentWei: balance.spentWei.toString(),
694
- availableWei: balance.availableWei.toString(),
695
- holdExpiresAt: balance.holdExpiresAt,
696
- };
786
+ const economy = wireEconomy(balance);
697
787
  return {
698
788
  v: LIVE_PROTOCOL_VERSION,
699
789
  type: 'snapshot',
@@ -702,6 +792,7 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
702
792
  view: room.publicView(),
703
793
  you: viewer ? room.playerView(viewer) : null,
704
794
  economy,
795
+ terms: wireTerms(room.terms),
705
796
  launch: room.launch,
706
797
  presence: presence(id),
707
798
  };
package/src/sessions.ts CHANGED
@@ -66,6 +66,11 @@ export class Sessions {
66
66
  }
67
67
  }
68
68
 
69
+ /** Exact configured gate origin, used as the default audience for a co-located game server. */
70
+ get origin(): string {
71
+ return this.gateOrigin;
72
+ }
73
+
69
74
  private sign(value: string): string {
70
75
  return createHmac('sha256', this.secret).update(value).digest('base64url');
71
76
  }
package/src/store.ts CHANGED
@@ -13,6 +13,11 @@ create table if not exists rounds (
13
13
  game_id text not null check (game_id <> ''),
14
14
  wei_per_point numeric(78,0) not null check (wei_per_point > 0),
15
15
  wallet_cap_wei numeric(78,0) not null check (wallet_cap_wei > 0),
16
+ -- The two settings that produced wei_per_point, frozen beside it. They are gate-process config,
17
+ -- so an operator who restarts with a new ETH price must not retroactively change what a round
18
+ -- was priced at.
19
+ points_per_dollar double precision not null check (points_per_dollar > 0),
20
+ usd_per_eth double precision not null check (usd_per_eth > 0),
16
21
  max_points_per_player bigint not null check (max_points_per_player > 0),
17
22
  score_authority text not null check (score_authority in ('rules', 'external-server')),
18
23
  pool_id text not null,
@@ -41,11 +46,16 @@ alter table rounds
41
46
  add column if not exists max_points_per_player bigint check (max_points_per_player > 0);
42
47
  alter table rounds
43
48
  add column if not exists score_authority text check (score_authority in ('rules', 'external-server'));
49
+ alter table rounds
50
+ add column if not exists points_per_dollar double precision check (points_per_dollar > 0);
51
+ alter table rounds
52
+ add column if not exists usd_per_eth double precision check (usd_per_eth > 0);
44
53
  do $$
45
54
  begin
46
55
  if exists (
47
56
  select 1 from rounds
48
57
  where game_id is null or max_points_per_player is null or score_authority is null
58
+ or points_per_dollar is null or usd_per_eth is null
49
59
  ) then
50
60
  raise exception 'pre-release round rows cannot be upgraded safely'
51
61
  using hint = 'Delete the pre-release round data, then run migrate(pool) again.';
@@ -55,6 +65,8 @@ $$;
55
65
  alter table rounds alter column game_id set not null;
56
66
  alter table rounds alter column max_points_per_player set not null;
57
67
  alter table rounds alter column score_authority set not null;
68
+ alter table rounds alter column points_per_dollar set not null;
69
+ alter table rounds alter column usd_per_eth set not null;
58
70
 
59
71
  create table if not exists balances (
60
72
  round_id text not null references rounds(id) on delete cascade,
@@ -107,8 +119,10 @@ create table if not exists registrations (
107
119
  game_id text not null check (game_id <> ''),
108
120
  gate_origin text not null check (gate_origin <> ''),
109
121
  deploy_id text not null check (deploy_id <> ''),
122
+ game_server_origins text[] not null default '{}'::text[],
110
123
  disabled_at timestamptz,
111
124
  updated_at timestamptz not null default now(),
125
+ constraint registrations_game_server_origins_bounded check (cardinality(game_server_origins) <= 4),
112
126
  primary key (chain_id, coin)
113
127
  );
114
128