@flayerlabs/gamemode-gate 0.1.0 → 0.3.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 (65) hide show
  1. package/README.md +81 -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 +6 -1
  8. package/dist/demo.js.map +1 -1
  9. package/dist/economy.d.ts +9 -0
  10. package/dist/economy.d.ts.map +1 -1
  11. package/dist/economy.js +2 -2
  12. package/dist/economy.js.map +1 -1
  13. package/dist/game-registry.d.ts.map +1 -1
  14. package/dist/game-registry.js +6 -8
  15. package/dist/game-registry.js.map +1 -1
  16. package/dist/game-server-awards.d.ts +76 -0
  17. package/dist/game-server-awards.d.ts.map +1 -0
  18. package/dist/game-server-awards.js +172 -0
  19. package/dist/game-server-awards.js.map +1 -0
  20. package/dist/index.d.ts +5 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/ledger.d.ts +0 -2
  25. package/dist/ledger.d.ts.map +1 -1
  26. package/dist/ledger.js +0 -7
  27. package/dist/ledger.js.map +1 -1
  28. package/dist/player-join-tickets.d.ts +29 -0
  29. package/dist/player-join-tickets.d.ts.map +1 -0
  30. package/dist/player-join-tickets.js +112 -0
  31. package/dist/player-join-tickets.js.map +1 -0
  32. package/dist/registry.d.ts +5 -9
  33. package/dist/registry.d.ts.map +1 -1
  34. package/dist/registry.js +24 -4
  35. package/dist/registry.js.map +1 -1
  36. package/dist/room.d.ts +8 -2
  37. package/dist/room.d.ts.map +1 -1
  38. package/dist/room.js +50 -9
  39. package/dist/room.js.map +1 -1
  40. package/dist/server.d.ts +16 -3
  41. package/dist/server.d.ts.map +1 -1
  42. package/dist/server.js +162 -10
  43. package/dist/server.js.map +1 -1
  44. package/dist/sessions.d.ts +2 -0
  45. package/dist/sessions.d.ts.map +1 -1
  46. package/dist/sessions.js +4 -0
  47. package/dist/sessions.js.map +1 -1
  48. package/dist/store.d.ts +1 -1
  49. package/dist/store.d.ts.map +1 -1
  50. package/dist/store.js +53 -0
  51. package/dist/store.js.map +1 -1
  52. package/package.json +2 -2
  53. package/src/bearer.ts +8 -0
  54. package/src/demo.ts +6 -1
  55. package/src/economy.ts +11 -2
  56. package/src/game-registry.ts +12 -10
  57. package/src/game-server-awards.ts +285 -0
  58. package/src/index.ts +13 -0
  59. package/src/ledger.ts +0 -10
  60. package/src/player-join-tickets.ts +151 -0
  61. package/src/registry.ts +34 -10
  62. package/src/room.ts +70 -9
  63. package/src/server.ts +231 -22
  64. package/src/sessions.ts +5 -0
  65. package/src/store.ts +53 -0
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
 
@@ -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.
@@ -38,7 +39,8 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
38
39
  private round: Round<Config, State, Event, Action, PublicView, PlayerView>,
39
40
  readonly window: RoundWindow,
40
41
  readonly launch: LaunchContext,
41
- readonly weiPerPoint: bigint,
42
+ readonly terms: RoundTerms,
43
+ readonly scoreAuthority: ScoreAuthority,
42
44
  private seq: number,
43
45
  ) {}
44
46
 
@@ -52,6 +54,10 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
52
54
  window: RoundWindow;
53
55
  weiPerPoint: bigint;
54
56
  walletCapWei: bigint;
57
+ pointsPerDollar: number;
58
+ usdPerEth: number;
59
+ maxPointsPerPlayer: number;
60
+ scoreAuthority: ScoreAuthority;
55
61
  poolId: string;
56
62
  booksCloseAt?: number;
