new UI, unify map/demo/live architecture more, cleanup

This commit is contained in:
Brian Beck 2026-03-12 16:25:04 -07:00
parent d9b5e30831
commit 4741f59582
146 changed files with 5477 additions and 3005 deletions

View file

@ -2,32 +2,24 @@ import { BitStreamWriter } from "./BitStreamWriter.js";
/** Hardcoded character frequency table from the V12 engine (bitStream.cc). */
const CSM_CHAR_FREQS: number[] = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 329, 21, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
2809, 68, 0, 27, 0, 58, 3, 62, 4, 7, 0, 0, 15, 65, 554, 3,
394, 404, 189, 117, 30, 51, 27, 15, 34, 32, 80, 1, 142, 3, 142, 39,
0, 144, 125, 44, 122, 275, 70, 135, 61, 127, 8, 12, 113, 246, 122, 36,
185, 1, 149, 309, 335, 12, 11, 14, 54, 151, 0, 0, 2, 0, 0, 211,
0, 2090, 344, 736, 993, 2872, 701, 605, 646, 1552, 328, 305, 1240, 735, 1533, 1713,
562, 3, 1775, 1149, 1469, 979, 407, 553, 59, 279, 31, 0, 0, 0, 68, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 329, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 2809, 68, 0, 27, 0, 58, 3, 62, 4, 7, 0, 0, 15, 65, 554,
3, 394, 404, 189, 117, 30, 51, 27, 15, 34, 32, 80, 1, 142, 3, 142, 39, 0, 144,
125, 44, 122, 275, 70, 135, 61, 127, 8, 12, 113, 246, 122, 36, 185, 1, 149,
309, 335, 12, 11, 14, 54, 151, 0, 0, 2, 0, 0, 211, 0, 2090, 344, 736, 993,
2872, 701, 605, 646, 1552, 328, 305, 1240, 735, 1533, 1713, 562, 3, 1775,
1149, 1469, 979, 407, 553, 59, 279, 31, 0, 0, 0, 68, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
];
const PROB_BOOST = 1;
function isAlphaNumeric(c: number): boolean {
return (
(c >= 48 && c <= 57) ||
(c >= 65 && c <= 90) ||
(c >= 97 && c <= 122)
);
return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
}
interface HuffLeaf {
@ -62,7 +54,8 @@ function buildTables(): void {
leaves = [];
for (let i = 0; i < 256; i++) {
leaves.push({
pop: CSM_CHAR_FREQS[i] + (isAlphaNumeric(i) ? PROB_BOOST : 0) + PROB_BOOST,
pop:
CSM_CHAR_FREQS[i] + (isAlphaNumeric(i) ? PROB_BOOST : 0) + PROB_BOOST,
symbol: i,
numBits: 0,
code: 0,

View file

@ -317,7 +317,10 @@ export class T2csriAuth {
// Sanitize: must be hex only
const challenge = this.encryptedChallenge.toLowerCase();
authLog.info(
{ challengeLen: challenge.length, clientChallengeLen: this.clientChallenge.length },
{
challengeLen: challenge.length,
clientChallengeLen: this.clientChallenge.length,
},
"Auth: starting challenge decryption",
);
for (let i = 0; i < challenge.length; i++) {
@ -339,7 +342,11 @@ export class T2csriAuth {
const modulusHex = fields[3];
authLog.debug(
{ encryptedLen: challenge.length, modulusLen: modulusHex?.length, privateKeyLen: this.credentials.privateKey.length },
{
encryptedLen: challenge.length,
modulusLen: modulusHex?.length,
privateKeyLen: this.credentials.privateKey.length,
},
"Auth: RSA parameters",
);

View file

@ -49,7 +49,9 @@ export interface CRCDataBlock {
}
// Manifest types (mirrored from src/manifest.ts)
type SourceTuple = [sourcePath: string] | [sourcePath: string, actualPath: string];
type SourceTuple =
| [sourcePath: string]
| [sourcePath: string, actualPath: string];
type ResourceEntry = [firstSeenPath: string, ...SourceTuple[]];
interface Manifest {
resources: Record<string, ResourceEntry>;
@ -120,8 +122,8 @@ export async function computeGameCRC(
console.log(
`[crc] starting computation: seed=0x${(seed >>> 0).toString(16)}, ` +
`${sorted.length} ShapeBaseData datablocks (of ${datablocks.length} total), ` +
`includeTextures=${includeTextures}`,
`${sorted.length} ShapeBaseData datablocks (of ${datablocks.length} total), ` +
`includeTextures=${includeTextures}`,
);
for (const db of sorted) {
@ -153,7 +155,7 @@ export async function computeGameCRC(
console.log(
`[crc] #${filesFound} id=${db.objectId} ${db.className} "${db.shapeName}" ` +
`size=${data.length} crc=0x${prevCrc.toString(16)}→0x${crc.toString(16)}`,
`size=${data.length} crc=0x${prevCrc.toString(16)}→0x${crc.toString(16)}`,
);
// TODO: If includeTextures && db.className !== "PlayerData",
@ -172,7 +174,7 @@ export async function computeGameCRC(
console.log(
`[crc] RESULT: ${filesFound} files CRC'd, ${filesMissing} missing, ` +
`crc=0x${crc.toString(16)}, totalSize=${totalSize}, elapsed=${elapsed.toFixed(0)}ms`,
`crc=0x${crc.toString(16)}, totalSize=${totalSize}, elapsed=${elapsed.toFixed(0)}ms`,
);
return { crc, totalSize };

View file

@ -58,7 +58,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
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 }[]>();
private sentEventsByPacket = new Map<
number,
{ seq: number; event: ClientEvent }[]
>();
/** Events waiting to be sent (new or retransmitted from lost packets). */
private eventSendQueue: { seq: number; event: ClientEvent }[] = [];
private stringTable = new ClientNetStringTable();
@ -178,7 +181,11 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
this.rawMessageCount++;
if (this.rawMessageCount <= 30 || this.rawMessageCount % 50 === 0) {
connLog.debug(
{ bytes: msg.length, firstByte: msg[0], rawTotal: this.rawMessageCount },
{
bytes: msg.length,
firstByte: msg[0],
rawTotal: this.rawMessageCount,
},
"Raw UDP message received",
);
}
@ -225,11 +232,16 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
case 36: // ConnectAccept
this.handleConnectAccept(msg);
break;
case 38: { // Disconnect — U8(type) + U32(seq1) + U32(seq2) + HuffString(reason)
case 38: {
// Disconnect — U8(type) + U32(seq1) + U32(seq2) + HuffString(reason)
let reason = "Server disconnected";
if (msg.length > 9) {
try {
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
const data = new Uint8Array(
msg.buffer,
msg.byteOffset,
msg.byteLength,
);
// 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));
@ -239,7 +251,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
// Fall back to default reason
}
}
connLog.warn({ reason, bytes: msg.length }, "Server sent Disconnect packet");
connLog.warn(
{ reason, bytes: msg.length },
"Server sent Disconnect packet",
);
this.setStatus("disconnected", reason);
this.disconnect();
break;
@ -249,16 +264,24 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
}
}
/** Handle ChallengeReject (type 28): U8(28) + U32(connectSeq) + ASCII reason. */
/** Handle ChallengeReject (type 28): U8(28) + U32(clientSeq) + HuffString(reason). */
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;
}
let reason = "Challenge rejected";
if (msg.length > 5) {
const chars: number[] = [];
for (let i = 5; i < msg.length && msg[i] !== 0; i++) {
chars.push(msg[i]);
}
if (chars.length > 0) {
reason = String.fromCharCode(...chars);
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
}
}
connLog.warn({ reason }, "ChallengeReject received");
@ -269,18 +292,11 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
/** Handle ConnectChallengeResponse. */
private handleChallengeResponse(msg: Buffer): void {
if (msg.length < 14) {
connLog.error(
{ bytes: msg.length },
"ChallengeResponse too short",
);
connLog.error({ bytes: msg.length }, "ChallengeResponse too short");
return;
}
const dv = new DataView(
msg.buffer,
msg.byteOffset,
msg.byteLength,
);
const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
const serverProtocolVersion = dv.getUint32(1, true);
this.serverConnectSequence = dv.getUint32(5, true);
const echoedClientSeq = dv.getUint32(9, true);
@ -354,14 +370,30 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
}
/** Handle ConnectReject. */
/** Handle ConnectReject (type 34): U8(34) + U32(serverSeq) + U32(clientSeq) + HuffString(reason). */
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;
}
let reason = "Connection rejected";
if (msg.length > 1) {
const chars: number[] = [];
for (let i = 1; i < msg.length && msg[i] !== 0; i++) {
chars.push(msg[i]);
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
}
reason = String.fromCharCode(...chars);
}
connLog.warn({ reason }, "ConnectReject received");
this.setStatus("disconnected", reason);
@ -385,7 +417,6 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
// 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. */
@ -415,7 +446,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
// 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");
connLog.debug(
{ seq: seqNumber },
"Received PingPacket, sending ping response",
);
const pingResponse = this.protocol.buildPingPacket();
this.sendRaw(pingResponse);
}
@ -472,19 +506,15 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
}
/** Handle a parsed T2csri event from the browser. */
handleAuthEvent(
eventName: string,
args: string[],
): void {
handleAuthEvent(eventName: string, args: string[]): void {
if (!this.auth) return;
switch (eventName) {
case "t2csri_pokeClient": {
connLog.info("Auth: received pokeClient, sending certificate + challenge");
const result = this.auth.onPokeClient(
args[0] || "",
this.host,
connLog.info(
"Auth: received pokeClient, sending certificate + challenge",
);
const result = this.auth.onPokeClient(args[0] || "", this.host);
for (const cmd of result.commands) {
this.sendCommand(cmd.name, ...cmd.args);
}
@ -512,10 +542,7 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
this.authDelayTimer = setTimeout(() => {
this.authDelayTimer = null;
if (this._status !== "authenticating") return;
this.sendCommand(
result.command.name,
...result.command.args,
);
this.sendCommand(result.command.name, ...result.command.args);
this.enforceObserver();
this.setStatus("connected");
}, delay);
@ -553,11 +580,20 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
basePath: string,
): Promise<void> {
connLog.info(
{ seed: `0x${(seed >>> 0).toString(16)}`, datablocks: datablocks.length, includeTextures },
{
seed: `0x${(seed >>> 0).toString(16)}`,
datablocks: datablocks.length,
includeTextures,
},
"Computing CRC over game files",
);
try {
const { crc, totalSize } = await computeGameCRC(seed, datablocks, basePath, includeTextures);
const { crc, totalSize } = await computeGameCRC(
seed,
datablocks,
basePath,
includeTextures,
);
connLog.info(
{ crc: `0x${(crc >>> 0).toString(16)}`, totalSize },
"CRC computed, sending response",
@ -586,7 +622,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
/** Send a commandToServer as a RemoteCommandEvent. */
sendCommand(command: string, ...args: string[]): void {
connLog.debug({ command, args, eventSeq: this.nextSendEventSeq }, "Sending commandToServer");
connLog.debug(
{ command, args, eventSeq: this.nextSendEventSeq },
"Sending commandToServer",
);
const events = buildRemoteCommandEvent(this.stringTable, command, ...args);
this.pendingEvents.push(...events);
this.flushEvents();
@ -608,9 +647,7 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
* 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 {
private sendDataPacketWithEvents(move?: ClientMoveData): void {
const events = this.eventSendQueue.splice(0);
if (events.length === 0) return;
@ -631,8 +668,12 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
this.sentEventsByPacket.set(packetSeq, events);
const moveData = move ?? {
x: 0, y: 0, z: 0,
yaw: 0, pitch: 0, roll: 0,
x: 0,
y: 0,
z: 0,
yaw: 0,
pitch: 0,
roll: 0,
freeLook: false,
trigger: [false, false, false, false, false, false],
};
@ -702,7 +743,14 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
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 },
{
yaw: move.yaw,
pitch: move.pitch,
x: move.x,
y: move.y,
z: move.z,
total: this.sendMoveCount,
},
"Sending move",
);
}
@ -779,7 +827,8 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
let keepaliveCount = 0;
this.keepaliveTimer = setInterval(() => {
keepaliveCount++;
if (keepaliveCount % 300 === 0) { // ~10s at 32ms tick rate
if (keepaliveCount % 300 === 0) {
// ~10s at 32ms tick rate
connLog.info(
{
dataPackets: this.dataPacketCount,

View file

@ -133,7 +133,12 @@ async function queryServers(addresses: string[]): Promise<ServerInfo[]> {
if (info) {
pingResults.set(addr, info);
masterLog.debug(
{ addr, name: info.name, build: info.buildVersion, ping: info.ping },
{
addr,
name: info.name,
build: info.buildVersion,
ping: info.ping,
},
"Ping response",
);
}
@ -246,10 +251,7 @@ async function queryServers(addresses: string[]): Promise<ServerInfo[]> {
* U32 buildVersion (e.g. 25034)
* HuffString serverName (24 chars max)
*/
function parsePingResponse(
data: Buffer,
sendTime?: number,
): PingInfo | null {
function parsePingResponse(data: Buffer, sendTime?: number): PingInfo | null {
if (data.length < 7 || data[0] !== 16) return null;
try {
const bs = new BitStream(
@ -301,7 +303,15 @@ function parseInfoResponse(data: Buffer): GameInfo | null {
const playerCount = bs.readU8();
const maxPlayers = bs.readU8();
const botCount = bs.readU8();
return { mod, gameType, mapName, status, playerCount, maxPlayers, botCount };
return {
mod,
gameType,
mapName,
status,
playerCount,
maxPlayers,
botCount,
};
} catch {
return null;
}

View file

@ -29,9 +29,7 @@ export class ConnectionProtocol {
private _sendCount = 0;
buildSendPacketHeader(
packetType: number = DataPacket,
): BitStreamWriter {
buildSendPacketHeader(packetType: number = DataPacket): BitStreamWriter {
const bs = new BitStreamWriter(1500);
// gameFlag — always true for data connection packets
@ -42,8 +40,7 @@ export class ConnectionProtocol {
// Increment send sequence
this.lastSendSeq = (this.lastSendSeq + 1) >>> 0;
this.lastSeqRecvdAtSend[this.lastSendSeq & 0x1f] =
this.lastSeqRecvd >>> 0;
this.lastSeqRecvdAtSend[this.lastSendSeq & 0x1f] = this.lastSeqRecvd >>> 0;
// seqNumber (9 bits)
bs.writeInt(this.lastSendSeq & 0x1ff, 9);
@ -71,12 +68,13 @@ export class ConnectionProtocol {
this._sendCount++;
if (this._sendCount <= 30 || this._sendCount % 50 === 0) {
const typeName = packetType === 0 ? "data" : packetType === 1 ? "ping" : "ack";
const typeName =
packetType === 0 ? "data" : packetType === 1 ? "ping" : "ack";
console.log(
`[proto] SEND #${this._sendCount} seq=${this.lastSendSeq} ` +
`highestAck=${this.lastSeqRecvd} type=${typeName} ` +
`ackBytes=${ackByteCount} mask=0x${mask.toString(16).padStart(8, "0")} ` +
`(${mask.toString(2).replace(/^0+/, "") || "0"})`,
`highestAck=${this.lastSeqRecvd} type=${typeName} ` +
`ackBytes=${ackByteCount} mask=0x${mask.toString(16).padStart(8, "0")} ` +
`(${mask.toString(2).replace(/^0+/, "") || "0"})`,
);
}
@ -131,8 +129,7 @@ export class ConnectionProtocol {
const isAcked =
(header.ackMask & (1 << ((highestAck - ackSeq) & 0x1f))) !== 0;
if (isAcked) {
this.lastRecvAckAck =
this.lastSeqRecvdAtSend[ackSeq & 0x1f] >>> 0;
this.lastRecvAckAck = this.lastSeqRecvdAtSend[ackSeq & 0x1f] >>> 0;
}
if (this.onNotify) {
this.onNotify(ackSeq, isAcked);
@ -144,8 +141,7 @@ export class ConnectionProtocol {
this.highestAckedSeq = highestAck;
const dispatchData =
this.lastSeqRecvd !== seqNumber &&
header.packetType === DataPacket;
this.lastSeqRecvd !== seqNumber && header.packetType === DataPacket;
this.lastSeqRecvd = seqNumber;
return { accepted: true, dispatchData };
@ -168,9 +164,7 @@ export class ConnectionProtocol {
* The caller provides a callback that writes game data to the stream
* after the dnet header.
*/
buildDataPacket(
writePayload: (bs: BitStreamWriter) => void,
): Uint8Array {
buildDataPacket(writePayload: (bs: BitStreamWriter) => void): Uint8Array {
const bs = this.buildSendPacketHeader(DataPacket);
writePayload(bs);
return bs.getBuffer();
@ -343,10 +337,7 @@ export class ClientNetStringTable {
}
/** Build a NetStringEvent to register a string with the server. */
export function buildNetStringEvent(
id: number,
value: string,
): ClientEvent {
export function buildNetStringEvent(id: number, value: string): ClientEvent {
return {
classId: NetStringEventClassId,
write(bs: BitStreamWriter) {
@ -489,9 +480,7 @@ export function buildConnectRequest(
}
/** Build a Disconnect (type 38) OOB packet. */
export function buildDisconnectPacket(
connectSequence: number,
): Uint8Array {
export function buildDisconnectPacket(connectSequence: number): Uint8Array {
const bs = new BitStreamWriter(64);
bs.writeU8(38); // Disconnect type
bs.writeU32(connectSequence);

View file

@ -19,8 +19,7 @@ const MANIFEST_PATH =
path.resolve(GAME_BASE_PATH, "..", "..", "public", "manifest.json");
const RELAY_PORT = parseInt(process.env.RELAY_PORT || "8765", 10);
const MASTER_SERVER =
process.env.T2_MASTER_SERVER || "master.tribesnext.com";
const MASTER_SERVER = process.env.T2_MASTER_SERVER || "master.tribesnext.com";
/** HTTP server for health checks; WebSocket upgrades are handled separately. */
const httpServer = http.createServer(async (req, res) => {
@ -58,7 +57,9 @@ const httpServer = http.createServer(async (req, res) => {
const allOk = Object.values(checks).every((c) => c.ok);
res.writeHead(allOk ? 200 : 503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: allOk ? "ok" : "degraded", checks }, null, 2));
res.end(
JSON.stringify({ status: allOk ? "ok" : "degraded", checks }, null, 2),
);
return;
}
@ -87,7 +88,11 @@ wss.on("connection", (ws) => {
const RETRY_DELAY_MS = 6000;
const RETRYABLE_REASONS = ["Server is cycling mission"];
async function connectToServer(ws: WebSocket, address: string, warriorName?: string): Promise<void> {
async function connectToServer(
ws: WebSocket,
address: string,
warriorName?: string,
): Promise<void> {
if (gameConnection) {
gameConnection.disconnect();
}
@ -95,9 +100,7 @@ wss.on("connection", (ws) => {
gameConnection = new GameConnection(address, { warriorName });
// Set mapName from the cached server list if available.
const cachedServer = cachedServers.find(
(s) => s.address === address,
);
const cachedServer = cachedServers.find((s) => s.address === address);
if (cachedServer?.mapName) {
gameConnection.setMapName(cachedServer.mapName);
}
@ -123,7 +126,11 @@ wss.on("connection", (ws) => {
) {
retryCount++;
relayLog.info(
{ attempt: retryCount, maxRetries: MAX_RETRIES, delay: RETRY_DELAY_MS },
{
attempt: retryCount,
maxRetries: MAX_RETRIES,
delay: RETRY_DELAY_MS,
},
"Retryable disconnect — will reconnect",
);
sendToClient(ws, {
@ -243,7 +250,10 @@ wss.on("connection", (ws) => {
}
case "joinServer": {
relayLog.info({ address: message.address, warriorName: message.warriorName }, "Join server requested");
relayLog.info(
{ address: message.address, warriorName: message.warriorName },
"Join server requested",
);
if (gameConnection) {
relayLog.info("Disconnecting existing game connection");
gameConnection.disconnect();
@ -285,10 +295,7 @@ wss.on("connection", (ws) => {
{ event: message.command },
"Forwarding auth event from browser",
);
gameConnection.handleAuthEvent(
message.command,
message.args,
);
gameConnection.handleAuthEvent(message.command, message.args);
} else {
relayLog.debug(
{ command: message.command },
@ -315,7 +322,10 @@ wss.on("connection", (ws) => {
case "sendCRCCompute": {
if (gameConnection) {
relayLog.info(
{ datablocks: message.datablocks.length, includeTextures: message.includeTextures },
{
datablocks: message.datablocks.length,
includeTextures: message.includeTextures,
},
"Computing CRC from game files",
);
gameConnection.computeAndSendCRC(

View file

@ -5,15 +5,32 @@ export type ClientMessage =
| { type: "disconnect" }
| { type: "sendMove"; move: ClientMove }
| { type: "sendCommand"; command: string; args: string[] }
| { type: "sendCRCResponse"; crcValue: number; field1: number; field2: number }
| { type: "sendCRCCompute"; seed: number; field2: number; includeTextures: boolean; datablocks: { objectId: number; className: string; shapeName: string }[] }
| {
type: "sendCRCResponse";
crcValue: number;
field1: number;
field2: number;
}
| {
type: "sendCRCCompute";
seed: number;
field2: number;
includeTextures: boolean;
datablocks: { objectId: number; className: string; shapeName: string }[];
}
| { type: "sendGhostAck"; sequence: number; ghostCount: number }
| { type: "wsPing"; ts: number };
/** Messages from relay server to browser client. */
export type ServerMessage =
| { type: "serverList"; servers: ServerInfo[] }
| { type: "status"; status: ConnectionStatus; message?: string; connectSequence?: number; mapName?: string }
| {
type: "status";
status: ConnectionStatus;
message?: string;
connectSequence?: number;
mapName?: string;
}
| { type: "gamePacket"; data: Uint8Array }
| { type: "ping"; ms: number }
| { type: "wsPong"; ts: number }