@flayerlabs/gamemode-gate 0.4.3 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPLOY.md +27 -3
- package/README.md +8 -3
- package/dist/chain/chains.d.ts +45 -6
- package/dist/chain/chains.d.ts.map +1 -1
- package/dist/chain/chains.js +56 -5
- package/dist/chain/chains.js.map +1 -1
- package/dist/chain/discover.d.ts +20 -2
- package/dist/chain/discover.d.ts.map +1 -1
- package/dist/chain/discover.js +74 -6
- package/dist/chain/discover.js.map +1 -1
- package/dist/chain/secure.d.ts +11 -1
- package/dist/chain/secure.d.ts.map +1 -1
- package/dist/chain/secure.js +67 -0
- package/dist/chain/secure.js.map +1 -1
- package/dist/claims.d.ts +1 -1
- package/dist/claims.d.ts.map +1 -1
- package/dist/claims.js +2 -2
- package/dist/claims.js.map +1 -1
- package/dist/demo.d.ts +1 -1
- package/dist/demo.d.ts.map +1 -1
- package/dist/demo.js +9 -9
- package/dist/demo.js.map +1 -1
- package/dist/economy.d.ts +25 -9
- package/dist/economy.d.ts.map +1 -1
- package/dist/economy.js +63 -22
- package/dist/economy.js.map +1 -1
- package/dist/index.d.ts +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +2 -2
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +16 -16
- package/dist/ledger.js.map +1 -1
- package/dist/main.d.ts +30 -5
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +158 -17
- package/dist/main.js.map +1 -1
- package/dist/room.d.ts +7 -4
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +31 -10
- package/dist/room.js.map +1 -1
- package/dist/server.d.ts +33 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +93 -25
- package/dist/server.js.map +1 -1
- package/dist/standings.d.ts +34 -0
- package/dist/standings.d.ts.map +1 -0
- package/dist/standings.js +103 -0
- package/dist/standings.js.map +1 -0
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +21 -0
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/chain/chains.ts +82 -9
- package/src/chain/discover.ts +87 -7
- package/src/chain/secure.ts +86 -1
- package/src/claims.ts +2 -2
- package/src/demo.ts +10 -10
- package/src/economy.ts +83 -31
- package/src/index.ts +8 -5
- package/src/ledger.ts +18 -18
- package/src/main.ts +181 -19
- package/src/room.ts +41 -14
- package/src/server.ts +142 -35
- package/src/standings.ts +138 -0
- package/src/store.ts +21 -0
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
|
|
4
|
+
import { ETH_SPEND_TOKEN, type LaunchContext, type RoundTerms, type SpendToken } from '@flayerlabs/gamemode-spec/live';
|
|
5
5
|
import { isRefusal } from '@flayerlabs/gamemode-spec';
|
|
6
6
|
import { inTransaction, toBigInt } from './store.js';
|
|
7
7
|
|
|
@@ -52,10 +52,13 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
52
52
|
config: Config;
|
|
53
53
|
seed: number;
|
|
54
54
|
window: RoundWindow;
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
unitsPerPoint: bigint;
|
|
56
|
+
walletCap: bigint;
|
|
57
57
|
pointsPerDollar: number;
|
|
58
|
-
|
|
58
|
+
/** The token spend is measured in. Defaults to ETH — a flETH- or ETH-paired round. */
|
|
59
|
+
spendToken?: SpendToken;
|
|
60
|
+
/** The market price of one whole spend token, in dollars, at adoption. */
|
|
61
|
+
usdPerSpendToken: number;
|
|
59
62
|
maxPointsPerPlayer: number;
|
|
60
63
|
scoreAuthority: ScoreAuthority;
|
|
61
64
|
poolId: string;
|
|
@@ -86,28 +89,39 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
86
89
|
closesAt: round.window.closesAt,
|
|
87
90
|
booksCloseAt,
|
|
88
91
|
});
|
|
92
|
+
const spendToken: SpendToken = Object.freeze({ ...(round.spendToken ?? ETH_SPEND_TOKEN) });
|
|
89
93
|
const terms: RoundTerms = Object.freeze({
|
|
90
94
|
maxPointsPerPlayer: round.maxPointsPerPlayer,
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
walletCap: round.walletCap,
|
|
96
|
+
unitsPerPoint: round.unitsPerPoint,
|
|
93
97
|
pointsPerDollar: round.pointsPerDollar,
|
|
94
|
-
|
|
98
|
+
usdPerSpendToken: round.usdPerSpendToken,
|
|
99
|
+
spendToken,
|
|
95
100
|
});
|
|
96
101
|
const started = Round.start(game, round.config, round.seed, round.window);
|
|
102
|
+
// The columns keep their original wei-flavoured names while the API does not. Renaming them
|
|
103
|
+
// would be a migration with no reader outside this file, so the divergence is deliberate:
|
|
104
|
+
// wei_per_point is unitsPerPoint, wallet_cap_wei is walletCap, and usd_per_spend_token is
|
|
105
|
+
// usdPerSpendToken. usd_per_eth is a legacy column, written for provenance only.
|
|
97
106
|
await pool.query(
|
|
98
107
|
`insert into rounds (
|
|
99
108
|
id, game_id, wei_per_point, wallet_cap_wei, points_per_dollar, usd_per_eth,
|
|
109
|
+
spend_token, spend_symbol, spend_decimals, usd_per_spend_token,
|
|
100
110
|
max_points_per_player, score_authority,
|
|
101
111
|
pool_id, opens_at, closes_at, books_close_at,
|
|
102
112
|
coin_address, coin_name, coin_symbol, coin_image_url, state, seq
|
|
103
|
-
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 0)`,
|
|
113
|
+
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, 0)`,
|
|
104
114
|
[
|
|
105
115
|
round.id,
|
|
106
116
|
game.id,
|
|
107
|
-
round.
|
|
108
|
-
round.
|
|
117
|
+
round.unitsPerPoint.toString(),
|
|
118
|
+
round.walletCap.toString(),
|
|
109
119
|
round.pointsPerDollar,
|
|
110
|
-
round.
|
|
120
|
+
round.usdPerSpendToken,
|
|
121
|
+
spendToken.address.toLowerCase(),
|
|
122
|
+
spendToken.symbol,
|
|
123
|
+
spendToken.decimals,
|
|
124
|
+
round.usdPerSpendToken,
|
|
111
125
|
round.maxPointsPerPlayer,
|
|
112
126
|
round.scoreAuthority,
|
|
113
127
|
round.poolId,
|
|
@@ -155,12 +169,17 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
155
169
|
wallet_cap_wei: string;
|
|
156
170
|
points_per_dollar: number;
|
|
157
171
|
usd_per_eth: number;
|
|
172
|
+
spend_token: string;
|
|
173
|
+
spend_symbol: string;
|
|
174
|
+
spend_decimals: number;
|
|
175
|
+
usd_per_spend_token: number | null;
|
|
158
176
|
max_points_per_player: string;
|
|
159
177
|
score_authority: ScoreAuthority;
|
|
160
178
|
game_id: string;
|
|
161
179
|
}>(
|
|
162
180
|
`select state, seq, pool_id, opens_at, closes_at, books_close_at, wei_per_point,
|
|
163
181
|
wallet_cap_wei, points_per_dollar, usd_per_eth,
|
|
182
|
+
spend_token, spend_symbol, spend_decimals, usd_per_spend_token,
|
|
164
183
|
max_points_per_player, score_authority, game_id,
|
|
165
184
|
coin_address, coin_name, coin_symbol, coin_image_url
|
|
166
185
|
from rounds where id = $1`,
|
|
@@ -183,10 +202,18 @@ export class Room<Config, State, Event, Action, PublicView, PlayerView> {
|
|
|
183
202
|
});
|
|
184
203
|
const terms: RoundTerms = Object.freeze({
|
|
185
204
|
maxPointsPerPlayer: Number(row.max_points_per_player),
|
|
186
|
-
|
|
187
|
-
|
|
205
|
+
walletCap: toBigInt(row.wallet_cap_wei),
|
|
206
|
+
unitsPerPoint: toBigInt(row.wei_per_point),
|
|
188
207
|
pointsPerDollar: Number(row.points_per_dollar),
|
|
189
|
-
|
|
208
|
+
spendToken: Object.freeze({
|
|
209
|
+
address: row.spend_token,
|
|
210
|
+
symbol: row.spend_symbol,
|
|
211
|
+
decimals: Number(row.spend_decimals),
|
|
212
|
+
isNative: row.spend_token.toLowerCase() === ETH_SPEND_TOKEN.address,
|
|
213
|
+
}),
|
|
214
|
+
// usd_per_eth is the legacy column: on a row written before the spend-token columns existed
|
|
215
|
+
// it holds that round's ETH price, which is exactly its spend token's price.
|
|
216
|
+
usdPerSpendToken: row.usd_per_spend_token === null ? Number(row.usd_per_eth) : Number(row.usd_per_spend_token),
|
|
190
217
|
});
|
|
191
218
|
return new Room(
|
|
192
219
|
pool,
|
package/src/server.ts
CHANGED
|
@@ -7,12 +7,17 @@ import { isRefusal } from '@flayerlabs/gamemode-spec';
|
|
|
7
7
|
import type {
|
|
8
8
|
EconomyBalance,
|
|
9
9
|
PresenceState,
|
|
10
|
+
RoundStandings,
|
|
10
11
|
RoundTerms,
|
|
12
|
+
SpendToken,
|
|
11
13
|
WireEconomyBalance,
|
|
14
|
+
WireRoundStandings,
|
|
12
15
|
WireRoundTerms,
|
|
13
16
|
} from '@flayerlabs/gamemode-spec/live';
|
|
14
|
-
import { isReactionId, LIVE_PROTOCOL_VERSION } from '@flayerlabs/gamemode-spec/live';
|
|
17
|
+
import { ETH_SPEND_TOKEN, isReactionId, LIVE_PROTOCOL_VERSION } from '@flayerlabs/gamemode-spec/live';
|
|
18
|
+
import type { FlaunchVariant } from './chain/chains.js';
|
|
15
19
|
import { Room, type ScoreAuthority } from './room.js';
|
|
20
|
+
import { Standings } from './standings.js';
|
|
16
21
|
import { CLAIM_AUTHORIZATION_TTL_MS, Claims, isClaimAmount, isClaimRequestId } from './claims.js';
|
|
17
22
|
import { NotSignedIn, Sessions } from './sessions.js';
|
|
18
23
|
import type { Discovery, Launch, PoolKey } from './chain/discover.js';
|
|
@@ -42,7 +47,15 @@ interface GateBaseOptions {
|
|
|
42
47
|
claims: Claims;
|
|
43
48
|
/** How many points a dollar buys. The only economic number anyone sets by hand. */
|
|
44
49
|
pointsPerDollar: number;
|
|
50
|
+
/** The price of ETH — what an ETH-paired round is priced at. */
|
|
45
51
|
usdPerEth: number;
|
|
52
|
+
/**
|
|
53
|
+
* The price of one whole spend token, for a round whose coin is paired with something other
|
|
54
|
+
* than ETH — or null to refuse the launch as unpriced. Consulted at adoption with the token the
|
|
55
|
+
* chain reported. Omitted, only native ETH is priced (at `usdPerEth`) and every other pairing is
|
|
56
|
+
* refused: a gate that guessed a price would be authorising real money on a made-up number.
|
|
57
|
+
*/
|
|
58
|
+
priceFor?: PriceFor;
|
|
46
59
|
/** Verifies from the chain that a coin really is a game launch. */
|
|
47
60
|
discovery?: Discovery;
|
|
48
61
|
/** Keeps the ledger's view of spending level with the chain's. */
|
|
@@ -85,6 +98,20 @@ interface GateBaseOptions {
|
|
|
85
98
|
reactionIds?: readonly string[];
|
|
86
99
|
}
|
|
87
100
|
|
|
101
|
+
/** A price for one whole spend token in dollars, or null when this gate has none for it. */
|
|
102
|
+
export type PriceFor = (token: SpendToken) => Promise<number | null> | number | null;
|
|
103
|
+
|
|
104
|
+
/** One entry of a gate's announced price list — what launch tooling checks a pairing against. */
|
|
105
|
+
export interface AnnouncedSpendToken {
|
|
106
|
+
address: `0x${string}`;
|
|
107
|
+
symbol: string;
|
|
108
|
+
decimals: number;
|
|
109
|
+
/** The price this gate will fix rounds on that token at. */
|
|
110
|
+
usdPerToken: number;
|
|
111
|
+
/** The per-wallet cap the launch should write, in this token's base units. A decimal string. */
|
|
112
|
+
walletCap: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
/** Resolve one immutable rules config after the launch has been verified. */
|
|
89
116
|
export type VerifiedLaunch = Readonly<Omit<Launch, 'poolKey'>> & { readonly poolKey: Readonly<PoolKey> };
|
|
90
117
|
export type ConfigForLaunch<Config> = (launch: VerifiedLaunch) => Promise<Config>;
|
|
@@ -118,17 +145,25 @@ export interface GateAnnouncement {
|
|
|
118
145
|
contracts: {
|
|
119
146
|
positionManager: `0x${string}`;
|
|
120
147
|
spendGatedCalculator: `0x${string}`;
|
|
148
|
+
/** The approved swap router a paired-token buy goes through. Present on a `v1_3` stack. */
|
|
149
|
+
poolSwap?: `0x${string}`;
|
|
121
150
|
};
|
|
122
151
|
/** The address launches must name as their trusted signer for this gate to adopt them. */
|
|
123
152
|
signer: `0x${string}`;
|
|
124
153
|
/** May lift the gate early or rotate the signer on-chain; the launch writes it into the pool. */
|
|
125
154
|
settler: `0x${string}`;
|
|
126
|
-
/** A decimal string — wei does not survive JSON as a number. */
|
|
127
|
-
|
|
155
|
+
/** The per-wallet cap for an ETH-paired launch, in wei. A decimal string — wei does not survive JSON as a number. */
|
|
156
|
+
walletCap: string;
|
|
128
157
|
roundDurationMs: number;
|
|
129
158
|
minLobbyLeadMs: number;
|
|
130
159
|
gateEndsAtGraceS: number;
|
|
131
|
-
flaunchVariant:
|
|
160
|
+
flaunchVariant: FlaunchVariant;
|
|
161
|
+
/**
|
|
162
|
+
* The non-ETH paired tokens this gate will run rounds on, each with the cap a launch on it should
|
|
163
|
+
* write. Absent or empty means ETH pairings only. Launch tooling refuses a pairing that is not
|
|
164
|
+
* listed here, which is the fail-closed answer for a gate that has not been told the token's price.
|
|
165
|
+
*/
|
|
166
|
+
spendTokens?: AnnouncedSpendToken[];
|
|
132
167
|
requiresEoa: boolean;
|
|
133
168
|
accepting: boolean;
|
|
134
169
|
/**
|
|
@@ -170,22 +205,32 @@ const ADMIT_ALL: Admit = () => ({ ok: true });
|
|
|
170
205
|
/** What the socket says, which is only the truth when nothing is proxying in front of the gate. */
|
|
171
206
|
const ipOf = (request: FastifyRequest): string | null => request.ip || null;
|
|
172
207
|
|
|
173
|
-
/**
|
|
208
|
+
/** Bigint has no JSON form, so terms cross as decimal strings, exactly as the economy balance does. */
|
|
174
209
|
const wireTerms = (terms: RoundTerms): WireRoundTerms => ({
|
|
175
210
|
maxPointsPerPlayer: terms.maxPointsPerPlayer,
|
|
176
|
-
|
|
177
|
-
|
|
211
|
+
walletCap: terms.walletCap.toString(),
|
|
212
|
+
unitsPerPoint: terms.unitsPerPoint.toString(),
|
|
178
213
|
pointsPerDollar: terms.pointsPerDollar,
|
|
179
|
-
|
|
214
|
+
usdPerSpendToken: terms.usdPerSpendToken,
|
|
215
|
+
spendToken: terms.spendToken,
|
|
180
216
|
});
|
|
181
217
|
|
|
218
|
+
/** Standings leave the process the same way every other amount does: base units as a string. */
|
|
219
|
+
const wireStandings = (standings: RoundStandings): WireRoundStandings => ({
|
|
220
|
+
...standings,
|
|
221
|
+
players: standings.players.map((line) => ({ ...line, spent: line.spent.toString() })),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/** Only native ETH is priced without being told: everything else needs a `priceFor`. */
|
|
225
|
+
const ethOnly = (usdPerEth: number): PriceFor => (token) => (token.isNative ? usdPerEth : null);
|
|
226
|
+
|
|
182
227
|
/** The same encoding for the per-player balance, wherever it leaves the process. */
|
|
183
228
|
const wireEconomy = (balance: EconomyBalance): WireEconomyBalance => ({
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
229
|
+
unitsPerPoint: balance.unitsPerPoint.toString(),
|
|
230
|
+
earned: balance.earned.toString(),
|
|
231
|
+
held: balance.held.toString(),
|
|
232
|
+
spent: balance.spent.toString(),
|
|
233
|
+
available: balance.available.toString(),
|
|
189
234
|
holdExpiresAt: balance.holdExpiresAt,
|
|
190
235
|
});
|
|
191
236
|
|
|
@@ -326,6 +371,7 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
326
371
|
>();
|
|
327
372
|
|
|
328
373
|
const playerSocketKey = (id: string, player: PlayerId): string => `${id}:${player}`;
|
|
374
|
+
const standings = new Standings(pool, game.id);
|
|
329
375
|
|
|
330
376
|
void app.register(cors, { origin: options.allowedOrigins });
|
|
331
377
|
void app.register(websocket, { options: { maxPayload: 1_024 } });
|
|
@@ -389,11 +435,27 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
389
435
|
app.log.error(error);
|
|
390
436
|
return { ok: false, status: 503, message: 'this game is still being prepared' };
|
|
391
437
|
}
|
|
438
|
+
// The token the chain measures this pool's spend in decides the round's units. A launch this
|
|
439
|
+
// gate has no price for is refused rather than priced as ETH: on a six-decimal token that guess
|
|
440
|
+
// authorises a trillion times the intended spend.
|
|
441
|
+
const spendToken = launch.spendToken ?? ETH_SPEND_TOKEN;
|
|
442
|
+
let usdPerSpendToken: number | null;
|
|
443
|
+
try {
|
|
444
|
+
usdPerSpendToken = await (options.priceFor ?? ethOnly(options.usdPerEth))(spendToken);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
app.log.error(error);
|
|
447
|
+
return { ok: false, status: 503, message: 'this game is still being prepared' };
|
|
448
|
+
}
|
|
449
|
+
if (usdPerSpendToken === null || !Number.isFinite(usdPerSpendToken) || usdPerSpendToken <= 0) {
|
|
450
|
+
app.log.error(`${launch.coinAddress}: no price for spend token ${spendToken.symbol} (${spendToken.address})`);
|
|
451
|
+
return { ok: false, status: 409, message: `this game is not set up for ${spendToken.symbol} yet` };
|
|
452
|
+
}
|
|
392
453
|
const economy = resolveEconomy({
|
|
393
454
|
maxPointsPerPlayer,
|
|
394
|
-
|
|
455
|
+
walletCap: launch.walletCap,
|
|
395
456
|
pointsPerDollar: options.pointsPerDollar,
|
|
396
|
-
|
|
457
|
+
usdPerSpendToken,
|
|
458
|
+
decimals: spendToken.decimals,
|
|
397
459
|
});
|
|
398
460
|
if (!economy.ok) {
|
|
399
461
|
app.log.error(
|
|
@@ -409,10 +471,11 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
409
471
|
config,
|
|
410
472
|
seed: Number(BigInt(launch.poolId) % 2147483647n),
|
|
411
473
|
window: { opensAt: launch.opensAt, closesAt: launch.closesAt },
|
|
412
|
-
|
|
474
|
+
unitsPerPoint: economy.economy.unitsPerPoint,
|
|
413
475
|
pointsPerDollar: economy.economy.pointsPerDollar,
|
|
414
|
-
|
|
415
|
-
|
|
476
|
+
spendToken,
|
|
477
|
+
usdPerSpendToken: economy.economy.usdPerSpendToken,
|
|
478
|
+
walletCap: launch.walletCap,
|
|
416
479
|
maxPointsPerPlayer,
|
|
417
480
|
scoreAuthority,
|
|
418
481
|
poolId: launch.poolId,
|
|
@@ -499,26 +562,63 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
499
562
|
*/
|
|
500
563
|
app.get('/stats', async (_request, reply) => {
|
|
501
564
|
void reply.header('access-control-allow-origin', '*');
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
565
|
+
// Spend is summed per spend token: base units of two different tokens do not add. The flat
|
|
566
|
+
// `spendWeiTotal` keeps its original meaning — wei, across ETH rounds — for dashboards built
|
|
567
|
+
// against it; `spendByToken` is the honest figure for a gate that runs more than one pairing.
|
|
568
|
+
const [{ rows }, byToken] = await Promise.all([
|
|
569
|
+
pool.query<{
|
|
570
|
+
rounds_total: string;
|
|
571
|
+
players_total: string;
|
|
572
|
+
spend_wei_total: string;
|
|
573
|
+
last_round_at: string | null;
|
|
574
|
+
}>(
|
|
575
|
+
`select (select count(*) from rounds) as rounds_total,
|
|
576
|
+
(select count(distinct player) from balances) as players_total,
|
|
577
|
+
(select coalesce(sum(b.chain_spent_wei), 0)::text
|
|
578
|
+
from balances b join rounds r on r.id = b.round_id
|
|
579
|
+
where r.spend_decimals = 18) as spend_wei_total,
|
|
580
|
+
(select max(opens_at) from rounds) as last_round_at`,
|
|
581
|
+
),
|
|
582
|
+
pool.query<{ spend_token: string; spend_symbol: string; spend_decimals: number; total: string }>(
|
|
583
|
+
`select r.spend_token, r.spend_symbol, r.spend_decimals, coalesce(sum(b.chain_spent_wei), 0)::text as total
|
|
584
|
+
from rounds r left join balances b on b.round_id = r.id
|
|
585
|
+
group by r.spend_token, r.spend_symbol, r.spend_decimals
|
|
586
|
+
order by r.spend_token`,
|
|
587
|
+
),
|
|
588
|
+
]);
|
|
513
589
|
const totals = rows[0]!;
|
|
514
590
|
return {
|
|
515
591
|
roundsTotal: Number(totals.rounds_total),
|
|
516
592
|
playersTotal: Number(totals.players_total),
|
|
517
593
|
spendWeiTotal: totals.spend_wei_total,
|
|
594
|
+
spendByToken: byToken.rows.map((row) => ({
|
|
595
|
+
address: row.spend_token,
|
|
596
|
+
symbol: row.spend_symbol,
|
|
597
|
+
decimals: Number(row.spend_decimals),
|
|
598
|
+
total: row.total,
|
|
599
|
+
})),
|
|
518
600
|
lastRoundAt: totals.last_round_at === null ? null : Number(totals.last_round_at),
|
|
519
601
|
};
|
|
520
602
|
});
|
|
521
603
|
|
|
604
|
+
/**
|
|
605
|
+
* How a coin's round stands: every wallet that scored, ranked, with what the chain says each
|
|
606
|
+
* spent. This is what the coin page shows once the game is over — and during it, as the running
|
|
607
|
+
* order — so it is keyed by the one thing that page holds, the coin's address.
|
|
608
|
+
*
|
|
609
|
+
* A read, and deliberately not through `roomFor`: that resurrects rooms, advances game state and
|
|
610
|
+
* answers nothing once the books close, none of which a table should do. Public for the same
|
|
611
|
+
* reason `/stats` is — a spectator on the live socket could tally every number here — and served
|
|
612
|
+
* with the same header, because the coin page is a different origin from the game's.
|
|
613
|
+
*/
|
|
614
|
+
app.get('/coins/:coin/rankings', async (request, reply) => {
|
|
615
|
+
void reply.header('access-control-allow-origin', '*');
|
|
616
|
+
const found = await standings.forCoin((request.params as { coin: string }).coin);
|
|
617
|
+
if (!found) return reply.code(404).send({ message: 'that coin has no game' });
|
|
618
|
+
// Points are monotone within a round; the spend beside them is the chain's latest word.
|
|
619
|
+
return reply.header('cache-control', 'no-store').send(wireStandings(found));
|
|
620
|
+
});
|
|
621
|
+
|
|
522
622
|
if (options.announce) {
|
|
523
623
|
const announce = options.announce;
|
|
524
624
|
app.get('/config', (_request, reply) => {
|
|
@@ -672,7 +772,7 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
672
772
|
return reply.code(result.refuse === 'award.invalid' ? 400 : 404).send({ refuse: result.refuse });
|
|
673
773
|
}
|
|
674
774
|
|
|
675
|
-
// `
|
|
775
|
+
// `held` moves on its own when an authorisation expires, so this answer is only true at the
|
|
676
776
|
// moment it is read. Points are monotone; the wei beside them are not.
|
|
677
777
|
const balance = await claims.economyBalance(result.standing.roundId, result.standing.player);
|
|
678
778
|
return reply
|
|
@@ -688,11 +788,18 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
688
788
|
const before = room.sequence();
|
|
689
789
|
await room.tick(Date.now());
|
|
690
790
|
if (room.sequence() !== before) announce(id);
|
|
791
|
+
// Who is here rides the round read so a lobby can show its seats without a socket of its own.
|
|
792
|
+
// Connection is decided by the sockets this process holds, which is the only place it is known.
|
|
793
|
+
const { roster, rosterTotal } = await standings.rosterFor(id, (who) =>
|
|
794
|
+
activePlayerSockets.has(playerSocketKey(id, who)),
|
|
795
|
+
);
|
|
691
796
|
return {
|
|
692
797
|
sequence: room.sequence(),
|
|
693
798
|
view: room.publicView(),
|
|
694
799
|
launch: room.launch,
|
|
695
800
|
terms: wireTerms(room.terms),
|
|
801
|
+
roster,
|
|
802
|
+
rosterTotal,
|
|
696
803
|
};
|
|
697
804
|
});
|
|
698
805
|
|
|
@@ -869,11 +976,11 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
|
|
|
869
976
|
const balance = viewer
|
|
870
977
|
? await claims.economyBalance(id, viewer)
|
|
871
978
|
: {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
979
|
+
unitsPerPoint: room.terms.unitsPerPoint,
|
|
980
|
+
earned: 0n,
|
|
981
|
+
held: 0n,
|
|
982
|
+
spent: 0n,
|
|
983
|
+
available: 0n,
|
|
877
984
|
holdExpiresAt: null,
|
|
878
985
|
};
|
|
879
986
|
const economy = wireEconomy(balance);
|
package/src/standings.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Pool } from 'pg';
|
|
2
|
+
import { isAddress } from 'viem';
|
|
3
|
+
import type { PlayerId } from '@flayerlabs/gamemode-spec';
|
|
4
|
+
import {
|
|
5
|
+
ETH_SPEND_TOKEN,
|
|
6
|
+
ROSTER_MAX_PLAYERS,
|
|
7
|
+
STANDINGS_MAX_PLAYERS,
|
|
8
|
+
roundPhaseAt,
|
|
9
|
+
type RoundRosterEntry,
|
|
10
|
+
type RoundStandings,
|
|
11
|
+
} from '@flayerlabs/gamemode-spec/live';
|
|
12
|
+
import { toBigInt } from './store.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The public reads of who played a round and how they did.
|
|
16
|
+
*
|
|
17
|
+
* Reads only, and never through a Room: loading one resurrects a round, advances its state and
|
|
18
|
+
* answers nothing once the books close — none of which a spectator's table should do. Everything
|
|
19
|
+
* here comes straight from `rounds` and `balances`, the same rows the ledger writes.
|
|
20
|
+
*
|
|
21
|
+
* Scoped by game, so another game's round sharing this database reads as no round at all rather
|
|
22
|
+
* than as somebody else's standings.
|
|
23
|
+
*/
|
|
24
|
+
export class Standings {
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly pool: Pool,
|
|
27
|
+
private readonly gameId: string,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The standings of the latest round this gate ran for a coin, or null when it ran none.
|
|
32
|
+
*
|
|
33
|
+
* Latest by opening time: a coin can only carry one live round, and a page asking about a coin
|
|
34
|
+
* wants the round people remember, not the first one ever adopted.
|
|
35
|
+
*/
|
|
36
|
+
async forCoin(coin: unknown, now: number = Date.now()): Promise<RoundStandings | null> {
|
|
37
|
+
if (typeof coin !== 'string' || !isAddress(coin, { strict: false })) return null;
|
|
38
|
+
|
|
39
|
+
const rounds = await this.pool.query<{
|
|
40
|
+
id: string;
|
|
41
|
+
pool_id: string;
|
|
42
|
+
coin_address: string | null;
|
|
43
|
+
coin_symbol: string;
|
|
44
|
+
opens_at: string;
|
|
45
|
+
closes_at: string;
|
|
46
|
+
spend_token: string;
|
|
47
|
+
spend_symbol: string;
|
|
48
|
+
spend_decimals: number;
|
|
49
|
+
usd_per_spend_token: number | null;
|
|
50
|
+
usd_per_eth: number;
|
|
51
|
+
}>(
|
|
52
|
+
`select id, pool_id, coin_address, coin_symbol, opens_at, closes_at,
|
|
53
|
+
spend_token, spend_symbol, spend_decimals, usd_per_spend_token, usd_per_eth
|
|
54
|
+
from rounds
|
|
55
|
+
where lower(coin_address) = $1 and game_id = $2
|
|
56
|
+
order by opens_at desc
|
|
57
|
+
limit 1`,
|
|
58
|
+
[coin.toLowerCase(), this.gameId],
|
|
59
|
+
);
|
|
60
|
+
const round = rounds.rows[0];
|
|
61
|
+
if (!round) return null;
|
|
62
|
+
|
|
63
|
+
// ORDER BY is qualified on purpose. A bare `points` there would bind to the OUTPUT column —
|
|
64
|
+
// the `::text` cast that shares its name — and sort as strings, ranking 99 above 100. The
|
|
65
|
+
// qualified name is the only spelling Postgres reads as the bigint underneath.
|
|
66
|
+
const [players, total] = await Promise.all([
|
|
67
|
+
this.pool.query<{ player: string; points: string; chain_spent_wei: string }>(
|
|
68
|
+
`select player, points::text, chain_spent_wei::text
|
|
69
|
+
from balances
|
|
70
|
+
where round_id = $1
|
|
71
|
+
order by balances.points desc, balances.chain_spent_wei desc, balances.player asc
|
|
72
|
+
limit $2`,
|
|
73
|
+
[round.id, STANDINGS_MAX_PLAYERS],
|
|
74
|
+
),
|
|
75
|
+
this.pool.query<{ total: string }>(`select count(*)::text as total from balances where round_id = $1`, [
|
|
76
|
+
round.id,
|
|
77
|
+
]),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const window = { opensAt: Number(round.opens_at), closesAt: Number(round.closes_at) };
|
|
81
|
+
return {
|
|
82
|
+
roundId: round.id,
|
|
83
|
+
poolId: round.pool_id,
|
|
84
|
+
coinAddress: round.coin_address,
|
|
85
|
+
symbol: round.coin_symbol,
|
|
86
|
+
phase: roundPhaseAt(window, now),
|
|
87
|
+
...window,
|
|
88
|
+
spendToken: {
|
|
89
|
+
address: round.spend_token,
|
|
90
|
+
symbol: round.spend_symbol,
|
|
91
|
+
decimals: Number(round.spend_decimals),
|
|
92
|
+
isNative: round.spend_token.toLowerCase() === ETH_SPEND_TOKEN.address,
|
|
93
|
+
},
|
|
94
|
+
// usd_per_eth is the legacy column: on a row written before the spend-token columns existed
|
|
95
|
+
// it holds that round's ETH price, which is exactly its spend token's price.
|
|
96
|
+
usdPerSpendToken:
|
|
97
|
+
round.usd_per_spend_token === null ? Number(round.usd_per_eth) : Number(round.usd_per_spend_token),
|
|
98
|
+
players: players.rows.map((row, index) => ({
|
|
99
|
+
rank: index + 1,
|
|
100
|
+
player: row.player,
|
|
101
|
+
points: Number(row.points),
|
|
102
|
+
spent: toBigInt(row.chain_spent_wei),
|
|
103
|
+
})),
|
|
104
|
+
playersTotal: Number(total.rows[0]?.total ?? 0),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Who holds a seat in a round: every wallet with a balance row, best first, and whether each is
|
|
110
|
+
* connected right now. Connection is the caller's to know — it owns the sockets.
|
|
111
|
+
*/
|
|
112
|
+
async rosterFor(
|
|
113
|
+
roundId: string,
|
|
114
|
+
connected: (player: PlayerId) => boolean,
|
|
115
|
+
): Promise<{ roster: RoundRosterEntry[]; rosterTotal: number }> {
|
|
116
|
+
const [seats, total] = await Promise.all([
|
|
117
|
+
this.pool.query<{ player: string; points: string }>(
|
|
118
|
+
`select player, points::text
|
|
119
|
+
from balances
|
|
120
|
+
where round_id = $1
|
|
121
|
+
order by balances.points desc, balances.player asc
|
|
122
|
+
limit $2`,
|
|
123
|
+
[roundId, ROSTER_MAX_PLAYERS],
|
|
124
|
+
),
|
|
125
|
+
this.pool.query<{ total: string }>(`select count(*)::text as total from balances where round_id = $1`, [
|
|
126
|
+
roundId,
|
|
127
|
+
]),
|
|
128
|
+
]);
|
|
129
|
+
return {
|
|
130
|
+
roster: seats.rows.map((row) => ({
|
|
131
|
+
player: row.player,
|
|
132
|
+
points: Number(row.points),
|
|
133
|
+
connected: connected(row.player),
|
|
134
|
+
})),
|
|
135
|
+
rosterTotal: Number(total.rows[0]?.total ?? 0),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -18,6 +18,15 @@ create table if not exists rounds (
|
|
|
18
18
|
-- was priced at.
|
|
19
19
|
points_per_dollar double precision not null check (points_per_dollar > 0),
|
|
20
20
|
usd_per_eth double precision not null check (usd_per_eth > 0),
|
|
21
|
+
-- The token spend is measured in: the coin's paired token, in whose base units every *_wei column
|
|
22
|
+
-- of this round is denominated. Defaults describe ETH, which is what every round was before the
|
|
23
|
+
-- columns existed, so a pre-existing row reads back exactly as it was priced. usd_per_eth holds
|
|
24
|
+
-- the rate normalised to eighteen decimals (see economy.ts); usd_per_spend_token the token's own
|
|
25
|
+
-- price, null on rows that predate it (for which usd_per_eth is that price).
|
|
26
|
+
spend_token text not null default '0x0000000000000000000000000000000000000000' check (spend_token <> ''),
|
|
27
|
+
spend_symbol text not null default 'ETH' check (spend_symbol <> ''),
|
|
28
|
+
spend_decimals integer not null default 18 check (spend_decimals between 0 and 36),
|
|
29
|
+
usd_per_spend_token double precision check (usd_per_spend_token > 0),
|
|
21
30
|
max_points_per_player bigint not null check (max_points_per_player > 0),
|
|
22
31
|
score_authority text not null check (score_authority in ('rules', 'external-server')),
|
|
23
32
|
pool_id text not null,
|
|
@@ -68,6 +77,18 @@ alter table rounds alter column score_authority set not null;
|
|
|
68
77
|
alter table rounds alter column points_per_dollar set not null;
|
|
69
78
|
alter table rounds alter column usd_per_eth set not null;
|
|
70
79
|
|
|
80
|
+
-- Paired-token rounds (0.5.0). Every earlier round was an ETH round, so the defaults ARE the
|
|
81
|
+
-- backfill: Postgres fills a new not-null column with its default, and ETH/18 is exactly what those
|
|
82
|
+
-- rows were priced in. No guard is needed — nothing here has to be guessed.
|
|
83
|
+
alter table rounds
|
|
84
|
+
add column if not exists spend_token text not null default '0x0000000000000000000000000000000000000000' check (spend_token <> '');
|
|
85
|
+
alter table rounds
|
|
86
|
+
add column if not exists spend_symbol text not null default 'ETH' check (spend_symbol <> '');
|
|
87
|
+
alter table rounds
|
|
88
|
+
add column if not exists spend_decimals integer not null default 18 check (spend_decimals between 0 and 36);
|
|
89
|
+
alter table rounds
|
|
90
|
+
add column if not exists usd_per_spend_token double precision check (usd_per_spend_token > 0);
|
|
91
|
+
|
|
71
92
|
create table if not exists balances (
|
|
72
93
|
round_id text not null references rounds(id) on delete cascade,
|
|
73
94
|
player text not null,
|