57
63
  launch?: {
@@ -66,6 +72,9 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
66
72
  if (!Number.isSafeInteger(booksCloseAt) || booksCloseAt < round.window.closesAt) {
67
73
  throw new RangeError('booksCloseAt must be a safe timestamp at or after closesAt');
68
74
  }
75
+ if (!Number.isSafeInteger(round.maxPointsPerPlayer) || round.maxPointsPerPlayer <= 0) {
76
+ throw new RangeError('maxPointsPerPlayer must be a positive safe integer');
77
+ }
69
78
  const launch = Object.freeze({
70
79
  roundId: round.id,
71
80
  poolId: round.poolId,
@@ -77,16 +86,30 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
77
86
  closesAt: round.window.closesAt,
78
87
  booksCloseAt,
79
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
+ });
80
96
  const started = Round.start(game, round.config, round.seed, round.window);
81
97
  await pool.query(
82
98
  `insert into rounds (
83
- id, wei_per_point, wallet_cap_wei, pool_id, opens_at, closes_at, books_close_at,
99
+ id, game_id, wei_per_point, wallet_cap_wei, points_per_dollar, usd_per_eth,
100
+ max_points_per_player, score_authority,
101
+ pool_id, opens_at, closes_at, books_close_at,
84
102
  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)`,
103
+ ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 0)`,
86
104
  [
87
105
  round.id,
106
+ game.id,
88
107
  round.weiPerPoint.toString(),
89
108
  round.walletCapWei.toString(),
109
+ round.pointsPerDollar,
110
+ round.usdPerEth,
111
+ round.maxPointsPerPlayer,
112
+ round.scoreAuthority,
90
113
  round.poolId,
91
114
  round.window.opensAt,
92
115
  round.window.closesAt,
@@ -98,7 +121,17 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
98
121
  serialise(game.id, started.snapshot()),
99
122
  ],
100
123
  );
101
- return new Room(pool, game, round.id, started, round.window, launch, round.weiPerPoint, 0);
124
+ return new Room(
125
+ pool,
126
+ game,
127
+ round.id,
128
+ started,
129
+ round.window,
130
+ launch,
131
+ terms,
132
+ round.scoreAuthority,
133
+ 0,
134
+ );
102
135
  }
103
136
 
104
137
  /** Pick up a round that already exists. Returns null if there is no such round. */
@@ -119,14 +152,23 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
119
152
  coin_symbol: string;
120
153
  coin_image_url: string | null;
121
154
  wei_per_point: string;
155
+ wallet_cap_wei: string;
156
+ points_per_dollar: number;
157
+ usd_per_eth: number;
158
+ max_points_per_player: string;
159
+ score_authority: ScoreAuthority;
160
+ game_id: string;
122
161
  }>(
123
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,
164
+ max_points_per_player, score_authority, game_id,
124
165
  coin_address, coin_name, coin_symbol, coin_image_url
125
166
  from rounds where id = $1`,
126
167
  [id],
127
168
  );
128
169
  const row = result.rows[0];
129
170
  if (!row) return null;
171
+ if (row.game_id !== game.id) return null;
130
172
 
131
173
  const window = { opensAt: Number(row.opens_at), closesAt: Number(row.closes_at) };
132
174
  const launch = Object.freeze({
@@ -139,6 +181,13 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
139
181
  ...window,
140
182
  booksCloseAt: Number(row.books_close_at),
141
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
+ });
142
191
  return new Room(
143
192
  pool,
144
193
  game,
@@ -146,7 +195,8 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
146
195
  Round.resume(game, row.state, window),
147
196
  window,
148
197
  launch,
149
- toBigInt(row.wei_per_point),
198
+ terms,
199
+ row.score_authority,
150
200
  row.seq,
151
201
  );
152
202
  }
@@ -200,6 +250,9 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
200
250
  * is meant to be one writer per round; this is what makes a broken assumption loud.
201
251
  */
