Skip to content

Commit

Permalink
chore: make epochtal compatible with spplice-cpp
Browse files Browse the repository at this point in the history
  • Loading branch information
p2r3 committed Oct 16, 2024
1 parent 0781ff3 commit f70c027
Show file tree
Hide file tree
Showing 3 changed files with 446 additions and 122 deletions.
328 changes: 207 additions & 121 deletions defaults/portal2/main.js
Original file line number Diff line number Diff line change
@@ -1,173 +1,259 @@
/*
* Due to valve's decision to break most coop commands on orange, a few workarounds are needed to make coop possible again.
* This main.js file is loaded by spplice and feeds off the console output to relay commands to the game.
* Additionally, this file is responisble for communicating the server's state to the game client.
/**
* This file provides some patches for co-op scripts, requests and injects
* server timestamps during runs, and provides a WebSocket interface to
* the Portal 2 console. The code is written for spplice-cpp, and is made
* compatible with Spplice 2 via a polyfill script.
*/

const SERVER_ADDRESS = fs.read("address.txt");
// Polyfills for Spplice 2
if (!("game" in this)) eval(fs.read("polyfill.js"));

let input = "";
let pongIgnore = 0;
// Read the server's HTTP address from file
const HTTP_ADDRESS = fs.read("address.txt");
// Get the WebSocket address from the HTTP address
const WS_ADDRESS = HTTP_ADDRESS.replace("http", "ws");

let ws = null, wsPrompt = null;

