diff --git a/ISLE/emscripten/config.cpp b/ISLE/emscripten/config.cpp index 26034910..58a42704 100644 --- a/ISLE/emscripten/config.cpp +++ b/ISLE/emscripten/config.cpp @@ -17,12 +17,6 @@ void Emscripten_SetupDefaultConfigOverrides(dictionary* p_dictionary) iniparser_set(p_dictionary, "isle:Full Screen", "false"); iniparser_set(p_dictionary, "isle:Flip Surfaces", "true"); - iniparser_set(p_dictionary, "extensions", NULL); - iniparser_set(p_dictionary, "extensions:multiplayer", "true"); - iniparser_set(p_dictionary, "multiplayer", NULL); - iniparser_set(p_dictionary, "multiplayer:relay url", "ws://localhost:8787"); - iniparser_set(p_dictionary, "multiplayer:room", "default"); - // Emscripten-only for now Emscripten_SetScaleAspect(iniparser_getboolean(p_dictionary, "isle:Original Aspect Ratio", true)); Emscripten_SetOriginalResolution(iniparser_getboolean(p_dictionary, "isle:Original Resolution", true)); diff --git a/ISLE/isleapp.cpp b/ISLE/isleapp.cpp index 8135b957..ac33af6b 100644 --- a/ISLE/isleapp.cpp +++ b/ISLE/isleapp.cpp @@ -38,6 +38,7 @@ #include #include +#include #include #include #include @@ -91,6 +92,8 @@ #include #endif +using namespace Extensions; + DECOMP_SIZE_ASSERT(IsleApp, 0x8c) // GLOBAL: ISLE 0x410030 @@ -1293,6 +1296,12 @@ inline bool IsleApp::Tick() if (!Lego()) { return true; } + + if (Extension::Call(CheckRejected).value_or(FALSE)) { + g_closed = TRUE; + return true; + } + if (!TickleManager()) { return true; } diff --git a/extensions/include/extensions/multiplayer.h b/extensions/include/extensions/multiplayer.h index c2fc5a80..21cd94d0 100644 --- a/extensions/include/extensions/multiplayer.h +++ b/extensions/include/extensions/multiplayer.h @@ -33,6 +33,9 @@ class MultiplayerExt { static std::string relayUrl; static std::string room; + // Returns true if the multiplayer connection was rejected (e.g. room full). + static MxBool CheckRejected(); + static void SetNetworkManager(Multiplayer::NetworkManager* p_networkManager); static Multiplayer::NetworkManager* GetNetworkManager(); @@ -44,9 +47,11 @@ class MultiplayerExt { #ifdef EXTENSIONS constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable; constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify; +constexpr auto CheckRejected = &MultiplayerExt::CheckRejected; #else constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr; constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr; +constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr; #endif }; // namespace Extensions diff --git a/extensions/include/extensions/multiplayer/networkmanager.h b/extensions/include/extensions/multiplayer/networkmanager.h index 64e7e299..89804686 100644 --- a/extensions/include/extensions/multiplayer/networkmanager.h +++ b/extensions/include/extensions/multiplayer/networkmanager.h @@ -39,6 +39,7 @@ class NetworkManager : public MxCore { void Connect(const char* p_roomId); void Disconnect(); bool IsConnected() const; + bool WasRejected() const; void OnWorldEnabled(LegoWorld* p_world); void OnWorldDisabled(LegoWorld* p_world); @@ -84,6 +85,7 @@ class NetworkManager : public MxCore { static const uint32_t BROADCAST_INTERVAL_MS = 66; // ~15Hz static const uint32_t TIMEOUT_MS = 5000; // 5 second timeout + static const int EXIT_ROOM_FULL = 10; }; } // namespace Multiplayer diff --git a/extensions/include/extensions/multiplayer/networktransport.h b/extensions/include/extensions/multiplayer/networktransport.h index 5a9d4e43..5fd05db6 100644 --- a/extensions/include/extensions/multiplayer/networktransport.h +++ b/extensions/include/extensions/multiplayer/networktransport.h @@ -14,6 +14,7 @@ class NetworkTransport { virtual void Connect(const char* p_roomId) = 0; virtual void Disconnect() = 0; virtual bool IsConnected() const = 0; + virtual bool WasRejected() const = 0; // Send binary data to all peers via relay virtual void Send(const uint8_t* p_data, size_t p_length) = 0; diff --git a/extensions/include/extensions/multiplayer/websockettransport.h b/extensions/include/extensions/multiplayer/websockettransport.h index 22dc0c0a..bd895922 100644 --- a/extensions/include/extensions/multiplayer/websockettransport.h +++ b/extensions/include/extensions/multiplayer/websockettransport.h @@ -17,6 +17,7 @@ class WebSocketTransport : public NetworkTransport { void Connect(const char* p_roomId) override; void Disconnect() override; bool IsConnected() const override; + bool WasRejected() const override; void Send(const uint8_t* p_data, size_t p_length) override; size_t Receive(std::function p_callback) override; @@ -24,6 +25,7 @@ class WebSocketTransport : public NetworkTransport { std::string m_relayBaseUrl; int m_socketId; volatile int32_t m_connectedFlag; // Shared with JS main thread via Atomics + volatile int32_t m_rejectedFlag; // Set by JS when connection is rejected (e.g. room full) uint8_t m_recvBuf[8192]; }; diff --git a/extensions/src/multiplayer.cpp b/extensions/src/multiplayer.cpp index 1332bb8f..285ce04b 100644 --- a/extensions/src/multiplayer.cpp +++ b/extensions/src/multiplayer.cpp @@ -100,6 +100,15 @@ MxBool MultiplayerExt::HandleEntityNotify(LegoEntity* p_entity) return s_networkManager->HandleEntityMutation(p_entity, changeType); } +MxBool MultiplayerExt::CheckRejected() +{ + if (s_networkManager && s_networkManager->WasRejected()) { + return TRUE; + } + + return FALSE; +} + void MultiplayerExt::SetNetworkManager(Multiplayer::NetworkManager* p_networkManager) { s_networkManager = p_networkManager; diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index 6feba6c9..a6f3eb7e 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -116,6 +116,11 @@ bool NetworkManager::IsConnected() const return m_transport && m_transport->IsConnected(); } +bool NetworkManager::WasRejected() const +{ + return m_transport && m_transport->WasRejected(); +} + void NetworkManager::OnWorldEnabled(LegoWorld* p_world) { if (!p_world) { diff --git a/extensions/src/multiplayer/server/gameroom.ts b/extensions/src/multiplayer/server/gameroom.ts index 8a892adf..3acfb406 100644 --- a/extensions/src/multiplayer/server/gameroom.ts +++ b/extensions/src/multiplayer/server/gameroom.ts @@ -12,10 +12,18 @@ import { } from "./protocol"; import type { Env } from "./relay"; +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Cross-Origin-Resource-Policy": "cross-origin", +}; + export class GameRoom implements DurableObject { private connections = new Map(); private nextPeerId = 1; private hostPeerId = 0; + private maxPlayers = 5; constructor( private state: DurableObjectState, @@ -23,8 +31,17 @@ export class GameRoom implements DurableObject { ) {} async fetch(request: Request): Promise { + // Handle non-WebSocket requests (HTTP API) if (request.headers.get("Upgrade") !== "websocket") { - return new Response("Expected WebSocket", { status: 426 }); + return this.handleHttpRequest(request); + } + + // Capacity check + if (this.connections.size >= this.maxPlayers) { + return new Response("Room is full", { + status: 503, + headers: CORS_HEADERS, + }); } const pair = new WebSocketPair(); @@ -49,6 +66,64 @@ export class GameRoom implements DurableObject { return new Response(null, { status: 101, webSocket: client }); } + // ---- HTTP API ---- + + private async handleHttpRequest(request: Request): Promise { + const method = request.method.toUpperCase(); + + if (method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + if (method === "POST") { + try { + const body = (await request.json()) as { + maxPlayers?: number; + }; + const ceiling = this.env.MAX_PLAYERS_CEILING + ? Number(this.env.MAX_PLAYERS_CEILING) + : 64; + if (body.maxPlayers !== undefined) { + this.maxPlayers = Math.max( + 2, + Math.min(body.maxPlayers, ceiling) + ); + } + } catch { + // Ignore parse errors, keep defaults + } + return new Response( + JSON.stringify({ maxPlayers: this.maxPlayers }), + { + headers: { + "Content-Type": "application/json", + ...CORS_HEADERS, + }, + } + ); + } + + if (method === "GET") { + return new Response( + JSON.stringify({ + players: this.connections.size, + maxPlayers: this.maxPlayers, + }), + { + headers: { + "Content-Type": "application/json", + ...CORS_HEADERS, + }, + } + ); + } + + return new Response("Method Not Allowed", { + status: 405, + headers: CORS_HEADERS, + }); + } + // ---- Connection lifecycle ---- private assignHostIfNeeded(peerId: number, ws: WebSocket): void { diff --git a/extensions/src/multiplayer/server/relay.ts b/extensions/src/multiplayer/server/relay.ts index 4fb804fb..ff0ec416 100644 --- a/extensions/src/multiplayer/server/relay.ts +++ b/extensions/src/multiplayer/server/relay.ts @@ -2,6 +2,7 @@ export { GameRoom } from "./gameroom"; export interface Env { GAME_ROOM: DurableObjectNamespace; + MAX_PLAYERS_CEILING?: number; } export default { diff --git a/extensions/src/multiplayer/server/wrangler.toml b/extensions/src/multiplayer/server/wrangler.toml index f9be0229..69e6919e 100644 --- a/extensions/src/multiplayer/server/wrangler.toml +++ b/extensions/src/multiplayer/server/wrangler.toml @@ -7,6 +7,9 @@ bindings = [ { name = "GAME_ROOM", class_name = "GameRoom" } ] +[vars] +MAX_PLAYERS_CEILING = 64 + [[migrations]] tag = "v1" new_sqlite_classes = ["GameRoom"] diff --git a/extensions/src/multiplayer/websockettransport.cpp b/extensions/src/multiplayer/websockettransport.cpp index 0f3ac0ac..cf9dcc85 100644 --- a/extensions/src/multiplayer/websockettransport.cpp +++ b/extensions/src/multiplayer/websockettransport.cpp @@ -9,7 +9,7 @@ namespace Multiplayer { WebSocketTransport::WebSocketTransport(const std::string& p_relayBaseUrl) - : m_relayBaseUrl(p_relayBaseUrl), m_socketId(-1), m_connectedFlag(0) + : m_relayBaseUrl(p_relayBaseUrl), m_socketId(-1), m_connectedFlag(0), m_rejectedFlag(0) { // clang-format off MAIN_THREAD_EM_ASM({ @@ -35,10 +35,13 @@ void WebSocketTransport::Connect(const char* p_roomId) std::string url = m_relayBaseUrl + "/room/" + p_roomId; + m_rejectedFlag = 0; + // clang-format off m_socketId = MAIN_THREAD_EM_ASM_INT({ var url = UTF8ToString($0); var connPtr = $1; + var rejPtr = $2; var socketId = Module._mpNextSocketId++; Module._mpMessageQueues[socketId] = []; @@ -58,7 +61,13 @@ void WebSocketTransport::Connect(const char* p_roomId) }; ws.onclose = function() { + var wasConnected = Atomics.load(HEAP32, connPtr >> 2); Atomics.store(HEAP32, connPtr >> 2, 0); + if (!wasConnected) { + // Never connected — server rejected (room full / 503) + Atomics.store(HEAP32, rejPtr >> 2, 1); + Module._exitCode = 10; + } }; ws.onerror = function() { @@ -72,7 +81,7 @@ void WebSocketTransport::Connect(const char* p_roomId) } return socketId; - }, url.c_str(), &m_connectedFlag); + }, url.c_str(), &m_connectedFlag, &m_rejectedFlag); // clang-format on if (m_socketId <= 0) { @@ -104,6 +113,11 @@ bool WebSocketTransport::IsConnected() const return m_socketId > 0 && m_connectedFlag != 0; } +bool WebSocketTransport::WasRejected() const +{ + return m_rejectedFlag != 0; +} + void WebSocketTransport::Send(const uint8_t* p_data, size_t p_length) { if (m_socketId <= 0 || !m_connectedFlag) {