@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flayerlabs/gamemode-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Server-authoritative rooms, economy, admission and registry for Flaunch Game Modes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Flayer Labs",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"db": "docker run -d --rm --name gamemode-pg -e POSTGRES_PASSWORD=gamemode -e POSTGRES_DB=gamemode -p 55439:5432 postgres:16-alpine"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@flayerlabs/gamemode-spec": "^0.1.0",
|
|
46
45
|
"@fastify/cors": "^11.3.0",
|
|
47
46
|
"@fastify/websocket": "^11.3.0",
|
|
47
|
+
"@flayerlabs/gamemode-spec": "^0.3.0",
|
|
48
48
|
"@types/node": "^22.10.2",
|
|
49
49
|
"@types/pg": "^8.21.0",
|
|
50
50
|
"fastify": "^5.11.3",
|
package/src/bearer.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** Compare a server-to-server bearer token without leaking how much of it matched. */
|
|
4
|
+
export function hasBearerToken(header: string | undefined, expected: string): boolean {
|
|
5
|
+
const supplied = header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
|
|
6
|
+
const digest = (value: string): Buffer => createHash('sha256').update(value).digest();
|
|
7
|
+
return timingSafeEqual(digest(supplied), digest(expected));
|
|
8
|
+
}
|
package/src/demo.ts
CHANGED
|
@@ -86,8 +86,9 @@ export async function createDemoGate<Config, State, Event, Action, PublicView, P
|
|
|
86
86
|
|
|
87
87
|
// The same check the real gate runs at boot. A demo that silently used an impossible economy
|
|
88
88
|
// would teach a builder that their numbers work when they do not.
|
|
89
|
+
const maxPointsPerPlayer = game.rewardBounds(config).maxPointsPerPlayer;
|
|
89
90
|
const economy = resolveEconomy({
|
|
90
|
-
maxPointsPerPlayer
|
|
91
|
+
maxPointsPerPlayer,
|
|
91
92
|
walletCapWei,
|
|
92
93
|
pointsPerDollar,
|
|
93
94
|
usdPerEth,
|
|
@@ -110,7 +111,11 @@ export async function createDemoGate<Config, State, Event, Action, PublicView, P
|
|
|
110
111
|
seed: Math.floor(Math.random() * 1_000_000),
|
|
111
112
|
window: { opensAt, closesAt: opensAt + roundMs },
|
|
112
113
|
weiPerPoint: economy.economy.weiPerPoint,
|
|
114
|
+
pointsPerDollar: economy.economy.pointsPerDollar,
|
|
115
|
+
usdPerEth: economy.economy.usdPerEth,
|
|
113
116
|
walletCapWei,
|
|
117
|
+
maxPointsPerPlayer,
|
|
118
|
+
scoreAuthority: 'rules',
|
|
114
119
|
poolId: DEMO_POOL_ID,
|
|
115
120
|
});
|
|
116
121
|
|
package/src/economy.ts
CHANGED
|
@@ -27,6 +27,15 @@ export interface Economy {
|
|
|
27
27
|
weiPerPoint: bigint;
|
|
28
28
|
/** What a flawless round pays. Always at or under `walletCapWei`, or this would not exist. */
|
|
29
29
|
maxPayoutWei: bigint;
|
|
30
|
+
/**
|
|
31
|
+
* The two inputs, returned beside the rate they produced.
|
|
32
|
+
*
|
|
33
|
+
* They travel together so a caller cannot persist a rate from one calculation and a dollar
|
|
34
|
+
* figure from another. Both are process-wide settings, so the only place they are true of a
|
|
35
|
+
* particular round is the moment that round's rate was fixed.
|
|
36
|
+
*/
|
|
37
|
+
pointsPerDollar: number;
|
|
38
|
+
usdPerEth: number;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
export type EconomyResult =
|
|
@@ -53,7 +62,7 @@ export function resolveEconomy(input: EconomyInput): EconomyResult {
|
|
|
53
62
|
if (!Number.isFinite(usdPerEth) || usdPerEth <= 0) {
|
|
54
63
|
return { ok: false, problem: 'the ETH price must be a positive number', suggestedPointsPerDollar: pointsPerDollar };
|
|
55
64
|
}
|
|
56
|
-
if (!Number.
|
|
65
|
+
if (!Number.isSafeInteger(maxPointsPerPlayer) || maxPointsPerPlayer <= 0) {
|
|
57
66
|
return {
|
|
58
67
|
ok: false,
|
|
59
68
|
problem: 'the game says a player can earn at most 0 points, so there is nothing to pay',
|
|
@@ -99,7 +108,7 @@ export function resolveEconomy(input: EconomyInput): EconomyResult {
|
|
|
99
108
|
};
|
|
100
109
|
}
|
|
101
110
|
|
|
102
|
-
return { ok: true, economy: { weiPerPoint, maxPayoutWei } };
|
|
111
|
+
return { ok: true, economy: { weiPerPoint, maxPayoutWei, pointsPerDollar, usdPerEth } };
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
/**
|
package/src/game-registry.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
2
2
|
import cors from '@fastify/cors';
|
|
3
|
-
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
4
3
|
import type { Pool } from 'pg';
|
|
5
4
|
import { isAddress } from 'viem';
|
|
5
|
+
import { hasBearerToken } from './bearer.js';
|
|
6
6
|
import {
|
|
7
7
|
InvalidRegistration,
|
|
8
8
|
RegistrationConflict,
|
|
@@ -19,12 +19,6 @@ export interface GameRegistryOptions {
|
|
|
19
19
|
writerToken: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const authorised = (header: string | undefined, token: string): boolean => {
|
|
23
|
-
const supplied = header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
|
|
24
|
-
const digest = (value: string): Buffer => createHash('sha256').update(value).digest();
|
|
25
|
-
return timingSafeEqual(digest(supplied), digest(token));
|
|
26
|
-
};
|
|
27
|
-
|
|
28
22
|
const routeChainId = (value: string): number | null => {
|
|
29
23
|
if (!/^[1-9][0-9]*$/.test(value)) return null;
|
|
30
24
|
const parsed = Number(value);
|
|
@@ -35,7 +29,7 @@ const internalRoute = (
|
|
|
35
29
|
request: { headers: { authorization?: string | undefined }; params: unknown },
|
|
36
30
|
token: string,
|
|
37
31
|
) => {
|
|
38
|
-
if (!
|
|
32
|
+
if (!hasBearerToken(request.headers.authorization, token)) {
|
|
39
33
|
return { ok: false as const, status: 401 as const, message: 'registry writer authentication failed' };
|
|
40
34
|
}
|
|
41
35
|
const { chainId: rawChainId, coin } = request.params as { chainId: string; coin: string };
|
|
@@ -53,7 +47,7 @@ const internalRoute = (
|
|
|
53
47
|
*/
|
|
54
48
|
export function createGameRegistry(options: GameRegistryOptions): FastifyInstance {
|
|
55
49
|
if (options.writerToken.length < 32) throw new RangeError('registry writer token must be at least 32 characters');
|
|
56
|
-
const app = Fastify({ bodyLimit:
|
|
50
|
+
const app = Fastify({ bodyLimit: 2_048 });
|
|
57
51
|
void app.register(cors, { origin: [...options.allowedOrigins], methods: ['GET'] });
|
|
58
52
|
|
|
59
53
|
app.get('/games/:chainId/:coin', async (request, reply) => {
|
|
@@ -72,7 +66,12 @@ export function createGameRegistry(options: GameRegistryOptions): FastifyInstanc
|
|
|
72
66
|
app.put('/internal/games/:chainId/:coin', async (request, reply) => {
|
|
73
67
|
const route = internalRoute(request, options.writerToken);
|
|
74
68
|
if (!route.ok) return reply.code(route.status).send({ message: route.message });
|
|
75
|
-
const body = (request.body ?? {}) as {
|
|
69
|
+
const body = (request.body ?? {}) as {
|
|
70
|
+
gameId?: unknown;
|
|
71
|
+
gateOrigin?: unknown;
|
|
72
|
+
deployId?: unknown;
|
|
73
|
+
gameServerOrigins?: unknown;
|
|
74
|
+
};
|
|
76
75
|
try {
|
|
77
76
|
const registration = await pinGameRegistration(options.pool, {
|
|
78
77
|
chainId: route.chainId,
|
|
@@ -80,6 +79,9 @@ export function createGameRegistry(options: GameRegistryOptions): FastifyInstanc
|
|
|
80
79
|
gameId: body.gameId as string,
|
|
81
80
|
gateOrigin: body.gateOrigin as string,
|
|
82
81
|
deployId: body.deployId as string,
|
|
82
|
+
...(body.gameServerOrigins === undefined
|
|
83
|
+
? {}
|
|
84
|
+
: { gameServerOrigins: body.gameServerOrigins as readonly string[] }),
|
|
83
85
|
});
|
|
84
86
|
return reply.send(registration);
|
|
85
87
|
} catch (error) {
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import type { Pool } from 'pg';
|
|
2
|
+
import { isAddress } from 'viem';
|
|
3
|
+
import type { PlayerId } from '@flayerlabs/gamemode-spec';
|
|
4
|
+
import { inTransaction } from './store.js';
|
|
5
|
+
|
|
6
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
7
|
+
|
|
8
|
+
/** The JSON body a trusted creator server sends to the gate. */
|
|
9
|
+
export interface GameServerAwardRequest {
|
|
10
|
+
eventId: string;
|
|
11
|
+
player: PlayerId;
|
|
12
|
+
points: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The durable receipt returned for a new award or an identical retry. */
|
|
16
|
+
export interface GameServerAwardReceipt extends GameServerAwardRequest {
|
|
17
|
+
roundId: string;
|
|
18
|
+
totalPoints: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type GameServerAwardRefusal =
|
|
22
|
+
| 'award.invalid'
|
|
23
|
+
| 'award.no_such_round'
|
|
24
|
+
| 'award.wrong_authority'
|
|
25
|
+
| 'award.not_open_yet'
|
|
26
|
+
| 'award.window_closed'
|
|
27
|
+
| 'award.event_mismatch'
|
|
28
|
+
| 'award.limit_exceeded';
|
|
29
|
+
|
|
30
|
+
export type GameServerAwardResult =
|
|
31
|
+
| { ok: true; created: boolean; award: GameServerAwardReceipt }
|
|
32
|
+
| { ok: false; refuse: GameServerAwardRefusal };
|
|
33
|
+
|
|
34
|
+
/** What one wallet has earned in a round, and how much more it is allowed to earn. */
|
|
35
|
+
export interface GameServerPlayerStanding {
|
|
36
|
+
roundId: string;
|
|
37
|
+
player: PlayerId;
|
|
38
|
+
/** This wallet's current round total, the same number an award receipt reports. */
|
|
39
|
+
totalPoints: number;
|
|
40
|
+
maxPointsPerPlayer: number;
|
|
41
|
+
/** `maxPointsPerPlayer` minus `totalPoints`, never below zero. */
|
|
42
|
+
remainingPoints: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A read cannot fail for a reason that only applies to a write, so it cannot return one.
|
|
47
|
+
*
|
|
48
|
+
* Extracted from the award vocabulary rather than restated beside it: the codes a game server
|
|
49
|
+
* switches on stay one list, and this read can only ever narrow that list, never extend it.
|
|
50
|
+
*/
|
|
51
|
+
export type GameServerPlayerRefusal = Extract<
|
|
52
|
+
GameServerAwardRefusal,
|
|
53
|
+
'award.invalid' | 'award.no_such_round' | 'award.wrong_authority'
|
|
54
|
+
>;
|
|
55
|
+
|
|
56
|
+
export type GameServerPlayerResult =
|
|
57
|
+
| { ok: true; standing: GameServerPlayerStanding }
|
|
58
|
+
| { ok: false; refuse: GameServerPlayerRefusal };
|
|
59
|
+
|
|
60
|
+
interface GameServerAwardInput extends GameServerAwardRequest {
|
|
61
|
+
roundId: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface StoredAward {
|
|
65
|
+
round_id: string;
|
|
66
|
+
event_id: string;
|
|
67
|
+
player: string;
|
|
68
|
+
points: string;
|
|
69
|
+
total_points: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A result that must roll back any setup writes before it reaches the caller. */
|
|
73
|
+
class RollbackResult extends Error {
|
|
74
|
+
constructor(readonly result: GameServerAwardResult) {
|
|
75
|
+
super('game server award did not commit');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const receipt = (row: StoredAward): GameServerAwardReceipt => ({
|
|
80
|
+
roundId: row.round_id,
|
|
81
|
+
eventId: row.event_id,
|
|
82
|
+
player: row.player,
|
|
83
|
+
points: Number(row.points),
|
|
84
|
+
totalPoints: Number(row.total_points),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const matches = (stored: GameServerAwardReceipt, input: GameServerAwardInput): boolean =>
|
|
88
|
+
stored.roundId === input.roundId &&
|
|
89
|
+
stored.eventId === input.eventId &&
|
|
90
|
+
stored.player === input.player &&
|
|
91
|
+
stored.points === input.points;
|
|
92
|
+
|
|
93
|
+
const replay = (stored: GameServerAwardReceipt, input: GameServerAwardInput): GameServerAwardResult =>
|
|
94
|
+
matches(stored, input)
|
|
95
|
+
? { ok: true, created: false, award: stored }
|
|
96
|
+
: { ok: false, refuse: 'award.event_mismatch' };
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Credit events from one trusted creator game server.
|
|
100
|
+
*
|
|
101
|
+
* The event receipt and balance move in one transaction. A stable event id makes retries safe,
|
|
102
|
+
* while the adopted round fixes the wallet, time and reward ceilings this module enforces.
|
|
103
|
+
*/
|
|
104
|
+
export class GameServerAwards {
|
|
105
|
+
constructor(
|
|
106
|
+
private readonly pool: Pool,
|
|
107
|
+
private readonly gameId: string,
|
|
108
|
+
) {}
|
|
109
|
+
|
|
110
|
+
async record(candidate: GameServerAwardInput): Promise<GameServerAwardResult> {
|
|
111
|
+
if (
|
|
112
|
+
!candidate ||
|
|
113
|
+
typeof candidate.roundId !== 'string' ||
|
|
114
|
+
!IDENTIFIER.test(candidate.roundId) ||
|
|
115
|
+
typeof candidate.eventId !== 'string' ||
|
|
116
|
+
!IDENTIFIER.test(candidate.eventId) ||
|
|
117
|
+
typeof candidate.player !== 'string' ||
|
|
118
|
+
!isAddress(candidate.player, { strict: false }) ||
|
|
119
|
+
!Number.isSafeInteger(candidate.points) ||
|
|
120
|
+
candidate.points <= 0
|
|
121
|
+
) {
|
|
122
|
+
return { ok: false, refuse: 'award.invalid' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const input = { ...candidate, player: candidate.player.toLowerCase() };
|
|
126
|
+
const receivedAt = Date.now();
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
return await inTransaction(this.pool, async (tx) => {
|
|
130
|
+
const prior = await tx.query<StoredAward>(
|
|
131
|
+
`select awards.round_id, awards.event_id, awards.player,
|
|
132
|
+
awards.points::text, awards.total_points::text
|
|
133
|
+
from award_events awards
|
|
134
|
+
join rounds on rounds.id = awards.round_id
|
|
135
|
+
where awards.round_id = $1 and awards.event_id = $2 and rounds.game_id = $3`,
|
|
136
|
+
[input.roundId, input.eventId, this.gameId],
|
|
137
|
+
);
|
|
138
|
+
if (prior.rows[0]) return replay(receipt(prior.rows[0]), input);
|
|
139
|
+
|
|
140
|
+
const round = await tx.query<{
|
|
141
|
+
opens_at: string;
|
|
142
|
+
closes_at: string;
|
|
143
|
+
max_points_per_player: string;
|
|
144
|
+
score_authority: 'rules' | 'external-server';
|
|
145
|
+
game_id: string;
|
|
146
|
+
}>(
|
|
147
|
+
`select opens_at, closes_at, max_points_per_player, score_authority, game_id
|
|
148
|
+
from rounds where id = $1`,
|
|
149
|
+
[input.roundId],
|
|
150
|
+
);
|
|
151
|
+
const terms = round.rows[0];
|
|
152
|
+
if (!terms || terms.game_id !== this.gameId) {
|
|
153
|
+
return { ok: false, refuse: 'award.no_such_round' } as const;
|
|
154
|
+
}
|
|
155
|
+
if (terms.score_authority !== 'external-server') {
|
|
156
|
+
return { ok: false, refuse: 'award.wrong_authority' } as const;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Reserve the event id before checking the balance. A concurrent retry waits on this
|
|
160
|
+
// unique row and then reads the first request's receipt. Without that ordering, it could
|
|
161
|
+
// see the first request's new balance and incorrectly fail the reward ceiling check.
|
|
162
|
+
const inserted = await tx.query<StoredAward>(
|
|
163
|
+
`insert into award_events (round_id, event_id, player, points, total_points)
|
|
164
|
+
values ($1, $2, $3, $4, $4)
|
|
165
|
+
on conflict (round_id, event_id) do nothing
|
|
166
|
+
returning round_id, event_id, player, points::text, total_points::text`,
|
|
167
|
+
[input.roundId, input.eventId, input.player, input.points],
|
|
168
|
+
);
|
|
169
|
+
if (!inserted.rows[0]) {
|
|
170
|
+
const concurrent = await tx.query<StoredAward>(
|
|
171
|
+
`select round_id, event_id, player, points::text, total_points::text
|
|
172
|
+
from award_events where round_id = $1 and event_id = $2`,
|
|
173
|
+
[input.roundId, input.eventId],
|
|
174
|
+
);
|
|
175
|
+
const stored = concurrent.rows[0];
|
|
176
|
+
if (!stored) throw new Error(`${input.roundId}: award conflict disappeared`);
|
|
177
|
+
throw new RollbackResult(replay(receipt(stored), input));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Establish idempotency before checking the clock. A retry may arrive after closesAt while
|
|
181
|
+
// the original request is still committing; it must recover that result, not lose it.
|
|
182
|
+
if (receivedAt < Number(terms.opens_at)) {
|
|
183
|
+
throw new RollbackResult({ ok: false, refuse: 'award.not_open_yet' });
|
|
184
|
+
}
|
|
185
|
+
if (receivedAt >= Number(terms.closes_at)) {
|
|
186
|
+
throw new RollbackResult({ ok: false, refuse: 'award.window_closed' });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A claim locks the same row. Creating it before locking means a first award and first claim
|
|
190
|
+
// cannot both pass through a missing row without seeing each other.
|
|
191
|
+
await tx.query(
|
|
192
|
+
`insert into balances (round_id, player, points) values ($1, $2, 0)
|
|
193
|
+
on conflict (round_id, player) do nothing`,
|
|
194
|
+
[input.roundId, input.player],
|
|
195
|
+
);
|
|
196
|
+
const balance = await tx.query<{ points: string }>(
|
|
197
|
+
`select points::text from balances where round_id = $1 and player = $2 for update`,
|
|
198
|
+
[input.roundId, input.player],
|
|
199
|
+
);
|
|
200
|
+
const total = BigInt(balance.rows[0]?.points ?? '0') + BigInt(input.points);
|
|
201
|
+
if (total > BigInt(terms.max_points_per_player)) {
|
|
202
|
+
throw new RollbackResult({ ok: false, refuse: 'award.limit_exceeded' });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await tx.query(`update balances set points = $3 where round_id = $1 and player = $2`, [
|
|
206
|
+
input.roundId,
|
|
207
|
+
input.player,
|
|
208
|
+
total.toString(),
|
|
209
|
+
]);
|
|
210
|
+
await tx.query(
|
|
211
|
+
`update award_events set total_points = $3 where round_id = $1 and event_id = $2`,
|
|
212
|
+
[input.roundId, input.eventId, total.toString()],
|
|
213
|
+
);
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
created: true,
|
|
217
|
+
award: { ...input, totalPoints: Number(total) },
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error instanceof RollbackResult) return error.result;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* What this wallet has earned so far, and how much more it may earn.
|
|
228
|
+
*
|
|
229
|
+
* Advisory. It takes no lock, so a concurrent award can move it between this answer and the next
|
|
230
|
+
* request; {@link record} remains the only arbiter of the ceiling, and a caller must still handle
|
|
231
|
+
* `award.limit_exceeded`. It lives here rather than in the route because it reads the same two
|
|
232
|
+
* tables under the same game scope as `record`, and a second copy of that scoping in the
|
|
233
|
+
* transport layer is how one of them ends up missing.
|
|
234
|
+
*/
|
|
235
|
+
async pointsFor(query: { roundId: string; player: PlayerId }): Promise<GameServerPlayerResult> {
|
|
236
|
+
if (
|
|
237
|
+
!query ||
|
|
238
|
+
typeof query.roundId !== 'string' ||
|
|
239
|
+
!IDENTIFIER.test(query.roundId) ||
|
|
240
|
+
typeof query.player !== 'string' ||
|
|
241
|
+
!isAddress(query.player, { strict: false })
|
|
242
|
+
) {
|
|
243
|
+
return { ok: false, refuse: 'award.invalid' };
|
|
244
|
+
}
|
|
245
|
+
// Balances are keyed by the lower-cased wallet, exactly as `record` writes them. Reading with a
|
|
246
|
+
// checksummed address instead would quietly answer zero for a player who has already scored.
|
|
247
|
+
const player = query.player.toLowerCase();
|
|
248
|
+
|
|
249
|
+
// Scoped by game in the `where`, so another game's round reads as no such round rather than as
|
|
250
|
+
// an empty standing — the same answer `record` gives, in one round trip instead of two.
|
|
251
|
+
const result = await this.pool.query<{
|
|
252
|
+
max_points_per_player: string;
|
|
253
|
+
score_authority: 'rules' | 'external-server';
|
|
254
|
+
points: string;
|
|
255
|
+
}>(
|
|
256
|
+
`select rounds.max_points_per_player::text,
|
|
257
|
+
rounds.score_authority,
|
|
258
|
+
coalesce(balances.points, 0)::text as points
|
|
259
|
+
from rounds
|
|
260
|
+
left join balances on balances.round_id = rounds.id and balances.player = $2
|
|
261
|
+
where rounds.id = $1 and rounds.game_id = $3`,
|
|
262
|
+
[query.roundId, player, this.gameId],
|
|
263
|
+
);
|
|
264
|
+
const round = result.rows[0];
|
|
265
|
+
if (!round) return { ok: false, refuse: 'award.no_such_round' };
|
|
266
|
+
if (round.score_authority !== 'external-server') {
|
|
267
|
+
return { ok: false, refuse: 'award.wrong_authority' };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Safe as numbers because the ceiling is a safe integer at round creation and the award path
|
|
271
|
+
// never lets a balance pass it.
|
|
272
|
+
const maxPointsPerPlayer = Number(round.max_points_per_player);
|
|
273
|
+
const totalPoints = Number(round.points);
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
standing: {
|
|
277
|
+
roundId: query.roundId,
|
|
278
|
+
player,
|
|
279
|
+
totalPoints,
|
|
280
|
+
maxPointsPerPlayer,
|
|
281
|
+
remainingPoints: Math.max(0, maxPointsPerPlayer - totalPoints),
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,13 +5,16 @@ export {
|
|
|
5
5
|
Room,
|
|
6
6
|
Sessions,
|
|
7
7
|
createGate,
|
|
8
|
+
createGameServerGate,
|
|
8
9
|
} from './server.js';
|
|
9
10
|
export type {
|
|
10
11
|
Admission,
|
|
11
12
|
AdmissionRequest,
|
|
12
13
|
Admit,
|
|
13
14
|
ConfigForLaunch,
|
|
15
|
+
GameServerGateOptions,
|
|
14
16
|
GateOptions,
|
|
17
|
+
MaxPointsForLaunch,
|
|
15
18
|
VerifiedLaunch,
|
|
16
19
|
} from './server.js';
|
|
17
20
|
export { createTurnstileAdmit } from './turnstile.js';
|
|
@@ -43,3 +46,13 @@ export {
|
|
|
43
46
|
export type { Registration, SecurityProfile } from './registry.js';
|
|
44
47
|
export { createGameRegistry } from './game-registry.js';
|
|
45
48
|
export type { GameRegistryOptions } from './game-registry.js';
|
|
49
|
+
export type {
|
|
50
|
+
GameServerAwardReceipt,
|
|
51
|
+
GameServerAwardRefusal,
|
|
52
|
+
GameServerAwardRequest,
|
|
53
|
+
GameServerPlayerRefusal,
|
|
54
|
+
GameServerPlayerResult,
|
|
55
|
+
GameServerPlayerStanding,
|
|
56
|
+
} from './game-server-awards.js';
|
|
57
|
+
export { PLAYER_JOIN_TICKET_TTL_MS, verifyPlayerJoinTicket } from './player-join-tickets.js';
|
|
58
|
+
export type { VerifiedPlayerJoin, VerifyPlayerJoinTicketOptions } from './player-join-tickets.js';
|
package/src/ledger.ts
CHANGED
|
@@ -87,16 +87,6 @@ export class Ledger {
|
|
|
87
87
|
await migrate(this.pool);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/** Credit points. Called once per award the game emits. */
|
|
91
|
-
async award(roundId: string, player: PlayerId, points: number): Promise<void> {
|
|
92
|
-
if (points <= 0) return;
|
|
93
|
-
await this.pool.query(
|
|
94
|
-
`insert into balances (round_id, player, points) values ($1, $2, $3)
|
|
95
|
-
on conflict (round_id, player) do update set points = balances.points + excluded.points`,
|
|
96
|
-
[roundId, player, Math.floor(points)],
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
90
|
/**
|
|
101
91
|
* Authorise up to `upToWei`, or refuse.
|
|
102
92
|
*
|
|
@@ -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
|
+
}
|