t2-mapper/relay/gameConnection.ts

904 lines
28 KiB
TypeScript
Raw Normal View History

2026-03-09 12:38:40 -07:00
import dgram from "node:dgram";
import { EventEmitter } from "node:events";
import {
ConnectionProtocol,
ClientNetStringTable,
buildConnectChallengeRequest,
buildConnectRequest,
buildClientGamePacket,
buildRemoteCommandEvent,
buildCRCChallengeResponseEvent,
buildGhostingMessageEvent,
buildDisconnectPacket,
type ClientEvent,
type ClientMoveData,
} from "./protocol.js";
import { BitStream } from "t2-demo-parser";
import { T2csriAuth, loadCredentials } from "./auth.js";
import { connLog } from "./logger.js";
import type { ConnectionStatus } from "./types.js";
import { computeGameCRC, type CRCDataBlock } from "./crc.js";
// Tribes 2 protocol version and class CRC from the binary.
// These must match what the server expects.
const PROTOCOL_VERSION = 0x33; // 51 — from Tribes2.exe binary
// Real T2 client sends at ~32ms tick rate. Using 32ms ensures the server
// receives steady acks for guaranteed event delivery (datablock phase).
const KEEPALIVE_INTERVAL_MS = 32;
const CONNECT_TIMEOUT_MS = 30000;
interface GameConnectionEvents {
status: [status: ConnectionStatus, message?: string];
packet: [data: Uint8Array];
ping: [ms: number];
error: [error: Error];
close: [];
}
/**
* Manages a UDP connection to a Tribes 2 game server.
* Handles the connection handshake, keepalive, and packet forwarding.
*/
export class GameConnection extends EventEmitter<GameConnectionEvents> {
private socket: dgram.Socket | null = null;
private host: string;
private port: number;
private protocol = new ConnectionProtocol();
private auth: T2csriAuth | null = null;
private clientConnectSequence = Math.floor(Math.random() * 0xffffffff);
private serverConnectSequence = 0;
private _status: ConnectionStatus = "disconnected";
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
private handshakeTimer: ReturnType<typeof setTimeout> | null = null;
private challengeRetryTimer: ReturnType<typeof setTimeout> | null = null;
private authDelayTimer: ReturnType<typeof setTimeout> | null = null;
private nextSendEventSeq = 0;
private pendingEvents: ClientEvent[] = [];
/** Events sent but not yet acked, keyed by packet sequence number. */
private sentEventsByPacket = new Map<
number,
{ seq: number; event: ClientEvent }[]
>();
2026-03-09 12:38:40 -07:00
/** Events waiting to be sent (new or retransmitted from lost packets). */
private eventSendQueue: { seq: number; event: ClientEvent }[] = [];
private stringTable = new ClientNetStringTable();
/** Incrementing move index so the server doesn't deduplicate our moves. */
private moveIndex = 0;
private dataPacketCount = 0;
private rawMessageCount = 0;
private sendMoveCount = 0;
private _mapName?: string;
private observerEnforced = false;
/** Buffered move state — merged into the next keepalive tick. */
private bufferedMove: ClientMoveData | null = null;
/** Ticks remaining to hold the current trigger state before clearing. */
private triggerHoldTicks = 0;
/** Send timestamps by sequence number for RTT measurement. */
private sendTimestamps = new Map<number, number>();
/** Smoothed RTT in ms (exponential moving average). */
private smoothedPing = 0;
private lastPingEmit = 0;
2026-03-09 23:19:14 -07:00
/** Warrior name to send in the ConnectRequest. */
private warriorName: string;
constructor(address: string, options?: { warriorName?: string }) {
2026-03-09 12:38:40 -07:00
super();
const [host, portStr] = address.split(":");
this.host = host;
this.port = parseInt(portStr, 10);
2026-03-09 23:19:14 -07:00
this.warriorName = options?.warriorName || "";
2026-03-09 12:38:40 -07:00
// Wire up packet delivery notifications for event retransmission.
this.protocol.onNotify = (packetSeq, acked) => {
this.handlePacketNotify(packetSeq, acked);
};
}
get status(): ConnectionStatus {
return this._status;
}
get connectSequence(): number {
return (this.clientConnectSequence ^ this.serverConnectSequence) >>> 0;
}
get mapName(): string | undefined {
return this._mapName;
}
private setStatus(status: ConnectionStatus, message?: string): void {
this._status = status;
this.emit("status", status, message);
}
/** Initiate connection to the game server. */
async connect(): Promise<void> {
connLog.info(
{ host: this.host, port: this.port },
"Connecting to game server",
);
const credentials = loadCredentials();
if (credentials) {
connLog.info("T2csri credentials loaded");
this.auth = new T2csriAuth(credentials);
} else {
connLog.warn("No T2csri credentials — connecting without auth");
}
this.socket = dgram.createSocket("udp4");
this.socket.on("message", (msg) => this.handleMessage(msg));
this.socket.on("error", (err) => {
this.emit("error", err);
this.disconnect();
});
this.setStatus("connecting");
// Start the handshake
this.sendChallengeRequest();
// Set overall connection timeout
this.handshakeTimer = setTimeout(() => {
if (this._status !== "connected" && this._status !== "authenticating") {
connLog.warn("Connection timed out");
this.setStatus("disconnected", "Connection timed out");
this.disconnect();
}
}, CONNECT_TIMEOUT_MS);
}
/** Send the initial ConnectChallengeRequest. */
private sendChallengeRequest(): void {
this.setStatus("challenging");
const packet = buildConnectChallengeRequest(
PROTOCOL_VERSION,
this.clientConnectSequence,
);
connLog.info(
{ bytes: packet.length, clientSeq: this.clientConnectSequence },
"Sending ConnectChallengeRequest",
);
this.sendRaw(packet);
// Retry challenge if no response
this.challengeRetryTimer = setTimeout(() => {
this.challengeRetryTimer = null;
if (this._status === "challenging") {
connLog.info("No challenge response, retrying");
this.sendRaw(packet);
}
}, 2000);
}
/** Handle an incoming UDP message. */
private handleMessage(msg: Buffer): void {
if (msg.length === 0) return;
this.rawMessageCount++;
if (this.rawMessageCount <= 30 || this.rawMessageCount % 50 === 0) {
connLog.debug(
{
bytes: msg.length,
firstByte: msg[0],
rawTotal: this.rawMessageCount,
},
2026-03-09 12:38:40 -07:00
"Raw UDP message received",
);
}
const firstByte = msg[0];
if (this.isOOBPacket(firstByte)) {
connLog.debug(
{ type: firstByte, bytes: msg.length },
"Received OOB packet",
);
this.handleOOBPacket(msg);
} else {
this.handleDataPacket(msg);
}
}
/** Check if a packet is OOB (out-of-band) vs data protocol. */
private isOOBPacket(firstByte: number): boolean {
// Disconnect (38) can arrive at any time
if (firstByte === 38) return true;
const oobTypes = [26, 28, 30, 32, 34, 36, 38, 40];
return (
this._status !== "connected" &&
this._status !== "authenticating" &&
oobTypes.includes(firstByte)
);
}
/** Handle out-of-band handshake packets. */
private handleOOBPacket(msg: Buffer): void {
const type = msg[0];
switch (type) {
case 28: // ChallengeReject
this.handleChallengeReject(msg);
break;
case 30: // ConnectChallengeResponse
this.handleChallengeResponse(msg);
break;
case 34: // ConnectReject
this.handleConnectReject(msg);
break;
case 36: // ConnectAccept
this.handleConnectAccept(msg);
break;
case 38: {
// Disconnect — U8(type) + U32(seq1) + U32(seq2) + HuffString(reason)
2026-03-09 12:38:40 -07:00
let reason = "Server disconnected";
if (msg.length > 9) {
try {
const data = new Uint8Array(
msg.buffer,
msg.byteOffset,
msg.byteLength,
);
2026-03-09 12:38:40 -07:00
// Skip 9-byte header (1 type + 4 connectSeq + 4 connectSeq2).
// Reason is Huffman-encoded via BitStream::writeString (no stringBuffer).
const bs = new BitStream(data.subarray(9));
const parsed = bs.readString();
if (parsed) reason = parsed;
} catch {
// Fall back to default reason
}
}
connLog.warn(
{ reason, bytes: msg.length },
"Server sent Disconnect packet",
);
2026-03-09 12:38:40 -07:00
this.setStatus("disconnected", reason);
this.disconnect();
break;
}
default:
connLog.warn({ type, bytes: msg.length }, "Unknown OOB packet type");
}
}
/** Handle ChallengeReject (type 28): U8(28) + U32(clientSeq) + HuffString(reason). */
2026-03-09 12:38:40 -07:00
private handleChallengeReject(msg: Buffer): void {
if (msg.length < 5) return;
const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
const seq = dv.getUint32(1, true);
if (seq !== this.clientConnectSequence) {
connLog.debug({ expected: this.clientConnectSequence, got: seq }, "ChallengeReject sequence mismatch, ignoring");
return;
}
2026-03-09 12:38:40 -07:00
let reason = "Challenge rejected";
if (msg.length > 5) {
try {
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
const bs = new BitStream(data.subarray(5));
const parsed = bs.readString();
if (parsed) reason = parsed;
} catch {
// Fall back to default reason
2026-03-09 12:38:40 -07:00
}
}
connLog.warn({ reason }, "ChallengeReject received");
this.setStatus("disconnected", reason);
this.disconnect();
}
/** Handle ConnectChallengeResponse. */
private handleChallengeResponse(msg: Buffer): void {
if (msg.length < 14) {
connLog.error({ bytes: msg.length }, "ChallengeResponse too short");
2026-03-09 12:38:40 -07:00
return;
}
const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
2026-03-09 12:38:40 -07:00
const serverProtocolVersion = dv.getUint32(1, true);
this.serverConnectSequence = dv.getUint32(5, true);
const echoedClientSeq = dv.getUint32(9, true);
connLog.info(
{
serverProto: serverProtocolVersion,
serverSeq: this.serverConnectSequence,
echoedClientSeq,
},
"Received ChallengeResponse",
);
if (echoedClientSeq !== this.clientConnectSequence) {
connLog.error(
{ expected: this.clientConnectSequence, got: echoedClientSeq },
"Client connect sequence mismatch",
);
return;
}
// Send ConnectRequest
const connectArgv = this.buildConnectArgv();
const packet = buildConnectRequest(
this.serverConnectSequence,
this.clientConnectSequence,
PROTOCOL_VERSION,
false, // not pre-authenticated
connectArgv,
);
connLog.info(
{ bytes: packet.length, argv: connectArgv },
"Sending ConnectRequest",
);
this.sendRaw(packet);
}
/** Build the connection argv (name, race/gender, skin, voice, voicePitch). */
private buildConnectArgv(): string[] {
2026-03-09 23:19:14 -07:00
const name = this.warriorName || process.env.T2_ACCOUNT_NAME || "Observer";
2026-03-09 12:38:40 -07:00
return [
name, // player name
"Male Human", // race/gender
"beagle", // skin
"male1", // voice
"1.0", // voice pitch
];
}
/** Handle ConnectAccept. */
private handleConnectAccept(_msg: Buffer): void {
connLog.info(
{
clientSeq: this.clientConnectSequence,
serverSeq: this.serverConnectSequence,
xorSeq: this.connectSequence,
connectSeqBit: this.connectSequence & 1,
},
"ConnectAccept received — connection established",
);
this.protocol.connectSequence = this.connectSequence;
this.startKeepalive();
if (this.auth) {
connLog.info("Starting T2csri authentication");
this.setStatus("authenticating");
} else {
this.enforceObserver();
this.setStatus("connected");
}
}
/** Handle ConnectReject. */
/** Handle ConnectReject (type 34): U8(34) + U32(serverSeq) + U32(clientSeq) + HuffString(reason). */
2026-03-09 12:38:40 -07:00
private handleConnectReject(msg: Buffer): void {
if (msg.length < 9) return;
const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
const serverSeq = dv.getUint32(1, true);
const clientSeq = dv.getUint32(5, true);
if (serverSeq !== this.serverConnectSequence || clientSeq !== this.clientConnectSequence) {
connLog.debug(
{ expectedServer: this.serverConnectSequence, gotServer: serverSeq,
expectedClient: this.clientConnectSequence, gotClient: clientSeq },
"ConnectReject sequence mismatch, ignoring",
);
return;
}
2026-03-09 12:38:40 -07:00
let reason = "Connection rejected";
if (msg.length > 9) {
try {
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
const bs = new BitStream(data.subarray(9));
const parsed = bs.readString();
if (parsed) reason = parsed;
} catch {
// Fall back to default reason
2026-03-09 12:38:40 -07:00
}
}
connLog.warn({ reason }, "ConnectReject received");
this.setStatus("disconnected", reason);
this.disconnect();
}
/** Handle a data protocol packet (established connection). */
private handleDataPacket(msg: Buffer): void {
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
this.dataPacketCount++;
if (this.dataPacketCount <= 20 || this.dataPacketCount % 50 === 0) {
connLog.debug(
{ bytes: data.length, total: this.dataPacketCount },
"Data packet received",
);
}
// Forward the raw packet to the browser for parsing
this.emit("packet", data);
// We still need to process the dnet header locally to track ack state
this.processPacketForAcks(data);
}
/** Process a packet's dnet header to maintain ack state. */
private processPacketForAcks(data: Uint8Array): void {
if (data.length < 4) return;
const bs = new BitStream(data);
bs.readFlag(); // gameFlag
const connectSeqBit = bs.readInt(1);
const seqNumber = bs.readInt(9);
const highestAck = bs.readInt(9);
const packetType = bs.readInt(2);
const ackByteCount = bs.readInt(3);
const ackMask = ackByteCount > 0 ? bs.readInt(8 * ackByteCount) : 0;
const result = this.protocol.processReceivedHeader({
seqNumber,
highestAck,
packetType,
connectSeqBit,
ackByteCount,
ackMask,
});
// Respond to PingPackets (type=1) with our own PingPacket.
// The server's processRawPacket calls sendPingResponse on receiving a
// PingPacket. Without this response, the server may time us out.
if (packetType === 1) {
connLog.debug(
{ seq: seqNumber },
"Received PingPacket, sending ping response",
);
2026-03-09 12:38:40 -07:00
const pingResponse = this.protocol.buildPingPacket();
this.sendRaw(pingResponse);
}
if (this.dataPacketCount <= 20 || this.dataPacketCount % 50 === 0) {
connLog.debug(
{
seq: seqNumber,
ack: highestAck,
type: packetType,
csb: connectSeqBit,
ackBytes: ackByteCount,
accepted: result.accepted,
dispatch: result.dispatchData,
ourSeq: this.protocol.lastSendSeq,
ourAck: this.protocol.lastSeqRecvd,
},
"Packet header parsed",
);
}
// Measure RTT from the acked sequence's send timestamp.
const sendTime = this.sendTimestamps.get(highestAck);
if (sendTime) {
const rtt = Date.now() - sendTime;
this.sendTimestamps.delete(highestAck);
// Exponential moving average (alpha=0.5 for responsive updates).
this.smoothedPing =
this.smoothedPing === 0 ? rtt : this.smoothedPing * 0.5 + rtt * 0.5;
// Emit ping updates at most every 2 seconds.
const now = Date.now();
if (now - this.lastPingEmit >= 2000) {
this.lastPingEmit = now;
this.emit("ping", Math.round(this.smoothedPing));
}
}
if (!result.accepted) {
connLog.warn(
{
seq: seqNumber,
ack: highestAck,
type: packetType,
csb: connectSeqBit,
expectedCsb: this.protocol.connectSequence & 1,
lastSeqRecvd: this.protocol.lastSeqRecvd,
lastSendSeq: this.protocol.lastSendSeq,
highestAckedSeq: this.protocol.highestAckedSeq,
total: this.dataPacketCount,
},
"Data packet REJECTED by protocol",
);
}
}
/** Handle a parsed T2csri event from the browser. */
handleAuthEvent(eventName: string, args: string[]): void {
2026-03-09 12:38:40 -07:00
if (!this.auth) return;
switch (eventName) {
case "t2csri_pokeClient": {
connLog.info(
"Auth: received pokeClient, sending certificate + challenge",
2026-03-09 12:38:40 -07:00
);
const result = this.auth.onPokeClient(args[0] || "", this.host);
2026-03-09 12:38:40 -07:00
for (const cmd of result.commands) {
this.sendCommand(cmd.name, ...cmd.args);
}
break;
}
case "t2csri_getChallengeChunk": {
connLog.debug(
{ chunkLen: args[0]?.length ?? 0 },
"Auth: received challenge chunk",
);
this.auth.onChallengeChunk(args[0] || "");
break;
}
case "t2csri_decryptChallenge": {
connLog.info("Auth: decrypting challenge");
const result = this.auth.onDecryptChallenge();
if (result) {
const delay = 64 + Math.floor(Math.random() * 448);
connLog.info(
{ delayMs: delay },
"Auth: challenge verified, sending response",
);
this.authDelayTimer = setTimeout(() => {
this.authDelayTimer = null;
if (this._status !== "authenticating") return;
this.sendCommand(result.command.name, ...result.command.args);
2026-03-09 12:38:40 -07:00
this.enforceObserver();
this.setStatus("connected");
}, delay);
} else {
connLog.error("Auth: challenge verification failed");
this.setStatus("disconnected", "Authentication failed");
this.disconnect();
}
break;
}
}
}
/** Respond to a CRCChallengeEvent by echoing values (legacy fallback). */
handleCRCChallenge(crcValue: number, field1: number, field2: number): void {
connLog.info(
{ crcValue, field1, field2 },
"CRC challenge received, sending echo response (legacy)",
);
const event = buildCRCChallengeResponseEvent(crcValue, field1, field2);
this.pendingEvents.push(event);
this.flushEvents();
}
/**
* Compute correct CRC over game shape files and send the response.
* The browser sends us the datablock list (from SimDataBlockEvents)
* along with the challenge seed and field2 to echo.
*/
async computeAndSendCRC(
seed: number,
field2: number,
datablocks: CRCDataBlock[],
includeTextures: boolean,
basePath: string,
): Promise<void> {
connLog.info(
{
seed: `0x${(seed >>> 0).toString(16)}`,
datablocks: datablocks.length,
includeTextures,
},
2026-03-09 12:38:40 -07:00
"Computing CRC over game files",
);
try {
const { crc, totalSize } = await computeGameCRC(
seed,
datablocks,
basePath,
includeTextures,
);
2026-03-09 12:38:40 -07:00
connLog.info(
{ crc: `0x${(crc >>> 0).toString(16)}`, totalSize },
"CRC computed, sending response",
);
const event = buildCRCChallengeResponseEvent(crc, totalSize, field2);
this.pendingEvents.push(event);
this.flushEvents();
} catch (e) {
connLog.error({ err: e }, "CRC computation failed");
}
}
/**
* Respond to a GhostingMessageEvent type 0 (GhostAlwaysDone) from the server.
* Sends back type 1 to enable ghosting (sets mGhosting=true on server).
*/
handleGhostAlwaysDone(sequence: number, ghostCount: number): void {
connLog.info(
{ sequence, ghostCount },
"GhostAlwaysDone received, sending acknowledgment (type 1)",
);
const event = buildGhostingMessageEvent(sequence, 1, ghostCount);
this.pendingEvents.push(event);
this.flushEvents();
}
/** Send a commandToServer as a RemoteCommandEvent. */
sendCommand(command: string, ...args: string[]): void {
connLog.debug(
{ command, args, eventSeq: this.nextSendEventSeq },
"Sending commandToServer",
);
2026-03-09 12:38:40 -07:00
const events = buildRemoteCommandEvent(this.stringTable, command, ...args);
this.pendingEvents.push(...events);
this.flushEvents();
}
/** Flush pending events in a data packet. */
private flushEvents(): void {
// Assign sequence numbers to new pending events and add to send queue.
for (const event of this.pendingEvents.splice(0)) {
const seq = this.nextSendEventSeq++;
this.eventSendQueue.push({ seq, event });
}
if (this.eventSendQueue.length === 0) return;
this.sendDataPacketWithEvents();
}
/**
* Build and send a data packet that includes events from the send queue.
* Events stay tracked per-packet so they can be re-queued on loss.
*/
private sendDataPacketWithEvents(move?: ClientMoveData): void {
2026-03-09 12:38:40 -07:00
const events = this.eventSendQueue.splice(0);
if (events.length === 0) return;
const startSeq = events[0].seq;
connLog.debug(
{
eventCount: events.length,
seqRange: `${startSeq}-${events[events.length - 1].seq}`,
sendSeq: this.protocol.lastSendSeq + 1,
},
"Sending data packet with guaranteed events",
);
// Track which events are in this packet for ack/loss handling.
// lastSendSeq+1 because buildSendPacketHeader increments it.
const packetSeq = this.protocol.lastSendSeq + 1;
this.sentEventsByPacket.set(packetSeq, events);
const moveData = move ?? {
x: 0,
y: 0,
z: 0,
yaw: 0,
pitch: 0,
roll: 0,
2026-03-09 12:38:40 -07:00
freeLook: false,
trigger: [false, false, false, false, false, false],
};
const packet = buildClientGamePacket(this.protocol, {
moves: [moveData],
moveStartIndex: this.moveIndex++,
events: events.map((e) => e.event),
nextSendEventSeq: startSeq,
});
this.sendRaw(packet);
}
/** Handle packet delivery notification from the protocol layer. */
private handlePacketNotify(packetSeq: number, acked: boolean): void {
const events = this.sentEventsByPacket.get(packetSeq);
if (!events || events.length === 0) {
this.sentEventsByPacket.delete(packetSeq);
return;
}
this.sentEventsByPacket.delete(packetSeq);
if (acked) {
connLog.debug(
{
packetSeq,
ackedEvents: events.map((e) => e.seq),
},
"Guaranteed events acked",
);
} else {
// Packet was lost — re-queue events at the HEAD of the send queue
// so they are retransmitted in the next outgoing data packet.
connLog.warn(
{
packetSeq,
lostEvents: events.map((e) => e.seq),
},
"Packet lost, re-queuing guaranteed events for retransmission",
);
this.eventSendQueue.unshift(...events);
}
}
/** Enforce observer team so we spectate instead of spawning. */
private enforceObserver(): void {
if (this.observerEnforced) return;
this.observerEnforced = true;
connLog.info("Enforcing observer mode (setPlayerTeam 0)");
this.sendCommand("setPlayerTeam", "0");
}
/** Set the map name (from GameInfoResponse during server query). */
setMapName(mapName: string): void {
this._mapName = mapName;
}
/**
* Buffer a move to be sent in the next keepalive tick.
* Moves are merged into the 32ms keepalive cadence rather than sent as
* separate packets, because the server's Camera control object processes
* moves from the regular tick stream (separate extra packets can be
* ignored or cause trigger edge detection issues).
*/
sendMove(move: ClientMoveData): void {
this.sendMoveCount++;
if (this.sendMoveCount <= 5 || this.sendMoveCount % 100 === 0) {
connLog.debug(
{
yaw: move.yaw,
pitch: move.pitch,
x: move.x,
y: move.y,
z: move.z,
total: this.sendMoveCount,
},
2026-03-09 12:38:40 -07:00
"Sending move",
);
}
// During trigger hold, merge trigger flags so rapid move updates
// (e.g. from useFrame at 60fps) can't overwrite a pending trigger
// before the server sees it.
if (this.triggerHoldTicks > 0 && this.bufferedMove) {
move = {
...move,
trigger: this.bufferedMove.trigger.map(
(held, i) => held || (move.trigger[i] ?? false),
),
};
}
this.bufferedMove = move;
// If any trigger is set, hold it for 2 ticks to ensure the server
// sees the edge (true then false on the next tick).
if (move.trigger.some(Boolean)) {
this.triggerHoldTicks = 2;
}
}
/** Send the current move state as a keepalive packet at the tick rate. */
private sendTickMove(): void {
const move: ClientMoveData = this.bufferedMove ?? {
x: 0,
y: 0,
z: 0,
yaw: 0,
pitch: 0,
roll: 0,
freeLook: false,
trigger: [false, false, false, false, false, false],
};
// Record send time keyed by the 9-bit sequence number (0511) that the
// server will echo back in highestAck. lastSendSeq is the full counter;
// the wire format uses only the low 9 bits.
const nextSeq9 = (this.protocol.lastSendSeq + 1) & 0x1ff;
this.sendTimestamps.set(nextSeq9, Date.now());
// Absorb any new pending events into the send queue.
for (const event of this.pendingEvents.splice(0)) {
const seq = this.nextSendEventSeq++;
this.eventSendQueue.push({ seq, event });
}
// If we have events waiting to be sent (new or re-queued from lost
// packets), include them in this tick's data packet.
if (this.eventSendQueue.length > 0) {
this.sendDataPacketWithEvents(move);
} else {
const packet = buildClientGamePacket(this.protocol, {
moves: [move],
moveStartIndex: this.moveIndex++,
});
this.sendRaw(packet);
}
// Count down trigger hold, then clear triggers.
if (this.triggerHoldTicks > 0) {
this.triggerHoldTicks--;
if (this.triggerHoldTicks === 0 && this.bufferedMove) {
this.bufferedMove = {
...this.bufferedMove,
trigger: [false, false, false, false, false, false],
};
}
}
}
/** Start keepalive timer. */
private startKeepalive(): void {
let keepaliveCount = 0;
this.keepaliveTimer = setInterval(() => {
keepaliveCount++;
if (keepaliveCount % 300 === 0) {
// ~10s at 32ms tick rate
2026-03-09 12:38:40 -07:00
connLog.info(
{
dataPackets: this.dataPacketCount,
rawMessages: this.rawMessageCount,
ourSeq: this.protocol.lastSendSeq,
ourAck: this.protocol.lastSeqRecvd,
theirAck: this.protocol.highestAckedSeq,
},
"Connection status",
);
}
this.sendTickMove();
}, KEEPALIVE_INTERVAL_MS);
}
/** Send raw bytes to the server. */
private sendRaw(data: Uint8Array): void {
if (!this.socket) return;
this.socket.send(data, this.port, this.host, (err) => {
if (err) {
connLog.error({ err, bytes: data.length }, "UDP send failed");
}
});
}
/** Disconnect from the server, sending a Disconnect OOB packet first. */
disconnect(): void {
if (this._status === "disconnected" && !this.socket) return;
connLog.info("Disconnecting");
// Send a Disconnect packet so the server knows we're leaving
if (this.socket && this.serverConnectSequence !== 0) {
try {
const packet = buildDisconnectPacket(this.connectSequence);
this.socket.send(packet, this.port, this.host);
connLog.info("Sent Disconnect packet to server");
} catch {
// Best effort
}
}
if (this.keepaliveTimer) {
clearInterval(this.keepaliveTimer);
this.keepaliveTimer = null;
}
if (this.challengeRetryTimer) {
clearTimeout(this.challengeRetryTimer);
this.challengeRetryTimer = null;
}
if (this.authDelayTimer) {
clearTimeout(this.authDelayTimer);
this.authDelayTimer = null;
}
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer);
this.handshakeTimer = null;
}
if (this.socket) {
try {
this.socket.close();
} catch {
// Already closed
}
this.socket = null;
}
if (this._status !== "disconnected") {
this.setStatus("disconnected");
}
this.emit("close");
}
}