cubyz-node-client 1.1.0 → 1.3.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.
@@ -2,15 +2,18 @@ import { Buffer } from "node:buffer";
2
2
  import { randomInt } from "node:crypto";
3
3
  import dgram from "node:dgram";
4
4
  import { EventEmitter } from "node:events";
5
- import { readInt32BE, writeInt32BE } from "./binary.js";
5
+ import { loadOrCreateIdentity, signEd25519, signMlDsa44, signP256, } from "./authentication.js";
6
+ import { decodeVarInt, readInt32BE, writeInt32BE } from "./binary.js";
6
7
  import { prepareChatMessage } from "./chatFormat.js";
7
- import { DEG_TO_RAD, LOG_LEVEL_ORDER, } from "./connectionTypes.js";
8
+ import { DEG_TO_RAD, GENERIC_UPDATE_TYPE, LOG_LEVEL_ORDER, WORLD_EDIT_POSITION, } from "./connectionTypes.js";
8
9
  import { AWAITING_SERVER_TIMEOUT_MS, CHANNEL, CONFIRMATION_BATCH_SIZE, DEFAULT_VERSION, HANDSHAKE_STATE, INIT_RESEND_INTERVAL_MS, KEEP_ALIVE_INTERVAL_MS, KEEP_ALIVE_TIMEOUT_MS, PROTOCOL, } from "./constants.js";
9
10
  import { parseEntityPositionPacket } from "./entityParser.js";
10
11
  import { buildHandshakePayload, parseHandshake, randomSequence, } from "./handshakeUtils.js";
11
12
  import { parseChannelPacket, ReceiveChannel } from "./receiveChannel.js";
13
+ import { SecureChannelHandler } from "./secureChannel.js";
12
14
  import { SendChannel } from "./sendChannel.js";
13
15
  import { parseZon } from "./zon.js";
