begin live server support

This commit is contained in:
Brian Beck 2026-03-09 12:38:40 -07:00
parent 0c9ddb476a
commit e4ae265184
368 changed files with 17756 additions and 7738 deletions

View file

@ -102,7 +102,7 @@ function getNodeWorldPosition(
if (nodeIdx === -1) return null;
// Walk up parent chain accumulating translations (ignoring rotation for now)
let pos: [number, number, number] = [0, 0, 0];
const pos: [number, number, number] = [0, 0, 0];
let current: number | undefined = nodeIdx;
while (current != null) {
const node = json.nodes[current];

View file

@ -26,7 +26,7 @@ async function run({
try {
await fs.stat(oggFile);
continue; // .ogg already exists, skip
} catch {}
} catch { /* expected */ }
}
inputFiles.push(wavFile);
}

322
scripts/t2-login.ts Normal file
View file

@ -0,0 +1,322 @@
/**
* Downloads TribesNext account credentials (certificate + encrypted private key)
* from the auth server using username and password.
*
* Usage:
* tsx scripts/t2-login.ts [--env=<path>]
*
* Reads T2_ACCOUNT_NAME and T2_ACCOUNT_PASSWORD from .env / environment (or prompts).
* Writes/updates T2_ACCOUNT_CERTIFICATE and T2_ACCOUNT_ENCRYPTED_KEY in the
* target env file (default: .env.local).
*
* This only needs to be run once the credentials persist until you change
* your password or the auth server rotates keys.
*/
import crypto from "node:crypto";
import net from "node:net";
import http from "node:http";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline/promises";
const ROOT = path.resolve(import.meta.dirname, "..");
function sha1(data: string): string {
return crypto.createHash("sha1").update(data).digest("hex");
}
/** Parse --env=<path> from argv, resolve relative to project root. */
function getEnvFilePath(): string {
for (const arg of process.argv.slice(2)) {
const match = arg.match(/^--env=(.+)$/);
if (match) {
return path.resolve(ROOT, match[1]);
}
}
return path.resolve(ROOT, ".env.local");
}
/**
* Read an env file and return its lines. Returns an empty array if the
* file doesn't exist.
*/
async function readEnvLines(filePath: string): Promise<string[]> {
try {
const content = await fs.readFile(filePath, "utf-8");
return content.split("\n");
} catch {
return [];
}
}
/**
* Update env file content: replace existing keys in-place, append new ones.
* Preserves comments, blank lines, and ordering of untouched keys.
*/
function updateEnvLines(
existingLines: string[],
updates: Record<string, string>,
): string[] {
const remaining = new Set(Object.keys(updates));
const result: string[] = [];
for (const line of existingLines) {
const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
if (match && remaining.has(match[1])) {
result.push(`${match[1]}=${updates[match[1]]}`);
remaining.delete(match[1]);
} else {
result.push(line);
}
}
// Append any keys that weren't already in the file
if (remaining.size > 0) {
// Ensure there's a blank line before new entries (unless file is empty
// or already ends with one)
const last = result[result.length - 1];
if (result.length > 0 && last !== "" && last !== undefined) {
result.push("");
}
for (const key of remaining) {
result.push(`${key}=${updates[key]}`);
}
}
// Ensure file ends with a newline
if (result[result.length - 1] !== "") {
result.push("");
}
return result;
}
/** Look up the auth server address from tribesnext.com */
async function findAuthServer(): Promise<string> {
return new Promise((resolve, reject) => {
const req = http.get("http://www.tribesnext.com/auth", (res) => {
let body = "";
res.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
res.on("end", () => {
const lines = body.trim().split("\n");
for (const line of lines) {
const fields = line.split("\t");
if (fields.length >= 1 && fields[0].includes(":")) {
resolve(fields[0]);
return;
}
}
reject(new Error("Could not parse auth server address from response"));
});
});
req.on("error", reject);
req.setTimeout(10000, () => {
req.destroy();
reject(new Error("Auth server lookup timed out"));
});
});
}
/** Download account credentials from the auth server via TCP. */
async function downloadAccount(
authAddress: string,
username: string,
password: string,
): Promise<{ certificate: string; encryptedKey: string }> {
const [host, portStr] = authAddress.split(":");
const port = parseInt(portStr, 10);
// Build the request hash (same as t2csri_downloadAccount)
const authStored = sha1("3.14159265" + username.toLowerCase() + password);
const utc = Math.floor(Date.now() / 1000).toString();
const timeNonce = sha1(utc + username.toLowerCase());
const requestHash = sha1(authStored + timeNonce);
const payload = `RECOVER\t${username}\t${utc}\t${requestHash}\n`;
return new Promise((resolve, reject) => {
const socket = new net.Socket();
let buffer = "";
let idleTimer: ReturnType<typeof setTimeout> | null = null;
// Overall timeout if we never get any data at all.
socket.setTimeout(15000);
socket.connect(port, host, () => {
socket.write(payload);
});
socket.on("data", (data: Buffer) => {
buffer += data.toString();
// The auth server doesn't close the connection after responding.
// Mimic Torque's 700ms idle timeout to detect response completion.
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
socket.destroy();
processResponse();
}, 700);
});
socket.on("end", () => {
processResponse();
});
socket.on("timeout", () => {
socket.destroy();
reject(new Error("Auth server connection timed out"));
});
socket.on("error", (err: Error) => {
reject(err);
});
socket.on("close", () => {
processResponse();
});
let processed = false;
function processResponse() {
if (processed) return;
processed = true;
if (idleTimer) clearTimeout(idleTimer);
const trimmed = buffer.trim();
if (trimmed === "RECOVERERROR") {
reject(new Error("Auth server returned RECOVERERROR (malformed request)"));
return;
}
if (trimmed === "NOTFOUND") {
reject(new Error("Account not found. Check your username."));
return;
}
if (trimmed === "INVALIDPASSWORD") {
reject(new Error("Invalid password."));
return;
}
// Success response format:
// Line 1: CERT: <certificate_fields_tab_separated>
// Line 2: EXP: <encrypted_private_exponent>
const lines = trimmed.split("\n");
if (lines.length < 2) {
reject(new Error(`Unexpected response from auth server: ${trimmed.slice(0, 200)}`));
return;
}
let certLine = lines[0];
let expLine = lines[1];
// Strip the "CERT: " prefix
if (certLine.startsWith("CERT:")) {
certLine = certLine.substring(6).trim();
}
// Strip the "EXP: " prefix if present
if (expLine.startsWith("EXP:")) {
expLine = expLine.substring(5).trim();
}
resolve({
certificate: certLine,
encryptedKey: expLine,
});
}
});
}
async function main() {
const envFilePath = getEnvFilePath();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// Credentials come from process.env (loaded via --env-file-if-exists in
// the npm script, or set manually in the shell environment).
let username = process.env.T2_ACCOUNT_NAME || "";
let password = process.env.T2_ACCOUNT_PASSWORD || "";
if (!username) {
username = await rl.question("TribesNext username: ");
} else {
console.log(`Using username: ${username}`);
}
if (!password) {
password = await rl.question("TribesNext password: ");
} else {
console.log("Using password from environment");
}
rl.close();
if (!username || !password) {
console.error("Username and password are required.");
process.exit(1);
}
// Step 1: Find auth server
let authAddress = process.env.T2_AUTH_SERVER || "";
if (authAddress) {
console.log(`Using auth server from environment: ${authAddress}`);
} else {
console.log("Looking up auth server...");
try {
authAddress = await findAuthServer();
console.log(`Auth server: ${authAddress}`);
} catch (e) {
console.error("Failed to find auth server:", e);
process.exit(1);
}
}
// Step 2: Download credentials
console.log("Downloading account credentials...");
let credentials: { certificate: string; encryptedKey: string };
try {
credentials = await downloadAccount(authAddress, username, password);
} catch (e) {
console.error("Failed to download credentials:", e);
process.exit(1);
}
console.log("Successfully downloaded credentials!");
// Verify the certificate looks valid
const certFields = credentials.certificate.split("\t");
if (certFields.length >= 4) {
console.log(` Account: ${certFields[0]}`);
console.log(` GUID: ${certFields[1]}`);
console.log(` Public key length: ${certFields[3].length} hex chars`);
}
// Step 3: Update the env file
const certB64 = Buffer.from(credentials.certificate).toString("base64");
const keyB64 = Buffer.from(credentials.encryptedKey).toString("base64");
const existingLines = await readEnvLines(envFilePath);
const updatedLines = updateEnvLines(
existingLines.length > 0 ? existingLines : ["# Generated by scripts/t2-login.ts"],
{
T2_ACCOUNT_NAME: username,
T2_ACCOUNT_PASSWORD: password,
T2_ACCOUNT_CERTIFICATE: certB64,
T2_ACCOUNT_ENCRYPTED_KEY: keyB64,
},
);
await fs.writeFile(envFilePath, updatedLines.join("\n"), "utf-8");
console.log(`\nCredentials written to ${envFilePath}`);
console.log(
"The relay server will read these automatically. Run: npm run relay:dev",
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

69
scripts/t2-server-list.ts Normal file
View file

@ -0,0 +1,69 @@
/**
* Queries the TribesNext master server for active game servers and prints
* their details to the console. Useful for testing the master query protocol
* without going through the browser.
*
* Usage:
* npm run server-list
*/
import { queryServerList } from "../relay/masterQuery.js";
const MASTER_SERVER =
process.env.T2_MASTER_SERVER || "master.tribesnext.com";
async function main() {
console.log(`Master server: ${MASTER_SERVER}`);
console.log("Querying server list...\n");
try {
const servers = await queryServerList(MASTER_SERVER);
if (servers.length === 0) {
console.log("No servers found.");
return;
}
console.log(`Found ${servers.length} server(s):\n`);
// Print as a table
const nameWidth = Math.max(
11,
...servers.map((s) => s.name.length),
);
const header = [
"Server Name".padEnd(nameWidth),
"Map".padEnd(20),
"Type".padEnd(18),
"Mod".padEnd(12),
"Players".padEnd(9),
"Ping".padEnd(6),
"Address",
].join(" ");
console.log(header);
console.log("-".repeat(header.length));
for (const server of servers) {
console.log(
[
server.name.padEnd(nameWidth),
server.mapName.padEnd(20),
server.gameType.padEnd(18),
server.mod.padEnd(12),
`${server.playerCount}/${server.maxPlayers}`.padEnd(9),
`${server.ping}ms`.padEnd(6),
server.address,
].join(" "),
);
}
} catch (e) {
console.error("Query failed:", e);
process.exit(1);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});