@flayerlabs/gamemode-gate 0.1.0 → 0.2.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 +48 -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 +4 -1
- package/dist/demo.js.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 +2 -7
- package/dist/game-registry.js.map +1 -1
- package/dist/game-server-awards.d.ts +39 -0
- package/dist/game-server-awards.d.ts.map +1 -0
- package/dist/game-server-awards.js +123 -0
- package/dist/game-server-awards.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -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/room.d.ts +5 -0
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +32 -7
- package/dist/room.js.map +1 -1
- package/dist/server.d.ts +14 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +77 -0
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +39 -0
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/bearer.ts +8 -0
- package/src/demo.ts +4 -1
- package/src/economy.ts +1 -1
- package/src/game-registry.ts +2 -8
- package/src/game-server-awards.ts +199 -0
- package/src/index.ts +8 -0
- package/src/ledger.ts +0 -10
- package/src/room.ts +47 -6
- package/src/server.ts +132 -11
- package/src/store.ts +39 -0
package/src/economy.ts
CHANGED
|
@@ -53,7 +53,7 @@ export function resolveEconomy(input: EconomyInput): EconomyResult {
|
|
|
53
53
|
if (!Number.isFinite(usdPerEth) || usdPerEth <= 0) {
|
|
54
54
|
return { ok: false, problem: 'the ETH price must be a positive number', suggestedPointsPerDollar: pointsPerDollar };
|
|
55
55
|
}
|
|
56
|
-
if (!Number.
|
|
56
|
+
if (!Number.isSafeInteger(maxPointsPerPlayer) || maxPointsPerPlayer <= 0) {
|
|
57
57
|
return {
|
|
58
58
|
ok: false,
|
|
59
59
|
problem: 'the game says a player can earn at most 0 points, so there is nothing to pay',
|
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 };
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
interface GameServerAwardInput extends GameServerAwardRequest {
|
|
35
|
+
roundId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface StoredAward {
|
|
39
|
+
round_id: string;
|
|
40
|
+
event_id: string;
|
|
41
|
+
player: string;
|
|
42
|
+
points: string;
|
|
43
|
+
total_points: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A result that must roll back any setup writes before it reaches the caller. */
|
|
47
|
+
class RollbackResult extends Error {
|
|
48
|
+
constructor(readonly result: GameServerAwardResult) {
|
|
49
|
+
super('game server award did not commit');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const receipt = (row: StoredAward): GameServerAwardReceipt => ({
|
|
54
|
+
roundId: row.round_id,
|
|
55
|
+
eventId: row.event_id,
|
|
56
|
+
player: row.player,
|
|
57
|
+
points: Number(row.points),
|
|
58
|
+
totalPoints: Number(row.total_points),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const matches = (stored: GameServerAwardReceipt, input: GameServerAwardInput): boolean =>
|
|
62
|
+
stored.roundId === input.roundId &&
|
|
63
|
+
stored.eventId === input.eventId &&
|
|
64
|
+
stored.player === input.player &&
|
|
65
|
+
stored.points === input.points;
|
|
66
|
+
|
|
67
|
+
const replay = (stored: GameServerAwardReceipt, input: GameServerAwardInput): GameServerAwardResult =>
|
|
68
|
+
matches(stored, input)
|
|
69
|
+
? { ok: true, created: false, award: stored }
|
|
70
|
+
: { ok: false, refuse: 'award.event_mismatch' };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Credit events from one trusted creator game server.
|
|
74
|
+
*
|
|
75
|
+
* The event receipt and balance move in one transaction. A stable event id makes retries safe,
|
|
76
|
+
* while the adopted round fixes the wallet, time and reward ceilings this module enforces.
|
|
77
|
+
*/
|
|
78
|
+
export class GameServerAwards {
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly pool: Pool,
|
|
81
|
+
private readonly gameId: string,
|
|
82
|
+
) {}
|
|
83
|
+
|
|
84
|
+
async record(candidate: GameServerAwardInput): Promise<GameServerAwardResult> {
|
|
85
|
+
if (
|
|
86
|
+
!candidate ||
|
|
87
|
+
typeof candidate.roundId !== 'string' ||
|
|
88
|
+
!IDENTIFIER.test(candidate.roundId) ||
|
|
89
|
+
typeof candidate.eventId !== 'string' ||
|
|
90
|
+
!IDENTIFIER.test(candidate.eventId) ||
|
|
91
|
+
typeof candidate.player !== 'string' ||
|
|
92
|
+
!isAddress(candidate.player, { strict: false }) ||
|
|
93
|
+
!Number.isSafeInteger(candidate.points) ||
|
|
94
|
+
candidate.points <= 0
|
|
95
|
+
) {
|
|
96
|
+
return { ok: false, refuse: 'award.invalid' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const input = { ...candidate, player: candidate.player.toLowerCase() };
|
|
100
|
+
const receivedAt = Date.now();
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
return await inTransaction(this.pool, async (tx) => {
|
|
104
|
+
const prior = await tx.query<StoredAward>(
|
|
105
|
+
`select awards.round_id, awards.event_id, awards.player,
|
|
106
|
+
awards.points::text, awards.total_points::text
|
|
107
|
+
from award_events awards
|
|
108
|
+
join rounds on rounds.id = awards.round_id
|
|
109
|
+
where awards.round_id = $1 and awards.event_id = $2 and rounds.game_id = $3`,
|
|
110
|
+
[input.roundId, input.eventId, this.gameId],
|
|
111
|
+
);
|
|
112
|
+
if (prior.rows[0]) return replay(receipt(prior.rows[0]), input);
|
|
113
|
+
|
|
114
|
+
const round = await tx.query<{
|
|
115
|
+
opens_at: string;
|
|
116
|
+
closes_at: string;
|
|
117
|
+
max_points_per_player: string;
|
|
118
|
+
score_authority: 'rules' | 'external-server';
|
|
119
|
+
game_id: string;
|
|
120
|
+
}>(
|
|
121
|
+
`select opens_at, closes_at, max_points_per_player, score_authority, game_id
|
|
122
|
+
from rounds where id = $1`,
|
|
123
|
+
[input.roundId],
|
|
124
|
+
);
|
|
125
|
+
const terms = round.rows[0];
|
|
126
|
+
if (!terms || terms.game_id !== this.gameId) {
|
|
127
|
+
return { ok: false, refuse: 'award.no_such_round' } as const;
|
|
128
|
+
}
|
|
129
|
+
if (terms.score_authority !== 'external-server') {
|
|
130
|
+
return { ok: false, refuse: 'award.wrong_authority' } as const;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Reserve the event id before checking the balance. A concurrent retry waits on this
|
|
134
|
+
// unique row and then reads the first request's receipt. Without that ordering, it could
|
|
135
|
+
// see the first request's new balance and incorrectly fail the reward ceiling check.
|
|
136
|
+
const inserted = await tx.query<StoredAward>(
|
|
137
|
+
`insert into award_events (round_id, event_id, player, points, total_points)
|
|
138
|
+
values ($1, $2, $3, $4, $4)
|
|
139
|
+
on conflict (round_id, event_id) do nothing
|
|
140
|
+
returning round_id, event_id, player, points::text, total_points::text`,
|
|
141
|
+
[input.roundId, input.eventId, input.player, input.points],
|
|
142
|
+
);
|
|
143
|
+
if (!inserted.rows[0]) {
|
|
144
|
+
const concurrent = await tx.query<StoredAward>(
|
|
145
|
+
`select round_id, event_id, player, points::text, total_points::text
|
|
146
|
+
from award_events where round_id = $1 and event_id = $2`,
|
|
147
|
+
[input.roundId, input.eventId],
|
|
148
|
+
);
|
|
149
|
+
const stored = concurrent.rows[0];
|
|
150
|
+
if (!stored) throw new Error(`${input.roundId}: award conflict disappeared`);
|
|
151
|
+
throw new RollbackResult(replay(receipt(stored), input));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Establish idempotency before checking the clock. A retry may arrive after closesAt while
|
|
155
|
+
// the original request is still committing; it must recover that result, not lose it.
|
|
156
|
+
if (receivedAt < Number(terms.opens_at)) {
|
|
157
|
+
throw new RollbackResult({ ok: false, refuse: 'award.not_open_yet' });
|
|
158
|
+
}
|
|
159
|
+
if (receivedAt >= Number(terms.closes_at)) {
|
|
160
|
+
throw new RollbackResult({ ok: false, refuse: 'award.window_closed' });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A claim locks the same row. Creating it before locking means a first award and first claim
|
|
164
|
+
// cannot both pass through a missing row without seeing each other.
|
|
165
|
+
await tx.query(
|
|
166
|
+
`insert into balances (round_id, player, points) values ($1, $2, 0)
|
|
167
|
+
on conflict (round_id, player) do nothing`,
|
|
168
|
+
[input.roundId, input.player],
|
|
169
|
+
);
|
|
170
|
+
const balance = await tx.query<{ points: string }>(
|
|
171
|
+
`select points::text from balances where round_id = $1 and player = $2 for update`,
|
|
172
|
+
[input.roundId, input.player],
|
|
173
|
+
);
|
|
174
|
+
const total = BigInt(balance.rows[0]?.points ?? '0') + BigInt(input.points);
|
|
175
|
+
if (total > BigInt(terms.max_points_per_player)) {
|
|
176
|
+
throw new RollbackResult({ ok: false, refuse: 'award.limit_exceeded' });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await tx.query(`update balances set points = $3 where round_id = $1 and player = $2`, [
|
|
180
|
+
input.roundId,
|
|
181
|
+
input.player,
|
|
182
|
+
total.toString(),
|
|
183
|
+
]);
|
|
184
|
+
await tx.query(
|
|
185
|
+
`update award_events set total_points = $3 where round_id = $1 and event_id = $2`,
|
|
186
|
+
[input.roundId, input.eventId, total.toString()],
|
|
187
|
+
);
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
created: true,
|
|
191
|
+
award: { ...input, totalPoints: Number(total) },
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error instanceof RollbackResult) return error.result;
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
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,8 @@ 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
|
+
} from './game-server-awards.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
|
*
|
package/src/room.ts
CHANGED
|
@@ -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.
|
|
@@ -39,6 +40,8 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
39
40
|
readonly window: RoundWindow,
|
|
40
41
|
readonly launch: LaunchContext,
|
|
41
42
|
readonly weiPerPoint: bigint,
|
|
43
|
+
readonly maxPointsPerPlayer: number,
|
|
44
|
+
readonly scoreAuthority: ScoreAuthority,
|
|
42
45
|
private seq: number,
|
|
43
46
|
) {}
|
|
44
47
|
|
|
@@ -52,6 +55,8 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
52
55
|
window: RoundWindow;
|
|
53
56
|
weiPerPoint: bigint;
|
|
54
57
|
walletCapWei: bigint;
|
|
58
|
+
maxPointsPerPlayer: number;
|
|
59
|
+
scoreAuthority: ScoreAuthority;
|
|
55
60
|
poolId: string;
|
|
56
61
|
booksCloseAt?: number;
|
|
57
62
|
launch?: {
|
|
@@ -66,6 +71,9 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
66
71
|
if (!Number.isSafeInteger(booksCloseAt) || booksCloseAt < round.window.closesAt) {
|
|
67
72
|
throw new RangeError('booksCloseAt must be a safe timestamp at or after closesAt');
|
|
68
73
|
}
|
|
74
|
+
if (!Number.isSafeInteger(round.maxPointsPerPlayer) || round.maxPointsPerPlayer <= 0) {
|
|
75
|
+
throw new RangeError('maxPointsPerPlayer must be a positive safe integer');
|
|
76
|
+
}
|
|
69
77
|
const launch = Object.freeze({
|
|
70
78
|
roundId: round.id,
|
|
71
79
|
poolId: round.poolId,
|
|
@@ -80,13 +88,17 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
80
88
|
const started = Round.start(game, round.config, round.seed, round.window);
|
|
81
89
|
await pool.query(
|
|
82
90
|
`insert into rounds (
|
|
83
|
-
id, wei_per_point, wallet_cap_wei,
|
|
91
|
+
id, game_id, wei_per_point, wallet_cap_wei, max_points_per_player, score_authority,
|
|
92
|
+
pool_id, opens_at, closes_at, books_close_at,
|
|
84
93
|
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)`,
|
|
94
|
+
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 0)`,
|
|
86
95
|
[
|
|
87
96
|
round.id,
|
|
97
|
+
game.id,
|
|
88
98
|
round.weiPerPoint.toString(),
|
|
89
99
|
round.walletCapWei.toString(),
|
|
100
|
+
round.maxPointsPerPlayer,
|
|
101
|
+
round.scoreAuthority,
|
|
90
102
|
round.poolId,
|
|
91
103
|
round.window.opensAt,
|
|
92
104
|
round.window.closesAt,
|
|
@@ -98,7 +110,18 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
98
110
|
serialise(game.id, started.snapshot()),
|
|
99
111
|
],
|
|
100
112
|
);
|
|
101
|
-
return new Room(
|
|
113
|
+
return new Room(
|
|
114
|
+
pool,
|
|
115
|
+
game,
|
|
116
|
+
round.id,
|
|
117
|
+
started,
|
|
118
|
+
round.window,
|
|
119
|
+
launch,
|
|
120
|
+
round.weiPerPoint,
|
|
121
|
+
round.maxPointsPerPlayer,
|
|
122
|
+
round.scoreAuthority,
|
|
123
|
+
0,
|
|
124
|
+
);
|
|
102
125
|
}
|
|
103
126
|
|
|
104
127
|
/** Pick up a round that already exists. Returns null if there is no such round. */
|
|
@@ -119,14 +142,19 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
119
142
|
coin_symbol: string;
|
|
120
143
|
coin_image_url: string | null;
|
|
121
144
|
wei_per_point: string;
|
|
145
|
+
max_points_per_player: string;
|
|
146
|
+
score_authority: ScoreAuthority;
|
|
147
|
+
game_id: string;
|
|
122
148
|
}>(
|
|
123
149
|
`select state, seq, pool_id, opens_at, closes_at, books_close_at, wei_per_point,
|
|
150
|
+
max_points_per_player, score_authority, game_id,
|
|
124
151
|
coin_address, coin_name, coin_symbol, coin_image_url
|
|
125
152
|
from rounds where id = $1`,
|
|
126
153
|
[id],
|
|
127
154
|
);
|
|
128
155
|
const row = result.rows[0];
|
|
129
156
|
if (!row) return null;
|
|
157
|
+
if (row.game_id !== game.id) return null;
|
|
130
158
|
|
|
131
159
|
const window = { opensAt: Number(row.opens_at), closesAt: Number(row.closes_at) };
|
|
132
160
|
const launch = Object.freeze({
|
|
@@ -147,6 +175,8 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
147
175
|
window,
|
|
148
176
|
launch,
|
|
149
177
|
toBigInt(row.wei_per_point),
|
|
178
|
+
Number(row.max_points_per_player),
|
|
179
|
+
row.score_authority,
|
|
150
180
|
row.seq,
|
|
151
181
|
);
|
|
152
182
|
}
|
|
@@ -200,6 +230,9 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
200
230
|
* is meant to be one writer per round; this is what makes a broken assumption loud.
|
|
201
231
|
*/
|
|
202
232
|
private async commit(seq: number, command: Command<Action>, state: State, awards: Award[]): Promise<void> {
|
|
233
|
+
if (awards.length > 0 && this.scoreAuthority !== 'rules') {
|
|
234
|
+
throw new Error(`${this.id}: rules cannot award points for an externally scored round`);
|
|
235
|
+
}
|
|
203
236
|
await inTransaction(this.pool, async (tx) => {
|
|
204
237
|
const updated = await tx.query(
|
|
205
238
|
`update rounds set state = $2, seq = $3 where id = $1 and seq = $4`,
|
|
@@ -216,11 +249,19 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
216
249
|
for (const award of awards) {
|
|
217
250
|
// Same transaction as the state that earned them. Points credited without the state that
|
|
218
251
|
// justifies them, or the reverse, is a ledger nobody can reconcile.
|
|
219
|
-
|
|
252
|
+
const points = Math.floor(award.points);
|
|
253
|
+
if (points > this.maxPointsPerPlayer) {
|
|
254
|
+
throw new Error(`${this.game.id}: awards exceeded the declared maximum for ${award.player}`);
|
|
255
|
+
}
|
|
256
|
+
const credited = await tx.query(
|
|
220
257
|
`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
|
-
|
|
258
|
+
on conflict (round_id, player) do update set points = balances.points + excluded.points
|
|
259
|
+
where balances.points + excluded.points <= $4`,
|
|
260
|
+
[this.id, award.player, points, this.maxPointsPerPlayer],
|
|
223
261
|
);
|
|
262
|
+
if (credited.rowCount !== 1) {
|
|
263
|
+
throw new Error(`${this.game.id}: awards exceeded the declared maximum for ${award.player}`);
|
|
264
|
+
}
|
|
224
265
|
}
|
|
225
266
|
});
|
|
226
267
|
}
|
package/src/server.ts
CHANGED
|
@@ -6,13 +6,15 @@ import type { Command, GameModule, PlayerId } from '@flayerlabs/gamemode-spec';
|
|
|
6
6
|
import { isRefusal } from '@flayerlabs/gamemode-spec';
|
|
7
7
|
import type { PresenceState } from '@flayerlabs/gamemode-spec/live';
|
|
8
8
|
import { isReactionId, LIVE_PROTOCOL_VERSION } from '@flayerlabs/gamemode-spec/live';
|
|
9
|
-
import { Room } from './room.js';
|
|
9
|
+
import { Room, type ScoreAuthority } from './room.js';
|
|
10
10
|
import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
|
|
11
11
|
import { NotSignedIn, Sessions } from './sessions.js';
|
|
12
12
|
import type { Discovery, Launch, PoolKey } from './chain/discover.js';
|
|
13
13
|
import type { Settlement } from './settlement.js';
|
|
14
14
|
import { resolveEconomy } from './economy.js';
|
|
15
15
|
import { encodeHookData } from './chain/signer.js';
|
|
16
|
+
import { hasBearerToken } from './bearer.js';
|
|
17
|
+
import { GameServerAwards, type GameServerAwardRequest } from './game-server-awards.js';
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* The gate's door.
|
|
@@ -26,9 +28,8 @@ import { encodeHookData } from './chain/signer.js';
|
|
|
26
28
|
* that size would be guessing at the shape of a problem nobody has.
|
|
27
29
|
*/
|
|
28
30
|
|
|
29
|
-
interface GateBaseOptions
|
|
31
|
+
interface GateBaseOptions {
|
|
30
32
|
pool: Pool;
|
|
31
|
-
game: GameModule<Config, State, Event, Action, PublicView, PlayerView>;
|
|
32
33
|
sessions: Sessions;
|
|
33
34
|
claims: Claims;
|
|
34
35
|
/** How many points a dollar buys. The only economic number anyone sets by hand. */
|
|
@@ -65,18 +66,24 @@ interface GateBaseOptions<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
65
66
|
export type VerifiedLaunch = Readonly<Omit<Launch, 'poolKey'>> & { readonly poolKey: Readonly<PoolKey> };
|
|
66
67
|
export type ConfigForLaunch<Config> = (launch: VerifiedLaunch) => Promise<Config>;
|
|
67
68
|
|
|
68
|
-
export type GateOptions<Config, State, Event, Action, PublicView, PlayerView> = GateBaseOptions
|
|
69
|
-
Config,
|
|
70
|
-
State,
|
|
71
|
-
Event,
|
|
72
|
-
Action,
|
|
73
|
-
PublicView,
|
|
74
|
-
PlayerView
|
|
75
|
-
> & {
|
|
69
|
+
export type GateOptions<Config, State, Event, Action, PublicView, PlayerView> = GateBaseOptions & {
|
|
70
|
+
game: GameModule<Config, State, Event, Action, PublicView, PlayerView>;
|
|
76
71
|
/** A static serialisable config, or a factory resolved once after each launch is verified. */
|
|
77
72
|
config: Config | ConfigForLaunch<Config>;
|
|
78
73
|
};
|
|
79
74
|
|
|
75
|
+
export type MaxPointsForLaunch = (launch: VerifiedLaunch) => Promise<number>;
|
|
76
|
+
|
|
77
|
+
/** Run a gate beside a creator-owned authoritative game server. */
|
|
78
|
+
export interface GameServerGateOptions extends GateBaseOptions {
|
|
79
|
+
/** Stable game identifier used to protect durable room state from a different game module. */
|
|
80
|
+
gameId: string;
|
|
81
|
+
/** The most one wallet can earn in a round, or a value resolved once for each verified launch. */
|
|
82
|
+
maxPointsPerPlayer: number | MaxPointsForLaunch;
|
|
83
|
+
/** Dedicated server-to-server token. Never expose it to browser game code. */
|
|
84
|
+
awardToken: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
/** Where an admission check is being made. Enough to tell a sign-in from a spend. */
|
|
81
88
|
export interface AdmissionRequest {
|
|
82
89
|
/** `session` on sign-in, `action` on gameplay, `claim` when allowance is about to be committed. */
|
|
@@ -131,8 +138,83 @@ type AdoptionResult<Config, State, Event, Action, PublicView, PlayerView> =
|
|
|
131
138
|
| { ok: true; room: Room<Config, State, Event, Action, PublicView, PlayerView>; created: boolean }
|
|
132
139
|
| { ok: false; status: 409 | 503; message: string };
|
|
133
140
|
|
|
141
|
+
interface AwardEndpoint {
|
|
142
|
+
token: string;
|
|
143
|
+
awards: GameServerAwards;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface GameServerConfig {
|
|
147
|
+
maxPointsPerPlayer: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type GameServerState = Record<string, never>;
|
|
151
|
+
type GameServerEvent = never;
|
|
152
|
+
type GameServerAction = never;
|
|
153
|
+
type GameServerPublicView = Record<string, never>;
|
|
154
|
+
type GameServerPlayerView = Record<string, never>;
|
|
155
|
+
|
|
156
|
+
const GAME_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
157
|
+
|
|
158
|
+
function gameServerModule(
|
|
159
|
+
id: string,
|
|
160
|
+
): GameModule<
|
|
161
|
+
GameServerConfig,
|
|
162
|
+
GameServerState,
|
|
163
|
+
GameServerEvent,
|
|
164
|
+
GameServerAction,
|
|
165
|
+
GameServerPublicView,
|
|
166
|
+
GameServerPlayerView
|
|
167
|
+
> {
|
|
168
|
+
return {
|
|
169
|
+
id,
|
|
170
|
+
parseAction: () => null,
|
|
171
|
+
initRound: () => ({}),
|
|
172
|
+
decide: (_state, command) => {
|
|
173
|
+
if (command.kind === 'action') return { refuse: 'gameplay.external' };
|
|
174
|
+
return { events: [] };
|
|
175
|
+
},
|
|
176
|
+
evolve: (state) => state,
|
|
177
|
+
publicView: () => ({}),
|
|
178
|
+
playerView: () => ({}),
|
|
179
|
+
nextWakeAt: () => null,
|
|
180
|
+
rewardBounds: (config) => ({ maxPointsPerPlayer: config.maxPointsPerPlayer }),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
134
184
|
export function createGate<Config, State, Event, Action, PublicView, PlayerView>(
|
|
135
185
|
options: GateOptions<Config, State, Event, Action, PublicView, PlayerView>,
|
|
186
|
+
): FastifyInstance {
|
|
187
|
+
return createGateRuntime(options, 'rules');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function createGameServerGate(options: GameServerGateOptions): FastifyInstance {
|
|
191
|
+
if (typeof options.gameId !== 'string' || !GAME_ID.test(options.gameId)) {
|
|
192
|
+
throw new RangeError('gameId must be a valid identifier');
|
|
193
|
+
}
|
|
194
|
+
if (typeof options.awardToken !== 'string' || options.awardToken.length < 32) {
|
|
195
|
+
throw new RangeError('awardToken must be at least 32 characters');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const maximum = options.maxPointsPerPlayer;
|
|
199
|
+
if (typeof maximum !== 'function' && (!Number.isSafeInteger(maximum) || maximum <= 0)) {
|
|
200
|
+
throw new RangeError('maxPointsPerPlayer must be a positive safe integer');
|
|
201
|
+
}
|
|
202
|
+
const config: GameServerConfig | ConfigForLaunch<GameServerConfig> =
|
|
203
|
+
typeof maximum === 'function'
|
|
204
|
+
? async (launch) => ({ maxPointsPerPlayer: await maximum(launch) })
|
|
205
|
+
: { maxPointsPerPlayer: maximum };
|
|
206
|
+
const { gameId, maxPointsPerPlayer: _maximum, awardToken, ...base } = options;
|
|
207
|
+
return createGateRuntime(
|
|
208
|
+
{ ...base, game: gameServerModule(gameId), config },
|
|
209
|
+
'external-server',
|
|
210
|
+
{ token: awardToken, awards: new GameServerAwards(options.pool, gameId) },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>(
|
|
215
|
+
options: GateOptions<Config, State, Event, Action, PublicView, PlayerView>,
|
|
216
|
+
scoreAuthority: ScoreAuthority,
|
|
217
|
+
awardEndpoint?: AwardEndpoint,
|
|
136
218
|
): FastifyInstance {
|
|
137
219
|
const { pool, game, sessions, claims, discovery, settlement } = options;
|
|
138
220
|
const admit = options.admit ?? ADMIT_ALL;
|
|
@@ -174,6 +256,10 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
174
256
|
const existing = rooms.get(id);
|
|
175
257
|
if (existing) return existing;
|
|
176
258
|
const loaded = await Room.load(pool, game, id);
|
|
259
|
+
if (loaded && loaded.scoreAuthority !== scoreAuthority) {
|
|
260
|
+
app.log.error(`${id}: round belongs to ${loaded.scoreAuthority}, not ${scoreAuthority}`);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
177
263
|
// A durable row is history after its books close, not permission to resurrect an active
|
|
178
264
|
// ticker and market stream every time somebody refreshes an old coin page. It still gets one
|
|
179
265
|
// bounded catch-up through closesAt: a restart spanning the whole tail must not strand final
|
|
@@ -246,6 +332,8 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
246
332
|
window: { opensAt: launch.opensAt, closesAt: launch.closesAt },
|
|
247
333
|
weiPerPoint: economy.economy.weiPerPoint,
|
|
248
334
|
walletCapWei: launch.walletCapWei,
|
|
335
|
+
maxPointsPerPlayer,
|
|
336
|
+
scoreAuthority,
|
|
249
337
|
poolId: launch.poolId,
|
|
250
338
|
booksCloseAt: launch.closesAt + settlementTailMs,
|
|
251
339
|
launch: {
|
|
@@ -381,6 +469,33 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
381
469
|
.send(adopted.room.launch);
|
|
382
470
|
});
|
|
383
471
|
|
|
472
|
+
if (awardEndpoint) {
|
|
473
|
+
app.post('/internal/rounds/:id/awards', async (request, reply) => {
|
|
474
|
+
if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
|
|
475
|
+
return reply.code(401).send({ message: 'game server authentication failed' });
|
|
476
|
+
}
|
|
477
|
+
const body = (request.body ?? {}) as Partial<GameServerAwardRequest>;
|
|
478
|
+
const result = await awardEndpoint.awards.record({
|
|
479
|
+
roundId: (request.params as { id: string }).id,
|
|
480
|
+
eventId: body.eventId as string,
|
|
481
|
+
player: body.player as PlayerId,
|
|
482
|
+
points: body.points as number,
|
|
483
|
+
});
|
|
484
|
+
if (!result.ok) {
|
|
485
|
+
const status =
|
|
486
|
+
result.refuse === 'award.invalid'
|
|
487
|
+
? 400
|
|
488
|
+
: result.refuse === 'award.no_such_round' || result.refuse === 'award.wrong_authority'
|
|
489
|
+
? 404
|
|
490
|
+
: 409;
|
|
491
|
+
return reply.code(status).send({ refuse: result.refuse });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
announcePlayer(result.award.roundId, result.award.player);
|
|
495
|
+
return reply.code(result.created ? 201 : 200).send(result.award);
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
384
499
|
app.get('/rounds/:id', async (request, reply) => {
|
|
385
500
|
const id = (request.params as { id: string }).id;
|
|
386
501
|
const room = await roomFor(id);
|
|
@@ -396,6 +511,10 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
396
511
|
if (!room) return reply.code(404).send({ message: 'that game is not running' });
|
|
397
512
|
|
|
398
513
|
const id = (request.params as { id: string }).id;
|
|
514
|
+
if (scoreAuthority === 'external-server') {
|
|
515
|
+
player(request);
|
|
516
|
+
return { joined: true };
|
|
517
|
+
}
|
|
399
518
|
const before = room.sequence();
|
|
400
519
|
const result = await room.apply({
|
|
401
520
|
kind: 'join',
|
|
@@ -432,6 +551,8 @@ export function createGate<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
432
551
|
app.post('/rounds/:id/claim', async (request, reply) => {
|
|
433
552
|
const id = (request.params as { id: string }).id;
|
|
434
553
|
const who = player(request);
|
|
554
|
+
const room = await roomFor(id);
|
|
555
|
+
if (!room) return reply.code(404).send({ message: 'that game is not running' });
|
|
435
556
|
const { maxSpendWei, requestId } = request.body as { maxSpendWei?: string; requestId?: unknown };
|
|
436
557
|
let upTo: bigint;
|
|
437
558
|
try {
|