@zaisma/game-server 1.0.0 → 1.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/bin/node-parts.js CHANGED
@@ -12,14 +12,29 @@ const MAXIMUM_PROFILE_COUNT = 10_000;
12
12
  /** The format the runner's saved room states are written in. A file with another is dropped, and rooms start fresh. */
13
13
  const ROOM_STATES_FORMAT_VERSION_NUMBER = 1;
14
14
 
15
- /** Fetch the public keys a JWKS URL publishes, caching them for an hour (fetched again at once when asked to refresh). */
15
+ /** A refresh asked for by a pass with a key ID not in the cache waits this long after the last fetch, so passes with made-up key IDs can't make this fetch on every hello. */
16
+ const MINIMUM_REFRESH_INTERVAL_MS = 30_000;
17
+
18
+ /**
19
+ * Fetch the public keys a JWKS URL publishes, caching them for an hour (fetched again at once when asked to refresh,
20
+ * at most once in MINIMUM_REFRESH_INTERVAL_MS). A fetch that fails keeps answering with the keys already held.
21
+ */
16
22
  function keysFrom(jwksUrl) {
17
23
  let cache = null;
24
+ let fetchedAtMs = 0;
18
25
  return async (isRefreshing = false) => {
19
- if (cache && !isRefreshing && Date.now() - cache.fetchedAtMs < KEYS_KEPT_MS) return cache.keys;
20
- const response = await fetch(jwksUrl);
21
- if (!response.ok) throw new Error(`Couldn't load Zaisma's keys from ${jwksUrl}`);
22
- cache = { fetchedAtMs: Date.now(), keys: (await response.json()).keys };
26
+ const nowMs = Date.now();
27
+ if (cache && !isRefreshing && nowMs - cache.fetchedAtMs < KEYS_KEPT_MS) return cache.keys;
28
+ if (cache && isRefreshing && nowMs - fetchedAtMs < MINIMUM_REFRESH_INTERVAL_MS) return cache.keys;
29
+ fetchedAtMs = nowMs;
30
+ try {
31
+ const response = await fetch(jwksUrl);
32
+ if (!response.ok) throw new Error(`Couldn't load Zaisma's keys from ${jwksUrl}`);
33
+ cache = { fetchedAtMs: nowMs, keys: (await response.json()).keys };
34
+ } catch (error) {
35
+ if (cache) return cache.keys;
36
+ throw error;
37
+ }
23
38
  return cache.keys;
24
39
  };
25
40
  }
@@ -273,7 +288,7 @@ export function secretsFromEnvironment() {
273
288
  return Object.freeze(Object.fromEntries(Object.entries(process.env).filter(([, environmentValue]) => typeof environmentValue === "string")));
274
289
  }
275
290
 
276
- const OPTION_NAMES = ["gameServer", "gameSlug", "port", "origins", "issuerUrl", "loadPassKeys", "loadReceiptKeys", "loadProfile", "database", "secrets", "webhookSigningSecret", "roomStateFile"];
291
+ const OPTION_NAMES = ["gameServer", "gameSlug", "port", "origins", "issuerUrl", "loadPassKeys", "loadReceiptKeys", "loadProfile", "database", "secrets", "webhookSigningSecret", "roomStateFile", "zaismaKey", "developerApiUrl"];
277
292
 
278
293
  /**
279
294
  * Check what startServer was given: unknown options and options of the wrong kind are a GameServerError,
@@ -307,5 +322,7 @@ export function checkedStartServerOptions(given, issuerUrlDefault) {
307
322
  if (options.secrets !== undefined && (options.secrets === null || typeof options.secrets !== "object" || Array.isArray(options.secrets))) throw bad("secrets", "an object of secrets", "Give an object of text values by name, like { STRIPE_KEY: \"…\" }.");
308
323
  if (options.webhookSigningSecret !== undefined && typeof options.webhookSigningSecret !== "string") throw bad("webhookSigningSecret", "text", "Give the signing_secret of the webhook whose events come to /v1/webhooks, as text.");
309
324
  if (options.roomStateFile !== undefined && options.roomStateFile !== null && !isText(options.roomStateFile)) throw bad("roomStateFile", "a file path", "Give a file path as text, like \"zaisma-room-states.sqlite\", or null to keep no states.");
325
+ if (options.zaismaKey !== undefined && !isText(options.zaismaKey)) throw bad("zaismaKey", "an API key", "Give an API key with purchase_access write for this game as text, or leave it out to use ZAISMA_KEY.");
326
+ if (options.developerApiUrl !== undefined && (!isText(options.developerApiUrl) || !URL.canParse(options.developerApiUrl))) throw bad("developerApiUrl", "a URL", 'Give the address of Zaisma\'s developer API, like "https://zaisma.com/api/dev".');
310
327
  return options;
311
328
  }
package/bin/node.d.ts CHANGED
@@ -27,6 +27,10 @@ export interface StartServerOptions {
27
27
  webhookSigningSecret?: string;
28
28
  /** Where rooms save their states, so a restart brings matches back. Default zaisma-room-states.sqlite; null keeps none. */
29
29
  roomStateFile?: string | null;
