onelobby 0.3.1 → 0.5.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.
@@ -1,4 +1,4 @@
1
- /*! onelobby 0.3.1 | MIT license */
1
+ /*! onelobby 0.5.0 | MIT license */
2
2
  "use strict";
3
3
  var OneLobby = (() => {
4
4
  var __defProp = Object.defineProperty;
@@ -22,9 +22,11 @@ var OneLobby = (() => {
22
22
  // src/global.ts
23
23
  var global_exports = {};
24
24
  __export(global_exports, {
25
+ BlueprintError: () => BlueprintError,
25
26
  CHAT_MAX_BYTES: () => CHAT_MAX_BYTES,
26
27
  Chat: () => Chat,
27
28
  DEFAULT_READY_TIMEOUT_MS: () => DEFAULT_READY_TIMEOUT_MS,
29
+ ErrorCodes: () => ErrorCodes,
28
30
  Keepalive: () => Keepalive,
29
31
  KeepaliveError: () => KeepaliveError,
30
32
  LIB_NAME: () => LIB_NAME,
@@ -41,12 +43,14 @@ var OneLobby = (() => {
41
43
  closeCodeFor: () => closeCodeFor,
42
44
  codeFromUrl: () => codeFromUrl,
43
45
  decodeJoinCode: () => decodeJoinCode,
46
+ defineBlueprint: () => defineBlueprint,
44
47
  defineLobbyElement: () => defineLobbyElement,
45
48
  describeError: () => describeError,
46
49
  formatCode: () => formatCode,
47
50
  lobbyServerUrl: () => lobbyServerUrl,
48
51
  looksLikeJoinCode: () => looksLikeJoinCode,
49
52
  normalizeCode: () => normalizeCode,
53
+ parseBlueprint: () => parseBlueprint,
50
54
  shareUrl: () => shareUrl,
51
55
  webCodecsOpusAvailable: () => webCodecsOpusAvailable,
52
56
  webRtcLinkFactoryFor: () => webRtcLinkFactoryFor
@@ -629,13 +633,17 @@ var OneLobby = (() => {
629
633
  }
630
634
  return out;
631
635
  }
636
+ var MAX_PARTIAL_SETS = 8;
632
637
  var Reassembler = class {
633
638
  parts = /* @__PURE__ */ new Map();
639
+ // insertion order = oldest first
634
640
  /** Returns the full payload when the last chunk arrives, otherwise null. */
635
641
  push(chunk) {
636
642
  if (chunk.count === 0 || chunk.index >= chunk.count) return null;
637
643
  let e = this.parts.get(chunk.id);
638
644
  if (!e || e.count !== chunk.count) {
645
+ this.parts.delete(chunk.id);
646
+ while (this.parts.size >= MAX_PARTIAL_SETS) this.parts.delete(this.parts.keys().next().value);
639
647
  e = { count: chunk.count, got: 0, parts: new Array(chunk.count), bytes: 0 };
640
648
  this.parts.set(chunk.id, e);
641
649
  }
@@ -647,7 +655,15 @@ var OneLobby = (() => {
647
655
  this.parts.delete(chunk.id);
648
656
  throw new CodecError("too_long", `chunk set over ${MAX_REASSEMBLED_BYTES} bytes`);
649
657
  }
650
- if (e.got < e.count) return null;
658
+ if (e.got < e.count) {
659
+ while (this.pendingBytes > MAX_REASSEMBLED_BYTES && this.parts.size > 1) {
660
+ for (const id of this.parts.keys()) if (id !== chunk.id) {
661
+ this.parts.delete(id);
662
+ break;
663
+ }
664
+ }
665
+ return null;
666
+ }
651
667
  this.parts.delete(chunk.id);
652
668
  const out = new Uint8Array(e.bytes);
653
669
  let pos = 0;
@@ -660,6 +676,12 @@ var OneLobby = (() => {
660
676
  get pending() {
661
677
  return this.parts.size;
662
678
  }
679
+ /** Bytes held across incomplete sets. */
680
+ get pendingBytes() {
681
+ let n = 0;
682
+ for (const p of this.parts.values()) n += p.bytes;
683
+ return n;
684
+ }
663
685
  clear() {
664
686
  this.parts.clear();
665
687
  }
@@ -669,9 +691,10 @@ var OneLobby = (() => {
669
691
  function deflateAvailable() {
670
692
  return typeof CompressionStream !== "undefined" && typeof DecompressionStream !== "undefined";
671
693
  }
672
- async function pipe(bytes, stream) {
694
+ async function pipe(bytes, stream, maxBytes = Infinity) {
673
695
  const w = stream.writable.getWriter();
674
- void w.write(bytes).then(() => w.close());
696
+ void w.write(bytes).then(() => w.close()).catch(() => {
697
+ });
675
698
  const chunks = [];
676
699
  const r = stream.readable.getReader();
677
700
  let total = 0;
@@ -680,6 +703,11 @@ var OneLobby = (() => {
680
703
  if (done) break;
681
704
  chunks.push(value);
682
705
  total += value.length;
706
+ if (total > maxBytes) {
707
+ void r.cancel().catch(() => {
708
+ });
709
+ throw new CodecError("too_long", `snapshot inflates to more than ${maxBytes} bytes`);
710
+ }
683
711
  }
684
712
  const out = new Uint8Array(total);
685
713
  let pos = 0;
@@ -692,8 +720,8 @@ var OneLobby = (() => {
692
720
  function deflate(bytes) {
693
721
  return pipe(bytes, new CompressionStream("deflate"));
694
722
  }
695
- function inflate(bytes) {
696
- return pipe(bytes, new DecompressionStream("deflate"));
723
+ function inflate(bytes, maxBytes = MAX_REASSEMBLED_BYTES) {
724
+ return pipe(bytes, new DecompressionStream("deflate"), maxBytes);
697
725
  }
698
726
 
699
727
  // src/lockstep.ts
@@ -701,6 +729,7 @@ var OneLobby = (() => {
701
729
  var MAX_RESTORE_RETRIES = 3;
702
730
  var HOST_SILENCE_MS = 3e3;
703
731
  var PEER_HASH_SILENCE_MS = 1e4;
732
+ var MAX_PENDING_PER_PEER = 256;
704
733
  var SNAPSHOT_COMPRESS_MIN = 1024;
705
734
  var Lockstep = class extends Emitter {
706
735
  constructor(hub, opts) {
@@ -732,6 +761,7 @@ var OneLobby = (() => {
732
761
  hosting = false;
733
762
  _started = false;
734
763
  pending = [];
764
+ _droppedCommands = 0;
735
765
  ready = /* @__PURE__ */ new Set();
736
766
  snapshotting = /* @__PURE__ */ new Set();
737
767
  cancelTicker = null;
@@ -746,6 +776,10 @@ var OneLobby = (() => {
746
776
  // joiner side
747
777
  pendingSnapshot = null;
748
778
  // ---- public game-facing surface -------------------------------------------------------------------------------
779
+ /** Remote commands the host dropped because that peer already had MAX_PENDING_PER_PEER waiting. */
780
+ get droppedCommands() {
781
+ return this._droppedCommands;
782
+ }
749
783
  get started() {
750
784
  return this._started;
751
785
  }
@@ -891,7 +925,12 @@ var OneLobby = (() => {
891
925
  handle(from, msg) {
892
926
  switch (msg.type) {
893
927
  case "cmd":
894
- if (this.hosting) this.pending.push({ peerId: from, bytes: msg.bytes });
928
+ if (this.hosting) {
929
+ let waiting = 0;
930
+ for (const c of this.pending) if (c.peerId === from) waiting++;
931
+ if (waiting >= MAX_PENDING_PER_PEER) this._droppedCommands++;
932
+ else this.pending.push({ peerId: from, bytes: msg.bytes });
933
+ }
895
934
  break;
896
935
  case "frame":
897
936
  if (!this.hosting) this.onFrame(msg.tick, msg.commands);
@@ -2978,9 +3017,243 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2978
3017
  return { host, port, isIPv6: v6, display, url: `ws://${display}/`, candidate: `candidate:1 1 UDP 2122317823 ${host} ${port} typ host` };
2979
3018
  }
2980
3019
 
3020
+ // src/blueprint.ts
3021
+ function defineBlueprint(x) {
3022
+ return x;
3023
+ }
3024
+ var BlueprintError = class extends OneLobbyError {
3025
+ constructor(path, rule, detail) {
3026
+ super("bad_config", `onelobby.json: ${path ? path + ": " : ""}${detail}`);
3027
+ this.path = path;
3028
+ this.rule = rule;
3029
+ }
3030
+ path;
3031
+ rule;
3032
+ };
3033
+ var obj = (props, req) => ({ k: "obj", props, req });
3034
+ var bool = { k: "bool" };
3035
+ var str = (o = {}) => ({ k: "str", ...o });
3036
+ var int = (min, max, zeroOr) => ({ k: "int", min, max, zeroOr });
3037
+ var en = (...vals) => ({ k: "enum", vals });
3038
+ var ROOT = obj(
3039
+ {
3040
+ version: { k: "const", val: 1 },
3041
+ game: obj(
3042
+ { id: str({ pattern: /^[A-Za-z0-9._-]{1,64}$/ }), netVersion: str({ min: 1, max: 32 }), title: str({ max: 64 }) },
3043
+ ["id"]
3044
+ ),
3045
+ engine: en("web", "node-browser", "godot-csharp", "unity", "dotnet"),
3046
+ hosting: obj({
3047
+ direct: bool,
3048
+ server: bool,
3049
+ lobbyServer: str(),
3050
+ directPort: int(1024, 65535),
3051
+ openPort: bool,
3052
+ relayOnly: bool,
3053
+ allowedOrigins: { k: "strarr" }
3054
+ }),
3055
+ lobby: obj({
3056
+ maxPlayers: int(2, 64),
3057
+ listed: bool,
3058
+ approveJoins: bool,
3059
+ password: bool,
3060
+ lateJoin: bool,
3061
+ rejoin: bool,
3062
+ joinLink: bool,
3063
+ meta: { k: "meta" }
3064
+ }),
3065
+ sync: obj({ mode: en("raw", "lockstep", "replication"), tickRate: int(1, 120), hashEveryTicks: int(1, 1e5) }),
3066
+ players: obj({ identity: en("name", "anonymous", "platform"), maxNameLength: int(1, 24) }),
3067
+ chat: obj({ text: bool, voice: bool, voiceMode: en("pushToTalk", "voiceActivity") }),
3068
+ session: obj({
3069
+ hostLeaves: en("end", "migrate"),
3070
+ pause: en("off", "host", "anyone"),
3071
+ readyTimeoutMs: int(0, 6e5),
3072
+ peerTimeoutMs: int(6e3, 6e5, true),
3073
+ rateLimit: { k: "rate" },
3074
+ runInBackground: bool
3075
+ }),
3076
+ platforms: obj({ steam: { k: "steam" }, epic: { k: "epic" }, discord: bool }),
3077
+ ui: obj({ kind: en("drop-in", "generated", "none"), entry: str(), theme: en("light", "dark", "auto") })
3078
+ },
3079
+ ["game"]
3080
+ );
3081
+ var IGNORED = /* @__PURE__ */ new Set(["$schema", "$comment"]);
3082
+ var Fail = class {
3083
+ constructor(path, rule, detail) {
3084
+ this.path = path;
3085
+ this.rule = rule;
3086
+ this.detail = detail;
3087
+ }
3088
+ path;
3089
+ rule;
3090
+ detail;
3091
+ };
3092
+ var join = (p, k) => p ? `${p}.${k}` : String(k);
3093
+ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3094
+ function check(n, v, path) {
3095
+ switch (n.k) {
3096
+ case "obj": {
3097
+ if (!isObj(v)) throw new Fail(path, "type", "must be an object");
3098
+ for (const r of n.req ?? []) if (!(r in v)) throw new Fail(join(path, r), "required", "is required");
3099
+ for (const k of Object.keys(v)) if (!IGNORED.has(k) && !(k in n.props)) throw new Fail(join(path, k), "unknown_key", "unknown key");
3100
+ for (const [k, sub] of Object.entries(n.props)) if (k in v) check(sub, v[k], join(path, k));
3101
+ return;
3102
+ }
3103
+ case "str": {
3104
+ if (typeof v !== "string") throw new Fail(path, "type", "must be a string");
3105
+ if (n.min !== void 0 && v.length < n.min || n.max !== void 0 && v.length > n.max)
3106
+ throw new Fail(path, "length", `must be ${n.min ?? 0} to ${n.max ?? "any"} characters`);
3107
+ if (n.pattern && !n.pattern.test(v)) throw new Fail(path, "pattern", `must match ${n.pattern.source}`);
3108
+ return;
3109
+ }
3110
+ case "int": {
3111
+ if (typeof v !== "number" || !Number.isInteger(v)) throw new Fail(path, "type", "must be an integer");
3112
+ if (n.zeroOr && v === 0) return;
3113
+ if (n.min !== void 0 && v < n.min || n.max !== void 0 && v > n.max)
3114
+ throw new Fail(path, "range", `must be ${n.zeroOr ? "0 or " : ""}${n.min ?? "any"} to ${n.max ?? "any"}`);
3115
+ return;
3116
+ }
3117
+ case "bool":
3118
+ if (typeof v !== "boolean") throw new Fail(path, "type", "must be true or false");
3119
+ return;
3120
+ case "enum":
3121
+ if (typeof v !== "string" || !n.vals.includes(v)) throw new Fail(path, "enum", `must be one of ${n.vals.join(", ")}`);
3122
+ return;
3123
+ case "const":
3124
+ if (v !== n.val) throw new Fail(path, "const", `must be ${n.val}`);
3125
+ return;
3126
+ case "strarr": {
3127
+ if (!Array.isArray(v)) throw new Fail(path, "type", "must be an array of strings");
3128
+ v.forEach((x, i) => {
3129
+ if (typeof x !== "string") throw new Fail(join(path, i), "type", "must be a string");
3130
+ });
3131
+ return;
3132
+ }
3133
+ case "meta": {
3134
+ if (!isObj(v)) throw new Fail(path, "type", "must be an object of strings");
3135
+ const keys = Object.keys(v);
3136
+ if (keys.length > 16) throw new Fail(path, "length", "at most 16 keys");
3137
+ for (const k of keys) {
3138
+ if (k.length > 64) throw new Fail(join(path, k), "length", "key is longer than 64 characters");
3139
+ const x = v[k];
3140
+ if (typeof x !== "string") throw new Fail(join(path, k), "type", "must be a string");
3141
+ if (x.length > 64) throw new Fail(join(path, k), "length", "value is longer than 64 characters");
3142
+ }
3143
+ return;
3144
+ }
3145
+ case "rate":
3146
+ check(obj({ messages: int(1), perMs: int(1) }, ["messages", "perMs"]), v, path);
3147
+ return;
3148
+ case "steam":
3149
+ if (typeof v === "boolean") return;
3150
+ if (!isObj(v)) throw new Fail(path, "type", "must be true, false or { appId }");
3151
+ check(obj({ appId: int(1) }), v, path);
3152
+ return;
3153
+ case "epic": {
3154
+ if (typeof v === "boolean") return;
3155
+ if (!isObj(v)) throw new Fail(path, "type", "must be true, false or { productId }");
3156
+ for (const k of Object.keys(v)) if (!IGNORED.has(k) && typeof v[k] !== "string") throw new Fail(join(path, k), "type", "must be a string");
3157
+ return;
3158
+ }
3159
+ }
3160
+ }
3161
+ var sect = (x) => isObj(x) ? x : {};
3162
+ function strip(o) {
3163
+ if (Array.isArray(o)) return o.map(strip);
3164
+ if (!isObj(o)) return o;
3165
+ const out = {};
3166
+ for (const [k, v] of Object.entries(o)) if (k !== "$comment" && k !== "$schema") out[k] = strip(v);
3167
+ return out;
3168
+ }
3169
+ function deepFreeze(o) {
3170
+ if (typeof o === "object" && o !== null && !Object.isFrozen(o)) {
3171
+ Object.freeze(o);
3172
+ for (const v of Object.values(o)) deepFreeze(v);
3173
+ }
3174
+ return o;
3175
+ }
3176
+ function fill(input) {
3177
+ const game = sect(input.game);
3178
+ const engine = input.engine;
3179
+ const hosting = sect(input.hosting);
3180
+ const lobby = sect(input.lobby);
3181
+ const sync = sect(input.sync);
3182
+ const players = sect(input.players);
3183
+ const chat = sect(input.chat);
3184
+ const session = sect(input.session);
3185
+ const platforms = sect(input.platforms);
3186
+ const ui = sect(input.ui);
3187
+ const tickRate = sync.tickRate ?? 20;
3188
+ const web = engine === "web";
3189
+ const out = {
3190
+ version: 1,
3191
+ game: { id: game.id, netVersion: game.netVersion ?? "1", ...game.title !== void 0 ? { title: game.title } : {} },
3192
+ ...engine !== void 0 ? { engine } : {},
3193
+ hosting: {
3194
+ direct: hosting.direct ?? !web,
3195
+ server: hosting.server ?? web,
3196
+ lobbyServer: hosting.lobbyServer ?? "",
3197
+ directPort: hosting.directPort ?? 47810,
3198
+ openPort: hosting.openPort ?? true,
3199
+ relayOnly: hosting.relayOnly ?? false,
3200
+ allowedOrigins: hosting.allowedOrigins ?? []
3201
+ },
3202
+ lobby: {
3203
+ maxPlayers: lobby.maxPlayers ?? 4,
3204
+ listed: lobby.listed ?? false,
3205
+ approveJoins: lobby.approveJoins ?? false,
3206
+ password: lobby.password ?? false,
3207
+ lateJoin: lobby.lateJoin ?? true,
3208
+ rejoin: lobby.rejoin ?? true,
3209
+ joinLink: lobby.joinLink ?? (web || engine === "node-browser"),
3210
+ meta: lobby.meta ?? {}
3211
+ },
3212
+ sync: { mode: sync.mode ?? "raw", tickRate, hashEveryTicks: sync.hashEveryTicks ?? tickRate },
3213
+ players: { identity: players.identity ?? "name", maxNameLength: players.maxNameLength ?? 24 },
3214
+ chat: { text: chat.text ?? true, voice: chat.voice ?? false, voiceMode: chat.voiceMode ?? "pushToTalk" },
3215
+ session: {
3216
+ hostLeaves: session.hostLeaves ?? "end",
3217
+ pause: session.pause ?? "off",
3218
+ readyTimeoutMs: session.readyTimeoutMs ?? 15e3,
3219
+ peerTimeoutMs: session.peerTimeoutMs ?? 0,
3220
+ ...session.rateLimit !== void 0 ? { rateLimit: session.rateLimit } : {},
3221
+ runInBackground: session.runInBackground ?? true
3222
+ },
3223
+ platforms: { steam: platforms.steam ?? false, epic: platforms.epic ?? false, discord: platforms.discord ?? false },
3224
+ ui: {
3225
+ kind: ui.kind ?? (web ? "drop-in" : "generated"),
3226
+ ...ui.entry !== void 0 ? { entry: ui.entry } : {},
3227
+ theme: ui.theme ?? "auto"
3228
+ }
3229
+ };
3230
+ return out;
3231
+ }
3232
+ function crossRules(b) {
3233
+ if (!b.hosting.direct && !b.hosting.server) throw new Fail("hosting", "hosting_none", "at least one of direct, server must be true");
3234
+ if (b.lobby.listed && b.lobby.approveJoins) throw new Fail("lobby", "listed_and_approve", "listed and approveJoins cannot both be true");
3235
+ if (b.engine === "web" && b.hosting.direct) throw new Fail("hosting.direct", "direct_needs_listener", "engine web cannot host direct (a browser cannot listen on a port)");
3236
+ const csharp = b.engine === "godot-csharp" || b.engine === "unity" || b.engine === "dotnet";
3237
+ if (b.sync.mode === "replication" && !csharp) throw new Fail("sync.mode", "replication_needs_csharp", "replication needs engine godot-csharp, unity or dotnet");
3238
+ if (b.sync.mode === "replication" && b.session.hostLeaves === "migrate") throw new Fail("session.hostLeaves", "migrate_with_replication", "replication has no host migration; use end");
3239
+ if (b.session.hostLeaves === "migrate" && !b.hosting.server) throw new Fail("session.hostLeaves", "migrate_needs_server", "migrate needs hosting.server (direct lobbies always end)");
3240
+ }
3241
+ function parseBlueprint(json) {
3242
+ let b;
3243
+ try {
3244
+ check(ROOT, json, "");
3245
+ b = fill(strip(json));
3246
+ crossRules(b);
3247
+ } catch (e) {
3248
+ if (e instanceof Fail) throw new BlueprintError(e.path, e.rule, e.detail);
3249
+ throw e;
3250
+ }
3251
+ return deepFreeze(b);
3252
+ }
3253
+
2981
3254
  // src/onelobby.ts
2982
3255
  var LIB_NAME = "onelobby";
2983
- var LIB_VERSION = "0.3.1";
3256
+ var LIB_VERSION = "0.5.0";
2984
3257
  var DEFAULT_READY_TIMEOUT_MS = 15e3;
2985
3258
  function hasMic() {
2986
3259
  return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof AudioWorkletNode !== "undefined";
@@ -2998,7 +3271,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
2998
3271
  for (const l of liveSeats) if (l.resumeToken === token) return true;
2999
3272
  return false;
3000
3273
  }
3001
- var OneLobby = class extends Emitter {
3274
+ var OneLobby = class _OneLobby extends Emitter {
3002
3275
  options;
3003
3276
  socketFactory;
3004
3277
  linkFactory;
@@ -3008,6 +3281,53 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3008
3281
  createMic;
3009
3282
  createSink;
3010
3283
  storage;
3284
+ /** The validated, frozen onelobby.json this kit was built from (`OneLobby.fromConfig`); undefined for `new OneLobby`. */
3285
+ blueprint;
3286
+ /**
3287
+ * Build a kit from an onelobby.json (already parsed; docs/design/blueprint.md s.7). Throws `bad_config` for an
3288
+ * invalid file or name, `identity_anonymous` for a name when `players.identity` is "anonymous". Then `host()` and
3289
+ * `hostDirect()` default their options from the file and the file's chat/listing switches are enforced.
3290
+ */
3291
+ static fromConfig(json, overrides = {}) {
3292
+ const bp = parseBlueprint(json);
3293
+ const name = (overrides.displayName ?? "").trim();
3294
+ if (name) {
3295
+ if (bp.players.identity === "anonymous") throw new OneLobbyError("identity_anonymous", "this game uses anonymous players: no display names");
3296
+ if (name.length > bp.players.maxNameLength)
3297
+ throw new BlueprintError("displayName", "length", `must be at most ${bp.players.maxNameLength} characters`);
3298
+ }
3299
+ const kit = new _OneLobby({
3300
+ gameId: bp.game.id,
3301
+ netVersion: bp.game.netVersion,
3302
+ lobbyServer: overrides.lobbyServer ?? bp.hosting.lobbyServer,
3303
+ mode: bp.sync.mode === "lockstep" ? "lockstep" : "raw",
3304
+ // replication is C# only; JS speaks raw
3305
+ tickRate: bp.sync.tickRate,
3306
+ hashEveryTicks: bp.sync.hashEveryTicks,
3307
+ displayName: name,
3308
+ relayOnly: bp.hosting.relayOnly,
3309
+ readyTimeoutMs: bp.session.readyTimeoutMs,
3310
+ peerTimeoutMs: bp.session.peerTimeoutMs,
3311
+ rememberSeat: overrides.rememberSeat ?? bp.lobby.rejoin,
3312
+ advanced: overrides.advanced
3313
+ });
3314
+ kit.blueprint = bp;
3315
+ return kit;
3316
+ }
3317
+ /** @internal Host options the blueprint implies (empty without one). */
3318
+ blueprintHostDefaults() {
3319
+ const bp = this.blueprint;
3320
+ if (!bp) return {};
3321
+ const name = this.options.displayName;
3322
+ return {
3323
+ name: name ? `${name}'s game` : "Game",
3324
+ maxPlayers: bp.lobby.maxPlayers,
3325
+ visibility: bp.lobby.listed ? "public" : bp.lobby.approveJoins ? "private" : "unlisted",
3326
+ lateJoin: bp.lobby.lateJoin,
3327
+ meta: { ...bp.lobby.meta },
3328
+ ...bp.session.rateLimit ? { rateLimit: { ...bp.session.rateLimit } } : {}
3329
+ };
3330
+ }
3011
3331
  constructor(o) {
3012
3332
  super();
3013
3333
  const tickRate = o.tickRate ?? 20;
@@ -3105,6 +3425,18 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3105
3425
  };
3106
3426
  }
3107
3427
  async connect(url = this.options.lobbyServer, factory = this.socketFactory, timeoutMs = 0) {
3428
+ const sig = await this.open(url, factory, timeoutMs);
3429
+ try {
3430
+ const welcome = await sig.request(this.helloMsg(), "welcome");
3431
+ return { sig, welcome };
3432
+ } catch (e) {
3433
+ sig.close();
3434
+ throw e;
3435
+ }
3436
+ }
3437
+ /** The signaling socket only (no hello yet); with a timeout (direct IP), no answer is `direct_unreachable`. */
3438
+ async open(url, factory, timeoutMs) {
3439
+ if (!url.trim() && factory === defaultSocketFactory) throw new OneLobbyError("no_server", "no lobby server address (set hosting.lobbyServer or pass lobbyServer)");
3108
3440
  const sig = new SignalingClient(url, factory, this.clock);
3109
3441
  if (timeoutMs > 0) {
3110
3442
  let cancel = () => {
@@ -3122,13 +3454,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3122
3454
  cancel();
3123
3455
  }
3124
3456
  } else await sig.connect();
3125
- try {
3126
- const welcome = await sig.request(this.helloMsg(), "welcome");
3127
- return { sig, welcome };
3128
- } catch (e) {
3129
- sig.close();
3130
- throw e;
3131
- }
3457
+ return sig;
3132
3458
  }
3133
3459
  makeLobby(sig, welcome, state, direct, rateLimit) {
3134
3460
  const lobby = new Lobby({
@@ -3148,6 +3474,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3148
3474
  rateLimit
3149
3475
  });
3150
3476
  lobby.errorSink = (e) => this.emit("error", e);
3477
+ if (this.blueprint) guardLobby(lobby, this.blueprint);
3151
3478
  if (this.storage && !direct) {
3152
3479
  liveSeats.add(lobby);
3153
3480
  this.saveSeat(lobby.code, lobby.resumeToken);
@@ -3159,7 +3486,9 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3159
3486
  }
3160
3487
  return lobby;
3161
3488
  }
3162
- async host(h) {
3489
+ /** With a blueprint (`fromConfig`) every option defaults from `lobby.*`; explicit options win. */
3490
+ async host(opts) {
3491
+ const h = { name: "Game", maxPlayers: 4, ...this.blueprintHostDefaults(), ...definedOnly(opts) };
3163
3492
  const { sig, welcome } = await this.connect();
3164
3493
  try {
3165
3494
  const state = await sig.request(
@@ -3210,6 +3539,30 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3210
3539
  * The address is also given to ICE as the host's UDP candidate. When the host leaves, `sessionEnded("host_left")`.
3211
3540
  */
3212
3541
  async joinDirect(codeOrAddress, opts = {}) {
3542
+ const { addr, links } = this.directTarget(codeOrAddress, opts);
3543
+ const { sig, welcome } = await this.connect(addr.url, this.socketFactory, opts.connectTimeoutMs ?? 5e3);
3544
+ try {
3545
+ const state = await sig.request({ t: "join", ...opts.password ? { password: opts.password } : {} }, "lobby");
3546
+ const lobby = this.makeLobby(sig, welcome, state, { links });
3547
+ lobby.joinCode = joinedWith(addr);
3548
+ return lobby;
3549
+ } catch (e) {
3550
+ sig.close();
3551
+ throw e;
3552
+ }
3553
+ }
3554
+ /**
3555
+ * Take a direct-IP seat back after a drop or a restart: the code (or address) joined before and that lobby's
3556
+ * `resumeToken` (save it after every join; it changes on each resume). The host keeps a dropped seat for 60 s and the
3557
+ * same peer id comes back. Rejects with `invalid_token` when the seat is gone: offer `joinDirect` then.
3558
+ */
3559
+ async rejoinDirect(codeOrAddress, resumeToken, opts = {}) {
3560
+ const { addr, links } = this.directTarget(codeOrAddress, opts);
3561
+ const lobby = await this.resumeAt(resumeToken, addr.url, opts.connectTimeoutMs ?? 5e3, { links });
3562
+ lobby.joinCode = joinedWith(addr);
3563
+ return lobby;
3564
+ }
3565
+ directTarget(codeOrAddress, opts) {
3213
3566
  let input = codeOrAddress.trim();
3214
3567
  if (looksLikeJoinCode(input)) {
3215
3568
  const d = decodeJoinCode(input);
@@ -3221,16 +3574,7 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3221
3574
  if (!addr) throw new OneLobbyError("bad_address", `"${codeOrAddress}" is not a join code or an address (try 6B01-RGFE-P2B or 192.168.1.20:47810)`);
3222
3575
  const inner = this.linkFactory;
3223
3576
  const links = { create: (o) => inner.create({ ...o, iceServers: [], extraRemoteCandidates: [addr.candidate] }) };
3224
- const { sig, welcome } = await this.connect(addr.url, this.socketFactory, opts.connectTimeoutMs ?? 5e3);
3225
- try {
3226
- const state = await sig.request({ t: "join", ...opts.password ? { password: opts.password } : {} }, "lobby");
3227
- const lobby = this.makeLobby(sig, welcome, state, { links });
3228
- lobby.joinCode = joinedWith(addr);
3229
- return lobby;
3230
- } catch (e) {
3231
- sig.close();
3232
- throw e;
3233
- }
3577
+ return { addr, links };
3234
3578
  }
3235
3579
  /** @internal onelobby/direct's host: create the one lobby on the embedded hub, in direct mode. */
3236
3580
  async hostDirectWith(h, hooks) {
@@ -3256,8 +3600,10 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3256
3600
  }
3257
3601
  }
3258
3602
  async rejoin(resumeToken) {
3259
- const sig = new SignalingClient(this.options.lobbyServer, this.socketFactory, this.clock);
3260
- await sig.connect();
3603
+ return this.resumeAt(resumeToken, this.options.lobbyServer, 0);
3604
+ }
3605
+ async resumeAt(resumeToken, url, timeoutMs, direct) {
3606
+ const sig = await this.open(url, this.socketFactory, timeoutMs);
3261
3607
  try {
3262
3608
  const lobbyP = sig.expect("lobby");
3263
3609
  const welcomeP = sig.request(this.helloMsg(resumeToken), "welcome");
@@ -3266,13 +3612,14 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3266
3612
  const welcome = await welcomeP;
3267
3613
  if (welcome.peerId === void 0) throw new OneLobbyError("invalid_token", "resume token unknown or expired");
3268
3614
  const state = await lobbyP;
3269
- return this.makeLobby(sig, welcome, state);
3615
+ return this.makeLobby(sig, welcome, state, direct);
3270
3616
  } catch (e) {
3271
3617
  sig.close();
3272
3618
  throw e;
3273
3619
  }
3274
3620
  }
3275
3621
  async list(opts = {}) {
3622
+ if (this.blueprint && !this.blueprint.hosting.server) throw new OneLobbyError("listing_disabled", "this game has no lobby server (hosting.server is false)");
3276
3623
  const { sig } = await this.connect();
3277
3624
  try {
3278
3625
  const res = await sig.request(
@@ -3293,8 +3640,70 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3293
3640
  return addr.display;
3294
3641
  }
3295
3642
  }
3643
+ function definedOnly(o) {
3644
+ const out = {};
3645
+ if (o) {
3646
+ for (const [k, v] of Object.entries(o)) if (v !== void 0) out[k] = v;
3647
+ }
3648
+ return out;
3649
+ }
3650
+ function guardLobby(lobby, bp) {
3651
+ lobby.voice.mode = bp.chat.voiceMode;
3652
+ if (!bp.chat.voice) {
3653
+ const voice = lobby.voice;
3654
+ const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(voice), "enabled");
3655
+ if (d?.get && d.set) {
3656
+ Object.defineProperty(voice, "enabled", {
3657
+ configurable: true,
3658
+ get: () => d.get.call(voice),
3659
+ set: (v) => {
3660
+ if (v) throw new OneLobbyError("voice_disabled", "this game has voice chat turned off (chat.voice is false)");
3661
+ d.set.call(voice, v);
3662
+ }
3663
+ });
3664
+ }
3665
+ }
3666
+ if (!bp.chat.text) {
3667
+ lobby.chat.send = () => {
3668
+ throw new OneLobbyError("chat_disabled", "this game has text chat turned off (chat.text is false)");
3669
+ };
3670
+ }
3671
+ }
3296
3672
 
3297
3673
  // src/friendly.ts
3674
+ var ErrorCodes = {
3675
+ // from the lobby server or a direct host's hub
3676
+ UnknownCode: "unknown_code",
3677
+ BadCode: "bad_code",
3678
+ LobbyFull: "lobby_full",
3679
+ VersionMismatch: "version_mismatch",
3680
+ InGame: "in_game",
3681
+ Kicked: "kicked",
3682
+ BadPassword: "bad_password",
3683
+ NotHost: "not_host",
3684
+ RateLimited: "rate_limited",
3685
+ ServerFull: "server_full",
3686
+ GameNotAllowed: "game_not_allowed",
3687
+ BadRequest: "bad_request",
3688
+ // from this client
3689
+ ServerClosed: "server_closed",
3690
+ IceFailed: "ice_failed",
3691
+ Timeout: "timeout",
3692
+ DirectUnreachable: "direct_unreachable",
3693
+ BadAddress: "bad_address",
3694
+ PortInUse: "port_in_use",
3695
+ ConnectFailed: "connect_failed",
3696
+ Closed: "closed",
3697
+ ServerLost: "server_lost",
3698
+ InvalidToken: "invalid_token",
3699
+ NoServer: "no_server",
3700
+ // from a blueprint (onelobby.json)
3701
+ BadConfig: "bad_config",
3702
+ VoiceDisabled: "voice_disabled",
3703
+ ChatDisabled: "chat_disabled",
3704
+ ListingDisabled: "listing_disabled",
3705
+ IdentityAnonymous: "identity_anonymous"
3706
+ };
3298
3707
  var TEXT = {
3299
3708
  // server errors (spec/signaling.md)
3300
3709
  unknown_code: { message: "We couldn't find a lobby with that code.", hint: "Check the code with the host. Codes stop working once everyone leaves." },
@@ -3316,7 +3725,13 @@ registerProcessor('onelobby-capture', OneLobbyCapture);
3316
3725
  server_lost: { message: "Lost the connection to the game server.", hint: "Your game may keep going; new players can't join until it's back." },
3317
3726
  invalid_token: { message: "Your old seat in that lobby is no longer available.", hint: "Join again with the lobby code." },
3318
3727
  ice_failed: { message: "Couldn't connect to the other players.", hint: "Some networks (school, work, public Wi-Fi) block game connections. Try another network or a phone hotspot." },
3728
+ no_server: { message: "This game has no lobby server address yet.", hint: "Enter one in the multiplayer settings, or host from your own computer with a join code." },
3319
3729
  timeout: { message: "The connection took too long.", hint: "Check your internet connection, then try again." },
3730
+ voice_disabled: { message: "Voice chat is turned off in this game." },
3731
+ chat_disabled: { message: "Text chat is turned off in this game." },
3732
+ listing_disabled: { message: "This game has no public lobby list.", hint: "Join with a code or a link from the host." },
3733
+ identity_anonymous: { message: "This game doesn't use player names.", hint: "Everyone shows up as Player 1, Player 2, and so on." },
3734
+ bad_config: { message: "The game's multiplayer settings file is not valid.", hint: "Let the game's owner know; the details are in the error message." },
3320
3735
  left: { message: "You left the lobby." },
3321
3736
  unknown: { message: "Something went wrong.", hint: "Try again. If it keeps happening, reload the page." }
3322
3737
  };