@zaisma/game-server 0.0.1 → 1.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/LICENSE +21 -0
- package/README.md +35 -1
- package/bin/node-parts.js +313 -0
- package/bin/node.d.ts +47 -0
- package/bin/node.js +701 -0
- package/package.json +42 -1
- package/src/chat.d.ts +65 -0
- package/src/chat.js +108 -0
- package/src/database.d.ts +150 -0
- package/src/database.js +244 -0
- package/src/dump.d.ts +61 -0
- package/src/dump.js +282 -0
- package/src/index.d.ts +10 -0
- package/src/index.js +12 -0
- package/src/internal.d.ts +13 -0
- package/src/internal.js +15 -0
- package/src/names.d.ts +4 -0
- package/src/names.js +18 -0
- package/src/outside-calls.d.ts +46 -0
- package/src/outside-calls.js +30 -0
- package/src/pass.d.ts +194 -0
- package/src/pass.js +231 -0
- package/src/relay.d.ts +7 -0
- package/src/relay.js +20 -0
- package/src/requests.d.ts +89 -0
- package/src/requests.js +127 -0
- package/src/room-calls.d.ts +92 -0
- package/src/room-calls.js +194 -0
- package/src/room.d.ts +1550 -0
- package/src/room.js +2315 -0
- package/src/schedules.d.ts +82 -0
- package/src/schedules.js +200 -0
- package/src/state-sync.d.ts +57 -0
- package/src/state-sync.js +175 -0
- package/src/webhooks.d.ts +114 -0
- package/src/webhooks.js +102 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedules: a game server running by itself, outside rooms and requests, for daily resets, weekly
|
|
3
|
+
* leaderboards or a persistent world's turns. The game server lists its schedules, each a name and a
|
|
4
|
+
* cron in UTC, and onSchedule(run, server) runs at each, with server.database. Zaisma runs them in the
|
|
5
|
+
* game's own object (apps/rooms/src/game-server.ts) and the Node runner with timers (startSchedules),
|
|
6
|
+
* both from the times here, so a schedule runs at the same minutes in both.
|
|
7
|
+
*
|
|
8
|
+
* Crons are the usual five fields, minute hour day-of-month month day-of-week, in UTC: numbers, *,
|
|
9
|
+
* ranges (1-5), steps (*\/15) and lists (1,15). So a schedule runs at most once a minute.
|
|
10
|
+
*/
|
|
11
|
+
import type { Server } from "./requests.ts";
|
|
12
|
+
/** One of a game server's schedules: its name, and when it runs. */
|
|
13
|
+
export interface Schedule {
|
|
14
|
+
/** Lowercase letters, digits and hyphens, like "daily-reset". onSchedule tells them apart by it. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** When it runs: five fields, minute hour day-of-month month day-of-week, in UTC. "0 0 * * *" is every midnight. */
|
|
17
|
+
cron: string;
|
|
18
|
+
}
|
|
19
|
+
/** A schedule's run, as onSchedule gets it: which schedule, and the minute it was due (an ISO time, UTC). */
|
|
20
|
+
export interface ScheduledRun {
|
|
21
|
+
name: string;
|
|
22
|
+
cron: string;
|
|
23
|
+
scheduledAt: string;
|
|
24
|
+
}
|
|
25
|
+
export declare const SCHEDULE_LIMITS: {
|
|
26
|
+
readonly maximumScheduleCount: 10;
|
|
27
|
+
readonly maximumNameCharacterCount: 40;
|
|
28
|
+
/** How long onSchedule may take, awaiting the database included, before the run fails. */
|
|
29
|
+
readonly runTimeoutSec: 30;
|
|
30
|
+
};
|
|
31
|
+
/** A game server that runs on schedules. */
|
|
32
|
+
export interface ScheduleHandler {
|
|
33
|
+
schedules?: readonly Schedule[];
|
|
34
|
+
onSchedule?(run: ScheduledRun, server: Server): unknown;
|
|
35
|
+
}
|
|
36
|
+
/** A cron read into the values each field allows. Day 7 of the week is Sunday, like 0. */
|
|
37
|
+
interface CronTimes {
|
|
38
|
+
minutes: boolean[];
|
|
39
|
+
hours: boolean[];
|
|
40
|
+
monthDays: boolean[];
|
|
41
|
+
months: boolean[];
|
|
42
|
+
weekDays: boolean[];
|
|
43
|
+
/** Both day fields given: a day matches either, as cron has always done. */
|
|
44
|
+
isEitherDay: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** Read a cron, or say what's wrong with it. */
|
|
47
|
+
export declare function readCron(cron: unknown): {
|
|
48
|
+
cronTimes: CronTimes;
|
|
49
|
+
} | {
|
|
50
|
+
mistake: string;
|
|
51
|
+
};
|
|
52
|
+
/** When a cron next runs after afterMs (epoch milliseconds): a whole minute, UTC. Null for a cron that doesn't read. */
|
|
53
|
+
export declare function nextRunAtMs(cron: string, afterMs: number): number | null;
|
|
54
|
+
/**
|
|
55
|
+
* A game server's schedules, checked: a list of up to SCHEDULE_LIMITS.maximumScheduleCount
|
|
56
|
+
* { name, cron }, names different, crons that read. Schedules need onSchedule, and onSchedule needs
|
|
57
|
+
* schedules. Otherwise what's wrong, and what to do.
|
|
58
|
+
*/
|
|
59
|
+
export declare function readSchedules(schedules: unknown, hasOnSchedule: boolean): {
|
|
60
|
+
schedules: Schedule[];
|
|
61
|
+
} | {
|
|
62
|
+
message: string;
|
|
63
|
+
fix: string;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Run a game server's onSchedule for one run, with a time limit. failure says why it failed, for the
|
|
67
|
+
* game server's errors: it threw, or took longer than SCHEDULE_LIMITS.runTimeoutSec.
|
|
68
|
+
*/
|
|
69
|
+
export declare function runSchedule(handler: ScheduleHandler, run: ScheduledRun, server: Server): Promise<{
|
|
70
|
+
isDone: true;
|
|
71
|
+
} | {
|
|
72
|
+
failure: string;
|
|
73
|
+
}>;
|
|
74
|
+
/**
|
|
75
|
+
* Run a game server's schedules with timers, as the Node runner does on the developer's own server:
|
|
76
|
+
* each at the minutes its cron gives, from the moment this starts. onFailure hears about a run that
|
|
77
|
+
* failed.
|
|
78
|
+
*/
|
|
79
|
+
export declare function startSchedules(handler: ScheduleHandler, server: Server, onFailure: (run: ScheduledRun, failure: string) => void): {
|
|
80
|
+
stop(): void;
|
|
81
|
+
};
|
|
82
|
+
export {};
|
package/src/schedules.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedules: a game server running by itself, outside rooms and requests, for daily resets, weekly
|
|
3
|
+
* leaderboards or a persistent world's turns. The game server lists its schedules, each a name and a
|
|
4
|
+
* cron in UTC, and onSchedule(run, server) runs at each, with server.database. Zaisma runs them in the
|
|
5
|
+
* game's own object (apps/rooms/src/game-server.ts) and the Node runner with timers (startSchedules),
|
|
6
|
+
* both from the times here, so a schedule runs at the same minutes in both.
|
|
7
|
+
*
|
|
8
|
+
* Crons are the usual five fields, minute hour day-of-month month day-of-week, in UTC: numbers, *,
|
|
9
|
+
* ranges (1-5), steps (*\/15) and lists (1,15). So a schedule runs at most once a minute.
|
|
10
|
+
*/
|
|
11
|
+
export const SCHEDULE_LIMITS = {
|
|
12
|
+
maximumScheduleCount: 10,
|
|
13
|
+
maximumNameCharacterCount: 40,
|
|
14
|
+
/** How long onSchedule may take, awaiting the database included, before the run fails. */
|
|
15
|
+
runTimeoutSec: 30,
|
|
16
|
+
};
|
|
17
|
+
const FIELDS = [
|
|
18
|
+
{ name: "minute", minimum: 0, maximum: 59 },
|
|
19
|
+
{ name: "hour", minimum: 0, maximum: 23 },
|
|
20
|
+
{ name: "day of the month", minimum: 1, maximum: 31 },
|
|
21
|
+
{ name: "month", minimum: 1, maximum: 12 },
|
|
22
|
+
{ name: "day of the week", minimum: 0, maximum: 7 },
|
|
23
|
+
];
|
|
24
|
+
const CRON_FIX = 'Give five fields in UTC: minute hour day-of-month month day-of-week, like "0 0 * * *" (every midnight), "*/15 * * * *" (every 15 minutes) or "0 4 * * 1" (Mondays at 04:00).';
|
|
25
|
+
/** Read a cron, or say what's wrong with it. */
|
|
26
|
+
export function readCron(cron) {
|
|
27
|
+
if (typeof cron !== "string")
|
|
28
|
+
return { mistake: `cron is ${JSON.stringify(cron) ?? String(cron)}, not text. ${CRON_FIX}` };
|
|
29
|
+
const parts = cron.trim().split(/\s+/);
|
|
30
|
+
if (parts.length !== 5)
|
|
31
|
+
return { mistake: `The cron "${cron}" has ${parts.length} field${parts.length === 1 ? "" : "s"}, not 5 (schedules run at most once a minute, so there's no seconds field). ${CRON_FIX}` };
|
|
32
|
+
const allowed = [];
|
|
33
|
+
for (const [index, field] of FIELDS.entries()) {
|
|
34
|
+
const values = Array(field.maximum + 1).fill(false);
|
|
35
|
+
for (const part of parts[index].split(",")) {
|
|
36
|
+
const match = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part);
|
|
37
|
+
if (!match)
|
|
38
|
+
return { mistake: `The cron "${cron}" has "${part}" as its ${field.name}. Fields are numbers, *, ranges like 1-5, steps like */15 and lists like 1,15: no names like MON, and no ?, L or #. ${CRON_FIX}` };
|
|
39
|
+
const stepNumber = match[2] === undefined ? 1 : Number(match[2]);
|
|
40
|
+
// *, a range, or one number; a number with a step (5/15) runs from there to the field's end.
|
|
41
|
+
let [fromNumber, toNumber] = [field.minimum, field.maximum];
|
|
42
|
+
if (match[1].includes("-"))
|
|
43
|
+
[fromNumber, toNumber] = match[1].split("-").map(Number);
|
|
44
|
+
else if (match[1] !== "*")
|
|
45
|
+
[fromNumber, toNumber] = [Number(match[1]), match[2] === undefined ? Number(match[1]) : field.maximum];
|
|
46
|
+
if (fromNumber < field.minimum || toNumber > field.maximum || fromNumber > toNumber || stepNumber < 1) {
|
|
47
|
+
return { mistake: `The cron "${cron}" has "${part}" as its ${field.name}, which goes from ${field.minimum} to ${field.maximum}. ${CRON_FIX}` };
|
|
48
|
+
}
|
|
49
|
+
for (let allowedNumber = fromNumber; allowedNumber <= toNumber; allowedNumber += stepNumber)
|
|
50
|
+
values[allowedNumber] = true;
|
|
51
|
+
}
|
|
52
|
+
allowed.push(values);
|
|
53
|
+
}
|
|
54
|
+
const weekDays = allowed[4];
|
|
55
|
+
if (weekDays[7])
|
|
56
|
+
weekDays[0] = true;
|
|
57
|
+
const cronTimes = { minutes: allowed[0], hours: allowed[1], monthDays: allowed[2], months: allowed[3], weekDays, isEitherDay: parts[2] !== "*" && parts[4] !== "*" };
|
|
58
|
+
if (nextAtMs(cronTimes, Date.UTC(2000, 0, 1)) === null)
|
|
59
|
+
return { mistake: `The cron "${cron}" never runs: no month has that day. ${CRON_FIX}` };
|
|
60
|
+
return { cronTimes };
|
|
61
|
+
}
|
|
62
|
+
/** How far ahead to look for the next run: past every leap day. */
|
|
63
|
+
const SEARCH_DAY_COUNT = 8 * 366;
|
|
64
|
+
const DAY_MS = 86_400_000;
|
|
65
|
+
function nextAtMs(cronTimes, afterMs) {
|
|
66
|
+
// The first whole minute after afterMs.
|
|
67
|
+
const firstMs = Math.floor(afterMs / 60_000) * 60_000 + 60_000;
|
|
68
|
+
const firstDayMs = Math.floor(firstMs / DAY_MS) * DAY_MS;
|
|
69
|
+
for (let dayIndex = 0; dayIndex < SEARCH_DAY_COUNT; dayIndex++) {
|
|
70
|
+
const dayMs = firstDayMs + dayIndex * DAY_MS;
|
|
71
|
+
const date = new Date(dayMs);
|
|
72
|
+
if (!cronTimes.months[date.getUTCMonth() + 1])
|
|
73
|
+
continue;
|
|
74
|
+
const isMonthDay = cronTimes.monthDays[date.getUTCDate()];
|
|
75
|
+
const isWeekDay = cronTimes.weekDays[date.getUTCDay()];
|
|
76
|
+
if (cronTimes.isEitherDay ? !(isMonthDay || isWeekDay) : !(isMonthDay && isWeekDay))
|
|
77
|
+
continue;
|
|
78
|
+
const fromMinuteIndex = dayIndex === 0 ? (firstMs - dayMs) / 60_000 : 0;
|
|
79
|
+
for (let minuteIndex = fromMinuteIndex; minuteIndex < 1440; minuteIndex++) {
|
|
80
|
+
if (cronTimes.hours[Math.floor(minuteIndex / 60)] && cronTimes.minutes[minuteIndex % 60])
|
|
81
|
+
return dayMs + minuteIndex * 60_000;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/** When a cron next runs after afterMs (epoch milliseconds): a whole minute, UTC. Null for a cron that doesn't read. */
|
|
87
|
+
export function nextRunAtMs(cron, afterMs) {
|
|
88
|
+
const read = readCron(cron);
|
|
89
|
+
return "mistake" in read ? null : nextAtMs(read.cronTimes, afterMs);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A game server's schedules, checked: a list of up to SCHEDULE_LIMITS.maximumScheduleCount
|
|
93
|
+
* { name, cron }, names different, crons that read. Schedules need onSchedule, and onSchedule needs
|
|
94
|
+
* schedules. Otherwise what's wrong, and what to do.
|
|
95
|
+
*/
|
|
96
|
+
export function readSchedules(schedules, hasOnSchedule) {
|
|
97
|
+
const example = 'Give a list like [{ name: "daily-reset", cron: "0 0 * * *" }].';
|
|
98
|
+
if (schedules === undefined || (Array.isArray(schedules) && schedules.length === 0)) {
|
|
99
|
+
return hasOnSchedule ? { message: "onSchedule is there but schedules isn't, so it would never run.", fix: `Add schedules, saying when it runs. ${example}` } : { schedules: [] };
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(schedules))
|
|
102
|
+
return { message: `schedules is ${JSON.stringify(schedules)?.slice(0, 80) ?? String(schedules)}, not a list.`, fix: example };
|
|
103
|
+
if (schedules.length > SCHEDULE_LIMITS.maximumScheduleCount) {
|
|
104
|
+
return { message: `There are ${schedules.length} schedules; a game server has up to ${SCHEDULE_LIMITS.maximumScheduleCount}.`, fix: "Run more than one job from a schedule: onSchedule can do several things at once." };
|
|
105
|
+
}
|
|
106
|
+
const read = [];
|
|
107
|
+
for (const schedule of schedules) {
|
|
108
|
+
const { name, cron, ...rest } = (schedule && typeof schedule === "object" && !Array.isArray(schedule) ? schedule : {});
|
|
109
|
+
if (!schedule || typeof schedule !== "object" || Array.isArray(schedule))
|
|
110
|
+
return { message: `A schedule is ${JSON.stringify(schedule)?.slice(0, 80)}, not { name, cron }.`, fix: example };
|
|
111
|
+
const extra = Object.keys(rest)[0];
|
|
112
|
+
if (extra !== undefined)
|
|
113
|
+
return { message: `A schedule has ${extra}, which schedules don't take.`, fix: `A schedule is only { name, cron }. ${example}` };
|
|
114
|
+
if (typeof name !== "string" || !new RegExp(`^[a-z0-9][a-z0-9-]{0,${SCHEDULE_LIMITS.maximumNameCharacterCount - 1}}$`).test(name)) {
|
|
115
|
+
return { message: `A schedule's name is ${JSON.stringify(name) ?? "missing"}.`, fix: `Name it with lowercase letters, digits and hyphens, up to ${SCHEDULE_LIMITS.maximumNameCharacterCount} characters, like "daily-reset".` };
|
|
116
|
+
}
|
|
117
|
+
if (read.some((other) => other.name === name))
|
|
118
|
+
return { message: `Two schedules are named "${name}".`, fix: "Give each schedule its own name: onSchedule tells them apart by it." };
|
|
119
|
+
const cronRead = readCron(cron);
|
|
120
|
+
if ("mistake" in cronRead)
|
|
121
|
+
return { message: `Schedule "${name}": ${cronRead.mistake.replace(` ${CRON_FIX}`, "")}`, fix: CRON_FIX };
|
|
122
|
+
read.push({ name, cron: cron.trim().split(/\s+/).join(" ") });
|
|
123
|
+
}
|
|
124
|
+
if (!hasOnSchedule)
|
|
125
|
+
return { message: "schedules is there but onSchedule isn't, so nothing would run.", fix: "Add onSchedule(run, server): run.name says which schedule is due." };
|
|
126
|
+
return { schedules: read };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Run a game server's onSchedule for one run, with a time limit. failure says why it failed, for the
|
|
130
|
+
* game server's errors: it threw, or took longer than SCHEDULE_LIMITS.runTimeoutSec.
|
|
131
|
+
*/
|
|
132
|
+
export async function runSchedule(handler, run, server) {
|
|
133
|
+
if (typeof handler.onSchedule !== "function")
|
|
134
|
+
return { failure: "This game server has no onSchedule" };
|
|
135
|
+
let timer;
|
|
136
|
+
try {
|
|
137
|
+
await Promise.race([
|
|
138
|
+
Promise.resolve().then(() => handler.onSchedule(run, server)),
|
|
139
|
+
new Promise((_, reject) => {
|
|
140
|
+
timer = setTimeout(() => reject(new Error(`onSchedule ("${run.name}") didn't finish within ${SCHEDULE_LIMITS.runTimeoutSec} seconds`)), SCHEDULE_LIMITS.runTimeoutSec * 1000);
|
|
141
|
+
}),
|
|
142
|
+
]);
|
|
143
|
+
return { isDone: true };
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
return { failure: String((error instanceof Error && error.stack) || error) };
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** The longest a timer waits in one go (setTimeout's own limit is about 24.8 days). */
|
|
153
|
+
const MAXIMUM_TIMER_MS = 24 * 3_600_000;
|
|
154
|
+
/**
|
|
155
|
+
* Run a game server's schedules with timers, as the Node runner does on the developer's own server:
|
|
156
|
+
* each at the minutes its cron gives, from the moment this starts. onFailure hears about a run that
|
|
157
|
+
* failed.
|
|
158
|
+
*/
|
|
159
|
+
export function startSchedules(handler, server, onFailure) {
|
|
160
|
+
const timers = new Set();
|
|
161
|
+
let isStopped = false;
|
|
162
|
+
for (const schedule of handler.schedules ?? []) {
|
|
163
|
+
const plan = (afterMs) => {
|
|
164
|
+
const atMs = nextRunAtMs(schedule.cron, afterMs);
|
|
165
|
+
if (atMs === null || isStopped)
|
|
166
|
+
return;
|
|
167
|
+
const wait = (timer) => {
|
|
168
|
+
timers.add(timer);
|
|
169
|
+
timer.unref?.();
|
|
170
|
+
};
|
|
171
|
+
const due = () => {
|
|
172
|
+
// Long waits go in steps, and the clock is read again after each.
|
|
173
|
+
if (atMs - Date.now() > 0) {
|
|
174
|
+
const timer = setTimeout(() => {
|
|
175
|
+
timers.delete(timer);
|
|
176
|
+
due();
|
|
177
|
+
}, Math.min(atMs - Date.now(), MAXIMUM_TIMER_MS));
|
|
178
|
+
return wait(timer);
|
|
179
|
+
}
|
|
180
|
+
plan(atMs);
|
|
181
|
+
// A run gives up after SCHEDULE_LIMITS.runTimeoutSec, so it's over before the next minute.
|
|
182
|
+
const run = { name: schedule.name, cron: schedule.cron, scheduledAt: new Date(atMs).toISOString() };
|
|
183
|
+
void runSchedule(handler, run, server).then((outcome) => {
|
|
184
|
+
if ("failure" in outcome)
|
|
185
|
+
onFailure(run, outcome.failure);
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
due();
|
|
189
|
+
};
|
|
190
|
+
plan(Date.now());
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
stop() {
|
|
194
|
+
isStopped = true;
|
|
195
|
+
for (const timer of timers)
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
timers.clear();
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State sync (docs/DECISIONS.md, "Rooms carry PC-grade real-time games"): a game server with
|
|
3
|
+
* stateSyncEnabled sends each player their view of the state, a full copy the first time and after that
|
|
4
|
+
* only what changed, as { type: "state", serverTimeMs, full } and { type: "state", serverTimeMs, changes }.
|
|
5
|
+
* Each change is [path, value] for a set and [path] for a removal: objects are compared key by key,
|
|
6
|
+
* arrays index by index while their length stays the same and whole when it changes, everything else by
|
|
7
|
+
* value. There are no acknowledgements: over a reliable connection a change can't be lost, and a player
|
|
8
|
+
* whose connection is backed up gets nothing and then a full copy once they've caught up.
|
|
9
|
+
*
|
|
10
|
+
* Everything runs where the state lives: in the room on the developer's own server (RoomCore), and in
|
|
11
|
+
* the game server's sandbox on Zaisma, which loads stateSyncModuleSource(). So it's one function with no
|
|
12
|
+
* outside references, whose source is copied as it is. The SDK has its own copy of applyStateChanges,
|
|
13
|
+
* which test/node/state-sync.test.ts holds equal to this one.
|
|
14
|
+
*/
|
|
15
|
+
/** Where in the state a change is: keys of objects and indexes of arrays, from the top. */
|
|
16
|
+
export type StatePath = (string | number)[];
|
|
17
|
+
/** A change to the state: [path, value] sets what's at path, [path] removes it. An empty path replaces the whole state. */
|
|
18
|
+
export type StateChange = [path: StatePath] | [path: StatePath, value: unknown];
|
|
19
|
+
/** One update for some players: a full copy of their view, or the changes since the last one they got. */
|
|
20
|
+
export type StateFrame = {
|
|
21
|
+
full: unknown;
|
|
22
|
+
} | {
|
|
23
|
+
changes: StateChange[];
|
|
24
|
+
};
|
|
25
|
+
/** What a round of state sync sends, to whom, and what went wrong (a view that threw or was too big), for the game's errors. */
|
|
26
|
+
export interface StateRound {
|
|
27
|
+
frames: {
|
|
28
|
+
playerIds: string[];
|
|
29
|
+
frame: StateFrame;
|
|
30
|
+
}[];
|
|
31
|
+
problems: string[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Who sees what, round after round: the last view each viewer was sent, so each round sends only what
|
|
35
|
+
* changed. A viewer it hasn't sent to, or has forgotten, gets a full copy.
|
|
36
|
+
*/
|
|
37
|
+
export interface StateTracker {
|
|
38
|
+
/** The viewer gets a full copy next: a new connection, or a backlog they've caught up from. */
|
|
39
|
+
forget(viewerId: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* This round's frames. viewOf answers a viewer's view (stateView), or null when everyone sees the
|
|
42
|
+
* whole state. Backed-up viewers get nothing and a full copy once they're back; viewers not listed are forgotten.
|
|
43
|
+
*/
|
|
44
|
+
round(state: unknown, viewerIds: readonly string[], viewOf: ((viewerId: string) => unknown) | null, backedUpViewerIds: ReadonlySet<string>): StateRound;
|
|
45
|
+
}
|
|
46
|
+
/** What changed from previous to next, as changes that applyStateChanges turns previous into next with. */
|
|
47
|
+
export declare const diffState: (previous: unknown, next: unknown) => StateChange[];
|
|
48
|
+
/** The state with the changes applied, as a new state; the one given stays as it was. */
|
|
49
|
+
export declare const applyStateChanges: (state: unknown, changes: readonly StateChange[]) => unknown;
|
|
50
|
+
/** A tracker of what each viewer was last sent, for rounds of state sync; views over maximumViewSizeBytes aren't sent. */
|
|
51
|
+
export declare const newStateTracker: (maximumViewSizeBytes: number) => StateTracker;
|
|
52
|
+
/**
|
|
53
|
+
* The same parts as an ES module's source, for the game server's sandbox: export default is
|
|
54
|
+
* { diffState, applyStateChanges, newStateTracker }. A bundler that names functions (esbuild's keepNames)
|
|
55
|
+
* calls __name inside them, so the module gives it.
|
|
56
|
+
*/
|
|
57
|
+
export declare function stateSyncModuleSource(): string;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State sync (docs/DECISIONS.md, "Rooms carry PC-grade real-time games"): a game server with
|
|
3
|
+
* stateSyncEnabled sends each player their view of the state, a full copy the first time and after that
|
|
4
|
+
* only what changed, as { type: "state", serverTimeMs, full } and { type: "state", serverTimeMs, changes }.
|
|
5
|
+
* Each change is [path, value] for a set and [path] for a removal: objects are compared key by key,
|
|
6
|
+
* arrays index by index while their length stays the same and whole when it changes, everything else by
|
|
7
|
+
* value. There are no acknowledgements: over a reliable connection a change can't be lost, and a player
|
|
8
|
+
* whose connection is backed up gets nothing and then a full copy once they've caught up.
|
|
9
|
+
*
|
|
10
|
+
* Everything runs where the state lives: in the room on the developer's own server (RoomCore), and in
|
|
11
|
+
* the game server's sandbox on Zaisma, which loads stateSyncModuleSource(). So it's one function with no
|
|
12
|
+
* outside references, whose source is copied as it is. The SDK has its own copy of applyStateChanges,
|
|
13
|
+
* which test/node/state-sync.test.ts holds equal to this one.
|
|
14
|
+
*/
|
|
15
|
+
/** The parts, as one function with nothing from outside it, so its source runs the same in the sandbox. */
|
|
16
|
+
function stateSyncParts() {
|
|
17
|
+
const isPlainObject = (given) => typeof given === "object" && given !== null && !Array.isArray(given);
|
|
18
|
+
function diffInto(previous, next, path, changes) {
|
|
19
|
+
if (previous === next)
|
|
20
|
+
return;
|
|
21
|
+
if (isPlainObject(previous) && isPlainObject(next)) {
|
|
22
|
+
for (const key of Object.keys(previous))
|
|
23
|
+
if (!Object.hasOwn(next, key))
|
|
24
|
+
changes.push([[...path, key]]);
|
|
25
|
+
for (const key of Object.keys(next)) {
|
|
26
|
+
if (Object.hasOwn(previous, key))
|
|
27
|
+
diffInto(previous[key], next[key], [...path, key], changes);
|
|
28
|
+
else
|
|
29
|
+
changes.push([[...path, key], next[key]]);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (Array.isArray(previous) && Array.isArray(next) && previous.length === next.length) {
|
|
34
|
+
for (let i = 0; i < next.length; i++)
|
|
35
|
+
diffInto(previous[i], next[i], [...path, i], changes);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
changes.push([path, next]);
|
|
39
|
+
}
|
|
40
|
+
function diffState(previous, next) {
|
|
41
|
+
const changes = [];
|
|
42
|
+
diffInto(previous, next, [], changes);
|
|
43
|
+
return changes;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The state with the changes applied, as a new state: what changed is copied along its path and the
|
|
47
|
+
* rest is shared, so the state it was given stays as it was (the SDK keeps earlier ones for smoothing).
|
|
48
|
+
*/
|
|
49
|
+
function applyStateChanges(state, changes) {
|
|
50
|
+
// Objects and arrays copied in this call, which its later changes may write to.
|
|
51
|
+
const copied = new Set();
|
|
52
|
+
const copyOf = (given) => {
|
|
53
|
+
if (copied.has(given))
|
|
54
|
+
return given;
|
|
55
|
+
const copy = Array.isArray(given) ? given.slice() : { ...given };
|
|
56
|
+
copied.add(copy);
|
|
57
|
+
return copy;
|
|
58
|
+
};
|
|
59
|
+
let root = state;
|
|
60
|
+
for (const change of changes) {
|
|
61
|
+
const path = change[0];
|
|
62
|
+
if (!path.length) {
|
|
63
|
+
root = change.length > 1 ? change[1] : null;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
root = copyOf(root);
|
|
67
|
+
let parent = root;
|
|
68
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
69
|
+
const child = copyOf(parent[path[i]]);
|
|
70
|
+
parent[path[i]] = child;
|
|
71
|
+
parent = child;
|
|
72
|
+
}
|
|
73
|
+
const key = path[path.length - 1];
|
|
74
|
+
// A key named __proto__ is plain data, as JSON.parse makes it, never the object's prototype.
|
|
75
|
+
if (change.length > 1)
|
|
76
|
+
Object.defineProperty(parent, key, { value: change[1], writable: true, enumerable: true, configurable: true });
|
|
77
|
+
else
|
|
78
|
+
delete parent[key];
|
|
79
|
+
}
|
|
80
|
+
return root;
|
|
81
|
+
}
|
|
82
|
+
function utf8SizeBytes(text) {
|
|
83
|
+
return new TextEncoder().encode(text).length;
|
|
84
|
+
}
|
|
85
|
+
function newStateTracker(maximumViewSizeBytes) {
|
|
86
|
+
// Each viewer's last view as JSON and as a copy; viewers sent the same view share one.
|
|
87
|
+
const sentViews = new Map();
|
|
88
|
+
let hasReportedTooBig = false;
|
|
89
|
+
return {
|
|
90
|
+
forget(viewerId) {
|
|
91
|
+
sentViews.delete(viewerId);
|
|
92
|
+
},
|
|
93
|
+
round(state, viewerIds, viewOf, backedUpViewerIds) {
|
|
94
|
+
const listed = new Set(viewerIds);
|
|
95
|
+
for (const viewerId of [...sentViews.keys()])
|
|
96
|
+
if (!listed.has(viewerId))
|
|
97
|
+
sentViews.delete(viewerId);
|
|
98
|
+
const round = { frames: [], problems: [] };
|
|
99
|
+
// This round's views by their JSON: each is copied, size-checked, sent in full and diffed once, whoever sees it.
|
|
100
|
+
const views = new Map();
|
|
101
|
+
let wholeStateText;
|
|
102
|
+
let hasViewFailed = false;
|
|
103
|
+
for (const viewerId of viewerIds) {
|
|
104
|
+
if (backedUpViewerIds.has(viewerId)) {
|
|
105
|
+
sentViews.delete(viewerId);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let text;
|
|
109
|
+
try {
|
|
110
|
+
text = viewOf ? JSON.stringify(viewOf(viewerId)) : (wholeStateText ??= JSON.stringify(state));
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!hasViewFailed)
|
|
114
|
+
round.problems.push(viewOf ? `stateView failed: ${String(error?.stack || error)}` : `The state isn't plain JSON, so it can't be sent: ${String(error)}`);
|
|
115
|
+
hasViewFailed = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
text ??= "null";
|
|
119
|
+
const last = sentViews.get(viewerId);
|
|
120
|
+
if (last && last.text === text)
|
|
121
|
+
continue;
|
|
122
|
+
let seen = views.get(text);
|
|
123
|
+
if (!seen) {
|
|
124
|
+
const sizeBytes = text.length * 3 > maximumViewSizeBytes ? utf8SizeBytes(text) : text.length;
|
|
125
|
+
seen = { sent: sizeBytes > maximumViewSizeBytes ? null : { text, view: JSON.parse(text) }, fullFrame: null, changeFrames: new Map() };
|
|
126
|
+
views.set(text, seen);
|
|
127
|
+
if (!seen.sent && !hasReportedTooBig) {
|
|
128
|
+
hasReportedTooBig = true;
|
|
129
|
+
round.problems.push(`A player's view of the state is ${Math.round(sizeBytes / 1024)} KB as JSON, over the ${Math.round(maximumViewSizeBytes / 1024)} KB a player can be sent, so it isn't sent. Cut it to what each player needs with stateView.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!seen.sent) {
|
|
133
|
+
sentViews.delete(viewerId);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
let frame;
|
|
137
|
+
if (!last) {
|
|
138
|
+
if (!seen.fullFrame)
|
|
139
|
+
round.frames.push((seen.fullFrame = { playerIds: [], frame: { full: seen.sent.view } }));
|
|
140
|
+
frame = seen.fullFrame;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
if (!seen.changeFrames.has(last)) {
|
|
144
|
+
const changes = diffState(last.view, seen.sent.view);
|
|
145
|
+
const made = changes.length ? { playerIds: [], frame: { changes } } : null;
|
|
146
|
+
if (made)
|
|
147
|
+
round.frames.push(made);
|
|
148
|
+
seen.changeFrames.set(last, made);
|
|
149
|
+
}
|
|
150
|
+
frame = seen.changeFrames.get(last);
|
|
151
|
+
}
|
|
152
|
+
sentViews.set(viewerId, seen.sent);
|
|
153
|
+
frame?.playerIds.push(viewerId);
|
|
154
|
+
}
|
|
155
|
+
return round;
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { diffState, applyStateChanges, newStateTracker };
|
|
160
|
+
}
|
|
161
|
+
const parts = stateSyncParts();
|
|
162
|
+
/** What changed from previous to next, as changes that applyStateChanges turns previous into next with. */
|
|
163
|
+
export const diffState = parts.diffState;
|
|
164
|
+
/** The state with the changes applied, as a new state; the one given stays as it was. */
|
|
165
|
+
export const applyStateChanges = parts.applyStateChanges;
|
|
166
|
+
/** A tracker of what each viewer was last sent, for rounds of state sync; views over maximumViewSizeBytes aren't sent. */
|
|
167
|
+
export const newStateTracker = parts.newStateTracker;
|
|
168
|
+
/**
|
|
169
|
+
* The same parts as an ES module's source, for the game server's sandbox: export default is
|
|
170
|
+
* { diffState, applyStateChanges, newStateTracker }. A bundler that names functions (esbuild's keepNames)
|
|
171
|
+
* calls __name inside them, so the module gives it.
|
|
172
|
+
*/
|
|
173
|
+
export function stateSyncModuleSource() {
|
|
174
|
+
return `var __name = (target) => target;\nexport default (${stateSyncParts.toString()})();\n`;
|
|
175
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhooks to the game server: a webhook can send Zaisma's events about a game to that game's own
|
|
3
|
+
* game server, whose onWebhook(event, server) turns them into whatever it likes, like a Discord post
|
|
4
|
+
* or a GitHub issue made with the developer's own tokens from server.secrets (docs/DECISIONS.md,
|
|
5
|
+
* "Webhooks to the game server"). Zaisma runs it in the game's own object (apps/rooms/src/game-server.ts)
|
|
6
|
+
* for a webhook whose destination_kind is game_server. On the developer's own server the Node runner
|
|
7
|
+
* (@zaisma/game-server/node) takes the same event as a signed POST at /v1/webhooks, checks it here with the
|
|
8
|
+
* webhook's whsec_ signing secret (Standard Webhooks, standardwebhooks.com) and runs the same
|
|
9
|
+
* onWebhook, so the code moves unchanged.
|
|
10
|
+
*/
|
|
11
|
+
import type { Server } from "./requests.ts";
|
|
12
|
+
/**
|
|
13
|
+
* Every kind of event Zaisma sends today. A WebhookEvent's kind is one of these or a kind added later,
|
|
14
|
+
* so a game server switches on the ones it knows and skips the rest.
|
|
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" | "purchase_completed" | "purchase_refunded" | "test";
|
|
17
|
+
/** Who deleted their Zaisma account, on an account_deleted event. */
|
|
18
|
+
export interface AccountDeletion {
|
|
19
|
+
/** The player who deleted their Zaisma account. */
|
|
20
|
+
player_id: string;
|
|
21
|
+
/** The game's deleted_account_erase_enabled: whether their rows are erased from the game's database (always false about a sign-in app). The Node runner erases them from its local database when it's true. */
|
|
22
|
+
deleted_account_erase_enabled: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* What a webhook is sent, as the developer API's get_game, get_version and get_reports answer each
|
|
26
|
+
* part (snake_case, like all the API's JSON: the one place the game server isn't camelCase). The same
|
|
27
|
+
* event comes on every try, with the same id. New kinds and fields can be added; none is removed.
|
|
28
|
+
*/
|
|
29
|
+
export interface WebhookEvent {
|
|
30
|
+
/** A webhook_event_… ID, the same on every try, so a game server can skip one it already handled. */
|
|
31
|
+
id: string;
|
|
32
|
+
/** What happened: a WebhookEventKind (security_alert only to your own server, never a game server on Zaisma; test from test_webhook), or a kind added later, to skip. alert_raised covers every alert: switch on alert.kind. */
|
|
33
|
+
kind: WebhookEventKind | (string & {});
|
|
34
|
+
/** When Zaisma made the event, an ISO time in UTC. Events can arrive out of order: compare created_at before overwriting newer state. */
|
|
35
|
+
created_at: string;
|
|
36
|
+
/** The game as get_game answers it; null for a test event, a security_alert, or an account_deleted event about a sign-in app. */
|
|
37
|
+
game: Record<string, unknown> | null;
|
|
38
|
+
/** The version as get_version answers it; null when the event isn't about one. */
|
|
39
|
+
version: Record<string, unknown> | null;
|
|
40
|
+
/** The bug report as get_reports lists it; null unless kind is bug_reported. */
|
|
41
|
+
bug_report: Record<string, unknown> | null;
|
|
42
|
+
/** The player report as get_reports lists it; null unless kind is player_reported. */
|
|
43
|
+
player_report: Record<string, unknown> | null;
|
|
44
|
+
/** The error as get_stats lists it; null unless kind is game_server_error_found. */
|
|
45
|
+
game_server_error: Record<string, unknown> | null;
|
|
46
|
+
/** The alert as the game's alerts list it; null unless kind is alert_raised. */
|
|
47
|
+
alert: Record<string, unknown> | null;
|
|
48
|
+
/** Who deleted their account and whether their rows are erased; null unless kind is account_deleted. */
|
|
49
|
+
account_deletion: AccountDeletion | null;
|
|
50
|
+
/** The sign-in app as list_sign_in_apps answers it; null unless an account_deleted event is about a site, not a game. */
|
|
51
|
+
sign_in_app: Record<string, unknown> | null;
|
|
52
|
+
/** The developer's own account event as get_activity lists it; null unless kind is security_alert. */
|
|
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;
|
|
56
|
+
}
|
|
57
|
+
export declare const WEBHOOK_LIMITS: {
|
|
58
|
+
/** How long onWebhook may take, awaiting the database and outside calls included, before the try fails and waits for the next. */
|
|
59
|
+
readonly answerTimeoutSec: 10;
|
|
60
|
+
/** The most of a webhook's POST verifyWebhook and the Node runner take, in bytes: well above any event Zaisma sends. */
|
|
61
|
+
readonly maximumEventSizeBytes: number;
|
|
62
|
+
/** How far a POST's webhook-timestamp may be from the server's clock, so an old one can't be sent again. */
|
|
63
|
+
readonly timestampToleranceDurationSec: 300;
|
|
64
|
+
};
|
|
65
|
+
/** A game server that takes webhooks. */
|
|
66
|
+
export interface WebhookHandler {
|
|
67
|
+
onWebhook?(event: WebhookEvent, server: Server): unknown;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Why a webhook POST wasn't trusted (verifyWebhook), or why onWebhook didn't take an event (runWebhook):
|
|
71
|
+
* - missing_headers: no webhook-id, webhook-timestamp or webhook-signature header.
|
|
72
|
+
* - too_large: the body is over WEBHOOK_LIMITS.maximumEventSizeBytes.
|
|
73
|
+
* - old_timestamp: webhook-timestamp isn't within WEBHOOK_LIMITS.timestampToleranceDurationSec of this server's clock.
|
|
74
|
+
* - bad_signing_secret: the signing secret isn't a whsec_ secret.
|
|
75
|
+
* - bad_signature: no webhook-signature matches the signing secret.
|
|
76
|
+
* - not_an_event: the body isn't JSON, or isn't an event with the webhook-id's id.
|
|
77
|
+
* - no_webhook_handler: the game server has no onWebhook.
|
|
78
|
+
* - game_server_error: onWebhook threw (message holds the error), or took longer than WEBHOOK_LIMITS.answerTimeoutSec.
|
|
79
|
+
* New codes can be added.
|
|
80
|
+
*/
|
|
81
|
+
export type WebhookErrorCode = "missing_headers" | "too_large" | "old_timestamp" | "bad_signing_secret" | "bad_signature" | "not_an_event" | "no_webhook_handler" | "game_server_error";
|
|
82
|
+
/** A webhook error, shaped like the developer API's: a code, what happened and what to do. */
|
|
83
|
+
export interface WebhookError {
|
|
84
|
+
code: WebhookErrorCode;
|
|
85
|
+
message: string;
|
|
86
|
+
fix: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Run a game server's onWebhook for one event, with a time limit. An error says why the try failed,
|
|
90
|
+
* for the game server's errors: it has no onWebhook, it threw, or it took longer than
|
|
91
|
+
* WEBHOOK_LIMITS.answerTimeoutSec.
|
|
92
|
+
*/
|
|
93
|
+
export declare function runWebhook(handler: WebhookHandler, event: WebhookEvent, server: Server): Promise<{
|
|
94
|
+
isDone: true;
|
|
95
|
+
} | {
|
|
96
|
+
error: WebhookError;
|
|
97
|
+
}>;
|
|
98
|
+
/** The Standard Webhooks headers a signed POST comes with. */
|
|
99
|
+
export interface WebhookHeaders {
|
|
100
|
+
webhookId: string | null;
|
|
101
|
+
webhookTimestamp: string | null;
|
|
102
|
+
webhookSignature: string | null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Check a webhook POST the Standard Webhooks way: its webhook-signature holds a "v1,<base64>" HMAC-SHA256
|
|
106
|
+
* of "<webhook-id>.<webhook-timestamp>.<body>" keyed by the signing secret's bytes, and its timestamp is
|
|
107
|
+
* within WEBHOOK_LIMITS.timestampToleranceDurationSec of now. Answers the event, or an error saying why
|
|
108
|
+
* it isn't one to trust.
|
|
109
|
+
*/
|
|
110
|
+
export declare function verifyWebhook(signingSecret: string, headers: WebhookHeaders, body: string, nowMs?: number): Promise<{
|
|
111
|
+
event: WebhookEvent;
|
|
112
|
+
} | {
|
|
113
|
+
error: WebhookError;
|
|
114
|
+
}>;
|