202
252
  private async commit(seq: number, command: Command<Action>, state: State, awards: Award[]): Promise<void> {
253
+ if (awards.length > 0 && this.scoreAuthority !== 'rules') {
254
+ throw new Error(`${this.id}: rules cannot award points for an externally scored round`);
255
+ }
203
256
  await inTransaction(this.pool, async (tx) => {
204
257
  const updated = await tx.query(
205
258
  `update rounds set state = $2, seq = $3 where id = $1 and seq = $4`,
@@ -216,11 +269,19 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
216
269
  for (const award of awards) {
217
270
  // Same transaction as the state that earned them. Points credited without the state that
218
271
  // justifies them, or the reverse, is a ledger nobody can reconcile.
219
- await tx.query(
272
+ const points = Math.floor(award.points);
273
+ if (points > this.terms.maxPointsPerPlayer) {
274
+ throw new Error(`${this.game.id}: awards exceeded the declared maximum for ${award.player}`);
275
+ }
276
+ const credited = await tx.query(
220
277
  `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)],
278
+ on conflict (round_id, player) do update set points = balances.points + excluded.points
279
+ where balances.points + excluded.points <= $4`,
280
+ [this.id, award.player, points, this.terms.maxPointsPerPlayer],
223
281
  );
282
+ if (credited.rowCount !== 1) {
283
+ throw new Error(`${this.game.id}: awards exceeded the declared maximum for ${award.player}`);
284
+ }
224
285
  }
225
286
  });
226
287
  }
package/src/server.ts CHANGED
@@ -4,15 +4,25 @@ 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
- import { Room } from './room.js';
15
+ import { Room, type ScoreAuthority } from './room.js';
10
16
  import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
11
17
  import { NotSignedIn, Sessions } from './sessions.js';
12
18
  import type { Discovery, Launch, PoolKey } from './chain/discover.js';
13
19
  import type { Settlement } from './settlement.js';
14
20
  import { resolveEconomy } from './economy.js';
15
21
  import { encodeHookData } from './chain/signer.js';
22
+ import { hasBearerToken } from './bearer.js';
23
+ import { GameServerAwards, type GameServerAwardRequest } from './game-server-awards.js';
24
+ import { issuePlayerJoinTicket } from './player-join-tickets.js';
25
+ import { gameServerOrigins } from './registry.js';
16
26
 
17
27
  /**
18
28
  * The gate's door.
@@ -26,9 +36,8 @@ import { encodeHookData } from './chain/signer.js';
26
36
  * that size would be guessing at the shape of a problem nobody has.
27
37
  */
28
38
 
29
- interface GateBaseOptions<Config, State, Event, Action, PublicView, PlayerView> {
39
+ interface GateBaseOptions {
30
40
  pool: Pool;
31
- game: GameModule<Config, State, Event, Action, PublicView, PlayerView>;
32
41
  sessions: Sessions;
33
42
  claims: Claims;
34
43
  /** How many points a dollar buys. The only economic number anyone sets by hand. */
@@ -65,18 +74,26 @@ interface GateBaseOptions<Config, State, Event, Action, PublicView, PlayerView>
65
74
  export type VerifiedLaunch = Readonly<Omit<Launch, 'poolKey'>> & { readonly poolKey: Readonly<PoolKey> };
66
75
  export type ConfigForLaunch<Config> = (launch: VerifiedLaunch) => Promise<Config>;
67
76
 
68
- export type GateOptions<Config, State, Event, Action, PublicView, PlayerView> = GateBaseOptions<
69
- Config,
70
- State,
71
- Event,
72
- Action,
73
- PublicView,
74
- PlayerView
75
- > & {
77
+ export type GateOptions<Config, State, Event, Action, PublicView, PlayerView> = GateBaseOptions & {
78
+ game: GameModule<Config, State, Event, Action, PublicView, PlayerView>;
76
79
  /** A static serialisable config, or a factory resolved once after each launch is verified. */
77
80
  config: Config | ConfigForLaunch<Config>;
78
81
  };
79
82
 
83
+ export type MaxPointsForLaunch = (launch: VerifiedLaunch) => Promise<number>;
84
+
85
+ /** Run a gate beside a creator-owned authoritative game server. */
86
+ export interface GameServerGateOptions extends GateBaseOptions {
87
+ /** Stable game identifier used to protect durable room state from a different game module. */
88
+ gameId: string;
89
+ /** The most one wallet can earn in a round, or a value resolved once for each verified launch. */
90
+ maxPointsPerPlayer: number | MaxPointsForLaunch;
91
+ /** Dedicated server-to-server token. Never expose it to browser game code. */
92
+ awardToken: string;
93
+ /** Reviewed HTTPS origins that may receive player join tickets. The gate origin is always allowed. */
94
+ gameServerOrigins?: readonly string[];
95
+ }
96
+
80
97
  /** Where an admission check is being made. Enough to tell a sign-in from a spend. */
81
98
  export interface AdmissionRequest {
82
99
  /** `session` on sign-in, `action` on gameplay, `claim` when allowance is about to be committed. */
@@ -105,6 +122,25 @@ const ADMIT_ALL: Admit = () => ({ ok: true });
105
122
  /** What the socket says, which is only the truth when nothing is proxying in front of the gate. */
106
123
  const ipOf = (request: FastifyRequest): string | null => request.ip || null;
107
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
+
108
144
  /** How often each open room is nudged so a deadline nobody is watching still fires. */
109
145
  const TICK_MS = 250;
110
146
 
@@ -131,8 +167,85 @@ type AdoptionResult<Config, State, Event, Action, PublicView, PlayerView> =
131
167
  | { ok: true; room: Room<Config, State, Event, Action, PublicView, PlayerView>; created: boolean }
132
168
  | { ok: false; status: 409 | 503; message: string };
133
169
 
170
+ interface AwardEndpoint {
171
+ token: string;
172
+ awards: GameServerAwards;
173
+ ticketAudiences: ReadonlySet<string>;
174
+ }
175
+
176
+ interface GameServerConfig {
177
+ maxPointsPerPlayer: number;
178
+ }
179
+
180
+ type GameServerState = Record<string, never>;
181
+ type GameServerEvent = never;
182
+ type GameServerAction = never;
183
+ type GameServerPublicView = Record<string, never>;
184
+ type GameServerPlayerView = Record<string, never>;
185
+
186
+ const GAME_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
187
+
188
+ function gameServerModule(
189
+ id: string,
190
+ ): GameModule<
191
+ GameServerConfig,
192
+ GameServerState,
193
+ GameServerEvent,
194
+ GameServerAction,
195
+ GameServerPublicView,
196
+ GameServerPlayerView
197
+ > {
198
+ return {
199
+ id,
200
+ parseAction: () => null,
201
+ initRound: () => ({}),
202
+ decide: (_state, command) => {
203
+ if (command.kind === 'action') return { refuse: 'gameplay.external' };
204
+ return { events: [] };
205
+ },
206
+ evolve: (state) => state,
207
+ publicView: () => ({}),
208
+ playerView: () => ({}),
209
+ nextWakeAt: () => null,
210
+ rewardBounds: (config) => ({ maxPointsPerPlayer: config.maxPointsPerPlayer }),
211
+ };
212
+ }
213
+
134
214
  export function createGate<Config, State, Event, Action, PublicView, PlayerView>(
135
215
  options: GateOptions<Config, State, Event, Action, PublicView, PlayerView>,
216
+ ): FastifyInstance {
217
+ return createGateRuntime(options, 'rules');
218
+ }
219
+
220
+ export function createGameServerGate(options: GameServerGateOptions): FastifyInstance {
221
+ if (typeof options.gameId !== 'string' || !GAME_ID.test(options.gameId)) {
222
+ throw new RangeError('gameId must be a valid identifier');
223
+ }
224
+ if (typeof options.awardToken !== 'string' || options.awardToken.length < 32) {
225
+ throw new RangeError('awardToken must be at least 32 characters');
226
+ }
227
+
228
+ const maximum = options.maxPointsPerPlayer;
229
+ if (typeof maximum !== 'function' && (!Number.isSafeInteger(maximum) || maximum <= 0)) {
230
+ throw new RangeError('maxPointsPerPlayer must be a positive safe integer');
231
+ }
232
+ const config: GameServerConfig | ConfigForLaunch<GameServerConfig> =
233
+ typeof maximum === 'function'
234
+ ? async (launch) => ({ maxPointsPerPlayer: await maximum(launch) })
235
+ : { maxPointsPerPlayer: maximum };
236
+ const ticketAudiences = new Set([options.sessions.origin, ...gameServerOrigins(options.gameServerOrigins)]);
237
+ const { gameId, maxPointsPerPlayer: _maximum, awardToken, gameServerOrigins: _origins, ...base } = options;
238
+ return createGateRuntime(
239
+ { ...base, game: gameServerModule(gameId), config },
240
+ 'external-server',
241
+ { token: awardToken, awards: new GameServerAwards(options.pool, gameId), ticketAudiences },
242
+ );
243
+ }
244
+
245
+ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>(
246
+ options: GateOptions<Config, State, Event, Action, PublicView, PlayerView>,
247
+ scoreAuthority: ScoreAuthority,
248
+ awardEndpoint?: AwardEndpoint,
136
249
  ): FastifyInstance {
137
250
  const { pool, game, sessions, claims, discovery, settlement } = options;
138
251
  const admit = options.admit ?? ADMIT_ALL;
@@ -174,6 +287,10 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
174
287
  const existing = rooms.get(id);
175
288
  if (existing) return existing;
176
289
  const loaded = await Room.load(pool, game, id);
290
+ if (loaded && loaded.scoreAuthority !== scoreAuthority) {
291
+ app.log.error(`${id}: round belongs to ${loaded.scoreAuthority}, not ${scoreAuthority}`);
292
+ return null;
293
+ }
177
294
  // A durable row is history after its books close, not permission to resurrect an active
178
295
  // ticker and market stream every time somebody refreshes an old coin page. It still gets one
179
296
  // bounded catch-up through closesAt: a restart spanning the whole tail must not strand final
@@ -245,7 +362,11 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
245
362
  seed: Number(BigInt(launch.poolId) % 2147483647n),
246
363
  window: { opensAt: launch.opensAt, closesAt: launch.closesAt },
247
364
  weiPerPoint: economy.economy.weiPerPoint,
365
+ pointsPerDollar: economy.economy.pointsPerDollar,
366
+ usdPerEth: economy.economy.usdPerEth,
248
367
  walletCapWei: launch.walletCapWei,
368
+ maxPointsPerPlayer,
369
+ scoreAuthority,
249
370
  poolId: launch.poolId,
250
371
  booksCloseAt: launch.closesAt + settlementTailMs,
251
372
  launch: {
@@ -381,6 +502,89 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
381
502
  .send(adopted.room.launch);
382
503
  });
383
504
 
505
+ if (awardEndpoint) {
506
+ app.post('/rounds/:id/join-ticket', async (request, reply) => {
507
+ const who = player(request);
508
+ const id = (request.params as { id: string }).id;
509
+ const room = await roomFor(id);
510
+ if (!room) return reply.code(404).send({ message: 'that game is not running' });
511
+ const now = Date.now();
512
+ if (now >= room.window.closesAt) {
513
+ return reply.code(409).send({ message: 'that game has ended' });
514
+ }
515
+ const requested = (request.body as { audience?: unknown } | null)?.audience;
516
+ const audience = requested === undefined ? sessions.origin : requested;
517
+ if (typeof audience !== 'string' || !awardEndpoint.ticketAudiences.has(audience)) {
518
+ return reply.code(400).send({ message: 'that game server is not registered' });
519
+ }
520
+ return issuePlayerJoinTicket({
521
+ awardToken: awardEndpoint.token,
522
+ player: who,
523
+ gameId: game.id,
524
+ roundId: id,
525
+ audience,
526
+ closesAt: room.window.closesAt,
527
+ now,
528
+ });
529
+ });
530
+
531
+ app.post('/internal/rounds/:id/awards', async (request, reply) => {
532
+ if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
533
+ return reply.code(401).send({ message: 'game server authentication failed' });
534
+ }
535
+ const body = (request.body ?? {}) as Partial<GameServerAwardRequest>;
536
+ const result = await awardEndpoint.awards.record({
537
+ roundId: (request.params as { id: string }).id,
538
+ eventId: body.eventId as string,
539
+ player: body.player as PlayerId,
540
+ points: body.points as number,
541
+ });
542
+ if (!result.ok) {
543
+ const status =
544
+ result.refuse === 'award.invalid'
545
+ ? 400
546
+ : result.refuse === 'award.no_such_round' || result.refuse === 'award.wrong_authority'
547
+ ? 404
548
+ : 409;
549
+ return reply.code(status).send({ refuse: result.refuse });
550
+ }
551
+
552
+ announcePlayer(result.award.roundId, result.award.player);
553
+ return reply.code(result.created ? 201 : 200).send(result.award);
554
+ });
555
+
556
+ /**
557
+ * What one wallet has earned, so a game server can refuse a seat before the player sits in it.
558
+ *
559
+ * Advisory, and deliberately so. The award route still enforces the ceiling inside the
560
+ * transaction that moves the balance, so a caller that stops handling `award.limit_exceeded`
561
+ * because it checked here first has moved the check out of the only place it is atomic.
562
+ *
563
+ * Never routed through `roomFor`: that resurrects rooms, advances game state and returns
564
+ * nothing once the books close — none of which a read should do.
565
+ */
566
+ app.get('/internal/rounds/:id/players/:player', async (request, reply) => {
567
+ if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
568
+ return reply.code(401).send({ message: 'game server authentication failed' });
569
+ }
570
+ const params = request.params as { id: string; player: string };
571
+ const result = await awardEndpoint.awards.pointsFor({
572
+ roundId: params.id,
573
+ player: params.player as PlayerId,
574
+ });
575
+ if (!result.ok) {
576
+ return reply.code(result.refuse === 'award.invalid' ? 400 : 404).send({ refuse: result.refuse });
577
+ }
578
+
579
+ // `heldWei` moves on its own when an authorisation expires, so this answer is only true at the
580
+ // moment it is read. Points are monotone; the wei beside them are not.
581
+ const balance = await claims.economyBalance(result.standing.roundId, result.standing.player);
582
+ return reply
583
+ .header('cache-control', 'no-store')
584
+ .send({ ...result.standing, economy: wireEconomy(balance) });
585
+ });
586
+ }
587
+
384
588
  app.get('/rounds/:id', async (request, reply) => {
385
589
  const id = (request.params as { id: string }).id;
386
590
  const room = await roomFor(id);
@@ -388,7 +592,12 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
388
592
  const before = room.sequence();
389
593
  await room.tick(Date.now());
390
594
  if (room.sequence() !== before) announce(id);
391
- return { sequence: room.sequence(), view: room.publicView(), launch: room.launch };
595
+ return {
596
+ sequence: room.sequence(),
597
+ view: room.publicView(),
598
+ launch: room.launch,
599
+ terms: wireTerms(room.terms),
600
+ };
392
601
  });
393
602
 
394
603
  app.post('/rounds/:id/join', async (request, reply) => {
@@ -396,6 +605,10 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
396
605
  if (!room) return reply.code(404).send({ message: 'that game is not running' });
397
606
 
398
607
  const id = (request.params as { id: string }).id;
608
+ if (scoreAuthority === 'external-server') {
609
+ player(request);
610
+ return { joined: true };
611
+ }
399
612
  const before = room.sequence();
400
613
  const result = await room.apply({
401
614
  kind: 'join',
@@ -432,6 +645,8 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
432
645
  app.post('/rounds/:id/claim', async (request, reply) => {
433
646
  const id = (request.params as { id: string }).id;
434
647
  const who = player(request);
648
+ const room = await roomFor(id);
649
+ if (!room) return reply.code(404).send({ message: 'that game is not running' });
435
650
  const { maxSpendWei, requestId } = request.body as { maxSpendWei?: string; requestId?: unknown };
436
651
  let upTo: bigint;
437
652
  try {
@@ -558,21 +773,14 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
558
773
  const balance = viewer
559
774
  ? await claims.economyBalance(id, viewer)
560
775
  : {
561
- weiPerPoint: room.weiPerPoint,
776
+ weiPerPoint: room.terms.weiPerPoint,
562
777
  earnedWei: 0n,
563
778
  heldWei: 0n,
564
779
  spentWei: 0n,
565
780
  availableWei: 0n,
566
781
  holdExpiresAt: null,
567
782
  };
568
- const economy = {
569
- weiPerPoint: balance.weiPerPoint.toString(),
570
- earnedWei: balance.earnedWei.toString(),
571
- heldWei: balance.heldWei.toString(),
572
- spentWei: balance.spentWei.toString(),
573
- availableWei: balance.availableWei.toString(),
574
- holdExpiresAt: balance.holdExpiresAt,
575
- };
783
+ const economy = wireEconomy(balance);
576
784
  return {
577
785
  v: LIVE_PROTOCOL_VERSION,
578
786
  type: 'snapshot',
@@ -581,6 +789,7 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
581
789
  view: room.publicView(),
582
790
  you: viewer ? room.playerView(viewer) : null,
583
791
  economy,
792
+ terms: wireTerms(room.terms),
584
793
  launch: room.launch,
585
794
  presence: presence(id),
586
795
  };
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
  }