16
+ export { GAMEMODE } from "./connectionTypes.js";
14
17
  export class CubyzConnection extends EventEmitter {
15
18
  host;
16
19
  port;
@@ -43,9 +46,11 @@ export class CubyzConnection extends EventEmitter {
43
46
  disconnectSent = false;
44
47
  disconnectEmitted = false;
45
48
  initSent = false;
46
- handshakeQueued = false;
47
49
  awaitingServerSince = null;
48
- constructor({ host, port, name, version = DEFAULT_VERSION, logger = console, logLevel = "error", }) {
50
+ identityFile;
51
+ identity = null;
52
+ secureChannel = null;
53
+ constructor({ host, port, name, version = DEFAULT_VERSION, logger = console, logLevel = "error", identityFile = "./cubyz-identity.txt", }) {
49
54
  super();
50
55
  this.host = host;
51
56
  this.port = port;
@@ -53,11 +58,12 @@ export class CubyzConnection extends EventEmitter {
53
58
  this.version = version;
54
59
  this.baseLogger = logger ?? console;
55
60
  this.logLevel = (logLevel in LOG_LEVEL_ORDER ? logLevel : "error");
61
+ this.identityFile = identityFile;
56
62
  this.socket = dgram.createSocket("udp4");
57
63
  this.connectionId = BigInt.asIntN(64, (BigInt(Date.now()) << 20n) | BigInt(randomInt(0, 0xfffff)));
58
64
  this.sendChannels = {
59
65
  [CHANNEL.LOSSY]: new SendChannel(CHANNEL.LOSSY, randomSequence()),
60
- [CHANNEL.FAST]: new SendChannel(CHANNEL.FAST, randomSequence()),
66
+ [CHANNEL.SECURE]: new SendChannel(CHANNEL.SECURE, randomSequence()),
61
67
  [CHANNEL.SLOW]: new SendChannel(CHANNEL.SLOW, randomSequence()),
62
68
  };
63
69
  this.socket.on("message", (msg) => {
@@ -113,6 +119,8 @@ export class CubyzConnection extends EventEmitter {
113
119
  return super.emit(event, ...args);
114
120
  }
115
121
  async start() {
122
+ this.identity = await loadOrCreateIdentity(this.identityFile);
123
+ this.log("info", `Loaded identity from ${this.identityFile}`);
116
124
  await new Promise((resolve, reject) => {
117
125
  const onError = (err) => {
118
126
  this.socket.off("listening", onListening);
@@ -192,6 +200,10 @@ export class CubyzConnection extends EventEmitter {
192
200
  }
193
201
  flushSendQueues(now) {
194
202
  for (const channel of Object.values(this.sendChannels)) {
203
+ // The SECURE channel is driven by SecureChannelHandler; skip normal queuing.
204
+ if (channel.channelId === CHANNEL.SECURE) {
205
+ continue;
206
+ }
195
207
  if (!channel.hasWork()) {
196
208
  continue;
197
209
  }
@@ -241,28 +253,69 @@ export class CubyzConnection extends EventEmitter {
241
253
  payload[0] = CHANNEL.INIT;
242
254
  payload.writeBigInt64BE(this.connectionId, 1);
243
255
  writeInt32BE(payload, 9, this.sendChannels[CHANNEL.LOSSY].initialSequence);
244
- writeInt32BE(payload, 13, this.sendChannels[CHANNEL.FAST].initialSequence);
256
+ writeInt32BE(payload, 13, this.sendChannels[CHANNEL.SECURE].initialSequence);
245
257
  writeInt32BE(payload, 17, this.sendChannels[CHANNEL.SLOW].initialSequence);
246
258
  this.socket.send(payload, this.port, this.host);
247
259
  this.initSent = true;
248
260
  }
249
- sendInitAck() {
261
+ sendInitAck(onSent) {
250
262
  const buffer = Buffer.alloc(1 + 8);
251
263
  buffer[0] = CHANNEL.INIT;
252
264
  buffer.writeBigInt64BE(this.connectionId, 1);
253
- this.socket.send(buffer, this.port, this.host);
265
+ this.socket.send(buffer, this.port, this.host, (err) => {
266
+ if (err) {
267
+ this.log("error", "Failed to send init ack:", err);
268
+ }
269
+ onSent?.();
270
+ });
254
271
  }
255
- ensureReceiveChannels(lossyStart, fastStart, slowStart) {
272
+ ensureReceiveChannels(lossyStart, secureStart, slowStart) {
256
273
  if (!this.receiveChannels.has(CHANNEL.LOSSY)) {
257
274
  this.receiveChannels.set(CHANNEL.LOSSY, new ReceiveChannel(CHANNEL.LOSSY, lossyStart));
258
275
  }
259
- if (!this.receiveChannels.has(CHANNEL.FAST)) {
260
- this.receiveChannels.set(CHANNEL.FAST, new ReceiveChannel(CHANNEL.FAST, fastStart));
276
+ if (!this.receiveChannels.has(CHANNEL.SECURE)) {
277
+ const secureRecv = new ReceiveChannel(CHANNEL.SECURE, secureStart);
278
+ // Route raw bytes from the SECURE channel into the TLS handler.
279
+ secureRecv.rawBytesCallback = (data) => {
280
+ this.secureChannel?.feedRawBytes(data);
281
+ };
282
+ this.receiveChannels.set(CHANNEL.SECURE, secureRecv);
261
283
  }
262
284
  if (!this.receiveChannels.has(CHANNEL.SLOW)) {
263
285
  this.receiveChannels.set(CHANNEL.SLOW, new ReceiveChannel(CHANNEL.SLOW, slowStart));
264
286
  }
265
287
  }
288
+ setupSecureChannel() {
289
+ if (this.secureChannel !== null) {
290
+ return;
291
+ }
292
+ const handler = new SecureChannelHandler({
293
+ socket: this.socket,
294
+ host: this.host,
295
+ port: this.port,
296
+ channelId: CHANNEL.SECURE,
297
+ mtu: 548,
298
+ initialSendSeq: this.sendChannels[CHANNEL.SECURE].initialSequence,
299
+ });
300
+ handler.onError = (err) => {
301
+ this.log("error", "Secure channel error:", err);
302
+ };
303
+ handler.onSecureConnect = (_verificationData) => {
304
+ this.log("debug", "TLS handshake complete, sending userData");
305
+ if (!this.identity) {
306
+ this.log("error", "No identity loaded before TLS handshake completed");
307
+ return;
308
+ }
309
+ const payload = buildHandshakePayload(this.name, this.version, this.identity);
310
+ handler.sendMessage(PROTOCOL.HANDSHAKE, payload);
311
+ };
312
+ handler.onMessage = (msg) => {
313
+ this.handleProtocol(CHANNEL.SECURE, msg.protocolId, msg.payload).catch((err) => {
314
+ this.log("error", `Secure protocol ${msg.protocolId} failed:`, err);
315
+ });
316
+ };
317
+ this.secureChannel = handler;
318
+ }
266
319
  handlePacket(buffer) {
267
320
  if (!buffer || buffer.length === 0) {
268
321
  return;
@@ -301,27 +354,24 @@ export class CubyzConnection extends EventEmitter {
301
354
  const remoteId = buffer.readBigInt64BE(1);
302
355
  this.remoteConnectionId = remoteId;
303
356
  const lossyStart = readInt32BE(buffer, 9);
304
- const fastStart = readInt32BE(buffer, 13);
357
+ const secureStart = readInt32BE(buffer, 13);
305
358
  const slowStart = readInt32BE(buffer, 17);
306
- this.ensureReceiveChannels(lossyStart, fastStart, slowStart);
359
+ this.ensureReceiveChannels(lossyStart, secureStart, slowStart);
307
360
  if (this.state !== "connected") {
308
361
  this.state = "connected";
309
362
  this.lastInbound = Date.now();
310
363
  this.awaitingServerSince = null;
311
364
  this.log("info", "Channel handshake completed with server");
312
- this.sendInitAck();
313
- this.queueHandshake();
365
+ this.setupSecureChannel();
366
+ // Send the init-ACK and only start the TLS handshake once the packet
367
+ // has been handed to the OS, guaranteeing the server transitions to
368
+ // .connected before it receives the TLS ClientHello.
369
+ this.sendInitAck(() => {
370
+ this.secureChannel?.startHandshake();
371
+ });
314
372
  this.emit("connected");
315
373
  }
316
374
  }
317
- queueHandshake() {
318
- if (this.handshakeQueued) {
319
- return;
320
- }
321
- const payload = buildHandshakePayload(this.name, this.version);
322
- this.sendChannels[CHANNEL.FAST].queue(PROTOCOL.HANDSHAKE, payload);
323
- this.handshakeQueued = true;
324
- }
325
375
  async handleSequencedPacket(buffer) {
326
376
  const parsed = parseChannelPacket(buffer);
327
377
  const channel = this.receiveChannels.get(parsed.channelId);
@@ -357,6 +407,7 @@ export class CubyzConnection extends EventEmitter {
357
407
  }
358
408
  }
359
409
  async handleProtocol(channelId, protocolId, payload) {
410
+ // this.log("debug", `handleProtocol: channelId=${channelId}, protocolId=${protocolId}, payloadLen=${payload.length}`);
360
411
  switch (protocolId) {
361
412
  case PROTOCOL.HANDSHAKE:
362
413
  await this.handleHandshake(payload);
@@ -365,10 +416,18 @@ export class CubyzConnection extends EventEmitter {
365
416
  this.handleEntityPosition(payload);
366
417
  this.emit("protocol", { channelId, protocolId, payload });
367
418
  break;
419
+ case PROTOCOL.BLOCK_UPDATE:
420
+ this.handleBlockUpdate(payload);
421
+ this.emit("protocol", { channelId, protocolId, payload });
422
+ break;
368
423
  case PROTOCOL.ENTITY:
369
424
  this.handleEntityUpdate(payload);
370
425
  this.emit("protocol", { channelId, protocolId, payload });
371
426
  break;
427
+ case PROTOCOL.GENERIC_UPDATE:
428
+ this.handleGenericUpdate(payload);
429
+ this.emit("protocol", { channelId, protocolId, payload });
430
+ break;
372
431
  case PROTOCOL.CHAT:
373
432
  this.emit("chat", payload.toString("utf8"));
374
433
  break;
@@ -379,6 +438,10 @@ export class CubyzConnection extends EventEmitter {
379
438
  async handleHandshake(payload) {
380
439
  const { state, data } = parseHandshake(payload);
381
440
  switch (state) {
441
+ case HANDSHAKE_STATE.SIGNATURE_REQUEST: {
442
+ await this.handleSignatureRequest(data);
443
+ break;
444
+ }
382
445
  case HANDSHAKE_STATE.ASSETS: {
383
446
  // Assets are compressed with zlib's raw DEFLATE
384
447
  // Skipping asset storage for brevity
@@ -479,6 +542,63 @@ export class CubyzConnection extends EventEmitter {
479
542
  this.log("debug", `Unhandled handshake state ${state}`);
480
543
  }
481
544
  }
545
+ async handleSignatureRequest(data) {
546
+ if (!this.identity) {
547
+ this.log("error", "No identity available for signature request");
548
+ return;
549
+ }
550
+ if (!this.secureChannel) {
551
+ this.log("error", "No secure channel for signature request");
552
+ return;
553
+ }
554
+ const verificationData = this.secureChannel.verificationDataBuffer ?? Buffer.alloc(0);
555
+ // Parse signatureRequest:
556
+ // [varint: len of algo1 name][algo1 name bytes][varint: len of algo2 name (0 if none)][algo2 name bytes if present]
557
+ let offset = 0;
558
+ const algo1LenResult = decodeVarInt(data, offset);
559
+ offset += algo1LenResult.consumed;
560
+ const algo1Name = data
561
+ .slice(offset, offset + algo1LenResult.value)
562
+ .toString("utf8");
563
+ offset += algo1LenResult.value;
564
+ let algo2Name = "";
565
+ if (offset < data.length) {
566
+ const algo2LenResult = decodeVarInt(data, offset);
567
+ offset += algo2LenResult.consumed;
568
+ if (algo2LenResult.value > 0) {
569
+ algo2Name = data
570
+ .slice(offset, offset + algo2LenResult.value)
571
+ .toString("utf8");
572
+ }
573
+ }
574
+ this.log("debug", "Signature request: algo1=", algo1Name, "algo2=", algo2Name);
575
+ const signatureBuffers = [];
576
+ const identity = this.identity;
577
+ const signOne = async (algoName) => {
578
+ switch (algoName) {
579
+ case "ed25519":
580
+ signatureBuffers.push(signEd25519(identity.keys.ed25519PrivKey, verificationData));
581
+ break;
582
+ case "ecdsaP256Sha256":
583
+ signatureBuffers.push(signP256(identity.keys.p256PrivKey, verificationData));
584
+ break;
585
+ case "mldsa44":
586
+ signatureBuffers.push(await signMlDsa44(identity.keys.mlDsa44PrivKey, verificationData));
587
+ break;
588
+ default:
589
+ this.log("warn", "Unknown signature algorithm requested:", algoName);
590
+ }
591
+ };
592
+ await signOne(algo1Name);
593
+ if (algo2Name) {
594
+ await signOne(algo2Name);
595
+ }
596
+ // signatureResponse format: [state byte = 3][sig1_bytes][optional sig2_bytes]
597
+ const statePrefix = Buffer.from([HANDSHAKE_STATE.SIGNATURE_RESPONSE]);
598
+ const responsePayload = Buffer.concat([statePrefix, ...signatureBuffers]);
599
+ this.secureChannel.sendMessage(PROTOCOL.HANDSHAKE, responsePayload);
600
+ this.log("debug", "Sent signature response");
601
+ }
482
602
  handleEntityUpdate(payload) {
483
603
  const text = payload.toString("utf8");
484
604
  let parsed;
@@ -495,8 +615,11 @@ export class CubyzConnection extends EventEmitter {
495
615
  return;
496
616
  }
497
617
  let changed = false;
498
- for (const entry of parsed) {
618
+ let nullIndex = -1;
619
+ for (let i = 0; i < parsed.length; i++) {
620
+ const entry = parsed[i];
499
621
  if (entry === null) {
622
+ nullIndex = i;
500
623
  break;
501
624
  }
502
625
  if (typeof entry === "number") {
@@ -539,24 +662,33 @@ export class CubyzConnection extends EventEmitter {
539
662
  }
540
663
  this.emitPlayers();
541
664
  }
665
+ // Process item drops after the null sentinel.
666
+ // Number entries are removals (by u16 index); object entries are additions
667
+ // or updates whose positions will arrive via the entityPositions protocol.
668
+ if (nullIndex >= 0) {
669
+ for (let i = nullIndex + 1; i < parsed.length; i++) {
670
+ const entry = parsed[i];
671
+ if (typeof entry === "number") {
672
+ this.itemStates.delete(entry);
673
+ }
674
+ // Object entries (add/update) don't carry position data here;
675
+ // their positions are delivered via protocol 6 (ENTITY_POSITION).
676
+ }
677
+ }
542
678
  }
543
679
  handleEntityPosition(payload) {
544
680
  const result = parseEntityPositionPacket(payload, this.log.bind(this));
545
681
  if (!result) {
546
682
  return;
547
683
  }
548
- // Update internal state with parsed entities
549
684
  this.entityStates.clear();
550
- const entityStatesMap = result._entityStates;
551
- for (const [id, state] of entityStatesMap) {
685
+ for (const [id, state] of result.entityStates) {
552
686
  this.entityStates.set(id, state);
553
687
  }
554
688
  this.itemStates.clear();
555
- const itemStatesMap = result._itemStates;
556
- for (const [index, state] of itemStatesMap) {
689
+ for (const [index, state] of result.itemStates) {
557
690
  this.itemStates.set(index, state);
558
691
  }
559
- // Emit the packet without internal state maps
560
692
  const packet = {
561
693
  timestamp: result.timestamp,
562
694
  basePosition: result.basePosition,
@@ -565,6 +697,204 @@ export class CubyzConnection extends EventEmitter {
565
697
  };
566
698
  this.emit("entityPositions", packet);
567
699
  }
700
+ handleBlockUpdate(payload) {
701
+ const updates = [];
702
+ let offset = 0;
703
+ while (offset + 20 <= payload.length) {
704
+ const x = payload.readInt32BE(offset);
705
+ offset += 4;
706
+ const y = payload.readInt32BE(offset);
707
+ offset += 4;
708
+ const z = payload.readInt32BE(offset);
709
+ offset += 4;
710
+ const block = payload.readUInt32BE(offset);
711
+ offset += 4;
712
+ // blockEntityData length is written as usize (8 bytes, big-endian) by the Zig server.
713
+ if (offset + 8 > payload.length) {
714
+ this.log("warn", "Block update payload truncated (no room for length)");
715
+ break;
716
+ }
717
+ const blockEntityDataLen = Number(payload.readBigUInt64BE(offset));
718
+ offset += 8;
719
+ if (offset + blockEntityDataLen > payload.length) {
720
+ this.log("warn", "Block update payload truncated");
721
+ break;
722
+ }
723
+ const blockEntityData = payload.slice(offset, offset + blockEntityDataLen);
724
+ offset += blockEntityDataLen;
725
+ updates.push({
726
+ position: { x, y, z },
727
+ block,
728
+ blockEntityData,
729
+ });
730
+ }
731
+ if (updates.length > 0) {
732
+ this.emit("blockUpdate", updates);
733
+ }
734
+ }
735
+ handleGenericUpdate(payload) {
736
+ if (payload.length < 1) {
737
+ this.log("warn", "Generic update payload too short");
738
+ return;
739
+ }
740
+ const updateType = payload.readUInt8(0);
741
+ let offset = 1;
742
+ this.log("debug", `handleGenericUpdate: type=${updateType}, payloadLen=${payload.length}`);
743
+ try {
744
+ switch (updateType) {
745
+ case GENERIC_UPDATE_TYPE.GAMEMODE: {
746
+ if (offset + 1 > payload.length) {
747
+ this.log("warn", "Gamemode update payload too short");
748
+ return;
749
+ }
750
+ const gamemode = payload.readUInt8(offset);
751
+ this.log("debug", `Gamemode update: ${gamemode}`);
752
+ this.emit("genericUpdate", {
753
+ type: "gamemode",
754
+ gamemode,
755
+ });
756
+ break;
757
+ }
758
+ case GENERIC_UPDATE_TYPE.TELEPORT: {
759
+ if (offset + 24 > payload.length) {
760
+ this.log("warn", "Teleport update payload too short");
761
+ return;
762
+ }
763
+ const x = payload.readDoubleBE(offset);
764
+ offset += 8;
765
+ const y = payload.readDoubleBE(offset);
766
+ offset += 8;
767
+ const z = payload.readDoubleBE(offset);
768
+ this.emit("genericUpdate", {
769
+ type: "teleport",
770
+ position: { x, y, z },
771
+ });
772
+ break;
773
+ }
774
+ case GENERIC_UPDATE_TYPE.WORLD_EDIT_POS: {
775
+ if (offset + 1 > payload.length) {
776
+ this.log("warn", "World edit pos update payload too short");
777
+ return;
778
+ }
779
+ const posType = payload.readUInt8(offset);
780
+ offset += 1;
781
+ let position = null;
782
+ if (posType === WORLD_EDIT_POSITION.SELECTED_POS1 ||
783
+ posType === WORLD_EDIT_POSITION.SELECTED_POS2) {
784
+ if (offset + 12 > payload.length) {
785
+ this.log("warn", "World edit pos update payload too short");
786
+ return;
787
+ }
788
+ const x = payload.readInt32BE(offset);
789
+ offset += 4;
790
+ const y = payload.readInt32BE(offset);
791
+ offset += 4;
792
+ const z = payload.readInt32BE(offset);
793
+ position = { x, y, z };
794
+ }
795
+ this.emit("genericUpdate", {
796
+ type: "worldEditPos",
797
+ positionType: posType,
798
+ position,
799
+ });
800
+ break;
801
+ }
802
+ case GENERIC_UPDATE_TYPE.TIME: {
803
+ if (offset + 8 > payload.length) {
804
+ this.log("warn", "Time update payload too short");
805
+ return;
806
+ }
807
+ const time = payload.readBigInt64BE(offset);
808
+ this.emit("genericUpdate", {
809
+ type: "time",
810
+ time,
811
+ });
812
+ break;
813
+ }
814
+ case GENERIC_UPDATE_TYPE.BIOME: {
815
+ if (offset + 4 > payload.length) {
816
+ this.log("warn", "Biome update payload too short");
817
+ return;
818
+ }
819
+ const biomeId = payload.readUInt32BE(offset);
820
+ this.emit("genericUpdate", {
821
+ type: "biome",
822
+ biomeId,
823
+ });
824
+ break;
825
+ }
826
+ case GENERIC_UPDATE_TYPE.PARTICLES: {
827
+ // [varint u16: particleIdLen][particleId][f64 x][f64 y][f64 z][u8 collides][varint u32: count][varint usize: spawnZonLen][spawnZon]
828
+ const particleIdLenResult = decodeVarInt(payload, offset);
829
+ offset += particleIdLenResult.consumed;
830
+ const particleIdLen = particleIdLenResult.value;
831
+ if (offset + particleIdLen > payload.length) {
832
+ this.log("warn", "Particles update payload truncated (particleId)");
833
+ return;
834
+ }
835
+ const particleId = payload
836
+ .slice(offset, offset + particleIdLen)
837
+ .toString("utf8");
838
+ offset += particleIdLen;
839
+ if (offset + 24 > payload.length) {
840
+ this.log("warn", "Particles update payload truncated (position)");
841
+ return;
842
+ }
843
+ const px = payload.readDoubleBE(offset);
844
+ offset += 8;
845
+ const py = payload.readDoubleBE(offset);
846
+ offset += 8;
847
+ const pz = payload.readDoubleBE(offset);
848
+ offset += 8;
849
+ if (offset + 1 > payload.length) {
850
+ this.log("warn", "Particles update payload truncated (collides)");
851
+ return;
852
+ }
853
+ const collides = payload.readUInt8(offset) !== 0;
854
+ offset += 1;
855
+ const countResult = decodeVarInt(payload, offset);
856
+ offset += countResult.consumed;
857
+ const count = countResult.value;
858
+ const spawnZonLenResult = decodeVarInt(payload, offset);
859
+ offset += spawnZonLenResult.consumed;
860
+ const spawnZonLen = spawnZonLenResult.value;
861
+ if (offset + spawnZonLen > payload.length) {
862
+ this.log("warn", "Particles update payload truncated (spawnZon)");
863
+ return;
864
+ }
865
+ const spawnZon = payload
866
+ .slice(offset, offset + spawnZonLen)
867
+ .toString("utf8");
868
+ this.emit("genericUpdate", {
869
+ type: "particles",
870
+ particleId,
871
+ position: { x: px, y: py, z: pz },
872
+ collides,
873
+ count,
874
+ spawnZon,
875
+ });
876
+ break;
877
+ }
878
+ case GENERIC_UPDATE_TYPE.CLEAR: {
879
+ // [u8 clearType: 0=chat]
880
+ if (offset + 1 > payload.length) {
881
+ this.log("warn", "Clear update payload too short");
882
+ return;
883
+ }
884
+ const clearTypeByte = payload.readUInt8(offset);
885
+ // Only clearType 0 (chat) is defined by the server.
886
+ const clearType = clearTypeByte === 0 ? "chat" : "chat";
887
+ this.emit("genericUpdate", { type: "clear", clearType });
888
+ break;
889
+ }
890
+ default:
891
+ this.log("debug", `Unknown generic update type: ${updateType}`);
892
+ }
893
+ }
894
+ catch (err) {
895
+ this.log("error", "Failed to parse generic update:", err);
896
+ }
897
+ }
568
898
  emitPlayers() {
569
899
  const players = this.getPlayers();
570
900
  this.emit("players", players);