@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.
- package/README.md +37 -4
- package/dist/demo.d.ts.map +1 -1
- package/dist/demo.js +2 -0
- package/dist/demo.js.map +1 -1
- package/dist/economy.d.ts +9 -0
- package/dist/economy.d.ts.map +1 -1
- package/dist/economy.js +1 -1
- package/dist/economy.js.map +1 -1
- package/dist/game-registry.d.ts.map +1 -1
- package/dist/game-registry.js +4 -1
- package/dist/game-registry.js.map +1 -1
- package/dist/game-server-awards.d.ts +37 -0
- package/dist/game-server-awards.d.ts.map +1 -1
- package/dist/game-server-awards.js +49 -0
- package/dist/game-server-awards.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/player-join-tickets.d.ts +29 -0
- package/dist/player-join-tickets.d.ts.map +1 -0
- package/dist/player-join-tickets.js +112 -0
- package/dist/player-join-tickets.js.map +1 -0
- package/dist/registry.d.ts +5 -9
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +24 -4
- package/dist/registry.js.map +1 -1
- package/dist/room.d.ts +4 -3
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +27 -11
- package/dist/room.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +91 -13
- package/dist/server.js.map +1 -1
- package/dist/sessions.d.ts +2 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +4 -0
- package/dist/sessions.js.map +1 -1
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +14 -0
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/demo.ts +2 -0
- package/src/economy.ts +10 -1
- package/src/game-registry.ts +10 -2
- package/src/game-server-awards.ts +86 -0
- package/src/index.ts +5 -0
- package/src/player-join-tickets.ts +151 -0
- package/src/registry.ts +34 -10
- package/src/room.ts +31 -11
- package/src/server.ts +105 -14
- package/src/sessions.ts +5 -0
- 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
|
|
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
|
|
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(
|
|
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: {
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|