30
+ /** An API key with purchase_access write for this game, which onPurchase and onRefund need. Default: the ZAISMA_KEY environment variable. */
31
+ zaismaKey?: string;
32
+ /** Zaisma's developer API, which marks purchases handled. Default: https://zaisma.com/api/dev. */
33
+ developerApiUrl?: string;
30
34
  }
31
35
 
32
36
  export interface RunningServer {
package/bin/node.js CHANGED
@@ -23,8 +23,12 @@ import { fileURLToPath, pathToFileURL } from "node:url";
23
23
  import { resolve } from "node:path";
24
24
  import { realpathSync } from "node:fs";
25
25
  import { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, ERROR_TEXT, NO_ANSWER_CLOSE_CODE, PASS_ISSUER_URL, PassError, ReceiptError, REMOVED_CLOSE_CODE, REQUEST_ERROR_HTTP_STATUS, REQUEST_LIMITS, ROOM_LIMITS, ROOM_TUNING, WEBHOOK_LIMITS, GameServerError, RoomCore, restoreInfoOf, passesAreFriends, passesHaveBlockBetween, answerRequest, startSchedules, checkRoomData, clientIdle, defineGameServer, errorMessage, isRoomCode, readRequestBody, relay, requestError, runWebhook, tidyRoomInfo, verifyPass, verifyReceipt, verifyWebhook } from "../src/internal.js";
26
+ import { OutsideCallError } from "../src/outside-calls.js";
26
27
  import { checkedStartServerOptions, localDatabase, localRoomStates, passKeysFrom, profilesFrom, receiptKeysFrom, secretsFromEnvironment } from "./node-parts.js";
27
28
 
29
+ // Code that names OutsideCallError, as Zaisma's sandbox lets it, moves unchanged: nothing here refuses a call, so instanceof is simply false.
30
+ globalThis.OutsideCallError ??= OutsideCallError;
31
+
28
32
  const HELLO_MS = 5000;
29
33
  /**
30
34
  * How often rooms check on their players (idle, hidden, held seats, players whose time to reconnect ran
@@ -61,13 +65,21 @@ const CATCH_UP_MS = 50;
61
65
  * database (room.database and server.database; by default a local file, zaisma-database.sqlite, made the
62
66
  * first time the game uses it), secrets (room.secrets and server.secrets; by default this process's
63
67
  * environment variables), webhookSigningSecret (the whsec_ secret of the webhook whose events come to
64
- * /v1/webhooks; by default the ZAISMA_WEBHOOK_SIGNING_SECRET environment variable) and roomStateFile (where
65
- * rooms save their states, so a restart brings matches back; by default zaisma-room-states.sqlite, null to keep none).
68
+ * /v1/webhooks; by default the ZAISMA_WEBHOOK_SIGNING_SECRET environment variable), roomStateFile (where
69
+ * rooms save their states, so a restart brings matches back; by default zaisma-room-states.sqlite, null to keep none),
70
+ * zaismaKey (an API key with purchase_access write for this game, which onPurchase and onRefund need; by default
71
+ * the ZAISMA_KEY environment variable) and developerApiUrl (Zaisma's developer API; by default https://zaisma.com/api/dev).
66
72
  * Answers { close(), httpServer }: close() saves every room first and finishes once the server has stopped.
67
73
  */
68
74
  export function startServer(options) {
69
- const { gameServer, gameSlug, port: portNumber, origins, issuerUrl, loadPassKeys = passKeysFrom(`${issuerUrl}/.well-known/jwks.json`), loadReceiptKeys = receiptKeysFrom(`${issuerUrl}/.well-known/receipt-jwks.json`), loadProfile = profilesFrom(issuerUrl), database = localDatabase("zaisma-database.sqlite"), secrets = secretsFromEnvironment(), webhookSigningSecret = process.env.ZAISMA_WEBHOOK_SIGNING_SECRET, roomStateFile = "zaisma-room-states.sqlite" } = checkedStartServerOptions(options, PASS_ISSUER_URL);
75
+ const { gameServer, gameSlug, port: portNumber, origins, issuerUrl, loadPassKeys = passKeysFrom(`${issuerUrl}/.well-known/jwks.json`), loadReceiptKeys = receiptKeysFrom(`${issuerUrl}/.well-known/receipt-jwks.json`), loadProfile = profilesFrom(issuerUrl), database = localDatabase("zaisma-database.sqlite"), secrets = secretsFromEnvironment(), webhookSigningSecret = process.env.ZAISMA_WEBHOOK_SIGNING_SECRET, roomStateFile = "zaisma-room-states.sqlite", zaismaKey = process.env.ZAISMA_KEY, developerApiUrl = "https://zaisma.com/api/dev" } = checkedStartServerOptions(options, PASS_ISSUER_URL);
70
76
  const definition = defineGameServer(gameServer);
77
+ // Exactly once needs Zaisma's record of what's handled: without a key, purchases would come twice, so it doesn't start.
78
+ if ((definition.onPurchase || definition.onRefund) && !zaismaKey) {
79
+ const hook = definition.onPurchase ? "onPurchase" : "onRefund";
80
+ throw new GameServerError("bad_option", "zaismaKey", `${hook} needs zaismaKey, so each purchase is delivered once.`, "Give an API key with purchase_access write for this game as zaismaKey, or set ZAISMA_KEY.");
81
+ }
82
+ const purchaseHandling = definition.onPurchase || definition.onRefund ? ownServerPurchaseHandling(gameSlug, zaismaKey, developerApiUrl) : undefined;
71
83
  const roomStates = roomStateFile === null ? null : localRoomStates(roomStateFile);
72
84
  const stateSaveIntervalMs = (definition.stateSaveIntervalSec ?? ROOM_LIMITS.defaultStateSaveIntervalSec) * 1000;
73
85
  // Schedules run while this server is up; one that's down when a run is due misses that run.
@@ -242,6 +254,7 @@ export function startServer(options) {
242
254
  database,
243
255
  secrets,
244
256
  verifyReceipt: receiptClaims,
257
+ purchaseHandling,
245
258
  send: (id, text) => sockets.get(id)?.send(text),
246
259
  sendBytes: (id, frame) => sockets.get(id)?.send(frame, { binary: true }),
247
260
  isBackedUp: (id) => (sockets.get(id)?.bufferedAmount ?? 0) >= BACKED_UP_SIZE_BYTES,
@@ -438,7 +451,7 @@ export function startServer(options) {
438
451
  const helloTimer = setTimeout(() => playerId || refuse("no_hello"), HELLO_MS);
439
452
  ws.zaismaSeenAtMs = Date.now();
440
453
 
441
- ws.on("message", async (received, isBinary) => {
454
+ const onMessage = async (received, isBinary) => {
442
455
  const text = isBinary ? null : received.toString();
443
456
  ws.zaismaSeenAtMs = Date.now();
444
457
  // Games ping every 25 seconds or so, so a connection that stops answering can be dropped.
@@ -509,6 +522,13 @@ export function startServer(options) {
509
522
  playerId = player.id;
510
523
  room.waiting = false;
511
524
  pace(code, room);
525
+ };
526
+ // Whatever a message hits, it ends that connection or that message, never the process and the matches on it.
527
+ ws.on("message", (received, isBinary) => {
528
+ onMessage(received, isBinary).catch((error) => {
529
+ console.error("Couldn't handle a message:", error);
530
+ if (!playerId) refuse("busy");
531
+ });
512
532
  });
513
533
 
514
534
  // A frame over the size limit (or a broken one) is an error on this one socket: it closes and the close handler below takes it out, the runner keeps going.
@@ -553,6 +573,83 @@ function stateKeyOf(stateJson, matchPlayers, isKept) {
553
573
  return `${isKept ? "kept " : ""}${matchPlayers.map((player) => player.id).join(" ")}\n${stateJson}`;
554
574
  }
555
575
 
576
+ /** How long this server holds a purchase while its hook runs, as Zaisma rooms do; and how long it remembers one it handled. */
577
+ const HANDLING_LEASE_MS = 60 * 1000;
578
+ const HANDLED_KEPT_MS = 10 * 60 * 1000;
579
+ /** How long this server keeps the game's purchase_delivery before reading it again. */
580
+ const DELIVERY_KEPT_MS = 60 * 1000;
581
+
582
+ /** A purchase from the developer API, as onPurchase and onRefund get it. */
583
+ function purchaseOf(answered) {
584
+ return {
585
+ id: answered.id,
586
+ itemName: answered.item_name,
587
+ itemKind: answered.item_kind,
588
+ itemCount: answered.item_count,
589
+ purchaseSource: answered.purchase_source,
590
+ purchaseStatus: answered.purchase_status,
591
+ gameAccountId: answered.game_account_id,
592
+ paidMinorUnits: answered.paid_minor_units,
593
+ currencyCode: answered.currency_code,
594
+ purchasedAt: answered.purchased_at,
595
+ refundedAt: answered.refunded_at,
596
+ receipt: answered.receipt,
597
+ };
598
+ }
599
+
600
+ /**
601
+ * Handing purchases over on your own server: Zaisma's record of what's handled, through the developer API
602
+ * with the developer's key, so a purchase comes once across restarts and Zaisma's own rooms. Rooms on this
603
+ * server don't race each other for one: each is held here while its hook runs. An API that can't be reached,
604
+ * or a key that ended, hands nothing over, and the player's next join tries again.
605
+ */
606
+ function ownServerPurchaseHandling(gameSlug, zaismaKey, developerApiUrl) {
607
+ const heldUntilMs = new Map(); // "<purchase id> purchase|refund" -> when this server lets go of it
608
+ const keyOf = (purchase) => `${purchase.id} ${purchase.purchaseStatus === "completed" ? "purchase" : "refund"}`;
609
+ async function callApi(method, path) {
610
+ const response = await fetch(`${developerApiUrl}${path}`, { method, headers: { authorization: `Bearer ${zaismaKey}`, "content-type": "application/json" }, body: method === "POST" ? "{}" : undefined });
611
+ const answer = await response.json().catch(() => null);
612
+ return { status: response.status, answer };
613
+ }
614
+ const failure = (what, { status, answer }) => new Error(`Zaisma's developer API answered ${status} ${what}${answer?.error ? `: ${answer.error.code}, ${answer.error.message} ${answer.error.fix}` : "."}`);
615
+ // Where the game's purchases go, read again at most once a minute: only game_server hands them to this server,
616
+ // so a game whose page or own backend takes them never races it for them.
617
+ let delivery = { purchaseDelivery: null, readAtMs: 0 };
618
+ async function isDeliveredHere() {
619
+ if (Date.now() - delivery.readAtMs >= DELIVERY_KEPT_MS) {
620
+ const answered = await callApi("GET", `/games/${encodeURIComponent(gameSlug)}/items`);
621
+ if (answered.status !== 200) throw failure("for the game's purchase settings", answered);
622
+ delivery = { purchaseDelivery: answered.answer.purchase_delivery, readAtMs: Date.now() };
623
+ }
624
+ return delivery.purchaseDelivery === "game_server";
625
+ }
626
+ return {
627
+ isReceiptNeeded: true,
628
+ async unhandledFor(playerId) {
629
+ if (!(await isDeliveredHere())) return [];
630
+ const answered = await callApi("GET", `/games/${encodeURIComponent(gameSlug)}/players/${encodeURIComponent(playerId)}/items`);
631
+ if (answered.status !== 200) throw failure(`for ${playerId}'s purchases`, answered);
632
+ return (answered.answer.unhandled_purchases ?? []).map(purchaseOf);
633
+ },
634
+ async startHandling(playerId, purchase) {
635
+ const nowMs = Date.now();
636
+ for (const [key, untilMs] of heldUntilMs) if (untilMs <= nowMs) heldUntilMs.delete(key);
637
+ if (heldUntilMs.has(keyOf(purchase))) return false;
638
+ heldUntilMs.set(keyOf(purchase), nowMs + HANDLING_LEASE_MS);
639
+ return true;
640
+ },
641
+ async finishHandling(playerId, purchase) {
642
+ // Remembered here first, so a room on this server can't hand it over again while the call is on its way.
643
+ heldUntilMs.set(keyOf(purchase), Date.now() + HANDLED_KEPT_MS);
644
+ const answered = await callApi("POST", `/games/${encodeURIComponent(gameSlug)}/purchases/${encodeURIComponent(purchase.id)}/handled`);
645
+ if (answered.status !== 200 && answered.answer?.error?.code !== "already_handled") throw failure(`marking ${purchase.id} handled`, answered);
646
+ },
647
+ async releaseHandling(playerId, purchase) {
648
+ heldUntilMs.delete(keyOf(purchase));
649
+ },
650
+ };
651
+ }
652
+
556
653
  /** A binary frame from ws (a Buffer, an ArrayBuffer or a fragmented frame's pieces) as a Uint8Array of its own. */
557
654
  function binaryBytes(received) {
558
655
  if (Array.isArray(received)) return new Uint8Array(Buffer.concat(received));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/game-server",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Run your Zaisma game server: the same rooms on Zaisma or on your own server.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://zaisma.com",
package/src/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, GAME_SERVER_OPTIONS, GameServerError, NO_ANSWER_CLOSE_CODE, REMOVED_CLOSE_CODE, ROOM_LIMITS, SEAT_GONE_CLOSE_CODE, VERSION_UPDATE_CLOSE_CODE, defineGameServer, type Audience, type AwayInfo, type AwayMode, type AwayReason, type BroadcastOptions, type Delivery, type GameServer, type GameServerErrorCode, type HandlerResult, type IdleCheck, type JoinAnswer, type JoinInfo, type KickOptions, type LeaveInfo, type LeaveReason, type MoveOptions, type MoveResult, type Player, type Refusal, type ReserveOptions, type RestoreInfo, type Room, type RoomErrorCode, type RoomInfo, type SendOptions, type VersionUpdate, } from "./room.ts";
1
+ export { BUSY_CLOSE_CODE, ENDED_CLOSE_CODE, GAME_SERVER_OPTIONS, GameServerError, NO_ANSWER_CLOSE_CODE, REMOVED_CLOSE_CODE, ROOM_LIMITS, SEAT_GONE_CLOSE_CODE, VERSION_UPDATE_CLOSE_CODE, defineGameServer, type Audience, type AwayInfo, type AwayMode, type AwayReason, type BroadcastOptions, type Delivery, type GameServer, type GameServerErrorCode, type HandlerResult, type IdleCheck, type JoinAnswer, type JoinInfo, type KickOptions, type LeaveInfo, type LeaveReason, type MoveOptions, type MoveResult, type Player, type Purchase, type Refusal, type ReserveOptions, type RestoreInfo, type Room, type RoomErrorCode, type RoomInfo, type SendOptions, type VersionUpdate, } from "./room.ts";
2
2
  export { REQUEST_LIMITS, type RequestErrorCode, type Server } from "./requests.ts";
3
3
  export { SCHEDULE_LIMITS, type Schedule, type ScheduledRun } from "./schedules.ts";
4
4
  export { WEBHOOK_LIMITS, verifyWebhook, type WebhookError, type WebhookErrorCode, type WebhookEvent, type WebhookEventKind, type WebhookHeaders } from "./webhooks.ts";
package/src/room.d.ts CHANGED
@@ -26,6 +26,46 @@ export interface Player {
26
26
  */
27
27
  ownedItemNames?: string[];
28
28
  }
29
+ /**
30
+ * A purchase or grant as onPurchase and onRefund get it: the developer API's purchase, in camelCase.
31
+ * purchaseStatus is "completed" in onPurchase, and "refunded" or "charged_back" in onRefund.
32
+ * receipt is its signed receipt (room.verifyReceipt checks one), for your own records.
33
+ */
34
+ export interface Purchase {
35
+ id: string;
36
+ itemName: string;
37
+ itemKind: "permanent" | "consumable";
38
+ /** How many: 1 for a permanent item. */
39
+ itemCount: number;
40
+ purchaseSource: "sale" | "grant";
41
+ purchaseStatus: "completed" | "refunded" | "charged_back";
42
+ /** Your game's own account ID for the player, when the game gave one to buy or grant_item; else null. */
43
+ gameAccountId: string | null;
44
+ /** What it paid, in the currency's minor units (cents); null for a grant. */
45
+ paidMinorUnits: number | null;
46
+ currencyCode: string | null;
47
+ /** When it was bought or granted, an ISO time. */
48
+ purchasedAt: string | null;
49
+ /** When the item was taken back (a full refund or a chargeback), an ISO time; null in onPurchase, and for a partial refund, which leaves the item owned. */
50
+ refundedAt: string | null;
51
+ receipt: string;
52
+ }
53
+ /**
54
+ * How a room hands purchases over (onPurchase, onRefund), from Zaisma's record of them: Zaisma rooms read
55
+ * it themselves, and the Node runner through the developer API with ZAISMA_KEY. Exactly once: a room hands
56
+ * a purchase over only after winning it (startHandling), and marks it handled after the hook, or lets go
57
+ * of it when the hook threw, so the player's next join tries again.
58
+ */
59
+ export interface PurchaseHandling {
60
+ /** The player's purchases and refunds still to hand over, oldest first. */
61
+ unhandledFor(playerId: string): Promise<Purchase[]>;
62
+ /** Wins the purchase (or its refund) for this room; false when another room has it, or it was handled. */
63
+ startHandling(playerId: string, purchase: Purchase): Promise<boolean>;
64
+ finishHandling(playerId: string, purchase: Purchase): Promise<void>;
65
+ releaseHandling(playerId: string, purchase: Purchase): Promise<void>;
66
+ /** Whether a zaisma:purchase message must carry the purchase's receipt, checked before anything is asked (your own server). */
67
+ isReceiptNeeded?: boolean;
68
+ }
29
69
  export interface Room {
30
70
  /**
31
71
  * This room's code, like K7Q2MX: the code players join it by. Use it to treat rooms differently, like
@@ -449,6 +489,21 @@ export interface GameServer<S = any> {
449
489
  onAway?(state: S, player: Player, room: Room, awayInfo: AwayInfo): HandlerResult<S>;
450
490
  onBack?(state: S, player: Player, room: Room): HandlerResult<S>;
451
491
  onMessage?(state: S, player: Player, room: Room, message: unknown): HandlerResult<S>;
492
+ /**
493
+ * A player bought or was granted an item: add it to their state, like a revive or 500 coins. It comes
494
+ * exactly once, within a second when the player is in a room (their game tells it, after Zaisma.items.buy),
495
+ * or the next time they join. Only with purchase_delivery "game_server" (set_purchase_settings). If it
496
+ * throws, it isn't handled: it comes again on the player's next join, and you see the error with the
497
+ * game server's others. Without onPurchase, a room never takes a purchase. On your own server, the Node
498
+ * runner needs ZAISMA_KEY for it.
499
+ */
500
+ onPurchase?(state: S, player: Player, room: Room, purchase: Purchase): HandlerResult<S>;
501
+ /**
502
+ * A purchase onPurchase got was refunded or charged back (purchase.purchaseStatus says which): take back
503
+ * what your refund policy says, like a revive not used yet. Permanent items leave player.ownedItemNames by
504
+ * themselves. Comes once, on the player's next join (and to webhooks at once, as purchase_refunded).
505
+ */
506
+ onRefund?(state: S, player: Player, room: Room, purchase: Purchase): HandlerResult<S>;
452
507
  /**
453
508
  * Bytes a player's game sent (room.sendBytes), like a compact input, under the same limits as
454
509
  * messages: up to 16 KB each, 30 a second. Read them with a DataView; they're a copy, yours to keep.
@@ -532,7 +587,7 @@ export declare function readJoinAnswer(answer: unknown): {
532
587
  mistake: GameServerError;
533
588
  };
534
589
  /** Everything a game server object may have, in the order the docs give them. Anything else is refused. */
535
- export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "reconnectTimeoutSec", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onTick", "onEnding", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
590
+ export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "reconnectTimeoutSec", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onPurchase", "onRefund", "onTick", "onEnding", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
536
591
  export declare const ROOM_LIMITS: {
537
592
  /** Players in one room: what one room carries at 60 ticks a second (test/load/room-load.test.ts). */
538
593
  readonly maximumPlayerCount: 100;
@@ -682,6 +737,11 @@ export interface RoomSettings {
682
737
  * of the match with their seat held gets {type: "away"}, and once the seat is no longer held, their
683
738
  * connection closes with SEAT_GONE_CLOSE_CODE.
684
739
  *
740
+ * Purchases: after Zaisma.items.buy, a game whose purchases go to its game server sends {type:
741
+ * "zaisma:purchase", purchaseId, receipt?} on each room it's in. It only says "look now": the room reads
742
+ * Zaisma's record itself and hands the player's unhandled purchases to onPurchase, as it does on every
743
+ * join. Each purchase ID counts once per player per room; on your own server, receipt is checked first.
744
+ *
685
745
  * Ping and the room's clock (docs/DECISIONS.md, "Rooms under lag"): the room sends {type: "probe",
686
746
  * probeNumber, serverTimeMs, pingDurationMs} right after welcome and again once it's answered (so the
687
747
  * client hears its first ping), every welcome.probeIntervalSec while
@@ -793,6 +853,10 @@ export type ClientMessage = {
793
853
  } | {
794
854
  type: "message";
795
855
  data: unknown;
856
+ } | {
857
+ type: "zaisma:purchase";
858
+ purchaseId: string;
859
+ receipt?: string;
796
860
  } | {
797
861
  type: "room";
798
862
  room: Partial<RoomSettings>;
@@ -912,6 +976,8 @@ export interface Transport {
912
976
  applyVersionUpdate?(): void;
913
977
  /** Whether a player's connection holds ROOM_TUNING.backedUpSizeBytes or more not yet sent. Without it, the room judges by its probes. */
914
978
  isBackedUp?(playerId: string): boolean;
979
+ /** Where the room hands purchases over (onPurchase, onRefund). Without it, purchases never reach the room. */
980
+ purchaseHandling?: PurchaseHandling;
915
981
  }
916
982
  /** room.secrets and server.secrets where a game has none. */
917
983
  export declare const NO_SECRETS: Readonly<Record<string, string>>;
@@ -1123,6 +1189,13 @@ export declare class RoomCore<S = any> {
1123
1189
  /** Handlers running right now, and what waits for them to finish (a kicked player's onLeave). */
1124
1190
  private depth;
1125
1191
  private afterHandler;
1192
+ /** Players whose purchases are being handed over now, and those to look at again once that's done. */
1193
+ private readonly purchasePlayerIds;
1194
+ private readonly purchaseAgainPlayerIds;
1195
+ /** The purchase IDs each player's zaisma:purchase messages named, so a client can't spin the room on one. */
1196
+ private readonly toldPurchaseIds;
1197
+ /** Hand-overs running now, for settled(). */
1198
+ private purchaseDeliveries;
1126
1199
  /**
1127
1200
  * startOptions.start makes a reserved room: closed, open only to the players it was made for, and
1128
1201
  * set up with its setup data. A saved room (startOptions.room) wakes as it was, with
@@ -1446,6 +1519,21 @@ export declare class RoomCore<S = any> {
1446
1519
  private take;
1447
1520
  /** A handler's answer becomes the state if it is one (a plain object or an array); nothing keeps the state, and anything else is a mistake that leaves it. */
1448
1521
  private keepAnswer;
1522
+ /**
1523
+ * A player's game says it bought something (after Zaisma.items.buy). The message only says "look now":
1524
+ * the room reads Zaisma's record itself, and each purchase ID counts once per player per room. On your
1525
+ * own server (isReceiptNeeded), the purchase's receipt must check out first, so a lying client can't
1526
+ * make the server spend calls to Zaisma.
1527
+ */
1528
+ private purchaseTold;
1529
+ /**
1530
+ * Hands a player's unhandled purchases to onPurchase and their refunds to onRefund, exactly once: each
1531
+ * only after this room wins it, marked handled after the hook, and let go when the hook threw, so their
1532
+ * next join tries again. One hand-over per player at a time; a call meanwhile looks again after it.
1533
+ */
1534
+ private deliverPurchases;
1535
+ /** Runs onPurchase or onRefund like any handler, answering whether it finished without throwing. */
1536
+ private runPurchaseHook;
1449
1537
  /**
1450
1538
  * Run a game server handler. A crash in game code is logged and never takes the room down. State sync's
1451
1539
  * round follows a turn-based room's handlers and a real-time room's tick (isTick).
package/src/room.js CHANGED
@@ -72,6 +72,8 @@ export const GAME_SERVER_OPTIONS = [
72
72
  "onBack",
73
73
  "onMessage",
74
74
  "onBytes",
75
+ "onPurchase",
76
+ "onRefund",
75
77
  "onTick",
76
78
  "onEnding",
77
79
  "onRestored",
@@ -284,6 +286,10 @@ export const ERROR_TEXT = {
284
286
  };
285
287
  /** What players are told when Zaisma ends a match at room.endsAt. */
286
288
  export const HOSTING_PAUSED_END_MESSAGE = "This game's hosting is paused, so this match has ended. New rooms open again when the month resets or its developer adds credit.";
289
+ /** A zaisma:purchase message's purchaseId, at most: longer is no purchase's. */
290
+ const MAXIMUM_PURCHASE_ID_CHARACTER_COUNT = 80;
291
+ /** The purchase IDs one player's zaisma:purchase messages count in one room; past that they're ignored, and joins still bring purchases. */
292
+ const MAXIMUM_TOLD_PURCHASE_COUNT = 200;
287
293
  /** How long a room holds a seat for a player another room is sending. */
288
294
  const SEAT_MS = 60_000;
289
295
  /** room.secrets and server.secrets where a game has none. */
@@ -505,6 +511,13 @@ export class RoomCore {
505
511
  /** Handlers running right now, and what waits for them to finish (a kicked player's onLeave). */
506
512
  depth = 0;
507
513
  afterHandler = [];
514
+ /** Players whose purchases are being handed over now, and those to look at again once that's done. */
515
+ purchasePlayerIds = new Set();
516
+ purchaseAgainPlayerIds = new Set();
517
+ /** The purchase IDs each player's zaisma:purchase messages named, so a client can't spin the room on one. */
518
+ toldPurchaseIds = new Map();
519
+ /** Hand-overs running now, for settled(). */
520
+ purchaseDeliveries = new Set();
508
521
  /**
509
522
  * startOptions.start makes a reserved room: closed, open only to the players it was made for, and
510
523
  * set up with its setup data. A saved room (startOptions.room) wakes as it was, with
@@ -846,10 +859,14 @@ export class RoomCore {
846
859
  }
847
860
  /** Resolves once every handler started so far has finished (at once for game servers that answer at once). */
848
861
  async settled() {
849
- while (this.queue) {
862
+ while (this.queue || this.purchaseDeliveries.size) {
863
+ if (this.purchaseDeliveries.size)
864
+ await Promise.all([...this.purchaseDeliveries]);
850
865
  const queued = this.queue;
866
+ if (!queued)
867
+ continue;
851
868
  await queued;
852
- if (this.queue === queued)
869
+ if (this.queue === queued && !this.purchaseDeliveries.size)
853
870
  return;
854
871
  }
855
872
  }
@@ -1071,6 +1088,8 @@ export class RoomCore {
1071
1088
  // A turn-based room has no tick to bring a returning connection its copy of the state.
1072
1089
  if (isRejoin && !this.ticking)
1073
1090
  this.syncSoon();
1091
+ // Every join looks for purchases the player hasn't had yet: ones bought while away, or whose hand-over failed.
1092
+ this.deliverPurchases(player.id);
1074
1093
  return { isAllowed: true, isRejoin };
1075
1094
  }
1076
1095
  /**
@@ -1386,6 +1405,9 @@ export class RoomCore {
1386
1405
  case "message":
1387
1406
  this.input(() => this.server.onMessage?.(this.state, player, this.api, message.data), "onMessage");
1388
1407
  return null;
1408
+ case "zaisma:purchase":
1409
+ this.purchaseTold(playerId, message);
1410
+ return null;
1389
1411
  case "block":
1390
1412
  return this.sessionBlock(playerId, message, true);
1391
1413
  case "unblock":
@@ -2084,6 +2106,107 @@ export class RoomCore {
2084
2106
  else
2085
2107
  console.error("Game server error:", badHandlerAnswer(handlerName, answer));
2086
2108
  }
2109
+ /**
2110
+ * A player's game says it bought something (after Zaisma.items.buy). The message only says "look now":
2111
+ * the room reads Zaisma's record itself, and each purchase ID counts once per player per room. On your
2112
+ * own server (isReceiptNeeded), the purchase's receipt must check out first, so a lying client can't
2113
+ * make the server spend calls to Zaisma.
2114
+ */
2115
+ purchaseTold(playerId, message) {
2116
+ const handling = this.transport.purchaseHandling;
2117
+ if (!handling || (!this.server.onPurchase && !this.server.onRefund))
2118
+ return;
2119
+ const purchaseId = message.purchaseId;
2120
+ if (typeof purchaseId !== "string" || !purchaseId || purchaseId.length > MAXIMUM_PURCHASE_ID_CHARACTER_COUNT)
2121
+ return;
2122
+ const told = this.toldPurchaseIds.get(playerId) ?? new Set();
2123
+ if (told.has(purchaseId) || told.size >= MAXIMUM_TOLD_PURCHASE_COUNT)
2124
+ return;
2125
+ // Counted as told only once it's believed, so a forged receipt can't use up a real purchase's ID.
2126
+ const believed = () => {
2127
+ told.add(purchaseId);
2128
+ this.toldPurchaseIds.set(playerId, told);
2129
+ this.deliverPurchases(playerId);
2130
+ };
2131
+ if (!handling.isReceiptNeeded)
2132
+ return believed();
2133
+ const receipt = message.receipt;
2134
+ if (typeof receipt !== "string" || !this.transport.verifyReceipt)
2135
+ return;
2136
+ this.transport.verifyReceipt(receipt).then((claims) => {
2137
+ if (claims.sub === playerId && claims.purchase_id === purchaseId && !told.has(purchaseId))
2138
+ believed();
2139
+ }, () => undefined);
2140
+ }
2141
+ /**
2142
+ * Hands a player's unhandled purchases to onPurchase and their refunds to onRefund, exactly once: each
2143
+ * only after this room wins it, marked handled after the hook, and let go when the hook threw, so their
2144
+ * next join tries again. One hand-over per player at a time; a call meanwhile looks again after it.
2145
+ */
2146
+ deliverPurchases(playerId) {
2147
+ const handling = this.transport.purchaseHandling;
2148
+ if (!handling || (!this.server.onPurchase && !this.server.onRefund))
2149
+ return;
2150
+ // Guests own nothing: their IDs last only for their pass.
2151
+ if (this.players.get(playerId)?.kind !== "account")
2152
+ return;
2153
+ if (this.purchasePlayerIds.has(playerId)) {
2154
+ this.purchaseAgainPlayerIds.add(playerId);
2155
+ return;
2156
+ }
2157
+ this.purchasePlayerIds.add(playerId);
2158
+ const delivery = (async () => {
2159
+ do {
2160
+ this.purchaseAgainPlayerIds.delete(playerId);
2161
+ for (const purchase of await handling.unhandledFor(playerId)) {
2162
+ const hookName = purchase.purchaseStatus === "completed" ? "onPurchase" : "onRefund";
2163
+ const hook = this.server[hookName];
2164
+ if (!hook)
2165
+ continue;
2166
+ if (!this.players.has(playerId) || this.ended)
2167
+ return;
2168
+ if (!(await handling.startHandling(playerId, purchase)))
2169
+ continue;
2170
+ const player = this.players.get(playerId);
2171
+ const isHandled = player && !this.ended ? await this.runPurchaseHook(() => hook.call(this.server, this.state, player, this.api, purchase), hookName) : false;
2172
+ await (isHandled ? handling.finishHandling(playerId, purchase) : handling.releaseHandling(playerId, purchase));
2173
+ }
2174
+ } while (this.purchaseAgainPlayerIds.has(playerId));
2175
+ })()
2176
+ .catch((error) => console.error("Couldn't hand purchases over:", error))
2177
+ .finally(() => {
2178
+ this.purchasePlayerIds.delete(playerId);
2179
+ this.purchaseDeliveries.delete(delivery);
2180
+ });
2181
+ this.purchaseDeliveries.add(delivery);
2182
+ }
2183
+ /** Runs onPurchase or onRefund like any handler, answering whether it finished without throwing. */
2184
+ runPurchaseHook(hook, hookName) {
2185
+ // A hook that answers at once runs at once, so a game server that never returns a promise doesn't start queueing.
2186
+ return new Promise((resolve) => {
2187
+ this.run(() => {
2188
+ let next;
2189
+ try {
2190
+ next = hook();
2191
+ }
2192
+ catch (error) {
2193
+ resolve(false);
2194
+ throw error;
2195
+ }
2196
+ if (!isPromise(next)) {
2197
+ resolve(true);
2198
+ return next;
2199
+ }
2200
+ return next.then((answered) => {
2201
+ resolve(true);
2202
+ return answered;
2203
+ }, (error) => {
2204
+ resolve(false);
2205
+ throw error;
2206
+ });
2207
+ }, hookName);
2208
+ });
2209
+ }
2087
2210
  /**
2088
2211
  * Run a game server handler. A crash in game code is logged and never takes the room down. State sync's
2089
2212
  * round follows a turn-based room's handlers and a real-time room's tick (isTick).
package/src/schedules.js CHANGED
@@ -159,7 +159,9 @@ const MAXIMUM_TIMER_MS = 24 * 3_600_000;
159
159
  export function startSchedules(handler, server, onFailure) {
160
160
  const timers = new Set();
161
161
  let isStopped = false;
162
- for (const schedule of handler.schedules ?? []) {
162
+ // Run from the checked list, so run.cron is the tidied cron on every runtime (get_stats lists it that way too).
163
+ const read = readSchedules(handler.schedules, typeof handler.onSchedule === "function");
164
+ for (const schedule of "schedules" in read ? read.schedules : []) {
163
165
  const plan = (afterMs) => {
164
166
  const atMs = nextRunAtMs(schedule.cron, afterMs);
165
167
  if (atMs === null || isStopped)
package/src/webhooks.d.ts CHANGED
@@ -13,7 +13,7 @@ import type { Server } from "./requests.ts";
13
13
  * Every kind of event Zaisma sends today. A WebhookEvent's kind is one of these or a kind added later,
14
14
  * so a game server switches on the ones it knows and skips the rest.
15
15
  */
16
- export type WebhookEventKind = "bug_reported" | "player_reported" | "scan_status_changed" | "live_version_changed" | "review_status_changed" | "country_blocks_changed" | "game_server_error_found" | "alert_raised" | "account_deleted" | "security_alert" | "test";
16
+ export type WebhookEventKind = "bug_reported" | "player_reported" | "scan_status_changed" | "live_version_changed" | "review_status_changed" | "country_blocks_changed" | "game_server_error_found" | "alert_raised" | "account_deleted" | "security_alert" | "purchase_completed" | "purchase_refunded" | "test";
17
17
  /** Who deleted their Zaisma account, on an account_deleted event. */
18
18
  export interface AccountDeletion {
19
19
  /** The player who deleted their Zaisma account. */
@@ -51,6 +51,8 @@ export interface WebhookEvent {
51
51
  sign_in_app: Record<string, unknown> | null;
52
52
  /** The developer's own account event as get_activity lists it; null unless kind is security_alert. */
53
53
  security_event: Record<string, unknown> | null;
54
+ /** The purchase as get_purchase answers it, with its receipt and game_account_id; null unless kind is purchase_completed or purchase_refunded. */
55
+ purchase: Record<string, unknown> | null;
54
56
  }
55
57
  export declare const WEBHOOK_LIMITS: {
56
58
  /** How long onWebhook may take, awaiting the database and outside calls included, before the try fails and waits for the next. */