@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/embed.ts ADDED
@@ -0,0 +1,395 @@
1
+ import type { PlayerId } from '@flayerlabs/gamemode-spec';
2
+ import {
3
+ EMBED_CHANNEL,
4
+ EMBED_PROTOCOL_VERSION,
5
+ isCanonicalSpendHookData,
6
+ isPlayerIdentities,
7
+ isSessionChallenge,
8
+ parseGameToHostMessage,
9
+ parseHostToGameMessage,
10
+ type EmbedFailure,
11
+ type EmbedRequest,
12
+ type EmbeddedContext,
13
+ type GameToHostMessage,
14
+ type HostToGameMessage,
15
+ } from '@flayerlabs/gamemode-spec/embed';
16
+ import type { MarketState, WireMarketState } from '@flayerlabs/gamemode-spec/live';
17
+ import type { ClientPlatform, IdentityResolver, Readable } from './index.js';
18
+ import { immutableLaunch, marketFrom, type Authorisation, type Host, type HostBuyProgress } from './live.js';
19
+ import { Signal } from './signal.js';
20
+
21
+ const DEFAULT_TIMEOUT_MS = 120_000;
22
+ /** A room uses far fewer; refusing after the cap keeps lifetime replay memory strictly bounded. */
23
+ const MAX_ACCEPTED_REQUEST_IDS = 1_024;
24
+
25
+ export interface EmbedEventWindow {
26
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void;
27
+ removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void;
28
+ }
29
+
30
+ export interface EmbedTargetWindow {
31
+ postMessage(message: unknown, targetOrigin: string): void;
32
+ }
33
+
34
+ export interface EmbeddedGameConnection {
35
+ context: EmbeddedContext;
36
+ host: Host;
37
+ platform: ClientPlatform;
38
+ dispose(): void;
39
+ }
40
+
41
+ interface Pending {
42
+ method: EmbedRequest['method'];
43
+ resolve(value: unknown): void;
44
+ reject(error: Error): void;
45
+ progress?: (event: HostBuyProgress) => void;
46
+ timer: ReturnType<typeof setTimeout>;
47
+ }
48
+
49
+ export interface ConnectEmbeddedGameOptions {
50
+ /** Fixed in trusted game code, never taken from a query string supplied by the parent. */
51
+ parentOrigin: string;
52
+ timeoutMs?: number;
53
+ currentWindow?: EmbedEventWindow;
54
+ parentWindow?: EmbedTargetWindow;
55
+ }
56
+
57
+ /** Connect an untrusted game frame to the semantic services of its trusted parent. */
58
+ export function connectEmbeddedGame(options: ConnectEmbeddedGameOptions): Promise<EmbeddedGameConnection> {
59
+ const current = options.currentWindow ?? window;
60
+ const parent = options.parentWindow ?? window.parent;
61
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
62
+ const market = new Signal<MarketState>({ status: 'unavailable', prices: [], trades: [], marketCapUsd: null });
63
+ const pending = new Map<string, Pending>();
64
+ let disposed = false;
65
+ let context: EmbeddedContext | null = null;
66
+ let resolveContext!: (value: EmbeddedContext) => void;
67
+ let rejectContext!: (error: Error) => void;
68
+ const ready = new Promise<EmbeddedContext>((resolve, reject) => {
69
+ resolveContext = resolve;
70
+ rejectContext = reject;
71
+ });
72
+
73
+ const post = (message: GameToHostMessage): void => parent.postMessage(message, options.parentOrigin);
74
+ const request = (value: EmbedRequest, progress?: (event: HostBuyProgress) => void): Promise<unknown> => {
75
+ if (disposed) return Promise.reject(new Error('the embedded connection is closed'));
76
+ const id = globalThis.crypto.randomUUID();
77
+ return new Promise((resolve, reject) => {
78
+ const timer = setTimeout(() => {
79
+ pending.delete(id);
80
+ reject(new Error('the trusted parent did not answer in time'));
81
+ }, timeoutMs);
82
+ pending.set(id, { method: value.method, resolve, reject, timer, ...(progress ? { progress } : {}) });
83
+ post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'request', id, request: value });
84
+ });
85
+ };
86
+
87
+ const onMessage: EventListener = (rawEvent): void => {
88
+ const event = rawEvent as MessageEvent;
89
+ if (event.origin !== options.parentOrigin || event.source !== parent) return;
90
+ const message = parseHostToGameMessage(event.data);
91
+ if (!message) return;
92
+ if (message.type === 'context') {
93
+ if (context === null) {
94
+ context = Object.freeze({ ...message.context, launch: immutableLaunch(message.context.launch) });
95
+ resolveContext(context);
96
+ }
97
+ return;
98
+ }
99
+ if (message.type === 'market') {
100
+ const parsed = marketFrom(message.market);
101
+ if (parsed) market.set(parsed);
102
+ return;
103
+ }
104
+ const waiting = pending.get(message.id);
105
+ if (!waiting) return;
106
+ if (message.type === 'buy-progress') {
107
+ waiting.progress?.(
108
+ message.transactionHash
109
+ ? { state: 'pending', transactionHash: message.transactionHash }
110
+ : { state: 'pending' },
111
+ );
112
+ return;
113
+ }
114
+ pending.delete(message.id);
115
+ clearTimeout(waiting.timer);
116
+ if (!message.ok) {
117
+ if (waiting.method === 'buy') {
118
+ const reason = message.error === 'no-wallet' ? 'try-again' : message.error;
119
+ waiting.resolve({ failed: { bought: false, reason } });
120
+ } else {
121
+ waiting.reject(new Error(message.error));
122
+ }
123
+ return;
124
+ }
125
+ waiting.resolve(message.value);
126
+ };
127
+ current.addEventListener('message', onMessage);
128
+
129
+ const contextTimer = setTimeout(() => {
130
+ disposed = true;
131
+ current.removeEventListener('message', onMessage);
132
+ rejectContext(new Error('the trusted parent did not provide game context'));
133
+ }, timeoutMs);
134
+ post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'ready' });
135
+
136
+ return ready.then((trustedContext) => {
137
+ clearTimeout(contextTimer);
138
+ const host: Host = {
139
+ address: async () => {
140
+ const value = await request({ method: 'address' });
141
+ return typeof value === 'string' || value === null ? value : null;
142
+ },
143
+ signIn: async (message) => {
144
+ const value = await request({ method: 'sign-in', message });
145
+ if (typeof value !== 'string' || !/^0x[0-9a-fA-F]+$/.test(value)) throw new Error('invalid signature response');
146
+ return value as `0x${string}`;
147
+ },
148
+ sessionEvidence: () => request({ method: 'session-evidence' }),
149
+ buy: async (authorisation, progress) => {
150
+ const value = await request({ method: 'buy', authorisation }, progress);
151
+ if (typeof value !== 'object' || value === null) return { failed: { bought: false, reason: 'try-again' } };
152
+ const spent = (value as { spentWei?: unknown }).spentWei;
153
+ try {
154
+ const spentWei = BigInt(String(spent));
155
+ return spentWei >= 0n ? { spentWei } : { failed: { bought: false, reason: 'try-again' } };
156
+ } catch {
157
+ return { failed: { bought: false, reason: 'try-again' } };
158
+ }
159
+ },
160
+ };
161
+ const identity: IdentityResolver = {
162
+ resolve: async (players) => {
163
+ const value = await request({ method: 'identity', players });
164
+ return isPlayerIdentities(value) ? value : [];
165
+ },
166
+ };
167
+ const marketReadable: Readable<MarketState> = {
168
+ current: () => market.current(),
169
+ subscribe: (listener) => market.subscribe(listener),
170
+ };
171
+ return {
172
+ context: trustedContext,
173
+ host,
174
+ platform: { identity, market: marketReadable },
175
+ dispose: () => {
176
+ if (disposed) return;
177
+ disposed = true;
178
+ current.removeEventListener('message', onMessage);
179
+ for (const waiting of pending.values()) {
180
+ clearTimeout(waiting.timer);
181
+ waiting.reject(new Error('the embedded connection is closed'));
182
+ }
183
+ pending.clear();
184
+ market.clear();
185
+ },
186
+ };
187
+ });
188
+ }
189
+
190
+ export interface ServeEmbeddedGameOptions {
191
+ currentWindow?: EmbedEventWindow;
192
+ frameWindow: EmbedTargetWindow;
193
+ gameOrigin: string;
194
+ context: EmbeddedContext;
195
+ host: Host;
196
+ /**
197
+ * Verify the gate signature and signer for the canonical spend fields.
198
+ *
199
+ * The bridge itself checks the complete ABI encoding, including the required zero referrer,
200
+ * before calling this seam. A false result fails closed before any wallet UI can open.
201
+ */
202
+ validateAuthorisation(
203
+ authorisation: Authorisation,
204
+ context: EmbeddedContext,
205
+ signal?: AbortSignal,
206
+ ): boolean | Promise<boolean>;
207
+ platform?: ClientPlatform;
208
+ /** Display domain used by the gate's canonical wallet challenge. */
209
+ signInDomain?: string;
210
+ }
211
+
212
+ function wireMarket(state: MarketState): WireMarketState {
213
+ return { ...state, trades: state.trades.map((trade) => ({ ...trade, spendWei: trade.spendWei.toString() })) };
214
+ }
215
+
216
+ function failure(reason: string): EmbedFailure {
217
+ return ['nothing-to-spend', 'declined', 'not-enough-for-fees', 'window-closed'].includes(reason)
218
+ ? (reason as EmbedFailure)
219
+ : 'try-again';
220
+ }
221
+
222
+ /** Serve one known iframe. Both its origin and WindowProxy are required for every request. */
223
+ export function serveEmbeddedGame(options: ServeEmbeddedGameOptions): () => void {
224
+ const current = options.currentWindow ?? window;
225
+ const active = new Set<string>();
226
+ const acceptedRequestIds = new Set<string>();
227
+ const usedAuthorisations = new Set<string>();
228
+ const requestTimes: number[] = [];
229
+ let buyActive = false;
230
+ let signInActive = false;
231
+ let evidenceActive = false;
232
+ let childReady = false;
233
+ let stopped = false;
234
+ const lifetime = new AbortController();
235
+ let latestMarket: MarketState | null = options.platform?.market?.current() ?? null;
236
+ const post = (message: HostToGameMessage): void => {
237
+ if (!stopped) options.frameWindow.postMessage(message, options.gameOrigin);
238
+ };
239
+ const answer = (id: string, value: unknown): void =>
240
+ post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'response', id, ok: true, value });
241
+ const refuse = (id: string, error: EmbedFailure): void =>
242
+ post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'response', id, ok: false, error });
243
+
244
+ const run = async (id: string, request: EmbedRequest): Promise<void> => {
245
+ if (stopped || acceptedRequestIds.has(id) || acceptedRequestIds.size >= MAX_ACCEPTED_REQUEST_IDS) return;
246
+ const now = Date.now();
247
+ while (requestTimes[0] !== undefined && requestTimes[0] <= now - 1_000) requestTimes.shift();
248
+ if (active.size >= 16 || requestTimes.length >= 64) return;
249
+ requestTimes.push(now);
250
+ acceptedRequestIds.add(id);
251
+ active.add(id);
252
+ try {
253
+ switch (request.method) {
254
+ case 'address':
255
+ answer(id, await options.host.address(lifetime.signal));
256
+ break;
257
+ case 'sign-in':
258
+ if (signInActive) {
259
+ refuse(id, 'try-again');
260
+ break;
261
+ }
262
+ signInActive = true;
263
+ try {
264
+ const address = await options.host.address(lifetime.signal);
265
+ if (stopped) break;
266
+ if (
267
+ !address ||
268
+ !isSessionChallenge(request.message, address, options.context.gateUrl, options.signInDomain)
269
+ ) {
270
+ refuse(id, address ? 'try-again' : 'no-wallet');
271
+ break;
272
+ }
273
+ const signature = await options.host.signIn(request.message, lifetime.signal);
274
+ if (stopped) break;
275
+ answer(id, signature);
276
+ } finally {
277
+ signInActive = false;
278
+ }
279
+ break;
280
+ case 'session-evidence':
281
+ if (evidenceActive) {
282
+ refuse(id, 'try-again');
283
+ break;
284
+ }
285
+ evidenceActive = true;
286
+ try {
287
+ const evidence = await options.host.sessionEvidence?.(lifetime.signal);
288
+ if (!stopped) answer(id, evidence);
289
+ } finally {
290
+ evidenceActive = false;
291
+ }
292
+ break;
293
+ case 'identity':
294
+ answer(id, (await options.platform?.identity?.resolve(request.players)) ?? []);
295
+ break;
296
+ case 'buy': {
297
+ if (buyActive) {
298
+ refuse(id, 'try-again');
299
+ break;
300
+ }
301
+ buyActive = true;
302
+ try {
303
+ const address = await options.host.address(lifetime.signal);
304
+ if (stopped) break;
305
+ if (
306
+ !address ||
307
+ address.toLowerCase() !== request.authorisation.buyer.toLowerCase() ||
308
+ request.authorisation.poolId.toLowerCase() !== options.context.launch.poolId.toLowerCase()
309
+ ) {
310
+ refuse(id, address ? 'try-again' : 'no-wallet');
311
+ break;
312
+ }
313
+ if (
314
+ !isCanonicalSpendHookData(request.authorisation) ||
315
+ !(await options.validateAuthorisation(request.authorisation, options.context, lifetime.signal))
316
+ ) {
317
+ refuse(id, 'try-again');
318
+ break;
319
+ }
320
+ if (stopped) break;
321
+ const authorisationId = [
322
+ request.authorisation.buyer.toLowerCase(),
323
+ request.authorisation.poolId.toLowerCase(),
324
+ request.authorisation.nonce,
325
+ ].join(':');
326
+ if (usedAuthorisations.has(authorisationId) || usedAuthorisations.size >= 256) {
327
+ refuse(id, 'try-again');
328
+ break;
329
+ }
330
+ usedAuthorisations.add(authorisationId);
331
+ const result = await options.host.buy(
332
+ request.authorisation,
333
+ (event) => {
334
+ post({
335
+ channel: EMBED_CHANNEL,
336
+ v: EMBED_PROTOCOL_VERSION,
337
+ type: 'buy-progress',
338
+ id,
339
+ ...(event.transactionHash ? { transactionHash: event.transactionHash } : {}),
340
+ });
341
+ },
342
+ lifetime.signal,
343
+ );
344
+ if (stopped) break;
345
+ if ('failed' in result) {
346
+ refuse(id, result.failed.bought ? 'try-again' : failure(result.failed.reason));
347
+ } else {
348
+ answer(id, { spentWei: result.spentWei.toString() });
349
+ }
350
+ } finally {
351
+ buyActive = false;
352
+ }
353
+ break;
354
+ }
355
+ }
356
+ } catch {
357
+ refuse(id, 'try-again');
358
+ } finally {
359
+ active.delete(id);
360
+ }
361
+ };
362
+
363
+ const onMessage: EventListener = (rawEvent): void => {
364
+ const event = rawEvent as MessageEvent;
365
+ if (event.origin !== options.gameOrigin || event.source !== options.frameWindow) return;
366
+ const message = parseGameToHostMessage(event.data);
367
+ if (!message) return;
368
+ if (message.type === 'ready') {
369
+ if (childReady) return;
370
+ childReady = true;
371
+ post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'context', context: options.context });
372
+ if (latestMarket) {
373
+ post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'market', market: wireMarket(latestMarket) });
374
+ }
375
+ return;
376
+ }
377
+ void run(message.id, message.request);
378
+ };
379
+ current.addEventListener('message', onMessage);
380
+ const stopMarket =
381
+ options.platform?.market?.subscribe((state) => {
382
+ latestMarket = state;
383
+ if (childReady) {
384
+ post({ channel: EMBED_CHANNEL, v: EMBED_PROTOCOL_VERSION, type: 'market', market: wireMarket(state) });
385
+ }
386
+ }) ?? (() => {});
387
+
388
+ return () => {
389
+ if (stopped) return;
390
+ stopped = true;
391
+ lifetime.abort();
392
+ current.removeEventListener('message', onMessage);
393
+ stopMarket();
394
+ };
395
+ }
@@ -0,0 +1,51 @@
1
+ import type { PlayerId } from '@flayerlabs/gamemode-spec';
2
+ import type { PlayerIdentity } from '@flayerlabs/gamemode-spec/live';
3
+ import type { IdentityResolver } from './index.js';
4
+
5
+ /** Batches misses through the platform adapter and gives every requested player a stable answer. */
6
+ export class CachedIdentityResolver implements IdentityResolver {
7
+ private readonly cache = new Map<PlayerId, PlayerIdentity>();
8
+ private readonly inFlight = new Map<PlayerId, Promise<void>>();
9
+
10
+ constructor(private readonly adapter?: IdentityResolver) {}
11
+
12
+ async resolve(players: readonly PlayerId[]): Promise<readonly PlayerIdentity[]> {
13
+ if (players.length > 1_000) throw new RangeError('at most 1,000 identities may be resolved at once');
14
+ const unique = [...new Set(players)];
15
+ const missing = unique.filter((player) => !this.cache.has(player));
16
+ const unclaimed = missing.filter((player) => !this.inFlight.has(player));
17
+
18
+ if (unclaimed.length > 0) {
19
+ let loading!: Promise<void>;
20
+ loading = this.resolveMissing(unclaimed).finally(() => {
21
+ for (const player of unclaimed) {
22
+ if (this.inFlight.get(player) === loading) this.inFlight.delete(player);
23
+ }
24
+ });
25
+ for (const player of unclaimed) this.inFlight.set(player, loading);
26
+ }
27
+
28
+ await Promise.all(missing.map((player) => this.inFlight.get(player)));
29
+
30
+ return unique.map((player) => this.cache.get(player)!);
31
+ }
32
+
33
+ private async resolveMissing(players: readonly PlayerId[]): Promise<void> {
34
+ const asked = new Set(players);
35
+ const batches: PlayerId[][] = [];
36
+ for (let offset = 0; offset < players.length; offset += 100) {
37
+ batches.push(players.slice(offset, offset + 100));
38
+ }
39
+ const results = await Promise.all(batches.map((batch) => this.adapter?.resolve(batch).catch(() => []) ?? []));
40
+ for (const resolved of results) {
41
+ for (const identity of resolved) {
42
+ if (asked.has(identity.player)) this.cache.set(identity.player, identity);
43
+ }
44
+ }
45
+ for (const player of players) {
46
+ if (!this.cache.has(player)) {
47
+ this.cache.set(player, { player, displayName: null, avatarUrl: null });
48
+ }
49
+ }
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,172 @@
1
+ import type { GameModule, PlayerId } from '@flayerlabs/gamemode-spec';
2
+ import type {
3
+ BuyFailure,
4
+ ConnectionState,
5
+ EconomyBalance,
6
+ LaunchContext,
7
+ MarketState,
8
+ PlayerIdentity,
9
+ PresenceState,
10
+ Reaction,
11
+ } from '@flayerlabs/gamemode-spec/live';
12
+ import { MockRoom } from './mock.js';
13
+
14
+ export type {
15
+ BuyFailure,
16
+ ConnectionState,
17
+ EconomyBalance,
18
+ LaunchContext,
19
+ MarketPrice,
20
+ MarketState,
21
+ MarketTrade,
22
+ PlayerIdentity,
23
+ PresenceState,
24
+ Reaction,
25
+ } from '@flayerlabs/gamemode-spec/live';
26
+
27
+ export { joinRoom, NoWallet } from './live.js';
28
+ export { connectEmbeddedGame, serveEmbeddedGame } from './embed.js';
29
+ export type {
30
+ ConnectEmbeddedGameOptions,
31
+ EmbeddedGameConnection,
32
+ ServeEmbeddedGameOptions,
33
+ } from './embed.js';
34
+ export { BUSY_LAUNCH, replayMarket } from './replay-market.js';
35
+ export type { MarketFixture, ReplayMarket } from './replay-market.js';
36
+ export type { Authorisation, Host, HostBuyProgress, LiveOptions } from './live.js';
37
+
38
+ /**
39
+ * What a game talks to. Gameplay stays small; reusable platform concerns live in named capabilities.
40
+ *
41
+ * A game never sees a socket, a wallet, a chain or a key. Everything it can do is here, which is
42
+ * why a bug in a game cannot lose anyone money: it has no way to express the transaction that
43
+ * would.
44
+ */
45
+ export interface Room<PublicView, PlayerView, Action> {
46
+ /** Called with the current view immediately, then whenever it changes. Returns an unsubscribe. */
47
+ subscribe(listener: (snapshot: Snapshot<PublicView, PlayerView>) => void): () => void;
48
+
49
+ /** Propose an action. Points exist only if the rules, running on the server, award them. */
50
+ send(action: Action): Promise<SendResult>;
51
+
52
+ /**
53
+ * The only clock a game may trust.
54
+ *
55
+ * Corrected against the server, so every player counts down to the same instant regardless of
56
+ * how wrong their own device's clock is. `Date.now()` in a game is a bug: two players in
57
+ * different timezones, or one with a drifting clock, see different games.
58
+ */
59
+ now(): number;
60
+
61
+ /** Fixed launch facts. Live rooms do not resolve until these have arrived. */
62
+ readonly launch: Readable<LaunchContext>;
63
+ readonly connection: Readable<ConnectionState>;
64
+ readonly economy: Economy;
65
+ readonly market: Readable<MarketState>;
66
+ readonly identity: IdentityResolver;
67
+ readonly presence: Presence;
68
+ readonly social: Social;
69
+
70
+ dispose(): void;
71
+ }
72
+
73
+ /** An observable value whose subscriber always receives the current value immediately. */
74
+ export interface Readable<T> {
75
+ current(): T;
76
+ subscribe(listener: (value: T) => void): () => void;
77
+ }
78
+
79
+ export interface Snapshot<PublicView, PlayerView> {
80
+ /** What everyone sees. */
81
+ publicView: PublicView;
82
+ /** What this player sees. Null until they have joined. */
83
+ playerView: PlayerView | null;
84
+ }
85
+
86
+ export type SendResult = { accepted: true } | { accepted: false; refuse: string };
87
+
88
+ export interface Economy extends Readable<EconomyState> {
89
+ /** What this player could spend right now, in wei. */
90
+ available(): bigint;
91
+ /**
92
+ * Spend up to `maxSpendWei` on the coin.
93
+ *
94
+ * The game does not build this transaction and cannot: the page it is embedded in constructs it
95
+ * from the server's authorisation and asks the player to approve it.
96
+ */
97
+ buy(maxSpendWei: bigint): Promise<BuyResult>;
98
+ }
99
+
100
+ export interface EconomyState extends EconomyBalance {
101
+ buy: BuyProgress | null;
102
+ }
103
+
104
+ export type BuyProgress =
105
+ | { state: 'signing'; spendWei: bigint }
106
+ | { state: 'pending'; spendWei: bigint; transactionHash?: string }
107
+ | { state: 'confirmed'; spentWei: bigint; transactionHash?: string }
108
+ | { state: 'failed'; reason: BuyFailure };
109
+
110
+ export type BuyResult = { bought: true; spentWei: bigint } | { bought: false; reason: BuyFailure };
111
+
112
+ /**
113
+ * Why a buy did not happen, in terms a player can be told about.
114
+ *
115
+ * Deliberately a short list of plain outcomes rather than anything from the chain. A game should
116
+ * never render a revert string or an error code at a player: the client maps each of these to its
117
+ * own copy, and the copy is free to change without the meaning moving.
118
+ */
119
+ export interface Social {
120
+ react(id: string): void;
121
+ onReaction(listener: (reaction: Reaction) => void): () => void;
122
+ }
123
+
124
+ /**
125
+ * Who is connected. Read-only, because readiness is a game's rule and not the platform's.
126
+ *
127
+ * A game that needs a ready-up lobby defines a ready Action, sends it with {@link Room.send}, and
128
+ * counts it in its own state — where `decide()` can actually see it and act on it.
129
+ */
130
+ export type Presence = Readable<PresenceState>;
131
+
132
+ export interface IdentityResolver {
133
+ resolve(players: readonly PlayerId[]): Promise<readonly PlayerIdentity[]>;
134
+ }
135
+
136
+ /** Trusted, normalized platform data supplied by the embedding integration. */
137
+ export interface ClientPlatform {
138
+ market?: Readable<MarketState>;
139
+ identity?: IdentityResolver;
140
+ }
141
+
142
+ export interface MockOptions<Config> {
143
+ config: Config;
144
+ /** Pins the round so a reload is the same round. Any number. */
145
+ seed?: number;
146
+ /** How long until the round opens. Short, because this is for building against. */
147
+ lobbyMs?: number;
148
+ roundMs?: number;
149
+ /** Who you are. A made-up address unless you say otherwise. */
150
+ player?: PlayerId;
151
+ /** Points-to-wei, only so the buy button has something to show. */
152
+ weiPerPoint?: bigint;
153
+ launch?: LaunchContext;
154
+ platform?: ClientPlatform;
155
+ /** The same cosmetic catalogue as live. Omitted disables reactions. */
156
+ reactionIds?: readonly string[];
157
+ }
158
+
159
+ /**
160
+ * A room with nothing behind it.
161
+ *
162
+ * This is what `pnpm dev` runs, and it is not a stand-in: it drives the game's REAL rules module,
163
+ * the same one the server runs. So a game cannot pass locally and fail live through a difference
164
+ * in the rules — there is no second implementation to disagree with. What is faked is only the
165
+ * things a laptop has no way to provide: the network, the wallet and the money.
166
+ */
167
+ export function createMockRoom<Config, State, Event, Action, PublicView, PlayerView>(
168
+ game: GameModule<Config, State, Event, Action, PublicView, PlayerView>,
169
+ options: MockOptions<Config>,
170
+ ): Room<PublicView, PlayerView, Action> {
171
+ return new MockRoom(game, options);
172
+ }