@hopple/netcode 0.0.0-stage → 0.1.1-ci.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@hopple/netcode",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1-ci.1",
4
+ "description": "What a Hopple game bundles to play a match over the network: exact byte encoding, a lobby, and a lockstep helper with rollback.",
5
+ "license": "MIT",
6
+ "homepage": "https://hopple.app",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/pamarit/hopple.git"
10
+ },
11
+ "keywords": [
12
+ "hopple",
13
+ "game",
14
+ "netcode",
15
+ "lockstep",
16
+ "rollback",
17
+ "lobby",
18
+ "multiplayer"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@hopple/bytes": "^0.1.1-ci.1"
25
+ },
26
+ "peerDependencies": {
27
+ "@hopple/sdk": "^1.0.0-next.1",
28
+ "@hopple/sdk-preview-network": "^0.1.0"
29
+ },
30
+ "type": "module",
31
+ "sideEffects": false,
32
+ "exports": {
33
+ ".": {
34
+ "types": "./index.d.ts",
35
+ "default": "./index.js"
36
+ },
37
+ "./session": {
38
+ "types": "./session.d.ts",
39
+ "default": "./session.js"
40
+ },
41
+ "./lockstep": {
42
+ "types": "./lockstep.d.ts",
43
+ "default": "./lockstep.js"
44
+ },
45
+ "./package.json": "./package.json"
46
+ },
47
+ "main": "./index.js",
48
+ "types": "./index.d.ts"
49
+ }
package/session.d.ts ADDED
@@ -0,0 +1,226 @@
1
+ import { ByteReader, ByteWriter } from "@hopple/bytes";
2
+ //#region src/session.d.ts
3
+ /**
4
+ * The first byte of every message the session sends. A game sending messages
5
+ * of its own in the same match starts them with any other byte.
6
+ *
7
+ * @group Session
8
+ */
9
+ declare const SESSION_TAG = 246;
10
+ /**
11
+ * How a value of the game's travels: written after the session's own bytes,
12
+ * and read back in the same order. A `read` that throws is a message that
13
+ * does not count.
14
+ *
15
+ * @group Session
16
+ */
17
+ type Codec<T> = {
18
+ write(out: ByteWriter, value: T): void;
19
+ read(input: ByteReader): T;
20
+ };
21
+ /**
22
+ * What the session needs of a person: `@hopple/sdk-preview-network`'s `Person` has it all.
23
+ *
24
+ * @group Session
25
+ */
26
+ type SessionPerson = {
27
+ readonly id: string;
28
+ readonly me: boolean;
29
+ readonly connected: boolean;
30
+ /** How many players play on their device, as the match counts them. One when left out. */
31
+ readonly locals?: number;
32
+ };
33
+ /**
34
+ * What the session needs of a match: `@hopple/sdk-preview-network`'s `Match` has it all.
35
+ *
36
+ * @group Session
37
+ */
38
+ type SessionMatch<P extends SessionPerson = SessionPerson> = {
39
+ readonly id: string;
40
+ readonly maxPlayers: number;
41
+ readonly role: 'player' | 'spectator' | null;
42
+ readonly me: P | null;
43
+ readonly players: readonly P[];
44
+ readonly host: P | null;
45
+ readonly ended: boolean;
46
+ readonly kept: readonly {
47
+ readonly data: Uint8Array;
48
+ readonly from: P;
49
+ readonly key: number;
50
+ readonly seq: number;
51
+ }[];
52
+ send(data: Uint8Array, options?: {
53
+ keep?: number;
54
+ }): void;
55
+ onMessage(listener: (data: Uint8Array, from: P, seq: number) => void): () => void;
56
+ on(listener: () => void): () => void;
57
+ };
58
+ /**
59
+ * What the session needs of the game's matches: `@hopple/sdk-preview-network`'s `sdk.match`
60
+ * has it all.
61
+ *
62
+ * @group Session
63
+ */
64
+ type SessionMatches<P extends SessionPerson = SessionPerson> = {
65
+ readonly current: SessionMatch<P> | null;
66
+ onChange(listener: (match: SessionMatch<P> | null) => void): () => void;
67
+ };
68
+ /**
69
+ * A game the host started, as every device builds it. It has what
70
+ * `lockstep` needs, so `lockstep({ ...start, sample, step })` runs it.
71
+ *
72
+ * @group Session
73
+ */
74
+ type Start<Pick, Config, P extends SessionPerson = SessionPerson> = {
75
+ /** The match it is played in. */
76
+ match: SessionMatch<P>;
77
+ /** Where the start fell in the match's order: the run hears only what follows. */
78
+ seq: number;
79
+ /** A number for this game, different from the match's other games. */
80
+ run: number;
81
+ /** For the game's random generator: the same on every device. */
82
+ seed: number;
83
+ /** Who plays, by person id, in seat order: a seat is a device. */
84
+ seats: string[];
85
+ /**
86
+ * How many players play on each seat's device, in seat order, as the match
87
+ * counted them when the host started: what `lockstep` takes to run one
88
+ * input per player.
89
+ */
90
+ locals: number[];
91
+ /** Every player of the game, seat by seat and each seat's players in order: what `step` is given inputs for. */
92
+ players: StartPlayer[];
93
+ /** What each seat picked, as the host saw it. */
94
+ picks: Pick[];
95
+ /** The game's own config, as the host gave it. */
96
+ config: Config;
97
+ /** This device's person id: a device whose person has no seat watches. */
98
+ me: string;
99
+ };
100
+ /**
101
+ * One player of a game: a seat's device, and which of its players.
102
+ *
103
+ * @group Session
104
+ */
105
+ type StartPlayer = {
106
+ /** Their seat, in `seats`. */
107
+ seat: number;
108
+ /** Their person's id. */
109
+ person: string;
110
+ /** Which of their device's players they are, from 0: `sdk.input.players`' order. */
111
+ local: number;
112
+ /** Whether they play on this device. */
113
+ me: boolean;
114
+ };
115
+ /**
116
+ * A player in the match, as the lobby shows them.
117
+ *
118
+ * @group Session
119
+ */
120
+ type SessionPlayer<Pick, P extends SessionPerson = SessionPerson> = {
121
+ person: P;
122
+ /** What they picked: the game's `initial` until they say. */
123
+ pick: Pick;
124
+ /** Whether they said they are ready for the next game. */
125
+ ready: boolean;
126
+ /** Whether they host the match: they set it up and start each game. */
127
+ host: boolean;
128
+ };
129
+ /**
130
+ * @group Session
131
+ */
132
+ type SessionOptions<Pick, Settings, Config, P extends SessionPerson> = {
133
+ /** `sdk.match`: the session is the lobby of whichever match is current. */
134
+ match: SessionMatches<P>;
135
+ /** What a player picks, as it travels, and what it is before they pick. */
136
+ pick: Codec<Pick> & {
137
+ initial: Pick;
138
+ };
139
+ /** The host's settings, as they travel, and what they are before a host says. */
140
+ settings: Codec<Settings> & {
141
+ initial: Settings;
142
+ };
143
+ /** What the host's start carries of the game's own, as it travels. */
144
+ config: Codec<Config>;
145
+ /**
146
+ * The host started a game: build it from `start` alone. Run in the listener
147
+ * that heard it, on every device in the match, watchers included.
148
+ */
149
+ onStart(start: Start<Pick, Config, P>): void;
150
+ /** The keys the session keeps its messages under: 0 and 1 by default. */
151
+ keys?: {
152
+ pick?: number;
153
+ settings?: number;
154
+ };
155
+ };
156
+ /**
157
+ * A match's lobby, followed as the player's match comes and goes.
158
+ *
159
+ * @group Session
160
+ */
161
+ type Session<Pick, Settings, Config, P extends SessionPerson = SessionPerson> = {
162
+ /** The match the player is in, or `null`. */
163
+ readonly match: SessionMatch<P> | null;
164
+ /** This player's person id, or `''` outside a match. */
165
+ readonly me: string;
166
+ /** Whether this player hosts the match: they set it up and start each game. */
167
+ readonly hosting: boolean;
168
+ /** Who plays the match, in the order they joined, with what each picked. */
169
+ readonly players: readonly SessionPlayer<Pick, P>[];
170
+ /**
171
+ * The players the host waits for, the host aside: those connected and not
172
+ * ready. Somebody whose connection is down is not waited for: seated
173
+ * anyway, a lockstep run hands their seat to the game until they are back.
174
+ */
175
+ readonly waiting: readonly P[];
176
+ /** What this player picked, and whether they are ready. */
177
+ readonly mine: {
178
+ readonly pick: Pick;
179
+ readonly ready: boolean;
180
+ };
181
+ /** The host's settings, as last heard: kept by a new host until it changes them. */
182
+ readonly settings: Settings;
183
+ /** Say what this player picks. */
184
+ pick(pick: Pick): void;
185
+ /** Say whether this player is ready for the next game. */
186
+ ready(ready: boolean): void;
187
+ /** The host changes the settings (an object's are merged). Anybody else's change is ignored, and `false`. */
188
+ configure(settings: Partial<Settings>): boolean;
189
+ /**
190
+ * The host starts a game with `config`, seating `seats` (every player, in the
191
+ * order they joined, by default). Every device hears it in `onStart`. `false`
192
+ * for anybody but the host.
193
+ */
194
+ start(config: Config, seats?: readonly string[]): boolean;
195
+ /** Stop following the match. */
196
+ close(): void;
197
+ };
198
+ /**
199
+ * Whether a match message is one of the session's, for a game's own listener
200
+ * to skip.
201
+ *
202
+ * @group Session
203
+ */
204
+ declare function isSessionMessage(data: Uint8Array): boolean;
205
+ /**
206
+ * Follow the player's match as a lobby. See the module for how.
207
+ *
208
+ * ```ts
209
+ * const lobby = session({
210
+ * match: sdk.match,
211
+ * pick: { initial: 0, write: (out, fighter) => out.uint(fighter), read: (input) => input.uint() },
212
+ * settings: { initial: { rounds: 3 }, write: (out, s) => out.uint(s.rounds), read: (input) => ({ rounds: input.uint() }) },
213
+ * config: { write: (out, c) => out.uint(c.rounds), read: (input) => ({ rounds: input.uint() }) },
214
+ * onStart: (start) => play(start),
215
+ * });
216
+ * await sdk.match.play({ maxPlayers: 4 });
217
+ * lobby.pick(fighter);
218
+ * lobby.ready(true);
219
+ * if (lobby.hosting && lobby.waiting.length === 0) lobby.start({ rounds: lobby.settings.rounds });
220
+ * ```
221
+ *
222
+ * @group Session
223
+ */
224
+ declare function session<Pick, Settings, Config, P extends SessionPerson>(options: SessionOptions<Pick, Settings, Config, P>): Session<Pick, Settings, Config, P>;
225
+ //#endregion
226
+ export { Codec, SESSION_TAG, Session, SessionMatch, SessionMatches, SessionOptions, SessionPerson, SessionPlayer, Start, StartPlayer, isSessionMessage, session };
package/session.js ADDED
@@ -0,0 +1,361 @@
1
+ import { ByteReader, ByteWriter } from "@hopple/bytes";
2
+ //#region src/session.ts
3
+ /**
4
+ * The lobby every networked game has between its games: what each player
5
+ * picked and whether they are ready, the host's settings, and starting each
6
+ * game so that every device builds the same one. Optional: a game is welcome
7
+ * to write its own on the SDK's match.
8
+ *
9
+ * **Nothing is said twice.** A player's pick and readiness are one message
10
+ * kept under one key, and the host's settings one kept under another: whoever
11
+ * joins later, or whose game loads again, finds them in `match.kept`, so
12
+ * nobody says them again on a timer. A game loaded again picks up its own; a
13
+ * player whose connection comes back says theirs again, as what they said as
14
+ * it went may have gone with it.
15
+ *
16
+ * **The host starts each game with one message**, not kept, carrying a run
17
+ * number, a seed, who sits where and what each of them picked, and the
18
+ * game's own config: every device hears it at the same place in the log, and
19
+ * builds the game from it alone. A start from anybody else starts nothing.
20
+ * Nobody is ready for the game after it until they say so again.
21
+ *
22
+ * **The host is the match's**, as the relay picks it: the player who made
23
+ * it, then whoever it was handed to, then, once the host has been gone a
24
+ * while, the one still there who has played it longest. A new host keeps the
25
+ * settings the last one left, and can start the next game.
26
+ *
27
+ * **Being in a match is the game's.** The session follows `sdk.match.current`
28
+ * and never joins or makes a match of its own: the game calls
29
+ * `sdk.match.play()` or `create()`, or the host puts the player in one, and
30
+ * the session is the lobby of whichever match that is.
31
+ *
32
+ * Its messages start with `SESSION_TAG`; a game's own in the same match start
33
+ * with any other byte, and `isSessionMessage` tells them apart.
34
+ *
35
+ * @module session
36
+ */
37
+ /**
38
+ * The first byte of every message the session sends. A game sending messages
39
+ * of its own in the same match starts them with any other byte.
40
+ *
41
+ * @group Session
42
+ */
43
+ const SESSION_TAG = 246;
44
+ const PICK = 1;
45
+ const SETTINGS = 2;
46
+ const START = 3;
47
+ /** The most seats a start message is read with. */
48
+ const MOST_SEATS = 64;
49
+ /**
50
+ * Whether a match message is one of the session's, for a game's own listener
51
+ * to skip.
52
+ *
53
+ * @group Session
54
+ */
55
+ function isSessionMessage(data) {
56
+ return data[0] === 246;
57
+ }
58
+ function encode(codec, value) {
59
+ const out = new ByteWriter();
60
+ codec.write(out, value);
61
+ return out.finish();
62
+ }
63
+ /**
64
+ * Follow the player's match as a lobby. See the module for how.
65
+ *
66
+ * ```ts
67
+ * const lobby = session({
68
+ * match: sdk.match,
69
+ * pick: { initial: 0, write: (out, fighter) => out.uint(fighter), read: (input) => input.uint() },
70
+ * settings: { initial: { rounds: 3 }, write: (out, s) => out.uint(s.rounds), read: (input) => ({ rounds: input.uint() }) },
71
+ * config: { write: (out, c) => out.uint(c.rounds), read: (input) => ({ rounds: input.uint() }) },
72
+ * onStart: (start) => play(start),
73
+ * });
74
+ * await sdk.match.play({ maxPlayers: 4 });
75
+ * lobby.pick(fighter);
76
+ * lobby.ready(true);
77
+ * if (lobby.hosting && lobby.waiting.length === 0) lobby.start({ rounds: lobby.settings.rounds });
78
+ * ```
79
+ *
80
+ * @group Session
81
+ */
82
+ function session(options) {
83
+ const matches = options.match;
84
+ const pickKey = options.keys?.pick ?? 0;
85
+ const settingsKey = options.keys?.settings ?? 1;
86
+ let match = null;
87
+ let stopMatch = null;
88
+ const mine = {
89
+ pick: options.pick.initial,
90
+ ready: false
91
+ };
92
+ let settings = options.settings.initial;
93
+ /** Where the settings last taken from the match fell in its order. */
94
+ let settingsSeq = 0;
95
+ /** Each player's kept pick, read once: by person id. */
96
+ const picks = /* @__PURE__ */ new Map();
97
+ /** Where the last start fell: readiness said before it was for that game. */
98
+ let startedAt = 0;
99
+ /** Whether this player's pick, and as host its settings, are kept in the match now. */
100
+ let said = false;
101
+ let hostSaid = false;
102
+ /** Whether this player's connection was down while playing: what it said as it went may have gone with it. */
103
+ let dropped = false;
104
+ function readPick(data) {
105
+ try {
106
+ const input = new ByteReader(data);
107
+ if (input.byte() !== 246 || input.byte() !== PICK) return null;
108
+ return {
109
+ ready: input.bool(),
110
+ pick: options.pick.read(input)
111
+ };
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+ function readSettings(data) {
117
+ try {
118
+ const input = new ByteReader(data);
119
+ if (input.byte() !== 246 || input.byte() !== SETTINGS) return null;
120
+ return { value: options.settings.read(input) };
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+ function playing() {
126
+ return match !== null && match.role === "player" && !match.ended;
127
+ }
128
+ function hosting() {
129
+ return playing() && match.host?.me === true;
130
+ }
131
+ function sendPick() {
132
+ if (!playing()) return;
133
+ const out = new ByteWriter().byte(246).byte(PICK).bool(mine.ready);
134
+ options.pick.write(out, mine.pick);
135
+ said = true;
136
+ match.send(out.finish(), { keep: pickKey });
137
+ }
138
+ function sendSettings() {
139
+ if (!hosting()) return;
140
+ const out = new ByteWriter().byte(246).byte(SETTINGS);
141
+ options.settings.write(out, settings);
142
+ hostSaid = true;
143
+ match.send(out.finish(), { keep: settingsKey });
144
+ }
145
+ /** What the match keeps now, read: everybody's picks, and the newest settings somebody else kept. */
146
+ function refresh() {
147
+ if (match === null) return;
148
+ let newest = null;
149
+ for (const one of match.kept) {
150
+ if (one.data[0] !== 246) continue;
151
+ if (one.key === pickKey) {
152
+ if (picks.get(one.from.id)?.seq === one.seq) continue;
153
+ const read = readPick(one.data);
154
+ if (read !== null) picks.set(one.from.id, {
155
+ seq: one.seq,
156
+ ...read
157
+ });
158
+ } else if (one.key === settingsKey && !one.from.me && one.seq > settingsSeq && (newest === null || one.seq > newest.seq)) newest = one;
159
+ }
160
+ if (newest !== null) {
161
+ settingsSeq = newest.seq;
162
+ const read = readSettings(newest.data);
163
+ if (read !== null) settings = read.value;
164
+ }
165
+ }
166
+ /** The match changed: read it, and say what this player has not said in it. */
167
+ function changed() {
168
+ refresh();
169
+ if (!playing()) {
170
+ said = hostSaid = false;
171
+ dropped = false;
172
+ return;
173
+ }
174
+ if (match.me?.connected === false) {
175
+ dropped = true;
176
+ return;
177
+ }
178
+ if (dropped) {
179
+ dropped = false;
180
+ said = hostSaid = false;
181
+ }
182
+ if (!said) sendPick();
183
+ if (!hosting()) hostSaid = false;
184
+ else if (!hostSaid) sendSettings();
185
+ }
186
+ function follow(next) {
187
+ if (next === match) return;
188
+ stopMatch?.();
189
+ stopMatch = null;
190
+ match = next;
191
+ picks.clear();
192
+ settingsSeq = 0;
193
+ startedAt = 0;
194
+ said = hostSaid = false;
195
+ dropped = false;
196
+ if (next !== null) {
197
+ let newest = 0;
198
+ for (const one of next.kept) newest = Math.max(newest, one.key === settingsKey ? one.seq : 0);
199
+ for (const one of next.kept) {
200
+ if (!one.from.me || next.role !== "player") continue;
201
+ if (one.key === pickKey) {
202
+ const read = readPick(one.data);
203
+ if (read !== null) {
204
+ mine.pick = read.pick;
205
+ mine.ready = read.ready;
206
+ said = true;
207
+ }
208
+ } else if (one.key === settingsKey) {
209
+ const read = readSettings(one.data);
210
+ if (read !== null && one.seq === newest) {
211
+ settings = read.value;
212
+ settingsSeq = one.seq;
213
+ hostSaid = true;
214
+ }
215
+ }
216
+ }
217
+ const stops = [next.onMessage(hear), next.on(changed)];
218
+ stopMatch = () => stops.forEach((stop) => stop());
219
+ }
220
+ changed();
221
+ }
222
+ function hear(data, from, seq) {
223
+ if (data[0] !== 246 || match === null) return;
224
+ if (data[1] !== START) {
225
+ refresh();
226
+ return;
227
+ }
228
+ if (from.id !== match.host?.id) return;
229
+ let start;
230
+ try {
231
+ const input = new ByteReader(data.subarray(2));
232
+ const run = input.uint();
233
+ const seed = input.uint();
234
+ const count = input.uint();
235
+ if (count > MOST_SEATS) return;
236
+ const seats = [];
237
+ const locals = [];
238
+ const chosen = [];
239
+ for (let i = 0; i < count; i++) {
240
+ seats.push(input.string());
241
+ locals.push(Math.max(1, Math.min(MOST_SEATS, input.uint())));
242
+ const bytes = input.bytes();
243
+ let pick = options.pick.initial;
244
+ try {
245
+ pick = options.pick.read(new ByteReader(bytes));
246
+ } catch {}
247
+ chosen.push(pick);
248
+ }
249
+ const config = options.config.read(input);
250
+ const me = match.me?.id ?? "";
251
+ const players = seats.flatMap((person, seat) => Array.from({ length: locals[seat] }, (_, local) => ({
252
+ seat,
253
+ person,
254
+ local,
255
+ me: person === me
256
+ })));
257
+ start = {
258
+ match,
259
+ seq,
260
+ run,
261
+ seed,
262
+ seats,
263
+ locals,
264
+ players,
265
+ picks: chosen,
266
+ config,
267
+ me
268
+ };
269
+ } catch {
270
+ return;
271
+ }
272
+ startedAt = seq;
273
+ if (mine.ready) {
274
+ mine.ready = false;
275
+ sendPick();
276
+ }
277
+ options.onStart(start);
278
+ }
279
+ const stop = matches.onChange(follow);
280
+ follow(matches.current);
281
+ return {
282
+ get match() {
283
+ return match;
284
+ },
285
+ get me() {
286
+ return match?.me?.id ?? "";
287
+ },
288
+ get hosting() {
289
+ return hosting();
290
+ },
291
+ get players() {
292
+ const current = match;
293
+ if (current === null) return [];
294
+ return current.players.map((person) => {
295
+ const host = person.id === current.host?.id;
296
+ if (person.me) return {
297
+ person,
298
+ pick: mine.pick,
299
+ ready: mine.ready,
300
+ host
301
+ };
302
+ const known = picks.get(person.id);
303
+ return {
304
+ person,
305
+ pick: known?.pick ?? options.pick.initial,
306
+ ready: known !== void 0 && known.ready && known.seq > startedAt,
307
+ host
308
+ };
309
+ });
310
+ },
311
+ get waiting() {
312
+ return this.players.filter((one) => !one.host && !one.ready && one.person.connected).map((one) => one.person);
313
+ },
314
+ get mine() {
315
+ return {
316
+ pick: mine.pick,
317
+ ready: mine.ready
318
+ };
319
+ },
320
+ get settings() {
321
+ return settings;
322
+ },
323
+ pick(pick) {
324
+ mine.pick = pick;
325
+ sendPick();
326
+ },
327
+ ready(ready) {
328
+ mine.ready = ready;
329
+ sendPick();
330
+ },
331
+ configure(change) {
332
+ if (!hosting()) return false;
333
+ settings = typeof settings === "object" && settings !== null && !Array.isArray(settings) ? {
334
+ ...settings,
335
+ ...change
336
+ } : change;
337
+ sendSettings();
338
+ return true;
339
+ },
340
+ start(config, seats) {
341
+ if (!hosting()) return false;
342
+ const ids = (seats ?? match.players.map((one) => one.id)).slice(0, MOST_SEATS);
343
+ const out = new ByteWriter().byte(246).byte(START).uint(Math.floor(Math.random() * 2147483647)).uint(Math.floor(Math.random() * 4294967296)).uint(ids.length);
344
+ for (const id of ids) {
345
+ const pick = id === match.me?.id ? mine.pick : picks.get(id)?.pick ?? options.pick.initial;
346
+ const locals = match.players.find((one) => one.id === id)?.locals ?? 1;
347
+ out.string(id).uint(Math.max(1, locals)).bytes(encode(options.pick, pick));
348
+ }
349
+ options.config.write(out, config);
350
+ match.send(out.finish());
351
+ return true;
352
+ },
353
+ close() {
354
+ stopMatch?.();
355
+ stopMatch = null;
356
+ stop();
357
+ }
358
+ };
359
+ }
360
+ //#endregion
361
+ export { SESSION_TAG, isSessionMessage, session };