@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.
- package/README.md +81 -2
- package/dist/bearer.d.ts +3 -0
- package/dist/bearer.d.ts.map +1 -0
- package/dist/bearer.js +8 -0
- package/dist/bearer.js.map +1 -0
- package/dist/demo.d.ts.map +1 -1
- package/dist/demo.js +6 -1
- 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 +2 -2
- package/dist/economy.js.map +1 -1
- package/dist/game-registry.d.ts.map +1 -1
- package/dist/game-registry.js +6 -8
- package/dist/game-registry.js.map +1 -1
- package/dist/game-server-awards.d.ts +76 -0
- package/dist/game-server-awards.d.ts.map +1 -0
- package/dist/game-server-awards.js +172 -0
- package/dist/game-server-awards.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +0 -2
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +0 -7
- package/dist/ledger.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 +8 -2
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +50 -9
- package/dist/room.js.map +1 -1
- package/dist/server.d.ts +16 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +162 -10
- 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 +53 -0
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/bearer.ts +8 -0
- package/src/demo.ts +6 -1
- package/src/economy.ts +11 -2
- package/src/game-registry.ts +12 -10
- package/src/game-server-awards.ts +285 -0
- package/src/index.ts +13 -0
- package/src/ledger.ts +0 -10
- package/src/player-join-tickets.ts +151 -0
- package/src/registry.ts +34 -10
- package/src/room.ts +70 -9
- package/src/server.ts +231 -22
- package/src/sessions.ts +5 -0
- package/src/store.ts +53 -0
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
|
|
|
@@ -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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
}
|