// Generates a random 4 digit code
function generateAuthCode () {
let code = Math.floor(Math.random() * 10000).toString();
while (code.length < 4) code = "0" + code;
return code;
/**
* Utility funtion, checks if the given file path exists
* @param {string} path Path to the file or directory, relative to tempcontent
* @returns {boolean} True if the path exists, false otherwise
*/
function pathExists (path) {
try { fs.rename(path, path) }
catch (_) { return false }
return true;
}

// Handles received WebSocket events
function wsMessageHandler (event) {
do { // Attempt connection with the game's console
var gameSocket = game.connect();
sleep(200);
} while (gameSocket === -1);

const data = JSON.parse(event.data);
console.log("Connected to Portal 2 console.");

switch (data.type) {
case "authenticated": {
ws.send(JSON.stringify({ type: "isGame", value: true }));
SendToConsole("clear; echo WebSocket connection established.");
return;
}
case "cmd": return SendToConsole(data.value);
case "ping": return ws.send(JSON.stringify({ type: "pong" }));
}
// Keep track of co-op sync pings/pongs
var pongIgnore = 0;
// Whether an attempt has been made to provide a WebSocket token
var webSocketAttempt = false;
// Store the last partially received console line until it can be processed
var lastLine = "";

}
/**
* Processes individual lines of Portal 2 console output
*
* @param {string} line A single complete line of console output
*/
function processConsoleLine (line) {

// Relay commands to the game
if (line.indexOf("[SendToConsole] ") === 0) {
game.send(gameSocket, line.slice(16) + "\n");
return;
}

// Authenticates and sets up a WebSocket connection
function wsSetup (token) {
// Respond to coop portalgun pings
if (line.indexOf("[coop_portal_ping]") !== -1) {

const protocol = SERVER_ADDRESS.startsWith("https:") ? "wss" : "ws";
const hostname = SERVER_ADDRESS.split("://")[1].split("/")[0];
// Hide the message from the onscreen chat and respond with a pong
game.send(gameSocket, "hud_saytext_time 0;say [coop_portal_pong]\n");
// Up pongIgnore to ignore the next two pings, as they are echos of the same ping on the wrong client
pongIgnore = 2;

if (ws) {
ws.addEventListener("close", () => {
ws = null;
wsSetup(token);
});
ws.close();
return;
}

ws = new WebSocket(`${protocol}://${hostname}/api/events/connect`);
// Respond to coop portalgun pongs
if (line.indexOf("[coop_portal_pong]") !== -1) {

ws.addEventListener("message", wsMessageHandler);
// Make sure we're not listening to our own pongs
if (pongIgnore > 0) {
pongIgnore --;
return;
}
pongIgnore = 1;

ws.addEventListener("open", () => {
ws.send(token);
});
// Trigger coop script function to update the portals
game.send(gameSocket, "script ::coopUpdatePortals()\n");

ws.addEventListener("close", (event) => {
ws = null;
SendToConsole("echo \"\"");
SendToConsole("echo WebSocket connection closed.");
if (event.reason === "ERR_TOKEN") {
SendToConsole("echo \"\"");
SendToConsole("echo The token you provided was invalid.");
return;
}

// Respond to request for server timestamp
if (line.indexOf("[RequestTimestamp]") !== -1 || line.indexOf("Recording to ") === 0) {

// Request server timestamp from API
const timestamp = download.string(HTTP_ADDRESS + "/api/timestamp/get");

if (!timestamp) {
// If the request fails, run a script that informs the player
game.send(gameSocket, "script ::serverUnreachable()\n");
} else {
// Send a no-op command to log the timestamp in the demo file
game.send(gameSocket, "-alt1 ServerTimestamp " + timestamp + "\n");
}
});

}
return;
}

// Workaround for lack of fs.exists in Spplice 2's JS API
function pathExists (path) {
try {
fs.rename(path, path);
return true;
} catch (e) {
return false;
// Process WebSocket token
if (line.indexOf("ws : ") === 0) {
if (!webSocketAttempt) {
webSocketAttempt = true;
game.send(gameSocket, "echo Looks like you're trying to connect to an Epochtal Live lobby.\n");
game.send(gameSocket, "echo Please use the dedicated Epochtal Live Spplice package for that instead, this will not work.\n");
game.send(gameSocket, "echo \"\"\n");
game.send(gameSocket, "echo If you know what you're doing, send the command again.\n");
return;
}
webSocketToken = line.slice(5, -1);
return;
}

// Force quit when SAR unloads
if (line.indexOf("Cya :)") === 0) {
if (webSocket) ws.disconnect(webSocket);
throw "Game closing";
}

}

// Handle console output
onConsoleOutput(async function (data) {
/**
* Processes output from the Portal 2 console, splitting it up into lines
* and passing those to processConsoleLine
*/
function processConsoleOutput () {

// Receive 1024 bytes from the game console socket
const buffer = game.read(gameSocket, 1024);
// If we received nothing, don't proceed
if (buffer.length === 0) return;

// Append new data to the input buffer
input += data.toString().replaceAll("\r", "");
// Add the latest buffer to any partial data we had before
lastLine += buffer;

// Split the input buffer by newline characters
const lines = input.split("\n");
input = lines.pop();
// Split up the output into lines
const lines = lastLine.replace(/\r/, "").split("\n");
// Store the last entry of the array as a partially received line
lastLine = lines.pop();

// Iterate over each completed new line
for (let i = 0; i < lines.length; i ++) {
// Process each complete line of console output
lines.forEach(processConsoleLine);

// Relay commands to the game
if (lines[i].startsWith("[SendToConsole] ")) {
SendToConsole(lines[i].slice(16));
continue;
}
}

// Respond to coop portalgun pings
if (lines[i].includes("[coop_portal_ping]")) {
/**
* Processes events sent by the server
*
* @param {Object} data Event data, as parsed from JSON
* @param {string} data.type Event type
*/
function processServerEvent (data) {

// Hide the message from the onscreen chat and respond with a pong
SendToConsole("hud_saytext_time 0;say [coop_portal_pong]");
// Up pongIgnore to ignore the next two pings, as they are echos of the same ping on the wrong client
pongIgnore = 2;
switch (data.type) {

continue;
}
// Handle authentication success
case "authenticated": {

// Respond to coop portalgun pongs
if (lines[i].includes("[coop_portal_pong]")) {
// Acknowledge success to the user
game.send(gameSocket, "echo Authentication complete.\n");

// Only respond to the correct pongs
if (pongIgnore > 0) {
pongIgnore --;
continue;
// Send isGame event to inform the server of our role
if (!ws.send(webSocket, '{"type":"isGame","value":"true"}')) {
game.send(gameSocket, "echo Failed to send isGame event. Disconnecting.\n");
ws.disconnect(webSocket);
webSocket = null;
}
pongIgnore = 1;

// Trigger coop vscript to update the portals
SendToConsole("script ::coopUpdatePortals()");
return;
}

// Handle server pings
case "ping": {

continue;
// Respond to the ping with a pong
ws.send(webSocket, '{"type":"pong"}');

return;
}

// Respond to request for server timestamp
if (lines[i].includes("[RequestTimestamp]") || (lines[i].startsWith("Recording to ") && lines[i].endsWith("..."))) {
try {
}

// Request server timestamp from API
const request = await fetch(`${SERVER_ADDRESS}/api/timestamp/get`);
const timestamp = await request.json();
}

// Send a no-op command to log the timestamp in the demo file
SendToConsole(`-alt1 ServerTimestamp ${timestamp}`);
// Keep track of WebSocket parameters
var webSocket = null;
var webSocketToken = null;

} catch (e) {
/**
* Processes communication with the WebSocket
*/
function processWebSocket () {

// If the request fails, run a script that informs the player
SendToConsole("script ::serverUnreachable()");
// If we have a token, attempt to use it
if (webSocketToken) {

}
continue;
// Disconnect existing socket, if any
if (webSocket) ws.disconnect(webSocket);
// Start a new WebSocket connection
game.send(gameSocket, "echo Connecting to WebSocket...\n");
webSocket = ws.connect(WS_ADDRESS + "/api/events/connect");

// Clear the token and exit if the connection failed
if (!webSocket) {
game.send(gameSocket, "echo WebSocket connection failed.\n");
webSocketToken = null;
return;
}

// Respond to request for WebSocket connection
if (lines[i].startsWith("ws : ")) {
if (!wsPrompt) {
SendToConsole("echo Looks like you're trying to connect to an Epochtal Live lobby.");
SendToConsole("echo Please use the dedicated Epochtal Live Spplice package for that instead, this will not work.");
SendToConsole("echo \"\"");
SendToConsole("echo If you know what you're doing, send the command again.");
wsPrompt = true;
continue;
}
wsSetup(lines[i].slice(5));
continue;
// Send the token to authenticate
game.send(gameSocket, "echo Connection established, authenticating...\n");
const success = ws.send(webSocket, webSocketToken);
webSocketToken = null;

// On transmission failure, assume the socket is dead
if (!success) {
game.send(gameSocket, "echo Failed to send authentication token.\n");
ws.disconnect(webSocket);
webSocket = null;
return;
}

// Work around an issue in Spplice 2 where the JS interface remains running after game close
if (lines[i].startsWith("Cya :)")) {
if (ws) ws.close();
throw "Game closing";
// Shortly after, send a ping to check that we're still connected
sleep(1000);
const pingSuccess = ws.send(webSocket, '{"type":"ping"}');

// If this failed, the server probably closed the connection, indicating auth failure
if (!pingSuccess) {
game.send(gameSocket, "echo Authentication failed.\n");
game.send(gameSocket, 'echo ""\n');
game.send(gameSocket, "echo The token you provided most likely expired. Tokens are only valid for 30 seconds after being issued.\n");

ws.disconnect(webSocket);
webSocket = null;
return;
}

}

});
// If a socket has not been instantiated, don't proceed
if (!webSocket) return;

log.appendl("SendToConsole relay enabled!");
// Process incoming data until the stack is empty
var message;
while ((message = ws.read(webSocket)) !== "") {

// Attempt to deserialize and process the message
try { processServerEvent(JSON.parse(message)) }
catch (_) { continue }

}

}

// Run each processing function on an interval
while (true) {
processConsoleOutput();
processWebSocket();
// Sleep for roughly one tick
sleep(16);
}
Loading

0 comments on commit f70c027

Please sign in to comment.