@flayerlabs/gamemode-client 0.1.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/src/live.ts ADDED
@@ -0,0 +1,609 @@
1
+ import type {
2
+ BuyFailure,
3
+ BuyProgress,
4
+ BuyResult,
5
+ ClientPlatform,
6
+ Economy,
7
+ EconomyState,
8
+ Room,
9
+ SendResult,
10
+ Snapshot,
11
+ Social,
12
+ } from './index.js';
13
+ import type { PlayerId } from '@flayerlabs/gamemode-spec';
14
+ import type { SpendAuthorisation } from '@flayerlabs/gamemode-spec/embed';
15
+ import {
16
+ LIVE_PROTOCOL_VERSION,
17
+ isReactionId,
18
+ isLaunchContext,
19
+ type ConnectionState,
20
+ type LaunchContext,
21
+ type LiveClientFrame,
22
+ type MarketState,
23
+ type PresenceState,
24
+ type Reaction,
25
+ type WireEconomyBalance,
26
+ type WireMarketState,
27
+ } from '@flayerlabs/gamemode-spec/live';
28
+ import { Signal } from './signal.js';
29
+ import { CachedIdentityResolver } from './identity.js';
30
+
31
+ /**
32
+ * The page the game is embedded in, as far as the game is concerned.
33
+ *
34
+ * This is the whole wallet surface, and it is two verbs rather than a pipe. The game cannot ask
35
+ * for a transaction to be sent, because there is no way to say it: `buy` names an authorisation
36
+ * the gate already signed, and the page builds the transaction from that itself.
37
+ *
38
+ * The version this replaces forwarded `eth_sendTransaction` with parameters from the frame, which
39
+ * meant a game could ask for any transaction at all and have it appear in the trusted context of
40
+ * the page. That is fine for a game you wrote and wrong for one you did not.
41
+ */
42
+ export interface Host {
43
+ /**
44
+ * Which wallet is connected to the page, or null if none is.
45
+ *
46
+ * Null is an answer, not a failure: a game can tell a player to connect. The version this
47
+ * replaces stayed silent instead, so the game waited out an eight-second timeout and then fell
48
+ * back to whatever extension the browser happened to have — which is how someone ended up signed
49
+ * in as one wallet while the page showed another.
50
+ */
51
+ address(signal?: AbortSignal): Promise<string | null>;
52
+
53
+ /** Ask the player to prove that wallet is theirs. Honour abort when the trusted embed closes. */
54
+ signIn(message: string, signal?: AbortSignal): Promise<`0x${string}`>;
55
+ /** Fresh, single-use admission evidence. Honour abort when the trusted embed closes. */
56
+ sessionEvidence?(signal?: AbortSignal): Promise<unknown>;
57
+ /** Spend an authorisation the gate issued. The page builds and submits the transaction. */
58
+ buy(
59
+ authorisation: Authorisation,
60
+ progress?: (event: HostBuyProgress) => void,
61
+ signal?: AbortSignal,
62
+ ): Promise<{ spentWei: bigint } | { failed: BuyResult }>;
63
+ }
64
+
65
+ /** The host reports only what it uniquely knows; LiveRoom owns the terminal result. */
66
+ export type HostBuyProgress = { state: 'pending'; transactionHash?: string };
67
+
68
+ /** Exactly what the gate signed. Everything is a string because bigints do not survive JSON. */
69
+ export type Authorisation = SpendAuthorisation;
70
+
71
+ export interface LiveOptions {
72
+ /** The gate's origin. The one place this game is allowed to talk to. */
73
+ gateUrl: string;
74
+ roundId: string;
75
+ host: Host;
76
+ /** May be supplied by the parent immediately; a new gate also sends it in its first snapshot. */
77
+ launch?: LaunchContext;
78
+ platform?: ClientPlatform;
79
+ }
80
+
81
+ /** Long enough not to hammer a gate that is restarting, short enough to feel instant. */
82
+ const RECONNECT_MS = 1_000;
83
+ const FIRST_SNAPSHOT_TIMEOUT_MS = 15_000;
84
+
85
+ export async function joinRoom<PublicView, PlayerView, Action>(
86
+ options: LiveOptions,
87
+ ): Promise<Room<PublicView, PlayerView, Action>> {
88
+ const room = new LiveRoom<PublicView, PlayerView, Action>(options);
89
+ try {
90
+ await room.start();
91
+ return room;
92
+ } catch (error) {
93
+ room.dispose();
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ class LiveRoom<PublicView, PlayerView, Action> implements Room<PublicView, PlayerView, Action> {
99
+ private socket: WebSocket | null = null;
100
+ private token: string | null = null;
101
+ private me: PlayerId | null = null;
102
+ private latest: Snapshot<PublicView, PlayerView> | null = null;
103
+ /** Server time minus our time. Every countdown is drawn through this. */
104
+ private skew = 0;
105
+ private closed = false;
106
+ private claimSequence = 0;
107
+ private balanceRevision = 0;
108
+ private activeBuy: symbol | null = null;
109
+ private readonly initialSnapshot: Promise<void>;
110
+ private resolveInitialSnapshot!: () => void;
111
+ private rejectInitialSnapshot!: (error: Error) => void;
112
+ private readonly connectionSignal = new Signal<ConnectionState>('connecting');
113
+ private readonly launchSignal: Signal<LaunchContext | null>;
114
+ private readonly economySignal = new Signal<EconomyState>(emptyEconomy());
115
+ private readonly marketSignal: Signal<MarketState>;
116
+ private readonly presenceSignal = new Signal<PresenceState>({ connectedCount: 1 });
117
+ private readonly stopMarket: () => void;
118
+ private readonly listeners = new Set<(s: Snapshot<PublicView, PlayerView>) => void>();
119
+ private readonly reactionListeners = new Set<(r: Reaction) => void>();
120
+
121
+ /** Told when the connection is unhappy, so a game can say so rather than look frozen. */
122
+ trouble: ((what: 'connection') => void) | null = null;
123
+
124
+ readonly identity: CachedIdentityResolver;
125
+
126
+ constructor(private readonly options: LiveOptions) {
127
+ this.initialSnapshot = new Promise((resolve, reject) => {
128
+ this.resolveInitialSnapshot = resolve;
129
+ this.rejectInitialSnapshot = reject;
130
+ });
131
+ this.launchSignal = new Signal(options.launch ? immutableLaunch(options.launch) : null);
132
+ this.marketSignal = new Signal(
133
+ options.platform?.market?.current() ?? { status: 'unavailable', prices: [], trades: [], marketCapUsd: null },
134
+ );
135
+ this.stopMarket = options.platform?.market?.subscribe((state) => this.marketSignal.set(state)) ?? (() => {});
136
+ this.identity = new CachedIdentityResolver(options.platform?.identity);
137
+ }
138
+
139
+ async start(): Promise<void> {
140
+ const address = await this.options.host.address();
141
+ if (!address) throw new NoWallet();
142
+
143
+ const challenge = await this.post<{ nonce: string; message: string }>('/session/challenge', { address });
144
+ const signature = await this.options.host.signIn(challenge.message);
145
+ const evidence = await this.options.host.sessionEvidence?.();
146
+ const session = await this.post<{ token: string }>('/session', {
147
+ address,
148
+ signature,
149
+ nonce: challenge.nonce,
150
+ ...(evidence === undefined ? {} : { evidence }),
151
+ });
152
+ this.token = session.token;
153
+ this.me = address.toLowerCase();
154
+
155
+ await this.post(`/rounds/${this.options.roundId}/join`, {});
156
+ this.connect();
157
+
158
+ let timeout: ReturnType<typeof setTimeout> | undefined;
159
+ try {
160
+ await Promise.race([
161
+ this.initialSnapshot,
162
+ new Promise<never>((_, reject) => {
163
+ timeout = setTimeout(
164
+ () => reject(new Error('the room did not send its first snapshot in time')),
165
+ FIRST_SNAPSHOT_TIMEOUT_MS,
166
+ );
167
+ }),
168
+ ]);
169
+ if (this.launchSignal.current() === null) {
170
+ throw new Error('the room did not provide launch context');
171
+ }
172
+ } catch (error) {
173
+ this.dispose();
174
+ throw error;
175
+ } finally {
176
+ if (timeout !== undefined) clearTimeout(timeout);
177
+ }
178
+ }
179
+
180
+ private connect(): void {
181
+ if (this.closed) return;
182
+ const url = new URL(`/rounds/${this.options.roundId}/live`, this.options.gateUrl);
183
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
184
+ if (this.token) url.searchParams.set('token', this.token);
185
+
186
+ const socket = new WebSocket(url.toString());
187
+ this.socket = socket;
188
+
189
+ socket.onmessage = (event) => this.receive(String(event.data));
190
+ // Without this a failed upgrade is silent, and the game shows an empty room forever with
191
+ // nothing anywhere saying why.
192
+ socket.onerror = () => {
193
+ if (!this.closed) this.connectionSignal.set('reconnecting');
194
+ this.trouble?.('connection');
195
+ };
196
+ // A dropped socket is normal — a laptop lid, a tunnel, a deploy. Reconnecting silently and
197
+ // taking a fresh snapshot is the whole recovery story, because the gate sends whole state.
198
+ socket.onclose = (event) => {
199
+ if (this.socket === socket) this.socket = null;
200
+ if (this.closed) return;
201
+ if (event.code === 4009 || event.code === 4409) {
202
+ this.closed = true;
203
+ this.connectionSignal.set('superseded');
204
+ this.rejectInitialSnapshot(new Error('this room connection was superseded'));
205
+ return;
206
+ }
207
+ if (event.code === 4404 || event.code === 4410) {
208
+ this.closed = true;
209
+ this.connectionSignal.set('ended');
210
+ this.rejectInitialSnapshot(new Error('this room has ended'));
211
+ return;
212
+ }
213
+ this.connectionSignal.set('reconnecting');
214
+ setTimeout(() => this.connect(), RECONNECT_MS);
215
+ };
216
+ }
217
+
218
+ private receive(data: string): void {
219
+ let frame: Record<string, unknown>;
220
+ try {
221
+ const parsed: unknown = JSON.parse(data);
222
+ if (typeof parsed !== 'object' || parsed === null) return;
223
+ frame = parsed as Record<string, unknown>;
224
+ } catch {
225
+ return;
226
+ }
227
+
228
+ if (frame.type === 'reaction') {
229
+ const reaction = reactionFrom(frame.reaction);
230
+ if (reaction) for (const listener of this.reactionListeners) listener(reaction);
231
+ return;
232
+ }
233
+ if (frame.type === 'market') {
234
+ const market = marketFrom(frame.market);
235
+ if (market) this.marketSignal.set(market);
236
+ return;
237
+ }
238
+ if (frame.type === 'presence') {
239
+ const presence = presenceFrom(frame.presence);
240
+ if (presence) this.presenceSignal.set(presence);
241
+ return;
242
+ }
243
+ if (frame.type !== undefined && frame.type !== 'snapshot') return;
244
+
245
+ if (typeof frame.now === 'number') this.skew = frame.now - Date.now();
246
+ const economy = economyFrom(frame.economy);
247
+ if (economy) this.setBalance(economy);
248
+
249
+ if (this.launchSignal.current() === null && isLaunchContext(frame.launch)) {
250
+ this.launchSignal.set(immutableLaunch(frame.launch));
251
+ }
252
+ const presence = presenceFrom(frame.presence);
253
+ if (presence) this.presenceSignal.set(presence);
254
+ if (!('view' in frame)) return;
255
+
256
+ this.latest = { publicView: frame.view as PublicView, playerView: (frame.you ?? null) as PlayerView | null };
257
+ this.connectionSignal.set('connected');
258
+ for (const listener of this.listeners) listener(this.latest);
259
+ this.resolveInitialSnapshot();
260
+ }
261
+
262
+ private async post<T>(path: string, body: unknown): Promise<T> {
263
+ const response = await fetch(new URL(path, this.options.gateUrl), {
264
+ method: 'POST',
265
+ headers: {
266
+ 'content-type': 'application/json',
267
+ ...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
268
+ },
269
+ body: JSON.stringify(body),
270
+ });
271
+ const payload = await response.json().catch(() => ({}));
272
+ if (!response.ok) throw new GateRefused(response.status, payload);
273
+ return payload as T;
274
+ }
275
+
276
+ subscribe(listener: (snapshot: Snapshot<PublicView, PlayerView>) => void): () => void {
277
+ this.listeners.add(listener);
278
+ if (this.latest) listener(this.latest);
279
+ return () => this.listeners.delete(listener);
280
+ }
281
+
282
+ readonly launch = {
283
+ current: () => this.requiredLaunch(),
284
+ subscribe: (listener: (value: LaunchContext) => void) =>
285
+ this.launchSignal.subscribe((value) => {
286
+ if (value) listener(value);
287
+ }),
288
+ };
289
+
290
+ readonly connection = {
291
+ current: () => this.connectionSignal.current(),
292
+ subscribe: (listener: (value: ConnectionState) => void) => this.connectionSignal.subscribe(listener),
293
+ };
294
+
295
+ readonly market = {
296
+ current: () => this.marketSignal.current(),
297
+ subscribe: (listener: (value: MarketState) => void) => this.marketSignal.subscribe(listener),
298
+ };
299
+
300
+ readonly presence = {
301
+ current: () => this.presenceSignal.current(),
302
+ subscribe: (listener: (value: PresenceState) => void) => this.presenceSignal.subscribe(listener),
303
+ };
304
+
305
+ async send(action: Action): Promise<SendResult> {
306
+ try {
307
+ await this.post(`/rounds/${this.options.roundId}/actions`, { action });
308
+ return { accepted: true };
309
+ } catch (error) {
310
+ if (error instanceof GateRefused) return { accepted: false, refuse: error.refuse };
311
+ throw error;
312
+ }
313
+ }
314
+
315
+ now(): number {
316
+ return Date.now() + this.skew;
317
+ }
318
+
319
+ readonly economy: Economy = {
320
+ current: () => this.economySignal.current(),
321
+ subscribe: (listener) => this.economySignal.subscribe(listener),
322
+ available: () => this.economySignal.current().availableWei,
323
+ buy: (maxSpendWei) => this.buy(maxSpendWei),
324
+ };
325
+
326
+ private async buy(maxSpendWei: bigint): Promise<BuyResult> {
327
+ // Set the token before the first await. Two clicks in the same turn must not both reserve the
328
+ // same allowance, and the losing call must not replace the lifecycle the active call renders.
329
+ if (this.activeBuy !== null) return { bought: false, reason: 'try-again' };
330
+ const attempt = Symbol('buy');
331
+ this.activeBuy = attempt;
332
+ try {
333
+ return await this.performBuy(maxSpendWei, attempt);
334
+ } finally {
335
+ if (this.activeBuy === attempt) this.activeBuy = null;
336
+ }
337
+ }
338
+
339
+ private async performBuy(maxSpendWei: bigint, attempt: symbol): Promise<BuyResult> {
340
+ if (maxSpendWei <= 0n) return this.failBuy(attempt, 'nothing-to-spend');
341
+ const balanceRevision = this.balanceRevision;
342
+ const requestId = this.nextRequestId();
343
+ let authorisation: Authorisation;
344
+ try {
345
+ authorisation = await this.postClaim<Authorisation>(`/rounds/${this.options.roundId}/claim`, {
346
+ maxSpendWei: maxSpendWei.toString(),
347
+ requestId,
348
+ });
349
+ } catch (error) {
350
+ if (error instanceof GateRefused) return this.failBuy(attempt, claimFailure(error.refuse));
351
+ return this.failBuy(attempt, 'try-again');
352
+ }
353
+
354
+ let spendWei: bigint;
355
+ try {
356
+ spendWei = BigInt(authorisation.maxSpendWei);
357
+ } catch {
358
+ return this.failBuy(attempt, 'try-again');
359
+ }
360
+ if (this.activeBuy !== attempt) return { bought: false, reason: 'try-again' };
361
+
362
+ const before = this.economySignal.current();
363
+ this.economySignal.set({
364
+ ...before,
365
+ ...(this.balanceRevision === balanceRevision
366
+ ? {
367
+ heldWei: before.heldWei + spendWei,
368
+ availableWei: before.availableWei > spendWei ? before.availableWei - spendWei : 0n,
369
+ }
370
+ : {}),
371
+ buy: { state: 'signing', spendWei },
372
+ });
373
+
374
+ let transactionHash: string | undefined;
375
+ let hostSettled = false;
376
+ // The page builds and submits. This game has no way to say what the transaction should be.
377
+ let result: Awaited<ReturnType<Host['buy']>>;
378
+ try {
379
+ result = await this.options.host.buy(authorisation, (progress) => {
380
+ if (hostSettled || this.activeBuy !== attempt) return;
381
+ transactionHash = progress.transactionHash;
382
+ const current = this.economySignal.current();
383
+ this.economySignal.set({
384
+ ...current,
385
+ buy: transactionHash === undefined
386
+ ? { state: 'pending', spendWei }
387
+ : { state: 'pending', spendWei, transactionHash },
388
+ });
389
+ });
390
+ hostSettled = true;
391
+ } catch {
392
+ hostSettled = true;
393
+ return this.failBuy(attempt, 'try-again');
394
+ }
395
+
396
+ if ('failed' in result) {
397
+ if (result.failed.bought) return this.failBuy(attempt, 'try-again');
398
+ return this.failBuy(attempt, result.failed.reason);
399
+ }
400
+ if (this.activeBuy !== attempt) return { bought: false, reason: 'try-again' };
401
+
402
+ const current = this.economySignal.current();
403
+ const confirmed: BuyProgress = transactionHash === undefined
404
+ ? { state: 'confirmed', spentWei: result.spentWei }
405
+ : { state: 'confirmed', spentWei: result.spentWei, transactionHash };
406
+ // The signed allowance remains held until the gate observes chain settlement or it expires.
407
+ this.economySignal.set({ ...current, buy: confirmed });
408
+ return { bought: true, spentWei: result.spentWei };
409
+ }
410
+
411
+ readonly social: Social = {
412
+ react: (id) => {
413
+ if (!isReactionId(id)) return;
414
+ this.sendFrame({ v: LIVE_PROTOCOL_VERSION, type: 'reaction', id });
415
+ },
416
+ onReaction: (listener) => {
417
+ this.reactionListeners.add(listener);
418
+ return () => this.reactionListeners.delete(listener);
419
+ },
420
+ };
421
+
422
+ dispose(): void {
423
+ this.closed = true;
424
+ this.activeBuy = null;
425
+ this.socket?.close();
426
+ this.connectionSignal.set('ended');
427
+ this.stopMarket();
428
+ this.listeners.clear();
429
+ this.reactionListeners.clear();
430
+ this.connectionSignal.clear();
431
+ this.launchSignal.clear();
432
+ this.economySignal.clear();
433
+ this.marketSignal.clear();
434
+ this.presenceSignal.clear();
435
+ }
436
+
437
+ private setBalance(balance: Omit<EconomyState, 'buy'>): void {
438
+ this.balanceRevision += 1;
439
+ this.economySignal.set({ ...balance, buy: this.economySignal.current().buy });
440
+ }
441
+
442
+ private failBuy(attempt: symbol, reason: BuyFailure): { bought: false; reason: BuyFailure } {
443
+ if (this.activeBuy === attempt) {
444
+ const current = this.economySignal.current();
445
+ this.economySignal.set({ ...current, buy: { state: 'failed', reason } });
446
+ }
447
+ return { bought: false, reason };
448
+ }
449
+
450
+ private nextRequestId(): string {
451
+ this.claimSequence += 1;
452
+ return `${globalThis.crypto.randomUUID()}:${this.claimSequence}`;
453
+ }
454
+
455
+ private requiredLaunch(): LaunchContext {
456
+ const launch = this.launchSignal.current();
457
+ if (launch === null) throw new Error('launch context is not ready');
458
+ return launch;
459
+ }
460
+
461
+ /** Claims are idempotent by request id, so a lost response can be retried exactly once. */
462
+ private async postClaim<T>(path: string, body: unknown): Promise<T> {
463
+ try {
464
+ return await this.post<T>(path, body);
465
+ } catch (error) {
466
+ if (error instanceof GateRefused && error.status < 500) throw error;
467
+ return this.post<T>(path, body);
468
+ }
469
+ }
470
+
471
+ private sendFrame(frame: LiveClientFrame): void {
472
+ if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(frame));
473
+ }
474
+ }
475
+
476
+ function emptyEconomy(): EconomyState {
477
+ return {
478
+ weiPerPoint: 0n,
479
+ earnedWei: 0n,
480
+ heldWei: 0n,
481
+ spentWei: 0n,
482
+ availableWei: 0n,
483
+ holdExpiresAt: null,
484
+ buy: null,
485
+ };
486
+ }
487
+
488
+ function economyFrom(input: unknown): Omit<EconomyState, 'buy'> | null {
489
+ if (typeof input !== 'object' || input === null) return null;
490
+ const value = input as Partial<WireEconomyBalance>;
491
+ try {
492
+ const amounts = {
493
+ weiPerPoint: BigInt(value.weiPerPoint ?? '0'),
494
+ earnedWei: BigInt(value.earnedWei ?? '-1'),
495
+ heldWei: BigInt(value.heldWei ?? '-1'),
496
+ spentWei: BigInt(value.spentWei ?? '-1'),
497
+ availableWei: BigInt(value.availableWei ?? '-1'),
498
+ };
499
+ if (amounts.weiPerPoint <= 0n || Object.values(amounts).some((amount) => amount < 0n)) return null;
500
+
501
+ // A deadline is either a whole millisecond timestamp or absent. Anything else — a string, a
502
+ // fraction, Infinity — is a malformed frame, and a countdown drawn from one would run to a
503
+ // moment that never arrives.
504
+ const holdExpiresAt = value.holdExpiresAt;
505
+ if (holdExpiresAt !== null && holdExpiresAt !== undefined && !Number.isSafeInteger(holdExpiresAt)) {
506
+ return null;
507
+ }
508
+ return { ...amounts, holdExpiresAt: holdExpiresAt ?? null };
509
+ } catch {
510
+ return null;
511
+ }
512
+ }
513
+
514
+ function presenceFrom(input: unknown): PresenceState | null {
515
+ if (typeof input !== 'object' || input === null) return null;
516
+ const value = input as Partial<PresenceState>;
517
+ if (
518
+ typeof value.connectedCount !== 'number' ||
519
+ !Number.isSafeInteger(value.connectedCount) ||
520
+ value.connectedCount < 0
521
+ ) return null;
522
+ return { connectedCount: value.connectedCount };
523
+ }
524
+
525
+ function reactionFrom(input: unknown): Reaction | null {
526
+ if (typeof input !== 'object' || input === null) return null;
527
+ const value = input as { player?: unknown; id?: unknown };
528
+ return typeof value.player === 'string' && typeof value.id === 'string' && isReactionId(value.id)
529
+ ? { player: value.player, id: value.id }
530
+ : null;
531
+ }
532
+
533
+ export function marketFrom(input: unknown): MarketState | null {
534
+ if (typeof input !== 'object' || input === null) return null;
535
+ const value = input as Partial<WireMarketState>;
536
+ if (!['unavailable', 'loading', 'live', 'stale'].includes(String(value.status))) return null;
537
+ if (
538
+ !Array.isArray(value.prices) ||
539
+ !Array.isArray(value.trades) ||
540
+ value.prices.length > 1_000 ||
541
+ value.trades.length > 500
542
+ ) return null;
543
+
544
+ const prices = value.prices.filter(
545
+ (price) =>
546
+ typeof price?.at === 'number' &&
547
+ Number.isFinite(price.at) &&
548
+ typeof price.priceEth === 'number' &&
549
+ Number.isFinite(price.priceEth) &&
550
+ price.priceEth > 0,
551
+ );
552
+ const trades = value.trades.flatMap((trade) => {
553
+ try {
554
+ if (
555
+ typeof trade?.id !== 'string' ||
556
+ typeof trade.at !== 'number' ||
557
+ !Number.isFinite(trade.at) ||
558
+ (trade.player !== null && typeof trade.player !== 'string') ||
559
+ (trade.side !== 'buy' && trade.side !== 'sell') ||
560
+ (trade.priceEth !== null && (typeof trade.priceEth !== 'number' || !Number.isFinite(trade.priceEth)))
561
+ ) return [];
562
+ const spendWei = BigInt(trade.spendWei);
563
+ if (spendWei < 0n) return [];
564
+ return [{
565
+ id: trade.id,
566
+ at: trade.at,
567
+ player: trade.player,
568
+ side: trade.side,
569
+ spendWei,
570
+ priceEth: trade.priceEth,
571
+ ...(typeof trade.transactionHash === 'string' ? { transactionHash: trade.transactionHash } : {}),
572
+ }];
573
+ } catch {
574
+ return [];
575
+ }
576
+ });
577
+ const marketCapUsd = typeof value.marketCapUsd === 'number' && Number.isFinite(value.marketCapUsd)
578
+ ? value.marketCapUsd
579
+ : null;
580
+ return { status: value.status!, prices, trades, marketCapUsd };
581
+ }
582
+
583
+ export function immutableLaunch(launch: LaunchContext): LaunchContext {
584
+ return Object.freeze({ ...launch });
585
+ }
586
+
587
+ function claimFailure(refuse: string): BuyFailure {
588
+ if (refuse === 'claim.nothing_earned' || refuse === 'claim.nothing_available') return 'nothing-to-spend';
589
+ if (refuse === 'claim.not_open_yet' || refuse === 'claim.window_closed') return 'window-closed';
590
+ return 'try-again';
591
+ }
592
+
593
+ /** No wallet is connected to the page. A game should ask the player to connect one. */
594
+ export class NoWallet extends Error {
595
+ constructor() {
596
+ super('no wallet is connected');
597
+ this.name = 'NoWallet';
598
+ }
599
+ }
600
+
601
+ /** A refusal from the gate, carrying its stable code for the client to turn into words. */
602
+ class GateRefused extends Error {
603
+ readonly refuse: string;
604
+ constructor(readonly status: number, payload: { refuse?: string; message?: string }) {
605
+ super(payload.message ?? payload.refuse ?? `request failed (${status})`);
606
+ this.name = 'GateRefused';
607
+ this.refuse = payload.refuse ?? 'request.failed';
608
+ }
609
+ }