fix typecheck script, animations

This commit is contained in:
Brian Beck 2026-03-16 18:16:34 -07:00
parent 642fce9c06
commit ceb9fea9f4
120 changed files with 1308 additions and 911 deletions

View file

@ -35,7 +35,11 @@ export class BitStreamWriter {
}
writeFlag(value: boolean): void {
if (this.bitNum >= this.maxBitNum) return;
if (this.bitNum >= this.maxBitNum) {
throw new RangeError(
`BitStreamWriter overflow: writeFlag at position ${this.bitNum}, max ${this.maxBitNum}`,
);
}
if (value) {
this.data[this.bitNum >> 3] |= 1 << (this.bitNum & 0x7);
} else {
@ -47,6 +51,11 @@ export class BitStreamWriter {
/** Write N bits from an unsigned integer, LSB-first. */
writeInt(value: number, bitCount: number): void {
if (bitCount === 0) return;
if (this.bitNum + bitCount > this.maxBitNum) {
throw new RangeError(
`BitStreamWriter overflow: need ${bitCount} bits at position ${this.bitNum}, max ${this.maxBitNum}`,
);
}
value = value >>> 0;
for (let i = 0; i < bitCount; i++) {
if (value & (1 << i)) {
@ -117,6 +126,11 @@ export class BitStreamWriter {
/** Write raw bits from a Uint8Array. */
writeBitsBuffer(data: Uint8Array, bitCount: number): void {
if (this.bitNum + bitCount > this.maxBitNum) {
throw new RangeError(
`BitStreamWriter overflow: need ${bitCount} bits at position ${this.bitNum}, max ${this.maxBitNum}`,
);
}
for (let i = 0; i < bitCount; i++) {
const byteIndex = i >> 3;
const bitIndex = i & 0x7;

View file

@ -80,6 +80,11 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
/** Warrior name to send in the ConnectRequest. */
private warriorName: string;
/** The server address as "host:port". */
get address(): string {
return `${this.host}:${this.port}`;
}
constructor(address: string, options?: { warriorName?: string }) {
super();
const [host, portStr] = address.split(":");
@ -373,7 +378,6 @@ 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;
@ -413,6 +417,16 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
/** Handle a data protocol packet (established connection). */
private handleDataPacket(msg: Buffer): void {
// Ignore data packets before the connection is fully established.
// They arrive in the window between socket creation and ConnectAccept
// and would confuse both the browser parser and our ack tracking.
if (
this._status !== "connected" &&
this._status !== "authenticating"
) {
return;
}
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
this.dataPacketCount++;
@ -482,11 +496,11 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
);
}
// Measure RTT from the acked sequence's send timestamp.
const sendTime = this.sendTimestamps.get(highestAck);
// Measure RTT from the acked sequence's send timestamp (full 32-bit key).
const sendTime = this.sendTimestamps.get(result.highestAck);
if (sendTime) {
const rtt = Date.now() - sendTime;
this.sendTimestamps.delete(highestAck);
this.sendTimestamps.delete(result.highestAck);
// Exponential moving average (alpha=0.5 for responsive updates).
this.smoothedPing =
this.smoothedPing === 0 ? rtt : this.smoothedPing * 0.5 + rtt * 0.5;
@ -732,9 +746,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
moves: ClientMoveData[],
moveStartIndex: number,
): void {
// Record send time for RTT measurement.
const nextSeq9 = (this.protocol.lastSendSeq + 1) & 0x1ff;
this.sendTimestamps.set(nextSeq9, Date.now());
// Record send time for RTT measurement using the full 32-bit sequence
// (not the 9-bit wire value) to avoid stale timestamps after wrap.
const nextSeqFull = (this.protocol.lastSendSeq + 1) >>> 0;
this.sendTimestamps.set(nextSeqFull, Date.now());
// Absorb any new pending events into the send queue.
for (const event of this.pendingEvents.splice(0)) {

View file

@ -152,7 +152,8 @@ async function queryServers(addresses: string[]): Promise<ServerInfo[]> {
}
});
socket.on("error", () => {
socket.on("error", (err) => {
masterLog.warn({ err }, "Socket error during Phase 1");
clearTimeout(timeout);
resolve();
});
@ -176,7 +177,8 @@ async function queryServers(addresses: string[]): Promise<ServerInfo[]> {
.map(([addr]) => addr);
if (compatibleAddrs.length > 0) {
socket.removeAllListeners("message");
// Clear all Phase 1 listeners (including stale error handler).
socket.removeAllListeners();
masterLog.debug(
{ count: compatibleAddrs.length },
@ -186,6 +188,12 @@ async function queryServers(addresses: string[]): Promise<ServerInfo[]> {
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), PHASE_TIMEOUT_MS);
socket.on("error", (err) => {
masterLog.warn({ err }, "Socket error during Phase 2");
clearTimeout(timeout);
resolve();
});
socket.on("message", (msg, rinfo) => {
const addr = resolveAddr(rinfo);
if (msg[0] === 20) {

View file

@ -1,5 +1,6 @@
import { BitStreamWriter } from "./BitStreamWriter.js";
import { packNetString, writeString } from "./HuffmanWriter.js";
import { connLog } from "./logger.js";
const DataPacket = 0;
const PingPacket = 1;
@ -68,13 +69,17 @@ export class ConnectionProtocol {
this._sendCount++;
if (this._sendCount <= 30 || this._sendCount % 50 === 0) {
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"})`,
connLog.debug(
{
n: this._sendCount,
seq: this.lastSendSeq,
ack: this.lastSeqRecvd,
type: packetType === 0 ? "data" : packetType === 1 ? "ping" : "ack",
ackBytes: ackByteCount,
mask: `0x${mask.toString(16).padStart(8, "0")}`,
},
"Sent packet #%d",
this._sendCount,
);
}
@ -89,12 +94,12 @@ export class ConnectionProtocol {
connectSeqBit: number;
ackByteCount: number;
ackMask: number;
}): { accepted: boolean; dispatchData: boolean } {
}): { accepted: boolean; dispatchData: boolean; highestAck: number } {
if (header.connectSeqBit !== (this.connectSequence & 1)) {
return { accepted: false, dispatchData: false };
return { accepted: false, dispatchData: false, highestAck: 0 };
}
if (header.ackByteCount > 4 || header.packetType > 2) {
return { accepted: false, dispatchData: false };
return { accepted: false, dispatchData: false, highestAck: 0 };
}
let seqNumber =
@ -103,7 +108,7 @@ export class ConnectionProtocol {
seqNumber = (seqNumber + 0x200) >>> 0;
}
if (this.lastSeqRecvd + 0x1f < seqNumber) {
return { accepted: false, dispatchData: false };
return { accepted: false, dispatchData: false, highestAck: 0 };
}
let highestAck =
@ -112,7 +117,7 @@ export class ConnectionProtocol {
highestAck = (highestAck + 0x200) >>> 0;
}
if (this.lastSendSeq < highestAck) {
return { accepted: false, dispatchData: false };
return { accepted: false, dispatchData: false, highestAck: 0 };
}
const seqShift = (seqNumber - this.lastSeqRecvd) & 0x1f;
@ -144,7 +149,7 @@ export class ConnectionProtocol {
this.lastSeqRecvd !== seqNumber && header.packetType === DataPacket;
this.lastSeqRecvd = seqNumber;
return { accepted: true, dispatchData };
return { accepted: true, dispatchData, highestAck };
}
/** Build a ping response packet. */

View file

@ -87,7 +87,11 @@ setInterval(() => {
wsClients,
gameConns,
);
}, 10_000);
if (gameConns > 0) {
const addrs = [...activeGameConnections].map((c) => c.address);
relayLog.debug({ connections: addrs }, "Active game connections: %s", addrs.join(", "));
}
}, 60_000);
wss.on("connection", (ws) => {
relayLog.info("Browser client connected");
@ -120,13 +124,14 @@ wss.on("connection", (ws) => {
gameConnection.setMapName(cachedServer.mapName);
}
const conn = gameConnection;
gameConnection.on("status", (status, statusMessage) => {
relayLog.info(
{
status,
statusMessage,
connectSequence: gameConnection?.connectSequence,
mapName: gameConnection?.mapName,
connectSequence: conn.connectSequence,
mapName: conn.mapName,
},
"Game connection status changed",
);
@ -152,13 +157,19 @@ wss.on("connection", (ws) => {
type: "status",
status: "connecting",
message: `${statusMessage} — retrying (${retryCount}/${MAX_RETRIES})...`,
connectSequence: gameConnection?.connectSequence,
mapName: gameConnection?.mapName,
connectSequence: conn.connectSequence,
mapName: conn.mapName,
});
retryTimer = setTimeout(() => {
retryTimer = null;
if (lastJoinAddress === address && ws.readyState === WebSocket.OPEN) {
connectToServer(ws, address, lastWarriorName);
connectToServer(ws, address, lastWarriorName).catch((err) => {
relayLog.error({ err }, "Retry connection failed");
sendToClient(ws, {
type: "error",
message: `Reconnect failed: ${err instanceof Error ? err.message : err}`,
});
});
}
}, RETRY_DELAY_MS);
return;
@ -168,8 +179,8 @@ wss.on("connection", (ws) => {
type: "status",
status,
message: statusMessage,
connectSequence: gameConnection?.connectSequence,
mapName: gameConnection?.mapName,
connectSequence: conn.connectSequence,
mapName: conn.mapName,
});
});
@ -206,10 +217,10 @@ wss.on("connection", (ws) => {
gameConnection.on("close", () => {
relayLog.info("Game connection closed");
if (gameConnection) {
activeGameConnections.delete(gameConnection);
activeGameConnections.delete(conn);
if (gameConnection === conn) {
gameConnection = null;
}
gameConnection = null;
});
await gameConnection.connect();
@ -241,7 +252,6 @@ wss.on("connection", (ws) => {
}
if (gameConnection) {
gameConnection.disconnect();
gameConnection = null;
}
});
@ -336,6 +346,7 @@ wss.on("connection", (ws) => {
},
"Computing CRC from game files",
);
// Fire-and-forget: computeAndSendCRC has its own try/catch.
gameConnection.computeAndSendCRC(
message.seed,
message.field2,