@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/LICENSE +21 -0
- package/README.md +197 -2
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/lockstep.d.ts +252 -0
- package/lockstep.js +672 -0
- package/package.json +47 -4
- package/session.d.ts +226 -0
- package/session.js +361 -0
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hopple/netcode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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 };
|